mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 17:04:43 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da5332b5df | ||
|
|
29ed409282 |
@@ -1,6 +0,0 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -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
|
||||
@@ -1,30 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -1,28 +0,0 @@
|
||||
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]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
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' }}
|
||||
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
@@ -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')
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
# Exercise the native print host and all UI tests on PRs; the broad FreeBSD/ARM64 gates
|
||||
# stay on their existing nightly/release schedule.
|
||||
name: Printing
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'Cargo*'
|
||||
- 'craft-i18n.rev'
|
||||
- 'apps/wordcraft/**'
|
||||
- 'apps/wordcraft-web/**'
|
||||
- 'crates/ui-egui/**'
|
||||
- '.github/workflows/printing.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'Cargo*'
|
||||
- 'craft-i18n.rev'
|
||||
- 'apps/wordcraft/**'
|
||||
- 'apps/wordcraft-web/**'
|
||||
- 'crates/ui-egui/**'
|
||||
- '.github/workflows/printing.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: printing-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
native:
|
||||
name: Print (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-24.04, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: '0'
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# The interface catalogs and craft-proof's Language data come from craft-i18n at the commit in
|
||||
# craft-i18n.rev, so these tests build with the same data as the gate (ci.yml).
|
||||
- name: Check out craft-i18n
|
||||
uses: storytold/craft-common-actions/actions/checkout-craft-i18n@main
|
||||
|
||||
# The catalogs include CJK interface languages, so the UI tests need the suite's fonts, as in
|
||||
# the gate (same commit as ci.yml and release.yml).
|
||||
- name: Check out craft-fonts
|
||||
uses: storytold/craft-common-actions/actions/checkout-craft-fonts@main
|
||||
with:
|
||||
ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
|
||||
- name: Build with craft-fonts
|
||||
shell: bash
|
||||
run: |
|
||||
echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV"
|
||||
echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Linux build and software-rendering dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \
|
||||
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev libvulkan1 mesa-vulkan-drivers
|
||||
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Native app and UI Clippy
|
||||
run: cargo clippy -p wordcraft -p wordcraft-ui-egui --all-targets --locked -- -D warnings
|
||||
|
||||
- name: Native app and UI tests
|
||||
run: cargo test -p wordcraft -p wordcraft-ui-egui --locked
|
||||
|
||||
- name: Browser compatibility
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo check -p wordcraft-web --target wasm32-unknown-unknown --locked
|
||||
@@ -11,7 +11,7 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
@@ -19,17 +19,12 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
# Only this job publishes the GitHub release, so only it gets write access.
|
||||
permissions:
|
||||
contents: write
|
||||
packages: 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 }}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
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' }}
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
@@ -31,5 +31,3 @@ jobs:
|
||||
with:
|
||||
app-name: wordcraft
|
||||
app-dir: WordCraft
|
||||
install-relative-path: Programs/ArtCraft/WordCraft
|
||||
install-scope: perUser
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# WordCraft — instructions for agents
|
||||
|
||||
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign). Shared rules and learnings: [`storytold/craftrules`](https://github.com/storytold/craftrules) (`../../craftrules`) — read its `AGENTS.md`.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. `plan/` is gitignored (local only); if it's missing, start from `ROADMAP.md`, `docs/gaps.md` and `docs/roadmap.md` (Current focus).
|
||||
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §5). Don't stop to ask unless a decision is genuinely the owner's (licensing, publishing, pushing to new remotes, secrets).
|
||||
|
||||
## Never crash
|
||||
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it.
|
||||
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it. Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or is disabled; it never panics.
|
||||
- **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.
|
||||
@@ -20,7 +20,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
- Never copy, trace, screenshot-crop or recreate icons, images, templates, themes, clip art, fonts, cursors, sounds or any other asset from Microsoft Word/Office or any Microsoft, Adobe, Avid or Autodesk product. Not even "temporarily".
|
||||
- Every asset must be **original** (drawn in code — prefer this: `crates/ui-egui/src/icons.rs`), **public domain / CC0**, **OSI open source**, or **Creative Commons that allows redistribution**, or licensed open source by a contributor who made it.
|
||||
- **Every asset file has a row in [`ATTRIBUTION.md`](ATTRIBUTION.md)** (author, source, licence). `cargo xtask assets` (part of `cargo xtask ci`) fails otherwise. Add the row in the same commit as the file.
|
||||
- Fonts: new font files go to [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), never this repo; it's an optional build input (`CRAFT_FONTS_DIR`). Spelling word lists and hyphenation patterns live in [`storytold/craft-i18n`](https://github.com/storytold/craft-i18n) `language-data/`, never this repo; they are embedded from `CRAFT_I18N_DIR` (pinned by `craft-i18n.rev`, which CI checks out). Without it the app builds, but English spelling falls back to an installed Hunspell English or the built-in Moby word list (with no American/British variant table) and pattern hyphenation is off (Bulgarian, Turkish and Russian still hyphenate by rule), so proofing tests need it. Documents asking for proprietary fonts (Calibri, Aptos, Cambria…) are rendered with installed system fonts or metric-compatible open substitutes (`crates/fonts/src/word.rs`) — never bundle proprietary fonts.
|
||||
- Fonts: new font files go to [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), never this repo; it's an optional build input (`CRAFT_FONTS_DIR`). Documents asking for proprietary fonts (Calibri, Aptos, Cambria…) are rendered with installed system fonts or metric-compatible open substitutes (`crates/fonts/src/word.rs`) — never bundle proprietary fonts.
|
||||
- Templates, sample documents and themes are our own text and colours (`crates/engine/src/sample.rs`, `cmd/design.rs`). Demo images are public domain, with sources recorded.
|
||||
|
||||
## Clean room
|
||||
@@ -34,24 +34,22 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
| L0 | `geom` | units, rects, measurement parsing |
|
||||
| L1 | `doc`, `fonts` | document model + edit primitives; font DB, shaping, Word font substitution |
|
||||
| L2 | `layout`, `docx`, `formats` | line breaking, pagination, tables, headers/footers, hit testing; OOXML I/O; TXT/MD/HTML/RTF/ODT |
|
||||
| L3 | `render`, `pdf` | vello_cpu rasteriser; krilla PDF |
|
||||
| L3 | `render`, `pdf`, `proof` | vello_cpu rasteriser; krilla PDF; proofing |
|
||||
| L4 | `engine` | Session, undo, **command registry**, Word feature catalog, samples, I/O dispatch |
|
||||
| L5 | `mcp` | MCP server (headless or bridged to the app) |
|
||||
| L6 | `ui-egui` | Word-style egui front end (swappable) |
|
||||
| apps | `apps/wordcraft`, `apps/wordcraft-cli`, `apps/wordcraft-web` | binaries |
|
||||
| external | `craft-proof` (craft-libs, used as `wordcraft-proof`, sits at L1) | proofing: spelling, grammar, hyphenation; its word lists and patterns are craft-i18n's `language-data/` (`CRAFT_I18N_DIR`) |
|
||||
|
||||
- **Everything is a command.** User-visible behaviour = a `CommandSpec` in `crates/engine/src/cmd/*.rs` (id, label, ribbon location, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. The ribbon, shortcuts, command search, CLI, control channel and MCP all dispatch by id. Programmatic calls never open dialogs.
|
||||
- **Parity is measured:** `crates/engine/src/catalog.rs` lists Word's ribbon/menu features with command ids. `cargo xtask parity` writes `docs/parity-checklist.md`; a test enforces a floor that only rises. Implement missing ids to raise it.
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin and reads `Session` state; colours come from `theme::Tokens`.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
## Quality gates
|
||||
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).
|
||||
@@ -64,24 +62,22 @@ Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's o
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
|
||||
|
||||
## Roadmap
|
||||
Progress docs: `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.
|
||||
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.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
|
||||
(commit stats; never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
themselves). See `docs/contributors.md`.
|
||||
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file)
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
|
||||
## 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.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
+17
-13
@@ -8,8 +8,9 @@ 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 | ~220 lowercase English base forms supplementing spelling (#520) |
|
||||
| `assets/app-icon/wordcraft-1024.png` | WordCraft contributors | original artwork drawn in SVG (`assets/app-icon/wordcraft.svg`), rendered by `packaging/icons.sh` | MIT OR Apache-2.0 | Placeholder app icon until the WordCraft mascot drawing lands |
|
||||
| `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the WordCraft app icon (`assets/app-icon/wordcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | WordCraft macOS DMG window background (2026-10-08); used for the Finder window of the macOS DMG, rendered to `background.tiff` by `packaging/macos/dmg/generate.py` |
|
||||
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
|
||||
@@ -50,15 +51,18 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `docs/images/dark.png` | WordCraft contributors | screenshot of WordCraft itself (sample document written for WordCraft, rendered by `ui_shot`) | MIT OR Apache-2.0 | Original; no Microsoft UI or assets |
|
||||
| `docs/images/backstage.png` | WordCraft contributors | screenshot of WordCraft itself (sample document written for WordCraft, rendered by `ui_shot`) | MIT OR Apache-2.0 | Original; no Microsoft UI or assets |
|
||||
| `docs/images/layout.png` | WordCraft contributors | screenshot of WordCraft itself (sample document written for WordCraft, rendered by `ui_shot`) | MIT OR Apache-2.0 | Original; no Microsoft UI or assets |
|
||||
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
|
||||
| `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 |
|
||||
| craft-i18n Language data (optional build input, not files in this repo): the spelling word lists and hyphenation patterns (`language-data/`: the Moby Hyphenator II list and our patterns for English, `en-extra.txt`, `en-variants.txt`, `ru-extra.txt`, and the hyph-utf8 German, Polish, Portuguese and Italian patterns) | see the Language data section of https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md (authors, upstream sources with SHA-256, licences); moved there from `assets/hyphenation/` and `assets/spelling/` byte for byte | embedded by craft-proof (storytold/craft-libs) only when built with `CRAFT_I18N_DIR`, at the commit in `craft-i18n.rev` (all official releases) | Public Domain / CC0-1.0, MIT, BSD-3-Clause, MIT OR Apache-2.0 (per file; see NOTICE) | Spelling and hyphenation; without it English spelling falls back to an installed Hunspell English or craft-proof's built-in Moby list, and pattern hyphenation is off |
|
||||
| `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/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/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 |
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
# WordCraft — instructions for agents
|
||||
|
||||
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign). Shared rules and learnings: [`storytold/craftrules`](https://github.com/storytold/craftrules) (`../../craftrules`) — read its `AGENTS.md`.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. `plan/` is gitignored (local only); if it's missing, start from `ROADMAP.md`, `docs/gaps.md` and `docs/roadmap.md` (Current focus).
|
||||
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §5). Don't stop to ask unless a decision is genuinely the owner's (licensing, publishing, pushing to new remotes, secrets).
|
||||
|
||||
## Never crash
|
||||
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it.
|
||||
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it. Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or is disabled; it never panics.
|
||||
- **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.
|
||||
- Never copy, trace, screenshot-crop or recreate icons, images, templates, themes, clip art, fonts, cursors, sounds or any other asset from Microsoft Word/Office or any Microsoft, Adobe, Avid or Autodesk product. Not even "temporarily".
|
||||
- Every asset must be **original** (drawn in code — prefer this: `crates/ui-egui/src/icons.rs`), **public domain / CC0**, **OSI open source**, or **Creative Commons that allows redistribution**, or licensed open source by a contributor who made it.
|
||||
- **Every asset file has a row in [`ATTRIBUTION.md`](ATTRIBUTION.md)** (author, source, licence). `cargo xtask assets` (part of `cargo xtask ci`) fails otherwise. Add the row in the same commit as the file.
|
||||
- Fonts: new font files go to [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), never this repo; it's an optional build input (`CRAFT_FONTS_DIR`). Spelling word lists and hyphenation patterns live in [`storytold/craft-i18n`](https://github.com/storytold/craft-i18n) `language-data/`, never this repo; they are embedded from `CRAFT_I18N_DIR` (pinned by `craft-i18n.rev`, which CI checks out). Without it the app builds, but English spelling falls back to an installed Hunspell English or the built-in Moby word list (with no American/British variant table) and pattern hyphenation is off (Bulgarian, Turkish and Russian still hyphenate by rule), so proofing tests need it. Documents asking for proprietary fonts (Calibri, Aptos, Cambria…) are rendered with installed system fonts or metric-compatible open substitutes (`crates/fonts/src/word.rs`) — never bundle proprietary fonts.
|
||||
- Fonts: new font files go to [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), never this repo; it's an optional build input (`CRAFT_FONTS_DIR`). Documents asking for proprietary fonts (Calibri, Aptos, Cambria…) are rendered with installed system fonts or metric-compatible open substitutes (`crates/fonts/src/word.rs`) — never bundle proprietary fonts.
|
||||
- Templates, sample documents and themes are our own text and colours (`crates/engine/src/sample.rs`, `cmd/design.rs`). Demo images are public domain, with sources recorded.
|
||||
|
||||
## Clean room
|
||||
@@ -34,12 +34,11 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
| L0 | `geom` | units, rects, measurement parsing |
|
||||
| L1 | `doc`, `fonts` | document model + edit primitives; font DB, shaping, Word font substitution |
|
||||
| L2 | `layout`, `docx`, `formats` | line breaking, pagination, tables, headers/footers, hit testing; OOXML I/O; TXT/MD/HTML/RTF/ODT |
|
||||
| L3 | `render`, `pdf` | vello_cpu rasteriser; krilla PDF |
|
||||
| L3 | `render`, `pdf`, `proof` | vello_cpu rasteriser; krilla PDF; proofing |
|
||||
| L4 | `engine` | Session, undo, **command registry**, Word feature catalog, samples, I/O dispatch |
|
||||
| L5 | `mcp` | MCP server (headless or bridged to the app) |
|
||||
| L6 | `ui-egui` | Word-style egui front end (swappable) |
|
||||
| apps | `apps/wordcraft`, `apps/wordcraft-cli`, `apps/wordcraft-web` | binaries |
|
||||
| external | `craft-proof` (craft-libs, used as `wordcraft-proof`, sits at L1) | proofing: spelling, grammar, hyphenation; its word lists and patterns are craft-i18n's `language-data/` (`CRAFT_I18N_DIR`) |
|
||||
|
||||
- **Everything is a command.** User-visible behaviour = a `CommandSpec` in `crates/engine/src/cmd/*.rs` (id, label, ribbon location, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. The ribbon, shortcuts, command search, CLI, control channel and MCP all dispatch by id. Programmatic calls never open dialogs.
|
||||
- **Parity is measured:** `crates/engine/src/catalog.rs` lists Word's ribbon/menu features with command ids. `cargo xtask parity` writes `docs/parity-checklist.md`; a test enforces a floor that only rises. Implement missing ids to raise it.
|
||||
@@ -50,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,9 +62,4 @@ Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's o
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
|
||||
|
||||
## Roadmap
|
||||
Progress docs: `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.
|
||||
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.
|
||||
|
||||
Generated
+380
-2281
File diff suppressed because it is too large
Load Diff
+12
-64
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.15.0"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
rust-version = "1.90"
|
||||
repository = "https://github.com/storytold/wordcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -21,15 +21,7 @@ wordcraft-docbin = { path = "crates/docbin" }
|
||||
wordcraft-formats = { path = "crates/formats" }
|
||||
wordcraft-render = { path = "crates/render" }
|
||||
wordcraft-pdf = { path = "crates/pdf" }
|
||||
# Proofing is craft-libs' craft-proof (it was crates/proof); its Language data is craft-i18n's
|
||||
# language-data/, embedded from CRAFT_I18N_DIR (craft-i18n.rev). The package keeps the
|
||||
# `wordcraft-proof` name here so `wordcraft_proof::` paths are unchanged.
|
||||
# craft-proof is storytold/craft-libs main at the merge of #497 (flag-based compounding), which
|
||||
# also has #496 (review follow-ups, en-AU) and the merged #482–#490/#495 stack; the font store's
|
||||
# `language-pack` kind is #486 (CP4.1).
|
||||
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "47c9f62530db72bc5c3831c86653b47b20c640be", version = "=0.1.0" }
|
||||
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
|
||||
craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
|
||||
wordcraft-proof = { path = "crates/proof" }
|
||||
wordcraft-engine = { path = "crates/engine" }
|
||||
wordcraft-mcp = { path = "crates/mcp" }
|
||||
wordcraft-zotero = { path = "crates/zotero" }
|
||||
@@ -43,8 +35,6 @@ thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
proptest = "1"
|
||||
rayon = "1"
|
||||
unicode-bidi = "0.3"
|
||||
@@ -60,90 +50,48 @@ 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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
krilla = { version = "0.8", features = ["pdf"] }
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
|
||||
miniz_oxide = "0.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
|
||||
# detector chardetng (Apache-2.0 OR MIT), both pure Rust (Firefox's).
|
||||
encoding_rs = "0.8"
|
||||
chardetng = "1"
|
||||
# Password-protected (agile-encrypted) Word documents: pure-Rust RustCrypto primitives.
|
||||
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 }
|
||||
|
||||
[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
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
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" }
|
||||
|
||||
@@ -20,171 +20,12 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho,
|
||||
Shippori Mincho), under the SIL Open Font License 1.1. Their licence texts ship with the
|
||||
packages as OFL-<family>.txt. Builds without CRAFT_FONTS_DIR do not include them.
|
||||
- Language data (craft-i18n): the word lists, dictionaries and hyphenation patterns below are embedded at build
|
||||
time from https://github.com/storytold/craft-i18n (language-data/, CRAFT_I18N_DIR at the commit
|
||||
in craft-i18n.rev; all official releases) by craft-proof (storytold/craft-libs). Builds without
|
||||
CRAFT_I18N_DIR do not include them.
|
||||
- English word list (craft-i18n language-data/en-us/en-us.dic) — derived from the Moby Hyphenator II list by
|
||||
- English word list (assets/hyphenation/en-us.dic) — derived from the Moby Hyphenator II list by
|
||||
Grady Ward, public domain (Project Gutenberg eBook #3204). The hyphenation patterns
|
||||
(craft-i18n language-data/en-us/hyphenation.pat) were trained from it and are released as CC0-1.0.
|
||||
- English spelling dictionaries (craft-i18n language-data/en-us, en-gb and en-ca: spelling.aff and
|
||||
spelling.dic) — the SCOWL rel-2026.02.25 Hunspell dictionaries en_US, en_GB-ise and en_CA from
|
||||
https://wordlist.aspell.net, bundled unmodified. Their upstream notice (embedded too, for the
|
||||
About window) is language-data/en-us/LICENCE-SCOWL.txt in craft-i18n. The word lists:
|
||||
|
||||
Copyright 2000-2026 by Kevin Atkinson
|
||||
|
||||
Permission to use, copy, modify, distribute, and sell any part of SCOWLv2, or
|
||||
word lists created from it, is hereby granted without fee, provided that the
|
||||
above copyright notice appears in all copies and that both the above
|
||||
copyright notice and this notice appear in supporting documentation. Kevin
|
||||
Atkinson makes no representations about the suitability of this database for
|
||||
any purpose. It is provided "as is" without express or implied warranty.
|
||||
|
||||
The Australian data:
|
||||
|
||||
Copyright 2016 by Benjamin Titze
|
||||
|
||||
Permission to use, copy, modify, distribute and sell this array, the
|
||||
associated software, and its documentation for any purpose is hereby
|
||||
granted without fee, provided that the above copyright notice appears
|
||||
in all copies and that both that copyright notice and this permission
|
||||
notice appear in supporting documentation. Benjamin Titze makes no
|
||||
representations about the suitability of this array for any
|
||||
purpose. It is provided "as is" without express or implied warranty.
|
||||
|
||||
The affix files, a modified version of Geoff Kuenning's Ispell english.aff:
|
||||
|
||||
Copyright 1993, Geoff Kuenning, Granada Hills, CA
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
3. All modifications to the source code must be clearly marked as
|
||||
such. Binary redistributions based on modified source code
|
||||
must be clearly marked as modified versions in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
(clause 4 removed with permission from Geoff Kuenning)
|
||||
5. The name of Geoff Kuenning may not be used to endorse or promote
|
||||
products derived from this software without specific prior
|
||||
written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY GEOFF KUENNING AND CONTRIBUTORS ``AS IS'' AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL GEOFF KUENNING OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
|
||||
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
|
||||
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
|
||||
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGE.
|
||||
- Polish hyphenation patterns (craft-i18n language-data/pl/hyphenation.pat) — hyph-pl.tex from the hyph-utf8
|
||||
package, Copyright (C) 1987-1995 Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko, bundled
|
||||
unmodified and used under the MIT License; the copyright and permission notice is in the file.
|
||||
- Portuguese hyphenation patterns (craft-i18n language-data/pt/hyphenation.pat) — hyph-pt.tex from the hyph-utf8
|
||||
package, bundled unmodified and used under the BSD 3-Clause License:
|
||||
|
||||
Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida,
|
||||
2024 Leonardo Araujo and Aline Benevides
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are
|
||||
permitted provided that the following conditions are met:
|
||||
* Redistributions of source code must retain the above copyright notice, this list of
|
||||
conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright notice, this list of
|
||||
conditions and the following disclaimer in the documentation and/or other materials
|
||||
provided with the distribution.
|
||||
* Neither the name of the University of Campinas, of the University of Minho nor the names
|
||||
of its contributors may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS
|
||||
OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL PEDRO
|
||||
J. DE REZENDE OR J.JOAO DIAS ALMEIDA BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
|
||||
GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
|
||||
OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
- German hyphenation patterns (craft-i18n language-data/de/hyphenation.pat) — hyph-de-1996.tex (reformed spelling)
|
||||
from the hyph-utf8 package, bundled unmodified and used under the MIT License; the copyright
|
||||
and permission notice is in the file:
|
||||
|
||||
Copyright (c) 2013-2024 Stephan Hennig, Werner Lemberg, Günter Milde, Sander van Geloven,
|
||||
Georg Pfeiffer, Gisbert W. Selke, Tobias Wendorf, Keno Wehr
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
||||
software and associated documentation files (the "Software"), to deal in the Software
|
||||
without restriction, including without limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
|
||||
to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or
|
||||
substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
||||
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
- Italian hyphenation patterns (craft-i18n language-data/it/hyphenation.pat) — hyph-it.tex from the hyph-utf8
|
||||
package, Copyright (C) 2008-2011 Claudio Beccari, bundled unmodified and used under the MIT
|
||||
License (offered under LPPL 1.3+ or MIT); the copyright and permission notice is in the file:
|
||||
|
||||
Copyright (C) 2008-2011 Claudio Beccari
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
||||
software and associated documentation files (the "Software"), to deal in the Software
|
||||
without restriction, including without limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
|
||||
to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or
|
||||
substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
||||
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
- 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.
|
||||
(assets/hyphenation/en-us.pat) were trained from it and are released as CC0-1.0.
|
||||
- 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
|
||||
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.
|
||||
are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io.
|
||||
|
||||
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.
|
||||
|
||||
@@ -7,18 +7,14 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.wordcraft.png" width="128" height="128" alt="WordCraft icon: a document page and pen on a blue tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">WordCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Writing and document design; an open-source, modern word processor, built in pure Rust.</b>
|
||||
<b>Writing and document design; an open-source, clean-room reimplementation of Microsoft Word, rebuilt in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A fast, open-source word processor with a familiar workflow: the ribbon, styles,
|
||||
A fast, open-source word processor with the Word workflow you already know: the ribbon, styles,
|
||||
tables, track changes, references and mail merge. It reads and writes .docx, runs natively on
|
||||
macOS, Windows, Linux and BSD, and in the browser via WebAssembly.<br>
|
||||
<i>By the ArtCraft team.</i>
|
||||
@@ -29,9 +25,6 @@
|
||||
<img alt="Runs on macOS, Windows, Linux, BSD and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20BSD%20%C2%B7%20Web-3b5bdb?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-2b47b5?style=flat-square">
|
||||
<img alt="Agent-drivable over MCP" src="https://img.shields.io/badge/agents-MCP%20%C2%B7%20CLI-3b5bdb?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_wordcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/wordcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -93,10 +86,10 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
|
||||
|
||||
## Why WordCraft
|
||||
|
||||
- **Familiar.** Ribbon tabs, groups, shortcuts and behaviour: Enter continues a list,
|
||||
- **Familiar.** Word's ribbon tabs, groups, shortcuts and behaviour: Enter continues a list,
|
||||
Tab demotes it, Ctrl/⌘+B bolds the word under the caret, the Styles gallery previews styles live,
|
||||
F4 repeats, F7 checks spelling, F8 extends the selection.
|
||||
- **Your files.** Opens and saves .docx (OOXML), and also .odt, .rtf, .html, .md, .tex (LaTeX), .txt; opens legacy 97-2003 .doc; exports PDF
|
||||
- **Your files.** Opens and saves .docx (OOXML), and also .odt, .rtf, .html, .md, .tex (LaTeX), .txt; opens Word 97-2003 .doc; exports PDF
|
||||
with real, selectable text, links and bookmarks.
|
||||
- **Fast.** Paragraph layout is cached, so typing in a 188-page document re-lays it out in about
|
||||
1.4 ms; pages render on demand.
|
||||
@@ -122,10 +115,10 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
|
||||
| **Pages** | Margins, orientation, size, columns, page/column/section breaks, headers and footers (first page, odd/even), page numbers, watermark, page borders, line numbers, vertical alignment, drop caps, automatic hyphenation |
|
||||
| **Objects** | Pictures (resize, crop, recolour, brightness/contrast, transparency, background removal, picture styles, rotate), shapes, text boxes, floating position with text wrapping (square, top and bottom, behind or in front of text) |
|
||||
| **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities |
|
||||
| **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
|
||||
| **Review** | Spelling and grammar with suggestions, thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
|
||||
| **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels |
|
||||
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어, Svenska, Hrvatski, Nederlands, Dansk, Română, العربية (mirrored) or Slovenščina (follows the system language by default) |
|
||||
| **Files** | .docx read/write, legacy 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
|
||||
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe or Русский (follows the system language by default) |
|
||||
| **Files** | .docx read/write (opens in Word), Word 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
|
||||
|
||||
The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the
|
||||
[parity assessment](docs/target-app-parity.md) and the generated
|
||||
@@ -140,19 +133,6 @@ cargo run --release -p wordcraft -- --sample # the desktop app with the s
|
||||
cargo run --release -p wordcraft -- report.docx # open a document
|
||||
```
|
||||
|
||||
The interface translations and the spelling and hyphenation data come from
|
||||
[craft-i18n](https://github.com/storytold/craft-i18n), an optional build input. To build with them,
|
||||
check it out at the commit in `craft-i18n.rev` and point `CRAFT_I18N_DIR` at it (an absolute
|
||||
path); without it WordCraft builds with an English interface, English spelling falls back to an
|
||||
installed Hunspell English or craft-proof's built-in word list, and only Bulgarian, Turkish and
|
||||
Russian hyphenate (by rule):
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/craft-i18n ../craft-i18n
|
||||
git -C ../craft-i18n checkout "$(cat craft-i18n.rev)"
|
||||
CRAFT_I18N_DIR="$PWD/../craft-i18n" cargo run --release -p wordcraft -- --sample
|
||||
```
|
||||
|
||||
Command line:
|
||||
|
||||
```sh
|
||||
@@ -161,7 +141,6 @@ wordcraft-cli text report.docx # plain text
|
||||
wordcraft-cli inspect report.docx # structure as JSON
|
||||
wordcraft-cli run --template sample \
|
||||
--cmd 'select.text={"text":"Membership"}' --cmd format.bold --save out.docx
|
||||
wordcraft-cli run --script job.json # the same from a file: commands, then save to docx and pdf
|
||||
```
|
||||
|
||||
Web: `cd apps/wordcraft-web && trunk serve`, then open <http://127.0.0.1:8771/?sample>.
|
||||
@@ -172,7 +151,6 @@ WordCraft was designed to be driven by people *and* by AI agents.
|
||||
|
||||
```sh
|
||||
claude mcp add wordcraft -- wordcraft-cli mcp # headless documents
|
||||
# confine the agent to folders: wordcraft-cli mcp --automation-read-root DIR --automation-write-root DIR
|
||||
wordcraft --control 7981 & # or drive the running app…
|
||||
claude mcp add wordcraft-app -- wordcraft-cli mcp --connect 127.0.0.1:7981
|
||||
```
|
||||
@@ -207,12 +185,12 @@ covers every target starting with it (`wordcraft*=debug`). The logger is
|
||||
| Layer | Crate | Job |
|
||||
|---|---|---|
|
||||
| L0 | `wordcraft-geom` | units and measurements |
|
||||
| L1 | `wordcraft-doc`, `wordcraft-fonts`, `wordcraft-proof` ([`craft-proof`](https://github.com/storytold/craft-libs/tree/main/crates/proof) from craft-libs) | document model and editing; fonts and shaping; spelling, grammar, hyphenation (data: craft-i18n `language-data/`, `CRAFT_I18N_DIR`) |
|
||||
| L1 | `wordcraft-doc`, `wordcraft-fonts`, `wordcraft-proof` | document model and editing; fonts and shaping; spelling, grammar, hyphenation |
|
||||
| L2 | `wordcraft-layout`, `wordcraft-docx`, `wordcraft-formats` | line breaking, pagination, tables, notes, hit testing; OOXML; ODT/RTF/HTML/Markdown/TXT |
|
||||
| L3 | `wordcraft-render`, `wordcraft-pdf` | rasteriser (vello_cpu); PDF ([craft-pdf](docs/shared-pdf.md), Krilla) |
|
||||
| L4 | `wordcraft-engine` | session, undo, 422 commands, document feature catalog |
|
||||
| L3 | `wordcraft-render`, `wordcraft-pdf` | rasteriser (vello_cpu); PDF (krilla) |
|
||||
| L4 | `wordcraft-engine` | session, undo, 422 commands, Word feature catalog |
|
||||
| L5 | `wordcraft-mcp` | MCP server |
|
||||
| L6 | `wordcraft-ui-egui` | the word processor front end (swappable) |
|
||||
| L6 | `wordcraft-ui-egui` | the Word-style front end (swappable) |
|
||||
| apps | `wordcraft`, `wordcraft-cli`, `wordcraft-web` | desktop, command line, browser |
|
||||
|
||||
`cargo xtask ci` runs formatting, clippy, ~800 tests, the asset-attribution check, the layering
|
||||
@@ -220,13 +198,12 @@ check and the wasm build. Contributor and agent instructions: [AGENTS.md](AGENTS
|
||||
|
||||
## Roadmap
|
||||
|
||||
WordCraft is in **alpha**. It has commands for 97% of the ribbon and menu features in its feature catalog
|
||||
([`docs/parity-checklist.md`](docs/parity-checklist.md)) today; counting
|
||||
depth, file fidelity, stability and the rest, we estimate it is about 60% ready for real work,
|
||||
about 55% for a typical professional's weekly work and about 63% for everyday essentials. Beta
|
||||
needs testing against real-world .docx files, keeping charts and SmartArt through a round trip,
|
||||
closer pagination fidelity, a stability sweep and native printing. Details and estimates:
|
||||
[ROADMAP.md](ROADMAP.md) and [where we fall short](docs/gaps.md).
|
||||
WordCraft is in **alpha**. It covers 88% of Word's ribbon features with commands today; counting
|
||||
depth, file fidelity, stability and the rest, we estimate it is about 60% of the way to replacing
|
||||
all of Word for real work, about 55% for a typical professional's weekly work and about 63% for
|
||||
everyday essentials. Beta needs testing against real-world .docx files, keeping charts and SmartArt
|
||||
through a round trip, pagination closer to Word's, a stability sweep and native printing. Details
|
||||
and estimates: [ROADMAP.md](ROADMAP.md) and [where we fall short](docs/gaps.md).
|
||||
|
||||
## Downloads
|
||||
|
||||
@@ -240,11 +217,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
|
||||
@@ -261,10 +234,6 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later
|
||||
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
|
||||
and web backend defaults are unchanged.
|
||||
|
||||
The **32-bit** build uses OpenGL even when a DirectX 12 graphics card is present. On 32-bit
|
||||
Windows, creating a DirectX 12 graphics pipeline fails immediately and the app exits before the
|
||||
window appears. `WGPU_BACKEND=vulkan` (or `dx12`) still overrides that.
|
||||
|
||||
**If the app quits right after its window appears with "Parent device is lost" in the log:**
|
||||
WordCraft compiles DirectX 12 shaders with FXC, the compiler built into Windows, so it needs no
|
||||
`dxcompiler.dll`/`dxil.dll` and ignores any copy another program put on `PATH` (#603).
|
||||
@@ -273,10 +242,9 @@ WordCraft compiles DirectX 12 shaders with FXC, the compiler built into Windows,
|
||||
also avoids DirectX 12 altogether.
|
||||
|
||||
**If the app is slow on an older Windows PC:** when DirectX 12 finds no graphics card with a
|
||||
driver for it (for example Intel 4th-generation graphics on Windows 11), it only offers the built-in
|
||||
software renderer, which draws every frame on the processor. WordCraft normally uses OpenGL instead,
|
||||
without setting anything. If OpenGL has no usable adapter, it can use DirectX 12's WARP software
|
||||
adapter. The log file records the choice (lines starting with
|
||||
driver for it (for example Intel 4th-generation graphics on Windows 11), it only offers Microsoft's
|
||||
software renderer, which draws every frame on the processor. In that case WordCraft uses OpenGL
|
||||
instead, without setting anything. The log file records the choice (lines starting with
|
||||
`graphics:`). `WGPU_BACKEND` still overrides it, e.g. `$env:WGPU_BACKEND = "gl"` or `"dx12"`.
|
||||
|
||||
### macOS
|
||||
@@ -299,38 +267,17 @@ adapter. The log file records the choice (lines starting with
|
||||
RISC-V (riscv64): `wordcraft-<ver>-linux-riscv64.tar.gz`, a tarball cross-compiled for glibc 2.39+
|
||||
(Ubuntu 24.04, Debian 13 and newer).
|
||||
|
||||
**Wayland:** on a Wayland desktop the app runs through XWayland when it is available, because
|
||||
winit (the windowing library) supports dragging files into the window only on X11. To use the
|
||||
native Wayland backend instead (for example for sharper text with fractional scaling, at the cost
|
||||
of file drag-and-drop), start it with `WORDCRAFT_WAYLAND=1`:
|
||||
|
||||
```sh
|
||||
WORDCRAFT_WAYLAND=1 wordcraft
|
||||
```
|
||||
|
||||
**Arch Linux (AUR).** Community members maintain three AUR packages; the project doesn't publish
|
||||
to the AUR itself. Install one of them:
|
||||
|
||||
| Package | What it installs | Choose it when |
|
||||
|---|---|---|
|
||||
| [`wordcraft-bin`](https://aur.archlinux.org/packages/wordcraft-bin) | The release tarball above, prebuilt (x86_64, aarch64) | You want the latest release without compiling (quickest) |
|
||||
| [`wordcraft`](https://aur.archlinux.org/packages/wordcraft) | The latest release, built from source with `cargo` | You prefer to build releases yourself |
|
||||
| [`wordcraft-git`](https://aur.archlinux.org/packages/wordcraft-git) | The current `main` branch, built from source | You want unreleased changes and accept an occasional broken build |
|
||||
|
||||
For example `yay -S wordcraft-bin` or `paru -S wordcraft-bin`.
|
||||
|
||||
#### Community packages
|
||||
|
||||
These packages are maintained by community members, not by the WordCraft team.
|
||||
|
||||
On Gentoo, the [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay) packages the
|
||||
Linux release as `app-office/wordcraft-bin` (prebuilt) and `app-office/wordcraft` (built from source):
|
||||
|
||||
```sh
|
||||
eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git
|
||||
emaint sync -r snakebyte
|
||||
echo 'app-office/wordcraft-bin ~amd64' >> /etc/portage/package.accept_keywords/wordcraft
|
||||
emerge --ask app-office/wordcraft-bin
|
||||
```
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| Build | File |
|
||||
@@ -358,15 +305,7 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | **Writing and document design · you are here** | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
@@ -400,18 +339,16 @@ Copyright (c) 2026 ArtCraft Team and the WordCraft contributors. Required notice
|
||||
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
|
||||
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
|
||||
|
||||
The English spelling dictionary and hyphenation come from Grady Ward's public-domain Moby
|
||||
Hyphenator II word list. Polish spelling reads the Hunspell `pl_PL` dictionary installed on the system (not bundled), as
|
||||
do Russian, Portuguese, Czech, Italian, Croatian and Turkish spelling (how to install them:
|
||||
[docs/spelling-dictionaries.md](docs/spelling-dictionaries.md)), and
|
||||
Polish hyphenation the TeX patterns by Hanna Kołodziejska, Bogusław Jackowski and Marek Ryćko (MIT
|
||||
option). The sample documents and templates are original text written for WordCraft.
|
||||
The spelling dictionary and hyphenation come from Grady Ward's public-domain Moby Hyphenator II
|
||||
word list. The sample documents and templates are original text written for WordCraft.
|
||||
|
||||
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
|
||||
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
|
||||
part of this repository and WordCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Microsoft and Microsoft Word are trademarks of the Microsoft group of companies. Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. WordCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation or Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+18
-878
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)).
|
||||
@@ -20,4 +20,3 @@ wordcraft-render = { workspace = true }
|
||||
wordcraft-mcp = { workspace = true }
|
||||
wordcraft-zotero = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sys-locale = "0.3"
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed
|
||||
//! flag must not silently remove a root and leave the server unconfined.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct AutomationArgs {
|
||||
pub read_root: Option<PathBuf>,
|
||||
pub write_root: Option<PathBuf>,
|
||||
read_given: bool,
|
||||
write_given: bool,
|
||||
}
|
||||
|
||||
impl AutomationArgs {
|
||||
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
|
||||
Self { read_root, write_root, read_given: false, write_given: false }
|
||||
}
|
||||
|
||||
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
|
||||
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
|
||||
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
|
||||
let (root, given) = match flag {
|
||||
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
|
||||
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
if *given {
|
||||
return Err(format!("{flag} given twice"));
|
||||
}
|
||||
let value = match inline {
|
||||
Some(value) => value.to_owned(),
|
||||
None => {
|
||||
let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
|
||||
if value.starts_with('-') {
|
||||
return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
|
||||
}
|
||||
value
|
||||
}
|
||||
};
|
||||
if value.is_empty() {
|
||||
return Err(format!("{flag} needs a non-empty folder"));
|
||||
}
|
||||
*root = Some(value.into());
|
||||
*given = true;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roots_are_taken_once_and_other_arguments_are_left() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
let mut args = ["a folder".to_owned(), "report.docx".to_owned()].into_iter();
|
||||
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
|
||||
assert!(!roots.consume("--author", &mut args).unwrap());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
assert_eq!(roots.write_root, Some("out".into()));
|
||||
assert_eq!(args.next().as_deref(), Some("report.docx"));
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_operands_leave_roots_unchanged() {
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] {
|
||||
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
|
||||
let mut args = operand.map(str::to_owned).into_iter();
|
||||
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
|
||||
assert_eq!(roots.read_root, Some("read".into()));
|
||||
assert_eq!(roots.write_root, Some("write".into()));
|
||||
}
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_root, Some("-folder".into()));
|
||||
assert_eq!(roots.write_root, Some("./-folder".into()));
|
||||
}
|
||||
}
|
||||
+100
-446
@@ -1,13 +1,8 @@
|
||||
//! `wordcraft-cli`: WordCraft from the command line.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod automation_args;
|
||||
mod script;
|
||||
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::fmt;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -17,175 +12,37 @@ const USAGE: &str = "\
|
||||
wordcraft-cli — WordCraft from the command line
|
||||
|
||||
USAGE:
|
||||
wordcraft-cli convert <in> <out> [--markup] [--update-fields] [--format wordml2003|flatopc]
|
||||
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png,
|
||||
xml); --update-fields refreshes unlocked fields before saving;
|
||||
an .xml file is a Word XML Document (Flat OPC), or with
|
||||
--format wordml2003 a Word 2003 XML Document;
|
||||
wordcraft-cli convert <in> <out> [--markup]
|
||||
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png);
|
||||
--markup (Print Markup): comments beside each page and
|
||||
tracked changes in pdf/png, a comment list in html/md/txt
|
||||
wordcraft-cli info <file> pages, words, paragraphs, properties (JSON)
|
||||
wordcraft-cli text <file> plain text
|
||||
wordcraft-cli inspect <file> document structure (JSON)
|
||||
wordcraft-cli render <file> <out.png> [--page N] [--scale S]
|
||||
wordcraft-cli run [--file F | --template T] [--script FILE|-] --cmd 'id={json}' [--cmd …]
|
||||
[--save OUT] [--print] [--author NAME]
|
||||
run commands headlessly, then save (--author names
|
||||
who tracked changes and comments are by).
|
||||
--script runs a JSON file (- is stdin) first:
|
||||
{\"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
|
||||
wordcraft-cli run [--file F | --template T] --cmd 'id={json}' [--cmd …] [--save OUT] [--print]
|
||||
[--author NAME] run commands headlessly, then save (--author names
|
||||
who tracked changes and comments are by)
|
||||
wordcraft-cli commands [--json] list every command
|
||||
wordcraft-cli parity [--markdown] feature-catalog parity
|
||||
wordcraft-cli mcp [--connect HOST:PORT | --author NAME] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
MCP server on stdio (headless, or bridged to the app).
|
||||
--automation-read-root / --automation-write-root confine
|
||||
the files the agent's commands read (open, insert text
|
||||
or picture, mail-merge data, templates…) and write (save,
|
||||
export, merge output…) to DIR, links followed; a root left
|
||||
out grants none of its access. They apply to the headless
|
||||
server and are an error with --connect.
|
||||
wordcraft-cli mcp [--connect HOST:PORT | --author NAME]
|
||||
MCP server on stdio (headless, or bridged to the app)
|
||||
wordcraft-cli zotero <command> <file> [--cmd 'id={json}' …] [--save OUT] [--trace] [--port P]
|
||||
run a Zotero command on a document (Zotero must be
|
||||
running): addEditCitation, addEditBibliography,
|
||||
addNote, refresh, removeCodes, setDocPrefs
|
||||
(--cmd runs first, e.g. --cmd caret.docEnd)
|
||||
wordcraft-cli [<command>] -h | --help this help
|
||||
wordcraft-cli --version
|
||||
|
||||
A password-protected Word file is opened with --password PASS, --password-file PATH or the
|
||||
WORDCRAFT_PASSWORD environment variable; a password given on the command line is readable by
|
||||
every other process, so the other two are better. A file opened with a password is written with
|
||||
it again, as the app does. Exports that cannot carry a password (pdf, html, txt) stay plain.
|
||||
|
||||
Paper: documents created here, and HTML, Markdown and text files (which give no page size), use
|
||||
the system region's paper (A4, or Letter in the US and other Letter regions), read from
|
||||
LC_ALL / LC_PAPER / LANG or the platform. Set WORDCRAFT_REGION=en-US (or another tag) for the
|
||||
same output on every machine.
|
||||
";
|
||||
|
||||
/// The subcommands, in `USAGE` order; `--help` or `-h` after any of them prints `USAGE` (#968).
|
||||
const SUBCOMMANDS: &[&str] = &["convert", "info", "text", "inspect", "render", "run", "commands", "parity", "mcp", "zotero"];
|
||||
|
||||
fn wants_help(cmd: &str, rest: &[String]) -> bool {
|
||||
SUBCOMMANDS.contains(&cmd) && rest.iter().any(|a| a == "--help" || a == "-h")
|
||||
}
|
||||
|
||||
/// The error every subcommand gives when a required path is not there.
|
||||
fn missing_argument() -> Failure {
|
||||
format!("missing argument\n\n{USAGE}").into()
|
||||
}
|
||||
|
||||
/// Open a document for a subcommand. A password-protected Word file needs its password
|
||||
/// ([`password_from`]). As in the app, a document opened with one is saved with it again, so
|
||||
/// `convert` does not quietly write a copy the password no longer protects.
|
||||
fn open(path: &Path, password: Option<&str>) -> Result<Session, String> {
|
||||
// A file that gives no page size (HTML, Markdown, plain text) opens on the system region's
|
||||
// paper, as in the app: A4 in Italy, Letter in the US (#1381).
|
||||
let region = system_region();
|
||||
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, ®ion)?;
|
||||
let mut s = session(doc);
|
||||
fn open(path: &str) -> Result<Session, String> {
|
||||
let doc = wordcraft_engine::io::open_path(std::path::Path::new(path))?;
|
||||
let mut s = Session::new(doc);
|
||||
s.path = Some(path.into());
|
||||
if encrypted {
|
||||
s.password = password.map(wordcraft_engine::io::Password::new);
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// The commands that open a file, and which of their arguments not starting with `--` it is (as
|
||||
/// `pos` counts). `run` opens the value of `--file`.
|
||||
const OPENS_FILE: &[(&str, usize)] = &[("convert", 0), ("info", 0), ("text", 0), ("inspect", 0), ("render", 0), ("zotero", 1)];
|
||||
|
||||
/// The arguments as text. Only the file the command opens may have a name that isn't valid Unicode,
|
||||
/// such as a Latin-1 one (#1440): it comes back as the path to open, and lossy among the arguments.
|
||||
/// Any other such argument is an error naming it.
|
||||
fn text_args<S: AsRef<OsStr>>(raw: &[S]) -> Result<(Vec<String>, Option<PathBuf>), String> {
|
||||
let args: Vec<String> = raw.iter().map(|a| a.as_ref().to_string_lossy().into_owned()).collect();
|
||||
let cmd = args.first().map(String::as_str).unwrap_or("");
|
||||
let file_at = match OPENS_FILE.iter().find(|(c, _)| *c == cmd) {
|
||||
Some((_, n)) => args.iter().enumerate().skip(1).filter(|(_, a)| !a.starts_with("--")).nth(*n).map(|(i, _)| i),
|
||||
None if cmd == "run" => args.iter().position(|a| a == "--file").map(|i| i + 1),
|
||||
None => None,
|
||||
};
|
||||
// `run --script FILE` may also name its script with such a file name.
|
||||
let script_at = if cmd == "run" { args.iter().position(|a| a == "--script").map(|i| i + 1) } else { None };
|
||||
let mut file = None;
|
||||
for (i, a) in raw.iter().map(AsRef::as_ref).enumerate() {
|
||||
if a.to_str().is_none() {
|
||||
if Some(i) != file_at && Some(i) != script_at {
|
||||
return Err(format!("argument {} is not valid Unicode: {}", i + 1, a.to_string_lossy()));
|
||||
}
|
||||
if Some(i) == file_at {
|
||||
file = Some(PathBuf::from(a));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((args, file))
|
||||
}
|
||||
|
||||
/// The password for a protected Word file: `--password`, `--password-file`, or
|
||||
/// `WORDCRAFT_PASSWORD`, in that order. The file form exists because a password on a command line
|
||||
/// is readable by every other process on the machine; a trailing newline is dropped so a file
|
||||
/// written by `echo` works. Never logged, and never named in an error.
|
||||
fn password_from(args: &[String]) -> Result<Option<String>, String> {
|
||||
if let Some(password) = arg_value(args, "--password") {
|
||||
return Ok(Some(password));
|
||||
}
|
||||
if let Some(path) = arg_value(args, "--password-file") {
|
||||
let raw = std::fs::read_to_string(&path).map_err(|e| format!("--password-file {path}: {e}"))?;
|
||||
return Ok(Some(raw.trim_end_matches(['\r', '\n']).to_string()));
|
||||
}
|
||||
Ok(std::env::var("WORDCRAFT_PASSWORD").ok().filter(|p| !p.is_empty()))
|
||||
}
|
||||
|
||||
/// The positional arguments, skipping every option's value so an option value is never read as a
|
||||
/// path. Mirrors [`validate_options`], which has already checked the options are known.
|
||||
fn positionals<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a String> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(arg) = args.get(i) {
|
||||
let takes_value =
|
||||
arg.starts_with("--") && specs.iter().any(|s| s.name == arg && s.takes_value) && args.get(i + 1).is_some_and(|v| !v.starts_with("--"));
|
||||
if takes_value {
|
||||
i += 2;
|
||||
} else {
|
||||
if !arg.starts_with("--") {
|
||||
out.push(arg);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A headless session in the system region: every way the CLI starts one gives `file.new` the
|
||||
/// same paper (#1381).
|
||||
fn session(doc: wordcraft_doc::Document) -> Session {
|
||||
let mut s = Session::new(doc);
|
||||
s.system_region = system_region();
|
||||
s
|
||||
}
|
||||
|
||||
/// The system region, read as the app reads it (locale variables, else the platform's locale).
|
||||
fn system_region() -> wordcraft_engine::Region {
|
||||
wordcraft_engine::Region::from_locale_vars(|name| std::env::var(name).ok(), sys_locale::get_locale)
|
||||
}
|
||||
|
||||
/// The body text for `text`: the plain-text export, so list items keep their bullet or number label.
|
||||
fn listed_text(doc: &wordcraft_doc::Document) -> Result<String, String> {
|
||||
let bytes = wordcraft_engine::io::save_bytes("out.txt", doc)?;
|
||||
Ok(String::from_utf8_lossy(&bytes).replace("\r\n", "\n"))
|
||||
}
|
||||
|
||||
/// `--author NAME`: the author recorded on tracked changes and comments (`file.setAuthor`).
|
||||
/// 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}"))
|
||||
}
|
||||
@@ -194,58 +51,6 @@ fn arg_value(args: &[String], name: &str) -> Option<String> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned()
|
||||
}
|
||||
|
||||
enum OptionArg<'a> {
|
||||
Absent,
|
||||
Duplicate,
|
||||
MissingValue,
|
||||
Value(&'a str),
|
||||
}
|
||||
|
||||
fn find_option_arg<'a>(args: &'a [String], name: &str) -> OptionArg<'a> {
|
||||
let mut matches = args.iter().enumerate().filter(|(_, a)| *a == name);
|
||||
let Some((i, _)) = matches.next() else {
|
||||
return OptionArg::Absent;
|
||||
};
|
||||
if matches.next().is_some() {
|
||||
return OptionArg::Duplicate;
|
||||
}
|
||||
match args.get(i + 1) {
|
||||
None => OptionArg::MissingValue,
|
||||
Some(val) if val.starts_with("--") => OptionArg::MissingValue,
|
||||
Some(val) => OptionArg::Value(val.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_page_arg(command: &str, args: &[String]) -> Result<Option<u64>, String> {
|
||||
match find_option_arg(args, "--page") {
|
||||
OptionArg::Absent => Ok(None),
|
||||
OptionArg::Duplicate => Err(format!("{command}: duplicate --page option")),
|
||||
OptionArg::MissingValue => Err(format!("{command}: --page requires a value")),
|
||||
OptionArg::Value(val) => {
|
||||
let n = val.parse::<u64>().map_err(|_| format!("{command}: invalid --page `{val}`: not a valid integer"))?;
|
||||
if n == 0 {
|
||||
return Err(format!("{command}: invalid --page `0`: page numbers start at 1"));
|
||||
}
|
||||
Ok(Some(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_scale_arg(command: &str, args: &[String]) -> Result<Option<f64>, String> {
|
||||
match find_option_arg(args, "--scale") {
|
||||
OptionArg::Absent => Ok(None),
|
||||
OptionArg::Duplicate => Err(format!("{command}: duplicate --scale option")),
|
||||
OptionArg::MissingValue => Err(format!("{command}: --scale requires a value")),
|
||||
OptionArg::Value(val) => {
|
||||
let scale = val.parse::<f64>().map_err(|_| format!("{command}: invalid --scale `{val}`: not a number"))?;
|
||||
if !scale.is_finite() || scale <= 0.0 {
|
||||
return Err(format!("{command}: invalid --scale `{val}`: scale must be a positive number"));
|
||||
}
|
||||
Ok(Some(scale))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct OptionSpec {
|
||||
name: &'static str,
|
||||
@@ -271,50 +76,13 @@ fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`,
|
||||
/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments
|
||||
/// that are left. A malformed root flag is an error, never a root silently left out.
|
||||
fn automation_roots(
|
||||
command: &str,
|
||||
args: &[String],
|
||||
) -> Result<(Option<std::sync::Arc<wordcraft_engine::file_access::AutomationRoots>>, Vec<String>), String> {
|
||||
let mut found = automation_args::AutomationArgs::new(None, None);
|
||||
let mut left = Vec::new();
|
||||
let mut it = args.iter().cloned();
|
||||
while let Some(arg) = it.next() {
|
||||
if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? {
|
||||
left.push(arg);
|
||||
}
|
||||
}
|
||||
let roots = wordcraft_engine::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref())
|
||||
.map_err(|e| format!("{command}: {e}"))?;
|
||||
Ok((roots, left))
|
||||
}
|
||||
|
||||
/// The password a protected Word file is opened with: `--password` (which any other process on
|
||||
/// the machine can read from the command line), `--password-file`, or `WORDCRAFT_PASSWORD`.
|
||||
const PASSWORD_OPTIONS: &[OptionSpec] =
|
||||
&[OptionSpec { name: "--password", takes_value: true }, OptionSpec { name: "--password-file", takes_value: true }];
|
||||
const CONVERT_OPTIONS: &[OptionSpec] = &[
|
||||
OptionSpec { name: "--markup", takes_value: false },
|
||||
OptionSpec { name: "--format", takes_value: true },
|
||||
OptionSpec { name: "--update-fields", takes_value: false },
|
||||
OptionSpec { name: "--password", takes_value: true },
|
||||
OptionSpec { name: "--password-file", takes_value: true },
|
||||
];
|
||||
const RENDER_OPTIONS: &[OptionSpec] = &[
|
||||
OptionSpec { name: "--page", takes_value: true },
|
||||
OptionSpec { name: "--scale", takes_value: true },
|
||||
OptionSpec { name: "--password", takes_value: true },
|
||||
OptionSpec { name: "--password-file", takes_value: true },
|
||||
];
|
||||
const NO_OPTIONS: &[OptionSpec] = &[];
|
||||
const CONVERT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--markup", takes_value: false }];
|
||||
const RENDER_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--scale", takes_value: true }];
|
||||
const RUN_OPTIONS: &[OptionSpec] = &[
|
||||
OptionSpec { name: "--file", takes_value: true },
|
||||
OptionSpec { name: "--password", takes_value: true },
|
||||
OptionSpec { name: "--password-file", takes_value: true },
|
||||
OptionSpec { name: "--template", takes_value: true },
|
||||
OptionSpec { name: "--cmd", takes_value: true },
|
||||
OptionSpec { name: "--script", takes_value: true },
|
||||
OptionSpec { name: "--save", takes_value: true },
|
||||
OptionSpec { name: "--page", takes_value: true },
|
||||
OptionSpec { name: "--scale", takes_value: true },
|
||||
@@ -377,150 +145,65 @@ fn write_stdout(write: impl FnOnce(&mut std::io::StdoutLock<'static>) -> std::io
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The operand after `--script`, by its real name (it may not be valid Unicode).
|
||||
fn script_operand<S: AsRef<OsStr>>(raw: &[S]) -> Option<PathBuf> {
|
||||
let at = raw.iter().position(|a| a.as_ref() == OsStr::new("--script"))?;
|
||||
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)?;
|
||||
// Opens the file by its real name, which its text in `args` may have lost, and with the
|
||||
// password of a protected document (`password_from`).
|
||||
let open = |text: &str, password: Option<&str>| open(file.as_deref().unwrap_or(Path::new(text)), password);
|
||||
fn run(args: &[String]) -> Result<(), Failure> {
|
||||
let cmd = args.first().map(String::as_str).unwrap_or("");
|
||||
let rest: Vec<String> = args.iter().skip(1).cloned().collect();
|
||||
// Before options are checked or files and folders opened.
|
||||
if wants_help(cmd, &rest) {
|
||||
return write_stdout(|o| write!(o, "{USAGE}"));
|
||||
}
|
||||
let pos = |i: usize| rest.iter().filter(|a| !a.starts_with("--")).nth(i).cloned().ok_or_else(|| format!("missing argument\n\n{USAGE}"));
|
||||
match cmd {
|
||||
"convert" => {
|
||||
validate_options("convert", &rest, CONVERT_OPTIONS)?;
|
||||
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
|
||||
let format = arg_value(&rest, "--format");
|
||||
let paths = positionals(&rest, CONVERT_OPTIONS);
|
||||
let (i, o) = (paths.first().ok_or_else(missing_argument)?.to_string(), paths.get(1).ok_or_else(missing_argument)?.to_string());
|
||||
let (i, o) = (pos(0)?, pos(1)?);
|
||||
let markup = rest.iter().any(|a| a == "--markup");
|
||||
let password = password_from(&rest)?;
|
||||
let mut s = open(&i, password.as_deref())?;
|
||||
if rest.iter().any(|a| a == "--update-fields") {
|
||||
s.run("references.updateFields", &json!({})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let mut s = open(&i)?;
|
||||
let r = if o.to_ascii_lowercase().ends_with(".png") {
|
||||
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup}))
|
||||
} else {
|
||||
let mut params = json!({"path": o, "markup": markup});
|
||||
if let Some(f) = format {
|
||||
params["format"] = json!(f);
|
||||
}
|
||||
s.run("file.save", ¶ms)
|
||||
s.run("file.save", &json!({"path": o, "markup": markup}))
|
||||
};
|
||||
r.map_err(|e| e.to_string())?;
|
||||
eprintln!("wrote {o}");
|
||||
Ok(())
|
||||
}
|
||||
"info" => {
|
||||
validate_options("info", &rest, PASSWORD_OPTIONS)?;
|
||||
let paths = positionals(&rest, PASSWORD_OPTIONS);
|
||||
let password = password_from(&rest)?;
|
||||
let mut s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?;
|
||||
validate_options("info", &rest, NO_OPTIONS)?;
|
||||
let mut s = open(&pos(0)?)?;
|
||||
let info = serde_json::to_string_pretty(&s.run("file.info", &json!({})).map_err(|e| e.to_string())?).unwrap_or_default();
|
||||
write_stdout(|o| writeln!(o, "{info}"))
|
||||
}
|
||||
"text" => {
|
||||
validate_options("text", &rest, PASSWORD_OPTIONS)?;
|
||||
let paths = positionals(&rest, PASSWORD_OPTIONS);
|
||||
let password = password_from(&rest)?;
|
||||
let s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?;
|
||||
let text = listed_text(&s.doc)?;
|
||||
write_stdout(|o| writeln!(o, "{text}"))
|
||||
validate_options("text", &rest, NO_OPTIONS)?;
|
||||
let s = open(&pos(0)?)?;
|
||||
write_stdout(|o| writeln!(o, "{}", s.doc.plain_text(wordcraft_doc::StoryRef::Body)))
|
||||
}
|
||||
"inspect" => {
|
||||
validate_options("inspect", &rest, PASSWORD_OPTIONS)?;
|
||||
let paths = positionals(&rest, PASSWORD_OPTIONS);
|
||||
let password = password_from(&rest)?;
|
||||
let mut s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?;
|
||||
validate_options("inspect", &rest, NO_OPTIONS)?;
|
||||
let mut s = open(&pos(0)?)?;
|
||||
let tree = serde_json::to_string_pretty(&s.run("document.inspect", &json!({})).map_err(|e| e.to_string())?).unwrap_or_default();
|
||||
write_stdout(|o| writeln!(o, "{tree}"))
|
||||
}
|
||||
"render" => {
|
||||
validate_options("render", &rest, RENDER_OPTIONS)?;
|
||||
let paths = positionals(&rest, RENDER_OPTIONS);
|
||||
let password = password_from(&rest)?;
|
||||
let (Some(i), Some(o)) = (paths.first(), paths.get(1)) else {
|
||||
return Err(missing_argument());
|
||||
};
|
||||
let (i, o) = (i.to_string(), o.to_string());
|
||||
let page = parse_page_arg("render", &rest)?.unwrap_or(1);
|
||||
let scale = parse_scale_arg("render", &rest)?.unwrap_or(2.0);
|
||||
let mut s = open(&i, password.as_deref())?;
|
||||
let (i, o) = (pos(0)?, pos(1)?);
|
||||
let mut s = open(&i)?;
|
||||
let page = arg_value(&rest, "--page").and_then(|p| p.parse::<u64>().ok()).unwrap_or(1);
|
||||
let scale = arg_value(&rest, "--scale").and_then(|p| p.parse::<f64>().ok()).unwrap_or(2.0);
|
||||
let r = s.run("file.exportPng", &json!({"path": o, "page": page, "scale": scale})).map_err(|e| e.to_string())?;
|
||||
write_stdout(|o| writeln!(o, "{r}"))
|
||||
}
|
||||
"run" => {
|
||||
validate_options("run", &rest, RUN_OPTIONS)?;
|
||||
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 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())?;
|
||||
let mut s = match (arg_value(&rest, "--file"), arg_value(&rest, "--template")) {
|
||||
(Some(f), _) => open(&f)?,
|
||||
(None, Some(t)) => {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("file.new", &json!({"template": t})).map_err(|e| e.to_string())?;
|
||||
s
|
||||
}
|
||||
_ => Session::new(wordcraft_doc::Document::new()),
|
||||
};
|
||||
if let Some(name) = arg_value(&rest, "--author") {
|
||||
set_author(&mut s, &name)?;
|
||||
}
|
||||
let print = rest.iter().any(|a| a == "--print");
|
||||
for (n, (id, params)) in script.iter().flat_map(|c| c.commands.iter()).enumerate() {
|
||||
let r = s.run(id, params).map_err(|e| format!("command {} ({id}): {e}", n + 1))?;
|
||||
if print {
|
||||
write_stdout(|o| writeln!(o, "{id}: {r}"))?;
|
||||
}
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < rest.len() {
|
||||
if rest.get(i).map(String::as_str) == Some("--cmd") {
|
||||
@@ -530,7 +213,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
None => (spec.clone(), json!({})),
|
||||
};
|
||||
let r = s.run(&id, ¶ms).map_err(|e| format!("{id}: {e}"))?;
|
||||
if print {
|
||||
if rest.iter().any(|a| a == "--print") {
|
||||
write_stdout(|o| writeln!(o, "{id}: {r}"))?;
|
||||
}
|
||||
i += 2;
|
||||
@@ -538,27 +221,13 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let outputs: Vec<String> = script.iter().flat_map(|c| c.save.iter().cloned()).chain(arg_value(&rest, "--save")).collect();
|
||||
// Check every output can be made before writing any, so a bad one leaves no half-done job.
|
||||
for out in &outputs {
|
||||
let parent = Path::new(out).parent().filter(|p| !p.as_os_str().is_empty());
|
||||
if let Some(p) = parent.filter(|p| !p.is_dir()) {
|
||||
return Err(format!("{out}: folder {} does not exist", p.display()).into());
|
||||
}
|
||||
if Path::new(out).is_dir() {
|
||||
return Err(format!("{out}: is a folder, not a file").into());
|
||||
}
|
||||
if !out.to_ascii_lowercase().ends_with(".png") {
|
||||
wordcraft_engine::io::save_bytes(out, &s.doc)?;
|
||||
}
|
||||
}
|
||||
for out in outputs {
|
||||
if let Some(out) = arg_value(&rest, "--save") {
|
||||
let r = if out.to_ascii_lowercase().ends_with(".png") {
|
||||
let mut v = json!({"path": out});
|
||||
if let Some(n) = page {
|
||||
if let Some(n) = arg_value(&rest, "--page").and_then(|p| p.parse::<u64>().ok()) {
|
||||
v["page"] = json!(n);
|
||||
}
|
||||
if let Some(k) = scale {
|
||||
if let Some(k) = arg_value(&rest, "--scale").and_then(|p| p.parse::<f64>().ok()) {
|
||||
v["scale"] = json!(k);
|
||||
}
|
||||
s.run("file.exportPng", &v)
|
||||
@@ -572,7 +241,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
}
|
||||
"commands" => {
|
||||
validate_options("commands", &rest, COMMANDS_OPTIONS)?;
|
||||
let s = session(wordcraft_doc::Document::new());
|
||||
let s = Session::new(wordcraft_doc::Document::new());
|
||||
if rest.iter().any(|a| a == "--json") {
|
||||
let list = serde_json::to_string_pretty(&s.registry.describe()).unwrap_or_default();
|
||||
write_stdout(|o| writeln!(o, "{list}"))
|
||||
@@ -587,7 +256,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
}
|
||||
"parity" => {
|
||||
validate_options("parity", &rest, PARITY_OPTIONS)?;
|
||||
let s = session(wordcraft_doc::Document::new());
|
||||
let s = Session::new(wordcraft_doc::Document::new());
|
||||
let p = wordcraft_engine::catalog::parity(&s.registry);
|
||||
if rest.iter().any(|a| a == "--markdown") {
|
||||
write_stdout(|o| write!(o, "{}", parity_markdown(&p)))
|
||||
@@ -596,53 +265,22 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
}
|
||||
}
|
||||
"mcp" => {
|
||||
let (roots, rest) = automation_roots("mcp", &rest)?;
|
||||
validate_options("mcp", &rest, MCP_OPTIONS)?;
|
||||
let author = arg_value(&rest, "--author");
|
||||
let connect = arg_value(&rest, "--connect");
|
||||
if roots.is_some() && connect.is_some() {
|
||||
return Err(
|
||||
"mcp: --automation-read-root and --automation-write-root confine the headless server; a running app is not confined by them"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let backend: Box<dyn wordcraft_mcp::Backend> = match connect {
|
||||
let backend: Box<dyn wordcraft_mcp::Backend> = match arg_value(&rest, "--connect") {
|
||||
// The app's user name is the person's own setting; don't rewrite it from a flag.
|
||||
Some(_) if author.is_some() => {
|
||||
return Err("mcp: --author applies to the headless server; with --connect, run file.setAuthor".into());
|
||||
}
|
||||
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();
|
||||
if let Some(roots) = roots {
|
||||
// Every thread of this process, not only the server's: nothing reaches past the roots.
|
||||
wordcraft_engine::file_access::confine_process(roots.clone());
|
||||
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
|
||||
eprintln!("wordcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
|
||||
headless = headless.with_automation_roots(Some(roots));
|
||||
}
|
||||
if let Some(name) = author {
|
||||
set_author(&mut headless.session, &name)?;
|
||||
}
|
||||
@@ -656,12 +294,10 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
}
|
||||
"zotero" => {
|
||||
validate_options("zotero", &rest, ZOTERO_OPTIONS)?;
|
||||
let paths = positionals(&rest, ZOTERO_OPTIONS);
|
||||
let (Some(name), Some(file)) = (paths.first(), paths.get(1)) else {
|
||||
return Err(missing_argument());
|
||||
};
|
||||
let command = wordcraft_zotero::Command::from_name(name).ok_or_else(|| format!("unknown Zotero command `{name}`\n\n{USAGE}"))?;
|
||||
let mut s = open(file, None)?;
|
||||
let name = pos(0)?;
|
||||
let command = wordcraft_zotero::Command::from_name(&name).ok_or_else(|| format!("unknown Zotero command `{name}`\n\n{USAGE}"))?;
|
||||
let file = pos(1)?;
|
||||
let mut s = open(&file)?;
|
||||
for spec in rest.iter().zip(rest.iter().skip(1)).filter(|(a, _)| *a == "--cmd").map(|(_, v)| v) {
|
||||
let (id, params) = match spec.split_once('=') {
|
||||
Some((id, p)) => (id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| format!("{id}: bad JSON params: {e}"))?),
|
||||
@@ -679,14 +315,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 +345,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",
|
||||
@@ -757,13 +372,7 @@ fn parity_markdown(p: &Value) -> String {
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<OsString> = std::env::args_os().skip(1).collect();
|
||||
// Language packs installed in the suite's font store are used; `proof.installPack` installs
|
||||
// only while the person has turned downloading on in the app.
|
||||
wordcraft_engine::packs::use_font_store(false);
|
||||
if let Some(source) = wordcraft_engine::packs::index_override() {
|
||||
eprintln!("wordcraft-cli: Language packs come from a test source: {source}");
|
||||
}
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
match run(&args) {
|
||||
Ok(()) | Err(Failure::StdoutClosed) => ExitCode::SUCCESS,
|
||||
Err(Failure::Error(e)) => {
|
||||
@@ -774,4 +383,49 @@ fn main() -> ExitCode {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
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"]), NO_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 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 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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
//! `run --script FILE`: commands read from a JSON file (`-` is stdin), so a job can be kept in a
|
||||
//! file instead of a long chain of `--cmd` flags (#1963).
|
||||
//!
|
||||
//! ```json
|
||||
//! {"file": "in.docx", "template": "sample",
|
||||
//! "commands": [{"command": "text.insert", "params": {"text": "Hi"}}, "caret.docEnd"],
|
||||
//! "save": ["out.docx", "out.pdf"]}
|
||||
//! ```
|
||||
//!
|
||||
//! `file` and `template` are optional (`--file` / `--template` on the command line win), and a
|
||||
//! command is an object with `command` and optional `params`, or just the id as a string. Relative
|
||||
//! `file` and `save` paths resolve against the script's folder (the working folder for stdin).
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// The largest script read. A job is a list of commands; anything bigger is a mistake.
|
||||
pub const MAX_SCRIPT_BYTES: u64 = 4 * 1024 * 1024;
|
||||
/// The most commands a script may hold.
|
||||
const MAX_COMMANDS: usize = 100_000;
|
||||
/// The most outputs a script may save to.
|
||||
const MAX_SAVES: usize = 64;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Script {
|
||||
pub file: Option<String>,
|
||||
pub template: Option<String>,
|
||||
pub commands: Vec<(String, Value)>,
|
||||
pub save: Vec<String>,
|
||||
}
|
||||
|
||||
/// Read the script at `arg` (`-` for stdin) and parse it.
|
||||
pub fn load(arg: &Path) -> Result<Script, String> {
|
||||
let name = arg.display().to_string();
|
||||
if arg == Path::new("-") {
|
||||
return load_from(std::io::stdin().lock(), &name, None);
|
||||
}
|
||||
let f = std::fs::File::open(arg).map_err(|e| format!("--script {name}: {e}"))?;
|
||||
load_from(f, &name, arg.parent().filter(|p| !p.as_os_str().is_empty()))
|
||||
}
|
||||
|
||||
/// Read and parse a script from `r`, called `name` in errors; relative paths resolve against `base`.
|
||||
pub fn load_from(r: impl Read, name: &str, base: Option<&Path>) -> Result<Script, String> {
|
||||
let text = read_capped(r, name)?;
|
||||
parse(&text, base).map_err(|e| format!("--script {name}: {e}"))
|
||||
}
|
||||
|
||||
fn read_capped(r: impl Read, name: &str) -> Result<String, String> {
|
||||
let mut bytes = Vec::new();
|
||||
r.take(MAX_SCRIPT_BYTES + 1).read_to_end(&mut bytes).map_err(|e| format!("--script {name}: {e}"))?;
|
||||
if bytes.len() as u64 > MAX_SCRIPT_BYTES {
|
||||
return Err(format!("--script {name}: script too large (over {} MiB)", MAX_SCRIPT_BYTES / 1024 / 1024));
|
||||
}
|
||||
String::from_utf8(bytes).map_err(|_| format!("--script {name}: not valid UTF-8"))
|
||||
}
|
||||
|
||||
fn resolve(path: &str, base: Option<&Path>) -> String {
|
||||
match base {
|
||||
Some(b) if Path::new(path).is_relative() => b.join(path).to_string_lossy().into_owned(),
|
||||
_ => path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn opt_string(obj: &serde_json::Map<String, Value>, key: &str) -> Result<Option<String>, String> {
|
||||
match obj.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(s)) => Ok(Some(s.clone())),
|
||||
Some(_) => Err(format!("`{key}` must be a string")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(text: &str, base: Option<&Path>) -> Result<Script, String> {
|
||||
let v: Value = serde_json::from_str(text).map_err(|e| format!("bad JSON: {e}"))?;
|
||||
let obj = v.as_object().ok_or("a script is a JSON object with a `commands` list")?;
|
||||
let list = match obj.get("commands") {
|
||||
Some(Value::Array(a)) => a,
|
||||
_ => return Err("`commands` must be a list".into()),
|
||||
};
|
||||
if list.is_empty() {
|
||||
return Err("`commands` is empty".into());
|
||||
}
|
||||
if list.len() > MAX_COMMANDS {
|
||||
return Err(format!("too many commands (over {MAX_COMMANDS})"));
|
||||
}
|
||||
let mut commands = Vec::with_capacity(list.len());
|
||||
for (i, c) in list.iter().enumerate() {
|
||||
let n = i + 1;
|
||||
let entry = match c {
|
||||
Value::String(id) => Some((id.clone(), Value::Object(Default::default()))),
|
||||
Value::Object(o) => match o.get("command") {
|
||||
Some(Value::String(id)) => Some((id.clone(), o.get("params").cloned().unwrap_or_else(|| Value::Object(Default::default())))),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
match entry {
|
||||
Some((id, _)) if id.is_empty() => return Err(format!("command {n}: empty command id")),
|
||||
Some(e) => commands.push(e),
|
||||
None => return Err(format!("command {n}: expected a command id or {{\"command\": id, \"params\": {{…}}}}")),
|
||||
}
|
||||
}
|
||||
let save = match obj.get("save") {
|
||||
None | Some(Value::Null) => Vec::new(),
|
||||
Some(Value::String(s)) => vec![resolve(s, base)],
|
||||
Some(Value::Array(a)) if a.len() <= MAX_SAVES => {
|
||||
a.iter().map(|s| s.as_str().map(|s| resolve(s, base)).ok_or("`save` entries must be strings")).collect::<Result<_, _>>()?
|
||||
}
|
||||
Some(_) => return Err(format!("`save` must be a path or a list of at most {MAX_SAVES} paths")),
|
||||
};
|
||||
Ok(Script { file: opt_string(obj, "file")?.map(|f| resolve(&f, base)), template: opt_string(obj, "template")?, commands, save })
|
||||
}
|
||||
@@ -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,49 +0,0 @@
|
||||
//! Explicit field refresh for headless conversions; ordinary conversion keeps cached results.
|
||||
use std::process::Command;
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_doc::{Document, Path, Pos, StoryRef};
|
||||
|
||||
#[test]
|
||||
fn convert_refresh_is_opt_in() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-convert-fields-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut doc = Document::from_text("Contents\nTarget");
|
||||
doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap().props.page_break_before = Some(true);
|
||||
doc.insert_object(&Pos::body(1, 0), InlineObject::BookmarkStart { name: "target".into() }, &Default::default()).unwrap();
|
||||
for locked in [true, false] {
|
||||
doc.insert_object(
|
||||
&Pos::body(0, 0),
|
||||
InlineObject::Field { instr: "PAGEREF target".into(), result: "104".into(), locked, dirty: true, code: false, form: String::new() },
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let input = dir.join("in.json");
|
||||
std::fs::write(&input, serde_json::to_vec(&doc).unwrap()).unwrap();
|
||||
let docx = dir.join("in.docx");
|
||||
let make = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).arg("convert").arg(&input).arg(&docx).output().unwrap();
|
||||
assert!(make.status.success(), "{}", String::from_utf8_lossy(&make.stderr));
|
||||
for refresh in [false, true] {
|
||||
let output = dir.join(if refresh { "refreshed.json" } else { "cached.json" });
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
|
||||
cmd.arg("convert").arg(&docx).arg(&output);
|
||||
if refresh {
|
||||
cmd.arg("--update-fields");
|
||||
}
|
||||
let run = cmd.output().unwrap();
|
||||
assert!(run.status.success(), "{}", String::from_utf8_lossy(&run.stderr));
|
||||
let out: Document = serde_json::from_slice(&std::fs::read(output).unwrap()).unwrap();
|
||||
let results: Vec<_> = out
|
||||
.para(StoryRef::Body, &Path::top(0))
|
||||
.unwrap()
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Field { result, .. } => Some(result.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(results, if refresh { vec!["2", "104"] } else { vec!["104", "104"] });
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
//! `wordcraft-cli convert page.html out.docx` puts an HTML file, which gives no page size, on the
|
||||
//! system region's paper, as the app does: A4 in Italy, Letter in the US (#1381).
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
const HTML: &str = "<html><body><h2>Premessa</h2><table border=1><thead><tr><th>Indicatore</th><th>Valore</th></tr></thead>\
|
||||
<tbody><tr><td>Audit</td><td style=\"text-align:right;\">4</td></tr></tbody></table></body></html>";
|
||||
|
||||
/// Run the CLI in `region` with `args` (`{src}` is the sample HTML, `{out}` a JSON file it
|
||||
/// writes) and read back the page size of the JSON it saved.
|
||||
fn page_size_of(region: &str, args: &[&str]) -> (f64, f64) {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-cli-region-{region}-{}-{}", args.len(), std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (src, out) = (dir.join("page.html"), dir.join("out.json"));
|
||||
std::fs::write(&src, HTML).unwrap();
|
||||
let args: Vec<String> = args.iter().map(|a| a.replace("{src}", &src.to_string_lossy()).replace("{out}", &out.to_string_lossy())).collect();
|
||||
let run = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(&args).env("WORDCRAFT_REGION", region).env_remove("LC_ALL").output().unwrap();
|
||||
assert!(run.status.success(), "{args:?}: {}", String::from_utf8_lossy(&run.stderr));
|
||||
let doc: serde_json::Value = serde_json::from_slice(&std::fs::read(&out).unwrap()).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let sp = &doc["lastSection"];
|
||||
(sp["pageW"].as_f64().unwrap(), sp["pageH"].as_f64().unwrap())
|
||||
}
|
||||
|
||||
/// Convert the sample to JSON in `region` and read back the page size.
|
||||
fn page_size(region: &str) -> (f64, f64) {
|
||||
page_size_of(region, &["convert", "{src}", "{out}"])
|
||||
}
|
||||
|
||||
fn is_a4((w, h): (f64, f64)) -> bool {
|
||||
(w - 595.28).abs() < 0.01 && (h - 841.89).abs() < 0.01
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_converts_onto_the_region_paper() {
|
||||
let (w, h) = page_size("it-IT");
|
||||
assert!(is_a4((w, h)), "A4 in Italy: {w} x {h}");
|
||||
assert_eq!(page_size("en-US"), (612.0, 792.0), "Letter in the US");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_run_session_follows_the_region() {
|
||||
// `run --file`, `run --template` and a bare `run` (with or without `file.new`) all start a session in the system region,
|
||||
// so `file.new` gives the same paper whichever way the session began.
|
||||
for args in [
|
||||
&["run", "--cmd", "file.new", "--save", "{out}"][..],
|
||||
&["run", "--cmd", "text.insert={\"text\":\"x\"}", "--save", "{out}"][..],
|
||||
&["run", "--template", "blank", "--save", "{out}"][..],
|
||||
&["run", "--file", "{src}", "--cmd", "file.new", "--save", "{out}"][..],
|
||||
] {
|
||||
assert!(is_a4(page_size_of("it-IT", args)), "A4 in Italy: {args:?} gave {:?}", page_size_of("it-IT", args));
|
||||
assert_eq!(page_size_of("en-US", args), (612.0, 792.0), "Letter in the US: {args:?}");
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
//! `--help` or `-h` after any subcommand prints the usage and exits 0, before options are checked
|
||||
//! or files and folders opened (#968).
|
||||
|
||||
use std::process::{Command, Output};
|
||||
|
||||
const SUBCOMMANDS: [&str; 10] = ["convert", "info", "text", "inspect", "render", "run", "commands", "parity", "mcp", "zotero"];
|
||||
|
||||
fn cli(args: &[&str]) -> Output {
|
||||
// `output()` gives the child no stdin, so `mcp` can't wait for a client.
|
||||
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
fn assert_prints_usage(args: &[&str]) {
|
||||
let out = cli(args);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "{args:?}: status {:?}, stderr: {stderr}", out.status);
|
||||
assert!(String::from_utf8_lossy(&out.stdout).starts_with("wordcraft-cli — "), "{args:?}: stdout: {}", String::from_utf8_lossy(&out.stdout));
|
||||
assert!(stderr.is_empty(), "{args:?}: stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_subcommand_prints_usage_for_help_and_h() {
|
||||
assert_prints_usage(&["--help"]);
|
||||
assert_prints_usage(&["-h"]);
|
||||
for sub in SUBCOMMANDS {
|
||||
for flag in ["--help", "-h"] {
|
||||
assert_prints_usage(&[sub, flag]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_comes_before_opening_files_and_folders() {
|
||||
assert_prints_usage(&["info", "missing.docx", "--help"]);
|
||||
assert_prints_usage(&["convert", "missing.docx", "out.pdf", "-h"]);
|
||||
assert_prints_usage(&["mcp", "--automation-read-root", "/definitely/not/a/folder", "--help"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn misspelled_options_and_unknown_commands_still_fail() {
|
||||
let out = cli(&["info", "--hepl"]);
|
||||
assert!(!out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("info: unknown option --hepl"));
|
||||
let out = cli(&["frobnicate", "--help"]);
|
||||
assert!(!out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("unknown command `frobnicate`"));
|
||||
}
|
||||
@@ -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,75 +0,0 @@
|
||||
//! 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.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn cli(args: &[OsString]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
/// A temp folder holding `caf\xe9.txt`, whose text is "Bonjour".
|
||||
fn folder(name: &str) -> (PathBuf, OsString) {
|
||||
let dir = std::env::temp_dir().join(format!("wc-cli-non-utf8-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join(OsString::from_vec(b"caf\xe9.txt".to_vec()));
|
||||
std::fs::write(&file, "Bonjour\n").unwrap();
|
||||
(dir, file.into_os_string())
|
||||
}
|
||||
|
||||
fn ok(out: &Output) -> String {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
|
||||
String::from_utf8_lossy(&out.stdout).into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_a_command_opens_may_have_any_name() {
|
||||
let (dir, file) = folder("open");
|
||||
assert_eq!(ok(&cli(&["text".into(), file.clone()])).trim(), "Bonjour");
|
||||
assert!(ok(&cli(&["info".into(), file.clone()])).contains("\"words\""));
|
||||
assert!(ok(&cli(&["inspect".into(), file.clone()])).contains("Bonjour"));
|
||||
|
||||
let md = dir.join("out.md");
|
||||
ok(&cli(&["convert".into(), file.clone(), md.clone().into()]));
|
||||
assert!(std::fs::read_to_string(&md).unwrap().contains("Bonjour"));
|
||||
|
||||
let saved = dir.join("run.txt");
|
||||
ok(&cli(&["run".into(), "--file".into(), file.clone(), "--save".into(), saved.clone().into()]));
|
||||
assert!(std::fs::read_to_string(&saved).unwrap().contains("Bonjour"));
|
||||
|
||||
// Saving without a path writes back to the file under its real name, not a lossy one.
|
||||
let mut args: Vec<OsString> = vec!["run".into(), "--file".into(), file.clone()];
|
||||
for cmd in ["caret.docEnd", "text.insert={\"text\":\" encore\"}", "file.save"] {
|
||||
args.extend(["--cmd".into(), cmd.into()]);
|
||||
}
|
||||
ok(&cli(&args));
|
||||
assert!(std::fs::read_to_string(&file).unwrap().contains("encore"));
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 3, "a second file was written");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_other_such_argument_is_an_error_naming_it() {
|
||||
let (dir, file) = folder("error");
|
||||
let out_name = dir.join(OsString::from_vec(b"caf\xe9.md".to_vec())).into_os_string();
|
||||
let cmd = OsString::from_vec(b"text.insert={\"text\":\"\xe9\"}".to_vec());
|
||||
for (args, n) in [
|
||||
(vec!["convert".into(), file.clone(), out_name.clone()], 3),
|
||||
(vec!["run".into(), "--file".into(), file.clone(), "--cmd".into(), cmd], 5),
|
||||
(vec![OsString::from_vec(b"t\xe9xt".to_vec()), file.clone()], 1),
|
||||
] {
|
||||
let out = cli(&args);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let shown = args[n - 1].to_string_lossy();
|
||||
assert!(shown.contains('\u{FFFD}'));
|
||||
assert_eq!(out.status.code(), Some(1), "{args:?}: stderr: {stderr}");
|
||||
assert_eq!(stderr, format!("wordcraft-cli: argument {n} is not valid Unicode: {shown}\n"), "{args:?}");
|
||||
}
|
||||
assert!(!std::path::Path::new(&out_name).exists());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
//! `wordcraft-cli run`: a picture inserted by `--cmd insert.picture` stays in the saved file when
|
||||
//! the next commands type (#965).
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
/// A 1x1 PNG.
|
||||
const PNG: &[u8] = &[
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
|
||||
0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn typing_after_insert_picture_keeps_it_in_the_saved_file() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-cli-965-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (pic, out) = (dir.join("pic.png"), dir.join("p.docx"));
|
||||
std::fs::write(&pic, PNG).unwrap();
|
||||
let picture = format!(r#"insert.picture={{"path":{},"width":250}}"#, serde_json::json!(pic.to_string_lossy()));
|
||||
let cmds = [r#"text.insert={"text":"x"}"#, "text.newParagraph", &picture, "text.newParagraph", r#"text.insert={"text":"cap"}"#];
|
||||
let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
|
||||
cli.args(["run", "--template", "blank"]);
|
||||
for c in cmds {
|
||||
cli.args(["--cmd", c]);
|
||||
}
|
||||
let run = cli.arg("--save").arg(&out).output().unwrap();
|
||||
let docx = std::fs::read(&out);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(run.status.success(), "stderr: {}", String::from_utf8_lossy(&run.stderr));
|
||||
assert!(docx.unwrap().windows(11).any(|w| w == b"word/media/"), "the saved file keeps the picture");
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
//! #866: `wordcraft-cli text` omits tracked deletions (it does not go through `document.text`).
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
fn cli() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_omits_tracked_deletions() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-cli-866-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("t.docx");
|
||||
let path_s = path.to_str().unwrap();
|
||||
|
||||
let make = cli()
|
||||
.args([
|
||||
"run",
|
||||
"--save",
|
||||
path_s,
|
||||
"--cmd",
|
||||
r#"text.insert={"text":"Signed by Alice Smith, Partner."}"#,
|
||||
"--cmd",
|
||||
r#"review.trackChanges={"value":true}"#,
|
||||
"--cmd",
|
||||
r#"select.text={"text":"Alice Smith, Partner"}"#,
|
||||
"--cmd",
|
||||
r#"text.insert={"text":"Bob Jones, Director"}"#,
|
||||
])
|
||||
.output()
|
||||
.expect("run");
|
||||
assert!(make.status.success(), "run failed: {}", String::from_utf8_lossy(&make.stderr));
|
||||
|
||||
let out = cli().args(["text", path_s]).output().expect("text");
|
||||
assert!(out.status.success(), "text failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert_eq!(stdout.trim_end(), "Signed by Bob Jones, Director.");
|
||||
assert!(!stdout.contains("Alice Smith"), "{stdout}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -6,20 +6,9 @@
|
||||
//!
|
||||
//! Differences from the desktop app: no TCP control channel; Open and Insert › Pictures use the
|
||||
//! 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))]
|
||||
mod prefs;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod save_fallback;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
//! Preferences between visits (#2132): the same [`UiState`] the desktop app keeps in `ui.json`
|
||||
//! (theme, interface size, recent symbols, editing options…), kept in the browser's
|
||||
//! `localStorage` under [`KEY`]. Restored at startup; written back whenever it changes, so a
|
||||
//! reload or a closed tab keeps the last setting (browsers don't reliably run exit code).
|
||||
|
||||
use wordcraft_ui_egui::{UiState, WordApp};
|
||||
|
||||
/// The `localStorage` key.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
pub const KEY: &str = "wordcraft.ui";
|
||||
/// Largest stored value read back (`localStorage` holds about 5 MB per site).
|
||||
const MAX_STORED: usize = 4 << 20;
|
||||
|
||||
/// Apply preferences stored on an earlier visit. Missing, oversized or unreadable values leave
|
||||
/// the defaults.
|
||||
pub fn restore(app: &mut WordApp, stored: Option<&str>) {
|
||||
if let Some(s) = stored.filter(|s| s.len() <= MAX_STORED)
|
||||
&& let Some(ui) = UiState::from_json(s.as_bytes())
|
||||
{
|
||||
app.apply_prefs(ui);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks what was last written, so the store is written only when a preference changed.
|
||||
#[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()));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
log::warn!("preferences: cannot serialize: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if self.last.as_deref() == Some(json.as_str()) {
|
||||
return None;
|
||||
}
|
||||
self.last = Some(json.clone());
|
||||
Some(json)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use wordcraft_ui_egui::Services;
|
||||
|
||||
fn app() -> WordApp {
|
||||
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() {
|
||||
let mut first = app();
|
||||
let mut sync = PrefsSync::new(None);
|
||||
let initial = sync.changed(&first);
|
||||
assert!(initial.is_some(), "first frame stores the preferences");
|
||||
assert_eq!(sync.changed(&first), None, "unchanged preferences are not rewritten");
|
||||
first.run("ui.theme", json!({"value": "dark"})).unwrap();
|
||||
let stored = sync.changed(&first).expect("the theme change is stored");
|
||||
|
||||
let mut reloaded = app();
|
||||
restore(&mut reloaded, Some(&stored));
|
||||
assert_eq!(reloaded.ui.theme, wordcraft_ui_egui::theme::Appearance::Dark);
|
||||
assert_eq!(PrefsSync::new(Some(stored)).changed(&reloaded), None, "restoring doesn't write back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_storage_keeps_defaults() {
|
||||
for junk in [None, Some(""), Some("not json"), Some("[1,2]"), Some("{\"theme\":42}")] {
|
||||
let mut a = app();
|
||||
restore(&mut a, junk);
|
||||
assert_eq!(a.ui.theme, wordcraft_ui_egui::theme::Appearance::System, "junk keeps the default appearance");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
//! Save-picker rejection policy, shared with native tests of the browser fallback.
|
||||
|
||||
use wordcraft_ui_egui::async_saves::SaveOutcome;
|
||||
|
||||
/// A cancelled picker ends the save. Other picker failures try the browser download path.
|
||||
/// Write/close errors do not pass through this function: they retain their own failure.
|
||||
pub(crate) fn picker_rejected(name: &str, download: impl FnOnce() -> Result<(), String>) -> SaveOutcome {
|
||||
if name == "AbortError" {
|
||||
SaveOutcome::Cancelled
|
||||
} else {
|
||||
match download() {
|
||||
Ok(()) => SaveOutcome::Saved,
|
||||
Err(error) => SaveOutcome::Failed(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unavailable_picker_downloads_once() {
|
||||
for name in ["SecurityError", "NotSupportedError", "TypeError", ""] {
|
||||
let mut calls = 0;
|
||||
let outcome = picker_rejected(name, || {
|
||||
calls += 1;
|
||||
Ok(())
|
||||
});
|
||||
assert_eq!(outcome, SaveOutcome::Saved);
|
||||
assert_eq!(calls, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_picker_never_downloads() {
|
||||
let outcome = picker_rejected("AbortError", || panic!("cancel must not download"));
|
||||
assert_eq!(outcome, SaveOutcome::Cancelled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_fallback_remains_a_failed_save() {
|
||||
let outcome = picker_rejected("SecurityError", || Err("download unavailable".into()));
|
||||
assert_eq!(outcome, SaveOutcome::Failed("download unavailable".into()));
|
||||
}
|
||||
}
|
||||
+43
-381
@@ -1,18 +1,16 @@
|
||||
//! The browser shell: web `Services`, drag-and-drop, the unsaved-changes guard and the eframe
|
||||
//! web runner.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome};
|
||||
use wordcraft_ui_egui::{Inbox, Services, WordApp};
|
||||
|
||||
const DOC_EXTS: &[&str] =
|
||||
&["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "mht", "mhtml", "tex", "json", "xml"];
|
||||
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
|
||||
const RECIPIENT_EXTS: &[&str] = wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS;
|
||||
const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
|
||||
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
|
||||
const RECIPIENT_EXTS: &[&str] = &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"];
|
||||
mod font_store;
|
||||
const CANVAS_ID: &str = "wordcraft_canvas";
|
||||
const LOADING_ID: &str = "wordcraft_loading";
|
||||
@@ -28,15 +26,8 @@ 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()) {
|
||||
log::error!("no unsaved-changes guard: {e}");
|
||||
}
|
||||
let mut options = eframe::WebOptions::default();
|
||||
@@ -56,61 +47,16 @@ pub fn start() {
|
||||
// Fonts the user added on an earlier visit (#582); the app notices them arrive.
|
||||
font_store::load_all(cc.egui_ctx.clone());
|
||||
let inbox: Inbox = Inbox::default();
|
||||
let pasted: Inbox = Inbox::default();
|
||||
if let Err(e) = listen_for_pasted_pictures(pasted.clone(), cc.egui_ctx.clone()) {
|
||||
if let Err(e) = listen_for_pasted_pictures(inbox.clone(), cc.egui_ctx.clone()) {
|
||||
log::error!("pasting pictures is unavailable: {e}");
|
||||
}
|
||||
// The browser's locale picks the new document's paper, the units and the
|
||||
// decimal separator (#838).
|
||||
let region = wordcraft_ui_egui::i18n::system_region();
|
||||
// In host mode the parent sends the document to edit; no sample meanwhile.
|
||||
let sample = app_bridge.is_none() && query().contains("sample");
|
||||
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(®ion) };
|
||||
let mut session = Session::new(doc);
|
||||
session.system_region = region;
|
||||
let messages = Rc::new(RefCell::new(Vec::new()));
|
||||
let mut services = services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone());
|
||||
if let Some(bridge) = &app_bridge {
|
||||
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
|
||||
}
|
||||
let mut app = WordApp::new(session, services);
|
||||
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
|
||||
app.services.pasted = Some(pasted);
|
||||
let doc = if query().contains("sample") { wordcraft_engine::sample::sample_document() } else { wordcraft_doc::Document::new() };
|
||||
let mut app = WordApp::new(Session::new(doc), services(inbox.clone(), cc.egui_ctx.clone(), dirty));
|
||||
app.autosave = false;
|
||||
// Preferences from an earlier visit (#2132), as the desktop app reads `ui.json`.
|
||||
let stored = stored_prefs();
|
||||
crate::prefs::restore(&mut app, stored.as_deref());
|
||||
// A region chosen in File › Options replaces the browser's for the first blank
|
||||
// document too (#838).
|
||||
if !sample && app.session.prefs.region.is_some() {
|
||||
let doc = app.session.blank_document();
|
||||
app.session.set_document(doc);
|
||||
}
|
||||
let mut prefs = crate::prefs::PrefsSync::new(stored);
|
||||
// No document to show: start on File › Home, as the desktop app does (#766).
|
||||
if !sample {
|
||||
app.start_screen();
|
||||
}
|
||||
// The host's name for the user signs this visit's work, but isn't stored as the
|
||||
// browser's own preference, which a visit outside the host would restore.
|
||||
if app_bridge.is_some()
|
||||
&& let Some(author) = query_param("author")
|
||||
{
|
||||
let own = app.session.author.clone();
|
||||
crate::host::Bridge::set_author(&mut app, &author);
|
||||
prefs.keep_own_author(&app.session.author, &own);
|
||||
}
|
||||
Ok(Box::new(WebShell { app, inbox, messages, prefs }))
|
||||
Ok(Box::new(WebShell { app, inbox }))
|
||||
}),
|
||||
)
|
||||
.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(),
|
||||
@@ -124,79 +70,38 @@ 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 {
|
||||
app: WordApp,
|
||||
inbox: Inbox,
|
||||
messages: Rc<RefCell<Vec<String>>>,
|
||||
prefs: crate::prefs::PrefsSync,
|
||||
}
|
||||
|
||||
fn local_storage() -> Option<web_sys::Storage> {
|
||||
web_sys::window()?.local_storage().ok()?
|
||||
}
|
||||
|
||||
/// The preferences stored on an earlier visit, if the browser allows storage.
|
||||
fn stored_prefs() -> Option<String> {
|
||||
local_storage()?.get_item(crate::prefs::KEY).ok()?
|
||||
}
|
||||
|
||||
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 msgs = std::mem::take(&mut *self.messages.borrow_mut());
|
||||
for msg in msgs {
|
||||
self.app.status(msg);
|
||||
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.logic(ctx);
|
||||
// Store changed preferences straight away: a reload or closed tab runs no exit code (#2132).
|
||||
if let Some(json) = self.prefs.changed(&self.app) {
|
||||
match local_storage() {
|
||||
Some(store) => {
|
||||
if let Err(e) = store.set_item(crate::prefs::KEY, &json) {
|
||||
log::warn!("preferences: cannot save: {e:?}");
|
||||
}
|
||||
}
|
||||
None => log::warn!("preferences: browser storage is unavailable"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -208,11 +113,10 @@ const MAX_PASTED_PICTURES: u32 = 20;
|
||||
|
||||
/// Pasting a picture (#45): eframe's paste handler only reads text, so a clipboard holding just
|
||||
/// pictures (a screenshot, a browser's Copy Image) is read here and the pictures arrive through
|
||||
/// the app's `pasted` queue, which inserts them only when the page is the edit target (#1349). A clipboard with text is left to eframe.
|
||||
/// the inbox like dropped ones. A clipboard with text is left to eframe.
|
||||
fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), String> {
|
||||
let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
|
||||
let on_paste = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::ClipboardEvent)>::new(move |e: web_sys::ClipboardEvent| {
|
||||
// The target is always eframe's hidden text input while the page has focus; the app decides (#1349).
|
||||
let Some(data) = e.clipboard_data() else { return };
|
||||
if !data.get_data("text").unwrap_or_default().is_empty() {
|
||||
return;
|
||||
@@ -233,7 +137,7 @@ fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), St
|
||||
match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
|
||||
Ok(buf) => {
|
||||
let bytes = js_sys::Uint8Array::new(&buf).to_vec();
|
||||
// The engine reads the format from the bytes; the name is ignored.
|
||||
// The inbox inserts files named like pictures; the engine reads the format from the bytes.
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(("pasted.png".into(), bytes));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
@@ -249,11 +153,8 @@ fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), St
|
||||
}
|
||||
|
||||
/// `dirty` is the flag the `beforeunload` guard reads ([`guard_unload`]).
|
||||
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: AsyncSaves, messages: Rc<RefCell<Vec<String>>>) -> Services {
|
||||
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
let save_ctx = ctx.clone();
|
||||
let font_ctx = ctx.clone();
|
||||
let font_messages = messages.clone();
|
||||
Services {
|
||||
open_async: Some(Box::new(move |purpose: &str| {
|
||||
let inbox = open_inbox.clone();
|
||||
@@ -267,145 +168,28 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: Asyn
|
||||
} else {
|
||||
rfd::AsyncFileDialog::new().add_filter("Documents", DOC_EXTS)
|
||||
};
|
||||
let many = purpose == "font";
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// Add Font takes every selected file (#1789); the others one.
|
||||
let files = if many { dialog.pick_files().await.unwrap_or_default() } else { dialog.pick_file().await.into_iter().collect() };
|
||||
for file in files.into_iter().take(MAX_PICKED_FILES) {
|
||||
let mut bytes = file.read().await;
|
||||
let mut pending = inbox.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Fonts waiting for the app to drain them stay under a byte cap. A file
|
||||
// over it is queued empty, so Add Font reports that file on its own.
|
||||
let held: usize = pending.iter().map(|(_, b)| b.len()).fold(0, usize::saturating_add);
|
||||
if many && held.saturating_add(bytes.len()) > MAX_PENDING_PICKED_BYTES {
|
||||
bytes = Vec::new();
|
||||
}
|
||||
pending.push((file.file_name(), bytes));
|
||||
drop(pending);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
let Some(file) = dialog.pick_file().await else {
|
||||
return;
|
||||
};
|
||||
let bytes = file.read().await;
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
})),
|
||||
// Exports ask for a name; the browser decides where the download goes.
|
||||
pick_save: Some(Box::new(|name: &str| Some(name.to_string()))),
|
||||
async_saves: Some(saves.clone()),
|
||||
download: Some(Box::new(move |path: &str, bytes: &[u8]| download(&saves, &save_ctx, path, bytes))),
|
||||
download: Some(Box::new(download)),
|
||||
print: Some(Box::new(print_pdf)),
|
||||
inbox: Some(inbox),
|
||||
on_dirty: Some(Box::new(move |d| dirty.set(d))),
|
||||
animations_enabled: Some(animations_enabled()),
|
||||
store_font: Some(Box::new(font_store::store)),
|
||||
forget_font: Some(Box::new(font_store::forget)),
|
||||
load_system_fonts: Some(Box::new(move || {
|
||||
load_system_fonts(font_ctx.clone(), font_messages.clone());
|
||||
})),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Most files one multi-select pick (Add Font) reads.
|
||||
const MAX_PICKED_FILES: usize = 2000;
|
||||
/// Most bytes of picked fonts held in the inbox before the app drains them.
|
||||
const MAX_PENDING_PICKED_BYTES: usize = 256 << 20;
|
||||
/// Most local fonts one Load System Installed Fonts installs (a designer's machine has thousands).
|
||||
const MAX_SYSTEM_FONTS: usize = 500;
|
||||
/// Most font bytes one Load System Installed Fonts reads into memory (wasm memory is small).
|
||||
const MAX_SYSTEM_FONT_BYTES: usize = 256 << 20;
|
||||
/// Most local fonts looked at, installed or not (already available, unreadable, too big).
|
||||
const MAX_SYSTEM_FONTS_TRIED: u32 = 4000;
|
||||
|
||||
/// Query the browser's Local Font Access API (`window.queryLocalFonts()`) and install the faces
|
||||
/// into the global font database, up to [`MAX_SYSTEM_FONTS`] faces and [`MAX_SYSTEM_FONT_BYTES`].
|
||||
fn load_system_fonts(ctx: egui::Context, messages: Rc<RefCell<Vec<String>>>) {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(window) = web_sys::window() else {
|
||||
return;
|
||||
};
|
||||
let query_fn = match js_sys::Reflect::get(&window, &wasm_bindgen::JsValue::from_str("queryLocalFonts")) {
|
||||
Ok(val) if val.is_function() => js_sys::Function::from(val),
|
||||
_ => {
|
||||
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("Local font access is not supported by your browser.").to_string());
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let fonts_promise = match query_fn.call0(&window) {
|
||||
Ok(p) => js_sys::Promise::from(p),
|
||||
Err(e) => {
|
||||
log::warn!("queryLocalFonts call failed: {e:?}");
|
||||
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("Local font access was denied.").to_string());
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let fonts_val = match wasm_bindgen_futures::JsFuture::from(fonts_promise).await {
|
||||
Ok(val) => val,
|
||||
Err(e) => {
|
||||
log::warn!("queryLocalFonts promise rejected: {e:?}");
|
||||
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("Local font access was denied.").to_string());
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let font_list = js_sys::Array::from(&fonts_val);
|
||||
let len = font_list.length();
|
||||
let mut loaded = 0usize;
|
||||
let mut total_bytes = 0usize;
|
||||
for i in 0..len.min(MAX_SYSTEM_FONTS_TRIED) {
|
||||
if loaded >= MAX_SYSTEM_FONTS || total_bytes >= MAX_SYSTEM_FONT_BYTES {
|
||||
log::warn!("local fonts: stopped after {loaded} fonts ({total_bytes} bytes)");
|
||||
break;
|
||||
}
|
||||
let font_data = font_list.get(i);
|
||||
let Ok(blob_fn) = js_sys::Reflect::get(&font_data, &wasm_bindgen::JsValue::from_str("blob")) else {
|
||||
continue;
|
||||
};
|
||||
if !blob_fn.is_function() {
|
||||
continue;
|
||||
}
|
||||
let Ok(blob_val) = js_sys::Function::from(blob_fn).call0(&font_data) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(blob_js) = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(blob_val)).await else {
|
||||
continue;
|
||||
};
|
||||
let Ok(blob) = blob_js.dyn_into::<web_sys::Blob>() else {
|
||||
continue;
|
||||
};
|
||||
// Skip a face too big on its own or for what is left of the budget, before reading it.
|
||||
let size = blob.size().clamp(0.0, usize::MAX as f64) as usize;
|
||||
if size > wordcraft_fonts::MAX_FONT_FILE_BYTES || total_bytes.saturating_add(size) > MAX_SYSTEM_FONT_BYTES {
|
||||
continue;
|
||||
}
|
||||
let Ok(array_buffer) = wasm_bindgen_futures::JsFuture::from(blob.array_buffer()).await else {
|
||||
continue;
|
||||
};
|
||||
let bytes = js_sys::Uint8Array::new(&array_buffer).to_vec();
|
||||
if bytes.len() > wordcraft_fonts::MAX_FONT_FILE_BYTES {
|
||||
continue;
|
||||
}
|
||||
total_bytes = total_bytes.saturating_add(bytes.len());
|
||||
let ps_name = js_sys::Reflect::get(&font_data, &wasm_bindgen::JsValue::from_str("postscriptName"))
|
||||
.ok()
|
||||
.and_then(|v| v.as_string())
|
||||
.unwrap_or_else(|| format!("local-font-{i}"));
|
||||
let name = format!("{ps_name}.ttf");
|
||||
match wordcraft_fonts::FontDb::global().install_font(&name, &bytes) {
|
||||
Ok(_) => loaded += 1,
|
||||
Err(wordcraft_fonts::UserFontError::AlreadyAvailable(_)) => {}
|
||||
Err(e) => log::warn!("local font {name} skipped: {e}"),
|
||||
}
|
||||
}
|
||||
if loaded > 0 {
|
||||
let msg = wordcraft_ui_egui::i18n::fmt(wordcraft_ui_egui::i18n::t("Loaded {count} system fonts."), &[("count", &loaded.to_string())]);
|
||||
messages.borrow_mut().push(msg);
|
||||
} else {
|
||||
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("All system fonts are already available.").to_string());
|
||||
}
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the system has animation effects on, as the browser passes it on: off while the
|
||||
/// `prefers-reduced-motion: reduce` media query matches. `None` when the browser can't say. The
|
||||
/// query is made once and asked every frame; it follows the system's setting by itself.
|
||||
@@ -416,11 +200,10 @@ fn animations_enabled() -> Box<dyn Fn() -> Option<bool>> {
|
||||
|
||||
/// Closing, reloading or leaving the tab with unsaved changes asks first. Browsers show their own
|
||||
/// confirmation (a page can only ask for it), so there is no Save button: Cancel, then File › Save.
|
||||
fn guard_unload(dirty: Rc<Cell<bool>>, saves: AsyncSaves) -> Result<(), String> {
|
||||
fn guard_unload(dirty: Rc<Cell<bool>>) -> Result<(), String> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(move |e: web_sys::BeforeUnloadEvent| {
|
||||
// A save still in the browser's picker hasn't written anything yet.
|
||||
if dirty.get() || saves.pending() > 0 {
|
||||
if dirty.get() {
|
||||
e.prevent_default();
|
||||
// Older browsers ask only when `returnValue` is set.
|
||||
e.set_return_value("unsaved changes");
|
||||
@@ -432,141 +215,20 @@ fn guard_unload(dirty: Rc<Cell<bool>>, saves: AsyncSaves) -> Result<(), String>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Trigger a browser save dialog or download of `bytes` named after the last component of `path`.
|
||||
fn download(saves: &AsyncSaves, ctx: &egui::Context, path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
/// Trigger a browser download of `bytes` named after the last component of `path`.
|
||||
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "document".into());
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
|
||||
// On browsers supporting the File System Access API (Chromium: Chrome, Edge, etc.),
|
||||
// `showSaveFilePicker` lets the user choose the save location and filename natively.
|
||||
if let Ok(picker_val) = js_sys::Reflect::get(&window, &wasm_bindgen::JsValue::from_str("showSaveFilePicker"))
|
||||
&& let Ok(picker_fn) = picker_val.dyn_into::<js_sys::Function>()
|
||||
{
|
||||
match trigger_save_file_picker(saves, ctx, &window, &picker_fn, &name, bytes) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) => {
|
||||
log::warn!("showSaveFilePicker failed: {e}; falling back to anchor download");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for browsers without showSaveFilePicker (e.g. Firefox, Safari):
|
||||
trigger_anchor_download(&window, &name, bytes)
|
||||
}
|
||||
|
||||
fn trigger_save_file_picker(
|
||||
saves: &AsyncSaves,
|
||||
ctx: &egui::Context,
|
||||
window: &web_sys::Window,
|
||||
picker_fn: &js_sys::Function,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("docx").to_ascii_lowercase();
|
||||
let dot_ext = format!(".{ext}");
|
||||
let mime = mime_for(name);
|
||||
|
||||
let accept_obj = js_sys::Object::new();
|
||||
let exts_arr = js_sys::Array::new();
|
||||
exts_arr.push(&wasm_bindgen::JsValue::from_str(&dot_ext));
|
||||
let _ = js_sys::Reflect::set(&accept_obj, &wasm_bindgen::JsValue::from_str(mime), &exts_arr);
|
||||
|
||||
// Our own neutral description: the format's name, as its extension says it.
|
||||
let type_desc = match ext.as_str() {
|
||||
"htm" => "HTML".to_string(),
|
||||
"txt" => "Text".to_string(),
|
||||
e if !e.is_empty() && e.len() <= 8 && e.chars().all(|c| c.is_ascii_alphanumeric()) => e.to_ascii_uppercase(),
|
||||
_ => "Document".to_string(),
|
||||
};
|
||||
|
||||
let type_obj = js_sys::Object::new();
|
||||
let _ = js_sys::Reflect::set(&type_obj, &wasm_bindgen::JsValue::from_str("description"), &wasm_bindgen::JsValue::from_str(&type_desc));
|
||||
let _ = js_sys::Reflect::set(&type_obj, &wasm_bindgen::JsValue::from_str("accept"), &accept_obj);
|
||||
|
||||
let types_arr = js_sys::Array::new();
|
||||
types_arr.push(&type_obj);
|
||||
|
||||
let picker_opts = js_sys::Object::new();
|
||||
let _ = js_sys::Reflect::set(&picker_opts, &wasm_bindgen::JsValue::from_str("suggestedName"), &wasm_bindgen::JsValue::from_str(name));
|
||||
let _ = js_sys::Reflect::set(&picker_opts, &wasm_bindgen::JsValue::from_str("types"), &types_arr);
|
||||
|
||||
// Call showSaveFilePicker synchronously while transient user activation is active:
|
||||
let prom_val = picker_fn.call1(window, &picker_opts).map_err(js)?;
|
||||
let prom: js_sys::Promise = prom_val.dyn_into().map_err(|_| "not a promise".to_string())?;
|
||||
|
||||
let bytes_vec = bytes.to_vec();
|
||||
let window = window.clone();
|
||||
let saves = saves.clone();
|
||||
let ctx = ctx.clone();
|
||||
let name = name.to_string();
|
||||
// The app holds back a Save Changes prompt's New/Open until this ticket ends (#1315).
|
||||
let ticket = saves.start();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// The document counts as saved only once the file is written and closed.
|
||||
let outcome = match wasm_bindgen_futures::JsFuture::from(prom).await {
|
||||
Ok(handle_val) => match write_file_handle(&handle_val, &bytes_vec).await {
|
||||
Ok(()) => {
|
||||
log::info!("saved {name} through the save picker");
|
||||
SaveOutcome::Saved
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("couldn't write {name}: {e}");
|
||||
SaveOutcome::Failed(e)
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let err_name =
|
||||
js_sys::Reflect::get(&e, &wasm_bindgen::JsValue::from_str("name")).ok().and_then(|v| v.as_string()).unwrap_or_default();
|
||||
// A browser can expose the picker but reject its promise (for example in
|
||||
// a cross-origin iframe). Use the same download path as an absent API;
|
||||
// cancelling a picker must never start an unwanted download (#1246).
|
||||
crate::save_fallback::picker_rejected(&err_name, || trigger_anchor_download(&window, &name, &bytes_vec))
|
||||
}
|
||||
};
|
||||
saves.finish(ticket, outcome);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_file_handle(handle: &wasm_bindgen::JsValue, bytes: &[u8]) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let create_fn: js_sys::Function = js_sys::Reflect::get(handle, &wasm_bindgen::JsValue::from_str("createWritable"))
|
||||
.map_err(js)?
|
||||
.dyn_into()
|
||||
.map_err(|_| "createWritable is not a function".to_string())?;
|
||||
let stream_prom: js_sys::Promise = create_fn.call0(handle).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?;
|
||||
let stream = wasm_bindgen_futures::JsFuture::from(stream_prom).await.map_err(js)?;
|
||||
|
||||
let write_fn: js_sys::Function = js_sys::Reflect::get(&stream, &wasm_bindgen::JsValue::from_str("write"))
|
||||
.map_err(js)?
|
||||
.dyn_into()
|
||||
.map_err(|_| "write is not a function".to_string())?;
|
||||
let uint8 = js_sys::Uint8Array::from(bytes);
|
||||
let write_prom: js_sys::Promise = write_fn.call1(&stream, &uint8).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?;
|
||||
wasm_bindgen_futures::JsFuture::from(write_prom).await.map_err(js)?;
|
||||
|
||||
let close_fn: js_sys::Function = js_sys::Reflect::get(&stream, &wasm_bindgen::JsValue::from_str("close"))
|
||||
.map_err(js)?
|
||||
.dyn_into()
|
||||
.map_err(|_| "close is not a function".to_string())?;
|
||||
let close_prom: js_sys::Promise = close_fn.call0(&stream).map_err(js)?.dyn_into().map_err(|_| "not a promise".to_string())?;
|
||||
wasm_bindgen_futures::JsFuture::from(close_prom).await.map_err(js)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trigger_anchor_download(window: &web_sys::Window, name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let document = window.document().ok_or("no document")?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type(mime_for(name));
|
||||
opts.set_type(mime_for(&name));
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
|
||||
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor".to_string())?;
|
||||
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
|
||||
a.set_href(&url);
|
||||
a.set_download(name);
|
||||
a.set_download(&name);
|
||||
a.style().set_property("display", "none").map_err(js)?;
|
||||
let body = document.body().ok_or("no body")?;
|
||||
body.append_child(&a).map_err(js)?;
|
||||
|
||||
@@ -36,7 +36,7 @@ async fn open() -> Result<IdbDatabase, String> {
|
||||
let req = factory.open_with_u32(DB_NAME, 1).map_err(js)?;
|
||||
let upgrade_req = req.clone();
|
||||
let upgrade = Closure::<dyn FnMut()>::new(move || {
|
||||
if let Ok(db) = upgrade_req.result().and_then(|r| r.dyn_into::<IdbDatabase>())
|
||||
if let Ok(db) = upgrade_req.result().and_then(|r| r.dyn_into::<IdbDatabase>().map_err(JsValue::from))
|
||||
&& !db.object_store_names().contains(STORE)
|
||||
&& let Err(e) = db.create_object_store(STORE)
|
||||
{
|
||||
|
||||
@@ -13,17 +13,15 @@ workspace = true
|
||||
[dependencies]
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-engine = { workspace = true }
|
||||
wordcraft-proof = { workspace = true }
|
||||
# Fonts the user added (#582) load from the settings folder at start-up.
|
||||
wordcraft-fonts = { workspace = true }
|
||||
wordcraft-ui-egui = { workspace = true }
|
||||
# The control key file's location (shared with the MCP bridge).
|
||||
wordcraft-control-key = { workspace = true }
|
||||
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 }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for wordcraft.exe; build.rs only uses it when the target is Windows.
|
||||
@@ -37,52 +35,35 @@ winresource = "0.1"
|
||||
fmv-macos-events = "=0.1.0"
|
||||
# The system's Reduce motion setting (System Settings › Accessibility › Display), read through
|
||||
# NSWorkspace: a safe call in these bindings. Already in the build through eframe, rfd and arboard,
|
||||
# at this version; this adds the `NSAccessibility` bindings and, to fit the title bar around the
|
||||
# traffic lights, the window, button and view ones.
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication", "NSWindow", "NSButton", "NSControl", "NSView", "NSResponder", "NSPasteboard"] }
|
||||
# at this version; this adds only the `NSAccessibility` bindings.
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication"] }
|
||||
objc2 = "0.6"
|
||||
# A copied equation's MathML goes on the pasteboard as `public.mathml.presentation` beside
|
||||
# arboard's HTML and text (#1297), through NSPasteboard's safe `setString:forType:`. Already in
|
||||
# the build through arboard, at this version; this adds only the NSString bindings.
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString"] }
|
||||
# Closing the window must quit through NSApplication rather than by dropping the NSWindow
|
||||
# inside the AppKit run loop (see `schedule_macos_terminate`).
|
||||
dispatch2 = "0.3"
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
# The desktop's animation setting on Linux and the BSDs, read from the XDG settings portal over
|
||||
# D-Bus. Already in the build through AccessKit, at this version; this adds only its blocking API.
|
||||
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
|
||||
# Wayland's clipboard through the data-control protocol (#687, #833): the RTF and other picture
|
||||
# types that arboard doesn't read, and the formats offered. Already in the build through arboard's
|
||||
# `wayland-data-control` feature, at this version.
|
||||
wl-clipboard-rs = "0.9"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# The upstream default-apps button opens Windows Settings; printing uses craft-print instead.
|
||||
open = "5"
|
||||
# The system's Animation effects setting (Settings › Accessibility › Visual effects), read through
|
||||
# the Windows Runtime's UISettings: a safe API, where the Win32 call needs unsafe, which our
|
||||
# workspace forbids. Already in the build through wgpu and AccessKit, at this version; this adds
|
||||
# only the `UI_ViewManagement` bindings, and `System_UserProfile` for the home region new
|
||||
# documents take their paper and units from (#838).
|
||||
windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfile"] }
|
||||
# The clipboard's "Rich Text Format" and the names of its formats (#687, #833), which arboard
|
||||
# doesn't read. Already in the build through arboard, at this version.
|
||||
clipboard-win = { version = "5.4", features = ["std"] }
|
||||
# only the `UI_ViewManagement` bindings.
|
||||
windows = { version = "0.62", features = ["UI_ViewManagement"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
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 }
|
||||
# File › Print opens the PDF in the system's default viewer (`xdg-open`/`gio` on Linux and BSD, the
|
||||
# file association on Windows, `open` on macOS) without blocking; already in the tree via egui_kittest.
|
||||
open = "5.4"
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
//! The system clipboard beyond egui's text (#45, #584, #687, #833): text for the ribbon's Paste,
|
||||
//! another program's formatted copy (HTML, and RTF where the platform offers it), pictures and
|
||||
//! copied files, and the names of the formats there for a paste that finds nothing it can use.
|
||||
//!
|
||||
//! On Linux, arboard talks to Wayland compositors through the data-control protocol (KDE Plasma,
|
||||
//! wlroots compositors); without it, a native Wayland window only sees what the compositor copies
|
||||
//! over to Xwayland, which KWin does only while an X11 window has focus — so pictures copied in a
|
||||
//! browser or Spectacle never arrived (#833). Compositors without the protocol fall back to X11.
|
||||
//!
|
||||
//! Everything read here comes from other programs: sizes are capped as it is read.
|
||||
//!
|
||||
//! Written here: a copy puts HTML beside its text, so other programs keep its formatting (#1523),
|
||||
//! with equations as MathML (#816). arboard sets HTML with a text alternative on every desktop
|
||||
//! platform. A copy of one equation alone also goes in the platform's MathML formats (#1297),
|
||||
//! added beside arboard's: on Windows "MathML" and "MathML Presentation" (UTF-16LE), on macOS
|
||||
//! `public.mathml.presentation` and `public.mathml` — the names W3C MathML 3 §6.2.4 registers
|
||||
//! for transferring MathML, and the ones Microsoft documents its Office apps reading first when
|
||||
//! they paste math (learn.microsoft.com/office/math/mathml, "Clipboard support"). Pasting into
|
||||
//! Word and LibreOffice is not yet verified. Linux and the BSDs have no Word, and arboard can't
|
||||
//! add MIME types of other names, so there the HTML carries the MathML alone.
|
||||
|
||||
use wordcraft_engine::cmd::paste::MAX_PASTE_BYTES;
|
||||
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
|
||||
|
||||
fn open() -> Option<arboard::Clipboard> {
|
||||
arboard::Clipboard::new().map_err(|e| log::warn!("clipboard: {e}")).ok()
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The clipboard copies are set through, kept open: on X11 the contents are served from it.
|
||||
static WRITER: std::cell::RefCell<Option<arboard::Clipboard>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Put `html` on the clipboard with `text` as its plain-text alternative, and `mathml` (a copy of
|
||||
/// one equation alone; empty for any other copy) in the platform's MathML formats. `false` when
|
||||
/// the HTML and text couldn't be set (the caller copies the text alone), including a Wayland
|
||||
/// session without the data-control protocol, where arboard would fall back to X11 and native
|
||||
/// Wayland programs might not see it. MathML that can't be added is logged: the HTML still
|
||||
/// carries it.
|
||||
pub fn set_html(html: &str, text: &str, mathml: &str) -> bool {
|
||||
if !platform::can_set() {
|
||||
return false;
|
||||
}
|
||||
let set = set_html_text(html, text);
|
||||
if set && !mathml.is_empty() && mathml.len() <= MAX_PASTE_BYTES {
|
||||
platform::add_mathml(mathml);
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
fn set_html_text(html: &str, text: &str) -> bool {
|
||||
WRITER.with(|w| {
|
||||
let Ok(mut slot) = w.try_borrow_mut() else { return false };
|
||||
if slot.is_none() {
|
||||
*slot = open();
|
||||
}
|
||||
match slot.as_mut().map(|c| c.set().html(html, Some(text))) {
|
||||
Some(Ok(())) => true,
|
||||
Some(Err(e)) => {
|
||||
log::warn!("clipboard: {e}");
|
||||
*slot = None;
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The clipboard's text.
|
||||
pub fn text() -> Option<String> {
|
||||
open()?.get_text().ok()
|
||||
}
|
||||
|
||||
/// The clipboard's HTML (Windows: the fragment of its "HTML Format"), if not too large.
|
||||
pub fn html() -> Option<String> {
|
||||
open()?.get().html().ok().filter(|h| h.len() <= MAX_PASTE_BYTES)
|
||||
}
|
||||
|
||||
/// The clipboard's RTF: Windows ("Rich Text Format") and Wayland (`text/rtf`); not offered by
|
||||
/// arboard, so read here.
|
||||
pub fn rtf() -> Option<String> {
|
||||
let bytes = platform::rtf()?;
|
||||
if bytes.len() > MAX_PASTE_BYTES {
|
||||
return None;
|
||||
}
|
||||
// RTF is 7-bit by the spec; stray 8-bit bytes become replacement characters.
|
||||
let s = String::from_utf8_lossy(&bytes).trim_end_matches('\0').to_string();
|
||||
s.trim_start().starts_with("{\\rtf").then_some(s)
|
||||
}
|
||||
|
||||
/// The names of the formats on the clipboard, where the platform lists them (Wayland, Windows).
|
||||
pub fn formats() -> Vec<String> {
|
||||
platform::formats()
|
||||
}
|
||||
|
||||
/// The picture on the clipboard: copied files, else image pixels, else (Wayland) an encoded
|
||||
/// picture in a format arboard doesn't decode. A picture that is there but can't be read says
|
||||
/// why (#833). The UI checks the sizes before inserting anything; on Windows they are checked
|
||||
/// from the header before anything is decoded (#3623).
|
||||
pub fn picture() -> Option<ClipboardPicture> {
|
||||
let mut clipboard = match arboard::Clipboard::new() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("clipboard: {e}");
|
||||
return Some(ClipboardPicture::Unreadable(format!("the system clipboard couldn't be opened ({e})")));
|
||||
}
|
||||
};
|
||||
if let Ok(files) = clipboard.get().file_list()
|
||||
&& !files.is_empty()
|
||||
{
|
||||
return Some(ClipboardPicture::Files(files));
|
||||
}
|
||||
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),
|
||||
Err(arboard::Error::ConversionFailure) => Some(
|
||||
platform::encoded_picture()
|
||||
.map(ClipboardPicture::Encoded)
|
||||
.unwrap_or_else(|| ClipboardPicture::Unreadable("WordCraft can't decode its format".into())),
|
||||
),
|
||||
Err(e) => {
|
||||
log::warn!("clipboard picture: {e}");
|
||||
platform::encoded_picture().map(ClipboardPicture::Encoded).or(Some(ClipboardPicture::Unreadable(e.to_string())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Picture types read straight from Wayland when arboard (PNG only) finds none, best first.
|
||||
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))]
|
||||
const PICTURE_MIMES: [&str; 6] = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp", "image/x-bmp"];
|
||||
|
||||
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))]
|
||||
mod platform {
|
||||
use std::io::Read;
|
||||
|
||||
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
|
||||
|
||||
fn wayland() -> bool {
|
||||
std::env::var_os("WAYLAND_DISPLAY").is_some()
|
||||
}
|
||||
|
||||
/// One MIME type's bytes, at most `cap`; `None` when absent or larger.
|
||||
fn read(mime: &str, cap: u64) -> Option<Vec<u8>> {
|
||||
let (pipe, _) = get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Specific(mime)).ok()?;
|
||||
let mut out = Vec::new();
|
||||
pipe.take(cap.saturating_add(1)).read_to_end(&mut out).ok()?;
|
||||
(out.len() as u64 <= cap).then_some(out)
|
||||
}
|
||||
|
||||
pub fn formats() -> Vec<String> {
|
||||
if !wayland() {
|
||||
return Vec::new();
|
||||
}
|
||||
get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether arboard sets the clipboard natively: X11, or Wayland with data-control.
|
||||
pub fn can_set() -> bool {
|
||||
!wayland()
|
||||
|| !matches!(
|
||||
get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified),
|
||||
Err(wl_clipboard_rs::paste::Error::MissingProtocol { .. } | wl_clipboard_rs::paste::Error::WaylandConnection(_))
|
||||
)
|
||||
}
|
||||
|
||||
/// arboard serves its own MIME types only; Word, which reads the MathML formats, isn't here.
|
||||
pub fn add_mathml(_mathml: &str) {}
|
||||
|
||||
pub fn rtf() -> Option<Vec<u8>> {
|
||||
if !wayland() {
|
||||
return None;
|
||||
}
|
||||
let offered = formats();
|
||||
let mime = ["text/rtf", "application/rtf", "text/richtext"].into_iter().find(|m| offered.iter().any(|o| o == m))?;
|
||||
read(mime, super::MAX_PASTE_BYTES as u64)
|
||||
}
|
||||
|
||||
pub fn encoded_picture() -> Option<Vec<u8>> {
|
||||
if !wayland() {
|
||||
return None;
|
||||
}
|
||||
let offered = formats();
|
||||
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
|
||||
read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod platform {
|
||||
use clipboard_win::{Clipboard, EnumFormats, raw};
|
||||
use wordcraft_ui_egui::paste_picture::{self, ClipboardPicture};
|
||||
|
||||
pub fn formats() -> Vec<String> {
|
||||
let Ok(_open) = Clipboard::new_attempts(10) else { return Vec::new() };
|
||||
// Standard formats are named too (CF_BITMAP, CF_UNICODETEXT…).
|
||||
EnumFormats::new().take(64).filter_map(raw::format_name_big).collect()
|
||||
}
|
||||
|
||||
pub fn rtf() -> Option<Vec<u8>> {
|
||||
let format = raw::register_format("Rich Text Format")?;
|
||||
let _open = Clipboard::new_attempts(10).ok()?;
|
||||
if raw::size(format.get()).is_none_or(|n| n.get() > super::MAX_PASTE_BYTES) {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
raw::get_vec(format.get(), &mut out).ok()?;
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The clipboard's picture, read without arboard: its "PNG" format as it is, else a DIB as
|
||||
/// pixels, each refused from its header when a paste can't take its size (#3623). None when
|
||||
/// it holds neither.
|
||||
pub fn picture() -> Option<ClipboardPicture> {
|
||||
use clipboard_win::formats::{CF_DIB, CF_DIBV5};
|
||||
let Ok(_open) = Clipboard::new_attempts(10) else {
|
||||
return Some(ClipboardPicture::Unreadable("the system clipboard couldn't be opened".into()));
|
||||
};
|
||||
if let Some(png) = raw::register_format("PNG").map(|f| f.get()).filter(|&f| raw::is_format_avail(f)) {
|
||||
return Some(read(png).map_or_else(ClipboardPicture::Unreadable, paste_picture::encoded_picture));
|
||||
}
|
||||
let dib = [CF_DIBV5, CF_DIB].into_iter().find(|&f| raw::is_format_avail(f))?;
|
||||
Some(read(dib).map_or_else(ClipboardPicture::Unreadable, paste_picture::dib_picture))
|
||||
}
|
||||
|
||||
/// A clipboard format's bytes, refused past the largest picture a paste takes.
|
||||
fn read(format: u32) -> Result<Vec<u8>, String> {
|
||||
match raw::size(format) {
|
||||
Some(n) if n.get() as u64 <= paste_picture::MAX_FILE_BYTES => {}
|
||||
Some(_) => return Err("the clipboard picture is larger than 200 MB".into()),
|
||||
None => return Err("the clipboard picture is empty".into()),
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
raw::get_vec(format, &mut out).map_err(|e| e.to_string())?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn can_set() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Add the MathML to what arboard just set, as "MathML" and "MathML Presentation": UTF-16LE
|
||||
/// text (with its terminating NUL, as Windows text formats have).
|
||||
pub fn add_mathml(mathml: &str) {
|
||||
let Ok(_open) = Clipboard::new_attempts(10) else {
|
||||
log::warn!("clipboard: couldn't open it to add MathML");
|
||||
return;
|
||||
};
|
||||
let bytes: Vec<u8> = mathml.encode_utf16().chain(std::iter::once(0)).flat_map(u16::to_le_bytes).collect();
|
||||
for name in super::MATHML_FORMATS {
|
||||
let Some(format) = raw::register_format(name) else { continue };
|
||||
if let Err(e) = raw::set_without_clear(format.get(), &bytes) {
|
||||
log::warn!("clipboard: {name}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The clipboard format names for MathML that Windows programs read (W3C MathML 3 §6.2.4; Office
|
||||
/// reads and writes both).
|
||||
#[cfg(target_os = "windows")]
|
||||
const MATHML_FORMATS: [&str; 2] = ["MathML", "MathML Presentation"];
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod platform {
|
||||
use objc2_app_kit::NSPasteboard;
|
||||
use objc2_foundation::NSString;
|
||||
|
||||
pub fn formats() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub fn rtf() -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn encoded_picture() -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn can_set() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Add the MathML to the pasteboard item arboard just wrote, as the MathML types (W3C MathML 3
|
||||
/// §6.2.4): presentation markup, and the generic type for readers that only know that one.
|
||||
pub fn add_mathml(mathml: &str) {
|
||||
let board = NSPasteboard::generalPasteboard();
|
||||
let text = NSString::from_str(mathml);
|
||||
for name in ["public.mathml.presentation", "public.mathml"] {
|
||||
if !board.setString_forType(&text, &NSString::from_str(name)) {
|
||||
log::warn!("clipboard: couldn't add {name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(
|
||||
target_os = "windows",
|
||||
target_os = "macos",
|
||||
all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten")))
|
||||
)))]
|
||||
mod platform {
|
||||
pub fn formats() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub fn rtf() -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn encoded_picture() -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn can_set() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn add_mathml(_mathml: &str) {}
|
||||
}
|
||||
@@ -484,16 +484,7 @@ mod tests {
|
||||
assert!(!dir.exists() || std::fs::read_dir(&dir).unwrap().next().is_none(), "no key file after a failed bind");
|
||||
drop(taken);
|
||||
// Bound: the key file holds the key that opens the port.
|
||||
let mut free_port = port.wrapping_add(1).max(1024);
|
||||
let mut started = None;
|
||||
for _ in 0..100 {
|
||||
if let Some(res) = start(free_port, egui::Context::default(), Some(&dir)) {
|
||||
started = Some((res, free_port));
|
||||
break;
|
||||
}
|
||||
free_port = free_port.wrapping_add(1).max(1024);
|
||||
}
|
||||
let ((rx, key_file), port) = started.unwrap();
|
||||
let (rx, key_file) = start(port, egui::Context::default(), Some(&dir)).unwrap();
|
||||
let key_file = key_file.unwrap();
|
||||
assert_eq!(key_file.path(), wordcraft_control_key::key_path(&dir, port));
|
||||
let key = std::fs::read_to_string(key_file.path()).unwrap();
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
//! File › Options › General › Make WordCraft the default for Word documents (#295).
|
||||
//!
|
||||
//! The installers register WordCraft for Word, RTF and ODT files without taking over the
|
||||
//! defaults (`packaging/windows/wordcraft.wxs`, the `.desktop` file's `MimeType=`, `Info.plist`).
|
||||
//! This is the button that makes it the default when the user asks:
|
||||
//! - **Windows** doesn't let apps set defaults: it opens Settings › Default apps on WordCraft's
|
||||
//! page (`ms-settings:defaultapps?registeredAppMachine=WordCraft`; Windows 10 ignores the query
|
||||
//! and shows the Default apps list), where the user picks WordCraft per type.
|
||||
//! - **Linux and BSD**: `xdg-mime default ai.storyteller.wordcraft.desktop <types>` for
|
||||
//! [`WORD_MIME_TYPES`], which writes the user's `mimeapps.list`. Only when the desktop entry is
|
||||
//! installed (deb, rpm, Flatpak; a bare AppImage has none), and not from inside the Flatpak
|
||||
//! sandbox, which can't change the host's defaults.
|
||||
//! - **macOS** has no API for it here: the Options page explains Finder's Get Info › Change All.
|
||||
//!
|
||||
//! [`WORD_MIME_TYPES`]: self::WORD_MIME_TYPES
|
||||
|
||||
use wordcraft_ui_egui::DefaultApp;
|
||||
|
||||
/// The `Services::make_default_app` hook.
|
||||
pub type Hook = Box<dyn Fn() -> Result<DefaultApp, String>>;
|
||||
|
||||
/// The hook for this platform; `None` where there is no button (macOS).
|
||||
pub fn hook() -> Option<Hook> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let hook: Option<Hook> = Some(Box::new(windows::open_default_apps));
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let hook: Option<Hook> = Some(Box::new(xdg::make_default));
|
||||
#[cfg(not(any(target_os = "windows", all(unix, not(target_os = "macos")))))]
|
||||
let hook: Option<Hook> = None;
|
||||
hook
|
||||
}
|
||||
|
||||
/// The desktop entry the Linux packages install, and the Flatpak's app id.
|
||||
#[cfg(any(all(unix, not(target_os = "macos")), test))]
|
||||
const DESKTOP_ID: &str = "ai.storyteller.wordcraft.desktop";
|
||||
|
||||
/// The types the button makes WordCraft the default for on Linux and BSD: Word documents and
|
||||
/// templates (also macro-enabled and legacy), RTF and ODT. The same set the Windows installer
|
||||
/// lists under WordCraft's Capabilities; Markdown is only offered under Open With, and plain
|
||||
/// text only on Windows and macOS (the `.desktop` file leaves it out, #4137).
|
||||
/// Every one is in the `.desktop` file's `MimeType=` (a test checks).
|
||||
#[cfg(any(all(unix, not(target_os = "macos")), test))]
|
||||
const WORD_MIME_TYPES: &[&str] = &[
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
|
||||
"application/vnd.ms-word.document.macroEnabled.12", // .docm
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.template", // .dotx
|
||||
"application/vnd.ms-word.template.macroEnabled.12", // .dotm
|
||||
"application/msword", // .doc
|
||||
"application/msword-template", // .dot
|
||||
"application/rtf", // .rtf
|
||||
"text/rtf", // .rtf (older name)
|
||||
"application/vnd.oasis.opendocument.text", // .odt
|
||||
];
|
||||
|
||||
/// The arguments for `xdg-mime` that make `desktop_id` the default for [`WORD_MIME_TYPES`]: one
|
||||
/// call sets them all.
|
||||
#[cfg(any(all(unix, not(target_os = "macos")), test))]
|
||||
fn xdg_mime_args(desktop_id: &str) -> Vec<String> {
|
||||
["default", desktop_id].into_iter().chain(WORD_MIME_TYPES.iter().copied()).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// The `applications` folders the desktop looks for entries in, from `XDG_DATA_HOME` (default
|
||||
/// `~/.local/share`) and `XDG_DATA_DIRS` (default `/usr/local/share:/usr/share`). Relative paths
|
||||
/// are ignored, as the XDG Base Directory spec says.
|
||||
#[cfg(all(unix, any(not(target_os = "macos"), test)))]
|
||||
fn application_dirs(data_home: Option<&str>, data_dirs: Option<&str>, home: Option<&str>) -> Vec<std::path::PathBuf> {
|
||||
use std::path::{Path, PathBuf};
|
||||
let absolute = |s: &&str| Path::new(s).is_absolute();
|
||||
let home_dir = data_home
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter(absolute)
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| home.filter(|s| !s.is_empty()).filter(absolute).map(|h| Path::new(h).join(".local/share")));
|
||||
let system = data_dirs.filter(|s| !s.is_empty()).unwrap_or("/usr/local/share:/usr/share");
|
||||
home_dir
|
||||
.into_iter()
|
||||
.chain(system.split(':').filter(|s| !s.is_empty()).filter(absolute).map(PathBuf::from))
|
||||
.map(|d| d.join("applications"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows {
|
||||
use wordcraft_ui_egui::DefaultApp;
|
||||
|
||||
/// Settings › Default apps, on the page of the app registered as `WordCraft` under
|
||||
/// `HKLM\Software\RegisteredApplications` (the MSI writes it).
|
||||
const SETTINGS_URI: &str = "ms-settings:defaultapps?registeredAppMachine=WordCraft";
|
||||
|
||||
pub(super) fn open_default_apps() -> Result<DefaultApp, String> {
|
||||
open::that_detached(SETTINGS_URI).map_err(|e| format!("couldn't open Settings: {e}"))?;
|
||||
Ok(DefaultApp::SettingsOpened)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
mod xdg {
|
||||
use std::process::Command;
|
||||
|
||||
use wordcraft_ui_egui::DefaultApp;
|
||||
|
||||
use super::{DESKTOP_ID, application_dirs, xdg_mime_args};
|
||||
|
||||
/// The longest error text from `xdg-mime` shown in the status bar.
|
||||
const MAX_ERROR: usize = 200;
|
||||
|
||||
pub(super) fn make_default() -> Result<DefaultApp, String> {
|
||||
if std::env::var_os("FLATPAK_ID").is_some() || std::path::Path::new("/.flatpak-info").exists() {
|
||||
return Err(
|
||||
"the Flatpak sandbox can't change default apps; choose WordCraft under Open With in your file manager or system settings".into()
|
||||
);
|
||||
}
|
||||
let var = |k: &str| std::env::var(k).ok();
|
||||
let dirs = application_dirs(var("XDG_DATA_HOME").as_deref(), var("XDG_DATA_DIRS").as_deref(), var("HOME").as_deref());
|
||||
if !dirs.iter().any(|d| d.join(DESKTOP_ID).is_file()) {
|
||||
return Err(format!("{DESKTOP_ID} isn't installed (an AppImage needs desktop integration first)"));
|
||||
}
|
||||
let out = Command::new("xdg-mime").args(xdg_mime_args(DESKTOP_ID)).output().map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => "xdg-mime isn't installed (it comes with xdg-utils)".to_string(),
|
||||
_ => format!("couldn't run xdg-mime: {e}"),
|
||||
})?;
|
||||
if out.status.success() {
|
||||
return Ok(DefaultApp::MadeDefault);
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
let first = stderr.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("");
|
||||
let message: String = first.chars().take(MAX_ERROR).collect();
|
||||
Err(if message.is_empty() { format!("xdg-mime failed ({})", out.status) } else { format!("xdg-mime failed: {message}") })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(unix)]
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn xdg_mime_sets_every_word_type_in_one_call() {
|
||||
let args = xdg_mime_args(DESKTOP_ID);
|
||||
assert_eq!(args.first().map(String::as_str), Some("default"));
|
||||
assert_eq!(args.get(1).map(String::as_str), Some("ai.storyteller.wordcraft.desktop"));
|
||||
assert_eq!(&args[2..], WORD_MIME_TYPES);
|
||||
for t in [
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/msword",
|
||||
"application/rtf",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
] {
|
||||
assert!(args.iter().any(|a| a == t), "{t}");
|
||||
}
|
||||
assert!(!args.iter().any(|a| a == "text/plain" || a == "text/markdown"), "plain text and Markdown are never claimed");
|
||||
}
|
||||
|
||||
/// Every type the button claims is one the desktop entry declares, or desktops ignore it.
|
||||
#[test]
|
||||
fn the_desktop_entry_declares_every_claimed_type() {
|
||||
let entry = include_str!("../../../packaging/linux/ai.storyteller.wordcraft.desktop");
|
||||
let declared: Vec<&str> =
|
||||
entry.lines().find_map(|l| l.strip_prefix("MimeType=")).map(|v| v.split(';').filter(|s| !s.is_empty()).collect()).unwrap_or_default();
|
||||
for t in WORD_MIME_TYPES {
|
||||
assert!(declared.contains(t), "{t} missing from MimeType=");
|
||||
}
|
||||
assert!(entry.lines().any(|l| l == "Exec=wordcraft %F"), "files are passed on the command line");
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
let p = |s: &str| PathBuf::from(s);
|
||||
assert_eq!(
|
||||
application_dirs(None, None, Some("/home/a")),
|
||||
[p("/home/a/.local/share/applications"), p("/usr/local/share/applications"), p("/usr/share/applications")]
|
||||
);
|
||||
assert_eq!(
|
||||
application_dirs(Some("/data"), Some("/opt/share::relative:/usr/share"), Some("/home/a")),
|
||||
[p("/data/applications"), p("/opt/share/applications"), p("/usr/share/applications")]
|
||||
);
|
||||
// Empty or relative values fall back to the defaults; no home at all is fine.
|
||||
assert_eq!(application_dirs(Some(""), Some(""), None), [p("/usr/local/share/applications"), p("/usr/share/applications")]);
|
||||
assert_eq!(application_dirs(Some("rel"), None, Some("also-rel")), [p("/usr/local/share/applications"), p("/usr/share/applications")]);
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,6 @@
|
||||
//! thread first checks that one of them is there and otherwise answers with an error, and the app
|
||||
//! shows its built-in file browser. `WORDCRAFT_FILE_DIALOG=builtin` always uses the built-in one.
|
||||
//!
|
||||
//! On Linux and the BSDs, rfd reports a portal that failed to show its dialog as a cancel (#2999).
|
||||
//! There a cancel quicker than anyone could dismiss a dialog is taken as that failure: the dialog
|
||||
//! is asked for once more without a parent window, and if that also comes back at once the
|
||||
//! built-in browser shows. Windows and macOS always show their dialog, so a quick cancel there is
|
||||
//! the person's and is answered as one.
|
||||
//!
|
||||
//! [`Services::file_dialog`]: wordcraft_ui_egui::Services::file_dialog
|
||||
|
||||
use std::future::Future;
|
||||
@@ -33,7 +27,7 @@ type Request = (FileDialogRequest, Sender<FileDialogAnswer>);
|
||||
pub type Hook = Box<dyn Fn(FileDialogRequest) -> Receiver<FileDialogAnswer>>;
|
||||
|
||||
/// A picker's answer, waited for off the UI thread.
|
||||
type Picking = Pin<Box<dyn Future<Output = Vec<rfd::FileHandle>> + Send>>;
|
||||
type Picking = Pin<Box<dyn Future<Output = Option<rfd::FileHandle>> + Send>>;
|
||||
|
||||
/// Shows the dialogs the app queued; lives in the eframe app, which has the window.
|
||||
pub struct Launcher {
|
||||
@@ -60,22 +54,20 @@ impl Launcher {
|
||||
pub fn show(&self, parent: &eframe::Frame) {
|
||||
while let Ok((req, answer)) = self.queue.try_recv() {
|
||||
// Built here, on the UI thread: macOS shows the panel as a sheet on the window now.
|
||||
let picking = picker(rfd::AsyncFileDialog::new().set_parent(parent), &req);
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let unparented = picker(rfd::AsyncFileDialog::new(), &req);
|
||||
let dialog = rfd::AsyncFileDialog::new().set_parent(parent);
|
||||
let picking: Picking = match req {
|
||||
FileDialogRequest::Open { purpose } => Box::pin(open_filters(dialog, &purpose).pick_file()),
|
||||
FileDialogRequest::Save { name } => {
|
||||
let exts = save_exts(&name);
|
||||
Box::pin(dialog.set_file_name(name).add_filter("Documents", &exts).save_file())
|
||||
}
|
||||
};
|
||||
let ctx = self.ctx.clone();
|
||||
let waiting = std::thread::Builder::new().name("file dialog".into()).spawn(move || {
|
||||
// The pickers only start when first polled: without a system dialog they never do.
|
||||
// The picker only starts when first polled: without a system dialog it never does.
|
||||
let picked = match no_system_dialog() {
|
||||
Some(reason) => Err(reason),
|
||||
None => {
|
||||
// Only the portal folds a failure into "cancelled" (#2999).
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let picked = pick(picking, unparented, TOO_QUICK);
|
||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||
let picked: Result<_, String> = Ok(pollster::block_on(picking));
|
||||
picked.map(|f| f.iter().map(|f| f.path().to_string_lossy().to_string()).collect())
|
||||
}
|
||||
None => Ok(pollster::block_on(picking).map(|f| f.path().to_string_lossy().to_string())),
|
||||
};
|
||||
let _ = answer.send(picked);
|
||||
ctx.request_repaint();
|
||||
@@ -88,57 +80,6 @@ impl Launcher {
|
||||
}
|
||||
}
|
||||
|
||||
/// The picker for `req` on `dialog`.
|
||||
fn picker(dialog: rfd::AsyncFileDialog, req: &FileDialogRequest) -> Picking {
|
||||
match req.clone() {
|
||||
FileDialogRequest::Open { purpose } => Box::pin(async move { open_filters(dialog, &purpose).pick_file().await.into_iter().collect() }),
|
||||
FileDialogRequest::OpenMany { purpose } => Box::pin(async move { open_filters(dialog, &purpose).pick_files().await.unwrap_or_default() }),
|
||||
FileDialogRequest::Save { name, dir } => {
|
||||
let mut dialog = dialog.set_file_name(name.clone());
|
||||
// Save as Word Template starts in the user templates folder (#2852).
|
||||
if let Some(dir) = dir.filter(|d| std::path::Path::new(d).is_dir()) {
|
||||
dialog = dialog.set_directory(dir);
|
||||
}
|
||||
for (label, exts) in save_filters(&name) {
|
||||
dialog = dialog.add_filter(label, &exts);
|
||||
}
|
||||
Box::pin(async move { dialog.save_file().await.into_iter().collect() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Quicker than anyone can see a dialog and dismiss it.
|
||||
// ponytail: a timing heuristic, as rfd folds the portal's failure code into "cancelled". A portal
|
||||
// failing slower than this still reads as a cancel; ask rfd for the response code if it matters.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
const TOO_QUICK: std::time::Duration = std::time::Duration::from_millis(300);
|
||||
|
||||
/// Wait for `picking`. Nothing picked within `too_quick` is a portal that failed to show its
|
||||
/// dialog (#2999), not a person cancelling: try `unparented`, as a portal may fail on the parent
|
||||
/// window; if that too comes back at once, an error, so the app shows its built-in browser.
|
||||
/// Linux and the BSDs only (built everywhere for its test).
|
||||
#[cfg(any(test, all(unix, not(target_os = "macos"))))]
|
||||
fn pick<T>(
|
||||
picking: impl Future<Output = Vec<T>>,
|
||||
unparented: impl Future<Output = Vec<T>>,
|
||||
too_quick: std::time::Duration,
|
||||
) -> Result<Vec<T>, String> {
|
||||
fn timed<T>(f: impl Future<Output = Vec<T>>, too_quick: std::time::Duration) -> (Vec<T>, bool) {
|
||||
let start = std::time::Instant::now();
|
||||
let picked = pollster::block_on(f);
|
||||
let quick = picked.is_empty() && start.elapsed() < too_quick;
|
||||
(picked, quick)
|
||||
}
|
||||
match timed(picking, too_quick) {
|
||||
(_, true) => log::warn!("the system file dialog closed at once; asking again without a parent window"),
|
||||
(picked, false) => return Ok(picked),
|
||||
}
|
||||
match timed(unparented, too_quick) {
|
||||
(_, true) => Err("the system file dialog closed at once".into()),
|
||||
(picked, false) => Ok(picked),
|
||||
}
|
||||
}
|
||||
|
||||
/// The file types Open offers (shared with the built-in browser).
|
||||
fn open_filters(mut d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog {
|
||||
for (name, exts) in wordcraft_ui_egui::open_filters(purpose) {
|
||||
@@ -207,8 +148,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());
|
||||
@@ -217,79 +158,9 @@ fn save_exts(name: &str) -> Vec<String> {
|
||||
exts
|
||||
}
|
||||
|
||||
/// The "Save as type" list (#1399): one named entry per format, the suggested one first (see
|
||||
/// [`save_exts`] for why the order matters).
|
||||
///
|
||||
/// rfd doesn't report which entry the user picked, so if they choose "PDF" but leave "name.docx"
|
||||
/// in the name box, the extension in the name wins, as the format is chosen by extension.
|
||||
fn save_filters(name: &str) -> Vec<(String, Vec<String>)> {
|
||||
save_exts(name).into_iter().map(|e| (format!("{} (*.{e})", format_name(&e)), vec![e])).collect()
|
||||
}
|
||||
|
||||
/// A readable name for a save format's extension.
|
||||
fn format_name(ext: &str) -> String {
|
||||
match ext {
|
||||
"docx" => "Word Document".into(),
|
||||
"docm" => "Word Macro-Enabled Document".into(),
|
||||
"dotx" => "Word Template".into(),
|
||||
"dotm" => "Word Macro-Enabled Template".into(),
|
||||
"pdf" => "PDF".into(),
|
||||
"txt" => "Plain Text".into(),
|
||||
"md" => "Markdown".into(),
|
||||
"html" => "Web Page".into(),
|
||||
"rtf" => "Rich Text Format".into(),
|
||||
"odt" => "OpenDocument Text".into(),
|
||||
"tex" => "LaTeX".into(),
|
||||
"png" => "PNG Image".into(),
|
||||
"json" => "JSON".into(),
|
||||
"xml" => "XML".into(),
|
||||
other => other.to_ascii_uppercase(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{pick, save_exts, save_filters};
|
||||
use std::time::Duration;
|
||||
|
||||
/// #2999: a dialog that "cancels" at once is a portal that failed: asked again without a
|
||||
/// parent, then the built-in browser. A pick, or a cancel after a while, is the answer.
|
||||
#[test]
|
||||
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
|
||||
Vec::<u8>::new()
|
||||
};
|
||||
assert_eq!(pick(async { vec![1u8] }, async { vec![2] }, quick), Ok(vec![1]));
|
||||
assert_eq!(pick(async { vec![] }, async { vec![2u8] }, quick), Ok(vec![2]), "asked again without a parent");
|
||||
assert!(pick(async { Vec::<u8>::new() }, async { vec![] }, quick).is_err(), "the built-in browser");
|
||||
assert_eq!(pick(slow_cancel(), async { vec![2u8] }, quick), Ok(vec![]), "a real cancel isn't asked again");
|
||||
assert_eq!(pick(async { vec![] }, slow_cancel(), quick), Ok(vec![]), "the second dialog cancelled for real");
|
||||
}
|
||||
|
||||
/// #1399: the Save dialog's type list has one named entry per format, the suggested one first,
|
||||
/// every savable extension exactly once.
|
||||
#[test]
|
||||
fn save_dialog_lists_one_named_type_per_format() {
|
||||
let filters = save_filters("report.odt");
|
||||
assert_eq!(filters.len(), wordcraft_engine::io::SAVE_EXTS.len());
|
||||
assert_eq!(filters[0].1, vec!["odt".to_string()]);
|
||||
assert!(filters[0].0.contains("OpenDocument"), "{}", filters[0].0);
|
||||
let all: Vec<&String> = filters.iter().flat_map(|(_, e)| e).collect();
|
||||
for e in wordcraft_engine::io::SAVE_EXTS {
|
||||
assert_eq!(all.iter().filter(|x| x.as_str() == *e).count(), 1, "{e}");
|
||||
}
|
||||
assert!(filters.iter().all(|(l, e)| !l.trim().is_empty() && e.len() == 1));
|
||||
let mut labels: Vec<&String> = filters.iter().map(|(l, _)| l).collect();
|
||||
labels.sort();
|
||||
labels.dedup();
|
||||
assert_eq!(labels.len(), filters.len(), "labels are distinct");
|
||||
// Unknown names fall back to Word's format first.
|
||||
assert_eq!(save_filters("no extension")[0].1, vec!["docx".to_string()]);
|
||||
}
|
||||
use super::save_exts;
|
||||
|
||||
/// #689: the Save dialog's default extension is the suggested name's own format, so a file
|
||||
/// name the user types without one is saved in the document's format instead of coming back
|
||||
|
||||
+22
-101
@@ -1,37 +1,12 @@
|
||||
//! Graphics instance configuration applied before wgpu can initialize any driver: Windows
|
||||
//! backends and the DirectX 12 shader compiler, and the Intel Mac indirect-validation skip.
|
||||
// On macOS only the indirect-validation items are used outside tests. Elsewhere it is the other
|
||||
// way round, so those two carry their own `allow`.
|
||||
#![cfg_attr(target_os = "macos", allow(dead_code))]
|
||||
//! Windows graphics instance configuration (backends and the DirectX 12 shader compiler), applied
|
||||
//! before wgpu can initialize any driver.
|
||||
|
||||
use eframe::{
|
||||
NativeOptions,
|
||||
egui_wgpu::WgpuSetup,
|
||||
wgpu::{Backends, DeviceType, Dx12Compiler, InstanceFlags},
|
||||
wgpu::{Backends, DeviceType, Dx12Compiler},
|
||||
};
|
||||
|
||||
/// wgpu compiles its indirect-call validation compute shaders inside `request_device`. On old
|
||||
/// Intel Macs (HD Graphics 5000 on Big Sur, #2652) Metal's Intel
|
||||
/// compiler aborts on them, the device is lost and eframe exits before the window opens. WordCraft
|
||||
/// issues no indirect draws or dispatches, so Intel Macs skip them; every other GPU keeps wgpu's
|
||||
/// default. Same fix as PhotoCraft's storytold/PhotoCraft#3171.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
pub fn skips_indirect_validation(macos: bool, intel: bool) -> bool {
|
||||
macos && intel
|
||||
}
|
||||
|
||||
/// Leaves wgpu's indirect-call validation out of the instance eframe creates when `skip`.
|
||||
/// `WGPU_VALIDATION_INDIRECT_CALL=1` still turns it back on.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
pub fn configure_indirect_validation(options: &mut NativeOptions, skip: bool) {
|
||||
if !skip {
|
||||
return;
|
||||
}
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
create.instance_descriptor.flags = create.instance_descriptor.flags.difference(InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
|
||||
}
|
||||
}
|
||||
|
||||
/// What the start-up probe learned about one DirectX 12 adapter.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Dx12Adapter {
|
||||
@@ -45,28 +20,18 @@ pub struct Dx12Adapter {
|
||||
/// The backends to initialize on Windows.
|
||||
///
|
||||
/// An explicit `backend_override` (normally `Backends::from_env()`, i.e. `WGPU_BACKEND`) always
|
||||
/// wins. Otherwise DirectX 12 only, as long as `dx12_graphics` is set and `dx12_adapters` (what a
|
||||
/// DirectX 12-only probe found) contains a usable hardware GPU. Without one, OpenGL is the way to
|
||||
/// the GPU: graphics chips whose driver has no DirectX 12 support (Intel 4th generation on Windows
|
||||
/// 11, #316) still have an OpenGL driver, and GL can't reach the AMD driver crash described in
|
||||
/// [`configure_backends`], because an AMD GPU with a driver shows up as a DirectX 12 adapter and
|
||||
/// keeps us on DirectX 12. When DirectX 12 offers only WARP, it stays enabled next to GL as a last
|
||||
/// resort for machines without an OpenGL driver either (virtual machines): wgpu prefers any GPU
|
||||
/// over a software adapter, so it's only picked when GL has nothing better.
|
||||
///
|
||||
/// `dx12_graphics` is false on 32-bit Windows. wgpu creates the graphics pipeline through
|
||||
/// `ID3D12Device2::CreatePipelineState`, and that stream is aligned to 8 bytes. The 32-bit runtime
|
||||
/// expects pointer alignment (4), so creation returns `E_INVALIDARG` ("The parameter is incorrect")
|
||||
/// and the process exits before the first frame. Compute pipelines use the older API and succeed,
|
||||
/// which is why startup gets as far as the egui pipeline and then quits. OpenGL does not use that
|
||||
/// stream.
|
||||
pub fn choose_backends(backend_override: Option<Backends>, dx12_adapters: &[Dx12Adapter], dx12_graphics: bool) -> Backends {
|
||||
/// wins. Otherwise DirectX 12 only, as long as `dx12_adapters` (what a DirectX 12-only probe found)
|
||||
/// contains a usable hardware GPU. Without one, OpenGL is the way to the GPU: graphics chips whose
|
||||
/// driver has no DirectX 12 support (Intel 4th generation on Windows 11, #316) still have an
|
||||
/// OpenGL driver, and GL can't reach the AMD driver crash described in [`configure_backends`],
|
||||
/// because an AMD GPU with a driver shows up as a DirectX 12 adapter and keeps us on DirectX 12.
|
||||
/// When DirectX 12 offers only WARP, it stays enabled next to GL as a last resort for machines
|
||||
/// without an OpenGL driver either (virtual machines): wgpu prefers any GPU over a software
|
||||
/// adapter, so it's only picked when GL has nothing better.
|
||||
pub fn choose_backends(backend_override: Option<Backends>, dx12_adapters: &[Dx12Adapter]) -> Backends {
|
||||
if let Some(backends) = backend_override {
|
||||
return backends;
|
||||
}
|
||||
if !dx12_graphics {
|
||||
return Backends::GL;
|
||||
}
|
||||
let hardware = |a: &&Dx12Adapter| a.device_type != DeviceType::Cpu;
|
||||
if dx12_adapters.iter().filter(hardware).any(|a| a.meets_limits) {
|
||||
Backends::DX12
|
||||
@@ -129,16 +94,9 @@ pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>
|
||||
backends
|
||||
}
|
||||
None => {
|
||||
// 32-bit: skip the DirectX 12 probe. A capable GPU would be selected, then
|
||||
// CreatePipelineState fails and the app exits with no window.
|
||||
let dx12_graphics = cfg!(not(target_arch = "x86"));
|
||||
let adapters = if dx12_graphics { probe_dx12(options) } else { Vec::new() };
|
||||
let backends = choose_backends(None, &adapters, dx12_graphics);
|
||||
if dx12_graphics {
|
||||
log::info!("graphics: {} DirectX 12 adapter(s) {adapters:?}, backends {backends:?}", adapters.len());
|
||||
} else {
|
||||
log::info!("graphics: 32-bit Windows uses OpenGL; DirectX 12 graphics pipeline creation fails");
|
||||
}
|
||||
let adapters = probe_dx12(options);
|
||||
let backends = choose_backends(None, &adapters);
|
||||
log::info!("graphics: {} DirectX 12 adapter(s) {adapters:?}, backends {backends:?}", adapters.len());
|
||||
backends
|
||||
}
|
||||
};
|
||||
@@ -178,33 +136,6 @@ fn probe_dx12(options: &NativeOptions) -> Vec<Dx12Adapter> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn intel_macs_skip_the_indirect_validation_shaders() {
|
||||
// #2652: Metal's Intel compiler aborted on them, losing the device before the window opened.
|
||||
assert!(skips_indirect_validation(true, true));
|
||||
// Apple silicon, Windows and Linux keep wgpu's validation.
|
||||
for (macos, intel) in [(true, false), (false, true), (false, false)] {
|
||||
assert!(!skips_indirect_validation(macos, intel), "macos={macos} intel={intel}");
|
||||
}
|
||||
let mut options = NativeOptions::default();
|
||||
let WgpuSetup::CreateNew(before) = &options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
let before = before.instance_descriptor.flags;
|
||||
configure_indirect_validation(&mut options, false);
|
||||
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert_eq!(create.instance_descriptor.flags, before, "flags untouched when not skipping");
|
||||
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
|
||||
configure_indirect_validation(&mut options, true);
|
||||
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert!(!create.instance_descriptor.flags.contains(InstanceFlags::VALIDATION_INDIRECT_CALL));
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_backends(backends: Backends) -> Backends {
|
||||
let mut options = NativeOptions::default();
|
||||
configure_backends(&mut options, backends);
|
||||
@@ -226,7 +157,7 @@ mod tests {
|
||||
};
|
||||
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
|
||||
let gpu = [adapter(DeviceType::IntegratedGpu, true)];
|
||||
assert_eq!(configured_backends(choose_backends(None, &gpu, true)), Backends::DX12);
|
||||
assert_eq!(configured_backends(choose_backends(None, &gpu)), Backends::DX12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -234,8 +165,8 @@ mod tests {
|
||||
let warp_only = [adapter(DeviceType::Cpu, true)];
|
||||
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
|
||||
// Whatever the probe would have found, the override wins (the probe doesn't run then).
|
||||
assert_eq!(choose_backends(Some(backends), &[], true), backends);
|
||||
assert_eq!(choose_backends(Some(backends), &warp_only, true), backends);
|
||||
assert_eq!(choose_backends(Some(backends), &[]), backends);
|
||||
assert_eq!(choose_backends(Some(backends), &warp_only), backends);
|
||||
assert_eq!(configured_backends(backends), backends);
|
||||
}
|
||||
}
|
||||
@@ -261,23 +192,13 @@ mod tests {
|
||||
fn gl_when_dx12_has_no_usable_gpu() {
|
||||
// #316: no DirectX 12 driver, so DirectX 12 offers only WARP (or nothing) and lags.
|
||||
let warp_only = [adapter(DeviceType::Cpu, true)];
|
||||
assert_eq!(choose_backends(None, &warp_only, true), Backends::GL | Backends::DX12);
|
||||
assert_eq!(choose_backends(None, &[], true), Backends::GL);
|
||||
assert_eq!(choose_backends(None, &warp_only), Backends::GL | Backends::DX12);
|
||||
assert_eq!(choose_backends(None, &[]), Backends::GL);
|
||||
// A GPU that can't create eframe's device: GL alone, so it isn't picked over the GL one.
|
||||
let weak_gpu = [adapter(DeviceType::IntegratedGpu, false), adapter(DeviceType::Cpu, true)];
|
||||
assert_eq!(choose_backends(None, &weak_gpu, true), Backends::GL);
|
||||
assert_eq!(choose_backends(None, &weak_gpu), Backends::GL);
|
||||
// Any usable GPU keeps DirectX 12 only, next to WARP or a weaker GPU.
|
||||
let mixed = [adapter(DeviceType::Cpu, true), adapter(DeviceType::IntegratedGpu, false), adapter(DeviceType::DiscreteGpu, true)];
|
||||
assert_eq!(choose_backends(None, &mixed, true), Backends::DX12);
|
||||
}
|
||||
|
||||
/// 32-bit Windows: a usable DirectX 12 GPU still starts on OpenGL. `WGPU_BACKEND` still wins.
|
||||
#[test]
|
||||
fn x86_uses_opengl_even_with_a_usable_gpu() {
|
||||
let gpu = [adapter(DeviceType::IntegratedGpu, true)];
|
||||
assert_eq!(choose_backends(None, &gpu, false), Backends::GL);
|
||||
assert_eq!(choose_backends(Some(Backends::DX12), &gpu, false), Backends::DX12);
|
||||
assert_eq!(choose_backends(Some(Backends::VULKAN), &[], false), Backends::VULKAN);
|
||||
assert_eq!(configured_backends(choose_backends(None, &gpu, false)), Backends::GL);
|
||||
assert_eq!(choose_backends(None, &mixed), Backends::DX12);
|
||||
}
|
||||
}
|
||||
|
||||
+338
-24
@@ -94,10 +94,7 @@ fn matches(name: &str, target: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the
|
||||
/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic
|
||||
/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the
|
||||
/// output never changes for a value the old formatter accepted and logging never panics.
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
@@ -106,25 +103,20 @@ pub fn timestamp(t: SystemTime) -> String {
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
|
||||
///
|
||||
/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
|
||||
/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
|
||||
/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
|
||||
/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
|
||||
/// reproducing the old `civil_from_days` result exactly without hand-rolled day math. 1970-01-01
|
||||
/// is `num_days_from_ce` 719_163.
|
||||
fn civil_from_days(days: u64) -> (i64, u32, u32) {
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (days / 146_097, days % 146_097);
|
||||
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
|
||||
}
|
||||
None => (1970, 1, 1), // unreachable
|
||||
}
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
|
||||
/// value can overflow it.
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
@@ -346,4 +338,326 @@ 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 instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-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");
|
||||
}
|
||||
+47
-396
@@ -1,6 +1,6 @@
|
||||
//! WordCraft desktop app.
|
||||
//!
|
||||
//! Usage: `wordcraft [--control <port>] [--sample] [files…]`; `--help` prints `USAGE`.
|
||||
//! Usage: `wordcraft [--control <port>] [--sample] [files…]`
|
||||
//!
|
||||
//! `--control <port>` (or `WORDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"key":"…","method":"engine.execute","params":{"command":"text.insert","params":{"text":"Hi"}}}`.
|
||||
@@ -12,83 +12,47 @@
|
||||
mod animations;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod clipboard;
|
||||
mod control_server;
|
||||
mod default_apps;
|
||||
mod file_dialogs;
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", test))]
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod graphics;
|
||||
mod logging;
|
||||
mod print;
|
||||
mod region;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod traffic_lights;
|
||||
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::{
|
||||
Services, UiState, WordApp,
|
||||
window_geometry::{RawWindowState, WindowGeometry, take_rescue},
|
||||
paste_picture::ClipboardPicture,
|
||||
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.
|
||||
/// The app, the restored window geometry until the first frame has checked it, the control
|
||||
/// server's key file (removed on exit), the file dialogs the app asked for and, on macOS, the
|
||||
/// documents opened from Finder.
|
||||
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)) {
|
||||
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);
|
||||
// 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) {
|
||||
self.0.ui.window = ctx.input(|i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size()));
|
||||
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() {
|
||||
} else if closing || self.0.quit_requested {
|
||||
// On macOS, letting eframe drop the window inside `-[NSApplication run]` aborts in
|
||||
// `-[_NSTouchBarFinderObservation invalidate]`. Command-Q calls `terminate:`. Once
|
||||
// the close is allowed, take that path instead of dropping the window on this turn.
|
||||
@@ -96,7 +60,7 @@ impl eframe::App for App {
|
||||
{
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
self.0.quit_requested = false;
|
||||
schedule_macos_terminate(&mut self.0, self.2.as_ref());
|
||||
schedule_macos_terminate(&self.0, self.2.as_ref());
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
if self.0.quit_requested {
|
||||
@@ -104,21 +68,14 @@ 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);
|
||||
self.3.show(frame);
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
print::finish_pending_jobs();
|
||||
self.0.session.finish_recovery();
|
||||
save_prefs(&self.0);
|
||||
if let Some(key_file) = &self.2 {
|
||||
key_file.remove();
|
||||
@@ -134,19 +91,13 @@ impl eframe::App for App {
|
||||
/// removed first, as `on_exit` does, because a fallback [`std::process::exit`]
|
||||
/// skips `on_exit`. A second close while a quit is already scheduled is ignored.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn schedule_macos_terminate(app: &mut WordApp, key_file: Option<&control_server::KeyFile>) {
|
||||
fn schedule_macos_terminate(app: &WordApp, key_file: Option<&control_server::KeyFile>) {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
if app.printing.busy() {
|
||||
return;
|
||||
}
|
||||
static QUIT: AtomicBool = AtomicBool::new(false);
|
||||
if QUIT.swap(true, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
log::info!("window close: quitting");
|
||||
// This path can bypass on_exit. Join the owned workers before termination and cleanup.
|
||||
print::finish_pending_jobs();
|
||||
app.session.finish_recovery();
|
||||
save_prefs(app);
|
||||
if let Some(key_file) = key_file {
|
||||
key_file.remove();
|
||||
@@ -171,21 +122,6 @@ fn log_dir() -> Option<std::path::PathBuf> {
|
||||
Some(prefs_path()?.parent()?.join("logs"))
|
||||
}
|
||||
|
||||
/// `custom-dictionary.txt` in the settings folder: the words Add to Dictionary kept (#40).
|
||||
fn user_dict_path() -> Option<std::path::PathBuf> {
|
||||
wordcraft_control_key::settings_dir().map(|b| b.join(wordcraft_engine::Session::USER_DICT_FILE))
|
||||
}
|
||||
|
||||
fn load_user_dictionary(app: &mut WordApp) {
|
||||
if !prefs_enabled() {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = user_dict_path() {
|
||||
wordcraft_proof::load_user_dictionary(&p);
|
||||
app.session.user_dict_path = Some(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs without preferences (`WORDCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
|
||||
fn prefs_enabled() -> bool {
|
||||
std::env::var_os("WORDCRAFT_NO_PREFS").is_none()
|
||||
@@ -230,58 +166,42 @@ fn save_prefs(app: &WordApp) {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = prefs_path() {
|
||||
if let Some(dir) = p.parent()
|
||||
&& let Err(e) = std::fs::create_dir_all(dir)
|
||||
{
|
||||
log::warn!("preferences: cannot create {}: {e}", dir.display());
|
||||
return;
|
||||
if let Some(dir) = p.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
match serde_json::to_vec_pretty(&app.prefs()) {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = write_prefs(&p, &bytes) {
|
||||
log::warn!("preferences: cannot save {}: {e}", p.display());
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("preferences: cannot serialize: {e}"),
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&app.prefs()) {
|
||||
let _ = std::fs::write(&p, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or
|
||||
/// interrupted write leaves the previous preferences in place.
|
||||
fn write_prefs(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "ui.json".into());
|
||||
let tmp = path.with_file_name(format!(".{name}.{}.tmp", std::process::id()));
|
||||
let written = std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path));
|
||||
if written.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), File › Print
|
||||
/// through the system spooler (`print`), the system clipboard's text, formatted copies,
|
||||
/// pictures and format names (`clipboard`, #45, #584, #687, #833), the system's animation setting
|
||||
/// (`animations`), and making WordCraft the default app for Word documents (`default_apps`, #295).
|
||||
/// through the system's PDF viewer (`print`, #15), pictures on the system clipboard (#45), and
|
||||
/// the system's animation setting (`animations`).
|
||||
fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
|
||||
Services {
|
||||
file_dialog: Some(file_dialog),
|
||||
printers: Some(print::discover()),
|
||||
print_native: Some(print::submit()),
|
||||
clipboard_picture: Some(Box::new(clipboard::picture)),
|
||||
make_default_app: default_apps::hook(),
|
||||
// Personal templates live in `Templates` in the settings folder (#2852), made on first save.
|
||||
templates_dir: wordcraft_control_key::settings_dir().map(|d| d.join("Templates")),
|
||||
print: Some(print::hook()),
|
||||
clipboard_picture: Some(Box::new(clipboard_picture)),
|
||||
animations_enabled: Some(animations::hook(ctx)),
|
||||
clipboard_text: Some(Box::new(clipboard::text)),
|
||||
clipboard_html: Some(Box::new(clipboard::html)),
|
||||
set_clipboard_html: Some(Box::new(clipboard::set_html)),
|
||||
clipboard_rtf: Some(Box::new(clipboard::rtf)),
|
||||
clipboard_formats: Some(Box::new(clipboard::formats)),
|
||||
clipboard_text: Some(Box::new(|| arboard::Clipboard::new().ok()?.get_text().ok())),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The picture on the system clipboard (#45): copied files, else image pixels. The UI checks the
|
||||
/// sizes before inserting anything.
|
||||
fn clipboard_picture() -> Option<ClipboardPicture> {
|
||||
let mut clipboard = arboard::Clipboard::new().map_err(|e| log::warn!("clipboard: {e}")).ok()?;
|
||||
if let Ok(files) = clipboard.get().file_list()
|
||||
&& !files.is_empty()
|
||||
{
|
||||
return Some(ClipboardPicture::Files(files));
|
||||
}
|
||||
let image = clipboard.get_image().ok()?;
|
||||
Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() })
|
||||
}
|
||||
|
||||
/// Window, Dock and taskbar icon.
|
||||
fn app_icon() -> Option<egui::IconData> {
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -291,130 +211,30 @@ fn app_icon() -> Option<egui::IconData> {
|
||||
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
|
||||
}
|
||||
|
||||
const USAGE: &str = "\
|
||||
wordcraft — the WordCraft desktop app
|
||||
|
||||
USAGE:
|
||||
wordcraft [--sample] [--control PORT] [FILE…]
|
||||
|
||||
OPTIONS:
|
||||
--sample open the sample document
|
||||
--control PORT start the localhost control server on PORT, for agents and scripts
|
||||
(or set WORDCRAFT_CONTROL_PORT); see docs/control-protocol.md
|
||||
--version print the version
|
||||
-h, --help print this help
|
||||
";
|
||||
|
||||
/// The commit the build came from (short), or `dev`.
|
||||
fn build_sha() -> &'static str {
|
||||
option_env!("WORDCRAFT_BUILD_SHA").map(|s| s.get(..8).unwrap_or(s)).unwrap_or("dev")
|
||||
}
|
||||
|
||||
/// The arguments as text, and apart the files whose names aren't valid Unicode, such as a Latin-1
|
||||
/// one (#1440). A `--control` port that isn't is an error.
|
||||
fn text_args() -> (Vec<String>, Vec<std::path::PathBuf>) {
|
||||
let (mut args, mut files) = (Vec::new(), Vec::new());
|
||||
for (i, a) in std::env::args_os().skip(1).enumerate() {
|
||||
match a.into_string() {
|
||||
Ok(a) => args.push(a),
|
||||
Err(a) if args.last().is_some_and(|p| p == "--control") => {
|
||||
// Unlike `eprintln!`, never panics.
|
||||
let (n, lossy) = (i + 1, a.to_string_lossy());
|
||||
let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("wordcraft: argument {n} is not valid Unicode: {lossy}\n"));
|
||||
std::process::exit(2);
|
||||
}
|
||||
Err(a) => files.push(a.into()),
|
||||
}
|
||||
}
|
||||
(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.
|
||||
fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
|
||||
let name = path.to_string_lossy();
|
||||
let opened = wordcraft_engine::io::read_document(path)
|
||||
.and_then(|bytes| app.run("file.open", serde_json::json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})));
|
||||
match opened {
|
||||
// Not when it waits for a password: that open names the document itself.
|
||||
Ok(r) if r.get("pending").is_none() => app.session.path = Some(path.into()),
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
app.report_open_failure(&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();
|
||||
let mut control_port: Option<u16> = std::env::var("WORDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
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();
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
// A flag after `--control` stays a flag; a bad or missing port is an error (#1426).
|
||||
"--control" => {
|
||||
let port = args.next_if(|p| p.strip_prefix('-').is_none_or(|n| n.starts_with(|c: char| c.is_ascii_digit())));
|
||||
match port.as_deref().and_then(|p| p.parse::<std::num::NonZeroU16>().ok()) {
|
||||
Some(p) => control_port = Some(p.get()),
|
||||
None => bad_control = Some(port),
|
||||
}
|
||||
}
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--sample" => sample = true,
|
||||
"--version" => {
|
||||
println!("wordcraft {} ({})", env!("CARGO_PKG_VERSION"), build_sha());
|
||||
return Ok(());
|
||||
}
|
||||
// Before the window opens (#968). Unlike `print!`, a closed stdout can't panic.
|
||||
"--help" | "-h" => {
|
||||
let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes());
|
||||
return Ok(());
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
// After the loop, so `--version` later on the line still wins. Unlike `eprintln!`, never panics.
|
||||
if let Some(port) = bad_control {
|
||||
let got = port.map(|p| format!(", got {p:?}")).unwrap_or_default();
|
||||
let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("wordcraft: --control needs a port number from 1 to 65535{got}\n"));
|
||||
std::process::exit(2);
|
||||
}
|
||||
// The log file lives in the settings directory, next to the preferences; opened after the
|
||||
// arguments, so `--version` leaves no file behind. Records logged until now are written to it
|
||||
// first. Runs without preferences (agents' test runs) log to standard error only, so they
|
||||
@@ -445,30 +265,15 @@ fn main() -> eframe::Result {
|
||||
options.viewport = options.viewport.with_icon(icon);
|
||||
}
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only, or OpenGL when
|
||||
// DirectX 12 has no GPU driver or this is a 32-bit build, and compile DirectX 12 shaders with
|
||||
// FXC, which needs no DLL of ours (#603; see graphics.rs).
|
||||
// DirectX 12 has no GPU driver, and compile DirectX 12 shaders with FXC, which needs no
|
||||
// DLL of ours (#603; see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652).
|
||||
#[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);
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; WORDCRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("WORDCRAFT_WAYLAND").is_none() {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
// Registered before the event loop starts, so it catches the Finder event that launched us as
|
||||
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -479,42 +284,12 @@ fn main() -> eframe::Result {
|
||||
"WordCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
// The system region picks the new document's paper, the units and the decimal
|
||||
// separator (#838).
|
||||
let region = region::system();
|
||||
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(®ion) };
|
||||
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_doc::Document::new() };
|
||||
let (file_dialog, dialogs) = file_dialogs::hook(&cc.egui_ctx);
|
||||
let mut session = Session::new(doc);
|
||||
session.system_region = region;
|
||||
let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx));
|
||||
app.set_startup_update_check(true);
|
||||
let mut app = WordApp::new(Session::new(doc), services(file_dialog, &cc.egui_ctx));
|
||||
load_prefs(&mut app);
|
||||
load_user_dictionary(&mut app);
|
||||
// Language packs from the suite's font store; an Install click is the person's own.
|
||||
wordcraft_engine::packs::use_font_store(true);
|
||||
// A region chosen in File › Options replaces the system's for the first blank
|
||||
// document too (#838).
|
||||
if !sample && app.session.prefs.region.is_some() {
|
||||
let doc = app.session.blank_document();
|
||||
app.replace_startup_document(doc);
|
||||
}
|
||||
app.watch_desktop_theme(&cc.egui_ctx);
|
||||
if prefs_enabled()
|
||||
&& let Some(dir) = wordcraft_control_key::settings_dir()
|
||||
{
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
if let Err(e) = app.session.enable_recovery(dir.join("recovery"), std::sync::Arc::new(move || ctx.request_repaint())) {
|
||||
log::warn!("local recovery unavailable: {e}");
|
||||
}
|
||||
}
|
||||
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() {
|
||||
log::info!("title bar: {bar:?}");
|
||||
app.mac_titlebar = bar;
|
||||
}
|
||||
let mut key_file = None;
|
||||
if let Some(port) = control_port
|
||||
&& let Some((rx, file)) = control_server::start(port, cc.egui_ctx.clone(), wordcraft_control_key::settings_dir().as_deref())
|
||||
@@ -522,143 +297,19 @@ fn main() -> eframe::Result {
|
||||
app = app.with_control(rx);
|
||||
key_file = file;
|
||||
}
|
||||
// Nothing to open (no files, no sample, no control channel driving the editor): start
|
||||
// on File › Home, as Word does (#766), unless the Start screen option is off. Files
|
||||
// macOS hands over later open on top of it and close it (`apple_events`).
|
||||
if files.is_empty() && other_files.is_empty() && !sample && control_port.is_none() {
|
||||
app.start_screen();
|
||||
}
|
||||
for f in files {
|
||||
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
|
||||
app.report_open_failure(&f, &e);
|
||||
log::warn!("{f}: {e}");
|
||||
}
|
||||
}
|
||||
for f in other_files {
|
||||
open_non_unicode(&mut app, &f);
|
||||
}
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
// With nothing restored, the window the system placed is checked all the same.
|
||||
Some(restored.unwrap_or_default()),
|
||||
restored,
|
||||
key_file,
|
||||
dialogs,
|
||||
RawWindowState::default(),
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events.connect(&cc.egui_ctx),
|
||||
)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Not on macOS: APFS refuses file names that aren't valid UTF-8, so no such file can exist there.
|
||||
#[cfg(all(test, unix, not(target_os = "macos")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
#[test]
|
||||
fn a_file_whose_name_isnt_unicode_opens_and_saves_back_to_itself() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-app-non-utf8-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join(std::ffi::OsString::from_vec(b"caf\xe9.txt".to_vec()));
|
||||
std::fs::write(&path, "Bonjour").unwrap();
|
||||
|
||||
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
|
||||
open_non_unicode(&mut app, &path);
|
||||
assert_eq!(app.session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "Bonjour");
|
||||
assert_eq!(app.session.path.as_deref(), Some(path.as_path()));
|
||||
|
||||
app.run("caret.docEnd", serde_json::json!({})).unwrap();
|
||||
app.run("text.insert", serde_json::json!({"text": " encore"})).unwrap();
|
||||
app.run("file.save", serde_json::json!({})).unwrap();
|
||||
assert!(std::fs::read_to_string(&path).unwrap().contains("Bonjour encore"));
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "a second file was written");
|
||||
|
||||
// A file that can't be read is a warning, as one that fails to open by name is.
|
||||
open_non_unicode(&mut app, &dir.join(std::ffi::OsString::from_vec(b"gon\xe9.txt".to_vec())));
|
||||
assert_eq!(app.session.path.as_deref(), Some(path.as_path()));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A named pipe or a device under such a name is a warning, not a read: the pipe blocked
|
||||
/// start-up until something wrote to it, and `/dev/zero` read until memory ran out.
|
||||
#[test]
|
||||
fn a_pipe_or_device_whose_name_isnt_unicode_is_not_read() {
|
||||
let dir = std::env::temp_dir().join(format!("wc-app-non-utf8-dev-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let fifo = dir.join(std::ffi::OsString::from_vec(b"fif\xe9.txt".to_vec()));
|
||||
if !std::process::Command::new("mkfifo").arg(&fifo).status().is_ok_and(|s| s.success()) {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
eprintln!("mkfifo unavailable; skipping");
|
||||
return;
|
||||
}
|
||||
let zero = dir.join(std::ffi::OsString::from_vec(b"z\xe9ro.txt".to_vec()));
|
||||
std::os::unix::fs::symlink("/dev/zero", &zero).unwrap();
|
||||
// If the open blocks on the pipe, a writer comes after 10 s, so the test fails rather than hangs.
|
||||
let unblock = fifo.clone();
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_secs(10));
|
||||
let _ = std::fs::OpenOptions::new().write(true).open(unblock);
|
||||
});
|
||||
|
||||
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
|
||||
app.run("text.insert", serde_json::json!({"text": "kept"})).unwrap();
|
||||
for p in [&fifo, &zero] {
|
||||
let started = std::time::Instant::now();
|
||||
open_non_unicode(&mut app, p);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(5), "{p:?} took {:?}", started.elapsed());
|
||||
assert_eq!(app.session.path, None, "{p:?}");
|
||||
assert!(app.session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("kept"), "{p:?}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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;
|
||||
|
||||
#[test]
|
||||
fn failed_preferences_write_keeps_the_old_file_and_reports_the_error() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-prefs-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("ui.json");
|
||||
std::fs::write(&path, b"old").unwrap();
|
||||
write_prefs(&path, b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
// A directory squatting on the temporary name makes the preparation step fail.
|
||||
std::fs::create_dir(dir.join(format!(".ui.json.{}.tmp", std::process::id()))).unwrap();
|
||||
assert!(write_prefs(&path, b"newer").is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"new");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
+211
-63
@@ -1,81 +1,229 @@
|
||||
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
|
||||
use wordcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
|
||||
//! Desktop printing (#15): File › Print hands the document to the system's print flow.
|
||||
//!
|
||||
//! The app renders the document to PDF (`ui.print`, the Print button on File › Print) and calls
|
||||
//! [`Services::print`] with the bytes. Here they go to a fresh temporary file
|
||||
//! (`wordcraft-print-….pdf` in the OS temp folder), which is then opened without waiting for it:
|
||||
//! in Preview on macOS (`open -a Preview`), and in the default PDF app elsewhere (`xdg-open` and
|
||||
//! friends on Linux and BSD, the file association on Windows, via the `open` crate). The user
|
||||
//! prints from there through the system print dialog.
|
||||
//!
|
||||
//! The viewer reads the file after we return, so it can't be removed right away: files older than
|
||||
//! [`KEEP`] are cleaned up off the UI thread when the app starts and after each print.
|
||||
//!
|
||||
//! [`Services::print`]: wordcraft_ui_egui::Services::print
|
||||
|
||||
/// 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 _ = sender.send(printers);
|
||||
});
|
||||
receiver
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Temporary print files start with this and end with `.pdf`.
|
||||
const PREFIX: &str = "wordcraft-print-";
|
||||
/// How long a print file is kept for its viewer before the clean-up removes it.
|
||||
const KEEP: Duration = Duration::from_secs(60 * 60);
|
||||
|
||||
/// The `Services::print` hook.
|
||||
pub type Hook = Box<dyn Fn(&[u8]) -> Result<(), String>>;
|
||||
|
||||
/// The desktop print hook; also clears print files left over from earlier runs.
|
||||
pub fn hook() -> Hook {
|
||||
let dir = std::env::temp_dir();
|
||||
spawn_cleanup(dir.clone());
|
||||
Box::new(move |bytes| {
|
||||
let r = print_with(&dir, bytes, open_viewer);
|
||||
spawn_cleanup(dir.clone());
|
||||
r
|
||||
})
|
||||
}
|
||||
|
||||
// Keep submission workers reachable until normal app shutdown; dropping a JoinHandle would
|
||||
// let process exit end an in-process Windows job or the CUPS hand-off before the spooler has it.
|
||||
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
|
||||
/// Writes `bytes` to a new print file in `dir` and has `open` show it. When `open` fails the file
|
||||
/// is removed again.
|
||||
fn print_with(dir: &Path, bytes: &[u8], open: impl Fn(&Path) -> Result<(), String>) -> Result<(), String> {
|
||||
let path = write_temp(dir, bytes).map_err(|e| format!("couldn't write the PDF to print: {e}"))?;
|
||||
open(&path).inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
})
|
||||
}
|
||||
|
||||
pub fn finish_pending_jobs() {
|
||||
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
|
||||
for worker in workers {
|
||||
if worker.join().is_err() {
|
||||
log::error!("the print worker stopped unexpectedly during shutdown");
|
||||
/// A new file in `dir` with a unique print-file name, holding `bytes`. Never overwrites (or
|
||||
/// follows a link at) an existing path; readable by the user only where the OS supports it.
|
||||
fn write_temp(dir: &Path, bytes: &[u8]) -> io::Result<PathBuf> {
|
||||
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
|
||||
for _ in 0..16 {
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let path = dir.join(format!("{PREFIX}{}-{stamp}-{n}.pdf", std::process::id()));
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
match options.open(&path) {
|
||||
Ok(mut file) => {
|
||||
return match file.write_all(bytes) {
|
||||
Ok(()) => Ok(path),
|
||||
Err(e) => {
|
||||
drop(file);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
Err(e)
|
||||
}
|
||||
};
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(io::ErrorKind::AlreadyExists, "no free temporary file name"))
|
||||
}
|
||||
|
||||
/// Removes print files in `dir` last written `keep` or longer before `now`; returns how many.
|
||||
/// Other files, links and files it may not remove are left alone.
|
||||
fn remove_stale(dir: &Path, now: SystemTime, keep: Duration) -> usize {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return 0;
|
||||
};
|
||||
let mut removed = 0;
|
||||
for entry in entries.flatten() {
|
||||
if !is_print_file(&entry.file_name().to_string_lossy()) {
|
||||
continue;
|
||||
}
|
||||
// `DirEntry::metadata` doesn't follow links.
|
||||
let Ok(meta) = entry.metadata() else { continue };
|
||||
let old = meta.modified().ok().and_then(|m| now.duration_since(m).ok()).is_some_and(|age| age >= keep);
|
||||
if meta.is_file() && old && std::fs::remove_file(entry.path()).is_ok() {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
fn is_print_file(name: &str) -> bool {
|
||||
name.starts_with(PREFIX) && name.ends_with(".pdf")
|
||||
}
|
||||
|
||||
/// Runs [`remove_stale`] on a background thread (the temp folder can be large).
|
||||
fn spawn_cleanup(dir: PathBuf) {
|
||||
let spawned = std::thread::Builder::new().name("print-cleanup".into()).spawn(move || {
|
||||
let n = remove_stale(&dir, SystemTime::now(), KEEP);
|
||||
if n > 0 {
|
||||
log::info!("removed {n} old print file(s)");
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("print clean-up: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submit() -> Submit {
|
||||
Box::new(|bytes, settings: Settings, collate, options| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let worker = std::thread::Builder::new().name("print-submit".into()).spawn(move || {
|
||||
let job = craft_print::Job {
|
||||
printer: Some(settings.printer),
|
||||
copies: settings.copies.clamp(1, 999),
|
||||
collate,
|
||||
duplex: settings.duplex.map(|value| match value {
|
||||
Duplex::LongEdge => craft_print::Duplex::LongEdge,
|
||||
Duplex::ShortEdge => craft_print::Duplex::ShortEdge,
|
||||
Duplex::Off => craft_print::Duplex::Off,
|
||||
}),
|
||||
grayscale: settings.grayscale,
|
||||
scaling: craft_print::Scaling::ShrinkToFit,
|
||||
title: "WordCraft document".into(),
|
||||
options,
|
||||
};
|
||||
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);
|
||||
jobs.retain(|job| !job.is_finished());
|
||||
jobs.push(worker);
|
||||
/// Opens the PDF in Preview without waiting for it; if Preview can't open it, the default app.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn open_viewer(path: &Path) -> Result<(), String> {
|
||||
let mut child =
|
||||
std::process::Command::new("open").arg("-a").arg("Preview").arg(path).spawn().map_err(|e| format!("couldn't open Preview: {e}"))?;
|
||||
let path = path.to_path_buf();
|
||||
let waiter = std::thread::Builder::new().name("print-open".into()).spawn(move || {
|
||||
if !child.wait().is_ok_and(|s| s.success())
|
||||
&& let Err(e) = open::that(&path)
|
||||
{
|
||||
log::error!("print: couldn't open {}: {e}", path.display());
|
||||
}
|
||||
receiver
|
||||
})
|
||||
});
|
||||
if let Err(e) = waiter {
|
||||
log::warn!("print: {e}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens the PDF in the default app without waiting for it (`xdg-open`, `gio`… on Linux and BSD;
|
||||
/// the file association on Windows).
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn open_viewer(path: &Path) -> Result<(), String> {
|
||||
open::that_detached(path).map_err(|e| format!("couldn't open a PDF viewer: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::{Services, WordApp};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-print-test-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// File › Print's Print button (`ui.print`) writes the document's PDF to a print file and
|
||||
/// hands that file to the viewer (faked here: no process is started); a viewer that can't
|
||||
/// start is reported in the status bar and leaves no file behind.
|
||||
#[test]
|
||||
fn shutdown_waits_for_pending_submission_workers() {
|
||||
let (release, wait) = std::sync::mpsc::channel();
|
||||
let (completed, completion) = std::sync::mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
wait.recv().unwrap();
|
||||
completed.send(()).unwrap();
|
||||
});
|
||||
JOBS.lock().unwrap().push(worker);
|
||||
let shutdown = std::thread::spawn(finish_pending_jobs);
|
||||
assert!(completion.try_recv().is_err(), "the print worker is still pending");
|
||||
release.send(()).unwrap();
|
||||
shutdown.join().unwrap();
|
||||
completion.recv().unwrap();
|
||||
assert!(JOBS.lock().unwrap().is_empty(), "workers were joined before shutdown returned");
|
||||
fn file_print_opens_a_pdf_file() {
|
||||
let dir = temp_dir("open");
|
||||
let opened: Rc<RefCell<Vec<(PathBuf, Vec<u8>)>>> = Rc::default();
|
||||
let seen = opened.clone();
|
||||
let hook_dir = dir.clone();
|
||||
let services = Services {
|
||||
print: Some(Box::new(move |bytes| {
|
||||
print_with(&hook_dir, bytes, |path| {
|
||||
seen.borrow_mut().push((path.to_path_buf(), std::fs::read(path).map_err(|e| e.to_string())?));
|
||||
Ok(())
|
||||
})
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), services);
|
||||
app.run("text.insert", json!({"text": "Print me"})).unwrap();
|
||||
app.run("file.print", json!({})).unwrap();
|
||||
assert!(app.ui.backstage && app.ui.backstage_page == "print", "File › Print opens the Print page");
|
||||
app.run("ui.print", json!({})).unwrap();
|
||||
app.run("ui.print", json!({})).unwrap();
|
||||
|
||||
let opened = opened.borrow();
|
||||
assert_eq!(opened.len(), 2, "each print opens the viewer once");
|
||||
for (path, bytes) in opened.iter() {
|
||||
assert!(bytes.starts_with(b"%PDF"), "the viewer gets a PDF");
|
||||
assert_eq!(path.parent(), Some(dir.as_path()));
|
||||
assert!(is_print_file(&path.file_name().unwrap().to_string_lossy()), "{}", path.display());
|
||||
}
|
||||
assert_ne!(opened[0].0, opened[1].0, "every print gets its own file");
|
||||
|
||||
let mut app = WordApp::new(
|
||||
Session::new(wordcraft_doc::Document::new()),
|
||||
Services {
|
||||
print: Some(Box::new({
|
||||
let dir = dir.clone();
|
||||
move |bytes| print_with(&dir, bytes, |_| Err("no PDF viewer".into()))
|
||||
})),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let e = app.run("ui.print", json!({})).unwrap_err();
|
||||
assert!(e.contains("no PDF viewer"), "{e}");
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "the failed print's file is removed");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The clean-up removes old print files only.
|
||||
#[test]
|
||||
fn stale_print_files_are_removed() {
|
||||
let dir = temp_dir("stale");
|
||||
let fresh = write_temp(&dir, b"%PDF-1.7").unwrap();
|
||||
let old = write_temp(&dir, b"%PDF-1.7").unwrap();
|
||||
let hour_ago = SystemTime::now() - Duration::from_secs(2 * 60 * 60);
|
||||
OpenOptions::new().write(true).open(&old).unwrap().set_modified(hour_ago).unwrap();
|
||||
let other = dir.join("notes.pdf");
|
||||
std::fs::write(&other, b"keep").unwrap();
|
||||
OpenOptions::new().write(true).open(&other).unwrap().set_modified(hour_ago).unwrap();
|
||||
|
||||
assert_eq!(remove_stale(&dir, SystemTime::now(), KEEP), 1);
|
||||
assert!(fresh.exists() && other.exists() && !old.exists());
|
||||
assert_eq!(remove_stale(&dir.join("missing"), SystemTime::now(), KEEP), 0, "a missing folder is fine");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
//! The system region new documents follow (#838): the paper File › New starts on, the default
|
||||
//! measurement unit and the decimal separator.
|
||||
//!
|
||||
//! - Windows: the preferred language with the home location from Settings › Time & language ›
|
||||
//! Region (`GlobalizationPreferences.HomeGeographicRegion`, a safe Windows Runtime API), since
|
||||
//! the language list alone says `en-US` for an English interface in Poland.
|
||||
//! - Elsewhere: the locale the UI crate reads ([`wordcraft_ui_egui::i18n::system_region`]).
|
||||
|
||||
/// The system region.
|
||||
pub fn system() -> wordcraft_engine::Region {
|
||||
// Runs without preferences (agents' and screenshot runs) behave the same on every machine:
|
||||
// en-US unless `WORDCRAFT_REGION` says otherwise.
|
||||
if std::env::var_os("WORDCRAFT_NO_PREFS").is_some() && std::env::var_os("WORDCRAFT_REGION").is_none() {
|
||||
return wordcraft_engine::Region::default();
|
||||
}
|
||||
let region = wordcraft_ui_egui::i18n::system_region();
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if std::env::var_os("WORDCRAFT_REGION").is_none() {
|
||||
return match windows::System::UserProfile::GlobalizationPreferences::HomeGeographicRegion() {
|
||||
Ok(home) => region.with_territory(&home.to_string()),
|
||||
Err(e) => {
|
||||
log::debug!("system home region: {e}");
|
||||
region
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
region
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
//! Where AppKit puts the traffic lights, so the integrated title bar fits around them (macOS).
|
||||
//!
|
||||
//! The lights' size and position change between macOS versions (they grew in macOS 26), so the
|
||||
//! title bar measures the real buttons instead of assuming the old 28 pt layout. These calls are
|
||||
//! safe in the `objc2-app-kit` bindings (our workspace forbids unsafe).
|
||||
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSApplication, NSWindowButton};
|
||||
use wordcraft_ui_egui::chrome::MacTitleBar;
|
||||
|
||||
/// The title bar around the app window's traffic lights, or `None` before the window exists or
|
||||
/// when AppKit has no zoom button to measure (the caller keeps the standard layout).
|
||||
pub fn title_bar() -> Option<MacTitleBar> {
|
||||
let mtm = MainThreadMarker::new()?;
|
||||
let window = NSApplication::sharedApplication(mtm).windows().firstObject()?;
|
||||
let zoom = window.standardWindowButton(NSWindowButton::ZoomButton)?;
|
||||
// In window coordinates, which count up from the bottom.
|
||||
let frame = zoom.convertRect_toView(zoom.bounds(), None);
|
||||
let window_height = window.frame().size.height;
|
||||
let right = frame.origin.x + frame.size.width;
|
||||
let centre_from_top = window_height - (frame.origin.y + frame.size.height / 2.0);
|
||||
if !(right.is_finite() && centre_from_top.is_finite() && right > 0.0 && centre_from_top > 0.0) {
|
||||
return None;
|
||||
}
|
||||
Some(MacTitleBar::around_lights(right as f32, centre_from_top as f32, frame.size.height as f32))
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
//! `wordcraft --control` checks its port before the window opens (#1426).
|
||||
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn app(args: &[&str]) -> Output {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
|
||||
.args(args)
|
||||
// With no display, an app that tries to open a window fails at once instead.
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.env_remove("WAYLAND_SOCKET")
|
||||
// A bad `--control` must not fall back to this port either.
|
||||
.env("WORDCRAFT_CONTROL_PORT", "7981")
|
||||
.env("WORDCRAFT_NO_PREFS", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while child.try_wait().unwrap().is_none() {
|
||||
if Instant::now() > deadline {
|
||||
let _ = child.kill();
|
||||
panic!("wordcraft {args:?} was still running after 30 s: it started the app");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_port_is_an_error_naming_it() {
|
||||
let cases: [(&[&str], &str); 8] = [
|
||||
(&["--control", "-99999999999999999999"], ", got \"-99999999999999999999\""),
|
||||
(&["--control", "abc"], ", got \"abc\""),
|
||||
(&["--control", "79811"], ", got \"79811\""),
|
||||
(&["--control", "0"], ", got \"0\""),
|
||||
(&["--control", "-5"], ", got \"-5\""),
|
||||
(&["--control", "abc", "--control", "7981"], ", got \"abc\""),
|
||||
(&["--control"], ""),
|
||||
(&["--control", "--sample"], ""),
|
||||
];
|
||||
for (args, got) in cases {
|
||||
let out = app(args);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(2), "{args:?}: stderr: {stderr}");
|
||||
assert_eq!(stderr, format!("wordcraft: --control needs a port number from 1 to 65535{got}\n"), "{args:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flag_after_control_is_read_as_a_flag() {
|
||||
let out = app(&["--control", "--version"]);
|
||||
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
|
||||
assert!(stdout.starts_with("wordcraft "), "stdout: {stdout}");
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
//! `wordcraft --help` and `-h` print the options and exit without opening a window (#968).
|
||||
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn app(flag: &str) -> Output {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
|
||||
.arg(flag)
|
||||
// With no display, an app that tries to open a window fails at once instead.
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.env_remove("WORDCRAFT_CONTROL_PORT")
|
||||
.env("WORDCRAFT_NO_PREFS", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while child.try_wait().unwrap().is_none() {
|
||||
if Instant::now() > deadline {
|
||||
let _ = child.kill();
|
||||
panic!("wordcraft {flag} was still running after 30 s: it started the app");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_and_h_print_the_options_and_exit() {
|
||||
for flag in ["--help", "-h"] {
|
||||
let out = app(flag);
|
||||
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
assert!(out.status.success(), "{flag}: status {:?}, stderr: {stderr}", out.status);
|
||||
assert!(stdout.starts_with("wordcraft — "), "{flag}: stdout: {stdout}");
|
||||
for option in ["--sample", "--control PORT", "--version", "-h, --help"] {
|
||||
assert!(stdout.contains(option), "{flag}: {option} missing from: {stdout}");
|
||||
}
|
||||
assert!(stderr.is_empty(), "{flag}: stderr: {stderr}");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
mod control_arg;
|
||||
mod help;
|
||||
#[cfg(unix)]
|
||||
mod non_utf8_args;
|
||||
@@ -1,52 +0,0 @@
|
||||
//! An argument that isn't valid Unicode, such as a Latin-1 file name, never panics the app (#1440).
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn app(args: &[OsString]) -> Output {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
|
||||
.args(args)
|
||||
// With no display, an app that tries to open a window fails at once instead.
|
||||
.env_remove("DISPLAY")
|
||||
.env_remove("WAYLAND_DISPLAY")
|
||||
.env_remove("WAYLAND_SOCKET")
|
||||
.env_remove("WORDCRAFT_CONTROL_PORT")
|
||||
.env("WORDCRAFT_NO_PREFS", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while child.try_wait().unwrap().is_none() {
|
||||
if Instant::now() > deadline {
|
||||
let _ = child.kill();
|
||||
panic!("wordcraft {args:?} was still running after 30 s: it started the app");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
|
||||
/// The file opens once the window is up (`open_non_unicode`, unit-tested in `main.rs`). Not on
|
||||
/// macOS, where the app opens its window without a display variable and so keeps running.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn a_file_with_such_a_name_starts_the_app() {
|
||||
let out = app(&["--sample".into(), OsString::from_vec(b"caf\xe9.docx".to_vec())]);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!stderr.contains("panic"), "stderr: {stderr}");
|
||||
// It went on to open the window, which fails without a display.
|
||||
assert!(stderr.contains("WinitEventLoop"), "stderr: {stderr}");
|
||||
assert_eq!(out.status.code(), Some(1), "stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_control_port_that_isnt_unicode_is_an_error_naming_it() {
|
||||
let out = app(&["--sample".into(), "--control".into(), OsString::from_vec(b"79\xe981".to_vec())]);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(2), "stderr: {stderr}");
|
||||
assert_eq!(stderr, "wordcraft: argument 3 is not valid Unicode: 79\u{FFFD}81\n");
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,224 @@
|
||||
# English spelling supplement: common words the public-domain Moby list (1990s) lacks.
|
||||
# Written by WordCraft contributors (MIT OR Apache-2.0); see ATTRIBUTION.md.
|
||||
# One lowercase base form per line, sorted. Plurals, -ed, -ing, -er, -ly… come from the
|
||||
# inflection rules in crates/proof/src/lib.rs, so list "desktop", not "desktops".
|
||||
airbag
|
||||
airfare
|
||||
artcraft
|
||||
autocomplete
|
||||
autocorrect
|
||||
autofill
|
||||
autoformat
|
||||
autosave
|
||||
backend
|
||||
bitcoin
|
||||
bitmap
|
||||
blockchain
|
||||
blog
|
||||
blogger
|
||||
bluetooth
|
||||
captcha
|
||||
caregiver
|
||||
carpool
|
||||
cellphone
|
||||
chatbot
|
||||
chatroom
|
||||
checkbox
|
||||
checklist
|
||||
childcare
|
||||
clickable
|
||||
configurable
|
||||
copyedit
|
||||
coronavirus
|
||||
covid
|
||||
crowdfunding
|
||||
crowdsource
|
||||
crypto
|
||||
cryptocurrency
|
||||
customizable
|
||||
customization
|
||||
cyber
|
||||
cybersecurity
|
||||
cyberspace
|
||||
database
|
||||
dataset
|
||||
daycare
|
||||
desktop
|
||||
download
|
||||
downloadable
|
||||
downsize
|
||||
dropdown
|
||||
earbud
|
||||
ebook
|
||||
ecommerce
|
||||
email
|
||||
emoji
|
||||
emoticon
|
||||
esports
|
||||
facebook
|
||||
favicon
|
||||
fintech
|
||||
firewall
|
||||
flowchart
|
||||
foodie
|
||||
freeware
|
||||
frontend
|
||||
fundraiser
|
||||
gameplay
|
||||
gamification
|
||||
geeky
|
||||
geolocation
|
||||
gigabit
|
||||
gigabyte
|
||||
github
|
||||
globalization
|
||||
google
|
||||
groupthink
|
||||
hackathon
|
||||
hardcode
|
||||
hashtag
|
||||
healthcare
|
||||
homepage
|
||||
homeschool
|
||||
hotspot
|
||||
hyperlink
|
||||
hypertext
|
||||
inbox
|
||||
incentivize
|
||||
infographic
|
||||
instagram
|
||||
internet
|
||||
interoperability
|
||||
intranet
|
||||
ipad
|
||||
iphone
|
||||
javascript
|
||||
kickstart
|
||||
kilobyte
|
||||
laptop
|
||||
lifestyle
|
||||
linkedin
|
||||
linux
|
||||
livestream
|
||||
lockdown
|
||||
login
|
||||
logout
|
||||
lowercase
|
||||
makeover
|
||||
malware
|
||||
megabyte
|
||||
megapixel
|
||||
meme
|
||||
metadata
|
||||
microchip
|
||||
microsoft
|
||||
middleware
|
||||
mindset
|
||||
multicultural
|
||||
multiplayer
|
||||
multitask
|
||||
nerdy
|
||||
netbook
|
||||
netflix
|
||||
newsfeed
|
||||
offboard
|
||||
offline
|
||||
onboard
|
||||
online
|
||||
onscreen
|
||||
outsource
|
||||
paralegal
|
||||
passcode
|
||||
petabyte
|
||||
phishing
|
||||
photobomb
|
||||
playdate
|
||||
playlist
|
||||
plugin
|
||||
podcast
|
||||
popup
|
||||
prequel
|
||||
printout
|
||||
prioritization
|
||||
prioritize
|
||||
ransomware
|
||||
recyclable
|
||||
ringtone
|
||||
roadmap
|
||||
rollout
|
||||
runtime
|
||||
scalability
|
||||
screencast
|
||||
screensaver
|
||||
screenshot
|
||||
scrollbar
|
||||
selfie
|
||||
signup
|
||||
sitemap
|
||||
skillset
|
||||
smartphone
|
||||
smartwatch
|
||||
smiley
|
||||
snowboard
|
||||
spellcheck
|
||||
spellchecker
|
||||
spreadsheet
|
||||
spyware
|
||||
stakeholder
|
||||
standup
|
||||
startup
|
||||
subfolder
|
||||
sunscreen
|
||||
superfood
|
||||
superuser
|
||||
tagline
|
||||
takeout
|
||||
techie
|
||||
telecommute
|
||||
teleconference
|
||||
telehealth
|
||||
telemedicine
|
||||
terabyte
|
||||
textbox
|
||||
timeline
|
||||
timestamp
|
||||
toolbar
|
||||
toolkit
|
||||
touchpad
|
||||
touchscreen
|
||||
trackball
|
||||
trackpad
|
||||
upcycle
|
||||
upload
|
||||
uppercase
|
||||
upsell
|
||||
upskill
|
||||
usability
|
||||
username
|
||||
videoconference
|
||||
videogame
|
||||
vlog
|
||||
vlogger
|
||||
voicemail
|
||||
walkthrough
|
||||
webcam
|
||||
webcast
|
||||
webinar
|
||||
weblog
|
||||
webmaster
|
||||
webpage
|
||||
website
|
||||
whatsapp
|
||||
whiteboard
|
||||
wifi
|
||||
wiki
|
||||
wikipedia
|
||||
wireframe
|
||||
wordcraft
|
||||
workaround
|
||||
workflow
|
||||
workforce
|
||||
workplace
|
||||
workspace
|
||||
workstation
|
||||
youtube
|
||||
@@ -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
|
||||
|
||||
+6
-136
@@ -1,10 +1,13 @@
|
||||
# Contributor names for the About window.
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Copy this block, replacing your-github-username (every field is optional):
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
@@ -14,13 +17,6 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.divyanshu2074]
|
||||
real_name = "Divyanshu Gupta"
|
||||
sync_github_name = true
|
||||
|
||||
[people.lokinmodar]
|
||||
sync_github_name = true
|
||||
|
||||
[people.echelon]
|
||||
real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
@@ -42,11 +38,6 @@ real_name = "Dmitrii Khlapov"
|
||||
display_name = "Dima Khlapov"
|
||||
sync_github_name = false
|
||||
|
||||
[people.DanielKnourek]
|
||||
real_name = "Daniel Kňourek"
|
||||
display_name = "Daniel Kňourek"
|
||||
sync_github_name = false
|
||||
|
||||
[people.arcanii]
|
||||
real_name = "Bryan Mark"
|
||||
display_name = "Arcanii"
|
||||
@@ -55,135 +46,14 @@ sync_github_name = false
|
||||
[people.henno]
|
||||
sync_github_name = true
|
||||
|
||||
[people.yeniufuklar-com]
|
||||
real_name = "Ufuk Efe"
|
||||
display_name = "Ufuk Efe"
|
||||
sync_github_name = true
|
||||
|
||||
[people.GhostMitra]
|
||||
sync_github_name = true
|
||||
|
||||
[people.DevCoderMax]
|
||||
real_name = "Gleyson do Nascimento Carvalho"
|
||||
display_name = "Gleyson Carvalho"
|
||||
sync_github_name = false
|
||||
[people.alexanderalber]
|
||||
real_name = "Alexander Alber"
|
||||
|
||||
[people.edurbs]
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
[people.manucarcor]
|
||||
real_name = "Manuel Carmona"
|
||||
display_name = "manucarcor"
|
||||
[people.odedigo]
|
||||
|
||||
[people.neupaneprashant]
|
||||
real_name = "Prashant Neupane"
|
||||
[people.cyril265]
|
||||
display_name = "cyril265"
|
||||
|
||||
[people.webdevtodayjason]
|
||||
display_name = "Jason Brashear"
|
||||
sync_github_name = false
|
||||
|
||||
[people.lupant]
|
||||
sync_github_name = true
|
||||
[people.Nael-Nathanael]
|
||||
[people.Losminek]
|
||||
[people.Nghiathan13]
|
||||
|
||||
[people.leoncral]
|
||||
sync_github_name = true
|
||||
|
||||
[people.ms-youssef]
|
||||
sync_github_name = true
|
||||
|
||||
[people.finwarman]
|
||||
real_name = "Fin Warman"
|
||||
|
||||
|
||||
[people.albayazid]
|
||||
real_name = "Sazid Al Bayazid"
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
display_name = "Gert Pellin"
|
||||
|
||||
[people.tlomts-dev]
|
||||
display_name = "The Legend Of MTS Dev"
|
||||
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
[people.rbr48]
|
||||
real_name = "M S Rana"
|
||||
display_name = "Izhaan Intellect"
|
||||
|
||||
|
||||
|
||||
[people.holmbom]
|
||||
real_name = "Erik Holmbom"
|
||||
display_name = "Erik Holmbom"
|
||||
sync_github_name = false
|
||||
|
||||
[people.andrejskvorc]
|
||||
real_name = "Andrej Škvorc"
|
||||
display_name = "Andrej Škvorc"
|
||||
sync_github_name = false
|
||||
|
||||
[people.olekkut]
|
||||
real_name = "Aleksander Kutycki"
|
||||
display_name = "Aleksander Kutycki"
|
||||
sync_github_name = true
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.rem5411]
|
||||
real_name = "Vivien Janny"
|
||||
sync_github_name = true
|
||||
|
||||
[people.C00kieMonsta]
|
||||
real_name = "Antoine Boxho"
|
||||
sync_github_name = true
|
||||
|
||||
[people.matteo-andreuzza]
|
||||
sync_github_name = true
|
||||
|
||||
[people.lucAmbr0]
|
||||
sync_github_name = true
|
||||
|
||||
[people.maxm11]
|
||||
real_name = "Max Murphy"
|
||||
|
||||
[people.SlavenDj]
|
||||
sync_github_name = true
|
||||
|
||||
[people.asystemoffields]
|
||||
display_name = "asystemoffields"
|
||||
|
||||
[people.dmim2502-commits]
|
||||
display_name = "Mulai"
|
||||
|
||||
[people.Bortlesboat]
|
||||
real_name = "Andrew Barnes"
|
||||
|
||||
[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 @@
|
||||
20467b63558f04fe075e83aaea501bd10f7fa684
|
||||
@@ -6,11 +6,7 @@ use std::path::{Path, PathBuf};
|
||||
/// The app's settings folder (preferences, logs, key files): macOS
|
||||
/// `~/Library/Application Support/WordCraft`, Windows `%APPDATA%\WordCraft`, otherwise
|
||||
/// `$XDG_CONFIG_HOME/wordcraft` or `~/.config/wordcraft`.
|
||||
/// `WORDCRAFT_PROFILE_DIR` overrides the entire folder for isolated runs.
|
||||
pub fn settings_dir() -> Option<PathBuf> {
|
||||
if let Some(path) = std::env::var_os("WORDCRAFT_PROFILE_DIR").filter(|p| !p.is_empty()) {
|
||||
return Some(PathBuf::from(path));
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/WordCraft"))
|
||||
} else if cfg!(windows) {
|
||||
|
||||
@@ -8,16 +8,8 @@ description = "WordCraft document model: stories, paragraphs, runs, tables, sect
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`,
|
||||
# `zip_guard::zip64_entry_count_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 }
|
||||
|
||||
@@ -37,26 +37,6 @@ pub fn has_rtl(text: &str) -> bool {
|
||||
text.chars().any(is_rtl_script)
|
||||
}
|
||||
|
||||
/// Does the first strong directional character in `text` belong to a right-to-left script?
|
||||
///
|
||||
/// Follows UAX #9 rules P2 and P3: characters with strong bidirectional class AL or R
|
||||
/// (Arabic, Hebrew, Syriac, Thaana…) give `true`; strong L characters (Latin, Greek,
|
||||
/// Cyrillic, CJK…) give `false`. Neutral, weak and whitespace characters are skipped.
|
||||
pub fn first_strong_is_rtl(text: &str) -> bool {
|
||||
for c in text.chars() {
|
||||
if c == '\u{200F}' {
|
||||
return true;
|
||||
}
|
||||
if c == '\u{200E}' {
|
||||
return false;
|
||||
}
|
||||
if c.is_alphabetic() {
|
||||
return is_rtl_script(c);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -76,11 +56,5 @@ mod tests {
|
||||
assert!("میخواهم".chars().all(is_word_char), "ZWNJ is inside the word");
|
||||
assert!("کِتاب".chars().all(is_word_char), "so are harakat");
|
||||
assert!(!is_word_char(' ') && !is_word_char('،'));
|
||||
assert!(first_strong_is_rtl("سلام"));
|
||||
assert!(first_strong_is_rtl("من ناوم هێڤاره"));
|
||||
assert!(first_strong_is_rtl(" 123. سلام"));
|
||||
assert!(!first_strong_is_rtl("Hello سلام"));
|
||||
assert!(!first_strong_is_rtl("123 Hello"));
|
||||
assert!(!first_strong_is_rtl(""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,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,130 +0,0 @@
|
||||
//! How [`crate::Document::media`] and [`crate::Document::passthrough`] are written in the native
|
||||
//! JSON form (`.json` files and crash-recovery copies): each part's bytes as one base64 string.
|
||||
//! serde's default for bytes, an array of numbers, took about 3.6 times their size compact and
|
||||
//! over six times pretty-printed (a 27 MB picture made a 99 MB recovery copy, #586).
|
||||
//!
|
||||
//! Reading takes either form, so `.json` files and recovery copies written before still open.
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::display::Base64Display;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use serde::de::{self, Deserializer, MapAccess, SeqAccess, Visitor};
|
||||
use serde::ser::{Serialize, Serializer};
|
||||
|
||||
type Store = BTreeMap<String, Arc<Vec<u8>>>;
|
||||
|
||||
/// One part's bytes, encoded as it is written out: no second copy of a large picture is made.
|
||||
struct Encoded<'a>(&'a [u8]);
|
||||
|
||||
impl Serialize for Encoded<'_> {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.collect_str(&Base64Display::new(self.0, &STANDARD))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn serialize<S: Serializer>(store: &Store, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.collect_map(store.iter().map(|(name, bytes)| (name, Encoded(bytes))))
|
||||
}
|
||||
|
||||
pub(crate) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Store, D::Error> {
|
||||
d.deserialize_map(StoreVisitor)
|
||||
}
|
||||
|
||||
struct StoreVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for StoreVisitor {
|
||||
type Value = Store;
|
||||
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("a map of part names to base64 text or byte arrays")
|
||||
}
|
||||
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Store, A::Error> {
|
||||
let mut store = Store::new();
|
||||
while let Some((name, Bytes(bytes))) = map.next_entry::<String, Bytes>()? {
|
||||
store.insert(name, Arc::new(bytes));
|
||||
}
|
||||
Ok(store)
|
||||
}
|
||||
}
|
||||
|
||||
/// A part's bytes as either form.
|
||||
struct Bytes(Vec<u8>);
|
||||
|
||||
impl<'de> de::Deserialize<'de> for Bytes {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
d.deserialize_any(BytesVisitor).map(Bytes)
|
||||
}
|
||||
}
|
||||
|
||||
struct BytesVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for BytesVisitor {
|
||||
type Value = Vec<u8>;
|
||||
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("base64 text or an array of bytes")
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, text: &str) -> Result<Vec<u8>, E> {
|
||||
STANDARD.decode(text).map_err(|e| E::custom(format!("bytes are not valid base64: {e}")))
|
||||
}
|
||||
|
||||
fn visit_bytes<E: de::Error>(self, bytes: &[u8]) -> Result<Vec<u8>, E> {
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
/// The form written before: one number per byte.
|
||||
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<u8>, A::Error> {
|
||||
// The length hint comes from the file: trust it only so far.
|
||||
let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0).min(1 << 20));
|
||||
while let Some(b) = seq.next_element::<u8>()? {
|
||||
out.push(b);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug, Default)]
|
||||
struct Parts {
|
||||
#[serde(default, with = "super")]
|
||||
media: Store,
|
||||
}
|
||||
|
||||
fn parts(entries: &[(&str, &[u8])]) -> Parts {
|
||||
Parts { media: entries.iter().map(|(n, b)| ((*n).to_string(), Arc::new(b.to_vec()))).collect() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_are_written_as_base64_and_read_back() {
|
||||
let all: Vec<u8> = (0..=255).collect();
|
||||
for n in [0, 1, 2, 3, 4, 255, 256] {
|
||||
let p = parts(&[("a.png", &all[..n.min(all.len())])]);
|
||||
let text = serde_json::to_string(&p).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Parts>(&text).unwrap(), p, "{n}");
|
||||
}
|
||||
assert_eq!(serde_json::to_string(&parts(&[("m", b"Man"), ("ma", b"Ma")])).unwrap(), r#"{"media":{"m":"TWFu","ma":"TWE="}}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_arrays_written_before_still_read() {
|
||||
let old = r#"{"media":{"a.png":[0,1,127,128,254,255],"empty":[]}}"#;
|
||||
assert_eq!(serde_json::from_str::<Parts>(old).unwrap(), parts(&[("a.png", &[0, 1, 127, 128, 254, 255]), ("empty", &[])]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_values_are_errors_not_panics() {
|
||||
for bad in [r#""T""#, r#""TWFu!""#, r#""-_""#, r#""TQ=x""#, "[256]", "[-1]", "[1.5]", "true", "{}", r#"["a"]"#] {
|
||||
let text = format!(r#"{{"media":{{"a.png":{bad}}}}}"#);
|
||||
assert!(serde_json::from_str::<Parts>(&text).is_err(), "{bad}");
|
||||
}
|
||||
assert!(serde_json::from_str::<Parts>(r#"{"media":"x"}"#).is_err());
|
||||
}
|
||||
}
|
||||
@@ -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,544 +0,0 @@
|
||||
//! Language-aware sorting of text: a small, pure-Rust collation.
|
||||
//!
|
||||
//! Every letter gets a *primary* weight (its place in the alphabet) and a *secondary* one (its
|
||||
//! diacritic). Strings compare by primary weights first, then by secondary ones, so `é` sorts
|
||||
//! with `e` (`resume` < `résumé` < `rex`) unless the language makes it a letter of its own:
|
||||
//! Polish puts `ą` right after `a` and `ż` after `ź` after `z`, Swedish puts `å ä ö` after `z`,
|
||||
//! Czech puts `ch` after `h`, Spanish `ñ` after `n`, Hungarian `cs`, `gy`, `sz`… after their
|
||||
//! first letter. The per-language tables follow the published alphabets of those languages.
|
||||
//! Text without diacritics sorts exactly as a case-insensitive code-point compare.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// The diacritic of a precomposed lowercase Latin letter: `(base letters, mark)`. Marks: 1 acute,
|
||||
/// 2 grave, 3 circumflex, 4 diaeresis, 5 tilde, 6 ring, 7 caron, 8 cedilla, 9 ogonek, 10 dot,
|
||||
/// 11 stroke, 12 breve, 13 macron, 14 double acute, 15 comma below, 16 ligature, 17 dotless,
|
||||
/// 18 eth.
|
||||
const DECOMP: &[(char, &str, u16)] = &[
|
||||
('à', "a", 2),
|
||||
('á', "a", 1),
|
||||
('â', "a", 3),
|
||||
('ã', "a", 5),
|
||||
('ä', "a", 4),
|
||||
('å', "a", 6),
|
||||
('ā', "a", 13),
|
||||
('ă', "a", 12),
|
||||
('ą', "a", 9),
|
||||
('æ', "ae", 16),
|
||||
('ç', "c", 8),
|
||||
('ć', "c", 1),
|
||||
('ĉ', "c", 3),
|
||||
('ċ', "c", 10),
|
||||
('č', "c", 7),
|
||||
('ď', "d", 7),
|
||||
('đ', "d", 11),
|
||||
('ð', "d", 18),
|
||||
('è', "e", 2),
|
||||
('é', "e", 1),
|
||||
('ê', "e", 3),
|
||||
('ë', "e", 4),
|
||||
('ē', "e", 13),
|
||||
('ĕ', "e", 12),
|
||||
('ė', "e", 10),
|
||||
('ę', "e", 9),
|
||||
('ě', "e", 7),
|
||||
('ĝ', "g", 3),
|
||||
('ğ', "g", 12),
|
||||
('ġ', "g", 10),
|
||||
('ģ', "g", 8),
|
||||
('ĥ', "h", 3),
|
||||
('ħ', "h", 11),
|
||||
('ì', "i", 2),
|
||||
('í', "i", 1),
|
||||
('î', "i", 3),
|
||||
('ï', "i", 4),
|
||||
('ĩ', "i", 5),
|
||||
('ī', "i", 13),
|
||||
('ĭ', "i", 12),
|
||||
('į', "i", 9),
|
||||
('ı', "i", 17),
|
||||
('ij', "ij", 16),
|
||||
('ĵ', "j", 3),
|
||||
('ķ', "k", 8),
|
||||
('ĺ', "l", 1),
|
||||
('ļ', "l", 8),
|
||||
('ľ', "l", 7),
|
||||
('ŀ', "l", 10),
|
||||
('ł', "l", 11),
|
||||
('ñ', "n", 5),
|
||||
('ń', "n", 1),
|
||||
('ņ', "n", 8),
|
||||
('ň', "n", 7),
|
||||
('ò', "o", 2),
|
||||
('ó', "o", 1),
|
||||
('ô', "o", 3),
|
||||
('õ', "o", 5),
|
||||
('ö', "o", 4),
|
||||
('ø', "o", 11),
|
||||
('ō', "o", 13),
|
||||
('ŏ', "o", 12),
|
||||
('ő', "o", 14),
|
||||
('œ', "oe", 16),
|
||||
('ŕ', "r", 1),
|
||||
('ŗ', "r", 8),
|
||||
('ř', "r", 7),
|
||||
('ś', "s", 1),
|
||||
('ŝ', "s", 3),
|
||||
('ş', "s", 8),
|
||||
('š', "s", 7),
|
||||
('ș', "s", 15),
|
||||
('ß', "ss", 16),
|
||||
('ţ', "t", 8),
|
||||
('ť', "t", 7),
|
||||
('ŧ', "t", 11),
|
||||
('ț', "t", 15),
|
||||
('ù', "u", 2),
|
||||
('ú', "u", 1),
|
||||
('û', "u", 3),
|
||||
('ü', "u", 4),
|
||||
('ũ', "u", 5),
|
||||
('ū', "u", 13),
|
||||
('ŭ', "u", 12),
|
||||
('ů', "u", 6),
|
||||
('ű', "u", 14),
|
||||
('ų', "u", 9),
|
||||
('ŵ', "w", 3),
|
||||
('ý', "y", 1),
|
||||
('ÿ', "y", 4),
|
||||
('ŷ', "y", 3),
|
||||
('ź', "z", 1),
|
||||
('ż', "z", 10),
|
||||
('ž', "z", 7),
|
||||
];
|
||||
|
||||
/// A letter (or letter group) a language sorts as a letter of its own: right after `after`
|
||||
/// (`rank` places after it), with secondary weight `sec` among letters of the same place.
|
||||
struct Tailor {
|
||||
seq: &'static str,
|
||||
after: char,
|
||||
rank: u32,
|
||||
sec: u16,
|
||||
}
|
||||
|
||||
const fn t(seq: &'static str, after: char, rank: u32) -> Tailor {
|
||||
Tailor { seq, after, rank, sec: 0 }
|
||||
}
|
||||
|
||||
const fn ts(seq: &'static str, after: char, rank: u32, sec: u16) -> Tailor {
|
||||
Tailor { seq, after, rank, sec }
|
||||
}
|
||||
|
||||
const PL: &[Tailor] =
|
||||
&[t("ą", 'a', 1), t("ć", 'c', 1), t("ę", 'e', 1), t("ł", 'l', 1), t("ń", 'n', 1), t("ó", 'o', 1), t("ś", 's', 1), t("ź", 'z', 1), t("ż", 'z', 2)];
|
||||
const CS: &[Tailor] = &[t("č", 'c', 1), t("ch", 'h', 1), t("ř", 'r', 1), t("š", 's', 1), t("ž", 'z', 1)];
|
||||
const SK: &[Tailor] = &[t("ä", 'a', 1), t("č", 'c', 1), t("ch", 'h', 1), t("ô", 'o', 1), t("š", 's', 1), t("ž", 'z', 1)];
|
||||
const HU: &[Tailor] = &[
|
||||
t("cs", 'c', 1),
|
||||
t("dz", 'd', 1),
|
||||
t("dzs", 'd', 2),
|
||||
t("gy", 'g', 1),
|
||||
t("ly", 'l', 1),
|
||||
t("ny", 'n', 1),
|
||||
t("ö", 'o', 1),
|
||||
ts("ő", 'o', 1, 14),
|
||||
t("sz", 's', 1),
|
||||
t("ty", 't', 1),
|
||||
t("ü", 'u', 1),
|
||||
ts("ű", 'u', 1, 14),
|
||||
t("zs", 'z', 1),
|
||||
];
|
||||
const SV: &[Tailor] = &[t("å", 'z', 1), t("ä", 'z', 2), ts("æ", 'z', 2, 16), t("ö", 'z', 3), ts("ø", 'z', 3, 11)];
|
||||
const DA: &[Tailor] = &[t("æ", 'z', 1), ts("ä", 'z', 1, 4), t("ø", 'z', 2), ts("ö", 'z', 2, 4), t("å", 'z', 3)];
|
||||
const ES: &[Tailor] = &[t("ñ", 'n', 1)];
|
||||
// Russian ё shares е's primary weight; the diaeresis breaks ties (CLDR Russian standard).
|
||||
const RU: &[Tailor] = &[ts("ё", 'е', 0, 4)];
|
||||
const TR: &[Tailor] = &[t("ç", 'c', 1), t("ğ", 'g', 1), t("ı", 'h', 1), t("ö", 'o', 1), t("ş", 's', 1), t("ü", 'u', 1)];
|
||||
const HR: &[Tailor] =
|
||||
&[t("č", 'c', 1), t("ć", 'c', 2), t("dž", 'd', 1), t("đ", 'd', 2), t("lj", 'l', 1), t("nj", 'n', 1), t("š", 's', 1), t("ž", 'z', 1)];
|
||||
const RO: &[Tailor] = &[t("ă", 'a', 1), t("â", 'a', 2), t("î", 'i', 1), t("ș", 's', 1), ts("ş", 's', 1, 8), t("ț", 't', 1), ts("ţ", 't', 1, 8)];
|
||||
const ET: &[Tailor] = &[t("š", 's', 1), t("z", 's', 2), t("ž", 's', 3), t("õ", 'w', 1), t("ä", 'w', 2), t("ö", 'w', 3), t("ü", 'w', 4)];
|
||||
const LT: &[Tailor] = &[t("č", 'c', 1), t("š", 's', 1), t("ž", 'z', 1)];
|
||||
const LV: &[Tailor] = &[t("č", 'c', 1), t("ģ", 'g', 1), t("ķ", 'k', 1), t("ļ", 'l', 1), t("ņ", 'n', 1), t("š", 's', 1), t("ž", 'z', 1)];
|
||||
const IS: &[Tailor] = &[
|
||||
t("á", 'a', 1),
|
||||
t("ð", 'd', 1),
|
||||
t("é", 'e', 1),
|
||||
t("í", 'i', 1),
|
||||
t("ó", 'o', 1),
|
||||
t("ú", 'u', 1),
|
||||
t("ý", 'y', 1),
|
||||
t("þ", 'z', 1),
|
||||
t("æ", 'z', 2),
|
||||
t("ö", 'z', 3),
|
||||
];
|
||||
|
||||
/// The primary language subtag of a BCP 47 tag (`pl-PL` → `pl`), lowercased.
|
||||
pub fn language(lang: Option<&str>) -> String {
|
||||
lang.and_then(|l| l.split(['-', '_']).next()).unwrap_or("").trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn tailoring(lang: Option<&str>) -> &'static [Tailor] {
|
||||
match language(lang).as_str() {
|
||||
"pl" => PL,
|
||||
"cs" => CS,
|
||||
"sk" => SK,
|
||||
"hu" => HU,
|
||||
"sv" | "fi" => SV,
|
||||
"da" | "nb" | "nn" | "no" => DA,
|
||||
"es" | "gl" => ES,
|
||||
"ru" => RU,
|
||||
"tr" | "az" => TR,
|
||||
"hr" | "bs" | "sr" => HR,
|
||||
"ro" => RO,
|
||||
"et" => ET,
|
||||
"lt" => LT,
|
||||
"lv" => LV,
|
||||
"is" => IS,
|
||||
"sl" => LT,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// A lowercase Latin or Russian letter followed by a combining mark, and the single letter Unicode
|
||||
/// composes them to (NFC; generated from the Unicode Character Database). Text pasted from
|
||||
/// decomposed sources (macOS file names: `z` + U+0307) collates like the composed letter.
|
||||
const COMPOSE: &[(char, char, char)] = &[
|
||||
('a', '\u{300}', 'à'),
|
||||
('a', '\u{301}', 'á'),
|
||||
('a', '\u{302}', 'â'),
|
||||
('a', '\u{303}', 'ã'),
|
||||
('a', '\u{304}', 'ā'),
|
||||
('a', '\u{306}', 'ă'),
|
||||
('a', '\u{307}', 'ȧ'),
|
||||
('a', '\u{308}', 'ä'),
|
||||
('a', '\u{30a}', 'å'),
|
||||
('a', '\u{30c}', 'ǎ'),
|
||||
('a', '\u{328}', 'ą'),
|
||||
('b', '\u{307}', 'ḃ'),
|
||||
('c', '\u{301}', 'ć'),
|
||||
('c', '\u{302}', 'ĉ'),
|
||||
('c', '\u{307}', 'ċ'),
|
||||
('c', '\u{30c}', 'č'),
|
||||
('c', '\u{327}', 'ç'),
|
||||
('d', '\u{307}', 'ḋ'),
|
||||
('d', '\u{30c}', 'ď'),
|
||||
('d', '\u{327}', 'ḑ'),
|
||||
('e', '\u{300}', 'è'),
|
||||
('e', '\u{301}', 'é'),
|
||||
('e', '\u{302}', 'ê'),
|
||||
('e', '\u{303}', 'ẽ'),
|
||||
('e', '\u{304}', 'ē'),
|
||||
('e', '\u{306}', 'ĕ'),
|
||||
('e', '\u{307}', 'ė'),
|
||||
('e', '\u{308}', 'ë'),
|
||||
('e', '\u{30c}', 'ě'),
|
||||
('e', '\u{327}', 'ȩ'),
|
||||
('e', '\u{328}', 'ę'),
|
||||
('f', '\u{307}', 'ḟ'),
|
||||
('g', '\u{301}', 'ǵ'),
|
||||
('g', '\u{302}', 'ĝ'),
|
||||
('g', '\u{304}', 'ḡ'),
|
||||
('g', '\u{306}', 'ğ'),
|
||||
('g', '\u{307}', 'ġ'),
|
||||
('g', '\u{30c}', 'ǧ'),
|
||||
('g', '\u{327}', 'ģ'),
|
||||
('h', '\u{302}', 'ĥ'),
|
||||
('h', '\u{307}', 'ḣ'),
|
||||
('h', '\u{308}', 'ḧ'),
|
||||
('h', '\u{30c}', 'ȟ'),
|
||||
('h', '\u{327}', 'ḩ'),
|
||||
('i', '\u{300}', 'ì'),
|
||||
('i', '\u{301}', 'í'),
|
||||
('i', '\u{302}', 'î'),
|
||||
('i', '\u{303}', 'ĩ'),
|
||||
('i', '\u{304}', 'ī'),
|
||||
('i', '\u{306}', 'ĭ'),
|
||||
('i', '\u{308}', 'ï'),
|
||||
('i', '\u{30c}', 'ǐ'),
|
||||
('i', '\u{328}', 'į'),
|
||||
('j', '\u{302}', 'ĵ'),
|
||||
('j', '\u{30c}', 'ǰ'),
|
||||
('k', '\u{301}', 'ḱ'),
|
||||
('k', '\u{30c}', 'ǩ'),
|
||||
('k', '\u{327}', 'ķ'),
|
||||
('l', '\u{301}', 'ĺ'),
|
||||
('l', '\u{30c}', 'ľ'),
|
||||
('l', '\u{327}', 'ļ'),
|
||||
('m', '\u{301}', 'ḿ'),
|
||||
('m', '\u{307}', 'ṁ'),
|
||||
('n', '\u{300}', 'ǹ'),
|
||||
('n', '\u{301}', 'ń'),
|
||||
('n', '\u{303}', 'ñ'),
|
||||
('n', '\u{307}', 'ṅ'),
|
||||
('n', '\u{30c}', 'ň'),
|
||||
('n', '\u{327}', 'ņ'),
|
||||
('o', '\u{300}', 'ò'),
|
||||
('o', '\u{301}', 'ó'),
|
||||
('o', '\u{302}', 'ô'),
|
||||
('o', '\u{303}', 'õ'),
|
||||
('o', '\u{304}', 'ō'),
|
||||
('o', '\u{306}', 'ŏ'),
|
||||
('o', '\u{307}', 'ȯ'),
|
||||
('o', '\u{308}', 'ö'),
|
||||
('o', '\u{30b}', 'ő'),
|
||||
('o', '\u{30c}', 'ǒ'),
|
||||
('o', '\u{328}', 'ǫ'),
|
||||
('p', '\u{301}', 'ṕ'),
|
||||
('p', '\u{307}', 'ṗ'),
|
||||
('r', '\u{301}', 'ŕ'),
|
||||
('r', '\u{307}', 'ṙ'),
|
||||
('r', '\u{30c}', 'ř'),
|
||||
('r', '\u{327}', 'ŗ'),
|
||||
('s', '\u{301}', 'ś'),
|
||||
('s', '\u{302}', 'ŝ'),
|
||||
('s', '\u{307}', 'ṡ'),
|
||||
('s', '\u{30c}', 'š'),
|
||||
('s', '\u{326}', 'ș'),
|
||||
('s', '\u{327}', 'ş'),
|
||||
('t', '\u{307}', 'ṫ'),
|
||||
('t', '\u{308}', 'ẗ'),
|
||||
('t', '\u{30c}', 'ť'),
|
||||
('t', '\u{326}', 'ț'),
|
||||
('t', '\u{327}', 'ţ'),
|
||||
('u', '\u{300}', 'ù'),
|
||||
('u', '\u{301}', 'ú'),
|
||||
('u', '\u{302}', 'û'),
|
||||
('u', '\u{303}', 'ũ'),
|
||||
('u', '\u{304}', 'ū'),
|
||||
('u', '\u{306}', 'ŭ'),
|
||||
('u', '\u{308}', 'ü'),
|
||||
('u', '\u{30a}', 'ů'),
|
||||
('u', '\u{30b}', 'ű'),
|
||||
('u', '\u{30c}', 'ǔ'),
|
||||
('u', '\u{328}', 'ų'),
|
||||
('v', '\u{303}', 'ṽ'),
|
||||
('w', '\u{300}', 'ẁ'),
|
||||
('w', '\u{301}', 'ẃ'),
|
||||
('w', '\u{302}', 'ŵ'),
|
||||
('w', '\u{307}', 'ẇ'),
|
||||
('w', '\u{308}', 'ẅ'),
|
||||
('w', '\u{30a}', 'ẘ'),
|
||||
('x', '\u{307}', 'ẋ'),
|
||||
('x', '\u{308}', 'ẍ'),
|
||||
('y', '\u{300}', 'ỳ'),
|
||||
('y', '\u{301}', 'ý'),
|
||||
('y', '\u{302}', 'ŷ'),
|
||||
('y', '\u{303}', 'ỹ'),
|
||||
('y', '\u{304}', 'ȳ'),
|
||||
('y', '\u{307}', 'ẏ'),
|
||||
('y', '\u{308}', 'ÿ'),
|
||||
('y', '\u{30a}', 'ẙ'),
|
||||
('z', '\u{301}', 'ź'),
|
||||
('z', '\u{302}', 'ẑ'),
|
||||
('z', '\u{307}', 'ż'),
|
||||
('z', '\u{30c}', 'ž'),
|
||||
('е', '\u{308}', 'ё'),
|
||||
('и', '\u{306}', 'й'),
|
||||
];
|
||||
|
||||
/// `s` lowercased, with each letter + combining mark that has a single-letter form composed.
|
||||
fn lower_composed(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.to_lowercase().chars() {
|
||||
if let Some(prev) = out.chars().next_back()
|
||||
&& let Some((_, _, composed)) = COMPOSE.iter().find(|(b, m, _)| *b == prev && *m == c)
|
||||
{
|
||||
out.pop();
|
||||
out.push(*composed);
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn weight(c: char, rank: u32) -> u32 {
|
||||
((c as u32) << 4).saturating_add(rank)
|
||||
}
|
||||
|
||||
/// One collation element: primary weight, secondary weight, the text it covers.
|
||||
struct Elem<'a> {
|
||||
primary: Vec<u32>,
|
||||
sec: u16,
|
||||
text: &'a str,
|
||||
/// Set when the text is a letter of its own in the language (shown as is in headings).
|
||||
own: bool,
|
||||
}
|
||||
|
||||
/// The next collation element of `rest` (lowercase), and its length in bytes.
|
||||
fn next_elem<'a>(rest: &'a str, rules: &[Tailor]) -> Option<(Elem<'a>, usize)> {
|
||||
if let Some(r) = rules.iter().filter(|r| rest.starts_with(r.seq)).max_by_key(|r| r.seq.len()) {
|
||||
let text = rest.get(..r.seq.len())?;
|
||||
return Some((Elem { primary: vec![weight(r.after, r.rank)], sec: r.sec, text, own: true }, r.seq.len()));
|
||||
}
|
||||
let c = rest.chars().next()?;
|
||||
let n = c.len_utf8();
|
||||
let text = rest.get(..n)?;
|
||||
if let Some((_, base, mark)) = DECOMP.iter().find(|(x, _, _)| *x == c) {
|
||||
return Some((Elem { primary: base.chars().map(|b| weight(b, 0)).collect(), sec: *mark, text, own: false }, n));
|
||||
}
|
||||
// A combining mark adds a secondary difference to the letter before it.
|
||||
if ('\u{300}'..='\u{36f}').contains(&c) {
|
||||
return Some((Elem { primary: Vec::new(), sec: 32 + (c as u32 - 0x300) as u16, text, own: false }, n));
|
||||
}
|
||||
Some((Elem { primary: vec![weight(c, 0)], sec: 0, text, own: false }, n))
|
||||
}
|
||||
|
||||
/// A sort key: compare keys to sort strings in a language's order. Equal keys mean the strings
|
||||
/// differ at most in letter case.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
|
||||
pub struct SortKey {
|
||||
primary: Vec<u32>,
|
||||
secondary: Vec<u16>,
|
||||
text: String,
|
||||
}
|
||||
|
||||
/// The sort key of `s` for language `lang` (a BCP 47 tag such as `pl-PL`; `None` sorts like
|
||||
/// English).
|
||||
pub fn sort_key(s: &str, lang: Option<&str>) -> SortKey {
|
||||
let text = lower_composed(s);
|
||||
let rules = tailoring(lang);
|
||||
let mut key = SortKey::default();
|
||||
let mut i = 0;
|
||||
while let Some(rest) = text.get(i..).filter(|r| !r.is_empty()) {
|
||||
let Some((e, n)) = next_elem(rest, rules) else { break };
|
||||
i += n.max(1);
|
||||
if e.primary.is_empty() {
|
||||
if let Some(last) = key.secondary.last_mut() {
|
||||
*last = last.saturating_add(e.sec);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (k, w) in e.primary.iter().enumerate() {
|
||||
key.primary.push(*w);
|
||||
key.secondary.push(if k == 0 { e.sec } else { 0 });
|
||||
}
|
||||
}
|
||||
key.text = text;
|
||||
key
|
||||
}
|
||||
|
||||
/// Compare two strings in the order of language `lang`, ignoring case.
|
||||
pub fn collate(a: &str, b: &str, lang: Option<&str>) -> Ordering {
|
||||
sort_key(a, lang).cmp(&sort_key(b, lang))
|
||||
}
|
||||
|
||||
/// The index heading letter `s` files under in language `lang`: `Ł` for `Łódź` in Polish, `L`
|
||||
/// in English; `Ch` for `chata` in Czech; `A` for `Äpfel` in German, `Ä` in Swedish.
|
||||
pub fn heading_letter(s: &str, lang: Option<&str>) -> Option<String> {
|
||||
let low = lower_composed(s.trim_start());
|
||||
let (e, _) = next_elem(&low, tailoring(lang))?;
|
||||
let shown = if e.own || e.sec == 0 {
|
||||
e.text.to_string()
|
||||
} else {
|
||||
DECOMP.iter().find(|(x, _, _)| e.text.starts_with(*x)).map(|(_, b, _)| b.chars().take(1).collect()).unwrap_or_else(|| e.text.to_string())
|
||||
};
|
||||
let mut cs = shown.chars();
|
||||
let first = cs.next()?;
|
||||
Some(first.to_uppercase().chain(cs).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sorted(words: &[&str], lang: &str) -> Vec<String> {
|
||||
let mut v: Vec<String> = words.iter().map(|w| w.to_string()).collect();
|
||||
v.sort_by(|a, b| collate(a, b, Some(lang)));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polish_letters_follow_their_base_letter() {
|
||||
assert_eq!(sorted(&["Zakopane", "Łódź", "Kraków"], "pl-PL"), ["Kraków", "Łódź", "Zakopane"]);
|
||||
assert_eq!(sorted(&["zebra", "ćma", "dom"], "pl-PL"), ["ćma", "dom", "zebra"]);
|
||||
assert_eq!(sorted(&["żaba", "źle", "zebra", "ząb", "lato", "łapa", "mama"], "pl"), ["lato", "łapa", "mama", "ząb", "zebra", "źle", "żaba"]);
|
||||
assert_eq!(sorted(&["b", "ą", "a"], "pl"), ["a", "ą", "b"]);
|
||||
// Every Polish letter is a letter of its own, after its base letter (review of #844).
|
||||
assert_eq!(sorted(&["Malbork", "Łódź", "Lublin"], "pl"), ["Lublin", "Łódź", "Malbork"]);
|
||||
assert_eq!(sorted(&["ósmy", "owca", "pies"], "pl"), ["owca", "ósmy", "pies"]);
|
||||
assert_eq!(sorted(&["ściana", "sum", "tak"], "pl"), ["sum", "ściana", "tak"]);
|
||||
assert_eq!(sorted(&["ęsi", "ezop", "fala"], "pl"), ["ezop", "ęsi", "fala"]);
|
||||
assert_eq!(sorted(&["ńa", "nz", "oko"], "pl"), ["nz", "ńa", "oko"]);
|
||||
assert_eq!(heading_letter("ósmy", Some("pl")).as_deref(), Some("Ó"));
|
||||
assert_eq!(heading_letter("ńa", Some("pl")).as_deref(), Some("Ń"));
|
||||
// Decomposed letters (`z` + combining dot above, as macOS file names write `ż`) sort like
|
||||
// the composed ones.
|
||||
assert_eq!(collate("z\u{307}aba", "żaba", Some("pl-PL")), Ordering::Equal);
|
||||
assert_eq!(collate("z\u{307}aba", "zebra", Some("pl-PL")), Ordering::Greater);
|
||||
assert_eq!(heading_letter("Z\u{307}aba", Some("pl")).as_deref(), Some("Ż"));
|
||||
assert_eq!(collate("a\u{328}", "ą", Some("pl")), Ordering::Equal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swedish_puts_a_ring_and_umlauts_after_z() {
|
||||
assert_eq!(sorted(&["ödla", "zebra", "äpple", "åka", "apa"], "sv-SE"), ["apa", "zebra", "åka", "äpple", "ödla"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn german_sorts_umlauts_with_their_base_letter() {
|
||||
assert_eq!(sorted(&["Zug", "Äpfel", "Apfel", "Bach"], "de-DE"), ["Apfel", "Äpfel", "Bach", "Zug"]);
|
||||
assert_eq!(sorted(&["Straße", "Strasse", "Strand"], "de"), ["Strand", "Strasse", "Straße"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_yo_sorts_with_e_instead_of_after_ya() {
|
||||
for lang in ["ru", "ru-RU", "RU_ru"] {
|
||||
assert_eq!(sorted(&["яблоко", "ёлка", "жук", "ель", "ёж", "еж"], lang), ["еж", "ёж", "ёлка", "ель", "жук", "яблоко"]);
|
||||
assert_eq!(collate("ЁЛКА", "ёлка", Some(lang)), Ordering::Equal);
|
||||
assert_eq!(heading_letter("Ёлка", Some(lang)).as_deref(), Some("Ё"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn russian_decomposed_letters_match_their_composed_forms() {
|
||||
for (composed, decomposed) in [("ёлка", "е\u{308}лка"), ("Ёлка", "Е\u{308}лка"), ("йод", "и\u{306}од"), ("Йод", "И\u{306}од")]
|
||||
{
|
||||
assert_eq!(collate(composed, decomposed, Some("ru-RU")), Ordering::Equal);
|
||||
assert_eq!(heading_letter(composed, Some("ru")), heading_letter(decomposed, Some("ru")));
|
||||
}
|
||||
assert_eq!(sorted(&["к", "и\u{306}", "и"], "ru"), ["и", "и\u{306}", "к"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spanish_n_tilde_after_n() {
|
||||
assert_eq!(sorted(&["oso", "ñu", "nube"], "es-ES"), ["nube", "ñu", "oso"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn czech_slovak_hungarian_alphabets() {
|
||||
assert_eq!(sorted(&["chata", "hrad", "ihla", "cesta", "čaj", "dům"], "cs-CZ"), ["cesta", "čaj", "dům", "hrad", "chata", "ihla"]);
|
||||
assert_eq!(sorted(&["ôsmy", "oko", "pes", "ä", "b"], "sk"), ["ä", "b", "oko", "ôsmy", "pes"]);
|
||||
assert_eq!(sorted(&["csak", "cukor", "dob", "szó", "sűrű", "tál"], "hu-HU"), ["cukor", "csak", "dob", "sűrű", "szó", "tál"]);
|
||||
// ö and ő are one letter after o, told apart by the accent only.
|
||||
assert_eq!(sorted(&["ötös", "őr", "pálya", "olló", "ör"], "hu"), ["olló", "ör", "őr", "ötös", "pálya"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_keeps_case_insensitive_order_and_folds_accents() {
|
||||
assert_eq!(sorted(&["rex", "résumé", "resume", "Apple", "banana"], "en-US"), ["Apple", "banana", "resume", "résumé", "rex"]);
|
||||
assert_eq!(collate("Łódź", "Lodz", None), Ordering::Greater);
|
||||
assert_eq!(collate("Łódź", "Lodzia", None), Ordering::Less);
|
||||
assert_eq!(collate("ABC", "abc", None), Ordering::Equal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_letters() {
|
||||
assert_eq!(heading_letter("Łódź", Some("pl-PL")).as_deref(), Some("Ł"));
|
||||
assert_eq!(heading_letter("Łódź", Some("en-US")).as_deref(), Some("L"));
|
||||
assert_eq!(heading_letter("chata", Some("cs")).as_deref(), Some("Ch"));
|
||||
assert_eq!(heading_letter("Äpfel", Some("de")).as_deref(), Some("A"));
|
||||
assert_eq!(heading_letter("Äpple", Some("sv")).as_deref(), Some("Ä"));
|
||||
assert_eq!(heading_letter("", Some("sv")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_never_panics() {
|
||||
for s in ["", "\u{301}", "a\u{301}\u{301}\u{301}", "dzs", "\u{10FFFF}", "ẞ", "İstanbul"] {
|
||||
for l in [None, Some("hu"), Some(""), Some("-"), Some("tr-TR")] {
|
||||
let _ = sort_key(s, l);
|
||||
let _ = heading_letter(s, l);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,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))
|
||||
}
|
||||
+13
-493
@@ -1,5 +1,5 @@
|
||||
//! Connectors and the shape properties beyond fill and outline: a shape's drawing id, its text
|
||||
//! warp, a rounded rectangle's corner, and a connector's ends.
|
||||
//! warp, and a connector's ends.
|
||||
//!
|
||||
//! A connector (DrawingML `straightConnector1`, `bentConnector3`, `curvedConnector3`) is a line
|
||||
//! from its frame's top-left to its bottom-right corner (flips swap the corners). Each end may be
|
||||
@@ -10,169 +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
|
||||
}
|
||||
}
|
||||
|
||||
/// An arrowhead's width across its line or length along it (ST_LineEndWidth and
|
||||
/// ST_LineEndLength, ECMA-376 §20.1.10.39–40): 2, 3 or 5 times the line's width, as Word draws it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ArrowSize {
|
||||
Small,
|
||||
#[default]
|
||||
Medium,
|
||||
Large,
|
||||
}
|
||||
|
||||
impl ArrowSize {
|
||||
/// Read a DrawingML size (`sm`, `med`, `lg`); absent or unknown values are medium.
|
||||
pub fn from_ooxml(value: &str) -> Self {
|
||||
match value {
|
||||
"sm" => Self::Small,
|
||||
"lg" => Self::Large,
|
||||
_ => Self::Medium,
|
||||
}
|
||||
}
|
||||
|
||||
/// The DrawingML spelling.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Self::Small => "sm",
|
||||
Self::Medium => "med",
|
||||
Self::Large => "lg",
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a VML width (`narrow`, `medium`, `wide`) or length (`short`, `medium`, `long`).
|
||||
pub fn from_vml(value: &str) -> Self {
|
||||
match value {
|
||||
"narrow" | "short" => Self::Small,
|
||||
"wide" | "long" => Self::Large,
|
||||
_ => Self::Medium,
|
||||
}
|
||||
}
|
||||
|
||||
/// How many [`arrow_unit`]s the arrowhead measures.
|
||||
pub fn factor(self) -> f32 {
|
||||
match self {
|
||||
Self::Small => 2.0,
|
||||
Self::Medium => 3.0,
|
||||
Self::Large => 5.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of an arrowhead (ST_LineEndType, ECMA-376 §20.1.10.33), kept for saving; every kind
|
||||
/// is drawn as a triangle.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ArrowKind {
|
||||
#[default]
|
||||
Triangle,
|
||||
Stealth,
|
||||
Diamond,
|
||||
Oval,
|
||||
Arrow,
|
||||
}
|
||||
|
||||
impl ArrowKind {
|
||||
/// Read a DrawingML end type other than `none`; unknown values are triangles.
|
||||
pub fn from_ooxml(value: &str) -> Self {
|
||||
match value {
|
||||
"stealth" => Self::Stealth,
|
||||
"diamond" => Self::Diamond,
|
||||
"oval" => Self::Oval,
|
||||
"arrow" => Self::Arrow,
|
||||
_ => Self::Triangle,
|
||||
}
|
||||
}
|
||||
|
||||
/// The DrawingML spelling.
|
||||
pub fn ooxml(self) -> &'static str {
|
||||
match self {
|
||||
Self::Triangle => "triangle",
|
||||
Self::Stealth => "stealth",
|
||||
Self::Diamond => "diamond",
|
||||
Self::Oval => "oval",
|
||||
Self::Arrow => "arrow",
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a VML arrow (`block`, `classic`, `diamond`, `oval`, `open`) other than `none`.
|
||||
pub fn from_vml(value: &str) -> Self {
|
||||
match value {
|
||||
"classic" => Self::Stealth,
|
||||
"diamond" => Self::Diamond,
|
||||
"oval" => Self::Oval,
|
||||
"open" => Self::Arrow,
|
||||
_ => Self::Triangle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An arrowhead's kind and size (`a:headEnd` / `a:tailEnd`). The default is a medium triangle.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ArrowStyle {
|
||||
pub kind: ArrowKind,
|
||||
pub width: ArrowSize,
|
||||
pub length: ArrowSize,
|
||||
}
|
||||
|
||||
impl ArrowStyle {
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
|
||||
/// The arrowhead's length along its line and width across it, for a line `line_width` wide.
|
||||
pub fn dims(self, line_width: f32) -> (f32, f32) {
|
||||
let unit = arrow_unit(line_width);
|
||||
(self.length.factor() * unit, self.width.factor() * unit)
|
||||
}
|
||||
}
|
||||
|
||||
/// What an arrowhead's size is a multiple of: the line's width, but at least 2 points (Word draws
|
||||
/// a thinner line's arrowheads as for a 2-point line).
|
||||
pub fn arrow_unit(line_width: f32) -> f32 {
|
||||
let w = if line_width.is_finite() { line_width.clamp(0.25, 200.0) } else { 0.75 };
|
||||
w.max(2.0)
|
||||
}
|
||||
|
||||
/// 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")]
|
||||
@@ -194,58 +34,6 @@ pub struct ShapeExtra {
|
||||
pub arrow_start: bool,
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub arrow_end: bool,
|
||||
/// The kind and size of the start and end arrowheads, when there are any.
|
||||
#[serde(skip_serializing_if = "ArrowStyle::is_default")]
|
||||
pub start_style: ArrowStyle,
|
||||
#[serde(skip_serializing_if = "ArrowStyle::is_default")]
|
||||
pub end_style: ArrowStyle,
|
||||
/// A rounded rectangle's corner radius (`roundRect`'s `adj`, ECMA-376 §20.1.10.56): in
|
||||
/// 100000ths of the shorter side, at most [`MAX_CORNER`]; `None` draws [`DEFAULT_CORNER`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub corner: Option<u32>,
|
||||
/// 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")]
|
||||
pub outline_fill: Option<crate::fill::Fill>,
|
||||
/// A horizontal rule (VML `o:hr`: a web page's `<hr>` pasted into Word): it takes its whole
|
||||
/// line, and is drawn across [`HorizontalRule::pct`] of it or at the shape's own width.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hr: Option<HorizontalRule>,
|
||||
}
|
||||
|
||||
/// How a horizontal rule sits on its line (VML `o:hrpct`, `o:hralign`), as Word draws one:
|
||||
/// across `pct` percent of the line's width (the paragraph's, inside its indents), or at the
|
||||
/// shape's own width when `pct` is 0; `align` places it when it is narrower than the line.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct HorizontalRule {
|
||||
pub pct: f32,
|
||||
pub align: crate::props::Align,
|
||||
/// Word's standard look (`o:hrstd`), kept for saving.
|
||||
pub standard: bool,
|
||||
}
|
||||
|
||||
impl HorizontalRule {
|
||||
/// The rule's width on a line `line` wide, for a shape `own` wide.
|
||||
pub fn width(&self, line: f32, own: f32) -> f32 {
|
||||
let line = line.max(0.0);
|
||||
if self.pct.is_finite() && self.pct > 0.0 { line * (self.pct.min(100.0) / 100.0) } else { own.clamp(0.0, line) }
|
||||
}
|
||||
|
||||
/// Where a rule `w` wide starts on a line from `x`, `line` wide.
|
||||
pub fn left(&self, x: f32, line: f32, w: f32) -> f32 {
|
||||
match self.align {
|
||||
crate::props::Align::Center => x + (line - w) / 2.0,
|
||||
crate::props::Align::Right => x + line - w,
|
||||
_ => x,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_zero(v: &u32) -> bool {
|
||||
@@ -261,19 +49,6 @@ impl ShapeExtra {
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest rounded-rectangle corner: half the shorter side, which makes its ends round.
|
||||
pub const MAX_CORNER: u32 = 50_000;
|
||||
|
||||
/// A rounded rectangle's corner radius without its own: 16% of the shorter side.
|
||||
pub const DEFAULT_CORNER: f32 = 0.16;
|
||||
|
||||
/// The corner radius, in points, of a `w` × `h` rounded rectangle with `corner` (see
|
||||
/// [`ShapeExtra::corner`]).
|
||||
pub fn corner_radius(corner: Option<u32>, w: f32, h: f32) -> f32 {
|
||||
let side = wordcraft_geom::finite(w).min(wordcraft_geom::finite(h)).max(0.0);
|
||||
side * corner.map_or(DEFAULT_CORNER, |c| c.min(MAX_CORNER) as f32 / 100_000.0)
|
||||
}
|
||||
|
||||
/// One glued end of a connector: the drawing id of the shape and its connection site index.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -308,8 +83,6 @@ pub fn sites(kind: ShapeKind, w: f32, h: f32) -> Vec<(f32, f32)> {
|
||||
vec![(cx, 0.0), (cx - dx, cy - dy), (0.0, cy), (cx - dx, cy + dy), (cx, h), (cx + dx, cy + dy), (w, cy), (cx + dx, cy - dy)]
|
||||
}
|
||||
ShapeKind::Triangle => vec![(cx, 0.0), (cx / 2.0, cy), (0.0, h), (cx, h), (w, h), (cx + cx / 2.0, cy)],
|
||||
// The corners, the legs' midpoints and the hypotenuse's.
|
||||
ShapeKind::RightTriangle => vec![(0.0, 0.0), (0.0, cy), (0.0, h), (cx, h), (w, h), (cx, cy)],
|
||||
ShapeKind::Line | ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => vec![(0.0, 0.0), (w, h)],
|
||||
_ => vec![(cx, 0.0), (0.0, cy), (cx, h), (w, cy)],
|
||||
}
|
||||
@@ -392,16 +165,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 {
|
||||
@@ -423,35 +189,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;
|
||||
@@ -471,19 +211,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 {
|
||||
@@ -501,10 +229,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))] {
|
||||
@@ -513,187 +237,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_radius_follows_the_shorter_side() {
|
||||
let near = |a: f32, b: f32| (a - b).abs() < 1e-4;
|
||||
assert!(near(corner_radius(None, 200.0, 100.0), 16.0));
|
||||
assert!(near(corner_radius(Some(0), 200.0, 100.0), 0.0));
|
||||
assert!(near(corner_radius(Some(3_565), 100.0, 200.0), 3.565));
|
||||
assert!(near(corner_radius(Some(MAX_CORNER), 200.0, 100.0), 50.0));
|
||||
assert!(near(corner_radius(Some(u32::MAX), 200.0, 100.0), 50.0), "capped at a half-circle end");
|
||||
assert!(near(corner_radius(Some(10_000), f32::NAN, 100.0), 0.0));
|
||||
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() };
|
||||
@@ -704,40 +247,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
-80
@@ -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;
|
||||
@@ -244,10 +242,6 @@ pub struct ContentControl {
|
||||
/// The run properties of `w:sdtPr` (`w:rPr`), kept verbatim (XML).
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub rpr_xml: String,
|
||||
/// The same run properties, read: the formatting text typed in place of the placeholder
|
||||
/// takes (ECMA-376 §17.5.2.27). `None` when the control names none.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub run_props: Option<crate::props::CharProps>,
|
||||
/// Other `w:sdtPr` children we don't model, kept verbatim (XML) in file order.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub extra: Vec<String>,
|
||||
@@ -314,10 +308,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 +416,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() {
|
||||
@@ -509,12 +490,6 @@ impl Document {
|
||||
out
|
||||
}
|
||||
|
||||
/// How many content controls allow form filling (`w:lock` content editing) across every
|
||||
/// story; 0 means a `w:edit="forms"` document has nothing left editable.
|
||||
pub fn fillable_controls(&self) -> usize {
|
||||
self.control_stories().into_iter().map(|story| self.control_ranges(story).iter().filter(|r| r.control.lock.can_edit()).count()).sum()
|
||||
}
|
||||
|
||||
/// The innermost content control holding `p`.
|
||||
pub fn control_at(&self, p: &Pos) -> Option<ControlRange> {
|
||||
let para = self.para_at(p)?;
|
||||
@@ -654,52 +629,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 +664,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 +678,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,865 +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 ([`crate::count_words`], plus 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
|
||||
}
|
||||
}
|
||||
+270
-903
File diff suppressed because it is too large
Load Diff
@@ -1,911 +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_formatting_they_copy() {
|
||||
// A story holding a text box and a run whose font name is 1 MiB: each copy clones the
|
||||
// formatting too, so 64 copies used to copy 64 MiB of it past the 32 MiB cap.
|
||||
let props = CharProps { font: Some("f".repeat(1 << 20)), ..CharProps::default() };
|
||||
let mut p = Paragraph::with_text("x", props);
|
||||
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();
|
||||
d.insert_fragment(&Pos::body(0, 1), &f).unwrap();
|
||||
let added = d.parts.len() - before;
|
||||
let font = |p: &Paragraph| p.runs.iter().map(|r| r.props.font.as_ref().map_or(0, String::len)).sum::<usize>();
|
||||
let copied: usize = d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { font(p) } else { 0 }).sum();
|
||||
assert!((1..64).contains(&added), "{added}");
|
||||
assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
#[test]
|
||||
fn adopting_a_document_renumbers_what_it_refers_to() {
|
||||
use crate::para::NoteKind;
|
||||
use crate::{Comment, Revision, RevisionKind};
|
||||
// Ours already has a footnote, a comment, a list and a revision with the ids the other's use.
|
||||
let mut ours = doc(&["ours"]);
|
||||
let our_note = ours.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("our note", CharProps::default()))]);
|
||||
ours.comments.insert(0, Comment { part: our_note, ..Comment::default() });
|
||||
let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
ours.revisions.push(Revision { kind: RevisionKind::Insert, ..Revision::default() });
|
||||
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
let note = theirs.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("their note", CharProps::default()))]);
|
||||
let said = theirs.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("their comment", CharProps::default()))]);
|
||||
theirs.comments.insert(0, Comment { author: "Them".into(), part: said, durable_id: Some("0A0B0C0D".into()), ..Comment::default() });
|
||||
let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
assert_eq!(list, our_list);
|
||||
theirs.revisions.push(Revision { kind: RevisionKind::Delete, ..Revision::default() });
|
||||
let mut style = crate::styles::Style { id: "TheirList".into(), name: "Their List".into(), ..Default::default() };
|
||||
style.para.numbering = Some(crate::props::NumRef { num: list, level: 0 });
|
||||
theirs.styles.upsert(style);
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.props.numbering = Some(crate::props::NumRef { num: list, level: 0 });
|
||||
p.runs[0].props.del = Some(0);
|
||||
// An insertion naming a revision it doesn't have.
|
||||
p.runs[0].props.ins = Some(7);
|
||||
let props = p.props_of_char(0).clone();
|
||||
p.insert_object(0, InlineObject::NoteRef { kind: NoteKind::Footnote, id: note, custom: String::new() }, &props).unwrap();
|
||||
p.insert_object(0, InlineObject::CommentStart { id: 0 }, &props).unwrap();
|
||||
|
||||
let frag = ours.adopt(theirs);
|
||||
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
|
||||
let new_list = p.props.numbering.unwrap().num;
|
||||
assert_ne!(new_list, our_list);
|
||||
assert!(ours.numbering.num(new_list).is_some());
|
||||
assert_eq!(ours.styles.get("TheirList").and_then(|s| s.para.numbering).map(|n| n.num), Some(new_list));
|
||||
// Their deletion is their revision, after ours.
|
||||
let del = p.runs.iter().find_map(|r| r.props.del).unwrap();
|
||||
assert_eq!(ours.revisions.get(del as usize).map(|r| r.kind), Some(RevisionKind::Delete));
|
||||
assert!(p.runs.iter().all(|r| r.props.ins.is_none()));
|
||||
// Their note and comment come along under ids of their own.
|
||||
let (note_id, comment_id) = p.objects.iter().fold((None, None), |(n, c), o| match o {
|
||||
InlineObject::NoteRef { id, .. } => (Some(*id), c),
|
||||
InlineObject::CommentStart { id } => (n, Some(*id)),
|
||||
_ => (n, c),
|
||||
});
|
||||
let text = |id: u32| {
|
||||
ours.parts
|
||||
.get(&id)
|
||||
.map(|p| p.blocks.iter().map(|b| if let Block::Para(p) = &**b { p.text.clone() } else { String::new() }).collect::<String>())
|
||||
};
|
||||
assert_eq!(text(note_id.unwrap()).as_deref(), Some("their note"));
|
||||
let comment = ours.comments.get(&comment_id.unwrap()).unwrap();
|
||||
assert_eq!((comment.author.as_str(), text(comment.part).as_deref()), ("Them", Some("their comment")));
|
||||
// Its durable id is the other document's: the writer gives the copy one of its own.
|
||||
assert_eq!(comment.durable_id, None);
|
||||
assert_eq!(text(our_note).as_deref(), Some("our note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kept_character_style_leaves_what_it_doesnt_set_to_the_paragraph_style() {
|
||||
use crate::styles::{Style, StyleKind, StyleSheet};
|
||||
let mut ours = doc(&["ours"]);
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.styles = StyleSheet::empty();
|
||||
theirs.styles.default_chr = CharProps { size: Some(9.0), ..Default::default() };
|
||||
let big = CharProps { size: Some(20.0), ..Default::default() };
|
||||
theirs.styles.upsert(Style { id: "Big".into(), name: "Big".into(), chr: big, ..Default::default() });
|
||||
theirs.styles.upsert(Style { id: "Link".into(), name: "Link".into(), kind: StyleKind::Character, ..Default::default() });
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.props.style = Some("Big".into());
|
||||
p.runs[0].props.style = Some("Link".into());
|
||||
|
||||
let frag = ours.adopt_with(theirs, true);
|
||||
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
|
||||
assert_eq!(ours.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props).size, 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_styles_renamed_on_adoption_are_renamed_in_list_levels_too() {
|
||||
use crate::styles::Style;
|
||||
let mut ours = doc(&["ours"]);
|
||||
ours.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
|
||||
let list = theirs.numbering.add_list(crate::numbering::ListKind::Numbered);
|
||||
let abs = theirs.numbering.nums.iter().find(|n| n.id == list).map(|n| n.abstract_id).unwrap();
|
||||
let a = theirs.numbering.abstracts.iter_mut().find(|a| a.id == abs).unwrap();
|
||||
a.levels[0].style = Some("Heading1".into());
|
||||
let level = a.levels[0].clone();
|
||||
theirs.numbering.nums.iter_mut().find(|n| n.id == list).unwrap().level_overrides.push((1, level));
|
||||
|
||||
let before = ours.numbering.abstracts.len();
|
||||
ours.adopt_with(theirs, true);
|
||||
let copied = &ours.numbering.abstracts[before..];
|
||||
let styles: Vec<_> = copied
|
||||
.iter()
|
||||
.flat_map(|a| a.levels.iter())
|
||||
.chain(ours.numbering.nums.iter().flat_map(|n| n.level_overrides.iter().map(|(_, l)| l)))
|
||||
.filter_map(|l| l.style.clone())
|
||||
.collect();
|
||||
assert_eq!(styles.len(), 2, "{styles:?}");
|
||||
for st in &styles {
|
||||
assert_ne!(st, "Heading1");
|
||||
assert!(ours.styles.get(st).is_some(), "{st}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adopted_section_breaks_tracked_change_names_its_revision() {
|
||||
use crate::{Revision, RevisionKind, SectionProps};
|
||||
let mut ours = doc(&["ours"]);
|
||||
ours.revisions.push(Revision { kind: RevisionKind::Insert, ..Revision::default() });
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() });
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.section = Some(Box::new(SectionProps { fmt_change: crate::props::PropChange::boxed(0, SectionProps::default()), ..SectionProps::default() }));
|
||||
|
||||
let frag = ours.adopt(theirs);
|
||||
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
|
||||
let rev = p.section.as_ref().and_then(|s| s.fmt_change.as_ref()).map(|c| c.rev);
|
||||
assert_eq!(rev.and_then(|r| ours.revisions.get(r as usize)).map(|r| r.kind), Some(RevisionKind::Format));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adopted_tracked_format_change_keeps_its_old_styles_and_list() {
|
||||
use crate::props::{NumRef, ParaProps, PropChange};
|
||||
use crate::{Revision, RevisionKind};
|
||||
for keep in [false, true] {
|
||||
let mut ours = doc(&["ours"]);
|
||||
let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() });
|
||||
let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
assert_eq!(list, our_list);
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
// Live and old props name the same styles (ours has them too) and their list.
|
||||
p.props.style = Some("Quote".into());
|
||||
p.props.numbering = Some(NumRef { num: list, level: 0 });
|
||||
let old = ParaProps { style: Some("Quote".into()), numbering: Some(NumRef { num: list, level: 0 }), ..ParaProps::default() };
|
||||
p.props.fmt_change = PropChange::boxed(0, old);
|
||||
p.runs[0].props.style = Some("Strong".into());
|
||||
p.runs[0].props.fmt_change = PropChange::boxed(0, CharProps { style: Some("Strong".into()), ..CharProps::default() });
|
||||
|
||||
let frag = ours.adopt_with(theirs, keep);
|
||||
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
|
||||
let old = &p.props.fmt_change.as_ref().unwrap().old;
|
||||
assert_eq!((&old.style, old.numbering), (&p.props.style, p.props.numbering), "keep_styles {keep}");
|
||||
assert_ne!(old.numbering.map(|n| n.num), Some(our_list));
|
||||
let run = &p.runs[0].props;
|
||||
assert_eq!(run.fmt_change.as_ref().unwrap().old.style, run.style, "keep_styles {keep}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopted_styles_never_become_our_defaults() {
|
||||
use crate::styles::{Style, StyleKind};
|
||||
for keep in [false, true] {
|
||||
let mut ours = doc(&["ours"]);
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
// A localized default character style, and one whose id we have too.
|
||||
theirs.styles.upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() });
|
||||
theirs.styles.upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() });
|
||||
ours.adopt_with(theirs, keep);
|
||||
assert!(ours.styles.styles.iter().all(|s| !s.default), "keep_styles {keep}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopted_permission_ranges_keep_ids_of_their_own() {
|
||||
let perm = |id, editor: &str| InlineObject::PermStart { id, group: String::new(), editor: editor.into() };
|
||||
let mut ours = doc(&["ours"]);
|
||||
ours.insert_object(&Pos::body(0, 4), InlineObject::PermEnd { id: 0 }, &CharProps::default()).unwrap();
|
||||
ours.insert_object(&Pos::body(0, 0), perm(0, "us"), &CharProps::default()).unwrap();
|
||||
// Theirs: a closed range and one left open, numbered from 0 like ours.
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.insert_object(&Pos::body(0, 6), perm(1, "them"), &CharProps::default()).unwrap();
|
||||
theirs.insert_object(&Pos::body(0, 6), InlineObject::PermEnd { id: 0 }, &CharProps::default()).unwrap();
|
||||
theirs.insert_object(&Pos::body(0, 0), perm(0, "them"), &CharProps::default()).unwrap();
|
||||
|
||||
let frag = ours.adopt(theirs);
|
||||
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
|
||||
let ids: Vec<u32> = p
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::PermStart { id, .. } | InlineObject::PermEnd { id } => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(ids.len(), 3);
|
||||
assert!(!ids.contains(&0), "{ids:?}");
|
||||
assert_eq!(ids[0], ids[1], "a range's start and end still pair: {ids:?}");
|
||||
assert_ne!(ids[1], ids[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adopted_notes_text_box_gets_a_story_of_its_own() {
|
||||
use crate::para::NoteKind;
|
||||
// Ours has a text box under the id their note's text box has.
|
||||
let mut ours = doc(&["ours"]);
|
||||
ours.add_part(PartKind::Footnote, Vec::new());
|
||||
let our_box = text_box(&mut ours, &Pos::body(0, 0), "our box");
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
let note = theirs.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("note", CharProps::default()))]);
|
||||
let their_box = theirs.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("their box", CharProps::default()))]);
|
||||
assert_eq!(their_box, our_box);
|
||||
theirs
|
||||
.insert_object(&Pos { story: StoryRef::Part(note), path: Path::top(0), off: 0 }, box_shape(Some(their_box)), &CharProps::default())
|
||||
.unwrap();
|
||||
theirs
|
||||
.insert_object(&Pos::body(0, 0), InlineObject::NoteRef { kind: NoteKind::Footnote, id: note, custom: String::new() }, &CharProps::default())
|
||||
.unwrap();
|
||||
|
||||
let blocks = ours.adopt(theirs).blocks;
|
||||
let Some(Block::Para(p)) = blocks.first() else { panic!() };
|
||||
let new_note = p.objects.iter().find_map(|o| if let InlineObject::NoteRef { id, .. } = o { Some(*id) } else { None }).unwrap();
|
||||
let story = ours.parts[&new_note]
|
||||
.blocks
|
||||
.iter()
|
||||
.find_map(|b| if let Block::Para(p) = &**b { p.objects.iter().find_map(|o| o.text_boxes().first().copied()) } else { None })
|
||||
.unwrap();
|
||||
assert_ne!(story, our_box);
|
||||
let part = &ours.parts[&story];
|
||||
assert_eq!(part.kind, PartKind::TextBox);
|
||||
assert!(matches!(part.blocks.first().map(|b| &**b), Some(Block::Para(p)) if p.text == "their box"));
|
||||
}
|
||||
+1
-412
@@ -1,8 +1,4 @@
|
||||
//! Windows-1252 (the byte encoding legacy Word text pieces use) and the other single-byte Windows
|
||||
//! code pages RTF files name with `\ansicpg` or a font's `\fcharset` (Cyrillic 1251, Central
|
||||
//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai), Mac OS Roman
|
||||
//! (10000, RTF `\mac`), and the East Asian multibyte code pages (932 Shift-JIS, 936 GBK, 949 UHC,
|
||||
//! 950 Big5), whose bytes decode as runs rather than one at a time.
|
||||
//! Windows-1252 (the byte encoding legacy Word text pieces and RTF bytes use).
|
||||
|
||||
/// Windows-1252 code points for 0x80..=0x9F.
|
||||
const CP1252: [u16; 32] = [
|
||||
@@ -18,410 +14,3 @@ pub fn cp1252(b: u8) -> char {
|
||||
b as char
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows-874 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP874: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2026, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x0E01, 0x0E02, 0x0E03,
|
||||
0x0E04, 0x0E05, 0x0E06, 0x0E07, 0x0E08, 0x0E09, 0x0E0A, 0x0E0B, 0x0E0C, 0x0E0D, 0x0E0E, 0x0E0F, 0x0E10, 0x0E11, 0x0E12, 0x0E13, 0x0E14, 0x0E15,
|
||||
0x0E16, 0x0E17, 0x0E18, 0x0E19, 0x0E1A, 0x0E1B, 0x0E1C, 0x0E1D, 0x0E1E, 0x0E1F, 0x0E20, 0x0E21, 0x0E22, 0x0E23, 0x0E24, 0x0E25, 0x0E26, 0x0E27,
|
||||
0x0E28, 0x0E29, 0x0E2A, 0x0E2B, 0x0E2C, 0x0E2D, 0x0E2E, 0x0E2F, 0x0E30, 0x0E31, 0x0E32, 0x0E33, 0x0E34, 0x0E35, 0x0E36, 0x0E37, 0x0E38, 0x0E39,
|
||||
0x0E3A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x0E3F, 0x0E40, 0x0E41, 0x0E42, 0x0E43, 0x0E44, 0x0E45, 0x0E46, 0x0E47, 0x0E48, 0x0E49, 0x0E4A, 0x0E4B,
|
||||
0x0E4C, 0x0E4D, 0x0E4E, 0x0E4F, 0x0E50, 0x0E51, 0x0E52, 0x0E53, 0x0E54, 0x0E55, 0x0E56, 0x0E57, 0x0E58, 0x0E59, 0x0E5A, 0x0E5B, 0xFFFD, 0xFFFD,
|
||||
0xFFFD, 0xFFFD,
|
||||
];
|
||||
|
||||
/// Windows-1250 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1250: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0xFFFD, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0x0160, 0x2039, 0x015A, 0x0164, 0x017D, 0x0179, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0161, 0x203A, 0x015B, 0x0165, 0x017E, 0x017A, 0x00A0, 0x02C7, 0x02D8, 0x0141,
|
||||
0x00A4, 0x0104, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x015E, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x017B, 0x00B0, 0x00B1, 0x02DB, 0x0142, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x0105, 0x015F, 0x00BB, 0x013D, 0x02DD, 0x013E, 0x017C, 0x0154, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x0139, 0x0106, 0x00C7,
|
||||
0x010C, 0x00C9, 0x0118, 0x00CB, 0x011A, 0x00CD, 0x00CE, 0x010E, 0x0110, 0x0143, 0x0147, 0x00D3, 0x00D4, 0x0150, 0x00D6, 0x00D7, 0x0158, 0x016E,
|
||||
0x00DA, 0x0170, 0x00DC, 0x00DD, 0x0162, 0x00DF, 0x0155, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x013A, 0x0107, 0x00E7, 0x010D, 0x00E9, 0x0119, 0x00EB,
|
||||
0x011B, 0x00ED, 0x00EE, 0x010F, 0x0111, 0x0144, 0x0148, 0x00F3, 0x00F4, 0x0151, 0x00F6, 0x00F7, 0x0159, 0x016F, 0x00FA, 0x0171, 0x00FC, 0x00FD,
|
||||
0x0163, 0x02D9,
|
||||
];
|
||||
|
||||
/// Windows-1251 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1251: [u16; 128] = [
|
||||
0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021, 0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F, 0x0452, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F, 0x00A0, 0x040E, 0x045E, 0x0408,
|
||||
0x00A4, 0x0490, 0x00A6, 0x00A7, 0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407, 0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457, 0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417,
|
||||
0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F, 0x0420, 0x0421, 0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x0429,
|
||||
0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F, 0x0430, 0x0431, 0x0432, 0x0433, 0x0434, 0x0435, 0x0436, 0x0437, 0x0438, 0x0439, 0x043A, 0x043B,
|
||||
0x043C, 0x043D, 0x043E, 0x043F, 0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B, 0x044C, 0x044D,
|
||||
0x044E, 0x044F,
|
||||
];
|
||||
|
||||
/// Windows-1253 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1253: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x0385, 0x0386, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0xFFFD, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x2015, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x0384, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x0388, 0x0389, 0x038A, 0x00BB, 0x038C, 0x00BD, 0x038E, 0x038F, 0x0390, 0x0391, 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397,
|
||||
0x0398, 0x0399, 0x039A, 0x039B, 0x039C, 0x039D, 0x039E, 0x039F, 0x03A0, 0x03A1, 0xFFFD, 0x03A3, 0x03A4, 0x03A5, 0x03A6, 0x03A7, 0x03A8, 0x03A9,
|
||||
0x03AA, 0x03AB, 0x03AC, 0x03AD, 0x03AE, 0x03AF, 0x03B0, 0x03B1, 0x03B2, 0x03B3, 0x03B4, 0x03B5, 0x03B6, 0x03B7, 0x03B8, 0x03B9, 0x03BA, 0x03BB,
|
||||
0x03BC, 0x03BD, 0x03BE, 0x03BF, 0x03C0, 0x03C1, 0x03C2, 0x03C3, 0x03C4, 0x03C5, 0x03C6, 0x03C7, 0x03C8, 0x03C9, 0x03CA, 0x03CB, 0x03CC, 0x03CD,
|
||||
0x03CE, 0xFFFD,
|
||||
];
|
||||
|
||||
/// Windows-1254 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1254: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0xFFFD, 0xFFFD, 0x0178, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x00C0, 0x00C1, 0x00C2, 0x00C3, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
|
||||
0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x00CC, 0x00CD, 0x00CE, 0x00CF, 0x011E, 0x00D1, 0x00D2, 0x00D3, 0x00D4, 0x00D5, 0x00D6, 0x00D7, 0x00D8, 0x00D9,
|
||||
0x00DA, 0x00DB, 0x00DC, 0x0130, 0x015E, 0x00DF, 0x00E0, 0x00E1, 0x00E2, 0x00E3, 0x00E4, 0x00E5, 0x00E6, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
|
||||
0x00EC, 0x00ED, 0x00EE, 0x00EF, 0x011F, 0x00F1, 0x00F2, 0x00F3, 0x00F4, 0x00F5, 0x00F6, 0x00F7, 0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x0131,
|
||||
0x015F, 0x00FF,
|
||||
];
|
||||
|
||||
/// Windows-1255 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1255: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
|
||||
0x20AA, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00D7, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00F7, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x05B0, 0x05B1, 0x05B2, 0x05B3, 0x05B4, 0x05B5, 0x05B6, 0x05B7,
|
||||
0x05B8, 0x05B9, 0x05BA, 0x05BB, 0x05BC, 0x05BD, 0x05BE, 0x05BF, 0x05C0, 0x05C1, 0x05C2, 0x05C3, 0x05F0, 0x05F1, 0x05F2, 0x05F3, 0x05F4, 0xFFFD,
|
||||
0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x05D0, 0x05D1, 0x05D2, 0x05D3, 0x05D4, 0x05D5, 0x05D6, 0x05D7, 0x05D8, 0x05D9, 0x05DA, 0x05DB,
|
||||
0x05DC, 0x05DD, 0x05DE, 0x05DF, 0x05E0, 0x05E1, 0x05E2, 0x05E3, 0x05E4, 0x05E5, 0x05E6, 0x05E7, 0x05E8, 0x05E9, 0x05EA, 0xFFFD, 0xFFFD, 0x200E,
|
||||
0x200F, 0xFFFD,
|
||||
];
|
||||
|
||||
/// Windows-1256 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1256: [u16; 128] = [
|
||||
0x20AC, 0x067E, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0679, 0x2039, 0x0152, 0x0686, 0x0698, 0x0688, 0x06AF, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x06A9, 0x2122, 0x0691, 0x203A, 0x0153, 0x200C, 0x200D, 0x06BA, 0x00A0, 0x060C, 0x00A2, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x06BE, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x061B, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x061F, 0x06C1, 0x0621, 0x0622, 0x0623, 0x0624, 0x0625, 0x0626, 0x0627,
|
||||
0x0628, 0x0629, 0x062A, 0x062B, 0x062C, 0x062D, 0x062E, 0x062F, 0x0630, 0x0631, 0x0632, 0x0633, 0x0634, 0x0635, 0x0636, 0x00D7, 0x0637, 0x0638,
|
||||
0x0639, 0x063A, 0x0640, 0x0641, 0x0642, 0x0643, 0x00E0, 0x0644, 0x00E2, 0x0645, 0x0646, 0x0647, 0x0648, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
|
||||
0x0649, 0x064A, 0x00EE, 0x00EF, 0x064B, 0x064C, 0x064D, 0x064E, 0x00F4, 0x064F, 0x0650, 0x00F7, 0x0651, 0x00F9, 0x0652, 0x00FB, 0x00FC, 0x200E,
|
||||
0x200F, 0x06D2,
|
||||
];
|
||||
|
||||
/// Windows-1257 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1257: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0xFFFD, 0x201E, 0x2026, 0x2020, 0x2021, 0xFFFD, 0x2030, 0xFFFD, 0x2039, 0xFFFD, 0x00A8, 0x02C7, 0x00B8, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0xFFFD, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0x00AF, 0x02DB, 0xFFFD, 0x00A0, 0xFFFD, 0x00A2, 0x00A3,
|
||||
0x00A4, 0xFFFD, 0x00A6, 0x00A7, 0x00D8, 0x00A9, 0x0156, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00C6, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00F8, 0x00B9, 0x0157, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00E6, 0x0104, 0x012E, 0x0100, 0x0106, 0x00C4, 0x00C5, 0x0118, 0x0112,
|
||||
0x010C, 0x00C9, 0x0179, 0x0116, 0x0122, 0x0136, 0x012A, 0x013B, 0x0160, 0x0143, 0x0145, 0x00D3, 0x014C, 0x00D5, 0x00D6, 0x00D7, 0x0172, 0x0141,
|
||||
0x015A, 0x016A, 0x00DC, 0x017B, 0x017D, 0x00DF, 0x0105, 0x012F, 0x0101, 0x0107, 0x00E4, 0x00E5, 0x0119, 0x0113, 0x010D, 0x00E9, 0x017A, 0x0117,
|
||||
0x0123, 0x0137, 0x012B, 0x013C, 0x0161, 0x0144, 0x0146, 0x00F3, 0x014D, 0x00F5, 0x00F6, 0x00F7, 0x0173, 0x0142, 0x015B, 0x016B, 0x00FC, 0x017C,
|
||||
0x017E, 0x02D9,
|
||||
];
|
||||
|
||||
/// Windows-1258 code points for 0x80..=0xFF (U+FFFD where the byte is unassigned).
|
||||
const CP1258: [u16; 128] = [
|
||||
0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0xFFFD, 0x2039, 0x0152, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0x0153, 0xFFFD, 0xFFFD, 0x0178, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00AA, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00BA, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x00C0, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x00C5, 0x00C6, 0x00C7,
|
||||
0x00C8, 0x00C9, 0x00CA, 0x00CB, 0x0300, 0x00CD, 0x00CE, 0x00CF, 0x0110, 0x00D1, 0x0309, 0x00D3, 0x00D4, 0x01A0, 0x00D6, 0x00D7, 0x00D8, 0x00D9,
|
||||
0x00DA, 0x00DB, 0x00DC, 0x01AF, 0x0303, 0x00DF, 0x00E0, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x00E5, 0x00E6, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
|
||||
0x0301, 0x00ED, 0x00EE, 0x00EF, 0x0111, 0x00F1, 0x0323, 0x00F3, 0x00F4, 0x01A1, 0x00F6, 0x00F7, 0x00F8, 0x00F9, 0x00FA, 0x00FB, 0x00FC, 0x01B0,
|
||||
0x20AB, 0x00FF,
|
||||
];
|
||||
|
||||
/// Mac OS Roman (code page 10000, RTF `\mac`) code points for 0x80..=0xFF, with the euro sign at
|
||||
/// 0xDB (Mac OS 8.5 and later).
|
||||
const MAC_ROMAN: [u16; 128] = [
|
||||
0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1, 0x00E0, 0x00E2, 0x00E4, 0x00E3, 0x00E5, 0x00E7, 0x00E9, 0x00E8, 0x00EA, 0x00EB,
|
||||
0x00ED, 0x00EC, 0x00EE, 0x00EF, 0x00F1, 0x00F3, 0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC, 0x2020, 0x00B0, 0x00A2, 0x00A3,
|
||||
0x00A7, 0x2022, 0x00B6, 0x00DF, 0x00AE, 0x00A9, 0x2122, 0x00B4, 0x00A8, 0x2260, 0x00C6, 0x00D8, 0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5,
|
||||
0x2202, 0x2211, 0x220F, 0x03C0, 0x222B, 0x00AA, 0x00BA, 0x03A9, 0x00E6, 0x00F8, 0x00BF, 0x00A1, 0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB,
|
||||
0x00BB, 0x2026, 0x00A0, 0x00C0, 0x00C3, 0x00D5, 0x0152, 0x0153, 0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0x00F7, 0x25CA, 0x00FF, 0x0178,
|
||||
0x2044, 0x20AC, 0x2039, 0x203A, 0xFB01, 0xFB02, 0x2021, 0x00B7, 0x201A, 0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1, 0x00CB, 0x00C8, 0x00CD, 0x00CE,
|
||||
0x00CF, 0x00CC, 0x00D3, 0x00D4, 0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0x0131, 0x02C6, 0x02DC, 0x00AF, 0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD,
|
||||
0x02DB, 0x02C7,
|
||||
];
|
||||
/// IBM PC (OEM) code page 437 code points for 0x80..=0xFF (the Unicode Consortium's CP437
|
||||
/// mapping file).
|
||||
const CP437: [u16; 128] = [
|
||||
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
|
||||
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00A2, 0x00A3, 0x00A5, 0x20A7, 0x0192, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
|
||||
0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
|
||||
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
|
||||
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
|
||||
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4, 0x03A6, 0x0398, 0x03A9, 0x03B4,
|
||||
0x221E, 0x03C6, 0x03B5, 0x2229, 0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2,
|
||||
0x25A0, 0x00A0,
|
||||
];
|
||||
|
||||
/// OEM code page 850 (DOS Latin-1, Western European) code points for 0x80..=0xFF (the Unicode
|
||||
/// Consortium's CP850 mapping file).
|
||||
const CP850: [u16; 128] = [
|
||||
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
|
||||
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x00D7, 0x0192, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
|
||||
0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x00AE, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
|
||||
0x00C2, 0x00C0, 0x00A9, 0x2563, 0x2551, 0x2557, 0x255D, 0x00A2, 0x00A5, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x00E3, 0x00C3,
|
||||
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x00F0, 0x00D0, 0x00CA, 0x00CB, 0x00C8, 0x0131, 0x00CD, 0x00CE, 0x00CF, 0x2518,
|
||||
0x250C, 0x2588, 0x2584, 0x00A6, 0x00CC, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x00D2, 0x00F5, 0x00D5, 0x00B5, 0x00FE, 0x00DE, 0x00DA, 0x00DB, 0x00D9,
|
||||
0x00FD, 0x00DD, 0x00AF, 0x00B4, 0x00AD, 0x00B1, 0x2017, 0x00BE, 0x00B6, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x00B7, 0x00B9, 0x00B3, 0x00B2,
|
||||
0x25A0, 0x00A0,
|
||||
];
|
||||
|
||||
/// OEM code page 852 (DOS Latin-2, Central European) code points for 0x80..=0xFF (the Unicode Consortium's CP852
|
||||
/// mapping file).
|
||||
const CP852: [u16; 128] = [
|
||||
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x016F, 0x0107, 0x00E7, 0x0142, 0x00EB, 0x0150, 0x0151, 0x00EE, 0x0179, 0x00C4, 0x0106, 0x00C9, 0x0139,
|
||||
0x013A, 0x00F4, 0x00F6, 0x013D, 0x013E, 0x015A, 0x015B, 0x00D6, 0x00DC, 0x0164, 0x0165, 0x0141, 0x00D7, 0x010D, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
|
||||
0x0104, 0x0105, 0x017D, 0x017E, 0x0118, 0x0119, 0x00AC, 0x017A, 0x010C, 0x015F, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
|
||||
0x00C2, 0x011A, 0x015E, 0x2563, 0x2551, 0x2557, 0x255D, 0x017B, 0x017C, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x0102, 0x0103,
|
||||
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x0111, 0x0110, 0x010E, 0x00CB, 0x010F, 0x0147, 0x00CD, 0x00CE, 0x011B, 0x2518,
|
||||
0x250C, 0x2588, 0x2584, 0x0162, 0x016E, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x0143, 0x0144, 0x0148, 0x0160, 0x0161, 0x0154, 0x00DA, 0x0155, 0x0170,
|
||||
0x00FD, 0x00DD, 0x0163, 0x00B4, 0x00AD, 0x02DD, 0x02DB, 0x02C7, 0x02D8, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x02D9, 0x0171, 0x0158, 0x0159,
|
||||
0x25A0, 0x00A0,
|
||||
];
|
||||
|
||||
/// OEM code page 866 (DOS Cyrillic) code points for 0x80..=0xFF (the Unicode Consortium's CP866
|
||||
/// mapping file).
|
||||
const CP866: [u16; 128] = [
|
||||
0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417, 0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F, 0x0420, 0x0421,
|
||||
0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x0429, 0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F, 0x0430, 0x0431, 0x0432, 0x0433,
|
||||
0x0434, 0x0435, 0x0436, 0x0437, 0x0438, 0x0439, 0x043A, 0x043B, 0x043C, 0x043D, 0x043E, 0x043F, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
|
||||
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
|
||||
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
|
||||
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B,
|
||||
0x044C, 0x044D, 0x044E, 0x044F, 0x0401, 0x0451, 0x0404, 0x0454, 0x0407, 0x0457, 0x040E, 0x045E, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x2116, 0x00A4,
|
||||
0x25A0, 0x00A0,
|
||||
];
|
||||
|
||||
/// OEM code page 737 (DOS Greek) code points for 0x80..=0xFF (the Unicode Consortium's CP737
|
||||
/// mapping file).
|
||||
const CP737: [u16; 128] = [
|
||||
0x0391, 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397, 0x0398, 0x0399, 0x039A, 0x039B, 0x039C, 0x039D, 0x039E, 0x039F, 0x03A0, 0x03A1, 0x03A3,
|
||||
0x03A4, 0x03A5, 0x03A6, 0x03A7, 0x03A8, 0x03A9, 0x03B1, 0x03B2, 0x03B3, 0x03B4, 0x03B5, 0x03B6, 0x03B7, 0x03B8, 0x03B9, 0x03BA, 0x03BB, 0x03BC,
|
||||
0x03BD, 0x03BE, 0x03BF, 0x03C0, 0x03C1, 0x03C3, 0x03C2, 0x03C4, 0x03C5, 0x03C6, 0x03C7, 0x03C8, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
|
||||
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
|
||||
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
|
||||
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x03C9, 0x03AC, 0x03AD, 0x03AE, 0x03CA, 0x03AF, 0x03CC, 0x03CD, 0x03CB, 0x03CE, 0x0386, 0x0388,
|
||||
0x0389, 0x038A, 0x038C, 0x038E, 0x038F, 0x00B1, 0x2265, 0x2264, 0x03AA, 0x03AB, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2,
|
||||
0x25A0, 0x00A0,
|
||||
];
|
||||
|
||||
/// OEM code page 857 (DOS Turkish; U+FFFD where the byte is unassigned) code points for 0x80..=0xFF (the Unicode Consortium's CP857
|
||||
/// mapping file).
|
||||
const CP857: [u16; 128] = [
|
||||
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x0131, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
|
||||
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x0130, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x015E, 0x015F, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
|
||||
0x00F1, 0x00D1, 0x011E, 0x011F, 0x00BF, 0x00AE, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
|
||||
0x00C2, 0x00C0, 0x00A9, 0x2563, 0x2551, 0x2557, 0x255D, 0x00A2, 0x00A5, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x00E3, 0x00C3,
|
||||
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x00BA, 0x00AA, 0x00CA, 0x00CB, 0x00C8, 0xFFFD, 0x00CD, 0x00CE, 0x00CF, 0x2518,
|
||||
0x250C, 0x2588, 0x2584, 0x00A6, 0x00CC, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x00D2, 0x00F5, 0x00D5, 0x00B5, 0xFFFD, 0x00D7, 0x00DA, 0x00DB, 0x00D9,
|
||||
0x00EC, 0x00FF, 0x00AF, 0x00B4, 0x00AD, 0x00B1, 0xFFFD, 0x00BE, 0x00B6, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x00B7, 0x00B9, 0x00B3, 0x00B2,
|
||||
0x25A0, 0x00A0,
|
||||
];
|
||||
|
||||
/// The glyphs the OEM code pages show for 0x01..=0x1F (smileys, card suits, arrows), as
|
||||
/// Windows' Alt+numpad entry produces them rather than the control characters.
|
||||
const OEM_GLYPHS: [u16; 31] = [
|
||||
0x263A, 0x263B, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022, 0x25D8, 0x25CB, 0x25D9, 0x2642, 0x2640, 0x266A, 0x266B, 0x263C, 0x25BA, 0x25C4, 0x2195,
|
||||
0x203C, 0x00B6, 0x00A7, 0x25AC, 0x21A8, 0x2191, 0x2193, 0x2192, 0x2190, 0x221F, 0x2194, 0x25B2, 0x25BC,
|
||||
];
|
||||
|
||||
/// The OEM code pages [`oem_byte`] decodes.
|
||||
pub const OEM_CODE_PAGES: [u16; 6] = [437, 737, 850, 852, 857, 866];
|
||||
|
||||
/// One byte of an OEM code page in [`OEM_CODE_PAGES`] (any other code page reads as 437) as a
|
||||
/// char, with the graphic glyphs for 0x01..=0x1F and 0x7F (⌂); 0x00 stays NUL.
|
||||
pub fn oem_byte(code_page: u16, b: u8) -> char {
|
||||
let table: &[u16; 128] = match code_page {
|
||||
737 => &CP737,
|
||||
850 => &CP850,
|
||||
852 => &CP852,
|
||||
857 => &CP857,
|
||||
866 => &CP866,
|
||||
_ => &CP437,
|
||||
};
|
||||
let u = match b {
|
||||
0x01..=0x1F => OEM_GLYPHS.get(usize::from(b - 1)).copied(),
|
||||
0x7F => Some(0x2302),
|
||||
0x80..=0xFF => table.get(usize::from(b - 0x80)).copied(),
|
||||
_ => Some(u16::from(b)),
|
||||
};
|
||||
u.and_then(|u| char::from_u32(u32::from(u))).unwrap_or('\u{FFFD}')
|
||||
}
|
||||
|
||||
/// The single-byte Windows code pages [`windows_byte`] decodes (1252 included).
|
||||
pub const SINGLE_BYTE_CODE_PAGES: [u16; 10] = [874, 1250, 1251, 1252, 1253, 1254, 1255, 1256, 1257, 1258];
|
||||
|
||||
/// Mac OS Roman, RTF's `\mac` character set.
|
||||
pub const MAC_ROMAN_CODE_PAGE: u16 = 10000;
|
||||
|
||||
/// One byte of a single-byte code page as a char: a Windows one in [`SINGLE_BYTE_CODE_PAGES`] or
|
||||
/// Mac OS Roman ([`MAC_ROMAN_CODE_PAGE`]); any other code page reads as Windows-1252.
|
||||
pub fn windows_byte(code_page: u16, b: u8) -> char {
|
||||
let table: &[u16; 128] = match code_page {
|
||||
874 => &CP874,
|
||||
1250 => &CP1250,
|
||||
1251 => &CP1251,
|
||||
1253 => &CP1253,
|
||||
1254 => &CP1254,
|
||||
1255 => &CP1255,
|
||||
1256 => &CP1256,
|
||||
1257 => &CP1257,
|
||||
1258 => &CP1258,
|
||||
MAC_ROMAN_CODE_PAGE => &MAC_ROMAN,
|
||||
_ => return cp1252(b),
|
||||
};
|
||||
if b < 0x80 {
|
||||
return b as char;
|
||||
}
|
||||
table.get(usize::from(b - 0x80)).and_then(|u| char::from_u32(u32::from(*u))).unwrap_or('\u{FFFD}')
|
||||
}
|
||||
|
||||
/// Whether [`code_page_byte`] decodes `code_page`: a Windows one ([`SINGLE_BYTE_CODE_PAGES`]), an
|
||||
/// OEM (DOS) one ([`OEM_CODE_PAGES`]) or Mac OS Roman. Document formats (RTF) name all three.
|
||||
pub fn is_byte_code_page(code_page: u16) -> bool {
|
||||
SINGLE_BYTE_CODE_PAGES.contains(&code_page) || OEM_CODE_PAGES.contains(&code_page) || code_page == MAC_ROMAN_CODE_PAGE
|
||||
}
|
||||
|
||||
/// A byte of 8-bit document text in `code_page` (see [`is_byte_code_page`]) as a char. Below 0x80
|
||||
/// it is ASCII in every code page (text, so an OEM page's glyphs for control codes don't apply).
|
||||
pub fn code_page_byte(code_page: u16, b: u8) -> char {
|
||||
if b >= 0x80 && OEM_CODE_PAGES.contains(&code_page) { oem_byte(code_page, b) } else { windows_byte(code_page, b) }
|
||||
}
|
||||
|
||||
/// The East Asian multibyte code pages a document format names with `\ansicpg` or a font's `\cpg`:
|
||||
/// 932 Shift-JIS (Japanese), 936 GBK (Simplified Chinese), 949 UHC (Korean) and 950 Big5
|
||||
/// (Traditional Chinese).
|
||||
pub const MULTIBYTE_CODE_PAGES: [u16; 4] = [932, 936, 949, 950];
|
||||
|
||||
/// Whether `code_page` is one of the East Asian multibyte code pages ([`MULTIBYTE_CODE_PAGES`]):
|
||||
/// its bytes decode as a run, a lead byte together with its trail byte.
|
||||
pub fn is_multibyte_code_page(code_page: u16) -> bool {
|
||||
multibyte_encoding_name(code_page).is_some()
|
||||
}
|
||||
|
||||
/// The WHATWG encoding name of an East Asian multibyte code page (see [`MULTIBYTE_CODE_PAGES`]),
|
||||
/// for `encoding_rs::Encoding::for_label`; `None` for the single-byte code pages, whose bytes
|
||||
/// decode one at a time, and anything else.
|
||||
pub fn multibyte_encoding_name(code_page: u16) -> Option<&'static str> {
|
||||
Some(match code_page {
|
||||
932 => "shift_jis",
|
||||
936 => "gbk",
|
||||
949 => "euc-kr",
|
||||
950 => "big5",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a document format can name `code_page` for its text: a single-byte Windows, DOS or Mac
|
||||
/// one ([`is_byte_code_page`]) or an East Asian multibyte one ([`is_multibyte_code_page`]).
|
||||
pub fn is_text_code_page(code_page: u16) -> bool {
|
||||
is_byte_code_page(code_page) || is_multibyte_code_page(code_page)
|
||||
}
|
||||
|
||||
/// The code page of an RTF or Word font charset (`\fcharset`): 204 Russian → 1251, 238 Eastern
|
||||
/// European → 1250, 77 Mac → 10000, 254 PC / 255 OEM → 437, 128 Japanese → 932, 129 Korean → 949,
|
||||
/// 134 Simplified Chinese → 936, 136 Traditional Chinese → 950… `None` for the ANSI and default
|
||||
/// charsets (the document's code page applies) and symbol fonts.
|
||||
pub fn charset_code_page(charset: i32) -> Option<u16> {
|
||||
Some(match charset {
|
||||
77 => MAC_ROMAN_CODE_PAGE,
|
||||
128 => 932,
|
||||
129 => 949,
|
||||
134 => 936,
|
||||
136 => 950,
|
||||
161 => 1253,
|
||||
162 => 1254,
|
||||
163 => 1258,
|
||||
177 => 1255,
|
||||
178 => 1256,
|
||||
186 => 1257,
|
||||
204 => 1251,
|
||||
222 => 874,
|
||||
238 => 1250,
|
||||
254 | 255 => 437,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn single_byte_code_pages() {
|
||||
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| windows_byte(cp, b)).collect::<String>();
|
||||
assert_eq!(decode(1251, &[0xD1, 0xF7, 0xE5, 0xF2, 0x20, 0xB9]), "Счет №");
|
||||
assert_eq!(decode(1251, &[0xA8, 0xB8]), "Ёё");
|
||||
assert_eq!(decode(1250, &[0xA3, 0xF3, 0x9F]), "Łóź");
|
||||
assert_eq!(decode(1253, &[0xC1, 0xE2]), "Αβ");
|
||||
assert_eq!(decode(1252, &[0x80, 0xE9]), "ێ");
|
||||
assert_eq!(decode(9999, &[0xE9]), "é");
|
||||
// Mac OS Roman (#1653): accents, the euro sign at 0xDB, typographic quotes.
|
||||
assert_eq!(decode(10000, &[0x80, 0x8E, 0xDB, 0xF1, 0xF2, 0xF3, 0xD2, 0xD3, 0x41]), "Äé€ÒÚÛ“”A");
|
||||
assert_eq!(charset_code_page(77), Some(10000));
|
||||
assert_eq!(charset_code_page(254), Some(437));
|
||||
assert_eq!(charset_code_page(255), Some(437));
|
||||
assert_eq!(charset_code_page(204), Some(1251));
|
||||
assert_eq!(charset_code_page(0), None);
|
||||
for cp in SINGLE_BYTE_CODE_PAGES {
|
||||
for b in 0..=255u8 {
|
||||
let _ = windows_byte(cp, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_1255_holam_haser_for_vav() {
|
||||
assert_eq!(windows_byte(1255, 0xCA), '\u{05BA}');
|
||||
}
|
||||
|
||||
/// #1653: one decoder for document text in any code page RTF names: Windows, DOS or Mac.
|
||||
#[test]
|
||||
fn document_text_in_windows_dos_and_mac_code_pages() {
|
||||
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| code_page_byte(cp, b)).collect::<String>();
|
||||
assert_eq!(decode(437, &[0x82, 0xA0, 0x41, 0x01]), "éáA\u{1}", "DOS text; ASCII stays ASCII");
|
||||
assert_eq!(decode(850, &[0x9D, 0xE9]), "ØÚ");
|
||||
assert_eq!(decode(10000, &[0x8E]), "é");
|
||||
assert_eq!(decode(1251, &[0xC4]), "Д");
|
||||
for cp in [437, 850, 866, 1250, 1252, 10000] {
|
||||
assert!(is_byte_code_page(cp), "{cp}");
|
||||
}
|
||||
assert!(!is_byte_code_page(932) && !is_byte_code_page(0) && !SINGLE_BYTE_CODE_PAGES.contains(&10000));
|
||||
}
|
||||
|
||||
/// #1653: the East Asian charsets and code pages RTF names are multibyte: their bytes decode
|
||||
/// as runs (a lead byte and its trail byte together), not one at a time.
|
||||
#[test]
|
||||
fn east_asian_code_pages_are_multibyte() {
|
||||
assert_eq!(charset_code_page(128), Some(932), "Japanese");
|
||||
assert_eq!(charset_code_page(129), Some(949), "Korean");
|
||||
assert_eq!(charset_code_page(134), Some(936), "Simplified Chinese");
|
||||
assert_eq!(charset_code_page(136), Some(950), "Traditional Chinese");
|
||||
assert_eq!(charset_code_page(255), Some(437), "OEM is single-byte");
|
||||
assert_eq!(MULTIBYTE_CODE_PAGES, [932, 936, 949, 950]);
|
||||
for cp in MULTIBYTE_CODE_PAGES {
|
||||
assert!(is_multibyte_code_page(cp) && is_text_code_page(cp), "{cp}");
|
||||
assert!(!is_byte_code_page(cp) && !SINGLE_BYTE_CODE_PAGES.contains(&cp), "{cp}");
|
||||
assert!(multibyte_encoding_name(cp).is_some(), "{cp}");
|
||||
}
|
||||
assert!(is_text_code_page(1251) && is_text_code_page(437) && is_text_code_page(10000));
|
||||
assert!(!is_text_code_page(0) && !is_text_code_page(9999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oem_code_pages() {
|
||||
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| oem_byte(cp, b)).collect::<String>();
|
||||
assert_eq!(decode(437, &[0x82, 0xE9, 0x01, 0x7F]), "éΘ☺⌂");
|
||||
assert_eq!(decode(850, &[0x9B, 0xE9]), "øÚ");
|
||||
assert_eq!(decode(852, &[0xA5, 0x88, 0xBE, 0xA9]), "ąłżę");
|
||||
assert_eq!(decode(866, &[0x8F, 0xE0, 0xA8, 0xA2, 0xA5, 0xE2, 0xF0, 0xFC]), "ПриветЁ№");
|
||||
assert_eq!(decode(737, &[0x80, 0x99, 0xE0]), "Αβω");
|
||||
assert_eq!(decode(857, &[0x8D, 0x98, 0xA6, 0x9F]), "ıİĞş");
|
||||
assert_eq!(oem_byte(857, 0xD5), '\u{FFFD}', "unassigned in 857");
|
||||
assert_eq!(decode(9999, &[0xE9]), "Θ", "unknown pages read as 437");
|
||||
for cp in OEM_CODE_PAGES {
|
||||
for b in 0..=255u8 {
|
||||
let _ = oem_byte(cp, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
@@ -93,21 +93,6 @@ impl Document {
|
||||
self.story(story).map(|bl| scan_story(story, bl).ranges).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `p` moved past the range-field ends right after it. A click just after a field's result
|
||||
/// lands before its end marker (the marker takes no room); typing or inserting there belongs
|
||||
/// after the field, as in Word, not in its result.
|
||||
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;
|
||||
}
|
||||
off += OBJ.len_utf8();
|
||||
}
|
||||
Pos { off, ..p.clone() }
|
||||
}
|
||||
|
||||
/// Whether any story holds a range-field marker without its partner.
|
||||
pub fn has_unbalanced_field_ranges(&self) -> bool {
|
||||
self.story_refs().into_iter().any(|s| self.story(s).is_some_and(|bl| !scan_story(s, bl).orphans.is_empty()))
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
//! Shape fills: what paints a shape's, text box's or canvas's interior, and a shape outline's
|
||||
//! gradient (DrawingML `a:solidFill` / `a:gradFill`, ECMA-376 §20.1.8.54 and §20.1.8.33; VML
|
||||
//! `v:fill`).
|
||||
//!
|
||||
//! A gradient is kept as the file states it (stops, linear angle or path focus, tile rectangle,
|
||||
//! `rotWithShape`, flip) so saving writes it back; drawing approximates rectangular and
|
||||
//! shape-following paths as circular. Transparencies are percent (0 = opaque); rectangles are
|
||||
//! percent insets (left, top, right, bottom) from the shape's edges.
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::props::Rgb;
|
||||
#[cfg(test)]
|
||||
use crate::wordart::MAX_STOPS;
|
||||
use crate::wordart::{GradStop, clamp, sanitized_stops};
|
||||
|
||||
/// A shape's fill. In `.json` documents an opaque solid fill is written `[r, g, b]` as before
|
||||
/// fills had transparency or gradients; the rest are tagged (`{"type": "gradient", ...}`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Fill {
|
||||
Solid { color: Rgb, transparency: f32 },
|
||||
Gradient(Arc<Gradient>),
|
||||
}
|
||||
|
||||
/// A gradient fill.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Gradient {
|
||||
pub stops: Vec<GradStop>,
|
||||
pub kind: GradientKind,
|
||||
/// `a:tileRect`: where the gradient's area sits relative to the shape (percent insets).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tile: Option<[f32; 4]>,
|
||||
/// Turns with the shape (`rotWithShape`).
|
||||
#[serde(default)]
|
||||
pub rot_with_shape: bool,
|
||||
#[serde(default, skip_serializing_if = "GradientFlip::is_none")]
|
||||
pub flip: GradientFlip,
|
||||
}
|
||||
|
||||
/// How a gradient runs.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum GradientKind {
|
||||
/// Along a line at `angle` degrees (0 = left to right, 90 = top to bottom). `scaled`: the
|
||||
/// angle is scaled with the shape's aspect ratio.
|
||||
Linear {
|
||||
angle: f32,
|
||||
#[serde(default)]
|
||||
scaled: bool,
|
||||
},
|
||||
/// Outwards from `focus` (percent insets from the shape's edges, `a:fillToRect`).
|
||||
Path { shape: PathShape, focus: [f32; 4] },
|
||||
}
|
||||
|
||||
/// A path gradient's outline (`a:path/@path`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PathShape {
|
||||
Circle,
|
||||
Rect,
|
||||
Shape,
|
||||
}
|
||||
|
||||
/// How the gradient's tile is mirrored when tiled (`a:gradFill/@flip`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GradientFlip {
|
||||
#[default]
|
||||
None,
|
||||
X,
|
||||
Y,
|
||||
Xy,
|
||||
}
|
||||
|
||||
impl GradientFlip {
|
||||
pub fn is_none(&self) -> bool {
|
||||
*self == GradientFlip::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest rectangle inset kept, percent (hostile files).
|
||||
const MAX_INSET: f32 = 1000.0;
|
||||
|
||||
fn rect(r: [f32; 4]) -> [f32; 4] {
|
||||
r.map(|v| clamp(v, -MAX_INSET, MAX_INSET, 0.0))
|
||||
}
|
||||
|
||||
impl Fill {
|
||||
/// An opaque solid fill.
|
||||
pub fn solid(color: Rgb) -> Fill {
|
||||
Fill::Solid { color, transparency: 0.0 }
|
||||
}
|
||||
/// The one colour standing for the fill where only one fits: the solid colour, or a
|
||||
/// gradient's middle stop.
|
||||
pub fn main_color(&self) -> Rgb {
|
||||
match self {
|
||||
Fill::Solid { color, .. } => *color,
|
||||
Fill::Gradient(g) => g.stops.get(g.stops.len() / 2).map_or(Rgb::BLACK, |s| s.color),
|
||||
}
|
||||
}
|
||||
/// Finite, in range: at most [`MAX_STOPS`] stops, in order, positions and transparencies
|
||||
/// 0–100%, angles 0–360°. A gradient with one stop is that stop's solid colour; with none,
|
||||
/// no fill (`None`).
|
||||
pub fn sanitized(&self) -> Option<Fill> {
|
||||
Some(match self {
|
||||
Fill::Solid { color, transparency } => Fill::Solid { color: *color, transparency: clamp(*transparency, 0.0, 100.0, 0.0) },
|
||||
Fill::Gradient(g) => {
|
||||
let stops = sanitized_stops(&g.stops);
|
||||
match stops[..] {
|
||||
[] => return None,
|
||||
[s] => return Some(Fill::Solid { color: s.color, transparency: s.transparency }),
|
||||
_ => {}
|
||||
}
|
||||
let kind = match g.kind {
|
||||
GradientKind::Linear { angle, scaled } => {
|
||||
GradientKind::Linear { angle: clamp(angle, -1e6, 1e6, 0.0).rem_euclid(360.0) % 360.0, scaled }
|
||||
}
|
||||
GradientKind::Path { shape, focus } => GradientKind::Path { shape, focus: rect(focus) },
|
||||
};
|
||||
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: g.tile.map(rect), rot_with_shape: g.rot_with_shape, flip: g.flip }))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The tagged `.json` form of fills other than opaque solid ones.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
enum Tagged {
|
||||
Solid {
|
||||
color: Rgb,
|
||||
#[serde(default)]
|
||||
transparency: f32,
|
||||
},
|
||||
Gradient(Gradient),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum AnyForm {
|
||||
Plain(Rgb),
|
||||
Tagged(Tagged),
|
||||
}
|
||||
|
||||
impl Serialize for Fill {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
|
||||
match self {
|
||||
Fill::Solid { color, transparency: 0.0 } => color.serialize(s),
|
||||
Fill::Solid { color, transparency } => Tagged::Solid { color: *color, transparency: *transparency }.serialize(s),
|
||||
Fill::Gradient(g) => Tagged::Gradient(Gradient::clone(g)).serialize(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Fill {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Fill, D::Error> {
|
||||
Ok(match AnyForm::deserialize(d)? {
|
||||
AnyForm::Plain(color) => Fill::solid(color),
|
||||
AnyForm::Tagged(Tagged::Solid { color, transparency }) => Fill::Solid { color, transparency },
|
||||
AnyForm::Tagged(Tagged::Gradient(g)) => Fill::Gradient(Arc::new(g)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn stop(pos: f32, color: Rgb, transparency: f32) -> GradStop {
|
||||
GradStop { pos, color, transparency }
|
||||
}
|
||||
|
||||
fn grad(stops: Vec<GradStop>, kind: GradientKind) -> Fill {
|
||||
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: None, rot_with_shape: true, flip: GradientFlip::None }))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_solid_fills_are_written_as_before_and_old_documents_load() {
|
||||
let f = Fill::solid(Rgb(1, 2, 3));
|
||||
assert_eq!(serde_json::to_string(&f).ok().as_deref(), Some("[1,2,3]"));
|
||||
assert_eq!(serde_json::from_str::<Fill>("[1,2,3]").ok(), Some(f));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_and_gradient_fills_round_trip_tagged() {
|
||||
let fills = [
|
||||
Fill::Solid { color: Rgb::WHITE, transparency: 70.0 },
|
||||
grad(vec![stop(0.0, Rgb(9, 9, 9), 0.0), stop(100.0, Rgb::WHITE, 50.0)], GradientKind::Linear { angle: 90.0, scaled: true }),
|
||||
Fill::Gradient(Arc::new(Gradient {
|
||||
stops: vec![stop(0.0, Rgb::BLACK, 0.0), stop(100.0, Rgb::WHITE, 0.0)],
|
||||
kind: GradientKind::Path { shape: PathShape::Rect, focus: [50.0, 50.0, 50.0, 50.0] },
|
||||
tile: Some([-10.0, 0.0, 0.0, 0.0]),
|
||||
rot_with_shape: false,
|
||||
flip: GradientFlip::Xy,
|
||||
})),
|
||||
];
|
||||
for f in fills {
|
||||
let json = serde_json::to_string(&f).unwrap_or_default();
|
||||
assert!(json.contains("\"type\""), "{json}");
|
||||
assert_eq!(serde_json::from_str::<Fill>(&json).ok(), Some(f), "{json}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_fills_are_sanitized() {
|
||||
let many: Vec<GradStop> = (0..30).map(|i| stop(100.0 - i as f32, Rgb::BLACK, f32::NAN)).collect();
|
||||
let Some(Fill::Gradient(g)) = grad(many, GradientKind::Linear { angle: -450.0, scaled: false }).sanitized() else { panic!() };
|
||||
assert_eq!(g.stops.len(), MAX_STOPS);
|
||||
assert!(g.stops.windows(2).all(|w| w[0].pos <= w[1].pos) && g.stops.iter().all(|s| s.transparency == 0.0));
|
||||
assert_eq!(g.kind, GradientKind::Linear { angle: 270.0, scaled: false });
|
||||
let path = grad(
|
||||
vec![stop(150.0, Rgb::BLACK, 200.0), stop(-5.0, Rgb::WHITE, 0.0)],
|
||||
GradientKind::Path { shape: PathShape::Circle, focus: [f32::NAN, 1e9, -1e9, 5.0] },
|
||||
);
|
||||
let Some(Fill::Gradient(g)) = path.sanitized() else { panic!() };
|
||||
assert_eq!((g.stops[0].pos, g.stops[1].pos, g.stops[1].transparency), (0.0, 100.0, 100.0));
|
||||
assert_eq!(g.kind, GradientKind::Path { shape: PathShape::Circle, focus: [0.0, MAX_INSET, -MAX_INSET, 5.0] });
|
||||
assert_eq!(
|
||||
grad(vec![stop(0.0, Rgb::WHITE, 30.0)], GradientKind::Linear { angle: 0.0, scaled: false }).sanitized(),
|
||||
Some(Fill::Solid { color: Rgb::WHITE, transparency: 30.0 })
|
||||
);
|
||||
assert_eq!(grad(vec![], GradientKind::Linear { angle: 0.0, scaled: false }).sanitized(), None);
|
||||
assert_eq!(Fill::Solid { color: Rgb::BLACK, transparency: f32::NAN }.sanitized(), Some(Fill::solid(Rgb::BLACK)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_colour_is_the_middle_stop() {
|
||||
let g = grad(
|
||||
vec![stop(0.0, Rgb(1, 0, 0), 0.0), stop(50.0, Rgb(2, 0, 0), 0.0), stop(100.0, Rgb(3, 0, 0), 0.0)],
|
||||
GradientKind::Linear { angle: 0.0, scaled: false },
|
||||
);
|
||||
assert_eq!(g.main_color(), Rgb(2, 0, 0));
|
||||
assert_eq!(Fill::solid(Rgb::WHITE).main_color(), Rgb::WHITE);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Charts, SmartArt diagrams and native ink: drawings kept as neutral items ready to draw. Charts and SmartArt
|
||||
//! Charts and SmartArt diagrams: drawings kept as neutral items ready to draw. Charts and SmartArt
|
||||
//! WordCraft can edit also keep their model ([`crate::chart::ChartSpec`],
|
||||
//! [`crate::smart_art::SmartArtSpec`]); the rest are shown, not edited.
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::para::ShapeKind;
|
||||
use crate::props::Rgb;
|
||||
|
||||
/// A chart, SmartArt diagram or native ink preview, as items ready to draw (and, for an editable chart or SmartArt
|
||||
/// A chart or a SmartArt diagram, as items ready to draw (and, for an editable chart or SmartArt
|
||||
/// graphic, its model).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -22,9 +22,9 @@ pub struct Graphic {
|
||||
pub w: f32,
|
||||
#[serde(default)]
|
||||
pub h: f32,
|
||||
/// Where the graphic was read from, so saving writes the original drawing itself back (not
|
||||
/// Where the graphic was read from, so saving writes the chart or diagram itself back (not
|
||||
/// only these items). `None` for graphics made some other way.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(skip)]
|
||||
pub source: Option<std::sync::Arc<Embedded>>,
|
||||
/// A SmartArt graphic WordCraft can edit (one it made, or one read back exactly as it wrote
|
||||
/// it): saving writes the diagram parts from it, and the items are drawn from it. See
|
||||
@@ -41,18 +41,17 @@ pub struct Graphic {
|
||||
/// source format and the package parts that markup refers to. Never edited; WordCraft only moves
|
||||
/// and resizes the object around it. The parts themselves (bytes, content type and their own
|
||||
/// relationships) are kept in [`crate::Document::passthrough`] under their part names.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Embedded {
|
||||
/// The object's markup as a standalone XML element with its namespace declarations (DOCX:
|
||||
/// the `a:graphic` of a chart, SmartArt diagram or native ink, an ink `mc:AlternateContent`,
|
||||
/// the `w:object` of an OLE object, or the `w:pict` of a signature line).
|
||||
/// the `a:graphic` of a chart or SmartArt diagram, the `w:object` of an OLE object).
|
||||
pub xml: String,
|
||||
/// The relationships it uses, by the ids in `xml` (or in its parts, for a SmartArt drawing).
|
||||
pub refs: Vec<EmbeddedRef>,
|
||||
}
|
||||
|
||||
/// One relationship an [`Embedded`] object uses.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct EmbeddedRef {
|
||||
/// The relationship id the markup uses.
|
||||
pub id: String,
|
||||
@@ -69,8 +68,6 @@ pub enum GraphicKind {
|
||||
#[default]
|
||||
Chart,
|
||||
Diagram,
|
||||
/// Word's native InkML, kept opaque; displayed as its fallback picture when available.
|
||||
Ink,
|
||||
}
|
||||
|
||||
impl GraphicKind {
|
||||
@@ -79,7 +76,6 @@ impl GraphicKind {
|
||||
match self {
|
||||
GraphicKind::Chart => "chart",
|
||||
GraphicKind::Diagram => "diagram",
|
||||
GraphicKind::Ink => "ink",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
//! Legacy form fields (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`): what their `w:ffData` says,
|
||||
//! read from the raw XML a [`crate::InlineObject::Field`] keeps in its `form` (ECMA-376 Part 1
|
||||
//! §17.16.17). Only what layout and PDF export need; the XML itself stays the source of truth
|
||||
//! for saving.
|
||||
|
||||
/// Most drop-down entries kept (Word allows 25).
|
||||
pub const MAX_ENTRIES: usize = 64;
|
||||
|
||||
/// A legacy form field's kind and state.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum LegacyForm {
|
||||
/// A check box: `checked` is its state (`w:checked`, else `w:default`), `size` its explicit
|
||||
/// size in points (`w:size`), `None` when it follows the text (`w:sizeAuto`).
|
||||
CheckBox { name: String, checked: bool, size: Option<f32> },
|
||||
/// A text field; its text is the field's result.
|
||||
Text { name: String, max_len: Option<u32> },
|
||||
/// A drop-down list showing entry `selected` (`w:result`, else `w:default`, else the first).
|
||||
DropDown { name: String, entries: Vec<String>, selected: usize },
|
||||
}
|
||||
|
||||
impl LegacyForm {
|
||||
/// The field's definition, or `None` when `ff_data` holds none of the three.
|
||||
pub fn parse(ff_data: &str) -> Option<LegacyForm> {
|
||||
let name = attr_of(ff_data, "w:name").unwrap_or_default();
|
||||
if let Some(cb) = element(ff_data, "w:checkBox") {
|
||||
let default = attr_of(cb, "w:default").is_some_and(|v| on(&v));
|
||||
// `<w:checked/>` with no value means checked.
|
||||
let checked = match tag(cb, "w:checked") {
|
||||
Some(t) => attr_in(t).is_none_or(|v| on(&v)),
|
||||
None => default,
|
||||
};
|
||||
let size =
|
||||
attr_of(cb, "w:size").and_then(|v| v.trim().parse::<f32>().ok()).filter(|v| v.is_finite() && *v > 0.0).map(|v| (v / 2.0).min(1584.0));
|
||||
return Some(LegacyForm::CheckBox { name, checked, size });
|
||||
}
|
||||
if let Some(dd) = element(ff_data, "w:ddList") {
|
||||
let entries: Vec<String> = tags(dd, "w:listEntry").filter_map(attr_in).take(MAX_ENTRIES).collect();
|
||||
let pick = |n: &str| attr_of(dd, n).and_then(|v| v.trim().parse::<usize>().ok());
|
||||
let selected = pick("w:result").or_else(|| pick("w:default")).filter(|i| *i < entries.len()).unwrap_or(0);
|
||||
return Some(LegacyForm::DropDown { name, entries, selected });
|
||||
}
|
||||
if ff_data.contains("<w:textInput") {
|
||||
let max_len =
|
||||
element(ff_data, "w:textInput").and_then(|t| attr_of(t, "w:maxLength")).and_then(|v| v.trim().parse::<u32>().ok()).filter(|n| *n > 0);
|
||||
return Some(LegacyForm::Text { name, max_len });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The text a drop-down shows (its selected entry); `None` for other kinds or an empty list.
|
||||
pub fn shown_text(&self) -> Option<&str> {
|
||||
match self {
|
||||
LegacyForm::DropDown { entries, selected, .. } => entries.get(*selected).map(String::as_str),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The field's bookmark-style name (`w:name`), possibly empty.
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
LegacyForm::CheckBox { name, .. } | LegacyForm::Text { name, .. } | LegacyForm::DropDown { name, .. } => name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on(v: &str) -> bool {
|
||||
!matches!(v.trim(), "0" | "false" | "off")
|
||||
}
|
||||
|
||||
/// The element `name` in `xml` with its content: from its start tag to its end tag, or the
|
||||
/// self-closing tag alone.
|
||||
fn element<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
|
||||
let open = tag(xml, name)?;
|
||||
let start = xml.find(open)?;
|
||||
if open.ends_with("/>") {
|
||||
return Some(open);
|
||||
}
|
||||
let close = format!("</{name}>");
|
||||
let end = xml.get(start..)?.find(&close)? + start + close.len();
|
||||
xml.get(start..end)
|
||||
}
|
||||
|
||||
/// The first start (or empty) tag `<name ...>` in `xml`.
|
||||
fn tag<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
|
||||
tags(xml, name).next()
|
||||
}
|
||||
|
||||
/// Every start (or empty) tag `<name ...>` in `xml`, in order.
|
||||
fn tags<'a>(xml: &'a str, name: &str) -> impl Iterator<Item = &'a str> + use<'a> {
|
||||
let pat = format!("<{name}");
|
||||
let mut from = 0;
|
||||
std::iter::from_fn(move || {
|
||||
loop {
|
||||
let at = from + xml.get(from..)?.find(&pat)?;
|
||||
let after = at + pat.len();
|
||||
from = after;
|
||||
// `<w:checkBox` must not match `<w:checkBoxes`: the name ends at a space, `/` or `>`.
|
||||
if xml.get(after..).and_then(|s| s.chars().next()).is_some_and(|c| c == ' ' || c == '/' || c == '>') {
|
||||
let end = after + xml.get(after..)?.find('>')? + 1;
|
||||
return xml.get(at..end);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The `w:val` of the first tag `name` in `xml`.
|
||||
fn attr_of(xml: &str, name: &str) -> Option<String> {
|
||||
tag(xml, name).and_then(attr_in)
|
||||
}
|
||||
|
||||
/// The `w:val` of a single tag, XML entities resolved.
|
||||
fn attr_in(tag: &str) -> Option<String> {
|
||||
let at = tag.find("w:val=")? + "w:val=".len();
|
||||
let quote = tag.get(at..)?.chars().next()?;
|
||||
if quote != '"' && quote != '\'' {
|
||||
return None;
|
||||
}
|
||||
let rest = tag.get(at + 1..)?;
|
||||
let end = rest.find(quote)?;
|
||||
Some(unescape(rest.get(..end)?))
|
||||
}
|
||||
|
||||
fn unescape(s: &str) -> String {
|
||||
s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn check_boxes_text_fields_and_drop_downs() {
|
||||
let cb = |inner: &str| format!(r#"<w:ffData><w:name w:val="Box1"/><w:enabled/><w:checkBox>{inner}</w:checkBox></w:ffData>"#);
|
||||
assert_eq!(
|
||||
LegacyForm::parse(&cb(r#"<w:sizeAuto/><w:default w:val="0"/>"#)),
|
||||
Some(LegacyForm::CheckBox { name: "Box1".into(), checked: false, size: None })
|
||||
);
|
||||
assert_eq!(
|
||||
LegacyForm::parse(&cb(r#"<w:sizeAuto/><w:default w:val="1"/>"#)).map(|f| matches!(f, LegacyForm::CheckBox { checked: true, .. })),
|
||||
Some(true)
|
||||
);
|
||||
// `w:checked` wins over `w:default`; without a value it means checked.
|
||||
assert!(matches!(
|
||||
LegacyForm::parse(&cb(r#"<w:default w:val="1"/><w:checked w:val="0"/>"#)),
|
||||
Some(LegacyForm::CheckBox { checked: false, .. })
|
||||
));
|
||||
assert!(matches!(LegacyForm::parse(&cb(r#"<w:default w:val="0"/><w:checked/>"#)), Some(LegacyForm::CheckBox { checked: true, .. })));
|
||||
assert!(matches!(
|
||||
LegacyForm::parse(&cb(r#"<w:size w:val="30"/><w:default w:val="0"/>"#)),
|
||||
Some(LegacyForm::CheckBox { size: Some(15.0), .. })
|
||||
));
|
||||
let dd = r#"<w:ffData><w:name w:val="List1"/><w:ddList><w:result w:val="1"/><w:listEntry w:val="Alpha"/><w:listEntry w:val="B & C"/></w:ddList></w:ffData>"#;
|
||||
let f = LegacyForm::parse(dd).unwrap();
|
||||
assert_eq!(f.shown_text(), Some("B & C"));
|
||||
assert_eq!(f.name(), "List1");
|
||||
let dd0 = r#"<w:ffData><w:ddList><w:listEntry w:val="Only"/></w:ddList></w:ffData>"#;
|
||||
assert_eq!(LegacyForm::parse(dd0).and_then(|f| f.shown_text().map(str::to_string)).as_deref(), Some("Only"));
|
||||
let tx = r#"<w:ffData><w:name w:val="Name1"/><w:textInput><w:maxLength w:val="40"/></w:textInput></w:ffData>"#;
|
||||
assert_eq!(LegacyForm::parse(tx), Some(LegacyForm::Text { name: "Name1".into(), max_len: Some(40) }));
|
||||
assert_eq!(LegacyForm::parse(""), None);
|
||||
assert_eq!(LegacyForm::parse("<w:ffData><w:name w:val=\"x\"/></w:ffData>"), None);
|
||||
}
|
||||
}
|
||||
+1192
-44
File diff suppressed because it is too large
Load Diff
@@ -1,124 +0,0 @@
|
||||
//! DrawingML outline dashes, measured in multiples of the stroke width.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const DASH_PERCENT: u32 = 100_000;
|
||||
pub const MAX_DASH_STOPS: usize = 64;
|
||||
const MIN_DASH_POINTS: f32 = 0.01;
|
||||
const MAX_DASH_POINTS: f32 = 20_000.0;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LineDash {
|
||||
#[default]
|
||||
Solid,
|
||||
Dot,
|
||||
Dash,
|
||||
LongDash,
|
||||
DashDot,
|
||||
LongDashDot,
|
||||
LongDashDotDot,
|
||||
SystemDash,
|
||||
SystemDot,
|
||||
SystemDashDot,
|
||||
SystemDashDotDot,
|
||||
/// Dash/space pairs in 100000ths of the line width (`a:ds/@d`, `@sp`).
|
||||
Custom(Vec<(u32, u32)>),
|
||||
}
|
||||
|
||||
impl LineDash {
|
||||
pub fn from_ooxml(value: &str) -> Self {
|
||||
match value {
|
||||
"dot" => Self::Dot,
|
||||
"dash" => Self::Dash,
|
||||
"lgDash" => Self::LongDash,
|
||||
"dashDot" => Self::DashDot,
|
||||
"lgDashDot" => Self::LongDashDot,
|
||||
"lgDashDotDot" => Self::LongDashDotDot,
|
||||
"sysDash" => Self::SystemDash,
|
||||
"sysDot" => Self::SystemDot,
|
||||
"sysDashDot" => Self::SystemDashDot,
|
||||
"sysDashDotDot" => Self::SystemDashDotDot,
|
||||
_ => Self::Solid,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ooxml(&self) -> Option<&'static str> {
|
||||
Some(match self {
|
||||
Self::Solid => "solid",
|
||||
Self::Dot => "dot",
|
||||
Self::Dash => "dash",
|
||||
Self::LongDash => "lgDash",
|
||||
Self::DashDot => "dashDot",
|
||||
Self::LongDashDot => "lgDashDot",
|
||||
Self::LongDashDotDot => "lgDashDotDot",
|
||||
Self::SystemDash => "sysDash",
|
||||
Self::SystemDot => "sysDot",
|
||||
Self::SystemDashDot => "sysDashDot",
|
||||
Self::SystemDashDotDot => "sysDashDotDot",
|
||||
Self::Custom(_) => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_solid(&self) -> bool {
|
||||
*self == Self::Solid
|
||||
}
|
||||
|
||||
/// Page-point lengths for the painters. Bound hostile custom lists and tiny spans.
|
||||
pub fn pattern(&self, width: f32) -> Vec<f32> {
|
||||
let preset: &[f32] = match self {
|
||||
Self::Solid => return Vec::new(),
|
||||
Self::Dot => &[1.0, 3.0],
|
||||
Self::Dash => &[4.0, 3.0],
|
||||
Self::LongDash => &[8.0, 3.0],
|
||||
Self::DashDot => &[4.0, 3.0, 1.0, 3.0],
|
||||
Self::LongDashDot => &[8.0, 3.0, 1.0, 3.0],
|
||||
Self::LongDashDotDot => &[8.0, 3.0, 1.0, 3.0, 1.0, 3.0],
|
||||
Self::SystemDash => &[3.0, 1.0],
|
||||
Self::SystemDot => &[1.0, 1.0],
|
||||
Self::SystemDashDot => &[3.0, 1.0, 1.0, 1.0],
|
||||
Self::SystemDashDotDot => &[3.0, 1.0, 1.0, 1.0, 1.0, 1.0],
|
||||
Self::Custom(stops) => {
|
||||
return stops
|
||||
.iter()
|
||||
.take(MAX_DASH_STOPS)
|
||||
.flat_map(|&(d, sp)| [d, sp])
|
||||
.map(|v| dash_points(v as f32 / DASH_PERCENT as f32, width))
|
||||
.collect();
|
||||
}
|
||||
};
|
||||
preset.iter().map(|v| dash_points(*v, width)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn dash_points(ratio: f32, width: f32) -> f32 {
|
||||
let width = if width.is_finite() { width.clamp(0.25, 200.0) } else { 0.75 };
|
||||
(ratio * width).clamp(MIN_DASH_POINTS, MAX_DASH_POINTS)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dash_units_follow_line_width() {
|
||||
assert_eq!(LineDash::Dash.pattern(1.0), [4.0, 3.0]);
|
||||
assert_eq!(LineDash::Dash.pattern(2.0), [8.0, 6.0]);
|
||||
assert_eq!(LineDash::Custom(vec![(200_000, 150_000)]).pattern(2.0), [4.0, 3.0]);
|
||||
assert!(LineDash::Solid.pattern(2.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_shapes_default_to_solid() {
|
||||
let extra: crate::connector::ShapeExtra = serde_json::from_str("{}").unwrap();
|
||||
assert!(extra.dash.is_solid());
|
||||
assert!(!serde_json::to_string(&extra).unwrap().contains("dash"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_dashes_are_bounded() {
|
||||
let pattern = LineDash::Custom(vec![(0, u32::MAX); MAX_DASH_STOPS + 1]).pattern(f32::NAN);
|
||||
assert_eq!(pattern.len(), MAX_DASH_STOPS * 2);
|
||||
assert!(pattern.iter().all(|v| v.is_finite() && *v >= MIN_DASH_POINTS && *v <= MAX_DASH_POINTS));
|
||||
}
|
||||
}
|
||||
+5
-19
@@ -9,23 +9,14 @@ pub fn is_web_or_mail_link(url: &str) -> bool {
|
||||
["http", "https", "mailto"].iter().any(|s| scheme.eq_ignore_ascii_case(s))
|
||||
}
|
||||
|
||||
/// Whether a hyperlink may be written as an `href` (or Markdown destination, RTF `HYPERLINK`
|
||||
/// field, ODT `xlink:href`, LaTeX `\href`) in an exported file: a web or email link, a bookmark
|
||||
/// (`#name`) or a relative path. Scripts (`javascript:`, `vbscript:`), `data:`, `file:` and every
|
||||
/// other scheme are refused, read the way a browser reads a URL: ASCII control characters and
|
||||
/// spaces are dropped first, so `java\nscript:` is a script. A protocol-relative reference
|
||||
/// (`//host/…`) or a UNC path (`\\host\…`, or `//host` once backslashes are read as separators)
|
||||
/// names another machine — following it on Windows leaks the user's NTLM credentials — so it is
|
||||
/// refused too.
|
||||
/// Whether a hyperlink may be written as an `href` (or Markdown destination) in an exported file:
|
||||
/// a web or email link, a bookmark (`#name`) or a relative path. Scripts (`javascript:`,
|
||||
/// `vbscript:`), `data:`, `file:` and every other scheme are refused, read the way a browser reads
|
||||
/// a URL: ASCII control characters and spaces are dropped first, so `java\nscript:` is a script.
|
||||
pub fn is_safe_href(url: &str) -> bool {
|
||||
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
|
||||
// `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does.
|
||||
if cleaned.replace('\\', "/").starts_with("//") {
|
||||
return false;
|
||||
}
|
||||
match scheme(url) {
|
||||
Some(s) => matches!(s.as_str(), "http" | "https" | "mailto"),
|
||||
None => !cleaned.is_empty(),
|
||||
None => !url.trim().is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,11 +73,6 @@ mod tests {
|
||||
"data:text/html,<script>alert(1)</script>",
|
||||
"file:///etc/passwd",
|
||||
"ms-msdt:/id",
|
||||
// Protocol-relative and UNC references name another machine (NTLM leak on Windows).
|
||||
"//attacker.example/share/x",
|
||||
"\\\\attacker.example\\share\\x",
|
||||
"/\\attacker.example\\x",
|
||||
" //attacker.example/x",
|
||||
"",
|
||||
] {
|
||||
assert!(!is_safe_href(bad), "{bad:?}");
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
//! Numbers written the way a language writes them: `1 250,50` in Polish, `1.250,50` in German,
|
||||
//! `1,250.50` in English. Used by table sort and table formulas.
|
||||
|
||||
use crate::collate::language;
|
||||
|
||||
/// The decimal separator of language `lang` (a BCP 47 tag; `None` means English).
|
||||
pub fn decimal_separator(lang: Option<&str>) -> char {
|
||||
let tag = lang.unwrap_or("").trim().to_ascii_lowercase().replace('_', "-");
|
||||
// Regions that write a decimal point where the language usually writes a comma.
|
||||
const POINT_REGIONS: &[&str] =
|
||||
&["de-ch", "de-li", "it-ch", "es-mx", "es-us", "es-419", "es-pr", "es-do", "es-gt", "es-hn", "es-ni", "es-pa", "es-sv", "es-cu", "es-pe"];
|
||||
if POINT_REGIONS.iter().any(|r| tag == *r || tag.starts_with(&format!("{r}-"))) {
|
||||
return '.';
|
||||
}
|
||||
const COMMA: &[&str] = &[
|
||||
"af", "az", "be", "bg", "bs", "ca", "cs", "da", "de", "el", "es", "et", "eu", "fi", "fo", "fr", "gl", "hr", "hu", "hy", "id", "is", "it",
|
||||
"ka", "kk", "ky", "lb", "lt", "lv", "mk", "mn", "nb", "nl", "nn", "no", "pl", "pt", "ro", "ru", "sk", "sl", "sq", "sr", "sv", "tr", "uk",
|
||||
"uz", "vi",
|
||||
];
|
||||
if COMMA.contains(&language(lang).as_str()) { ',' } else { '.' }
|
||||
}
|
||||
|
||||
const SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\u{2009}'];
|
||||
const CURRENCY: &[char] = &['$', '€', '£', '¥', '¢', '₽', '₴', '₺', '₹', '₩', '₪', '₫', '฿', '₱', '₸', '₼', '₾'];
|
||||
|
||||
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`) from either end.
|
||||
fn strip_currency(s: &str) -> &str {
|
||||
let mut s = s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c));
|
||||
if let Some((head, word)) = s.rsplit_once(SPACES)
|
||||
&& (1..=3).contains(&word.chars().count())
|
||||
&& word.chars().all(|c| c.is_alphabetic() || c == '.')
|
||||
{
|
||||
s = head;
|
||||
}
|
||||
if let Some((word, tail)) = s.split_once(SPACES)
|
||||
&& word.chars().count() == 3
|
||||
&& word.chars().all(|c| c.is_ascii_uppercase())
|
||||
{
|
||||
s = tail;
|
||||
}
|
||||
s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c))
|
||||
}
|
||||
|
||||
/// Digits of an integer part with group separators `groups`: the first group has 1–3 digits,
|
||||
/// every later one exactly 3.
|
||||
fn grouped_digits(int: &str, groups: &[char]) -> Option<String> {
|
||||
if int.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Some(int.to_string());
|
||||
}
|
||||
let parts: Vec<&str> = int.split(groups).collect();
|
||||
let (first, rest) = parts.split_first()?;
|
||||
// A first group of `0` is never grouping (`0.125` is a fraction, not 125).
|
||||
let ok = (1..=3).contains(&first.len())
|
||||
&& !first.starts_with('0')
|
||||
&& first.chars().all(|c| c.is_ascii_digit())
|
||||
&& rest.iter().all(|g| g.len() == 3 && g.chars().all(|c| c.is_ascii_digit()));
|
||||
ok.then(|| parts.concat())
|
||||
}
|
||||
|
||||
/// Parse `s` as a number written in language `lang`, or `None` when it isn't one. The decimal
|
||||
/// separator is the language's; group separators are spaces (and `.` or `'` where the decimal
|
||||
/// separator is a comma, `,` or `'` where it is a point) between groups of three digits. A
|
||||
/// currency sign or short currency word is ignored, and so is a trailing `%` (`12 %` is 12, as
|
||||
/// table sort reads it). In comma languages a lone `.` that cannot be a group separator (`1.5`)
|
||||
/// is read as a decimal point. A trailing separator with no digits after it (`1.`, an ordinal
|
||||
/// in a numbered column) is the whole number. A minus may be `-`, `−` or an en dash.
|
||||
pub fn parse_number(s: &str, lang: Option<&str>) -> Option<f64> {
|
||||
let dec = decimal_separator(lang);
|
||||
let s = s.trim();
|
||||
let s = s.strip_suffix('%').map_or(s, str::trim_end);
|
||||
let mut body = strip_currency(s);
|
||||
let mut neg = false;
|
||||
if let Some(rest) = body.strip_prefix(['-', '\u{2212}', '\u{2013}']) {
|
||||
neg = true;
|
||||
body = strip_currency(rest);
|
||||
} else if let Some(rest) = body.strip_prefix('+') {
|
||||
body = strip_currency(rest);
|
||||
}
|
||||
if body.is_empty() || body.len() > 400 {
|
||||
return None;
|
||||
}
|
||||
let mut groups: Vec<char> = SPACES.to_vec();
|
||||
groups.extend(['\'', '\u{2019}']);
|
||||
groups.push(if dec == ',' { '.' } else { ',' });
|
||||
let (int, frac) = match body.split_once(dec) {
|
||||
Some((i, f)) => (i, Some(f)),
|
||||
None => (body, None),
|
||||
};
|
||||
let int_digits = match grouped_digits(int, &groups) {
|
||||
Some(d) => d,
|
||||
None if dec == ',' && frac.is_none() && int.matches('.').count() == 1 => {
|
||||
return parse_number(&format!("{}{}", if neg { "-" } else { "" }, int.replace('.', ",")), lang);
|
||||
}
|
||||
None => return None,
|
||||
};
|
||||
let frac_digits = match frac {
|
||||
Some(f) if f.chars().all(|c| c.is_ascii_digit()) => f,
|
||||
Some(_) => return None,
|
||||
None => "",
|
||||
};
|
||||
if int_digits.is_empty() && frac_digits.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let v: f64 = format!("0{int_digits}.{frac_digits}0").parse().ok()?;
|
||||
if !v.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some(if neg { -v } else { v })
|
||||
}
|
||||
|
||||
/// Write `v` the way language `lang` writes a formula result: whole numbers without decimals,
|
||||
/// others with two decimals and the language's decimal separator (`5,75` in Polish).
|
||||
pub fn format_number(v: f64, lang: Option<&str>) -> String {
|
||||
if !v.is_finite() {
|
||||
return "0".into();
|
||||
}
|
||||
if v.fract() == 0.0 && v.abs() < 9.2e18 {
|
||||
return format!("{}", v as i64);
|
||||
}
|
||||
let s = format!("{v:.2}");
|
||||
let s = if s == "-0.00" { "0.00".to_string() } else { s };
|
||||
if decimal_separator(lang) == ',' { s.replace('.', ",") } else { s }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn polish_numbers() {
|
||||
let pl = Some("pl-PL");
|
||||
assert_eq!(parse_number("1,5", pl), Some(1.5));
|
||||
assert_eq!(parse_number("3,50", pl), Some(3.5));
|
||||
assert_eq!(parse_number("1 250,00", pl), Some(1250.0));
|
||||
assert_eq!(parse_number("1\u{a0}250,00 zł", pl), Some(1250.0));
|
||||
assert_eq!(parse_number("1.250,00", pl), Some(1250.0));
|
||||
assert_eq!(parse_number("1.5", pl), Some(1.5));
|
||||
assert_eq!(parse_number("-2,25", pl), Some(-2.25));
|
||||
assert_eq!(parse_number("PLN 12", pl), Some(12.0));
|
||||
assert_eq!(parse_number("abc", pl), None);
|
||||
assert_eq!(parse_number("1,2,3", pl), None);
|
||||
// From review: ordinals, a leading zero, percentages and an en dash minus.
|
||||
assert_eq!(parse_number("1.", pl), Some(1.0), "a numbered column: 1.");
|
||||
assert_eq!(parse_number("10.", pl), Some(10.0));
|
||||
assert_eq!(parse_number("0.125", pl), Some(0.125), "a leading 0 is never a group");
|
||||
assert_eq!(parse_number("0,125", pl), Some(0.125));
|
||||
assert_eq!(parse_number("12 %", pl), Some(12.0));
|
||||
assert_eq!(parse_number("12%", pl), Some(12.0));
|
||||
assert_eq!(parse_number("\u{2013}2,5", pl), Some(-2.5));
|
||||
assert_eq!(parse_number("%", pl), None);
|
||||
assert_eq!(parse_number(".", pl), None);
|
||||
assert_eq!(format_number(5.75, pl), "5,75");
|
||||
assert_eq!(format_number(1350.0, pl), "1350");
|
||||
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")] {
|
||||
assert_eq!(parse_number("1.234", lang), Some(1.234));
|
||||
assert_eq!(parse_number("1,234.50", lang), Some(1234.5));
|
||||
assert_eq!(format_number(5.75, lang), "5.75");
|
||||
}
|
||||
assert_eq!(parse_number("1.234,50", Some("es-ES")), Some(1234.5));
|
||||
assert_eq!(format_number(5.75, Some("es-ES")), "5,75");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_numbers() {
|
||||
let en = Some("en-US");
|
||||
assert_eq!(parse_number("1,250.50", en), Some(1250.5));
|
||||
assert_eq!(parse_number("$1,250", en), Some(1250.0));
|
||||
assert_eq!(parse_number("1.5", en), Some(1.5));
|
||||
assert_eq!(parse_number("12", None), Some(12.0));
|
||||
assert_eq!(parse_number(".5", None), Some(0.5));
|
||||
assert_eq!(parse_number("1,5", en), None);
|
||||
assert_eq!(parse_number("1.", en), Some(1.0));
|
||||
assert_eq!(parse_number("0,125", en), None, "never 125");
|
||||
assert_eq!(format_number(5.75, en), "5.75");
|
||||
assert_eq!(format_number(-0.001, en), "0.00", "a rounded zero has no sign");
|
||||
assert_eq!(format_number(-0.0, None), "0");
|
||||
assert_eq!(decimal_separator(Some("de-CH")), '.');
|
||||
assert_eq!(decimal_separator(Some("de-DE")), ',');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_numbers_never_panic() {
|
||||
for s in ["", "-", "+", ",", ".", "1e400", "inf", "NaN", "--1", "₽", "1 ,", " zł", "\u{2212}", "9".repeat(500).as_str()] {
|
||||
for l in [None, Some("pl"), Some("en"), Some("")] {
|
||||
let _ = parse_number(s, l);
|
||||
}
|
||||
}
|
||||
assert_eq!(format_number(f64::NAN, None), "0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_whole_numbers_have_no_decimals() {
|
||||
assert_eq!(format_number(1e15, None), "1000000000000000");
|
||||
assert_eq!(format_number(2.5e15, None), "2500000000000000");
|
||||
assert_eq!(format_number(-1e15, None), "-1000000000000000");
|
||||
assert_eq!(format_number(9.0e18, None), "9000000000000000000");
|
||||
assert_eq!(format_number(1e15 + 0.5, None), "1000000000000000.50");
|
||||
}
|
||||
}
|
||||
+21
-333
@@ -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`).
|
||||
@@ -358,7 +340,7 @@ fn yes() -> bool {
|
||||
|
||||
/// A row holds an equation-number mark (`#`) in a top-level run.
|
||||
pub fn has_number_mark(row: &[MNode]) -> bool {
|
||||
row.iter().any(|n| matches!(n, MNode::Run(r) if !r.lit && !r.nor && r.text.contains('#')))
|
||||
row.iter().any(|n| matches!(n, MNode::Run(r) if !r.lit && r.text.contains('#')))
|
||||
}
|
||||
|
||||
/// Integral signs (their limits default to sub/sup positions).
|
||||
@@ -488,27 +470,8 @@ fn lin_arg(a: &[MNode], s: &mut String, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for (i, n) in a.iter().enumerate() {
|
||||
for n in a {
|
||||
lin_node(n, s, depth + 1);
|
||||
// A bare script or limit ends at a space: a letter, digit or period after it is its own item.
|
||||
if matches!(n, MNode::Script { kind, .. } if *kind != ScriptKind::Pre) || matches!(n, MNode::Lim { .. }) {
|
||||
let ends_bare = s.chars().next_back().is_some_and(|c| c.is_alphanumeric() || c == '.');
|
||||
if ends_bare && a.get(i + 1).and_then(first_lin_char).is_some_and(|c| c.is_alphanumeric() || c == '.') {
|
||||
s.push(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first character `n` is written with, when that is cheap to tell (letters and digits).
|
||||
fn first_lin_char(n: &MNode) -> Option<char> {
|
||||
match n {
|
||||
MNode::Run(r) => r.text.chars().next(),
|
||||
MNode::Func { name, .. } => name.first().and_then(first_lin_char),
|
||||
MNode::Script { kind, base, .. } if *kind != ScriptKind::Pre => base.first().and_then(first_lin_char),
|
||||
MNode::Lim { e, .. } => e.first().and_then(first_lin_char),
|
||||
MNode::Frac { num, .. } => num.first().and_then(first_lin_char),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,27 +499,6 @@ fn lin_operand(a: &[MNode], s: &mut String, depth: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A script or limit operand. The parser reads a run of letters and digits up to the next space or
|
||||
/// operator, so `V_dd` is written bare as Word's Linear format shows it (`V_(dd)` reads back the same).
|
||||
fn lin_script(a: &[MNode], s: &mut String, depth: usize) {
|
||||
// Bare only when the parser reads the whole run back, in the same style.
|
||||
if let [MNode::Run(r)] = a
|
||||
&& r.text.chars().nth(1).is_some()
|
||||
{
|
||||
let upright_name = r.sty == Some(MSty::Plain) && crate::math_linear::FUNCTION_NAMES.contains(&r.text.as_str());
|
||||
let number = r.text.starts_with(|c: char| c.is_ascii_digit()) && r.text.chars().all(|c| c.is_alphanumeric() || c == '.');
|
||||
let word = r.text.starts_with(char::is_alphabetic) && r.text.chars().all(char::is_alphanumeric);
|
||||
let upright_other = r.sty == Some(MSty::Plain) && !upright_name;
|
||||
if upright_other || (!number && !word) {
|
||||
s.push('(');
|
||||
s.push_str(&r.text);
|
||||
s.push(')');
|
||||
return;
|
||||
}
|
||||
}
|
||||
lin_operand(a, s, depth);
|
||||
}
|
||||
|
||||
fn lin_node(n: &MNode, s: &mut String, depth: usize) {
|
||||
match n {
|
||||
MNode::Run(r) => s.push_str(&r.text),
|
||||
@@ -573,9 +515,9 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
|
||||
MNode::Script { kind, base, sub, sup } => {
|
||||
if *kind == ScriptKind::Pre {
|
||||
s.push('_');
|
||||
lin_script(sub, s, depth);
|
||||
lin_operand(sub, s, depth);
|
||||
s.push('^');
|
||||
lin_script(sup, s, depth);
|
||||
lin_operand(sup, s, depth);
|
||||
s.push('▒');
|
||||
lin_operand(base, s, depth);
|
||||
return;
|
||||
@@ -583,11 +525,11 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
|
||||
lin_operand(base, s, depth);
|
||||
if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) {
|
||||
s.push('_');
|
||||
lin_script(sub, s, depth);
|
||||
lin_operand(sub, s, depth);
|
||||
}
|
||||
if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) {
|
||||
s.push('^');
|
||||
lin_script(sup, s, depth);
|
||||
lin_operand(sup, s, depth);
|
||||
}
|
||||
}
|
||||
MNode::Rad { deg, deg_hide, e } => {
|
||||
@@ -606,11 +548,11 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
|
||||
s.push(*chr);
|
||||
if !sub_hide && !sub.is_empty() {
|
||||
s.push('_');
|
||||
lin_script(sub, s, depth);
|
||||
lin_operand(sub, s, depth);
|
||||
}
|
||||
if !sup_hide && !sup.is_empty() {
|
||||
s.push('^');
|
||||
lin_script(sup, s, depth);
|
||||
lin_operand(sup, s, depth);
|
||||
}
|
||||
s.push('▒');
|
||||
lin_operand(e, s, depth);
|
||||
@@ -650,7 +592,7 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
|
||||
MNode::Lim { upper, e, lim } => {
|
||||
lin_operand(e, s, depth);
|
||||
s.push(if *upper { '┴' } else { '┬' });
|
||||
lin_script(lim, s, depth);
|
||||
lin_operand(lim, s, depth);
|
||||
}
|
||||
MNode::Acc { chr, e } => {
|
||||
lin_operand(e, s, depth);
|
||||
@@ -759,7 +701,7 @@ pub fn math_class(c: char) -> MClass {
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge adjacent runs with the same formatting; recurse into every structure.
|
||||
/// Merge adjacent runs with the same formatting; recurse into structures.
|
||||
pub fn merge_runs(a: Arg) -> Arg {
|
||||
let mut out: Arg = Vec::with_capacity(a.len());
|
||||
for n in a {
|
||||
@@ -780,79 +722,23 @@ pub fn merge_runs(a: Arg) -> Arg {
|
||||
}
|
||||
MNode::Run(r)
|
||||
}
|
||||
other => crate::math_linear::map_args(other, &merge_runs),
|
||||
MNode::Frac { kind, num, den } => MNode::Frac { kind, num: merge_runs(num), den: merge_runs(den) },
|
||||
MNode::Script { kind, base, sub, sup } => MNode::Script { kind, base: merge_runs(base), sub: merge_runs(sub), sup: merge_runs(sup) },
|
||||
MNode::Rad { deg, deg_hide, e } => MNode::Rad { deg: merge_runs(deg), deg_hide, e: merge_runs(e) },
|
||||
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } => {
|
||||
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub: merge_runs(sub), sup: merge_runs(sup), e: merge_runs(e) }
|
||||
}
|
||||
MNode::Delim { beg, end, sep, grow, shp_match, elems } => {
|
||||
MNode::Delim { beg, end, sep, grow, shp_match, elems: elems.into_iter().map(merge_runs).collect() }
|
||||
}
|
||||
MNode::Func { name, e } => MNode::Func { name: merge_runs(name), e: merge_runs(e) },
|
||||
other => other,
|
||||
};
|
||||
out.push(n);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Deepest node (a leaf counts 1) the parsers wrap further. Half of [`MAX_DEPTH`]: serde_json
|
||||
/// stops reading at 128 nested containers and a node costs two (object and array).
|
||||
pub(crate) const WRAP_DEPTH: usize = MAX_DEPTH / 2;
|
||||
|
||||
/// Calls `f` with every argument of `n`.
|
||||
fn each_arg<'a>(n: &'a MNode, f: &mut impl FnMut(&'a [MNode])) {
|
||||
match n {
|
||||
MNode::Run(_) => {}
|
||||
MNode::Frac { num, den, .. } => {
|
||||
f(num);
|
||||
f(den);
|
||||
}
|
||||
MNode::Script { base, sub, sup, .. } => {
|
||||
f(base);
|
||||
f(sub);
|
||||
f(sup);
|
||||
}
|
||||
MNode::Rad { deg, e, .. } => {
|
||||
f(deg);
|
||||
f(e);
|
||||
}
|
||||
MNode::Nary { sub, sup, e, .. } => {
|
||||
f(sub);
|
||||
f(sup);
|
||||
f(e);
|
||||
}
|
||||
MNode::Delim { elems, .. } => elems.iter().for_each(|a| f(a)),
|
||||
MNode::Func { name, e } => {
|
||||
f(name);
|
||||
f(e);
|
||||
}
|
||||
MNode::Lim { e, lim, .. } => {
|
||||
f(e);
|
||||
f(lim);
|
||||
}
|
||||
MNode::Acc { e, .. }
|
||||
| MNode::Bar { e, .. }
|
||||
| MNode::BorderBox { e, .. }
|
||||
| MNode::Boxed { e }
|
||||
| MNode::GroupChr { e, .. }
|
||||
| MNode::Phant { e, .. } => f(e),
|
||||
MNode::EqArr { rows } => rows.iter().for_each(|a| f(a)),
|
||||
MNode::Matrix { rows, .. } => rows.iter().flatten().for_each(|a| f(a)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` is already nested too deeply to be wrapped in another structure. The parsers use this so
|
||||
/// `x^1^1^1…` stays shallow (a left-nested tree 10 000 deep overflows the stack in `Clone`,
|
||||
/// `Drop`, `PartialEq` and serde). Iterative, stops at the first too-deep path; costs at most the
|
||||
/// size of `n`, which the parsers' input caps bound.
|
||||
pub(crate) fn too_deep(n: &MNode) -> bool {
|
||||
let mut stack: Vec<(&MNode, usize)> = vec![(n, 1)];
|
||||
while let Some((n, d)) = stack.pop() {
|
||||
if d >= WRAP_DEPTH {
|
||||
return true;
|
||||
}
|
||||
each_arg(n, &mut |a| stack.extend(a.iter().map(|c| (c, d + 1))));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Any of `a` is too deep to be wrapped ([`too_deep`]).
|
||||
pub(crate) fn any_too_deep(a: &[MNode]) -> bool {
|
||||
a.iter().any(too_deep)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -921,188 +807,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
//T1
|
||||
/// Deepest nesting of `a` (a leaf counts 1), without recursion.
|
||||
fn depth_of(a: &[MNode]) -> usize {
|
||||
let mut best = 0;
|
||||
let mut stack: Vec<(&MNode, usize)> = a.iter().map(|n| (n, 1)).collect();
|
||||
while let Some((n, d)) = stack.pop() {
|
||||
best = best.max(d);
|
||||
each_arg(n, &mut |a| stack.extend(a.iter().map(|c| (c, d + 1))));
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Run `f` on a thread with a stack far below the 8 MB of a main thread (#1456).
|
||||
fn on_small_stack(f: impl FnOnce() + Send + 'static) {
|
||||
std::thread::Builder::new().stack_size(512 * 1024).spawn(f).unwrap().join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_chained_input_stays_shallow() {
|
||||
on_small_stack(|| {
|
||||
let cases: Vec<(&str, Arg)> = vec![
|
||||
("x^1^1…", parse_linear(&format!("x{}", "^1".repeat(9999)))),
|
||||
("a/a/a…", parse_linear(&format!("a{}", "/a".repeat(9999)))),
|
||||
("accents", parse_linear(&format!("x{}", "\u{302}".repeat(9999)))),
|
||||
("limits", parse_linear(&format!("x{}", "┬a".repeat(9000)))),
|
||||
("(a b)^1^1…", parse_linear(&format!("(a b){}", "^1".repeat(9000)))),
|
||||
("latex x^1^1…", crate::math_latex::parse_latex(&format!("x{}", "^1".repeat(9999)))),
|
||||
("latex \\sum…", crate::math_latex::parse_latex(&"\\sum".repeat(5000))),
|
||||
("latex \\sin x^1…", crate::math_latex::parse_latex(&format!("\\sin x{}", "^1".repeat(9000)))),
|
||||
];
|
||||
for (name, n) in cases {
|
||||
assert!(!n.is_empty(), "{name}");
|
||||
assert!(depth_of(&n) <= 70, "{name}: depth {}", depth_of(&n));
|
||||
let copy = n.clone();
|
||||
assert_eq!(copy, n, "{name}");
|
||||
let _ = to_linear(&n);
|
||||
let _ = crate::math_latex::to_latex(&n);
|
||||
let json = serde_json::to_string(&n).unwrap();
|
||||
let back: Arg = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, n, "{name}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_nesting_is_still_built() {
|
||||
// Well below the cap: scripts, fractions and operators still wrap.
|
||||
let n = parse_linear(&format!("x{}", "^1".repeat(10)));
|
||||
assert_eq!(depth_of(&n), 11, "{n:#?}");
|
||||
let n = crate::math_latex::parse_latex(r"\sum_{i}\sum_{j}a_{ij}");
|
||||
assert!(matches!(&n[0], MNode::Nary { e, .. } if matches!(e.first(), Some(MNode::Nary { .. }))), "{n:#?}");
|
||||
}
|
||||
|
||||
//T2
|
||||
#[test]
|
||||
fn latex_text_escapes_what_latex_reads_as_markup() {
|
||||
for text in ["a}b\\c", "{x}", "50% of #1", "a_b&c$d~e^f", "\\", "\\alpha", "\\{"] {
|
||||
let n = vec![MNode::Run(MRun { text: text.into(), nor: true, sty: Some(MSty::Plain), ..Default::default() })];
|
||||
let tex = crate::math_latex::to_latex(&n);
|
||||
assert_eq!(crate::math_latex::parse_latex(&tex), n, "{text:?} → {tex}");
|
||||
}
|
||||
let n = vec![MNode::Run(MRun::new("a\\b"))];
|
||||
assert_eq!(crate::math_latex::parse_latex(&crate::math_latex::to_latex(&n)), n);
|
||||
}
|
||||
|
||||
//T3
|
||||
#[test]
|
||||
fn latex_runs_merge_inside_every_structure() {
|
||||
use crate::math_latex::parse_latex;
|
||||
for (tex, lin) in [(r"\overline{ab}", "¯(ab)"), (r"\underline{ab}", "▁(ab)"), (r"\boxed{ab}", "▭(ab)"), (r"\phantom{ab}", "⟡(ab)")] {
|
||||
assert_eq!(parse_latex(tex), parse_linear(lin), "{tex}");
|
||||
}
|
||||
let n = parse_latex(r"\begin{matrix}a b&c d\\e f&g\end{matrix}");
|
||||
let MNode::Matrix { rows, .. } = &n[0] else { panic!("{n:#?}") };
|
||||
assert!(rows.iter().flatten().all(|c| c.len() == 1), "{rows:#?}");
|
||||
let n = parse_latex(r"\hat{ab}\underbrace{ab}\underset{ab}{cd}");
|
||||
let runs = |a: &Arg| a.iter().filter(|n| matches!(n, MNode::Run(_))).count();
|
||||
assert!(matches!(&n[0], MNode::Acc { e, .. } if runs(e) == 1), "{n:#?}");
|
||||
assert!(matches!(&n[1], MNode::GroupChr { e, .. } if runs(e) == 1), "{n:#?}");
|
||||
assert!(matches!(&n[2], MNode::Lim { e, lim, .. } if runs(e) == 1 && runs(lim) == 1), "{n:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_letter_scripts_round_trip() {
|
||||
// Word for Mac 16.113.4 (#1609): a script is the whole run up to a space or operator, and Word's
|
||||
// Linear format shows it bare (`V_dd`); the parenthesised form reads back identically.
|
||||
let script = |kind, sub: &str, sup: &str| MNode::Script { kind, base: vec![run("V")], sub: parse_linear(sub), sup: parse_linear(sup) };
|
||||
for n in [
|
||||
vec![script(ScriptKind::Sub, "dd", "")],
|
||||
vec![script(ScriptKind::Sup, "", "ab")],
|
||||
vec![script(ScriptKind::SubSup, "in", "2")],
|
||||
vec![MNode::Nary {
|
||||
chr: '∑',
|
||||
lim_loc: None,
|
||||
grow: false,
|
||||
sub_hide: false,
|
||||
sup_hide: false,
|
||||
sub: vec![run("ij")],
|
||||
sup: vec![run("n")],
|
||||
e: vec![run("x")],
|
||||
}],
|
||||
vec![MNode::Lim { upper: false, e: vec![run("⟶")], lim: vec![run("yields")] }],
|
||||
] {
|
||||
assert_eq!(parse_linear(&to_linear(&n)), n, "{}", to_linear(&n));
|
||||
}
|
||||
// Numbers and single letters stay bare.
|
||||
assert_eq!(to_linear(&[script(ScriptKind::SubSup, "10", "2.5")]), "V_10^2.5");
|
||||
assert_eq!(to_linear(&[script(ScriptKind::Sub, "d", "")]), "V_d");
|
||||
assert_eq!(to_linear(&[script(ScriptKind::Sub, "dd", "")]), "V_dd");
|
||||
assert_eq!(to_linear(&[script(ScriptKind::Sup, "", "ab")]), "V^ab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_letter_script_operands_follow_word() {
|
||||
// #1609, Word for Mac 16.113.4: `V_dd` and `V_(dd)` build up the same; `dd` is all subscript.
|
||||
let sub_dd = MNode::Script { kind: ScriptKind::Sub, base: vec![run("V")], sub: vec![run("dd")], sup: vec![] };
|
||||
assert_eq!(parse_linear("V_dd"), vec![sub_dd.clone()]);
|
||||
assert_eq!(parse_linear("V_(dd)"), vec![sub_dd]);
|
||||
let sup_ab = MNode::Script { kind: ScriptKind::Sup, base: vec![run("x")], sub: vec![], sup: vec![run("ab")] };
|
||||
assert_eq!(parse_linear("x^ab"), vec![sup_ab.clone()]);
|
||||
assert_eq!(parse_linear("x^(ab)"), vec![sup_ab]);
|
||||
// Delimiters still end the operand.
|
||||
let both = MNode::Script { kind: ScriptKind::SubSup, base: vec![run("x")], sub: vec![run("i")], sup: vec![run("2")] };
|
||||
assert_eq!(parse_linear("x_i^2"), vec![both]);
|
||||
let a_i = MNode::Script { kind: ScriptKind::Sub, base: vec![run("a")], sub: vec![run("i")], sup: vec![] };
|
||||
assert_eq!(parse_linear("a_i b"), vec![a_i, run("b")]);
|
||||
let both2 = MNode::Script { kind: ScriptKind::SubSup, base: vec![run("x")], sub: vec![run("ij")], sup: vec![run("kl")] };
|
||||
assert_eq!(parse_linear("x_ij^kl"), vec![both2]);
|
||||
// Round trip is Word's bare form.
|
||||
assert_eq!(to_linear(&parse_linear("V_(dd)")), "V_dd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_script_does_not_absorb_what_follows() {
|
||||
// #1609 review: `x_i` then `y` must not read back as x with subscript `iy`.
|
||||
let sc =
|
||||
|kind, base: &str, sub: &str, sup: &str| MNode::Script { kind, base: vec![run(base)], sub: parse_linear(sub), sup: parse_linear(sup) };
|
||||
let sin_x = MNode::Func { name: vec![MNode::Run(MRun::plain("sin"))], e: vec![run("x")] };
|
||||
let lim = MNode::Lim { upper: false, e: vec![run("a")], lim: vec![run("n")] };
|
||||
for n in [
|
||||
vec![sc(ScriptKind::Sub, "x", "i", ""), run("y")],
|
||||
vec![sc(ScriptKind::Sub, "a", "i", ""), sc(ScriptKind::Sub, "b", "j", "")],
|
||||
vec![sc(ScriptKind::Sup, "x", "", "2"), run("y")],
|
||||
vec![sc(ScriptKind::Sup, "x", "", "2"), run("3")],
|
||||
vec![sc(ScriptKind::Sub, "x", "i", ""), sin_x],
|
||||
vec![lim.clone(), run("y")],
|
||||
vec![sc(ScriptKind::Sub, "x", "ab", ""), run("y")],
|
||||
vec![sc(ScriptKind::Sub, "x", "i", ""), run(".5")],
|
||||
] {
|
||||
let lin = to_linear(&n);
|
||||
assert_eq!(parse_linear(&lin), n, "{lin}");
|
||||
}
|
||||
assert_eq!(to_linear(&[sc(ScriptKind::Sub, "x", "i", ""), run("y")]), "x_i y");
|
||||
// Nothing extra when the script already ends in a bracket or a symbol follows.
|
||||
assert_eq!(to_linear(&[sc(ScriptKind::Sub, "x", "i", ""), run("+")]), "x_i+");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_function_name_in_a_script_stays_upright() {
|
||||
// Word for Mac (#1609): `x_max` and `x_(max)` both give an upright function name.
|
||||
let up = vec![MNode::Script { kind: ScriptKind::Sub, base: vec![run("x")], sub: vec![MNode::Run(MRun::plain("max"))], sup: vec![] }];
|
||||
assert_eq!(parse_linear("x_max"), up);
|
||||
assert_eq!(parse_linear("x_(max)"), up);
|
||||
assert_eq!(parse_linear(&to_linear(&up)), up, "{}", to_linear(&up));
|
||||
// Other upright text is not a function name: it keeps its parentheses.
|
||||
let other = vec![MNode::Script { kind: ScriptKind::Sub, base: vec![run("x")], sub: vec![MNode::Run(MRun::plain("ab"))], sup: vec![] }];
|
||||
assert_eq!(to_linear(&other), "x_(ab)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digits_then_letters_continue_a_script() {
|
||||
// Word for Mac (#1609): a script runs to a space or operator, so `x_2a` is subscript "2a".
|
||||
let sub = |t: &str| vec![MNode::Script { kind: ScriptKind::Sub, base: vec![run("x")], sub: vec![run(t)], sup: vec![] }];
|
||||
assert_eq!(parse_linear("x_2a"), sub("2a"));
|
||||
assert_eq!(parse_linear("x_a2"), sub("a2"));
|
||||
assert_eq!(parse_linear("x_αβ"), sub("αβ"));
|
||||
assert_eq!(parse_linear("x_10"), sub("10"));
|
||||
assert_eq!(parse_linear("x_2.5"), sub("2.5"));
|
||||
assert_eq!(to_linear(&sub("2a")), "x_2a");
|
||||
assert_eq!(parse_linear(&to_linear(&sub("2a"))), sub("2a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_linear_never_panics() {
|
||||
for s in ["", "/", "^", "_", "√", "∑", "((((", "))))", "■(", "█(@@&&", "a/", "/b", "^^^__", "√(&)", "∫_^▒", "lim_", "sin"] {
|
||||
@@ -1113,20 +817,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
-97
@@ -362,26 +362,6 @@ pub fn tidy(root: &mut Arg) {
|
||||
go(root, 0);
|
||||
}
|
||||
|
||||
/// Call `f` on every run in the tree.
|
||||
pub fn each_run_mut(root: &mut Arg, f: &mut dyn FnMut(&mut MRun)) {
|
||||
fn go(a: &mut Arg, f: &mut dyn FnMut(&mut MRun), depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for n in a.iter_mut() {
|
||||
if let MNode::Run(r) = n {
|
||||
f(r);
|
||||
}
|
||||
for c in 0..child_count(n) {
|
||||
if let Some(ch) = child_mut(n, c) {
|
||||
go(ch, f, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
go(root, f, 0);
|
||||
}
|
||||
|
||||
fn merge_shallow(a: Arg) -> Arg {
|
||||
let mut out: Arg = Vec::with_capacity(a.len());
|
||||
for n in a {
|
||||
@@ -1061,8 +1041,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 +1121,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() {
|
||||
@@ -1204,21 +1179,6 @@ mod tests {
|
||||
assert!(matches!(eq[0], MNode::Frac { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_up_keeps_a_multi_letter_subscript() {
|
||||
// #1609: `dd` typed in a subscript box split into V_d and d on the next space.
|
||||
let mut eq: Arg = Vec::new();
|
||||
let p = insert_nodes(&mut eq, &MathPos::default(), parse_linear("⬚_⬚"));
|
||||
let p = insert_text(&mut eq, &p, "V", TypeStyle::default());
|
||||
let p = move_right(&eq, &p).unwrap();
|
||||
let p = insert_text(&mut eq, &p, "dd", TypeStyle::default());
|
||||
let p = move_right(&eq, &p).unwrap();
|
||||
assert_eq!(p, MathPos::new(vec![], 1));
|
||||
let built = eq.clone();
|
||||
assert_eq!(build_up(&mut eq, &p, false), None);
|
||||
assert_eq!(eq, built, "{}", lin(&eq));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_dissolves_and_removes() {
|
||||
let mut eq = parse_linear("√x");
|
||||
|
||||
+224
-452
@@ -5,9 +5,7 @@
|
||||
//! accents, `\overbrace`/`\underbrace`, `\text`/`\mathrm`/`\mathbf`/`\mathbb`…, function names,
|
||||
//! spacing and every named symbol in [`crate::math_symbols`]. Unknown commands stay as text.
|
||||
|
||||
use crate::math::{
|
||||
Arg, ColJc, FracKind, MAX_DEPTH, MNode, MRun, MScr, MSty, ScriptKind, WRAP_DEPTH, any_too_deep, is_nary_char, math_class, merge_runs, too_deep,
|
||||
};
|
||||
use crate::math::{Arg, FracKind, MAX_DEPTH, MNode, MRun, MScr, MSty, ScriptKind, is_nary_char, math_class, merge_runs};
|
||||
use crate::math_linear::{FUNCTION_NAMES, map_args};
|
||||
use crate::math_symbols::{name_of, symbol};
|
||||
|
||||
@@ -47,7 +45,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 {
|
||||
@@ -111,7 +109,7 @@ pub fn parse_latex(s: &str) -> Arg {
|
||||
p.i += 1;
|
||||
}
|
||||
let out = merge_runs(out);
|
||||
if out.iter().any(|n| matches!(n, MNode::Run(r) if !r.nor && r.text.contains('#'))) {
|
||||
if out.iter().any(|n| matches!(n, MNode::Run(r) if r.text.contains('#'))) {
|
||||
return vec![MNode::EqArr { rows: vec![out] }];
|
||||
}
|
||||
out
|
||||
@@ -149,13 +147,8 @@ impl Lp {
|
||||
Tok::Sup | Tok::Sub => {
|
||||
self.i += 1;
|
||||
let s = self.arg();
|
||||
if out.last().is_some_and(too_deep) {
|
||||
// Too deep to wrap (`x^1^1^1…`): the operator is dropped, the script kept.
|
||||
out.extend(s);
|
||||
} else {
|
||||
let base = out.pop();
|
||||
out.push(script(base, tok == Tok::Sup, s));
|
||||
}
|
||||
let base = out.pop();
|
||||
out.push(script(base, tok == Tok::Sup, s));
|
||||
}
|
||||
Tok::Cmd(ref c) if c == "limits" || c == "nolimits" || c == "displaystyle" || c == "textstyle" => {
|
||||
self.i += 1;
|
||||
@@ -178,7 +171,7 @@ impl Lp {
|
||||
}
|
||||
self.depth -= 1;
|
||||
// Big operators take the following term as their operand.
|
||||
nary_operands(out, self.depth)
|
||||
nary_operands(out)
|
||||
}
|
||||
|
||||
/// A command argument: a `{group}` or one token.
|
||||
@@ -231,14 +224,10 @@ impl Lp {
|
||||
}
|
||||
self.i += 1;
|
||||
let mut level = 0i32;
|
||||
// The previous token was a control word (`\backslash{}`: the empty braces end its name).
|
||||
let mut after_word = false;
|
||||
while let Some(t) = self.peek().cloned() {
|
||||
self.i += 1;
|
||||
let was_word = std::mem::replace(&mut after_word, matches!(&t, Tok::Cmd(c) if c.chars().all(|c| c.is_ascii_alphabetic())));
|
||||
match t {
|
||||
Tok::Close if level == 0 => break,
|
||||
Tok::Open if was_word && self.peek() == Some(&Tok::Close) => self.i += 1,
|
||||
Tok::Open => {
|
||||
level += 1;
|
||||
s.push('{');
|
||||
@@ -324,21 +313,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();
|
||||
@@ -395,30 +369,9 @@ impl Lp {
|
||||
one(MNode::Run(MRun { text: t, nor: true, sty: Some(MSty::Italic), ..Default::default() }))
|
||||
}
|
||||
"operatorname" => {
|
||||
let starred = self.peek() == Some(&Tok::Ch('*'));
|
||||
if starred {
|
||||
self.i += 1;
|
||||
}
|
||||
let t = self.raw_arg();
|
||||
let name = vec![MNode::Run(MRun::plain(t))];
|
||||
if starred && self.peek() == Some(&Tok::Sub) {
|
||||
self.i += 1;
|
||||
let lim = self.arg();
|
||||
vec![MNode::Lim { upper: false, e: name, lim }]
|
||||
} else {
|
||||
self.apply(name)
|
||||
}
|
||||
}
|
||||
"tag" => {
|
||||
let starred = self.peek() == Some(&Tok::Ch('*'));
|
||||
if starred {
|
||||
self.i += 1;
|
||||
}
|
||||
let t = self.raw_arg();
|
||||
let num_str = if starred { format!("#{t}") } else { format!("# ({t})") };
|
||||
let mut r = MRun::new(num_str);
|
||||
r.lit = false;
|
||||
vec![MNode::Run(r)]
|
||||
self.apply(name)
|
||||
}
|
||||
"mathrm" | "rm" => {
|
||||
let a = self.arg();
|
||||
@@ -460,15 +413,13 @@ impl Lp {
|
||||
"underline" => one(MNode::Bar { top: false, e: self.arg() }),
|
||||
"boxed" | "fbox" => one(MNode::BorderBox { hide: [false; 4], strike: [false; 4], e: self.arg() }),
|
||||
"phantom" => one(MNode::Phant { show: false, zero_wid: false, zero_asc: false, zero_desc: false, e: self.arg() }),
|
||||
"overbrace" | "underbrace" | "overparen" | "underparen" | "overbracket" | "underbracket" => {
|
||||
"overbrace" | "underbrace" | "overparen" | "underparen" => {
|
||||
let top = c.starts_with("over");
|
||||
let chr = match c {
|
||||
"overbrace" => '⏞',
|
||||
"underbrace" => '⏟',
|
||||
"overparen" => '⏜',
|
||||
"underparen" => '⏝',
|
||||
"overbracket" => '⎴',
|
||||
_ => '⎵',
|
||||
_ => '⏝',
|
||||
};
|
||||
let e = self.arg();
|
||||
let g = MNode::GroupChr { chr, top, e };
|
||||
@@ -564,10 +515,6 @@ impl Lp {
|
||||
while let Some(t @ (Tok::Sup | Tok::Sub)) = self.peek().cloned() {
|
||||
self.i += 1;
|
||||
let s = self.arg();
|
||||
if any_too_deep(&e) {
|
||||
e.extend(s);
|
||||
continue;
|
||||
}
|
||||
let base = if e.len() == 1 { e.pop() } else { Some(group(std::mem::take(&mut e))) };
|
||||
e.push(script(base, t == Tok::Sup, s));
|
||||
}
|
||||
@@ -578,22 +525,6 @@ impl Lp {
|
||||
}
|
||||
|
||||
fn environment(&mut self, env: &str) -> Arg {
|
||||
let base = env.trim_end_matches('*');
|
||||
let mut col_jc = Vec::new();
|
||||
if base == "array" || base == "tabular" {
|
||||
let _ = self.opt();
|
||||
let spec = self.raw_arg();
|
||||
for ch in spec.chars() {
|
||||
match ch {
|
||||
'l' | 'L' => col_jc.push(ColJc::Left),
|
||||
'r' | 'R' => col_jc.push(ColJc::Right),
|
||||
'c' | 'C' => col_jc.push(ColJc::Center),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
} else if base == "aligned" {
|
||||
let _ = self.opt();
|
||||
}
|
||||
// Rows of `&`-separated cells up to `\end{…}`.
|
||||
let mut rows: Vec<Vec<Arg>> = vec![vec![]];
|
||||
loop {
|
||||
@@ -621,6 +552,7 @@ impl Lp {
|
||||
if rows.len() > 1 && rows.last().is_some_and(|r| r.iter().all(|c| c.is_empty())) {
|
||||
rows.pop();
|
||||
}
|
||||
let base = env.trim_end_matches('*');
|
||||
let delims = match base {
|
||||
"pmatrix" => Some((Some('('), Some(')'))),
|
||||
"bmatrix" => Some((Some('['), Some(']'))),
|
||||
@@ -638,8 +570,8 @@ impl Lp {
|
||||
elems: vec![vec![inner]],
|
||||
};
|
||||
match base {
|
||||
"matrix" | "pmatrix" | "bmatrix" | "Bmatrix" | "vmatrix" | "Vmatrix" | "smallmatrix" | "array" | "tabular" => {
|
||||
let m = MNode::Matrix { rows, col_jc };
|
||||
"matrix" | "pmatrix" | "bmatrix" | "Bmatrix" | "vmatrix" | "Vmatrix" | "smallmatrix" | "array" => {
|
||||
let m = MNode::Matrix { rows, col_jc: Vec::new() };
|
||||
match delims {
|
||||
Some((b, e)) => vec![wrap(m, b, e)],
|
||||
None => vec![m],
|
||||
@@ -697,14 +629,12 @@ fn script(base: Option<MNode>, sup: bool, s: Arg) -> MNode {
|
||||
}
|
||||
|
||||
/// Big operators without an operand take the atoms after them up to a relation or binary operator.
|
||||
/// `depth` is how deep the operators already sit; past [`WRAP_DEPTH`] they stay without operand
|
||||
/// (`\sum\sum\sum…` must not nest once per operator).
|
||||
fn nary_operands(a: Arg, depth: usize) -> Arg {
|
||||
fn nary_operands(a: Arg) -> Arg {
|
||||
let mut out: Arg = Vec::with_capacity(a.len());
|
||||
let mut it = a.into_iter().peekable();
|
||||
while let Some(n) = it.next() {
|
||||
match n {
|
||||
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } if e.is_empty() && depth < WRAP_DEPTH => {
|
||||
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } if e.is_empty() => {
|
||||
let mut body = Vec::new();
|
||||
while let Some(next) = it.peek() {
|
||||
let stop = match next {
|
||||
@@ -720,14 +650,8 @@ fn nary_operands(a: Arg, depth: usize) -> Arg {
|
||||
body.push(x);
|
||||
}
|
||||
}
|
||||
let body = merge_runs(nary_operands(body, depth + 1));
|
||||
if any_too_deep(&body) {
|
||||
// No room to wrap the operand: it follows the operator.
|
||||
out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e });
|
||||
out.extend(body);
|
||||
} else {
|
||||
out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e: body });
|
||||
}
|
||||
let body = nary_operands(body);
|
||||
out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e: merge_runs(body) });
|
||||
}
|
||||
other => out.push(other),
|
||||
}
|
||||
@@ -801,46 +725,6 @@ fn chars(text: &str, s: &mut String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Text inside `\text{…}`: only what LaTeX would read as markup is escaped.
|
||||
fn text_chars(text: &str, s: &mut String) {
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'{' | '}' | '%' | '$' | '#' | '&' | '_' | '~' => {
|
||||
s.push('\\');
|
||||
s.push(c);
|
||||
}
|
||||
// `{}` ends the command name without adding a space (spaces count in text).
|
||||
'\\' => s.push_str("\\backslash{}"),
|
||||
c => s.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
@@ -852,261 +736,228 @@ fn delim_tex(c: Option<char>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn group_chr_tex(chr: char, top: bool) -> &'static str {
|
||||
match chr {
|
||||
'⏜' => "\\overparen",
|
||||
'⏝' => "\\underparen",
|
||||
'⎴' => "\\overbracket",
|
||||
'⎵' => "\\underbracket",
|
||||
_ if top => "\\overbrace",
|
||||
_ => "\\underbrace",
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
s.push_str(&r.text);
|
||||
} 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, elems, .. } => {
|
||||
s.push_str("\\left");
|
||||
s.push_str(&delim_tex(*beg));
|
||||
for (i, e) in elems.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push_str("\\middle|");
|
||||
}
|
||||
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(if *top { "\\overbrace" } else { "\\underbrace" });
|
||||
braced(inner, s, depth);
|
||||
s.push(if *upper { '^' } else { '_' });
|
||||
braced(lim, s, depth);
|
||||
let _ = chr;
|
||||
} 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::GroupChr { chr, top, e } => {
|
||||
output.push_str(group_chr_tex(*chr, *top));
|
||||
braced(e, output, depth);
|
||||
MNode::Boxed { e } => braced(e, s, depth),
|
||||
MNode::GroupChr { top, e, .. } => {
|
||||
s.push_str(if *top { "\\overbrace" } else { "\\underbrace" });
|
||||
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 +986,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)"] {
|
||||
@@ -1173,15 +996,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_character_latex_roundtrip() {
|
||||
for cmd in ["\\overparen{a+b}", "\\underparen{a+b}", "\\overbracket{x}", "\\underbracket{x}"] {
|
||||
let n = parse_latex(cmd);
|
||||
let tex = to_latex(&n);
|
||||
assert_eq!(tex, cmd);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_latex_never_panics() {
|
||||
for s in ["", "\\", "{", "}", "^", "_", "\\frac", "\\sqrt[", "\\left(", "\\right)", "\\begin{matrix", "&&\\\\", "{{{{", "\\end{x}", "a^^b__c"]
|
||||
@@ -1197,46 +1011,4 @@ mod tests {
|
||||
let deep = "\\hat".repeat(5000) + "x";
|
||||
let _ = parse_latex(&deep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_column_specs_and_aligned_optional_args() {
|
||||
let n = parse_latex(r"\begin{array}{lcr} a & b & c \\ d & e & f \end{array}");
|
||||
let MNode::Matrix { rows, col_jc } = &n[0] else { panic!("{n:#?}") };
|
||||
assert_eq!(col_jc, &[ColJc::Left, ColJc::Center, ColJc::Right]);
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(crate::math::to_linear(&rows[0][0]), "a");
|
||||
assert_eq!(crate::math::to_linear(&rows[0][1]), "b");
|
||||
assert_eq!(crate::math::to_linear(&rows[0][2]), "c");
|
||||
|
||||
let n = parse_latex(r"\begin{aligned}[t] x &= 1 \\ y &= 2 \end{aligned}");
|
||||
assert!(matches!(&n[0], MNode::EqArr { rows } if rows.len() == 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operatorname_starred_and_tag() {
|
||||
let n = parse_latex(r"\operatorname*{argmin}_{x} f(x)");
|
||||
assert!(matches!(&n[0], MNode::Lim { upper: false, .. }));
|
||||
|
||||
let n = parse_latex(r"E=mc^2 \tag{1}");
|
||||
let MNode::EqArr { rows } = &n[0] else { panic!("{n:#?}") };
|
||||
assert!(crate::math::has_number_mark(&rows[0]));
|
||||
|
||||
let n = parse_latex(r"E=mc^2 \tag*{(A)}");
|
||||
let MNode::EqArr { rows } = &n[0] else { panic!("{n:#?}") };
|
||||
assert!(crate::math::has_number_mark(&rows[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delim_preserves_custom_separator() {
|
||||
let delim = MNode::Delim {
|
||||
beg: Some('{'),
|
||||
end: Some('}'),
|
||||
sep: Some('‖'),
|
||||
grow: true,
|
||||
shp_match: false,
|
||||
elems: vec![vec![MNode::Run(MRun::new("x"))], vec![MNode::Run(MRun::new("y"))]],
|
||||
};
|
||||
let tex = to_latex(&[delim]);
|
||||
assert!(tex.contains(r"\middle\|"), "Expected \\middle\\|, got: {tex}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! an operator or space; a parenthesised operand loses its parentheses). `^`/`_` with nothing
|
||||
//! before them are pre-scripts. A top-level `#` numbers the equation (`E=mc^2#(1)`).
|
||||
|
||||
use crate::math::{Arg, FracKind, MAX_DEPTH, MClass, MNode, MRun, ScriptKind, any_too_deep, is_nary_char, math_class, merge_runs, too_deep};
|
||||
use crate::math::{Arg, FracKind, MAX_DEPTH, MClass, MNode, MRun, ScriptKind, is_nary_char, math_class, merge_runs};
|
||||
|
||||
/// Function names set upright and applied to the following operand.
|
||||
pub const FUNCTION_NAMES: &[&str] = &[
|
||||
@@ -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"];
|
||||
|
||||
@@ -147,11 +135,6 @@ impl P {
|
||||
'/' | '⁄' | '∕' | '¦' => {
|
||||
self.i += 1;
|
||||
let start = operand_start(&out);
|
||||
// A numerator that is already deep stays as it is: `/` is text then.
|
||||
if out.get(start..).is_some_and(any_too_deep) {
|
||||
out.push(text(c));
|
||||
continue;
|
||||
}
|
||||
let num = strip_parens(out.drain(start..).collect());
|
||||
let den = self.operand(stops);
|
||||
let kind = match c {
|
||||
@@ -173,34 +156,20 @@ impl P {
|
||||
} else {
|
||||
self.i += 1;
|
||||
let s = self.script_operand(stops);
|
||||
if out.last().is_some_and(too_deep) {
|
||||
out.push(text(c));
|
||||
out.extend(s);
|
||||
} else {
|
||||
let base = out.pop();
|
||||
out.push(attach_script(base, c == '^', s));
|
||||
}
|
||||
let base = out.pop();
|
||||
out.push(attach_script(base, c == '^', s));
|
||||
}
|
||||
}
|
||||
'┬' | '┴' => {
|
||||
self.i += 1;
|
||||
let lim = self.script_operand(stops);
|
||||
if out.last().is_some_and(too_deep) {
|
||||
out.push(text(c));
|
||||
out.extend(lim);
|
||||
} else {
|
||||
let e = out.pop().map(|n| vec![n]).unwrap_or_default();
|
||||
out.push(MNode::Lim { upper: c == '┴', e: strip_parens(e), lim });
|
||||
}
|
||||
let e = out.pop().map(|n| vec![n]).unwrap_or_default();
|
||||
out.push(MNode::Lim { upper: c == '┴', e: strip_parens(e), lim });
|
||||
}
|
||||
c if is_combining(c) => {
|
||||
self.i += 1;
|
||||
if out.last().is_some_and(too_deep) {
|
||||
out.push(text(c));
|
||||
} else {
|
||||
let e = out.pop().map(|n| strip_parens(vec![n])).unwrap_or_default();
|
||||
out.push(MNode::Acc { chr: c, e });
|
||||
}
|
||||
let e = out.pop().map(|n| strip_parens(vec![n])).unwrap_or_default();
|
||||
out.push(MNode::Acc { chr: c, e });
|
||||
}
|
||||
'▒' | '\u{2061}' => self.i += 1,
|
||||
' ' => {
|
||||
@@ -351,7 +320,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 +391,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,39 +537,31 @@ impl P {
|
||||
}
|
||||
out.pop()
|
||||
};
|
||||
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`).
|
||||
let deep = if whole { any_too_deep(out) } else { out.last().is_some_and(too_deep) };
|
||||
self.i += 1;
|
||||
if deep {
|
||||
let s = if matches!(c, '^' | '_' | '┬' | '┴') { self.script_operand(stops) } else { Vec::new() };
|
||||
out.push(text(c));
|
||||
out.extend(s);
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'^' | '_' => {
|
||||
self.i += 1;
|
||||
let s = self.script_operand(stops);
|
||||
let b = base(out);
|
||||
out.push(attach_script(b, c == '^', s));
|
||||
}
|
||||
'┬' | '┴' => {
|
||||
'┬' | '┴' if self.no_limits == 0 => {
|
||||
self.i += 1;
|
||||
let lim = self.script_operand(stops);
|
||||
let b = base(out);
|
||||
out.push(MNode::Lim { upper: c == '┴', e: b.map(|n| vec![n]).unwrap_or_default(), lim });
|
||||
}
|
||||
_ => {
|
||||
c if is_combining(c) => {
|
||||
self.i += 1;
|
||||
let b = base(out);
|
||||
out.push(MNode::Acc { chr: c, e: b.map(|n| strip_parens(vec![n])).unwrap_or_default() });
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A script or limit: a parenthesised group, a structure, a number, a signed operand, a run of
|
||||
/// letters (and digits) or one other character.
|
||||
/// A script or limit: a parenthesised group, a structure, a number, a signed operand or one
|
||||
/// character.
|
||||
fn script_operand(&mut self, stops: &[char]) -> Arg {
|
||||
if self.tired() {
|
||||
return self.rest();
|
||||
@@ -626,7 +585,7 @@ impl P {
|
||||
Some(c) if is_prefix(c) || closer(c).is_some() => self.atom(stops),
|
||||
Some(c) if c.is_ascii_digit() => {
|
||||
let start = self.i;
|
||||
while self.peek().is_some_and(|k| k.is_alphanumeric() || k == '.') {
|
||||
while self.peek().is_some_and(|k| k.is_ascii_digit() || k == '.') {
|
||||
self.i += 1;
|
||||
}
|
||||
let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect();
|
||||
@@ -638,16 +597,10 @@ impl P {
|
||||
v.extend(self.script_operand(stops));
|
||||
v
|
||||
}
|
||||
Some(c) if c.is_alphabetic() => {
|
||||
// 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) {
|
||||
self.i += 1;
|
||||
}
|
||||
let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect();
|
||||
// A known function name stays upright, as Word builds it.
|
||||
vec![MNode::Run(if FUNCTION_NAMES.contains(&t.as_str()) { MRun::plain(t) } else { MRun::new(t) })]
|
||||
Some(c) if c.is_ascii_alphabetic() => {
|
||||
// A function name as a script (`e^sin x` is rare): one letter.
|
||||
self.i += 1;
|
||||
vec![text(c)]
|
||||
}
|
||||
Some(c) if !stops.contains(&c) && c != ' ' && !is_closer(c) => {
|
||||
self.i += 1;
|
||||
|
||||
@@ -85,7 +85,6 @@ pub const SYMBOLS: &[(&str, char)] = &[
|
||||
("vee", '∨'),
|
||||
("wedge", '∧'),
|
||||
("setminus", '∖'),
|
||||
("backslash", '\\'),
|
||||
("wr", '≀'),
|
||||
("diamond", '⋄'),
|
||||
("dagger", '†'),
|
||||
|
||||
+29
-243
@@ -7,7 +7,6 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::props::{Align, CharProps};
|
||||
use crate::section::NumFormat;
|
||||
use crate::{DocError, free_id};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -76,17 +75,6 @@ pub struct AbstractNum {
|
||||
pub id: u32,
|
||||
pub name: Option<String>,
|
||||
pub levels: Vec<Level>,
|
||||
/// Word's list identity (`w:nsid`, 8 hex digits), which it uses to match lists across
|
||||
/// documents (paste, compare); kept from the file.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub nsid: Option<String>,
|
||||
/// The list template the definition came from (`w:tmpl`); kept from the file.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tmpl: Option<String>,
|
||||
/// `w:multiLevelType` from the file (`singleLevel`, `multilevel`, `hybridMultilevel`);
|
||||
/// `None`: derived from the levels.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub multi_level_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
@@ -156,11 +144,6 @@ impl ListKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bullet a level of a plain bulleted list shows: • ○ ▪ repeating (`level` is zero-based).
|
||||
pub fn default_bullet(level: usize) -> char {
|
||||
['•', '○', '▪'][level % 3]
|
||||
}
|
||||
|
||||
fn bullet_level(i: usize, c: char) -> Level {
|
||||
Level {
|
||||
format: NumFormat::Bullet,
|
||||
@@ -193,8 +176,8 @@ pub fn levels_for(kind: ListKind) -> Vec<Level> {
|
||||
let lvl = |format: NumFormat, text: String| Level { format, text, indent: ind, hanging: 18.0, ..Level::default() };
|
||||
let n = i + 1;
|
||||
match kind {
|
||||
ListKind::Bullet => bullet_level(i, default_bullet(i)),
|
||||
ListKind::BulletChar(c) => bullet_level(i, if i == 0 { c } else { default_bullet(i) }),
|
||||
ListKind::Bullet => bullet_level(i, ['•', '○', '▪'][i % 3]),
|
||||
ListKind::BulletChar(c) => bullet_level(i, if i == 0 { c } else { ['•', '○', '▪'][i % 3] }),
|
||||
ListKind::Numbered => lvl(cyc(NumFormat::Decimal, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n}.")),
|
||||
ListKind::NumberedParen => lvl(cyc(NumFormat::Decimal, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n})")),
|
||||
ListKind::UpperLetter => lvl(cyc(NumFormat::UpperLetter, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n}.")),
|
||||
@@ -225,51 +208,26 @@ impl Numbering {
|
||||
let n = self.num(num)?;
|
||||
self.abstracts.iter().find(|a| a.id == n.abstract_id)
|
||||
}
|
||||
/// Whether a paragraph in list `num` at `level` gets a label ([`Counters::next_label`] gives
|
||||
/// one), without counting it.
|
||||
pub fn shows_label(&self, num: u32, level: u8) -> bool {
|
||||
self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some_and(|d| d.format != NumFormat::None)
|
||||
}
|
||||
pub fn level(&self, num: u32, level: u8) -> Option<&Level> {
|
||||
if let Some((_, l)) = self.num(num)?.level_overrides.iter().find(|(i, _)| *i == level) {
|
||||
return Some(l);
|
||||
}
|
||||
self.abstract_of(num)?.levels.get(level as usize)
|
||||
}
|
||||
/// Create a new list of `kind`; returns the `num` id. List ids run out only past `u32::MAX`
|
||||
/// lists, more than memory holds; then nothing is added and the id is 0, "no list".
|
||||
/// Create a new list of `kind`; returns the `num` id.
|
||||
pub fn add_list(&mut self, kind: ListKind) -> u32 {
|
||||
self.try_add_list(kind).unwrap_or(0)
|
||||
}
|
||||
/// [`Numbering::add_list`], or an error, adding nothing, when list ids are all taken.
|
||||
pub fn try_add_list(&mut self, kind: ListKind) -> crate::Result<u32> {
|
||||
self.try_add_levels(None, levels_for(kind))
|
||||
}
|
||||
/// A new list definition (`name`, `levels`) and a `num` for it; returns the `num` id. Both ids
|
||||
/// are [`free_id`]s, so the largest ids a file can hold don't make them collide with its lists;
|
||||
/// an error, adding nothing, when they are all taken.
|
||||
pub fn try_add_levels(&mut self, name: Option<String>, levels: Vec<Level>) -> crate::Result<u32> {
|
||||
let aid = free_id(self.abstracts.iter().map(|a| a.id).max(), self.abstracts.iter().map(|a| a.id), 0);
|
||||
let (Some(aid), Some(nid)) = (aid, self.free_num_id()) else { return Err(DocError::Invalid("no free list id".into())) };
|
||||
self.abstracts.push(AbstractNum { id: aid, name, levels, ..Default::default() });
|
||||
let aid = self.abstracts.iter().map(|a| a.id + 1).max().unwrap_or(0);
|
||||
self.abstracts.push(AbstractNum { id: aid, name: None, levels: levels_for(kind) });
|
||||
let nid = self.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
|
||||
self.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() });
|
||||
Ok(nid)
|
||||
nid
|
||||
}
|
||||
/// A `num` id no list has (`num` 0 means "no list"), `None` when they are all taken.
|
||||
fn free_num_id(&self) -> Option<u32> {
|
||||
free_id(self.nums.iter().map(|n| n.id).max(), self.nums.iter().map(|n| n.id), 1)
|
||||
}
|
||||
/// A new `num` that restarts numbering of an existing list (`None`: no such list, or list ids
|
||||
/// are all taken).
|
||||
/// A new `num` that restarts numbering of an existing list.
|
||||
pub fn restart(&mut self, num: u32) -> Option<u32> {
|
||||
let source = self.num(num)?;
|
||||
let a = source.abstract_id;
|
||||
let level_overrides = source.level_overrides.clone();
|
||||
let nid = self.free_num_id()?;
|
||||
// Every level starts again at its own effective start, so a level this list defines itself keeps its start.
|
||||
let levels = self.abstracts.iter().find(|x| x.id == a).map_or(0, |x| x.levels.len());
|
||||
let start_overrides = (0..levels).filter_map(|i| self.level(num, i as u8).map(|l| (i as u8, l.start))).collect();
|
||||
self.nums.push(Num { id: nid, abstract_id: a, start_overrides, level_overrides });
|
||||
let a = self.num(num)?.abstract_id;
|
||||
let nid = self.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
|
||||
let starts = self.abstracts.iter().find(|x| x.id == a).map(|x| x.levels.iter().enumerate().map(|(i, l)| (i as u8, l.start)).collect());
|
||||
self.nums.push(Num { id: nid, abstract_id: a, start_overrides: starts.unwrap_or_default(), ..Default::default() });
|
||||
Some(nid)
|
||||
}
|
||||
/// Find an existing list whose first level matches `kind`, so consecutive bullet clicks reuse it.
|
||||
@@ -288,28 +246,11 @@ impl Numbering {
|
||||
pub struct Counters {
|
||||
/// Per abstract list: current value per level.
|
||||
state: HashMap<u32, [u32; 9]>,
|
||||
/// Which levels of which nums have started: a num's start override for a level applies
|
||||
/// once, when that level of that num is first used (ECMA-376 Part 1, 17.9.27 startOverride).
|
||||
seen: HashMap<(u32, u8), bool>,
|
||||
/// Per abstract list: whether each level has counted yet (a value of 0 can be a real count when a
|
||||
/// level starts at 0).
|
||||
flags: HashMap<u32, [LevelFlags; 9]>,
|
||||
}
|
||||
|
||||
/// Whether a level has counted yet, and the value its next item takes if a start override says so.
|
||||
#[derive(Default, Debug, Clone, Copy)]
|
||||
struct LevelFlags {
|
||||
started: bool,
|
||||
first: Option<u32>,
|
||||
/// Which nums have started (to apply start overrides once).
|
||||
seen: HashMap<u32, bool>,
|
||||
}
|
||||
|
||||
impl Counters {
|
||||
/// The current value of each level of `num`'s list (0 for a level not counted yet).
|
||||
pub fn values(&self, numbering: &Numbering, num: u32) -> Option<[u32; 9]> {
|
||||
let abs = numbering.abstract_of(num)?;
|
||||
Some(self.state.get(&abs.id).copied().unwrap_or([0; 9]))
|
||||
}
|
||||
|
||||
/// Advance the counter for `num`/`level` and return the label (`"1."`, `"a)"`, `"•"`).
|
||||
pub fn next_label(&mut self, numbering: &Numbering, num: u32, level: u8) -> Option<(String, Level)> {
|
||||
let n = numbering.num(num)?;
|
||||
@@ -323,59 +264,34 @@ impl Counters {
|
||||
return None;
|
||||
}
|
||||
let st = self.state.entry(abs.id).or_insert([0; 9]);
|
||||
let fl = self.flags.entry(abs.id).or_default();
|
||||
// Only this level's override, not the num's others: the counters are shared by every
|
||||
// num of the abstract list, so restarting its other levels here would renumber
|
||||
// paragraphs of the other nums. A heading style can use one num for levels 1-4 and
|
||||
// another, with start overrides on every level, for 5-9; its first level-5 heading
|
||||
// must not restart the chapter count.
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = self.seen.entry((num, lv as u8)) {
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = self.seen.entry(num) {
|
||||
e.insert(true);
|
||||
if let Some((_, start)) = n.start_overrides.iter().find(|(l, _)| *l as usize == lv)
|
||||
&& let (Some(s), Some(f)) = (st.get_mut(lv), fl.get_mut(lv))
|
||||
{
|
||||
*s = 0;
|
||||
*f = LevelFlags { started: false, first: Some(*start) };
|
||||
}
|
||||
}
|
||||
// A level above that hasn't counted yet counts its first number here, as in Word: after
|
||||
// 1.1 and 1.2 with no level-1 item before them, the next level-1 item is 2, whether or not
|
||||
// the deeper level's text shows it.
|
||||
for (k, (v, f)) in st.iter_mut().zip(fl.iter_mut()).enumerate().take(lv) {
|
||||
if f.started {
|
||||
continue;
|
||||
}
|
||||
let start = match self.seen.entry((num, k as u8)) {
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
e.insert(true);
|
||||
n.start_overrides.iter().find(|(l, _)| *l as usize == k).map(|(_, s)| *s)
|
||||
for (l, start) in &n.start_overrides {
|
||||
if let Some(s) = st.get_mut(*l as usize) {
|
||||
*s = start.saturating_sub(1);
|
||||
}
|
||||
std::collections::hash_map::Entry::Occupied(_) => None,
|
||||
};
|
||||
*v = start.or(f.first).unwrap_or_else(|| level_def(k).map_or(1, |l| l.start));
|
||||
*f = LevelFlags { started: true, first: None };
|
||||
}
|
||||
}
|
||||
if let (Some(v), Some(f)) = (st.get_mut(lv), fl.get_mut(lv)) {
|
||||
*v = if f.started { v.saturating_add(1) } else { f.first.unwrap_or(def.start) };
|
||||
*f = LevelFlags { started: true, first: None };
|
||||
if let Some(v) = st.get_mut(lv) {
|
||||
*v = if *v == 0 { def.start.max(if def.start == 0 { 0 } else { 1 }) } else { v.saturating_add(1) };
|
||||
if *v == 0 {
|
||||
*v = def.start;
|
||||
}
|
||||
}
|
||||
// Deeper levels restart, unless they never restart or only restart after a level above
|
||||
// this one (Word's lvlRestart).
|
||||
for (d, (deeper, f)) in st.iter_mut().zip(fl.iter_mut()).enumerate().skip(lv + 1) {
|
||||
for (d, deeper) in st.iter_mut().enumerate().skip(lv + 1) {
|
||||
let restarts = level_def(d).is_none_or(|l| l.restart && l.restart_after.is_none_or(|k| lv < k as usize));
|
||||
if restarts {
|
||||
*deeper = 0;
|
||||
*f = LevelFlags::default();
|
||||
}
|
||||
}
|
||||
let vals = *st;
|
||||
let started = *fl;
|
||||
if def.format == NumFormat::Bullet {
|
||||
return Some((def.text.clone(), def));
|
||||
}
|
||||
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))
|
||||
@@ -383,8 +299,7 @@ impl Counters {
|
||||
chars.next();
|
||||
let k = (d as usize).saturating_sub(1).min(8);
|
||||
let val = vals.get(k).copied().unwrap_or(0);
|
||||
let counted = started.get(k).is_some_and(|f| f.started);
|
||||
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
|
||||
let val = if val == 0 { level_def(k).map(|l| l.start).unwrap_or(1) } else { val };
|
||||
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
|
||||
// format (二 of a Chinese outline becomes 2 in "2.1").
|
||||
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
|
||||
@@ -393,9 +308,6 @@ impl Counters {
|
||||
f => f,
|
||||
};
|
||||
label.push_str(&fmt.format(val));
|
||||
if label.len() > MAX_LABEL_BYTES {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
label.push(c);
|
||||
}
|
||||
@@ -404,29 +316,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
|
||||
@@ -443,35 +336,11 @@ mod tests {
|
||||
assert_eq!(c.next_label(&n, id, 2).unwrap().0, "1.02");
|
||||
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "");
|
||||
assert_eq!(c.next_label(&n, id, 2).unwrap().0, "2.01");
|
||||
// Levels it doesn't define still come from the abstract list; level 0 took its first number
|
||||
// with the first level-1 item, so this is its second.
|
||||
assert_eq!(c.next_label(&n, id, 0).unwrap().0, "2.");
|
||||
// Levels it doesn't define still come from the abstract list.
|
||||
assert_eq!(c.next_label(&n, id, 0).unwrap().0, "1.");
|
||||
assert_eq!(n.level(id, 2).map(|l| l.format), Some(NumFormat::DecimalZero));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_override_restarts_only_its_own_level_of_a_shared_list() {
|
||||
// One abstract list numbers the headings through two nums: one for levels 1-4, and one
|
||||
// restarting every level (startOverride 1 on 0..8) for levels 5-9, first used deep in
|
||||
// chapter 2. Chapter 3 is still chapter 3, and the deep heading counts from 1.
|
||||
let mut n = Numbering::default();
|
||||
let chapters = n.add_list(ListKind::Legal);
|
||||
let abs = n.abstract_of(chapters).map(|a| a.id).unwrap();
|
||||
let deep = chapters + 1;
|
||||
n.nums.push(Num { id: deep, abstract_id: abs, start_overrides: (0..9).map(|l| (l, 1)).collect(), ..Num::default() });
|
||||
let mut c = Counters::default();
|
||||
let mut l = |num, lv| c.next_label(&n, num, lv).unwrap().0;
|
||||
assert_eq!(l(chapters, 0), "1.");
|
||||
assert_eq!(l(chapters, 0), "2.");
|
||||
assert_eq!(l(chapters, 1), "2.1.");
|
||||
assert_eq!(l(chapters, 2), "2.1.1.");
|
||||
assert_eq!(l(chapters, 3), "2.1.1.1.");
|
||||
assert_eq!(l(deep, 4), "2.1.1.1.1.", "the deep num restarts only level 5");
|
||||
assert_eq!(l(deep, 4), "2.1.1.1.2.");
|
||||
assert_eq!(l(chapters, 0), "3.", "and not the chapter count");
|
||||
assert_eq!(l(chapters, 1), "3.1.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_restart_only_after_their_restart_level() {
|
||||
let mut n = Numbering::default();
|
||||
@@ -494,74 +363,6 @@ mod tests {
|
||||
assert_eq!(l(2), "(1)", "level 1 restarts level 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deeper_item_first_counts_the_levels_above_it() {
|
||||
// Measured in Word: a level above that hasn't counted yet takes its first number at the
|
||||
// first deeper item, whether or not that item's text shows it.
|
||||
let labels_with = |starts: [u32; 3], texts: [&str; 3], overrides: &[(u8, u32)], seq: &[u8]| {
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Legal);
|
||||
if let Some(a) = n.abstracts.first_mut() {
|
||||
for (l, (s, t)) in a.levels.iter_mut().zip(starts.into_iter().zip(texts)) {
|
||||
l.start = s;
|
||||
l.text = t.into();
|
||||
}
|
||||
}
|
||||
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
|
||||
num.start_overrides = overrides.to_vec();
|
||||
}
|
||||
let mut c = Counters::default();
|
||||
seq.iter().map(|lv| c.next_label(&n, id, *lv).unwrap().0).collect::<Vec<_>>()
|
||||
};
|
||||
let labels = |starts: [u32; 3], texts: [&str; 3], seq: &[u8]| labels_with(starts, texts, &[], seq);
|
||||
let std = ["%1.", "%1.%2.", "%1.%2.%3."];
|
||||
assert_eq!(labels([1, 1, 1], std, &[1, 1, 0, 0]), ["1.1.", "1.2.", "2.", "3."]);
|
||||
assert_eq!(labels([10, 1, 1], std, &[1, 1, 1, 1, 0, 0]), ["10.1.", "10.2.", "10.3.", "10.4.", "11.", "12."]);
|
||||
assert_eq!(labels([1, 1, 1], ["%1.", "(%2)", "%1.%2.%3."], &[1, 1, 0, 0]), ["(1)", "(2)", "2.", "3."]);
|
||||
assert_eq!(labels([1, 1, 1], std, &[2, 1, 0, 1]), ["1.1.1.", "1.2.", "2.", "2.1."]);
|
||||
assert_eq!(labels([1, 1, 1], ["%1.", "%1.%2.", "(%3)"], &[2, 1, 0]), ["(1)", "1.2.", "2."]);
|
||||
assert_eq!(labels([1, 1, 1], std, &[0, 2, 1, 0]), ["1.", "1.1.1.", "1.2.", "2."]);
|
||||
// The num's own start for the level above counts in its place, as the level's start does.
|
||||
assert_eq!(labels_with([1, 1, 1], std, &[(0, 10)], &[1, 1, 0]), ["10.1.", "10.2.", "11."]);
|
||||
assert_eq!(labels_with([1, 1, 1], std, &[(0, 5)], &[1, 0, 1]), ["5.1.", "6.", "6.1."]);
|
||||
// A level-1 item after the first deeper ones restarts level 2 below it, as usual.
|
||||
assert_eq!(labels([1, 1, 1], std, &[1, 1, 0, 1, 1, 0, 2]), ["1.1.", "1.2.", "2.", "2.1.", "2.2.", "3.", "3.1.1."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_level_starting_at_zero_counts_up() {
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Numbered);
|
||||
if let Some(a) = n.abstracts.first_mut() {
|
||||
a.levels[0].start = 0;
|
||||
}
|
||||
let mut c = Counters::default();
|
||||
let labels: Vec<String> = (0..4).map(|_| c.next_label(&n, id, 0).unwrap().0).collect();
|
||||
assert_eq!(labels, ["0.", "1.", "2.", "3."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_override_of_one_or_zero_is_honoured() {
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Numbered);
|
||||
if let Some(a) = n.abstracts.first_mut() {
|
||||
a.levels[0].start = 5;
|
||||
}
|
||||
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
|
||||
num.start_overrides = vec![(0, 1)];
|
||||
}
|
||||
let mut c = Counters::default();
|
||||
let labels: Vec<String> = (0..2).map(|_| c.next_label(&n, id, 0).unwrap().0).collect();
|
||||
assert_eq!(labels, ["1.", "2."]);
|
||||
|
||||
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
|
||||
num.start_overrides = vec![(0, 0)];
|
||||
}
|
||||
let mut c = Counters::default();
|
||||
let labels: Vec<String> = (0..3).map(|_| c.next_label(&n, id, 0).unwrap().0).collect();
|
||||
assert_eq!(labels, ["0.", "1.", "2."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbered_labels() {
|
||||
let mut n = Numbering::default();
|
||||
@@ -626,19 +427,4 @@ mod tests {
|
||||
assert_eq!(c.next_label(&n, b, 0).unwrap().0, "•");
|
||||
assert_eq!(n.find_kind(ListKind::Bullet), Some(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_keeps_the_list_s_own_level_definitions() {
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Numbered);
|
||||
let own = Level { format: NumFormat::UpperRoman, text: "Part %1".into(), indent: 90.0, hanging: 40.0, start: 5, ..Level::default() };
|
||||
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
|
||||
num.level_overrides.push((0, own.clone()));
|
||||
}
|
||||
let r = n.restart(id).unwrap();
|
||||
assert_eq!(n.num(r).unwrap().level_overrides, vec![(0, own.clone())]);
|
||||
assert_eq!(n.level(r, 0), Some(&own));
|
||||
let mut c = Counters::default();
|
||||
assert_eq!(c.next_label(&n, r, 0).unwrap().0, "Part V");
|
||||
}
|
||||
}
|
||||
|
||||
+755
-116
@@ -13,15 +13,734 @@ 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> {
|
||||
match v {
|
||||
"bothSides" => Some(WrapText::BothSides),
|
||||
"left" => Some(WrapText::Left),
|
||||
"right" => Some(WrapText::Right),
|
||||
"largest" => Some(WrapText::Largest),
|
||||
_ => 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,
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
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,
|
||||
Triangle,
|
||||
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,
|
||||
}
|
||||
|
||||
/// 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) this picture shows, as read from a file:
|
||||
/// saving writes the object back, not only its picture. See [`crate::graphic::Embedded`].
|
||||
#[serde(skip)]
|
||||
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::props::Rgb>,
|
||||
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,
|
||||
/// 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,
|
||||
},
|
||||
/// 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,
|
||||
},
|
||||
/// 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>,
|
||||
},
|
||||
/// 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, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct CanvasStyle {
|
||||
pub fill: Option<crate::props::Rgb>,
|
||||
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()
|
||||
}
|
||||
/// 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::Opaque { text, .. } => text,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -112,30 +831,6 @@ impl Paragraph {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// [`Self::plain_text`] as it reads on screen: characters in runs set in a symbol font
|
||||
/// (Symbol, Wingdings…) become their standard Unicode equivalents
|
||||
/// ([`crate::symbol_fonts`]). For copying as text and plain-text export; the stored text is
|
||||
/// unchanged.
|
||||
pub fn shown_text(&self) -> String {
|
||||
let mut out = String::with_capacity(self.text.len());
|
||||
let mut k = 0;
|
||||
let mut runs = self.run_ranges().peekable();
|
||||
for (i, c) in self.text.char_indices() {
|
||||
while runs.peek().is_some_and(|(r, _)| r.end <= i) {
|
||||
runs.next();
|
||||
}
|
||||
if c == OBJ {
|
||||
if let Some(o) = self.objects.get(k) {
|
||||
out.push_str(o.plain_text());
|
||||
}
|
||||
k += 1;
|
||||
continue;
|
||||
}
|
||||
let font = runs.peek().filter(|(r, _)| r.contains(&i)).and_then(|(_, p)| p.shown_font());
|
||||
out.push(font.and_then(|f| crate::symbol_fonts::symbol_char(f, c)).unwrap_or(c));
|
||||
}
|
||||
out
|
||||
}
|
||||
/// [`Self::plain_text`] without tracked deletions: the text as it reads with every change accepted.
|
||||
pub fn final_text(&self) -> String {
|
||||
self.text_without(&self.deleted_ranges())
|
||||
@@ -252,12 +947,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> {
|
||||
@@ -312,32 +1001,32 @@ impl Paragraph {
|
||||
|
||||
/// Merge adjacent equal runs and drop empty ones.
|
||||
pub fn normalize(&mut self) {
|
||||
// Runs must end on character boundaries and cover the text exactly (never expected, but
|
||||
// files lie): clamp each end to the text, push it past a split character, pad the tail.
|
||||
let n = self.text.len();
|
||||
let mut end = 0usize;
|
||||
let mut out: Vec<Run> = Vec::with_capacity(self.runs.len());
|
||||
for r in self.runs.drain(..) {
|
||||
let mut e = end.saturating_add(r.len).min(n);
|
||||
while !self.text.is_char_boundary(e) {
|
||||
e += 1;
|
||||
}
|
||||
if e == end {
|
||||
if r.len == 0 {
|
||||
continue;
|
||||
}
|
||||
match out.last_mut() {
|
||||
Some(l) if l.props == r.props => l.len += e - end,
|
||||
_ => out.push(Run { len: e - end, props: r.props }),
|
||||
}
|
||||
end = e;
|
||||
}
|
||||
if end < n {
|
||||
match out.last_mut() {
|
||||
Some(l) => l.len += n - end,
|
||||
None => out.push(Run { len: n, props: self.mark.clone() }),
|
||||
Some(l) if l.props == r.props => l.len += r.len,
|
||||
_ => out.push(r),
|
||||
}
|
||||
}
|
||||
self.runs = out;
|
||||
// Repair a run list that doesn't cover the text exactly (never expected, but files lie).
|
||||
let total: usize = self.runs.iter().map(|r| r.len).sum();
|
||||
if total != self.text.len() {
|
||||
if total < self.text.len() {
|
||||
let p = self.runs.last().map(|r| r.props.clone()).unwrap_or_else(|| self.mark.clone());
|
||||
self.runs.push(Run { len: self.text.len() - total, props: p });
|
||||
} else {
|
||||
let mut keep = self.text.len();
|
||||
for r in &mut self.runs {
|
||||
r.len = r.len.min(keep);
|
||||
keep -= r.len;
|
||||
}
|
||||
self.runs.retain(|r| r.len > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete `a..b` (bytes). Objects inside are removed.
|
||||
@@ -351,12 +1040,7 @@ impl Paragraph {
|
||||
if self.runs.is_empty() || a == 0 {
|
||||
// Keep the formatting of what's deleted for the mark when emptying.
|
||||
if b == self.text.len() {
|
||||
// Formatting only: the mark is a tracked change of its own or it is not, whatever
|
||||
// revision the deleted text was part of.
|
||||
let revisions = (self.mark.ins, self.mark.del, self.mark.fmt_change.clone());
|
||||
let mut mark = self.props_of_char(a).clone();
|
||||
(mark.ins, mark.del, mark.fmt_change) = revisions;
|
||||
self.mark = mark;
|
||||
self.mark = self.props_of_char(a).clone();
|
||||
}
|
||||
}
|
||||
let k0 = self.objects_before(a);
|
||||
@@ -501,8 +1185,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()) {
|
||||
@@ -588,7 +1273,7 @@ impl Paragraph {
|
||||
let mut a = 0;
|
||||
let mut prev_end = false;
|
||||
for (i, c) in t.char_indices() {
|
||||
if i > off {
|
||||
if i >= off {
|
||||
break;
|
||||
}
|
||||
if prev_end && c != ' ' {
|
||||
@@ -601,9 +1286,7 @@ impl Paragraph {
|
||||
}
|
||||
}
|
||||
let mut b = t.len();
|
||||
// A caret on the space right after a sentence's end belongs to that sentence; a terminator
|
||||
// directly followed by text ("Hello.World") doesn't end the sentence the caret is in.
|
||||
let mut ended = t.get(..off).unwrap_or("").ends_with(['.', '!', '?']) && t.get(off..).unwrap_or("").starts_with(' ');
|
||||
let mut ended = false;
|
||||
for (i, c) in t.get(off..).unwrap_or("").char_indices() {
|
||||
if ended && c != ' ' {
|
||||
b = off + i;
|
||||
@@ -621,19 +1304,6 @@ impl Paragraph {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sentence_at_sentence_start() {
|
||||
let p = Paragraph::with_text("Hello. World", CharProps::default());
|
||||
assert_eq!(p.sentence_at(0), (0, 7));
|
||||
assert_eq!(p.sentence_at(6), (0, 7));
|
||||
assert_eq!(p.sentence_at(7), (7, 12));
|
||||
assert_eq!(p.sentence_at(8), (7, 12));
|
||||
assert_eq!(p.sentence_at(99), (7, 12));
|
||||
let p = Paragraph::with_text("Hello.World", CharProps::default());
|
||||
assert_eq!(p.sentence_at(6), (6, 11));
|
||||
assert_eq!(p.sentence_at(8), (6, 11));
|
||||
}
|
||||
|
||||
fn bold() -> CharProps {
|
||||
CharProps { bold: Some(true), ..Default::default() }
|
||||
}
|
||||
@@ -642,12 +1312,8 @@ mod tests {
|
||||
fn text_without_takes_ranges_in_any_order() {
|
||||
// Multi-byte chars at range edges, an object, and ranges unsorted, overlapping, nested and empty.
|
||||
let mut p = Paragraph::with_text("añb€c", CharProps::default());
|
||||
p.insert_object(
|
||||
3,
|
||||
InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
p.insert_object(3, InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, code: false }, &CharProps::default())
|
||||
.unwrap();
|
||||
let text = p.text.clone();
|
||||
let naive = |dropped: &[std::ops::Range<usize>]| {
|
||||
let mut out = String::new();
|
||||
@@ -716,22 +1382,13 @@ mod tests {
|
||||
#[test]
|
||||
fn objects_track_text() {
|
||||
let mut p = Paragraph::with_text("ab", CharProps::default());
|
||||
p.insert_object(
|
||||
1,
|
||||
InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
|
||||
&CharProps::default(),
|
||||
)
|
||||
.unwrap();
|
||||
p.insert_object(1, InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false }, &CharProps::default())
|
||||
.unwrap();
|
||||
p.insert_object(0, InlineObject::BookmarkStart { name: "x".into() }, &CharProps::default()).unwrap();
|
||||
assert_eq!(p.objects.len(), 2);
|
||||
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);
|
||||
@@ -755,24 +1412,6 @@ mod tests {
|
||||
assert_eq!(t.mark.bold, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emptying_a_paragraph_keeps_the_formatting_but_not_the_revisions_of_its_text() {
|
||||
let mut props = bold();
|
||||
props.ins = Some(3);
|
||||
props.del = Some(4);
|
||||
let mut p = Paragraph::with_text("Hello", props);
|
||||
p.mark = bold();
|
||||
p.delete(0, 5).unwrap();
|
||||
assert!(p.is_empty());
|
||||
assert_eq!(p.mark.bold, Some(true));
|
||||
assert_eq!((p.mark.ins, p.mark.del), (None, None));
|
||||
// A mark that is itself a tracked change keeps its own revision.
|
||||
let mut p = Paragraph::with_text("Hello", bold());
|
||||
p.mark.del = Some(7);
|
||||
p.delete(0, 5).unwrap();
|
||||
assert_eq!((p.mark.ins, p.mark.del), (None, Some(7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words() {
|
||||
let p = Paragraph::with_text("The quick brown fox.", CharProps::default());
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user