Merge upstream/main into feat/paper-colors-3398

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76andClaude Opus 5.5 committed 2026-11-08 04:20:39 +01:00
commit 1c6c7c732e
263 files changed
+16151 -1319

No files matched your search

+20 -1
View File
@@ -7,11 +7,30 @@ updates:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
# Minor and patch updates share one PR. A breaking bump gets its own PR so one API change
# doesn't hold the safe updates back (#2973). For cargo, Dependabot counts a 0.y bump of a
# 0.x crate as major, as Cargo does. A dependency goes to the first group it matches, so the
# crate families that must move together come before the catch-all.
groups:
egui:
patterns:
- "egui"
- "egui_*"
- "eframe"
- "winit"
fonts:
patterns:
- "skrifa"
- "harfrust"
hayro:
patterns:
- "hayro-*"
cargo-dependencies:
patterns:
- "*"
update-types:
- "minor"
- "patch"
commit-message:
prefix: "chore(deps)"
+28
View File
@@ -0,0 +1,28 @@
name: House rules
on:
push:
branches: [main]
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
pull_request:
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: house-rules-${{ github.ref }}
cancel-in-progress: true
jobs:
house-rules:
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
with:
rust: true
+8
View File
@@ -18,7 +18,14 @@ concurrency:
cancel-in-progress: false
jobs:
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
credits:
permissions:
contents: read
pull-requests: read
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
release:
needs: credits
# Only this job publishes the GitHub release, so only it gets write access.
permissions:
contents: write
@@ -31,6 +38,7 @@ jobs:
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
web-craft-fonts: true
version: ${{ inputs.version }}
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+57 -4
View File
@@ -1,10 +1,62 @@
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
## House rules
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
10. **Keep secrets and personal details out** of the repository, logs and requests.
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
<!-- craft-house-rules:end -->
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
## Rust Craft repositories
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
## Licence allowlist
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
- `ISC`
- `MIT`
- `BSD-2-Clause`
- `BSD-3-Clause`
- `Apache-2.0`
- `OFL-1.1`
- `Ubuntu-font-1.0`
- `Bitstream-Vera`
- `CC0-1.0`
- `CC-BY-4.0`
- `CC-BY-SA-4.0`
- `LicenseRef-Public-Domain`
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
<!-- craft-house-rules:end -->
# 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).
## 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).
Read [`ROADMAP.md`](ROADMAP.md), [`docs/gaps.md`](docs/gaps.md) and [`docs/roadmap.md`](docs/roadmap.md). `plan/` is an optional gitignored notebook. Do not ask for it and do not stop when it is absent.
## 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.
@@ -28,7 +80,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
- Never read, disassemble or copy anything inside the Word bundle (file names/listings only). Never commit files produced by Word. Never copy Microsoft wording beyond feature names.
- File formats come from public specs: ECMA-376 / ISO 29500 (OOXML), the published RTF spec, OASIS ODF 1.3. Never copy GPL/LGPL/AGPL code (LibreOffice, AbiWord, pandoc…).
## Architecture (see `plan/architecture.md`)
## Architecture
| Layer | Crate | Job |
|---|---|---|
| L0 | `geom` | units, rects, measurement parsing |
@@ -44,7 +96,6 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
- **Everything is a command.** User-visible behaviour = a `CommandSpec` in `crates/engine/src/cmd/*.rs` (id, label, ribbon location, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. The ribbon, shortcuts, command search, CLI, control channel and MCP all dispatch by id. Programmatic calls never open dialogs.
- **Parity is measured:** `crates/engine/src/catalog.rs` lists Word's ribbon/menu features with command ids. `cargo xtask parity` writes `docs/parity-checklist.md`; a test enforces a floor that only rises. Implement missing ids to raise it.
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin and reads `Session` state; colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
## Quality gates
@@ -85,3 +136,5 @@ Progress docs: `ROADMAP.md` (stage, headline numbers, progress log), `docs/targe
- 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.
New workspace crates include `workspace = true` under `[lints]` in `Cargo.toml`. Production crate roots declare the crate-level lint attributes before modules; crate-level `//!` documentation may precede them.
+1 -71
View File
@@ -1,71 +1 @@
# 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).
## 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.
- **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`).
## 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.
- 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
- Microsoft Word is installed on the dev machine and may be **observed** black-box: run it, use its UI with synthetic documents, take screenshots **of its window only** (by window id — the machine runs other work; never capture the whole desktop) stored only under `plan/word/screenshots/` (gitignored, never committed).
- Never read, disassemble or copy anything inside the Word bundle (file names/listings only). Never commit files produced by Word. Never copy Microsoft wording beyond feature names.
- File formats come from public specs: ECMA-376 / ISO 29500 (OOXML), the published RTF spec, OASIS ODF 1.3. Never copy GPL/LGPL/AGPL code (LibreOffice, AbiWord, pandoc…).
## Architecture (see `plan/architecture.md`)
| Layer | Crate | Job |
|---|---|---|
| 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 |
| 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`.
- **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.
## Running and looking at the app
- `cargo run --release -p wordcraft -- --sample --control 7981` (sample document + control channel).
- Drive it with JSON lines on `127.0.0.1:7981`, each with the window's key from `~/.config/wordcraft/control-key.local-7981` (macOS: `~/Library/Application Support/WordCraft/`), e.g. `{"id":1,"key":"…","method":"engine.execute","params":{"command":"text.insert","params":{"text":"Hello"}}}` then `{"id":2,"key":"…","method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`; docs: `docs/control-protocol.md` (Keys).
- **Headless window screenshots** (no focus stealing, works with a locked screen): `cargo run --release -p wordcraft-ui-egui --example ui_shot -- script.jsonl` (see the example's header).
- **For UI work, look at the result** (screenshot, read the PNG) and compare with Word side by side.
- CLI: `wordcraft-cli convert in.docx out.pdf`, `wordcraft-cli run --template sample --cmd 'select.text={"text":"Studio"}' --cmd format.bold --save out.docx`, `wordcraft-cli commands`, `wordcraft-cli parity`.
- MCP: `wordcraft-cli mcp` (headless) or `wordcraft-cli mcp --connect 127.0.0.1:7981` (drives the running app). See `docs/mcp.md`.
- Shell gotcha: `mv`/`cp` may be aliased interactive — use `/bin/mv -f` / `/bin/cp -f`.
- 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.
@AGENTS.md
Generated
+16 -1
View File
@@ -1090,9 +1090,10 @@ dependencies = [
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=a3e0b5a9440d18f19422a9dca13516747b7d3181#a3e0b5a9440d18f19422a9dca13516747b7d3181"
dependencies = [
"craft-winprint-gdi",
"log",
"miniz_oxide 0.9.1",
"roxmltree",
"tempfile",
@@ -1157,6 +1158,15 @@ dependencies = [
"zip 9.1.0",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "craft-window"
version = "0.1.0"
@@ -6805,7 +6815,9 @@ dependencies = [
"open",
"pollster 1.0.1",
"rfd",
"serde",
"serde_json",
"tempfile",
"windows",
"winit",
"winresource",
@@ -6815,6 +6827,7 @@ dependencies = [
"wordcraft-engine",
"wordcraft-fonts",
"wordcraft-ui-egui",
"x11rb",
"zbus",
]
@@ -6880,6 +6893,7 @@ dependencies = [
"log",
"proptest",
"quick-xml 0.41.0",
"serde_json",
"sha1 0.11.0",
"sha2 0.11.1",
"thiserror 2.0.21",
@@ -7084,6 +7098,7 @@ dependencies = [
name = "wordcraft-web"
version = "0.15.1"
dependencies = [
"craft-web-host",
"eframe",
"egui",
"js-sys",
+12 -1
View File
@@ -299,10 +299,21 @@ 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).
**Read Aloud** (Review › Read Aloud) speaks through a speech program installed on the system: it
tries `espeak-ng`, then `espeak`, and when neither is installed it talks to Speech Dispatcher.
The `.rpm` package recommends `espeak-ng`; the `.deb` recommends `espeak-ng` or
`speech-dispatcher`, so the package manager installs one of the two by default. With the
AppImage or the tarball, install one yourself if neither is there (`sudo apt install espeak-ng`,
`sudo dnf install espeak-ng`, `sudo pacman -S espeak-ng`). The Flatpak doesn't speak yet: it has
no audio access and doesn't include `espeak-ng`, `espeak` or Speech Dispatcher.
**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`:
of file drag-and-drop), choose **File › Options › Advanced › Linux display server › Wayland**; it
applies the next time WordCraft starts, and **X11 (XWayland)** or **Automatic** switches back. The
`WORDCRAFT_WAYLAND` environment variable overrides the setting: `1` for native Wayland, `0` for
XWayland:
```sh
WORDCRAFT_WAYLAND=1 wordcraft
+136 -2
View File
@@ -1,12 +1,21 @@
# WordCraft roadmap
> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (#3398: persistent screen paper colors; parity estimates unchanged)
> **CLI path update:** 2026-11-04 · Options before a non-Unicode input filename no longer make the CLI reject that filename; text option values still require Unicode; `run --file` and `run --script` retain native paths. No ranked gap or readiness estimate changes.
> **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged.
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a pictureLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial (progress log: a shape dropped just after a page break inside a paragraph stays on the new page, #4262); previously trivial (progress log: a float Line truncated
>
> **Recipient CSV update:** 2026-10-27 · Complete quoted header records choose the delimiter (#4249); detection and regressions follow the current mailings module split. Recipient limits and readiness estimates are unchanged.
>
> **Headered CSV prefix update:** 2026-10-31 · Unquoted whitespace-only prefix lines are skipped before choosing recipient headers (#4249); quoted whitespace headers, headerless records, the shared CSV reader and recipient limits remain unchanged.
> **Page Setup update:** 2026-11-07 · Page Setup number boxes keep long input inside the field, including accepted margin values after focus leaves (#5650); accepted ranges and readiness estimates are unchanged.
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
> **Legacy form update:** 2026-10-22 · trivial: numeric XML references decode in field names and labels; readiness estimates unchanged.
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
agent control. This page is the summary; the assessment is
@@ -140,6 +149,36 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
## Progress log
- 2026-11-06: Arabic interface: the Home tab's Styles gallery reads from the right, as the rest of the mirrored ribbon does (#5566). Normal is the rightmost tile, the next styles follow leftwards and the scroll arrows and More sit at the gallery's left end; left to right is unchanged. Readiness unchanged.
- 2026-11-05: ODT heading text of another size keeps it (gap 19): a smaller word in a heading came back at the heading's size in LibreOffice after a save.
- 2026-11-05: ODT table rows kept from breaking across pages stay so (gap 19): they came back from a save free to break.
- 2026-11-05: ODT: a watermark added, changed or removed, and Different Odd & Even Pages turned on or off, in a file read from ODT are saved.
- 2026-11-05: ODT font declarations are kept (gap 19): replacing them made LibreOffice pick other substitutes for fonts not installed, reflowing a document from 83 pages to 70 on a save with no edits.
- 2026-11-05: ODT pictures offered in several formats show and survive a save (gap 19): LibreOffice's letter templates lost theirs.
- 2026-11-07: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). Exact spacing keeps the line's baseline.
- 2026-11-03: Search types in place: a click on the title bar's Search box, or Alt+Q, takes the typing in that box, with the results under it, as in Word, instead of a second box in a dialog (#5398).
- 2026-10-31: List labels: a numbering level with format `none` and literal text, such as `RESPONSE:` with a one-inch hanging indent, shows that text as its label, as Word does; it used to show nothing. As in Word, a `none` level still counts and restarts the levels below it, its own number is empty (in legal numbering too), and an empty label still tabs the text to the indent (#4985). Readiness unchanged.
- 2026-10-31: Font menus draw their entries from a few shared textures written in place, as the Equation tab's pictures are (#1002), instead of a texture per font: on Windows (DirectX 12) browsing the list showed the names on black strips in light mode and white or red ones in dark mode (#5105). Readiness unchanged.
- 2026-11-02: A picture linked from its context menu shows its address on hover and opens it with Ctrl/⌘+click, in line or floating, as linked text does; DOCX files keep the link where Word does, on the drawing (`a:hlinkClick`), so links on pictures from Word open too (#5267). Readiness unchanged.
- 2026-11-01: DOCX tables keep rows and cells wrapped in `mc:AlternateContent`, selecting one supported Choice or Fallback with the existing reader policy (#5219). Original synthetic checks cover namespace aliases, unsupported requirements, nested row/cell content controls and two save/reopen cycles; broader corpus and native Word qualification remain open.
- 2026-11-01: Shapes: a line's arrowheads are as long and as wide as Word draws them: 2, 3 or 5 times the line's width for small, medium and large (a line under 2 points as a 2-point one). Their kind, width and length are read from DOCX (DrawingML and VML) and saved back; before, every arrowhead was one size and saved as a medium triangle.
- 2026-11-06: Windows print submissions run in separate helper processes (#5710). A helper acknowledges cancellation before it stops, so a driver stalled in a cancelled job cannot hold another job’s provider lock. A private temporary PDF and bounded settings messages cross the process boundary; normal shutdown reaps helpers and removes their inputs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Jobs already sending still complete. Readiness estimates unchanged.
- 2026-11-06: File › Print: before sending starts, Cancel printing stops a waiting job, frees the Print page and keeps the settings; a successfully cancelled job is never sent. Cancellation remains available after the last document closes. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. Readiness estimates unchanged.
- 2026-11-06: File › Print on Windows no longer waits on printer drivers to list printers (#5710). The list used to wait until each printer's driver said whether it prints on both sides, and a networked printer asleep in power save could keep it empty with Refresh unavailable; now discovery asks no driver (the Windows spooler still supplies the list), and printing still checks the job's options with the driver. The reporter's sleeping printer has not been retested yet. Readiness estimates unchanged.
- 2026-11-04: CLI `convert` rejects a missing or repeated `--format` value before opening the input or writing output, matching the existing page/scale option checks. Readiness estimates unchanged.
- 2026-11-04: MHT import keeps semicolons and escaped quotes inside quoted MIME parameters, so an unrelated parameter cannot supply a false multipart boundary and hide the document text. Readiness estimates unchanged.
- 2026-10-30: Equations: `\int`, `\sum` and the other n-ary operators typed with AutoCorrect wait for their limits. `\int` + Space, then `_0^1` and Space, gives ∫ with its limits and the caret in its operand, and `\int_0^1` typed in one go does the same, since any character that can't continue `\name` corrects it; this is observed in Word for Windows on #4938. Enter or an arrow key builds it without going into the operand, and `∫∫` puts the operand in the inner operator; those two are not observed in Word (#4917). Readiness unchanged.
- 2026-11-04: RTF import skips the declared Unicode fallback count even when it exceeds eight fallback units, so longer ANSI alternatives do not leak into the document text. Skipping stays bounded by the available input and ends at a group boundary. Readiness estimates unchanged.
- 2026-11-05: Linux: a WordCraft started by an AppImage app no longer takes that app's file for its own (#5653). The AppImage runtime's `APPIMAGE`, `APPDIR`, `ARGV0` and `OWD` pass to whatever the app starts, so an installed WordCraft opened through `xdg-open` saw another program's `APPIMAGE`, and its self-update replaced that program's file. The app now starts over without those variables unless its executable is under `APPDIR`, so the update goes through the package or plain file it really is. Should starting over fail, the self-update is off in that window and Help › Check for Updates says why. Readiness estimates unchanged.
- 2026-11-05: A Word template (`.dotx`, `.dotm`, `.dot`) opened from the file manager, the command line or the Finder starts a new, untitled document based on it, as Word's default verb for a template does (#3433). It opened the template itself, so Save (and AutoSave) wrote over it. The new document is attached to the template and Save asks where to write; a protected template asks for its password and still starts a new document. File › Open and a file dropped on the window still open a template itself, to edit it.
- 2026-11-05: Arabic letters join across a change of formatting (gap 16, #4093). Each run was shaped on its own, so a letter at a run's edge took the form of a word's start or end: a coloured letter stood apart, and a word edited with Track Changes on read with a space in it (ال ذكاء). Each run is now shaped knowing the text shown on either side, past hidden or deleted text left out of the view, as for Syriac, Mongolian and the other joining scripts. Readiness estimates unchanged.
- 2026-11-05: Shapes, text boxes, groups and drawing canvases in Strict Open XML documents open (#3548). Strict puts them in its one drawing namespace, where Transitional documents use Word 2010's `wps`, `wpg` and `wpc`, and Word writes a Strict text box's text in `wne:txbxContent`; the reader knew neither, so a Strict file opened with its text and no shapes at all. They now read like their Transitional twins and are saved as Transitional. Charts and SmartArt in Strict namespaces are not mapped yet.
- 2026-10-28: Ink replay, eraser picking, lasso selection and arrow-candidate discovery reuse the paragraph prefixes they have already scanned (#3614). Page item order, stroke geometry and saved data retain their behavior. This narrows the stability work; ink import fidelity, native pen input and broader corpus qualification remain open.
- 2026-10-29: Page drawing finds text-box warps without rescanning the paragraph for every box (#3614). Only requested offsets are indexed, and each paragraph walk stops after its last requested character. Original Linux discovery, complete display-output and CLI/MCP save/reopen controls; native Word comparison untested. Readiness estimates unchanged.
- 2026-10-28: Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. Existing mutation and protection checks retain their costs; this does not close the broader performance gap.
- 2026-11-04: OpenDocument files have no display settings any more (gap 19): File › Options › Advanced › OpenDocument files, `file.options` `odf`, `WORDCRAFT_ODF`, the LibreOffice style and custom values are gone. WordCraft makes one choice for each case the standard leaves open, the one the default made, and still follows what a file states ([docs/odf-interpretation.md](docs/odf-interpretation.md)).
- 2026-10-31: Dialogs are modal, as in Word (#3859): while one is open, the ribbon, File, the page and the panes of its document window take no clicks, and typing and shortcuts (Ctrl+O…) do nothing until it closes; WordCraft's own Open/Save As window is modal too. A press beside a modal dialog shakes it a little, so it is clear why nothing behind it reacts (Word beeps); with animation effects off, its border flashes instead. Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field stay modeless, as in Word for Mac 16.114.1 (checked on a newer build than the 16.113.4 the parity target names).
- 2026-11-04: OpenDocument files are shown in one of two styles (gap 19): Microsoft Word style, the default, or LibreOffice style (File › Options › Advanced › OpenDocument files; `file.options` `{"odf": {"preset": "word"}}` or `"libreoffice"`; `WORDCRAFT_ODF=word` or `WORDCRAFT_ODF=libreoffice`). Custom stays for one's own value per case; `wordcraft`, the earlier id of Word's style, is still taken.
@@ -185,10 +224,16 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-10-31: ODT: paragraph spacing, indents and line spacing are read and written, and an ODT's own default spacing (none, single lines) replaces Word's 8 pt after and 1.15 lines, so LibreOffice documents keep their vertical rhythm on open and save (gap 19). Readiness unchanged.
- 2026-11-01: PDF export, printing and page images show the body's SEQ, STYLEREF and REF fields with their current results, as Word fills them in when it prints or exports even with Update fields before printing off: a caption numbered 7 when it is the first shows 1 on paper, and a REF to a bookmark the document lacks prints Word's "Error! Reference source not found.". NUMWORDS, FILENAME, the document properties, formulas and locked fields keep their stored results there, as in Word; the editor and the saved document keep every stored result.
- 2026-10-28: Print Markup HTML, Markdown and text copies discover comment anchors in one paragraph walk, so unrelated bookmark markers no longer cause repeated prefix scans (#3614). Existing quotes, replies, order and export limits are unchanged; original synthetic semantic and scaling regressions cover the traversal.
- 2026-11-04: Table Sort refuses malformed and nonexistent explicit columns before reordering rows (#5531). Omitted column 0, valid columns, header/order/language behavior and Undo/Redo are retained. Original Linux CLI/MCP controls and repeated native/DOCX reopens cover the command; native Word and desktop comparison remain untested. Readiness estimates unchanged.
- 2026-10-30: Layout resource caps (#3867, #4385) and linear-time passes (#3866, #4392): repeated table-header rows are capped by item counts, a clipped group counting every nested member, the wrap cache by decoded pixels and the widths a linked text box chain lays out by their lines and items; PDF export, hit testing, tracked-change and clipped-table costs, and long endnote selections no longer grow quadratically. Synthetic regression tests only; no new Word comparison.
- 2026-11-03: Proofing (CP4.3): craft-proof moves to craft-libs main with #589 and #590. French spelling suggestions offer the elided form first (`Jadore` → `J'adore`, before `J adore`), and Valencian text (`ca-ES-valencia`) is proofed with the `ca-valencia` pack instead of plain Catalan. Readiness unchanged.
- 2026-11-03: Security: PDF export leaves the document's author out of the PDF's properties when "Remove personal information from file properties on save" is on, as DOCX saving already does. Readiness unchanged.
- 2026-11-03: A long comment no longer grows its card or balloon without limit (#3382). Its field in the Comments pane and in a selected balloon stops at ten lines and scrolls, keeping the caret in view; a balloon that isn't selected shows the first ten lines of the comment and of each reply, and clicking it opens the scrolling field. The buttons and the next comment stay in view.
- 2026-11-03: Arabic dialogs: the dialogs of the mirrored interface now read right to left. Rows of a label and its field, lists of columns (Compare, AutoCorrect, Special Characters, Track Changes Options, the Columns widths), the tile galleries (chart types, SmartArt layouts) and the ribbon Paragraph group start at the right edge, through an `rtl::Table` helper that is an `egui::Grid` in English; the Add >> and << Remove arrows swap, the Columns preset captions and the Macros buttons no longer overlap, and the Recipient List grid and About › Models scroll instead of running past the left edge. Readiness unchanged.
- 2026-11-03: Shape Format › Size: the width and height fields show the interface unit (centimetres, inches…) instead of always points, and take a size typed in any unit, such as `3 cm` or `300 px`, applied once typed (Esc cancels), each side on its own, as Picture Format › Size does (#4196). In both, a field clicked and left with Enter or Tab without typing no longer resizes the object by the rounding of the size it shows (a 100 pt shape became 100.08 pt in inches).
- 2026-11-03: Security: Paste Special with picture files on the clipboard refuses anything that isn't a regular file (a pipe or device reports no length and can produce endless data), and `cargo xtask stats` no longer follows directory symlinks. Readiness unchanged.
- 2026-11-03: Size keeps the side it isn't asked to change (#5469): typing only the width of a straight line keeps its height of 0 instead of making it 1 pt; the 1–4000 pt limits apply only to the sides a `picture.size` call changes. Readiness unchanged.
- 2026-11-03: Find and Replace: a keyboard apostrophe also finds typographic single quotes, so smart-quoted typing stays searchable (#5388). Regex, wildcards, literal searches and numeric character codes remain exact. Original UTF-8 match ranges and replacement spelling are preserved; readiness unchanged.
- 2026-11-03: Security: pasting text boxes charges each copied story for its formatting too (run, mark, paragraph and section properties), not only its text and objects, so a story with huge style or font names can't copy past the paste's 32 MiB text box budget. Readiness unchanged.
- 2026-11-03: Security: `.doc` import keeps one width, shading, alignment and merge override per table cell, the last one winning as before, instead of a copy every time a paragraph's properties repeat the sprm, so a shared PAPX can't pile up copies on every paragraph mark. Readiness unchanged.
- 2026-11-03: Security: `.doc` import attaches each section to the paragraph that ends it through an index of paragraph ends, instead of scanning every paragraph for every section, so a file with a thousand sections that end nowhere can't multiply the work over its paragraphs. Readiness unchanged.
@@ -199,6 +244,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-11-02: Language packs (CP4.4): craft-i18n's first pack release, language-packs-1, is published and WordCraft pins its index (sha256): 61 languages install from File › Options › Language, among them Lithuanian (#4707), Romanian (#3438), Catalan (#2895, #4947), French (#1325), Greek (#2561), Slovak (#3806), Serbian (#4968) and Latvian (#4438). Dutch (#4145, #2505), German and the other compounding languages wait on craft-proof engine support. Readiness unchanged.
- 2026-11-01: Language packs (CP4.3–CP4.5): languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language, or from a bar a document in that language shows ("Polish proofing isn't installed. Install (4.6 MB)"), through the font store and its single download setting; About › Language data shows every licence; `proof.packs`, `proof.installPack` and `proof.removePack` for agents. Install works once language-packs-1 is published (until then with a test index); the default build then embeds English data only, and keeps today's carry-over data embedded until then (`cargo xtask language`). Lithuanian (#4707) and Romanian (#3438) are available as a Language pack once language-packs-1 is published; the Dutch, Catalan, French, Greek, Slovak, Serbian and Latvian packs (#4145, #2505, #2895, #4947, #1325, #2561, #3806, #4968, #4438) are defined and wait on craft-proof engine support (FLAG long/num, OCONV, ISO 8859-7, CIRCUMFIX, FULLSTRIP). Readiness unchanged.
- 2026-11-01: DOCX tables keep rows and cells wrapped in `mc:AlternateContent`, selecting one supported Choice or Fallback with the existing reader policy (#5219). Original synthetic checks cover namespace aliases, unsupported requirements, nested row/cell content controls and two save/reopen cycles; broader corpus and native Word qualification remain open.- 2026-11-01: Shapes: a line's arrowheads are as long and as wide as Word draws them: 2, 3 or 5 times the line's width for small, medium and large (a line under 2 points as a 2-point one). Their kind, width and length are read from DOCX (DrawingML and VML) and saved back; before, every arrowhead was one size and saved as a medium triangle.
- 2026-11-02: Page break before applies to a paragraph opening a continuous section mid-page in paginated layout (#5287). The original Word for Mac 16.113 probe puts each heading on a new page. At the top of a later column in such a section, the break also starts a new page; that case remains unmeasured in Word. Readiness unchanged.
- 2026-11-01: Columns with Line between, made for selected text (a section in the middle of a page), draw their line; the line runs as tall as the section's text on the page, not the whole page (#5178). Readiness unchanged.
- 2026-10-31: `.doc` import: text that names no font takes the stylesheet's standard font, so a Word 365 `.doc` in Aptos opens in Aptos (or its substitute) instead of Times New Roman, the font table's first entry (#5066). Readiness unchanged.
- 2026-10-31: Tables: a row that doesn't fit at the bottom of a page no longer splits off its picture. When a cell's inline picture (one line, which is never split) or first line runs past the room left on a page that already holds something, the row moves to the next page whole instead of leaving the other cells' text beside an empty cell, so a photo row at least 193 pt tall grown to 207 pt by its picture goes on in one piece (#4272). At the top of a page such a row still splits. The reporter's 4-page file is still 5 pages: there the row has less room than its minimum and already moves on whole (#1742); the extra page now comes from the pages above taking more room than in Word.
@@ -233,13 +279,18 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-10-29: HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged.
- 2026-10-27: File › Print: Return prints, as in Word, where ⌘P opens on Print as the default button; a field or list on the page keeps Return while it has the keyboard.
- 2026-10-28: A window restored to a place no monitor covers now opens centred on the primary monitor. Before, a monitor replaced by one of the same size left the window off screen, and the app seemed to close as soon as it started.
- 2026-10-29: Built-in citation refresh indexes source tags and bibliography numbers for each operation (#4888). Existing ordering, duplicate-tag first matches and saved sources remain unchanged.
- 2026-10-25: Select Recipients detects comma, semicolon and tab separators across quoted multiline headers. Quoted punctuation and doubled quotes stay in their cells; detection stops at the first unquoted record boundary. Existing tie preferences and parsing behavior remain unchanged.
- 2026-10-22: View › Window › New Window opens a second window on the document (desktop). The windows are titled `Name:1` and `Name:2` and each keeps its own caret, scroll position, zoom and view; edits and Undo are the document's and show in both. Closing one of them asks nothing; the last one asks about unsaved changes, and quitting asks once per document.
- 2026-10-22: One native window per open document on the desktop. File › New and Open (from the ribbon, File, shortcuts, the file manager, the command line or the control channel) open the document in a window of its own, focused and placed a step below and right of the last one; opening a file replaces a blank document nobody has used in its own window, and a file that is already open brings its window to the front. Each window keeps its own ribbon tab, File page, dialogs, panes, scroll position and composition. View › Window › Switch Windows lists the windows and brings the chosen one forward. File › Close and a window's close button close that window, asking about unsaved changes first; the last window stays open with no document. Quitting asks about each document with unsaved changes in its own window, and Cancel keeps everything open. The control channel's input and screenshots go to the window focused last. The web app keeps one window.
- 2026-10-22: Recovery covers every open document. Each dirty document keeps its own copy, written on the same ten-second cadence whether it is shown or not; closing a document without saving deletes its copy, as saving does, and a clean exit deletes the copies of every document saved or closed since its last copy. File › Recover lists only copies no open document keeps, and Restore opens a copy as another document (or in place of an untouched blank one), so it never replaces work. After a crash with several unsaved documents, the next start lists all of them.
- 2026-10-22: The desktop window works with several open documents (one window per document comes next). View › Window › Switch Windows lists them and shows the one chosen, back where it was left; New, Open and the mailings open beside unsaved work instead of asking about it; File › Close shows the next document; quitting asks about every document with unsaved changes, one at a time, and Cancel keeps the app open.
- 2026-10-22: Several open documents in one session (engine part of one window per document). File › New opens a new document beside the open ones; File › Open opens beside them too, replaces a blank document nobody has used, and shows a file that is already open instead of opening it twice. Each document keeps its own text, selection, undo history, find state, view and recovery copy. View › Window › Switch Windows (`view.switchWindows`) lists the open documents and switches between them; `file.closeDocument` closes one where no window asks about unsaved changes (agents, headless sessions). Envelopes, Labels, Finish & Merge and New from Template open their result as a new document and leave the main document open as it was. Document ids are unique in the process. The desktop app still shows one document per window; the windows come next.
- 2026-10-22: Legacy form names and selected drop-down labels decode numeric XML references, including saved whitespace, without decoding escaped reference text twice.
- 2026-10-25: Interface catalogs are still the shared storytold/craft-i18n ones embedded from `craft-i18n.rev` (`9205108c…`): 27 WordCraft languages with English fallback for missing strings, and the equation “Delete Around Caret in Equation” translation preserved. Native-speaker review remains human.
- 2026-10-25: Input methods (egui 0.37): character- and byte-counted surrounding deletions, a fresh composition after body/equation replacement, rejection of queued stale replacements before mutation, transactional equation deletes and one undo group, with tracked and placeholder length mapping. Synthetic regressions document these paths; managed CI passed, and native IME acceptance (#155–#164) plus browser IME behavior remain open.
- 2026-10-25: Justified and distributed lines keep the width of the spaces before their first character, as Word does: Thai official letters indent paragraphs with a run of spaces, and their first line no longer moves right by the line's slack (86 pt in a measured case). A distributed line's text reaches the margin even when the line ends with the space it wrapped at.
- 2026-10-25: Text Highlight Color shows the last colour as the button's bar in the ribbon and mini toolbar; after No Color it shows the pen without a misleading yellow bar, and the button removes highlighting (#4087).
- 2026-10-23: cu20we: DrawingML flat, square and round line caps are imported, saved, displayed and exported to PDF; absent or unknown caps default to flat. VML end caps (including shapetype inheritance) are preserved through DOCX save. Ink and metafile stroke handling remain separate. Synthetic DOCX, raster and PDF regressions cover the change; no new native Word comparison.
- 2026-10-23: cu20we: paragraph-relative box floats start above effective space before; header, footer, cell and text-box layout regressions pass. Framed paragraph positioning is unchanged; no new native Word comparison.
@@ -247,6 +298,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-10-24: Parity catalog: Read Mode's View menu has its own rows. Column Width (`view.readColumnWidth`) and Page Color (`view.readPageColor`) count as live; Layout (`view.readLayout`) is listed as missing. Catalog 504/520 (96.9%).
- 2026-10-24: Find and Replace: with Regular expressions on, Replace with expands capture groups instead of inserting them as typed. `(\w+), (\w+)` replaced with `$2 $1` or `\2 \1` swaps the words, and `(?<y>\d{4})-` with `${y}/` names a group; `$0` is the whole match, `$$` a dollar sign, `\t` a tab and `\n` a paragraph mark that splits the paragraph, as `^p` does. Missing or out-of-range groups expand to nothing, and the `^` codes still work (#4177).
- 2026-10-24: A click just outside a floating table, within 2pt of a cell whose text is turned (Text Direction, or a section whose text runs down or up the page), goes to the text beside or after the table, as it already did by upright cell text, instead of putting the caret in that cell (#4181).
- 2026-10-23: DOCX: a document whose default font size is unset keeps its size when saved and reopened. The size was left out of the file, and the reader read the missing size as 10 pt while the document resolved it as 11 pt; the file now says 11 pt (`DEFAULT_FONT_SIZE`). Files from other programs without a size are read as before.
- 2026-10-23: Insert › Table: hovering the grid shows the table on the page where it would go, as Word's Live Preview does, and the preview follows the pointer from square to square. It is laid out on a copy of the document, so nothing changes, and Undo has nothing new, until a square is clicked (#3931).
- 2026-10-24: Exact line spacing fixes the floating-table contact boundary independently of paragraph-mark metrics (#3977). Both row selection and word skipping use it; original regressions cover oversized and undersized marks, contact and one-twip offsets in modes 11/12/14/15. Shape behavior is unchanged.
@@ -290,16 +342,23 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
- 2026-10-20: Restrict Editing: read only and comments-only protection grey out every edit in the ribbon when no exception lets the author edit, and tracked-changes protection greys out Accept and Reject, instead of buttons that look live and refuse when clicked.
- 2026-10-20: Restrict Editing: with Filling in forms enforced, the Developer tab's content-control buttons and Properties grey out in the ribbon, their tooltips saying why, as in Word; before, they looked live and only refused when clicked.
- 2026-10-20: The page tip shown while dragging the vertical scrollbar stays on one line; it broke into two or three lines ("Page 10 of" / "40") as the page number grew during the drag (#3341).
- 2026-10-20: Layout: text in Apple's Times and Courier takes Times New Roman's and Courier New's single-line spacing, as in Word for Mac; in a corpus of 180 real manuscripts, the five set in Times no longer gain a page.
- 2026-10-19: Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in.
- 2026-10-19: Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey.
- 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096).
| Date | What landed |
| 2026-11-04 | The CLI opens a non-Unicode input filename even after options such as `--page 2` or `--format flatopc`; text option values still require Unicode; `run --file` and `run --script` retain native paths. |
| 2026-11-04 | Pictures keep the brightness, contrast and transparency their file gives them (#1711, #1766): a "Washout" picture watermark (VML `gain`/`blacklevel`) and a washed-out letterhead picture behind the text (`a:lum`) are drawn faded on screen and in PDF instead of at full colour, with the pixel curve measured on Word's own PDF export; `a:alphaModFix` transparency too. Saved back as DrawingML (and as VML `gain`/`blacklevel` in Word 2003 XML, without the transparency). Picture Format › Corrections, Transparency and Color › Washout replace the file's values (other Color modes leave them alone), and Reset Picture discards them all, as in Word; the PDF embeds the corrected pixels once per picture |
|---|---|
| 2026-10-17 | Copies keep their formatting in programs that read RTF rather than HTML: Copy and Cut put RTF beside the text and HTML on the system clipboard on Windows, macOS and Wayland (an X11 copy stays text and HTML, since arboard owns the selection there; a copy of only pictures or shapes carries no HTML or RTF, so pasting it back keeps every detail), and Paste reads another program's RTF on macOS and X11 too, not only on Windows and Wayland |
| 2026-11-03 | **Arabic interface (#1195):** `ar` selectable (and every `ar-*` system locale); craft-i18n catalog; mirrored ribbon, panes, File page, dialogs and menus; Arabic UI font fallback; document direction stays independent. No kashida or Arabic proofing yet; Hebrew UI still open. |
| 2026-11-01 | **English spelling reads SCOWL 2026 (CP2, #40, #520, #1281, #1045):** en-US, en-GB (also en-AU, en-NZ, en-IE, en-IN…) and en-CA each check against their own SCOWL `rel-2026.02.25` Hunspell dictionary from craft-i18n, embedded and read by craft-proof's affix engine (which now understands `COMPOUNDRULE`, so ordinals like `21st` are words). Suggestions come from the dialect's own dictionary; our American/British policy (`en-variants.txt`) still decides the other dialect's spellings, because Word accepts more than SCOWL's one spelling per dialect (`organize` and `organise` in en-GB). Rare words the 1990s Moby list accepted (`aposiopesis`) are now flagged. The Moby list stays for hyphenation and as a fallback. The build carries 1.5 MB more Language data. |
| 2026-11-01 | Proofing moved to the shared libraries, with nothing users see changing: the code is `craft-proof` in storytold/craft-libs (with its history) and the spelling and hyphenation data (the Moby list, our English patterns and word lists, the German, Polish, Portuguese and Italian patterns, `ru-extra`) is craft-i18n's `language-data/`, embedded from `CRAFT_I18N_DIR` at the commit in `craft-i18n.rev`. Before/after `wordcraft-cli` runs on a proofing document give the same issues, suggestions and hyphenated PDF (CP1). |
| 2026-10-28 | Fonts a document uses that aren't installed are no longer silent: the font box shows a warning mark (hover names the font drawn instead) and the status bar says how many are missing, listing them (the first 20, then how many more) with the faces drawn instead and Add Font…. The list is what layout actually drew, so hidden text, unused styles and orphaned stories don't count; substitutes that measure the same (Carlito for Calibri, Liberation for Arial) and Symbol/Wingdings bullets aren't flagged |
| 2026-10-21 | Read Aloud on Linux speaks through Speech Dispatcher when neither `espeak-ng` nor `espeak` is installed (#4576). It talks to the server over its own socket (SSIP), so the text is never taken for a command and stopping cancels only WordCraft's own speech, not a screen reader's; each sentence goes in its language, region included, and a sentence a screen reader's announcement cuts off is said again after a short pause; if another program keeps stopping it, reading pauses on it with a message. With no speech program at all, Read Aloud names the package to install, and the `.deb` and `.rpm` packages recommend one. |
| 2026-10-31 | trivial | Preserve recipient columns after an unquoted whitespace-only prefix in a headered CSV (#4249); keep quoted headers, headerless whitespace records and shared CSV parsing. |
| 2026-10-31 | trivial | Integrate complete-record recipient CSV delimiter detection (#4249) with current main; preserve leading blank-line detection (#5042), original regressions and readiness estimates. |
| 2026-10-28 | **Arabic diacritic editing (#4757):** Backspace removes the last stored Arabic combining mark, so its base letter can stay while the mark is replaced. Caret movement and forward Delete keep their cluster rules. Synthetic command, history, protection and native/DOCX fresh-reopen checks; no native Word comparison or readiness change. |
| 2026-10-29 | Insert › Shapes shows the basic shapes as a grid of code-drawn icon tiles, each with an accessible name and tooltip, instead of a text list (#4349) |
| 2026-11-03 | A password-protected file named on the command line (double-clicked in the file manager) asks for its password when the first blank document was replaced because File › Options chose a region; the window now follows that replacement at once, so the first frame no longer takes it for a document switch and drops the Password box (#5299) |
@@ -308,9 +367,15 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| 2026-10-29 | A NUL in a document title no longer reaches the native window title, which panicked on X11 (#3634): displayed titles and suggested file names drop NUL characters and fall back to the untitled name when empty; the stored title is unchanged |
| 2026-10-28 | The Traditional Chinese interface draws hanzi to the Taiwan standard: on the desktop an installed Traditional Chinese font (PingFang TC on macOS, Microsoft JhengHei on Windows, Noto Sans CJK TC on Linux) comes before the embedded Noto Sans CJK SC, which drew 說, 這, 為, 戶 in mainland shapes; without one, and on the web, nothing changes. |
| 2026-10-17 | macOS launch arguments (`-psn_*`, `-NS*`, `-Apple*`) are no longer opened as files, and a file that fails to open names the file and the real cause on the status bar and in the log (#1103). The reported "corrupt" error could not be reproduced. |
| 2026-10-19 | Linux: File › Options › Advanced › Linux display server picks Automatic (the default: XWayland where there's an X server, for file drag-and-drop), Wayland or X11 (XWayland), so people who don't start WordCraft from a terminal can switch without an environment variable (#2184). It's kept in `ui.json` and applies at the next launch; `WORDCRAFT_WAYLAND` still overrides it, and `WORDCRAFT_WAYLAND=0` now asks for X11. A choice that isn't available falls back to the other instead of failing to start, and the log's `display:` line says which ran and why. Hidden on Windows, macOS and the web; `ui.displayServer` for agents. |
| 2026-10-19 | The status bar's count is selectable (#2797): words (default), characters (with spaces) or characters (no spaces), from a right-click menu on the count itself or File › Options › Display › Status bar (`ui.statusCount`), kept in `ui.json` between runs; a selection shows "selected of total" in the chosen unit, counted exactly as the Word Count dialog counts them, whose numbers and meaning are unchanged. |
| 2026-10-14 | Insert › Date & Time and DATE, TIME and other date fields show the computer's local date, time and weekday, daylight saving included, as Word does (#1707); they showed UTC, so a user in New Zealand at noon on 31 October got 30 October 23:00. Timestamps saved in files (revisions, comments, document properties) stay UTC. Backstage's greeting follows the local hour too |
| 2026-10-23 | File > Print: N-up physical sheets and left/right-bound booklets, shared preview/PDF geometry and padded copies (#641); printer jobs keep upstream’s link-annotation fix. |
| 2026-10-17 | Password-protected Word files from Word 2007 (Standard encryption: AES-128/192/256, SHA-1) open with their password, also when named `.doc` or `.dot`; a wrong or missing password is reported as for newer files. Word 97-2003 files with an AES header, which the public spec doesn't define, are refused with a clear message (gap 9) |
| 2026-11-04 | Quitting over the control channel and MCP bridge is refused while any open document has unsaved changes — a programmatic call never prompts — and `{"force": true}` quits without saving. The Save Changes prompt and the refusal now share one predicate, so they cannot drift apart (#3274). |
| 2026-10-31 | A picture or shape anchored in a line of a long paragraph on a later page is drawn on that page, as in Word, instead of the page the paragraph starts on; one dropped on such a page stays where it was dropped (#4250) |
| 2026-10-31 | A picture or shape anchored in a line of a long paragraph on a later page is drawn on that page, as in Word, instead of the page the paragraph starts on; one dropped on such a page stays where it was dropped (#4250) |
| 2026-10-31 | A picture or shape dropped beside the first line after a page break inside a paragraph stays on the new page, as in Word, instead of going back to the page before the break (#4262) |
| 2026-10-27 | Connector rerouting indexes laid-out object positions once per batch, preserving page hints, first-match order and same-page glue; synthetic equivalence, geometry and scaling regressions (#3631); routing policy and readiness estimates unchanged |
| 2026-10-28 | Markdown import bounds nested undefined reference-label work within the existing link allowance; literal source and normal reference matching are preserved (#4658). |
| 2026-10-23 | A command that changes nothing is not an edit: it adds no undo step, marks no unsaved changes and forces no relayout, so the redo stack survives it and five hundred of them can no longer push the user's own edits out of the undo history. |
@@ -428,6 +493,8 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| 2026-10-17 | Non-overlapping body floating tables clear header tables. Original native controls distinguish never-overlap from allowed overlap. |
| 2026-10-14 | Comment text being typed is no longer lost or misplaced (#1348): an edit or reply the document refuses (for example read-only protection) keeps the typed text in its field and says why on the status line; drafts belong to their document, so after New or Open a comment with the same number shows its own text; Save, Export, Print, Close, New and Open write pending comment text first; if the document refuses it, Save, Export and Print carry on with the stored comments and show why on the status line (a refused draft is dropped when Save is the only change), while Close, New and Open show the Save Changes prompt (Don't Save drops text the document refuses); Escape discards what was typed; in a document protected as read only or for forms the comment fields can't be typed in |
|---|---|
| 2026-11-07 | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). |
| 2026-11-05 | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. |
| 2026-10-29 | Print Markup discovers comment balloon anchors in one forward paragraph-object walk; anchor positions, filtering and the existing cap stay unchanged (#3614) |
| 2026-10-26 | Spelling & Grammar opens a right-side Proofing task pane observed in Word for Windows: sentence context, directly clickable suggestion cards and ignore/dictionary actions. Current-story traversal and stale-target checks are retained. Right-click keeps its compact menu. |
| 2026-10-22 | Documents that keep content in another format (`w:altChunk`: a web page, RTF, text or another Word document, as generators and mail-merge tools write them) show it where it stands, as Word does, instead of leaving it out; one such file in the docxide-pdf corpus opened as an empty page and now matches Word's 8 pages. `.mht` web archives open, and web pages open the way Word opens them (style sheets, paragraph margins, Times New Roman, single lines, automatic spacing). Insert › Text from File keeps the inserted file's pictures |
@@ -759,8 +826,18 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
Footer page-table height and body wrapping are corrected. Other pagination gaps remain open.
Quoted field escapes are interpreted like Word, and body `PAGE` fields use their active section number format in paragraphs and tables. Synthetic regressions cover fields and section layout.
Section page-numbering settings preserve `w:chapStyle` values across the full unsigned-byte range.
MATH glyphs that reference the same construction now share its immutable variants and assembly data (#3630). Glyph mappings, values and the existing partial-record fallback stay unchanged. Repeated glyph IDs keep their last valid reference without retaining superseded constructions. Distinct overlapping constructions and assembly-only sharing remain separate.
## Revision history
> 2026-11-05 · Linux self-update ignores another program's AppImage variables (#5653); recorded in the progress log, readiness unchanged.
> 2026-11-05 · A template opened from the file manager starts a new document instead of being edited and saved over (#3433); recorded in the progress log, readiness unchanged.
> 2026-11-05 · Arabic letters join across a change of formatting and past hidden or deleted text (#4093); recorded in the progress log, readiness unchanged.
> 2026-11-05 · Shapes, text boxes, groups and canvases in Strict Open XML documents open (#3548); recorded in the progress log, readiness unchanged.
>
> 2026-11-03 · Arabic dialogs read right to left (rtl::Table); recorded in the progress log, readiness unchanged.
>
> 2026-10-30 · Technical synchronization: retain the Proofing pane and command/selection/undo contracts on main `3b7f7ed`; new-SHA validation pending, readiness unchanged.
@@ -773,12 +850,47 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| Date | Change | Summary |
|---|---|---|
| 2026-11-06 | trivial | Progress log: the Arabic interface's Styles gallery reads from the right (#5566) |
| 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). |
| 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. |
| 2026-11-07 | trivial | Progress log: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). |
| 2026-11-03 | trivial | Progress log: the title bar's Search box takes the typing in place (#5398) |
| 2026-10-31 | trivial | Progress log: a numFmt none list level shows its literal text (#4985); readiness unchanged |
| 2026-10-31 | trivial | Progress log: font menu entries are drawn from shared textures (#5105) |
| 2026-11-02 | trivial | Progress log: a linked picture shows and follows its link; DOCX `a:hlinkClick` (#5267) |
| 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open |
| 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) |
| 2026-11-06 | trivial | Windows print submissions isolated per helper process; cancelled driver work cannot block a retry (#5710) |
| 2026-11-06 | trivial | Progress log: print cancellation before sending, including without a document, and the Windows repeated-conversion limitation (#5710) |
| 2026-11-06 | trivial | Progress log: the printer list no longer waits on printer drivers (#5710) |
| 2026-11-05 | trivial | PR #4545: eligible cell floats retain in-bounds offsets and touch the outer cell edge only on overflow, ignoring wrap distances for object placement. Front-of-text, turned-cell and outside-cell opt-outs remain. Synthetic geometry controls; native front/behind-text parity remains unqualified. |
| 2026-11-04 | trivial | Progress log: CLI convert format options; readiness estimates unchanged |
| 2026-11-04 | trivial | Progress log: MHT quoted MIME parameters; readiness estimates unchanged |
| 2026-10-30 | trivial | Progress log: typed n-ary operators such as ∫ and ∑ wait for their limits, and any character that cannot continue a math name corrects it, as observed in Word for Windows on #4938; Enter, arrow keys and nested operators are WordCraft's own choices (#4917); readiness unchanged |
| 2026-11-04 | trivial | CLI input paths keep their native filename when a value-taking option comes first; regression coverage includes conversion to Flat OPC and rejection of non-Unicode option values. |
| 2026-11-05 | trivial | DOCX paragraph `w:adjustRightInd` is preserved with true, false and absent values; OOXML properties follow schema order. Grid layout remains open. |
| 2026-11-04 | trivial | Progress log: RTF Unicode fallback counts; readiness estimates unchanged |
| 2026-10-28 | trivial | Per-page ink object lookups, #3614. Drawing order, geometry and saved data retain their behavior; no readiness estimate change |
| 2026-11-05 | trivial | Progress log: ODT heading text of another size (gap 19) |
| 2026-11-05 | trivial | Progress log: ODT table rows that don't break (gap 19) |
| 2026-11-05 | trivial | Progress log: a watermark or odd and even headers change to a file is saved |
| 2026-11-05 | trivial | Progress log: ODT font declarations kept (gap 19) |
| 2026-11-05 | trivial | Progress log: ODT pictures offered in several formats (gap 19) |
| 2026-10-28 | trivial | Shared MATH construction records reuse variants and assembly data without changing glyph mappings or existing limits (#3630); readiness estimates unchanged |
| 2026-10-29 | trivial | Page drawing indexes text-box warp lookups per paragraph (#3614); requested offsets only, bounded prefix traversal, original Linux display and persistence controls; readiness estimates unchanged |
| 2026-10-31 | trivial | Progress log: a shape dropped just after a page break inside a paragraph stays on the new page (#4262) |
| 2026-10-31 | trivial | Progress log: a floating object anchored on a later page of a long paragraph is drawn there (#4250) |
| 2026-10-28 | trivial | Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. |
| 2026-11-04 | trivial | Progress log: OpenDocument display settings removed (gap 19) |
| 2026-10-31 | trivial | Progress log: dialogs are modal except the five Word keeps modeless; a press beside one shakes it (#3859) |
| 2026-11-04 | trivial | Progress log: picture brightness, contrast and transparency (#1711, #1766) |
| 2026-11-04 | minor | `app.quit` over the control channel and MCP bridge is refused while a document has unsaved changes, and `{"force": true}` quits without saving; the prompt and the refusal share one predicate (#3274). |
| 2026-11-04 | minor | `file.closeDocument` over the control channel and MCP bridge is refused while the document it names has unsaved changes, a save the browser's picker hasn't finished, or a comment still being typed; `{"force": true}` discards them. The prompt and the refusal share one predicate (#3274). |
| 2026-11-04 | minor | A programmatic `file.close` on a document shown in two windows closes the window alone, as the window's own close does: the document and its unsaved work stay open, so the control channel no longer refuses a close that loses nothing (#3274). |
| 2026-11-04 | trivial | Progress log: ODT element audit (gap 19) |
| 2026-11-04 | trivial | Progress log: ODT checked against 220 files from other applications (gap 19) |
| 2026-11-04 | trivial | Progress log: OpenDocument files shown in Microsoft Word style or LibreOffice style (gap 19) |
| 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. |
| 2026-11-01 | trivial | Progress log: SEQ, STYLEREF and REF fields show fresh results in output |
| 2026-10-29 | trivial | Progress log: Print Markup balloon anchors use one object walk per paragraph (#3614); readiness estimates unchanged |
| 2026-10-28 | trivial | Print Markup text-copy comment discovery walks paragraph objects once (#3614); quotes, order, limits and readiness estimates unchanged |
@@ -786,15 +898,18 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-11-03 | trivial | Progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language (CP4.3) |
| 2026-11-03 | trivial | Progress log: Options › Language and Proofing redesign (CP4.3) |
| 2026-10-30 | trivial | Progress log: layout resource caps — repeated table headers are bounded by item counts, the wrap cache by decoded pixels and linked text box layout by its lines and items; PDF export, hit testing, tracked changes, clipped tables and long endnote selections cost linear time (#3867, #3866) |
| 2026-10-17 | trivial | Progress log: RTF on the system clipboard |
| 2026-11-03 | trivial | Progress log: craft-proof pin, French elision suggestions and Valencian (CP4.3) |
| 2026-11-03 | trivial | Progress log: a long comment's card and balloon stop at ten lines (#3382) |
| 2026-11-03 | trivial | Progress log: Shape Format › Size in the interface unit (#4196) |
| 2026-11-03 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. |
| 2026-11-03 | trivial | Progress log: Replace and Remove Table of Contents with Word's title above a section break (#4041) |
| 2026-11-03 | trivial | Progress log: language-packs-2 published and pinned, 75 Language packs |
| 2026-11-02 | trivial | Progress log: language-packs-1 published and pinned, 61 Language packs (CP4.4) |
| 2026-11-01 | trivial | Progress log: Language packs install from File › Options › Language and the missing-pack bar once language-packs-1 is published (CP4) |
| 2026-11-01 | trivial | Progress log: English spelling reads SCOWL 2026 (CP2) |
| 2026-11-02 | trivial | Honor page break before at a continuous section's mid-page top (#5287); its later-column case remains unmeasured in Word. Readiness unchanged. |
| 2026-11-01 | trivial | Progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes |
| 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open |
| 2026-11-01 | trivial | Progress log: columns for selected text draw their line between (#5178) |
| 2026-10-31 | trivial | Progress log: a Word 365 .doc in Aptos opens in Aptos (#5066); readiness unchanged |
| 2026-10-31 | trivial | Progress log: a table row doesn't split off a picture that doesn't fit (#4272) |
@@ -803,6 +918,8 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-31 | trivial | Progress log: a list level above counts its first number at the first deeper item |
| 2026-11-01 | trivial | Progress log: paragraph side borders lie outside the text (#5036) |
| 2026-11-01 | trivial | Progress log: proofing moved to craft-proof (craft-libs) and its data to craft-i18n `language-data/` (CP1) |
| 2026-11-01 | trivial | #3915: preserve guarded whole-line ordinary-tab alignment with current main. Existing controls retained; source checks only, primary qualification pending. |
| 2026-11-01 | trivial | #2437: Preserve finite paragraph-border flow extents and terminal-break keep preflight with current-main changes. Recursive terminal-successor lookahead remains outside scope. Source checks only; primary qualification pending. |
| 2026-10-31 | trivial | Progress log: a paragraph naming an undefined DOCX style is formatted as Normal (#5077) |
| 2026-10-31 | minor | #3398: persistent screen-only green, cream and gray paper presets, with document backgrounds and export colors retained. Read Mode None follows the screen preset; Sepia/Inverse retain priority. |
| 2026-10-31 | trivial | Progress log: TOC page numbers are PAGEREF fields (#5097) |
@@ -811,6 +928,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-29 | trivial | Progress log: documents with links print (#4734) |
| 2026-10-31 | trivial | Synchronize the existing Proofing pane with main `eecbb8f`; preserve explicit-position lookup, implicit selection, navigation, undo and per-window state; fresh exact-SHA hosted validation pending, readiness unchanged |
| 2026-10-30 | trivial | Synchronize the existing Proofing pane and navigation/selection/undo regressions with main `3b7f7ed`; new-SHA validation pending, readiness unchanged |
| 2026-10-31 | trivial | Progress log: Linux Read Aloud through Speech Dispatcher (#4576) |
| 2026-10-28 | trivial | Aligned copies retain their locked document coordinate during middle-button scrolling and can start on secondary selected objects; existing Ctrl and Shift selection behavior stays (#4764) |
| 2026-10-28 | trivial | Ctrl/Command+Shift drags a selected floating object into an axis-aligned copy (#4764); synthetic Linux interaction and persistence controls, native desktop and Word untested; gap/readiness estimates unchanged |
| 2026-10-28 | trivial | Connector object lookups retain the typed column-selection inputs on current main (#3631); readiness estimates unchanged |
@@ -826,14 +944,22 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-29 | trivial | Progress log: window fallback geometry and equation symbol tooltips |
| 2026-10-29 | trivial | Progress log: window titles omit NUL characters (#3634) |
| 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. |
| 2026-10-19 | trivial | Progress log: Linux display server setting (#2184) |
| 2026-10-19 | trivial | Progress log: the status bar's count can be words, characters or characters without spaces (#2797) |
| 2026-10-27 | trivial | Progress log: Return on File › Print prints, as Word's default Print button does |
| 2026-10-27 | trivial | PR #4609: Numeric cross-references (#4533): preserve the numbered clause through field updates; adapted from @Alyosha-dot-shop's issue patch, with extra relative-context and cache guards. Synthetic checks only; readiness estimates unchanged. |
| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; inspected previews prompted a full-width context and scrollable three-card list; exact selected spelling takes priority over overlapping grammar, with command/pane regressions and integration workflow paths; readiness estimates unchanged |
| 2026-10-14 | trivial | Progress log: Insert Date and Time and DATE/TIME fields use local time (#1707) |
| 2026-10-27 | trivial | Preserve #4249 complete-record CSV delimiter detection and its regressions in the mailings modules; recipient commands, limits and refusal behavior unchanged. |
| 2026-10-26 | trivial | Integrate #4249 with current-main CSV helpers, preserving logical-record detection, existing recipient limits and refusal behavior. |
| 2026-10-25 | trivial | Recipient CSV delimiter detection reads the first complete record, including quoted line breaks and doubled quotes; separator ties and cell parsing remain unchanged. Original parser and CLI import/save/reopen checks; readiness estimates unchanged. |
| 2026-10-22 | trivial | Progress log: one native window per open document |
| 2026-10-22 | trivial | Progress log: every open document keeps its own recovery copy |
| 2026-10-22 | trivial | Progress log: several open documents in the desktop window |
| 2026-10-22 | trivial | Progress log: several open documents in one session |
| 2026-10-22 | trivial | Decode numeric XML references once in legacy form names and drop-down labels |
| 2026-10-25 | trivial | Progress log: egui 0.37 migration records the `craft-i18n.rev` catalog pin (9205108…; 27 languages) and the input-method handling; managed CI passed; native acceptance and native-speaker review remain open |
| 2026-10-25 | trivial | Progress log: justified and distributed lines keep the width of the spaces before their first character |
| 2026-10-17 | trivial | Progress log: Standard-encrypted (AES) Word files open (gap 9) |
| 2026-10-25 | trivial | Progress log: Text Highlight Color buttons show the remembered colour, or no colour bar after No Color (#4087) |
| 2026-10-23 | minor | A command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it. |
@@ -848,6 +974,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-24 | trivial | Progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting |
| 2026-10-24 | trivial | Progress log: the parity catalog counts Read Mode's View menu (Column Width, Page Color, Layout) |
| 2026-10-24 | trivial | Progress log: regex Replace expands capture groups (#4177) |
| 2026-10-24 | trivial | Progress log: a click just outside a floating table by turned cell text goes to the text after it (#4181) |
| 2026-10-23 | trivial | Progress log: interface translations from storytold/craft-i18n through the `craft-i18n` crate |
| 2026-10-23 | trivial | Progress log: comment times in local time (#2322, #2337) |
| 2026-10-22 | trivial | Progress log: mail merge record navigation and record ranges for Finish & Merge and Print Documents (#3028) |
@@ -893,6 +1020,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-19 | trivial | A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, starts the next line with its text instead of ending the line (a TOC entry's page number keeps its leader); a left tab still past the right indent there takes no room (#1840) |
| 2026-10-19 | trivial | Progress log: a picture dragged near the bottom of a page lands there (#3169) |
| 2026-10-19 | trivial | Progress log: a Thai run with Latin finds one installed font covering it all on Linux too (#1549) |
| 2026-10-20 | trivial | Progress log: Times and Courier take their Windows counterparts' line spacing |
| 2026-10-19 | trivial | Progress log: forms protection keeps its content controls: no inserting, Properties or Remove Content Control |
| 2026-10-19 | trivial | Progress log: text typed into a content control takes the control's formatting; localized placeholder styles recognised |
| 2026-10-17 | trivial | Progress log: #2048 entry covers anchors pushed onto the next page |
@@ -1207,6 +1335,12 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-29 | trivial | Progress log: corporate authors in bibliographies (#4848) |
| 2026-10-29 | trivial | Progress log: Table Properties follows the selection (#4857) |
| 2026-11-01 | trivial | Progress log: a list inside a Markdown block quote stays quoted |
| 2026-10-29 | trivial | Progress log: citation source and bibliography-number lookups (#4888) |
| 2026-10-27 | trivial | Progress log: AutoCorrect Options with no document open (#4585) |
| 2026-10-28 | trivial | Progress log: RTF metafile pictures are imported (#4259) |
| 2026-10-28 | trivial | Progress log: Chinese and Japanese text counts a word per character (#4547) |
| 2026-10-27 | trivial | 2026-10-27 · #4533 review: numeric REF updates lazily index body bookmarks once per update, retaining the first body bookmark for duplicate names. Unsupported switches and ordinary REF/PAGEREF behavior are unchanged; a synthetic 64-reference regression covers repeated updates and duplicate names. Estimates unchanged. |
| 2026-10-27 | trivial | 2026-10-27 · #4533: numeric REF fields use paragraph list numbers for no/full/relative context, reusing bounded numbering. Shared context already embedded in a level label is removed; merged-away cells do not count. Unsupported separator/text/position switches and ambiguous contexts or delimiter-free joins retain cached results. Synthetic OOXML regressions; no native Word or real-document validation, estimates unchanged. |
> 2026-10-29 · Port #3915 to split layout modules: ordinary LTR tab groups follow paragraph alignment. Positional tabs, lists, RTL and display math retain their alignment paths. Source port only; no test execution or new native measurements.
> 2026-10-29 · Port #2437 to split layout modules: finite border extents and terminal-break keep preflight. Recursive keep lookahead remains outside scope. Source port only; no test execution or new native measurements.
+22 -6
View File
@@ -98,8 +98,8 @@ fn open(path: &Path, password: Option<&str>) -> Result<Session, String> {
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`.
/// The commands that open a file, and which positional argument it is, excluding option values.
/// `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,
@@ -108,8 +108,14 @@ const OPENS_FILE: &[(&str, usize)] = &[("convert", 0), ("info", 0), ("text", 0),
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 specs = match cmd {
"convert" => CONVERT_OPTIONS,
"render" => RENDER_OPTIONS,
"zotero" => ZOTERO_OPTIONS,
_ => PASSWORD_OPTIONS,
};
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),
Some((_, n)) => positional_indices(args.get(1..).unwrap_or_default(), specs).get(*n).map(|i| i + 1),
None if cmd == "run" => args.iter().position(|a| a == "--file").map(|i| i + 1),
None => None,
};
@@ -145,8 +151,13 @@ fn password_from(args: &[String]) -> Result<Option<String>, String> {
}
/// 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.
/// path. Uses the same option specifications as [`validate_options`].
fn positionals<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a String> {
positional_indices(args, specs).into_iter().filter_map(|i| args.get(i)).collect()
}
/// Keep the original indices too, so [`text_args`] can open a positional path by its native name.
fn positional_indices(args: &[String], specs: &[OptionSpec]) -> Vec<usize> {
let mut out = Vec::new();
let mut i = 0;
while let Some(arg) = args.get(i) {
@@ -156,7 +167,7 @@ fn positionals<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a String>
i += 2;
} else {
if !arg.starts_with("--") {
out.push(arg);
out.push(i);
}
i += 1;
}
@@ -430,7 +441,12 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
"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 format = match find_option_arg(&rest, "--format") {
OptionArg::Absent => None,
OptionArg::Duplicate => return Err("convert: duplicate --format option".into()),
OptionArg::MissingValue => return Err("convert: --format requires a value".into()),
OptionArg::Value(value) => Some(value),
};
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 markup = rest.iter().any(|a| a == "--markup");
+21
View File
@@ -75,6 +75,22 @@ fn unknown_options_are_rejected_before_file_access() {
assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into()));
}
#[test]
fn convert_rejects_malformed_format_options_before_file_access() {
for (options, error) in [
(vec!["--format"], "convert: --format requires a value"),
(vec!["--format", "--markup"], "convert: --format requires a value"),
(vec!["--format", "flatopc", "--format", "wordml2003"], "convert: duplicate --format option"),
] {
let mut argv = args(&["convert", "missing.docx", "out.xml"]);
argv.extend(args(&options));
assert_eq!(run(&argv), Err(error.into()), "{options:?}");
}
// A valid single value reaches file access, rather than a format-option error.
let error = run(&args(&["convert", "missing.docx", "out.xml", "--format", "flatopc"])).unwrap_err().to_string();
assert!(error.starts_with("missing.docx:"), "{error}");
}
#[test]
fn help_or_h_after_a_subcommand_asks_for_the_usage() {
for cmd in SUBCOMMANDS {
@@ -195,6 +211,10 @@ fn only_the_opened_file_may_be_other_than_unicode() {
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!["render".into(), "--page".into(), "2".into(), latin1(), "out.png".into()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["convert".into(), "--format".into(), "flatopc".into(), latin1(), "out.xml".into()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["info".into(), "--password".into(), "secret".into(), latin1()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["zotero".into(), "--port".into(), "23119".into(), "refresh".into(), latin1()]), 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
@@ -212,6 +232,7 @@ fn only_the_opened_file_may_be_other_than_unicode() {
for (raw, n) in [
(vec!["zotero".into(), latin1(), "in.docx".into()], 2),
(vec!["render".into(), "in.docx".into(), latin1()], 3),
(vec!["render".into(), "--page".into(), latin1(), "in.docx".into(), "out.png".into()], 3),
(vec!["run".into(), "--save".into(), latin1()], 3),
(vec!["commands".into(), latin1()], 2),
] {
@@ -53,6 +53,15 @@ fn the_file_a_command_opens_may_have_any_name() {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn an_option_before_the_input_keeps_its_native_name() {
let (dir, file) = folder("option-first");
let saved = dir.join("out.xml");
ok(&cli(&["convert".into(), "--format".into(), "flatopc".into(), file, saved.clone().into()]));
assert!(std::fs::read_to_string(saved).unwrap().contains("Bonjour"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn any_other_such_argument_is_an_error_naming_it() {
let (dir, file) = folder("error");
+3 -1
View File
@@ -16,6 +16,8 @@ path = "src/main.rs"
[dependencies]
wordcraft-engine = { workspace = true }
wordcraft-ui-egui = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
@@ -29,4 +31,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams", "CustomEvent", "CustomEventInit"] }
+65 -107
View File
@@ -1,14 +1,20 @@
//! 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`:
//! Host modes: a page that runs WordCraft (as an `<iframe>`, or in its own document) opens
//! documents in it and stores what it saves instead of the browser downloading. The messages and
//! the two transports are `craft-web-host`'s (storytold/craft-libs), the same contract as the
//! other Craft apps' bridges, with the `wordcraft:` prefix. This module is WordCraft's part of it.
//!
//! - `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:
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
//! `postMessage`; `data-host="page"` on the canvas (the page's own document, no iframe) uses
//! DOM `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
//! - `wordcraft:ready` once the app is running and listening; send a file now. WordCraft reads
//! bytes, so it says `file: false` and a host sends `bytes`.
//! - `wordcraft:open` (from the host): `{ name, bytes }`. It opens like a document picked with
//! File › Open or dropped on the window; a file over [`MAX_OPEN_BYTES`] is refused before it is
//! copied.
//! - `wordcraft:save` (to the host): `{ name, saveAs, bytes }` for every file WordCraft would
//! otherwise download. The host 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.
@@ -17,70 +23,57 @@
//! 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.
//! - `wordcraft:dirty` whenever unsaved changes appear or go away, `wordcraft:failed` 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.
//! In both host modes the host sends the document to edit, so no sample opens, saves are handed
//! to the host rather than downloaded, the host 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 craft_web_host::web::{FileData, Host, Message};
use wordcraft_ui_egui::{Inbox, Services, WordApp};
/// The app id in the host messages (`wordcraft:ready`).
const APP: &str = "wordcraft";
/// The name a document gets when the host sent none (the crate says `document` for a blank name).
const DEFAULT_NAME: &str = "Document.docx";
/// The largest document taken from the host page, refused before it is copied.
const MAX_OPEN_BYTES: u64 = 512 << 20;
/// 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,
host: Rc<Host>,
}
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 })
/// The bridge for this page, or `None` when WordCraft isn't hosted: no `?host=parent` and no
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
/// with downloads), or a parent on another origin.
pub fn detect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
Some(Self { host: Rc::new(Host::connect(APP, canvas)?) })
}
/// Hand saved and exported files to the parent instead of downloading them, report unsaved
/// Hand saved and exported files to the host 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.
// Anything else that would download goes to the host 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));
}
}));
// The crate does not repeat a state it already reported.
let host = self.host.clone();
services.on_dirty = Some(Box::new(move |dirty: bool| host.post_dirty(dirty)));
}
/// `?author=`: the host's name for the user signs comments and tracked changes.
@@ -92,78 +85,43 @@ impl Bridge {
}
pub fn post_ready(&self) {
self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
}
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
pub fn post_failed(&self, error: &str) {
self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
let _ = self.host.post_failed(error, None);
}
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:?}")))
self.host.post_save(&name, bytes, None, save_as)
}
/// Hear `wordcraft:open` from the parent and queue the file like a dropped one.
/// Hear `wordcraft:open` 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;
self.host.listen(move |message| {
if let Message::Open { name, data } = message {
deliver_open(&inbox, &ctx, &name, &data);
}
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
/// The file an open message carries, pushed onto the inbox for the next frame, like a dropped
/// file. A host's file is untrusted input: an oversized one is refused before it is copied into
/// the app's memory (the data's own length, not the host's claim). The crate has cleaned the name
/// (and cut it to 255 characters); a blank one comes as `document` and becomes `Document.docx`.
fn deliver_open(inbox: &Inbox, ctx: &egui::Context, name: &str, data: &FileData) {
let name = if name == "document" { DEFAULT_NAME } else { name }.to_string();
let FileData::Bytes(array) = data else {
log::warn!("host file {name} came as a File; WordCraft reads bytes only; not opened");
return;
};
if data.size() > MAX_OPEN_BYTES as f64 {
log::warn!("host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20);
return;
}
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, array.to_vec()));
ctx.request_repaint();
}
+4 -2
View File
@@ -9,8 +9,10 @@
//! 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.
//! document to open and stores what WordCraft saves (`host`). A page can also host WordCraft in
//! its own document (no iframe) with `data-host="page"` on the canvas, talking over DOM
//! `CustomEvent`s on the canvas. `&author=` names the user for comments and tracked changes in
//! either host mode.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(any(target_arch = "wasm32", test))]
+6 -3
View File
@@ -28,9 +28,12 @@ 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 };
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing WordCraft, opens documents in
// it and stores what it saves (`host`); unframed there is no parent, so that URL on its
// own stays the normal app with downloads. `data-host="page"` on the canvas opts a page
// in instead: it runs WordCraft in its own document and talks over DOM `CustomEvent`s
// on the canvas (`host`). The host guards leaving the page, so this one doesn't.
let bridge = crate::host::Bridge::detect(&canvas);
let app_bridge = bridge.clone();
let dirty = Rc::new(Cell::new(false));
let saves = AsyncSaves::default();
+12 -4
View File
@@ -22,6 +22,8 @@ wordcraft-control-key = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11", "links"] }
egui = { workspace = true }
serde_json = { workspace = true }
serde = { workspace = true }
tempfile = "3"
log = { workspace = true }
chrono = { workspace = true }
@@ -38,13 +40,14 @@ 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.
# traffic lights, the window, button and view ones; and `NSPasteboard` for the clipboard's RTF.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication", "NSWindow", "NSButton", "NSControl", "NSView", "NSResponder", "NSPasteboard"] }
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"] }
# the build through arboard, at this version; this adds only the NSString bindings, and NSData
# for the clipboard's RTF (`public.rtf`).
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSData"] }
# 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"
@@ -58,6 +61,9 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block
# 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"
# Another program's RTF on X11 (`text/rtf`), which arboard doesn't read. Already in the build
# through arboard, at this version.
x11rb = "0.13"
[target.'cfg(target_os = "windows")'.dependencies]
# The upstream default-apps button opens Windows Settings; printing uses craft-print instead.
@@ -73,7 +79,9 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi
clipboard-win = { version = "5.4", features = ["std"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# Pinned apart from craft-ui, which doesn't depend on it: `printer_queues`, the printer list that
# asks no driver (#5710, storytold/craft-libs#618), and `submit_cancellable` (storytold/craft-libs#622).
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "a3e0b5a9440d18f19422a9dca13516747b7d3181" }
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.
+139
View File
@@ -0,0 +1,139 @@
//! An AppImage's environment inherited from another program (#5653).
//!
//! The AppImage runtime gives the program it starts `APPIMAGE` (the image's file), `APPDIR` (where
//! it is mounted), `ARGV0` and `OWD`, and whatever that program starts inherits them. So an AppImage
//! app that opens a document through `xdg-open` starts an installed WordCraft with *its* `APPIMAGE`,
//! and the self-update, which trusts that variable, replaces the other app's file with WordCraft.
//!
//! This copy is in the image only when its executable is under `APPDIR`. Otherwise the variables
//! are another program's, and the process starts over without them, before anything reads them.
//! (Removing them in place is `unsafe` under this edition, and `exec` keeps the process id, the
//! arguments and the standard streams.)
use std::ffi::OsStr;
use std::io::Write;
use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::Command;
/// What the AppImage runtime sets; the same list as the documented workaround on the issue.
const RUNTIME_VARS: [&str; 4] = ["APPIMAGE", "APPDIR", "ARGV0", "OWD"];
/// Whether the executable runs from the image mounted at `appdir`. Anything unclear counts as not:
/// the update then goes through the package or the plain file this copy really is, and at worst
/// says it can't, where the other answer would overwrite a file that isn't WordCraft's.
fn runs_from_image(exe: Option<&Path>, appdir: Option<&OsStr>) -> bool {
let (Some(exe), Some(appdir)) = (exe, appdir) else { return false };
let appdir = Path::new(appdir);
if !appdir.is_absolute() {
return false;
}
let Ok(appdir) = appdir.canonicalize() else { return false };
// No image mounts at the root, where every program on the machine would count as inside it.
appdir.parent().is_some() && exe.starts_with(&appdir)
}
/// Starts this process over without another program's AppImage variables; returns only when there
/// are none to drop (`None`) or starting over failed. Call it first, before any other thread starts.
pub(crate) fn drop_inherited() -> Option<std::io::Error> {
let appimage = std::env::var_os("APPIMAGE")?;
let exe = std::env::current_exe().ok();
if runs_from_image(exe.as_deref(), std::env::var_os("APPDIR").as_deref()) {
return None;
}
// The logger isn't up yet, and wouldn't survive `exec`. Unlike `eprintln!`, never panics.
let _ = writeln!(std::io::stderr(), "wordcraft: ignoring the AppImage {:?} inherited from another program", Path::new(&appimage));
let mut args = std::env::args_os();
// This file, whatever has happened to its path since it started.
let mut again = Command::new("/proc/self/exe");
if let Some(arg0) = args.next() {
again.arg0(arg0);
}
again.args(args);
for var in RUNTIME_VARS {
again.env_remove(var);
}
Some(again.exec())
}
#[cfg(test)]
mod tests {
use super::runs_from_image;
use std::path::{Path, PathBuf};
/// A folder of its own for one test, removed when it ends.
struct Tmp(PathBuf);
impl Tmp {
/// An empty folder named after `test` and this process, made afresh.
fn new(test: &str) -> Tmp {
let dir = std::env::temp_dir().join(format!("wordcraft-appimage-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Tmp(dir)
}
}
impl Drop for Tmp {
/// Removes the folder and what the test left in it.
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A mount with the executable inside it, by real path (`/tmp` may be a link).
fn mount(root: &Path, name: &str) -> (PathBuf, PathBuf) {
let dir = root.join(name);
std::fs::create_dir_all(dir.join("usr/bin")).unwrap();
let dir = dir.canonicalize().unwrap();
let exe = dir.join("usr/bin/wordcraft");
(dir, exe)
}
#[test]
fn an_executable_in_the_mount_is_the_image() {
let tmp = Tmp::new("own");
let (dir, exe) = mount(&tmp.0, ".mount_WordCr");
assert!(runs_from_image(Some(&exe), Some(dir.as_os_str())));
}
#[test]
fn a_link_to_the_mount_is_the_same_mount() {
let tmp = Tmp::new("link");
let (dir, exe) = mount(&tmp.0, ".mount_WordCr");
let link = tmp.0.join("link");
std::os::unix::fs::symlink(&dir, &link).unwrap();
assert!(runs_from_image(Some(&exe), Some(link.as_os_str())));
}
#[test]
fn an_installed_executable_is_not_in_another_programs_mount() {
let tmp = Tmp::new("other");
let (other, _) = mount(&tmp.0, ".mount_other");
assert!(!runs_from_image(Some(Path::new("/usr/bin/wordcraft")), Some(other.as_os_str())));
}
/// Paths compare by component: one mount's name can start with another's.
#[test]
fn a_mount_whose_name_starts_the_same_is_another_mount() {
let tmp = Tmp::new("prefix");
let (other, _) = mount(&tmp.0, ".mount_ab");
let (_, exe) = mount(&tmp.0, ".mount_abc");
assert!(!runs_from_image(Some(&exe), Some(other.as_os_str())));
}
#[test]
fn anything_unclear_is_not_the_image() {
let tmp = Tmp::new("unclear");
let (dir, exe) = mount(&tmp.0, ".mount_WordCr");
// No `APPDIR`, one that's gone (the other program has exited) or empty.
assert!(!runs_from_image(Some(&exe), None));
assert!(!runs_from_image(Some(&exe), Some(tmp.0.join("gone").as_os_str())));
assert!(!runs_from_image(Some(&exe), Some("".as_ref())));
// The root, and a relative path that happens to lead here.
assert!(!runs_from_image(Some(&exe), Some("/".as_ref())));
assert!(!runs_from_image(Some(&exe), Some(".".as_ref())));
// No executable path at all.
assert!(!runs_from_image(None, Some(dir.as_os_str())));
}
}
+3 -2
View File
@@ -36,14 +36,15 @@ 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
/// is already open, by showing it; a Word template starts a new document based on it, as Word's
/// default verb for one does (#3433). Quitting closes the window, which asks about every document
/// with unsaved changes.
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.open_from_platform(p, None) {
// Leave Backstage, as opening from the Open dialog does.
Ok(_) => app.ui.backstage = false,
Err(e) => {
+222 -15
View File
@@ -18,6 +18,11 @@
//! 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.
//!
//! RTF goes beside them where we can add it: Windows ("Rich Text Format") and macOS
//! (`public.rtf`) after arboard's HTML, Wayland (`text/rtf`) in one offer with the text and HTML.
//! On X11 arboard owns the selection and serves only its own targets, so a copy there carries text
//! and HTML only; reading another program's RTF works on all four.
use wordcraft_engine::cmd::paste::MAX_PASTE_BYTES;
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
@@ -31,17 +36,24 @@ thread_local! {
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 {
/// Put a copy on the clipboard: `text`, with `html` and `rtf` beside it where the platform takes
/// them (an empty one isn't offered), and `mathml` (a copy of one equation alone; empty for any
/// other copy) in the platform's MathML formats. `false` when the text and HTML 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.
/// RTF or MathML that can't be added is logged: the HTML still carries the copy.
pub fn set_rich(text: &str, html: &str, rtf: &str, mathml: &str) -> bool {
// Wayland: one offer with the text, HTML and RTF (no MathML formats there; see above).
if platform::set_rich(text, html, rtf) {
return true;
}
if !platform::can_set() {
return false;
}
let set = set_html_text(html, text);
if set && !rtf.is_empty() {
platform::add_rtf(rtf);
}
if set && !mathml.is_empty() && mathml.len() <= MAX_PASTE_BYTES {
platform::add_mathml(mathml);
}
@@ -54,7 +66,8 @@ fn set_html_text(html: &str, text: &str) -> bool {
if slot.is_none() {
*slot = open();
}
match slot.as_mut().map(|c| c.set().html(html, Some(text))) {
let set = slot.as_mut().map(|c| if html.is_empty() { c.set_text(text) } else { c.set().html(html, Some(text)) });
match set {
Some(Ok(())) => true,
Some(Err(e)) => {
log::warn!("clipboard: {e}");
@@ -76,8 +89,8 @@ 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.
/// The clipboard's RTF: Windows ("Rich Text Format"), macOS (`public.rtf`), Wayland and X11
/// (`text/rtf`); not offered by arboard, so read here.
pub fn rtf() -> Option<String> {
let bytes = platform::rtf()?;
if bytes.len() > MAX_PASTE_BYTES {
@@ -176,15 +189,51 @@ mod platform {
)
}
/// Put the copy on a Wayland clipboard in one offer: the text (with the usual plain-text
/// aliases), HTML and RTF, served from a background thread until something else is copied.
/// `false` outside Wayland (X11 goes through arboard) or without the data-control protocol.
pub fn set_rich(text: &str, html: &str, rtf: &str) -> bool {
use wl_clipboard_rs::copy::{MimeSource, MimeType, Options, Source};
if !wayland() {
return false;
}
let offer = |mime: &str, data: &str| MimeSource { source: Source::Bytes(data.as_bytes().into()), mime_type: MimeType::Specific(mime.into()) };
// The text first: the plain-text aliases serve the first text type offered.
let mut sources = vec![offer("text/plain;charset=utf-8", text)];
for (mime, data) in [("text/html", html), ("text/rtf", rtf)] {
if !data.is_empty() {
sources.push(offer(mime, data));
}
}
match Options::new().copy_multi(sources) {
Ok(()) => true,
Err(e) => {
log::warn!("clipboard: {e}");
false
}
}
}
/// Wayland copies carry their RTF already; X11 ones can't (see the module docs).
pub fn add_rtf(_rtf: &str) {}
/// RTF types a clipboard may offer, best first.
const RTF_MIMES: [&str; 3] = ["text/rtf", "application/rtf", "text/richtext"];
/// The RTF type to ask for among those `offered`.
fn rtf_target(offered: &[String]) -> Option<&'static str> {
RTF_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))
}
/// 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;
return std::env::var_os("DISPLAY").is_some().then(|| x11::rtf(super::MAX_PASTE_BYTES)).flatten();
}
let offered = formats();
let mime = ["text/rtf", "application/rtf", "text/richtext"].into_iter().find(|m| offered.iter().any(|o| o == m))?;
let mime = rtf_target(&formats())?;
read(mime, super::MAX_PASTE_BYTES as u64)
}
@@ -196,6 +245,125 @@ mod platform {
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)
}
/// Whether a property read of `len` bytes, with `bytes_after` left unread, is the whole value
/// and at most `cap` bytes.
fn complete(len: usize, bytes_after: u32, cap: usize) -> bool {
bytes_after == 0 && len <= cap
}
/// Reading another program's RTF on X11, which arboard doesn't offer: ask the CLIPBOARD
/// selection's owner for its targets, then for its RTF, into a property of a hidden window.
mod x11 {
use std::time::{Duration, Instant};
use x11rb::connection::Connection;
use x11rb::protocol::Event;
use x11rb::protocol::xproto::{Atom, AtomEnum, ConnectionExt as _, CreateWindowAux, GetPropertyReply, Window, WindowClass};
use x11rb::rust_connection::RustConnection;
/// How long a paste waits for the selection's owner, in all.
const TIMEOUT: Duration = Duration::from_secs(1);
/// Most targets looked at.
const MAX_TARGETS: usize = 128;
struct Reader {
conn: RustConnection,
win: Window,
clipboard: Atom,
prop: Atom,
deadline: Instant,
}
fn atom(conn: &RustConnection, name: &str) -> Option<Atom> {
conn.intern_atom(false, name.as_bytes()).ok()?.reply().ok().map(|r| r.atom)
}
impl Reader {
fn new() -> Option<Self> {
let (conn, screen) = x11rb::connect(None).ok()?;
let root = conn.setup().roots.get(screen)?.root;
let win = conn.generate_id().ok()?;
conn.create_window(0, win, root, 0, 0, 1, 1, 0, WindowClass::INPUT_ONLY, x11rb::COPY_FROM_PARENT, &CreateWindowAux::new()).ok()?;
let clipboard = atom(&conn, "CLIPBOARD")?;
let prop = atom(&conn, "WORDCRAFT_PASTE")?;
Some(Self { conn, win, clipboard, prop, deadline: Instant::now() + TIMEOUT })
}
/// The selection converted to `target`, at most `cap` bytes; `None` when the owner
/// refuses or doesn't answer in time. A longer value is `None` when `whole`, else
/// its first `cap` bytes.
fn convert(&self, target: Atom, cap: usize, whole: bool) -> Option<GetPropertyReply> {
self.conn.convert_selection(self.win, self.clipboard, target, self.prop, x11rb::CURRENT_TIME).ok()?;
self.conn.flush().ok()?;
loop {
// Checked every time round, so a stream of other events can't keep us here.
if Instant::now() >= self.deadline {
return None;
}
match self.conn.poll_for_event().ok()? {
Some(Event::SelectionNotify(e)) if e.requestor == self.win => {
if e.property == x11rb::NONE {
return None;
}
break;
}
Some(_) => {}
None => std::thread::sleep(Duration::from_millis(5)),
}
}
// In 32-bit units; for a whole value one more than the cap, so a larger one shows
// as `bytes_after`.
let units = u32::try_from(cap / 4 + usize::from(whole)).unwrap_or(u32::MAX);
let r = self.conn.get_property(true, self.win, self.prop, AtomEnum::ANY, 0, units).ok()?.reply().ok()?;
(!whole || super::complete(r.value.len(), r.bytes_after, cap)).then_some(r)
}
}
impl Drop for Reader {
fn drop(&mut self) {
let _ = self.conn.destroy_window(self.win);
let _ = self.conn.flush();
}
}
/// The CLIPBOARD selection's RTF, at most `cap` bytes. Copies too large to send at once
/// arrive in pieces (INCR), which aren't read: `None` then.
pub fn rtf(cap: usize) -> Option<Vec<u8>> {
let r = Reader::new()?;
// The first targets only: a longer list is cut short, not refused.
let targets = r.convert(atom(&r.conn, "TARGETS")?, MAX_TARGETS.saturating_mul(4), false)?;
let cookies: Vec<_> = targets.value32()?.take(MAX_TARGETS).filter_map(|a| r.conn.get_atom_name(a).ok()).collect();
let names: Vec<String> =
cookies.into_iter().filter_map(|c| c.reply().ok()).map(|n| String::from_utf8_lossy(&n.name).into_owned()).collect();
let target = atom(&r.conn, super::rtf_target(&names)?)?;
let incr = atom(&r.conn, "INCR")?;
let reply = r.convert(target, cap, true)?;
(reply.type_ != incr).then_some(reply.value)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rtf_target_prefers_text_rtf() {
let names = |n: &[&str]| n.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(rtf_target(&names(&["TARGETS", "text/richtext", "application/rtf", "text/rtf"])), Some("text/rtf"));
assert_eq!(rtf_target(&names(&["text/richtext", "UTF8_STRING"])), Some("text/richtext"));
assert_eq!(rtf_target(&names(&["text/html", "text/rtf;charset=x"])), None);
assert_eq!(rtf_target(&[]), None);
}
#[test]
fn property_reads_must_be_whole_and_capped() {
assert!(complete(10, 0, 10));
assert!(!complete(11, 0, 10), "over the cap");
assert!(!complete(8, 4, 10), "more left to read");
assert!(complete(0, 0, 0));
}
}
}
#[cfg(target_os = "windows")]
@@ -251,6 +419,23 @@ mod platform {
true
}
/// arboard sets the text and HTML.
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
false
}
/// Add "Rich Text Format" to what arboard just set, NUL-terminated as is usual.
pub fn add_rtf(rtf: &str) {
let Some(format) = raw::register_format("Rich Text Format") else { return };
let Ok(_open) = Clipboard::new_attempts(10) else { return };
let mut bytes = Vec::with_capacity(rtf.len().saturating_add(1));
bytes.extend_from_slice(rtf.as_bytes());
bytes.push(0);
if let Err(e) = raw::set_without_clear(format.get(), &bytes) {
log::warn!("clipboard RTF: {e}");
}
}
/// 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) {
@@ -276,14 +461,18 @@ const MATHML_FORMATS: [&str; 2] = ["MathML", "MathML Presentation"];
#[cfg(target_os = "macos")]
mod platform {
use objc2_app_kit::NSPasteboard;
use objc2_foundation::NSString;
use objc2_foundation::{NSData, NSString};
/// RTF's pasteboard type (`NSPasteboardTypeRTF`, whose extern static needs unsafe to read).
const RTF: &str = "public.rtf";
pub fn formats() -> Vec<String> {
Vec::new()
}
pub fn rtf() -> Option<Vec<u8>> {
None
let data = NSPasteboard::generalPasteboard().dataForType(&NSString::from_str(RTF))?;
(data.len() <= super::MAX_PASTE_BYTES).then(|| data.to_vec())
}
pub fn encoded_picture() -> Option<Vec<u8>> {
@@ -294,6 +483,18 @@ mod platform {
true
}
/// arboard sets the text and HTML.
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
false
}
/// Add RTF to the pasteboard item arboard just wrote (it cleared the pasteboard first).
pub fn add_rtf(rtf: &str) {
if !NSPasteboard::generalPasteboard().setData_forType(Some(&NSData::with_bytes(rtf.as_bytes())), &NSString::from_str(RTF)) {
log::warn!("clipboard: the RTF couldn't be added");
}
}
/// 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) {
@@ -329,5 +530,11 @@ mod platform {
true
}
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
false
}
pub fn add_rtf(_rtf: &str) {}
pub fn add_mathml(_mathml: &str) {}
}
+50 -3
View File
@@ -15,9 +15,25 @@ use eframe::{
/// 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.
///
/// `x86_64` is the build's architecture. An x86_64 build also runs on Apple silicon under Rosetta
/// (#5199), so it skips only when the machine itself isn't Apple silicon.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub fn skips_indirect_validation(macos: bool, intel: bool) -> bool {
macos && intel
pub fn skips_indirect_validation(macos: bool, x86_64: bool) -> bool {
// Short-circuits: only x86_64 macOS builds ask the system.
macos && x86_64 && !runs_on_apple_silicon()
}
/// Whether the machine is Apple silicon, also when this process is translated by Rosetta:
/// `hw.optional.arm64` describes the hardware, not the process. Assumes not (an Intel Mac) when
/// the system can't tell.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn runs_on_apple_silicon() -> bool {
cfg!(target_os = "macos")
&& std::process::Command::new("/usr/sbin/sysctl")
.args(["-n", "hw.optional.arm64"])
.output()
.is_ok_and(|out| out.status.success() && out.stdout.trim_ascii() == b"1")
}
/// Leaves wgpu's indirect-call validation out of the instance eframe creates when `skip`.
@@ -181,7 +197,8 @@ mod tests {
#[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));
// An x86_64 build on Apple silicon (Rosetta) isn't an Intel Mac (#5199).
assert_eq!(skips_indirect_validation(true, true), !host_is_apple_silicon());
// 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}");
@@ -205,6 +222,36 @@ mod tests {
}
}
/// Whether the machine running the tests is Apple silicon, whatever this build's architecture.
fn host_is_apple_silicon() -> bool {
cfg!(target_os = "macos")
&& std::process::Command::new("/usr/sbin/sysctl")
.args(["-n", "hw.optional.arm64"])
.output()
.is_ok_and(|out| out.status.success() && out.stdout.trim_ascii() == b"1")
}
/// #5199: an x86_64 build running under Rosetta on Apple silicon isn't an Intel Mac and keeps
/// wgpu's indirect-call validation.
#[test]
fn x86_64_builds_on_apple_silicon_keep_indirect_validation() {
if !host_is_apple_silicon() {
return;
}
// What main.rs passes from an x86_64 build.
assert!(!skips_indirect_validation(true, true), "Rosetta build on Apple silicon must keep validation");
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, skips_indirect_validation(true, true));
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, "indirect-call validation kept");
}
fn configured_backends(backends: Backends) -> Backends {
let mut options = NativeOptions::default();
configure_backends(&mut options, backends);
+105 -18
View File
@@ -10,6 +10,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod animations;
#[cfg(target_os = "linux")]
mod appimage_env;
#[cfg(target_os = "macos")]
mod apple_events;
mod clipboard;
@@ -253,13 +255,45 @@ fn load_user_fonts() {
}
}
/// The saved window geometry, read before the window opens (`load_prefs` runs after).
fn saved_window() -> Option<WindowGeometry> {
/// The saved preferences, read before the window opens for its geometry and, on Linux and the
/// BSDs, the display server (`load_prefs` runs after).
fn saved_ui() -> Option<UiState> {
if !prefs_enabled() {
return None;
}
let bytes = std::fs::read(prefs_path()?).ok()?;
UiState::from_json(&bytes)?.window?.sanitized()
UiState::from_json(&bytes)
}
/// Whether the window runs through XWayland (X11) rather than native Wayland, and the log line
/// saying why. `WORDCRAFT_WAYLAND` wins (`0`, `false`, `no` or `off` asks for X11, any other value
/// for Wayland), then File › Options › Advanced › Linux display server (#2184). Automatic is X11
/// whenever there's an X server: winit has no file drag-and-drop on Wayland, so dropping files from
/// the file manager showed a "no" cursor. X11 is only forced with an X server (`DISPLAY`), and
/// unforced winit picks Wayland when it's there and X11 otherwise, so a choice that isn't
/// available falls back to the other instead of failing to start.
#[cfg(all(unix, not(target_os = "macos")))]
fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Option<std::ffi::OsString>) -> (bool, String) {
use wordcraft_ui_egui::DisplayServer;
let set = |name: &str| env(name).is_some_and(|v| !v.is_empty());
let (wanted, by) = match env("WORDCRAFT_WAYLAND") {
Some(v) if matches!(v.to_string_lossy().trim().to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off") => {
(DisplayServer::X11, "WORDCRAFT_WAYLAND")
}
Some(_) => (DisplayServer::Wayland, "WORDCRAFT_WAYLAND"),
None => (setting, "the setting"),
};
let x11 = wanted != DisplayServer::Wayland && set("DISPLAY");
let runs = if x11 {
"X11"
} else if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") {
"native Wayland"
} else if set("DISPLAY") {
"X11 (no Wayland)"
} else {
"neither (no Wayland or X server found)"
};
(x11, format!("display: {runs}; {} asks for {}", by, wanted.code()))
}
fn save_prefs(app: &WordApp) {
@@ -305,6 +339,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
file_dialog: Some(file_dialog),
printers: Some(print::discover()),
print_native: Some(print::submit()),
cancel_print: Some(print::cancel()),
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.
@@ -312,7 +347,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
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)),
set_clipboard_rich: Some(Box::new(clipboard::set_rich)),
clipboard_rtf: Some(Box::new(clipboard::rtf)),
clipboard_formats: Some(Box::new(clipboard::formats)),
..Default::default()
@@ -388,14 +423,15 @@ fn without_launch_args(args: Vec<String>) -> Vec<String> {
/// 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.
/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB. A Word
/// template starts a new document instead ([`WordApp::open_from_platform`]), which has no path.
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)})));
.and_then(|bytes| app.open_from_platform(&name, Some(&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(r) if r.get("pending").is_none() && !wordcraft_ui_egui::documents::is_word_template(&name) => app.session.path = Some(path.into()),
Ok(_) => {}
Err(e) => {
app.report_open_failure(&name, &e);
@@ -407,14 +443,31 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
/// geometry, opens requested documents or the Start screen, starts the control server when
/// asked, and runs the eframe event loop.
fn main() -> eframe::Result {
#[cfg(windows)]
if let Some(code) = print::isolated::helper_mode() {
std::process::exit(code);
}
// 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()));
}
// Linux: before anything reads another program's AppImage variables, or a thread starts (#5653).
#[cfg(target_os = "linux")]
let start_over_failed = appimage_env::drop_inherited();
// Still carrying them, this process must never update itself: the update would replace the
// other program's file.
#[cfg(target_os = "linux")]
let updates_unsafe = start_over_failed.is_some();
#[cfg(not(target_os = "linux"))]
let updates_unsafe = false;
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
logging::install_panic_hook();
#[cfg(target_os = "linux")]
if let Some(e) = start_over_failed {
log::error!("couldn't start again without another program's AppImage variables, so the self-update is off in this window: {e}");
}
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;
@@ -486,25 +539,28 @@ fn main() -> eframe::Result {
// 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).
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652). An x86_64
// build under Rosetta on Apple silicon keeps them (#5199).
#[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();
let saved = saved_ui();
let restored = saved.as_ref().and_then(|ui| ui.window?.sanitized());
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();
}));
{
let (x11, why) = uses_x11(saved.as_ref().map(|ui| ui.display_server).unwrap_or_default(), |name| std::env::var_os(name));
log::info!("{why}");
if x11 {
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.
@@ -526,6 +582,10 @@ fn main() -> eframe::Result {
let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx));
app.set_startup_update_check(true);
load_prefs(&mut app);
// After the preferences, so the reason it gives is in the interface language.
if updates_unsafe {
app.disable_self_update();
}
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);
@@ -565,8 +625,9 @@ fn main() -> eframe::Result {
if files.is_empty() && other_files.is_empty() && !sample && control_port.is_none() {
app.start_screen();
}
// A Word template named here starts a new document based on it (#3433).
for f in files {
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
if let Err(e) = app.open_from_platform(&f, None) {
app.report_open_failure(&f, &e);
}
}
@@ -618,6 +679,32 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
/// #2184: `WORDCRAFT_WAYLAND` beats the setting, Automatic keeps XWayland where there's an X
/// server, and a choice that isn't available falls back to the other.
#[test]
fn the_display_server_follows_the_variable_then_the_setting() {
use wordcraft_ui_egui::DisplayServer::{Auto, Wayland, X11};
let x11 = |setting, vars: &[(&str, &str)]| {
uses_x11(setting, |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| std::ffi::OsString::from(v))).0
};
let both = [("DISPLAY", ":0"), ("WAYLAND_DISPLAY", "wayland-0")];
assert!(x11(Auto, &both), "Automatic keeps XWayland, for file drag-and-drop");
assert!(x11(X11, &both));
assert!(!x11(Wayland, &both));
for (value, wants_x11) in [("1", false), ("", false), ("yes", false), ("0", true), (" Off ", true), ("FALSE", true), ("no", true)] {
let vars = [both[0], both[1], ("WORDCRAFT_WAYLAND", value)];
for setting in [Auto, Wayland, X11] {
assert_eq!(x11(setting, &vars), wants_x11, "WORDCRAFT_WAYLAND={value:?} beats {setting:?}");
}
}
// Without an X server X11 isn't forced: winit connects to Wayland instead of failing.
let wayland_only = [("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")];
assert!(!x11(X11, &wayland_only) && !x11(Auto, &wayland_only));
// Without Wayland, Wayland isn't forced either: unforced winit picks X11.
let (forced, why) = uses_x11(Wayland, |name| (name == "DISPLAY").then(|| ":0".into()));
assert!(!forced && why.contains("X11 (no Wayland)"), "{why}");
}
/// 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]
+116 -20
View File
@@ -1,13 +1,21 @@
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
#[cfg(any(windows, test))]
pub(crate) mod isolated;
use wordcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
/// Returns a discovery callback that enumerates spooler printers on a named worker thread and
/// sends the list through a channel, keeping spooler calls off the UI thread.
///
/// The Print panel needs only names and the default, so the list comes from
/// `craft_print::printer_queues`, which asks no printer driver anything: on Windows a networked
/// printer in power save can hold a driver's capability query for a long time, and the list must
/// not wait on it (#5710). Submission still checks the job's options with the driver.
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()
let printers = craft_print::printer_queues()
.into_iter()
.map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default })
.collect();
@@ -17,22 +25,59 @@ pub fn discover() -> Discover {
})
}
/// A submission worker and how to stop its job: `true` when it will never be sent, `false` when
/// it was already being sent.
struct Job {
worker: std::thread::JoinHandle<()>,
cancel: Box<dyn Fn() -> bool + Send>,
// Acknowledged helper cancellation permits joining it to reap the process and its input PDF.
isolated: bool,
}
// 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());
static JOBS: std::sync::Mutex<Vec<Job>> = std::sync::Mutex::new(Vec::new());
fn jobs() -> std::sync::MutexGuard<'static, Vec<Job>> {
JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Waits for the jobs already being sent, so the spooler has them before the process exits. A
/// job still waiting (on a sleeping printer's driver, say: #5710) is cancelled instead, so it is
/// never sent and quitting doesn't wait on the driver.
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() {
let pending = std::mem::take(&mut *jobs());
// Cancel them all first: a job left waiting while another is joined could start sending.
let finishing: Vec<_> = pending
.into_iter()
.filter(|job| {
let cancelled = (job.cancel)();
job.isolated || !cancelled
})
.collect();
for job in finishing {
if job.worker.join().is_err() {
log::error!("the print worker stopped unexpectedly during shutdown");
}
}
}
/// The Print page's Cancel: stops the job being submitted (the Print page submits one at a
/// time, the latest) unless it has started reaching the spooler. `true` when it will never be
/// sent. A job that has finished sending answers `false`, so its receipt is still shown.
pub fn cancel() -> Box<dyn Fn() -> bool> {
Box::new(|| jobs().last().is_none_or(|job| (job.cancel)()))
}
/// Submit off the UI thread, isolating Windows drivers while retaining CUPS token cancellation.
pub fn submit() -> Submit {
Box::new(|bytes, settings: Settings, collate, options| {
let (sender, receiver) = std::sync::mpsc::channel();
#[cfg(not(windows))]
let cancel = craft_print::Cancel::new();
#[cfg(windows)]
let cancel = isolated::Submission::new();
let token = cancel.clone();
let worker = std::thread::Builder::new().name("print-submit".into()).spawn(move || {
let job = craft_print::Job {
printer: Some(settings.printer),
@@ -48,12 +93,16 @@ pub fn submit() -> Submit {
title: "WordCraft document".into(),
options,
};
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
#[cfg(not(windows))]
let sent = craft_print::submit_cancellable(&bytes, &job, &mut |_, _| {}, &token).map_err(|error| error.to_string());
#[cfg(windows)]
let sent = token.submit(&bytes, &job);
let _ = sender.send(sent);
});
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);
let mut jobs = jobs();
jobs.retain(|job| !job.worker.is_finished());
jobs.push(Job { worker, cancel: Box::new(move || cancel.cancel()), isolated: cfg!(windows) });
}
receiver
})
@@ -62,20 +111,67 @@ pub fn submit() -> Submit {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shutdown_waits_for_pending_submission_workers() {
/// A worker that waits until released, then reports that it finished.
fn worker() -> (std::thread::JoinHandle<()>, std::sync::mpsc::Sender<()>, std::sync::mpsc::Receiver<()>) {
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();
let _ = wait.recv();
let _ = completed.send(());
});
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");
(worker, release, completion)
}
/// A cancel action that records when it was asked and answers `stops`.
fn asked(stops: bool) -> (Box<dyn Fn() -> bool + Send>, std::sync::Arc<std::sync::atomic::AtomicBool>) {
let flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let set = flag.clone();
let cancel = Box::new(move || {
set.store(true, std::sync::atomic::Ordering::SeqCst);
stops
});
(cancel, flag)
}
/// Shutdown waits for a job already being sent, so the spooler has it before the process
/// exits, but not for one still waiting on a driver (a sleeping printer, #5710): that one is
/// cancelled, so it is never sent, and quitting doesn't hang on it. Every job is cancelled
/// before any is waited for, so a waiting job can't start sending meanwhile. The Print page's
/// Cancel asks only the latest job. One test, because JOBS is global.
#[test]
fn shutdown_waits_for_jobs_being_sent_and_cancels_the_rest() {
let (sending, release_sending, sent) = worker();
let (stuck, release_stuck, _) = worker();
let (stop_sending, sending_asked) = asked(false);
let (stop_stuck, cancelled) = asked(true);
// The job being sent comes first, so a one-at-a-time shutdown would join it before
// cancelling the waiting one.
*JOBS.lock().unwrap() =
vec![Job { worker: sending, cancel: stop_sending, isolated: false }, Job { worker: stuck, cancel: stop_stuck, isolated: false }];
// Cancel from the Print page asks the latest job only.
assert!(cancel()(), "the latest job was still waiting: never sent");
assert!(!sending_asked.load(std::sync::atomic::Ordering::SeqCst), "an earlier job isn't the one being submitted");
cancelled.store(false, std::sync::atomic::Ordering::SeqCst);
let (finished, shutdown) = std::sync::mpsc::channel();
std::thread::spawn(move || {
finish_pending_jobs();
let _ = finished.send(());
});
let wait = std::time::Duration::from_millis(200);
assert!(shutdown.recv_timeout(wait).is_err(), "shutdown waits for the job being sent");
assert!(cancelled.load(std::sync::atomic::Ordering::SeqCst), "the waiting job was cancelled before the wait");
release_sending.send(()).unwrap();
assert!(shutdown.recv_timeout(std::time::Duration::from_secs(10)).is_ok(), "shutdown doesn't wait for the stuck job");
sent.recv().unwrap();
assert!(JOBS.lock().unwrap().is_empty(), "shutdown took every job");
// A job that has already been sent answers false, so the Print page keeps its receipt.
let (done, release_done, finished) = worker();
release_done.send(()).unwrap();
finished.recv().unwrap();
*JOBS.lock().unwrap() = vec![Job { worker: done, cancel: Box::new(|| false), isolated: false }];
assert!(!cancel()(), "a sent job isn't reported as cancelled");
JOBS.lock().unwrap().clear();
// The stuck worker was left behind without blocking shutdown; let it end.
release_stuck.send(()).unwrap();
}
}
+592
View File
@@ -0,0 +1,592 @@
//! One Windows driver submission per process: a cancelled driver cannot retain another
//! job's provider lock. The child acknowledges its Cancel token before it exits.
use std::{
io::{BufRead, BufReader, Read, Write},
path::Path,
process::{Child, ChildStdin, Command, Stdio},
sync::{Arc, Condvar, Mutex},
time::Duration,
};
use serde::{Deserialize, Serialize};
#[cfg(windows)]
const FLAG: &str = "--wordcraft-print-worker";
const PREFIX: &str = "WORDCRAFT_PRINT:";
const MAX_MESSAGE: u64 = 64 * 1024;
const MAX_PDF: u64 = 512 * 1024 * 1024;
const CANCELLED: &str = "the print job was cancelled before anything was sent to the printer";
#[derive(Serialize, Deserialize)]
enum Duplex {
Off,
LongEdge,
ShortEdge,
}
#[derive(Serialize, Deserialize)]
struct Request {
printer: Option<String>,
copies: u32,
collate: bool,
duplex: Option<Duplex>,
grayscale: bool,
shrink: bool,
title: String,
options: Vec<(String, String)>,
}
impl Request {
/// Preserve all spooler options in a platform-independent wire representation.
fn from_job(job: &craft_print::Job) -> Self {
Self {
printer: job.printer.clone(),
copies: job.copies,
collate: job.collate,
duplex: job.duplex.map(|d| match d {
craft_print::Duplex::Off => Duplex::Off,
craft_print::Duplex::LongEdge => Duplex::LongEdge,
craft_print::Duplex::ShortEdge => Duplex::ShortEdge,
}),
grayscale: job.grayscale,
shrink: job.scaling == craft_print::Scaling::ShrinkToFit,
title: job.title.clone(),
options: job.options.clone(),
}
}
/// Restore the job without substituting printer defaults or changing copy semantics.
fn job(self) -> craft_print::Job {
craft_print::Job {
printer: self.printer,
copies: self.copies,
collate: self.collate,
duplex: self.duplex.map(|d| match d {
Duplex::Off => craft_print::Duplex::Off,
Duplex::LongEdge => craft_print::Duplex::LongEdge,
Duplex::ShortEdge => craft_print::Duplex::ShortEdge,
}),
grayscale: self.grayscale,
scaling: if self.shrink { craft_print::Scaling::ShrinkToFit } else { craft_print::Scaling::Exact },
title: self.title,
options: self.options,
}
}
}
#[derive(Serialize, Deserialize)]
enum Event {
Ready,
Cancelled,
Sending,
Done(Result<String, String>),
}
enum State {
Starting,
Waking(Arc<Mutex<Child>>),
Running(ChildStdin),
Sending(ChildStdin),
Cancelled,
Finished,
}
#[derive(Clone)]
pub(crate) struct Submission(Arc<(Mutex<State>, Condvar)>);
impl Submission {
/// Create one submission whose driver cannot run until the parent grants startup.
pub(crate) fn new() -> Self {
Self(Arc::new((Mutex::new(State::Starting), Condvar::new())))
}
/// Recover the lifecycle state after poisoning without invoking another driver call.
fn state(&self) -> std::sync::MutexGuard<'_, State> {
self.0.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Only an acknowledgement of the child's token can cancel a running helper. A timeout
/// is a refusal, never permission to kill a process that may already be sending.
pub(crate) fn cancel(&self) -> bool {
let mut state = self.state();
match &mut *state {
State::Starting => {
*state = State::Cancelled;
return true;
}
State::Waking(child) => {
let _ = child.lock().unwrap_or_else(std::sync::PoisonError::into_inner).kill();
*state = State::Cancelled;
return true;
}
State::Cancelled => return true,
State::Finished | State::Sending(_) => return false,
State::Running(input) => {
if input.write_all(b"cancel\n").and_then(|_| input.flush()).is_err() {
return false;
}
}
}
let (state, _) = self
.0
.1
.wait_timeout_while(state, Duration::from_secs(1), |s| matches!(s, State::Running(_)))
.unwrap_or_else(std::sync::PoisonError::into_inner);
matches!(*state, State::Cancelled)
}
#[cfg(windows)]
/// Submit through this executable's private helper mode and await its final receipt.
pub(crate) fn submit(&self, pdf: &[u8], job: &craft_print::Job) -> Result<String, String> {
self.submit_with(pdf, job, |directory| {
let mut command = Command::new(std::env::current_exe()?);
command.arg(FLAG).arg(directory);
Ok(command)
})
}
/// Publish a terminal state on every result, waking a cancellation caller awaiting an ack.
fn submit_with(&self, pdf: &[u8], job: &craft_print::Job, command: impl FnOnce(&Path) -> std::io::Result<Command>) -> Result<String, String> {
let result = self.run(pdf, job, command);
let mut state = self.state();
if !matches!(*state, State::Cancelled) {
*state = State::Finished;
}
self.0.1.notify_all();
if matches!(*state, State::Cancelled) { Err(CANCELLED.into()) } else { result }
}
/// Own the private inputs and child until receipt, acknowledged cancellation or failure.
fn run(&self, pdf: &[u8], job: &craft_print::Job, command: impl FnOnce(&Path) -> std::io::Result<Command>) -> Result<String, String> {
if matches!(*self.state(), State::Cancelled) {
return Err(CANCELLED.into());
}
if pdf.len() as u64 > MAX_PDF {
return Err("the print PDF exceeds the 512 MiB helper limit".into());
}
let request = serde_json::to_vec(&Request::from_job(job)).map_err(error)?;
if request.len() as u64 > MAX_MESSAGE {
return Err("the print settings exceed the helper message limit".into());
}
let directory = tempfile::tempdir().map_err(error)?;
std::fs::write(directory.path().join("job.json"), request).map_err(error)?;
std::fs::write(directory.path().join("input.pdf"), pdf).map_err(error)?;
let child =
command(directory.path()).map_err(error)?.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().map_err(error)?;
let child = Arc::new(Mutex::new(child));
let outcome = (|| {
let (input, output) = {
let mut process = child.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
(process.stdin.take().ok_or("print helper has no input pipe")?, process.stdout.take().ok_or("print helper has no output pipe")?)
};
{
let mut state = self.state();
if matches!(*state, State::Cancelled) {
return Err(CANCELLED.into());
}
*state = State::Waking(child.clone());
}
let mut output = BufReader::new(output);
loop {
let message = line(&mut output)?.ok_or("the print helper stopped before it was ready")?;
let Some((_, json)) = message.split_once(PREFIX) else { continue };
if !matches!(serde_json::from_str::<Event>(json).map_err(error)?, Event::Ready) {
return Err("the print helper did not acknowledge startup".into());
}
break;
}
{
let mut state = self.state();
if matches!(*state, State::Cancelled) {
return Err(CANCELLED.into());
}
*state = State::Running(input);
if let State::Running(input) = &mut *state {
input.write_all(b"start\n").and_then(|_| input.flush()).map_err(error)?;
}
}
while let Some(line) = line(&mut output)? {
let Some((_, json)) = line.split_once(PREFIX) else { continue };
match serde_json::from_str::<Event>(json).map_err(error)? {
Event::Ready => return Err("the print helper repeated its startup acknowledgement".into()),
Event::Cancelled => {
*self.state() = State::Cancelled;
self.0.1.notify_all();
return Err(CANCELLED.into());
}
Event::Sending => {
let mut state = self.state();
let previous = std::mem::replace(&mut *state, State::Finished);
*state = match previous {
State::Running(input) | State::Sending(input) => State::Sending(input),
other => other,
};
self.0.1.notify_all();
}
Event::Done(result) => return result,
}
}
Err("the print helper stopped without a receipt".into())
})();
// A cancelled child has acknowledged that it cannot send. Other outcomes are terminal
// receipts or a broken protocol/pipe; reap the process and its private input files.
if outcome.is_err() {
let _ = child.lock().unwrap_or_else(std::sync::PoisonError::into_inner).kill();
}
let status = child.lock().unwrap_or_else(std::sync::PoisonError::into_inner).wait().map_err(error)?;
if outcome.is_ok() && !status.success() {
return Err("the print helper failed after its receipt".into());
}
outcome
}
}
/// Keep helper transport failures distinct from a printer's own error message.
fn error(error: impl std::fmt::Display) -> String {
format!("print helper: {error}")
}
/// Read at most one byte beyond the cap so oversized files fail without unbounded allocation.
fn bounded(mut input: impl Read, limit: u64) -> Result<Vec<u8>, String> {
let mut bytes = Vec::new();
input.by_ref().take(limit.saturating_add(1)).read_to_end(&mut bytes).map_err(error)?;
if bytes.len() as u64 > limit { Err("print helper input exceeds its limit".into()) } else { Ok(bytes) }
}
/// Read one bounded UTF-8 protocol record, distinguishing EOF from an oversized message.
fn line(input: &mut impl BufRead) -> Result<Option<String>, String> {
let mut bytes = Vec::new();
input.take(MAX_MESSAGE + 1).read_until(b'\n', &mut bytes).map_err(error)?;
if bytes.len() as u64 > MAX_MESSAGE {
return Err("print helper message exceeds its limit".into());
}
if bytes.is_empty() { Ok(None) } else { String::from_utf8(bytes).map(|s| Some(s.trim_end().to_owned())).map_err(error) }
}
/// Serialize complete events under stdout's lock so the driver and listener cannot interleave.
fn emit(event: &Event) -> Result<(), String> {
let mut output = std::io::stdout().lock();
write!(output, "{PREFIX}").map_err(error)?;
serde_json::to_writer(&mut output, event).map_err(error)?;
writeln!(output).and_then(|_| output.flush()).map_err(error)
}
/// Remove both plaintext inputs even when one fails; already-removed inputs count as success.
fn remove_inputs(directory: &Path) -> Result<(), String> {
let mut first_error = None;
for name in ["job.json", "input.pdf"] {
if let Err(failure) = std::fs::remove_file(directory.join(name))
&& failure.kind() != std::io::ErrorKind::NotFound
{
first_error.get_or_insert_with(|| error(format!("could not remove {name}: {failure}")));
}
}
first_error.map_or(Ok(()), Err)
}
/// Load and erase the private inputs before driver work, while an independent listener can cancel.
fn helper(
directory: &Path,
cancel_request: impl Fn(&craft_print::Cancel) -> bool + Send + 'static,
submit: impl FnOnce(&[u8], &craft_print::Job, &craft_print::Cancel) -> Result<String, String>,
) -> Result<(), String> {
let mut input = BufReader::new(std::io::stdin());
emit(&Event::Ready)?;
if line(&mut input)?.as_deref() != Some("start") {
return Err("print helper requires a start acknowledgement".into());
}
let cancel = craft_print::Cancel::new();
let listener = cancel.clone();
std::thread::Builder::new()
.name("print-cancel".into())
.spawn(move || {
loop {
match line(&mut input) {
Ok(Some(command)) if command == "cancel" => {
if cancel_request(&listener) {
let _ = emit(&Event::Cancelled);
std::process::exit(0);
}
if emit(&Event::Sending).is_err() {
break;
}
}
_ => break,
}
}
// If the parent disappears, never leave a waiting driver process behind. Sending
// jobs finish normally so the OS gets their pages even if the UI has gone away.
if cancel_request(&listener) {
std::process::exit(0);
}
})
.map_err(error)?;
let request: Request =
serde_json::from_slice(&bounded(std::fs::File::open(directory.join("job.json")).map_err(error)?, MAX_MESSAGE)?).map_err(error)?;
let pdf = bounded(std::fs::File::open(directory.join("input.pdf")).map_err(error)?, MAX_PDF)?;
remove_inputs(directory)?;
let result = submit(&pdf, &request.job(), &cancel);
emit(&Event::Done(result))
}
#[cfg(windows)]
/// Handle only the private first-argument flag, before logging or GUI initialization.
pub(crate) fn helper_mode() -> Option<i32> {
let mut args = std::env::args_os().skip(1);
if args.next().as_deref() != Some(std::ffi::OsStr::new(FLAG)) {
return None;
}
let result = args.next().ok_or_else(|| "print helper directory missing".to_owned()).and_then(|dir| {
helper(Path::new(&dir), craft_print::Cancel::cancel, |pdf, job, cancel| {
craft_print::submit_cancellable(pdf, job, &mut |_, _| {}, cancel).map_err(error)
})
});
if let Err(message) = &result {
let _ = emit(&Event::Done(Err(message.clone())));
}
Some(i32::from(result.is_err()))
}
#[cfg(test)]
mod tests {
use super::*;
/// A real test child exercises production pipes, token handling and process cleanup.
#[test]
#[ignore = "launched by the process protocol tests"]
fn helper_process() {
let Some(directory) = std::env::var_os("WORDCRAFT_TEST_PRINT_DIR") else { return };
if std::env::var("WORDCRAFT_TEST_PRINT_MODE").as_deref() == Ok("waking") {
std::fs::write(Path::new(&directory).join("waking"), b"starting").unwrap();
loop {
std::thread::park();
}
}
let result = helper(
Path::new(&directory),
|token| {
if std::env::var("WORDCRAFT_TEST_PRINT_MODE").as_deref() == Ok("sending") { false } else { token.cancel() }
},
|pdf, job, _token| {
assert_eq!(pdf, b"synthetic PDF");
assert_eq!(job.printer.as_deref(), Some("test queue"));
match std::env::var("WORDCRAFT_TEST_PRINT_MODE").as_deref() {
Ok("held") => {
let _provider = Mutex::new(());
let _held = _provider.lock().unwrap();
std::fs::write(Path::new(&directory).join("held"), b"driver entered").unwrap();
loop {
std::thread::park();
}
}
Ok("crash") => std::process::exit(7),
Ok("sending") => {
std::fs::write(Path::new(&directory).join("sending"), b"sending").unwrap();
while !Path::new(&directory).join("release").exists() {
std::thread::sleep(Duration::from_millis(5));
}
Ok("spooled synthetic job".into())
}
_ => Ok("spooled synthetic job".into()),
}
},
);
std::process::exit(i32::from(result.is_err()));
}
/// Launch just the ignored helper entry, without invoking printer hardware.
fn command(directory: &Path, mode: &str) -> std::io::Result<Command> {
let mut command = Command::new(std::env::current_exe()?);
let name = module_path!().split_once("::").map_or(module_path!(), |(_, name)| name);
command.args(["--ignored", "--exact", &format!("{name}::helper_process"), "--nocapture"]);
command.env("WORDCRAFT_TEST_PRINT_DIR", directory).env("WORDCRAFT_TEST_PRINT_MODE", mode);
Ok(command)
}
/// Give the child a literal printer identity to verify transport.
fn job() -> craft_print::Job {
craft_print::Job { printer: Some("test queue".into()), ..Default::default() }
}
#[test]
/// Cancelling before submission cannot launch a driver process.
fn cancelled_start_never_launches_a_helper() {
let submission = Submission::new();
assert!(submission.cancel());
let result = submission.submit_with(b"synthetic PDF", &job(), |_| Err(std::io::Error::other("must not launch")));
assert_eq!(result, Err(CANCELLED.into()));
}
#[test]
/// A cancellation during creation withholds the start grant and removes private inputs.
fn cancellation_during_startup_never_starts_the_driver() {
let submission = Submission::new();
let worker = submission.clone();
let (started, starting) = std::sync::mpsc::channel();
let (release, wait) = std::sync::mpsc::channel();
let running = std::thread::spawn(move || {
worker.submit_with(b"synthetic PDF", &job(), |directory| {
started.send(directory.to_path_buf()).unwrap();
wait.recv().unwrap();
command(directory, "held")
})
});
let directory = starting.recv_timeout(Duration::from_secs(10)).unwrap();
assert!(submission.cancel());
release.send(()).unwrap();
assert_eq!(running.join().unwrap(), Err(CANCELLED.into()));
assert!(!directory.exists(), "startup cancellation reaps the child and removes its inputs");
}
#[test]
/// A hung startup has no permission to send and can be killed without a token ack.
fn an_unresponsive_starting_process_can_be_stopped_without_an_acknowledgement() {
let submission = Submission::new();
let worker = submission.clone();
let (path_tx, path_rx) = std::sync::mpsc::channel();
let running = std::thread::spawn(move || {
worker.submit_with(b"synthetic PDF", &job(), |dir| {
path_tx.send(dir.to_path_buf()).unwrap();
command(dir, "waking")
})
});
let directory = path_rx.recv_timeout(Duration::from_secs(10)).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !directory.join("waking").exists() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
assert!(directory.join("waking").exists());
assert!(submission.cancel(), "a process without a start grant cannot send anything");
assert_eq!(running.join().unwrap(), Err(CANCELLED.into()));
assert!(!directory.exists());
}
#[test]
/// Job transport preserves every explicit setting, including Unicode and duplex direction.
fn job_options_survive_the_process_boundary() {
let expected = craft_print::Job {
printer: Some("Office λ".into()),
copies: 3,
collate: false,
duplex: Some(craft_print::Duplex::ShortEdge),
grayscale: true,
scaling: craft_print::Scaling::ShrinkToFit,
title: "A document".into(),
options: vec![("media".into(), "iso_a4_210x297mm".into())],
};
let bytes = serde_json::to_vec(&Request::from_job(&expected)).unwrap();
let received: Request = serde_json::from_slice(&bytes).unwrap();
assert_eq!(received.job(), expected);
}
#[test]
/// Retry succeeds immediately after cancellation, before joining the old worker.
fn cancelled_driver_does_not_block_the_next_process() {
let submission = Submission::new();
let worker = submission.clone();
let (path_tx, path_rx) = std::sync::mpsc::channel();
let running = std::thread::spawn(move || {
worker.submit_with(b"synthetic PDF", &job(), |dir| {
path_tx.send(dir.to_path_buf()).unwrap();
command(dir, "held")
})
});
let directory = path_rx.recv_timeout(Duration::from_secs(10)).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !directory.join("held").exists() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
assert!(directory.join("held").exists(), "the child is held inside its driver");
assert!(!directory.join("input.pdf").exists(), "the stalled driver retains no plaintext PDF");
assert!(!directory.join("job.json").exists(), "loaded settings were erased before driver work");
assert!(submission.cancel(), "a stalled driver still acknowledges cancellation");
let next = Submission::new();
assert_eq!(next.submit_with(b"synthetic PDF", &job(), |dir| command(dir, "ready")), Ok("spooled synthetic job".into()));
assert_eq!(running.join().unwrap(), Err(CANCELLED.into()));
assert!(!directory.exists(), "the reaped helper's PDF and settings were removed");
assert!(!next.cancel(), "a completed job keeps its receipt");
}
#[test]
/// A refused cancellation keeps the sending process alive until its receipt.
fn cancellation_refusal_preserves_the_sending_job() {
let submission = Submission::new();
let worker = submission.clone();
let (path_tx, path_rx) = std::sync::mpsc::channel();
let running = std::thread::spawn(move || {
worker.submit_with(b"synthetic PDF", &job(), |dir| {
path_tx.send(dir.to_path_buf()).unwrap();
command(dir, "sending")
})
});
let directory = path_rx.recv_timeout(Duration::from_secs(10)).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !directory.join("sending").exists() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
assert!(directory.join("sending").exists());
assert!(!submission.cancel(), "a sending job refuses cancellation");
assert!(matches!(*submission.state(), State::Sending(_)));
assert!(!running.is_finished(), "the child is retained until it finishes sending");
std::fs::write(directory.join("release"), b"continue").unwrap();
assert_eq!(running.join().unwrap(), Ok("spooled synthetic job".into()));
assert!(!directory.exists());
}
#[test]
/// Closing the parent's pipe stops a waiting driver and permits parent-side cleanup.
fn parent_disconnect_stops_a_waiting_driver() {
let submission = Submission::new();
let worker = submission.clone();
let (path_tx, path_rx) = std::sync::mpsc::channel();
let running = std::thread::spawn(move || {
worker.submit_with(b"synthetic PDF", &job(), |dir| {
path_tx.send(dir.to_path_buf()).unwrap();
command(dir, "held")
})
});
let directory = path_rx.recv_timeout(Duration::from_secs(10)).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !directory.join("held").exists() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
assert!(directory.join("held").exists());
*submission.state() = State::Finished; // Drops the parent's pipe as on process exit.
assert_eq!(running.join().unwrap(), Err("the print helper stopped without a receipt".into()));
assert!(!directory.exists());
}
#[test]
/// Helper failure removes its input directory; input limits reject oversize data.
fn helper_crashes_are_errors_and_inputs_are_bounded() {
let submission = Submission::new();
let mut inputs = None;
assert_eq!(
submission.submit_with(b"synthetic PDF", &job(), |dir| {
inputs = Some(dir.to_path_buf());
command(dir, "crash")
}),
Err("the print helper stopped without a receipt".into())
);
assert!(!inputs.unwrap().exists(), "a helper crash removes the PDF and settings");
assert_eq!(bounded(&b"abcd"[..], 3), Err("print helper input exceeds its limit".into()));
assert_eq!(bounded(&b"abc"[..], 3), Ok(b"abc".to_vec()));
}
/// Cleanup is repeatable and reports real failures while still attempting both inputs.
#[test]
fn input_cleanup_tolerates_missing_files_and_reports_failures() {
let directory = tempfile::tempdir().unwrap();
std::fs::write(directory.path().join("job.json"), b"settings").unwrap();
std::fs::write(directory.path().join("input.pdf"), b"private PDF").unwrap();
assert_eq!(remove_inputs(directory.path()), Ok(()));
assert_eq!(remove_inputs(directory.path()), Ok(()));
assert!(!directory.path().join("input.pdf").exists());
std::fs::create_dir(directory.path().join("job.json")).unwrap();
std::fs::write(directory.path().join("input.pdf"), b"private PDF").unwrap();
let error = remove_inputs(directory.path()).unwrap_err();
assert!(error.contains("could not remove job.json"), "{error}");
assert!(!directory.path().join("input.pdf").exists(), "a settings cleanup failure does not retain the PDF");
}
}
+99
View File
@@ -0,0 +1,99 @@
//! A WordCraft started by an AppImage app inherits that app's `APPIMAGE`, which the self-update
//! would take for this copy's file and replace (#5653). The real program, started both ways.
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
const NOTICE: &str = "ignoring the AppImage";
/// A folder of its own for one test, removed when it ends.
struct Tmp(PathBuf);
impl Tmp {
/// An empty folder named after `test` and this process, made afresh.
fn new(test: &str) -> Tmp {
let dir = std::env::temp_dir().join(format!("wordcraft-appimage-it-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Tmp(dir)
}
}
impl Drop for Tmp {
/// Removes the folder and what the test left in it.
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Runs the real app with `args`, as an AppImage app with `appimage` mounted at `appdir` would
/// start it, and waits up to 30 s for it to exit (with no display it can't open a window).
fn app(args: &[OsString], appimage: &Path, appdir: &Path) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
.args(args)
.env("APPIMAGE", appimage)
.env("APPDIR", appdir)
.env("ARGV0", "other-app")
.env("OWD", "/")
// 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();
// A start-over that kept the variables would start over for ever, and end here.
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");
}
std::thread::sleep(Duration::from_millis(50));
}
child.wait_with_output().unwrap()
}
/// The notice comes once: the program it started over as no longer sees the variables.
#[test]
fn another_programs_variables_are_dropped_once_and_the_program_carries_on() {
let tmp = Tmp::new("dropped");
let other = tmp.0.join("other-app.AppImage");
std::fs::write(&other, b"another program").unwrap();
let out = app(&["--version".into()], &other, &tmp.0);
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}");
assert_eq!(stderr.matches(NOTICE).count(), 1, "stderr: {stderr}");
assert!(stderr.contains("other-app.AppImage"), "stderr: {stderr}");
assert_eq!(std::fs::read(&other).unwrap(), b"another program");
}
/// Arguments reach the program it starts over as byte for byte, even one that isn't Unicode.
#[test]
fn starting_over_keeps_the_arguments() {
let tmp = Tmp::new("args");
let out = app(&["--sample".into(), "--control".into(), OsString::from_vec(b"79\xe981".to_vec())], &tmp.0.join("gone"), &tmp.0);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("argument 3 is not valid Unicode: 79\u{FFFD}81"), "stderr: {stderr}");
}
/// Its own image's variables stay, or the self-update couldn't find the file to replace.
#[test]
fn its_own_images_variables_stay() {
let exe = Path::new(env!("CARGO_BIN_EXE_wordcraft")).canonicalize().unwrap();
let mount = exe.parent().unwrap();
let out = app(&["--version".into()], &mount.join("WordCraft.AppImage"), mount);
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}");
assert!(!stderr.contains(NOTICE), "stderr: {stderr}");
}
+2
View File
@@ -1,3 +1,5 @@
#[cfg(target_os = "linux")]
mod appimage_env;
mod control_arg;
mod help;
#[cfg(unix)]
+5
View File
@@ -162,6 +162,8 @@ sync_github_name = true
[people.maxm11]
real_name = "Max Murphy"
display_name = "Max Murphy"
sync_github_name = false
[people.SlavenDj]
sync_github_name = true
@@ -192,3 +194,6 @@ sync_github_name = true
[people.entropybeater]
display_name = "entropybeater"
[people.helenkwok]
real_name = "Helen Kwok"
+1 -1
View File
@@ -1 +1 @@
9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
1e515422afdda2f317af430c1f9d68a5f83935b4
+19 -10
View File
@@ -7,8 +7,9 @@
/// 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
/// than the file has sectors — every FAT sector is a sector of the file, so a longer list is
/// duplicates or names `cfb` refuses anyway, and the FAT `cfb` builds (one sector of entries per
/// listed name) stays within about the file's own size. 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];
@@ -52,12 +53,14 @@ pub fn check(bytes: &[u8]) -> Result<(), String> {
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.
// Each listed FAT sector adds a whole sector of entries. A real file lists no more FAT
// sectors than the file has — every FAT sector is a sector of the file, and none of them
// twice (checked below) — so the FAT `cfb` builds stays within about the file's own size.
// The #3642 blowup listed 2048 × 127 names in a ~1 MB file. A tighter bound (one FAT sector
// per `sector_len / 4` sectors plus a spare) refused real files, whose writers reserve FAT
// for growth: an encrypted package of 78 sectors lists 4 and didn't open (#5109).
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 {
if listed.len() > num_sectors {
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());
@@ -123,10 +126,16 @@ mod tests {
}
#[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.
fn a_fat_sector_listed_twice_or_more_than_the_file_has_is_refused() {
// A duplicate, and a list longer than the file has sectors: every FAT sector is a
// sector of the file, so a longer list is one of the two.
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, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])).is_err_and(|e| e.contains("11 FAT sectors in a file of 10 sectors"))
);
// A writer may reserve FAT for growth: a real encrypted package of 78 sectors lists
// 4 FAT sectors and didn't open since the #3642 guard landed (#5109).
assert!(super::check(&header_difat(78, &[0, 1, 2, 3])).is_ok());
assert!(super::check(&header_difat(10, &[0])).is_ok());
assert!(super::check(&header_difat(200, &[0, 1])).is_ok());
}
+2
View File
@@ -213,7 +213,9 @@ impl Document {
if !parent.len().is_multiple_of(3) {
return None;
}
crate::work::steps(1);
for ch in parent.chunks(3) {
crate::work::steps(1);
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;
+2
View File
@@ -609,8 +609,10 @@ impl Document {
let mut at = at.into_iter().peekable();
let mut start = 0usize;
for r in &mut p.runs {
crate::work::steps(1);
let end = start.saturating_add(r.len);
while at.peek().is_some_and(|&o| o < start) {
crate::work::steps(1);
at.next();
}
if at.peek().is_some_and(|&o| o < end) {
+5 -2
View File
@@ -478,8 +478,9 @@ fn family_fingerprint(doc: &crate::Document, root: u32, replies: &HashMap<u32, V
}
/// What `doc`'s page setup is: each section's setup (where it starts, its size, margins,
/// columns, headers and footers), the headers' and footers' content, the page colour and
/// picture, and the notes' numbering.
/// columns, headers and footers), the headers' and footers' content, whether odd and even pages
/// differ, the page colour and picture, the watermark (one with no text isn't written), and the
/// notes' numbering.
pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
let mut sections: Vec<&crate::SectionProps> = doc.body.iter().filter_map(|b| b.as_para()).filter_map(|p| p.section.as_deref()).collect();
sections.push(&doc.last_section);
@@ -490,8 +491,10 @@ pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
match serde_json::to_vec(&(
sections,
parts,
s.even_odd_headers,
&s.page_color,
&s.page_picture,
s.watermark.as_ref().filter(|w| !w.text.trim().is_empty()),
(&s.footnote_format, &s.endnote_format, &s.footnote_pr, &s.endnote_pr),
(&doc.footnote_separators, &doc.endnote_separators),
)) {
+45 -2
View File
@@ -120,14 +120,57 @@ fn attr_in(tag: &str) -> Option<String> {
Some(unescape(rest.get(..end)?))
}
fn unescape(s: &str) -> String {
s.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&")
fn unescape(mut s: &str) -> String {
let mut out = String::with_capacity(s.len());
while let Some((text, rest)) = s.split_once('&') {
out.push_str(text);
if let Some(end) = rest.find(['&', ';'])
&& rest.as_bytes()[end] == b';'
{
let entity = &rest[..end];
let decoded = match entity {
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
"amp" => Some('&'),
_ => entity.strip_prefix('#').and_then(|n| {
let value = if let Some(hex) = n.strip_prefix('x') {
(!hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit())).then(|| u32::from_str_radix(hex, 16).ok()).flatten()
} else {
(!n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())).then(|| n.parse::<u32>().ok()).flatten()
}?;
char::from_u32(value)
.filter(|c| matches!(*c, '\t' | '\n' | '\r' | '\u{20}'..='\u{d7ff}' | '\u{e000}'..='\u{fffd}' | '\u{10000}'..='\u{10ffff}'))
}),
};
if let Some(c) = decoded {
out.push(c);
s = &rest[end + 1..];
continue;
}
}
out.push('&');
s = rest;
}
out.push_str(s);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn drop_down_entities_decode_once_including_xml_whitespace() {
let xml = r#"<w:ffData><w:name w:val="N&#x61;me"/><w:ddList><w:listEntry w:val="A&#10;B&#9;C&#13;D&#x1F600;&amp;#10;&lt;&gt;&quot;&apos;"/></w:ddList></w:ffData>"#;
let f = LegacyForm::parse(xml).unwrap();
assert_eq!(f.name(), "Name");
assert_eq!(f.shown_text(), Some("A\nB\tC\rD😀&#10;<>\"'"));
let malformed = r#"<w:ffData><w:ddList><w:listEntry w:val="&#0;&#xD800;&#x110000;&#-1;&#xZZ;&unknown;&amp"/></w:ddList></w:ffData>"#;
assert_eq!(LegacyForm::parse(malformed).unwrap().shown_text(), Some("&#0;&#xD800;&#x110000;&#-1;&#xZZ;&unknown;&amp"));
}
#[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>"#);
+1 -1
View File
@@ -90,8 +90,8 @@ pub use settings::{
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, OdfLayout, PagePicture, Settings, Source,
THEME_COLORS, Watermark, Zoom, ZoomFit,
};
pub use text_walk::count_words;
pub(crate) use text_walk::{PARA_WALK_DEPTH, walk_between};
pub use text_walk::{count_chars, count_chars_no_spaces, count_words};
/// A document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+1
View File
@@ -125,6 +125,7 @@ mod tests {
("HYPERLINK\t\"http://example.com\"", Some("http://example.com")),
(r#"HYPERLINK"http://example.com""#, Some("http://example.com")),
(r#"HYPERLINK "http://example.com/path\latest""#, Some("http://example.com/pathlatest")),
(r#"HYPERLINK "x \* Upper""#, Some("x * Upper")),
(r#"HYPERLINK "C:\\Docs\\My file.docx""#, Some(r"C:\Docs\My file.docx")),
(r#"HYPERLINK "say \"hi\"""#, Some(r#"say "hi""#)),
(r#"HYPERLINK " http://example.com ""#, Some("http://example.com")),
+51 -2
View File
@@ -22,10 +22,29 @@ pub fn decimal_separator(lang: Option<&str>) -> char {
const SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\u{2009}'];
const CURRENCY: &[char] = &['$', '€', '£', '¥', '¢', '₽', '₴', '₺', '₹', '₩', '₪', '₫', '฿', '₱', '₸', '₼', '₾'];
/// Currency signs spelled with more than one character: the Panamanian balboa (`B/.`).
const CURRENCY_SYMBOLS: &[&str] = &["B/."];
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`) from either end.
/// Trim spaces and single-character currency signs from either end.
fn trim_currency(s: &str) -> &str {
s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c))
}
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`, `B/.`) from either end.
/// As Word does, a multi-character sign is a word of its own (`B/. 1,250.50`, `1,250.50 B/.`);
/// `B/.1,250.50` and `B/ 1,250.50` are not numbers.
fn strip_currency(s: &str) -> &str {
let mut s = s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c));
let mut s = trim_currency(s);
if let Some(rest) =
CURRENCY_SYMBOLS.iter().find_map(|sym| s.strip_prefix(sym).filter(|rest| rest.chars().next().is_some_and(char::is_whitespace)))
{
s = trim_currency(rest);
}
if let Some(rest) =
CURRENCY_SYMBOLS.iter().find_map(|sym| s.strip_suffix(sym).filter(|rest| rest.chars().next_back().is_some_and(char::is_whitespace)))
{
s = trim_currency(rest);
}
if let Some((head, word)) = s.rsplit_once(SPACES)
&& (1..=3).contains(&word.chars().count())
&& word.chars().all(|c| c.is_alphabetic() || c == '.')
@@ -137,6 +156,7 @@ mod tests {
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("-B/. 1 250,50", pl), Some(-1250.5));
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.
@@ -174,11 +194,40 @@ mod tests {
assert_eq!(format_number(5.75, Some("es-ES")), "5,75");
}
#[test]
fn balboa_numbers() {
let en = Some("en-US");
// Word 16.0 reads `B/.` as a word of its own, before or after the number.
assert_eq!(parse_number("B/. 1,234.50", en), Some(1234.5));
assert_eq!(parse_number("1,234.50 B/.", en), Some(1234.5));
assert_eq!(parse_number("B/.\u{a0}1,234.50", en), Some(1234.5), "a no-break space after the sign");
assert_eq!(parse_number("B/. 1,234.50 B/.", en), Some(1234.5), "the sign at both ends");
assert_eq!(parse_number("-B/. 1,234.50", en), Some(-1234.5), "a minus before the sign");
assert_eq!(parse_number("B/. 1,234.50", Some("es-PA")), Some(1234.5), "the balboa's own region");
// The sign has no space of its own in Word: touching the number is text.
assert_eq!(parse_number("B/.1,234.50", en), None);
assert_eq!(parse_number("1,234.50B/.", en), None);
assert_eq!(parse_number("B/ 1,234.50", en), None, "the balboa is B/. in Word");
assert_eq!(parse_number("B/.", en), None);
// The slash and the full stop make `B/.` ambiguous with a fraction: the sign is matched at
// the ends of the text only, so a fraction is never read as a balboa amount and a sign in
// the middle is not a sign.
assert_eq!(parse_number("1/.5", en), None, "a fraction is not a price");
assert_eq!(parse_number("1/.5", Some("es-PA")), None);
assert_eq!(parse_number("1/.5 B/.", en), None, "the sign at the end does not rescue a fraction");
assert_eq!(parse_number("B/. 1/.5", en), None);
assert_eq!(parse_number("2 B/. 1,234.50", en), None, "the sign sits before the number");
assert_eq!(parse_number("1,234.50 B/. 1,234.50", en), None, "the sign sits between two numbers");
assert_eq!(parse_number("1,234.50/2", en), None, "a division is not a balboa amount");
assert_eq!(parse_number("B/. 1,234.50,50", en), None, "a second decimal separator");
}
#[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("PAB 1,250.50", en), Some(1250.5), "the ISO code");
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));
+6
View File
@@ -16,6 +16,12 @@ pub type Arg = Vec<MNode>;
/// Deepest structure nesting kept (parsers stop here; layout draws nothing deeper).
pub const MAX_DEPTH: usize = 64;
/// Most matrix columns kept (writers pad every row to the widest, so this bounds a row's cost).
pub const MAX_MATRIX_COLS: usize = 64;
/// Most matrix rows kept.
pub const MAX_MATRIX_ROWS: usize = 1000;
/// An equation's structure, plus what's needed to write it back.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
pub struct Math {
+46
View File
@@ -769,6 +769,30 @@ pub fn autocorrect(root: &mut Arg, pos: &MathPos) -> Option<MathPos> {
Some(MathPos { path: pos.path, off: pos.off - name.chars().count() })
}
/// The character just before the caret is an n-ary operator (∫, ∑…) in plain math text, as
/// AutoCorrect leaves `\int` or `\sum`.
pub fn nary_before(root: &Arg, pos: &MathPos) -> bool {
let pos = clamp(root, pos);
let Some(a) = arg_at(root, &pos.path) else { return false };
let (i, k) = match locate(a, pos.off) {
Loc::Inside(i, k) => (i, k),
Loc::Before(i) => match i.checked_sub(1).and_then(|i| a.get(i).map(|n| (i, node_units(n)))) {
Some(at) => at,
None => return false,
},
};
let Some(MNode::Run(r)) = a.get(i) else { return false };
!r.nor && !r.lit && k.checked_sub(1).and_then(|k| r.text.chars().nth(k)).is_some_and(crate::math::is_nary_char)
}
/// The node just before the caret is a built n-ary operator.
pub fn nary_node_before(root: &Arg, pos: &MathPos) -> bool {
let pos = clamp(root, pos);
let Some(a) = arg_at(root, &pos.path) else { return false };
let Loc::Before(i) = locate(a, pos.off) else { return false };
i.checked_sub(1).and_then(|i| a.get(i)).is_some_and(|n| matches!(n, MNode::Nary { .. }))
}
/// Characters that make typed linear text build up into structures.
fn builds(s: &str) -> bool {
s.chars().any(|c| {
@@ -872,6 +896,28 @@ pub fn build_up(root: &mut Arg, pos: &MathPos, latex: bool) -> Option<MathPos> {
Some(clamp(root, &out))
}
/// Typing that just built an n-ary operator without its operand (`∫_0^1` then Space) goes on in
/// the empty operand, as after inserting one from the gallery, so what is typed next is
/// integrated or summed (#4917). An operator whose operand ends with another (`∫∫`) leads to the
/// innermost one. Other carets, and moves or Enter that build up, stay put.
pub fn into_empty_operand(root: &Arg, pos: &MathPos) -> MathPos {
let mut path = pos.path.clone();
let mut off = pos.off;
while path.len() < MAX_DEPTH
&& let Some(a) = arg_at(root, &path)
&& let Loc::Before(i) = locate(a, off)
&& let Some(k) = i.checked_sub(1)
&& let Some(MNode::Nary { e, .. }) = a.get(k)
{
path.push((k, 2));
if e.is_empty() {
return MathPos { path, off: 0 };
}
off = units(e);
}
pos.clone()
}
/// A top-level `#` numbers the equation: wrap it as a one-row equation array.
pub fn number_equation(root: &mut Arg) -> bool {
if has_number_mark(root) && !matches!(root.as_slice(), [MNode::EqArr { .. }]) {
+167 -35
View File
@@ -228,7 +228,7 @@ impl Numbering {
/// 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)
self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some()
}
pub fn level(&self, num: u32, level: u8) -> Option<&Level> {
if let Some((_, l)) = self.num(num)?.level_overrides.iter().find(|(i, _)| *i == level) {
@@ -294,6 +294,14 @@ pub struct Counters {
/// 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]>,
/// The last explicitly rendered label and template at each abstract-list level.
labels: HashMap<(u32, u8), CountedLabel>,
}
#[derive(Debug, Clone)]
struct CountedLabel {
label: Option<String>,
levels: Vec<usize>,
}
/// Whether a level has counted yet, and the value its next item takes if a start override says so.
@@ -317,11 +325,9 @@ impl Counters {
let lv = (level as usize).min(8);
// This list's own definition of a level wins over its abstract list's.
let level_def = |k: usize| numbering.level(num, k as u8);
// A "none" level (Word's numFmt none) still counts and restarts the levels below it; only
// its number reads as nothing, so its literal text ("RESPONSE:") shows (#4985).
let def = level_def(lv)?.clone();
// A "none" level shows no label at all (Word's numFmt none) — not even its text.
if def.format == NumFormat::None {
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
@@ -366,42 +372,108 @@ impl Counters {
if restarts {
*deeper = 0;
*f = LevelFlags::default();
self.labels.remove(&(abs.id, d as u8));
}
}
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();
while let Some(c) = chars.next() {
if c == '%'
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
{
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) };
// 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) {
NumFormat::DecimalZero => NumFormat::DecimalZero,
_ if def.legal => NumFormat::Decimal,
f => f,
};
label.push_str(&fmt.format(val));
if label.len() > MAX_LABEL_BYTES {
break;
}
} else {
label.push(c);
}
}
let numeric = def.format != NumFormat::Bullet;
let label = if numeric { render_label(numbering, num, &def, &vals, &started) } else { def.text.clone() };
// A "none" level shows its literal text, but has no number of its own for a reference to
// show (#4985): it is kept as one that shows none.
let counted = if def.format == NumFormat::None {
CountedLabel { label: None, levels: Vec::new() }
} else {
CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) }
};
self.labels.insert((abs.id, lv as u8), counted);
Some((label, def))
}
/// Which levels of `num`'s list have counted a paragraph yet (a level never used has no
/// number of its own to show in a reference).
pub fn started(&self, numbering: &Numbering, num: u32) -> Option<[bool; 9]> {
let abs = numbering.abstract_of(num)?;
Some(std::array::from_fn(|k| self.labels.get(&(abs.id, k as u8)).is_some_and(|last| last.label.is_some())))
}
/// The label `level` of `num`'s list shows at this point of the walk, without counting: what
/// the last paragraph at that level showed (`"14."` while walking clause 14's sub-clauses).
/// `None` for a bullet or "none" level, or a level the list doesn't define.
pub fn current_label(&self, numbering: &Numbering, num: u32, level: u8) -> Option<String> {
let abs = numbering.abstract_of(num)?;
if let Some(last) = self.labels.get(&(abs.id, level.min(8))) {
return last.label.clone();
}
// A deeper item can count a level implicitly without rendering a paragraph there.
let def = numbering.level(num, level.min(8))?;
if matches!(def.format, NumFormat::None | NumFormat::Bullet) {
return None;
}
let vals = self.state.get(&abs.id).copied().unwrap_or([0; 9]);
let started = self.flags.get(&abs.id).copied().unwrap_or_default();
Some(render_label(numbering, num, def, &vals, &started))
}
/// Levels shown by the last rendered label, or the current definition for an implicit level.
pub fn current_template_levels(&self, numbering: &Numbering, num: u32, level: u8) -> Vec<usize> {
if let Some(abs) = numbering.abstract_of(num)
&& let Some(last) = self.labels.get(&(abs.id, level.min(8)))
{
return last.levels.clone();
}
numbering.level(num, level.min(8)).map(|def| template_levels(&def.text)).unwrap_or_default()
}
}
/// The zero-based levels a label template shows (`"%1.%2"` → `[0, 1]`, `"(%3)"` → `[2]`).
pub fn template_levels(text: &str) -> Vec<usize> {
let mut out = Vec::new();
let mut chars = text.chars().take(MAX_LEVEL_TEXT).peekable();
while let Some(c) = chars.next() {
if c == '%'
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
{
chars.next();
let k = (d as usize).saturating_sub(1).min(8);
if !out.contains(&k) {
out.push(k);
}
}
}
out
}
/// Render a list label without advancing counters, retaining the existing hostile-input caps.
fn render_label(numbering: &Numbering, num: u32, def: &Level, vals: &[u32; 9], started: &[LevelFlags; 9]) -> String {
let level_def = |k: usize| numbering.level(num, k as u8);
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();
while let Some(c) = chars.next() {
if c == '%'
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
{
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) };
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
// format (二 of a Chinese outline becomes 2 in "2.1"); a "none" level stays empty.
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
f @ (NumFormat::DecimalZero | NumFormat::None) => f,
_ if def.legal => NumFormat::Decimal,
f => f,
};
label.push_str(&fmt.format(val));
if label.len() > MAX_LABEL_BYTES {
break;
}
} else {
label.push(c);
}
}
label
}
/// Longest level text (in characters) expanded into a list label.
@@ -413,6 +485,45 @@ const MAX_LABEL_BYTES: usize = 256;
mod tests {
use super::*;
/// Read-only counter APIs retain the renderer's bounds and never advance a list.
#[test]
fn reference_labels_are_bounded_and_do_not_advance_counters() {
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
num.level_overrides = vec![(0, Level { text: "%1".repeat(30_000), ..Level::default() })];
}
let mut c = Counters::default();
let label = c.next_label(&n, id, 0).unwrap().0;
let before = c.values(&n, id);
assert_eq!(c.current_label(&n, id, 0).as_deref(), Some(label.as_str()));
assert_eq!(c.values(&n, id), before);
assert!(label.len() <= 512);
assert_eq!(template_levels(&"%1%9".repeat(30_000)), [0, 8]);
assert!(c.started(&n, id).unwrap()[0]);
}
#[test]
fn last_ancestor_label_keeps_the_instance_that_rendered_it() {
let mut numbering = Numbering::default();
let first = numbering.add_list(ListKind::Numbered);
numbering.abstracts[0].levels[0].text = "Clause %1.".into();
let mut second = numbering.num(first).unwrap().clone();
second.id = first + 1;
second.level_overrides = vec![
(0, Level { text: "Part %1.%2.".into(), ..Level::default() }),
(1, Level { format: NumFormat::LowerLetter, text: "(%2)".into(), ..Level::default() }),
];
let second_id = second.id;
numbering.nums.push(second);
let mut counters = Counters::default();
assert_eq!(counters.next_label(&numbering, first, 0).unwrap().0, "Clause 1.");
assert_eq!(counters.next_label(&numbering, second_id, 1).unwrap().0, "(a)");
assert_eq!(counters.current_label(&numbering, second_id, 0).as_deref(), Some("Clause 1."));
assert_eq!(counters.current_template_levels(&numbering, second_id, 0), vec![0]);
assert_eq!(counters.started(&numbering, second_id).unwrap()[..2], [true, true]);
}
#[test]
fn a_huge_level_text_gives_a_bounded_label() {
let mut n = Numbering::default();
@@ -449,6 +560,27 @@ mod tests {
assert_eq!(n.level(id, 2).map(|l| l.format), Some(NumFormat::DecimalZero));
}
#[test]
fn a_none_level_shows_its_text_and_still_counts() {
// As Word shows them (#4985): a "none" level's literal text is its label, its own number
// reads as nothing (also in legal numbering), and it restarts the levels below it.
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
let lvl = |format, text: &str| Level { format, text: text.into(), ..Level::default() };
let legal = Level { legal: true, ..lvl(NumFormat::Decimal, "%2.%3") };
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
num.level_overrides = vec![(0, lvl(NumFormat::None, "RESPONSE:")), (1, lvl(NumFormat::None, "[%2]")), (2, legal)];
}
assert!(n.shows_label(id, 0));
let mut c = Counters::default();
assert_eq!(c.next_label(&n, id, 0).unwrap().0, "RESPONSE:");
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "[]");
assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".1");
assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".2");
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "[]");
assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".1");
}
#[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
+19 -5
View File
@@ -316,20 +316,34 @@ impl InlineObject {
/// 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 InlineObject::Group { children, .. } = self else { return Vec::new() };
self.member_rects(x, y, w, h).into_iter().filter_map(|(i, r)| Some((r, &children.get(i)?.obj))).collect()
}
/// [`InlineObject::group_rects`] with each member's index in `children` instead of the member.
pub fn member_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<(usize, [f32; 4])> {
let InlineObject::Group { children, .. } = self else { return Vec::new() };
let (sx, sy) = self.group_scale(w, h);
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| {
.enumerate()
.filter_map(|(i, 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))
(!matches!(c.obj, InlineObject::Group { .. })).then_some((i, r))
})
.collect()
}
/// How much a group drawn `w` × `h` scales its members' space (x, y): 1 for a canvas, whose
/// members keep their size.
pub fn group_scale(&self, w: f32, h: f32) -> (f32, f32) {
let InlineObject::Group { ch_w, ch_h, .. } = self else { return (1.0, 1.0) };
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 };
(sx, sy)
}
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`), read from
/// the `w:ffData` it keeps; `None` for anything else.
pub fn legacy_form(&self) -> Option<crate::legacy_form::LegacyForm> {
+7 -1
View File
@@ -305,6 +305,9 @@ pub struct TextFrame {
pub v_anchor: String,
/// `around`, `auto`, `notBeside`, `none`, `tight` or `through`.
pub wrap: String,
/// Whether the frame stays fixed to its page anchor when its paragraph moves.
#[serde(skip_serializing_if = "Option::is_none")]
pub anchor_lock: Option<bool>,
/// Distance from the text left and right, and above and below.
pub h_space: f32,
pub v_space: f32,
@@ -359,6 +362,9 @@ pub struct ParaProps {
/// outside ones, so they swap sides on even (left-hand) pages.
#[serde(skip_serializing_if = "Option::is_none")]
pub mirror_indents: Option<bool>,
/// `w:adjustRightInd`: automatically adjust the right indent for the document grid when enabled; keep the specified right indent when disabled.
#[serde(skip_serializing_if = "Option::is_none")]
pub adjust_right_indent: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub keep_next: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -447,7 +453,7 @@ impl ParaProps {
fn overlay_layout(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars,
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, tabs, shading, borders);
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, adjust_right_indent, tabs, shading, borders);
}
fn overlay_pagination(&mut self, patch: &ParaProps) {
+8
View File
@@ -153,6 +153,10 @@ impl ResolvedChar {
}
/// Concrete paragraph formatting.
fn default_adjust_right_indent() -> bool {
true
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolvedPara {
@@ -173,6 +177,9 @@ pub struct ResolvedPara {
pub contextual_spacing: bool,
/// Left/right indents are inside/outside: they swap on even pages.
pub mirror_indents: bool,
/// Automatically adjust the right indent for the document grid when enabled; keep the specified right indent when disabled.
#[serde(default = "default_adjust_right_indent")]
pub adjust_right_indent: bool,
pub keep_next: bool,
pub keep_lines: bool,
pub page_break_before: bool,
@@ -264,6 +271,7 @@ impl ResolvedPara {
},
contextual_spacing: p.contextual_spacing.unwrap_or(false),
mirror_indents: p.mirror_indents.unwrap_or(false),
adjust_right_indent: p.adjust_right_indent.unwrap_or(true),
keep_next: p.keep_next.unwrap_or(false),
keep_lines: p.keep_lines.unwrap_or(false),
page_break_before: p.page_break_before.unwrap_or(false),
+71 -4
View File
@@ -115,6 +115,40 @@ pub enum NumFormat {
DollarText,
}
/// Chapter separator for page numbers (`w:chapSep`, ECMA-376 §17.18.12).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ChapterSep {
Hyphen,
Period,
Colon,
EmDash,
EnDash,
}
impl ChapterSep {
pub fn from_ooxml(value: &str) -> Option<Self> {
match value {
"hyphen" => Some(Self::Hyphen),
"period" => Some(Self::Period),
"colon" => Some(Self::Colon),
"emDash" => Some(Self::EmDash),
"enDash" => Some(Self::EnDash),
_ => None,
}
}
pub const fn ooxml(self) -> &'static str {
match self {
Self::Hyphen => "hyphen",
Self::Period => "period",
Self::Colon => "colon",
Self::EmDash => "emDash",
Self::EnDash => "enDash",
}
}
}
impl NumFormat {
/// Every format, in the order ST_NumberFormat lists them (which is also the order of the
/// binary .doc number format codes up to 0x27).
@@ -255,13 +289,14 @@ impl NumFormat {
pub fn from_ooxml(s: &str) -> NumFormat {
NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal)
}
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai,
/// Vietnamese, text amounts) show decimal, as do values past a sequence's end
/// (the 21st item of ①…⑳).
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai letters
/// and counting, Vietnamese, text amounts) show decimal, as do values past a sequence's end
/// (the 21st item of ①…⑳). Thai numbers are the decimal number in Thai digits (๑, ๒ … ๑๐).
pub fn format(self, n: u32) -> String {
use crate::cjk_numbers as cjk;
match self {
NumFormat::DecimalZero => format!("{n:02}"),
NumFormat::ThaiNumbers => thai_digits(n),
NumFormat::UpperRoman => roman(n).to_uppercase(),
NumFormat::LowerRoman => roman(n),
NumFormat::UpperLetter => letters(n).to_uppercase(),
@@ -315,7 +350,6 @@ impl NumFormat {
| NumFormat::HindiNumbers
| NumFormat::HindiCounting
| NumFormat::ThaiLetters
| NumFormat::ThaiNumbers
| NumFormat::ThaiCounting
| NumFormat::BahtText
| NumFormat::DollarText => n.to_string(),
@@ -323,6 +357,11 @@ impl NumFormat {
}
}
/// `n` in Thai digits, ๐ (U+0E50) to ๙ (#5757).
fn thai_digits(n: u32) -> String {
n.to_string().chars().filter_map(|c| c.to_digit(10).and_then(|d| char::from_u32(0x0E50 + d))).collect()
}
/// Chicago Manual of Style note marks: *, †, ‡, §, then doubled (**, ††, …) and so on.
fn chicago(n: u32) -> String {
const MARKS: [char; 4] = ['*', '\u{2020}', '\u{2021}', '\u{a7}'];
@@ -749,6 +788,9 @@ pub struct SectionProps {
pub title_page: bool,
pub page_num_start: Option<u32>,
pub page_num_format: NumFormat,
/// Chapter heading level and separator in `w:pgNumType`.
pub chapter_style: Option<u8>,
pub chapter_sep: Option<ChapterSep>,
/// The file carries a `w:pgNumType` element that sets nothing else (no start, decimal
/// format), so saving writes it back rather than dropping it.
#[serde(skip_serializing_if = "std::ops::Not::not")]
@@ -779,6 +821,13 @@ pub struct SectionProps {
pub footnote_pr: NoteProps,
#[serde(skip_serializing_if = "NoteProps::is_empty")]
pub endnote_pr: NoteProps,
/// First-page and other-page printer trays (`w:paperSrc`).
pub paper_src_first: Option<u16>,
pub paper_src_other: Option<u16>,
/// Section-specific suppression of endnotes (`w:noEndnote`).
pub no_endnote: Option<bool>,
/// Independent footnote column count (`w15:footnoteColumns`).
pub footnote_columns: Option<u32>,
/// Whether its columns end balanced (an ODF section's `text:dont-balance-text-columns`):
/// `None` is Word's rule, balanced when a continuous section follows; `Some(true)` also at
/// the end of the document, `Some(false)` never.
@@ -809,6 +858,8 @@ impl Default for SectionProps {
title_page: false,
page_num_start: None,
page_num_format: NumFormat::Decimal,
chapter_style: None,
chapter_sep: None,
page_num_set: false,
line_numbers: None,
valign: PageVAlign::Top,
@@ -821,6 +872,10 @@ impl Default for SectionProps {
doc_grid: None,
footnote_pr: NoteProps::default(),
endnote_pr: NoteProps::default(),
paper_src_first: None,
paper_src_other: None,
no_endnote: None,
footnote_columns: None,
balance_columns: None,
}
}
@@ -949,6 +1004,18 @@ mod tests {
assert_eq!(NumFormat::from_ooxml("klingon"), NumFormat::Decimal, "unknown formats are decimal");
}
/// #5757: Thai numbers are the decimal number in Thai digits, every digit, at any length.
#[test]
fn thai_numbers_use_thai_digits() {
let labels: Vec<String> = [1, 2, 3, 9, 10, 11, 20, 105, 2569].iter().map(|n| NumFormat::ThaiNumbers.format(*n)).collect();
assert_eq!(labels.join(" "), "๑ ๒ ๓ ๙ ๑๐ ๑๑ ๒๐ ๑๐๕ ๒๕๖๙");
assert_eq!(NumFormat::ThaiNumbers.format(0), "๐");
assert_eq!(NumFormat::ThaiNumbers.format(u32::MAX), "๔๒๙๔๙๖๗๒๙๕");
assert_eq!(NumFormat::from_ooxml("thaiNumbers"), NumFormat::ThaiNumbers);
// Formats not drawn yet still show decimal.
assert_eq!(NumFormat::ThaiLetters.format(3), "3");
}
#[test]
fn russian_letters_follow_word() {
// Word's own labels for a russianLower list: no ё, й, ъ or ь, then doubled letters.
+25
View File
@@ -966,6 +966,31 @@ fn table_styles() -> Vec<Style> {
#[cfg(test)]
mod tests {
use super::*;
use crate::resolve::ResolvedPara;
#[test]
fn adjust_right_indent_inherits_and_can_be_overridden() {
let resolved = StyleSheet::empty().resolve_para(&ParaProps::default());
assert!(resolved.adjust_right_indent);
let mut older_json = serde_json::to_value(&resolved).unwrap();
older_json.as_object_mut().unwrap().remove("adjustRightIndent");
assert!(serde_json::from_value::<ResolvedPara>(older_json).unwrap().adjust_right_indent);
let mut s = StyleSheet::empty();
s.styles.push(Style {
id: "Normal".into(),
name: "Normal".into(),
para: ParaProps { adjust_right_indent: Some(true), ..Default::default() },
..Default::default()
});
assert!(s.resolve_para(&ParaProps::default()).adjust_right_indent);
assert!(!s.resolve_para(&ParaProps { adjust_right_indent: Some(false), ..Default::default() }).adjust_right_indent);
let json = serde_json::to_value(ParaProps { adjust_right_indent: Some(false), ..Default::default() }).unwrap();
assert_eq!(json["adjustRightIndent"], false);
assert_eq!(serde_json::from_value::<ParaProps>(json).unwrap().adjust_right_indent, Some(false));
}
#[test]
fn builtins_present() {
+19
View File
@@ -19,6 +19,25 @@ fn word_count() {
assert_eq!(count_words(" -- "), 0);
}
/// #2797: the status bar's character counts, worded like the Word Count dialog's: characters
/// with spaces leave the paragraph breaks out, characters without spaces leave every
/// whitespace character out.
#[test]
fn character_counts() {
assert_eq!(count_chars(""), 0);
assert_eq!(count_chars("Hello"), 5);
// Paragraph breaks join the paragraphs but aren't characters.
assert_eq!(count_chars("Hello\nworld\r\n"), 10);
assert_eq!(count_chars("a\tb"), 3, "a tab is a character");
// Every kind of whitespace goes, spaces, tabs and (non-ASCII) spaces between words.
assert_eq!(count_chars_no_spaces("Hello world"), 10);
assert_eq!(count_chars_no_spaces("a\tb c\u{a0}d"), 4, "including a non-breaking space");
assert_eq!(count_chars_no_spaces(" \n\r\t "), 0);
// Accented letters and emoji count as one character each, as they are.
assert_eq!(count_chars("café 🚀"), 6);
assert_eq!(count_chars_no_spaces("café 🚀"), 5);
}
/// #4547: each Han ideograph and kana counts as a word; punctuation and Hangul spacing don't change.
#[test]
fn word_count_counts_each_chinese_and_japanese_character() {
+9 -26
View File
@@ -102,18 +102,6 @@ fn dense(n: usize) -> Document {
d
}
// Thread CPU excludes scheduler delays. Missing or nonadvancing counters provide no timing
// evidence; the semantic assertions still run on those platforms.
fn cpu_cost(f: impl FnOnce()) -> Option<f64> {
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
let before = ran();
f();
match (before, ran()) {
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
_ => None,
}
}
#[test]
fn conversion_work_scales_with_reference_and_formatting_runs() {
let (small, big) = (dense(2_000), dense(16_000));
@@ -128,18 +116,13 @@ fn conversion_work_scales_with_reference_and_formatting_runs() {
};
convert(&small);
convert(&big);
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
for _ in 0..3 {
let a = cpu_cost(|| (0..8).for_each(|_| convert(&small)));
let b = cpu_cost(|| convert(&big));
if let (Some(a), Some(b)) = (a, b) {
t1 = t1.min(a / 8.0);
t8 = t8.min(b);
}
}
if t1.is_finite() && t8.is_finite() {
let ratio = t8 / t1;
eprintln!("note conversion CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
assert!(ratio < 24.0, "8x the references and runs cost {ratio:.2}x CPU");
}
// The characters and runs the conversion walks (`crate::work`), not a timing: about 8x for 8x
// the references and runs when they are walked together, about 64x when each run rescans the
// references.
let s1 = crate::work::count(|| convert(&small));
let s8 = crate::work::count(|| convert(&big));
let ratio = s8 as f64 / s1.max(1) as f64;
eprintln!("note conversion steps: {s1} -> {s8}, {ratio:.1}x for 8x references and runs");
assert!(s1 > 0, "no steps counted, so the walk this test watches no longer counts them (crate::work)");
assert!(ratio <= 12.0, "8x the references and runs cost {ratio:.1}x the steps ({s1} -> {s8})");
}
+13
View File
@@ -35,6 +35,18 @@ fn is_cjk_word(c: char) -> bool {
) && c.is_alphanumeric()
}
/// Characters in `s`, as the Word Count dialog counts them ("Characters (with spaces)"):
/// paragraph breaks aren't characters (#2797).
pub fn count_chars(s: &str) -> usize {
s.chars().filter(|c| *c != '\n' && *c != '\r').count()
}
/// Characters in `s` ignoring every whitespace character, as the Word Count dialog counts them
/// ("Characters (no spaces)"; #2797).
pub fn count_chars_no_spaces(s: &str) -> usize {
s.chars().filter(|c| !c.is_whitespace()).count()
}
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
pub(crate) const PARA_WALK_DEPTH: usize = 16;
@@ -51,6 +63,7 @@ pub(crate) fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, dept
return;
}
for (i, b) in bl.iter().enumerate() {
crate::work::steps(1);
prefix.push(i as u32);
match &**b {
Block::Para(_) => out.push(Path(prefix.clone())),
+9 -7
View File
@@ -1,8 +1,9 @@
//! A count of the characters a paragraph's object lookups walk, for the linear-time tests of the
//! crates that use them: an input 8x the size must cost about 8x the steps, which a busy machine
//! can't skew the way it skews a timing. Counted only in tests (this crate's, and other crates'
//! through the `test-support` feature, which they turn on as a dev-dependency); in the app the
//! count is nothing.
//! A count of the characters a paragraph's object lookups walk, the blocks
//! [`crate::Document::para_paths`] visits, and each block lookup by path with the table levels it
//! descends, for the linear-time tests of the crates that use them: an input 8x the size must cost
//! about 8x the steps, which a busy machine can't skew the way it skews a timing. Counted only in
//! tests (this crate's, and other crates' through the `test-support` feature, which they turn on
//! as a dev-dependency); in the app the count is nothing.
#[cfg(any(test, feature = "test-support"))]
thread_local! {
@@ -10,9 +11,10 @@ thread_local! {
static STEPS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// Count `n` steps of a walk (nothing outside tests).
/// Count `n` steps of a walk (nothing outside tests). Public for the walks other crates do over a
/// paragraph's text themselves (Markdown and HTML comment export, for one).
#[inline(always)]
pub(crate) fn steps(n: usize) {
pub fn steps(n: usize) {
#[cfg(any(test, feature = "test-support"))]
STEPS.with(|s| s.set(s.get().saturating_add(n as u64)));
#[cfg(not(any(test, feature = "test-support")))]
+52 -13
View File
@@ -7,9 +7,26 @@ use crate::DocbinError;
/// Largest bin table (entries) we accept.
const MAX_BINS: usize = 100_000;
/// A Chpx grpprl and the FCs `[lo, hi)` for which it is the one [`Bins::chpx`] returns.
#[derive(Clone, Copy)]
pub(crate) struct ChpxRun<'w> {
pub(crate) grpprl: &'w [u8],
lo: u32,
hi: u32,
}
impl ChpxRun<'_> {
pub(crate) fn contains(&self, fc: u32) -> bool {
self.lo <= fc && fc < self.hi
}
}
/// A parsed `PlcBte*`: FC breakpoints and the FKP page each range lives in.
pub(crate) struct Bins {
fcs: Vec<u32>,
/// `fcs` ascends, so the bin of an FC holds every FC up to the next breakpoint (a corrupt
/// table that does not gets no cached ranges, [`ChpxRun`]).
sorted: bool,
/// Page number (× 512 = byte offset in the WordDocument stream).
pns: Vec<u32>,
}
@@ -20,7 +37,7 @@ impl Bins {
let fc = fc as usize;
let lcb = lcb as usize;
if lcb < 4 || !(lcb - 4).is_multiple_of(8) {
return Ok(Bins { fcs: Vec::new(), pns: Vec::new() });
return Ok(Bins { fcs: Vec::new(), sorted: true, pns: Vec::new() });
}
let end = fc.checked_add(lcb).ok_or_else(|| DocbinError::Malformed("bin table overflow".into()))?;
let plc = table.get(fc..end).ok_or_else(|| DocbinError::Malformed("bin table outside the Table stream".into()))?;
@@ -38,36 +55,58 @@ impl Bins {
// PnBte* keeps the page number in its low bits; the rest must be zero.
pns.push(at(n + 1 + i).ok_or_else(|| DocbinError::Malformed("truncated bin aPn".into()))? & 0x003F_FFFF);
}
Ok(Bins { fcs, pns })
let sorted = fcs.windows(2).all(|w| w.first() <= w.get(1));
Ok(Bins { fcs, sorted, pns })
}
/// The page containing `fc`, as a slice of the WordDocument stream.
fn page<'w>(&self, word: &'w [u8], fc: u32) -> Option<&'w [u8]> {
/// The page containing `fc`, as a slice of the WordDocument stream, and the FC range
/// `[lo, hi)` of its bin (`hi` is `u32::MAX` for the last; an empty range when the table
/// is not ascending, so nothing is cached from it).
fn page<'w>(&self, word: &'w [u8], fc: u32) -> Option<(&'w [u8], u32, u32)> {
let i = self.fcs.partition_point(|&f| f <= fc).checked_sub(1)?;
let pn = *self.pns.get(i)?;
let at = pn.checked_mul(512)? as usize;
word.get(at..at.checked_add(512)?)
let page = word.get(at..at.checked_add(512)?)?;
if !self.sorted {
return Some((page, 1, 0));
}
let lo = self.fcs.get(i).copied().unwrap_or(0);
let hi = self.fcs.get(i + 1).copied().unwrap_or(u32::MAX);
Some((page, lo, hi))
}
/// The grpprl of the Chpx covering `fc` ([MS-DOC] ChpxFkp: rgfc u32s, rgb offsets ×2,
/// crun in the last byte).
pub(crate) fn chpx<'w>(&self, word: &'w [u8], fc: u32) -> &'w [u8] {
let Some(page) = self.page(word, fc) else { return &[] };
let Some(&crun) = page.get(511) else { return &[] };
self.chpx_run(word, fc).grpprl
}
/// [`Bins::chpx`] with the FC range over which the same grpprl is the answer, so a walk
/// over consecutive characters looks it up once per run, not once per character.
pub(crate) fn chpx_run<'w>(&self, word: &'w [u8], fc: u32) -> ChpxRun<'w> {
let none = ChpxRun { grpprl: &[], lo: 1, hi: 0 };
let Some((page, page_lo, page_hi)) = self.page(word, fc) else { return none };
let Some(&crun) = page.get(511) else { return none };
let crun = crun.min(0x65) as usize;
let rgfc = |i: usize| -> Option<u32> { page.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) };
// rgb array sits right after rgfc (crun+1 u32s); each byte is a half-offset into the
// region between rgb's end and crun.
let rgb_at = (crun + 1) * 4;
// The last run starting at or before `fc`. It stays the answer for every FC from its own
// start up to the smallest start after it (also in a page whose starts do not ascend).
let j = (0..crun).rev().find(|&j| rgfc(j).is_some_and(|f| f <= fc));
let Some(j) = j else { return &[] };
let Some(&off) = page.get(rgb_at + j) else { return &[] };
let first_after = j.map_or(0, |j| j + 1);
let hi = (first_after..crun).filter_map(rgfc).min().map_or(page_hi, |m| m.min(page_hi));
let lo = j.and_then(rgfc).map_or(page_lo, |f| f.max(page_lo));
let run = |grpprl: &'w [u8]| ChpxRun { grpprl, lo, hi };
let Some(j) = j else { return run(&[]) };
let Some(&off) = page.get(rgb_at + j) else { return run(&[]) };
if off == 0 {
return &[];
return run(&[]);
}
let at = off as usize * 2;
let Some(&cb) = page.get(at) else { return &[] };
page.get(at + 1..at + 1 + cb as usize).unwrap_or(&[])
let Some(&cb) = page.get(at) else { return run(&[]) };
run(page.get(at + 1..at + 1 + cb as usize).unwrap_or(&[]))
}
/// The (istd, grpprl) of the Papx of the paragraph whose mark is just before `fc`
@@ -75,7 +114,7 @@ impl Bins {
/// entries (1 offset byte + 12 PHE bytes), crun in the last byte; the PAPX itself is a
/// PapxInFkp with the cb/cb' size quirk). Query with the FC of the character after the mark.
pub(crate) fn papx<'w>(&self, word: &'w [u8], fc: u32) -> (u16, &'w [u8]) {
let Some(page) = self.page(word, fc) else { return (0, &[]) };
let Some((page, _, _)) = self.page(word, fc) else { return (0, &[]) };
let Some(&crun) = page.get(511) else { return (0, &[]) };
// BX is 13 bytes; cap crun at what fits before byte 511.
let max_run = (511usize.saturating_sub((crun as usize + 1) * 4)) / 13;
+187 -61
View File
@@ -51,6 +51,14 @@ const MAX_PARTS: usize = 50_000;
/// headers, notes, text boxes), whatever the file's size. The working budget is tied to the
/// WordDocument stream (see [`read`]); this caps even a maximal stream.
const MAX_TOTAL_CHARS: u64 = 64 << 20;
/// Most paragraph and cell marks across every story walk of one document. The character
/// budget does not see them: a mark is one byte of the file but about 1 KB of document, so a
/// file of nothing but marks would grow memory about a thousand times faster than its size.
/// The cap bounds a flood to about 2 GB while reading (about 4 GB at the very cap, when the
/// blocks are assembled). Past it the read fails with [`DocbinError::Limit`]. Real documents stay far below it:
/// 50 paragraphs on each of 10,000 pages is 500,000, and a 10,000-page one-column table (46
/// rows, each a cell mark plus a row mark) is about 920,000.
const MAX_PARAGRAPHS: usize = 2_000_000;
/// Bytes of repeated work per document beyond the text itself: piece Prm grpprls (Prcs, up
/// to 64 KB each) interpreted (#3692), hyperlink targets (#3708), picture alt text (#3709)
/// and floating objects (#3710) copied into every run or anchor. Past it Prms are skipped,
@@ -195,6 +203,12 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
/// verifier ([`DocbinError::WrongPassword`] when it does not match) and then decrypted. The
/// password is ignored for an unprotected file.
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Document, DocbinError> {
read_limited(bytes, password, MAX_PARAGRAPHS)
}
/// [`read_with_password`] with the paragraph cap as a parameter, so tests need not build a
/// file of [`MAX_PARAGRAPHS`] marks.
fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) -> Result<Document, DocbinError> {
let mut comp = open_container(bytes)?;
let mut word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
if word.len() as u64 > MAX_STREAM {
@@ -270,6 +284,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
// tight against aliasing — and never past the absolute ceiling.
let budget = std::cell::Cell::new((word.len() as u64).saturating_mul(2).min(MAX_TOTAL_CHARS));
let work = Work::new(MAX_WORK);
let paragraphs = ParaBudget { left: std::cell::Cell::new(max_paragraphs), exceeded: std::cell::Cell::new(false) };
let ctx = WalkCtx {
word: &word,
@@ -285,6 +300,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
drawn: &[],
sect_ends: &[],
budget: &budget,
paragraphs: &paragraphs,
work: &work,
};
@@ -304,7 +320,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
if doc.parts.len() >= MAX_PARTS || tb_stories.contains_key(&spid) {
continue;
}
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
tb_stories.insert(spid, doc.add_part(PartKind::TextBox, blocks));
}
}
@@ -329,7 +345,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
0 | 1 | 4 => PartKind::Header,
_ => PartKind::Footer,
};
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
if !blocks.is_empty() {
let id = doc.add_part(kind, blocks);
hf_ids.push((si, k, id));
@@ -342,7 +358,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
let mut note_links: Vec<(u32, NoteKind, u32)> = Vec::new();
for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_FND_REF, fib::pair::PLCF_FND_TXT) {
let Some((a, b)) = story(ftn_base, fib.ccp.ftn, ab) else { continue };
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
let id = doc.add_part(PartKind::Footnote, blocks);
note_links.push((cp, NoteKind::Footnote, id));
@@ -350,7 +366,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
}
for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_END_REF, fib::pair::PLCF_END_TXT) {
let Some((a, b)) = story(edn_base, fib.ccp.edn, ab) else { continue };
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
let id = doc.add_part(PartKind::Endnote, blocks);
note_links.push((cp, NoteKind::Endnote, id));
@@ -393,6 +409,9 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
doc.last_section = l;
}
if paragraphs.exceeded.get() {
return Err(paragraph_limit(max_paragraphs));
}
doc.media.extend(pics.media);
doc.body = table::assemble(paras);
doc.ensure_nonempty();
@@ -400,6 +419,20 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
Ok(doc)
}
fn paragraph_limit(max_paragraphs: usize) -> DocbinError {
DocbinError::Limit(format!("the document has more than {max_paragraphs} paragraphs and table cells"))
}
/// Walks one story into blocks. A walk that spent the shared paragraph budget is refused before
/// its partial list is assembled, so a flood does not build blocks it will throw away.
fn story_blocks(ctx: &WalkCtx<'_>, a: u32, b: u32, pics: &mut media::Pictures, max_paragraphs: usize) -> Result<wordcraft_doc::Blocks, DocbinError> {
let paras = walk(ctx, a, b, pics);
if ctx.paragraphs.exceeded.get() {
return Err(paragraph_limit(max_paragraphs));
}
Ok(table::assemble(paras))
}
/// A story's CP range `[a, b)`, relative to a subdocument starting at `base` with `len`
/// CPs, as absolute CPs clamped to that subdocument; `None` when empty.
fn story(base: u32, len: u32, (a, b): (u32, u32)) -> Option<(u32, u32)> {
@@ -433,10 +466,35 @@ struct WalkCtx<'a> {
/// footnotes, text boxes) are hostile and can each span a whole subdocument, so thousands of
/// them could otherwise materialise the same text thousands of times; this bounds the total.
budget: &'a std::cell::Cell<u64>,
/// Paragraph and cell marks still allowed across every story walk ([`MAX_PARAGRAPHS`]).
paragraphs: &'a ParaBudget,
/// Repeated-work bytes left ([`MAX_WORK`]), and what they left out.
work: &'a Work,
}
/// The paragraph and cell marks still allowed in this document, and whether a walk ran into
/// the end of them (the read then fails).
struct ParaBudget {
left: std::cell::Cell<usize>,
exceeded: std::cell::Cell<bool>,
}
impl ParaBudget {
/// Spends one mark; false (and the overrun noted) when none are left.
fn spend(&self) -> bool {
match self.left.get().checked_sub(1) {
Some(left) => {
self.left.set(left);
true
}
None => {
self.exceeded.set(true);
false
}
}
}
}
/// State while assembling one paragraph from per-CP formatting.
#[derive(Default)]
struct ParaBuild {
@@ -444,29 +502,37 @@ struct ParaBuild {
runs: Vec<Run>,
props: wordcraft_doc::props::ParaProps,
mark: CharProps,
/// The current run's properties, so runs only break when formatting changes.
cur: Option<CharProps>,
/// Which resolved formatting ([`FmtState::generation`]) the last character came from, while
/// the last run is still the one it extended: the same generation means equal properties.
run_gen: Option<u64>,
/// Inline objects, each anchored at a U+FFFC pushed into `text`.
objects: Vec<InlineObject>,
}
impl ParaBuild {
fn push(&mut self, c: char, props: CharProps) {
self.run_gen = None;
let len = c.len_utf8();
match &self.cur {
Some(p) if *p == props => {
if let Some(r) = self.runs.last_mut() {
r.len += len;
}
}
_ => {
self.runs.push(Run { len, props: props.clone() });
self.cur = Some(props);
}
match self.runs.last_mut() {
Some(r) if r.props == props => r.len += len,
_ => self.runs.push(Run { len, props }),
}
self.text.push(c);
}
/// [`ParaBuild::push`] for properties that are resolved once and shared by many characters:
/// they are cloned only when a run starts, and not even compared while `generation` stays
/// the same.
fn push_shared(&mut self, c: char, props: &CharProps, generation: u64) {
let len = c.len_utf8();
match self.runs.last_mut() {
Some(r) if self.run_gen == Some(generation) || r.props == *props => r.len += len,
_ => self.runs.push(Run { len, props: props.clone() }),
}
self.run_gen = Some(generation);
self.text.push(c);
}
/// Append `c` to the current run, whose properties the caller knows it matches.
fn extend(&mut self, c: char) {
if let Some(r) = self.runs.last_mut() {
@@ -494,7 +560,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
// Field assembly state: instruction characters are swallowed, the result is kept until
// the end character, then emitted as one object (hyperlinks keep their result text).
let mut field: Option<FieldBuild> = None;
let mut memo: Option<FmtMemo<'a>> = None;
let mut fmt = FmtState::default();
while cp < cp_end {
// Bookmark boundaries at this CP (main-document walks only; subdocument walks get
// an empty table and `cp` never matches the main-document CPs).
@@ -527,6 +593,9 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
let section_mark = c == '\u{C}' && ctx.sect_ends.binary_search(&cp.saturating_add(1)).is_ok();
match c {
'\r' | '\u{7}' | '\u{C}' if c != '\u{C}' || section_mark => {
if !ctx.paragraphs.spend() {
break;
}
// Paragraph mark: its PAPX covers the FC range of the paragraph it ends, so
// querying at the mark's own FC finds that paragraph's properties.
let fc_mark = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
@@ -536,6 +605,9 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
}
let mut row = table::decode(papx);
row.cell_mark = c == '\u{7}';
// Only table marks keep their table context; a plain paragraph would carry
// a `RowInfo` (about 300 bytes) it never uses.
let row = row.is_table().then(|| Box::new(row));
let mut ilvl = 0u8;
let mut ilfo = 0i32;
for prl in sprm::iter(papx) {
@@ -589,7 +661,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
&& f.depth == 1
&& f.sep.is_none()
{
f.sep = Some(Vec::new());
f.sep = Some(FieldResult::default());
}
cp = cp.saturating_add(1);
continue;
@@ -651,16 +723,16 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
pb.objects.push(InlineObject::NoteRef { kind: *kind, id: *id, custom: String::new() });
}
}
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut memo),
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut memo),
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut memo),
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut memo),
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut memo),
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut memo),
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut fmt),
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut fmt),
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut fmt),
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut fmt),
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut fmt),
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut fmt),
// Object anchors, comment references and other special characters are dropped
// (or handled by later passes).
c if (c as u32) < 0x20 => {}
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut memo),
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut fmt),
}
cp = cp.saturating_add(units);
}
@@ -669,7 +741,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
out.push(ParaOut {
para: Paragraph { text: done.text, runs: done.runs, props: done.props, mark: done.mark, objects: done.objects, ..Default::default() },
end_cp: cp_end,
row: table::RowInfo::default(),
row: None,
});
}
out
@@ -680,7 +752,28 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
struct FieldBuild {
depth: u32,
instr: String,
sep: Option<Vec<(char, CharProps)>>,
sep: Option<FieldResult>,
}
/// A field's cached result: its text and, as runs, the formatting of that text. A run is added
/// only when the formatting changes, so a result of millions of equally formatted characters
/// costs one `CharProps`, not one per character (365 bytes each).
#[derive(Default)]
struct FieldResult {
text: String,
/// (byte length in `text`, formatting) in order; adjacent runs differ.
runs: Vec<(usize, CharProps)>,
}
impl FieldResult {
fn push(&mut self, c: char, props: CharProps) {
let len = c.len_utf8();
match self.runs.last_mut() {
Some((n, last)) if *last == props => *n += len,
_ => self.runs.push((len, props)),
}
self.text.push(c);
}
}
/// Bytes a clone of `obj` copies: the object itself plus a picture's alt text, a group with all
@@ -705,24 +798,26 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) {
let result = f.sep.unwrap_or_default();
if let Some(target) = hyperlink_target(&instr) {
let cost = target.len() as u64;
let mut run: Option<CharProps> = None;
for (c, props) in result {
if run.as_ref() == Some(&props) {
pb.extend(c);
continue;
}
let mut linked = props.clone();
let mut chars = result.text.chars();
for (len, props) in result.runs {
let mut linked = props;
if work.charge(cost, Cost::LinkTarget) {
linked.link = Some(target.clone());
}
pb.push(c, linked);
run = Some(props);
let mut left = len;
let Some(first) = chars.next() else { break };
pb.push(first, linked);
left = left.saturating_sub(first.len_utf8());
while left > 0 {
let Some(c) = chars.next() else { break };
pb.extend(c);
left = left.saturating_sub(c.len_utf8());
}
}
return;
}
let result_text: String = result.iter().map(|(c, _)| *c).collect();
pb.push('\u{FFFC}', CharProps::default());
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
pb.objects.push(InlineObject::Field { instr, result: result.text, locked: false, dirty: false, code: false, form: String::new() });
}
/// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream.
@@ -767,34 +862,53 @@ fn num_of(ilfo: i32) -> Option<u32> {
/// plus the piece's Prm, over the paragraph style's base character props. Inside a field,
/// the character is recorded into the instruction or the cached result instead of (or as
/// well as) the visible text.
fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, memo: &mut Option<FmtMemo<'a>>) {
let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
let grpprl = ctx.chpx_bins.chpx(ctx.word, fc);
let prm = ctx.pieces.prm_of(cp);
let style = pb.props.style.as_deref();
let mut props = match memo.as_ref().filter(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style) {
Some(m) => m.props.clone(),
fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, st: &mut FmtState<'a>) {
// The piece and the CHPX run of the previous character usually cover this one too.
let span = match st.span.filter(|s| s.contains(cp)) {
Some(s) => Some(s),
None => {
let default;
let base = match style.and_then(|id| ctx.sheet.get(id)) {
Some(st) => &st.chr,
None => {
default = CharProps::default();
&default
}
};
let mut props = char_props(grpprl, ctx.fonts, base);
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work);
*memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props: props.clone() });
props
st.span = ctx.pieces.span(cp);
st.span
}
};
let fc = span.and_then(|s| s.fc_at(cp)).unwrap_or(0);
let run = match st.chpx.filter(|r| r.contains(fc)) {
Some(r) => r,
None => {
let r = ctx.chpx_bins.chpx_run(ctx.word, fc);
st.chpx = Some(r);
r
}
};
let grpprl = run.grpprl;
let prm = span.map_or(0, |s| s.prm());
let style = pb.props.style.as_deref();
let hit = st.memo.as_ref().is_some_and(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style);
if !hit {
let default;
let base = match style.and_then(|id| ctx.sheet.get(id)) {
Some(sty) => &sty.chr,
None => {
default = CharProps::default();
&default
}
};
let mut props = char_props(grpprl, ctx.fonts, base);
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work);
st.memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props });
st.generation += 1;
}
let Some(m) = st.memo.as_ref() else { return };
// A symbol character (Insert › Symbol) is stored as the placeholder U+0028 with
// sprmCSymbol carrying the font and the character code ([MS-DOC] §2.6.1).
let symbol = if c == '(' { symbol_operand(grpprl) } else { None };
if field.is_none() && symbol.is_none() {
pb.push_shared(c, &m.props, st.generation);
return;
}
let mut props = m.props.clone();
let mut c = c;
if c == '('
&& let Some((ftc, xchar)) = symbol_operand(grpprl)
{
if let Some((ftc, xchar)) = symbol {
if let Some(font) = ctx.fonts.get(usize::from(ftc)) {
props.font = Some(font.clone());
}
@@ -806,13 +920,25 @@ fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, f
Some(f) => match f.sep.as_mut() {
// Inside a field, characters are recorded for the field object instead of shown;
// `finish_field` emits the visible text (hyperlink runs or one object anchor).
Some(result) => result.push((c, props)),
Some(result) => result.push(c, props),
None => f.instr.push(c),
},
None => pb.push(c, props),
}
}
/// What the walk carries from one character to the next while it resolves formatting: the piece
/// and the CHPX run the last character was in (so most characters need no search), and the last
/// formatting resolved.
#[derive(Default)]
struct FmtState<'a> {
span: Option<piece::PieceSpan<'a>>,
chpx: Option<fkp::ChpxRun<'a>>,
memo: Option<FmtMemo<'a>>,
/// Counts the memo's replacements, so equal values mean the same properties.
generation: u64,
}
/// The last character formatting resolved, reused while the CHPX, the piece's Prm and the
/// paragraph style stay the same, so a Prm grpprl (up to 64 KB) is interpreted once per
/// formatting change, not once per character (#3692).
+36 -6
View File
@@ -117,12 +117,6 @@ impl PieceTable {
}
}
/// The raw Prm of the piece holding `cp` (0, no properties, when there is none): two CPs
/// with the same value get the same [`PrmRef`].
pub(crate) fn prm_of(&self, cp: u32) -> u16 {
self.piece_of(cp).map_or(0, |p| p.prm)
}
fn piece_of(&self, cp: u32) -> Option<&Pcd> {
let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?;
if i >= self.pieces.len() {
@@ -143,6 +137,17 @@ impl PieceTable {
if p.compressed { p.fc.checked_add(step)?.checked_div(2) } else { p.fc.checked_add(step) }
}
/// The piece holding `cp`, with the CP range it covers: for every CP in that range the
/// answers of [`PieceTable::fc_of_cp`] and the piece's Prm come from this span alone,
/// so a sequential walk searches the piece table once per piece.
pub(crate) fn span(&self, cp: u32) -> Option<PieceSpan<'_>> {
let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?;
let pcd = self.pieces.get(i)?;
let cp_lo = *self.cps.get(i)?;
let cp_hi = *self.cps.get(i + 1)?;
Some(PieceSpan { cp_lo, cp_hi, pcd })
}
/// The CP just past the last piece: no text exists at or beyond it.
pub(crate) fn cp_end(&self) -> u32 {
self.cps.last().copied().unwrap_or(0)
@@ -187,6 +192,31 @@ impl PieceTable {
}
}
/// One piece and the CPs `[cp_lo, cp_hi)` it covers ([`PieceTable::span`]).
#[derive(Clone, Copy)]
pub(crate) struct PieceSpan<'a> {
cp_lo: u32,
cp_hi: u32,
pcd: &'a Pcd,
}
impl PieceSpan<'_> {
pub(crate) fn contains(&self, cp: u32) -> bool {
self.cp_lo <= cp && cp < self.cp_hi
}
/// [`PieceTable::fc_of_cp`] for a CP inside the span.
pub(crate) fn fc_at(&self, cp: u32) -> Option<u32> {
let step = cp.checked_sub(self.cp_lo)?.checked_mul(2)?;
if self.pcd.compressed { self.pcd.fc.checked_add(step)?.checked_div(2) } else { self.pcd.fc.checked_add(step) }
}
/// The raw Prm of the piece: two CPs with the same value get the same [`PrmRef`].
pub(crate) fn prm(&self) -> u16 {
self.pcd.prm
}
}
/// What a piece's `Prm` refers to.
pub(crate) enum PrmRef<'a> {
None,
+9 -6
View File
@@ -83,6 +83,11 @@ pub(crate) struct RowInfo {
/// Read the table context out of a paragraph mark's PAPX grpprl.
impl RowInfo {
/// Does the mark belong to a table: inside one, ending a row, or ending a cell?
pub(crate) fn is_table(&self) -> bool {
self.in_table || self.ttp || self.cell_mark
}
/// The row height from `sprmTDyaRowHeight`, for tests.
#[cfg(test)]
pub(crate) fn height(&self) -> Option<f32> {
@@ -263,8 +268,8 @@ pub(crate) struct ParaOut {
pub(crate) para: Paragraph,
/// CP just past the terminating mark.
pub(crate) end_cp: u32,
/// Table context from the mark's PAPX (empty default = not in a table).
pub(crate) row: RowInfo,
/// Table context from the mark's PAPX; `None` for a paragraph outside a table.
pub(crate) row: Option<Box<RowInfo>>,
}
/// Group consecutive in-table paragraphs into [`Table`] blocks; the rest pass through.
@@ -369,9 +374,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
}
for p in paras {
let info = p.row;
let is_table = info.in_table || info.ttp || info.cell_mark;
if !is_table {
let Some(info) = p.row else {
if open {
flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1);
grid.clear();
@@ -386,7 +389,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
}
out.push(para_block(p.para));
continue;
}
};
if !open {
open = true;
}
+286 -3
View File
@@ -116,6 +116,12 @@ fn piece_bytes(p: &Piece) -> Vec<u8> {
/// A document whose main text is the given pieces (extra subdocument text may follow).
fn text_doc(main: &[Piece], extra: &[Piece], ccp_hdd: u32) -> Vec<u8> {
text_doc_with(main, extra, FibSpec { ccp_hdd, ..Default::default() }, &[])
}
/// [`text_doc`] with the FIB knobs given (`ccp_text` is filled in from `main`; `pairs` must be empty) and extra
/// Table-stream data placed after the piece table: `(pair index, bytes)` for each.
fn text_doc_with(main: &[Piece], extra: &[Piece], spec: FibSpec, table_data: &[(usize, &[u8])]) -> Vec<u8> {
let all: Vec<Piece> = main.iter().chain(extra.iter()).cloned().collect();
let ccp_text: u32 = main.iter().map(|p| p.text.chars().count() as u32).sum();
let mut word = vec![0u8; TEXT_FC as usize];
@@ -128,7 +134,13 @@ fn text_doc(main: &[Piece], extra: &[Piece], ccp_hdd: u32) -> Vec<u8> {
clx.extend_from_slice(&plc);
let mut table = vec![0u8; CLX_AT as usize];
table.extend_from_slice(&clx);
let spec = FibSpec { ccp_text, ccp_hdd, pairs: vec![(33, CLX_AT, clx.len() as u32)], ..Default::default() };
let mut pairs = vec![(33, CLX_AT, clx.len() as u32)];
for (pair, bytes) in table_data {
pairs.push((*pair, table.len() as u32, bytes.len() as u32));
table.extend_from_slice(bytes);
}
debug_assert!(spec.pairs.is_empty(), "text_doc_with sets the pairs itself");
let spec = FibSpec { ccp_text, pairs, ..spec };
word[..fib_bytes(&spec).len()].copy_from_slice(&fib_bytes(&spec));
cfb_file(&[("WordDocument", &word), ("0Table", &table)])
}
@@ -247,6 +259,77 @@ fn empty_paragraphs_kept() {
assert_eq!(body_texts(&doc), ["a", "", "b"]);
}
/// A document of `n` copies of `mark` as one compressed piece.
fn marks_doc(mark: char, n: usize) -> Vec<u8> {
text_doc(&[Piece { compressed: true, text: mark.to_string().repeat(n) }], &[], 0)
}
/// Paragraph marks are one byte each in the file but about 1 KB each in memory, so they have
/// their own cap ([`super::MAX_PARAGRAPHS`]); a file past it is refused instead of growing
/// until the machine runs out. A document exactly at the cap still opens.
#[test]
fn a_flood_of_paragraph_marks_is_refused() {
let at_cap = super::read_limited(&marks_doc('\r', 1000), None, 1000).expect("opens");
assert_eq!(at_cap.body.len(), 1000);
let over = super::read_limited(&marks_doc('\r', 1001), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
}
/// The paragraph cap is one budget for the whole document, not one per story: marks in the
/// body and in a footnote add up. (A walk that got its own budget would open both over-cap files.)
#[test]
fn the_paragraph_cap_is_shared_by_the_body_and_a_footnote() {
// One footnote at CP 0 whose text is `note` marks, stored right after the body in the stream.
let doc_of = |body: usize, note: usize| {
let cps = |v: &[u32]| v.iter().flat_map(|c| c.to_le_bytes()).collect::<Vec<u8>>();
let mut refs = cps(&[0, 1]);
refs.extend_from_slice(&[0, 0]); // the one reference's data
let txt = cps(&[0, note as u32, note as u32]); // story 0..note, then the limit
let spec = FibSpec { ccp_ftn: note as u32, ..Default::default() };
text_doc_with(
&[Piece { compressed: true, text: "\r".repeat(body) }],
&[Piece { compressed: true, text: "\r".repeat(note) }],
spec,
&[(2, &refs), (3, &txt)],
)
};
let at_cap = super::read_limited(&doc_of(600, 400), None, 1000).expect("opens");
assert_eq!(at_cap.body.len(), 600);
assert_eq!(at_cap.parts.len(), 1);
let over = super::read_limited(&doc_of(600, 401), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
let over = super::read_limited(&doc_of(601, 400), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
}
/// Cell marks are paragraph marks too: a never-closed row of them is refused the same way.
#[test]
fn a_flood_of_cell_marks_is_refused() {
let over = super::read_limited(&marks_doc('\u{7}', 5000), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
}
/// The budget counts down across spends and notes the overrun (`walk` receives one shared by
/// every story of the document).
#[test]
fn the_paragraph_budget_counts_down_and_notes_the_overrun() {
let budget = super::ParaBudget { left: std::cell::Cell::new(2), exceeded: std::cell::Cell::new(false) };
assert!(budget.spend() && budget.spend());
assert!(!budget.exceeded.get());
assert!(!budget.spend());
assert!(budget.exceeded.get());
}
/// The cap is far above any real document (10,000 pages of 50 paragraphs is 500,000), and a
/// paragraph outside a table no longer carries a table context (a `ParaOut` was 1,224 bytes).
#[test]
fn the_paragraph_cap_leaves_room_and_a_paragraph_stays_small() {
const { assert!(super::MAX_PARAGRAPHS >= 1_000_000) };
assert!(std::mem::size_of::<crate::table::ParaOut>() < 1000, "{}", std::mem::size_of::<crate::table::ParaOut>());
let doc = read(&marks_doc('\r', 10)).expect("opens");
assert_eq!(doc.body.len(), 10);
}
#[test]
fn empty_document_gets_a_paragraph() {
let f = text_doc(&[Piece { compressed: false, text: "".into() }], &[], 0);
@@ -2660,7 +2743,8 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
assert_eq!(linked.first().map(|r| r.len), Some(n));
// One copy of the target for the one run, not one per character (~2.5 s here): counted by
// what the copies charge to the work budget, which a busy machine can't skew.
let result = vec![('y', wordcraft_doc::props::CharProps::default()); n];
let mut result = super::FieldResult::default();
(0..n).for_each(|_| result.push('y', wordcraft_doc::props::CharProps::default()));
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"http://{target}\""), sep: Some(result) };
let work = super::Work::new(u64::MAX);
super::finish_field(&mut super::ParaBuild::default(), f, &work);
@@ -2673,7 +2757,8 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
fn hyperlink_target_copies_are_charged_to_the_work_budget() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let result: Vec<(char, CharProps)> = (0..1000).map(|i| ('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() })).collect();
let mut result = super::FieldResult::default();
(0..1000).for_each(|i| result.push('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() }));
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"{}\"", "u".repeat(100)), sep: Some(result) };
let mut pb = super::ParaBuild::default();
let budget = super::Work::new(10_000);
@@ -2747,3 +2832,201 @@ fn prm_grpprls_past_the_work_budget_are_skipped() {
assert_eq!(work.left.get(), 1);
assert_eq!(work.dropped(), [super::Cost::Formatting]);
}
/// A field's cached result keeps one `CharProps` per formatting run, not one per character
/// (365 bytes each: 4 million result characters took 1.4 GB).
#[test]
fn a_field_result_stores_one_props_per_run() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut r = super::FieldResult::default();
for _ in 0..200_000 {
r.push('x', CharProps::default());
}
assert_eq!(r.runs.len(), 1);
assert_eq!(r.runs.first().map(|(n, _)| *n), Some(200_000));
r.push('\u{e9}', bold.clone());
r.push('y', bold);
r.push('z', CharProps::default());
assert_eq!(r.runs.iter().map(|(n, _)| *n).collect::<Vec<_>>(), [200_000, 3, 1], "a two-byte char counts its bytes");
assert_eq!(r.text.len(), 200_004);
}
/// A hyperlink whose result changes formatting, with multi-byte text, keeps every run's text and
/// links all of it; a plain field keeps its result text whole.
#[test]
fn field_results_keep_their_text_and_runs() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut result = super::FieldResult::default();
for (c, p) in
[('a', CharProps::default()), ('\u{e9}', CharProps::default()), ('\u{20ac}', bold.clone()), ('b', bold), ('c', CharProps::default())]
{
result.push(c, p);
}
let f = super::FieldBuild { depth: 1, instr: "HYPERLINK \"http://x\"".into(), sep: Some(result) };
let mut pb = super::ParaBuild::default();
super::finish_field(&mut pb, f, &super::Work::new(u64::MAX));
assert_eq!(pb.text, "a\u{e9}\u{20ac}bc");
assert_eq!(pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(), [3, 4, 1]);
assert!(pb.runs.iter().all(|r| r.props.link.as_deref() == Some("http://x")));
let doc = read(&text_doc(&[Piece { compressed: false, text: "\u{13} PAGE \u{14}1\u{e9}2\u{15}\r".into() }], &[], 0)).expect("opens");
let p = doc.body.first().and_then(|b| b.as_para()).expect("paragraph");
assert!(
p.objects
.iter()
.any(|o| matches!(o, wordcraft_doc::para::InlineObject::Field { instr, result, .. } if instr == "PAGE" && result == "1\u{e9}2")),
"{:?}",
p.objects
);
}
/// A one-page CHPX bin table over `page`: FCs 0..`end` live in page 1 of the returned stream.
fn one_page_bins(page: &[u8], end: u32) -> (Vec<u8>, crate::fkp::Bins) {
let mut word = vec![0u8; 512];
word.extend_from_slice(page);
let plc = [0u32.to_le_bytes().as_slice(), end.to_le_bytes().as_slice(), 1u32.to_le_bytes().as_slice()].concat();
let bins = crate::fkp::Bins::parse(&plc, 0, 12).expect("bins");
(word, bins)
}
/// The CHPX run a lookup reports covers exactly the FCs that give the same grpprl: the walk
/// reuses it for the following characters without asking again.
#[test]
fn a_chpx_run_covers_the_fcs_with_the_same_answer() {
let runs = [(0u32, grpprl(&[(0x0835, &[1])])), (5, Vec::new()), (9, grpprl(&[(0x0836, &[1])])), (30, grpprl(&[(0x0835, &[1])]))];
let (word, bins) = one_page_bins(&chpx_fkp(&runs, 60), 0x2000);
for fc in (fc_of(0) - 4)..(fc_of(70)) {
let run = bins.chpx_run(&word, fc);
assert_eq!(run.grpprl, bins.chpx(&word, fc), "fc {fc}");
for other in (fc_of(0) - 4)..(fc_of(70)) {
if run.contains(other) {
assert_eq!(bins.chpx(&word, other), run.grpprl, "fc {fc} run reused at {other}");
}
}
}
assert!(bins.chpx_run(&word, fc_of(6)).contains(fc_of(8)) && !bins.chpx_run(&word, fc_of(6)).contains(fc_of(9)));
}
/// A page whose run starts do not ascend (a corrupt file) gives the same answers as before and
/// a range that never claims an FC with a different answer.
#[test]
fn a_chpx_run_is_sound_on_a_page_whose_starts_do_not_ascend() {
let runs = [(10u32, grpprl(&[(0x0835, &[1])])), (3, grpprl(&[(0x0836, &[1])])), (20, Vec::new()), (7, grpprl(&[(0x0835, &[1])]))];
let (word, bins) = one_page_bins(&chpx_fkp(&runs, 60), 0x2000);
for fc in (fc_of(0) - 4)..(fc_of(70)) {
let run = bins.chpx_run(&word, fc);
assert_eq!(run.grpprl, bins.chpx(&word, fc));
for other in (fc_of(0) - 4)..(fc_of(70)) {
if run.contains(other) {
assert_eq!(bins.chpx(&word, other), run.grpprl, "fc {fc} run reused at {other}");
}
}
}
}
/// A piece span answers exactly what the per-CP lookup answers, in every piece (compressed
/// and UTF-16) and past the last.
#[test]
fn a_piece_span_matches_the_per_cp_lookups() {
let doc = text_doc(
&[
Piece { compressed: true, text: "abc".into() },
Piece { compressed: false, text: "d\u{e9}fgh".into() },
Piece { compressed: true, text: "ij".into() },
],
&[],
0,
);
let mut comp = crate::open_container(&doc).expect("container");
let table = crate::stream(&mut comp, "0Table").expect("table");
let pieces = crate::piece::PieceTable::parse(&table, CLX_AT, table.len() as u32 - CLX_AT).expect("pieces");
for cp in 0..14 {
let span = pieces.span(cp);
assert_eq!(span.and_then(|s| s.fc_at(cp)), pieces.fc_of_cp(cp), "cp {cp}");
for other in 0..14 {
if span.is_some_and(|s| s.contains(other)) {
assert_eq!(pieces.span(other).and_then(|s| s.fc_at(other)), pieces.fc_of_cp(other));
}
}
}
assert!(pieces.span(10).is_none(), "past the last piece");
}
/// Characters of one formatting share one `CharProps`: it is cloned when a run starts, and not
/// even compared while the formatting stays the same.
#[test]
fn shared_props_start_one_run_per_formatting() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut pb = super::ParaBuild::default();
for _ in 0..100_000 {
pb.push_shared('a', &bold, 1);
}
pb.push_shared('b', &CharProps::default(), 2);
pb.push_shared('c', &bold, 3);
pb.push_shared('d', &bold, 4);
assert_eq!(pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(), [100_000, 1, 2], "equal props under a new generation join the run");
pb.push('e', bold.clone());
pb.push_shared('f', &bold, 4);
assert_eq!(
pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(),
[100_000, 1, 4],
"a plain push forgets the generation but still joins an equal run"
);
assert_eq!(pb.text.len(), 100_005);
}
/// Several bins, equal and descending breakpoints, a missing page and the last FC: wherever a
/// run is reported as valid it gives the answer a fresh lookup gives.
#[test]
fn chpx_runs_are_sound_across_bins() {
let page_a = chpx_fkp(&[(0, grpprl(&[(0x0835, &[1])])), (8, grpprl(&[(0x0836, &[1])]))], 20);
let page_b = chpx_fkp(&[(20, Vec::new()), (26, grpprl(&[(0x0835, &[1])]))], 40);
let mut word = vec![0u8; 512];
word.extend_from_slice(&page_a);
word.extend_from_slice(&page_b);
let (a, b, end) = (fc_of(20), fc_of(40), fc_of(60));
let plc = |fcs: &[u32], pns: &[u32]| {
[fcs.iter().flat_map(|f| f.to_le_bytes()).collect::<Vec<u8>>(), pns.iter().flat_map(|p| p.to_le_bytes()).collect()].concat()
};
let tables = [
("two bins", plc(&[0, a, end], &[1, 2])),
("an empty bin", plc(&[0, a, a, end], &[1, 2, 2])),
("a missing page", plc(&[0, a, end], &[1, 9])),
("descending breakpoints", plc(&[0, b, a, end], &[1, 2, 1])),
("fcs up to the maximum", plc(&[0, a, u32::MAX], &[1, 2])),
];
for (what, t) in &tables {
let bins = crate::fkp::Bins::parse(t, 0, t.len() as u32).expect("bins");
let probes: Vec<u32> = (0..fc_of(50)).step_by(2).chain([u32::MAX - 1, u32::MAX]).collect();
for &fc in &probes {
let run = bins.chpx_run(&word, fc);
assert_eq!(run.grpprl, bins.chpx(&word, fc), "{what}: fc {fc}");
for &other in &probes {
if run.contains(other) {
assert_eq!(bins.chpx(&word, other), run.grpprl, "{what}: fc {fc} run reused at {other}");
}
}
}
}
}
/// The same formatting changes through a whole read: runs break exactly where the CHPX changes
/// (with a field and a symbol character in between), and nowhere else.
#[test]
fn formatting_runs_break_where_the_chpx_changes() {
let spec = RichSpec {
text: "aaaabbbb\u{13}PAGE\u{14}12\u{15}ccdd(ee\r",
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())],
fonts: vec!["Times New Roman", "Arial"],
chpx_runs: vec![(0, grpprl(&[(0x0835, &[1])])), (4, Vec::new()), (8, grpprl(&[(0x0835, &[1])])), (20, grpprl(&[(0x0836, &[1])]))],
papx_entries: vec![(30, 0, Vec::new())],
};
let doc = read(&rich_doc(&spec)).expect("opens");
let p = para0(&doc);
let shape: Vec<(usize, Option<bool>, Option<bool>)> = p.runs.iter().map(|r| (r.len, r.props.bold, r.props.italic)).collect();
// "aaaa" bold, "bbbb" plain plus the field's anchor (3 bytes, default formatting), "ccd" bold
// (the run from the field to CP 20), "d(ee" italic.
assert_eq!(shape, [(4, Some(true), None), (7, None, None), (3, Some(true), None), (4, None, Some(true))], "{shape:?}");
}
+1
View File
@@ -32,6 +32,7 @@ getrandom = { workspace = true, features = ["wasm_js"] }
[dev-dependencies]
wordcraft-doc = { workspace = true, features = ["test-support"] }
serde_json = { workspace = true }
proptest = { workspace = true }
image = { workspace = true }
zip = { workspace = true }
+20
View File
@@ -33,6 +33,7 @@ fn text_frame(k: &El) -> wordcraft_doc::props::TextFrame {
h_anchor: word("w:hAnchor", &["text", "margin", "page"], "page"),
v_anchor: word("w:vAnchor", &["text", "margin", "page"], "page"),
wrap: word("w:wrap", &["around", "auto", "notBeside", "none", "tight", "through"], "auto"),
anchor_lock: k.attr("w:anchorLock").map(|v| !matches!(v.trim(), "0" | "false" | "off" | "False" | "FALSE")),
h_space: len("w:hSpace").unwrap_or(0.0).max(0.0),
v_space: len("w:vSpace").unwrap_or(0.0).max(0.0),
}
@@ -333,6 +334,7 @@ impl PropCtx {
"w:widowControl" => p.widow_control = Some(on_off(k)),
"w:contextualSpacing" => p.contextual_spacing = Some(on_off(k)),
"w:mirrorIndents" => p.mirror_indents = Some(on_off(k)),
"w:adjustRightInd" => p.adjust_right_indent = Some(on_off(k)),
"w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)),
"w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)),
"w:bidi" => p.bidi = Some(on_off(k)),
@@ -763,8 +765,26 @@ pub fn sectpr(pc: &PropCtx, e: &El) -> (SectionProps, Vec<HfRef>) {
if let Some(f) = k.attr("w:fmt") {
s.page_num_format = NumFormat::from_ooxml(f);
}
s.chapter_style = k.attr("w:chapStyle").and_then(u32_of).filter(|v| *v <= u8::MAX as u32).map(|v| v as u8);
s.chapter_sep = k.attr("w:chapSep").and_then(wordcraft_doc::section::ChapterSep::from_ooxml);
s.page_num_set = s.page_num_start.is_none() && s.page_num_format == NumFormat::Decimal;
}
"w:paperSrc" => {
let tray = |name| {
let value = k.attr(name)?.trim();
let digits = value.strip_prefix('+').unwrap_or(value);
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
u32_of(digits).filter(|v| *v <= u16::MAX as u32).map(|v| v as u16)
};
s.paper_src_first = tray("w:first");
s.paper_src_other = tray("w:other");
}
"w:noEndnote" => s.no_endnote = Some(on_off(k)),
"w15:footnoteColumns" => {
s.footnote_columns = k.attr("w:val").and_then(u32_of).filter(|v| (1..=45).contains(v));
}
"w:lnNumType" => {
let mut l = LineNumbering::default();
if let Some(v) = k.attr("w:countBy").and_then(u32_of) {
+32 -5
View File
@@ -118,9 +118,33 @@ impl Reader<'_> {
Some(obj)
}
/// The hyperlink Word keeps on a drawing itself: its `wp:docPr`'s `a:hlinkClick` (ECMA-376
/// Part 1 §20.4.2.5, §21.1.2.3.5) naming an external hyperlink relationship. Word stores a
/// picture's link there rather than in a `w:hyperlink` around its run (#5267). Its `#name`
/// part, when the address has one, stays part of the address.
pub(super) fn drawing_link(d: &El, rels: &Rels) -> Option<String> {
let frame = d.child("wp:inline").or_else(|| d.child("wp:anchor"))?;
let id = frame.child("wp:docPr")?.child("a:hlinkClick")?.attr("r:id")?;
let rel = rels.by_id(id).filter(|r| r.external && crate::package::rel_is(&r.kind, rt::HYPERLINK))?;
Some(rel.target.trim().to_string()).filter(|u| !u.is_empty())
}
/// Native ink or an Office 2016 chart kept with its fallback (`mc:AlternateContent`), and the
/// [`Self::drawing_link`] of the choice it was read from, else of its fallback: another
/// choice's link is not this object's.
pub(super) fn read_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<(InlineObject, Option<String>)> {
let (object, choice) = match self.read_ink_alternate(sc, ac, rels) {
Some(o) => (o, children_of_choice(ac).filter(|e| e.name == "mc:Choice")),
None => (self.read_chartex_alternate(sc, ac, rels)?, chartex_choice(ac)),
};
let link =
[choice, ac.child("mc:Fallback")].into_iter().flatten().filter_map(|e| e.child("w:drawing")).find_map(|d| Self::drawing_link(d, rels));
Some((object, link))
}
/// Keep the selected native-ink branch and its picture fallback as one object. Reading only
/// the fallback flattens the ink; reading only the choice loses its preview on the next save.
pub(super) fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
let choice = children_of_choice(ac).filter(|e| e.name == "mc:Choice")?;
let drawing = choice.child("w:drawing")?;
if drawing.find("a:graphicData")?.attr("uri") != Some(INK_URI) {
@@ -149,10 +173,8 @@ impl Reader<'_> {
/// An Office 2016 chart (`cx:chart`: waterfall, sunburst, box & whisker…) drawn from its
/// fallback picture, keeping both branches so saving writes the chart back rather than only
/// the picture. `None` (read the fallback alone) when it can't be kept whole.
pub(super) fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
let choice = ac
.children("mc:Choice")
.find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))?;
fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
let choice = chartex_choice(ac)?;
// A requirement in a namespace we can't name couldn't be written back.
let requires = choice.attr("Requires").unwrap_or("");
if requires.split_whitespace().next().is_none() || requires.split_whitespace().any(|p| p == "?") {
@@ -563,6 +585,11 @@ impl Reader<'_> {
}
}
/// The `mc:Choice` holding an Office 2016 chart (`cx:chart`) in an `mc:AlternateContent`.
fn chartex_choice(ac: &El) -> Option<&El> {
ac.children("mc:Choice").find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))
}
/// The alternative text of a `wp:docPr`, `pic:cNvPr` or `wps:cNvPr` (ECMA-376
/// `CT_NonVisualDrawingProps`): the description (the title when there is none, as before), and
/// the title and decorative mark (the `adec:decorative` extension of [MS-ODRAWXML]).
+13 -5
View File
@@ -98,9 +98,9 @@ impl Reader<'_> {
}
}
"mc:AlternateContent" => {
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
if let Some((o, link)) = self.read_alternate(sc, k, rels) {
let props = self.run_props_none(ctx);
self.emit_obj(sc, pb, o, &props);
self.emit_linked_obj(sc, pb, o, &props, link);
} else if let Some(c) = children_of_choice(k) {
self.read_inline_children(sc, pb, c, rels, ctx, depth + 1);
}
@@ -237,7 +237,7 @@ impl Reader<'_> {
},
"w:drawing" => {
if let Some(o) = self.read_drawing(sc, k, rels) {
self.emit_obj(sc, pb, o, props);
self.emit_linked_obj(sc, pb, o, props, Self::drawing_link(k, rels));
}
}
"w:pict" => {
@@ -298,8 +298,8 @@ impl Reader<'_> {
),
"w:ruby" => self.read_ruby(sc, pb, k, props),
"mc:AlternateContent" => {
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
self.emit_obj(sc, pb, o, props);
if let Some((o, link)) = self.read_alternate(sc, k, rels) {
self.emit_linked_obj(sc, pb, o, props, link);
} else if let Some(c) = children_of_choice(k) {
for x in c.els() {
self.read_run_child(sc, pb, x, (props, rels), note, depth + 1);
@@ -376,6 +376,14 @@ impl Reader<'_> {
}
}
/// [`Self::emit_obj`] for a drawing that carries its own `link`: a link around the run wins.
fn emit_linked_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps, link: Option<String>) {
match link.filter(|_| props.link.is_none()) {
Some(link) => self.emit_obj(sc, pb, o, &CharProps { link: Some(link), ..props.clone() }),
None => self.emit_obj(sc, pb, o, props),
}
}
pub(super) fn emit_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps) {
match sink(sc) {
Some(f) => {
+6 -2
View File
@@ -1,7 +1,8 @@
//! [`wordcraft_doc::math`] tree → Office Math (OMML).
use wordcraft_doc::math::{
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, parse_linear,
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MAX_MATRIX_COLS, MAX_MATRIX_ROWS, MNode, MRun, MScr, MSty, Math, MathJc, MathProps,
ScriptKind, parse_linear,
};
use wordcraft_doc::props::Rgb;
@@ -181,7 +182,10 @@ fn char_prop(w: &mut W, name: &str, c: Option<char>) {
fn write_matrix(w: &mut W, n: &MNode, depth: usize) {
let MNode::Matrix { rows, col_jc } = n else { return };
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1);
// Every row is padded to the widest one, so a ragged matrix is clamped to the reader's
// caps rather than written as rows × widest cells (#3643).
let rows = rows.get(..rows.len().min(MAX_MATRIX_ROWS)).unwrap_or_default();
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).clamp(1, MAX_MATRIX_COLS);
w.open("m:m", &[]);
w.open("m:mPr", &[]);
w.open("m:mcs", &[]);
+4
View File
@@ -326,6 +326,9 @@ fn write_frame(w: &mut W, p: &ParaProps, framed: bool) {
a.push(("w:vSpace", &vs));
}
a.push(("w:wrap", &f.wrap));
if let Some(anchor_lock) = f.anchor_lock {
a.push(("w:anchorLock", if anchor_lock { "1" } else { "0" }));
}
a.push(("w:vAnchor", &f.v_anchor));
a.push(("w:hAnchor", &f.h_anchor));
match &f.x_align {
@@ -373,6 +376,7 @@ fn write_text_flow(w: &mut W, p: &ParaProps) {
toggle(w, "w:autoSpaceDE", p.auto_space_de);
toggle(w, "w:autoSpaceDN", p.auto_space_dn);
toggle(w, "w:bidi", p.bidi);
toggle(w, "w:adjustRightInd", p.adjust_right_indent);
toggle(w, "w:snapToGrid", p.snap_to_grid);
}
+28 -12
View File
@@ -10,9 +10,19 @@ pub(super) struct DrawingInfo<'a> {
pub docpr: &'a str,
pub name: &'a str,
pub alt: &'a str,
/// The relationship id of the drawing's hyperlink (`a:hlinkClick`), see [`Writer::drawing_link`].
pub link: Option<&'a str>,
}
impl Writer<'_> {
/// The relationship of the hyperlink a drawing in a run with `props` carries on itself, as
/// Word writes a picture's link (`wp:docPr/a:hlinkClick`, #5267): a web or file address. A
/// link to a place in the document (`#name`) is only the `w:hyperlink` around the run.
pub(super) fn drawing_link(props: &wordcraft_doc::CharProps, rels: &mut PartRels) -> Option<String> {
let url = props.link.as_deref().filter(|l| !l.is_empty() && !l.starts_with('#'))?;
Some(rels.add(rt::HYPERLINK, url, true))
}
/// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0).
pub(super) fn pic(&mut self, w: &mut W, o: &InlineObject, file: &str, off: (f32, f32), rels: &mut PartRels) {
let InlineObject::Image { media, w: iw, h: ih, alt, crop, float, fx, .. } = o else { return };
@@ -20,7 +30,7 @@ impl Writer<'_> {
self.used_media.insert(media.clone());
w.open("pic:pic", &[]);
w.open("pic:nvPicPr", &[]);
nv_props(w, "pic:cNvPr", "0", file, alt, &float.alt, float.hidden);
nv_props(w, "pic:cNvPr", ("0", file), alt, &float.alt, float.hidden, None);
w.open("pic:cNvPicPr", &[]);
w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]);
w.close("pic:cNvPicPr");
@@ -66,7 +76,7 @@ impl Writer<'_> {
w.open("wps:wsp", &[]);
if let Some(id) = id {
let name = shape_name(*kind, freeform.as_deref(), id);
nv_props(w, "wps:cNvPr", id, &name, &float.alt.text, &float.alt, float.hidden);
nv_props(w, "wps:cNvPr", (id, &name), &float.alt.text, &float.alt, float.hidden, None);
}
if kind.is_connector() {
// A connector's glued ends (ECMA-376 §20.1.2.2.8 `cNvCxnSpPr`).
@@ -235,7 +245,7 @@ impl Writer<'_> {
}
pub(super) fn drawing_open(&mut self, w: &mut W, info: DrawingInfo<'_>) {
let DrawingInfo { float, size: (iw, ih), docpr, name, alt } = info;
let DrawingInfo { float, size: (iw, ih), docpr, name, alt, link } = info;
let (cx, cy) = (emu(iw.max(0.0)), emu(ih.max(0.0)));
if float.wrap == Wrap::Inline {
w.open("wp:inline", &[("distT", "0"), ("distB", "0"), ("distL", "0"), ("distR", "0")]);
@@ -316,7 +326,7 @@ impl Writer<'_> {
Wrap::TopAndBottom => w.empty("wp:wrapTopAndBottom", &[]),
Wrap::BehindText | Wrap::InFrontOfText => w.empty("wp:wrapNone", &[]),
}
nv_props(w, "wp:docPr", docpr, name, alt, &float.alt, float.hidden);
nv_props(w, "wp:docPr", (docpr, name), alt, &float.alt, float.hidden, link);
}
}
@@ -502,8 +512,9 @@ pub(in super::super) fn spin_attrs(spin: wordcraft_geom::Spin) -> Vec<(&'static
/// A drawing's `wp:docPr` (or a group member's `pic:cNvPr`/`wps:cNvPr`, ECMA-376
/// `CT_NonVisualDrawingProps`) with its alt text `descr`, its title, and the decorative mark as
/// the `adec:decorative` extension ([MS-ODRAWXML]). A description that is only the title (read
/// from a file with no description) is not written twice.
pub(in super::super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, alt: &AltText, hidden: bool) {
/// from a file with no description) is not written twice. `link` is the relationship id of the
/// drawing's hyperlink (`a:hlinkClick`, the first child the schema allows).
pub(in super::super) fn nv_props(w: &mut W, tag: &str, (id, name): (&str, &str), descr: &str, alt: &AltText, hidden: bool, link: Option<&str>) {
let mut attrs = vec![("id", id), ("name", name)];
if descr != alt.title || descr.is_empty() {
attrs.push(("descr", descr));
@@ -514,16 +525,21 @@ pub(in super::super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, des
if hidden {
attrs.push(("hidden", "1"));
}
if !alt.decorative {
if !alt.decorative && link.is_none() {
w.empty(tag, &attrs);
return;
}
w.open(tag, &attrs);
w.open("a:extLst", &[]);
w.open("a:ext", &[("uri", crate::xml::DECORATIVE_EXT)]);
w.empty("adec:decorative", &[("xmlns:adec", crate::xml::DECORATIVE_NS), ("val", "1")]);
w.close("a:ext");
w.close("a:extLst");
if let Some(rid) = link {
w.empty("a:hlinkClick", &[("r:id", rid)]);
}
if alt.decorative {
w.open("a:extLst", &[]);
w.open("a:ext", &[("uri", crate::xml::DECORATIVE_EXT)]);
w.empty("adec:decorative", &[("xmlns:adec", crate::xml::DECORATIVE_NS), ("val", "1")]);
w.close("a:ext");
w.close("a:extLst");
}
w.close(tag);
}
+17 -5
View File
@@ -220,13 +220,18 @@ impl Writer<'_> {
w.open("mc:Choice", &[("Requires", requires)]);
}
let docpr = self.next_docpr();
let link = Self::drawing_link(props, rels);
let name = match graphic.kind {
wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"),
wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"),
wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"),
};
let z = self.z;
self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, native.unwrap_or(&root));
self.graphic_frame(
w,
DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt, link: link.as_deref() },
native.unwrap_or(&root),
);
if let Some(fallback) = fallback {
w.close("mc:Choice");
match picture {
@@ -234,7 +239,11 @@ impl Writer<'_> {
// Both branches are one object: same id and stacking order.
self.z = z;
w.open("mc:Fallback", &[]);
self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, picture);
self.graphic_frame(
w,
DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt, link: link.as_deref() },
picture,
);
w.close("mc:Fallback");
}
None => w.raw(&fallback.to_xml()),
@@ -266,8 +275,9 @@ impl Writer<'_> {
self.rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let link = Self::drawing_link(props, rels);
let name = format!("Picture {docpr}");
self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt });
self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt, link: link.as_deref() });
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]);
@@ -355,6 +365,7 @@ impl Writer<'_> {
self.rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.shape_docpr(extra.id);
let link = Self::drawing_link(props, rels);
// Ink is told apart by its name (and its pen), which reading looks for.
let name = shape_name(*kind, freeform.as_deref(), &docpr);
// The effect extent leaves room for the shadow and glow.
@@ -369,7 +380,7 @@ impl Writer<'_> {
.map(std::sync::Arc::new);
}
let float = &float;
self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text() });
self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text(), link: link.as_deref() });
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]);
@@ -389,8 +400,9 @@ impl Writer<'_> {
self.rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let link = Self::drawing_link(props, rels);
let name = format!("{} {docpr}", if canvas.is_some() { "Canvas" } else { "Group" });
self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text() });
self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text(), link: link.as_deref() });
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
let outer = match canvas {
+45 -8
View File
@@ -87,6 +87,7 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
("w:gutter", &twips(s.gutter.max(0.0))),
],
);
paper_source(w, s);
if let Some(b) = &s.page_borders {
borders(w, "w:pgBorders", b, None, &[("w:offsetFrom", "text")]);
}
@@ -107,14 +108,7 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:lnNumType", &refs);
}
if s.page_num_set || s.page_num_start.is_some() || s.page_num_format != wordcraft_doc::section::NumFormat::Decimal {
let mut a = vec![("w:fmt", s.page_num_format.ooxml().to_string())];
if let Some(st) = s.page_num_start {
a.push(("w:start", st.to_string()));
}
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:pgNumType", &refs);
}
page_number_type(w, s);
let c = &s.columns;
let count = c.count.clamp(1, 45);
let mut ca = vec![("w:space", twips(c.space.max(0.0))), ("w:num", n(count as i64))];
@@ -134,6 +128,9 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
let refs: Vec<(&str, &str)> = ca.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:cols", &refs);
}
if let Some(count) = s.footnote_columns.filter(|count| *count > 0) {
w.empty("w15:footnoteColumns", &[("w:val", &count.clamp(1, 45).to_string())]);
}
if let Some(on) = s.form_prot {
w.empty("w:formProt", if on { &[] } else { &[("w:val", "0")] });
}
@@ -143,6 +140,9 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
wordcraft_doc::section::PageVAlign::Justify => w.val("w:vAlign", "both"),
wordcraft_doc::section::PageVAlign::Bottom => w.val("w:vAlign", "bottom"),
}
if let Some(on) = s.no_endnote {
w.empty("w:noEndnote", if on { &[] } else { &[("w:val", "0")] });
}
if s.title_page {
w.empty("w:titlePg", &[]);
}
@@ -167,6 +167,43 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
}
}
fn paper_source(w: &mut W, s: &SectionProps) {
let mut attrs = Vec::new();
if let Some(first) = s.paper_src_first {
attrs.push(("w:first", first.to_string()));
}
if let Some(other) = s.paper_src_other {
attrs.push(("w:other", other.to_string()));
}
if !attrs.is_empty() {
let refs: Vec<(&str, &str)> = attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:paperSrc", &refs);
}
}
fn page_number_type(w: &mut W, s: &SectionProps) {
if !(s.page_num_set
|| s.page_num_start.is_some()
|| s.page_num_format != wordcraft_doc::section::NumFormat::Decimal
|| s.chapter_style.is_some()
|| s.chapter_sep.is_some())
{
return;
}
let mut attrs = vec![("w:fmt", s.page_num_format.ooxml().to_string())];
if let Some(start) = s.page_num_start {
attrs.push(("w:start", start.to_string()));
}
if let Some(style) = s.chapter_style {
attrs.push(("w:chapStyle", style.to_string()));
}
if let Some(separator) = s.chapter_sep {
attrs.push(("w:chapSep", separator.ooxml().to_string()));
}
let refs: Vec<(&str, &str)> = attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:pgNumType", &refs);
}
/// The children of a `w:footnotePr` / `w:endnotePr` in schema order (`w:pos`, `w:numFmt`,
/// `w:numStart`, `w:numRestart`; ECMA-376 §17.11.11), for the fields that are set.
pub(in super::super) fn note_pr_inner(w: &mut W, pr: &NoteProps) {
+83 -4
View File
@@ -94,6 +94,49 @@ fn prefix_for(uri: &[u8], extra: &[(&'static str, &str)]) -> &'static str {
"?"
}
/// Word's 2006 namespace (`wne`). A Strict document from Word keeps a text box's content in
/// `wne:txbxContent`; a Transitional one uses `w:txbxContent`.
const WNE: &[u8] = b"http://schemas.microsoft.com/office/word/2006/wordml";
/// The canonical prefix of an element named `local` in namespace `uri`, inside an element
/// named `parent` (canonical, `None` at the root). This is [`prefix_for`] with two exceptions
/// for Strict documents (#3548):
///
/// - Shapes, groups and canvases (`wsp`, `wgp`, `wpc`) and their parts are elements of the
/// WordprocessingDrawing namespace in ECMA-376 Part 1, 5th edition (`dml-wordprocessingDrawing.xsd`,
/// Strict and Transitional alike). A Strict document from Word puts them there; a Transitional
/// one uses Word 2010's own namespaces, whose names the reader knows. So they read as `wps:`,
/// `wpg:` and `wpc:` (see [`drawing_prefix`]).
/// - A shape's text box content reads as `w:txbxContent` from the drawing namespace (the
/// schema's name) and from Word's `wne:txbxContent`.
fn element_prefix(uri: &[u8], local: &str, parent: Option<&str>, extra: &[(&'static str, &str)]) -> &'static str {
match prefix_for(uri, extra) {
"wp" => drawing_prefix(local, parent),
"?" if uri == WNE && local == "txbxContent" && parent == Some("wps:txbx") => "w",
p => p,
}
}
/// The prefix for `local`, an element of the WordprocessingDrawing namespace inside `parent`.
/// Word 2010's namespaces split what this one holds: a shape and its parts are `wps:`, a group
/// (`wgp`, a nested `grpSp`) and a graphic frame in it are `wpg:`, and a canvas is `wpc:`. Its
/// members are named for what they are. A text box's content is `w:txbxContent`, and anything
/// else (`inline`, `anchor`, `docPr`…) stays `wp:`.
fn drawing_prefix(local: &str, parent: Option<&str>) -> &'static str {
match (parent, local) {
(_, "wsp") => "wps",
(_, "wgp" | "grpSp" | "graphicFrame") => "wpg",
(_, "wpc") => "wpc",
(Some("wps:wsp"), "cNvPr" | "cNvSpPr" | "cNvCnPr" | "spPr" | "style" | "extLst" | "txbx" | "linkedTxbx" | "bodyPr") => "wps",
(Some("wps:txbx"), "txbxContent") => "w",
(Some("wps:txbx" | "wps:linkedTxbx"), "extLst") => "wps",
(Some("wpg:wgp" | "wpg:grpSp"), "cNvPr" | "cNvGrpSpPr" | "grpSpPr" | "extLst") => "wpg",
(Some("wpg:graphicFrame"), "cNvPr" | "cNvFrPr" | "xfrm" | "extLst") => "wpg",
(Some("wpc:wpc"), "bg" | "whole" | "extLst") => "wpc",
_ => "wp",
}
}
/// Compatibility feature namespaces whose elements are not otherwise handled by this tree.
/// Keep the reader's existing feature set when canonicalizing `mc:Choice/@Requires` aliases.
fn requirement_prefix(uri: &[u8]) -> &'static str {
@@ -342,8 +385,8 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
loop {
buf.clear();
let (res, ev) = r.read_resolved_event_into(&mut buf).map_err(|e| DocxError::Xml(e.to_string()))?;
let el_prefix = match &res {
ResolveResult::Bound(ns) => Some(prefix_for(ns.as_ref(), extra)),
let el_ns = match &res {
ResolveResult::Bound(ns) => Some(ns.as_ref()),
_ => None,
};
match ev {
@@ -356,8 +399,8 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
return Err(DocxError::Limit("too many XML elements".into()));
}
let local = String::from_utf8_lossy(e.local_name().as_ref()).into_owned();
let name = match el_prefix {
Some(p) => format!("{p}:{local}"),
let name = match el_ns {
Some(ns) => format!("{}:{local}", element_prefix(ns, &local, stack.last().map(|p| p.name.as_str()), extra)),
None => local,
};
let mut el = El { name, attrs: Vec::new(), kids: Vec::new() };
@@ -593,6 +636,42 @@ mod tests {
assert!(parse(b"<a></a><b></b>").is_ok(), "lenient parsing still stops at the root");
}
/// Every element name in `e`, depth first.
fn names(e: &El, out: &mut Vec<String>) {
out.push(e.name.clone());
for c in e.els() {
names(c, out);
}
}
#[test]
fn strict_drawing_names_read_as_word_2010_names() {
// A Strict shape with a text box, a group with a nested group and a graphic frame, and
// a canvas, each in the one Strict WordprocessingDrawing namespace (#3548).
let xml = br#"<w:body xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main" xmlns:a="http://purl.oclc.org/ooxml/drawingml/main" xmlns:d="http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml">
<d:anchor><d:extent/><d:docPr/><d:cNvGraphicFramePr/><a:graphic><a:graphicData>
<d:wsp><d:cNvPr/><d:cNvSpPr/><d:spPr/><d:style/><d:extLst/><d:txbx><wne:txbxContent><w:p/></wne:txbxContent></d:txbx><d:bodyPr/></d:wsp>
<d:wgp><d:cNvPr/><d:cNvGrpSpPr/><d:grpSpPr/><d:grpSp><d:cNvGrpSpPr/><d:grpSpPr/><d:wsp><d:cNvCnPr/><d:spPr/><d:linkedTxbx/><d:bodyPr/></d:wsp></d:grpSp>
<d:graphicFrame><d:cNvPr/><d:cNvFrPr/><d:xfrm/><a:graphic/><d:extLst/></d:graphicFrame><d:extLst/></d:wgp>
<d:wpc><d:bg/><d:whole/><d:wsp><d:spPr/><d:txbx><d:txbxContent/><d:extLst/></d:txbx></d:wsp><d:wgp><d:grpSpPr/></d:wgp><d:extLst/></d:wpc>
</a:graphicData></a:graphic></d:anchor>
<wne:txbxContent/><d:inline><d:docPr/><d:bodyPr/><d:bg/></d:inline></w:body>"#;
let mut got = Vec::new();
names(&parse(xml).unwrap(), &mut got);
let want = "w:body wp:anchor wp:extent wp:docPr wp:cNvGraphicFramePr a:graphic a:graphicData \
wps:wsp wps:cNvPr wps:cNvSpPr wps:spPr wps:style wps:extLst wps:txbx w:txbxContent w:p wps:bodyPr \
wpg:wgp wpg:cNvPr wpg:cNvGrpSpPr wpg:grpSpPr wpg:grpSp wpg:cNvGrpSpPr wpg:grpSpPr wps:wsp wps:cNvCnPr wps:spPr wps:linkedTxbx wps:bodyPr \
wpg:graphicFrame wpg:cNvPr wpg:cNvFrPr wpg:xfrm a:graphic wpg:extLst wpg:extLst \
wpc:wpc wpc:bg wpc:whole wps:wsp wps:spPr wps:txbx w:txbxContent wps:extLst wpg:wgp wpg:grpSpPr wpc:extLst \
?:txbxContent wp:inline wp:docPr wp:bodyPr wp:bg";
assert_eq!(got.join(" "), want);
// The Transitional namespaces read as before.
let xml = br#"<w:body xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"><wp:inline><wp:docPr/></wp:inline><wps:wsp><wps:txbx><w:txbxContent/></wps:txbx></wps:wsp><wne:toolbars/></w:body>"#;
let mut got = Vec::new();
names(&parse(xml).unwrap(), &mut got);
assert_eq!(got.join(" "), "w:body wp:inline wp:docPr wps:wsp wps:txbx w:txbxContent ?:toolbars");
}
#[test]
fn a_relationship_attribute_is_the_namespaced_one_or_the_alias_of_one() {
// The relationship namespace, and VML's `o:relid`.
+29
View File
@@ -80,3 +80,32 @@ fn automatic_spacing_reads_and_round_trips() {
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(props(&again)[..3], p[..3]);
}
#[test]
fn adjust_right_indent_is_preserved() {
let body = r#"<w:p><w:pPr><w:adjustRightInd w:val="0"/><w:snapToGrid w:val="0"/><w:spacing w:after="120"/><w:ind w:right="120"/></w:pPr><w:r><w:t>false</w:t></w:r></w:p>
<w:p><w:pPr><w:adjustRightInd w:val="1"/></w:pPr><w:r><w:t>true</w:t></w:r></w:p>
<w:p><w:r><w:t>absent</w:t></w:r></w:p>"#;
let mut d = wordcraft_docx::read(&docx(body)).unwrap();
assert_eq!(props(&d).iter().map(|p| p.adjust_right_indent).collect::<Vec<_>>(), [Some(false), Some(true), None]);
for _ in 0..2 {
let out = wordcraft_docx::write(&d).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(out.clone())).unwrap();
let mut xml = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
assert!(xml.contains(r#"<w:adjustRightInd w:val="0""#), "explicit false was lost: {xml}");
assert!(xml.contains("<w:adjustRightInd/>"), "explicit true was lost: {xml}");
let tags = ["<w:adjustRightInd", "<w:snapToGrid", "<w:spacing", "<w:ind"];
let pprs = xml.split("<w:pPr>").skip(1).filter_map(|rest| rest.split("</w:pPr>").next()).collect::<Vec<_>>();
let first = pprs.first().expect("paragraph properties must be present");
for &tag in &tags {
assert!(first.contains(tag), "{tag} missing: {xml}");
}
for ppr in pprs {
let order = tags.iter().filter_map(|tag| ppr.find(*tag)).collect::<Vec<_>>();
assert!(order.windows(2).all(|pair| pair[0] < pair[1]), "paragraph properties are out of schema order: {xml}");
}
d = wordcraft_docx::read(&out).unwrap();
assert_eq!(props(&d).iter().map(|p| p.adjust_right_indent).collect::<Vec<_>>(), [Some(false), Some(true), None]);
}
}
+102 -3
View File
@@ -247,6 +247,46 @@ fn frame_zero_side_is_automatic() {
}
}
#[test]
fn locked_frame_anchor_survives_two_saves() {
for (lock, expected) in [("", None), (" w:anchorLock=\"1\"", Some(true)), (" w:anchorLock=\"0\"", Some(false))] {
let mut document = read_body(&format!(r#"<w:p><w:pPr><w:framePr w:w="1440" w:h="720"{lock}/></w:pPr><w:r><w:t>Locked</w:t></w:r></w:p>"#));
let paragraph = paras(&document)[0];
assert_eq!(paragraph.props.frame.as_ref().and_then(|f| f.anchor_lock), expected);
if expected == Some(true) {
let Some(wordcraft_doc::Block::Para(paragraph)) = document.body.get_mut(0).map(std::sync::Arc::make_mut) else {
panic!("first block is the framed paragraph");
};
let frame = paragraph.props.frame.as_mut().unwrap();
frame.x = 36.0;
frame.y = 72.0;
frame.w = Some(108.0);
}
let json = serde_json::to_vec(&document).unwrap();
document = serde_json::from_slice(&json).unwrap();
if expected == Some(true) {
let frame = paras(&document)[0].props.frame.as_ref().unwrap();
assert_eq!((frame.x, frame.y, frame.w), (36.0, 72.0, Some(108.0)));
}
for pass in 0..2 {
let bytes = wordcraft_docx::write(&document).unwrap();
let xml = text(&unzip(&bytes), "word/document.xml");
let expected_xml = match expected {
Some(true) => "w:anchorLock=\"1\"",
Some(false) => "w:anchorLock=\"0\"",
None => "w:anchorLock=",
};
assert_eq!(xml.contains(expected_xml), expected.is_some(), "lock changed on save {}: {xml}", pass + 1);
document = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(paras(&document)[0].props.frame.as_ref().and_then(|f| f.anchor_lock), expected);
if expected == Some(true) {
let frame = paras(&document)[0].props.frame.as_ref().unwrap();
assert_eq!((frame.x, frame.y, frame.w), (36.0, 72.0, Some(108.0)));
}
}
}
}
#[test]
fn omitted_paragraph_defaults_can_be_edited() {
let styles =
@@ -1726,12 +1766,27 @@ const CT_CHARTEX: &str = "application/vnd.ms-office.chartex+xml";
/// A package holding one box & whisker chart the way Word writes it: the `cx:chart` in an
/// `mc:Choice` requiring `requires` (bound to `requires_ns`), a picture of it in the `mc:Fallback`.
fn chartex_docx(requires: &str, requires_ns: &str) -> Vec<u8> {
let frame = r#"<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="Chart 1"/><wp:cNvGraphicFramePr/>"#;
chartex_docx_linked(requires, requires_ns, false)
}
/// [`chartex_docx`], its drawing linked to a web address (`wp:docPr/a:hlinkClick`) when `link`,
/// after a first choice linked elsewhere, which needs a namespace no reader knows, so a reader
/// takes the chart's choice.
fn chartex_docx_linked(requires: &str, requires_ns: &str, link: bool) -> Vec<u8> {
let click = if link { r#"<a:hlinkClick r:id="rIdWeb"/>"# } else { "" };
let other = if link {
r#"<mc:Choice xmlns:w99="urn:example:unsupported" Requires="w99"><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="254000" cy="254000"/><wp:docPr id="9" name="Other"><a:hlinkClick r:id="rIdOther"/></wp:docPr><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:spPr><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice>"#
} else {
""
};
let frame = format!(
r#"<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="Chart 1">{click}</wp:docPr><wp:cNvGraphicFramePr/>"#
);
let body = format!(
r#"<w:p><w:r><w:t>Spread</w:t></w:r><w:r><mc:AlternateContent><mc:Choice xmlns:{requires}="{requires_ns}" Requires="{requires}"><w:drawing>{frame}<a:graphic><a:graphicData uri="{CHARTEX_NS}"><cx:chart xmlns:cx="{CHARTEX_NS}" r:id="rIdCx"/></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice><mc:Fallback><w:drawing>{frame}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="Chart 1"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2743200" cy="1371600"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Fallback></mc:AlternateContent></w:r></w:p>"#
r#"<w:p><w:r><w:t>Spread</w:t></w:r><w:r><mc:AlternateContent>{other}<mc:Choice xmlns:{requires}="{requires_ns}" Requires="{requires}"><w:drawing>{frame}<a:graphic><a:graphicData uri="{CHARTEX_NS}"><cx:chart xmlns:cx="{CHARTEX_NS}" r:id="rIdCx"/></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice><mc:Fallback><w:drawing>{frame}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="Chart 1"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2743200" cy="1371600"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Fallback></mc:AlternateContent></w:r></w:p>"#
);
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdCx" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="charts/chartEx1.xml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/></Relationships>"#;
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdCx" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="charts/chartEx1.xml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/><Relationship Id="rIdWeb" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/chart" TargetMode="External"/><Relationship Id="rIdOther" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/other" TargetMode="External"/></Relationships>"#;
let chart_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="../embeddings/Microsoft_Excel_Worksheet.xlsx"/><Relationship Id="rId2" Type="http://schemas.microsoft.com/office/2011/relationships/chartColorStyle" Target="colors1.xml"/></Relationships>"#;
let types = format!(
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="xlsx" ContentType="{CT_XLSX}"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/charts/chartEx1.xml" ContentType="{CT_CHARTEX}"/><Override PartName="/word/charts/colors1.xml" ContentType="application/vnd.ms-office.chartcolorstyle+xml"/></Types>"#
@@ -1787,6 +1842,25 @@ fn office_2016_chart_keeps_its_chart_part_and_fallback_through_saves() {
}
}
/// #5267: a link on an Office 2016 chart's drawing is the chart's link (not an earlier choice's),
/// and saving keeps it on both branches (the writer rebuilds their frames).
#[test]
fn a_link_on_an_office_2016_charts_drawing_is_kept() {
let mut d = wordcraft_docx::read(&chartex_docx_linked("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex", true)).unwrap();
for _ in 0..2 {
let p = d.body[0].as_para().unwrap();
let off = p.object_offsets().into_iter().find(|o| matches!(p.object_at(*o), Some(InlineObject::Graphic { .. }))).unwrap();
assert_eq!(p.props_of_char(off).link.as_deref(), Some("https://example.com/chart"));
let files = unzip(&wordcraft_docx::write(&d).unwrap());
let body = text(&files, "word/document.xml");
let ids = attr_values(&body, "a:hlinkClick", "r:id");
assert_eq!(ids.len(), 2, "on the chart and its fallback picture: {body}");
assert_eq!(rel(&text(&files, "word/_rels/document.xml.rels"), &ids[0]).1, "https://example.com/chart");
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
d = wordcraft_docx::read(&zip(&entries)).unwrap();
}
}
#[test]
fn resized_office_2016_chart_resizes_its_fallback_too() {
let mut d = wordcraft_docx::read(&chartex_docx("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex")).unwrap();
@@ -2422,6 +2496,31 @@ fn east_asian_number_formats_read_label_and_round_trip() {
assert_eq!(formats(&back), want);
}
/// #4985: a level with numFmt none and literal text (`RESPONSE:`) labels its paragraph with that
/// text, as in Word, and keeps both through a save.
#[test]
fn a_none_level_s_literal_text_is_its_label() {
use wordcraft_doc::numbering::Counters;
use wordcraft_doc::section::NumFormat;
let numbering = format!(
r#"<w:numbering {W_NS}><w:abstractNum w:abstractNumId="0"><w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="none"/><w:lvlText w:val="RESPONSE:"/><w:pPr><w:ind w:left="1440" w:hanging="1440"/></w:pPr></w:lvl></w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num></w:numbering>"#
);
let body = r#"<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>X</w:t></w:r></w:p>"#;
let bytes = docx(body, &[("rId1", "numbering", "numbering.xml")], &[("word/numbering.xml", &numbering)]);
let label = |d: &Document| {
let n = paras(d).first().and_then(|p| p.props.numbering)?;
Counters::default().next_label(&d.numbering, n.num, n.level).map(|l| l.0)
};
let d = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(label(&d).as_deref(), Some("RESPONSE:"));
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(
back.numbering.level(1, 0).map(|l| (l.format, l.text.as_str(), l.indent, l.hanging)),
Some((NumFormat::None, "RESPONSE:", 72.0, 72.0))
);
assert_eq!(label(&back).as_deref(), Some("RESPONSE:"));
}
/// #464: a bookmark whose end follows the document's final table keeps its end (it goes into
/// the table's last cell) and is saved as a matched pair.
#[test]
+3
View File
@@ -23,16 +23,19 @@ mod ligatures;
mod malformed;
mod note_comments;
mod passthrough;
mod picture_links;
mod positional_tabs;
mod privacy;
mod roundtrip;
mod row_property_exceptions;
mod run_props;
mod section_form_protection;
mod section_metadata_preservation;
mod section_page_number_type;
mod section_text_direction;
mod section_valign;
mod settings_merge;
mod strict_drawings;
mod text_alignment;
mod text_boxes;
mod toc_ranges;
+127
View File
@@ -0,0 +1,127 @@
//! A picture's hyperlink (#5267): Word keeps it on the drawing (`wp:docPr/a:hlinkClick`,
//! ECMA-376 Part 1 §20.4.2.5) rather than in a `w:hyperlink` around the run. Original
//! synthetic packages; the 2×2 picture is generated in code.
use std::io::{Read, Write};
use std::sync::Arc;
use wordcraft_doc::{CharProps, Document, InlineObject, Pos};
fn png() -> Vec<u8> {
let mut out = Vec::new();
image::RgbaImage::from_pixel(2, 2, image::Rgba([20, 90, 200, 255]))
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
out
}
/// An inline picture's run; `link` goes into its `wp:docPr`.
fn picture_run(id: u32, link: &str) -> String {
format!(
r#"<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="254000" cy="254000"/><wp:docPr id="{id}" name="Picture {id}">{link}</wp:docPr><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="square.png"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
}
fn package(body: &str) -> Vec<u8> {
let xml = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><w:body>{body}<w:sectPr/></w:body></w:document>"#
);
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/square.png"/><Relationship Id="rIdWeb" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/picture" TargetMode="External"/><Relationship Id="rIdRun" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/run" TargetMode="External"/></Relationships>"#;
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
let png = png();
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [
("[Content_Types].xml", types.as_bytes()),
("_rels/.rels", rels.as_bytes()),
("word/document.xml", xml.as_bytes()),
("word/_rels/document.xml.rels", doc_rels.as_bytes()),
("word/media/square.png", png.as_slice()),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap().into_inner()
}
/// The link on each picture of the first paragraph, in order.
fn picture_links(doc: &Document) -> Vec<Option<String>> {
let p = doc.body[0].as_para().unwrap();
p.object_offsets()
.into_iter()
.filter(|off| matches!(p.object_at(*off), Some(InlineObject::Image { .. })))
.map(|off| p.props_of_char(off).link.clone())
.collect()
}
fn part(bytes: &[u8], name: &str) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name(name).unwrap().read_to_string(&mut s).unwrap();
s
}
#[test]
fn a_link_on_a_pictures_drawing_is_read() {
let body = format!(
"<w:p>{}{}{}<w:hyperlink r:id=\"rIdRun\">{}</w:hyperlink></w:p>",
// Word's way: the link on the drawing.
picture_run(1, r#"<a:hlinkClick r:id="rIdWeb"/>"#),
// A click naming a relationship that is no hyperlink, and one naming none.
picture_run(2, r#"<a:hlinkClick r:id="rIdPic"/>"#),
picture_run(3, r#"<a:hlinkClick r:id="rIdMissing"/>"#),
// A link around the run wins over the drawing's.
picture_run(4, r#"<a:hlinkClick r:id="rIdWeb"/>"#),
);
let doc = wordcraft_docx::read(&package(&body)).unwrap();
assert_eq!(picture_links(&doc), [Some("https://example.com/picture".to_string()), None, None, Some("https://example.com/run".to_string())]);
}
#[test]
fn a_linked_picture_is_saved_with_its_link_on_the_drawing_too() {
let mut doc = Document::new();
doc.media.insert("square.png".into(), Arc::new(png()));
let picture = || InlineObject::Image {
media: "square.png".into(),
w: 20.0,
h: 20.0,
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
ole: None,
fx: Default::default(),
};
let linked = |l: &str| CharProps { link: Some(l.to_string()), ..Default::default() };
// A place in this document stays a `w:hyperlink w:anchor` only: `a:hlinkClick` names a relationship.
doc.insert_object(&Pos::body(0, 0), picture(), &linked("#Spot")).unwrap();
doc.insert_object(&Pos::body(0, 0), picture(), &linked("https://example.com/saved")).unwrap();
let bytes = wordcraft_docx::write(&doc).unwrap();
let xml = part(&bytes, "word/document.xml");
let clicks: Vec<&str> = xml.match_indices("<a:hlinkClick ").map(|(i, _)| &xml[i..]).collect();
assert_eq!(clicks.len(), 1, "{xml}");
let rid = clicks[0].split('"').nth(1).unwrap();
let docpr = xml.find("<wp:docPr ").unwrap();
assert!(docpr < xml.find("<a:hlinkClick ").unwrap() && xml.find("<a:hlinkClick ").unwrap() < xml.find("</wp:docPr>").unwrap(), "{xml}");
let rels = part(&bytes, "word/_rels/document.xml.rels");
assert!(rels.contains(&format!(r#"Id="{rid}""#)) && rels.contains(r#"Target="https://example.com/saved""#), "{rels}");
// The run's own hyperlink names the same relationship, so the address is stored once.
assert_eq!(rels.matches("https://example.com/saved").count(), 1, "{rels}");
let back = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(picture_links(&back), [Some("https://example.com/saved".to_string()), Some("#Spot".to_string())]);
// The drawing's link alone (as Word writes it) reads back the same.
let without_run_link = xml.replace(&format!(r#"<w:hyperlink r:id="{rid}" w:history="1">"#), "").replacen("</w:hyperlink>", "", 1);
assert_ne!(without_run_link, xml);
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap();
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
let name = f.name().to_string();
let mut data = Vec::new();
f.read_to_end(&mut data).unwrap();
if name == "word/document.xml" {
data = without_run_link.clone().into_bytes();
}
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
out.write_all(&data).unwrap();
}
let edited = out.finish().unwrap().into_inner();
assert_eq!(picture_links(&wordcraft_docx::read(&edited).unwrap())[0], Some("https://example.com/saved".to_string()));
}
+2
View File
@@ -176,6 +176,7 @@ fn every_para_prop_round_trips() {
line_spacing: Some(LineSpacing::Multiple(1.5)),
contextual_spacing: Some(true),
mirror_indents: Some(true),
adjust_right_indent: Some(false),
keep_next: Some(true),
keep_lines: Some(false),
page_break_before: Some(true),
@@ -209,6 +210,7 @@ fn every_para_prop_round_trips() {
h_anchor: "margin".into(),
v_anchor: "text".into(),
wrap: "around".into(),
anchor_lock: Some(true),
h_space: 9.0,
v_space: 0.0,
}),
+24
View File
@@ -29,6 +29,30 @@ fn part(bytes: &[u8], name: &str) -> String {
xml
}
#[test]
fn paragraph_adjust_right_indent_inherits_from_style() {
let styles =
r#"<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:pPr><w:adjustRightInd/></w:pPr></w:style>"#;
let body = r#"<w:p><w:pPr><w:adjustRightInd w:val="0"/></w:pPr><w:r><w:t>off</w:t></w:r></w:p>
<w:p><w:r><w:t>inherited</w:t></w:r></w:p>"#;
let mut d = wordcraft_docx::read(&docx(body, styles)).unwrap();
let paragraphs: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
assert_eq!(paragraphs[0].props.adjust_right_indent, Some(false));
assert!(!d.styles.resolve_para(&paragraphs[0].props).adjust_right_indent);
assert!(d.styles.resolve_para(&paragraphs[1].props).adjust_right_indent);
for _ in 0..2 {
let out = wordcraft_docx::write(&d).unwrap();
assert!(part(&out, "word/document.xml").contains(r#"<w:adjustRightInd w:val="0""#));
assert!(part(&out, "word/styles.xml").contains("<w:adjustRightInd/>"));
d = wordcraft_docx::read(&out).unwrap();
let paragraphs: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
assert!(!d.styles.resolve_para(&paragraphs[0].props).adjust_right_indent);
assert!(d.styles.resolve_para(&paragraphs[1].props).adjust_right_indent);
assert_eq!(paragraphs[0].props.adjust_right_indent, Some(false));
}
}
#[test]
fn run_and_style_snap_to_grid_and_web_hidden_survive_two_saves() {
let styles = r#"
@@ -0,0 +1,66 @@
//! Section printer, note and chapter-number metadata survives DOCX and native JSON saves.
use std::io::{Read, Write};
fn docx(body: &str) -> Vec<u8> {
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="w15"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
#[test]
fn section_metadata_survives_native_json_and_two_docx_save_reopen_cycles() {
let input = docx(
r#"<w:p><w:pPr><w:sectPr><w:paperSrc w:first=" +261 " w:other=" 260 "/><w:noEndnote/><w15:footnoteColumns w:val="2"/><w:pgNumType w:fmt="decimal" w:chapStyle="1" w:chapSep="hyphen"/></w:sectPr></w:pPr><w:r><w:t>First section</w:t></w:r></w:p><w:p><w:r><w:t>Second section</w:t></w:r></w:p><w:sectPr><w:paperSrc w:first="12" w:other="34"/><w:noEndnote w:val="0"/><w15:footnoteColumns w:val="3"/><w:pgNumType w:fmt="upperRoman" w:chapStyle="10" w:chapSep="colon"/></w:sectPr>"#,
);
let mut document = wordcraft_docx::read(&input).unwrap();
let json = serde_json::to_vec(&document).unwrap();
document = serde_json::from_slice(&json).unwrap();
for cycle in 0..2 {
let bytes = wordcraft_docx::write(&document).unwrap();
let xml = document_xml(&bytes);
assert!(xml.contains(r#"<w:paperSrc w:first="261" w:other="260"/>"#), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"<w:paperSrc w:first="12" w:other="34"/>"#), "cycle {cycle}: {xml}");
assert!(xml.contains("<w:noEndnote/>"), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"<w:noEndnote w:val="0"/>"#), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"<w15:footnoteColumns w:val="2"/>"#), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"<w15:footnoteColumns w:val="3"/>"#), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"w:chapStyle="1" w:chapSep="hyphen""#), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"w:chapStyle="10" w:chapSep="colon""#), "cycle {cycle}: {xml}");
assert!(xml.contains("xmlns:w15=") && xml.contains("mc:Ignorable=\"w14 w15 wp14\""), "cycle {cycle}: {xml}");
document = wordcraft_docx::read(&bytes).unwrap();
let json = serde_json::to_vec(&document).unwrap();
document = serde_json::from_slice(&json).unwrap();
}
}
#[test]
fn invalid_section_metadata_is_ignored_and_missing_values_stay_missing() {
let bytes = docx(
r#"<w:p><w:r><w:t>Text</w:t></w:r></w:p><w:sectPr><w:paperSrc w:first="65536" w:other="-1"/><w15:footnoteColumns w:val="0"/><w:pgNumType w:chapStyle="256" w:chapSep="invalid"/></w:sectPr>"#,
);
let mut d = wordcraft_docx::read(&bytes).unwrap();
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
assert!(!xml.contains("paperSrc"), "{xml}");
assert!(!xml.contains("footnoteColumns"), "{xml}");
assert!(!xml.contains("chapStyle") && !xml.contains("chapSep"), "{xml}");
assert!(!xml.contains("noEndnote"), "{xml}");
d.last_section.footnote_columns = Some(0);
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
assert!(!xml.contains("footnoteColumns"), "{xml}");
}
+182
View File
@@ -0,0 +1,182 @@
//! Shapes, text boxes, groups and canvases in a Strict document (#3548). ISO/IEC 29500 Strict
//! puts them in the one WordprocessingDrawing namespace, where Transitional documents use Word
//! 2010's `wps`, `wpg` and `wpc` namespaces. Fixtures are original and hand-written from
//! ECMA-376 Part 1, 5th edition (`dml-wordprocessingDrawing.xsd`, Strict and Transitional): each
//! drawing is written once and filled in with either set of namespaces.
use std::io::{Read, Write};
use wordcraft_doc::{Document, InlineObject, PartKind};
const STRICT_NS: &str = r#"xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main" xmlns:r="http://purl.oclc.org/ooxml/officeDocument/relationships" xmlns:wp="http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing" xmlns:a="http://purl.oclc.org/ooxml/drawingml/main" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml" w:conformance="strict""#;
const TRANSITIONAL_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas""#;
const STRICT_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
const TRANSITIONAL_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
/// How a fixture names its drawing elements: the shape, group and canvas prefixes, and the
/// text box content element.
struct Names {
ns: &'static str,
rels: &'static str,
shape: &'static str,
group: &'static str,
canvas: &'static str,
content: &'static str,
}
/// Word's Strict output: everything in `wp:`, text box content in `wne:txbxContent`.
const STRICT_WORD: Names = Names { ns: STRICT_NS, rels: STRICT_RELS, shape: "wp", group: "wp", canvas: "wp", content: "wne:txbxContent" };
/// The Strict schema's own names: the text box content in `wp:` too.
const STRICT_SCHEMA: Names = Names { ns: STRICT_NS, rels: STRICT_RELS, shape: "wp", group: "wp", canvas: "wp", content: "wp:txbxContent" };
/// The same drawings in a Transitional document.
const TRANSITIONAL: Names =
Names { ns: TRANSITIONAL_NS, rels: TRANSITIONAL_RELS, shape: "wps", group: "wpg", canvas: "wpc", content: "w:txbxContent" };
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in entries {
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
fn solid(rgb: &str) -> String {
format!(r#"<a:solidFill><a:srgbClr val="{rgb}"/></a:solidFill>"#)
}
/// A `wsp` at (`x`, `y`) EMUs in its group, `cx` × `cy`. `text` gives it a text box.
fn shape(n: &Names, prst: &str, (x, y, cx, cy): (u32, u32, u32, u32), fill: &str, text: Option<&str>) -> String {
let s = n.shape;
let connector = prst.contains("Connector");
let nv = if connector {
format!("<{s}:cNvCnPr/>")
} else if text.is_some() {
format!(r#"<{s}:cNvSpPr txBox="1"/>"#)
} else {
format!("<{s}:cNvSpPr/>")
};
let fill = if connector { String::new() } else { solid(fill) };
let line =
if connector { r#"<a:ln w="25400"><a:solidFill><a:srgbClr val="117A65"/></a:solidFill><a:tailEnd type="triangle"/></a:ln>"# } else { "" };
let txbx = match text {
Some(t) => {
let c = n.content;
format!(r#"<{s}:txbx><{c}><w:p><w:r><w:t>{t}</w:t></w:r></w:p></{c}></{s}:txbx>"#)
}
None => String::new(),
};
format!(
r#"<{s}:wsp><{s}:cNvPr id="{cx}" name="{prst}"/>{nv}<{s}:spPr><a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm><a:prstGeom prst="{prst}"><a:avLst/></a:prstGeom>{fill}{line}</{s}:spPr>{txbx}<{s}:bodyPr anchor="ctr"/></{s}:wsp>"#
)
}
/// Drawing `id`, anchored `x` EMUs from the page's left edge, 1 in × ½ in.
fn anchor(id: u32, x: u32, uri: &str, payload: &str) -> String {
format!(
r#"<w:r><w:drawing><wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="{id}" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="page"><wp:posOffset>{x}</wp:posOffset></wp:positionH><wp:positionV relativeFrom="page"><wp:posOffset>914400</wp:posOffset></wp:positionV><wp:extent cx="914400" cy="457200"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapNone/><wp:docPr id="{id}" name="Drawing {id}"/><wp:cNvGraphicFramePr/><a:graphic><a:graphicData uri="{uri}">{payload}</a:graphicData></a:graphic></wp:anchor></w:drawing></w:r>"#
)
}
/// A rectangle, a text box, an arrow, a group holding a nested group, and a canvas holding a
/// shape and a group, each 1 in × ½ in.
fn package(n: &Names) -> Vec<u8> {
const WPS: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape";
const WPG: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup";
const WPC: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas";
let full = (0, 0, 914_400, 457_200);
let half = (0, 0, 457_200, 457_200);
let g = n.group;
let group_xfrm = |(x, y, cx, cy): (u32, u32, u32, u32)| {
format!(
r#"<{g}:cNvGrpSpPr/><{g}:grpSpPr><a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/><a:chOff x="0" y="0"/><a:chExt cx="{cx}" cy="{cy}"/></a:xfrm>{}</{g}:grpSpPr>"#,
solid("F39C12")
)
};
let group = format!(
"<{g}:wgp>{}{}<{g}:grpSp>{}{}</{g}:grpSp></{g}:wgp>",
group_xfrm(full),
shape(n, "ellipse", half, "28B463", None),
group_xfrm((457_200, 0, 457_200, 457_200)),
shape(n, "triangle", half, "8E44AD", None),
);
let c = n.canvas;
let canvas = format!(
r#"<{c}:wpc><{c}:bg>{}</{c}:bg><{c}:whole><a:ln w="12700">{}</a:ln></{c}:whole>{}<{g}:wgp>{}{}</{g}:wgp></{c}:wpc>"#,
solid("FDF2E9"),
solid("A04000"),
shape(n, "roundRect", (45_720, 45_720, 365_760, 365_760), "16A085", None),
group_xfrm((457_200, 45_720, 365_760, 365_760)),
shape(n, "diamond", (0, 0, 365_760, 365_760), "2E86C1", None),
);
let drawings = [
anchor(1, 914_400, WPS, &shape(n, "rect", full, "C0392B", None)),
anchor(2, 2_286_000, WPS, &shape(n, "rect", full, "EAF2F8", Some("Strict text box"))),
anchor(3, 3_657_600, WPS, &shape(n, "straightConnector1", full, "", None)),
anchor(4, 5_029_200, WPG, &group),
anchor(5, 6_400_800, WPC, &canvas),
];
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {}><w:body><w:p><w:r><w:t>Shapes</w:t></w:r>{}</w:p><w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr></w:body></w:document>"#,
n.ns,
drawings.concat()
);
zip(&[("_rels/.rels", n.rels.as_bytes()), ("word/document.xml", doc.as_bytes())])
}
fn objects(d: &Document) -> Vec<InlineObject> {
d.body[0].as_para().unwrap().objects.clone()
}
/// The text of each text box story, in reading order.
fn box_texts(d: &Document) -> Vec<String> {
let mut ids = Vec::new();
d.objects_in_reading_order(&mut |o| ids.extend(o.text_boxes()));
ids.iter()
.filter_map(|id| d.parts.get(id).filter(|p| p.kind == PartKind::TextBox))
.map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::<Vec<_>>().join("\n"))
.collect()
}
#[test]
fn strict_shapes_read_like_their_transitional_twins() {
let transitional = wordcraft_docx::read(&package(&TRANSITIONAL)).unwrap();
let want = objects(&transitional);
// The twin itself reads every drawing: a rectangle, a text box, an arrow, a group whose
// nested group is flattened into it, and a canvas with its background.
assert_eq!(want.len(), 5, "{want:#?}");
assert!(matches!(&want[0], InlineObject::Shape { story: None, freeform: None, .. }), "{:?}", want[0]);
assert!(matches!(&want[1], InlineObject::Shape { story: Some(_), .. }), "{:?}", want[1]);
assert!(matches!(&want[3], InlineObject::Group { children, canvas: None, .. } if children.len() == 2), "{:?}", want[3]);
assert!(
matches!(&want[4], InlineObject::Group { children, canvas: Some(c), .. } if children.len() == 2 && c.fill.is_some() && c.stroke.is_some()),
"{:?}",
want[4]
);
assert_eq!(box_texts(&transitional), ["Strict text box"]);
for (name, names) in [("Word's Strict names", &STRICT_WORD), ("the Strict schema's names", &STRICT_SCHEMA)] {
let strict = wordcraft_docx::read(&package(names)).unwrap();
assert_eq!(objects(&strict), want, "{name}");
assert_eq!(box_texts(&strict), ["Strict text box"], "{name}");
}
}
#[test]
fn strict_shapes_are_saved_and_reopened() {
let strict = wordcraft_docx::read(&package(&STRICT_WORD)).unwrap();
let saved = wordcraft_docx::write(&strict).unwrap();
let xml = document_xml(&saved);
for name in ["<wps:wsp>", "<wpg:wgp>", "<wpc:wpc>", "<wpc:bg>", "<w:txbxContent>", "<wps:cNvCnPr"] {
assert!(xml.contains(name), "{name} missing from the saved document");
}
let reopened = wordcraft_docx::read(&saved).unwrap();
assert_eq!(objects(&reopened), objects(&strict));
assert_eq!(box_texts(&reopened), ["Strict text box"]);
}
+1
View File
@@ -228,6 +228,7 @@ fn hyperlink_field_targets_follow_the_field_code() {
for (para, link) in [
(complex(r#"HYPERLINK "https://example.org/a" \l "sec""#), Some("https://example.org/a#sec")),
(complex(r#"hyperlink \o "A tip" \t "_blank" "https://example.org/""#), Some("https://example.org/")),
(complex(r#"HYPERLINK "x \* Upper""#), Some("x * Upper")),
(complex(r#"HYPERLINK "C:\\Docs\\My file.docx""#), Some(r"C:\Docs\My file.docx")),
(simple(r#"HYPERLINK \l &quot;Sec&quot; \m"#), Some("#Sec")),
(simple("HYPERLINK https://example.org/b"), Some("https://example.org/b")),
+23
View File
@@ -0,0 +1,23 @@
//! A ragged equation matrix doesn't make the saved `document.xml` grow with rows × widest row
//! (#3643): one wide row plus many empty rows used to be padded out to a full grid.
use wordcraft_doc::math::{MNode, MRun, Math};
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Document, Paragraph, para_block};
#[test]
fn ragged_matrix_save_stays_small() {
let n = 5000;
let mut rows = vec![vec![vec![MNode::Run(MRun::new("a"))]; n]];
rows.extend(std::iter::repeat_with(Vec::new).take(n));
let math = Math { nodes: vec![MNode::Matrix { rows, col_jc: Vec::new() }], ..Default::default() };
let mut p = Paragraph::new();
p.insert_object(0, InlineObject::Equation { linear: String::new(), display: true, math }, &CharProps::default()).unwrap();
let mut doc = Document::new();
doc.body = vec![para_block(p)];
let bytes = wordcraft_docx::write(&doc).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let size = zip.by_name("word/document.xml").unwrap().size();
assert!(size < 1_000_000, "document.xml is {size} bytes");
}
+1
View File
@@ -52,6 +52,7 @@ chrono = { workspace = true, features = ["clock"] }
libc = { workspace = true }
[dev-dependencies]
wordcraft-doc = { workspace = true, features = ["test-support"] }
encoding_rs = { workspace = true }
zip = { workspace = true }
cfb = { workspace = true }
+6 -1
View File
@@ -169,6 +169,9 @@ pub(crate) fn delete_selection_effect(s: &mut Session) -> Result<SelectionDelete
if a == b {
return Ok(SelectionDelete { at: a, content_changed: false, state_changed: false });
}
if canvas::selected_member(s).is_some() {
return Err(CmdError::Disabled("a shape inside a drawing canvas is selected".into()));
}
controls::check_delete(s, &a, &b)?;
let effect = if s.doc.settings.track_changes {
if !track_delete_would_change(s, &a, &b) {
@@ -186,7 +189,9 @@ pub(crate) fn delete_selection_effect(s: &mut Session) -> Result<SelectionDelete
Ok(effect)
}
/// Delete the selection (respecting track changes); returns the collapsed position.
/// Delete the selection (respecting track changes); returns the collapsed position. With a
/// Drawing Canvas member picked (#2637) the selection is the whole canvas, which a command that
/// replaces the selection (Insert › Table, Equation, Symbol, Field…) would take away: refused.
pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
delete_selection_effect(s).map(|e| e.at)
}
+320
View File
@@ -5,6 +5,10 @@
//! top-left corner, unscaled). While a canvas is selected, Insert › Pictures, Shapes and Text Box
//! put the new object inside it ([`add_member`]). Fit, Expand and Scale Drawing change its frame;
//! Shape Fill and Shape Outline colour its background and border.
//!
//! A member can be picked on its own (#2637, `select.member`; a click on it does this): Shape
//! Format, Size, Rotate, Alt Text, Delete, Cut and Copy then act on that member, and Move or
//! Resize and Nudge move it inside the canvas. The canvas stays the selection in the text.
use serde_json::{Value, json};
use wordcraft_doc::para::{CanvasStyle, Float, GroupChild, InlineObject, MAX_GROUP_CHILDREN};
@@ -14,6 +18,29 @@ use super::objects::object_selection;
use super::{delete_selection, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
/// A member of the selected canvas picked on its own: its index, and the selection it was picked
/// in (it counts only while that selection stands).
#[derive(Clone, Debug, PartialEq)]
pub struct CanvasMember {
sel: Selection,
pub index: usize,
}
/// The canvas member picked on its own: the canvas's position, the member's index and the member.
pub fn selected_member(s: &Session) -> Option<(Pos, usize, &InlineObject)> {
let m = s.member.as_ref().filter(|m| m.sel == s.sel)?;
let (pos, canvas) = selected_canvas(s)?;
let InlineObject::Group { children, .. } = canvas else { return None };
let c = children.get(m.index).filter(|c| pickable(c))?;
Some((pos, m.index, &c.obj))
}
/// Whether a member can be picked on its own: a picture, shape or text box that is drawn (a
/// group inside a canvas isn't, nor a hidden member).
pub fn pickable(c: &GroupChild) -> bool {
c.obj.is_drawing() && !matches!(c.obj, InlineObject::Group { .. }) && !c.obj.frame().is_some_and(|(_, _, f)| f.hidden)
}
/// Largest canvas side and member offset, points (well past any page).
const MAX_SIDE: f32 = 4000.0;
/// Smallest canvas side, points.
@@ -47,6 +74,10 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("canvas.scale", "Scale Drawing", "Shape Format › Drawing Canvas", scale)
.params(r#"{"factor": number (0.1–10)} (scales the frame and every member together)"#)
.when(has_canvas),
CommandSpec::new("select.member", "Select Canvas Member", "Shape Format › Drawing Canvas", select_member)
.params(r#"{"index": n | null} (picks member `index` of the selected drawing canvas on its own: Shape Format, Size, Rotate, Alt Text, Delete, Cut and Copy then act on it, and Move or Resize and Nudge move it inside the canvas; null: the canvas itself again)"#)
.when(has_canvas)
.pure(),
CommandSpec::new("canvas.member", "Canvas Member", "Shape Format › Drawing Canvas", member)
.params(r#"{"index": n, "x"?: pt, "y"?: pt, "width"?: pt, "height"?: pt, "delete"?: bool} (moves, resizes or removes member `index` of the selected canvas; x/y from the canvas's top-left; with no change, returns the members)"#)
.when(has_canvas),
@@ -224,9 +255,171 @@ fn member(s: &mut Session, v: &Value) -> CmdResult {
c.obj.set_size(nw.unwrap_or(w0).clamp(1.0, MAX_SIDE), nh.unwrap_or(h0).clamp(1.0, MAX_SIDE));
}
})?;
// The indices after a removed member shift down, so a picked member would name another one.
if delete {
s.member = None;
}
result(&o)
}
/// `select.member`: pick a member of the selected canvas on its own, or (`null`) the canvas again.
fn select_member(s: &mut Session, v: &Value) -> CmdResult {
let Some((_, InlineObject::Group { children, .. })) = selected_canvas(s) else {
return Err(CmdError::Disabled("select a drawing canvas first".into()));
};
let index = match v.get("index") {
None | Some(Value::Null) => None,
Some(_) => {
Some(p::u64(v, "index").and_then(|i| usize::try_from(i).ok()).ok_or_else(|| CmdError::Params("`index` is a member number".into()))?)
}
};
let Some(i) = index else {
s.member = None;
return sel_result(s);
};
let count = children.len();
let c = children.get(i).ok_or_else(|| CmdError::Params(format!("no member {i} (the canvas has {count})")))?;
if !pickable(c) {
return Err(CmdError::Params(format!("member {i} can't be selected on its own")));
}
let (w, h) = c.obj.frame().map(|(w, h, _)| (w, h)).unwrap_or((0.0, 0.0));
let out = json!({"index": i, "x": c.x, "y": c.y, "width": w, "height": h, "object": serde_json::to_value(&c.obj).unwrap_or(Value::Null)});
s.member = Some(CanvasMember { sel: s.sel.clone(), index: i });
Ok(out)
}
/// Delete, Backspace (and their word forms), Cut and Copy with a member picked act on the member
/// alone; the canvas stays selected. Typing and pasting, which would replace the whole canvas,
/// are disabled. `None`: another command, or no member picked.
pub(crate) fn member_command(s: &mut Session, id: &str, _: &Value) -> Option<CmdResult> {
let replaces = (id.starts_with("text.") && id != "text.overtype")
|| matches!(
id,
"edit.paste" | "edit.pasteText" | "edit.pasteMerge" | "edit.pasteSpecial" | "edit.pasteClipboardItem" | "edit.pasteAllClipboard"
);
if !replaces && !matches!(id, "edit.cut" | "edit.copy") {
return None;
}
let (pos, index, _) = selected_member(s)?;
Some(match id {
"edit.copy" => copy_member(s, &pos, index),
"edit.cut" => copy_member(s, &pos, index).and_then(|r| remove_member(s, &pos, index).map(|_| r)),
"text.delete" | "text.backspace" | "text.deleteWordBack" | "text.deleteWordForward" => {
remove_member(s, &pos, index).and_then(|_| sel_result(s))
}
_ => Err(CmdError::Disabled("a shape inside a drawing canvas is selected".into())),
})
}
/// Copy member `index` of the canvas at `pos` alone: pasted, it is a picture or shape of its own.
fn copy_member(s: &mut Session, pos: &Pos, index: usize) -> CmdResult {
let end = Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos.clone() };
let mut f = s.doc.copy_range(pos, &end);
let mut copied = false;
for b in &mut f.blocks {
let wordcraft_doc::Block::Para(p) = b else { continue };
for o in &mut p.objects {
if let InlineObject::Group { children, canvas: Some(_), .. } = o
&& let Some(c) = children.get(index)
{
*o = c.obj.clone();
copied = true;
}
}
}
if !copied {
return Err(CmdError::Failed("the member vanished".into()));
}
s.clipboard_text = f.clipboard_text();
s.clip_history.push(&f, &s.clipboard_text);
s.clipboard = Some(f);
Ok(super::edit::copy_result(s))
}
/// Take member `index` out of the canvas at `pos`; the canvas stays selected.
fn remove_member(s: &mut Session, pos: &Pos, index: usize) -> Result<(), CmdError> {
edit_canvas(s, pos, |_, _, children| {
if index < children.len() {
children.remove(index);
}
})?;
s.member = None;
Ok(())
}
/// Move or Resize with a member picked: `x`/`y` (the page point of its top-left, as dragging its
/// frame gives it) and the size become its place and size in the canvas. `None`: no member.
pub(crate) fn member_bounds(s: &mut Session, v: &Value) -> Option<CmdResult> {
let (pos, index, obj) = selected_member(s)?;
let (w0, h0, _) = obj.frame()?;
let min = super::objects::min_size(obj);
let to = match (p::f32(v, "x"), p::f32(v, "y")) {
(Some(x), Some(y)) => Some((x, y)),
(None, None) => None,
_ => return Some(Err(CmdError::Params("`x` and `y` go together".into()))),
};
// `x`/`y` are on the canvas's page: a drop on another page would put the member outside it.
if to.is_some()
&& let Some(page) = p::u64(v, "page").and_then(|n| usize::try_from(n).ok())
&& s.layout().object(&pos, s.page_hint).is_some_and(|o| o.page != page)
{
return Some(Err(CmdError::Disabled("a shape can't leave its drawing canvas".into())));
}
let (nw, nh) = (p::f32(v, "width"), p::f32(v, "height"));
Some(edit_member(s, &pos, index, |frame, (sx, sy), c| {
if let Some((x, y)) = to {
c.x = ((x - frame.x) / sx).clamp(-MAX_SIDE, MAX_SIDE);
c.y = ((y - frame.y) / sy).clamp(-MAX_SIDE, MAX_SIDE);
}
let w = nw.map_or(w0, |w| w / sx).clamp(min, MAX_SIDE);
let h = nh.map_or(h0, |h| h / sy).clamp(min, MAX_SIDE);
if (w, h) != (w0, h0) {
c.obj.set_size(w, h);
}
}))
}
/// Nudge with a member picked: it moves inside the canvas. `None`: no member.
pub(crate) fn member_nudge(s: &mut Session, dx: f32, dy: f32) -> Option<CmdResult> {
let (pos, index, _) = selected_member(s)?;
Some(edit_member(s, &pos, index, |_, (sx, sy), c| {
c.x = (c.x + dx / sx).clamp(-MAX_SIDE, MAX_SIDE);
c.y = (c.y + dy / sy).clamp(-MAX_SIDE, MAX_SIDE);
}))
}
/// Change member `index` of the canvas at `pos`: `f` gets the canvas's frame on the page, the
/// scale of its members' space and the member.
fn edit_member(s: &mut Session, pos: &Pos, index: usize, f: impl FnOnce(wordcraft_geom::Rect, (f32, f32), &mut GroupChild)) -> CmdResult {
let frame = s.layout().object(pos, s.page_hint).ok_or_else(|| CmdError::Failed("the drawing canvas isn't laid out".into()))?.rect;
let canvas =
s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("the drawing canvas vanished".into()))?;
let scale = canvas.group_scale(frame.w, frame.h);
let mut f = Some(f);
let canvas = edit_canvas(s, pos, |_, _, children| {
if let (Some(c), Some(f)) = (children.get_mut(index), f.take()) {
f(frame, scale, c);
}
})?;
let InlineObject::Group { children, .. } = &canvas else { return result(&canvas) };
let member = children.get(index).map(|c| serde_json::to_value(&c.obj).unwrap_or(Value::Null)).unwrap_or(Value::Null);
Ok(json!({"object": member, "pos": super::pos_json(pos), "member": index}))
}
/// Change the picked member's own object (Shape Format, Size, Rotate, Alt Text) when `pos` is its
/// canvas. `None`: no member picked there.
pub(crate) fn edit_member_object(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Option<Result<InlineObject, CmdError>> {
let (at, index, _) = selected_member(s).filter(|(at, ..)| at == pos)?;
let mut out = None;
let r = edit_canvas(s, &at, |_, _, children| {
if let Some(c) = children.get_mut(index) {
f(&mut c.obj);
out = Some(c.obj.clone());
}
});
Some(r.and_then(|_| out.ok_or_else(|| CmdError::Failed("the member vanished".into()))))
}
/// Shape Fill and Shape Outline on a selected canvas colour its background and border.
pub fn style_mut(o: &mut InlineObject) -> Option<&mut CanvasStyle> {
match o {
@@ -327,4 +520,131 @@ mod tests {
fn canvas_in_text(s: &Session) -> InlineObject {
s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.objects.first()).cloned().unwrap()
}
fn member_fill(o: &InlineObject, i: usize) -> Option<wordcraft_doc::fill::Fill> {
let InlineObject::Group { children, .. } = o else { panic!("{o:?}") };
match &children[i].obj {
InlineObject::Shape { fill, .. } => fill.clone(),
o => panic!("{o:?}"),
}
}
/// #2637: a shape inside a drawing canvas can be picked on its own, as in Word. Shape Format,
/// Move or Resize, Nudge, Delete and Copy then act on it rather than on the whole canvas,
/// which stays the selection in the text; the commands that place an object in the text
/// (wrapping, position) don't apply to it.
#[test]
fn a_canvas_member_is_selected_and_changed_on_its_own() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.canvas", &json!({"width": 400, "height": 300})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle", "width": 100, "height": 40})).unwrap();
s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap();
s.run("canvas.member", &json!({"index": 0, "x": 40, "y": 30})).unwrap();
s.run("canvas.member", &json!({"index": 1, "x": 200, "y": 100})).unwrap();
let canvas_sel = s.sel.clone();
assert!(s.run("select.member", &json!({"index": 5})).is_err(), "no such member");
s.run("select.member", &json!({"index": 1})).unwrap();
assert_eq!(selected_member(&s).map(|(_, i, _)| i), Some(1));
assert_eq!(s.sel, canvas_sel, "the canvas stays the selection in the text");
// Shape Fill colours the member, not the canvas or the other member.
let other = member_fill(&canvas(&s), 0);
s.run("shape.fill", &json!({"color": "FF0000"})).unwrap();
let c = canvas(&s);
assert_eq!(member_fill(&c, 1), wordcraft_doc::props::Rgb::parse("FF0000").map(wordcraft_doc::fill::Fill::solid));
assert_eq!(member_fill(&c, 0), other);
assert_eq!(c.canvas_style().and_then(|st| st.fill.clone()), None);
assert!(selected_member(&s).is_some(), "still picked after a change");
// Dragging its frame: the page point becomes its place in the canvas.
let frame = s.layout().object(&selected_canvas(&s).unwrap().0, 0).unwrap().rect;
s.run("arrange.bounds", &json!({"page": 0, "x": frame.x + 10.0, "y": frame.y + 20.0, "width": 60, "height": 30})).unwrap();
assert_eq!(members(&canvas(&s))[1], (10.0, 20.0, 60.0, 30.0));
assert_eq!(canvas(&s).frame().map(|(w, h, _)| (w, h)), Some((400.0, 300.0)), "the canvas keeps its frame");
s.run("arrange.nudge", &json!({"dx": 6, "dy": -6})).unwrap();
assert_eq!(members(&canvas(&s))[1], (16.0, 14.0, 60.0, 30.0));
assert!(s.run("arrange.wrap", &json!({"wrap": "square"})).is_err(), "the canvas places its members");
// Copy puts the member alone on the clipboard; Delete takes it out of the canvas.
s.run("edit.copy", &json!({})).unwrap();
let clip = s.clipboard.clone().unwrap();
let copied: Vec<&InlineObject> = clip.blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).collect();
assert!(matches!(copied.as_slice(), [InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Ellipse, .. }]), "{copied:?}");
s.run("text.delete", &json!({})).unwrap();
assert_eq!(members(&canvas(&s)), [(40.0, 30.0, 100.0, 40.0)]);
assert!(selected_member(&s).is_none());
assert_eq!(s.sel, canvas_sel, "the canvas is still selected");
// Undo puts the member back.
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(members(&canvas_in_text(&s)).len(), 2);
// Picking the canvas again (or anything else) ends the member's selection.
s.run("select.objects", &json!({"index": 0})).unwrap();
s.run("select.member", &json!({"index": 0})).unwrap();
s.run("select.member", &json!({"index": null})).unwrap();
assert!(selected_member(&s).is_none());
s.run("select.member", &json!({"index": 0})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
assert!(selected_member(&s).is_none());
// Pasted outside the canvas, the copied member is a shape of its own in the text.
s.run("edit.paste", &json!({})).unwrap();
let para = s.doc.body.first().and_then(|b| b.as_para()).unwrap();
assert!(matches!(para.objects.last(), Some(InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Ellipse, .. })), "{:?}", para.objects);
}
#[test]
fn typing_or_removing_a_member_never_hits_another_shape_or_the_canvas() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.canvas", &json!({"width": 400, "height": 300})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle", "width": 100, "height": 40})).unwrap();
s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap();
s.run("select.member", &json!({"index": 1})).unwrap();
// Typing or pasting would replace the whole canvas: refused, and the member stays picked.
for (id, v) in [
("text.insert", json!({"text": "x"})),
("text.newParagraph", json!({})),
("edit.paste", json!({"text": "x"})),
("edit.pasteText", json!({"text": "x"})),
] {
assert!(matches!(s.run(id, &v), Err(CmdError::Disabled(_))), "{id}");
assert_eq!(members(&canvas(&s)).len(), 2, "{id}");
}
assert_eq!(selected_member(&s).map(|(_, i, _)| i), Some(1));
// Ctrl+Backspace removes the member alone, like Backspace.
s.run("text.deleteWordBack", &json!({})).unwrap();
let sizes: Vec<(f32, f32)> = members(&canvas(&s)).iter().map(|m| (m.2, m.3)).collect();
assert_eq!(sizes, [(100.0, 40.0)], "the ellipse went, the rectangle stays");
assert!(selected_member(&s).is_none());
// Removing an earlier member through `canvas.member` drops the pick: its index would now
// name the next member.
s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle", "width": 20, "height": 20})).unwrap();
s.run("select.member", &json!({"index": 1})).unwrap();
s.run("canvas.member", &json!({"index": 0, "delete": true})).unwrap();
assert!(selected_member(&s).is_none(), "the pick doesn't slide onto the member that moved up");
// Commands that replace the selection (the whole canvas) are refused too.
s.run("select.member", &json!({"index": 0})).unwrap();
for (id, v) in [
("insert.table", json!({"rows": 1, "cols": 1})),
("table.quick", json!({})),
("insert.equation", json!({})),
("insert.symbol", json!({"char": "§"})),
("insert.field", json!({"instr": "AUTHOR"})),
("insert.pageBreak", json!({})),
] {
assert!(s.run(id, &v).is_err(), "{id}");
assert_eq!(members(&canvas(&s)).len(), 2, "{id}: the canvas and its members stay");
}
// Moving the member: on the canvas's page it moves; a drop on another page is refused.
let frame = s.layout().object(&selected_canvas(&s).unwrap().0, 0).unwrap();
let before = members(&canvas(&s));
let other = frame.page + 1;
assert!(matches!(s.run("arrange.bounds", &json!({"page": other, "x": 100, "y": 100})), Err(CmdError::Disabled(_))));
assert_eq!(members(&canvas(&s)), before);
s.run("arrange.bounds", &json!({"page": frame.page, "x": frame.rect.x + 5.0, "y": frame.rect.y + 5.0})).unwrap();
assert_ne!(members(&canvas(&s)), before);
}
}
+6
View File
@@ -323,6 +323,7 @@ fn select_all(s: &mut Session, v: &Value) -> CmdResult {
}
let st = s.sel.focus.story;
s.sel = Selection { anchor: s.doc.start_of(st), focus: s.doc.end_of(st) };
s.pending = None;
s.column = None;
s.column_mode = false;
sel_result(s)
@@ -332,6 +333,7 @@ fn select_range(s: &mut Session, v: &Value) -> CmdResult {
let a = v.get("anchor").and_then(parse_pos).ok_or_else(|| CmdError::Params("bad `anchor`".into()))?;
let b = v.get("focus").and_then(parse_pos).ok_or_else(|| CmdError::Params("bad `focus`".into()))?;
s.sel = Selection { anchor: s.doc.clamp(&a), focus: s.doc.clamp(&b) };
s.pending = None;
sel_result(s)
}
@@ -392,6 +394,7 @@ fn select_unit(s: &mut Session, unit: Unit) -> CmdResult {
if s.doc.para_at(&f).is_some() {
let (a, b) = unit_range(s, &f, unit);
s.sel = Selection { anchor: a, focus: b };
s.pending = None;
}
sel_result(s)
}
@@ -486,6 +489,9 @@ fn select_text(s: &mut Session, v: &Value) -> CmdResult {
if count == n {
s.sel =
Selection { anchor: Pos { story, path: path.clone(), off: i }, focus: Pos { story, path: path.clone(), off: i + needle.len() } };
// Formatting picked for the old caret doesn't carry over (as `select.word`): what
// replaces the selection takes its formatting.
s.pending = None;
return sel_result(s);
}
}
+23 -3
View File
@@ -201,7 +201,11 @@ pub(super) fn bib_order(doc: &Document, st: &CiteStyle, lang: Option<String>) ->
let mut idx: Vec<usize> = if cited.is_empty() {
(0..doc.sources.len()).collect()
} else if st.order == Order::Cited {
cited.iter().filter_map(|t| doc.sources.iter().position(|x| &x.tag == t)).collect()
let mut by_tag = std::collections::HashMap::new();
for (i, source) in doc.sources.iter().enumerate() {
by_tag.entry(source.tag.as_str()).or_insert(i);
}
cited.iter().filter_map(|t| by_tag.get(t.as_str()).copied()).collect()
} else {
let cited: std::collections::HashSet<&str> = cited.iter().map(String::as_str).collect();
doc.sources.iter().enumerate().filter(|(_, x)| cited.contains(x.tag.as_str())).map(|(i, _)| i).collect()
@@ -233,6 +237,21 @@ pub fn update_citations(s: &mut Session) -> Result<(), CmdError> {
fn refresh_citations(s: &mut Session) -> Result<(), CmdError> {
let st = style(&s.bib_style);
let order = bib_order(&s.doc, st, crate::cmd::default_lang(s));
// Native data can repeat tags: keep the first source record and, independently, the first
// bibliography position with that tag. Own the keys so paragraph writes don't borrow sources.
let fallback = order.len().saturating_add(1);
let mut by_tag = std::collections::HashMap::new();
for (i, source) in s.doc.sources.iter().enumerate() {
by_tag.entry(source.tag.clone()).or_insert((i, fallback));
}
for (n, &i) in order.iter().enumerate() {
if let Some(source) = s.doc.sources.get(i)
&& let Some((_, number)) = by_tag.get_mut(&source.tag)
&& *number == fallback
{
*number = n.saturating_add(1);
}
}
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
let updates: Vec<(usize, String)> = p
@@ -242,8 +261,9 @@ fn refresh_citations(s: &mut Session) -> Result<(), CmdError> {
.filter_map(|(k, o)| match o {
InlineObject::Field { instr, .. } => {
let (tag, pages) = citation_field(instr)?;
let src = s.doc.sources.iter().find(|x| x.tag == tag)?;
Some((k, cite(src, st.name, number_of(&s.doc, &order, tag), pages)))
let &(i, number) = by_tag.get(tag)?;
let src = s.doc.sources.get(i)?;
Some((k, cite(src, st.name, number, pages)))
}
_ => None,
})
+115
View File
@@ -785,3 +785,118 @@ fn gost_numbers_follow_its_name_or_title_sort() {
assert_eq!(find_style("Harvard - Anglia 2008").map(|x| x.name), Some("Harvard (Anglia)"));
assert!(find_style("").is_none() && find_style("Vancouver").is_none());
}
fn lookup_session(tags: &[&str]) -> Session {
let mut p = Paragraph::new();
for tag in tags {
p.insert_object(
p.len(),
InlineObject::Field {
instr: format!("CITATION {tag}"),
result: "Original cache".into(),
locked: false,
dirty: true,
code: false,
form: String::new(),
},
&CharProps::default(),
)
.unwrap();
}
let mut doc = Document::new();
doc.body = vec![wordcraft_doc::para_block(p)];
Session::new(doc)
}
#[test]
fn citation_source_lookups_keep_first_duplicates_case_and_style_order() {
let mut s = lookup_session(&["A", "B", "missing", "a", "B"]);
s.doc.sources = [
("B", "Zed, Zo", "Zulu"),
("A", "Able, Ann", "Alpha"),
("B", "Baker, Ben", "Beta duplicate"),
("a", "Middle, May", "Middle"),
("unused", "Other, One", "Unused"),
]
.into_iter()
.map(|(tag, author, title)| Source { tag: tag.into(), author: author.into(), title: title.into(), year: "2024".into(), ..Default::default() })
.collect();
let original = s.doc.clone();
for st in &STYLES {
s.bib_style = st.name.into();
let expected = if st.order == Order::Cited { vec![1, 0, 3] } else { vec![1, 2, 3, 0] };
assert_eq!(sources::bib_order(&s.doc, st, None), expected, "{}", st.name);
update_citations(&mut s).unwrap();
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
for (k, (source, number)) in [(1, 1), (0, 2), (usize::MAX, 0), (3, 3), (0, 2)].into_iter().enumerate() {
let InlineObject::Field { instr, result, locked, dirty, code, form } = &p.objects[k] else { panic!() };
let InlineObject::Field { instr: before, .. } = &original.para(StoryRef::Body, &Path::top(0)).unwrap().objects[k] else { panic!() };
assert_eq!(instr, before);
let expected = original.sources.get(source).map(|x| cite(x, st.name, number, "")).unwrap_or_else(|| "Original cache".into());
assert_eq!(result, &expected, "{}: {instr}", st.name);
assert!(!locked && *dirty && !code && form.is_empty());
}
assert_eq!(s.doc.sources, original.sources);
}
// Without citations even duplicate and unused source records remain in the bibliography.
s.doc.body.clear();
assert_eq!(sources::bib_order(&s.doc, style("IEEE"), None), vec![0, 1, 2, 3, 4]);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn citation_refresh_source_lookup_work_scales_with_document_size() {
fn doc(n: usize) -> Session {
let mut p = Paragraph::new();
p.text = wordcraft_doc::para::OBJ.to_string().repeat(n);
p.runs = vec![wordcraft_doc::Run { len: p.text.len(), props: CharProps::default() }];
p.objects = (0..n)
.rev()
.map(|i| InlineObject::Field {
instr: format!("CITATION T{i}"),
result: String::new(),
locked: false,
dirty: false,
code: false,
form: String::new(),
})
.collect();
let mut d = Document::new();
d.body = vec![wordcraft_doc::para_block(p)];
d.sources = (0..n).map(|i| Source { tag: format!("T{i}"), ..Default::default() }).collect();
let mut s = Session::new(d);
s.bib_style = "IEEE".into();
s
}
// Use the running thread's CPU time on Linux; scheduler pauses are not lookup work.
fn cost(f: &mut impl FnMut()) -> f64 {
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
let (t, before) = (std::time::Instant::now(), ran());
f();
match (before, ran()) {
(Some(a), Some(b)) if b > a => (b - a) as f64 * 1e-9,
_ => t.elapsed().as_secs_f64(),
}
}
const SCALE: usize = 8;
let (mut small, mut big) = (doc(2_000), doc(SCALE * 2_000));
update_citations(&mut small).unwrap();
update_citations(&mut big).unwrap();
let (mut a, mut b) = (f64::INFINITY, f64::INFINITY);
for _ in 0..5 {
a = a.min(
cost(&mut || {
for _ in 0..SCALE {
update_citations(&mut small).unwrap();
}
}) / SCALE as f64,
);
b = b.min(cost(&mut || {
update_citations(&mut big).unwrap();
}));
}
let ratio = b / a.max(1e-9);
assert!(ratio < 32.0, "citation refresh: {a:.6}s -> {b:.6}s, {ratio:.1}x for {SCALE}x input");
let p = big.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert!(p.objects.iter().enumerate().all(|(i, o)| matches!(o, InlineObject::Field { result, .. } if result == &format!("[{}]", i + 1))));
}
+9 -3
View File
@@ -26,6 +26,9 @@ use super::ink_shape::{ArrowParts, InkShape, arrow_parts, pair_parts, recognise}
use super::pos_json;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
mod page_objects;
use page_objects::PageObjects;
/// What dragging on the page does (Draw tab).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -325,12 +328,13 @@ impl PlacedInk {
/// turn and geometry on the page.
pub fn ink_on(s: &Session, layout: &DocLayout, page: usize) -> Vec<PlacedInk> {
let Some(pg) = layout.pages.get(page) else { return Vec::new() };
let mut objects = PageObjects::new(&s.doc);
pg.items
.iter()
.filter_map(|it| match it {
Placed::Object { rect, spin, story, path, off, .. } => {
let pos = Pos { story: *story, path: path.clone(), off: *off };
match s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) {
match objects.get(&pos) {
Some(InlineObject::Shape { freeform: Some(f), stroke_width, stroke, .. }) if f.is_ink() => {
Some(PlacedInk { pos, rect: *rect, spin: *spin, freeform: f.clone(), color: *stroke, width: *stroke_width })
}
@@ -577,10 +581,11 @@ fn lasso(s: &mut Session, v: &Value) -> CmdResult {
return Err(CmdError::Params("a lasso loop needs at least three points".into()));
}
let mut items = Vec::new();
let mut objects = PageObjects::new(&s.doc);
for it in &pg.items {
let Placed::Object { rect, spin, story, path, off, .. } = it else { continue };
let pos = Pos { story: *story, path: path.clone(), off: *off };
let Some(obj) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue };
let Some(obj) = objects.get(&pos) else { continue };
let picked = match obj.ink() {
Some(_) if s.view.hide_ink => false,
Some(f) => {
@@ -771,13 +776,14 @@ fn near(a: Rect, b: Rect) -> bool {
fn arrow_pieces(s: &Session, layout: &DocLayout, page: usize, keep: impl Fn(&Pos, &Rect) -> bool) -> Vec<ArrowPiece> {
let Some(pg) = layout.pages.get(page) else { return Vec::new() };
let mut out = Vec::new();
let mut objects = PageObjects::new(&s.doc);
for it in &pg.items {
let Placed::Object { rect, spin, story, path, off, .. } = it else { continue };
let pos = Pos { story: *story, path: path.clone(), off: *off };
if !spin.is_identity() || !keep(&pos, rect) {
continue;
}
let Some(InlineObject::Shape { kind, freeform: Some(f), float, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue };
let Some(InlineObject::Shape { kind, freeform: Some(f), float, .. }) = objects.get(&pos) else { continue };
let Some(fp) = f.paths.first().filter(|_| f.paths.len() == 1) else { continue };
let pen = matches!(f.ink, Some(InkTool::Pen | InkTool::Pencil)) && !s.view.hide_ink;
let shape = f.ink.is_none() && *kind == ShapeKind::Freeform && matches!((fp.pts.len(), fp.closed), (2, false) | (3, true));
@@ -0,0 +1,49 @@
//! Per-call object lookups for drawing-order page walks, which can revisit a paragraph's
//! offsets out of text order. Scan only as far as the furthest requested object, once.
use std::collections::HashMap;
use wordcraft_doc::para::OBJ;
use wordcraft_doc::{Document, InlineObject, Path, Pos, StoryRef};
#[derive(Default)]
struct ParagraphObjects<'a> {
scanned: usize,
ordinal: usize,
objects: Vec<(usize, &'a InlineObject)>,
}
pub(super) struct PageObjects<'a> {
doc: &'a Document,
paragraphs: HashMap<(StoryRef, Path), ParagraphObjects<'a>>,
}
impl<'a> PageObjects<'a> {
pub(super) fn new(doc: &'a Document) -> Self {
Self { doc, paragraphs: HashMap::new() }
}
pub(super) fn get(&mut self, pos: &Pos) -> Option<&'a InlineObject> {
let p = self.doc.para_at(pos)?;
if !p.text.get(pos.off..)?.starts_with(OBJ) {
return None;
}
let end = pos.off.checked_add(OBJ.len_utf8())?;
let cached = self.paragraphs.entry((pos.story, pos.path.clone())).or_default();
if end > cached.scanned {
// Start after the last scanned marker, not at the paragraph's start. In particular,
// a lone object near the start never scans a long text suffix it doesn't need.
for (off, c) in p.text.get(cached.scanned..end)?.char_indices() {
wordcraft_doc::work::steps(1);
if c == OBJ {
if let Some(object) = p.objects.get(cached.ordinal) {
cached.objects.push((cached.scanned + off, object));
}
cached.ordinal += 1;
}
}
cached.scanned = end;
}
let i = cached.objects.binary_search_by_key(&pos.off, |(off, _)| *off).ok()?;
cached.objects.get(i).map(|(_, object)| *object)
}
}
+2
View File
@@ -5,6 +5,8 @@ use wordcraft_doc::para::{InlineObject, ShapeKind, Wrap};
use crate::Session;
mod page_lookup;
fn session() -> Session {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("document.setText", &json!({"text": "First paragraph of text.\nSecond paragraph of text."})).unwrap();
@@ -0,0 +1,106 @@
use super::super::{PlacedInk, ink_on};
use crate::Session;
use std::sync::Arc;
use wordcraft_doc::freeform::{Freeform, InkTool};
use wordcraft_doc::para::{Float, InlineObject, OBJ, ShapeKind, Wrap};
use wordcraft_doc::{CharProps, Document, Paragraph, Path, Pos, StoryRef, para_block};
use wordcraft_geom::{Rect, Spin};
use wordcraft_layout::{DocLayout, Page, Placed};
fn stroke(order: u32) -> InlineObject {
InlineObject::Shape {
kind: ShapeKind::Freeform,
w: 20.0,
h: 10.0,
fill: None,
stroke: Some(wordcraft_doc::Rgb(12, 34, 56)),
stroke_width: 2.0,
float: Float { wrap: Wrap::InFrontOfText, ..Default::default() },
story: None,
freeform: Some(Arc::new(Freeform { order: Some(order), ..Freeform::ink(InkTool::Pen, 20.0, 10.0, vec![[1.0, 1.0], [19.0, 9.0]]) })),
effects: Default::default(),
extra: Default::default(),
}
}
fn placed(story: StoryRef, path: Path, off: usize, x: f32) -> Placed {
Placed::Object {
rect: Rect::new(x, 30.0, 20.0, 10.0),
spin: Spin::default(),
story,
path,
off,
text_box: None,
wrap: Wrap::InFrontOfText,
origin: Default::default(),
}
}
fn orders(ink: &[PlacedInk]) -> Vec<u32> {
ink.iter().map(|k| k.freeform.order.unwrap()).collect()
}
#[test]
fn page_ink_keeps_item_order_duplicates_story_paths_and_unmatched_storage() {
let mut d = Document::new();
let mut p = Paragraph::new();
p.text = format!("é{OBJ}{OBJ}{OBJ}漢{OBJ}");
p.runs = vec![wordcraft_doc::Run { len: p.text.len(), props: CharProps::default() }];
p.objects = vec![InlineObject::BookmarkStart { name: "original".into() }, stroke(11), stroke(22)];
d.body = vec![para_block(p.clone())];
let h = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(p.clone())]);
let mut table = wordcraft_doc::Table::new(1, 1, 100.0);
p.objects.push(stroke(33));
p.objects.push(stroke(99)); // Surplus storage has no character and is not placed.
table.rows[0].cells[0].blocks = vec![para_block(p)];
d.body.push(Arc::new(wordcraft_doc::Block::Table(table)));
let cell = Path(vec![1, 0, 0, 0]);
let mut items = vec![
placed(StoryRef::Body, Path::top(0), 8, 50.0),
placed(StoryRef::Part(h), Path::top(0), 5, 60.0),
placed(StoryRef::Body, cell.clone(), 14, 70.0),
placed(StoryRef::Body, Path::top(0), 5, 80.0),
placed(StoryRef::Body, Path::top(0), 8, 90.0),
];
items.extend([0, 1, 2, 3, 4, 11, 14, usize::MAX].map(|off| placed(StoryRef::Body, Path::top(0), off, 0.0)));
items.push(placed(StoryRef::Part(999), Path::top(0), 0, 0.0));
let l = DocLayout { pages: vec![Page { items, ..Default::default() }], ..Default::default() };
let mut s = Session::new(d);
let original = s.doc.clone();
let got = ink_on(&s, &l, 0);
assert_eq!(orders(&got), [22, 11, 33, 11, 22]);
assert_eq!(got.iter().map(|k| k.rect.x).collect::<Vec<_>>(), [50.0, 60.0, 70.0, 80.0, 90.0]);
assert_eq!(got[1].pos, Pos::new(StoryRef::Part(h), Path::top(0), 5));
assert_eq!(got[2].pos.path, cell);
assert!(got.iter().all(|k| k.width == 2.0 && k.color == Some(wordcraft_doc::Rgb(12, 34, 56))));
assert!(ink_on(&s, &l, 1).is_empty());
s.view.hide_ink = true;
assert_eq!(orders(&ink_on(&s, &l, 0)), orders(&got), "discovery retains hidden ink; callers decide visibility");
assert_eq!(s.doc, original);
}
#[test]
fn page_ink_lookup_work_does_not_multiply_with_paragraph_objects() {
fn fixture(n: usize) -> (Session, DocLayout) {
let mut d = Document::new();
let mut p = Paragraph::new();
p.text = OBJ.to_string().repeat(n);
p.runs = vec![wordcraft_doc::Run { len: p.text.len(), props: CharProps::default() }];
p.objects = (0..n).map(|i| stroke(i as u32)).collect();
d.body = vec![para_block(p)];
let items = (0..n).map(|i| placed(StoryRef::Body, Path::top(0), i * OBJ.len_utf8(), 50.0)).collect();
(Session::new(d), DocLayout { pages: vec![Page { items, ..Default::default() }], ..Default::default() })
}
let (small, a) = fixture(250);
let (big, b) = fixture(2000);
assert_eq!(ink_on(&small, &a, 0).len(), 250);
assert_eq!(ink_on(&big, &b, 0).len(), 2000);
// The characters the object lookups walk (`wordcraft_doc::work`), not a timing: about 8x for
// 8x the objects when each paragraph is scanned once, about 64x when every lookup rescans it.
let s1 = wordcraft_doc::work::count(|| drop(std::hint::black_box(ink_on(&small, &a, 0))));
let s8 = wordcraft_doc::work::count(|| drop(std::hint::black_box(ink_on(&big, &b, 0))));
let ratio = s8 as f64 / s1.max(1) as f64;
eprintln!("page ink steps: {s1} -> {s8}, {ratio:.1}x for 8x objects");
assert!(s1 > 0, "no steps counted, so the walk this test watches no longer counts them (wordcraft_doc::work)");
assert!(ratio <= 12.0, "page ink lookup repeats paragraph-prefix work: {ratio:.1}x the steps for 8x objects ({s1} -> {s8})");
}
+13 -8
View File
@@ -199,16 +199,21 @@ fn copy_with(s: &mut Session, whole: Option<&Path>) -> CmdResult {
}
/// What Copy and Cut return: the clipboard's text, and the copy as HTML (equations as MathML,
/// #816) for the host to put on the system clipboard too, so other programs keep the formatting;
/// and for a copy of one equation alone, its MathML (#1297).
/// #816) and as RTF for the host to put on the system clipboard too, so other programs keep the
/// formatting; and for a copy of one equation alone, its MathML (#1297).
pub(crate) fn copy_result(s: &mut Session) -> Value {
s.clipboard_html = match &s.clipboard {
Some(f) => wordcraft_formats::html::export_fragment(&f.to_document()),
None => String::new(),
(s.clipboard_html, s.clipboard_rtf) = match &s.clipboard {
Some(f) => {
let d = f.to_document();
(wordcraft_formats::html::export_fragment(&d), wordcraft_formats::rtf::export(&d))
}
None => (String::new(), String::new()),
};
let mut r = json!({"text": s.clipboard_text});
if !s.clipboard_html.is_empty() {
r["html"] = json!(s.clipboard_html);
for (k, v) in [("html", &s.clipboard_html), ("rtf", &s.clipboard_rtf)] {
if !v.is_empty() {
r[k] = json!(v);
}
}
// A copy of one equation alone, as MathML for the clipboard formats equation editors read
// ("MathML" on Windows, `public.mathml.presentation` on macOS; #1297). Word looks for them
@@ -356,7 +361,7 @@ impl ClipHistory {
self.items.is_empty()
}
fn push(&mut self, f: &Fragment, text: &str) {
pub(crate) fn push(&mut self, f: &Fragment, text: &str) {
// Text plus per-paragraph overhead; pictures are references into the document.
let paras = text.matches('\n').count().saturating_add(f.blocks.len()).saturating_add(f.parts.len()).saturating_add(1);
let bytes = text.len().saturating_mul(2).saturating_add(paras.saturating_mul(256));
+16 -2
View File
@@ -326,8 +326,14 @@ fn type_text(s: &mut Session, v: &Value) -> CmdResult {
pos = p;
changed = true;
}
if let Some(p) = me::build_up(&mut math.nodes, &pos, latex) {
pos = p;
// As in Word, `\int` + Space leaves ∫ waiting for its limits: `_a^b` and then
// Space, or Space alone, builds the operator (#4917).
let waits = changed && c == ' ' && me::nary_before(&math.nodes, &pos);
// Only an operator this build-up makes takes the caret: one already there (from
// the gallery, say) keeps it after it.
let built_before = me::nary_node_before(&math.nodes, &pos);
if !waits && let Some(p) = me::build_up(&mut math.nodes, &pos, latex) {
pos = if built_before { p } else { me::into_empty_operand(&math.nodes, &p) };
changed = true;
}
if changed {
@@ -336,6 +342,14 @@ fn type_text(s: &mut Session, v: &Value) -> CmdResult {
if c == ' ' && changed {
continue;
}
} else if !style.nor && !latex && !c.is_alphanumeric() {
// Word corrects `\name` on any character that can't continue it (`\int_`,
// `\alpha+`), keeps that character and builds nothing up yet. LaTeX input keeps
// its commands (`\sqrt{`).
if let Some(p) = me::autocorrect(&mut math.nodes, &pos) {
pos = p;
transformed = true;
}
}
pos = me::insert_text(&mut math.nodes, &pos, &c.to_string(), style);
}
+109
View File
@@ -530,3 +530,112 @@ fn equation_delete_surrounding_moves_the_caret_for_a_one_step_delete() {
assert_eq!(linear(&s), "bc");
assert_eq!(s.math.as_ref().unwrap().pos, MathPos::new(Vec::new(), 0));
}
/// The n-ary operator in the equation, with its limits (shown or not) and operand as text.
fn nary_of(s: &Session) -> Option<(char, Option<String>, Option<String>, String)> {
nodes_of(s, &s.math.as_ref()?.at)?.iter().find_map(|n| match n {
MNode::Nary { chr, sub_hide, sup_hide, sub, sup, e, .. } => {
Some((*chr, (!sub_hide).then(|| to_linear(sub)), (!sup_hide).then(|| to_linear(sup)), to_linear(e)))
}
_ => None,
})
}
#[test]
fn an_autocorrected_operator_waits_for_its_limits() {
// #4917: as in Word, `\int` + Space gives a ∫ that `_x^y` then Space decorates; the
// operand typed next goes inside the operator.
let typed = |keys: &[&str]| {
let mut s = session();
s.run("insert.equation", &json!({})).unwrap();
for t in keys {
s.run("equation.type", &json!({"text": t})).unwrap();
}
s
};
let s = typed(&[r"\int", " "]);
assert_eq!(linear(&s), "∫", "the operator waits, not yet built");
assert!(nary_of(&s).is_none());
let s = typed(&[r"\int", " ", "_x^y", " ", "f"]);
assert_eq!(nary_of(&s), Some(('∫', Some("x".into()), Some("y".into()), "f".into())));
let s = typed(&[r"\sum", " ", "_(k=1)^n", " ", "k"]);
assert_eq!(nary_of(&s), Some(('∑', Some("k=1".into()), Some("n".into()), "k".into())));
// Only a lower limit.
let s = typed(&[r"\prod", " ", "_i", " ", "a"]);
assert_eq!(nary_of(&s), Some(('∏', Some("i".into()), None, "a".into())));
// Space alone builds it without limits, the caret in its operand.
let s = typed(&[r"\int", " ", " ", "x"]);
assert_eq!(nary_of(&s), Some(('∫', None, None, "x".into())));
// Typing the operand straight after it, as before.
let s = typed(&[r"\int", " ", "x", " "]);
assert_eq!(nary_of(&s), Some(('∫', None, None, "x".into())));
// Enter builds the operator and leaves the caret after it; Right builds it and, at the
// equation's end, leaves the equation, both as before.
let mut s = typed(&[r"\int", " ", "_0^1"]);
s.run("equation.enter", &json!({})).unwrap();
assert_eq!(nary_of(&s), Some(('∫', Some("0".into()), Some("1".into()), String::new())));
assert_eq!(s.math.as_ref().unwrap().pos, MathPos::new(Vec::new(), 1));
let mut s = typed(&[r"\int", " ", "_0^1"]);
let at = s.math.as_ref().unwrap().at.clone();
s.run("equation.move", &json!({"dir": "right"})).unwrap();
assert!(s.math.is_none(), "Right at the end leaves the equation");
assert!(matches!(nodes_of(&s, &at).as_deref(), Some([MNode::Nary { e, .. }]) if e.is_empty()));
// One undo step takes the whole typing away, leaving the empty equation.
let mut s = typed(&[r"\int", " ", "_0^1", " ", "t"]);
let at = s.math.as_ref().unwrap().at.clone();
s.run("equation.exit", &json!({})).unwrap();
s.run("edit.undo", &json!({})).unwrap();
let left = s.doc.para_at(&at).and_then(|p| p.object_at(at.off)).map(|o| o.plain_text().to_string());
assert_eq!(left.as_deref(), Some(""), "the equation is there, empty");
// An operator ending another one's operand (`∫∫`) takes what is typed next.
let s = typed(&[r"\int", " ", r"\int", " ", " ", "f"]);
let inner = nary_of(&s).and_then(|_| {
nodes_of(&s, &s.math.as_ref()?.at)?.iter().find_map(|n| match n {
MNode::Nary { e, .. } => match e.as_slice() {
[MNode::Nary { e, .. }] => Some(to_linear(e)),
_ => None,
},
_ => None,
})
});
assert_eq!(inner.as_deref(), Some("f"), "f is the inner operator's operand");
// Space after an operator that was already there (from the gallery, its operand left empty)
// doesn't send the caret back into it: what is typed next follows the operator.
let mut s = session();
s.run("insert.equation", &json!({})).unwrap();
s.run("equation.insertStructure", &json!({"id": "nary.sumLimits"})).unwrap();
s.run("equation.type", &json!({"text": "k"})).unwrap();
if let Some(m) = s.math.as_mut() {
m.pos = MathPos::new(Vec::new(), 1);
}
s.run("equation.type", &json!({"text": " x"})).unwrap();
assert_eq!(nary_of(&s).map(|n| n.3), Some(String::new()), "the operand stays empty");
assert_eq!(s.math.as_ref().unwrap().pos.path, Vec::new(), "the caret stays after the operator");
}
#[test]
fn a_math_name_is_corrected_by_any_character_that_cannot_continue_it() {
// Observed in Word for Windows (#4938): `\int_0^1` typed in one go gives ∫ as soon as `_` is
// typed, so Space then builds the integral with its limits; `\alpha` + `+` gives α+, and a
// letter or digit leaves the name as typed.
let typed = |keys: &[&str]| {
let mut s = session();
s.run("insert.equation", &json!({})).unwrap();
for t in keys {
s.run("equation.type", &json!({"text": t})).unwrap();
}
s
};
let s = typed(&[r"\int_0^1", " ", "x"]);
assert_eq!(nary_of(&s), Some(('∫', Some("0".into()), Some("1".into()), "x".into())));
let s = typed(&[r"\alpha+\beta\gamma1"]);
assert_eq!(linear(&s), r"α+β\gamma1");
let s = typed(&[r"\alpha(x)"]);
assert_eq!(linear(&s), "α(x)");
// LaTeX input keeps its commands until they are built up.
let mut s = session();
s.run("equation.inputFormat", &json!({"format": "latex"})).unwrap();
s.run("insert.equation", &json!({})).unwrap();
s.run("equation.type", &json!({"text": r"\alpha+"})).unwrap();
assert_eq!(linear(&s), r"\alpha+");
}
+49 -21
View File
@@ -253,27 +253,8 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
);
}
let password = p::str(v, "password");
let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") {
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
// An empty file (File Explorer's New › Microsoft Word Document makes one) opens as a new
// blank document that saves back to it (#1384).
if bytes.is_empty() {
(normal_document(s), false)
} else {
crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)?
}
} else if crate::io::is_empty_file(std::path::Path::new(path)) {
(normal_document(s), false)
} else {
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)?
};
// Plain text and Markdown carry no language: they are in the editing language.
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
{
Arc::make_mut(&mut doc.styles).default_chr.lang = Some(lang.clone());
}
// An empty file opens as a new blank document that saves back to it (#1384).
let (doc, encrypted) = read_document(s, path, v)?;
match s.used_because() {
None => s.set_document(doc),
Some(why) => {
@@ -294,6 +275,53 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
)
}
/// The document the file at `path` holds (or its bytes, base64 in `data`, with `path` only
/// naming it), read with `password`, and whether it was encrypted: what `file.open` and
/// `file.newFromTemplate` read. An empty file (File Explorer's New › Microsoft Word Document makes
/// one) is a new blank document (#1384). Plain text and Markdown carry no language, so they are
/// in the editing language.
fn read_document(s: &Session, path: &str, v: &Value) -> Result<(Document, bool), CmdError> {
let password = p::str(v, "password");
let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") {
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
if bytes.is_empty() {
(normal_document(s), false)
} else {
crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)?
}
} else if crate::io::is_empty_file(std::path::Path::new(path)) {
(normal_document(s), false)
} else {
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)?
};
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
{
Arc::make_mut(&mut doc.styles).default_chr.lang = Some(lang.clone());
}
Ok((doc, encrypted))
}
/// File › New from a template (#2852): a new, untitled document with the template's content,
/// attached to the template as in Word, read as `file.open` reads a file (`data`, `password`).
/// It opens beside the open documents, as `file.new` does. With `replaceBlank` an untouched blank
/// document gives way to it, as to a file `file.open` opens: that is how a template the platform
/// hands the app starts (#3433). The template is only read; the new document has no file, so
/// saving it asks where to write, and a protected template's password doesn't come along.
pub(crate) fn new_from_template(s: &mut Session, v: &Value) -> CmdResult {
let path = p::req_str(v, "path")?;
let (mut doc, _) = read_document(s, path, v)?;
doc.settings.attached_template = Some(path.to_string());
if p::bool(v, "replaceBlank") == Some(true) && s.used_because().is_none() {
s.set_document(doc);
} else {
s.add_document(doc);
}
s.dirty = true;
sel_result(s)
}
/// Close a document with no window to ask about it (agents, headless sessions).
fn close_document(s: &mut Session, v: &Value) -> CmdResult {
let id = match v.get("id").filter(|x| !x.is_null()) {
+3 -1
View File
@@ -198,7 +198,9 @@ fn special(query: &str, o: &Opts) -> (String, bool) {
if (o.ignore_punct && is_punct(c)) || (o.ignore_space && is_space(c)) {
continue;
}
atoms.push(lit(c));
// A keyboard apostrophe also finds text produced by smart quotes. Explicit character
// codes above, and literal/regex/wildcard searches, keep exact character matching.
atoms.push(if c == '\'' { "['‘’]".into() } else { lit(c) });
}
let filler = match (o.ignore_punct, o.ignore_space) {
(true, true) => r"[\p{P} \t\x{A0}]*",
+38
View File
@@ -63,6 +63,17 @@ pub(super) fn link(s: &mut Session, v: &Value) -> CmdResult {
let end = s.doc.insert_text(&at, &text, &props)?;
s.sel = Selection::caret(end);
s.pending = Some(CharProps::default());
} else if let Some(text) = retyped_text(s, v) {
// Text to display changed: the selection is retyped with it, then linked (as in Word).
crate::cmd::paste_text(s, &text)?;
let end = s.sel.focus.clone();
let start = Pos { off: end.off.saturating_sub(text.len()), ..end.clone() };
s.doc.format_range(&start, &end, &|c| {
c.link = Some(url.clone());
c.style = style.clone();
})?;
s.sel = Selection::caret(end);
s.pending = Some(CharProps::default());
} else {
let (a, b) = s.sel.ordered();
s.doc.format_range(&a, &b, &|c| {
@@ -73,6 +84,33 @@ pub(super) fn link(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// The `text` to retype a selection with before linking it: only when it differs from the selected
/// text and both fit in one line of one paragraph without pictures or fields, outside any field's
/// result. Otherwise (as Word shows `<<Selection in Document>>`) the selection keeps its text.
fn retyped_text(s: &Session, v: &Value) -> Option<String> {
use wordcraft_doc::para::{COLUMN_BREAK, LINE_BREAK, OBJ, PAGE_BREAK};
/// What can't stand in one line of text: breaks, and pictures or fields.
const NOT_ONE_LINE: [char; 7] = [LINE_BREAK, '\r', '\u{2029}', '\u{b}', PAGE_BREAK, COLUMN_BREAK, OBJ];
let text = p::str(v, "text").filter(|t| !t.is_empty() && t.len() <= 4096)?;
if text.contains(NOT_ONE_LINE) {
return None;
}
let (a, b) = s.sel.ordered();
if a.story != b.story || a.path != b.path {
return None;
}
let shown = s.doc.para(a.story, &a.path)?.text.get(a.off..b.off)?;
if shown.contains(NOT_ONE_LINE) || shown == text {
return None;
}
// A selection in a field's result (between its markers, which it doesn't hold) is retyped by
// the next update of the field, not here.
if s.doc.field_ranges(a.story).iter().any(|r| r.start < b && a <= r.end) {
return None;
}
Some(text.to_string())
}
/// Whether `name` can name a bookmark: starts with a letter or underscore, contains only alphanumeric characters
/// or underscores, and is at most 40 characters (#3491). Word allows leading underscores for hidden bookmarks (_Toc, _Ref).
pub fn bookmark_name_ok(name: &str) -> bool {
+32 -1
View File
@@ -452,6 +452,36 @@ mod tests {
assert_eq!(numbered(&mut b), want);
}
/// #5757: a list numbered in Thai digits shows them on the page, with the level's text around
/// them (`๑.`, `(๒)`), and keeps its format through a save to .docx and back.
#[test]
fn a_thai_numbered_list_shows_thai_digits_and_survives_docx() {
let mut s = Session::new(Document::new());
for (i, t) in ["หนึ่ง", "สอง", "สาม", "a", "b", "c", "d", "e", "f", "g", "h"].iter().enumerate() {
if i > 0 {
s.run("text.newParagraph", &json!({})).unwrap();
}
s.run("text.insert", &json!({"text": t})).unwrap();
}
s.run("select.all", &json!({})).unwrap();
let levels = json!([{"format": "thaiNumbers", "text": "%1.", "start": 1}]);
s.run("list.define", &json!({"levels": levels})).unwrap();
let page: Vec<Option<String>> = numbered(&mut s).into_iter().map(|x| x.0).collect();
let want: Vec<Option<String>> =
["๑.", "๒.", "๓.", "๔.", "๕.", "๖.", "๗.", "๘.", "๙.", "๑๐.", "๑๑."].iter().map(|l| Some(l.to_string())).collect();
assert_eq!(page, want);
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let mut b = Session::new(back);
let again: Vec<Option<String>> = numbered(&mut b).into_iter().map(|x| x.0).collect();
assert_eq!(again, want, "thaiNumbers survives .docx");
// In parentheses too.
let mut s = Session::new(Document::new());
s.run("text.insert", &json!({"text": "x"})).unwrap();
s.run("select.all", &json!({})).unwrap();
s.run("list.define", &json!({"levels": [{"format": "thaiNumbers", "text": "(%1)", "start": 2}]})).unwrap();
assert_eq!(numbered(&mut s)[0].0.as_deref(), Some("(๒)"));
}
/// #1793: the other heading presets of Word's List Library, level by level.
#[test]
fn every_heading_preset_numbers_three_heading_levels() {
@@ -459,7 +489,8 @@ mod tests {
("headings", [Some("1"), Some("1.1"), Some("1.1.1")]),
("article", [Some("Article I."), Some("Section 1.01"), Some("(a)")]),
("outlineHeadings", [Some("I."), Some("A."), Some("1.")]),
("chapter", [Some("Chapter 1"), None, None]),
// The lower levels are "none" levels with no text: an empty label, as in Word (#4985).
("chapter", [Some("Chapter 1"), Some(""), Some("")]),
] {
let mut s = headings("Heading3");
s.run("list.multilevelPreset", &json!({"preset": preset})).unwrap();
+19 -6
View File
@@ -1,16 +1,18 @@
use super::filter::refresh_shown;
use super::{CmdError, CmdResult, Session, Value, json, p};
/// Parse CSV (RFC 4180-ish: quotes, doubled quotes, commas/semicolons/tabs).
/// The field separator: whichever of `,` `;` and tab the first non-blank line (outside quotes) uses most.
/// The field separator: whichever of `,` `;` and tab the first non-blank record (outside quotes) uses most.
fn csv_separator(text: &str) -> char {
let first = text.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
let mut quoted = false;
let mut unquoted = String::new();
for c in first.chars() {
// A quoted header may span physical lines; infer from the first complete record.
for c in text.split_inclusive(['\n', '\r']).skip_while(|line| line.trim().is_empty()).flat_map(str::chars) {
if c == '"' {
quoted = !quoted;
} else if !quoted {
if c == '\n' || c == '\r' {
break;
}
unquoted.push(c);
}
}
@@ -26,6 +28,7 @@ fn push_csv_row(rows: &mut Vec<Vec<String>>, row: &mut Vec<String>) {
}
}
/// Parse CSV (RFC 4180-ish: quotes, doubled quotes, commas/semicolons/tabs).
pub fn parse_csv(text: &str) -> Vec<Vec<String>> {
let sep = csv_separator(text);
let mut rows = Vec::new();
@@ -112,13 +115,23 @@ fn table_from_rows(fields: Option<&Vec<Value>>, arr: &[Value]) -> Result<(Vec<St
Ok((headers, rows))
}
/// Skip unquoted blank prefix lines only when a text recipient list has a header.
/// Headerless lists and the shared CSV reader keep whitespace records.
fn recipient_csv(text: &str, headers: bool) -> Vec<Vec<String>> {
let prefix = text
.split_inclusive(['\n', '\r'])
.take_while(|line| headers && line.trim().is_empty())
.fold(0_usize, |bytes, line| bytes.saturating_add(line.len()));
parse_csv(text.get(prefix..).unwrap_or_default())
}
pub(super) fn recipients(s: &mut Session, v: &Value) -> CmdResult {
let mut sheet = Value::Null;
let (headers, rows) = if let Some(arr) = v.get("rows").and_then(Value::as_array) {
table_from_rows(v.get("fields").and_then(Value::as_array), arr)?
} else {
let all = if let Some(c) = p::str(v, "csv") {
parse_csv(c)
recipient_csv(c, p::bool(v, "headers").unwrap_or(true))
} else {
let (bytes, ext) = if let Some(d) = p::str(v, "data") {
let b = crate::cmd::insert::base64_decode(d.trim()).ok_or_else(|| CmdError::Params("`data` isn't valid base64".into()))?;
@@ -175,7 +188,7 @@ pub(super) fn recipients(s: &mut Session, v: &Value) -> CmdResult {
sheet = json!(chosen);
rows
} else {
parse_csv(&decode(&bytes, v)?)
recipient_csv(&decode(&bytes, v)?, p::bool(v, "headers").unwrap_or(true))
}
};
header_split(all, p::bool(v, "headers").unwrap_or(true))?
+70
View File
@@ -46,6 +46,76 @@ fn finishing_a_merge_to_a_file_is_not_an_edit() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn csv_delimiter_reads_complete_header_record() {
for sep in [',', ';', '\t'] {
for newline in ["\n", "\r\n", "\r"] {
// All candidate separators and doubled quotes occur on both sides of the newline.
// The real separators follow the closing quote, beyond the first physical line.
let header = format!("Name,;\t \"given\"{newline}family,;\t \"last\"");
let quoted = header.replace('"', "\"\"");
let text = format!("\"{quoted}\"{sep}City{sep}{sep}\r\nAda{sep}Paris{sep}{sep}\nBo{sep}Oslo{sep}{sep}");
assert_eq!(
parse_csv(&text),
vec![vec![header.as_str(), "City", "", ""], vec!["Ada", "Paris", "", ""], vec!["Bo", "Oslo", "", ""]],
"separator {sep:?}, embedded newline {newline:?}"
);
// A multiline field after an ordinary column must not end detection either.
let text = format!("ID{sep}\"{quoted}\"{sep}City\n1{sep}Ada{sep}Paris\n2{sep}Bo{sep}Oslo");
assert_eq!(parse_csv(&text), vec![vec!["ID", header.as_str(), "City"], vec!["1", "Ada", "Paris"], vec!["2", "Bo", "Oslo"]]);
}
}
}
#[test]
fn csv_delimiter_reads_multiline_headers_after_blank_lines() {
for sep in [',', ';', '\t'] {
for newline in ["\n", "\r\n", "\r"] {
let header = format!("Given{newline}Family");
for prefix in ["\n\r\n", "\r", "\r\r"] {
let text = format!("{prefix}\"{header}\"{sep}City\rAda{sep}Paris");
assert_eq!(parse_csv(&text), vec![vec![header.as_str(), "City"], vec!["Ada", "Paris"]]);
}
}
}
}
#[test]
fn recipient_csv_blank_prefix_keeps_columns_without_dropping_literal_records() {
let mut s = Session::new(Document::new());
for sep in [',', ';', '\t'] {
for newline in ["\n", "\r\n", "\r"] {
let header = format!("First{newline}Name");
let csv = format!(" \t{newline}\"{header}\"{sep}Email{newline}Ada{sep}ada@example.com");
let result = s.run("mailings.recipients", &json!({"csv": csv})).unwrap();
assert_eq!(result, json!({"fields": [header, "Email"], "records": 1}));
assert_eq!(s.run("mailings.editRecipients", &json!({})).unwrap()["rows"], json!([["Ada", "ada@example.com"]]));
}
}
s.run("mailings.recipients", &json!({"csv": " \nName,Email\nAda,ada@example.com", "headers": false})).unwrap();
assert_eq!(s.run("mailings.editRecipients", &json!({})).unwrap()["rows"], json!([[" ", ""], ["Name", "Email"], ["Ada", "ada@example.com"]]));
s.run("mailings.recipients", &json!({"csv": "\" \",Email\nAda,ada@example.com"})).unwrap();
assert_eq!(s.run("mailings.editRecipients", &json!({})).unwrap()["rows"], json!([["Ada", "ada@example.com"]]));
let rows = s.merge.rows.clone();
assert!(s.run("mailings.recipients", &json!({"csv": " \t\r\n \r"})).is_err());
assert_eq!(s.merge.rows, rows, "an empty headered list must not replace existing recipients");
assert_eq!(parse_csv(" \nName,Email\nAda,ada@example.com")[0], vec![" "], "the shared CSV reader keeps literal whitespace rows");
}
#[test]
fn csv_delimiter_keeps_record_boundaries_and_ties() {
// Later records cannot choose the header's delimiter.
assert_eq!(parse_csv("Name;City\rAda;Paris,,,,\rBo;Oslo"), vec![vec!["Name", "City"], vec!["Ada", "Paris,,,,"], vec!["Bo", "Oslo"]]);
// Existing ties prefer tab, then semicolon, then comma.
assert_eq!(parse_csv("A,B;C\n1,2;3"), vec![vec!["A,B", "C"], vec!["1,2", "3"]]);
assert_eq!(parse_csv("A;B\tC\n1;2\t3"), vec![vec!["A;B", "C"], vec!["1;2", "3"]]);
assert_eq!(parse_csv("Name\nAda,Paris;Oslo"), vec![vec!["Name"], vec!["Ada,Paris;Oslo"]]);
assert!(parse_csv("").is_empty());
assert_eq!(parse_csv("\nName\nAda"), vec![vec!["Name"], vec!["Ada"]]);
// The reader remains permissive about an unterminated quoted cell.
assert_eq!(parse_csv("Name,City\nAda,\"Paris\nOslo"), vec![vec!["Name", "City"], vec!["Ada", "Paris\nOslo"]]);
}
#[test]
fn csv_delimiter_ignores_quoted_text() {
let r = parse_csv("\"Name, first, last\";City\nAnn;Paris\n");
+7 -3
View File
@@ -6,6 +6,9 @@ pub(super) const MAX_OFFSET: f32 = 4000.0;
/// Resize and/or move the selected object (dragging its frame or handles).
pub(super) fn bounds(s: &mut Session, v: &Value) -> CmdResult {
if let Some(r) = crate::cmd::canvas::member_bounds(s, v) {
return r;
}
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
let to = match (p::f32(v, "x"), p::f32(v, "y")) {
(Some(x), Some(y)) => Some((x, y)),
@@ -58,7 +61,7 @@ fn landing(s: &Session, pos: &Pos, to: &Pos) -> Result<Pos, CmdError> {
/// Move the In Line with Text object at `pos` to the text position `to`, where it stays In Line
/// with Text, keeping its character formatting: Word's drag of an inline picture, which lands
/// where its dashed insertion cursor was (#2233, #2234). Dropped on itself, nothing changes.
/// where its insertion cursor was (#2233, #2234). Dropped on itself, nothing changes.
/// Returns the object's position afterwards.
fn move_in_text(s: &mut Session, pos: Pos, to: &Pos) -> Result<Pos, CmdError> {
let to = landing(s, &pos, to)?;
@@ -161,8 +164,9 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w,
Some(path) => (Pos { story, path, off: 0 }, false),
None => (nearest_line(&layout, page, y, story).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true),
};
// Move its character to the anchor (unless it's already in that paragraph).
let at = if anchor.story == pos.story && anchor.path == pos.path && !on_page {
// Move its character to the anchor, unless it's already in that paragraph and drawn on this
// page (one anchored on a later page of it is drawn there, #4250).
let at = if anchor.story == pos.story && anchor.path == pos.path && !on_page && layout.object(pos, page).is_some_and(|o| o.page == page) {
pos.clone()
} else {
let obj = s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
+14 -3
View File
@@ -76,9 +76,11 @@ pub fn cropped_original_size(s: &Session, o: &InlineObject) -> Option<(f32, f32)
pub(super) fn size(s: &mut Session, v: &Value) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no object".into()))?;
let lock = p::bool(v, "lockAspect").unwrap_or(true);
let (mut w, mut h) = obj_size(&o);
let (w0, h0) = obj_size(&o);
let (mut w, mut h) = (w0, h0);
let scale = p::f32(v, "scale");
if p::bool(v, "original").unwrap_or(false) {
let original = p::bool(v, "original").unwrap_or(false);
if original {
let (ow, oh) =
cropped_original_size(s, &o).ok_or_else(|| CmdError::Disabled("only a picture whose image can be read has an original size".into()))?;
let k = scale.unwrap_or(100.0) / 100.0;
@@ -113,7 +115,16 @@ pub(super) fn size(s: &mut Session, v: &Value) -> CmdResult {
(None, None) => true,
_ => lock,
};
let (w, h) = fit_size(w, h, keep);
// Sides set in proportion (from the original, or from a size whose sides both have a length)
// fit the limits together. Otherwise each side the call sets fits on its own, and a side it
// leaves as it was keeps its size, a straight line's 0 too.
let (w, h) = if keep && (original || (w0 > 0.0 && h0 > 0.0 && (w, h) != (w0, h0))) {
fit_size(w, h, true)
} else {
let (fw, fh) = fit_size(w, h, false);
let left = |new: f32, old: f32| !original && new == old;
(if left(w, w0) { w } else { fw }, if left(h, h0) { h } else { fh })
};
// Any drawing: a group's members scale with its frame (its child space is kept); a chart or
// SmartArt graphic lays itself out again at the new size.
let theme = s.doc.settings.theme_colors.clone();
+24 -2
View File
@@ -12,6 +12,10 @@ pub(super) fn has_object(s: &Session) -> Option<&'static str> {
}
pub(super) fn has_floating(s: &Session) -> Option<&'static str> {
// A canvas member moves inside its canvas.
if crate::cmd::canvas::selected_member(s).is_some() {
return None;
}
match selected(s) {
Some((_, InlineObject::Graphic { .. })) => Some(FROZEN),
Some((_, o)) if o.is_floating() => None,
@@ -51,6 +55,15 @@ pub(super) fn has_movable(s: &Session) -> Option<&'static str> {
}
}
/// `has_movable` for what places an object in the text (wrapping, position, alignment, Move with
/// Text): not for a member picked inside a drawing canvas, which the canvas places.
pub(super) fn has_placeable(s: &Session) -> Option<&'static str> {
if crate::cmd::canvas::selected_member(s).is_some() {
return Some("a shape inside a drawing canvas is placed by the canvas");
}
has_movable(s)
}
/// Word's own ink in a group is kept in the form a group member takes; it can't stand on its own
/// yet, so such a group stays whole.
pub(super) const INK_GROUP: &str = "a group holding Word ink can't be ungrouped yet";
@@ -99,7 +112,9 @@ pub fn object_selection(s: &Session) -> Option<(Pos, &InlineObject)> {
o.is_drawing().then_some((a, o))
}
/// The first picture/shape in the selection, or just before a collapsed caret.
/// The first picture/shape in the selection, or just before a collapsed caret. With a canvas
/// member picked ([`crate::cmd::canvas::selected_member`]), that member, at its canvas's position:
/// the commands that change it go through [`edit_obj`], which changes the member.
pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
selected_all(s, 1).into_iter().next()
}
@@ -107,6 +122,9 @@ pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
/// The pictures and shapes in the selection (at most `max`), or the one just before a collapsed
/// caret.
pub fn selected_all(s: &Session, max: usize) -> Vec<(Pos, InlineObject)> {
if let Some((pos, _, member)) = crate::cmd::canvas::selected_member(s) {
return vec![(pos, member.clone())];
}
let mut out = Vec::new();
let (a, b) = s.sel.ordered();
let max = if a == b { 1 } else { max };
@@ -175,8 +193,12 @@ pub(super) fn with_obj(s: &mut Session, f: impl Fn(&mut InlineObject)) -> CmdRes
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)}))
}
/// Change the object at `pos`; returns it afterwards.
/// Change the object at `pos`; returns it afterwards. With a member of the canvas at `pos`
/// picked, the member changes.
pub(crate) fn edit_obj(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Result<InlineObject, CmdError> {
if let Some(r) = crate::cmd::canvas::edit_member_object(s, pos, &f) {
return r;
}
let para = s.doc.para_mut(pos.story, &pos.path)?;
let o = para.object_at_mut(pos.off).ok_or_else(|| CmdError::Failed("object vanished".into()))?;
f(o);
+14 -11
View File
@@ -2,8 +2,8 @@ use super::arrange::{MAX_OFFSET, bounds, copy_to, move_with_text, unalign};
use super::group::{add_object, group, ungroup};
use super::picture::{adjust, adjust_media, clear_lum, crop, original_media, recolor, relook, remove_background, reset, size};
use super::select::{
all_objects, alt_text, can_group, edit_obj, has_floating, has_group, has_movable, has_object, has_picture, has_shape, has_shape_or_canvas,
has_sizable, obj_size, objects_list, selected, with_float, with_obj,
all_objects, alt_text, can_group, edit_obj, has_floating, has_group, has_movable, has_object, has_picture, has_placeable, has_shape,
has_shape_or_canvas, has_sizable, obj_size, objects_list, selected, with_float, with_obj,
};
use super::shape::{NO_TEXT_BOX, can_link, has_text_box, link_text_box, set_text_body, shape_effects, text_box_target};
use super::{CmdError, CommandSpec, InlineObject, Pos, Rgb, Selection, Spin, TextVert, VAlign, Wrap, WrapText, json, p, sel_result};
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("picture.size", "Size", "Picture Format › Size", size)
.params(
r#"{"width"?: pt, "height"?: pt, "lockAspect"?: bool, "scale"?: percent, "original"?: bool} (scale: of the current size, or with original of the picture's original size, its pixels at 96 ppi less what is cropped; original alone restores that size; one side alone keeps the proportions unless lockAspect is false; 1–4000 pt: when the proportions are kept, a side past 4000 pt shrinks the other with it)"#,
r#"{"width"?: pt, "height"?: pt, "lockAspect"?: bool, "scale"?: percent, "original"?: bool} (scale: of the current size, or with original of the picture's original size, its pixels at 96 ppi less what is cropped; original alone restores that size; one side alone keeps the proportions unless lockAspect is false; 1–4000 pt: when the proportions are kept (from the original, or with both sides having a length), a side past 4000 pt shrinks the other with it; a side the call leaves as it was keeps its size, a straight line's 0 too)"#,
)
.when(has_sizable),
CommandSpec::new("picture.crop", "Crop", "Picture Format › Size", crop)
@@ -193,17 +193,17 @@ pub fn specs() -> Vec<CommandSpec> {
with_float(s, |f| f.wrap = wrap)
})
.params(r#"{"wrap": "inline|square|tight|through|topAndBottom|behindText|inFrontOfText"}"#)
.when(has_movable),
.when(has_placeable),
CommandSpec::new("arrange.wrapText", "Wrap Text Sides", "Layout › Arrange", |s, v| {
let side = p::str(v, "side").unwrap_or("bothSides");
let side = WrapText::from_ooxml(side).ok_or_else(|| CmdError::Params(format!("unknown side {side:?}")))?;
with_float(s, |f| f.wrap_text = side)
})
.params(r#"{"side": "bothSides|left|right|largest"} (the sides of the selected Square, Tight or Through object text flows on; largest: the wider side, line by line)"#)
.when(has_movable),
.when(has_placeable),
CommandSpec::new("arrange.moveWithText", "Move with Text", "Layout › Arrange › Wrap Text", |s, v| move_with_text(s, p::bool(v, "value").unwrap_or(true)))
.params(r#"{"value": bool} (true: Move with Text, the floating object's vertical position follows its paragraph; false: Fix Position on Page. It stays where it is)"#)
.when(has_floating),
.when(|s| has_placeable(s).or_else(|| has_floating(s))),
CommandSpec::new("arrange.position", "Position", "Layout › Arrange", |s, v| {
let preset = p::str(v, "preset").unwrap_or("middleCenter").to_string();
let (x, y) = (p::f32(v, "x"), p::f32(v, "y"));
@@ -224,9 +224,9 @@ pub fn specs() -> Vec<CommandSpec> {
})
})
.params(r#"{"preset"?: "topLeft|topCenter|topRight|middleLeft|middleCenter|middleRight|bottomLeft|bottomCenter|bottomRight", "x"?: pt, "y"?: pt}"#)
.when(has_movable),
CommandSpec::new("arrange.bringForward", "Bring Forward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::InFrontOfText)).when(has_movable),
CommandSpec::new("arrange.sendBackward", "Send Backward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::BehindText)).when(has_movable),
.when(has_placeable),
CommandSpec::new("arrange.bringForward", "Bring Forward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::InFrontOfText)).when(has_placeable),
CommandSpec::new("arrange.sendBackward", "Send Backward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::BehindText)).when(has_placeable),
CommandSpec::new("arrange.align", "Align", "Layout › Arrange", |s, v| {
let h = p::str(v, "value").unwrap_or("center").to_string();
let tw = crate::cmd::page::sect(s).text_width();
@@ -245,17 +245,20 @@ pub fn specs() -> Vec<CommandSpec> {
})
})
.params(r#"{"value": "left|center|right"}"#)
.when(has_movable),
.when(has_placeable),
CommandSpec::new("arrange.selectionPane", "Selection Pane", "Layout › Arrange", objects_list).pure(),
CommandSpec::new("arrange.bounds", "Move or Resize", "Layout › Arrange", bounds)
.params(r#"{"width"?: pt, "height"?: pt, "x"?: pt, "y"?: pt, "page"?: n, "to"?: Pos} (x/y: the top-left on page `page` (0-based, default its page); moving an inline object by x/y floats it. to: an In Line with Text object moves to that text position and stays inline, as a drag does)"#)
.when(has_movable),
CommandSpec::new("arrange.copyTo", "Copy to", "Layout › Arrange", copy_to)
.params(r#"{"x": pt, "y": pt, "page"?: n} | {"to": Pos} (Ctrl+drag: a copy of the selected object lands with its top-left there, or an In Line with Text one at the text position `to`, and is selected; the original and the clipboard stay as they were)"#)
.when(has_movable),
.when(has_placeable),
CommandSpec::new("arrange.nudge", "Nudge", "Layout › Arrange", |s, v| {
let d = |k| p::f32(v, k).unwrap_or(0.0).clamp(-MAX_OFFSET, MAX_OFFSET);
let (dx, dy) = (d("dx"), d("dy"));
if let Some(r) = crate::cmd::canvas::member_nudge(s, dx, dy) {
return r;
}
unalign(s)?;
with_float(s, |f| {
f.x = (f.x + dx).clamp(-MAX_OFFSET, MAX_OFFSET);
+123
View File
@@ -14,6 +14,69 @@ fn object_area(s: &mut Session) -> wordcraft_layout::hit::ObjectHit {
s.layout().find_object(0, |o| o.text_box.is_none()).unwrap()
}
/// #4250: a shape dragged into the middle of a page that only continues a long paragraph
/// stays there, as in Word: anchored at the line beside it, it's drawn on that page, not
/// thrown back to the page the paragraph starts on. One undo step puts it back.
#[test]
fn a_shape_dropped_on_a_page_a_paragraph_continues_onto_stays_there() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("text.insert", &json!({"text": "The quick brown fox jumps over the lazy dog. ".repeat(400)})).unwrap();
s.run("caret.docStart", &json!({})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 250, "width": 100, "height": 50})).unwrap();
assert!(s.layout().pages.len() > 2);
let before = (object_area(&mut s), s.undo_depth());
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 275})).unwrap();
let o = object_area(&mut s);
assert_eq!(o.page, 1, "on the page it was dropped on: {o:?}");
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 275.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
assert_eq!(s.undo_depth(), before.1 + 1);
s.run("edit.undo", &json!({})).unwrap();
let back = object_area(&mut s);
assert_eq!((back.page, back.rect), (before.0.page, before.0.rect));
}
/// #4250: and dragged back onto the page the paragraph starts on, it stays there: its anchor
/// goes back to the paragraph's start rather than staying on the later page's line.
#[test]
fn a_shape_dragged_back_to_the_page_its_paragraph_starts_on_stays_there() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("text.insert", &json!({"text": "The quick brown fox jumps over the lazy dog. ".repeat(400)})).unwrap();
s.run("caret.docStart", &json!({})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 275, "width": 100, "height": 50})).unwrap();
let before = (object_area(&mut s), s.undo_depth());
assert!(before.0.page == 1 && before.0.off > 0, "anchored on page 2's line: {:?}", before.0);
s.run("arrange.bounds", &json!({"page": 0, "x": 120, "y": 300})).unwrap();
let o = object_area(&mut s);
assert_eq!((o.page, o.off), (0, 0), "on the page it was dropped on, anchored at the paragraph's start: {o:?}");
assert!((o.rect.x - 120.0).abs() < 0.5 && (o.rect.y - 300.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
assert_eq!(s.undo_depth(), before.1 + 1);
}
/// #4262: dropped beside the first line after a page break inside a paragraph, a shape stays
/// on the page it was dropped on, anchored in that line.
#[test]
fn a_shape_dropped_just_after_a_page_break_in_a_paragraph_stays_there() {
let mut s = Session::new(wordcraft_doc::Document::new());
let text = "The quick brown fox jumps over the lazy dog. ";
s.run("text.insert", &json!({"text": text.repeat(20)})).unwrap();
s.run("insert.pageBreak", &json!({})).unwrap();
s.run("text.insert", &json!({"text": text.repeat(40)})).unwrap();
assert_eq!(s.doc.body.len(), 1, "one paragraph");
s.run("caret.docStart", &json!({})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 250, "width": 100, "height": 50})).unwrap();
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 80})).unwrap();
let o = object_area(&mut s);
let after_break = text.len() * 20 + wordcraft_doc::para::PAGE_BREAK.len_utf8();
assert_eq!((o.page, o.off), (1, after_break), "on the page it was dropped on, anchored right after the break: {o:?}");
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 80.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
}
/// What a click and a handle or body drag do in the editor (#142, #82): hit-test the object,
/// select it, then resize and move it with `arrange.bounds`.
fn click_resize_and_drag(insert: &str, params: Value) {
@@ -971,6 +1034,66 @@ fn picture_size_past_the_limit_keeps_the_proportions() {
}
}
/// A side the call leaves as it was keeps its size, even outside 1–4000 pt: a straight line's 0
/// stays 0 when only its length is typed. Sides set in proportion (the original, or a size whose
/// sides both have a length) fit the limits together; any other side the call sets, on its own.
#[test]
fn picture_size_keeps_the_side_it_leaves() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.shape", &json!({"kind": "straightConnector", "width": 100.0, "height": 0.0})).unwrap();
let size = |s: &Session| obj_size(&selected(s).unwrap().1);
// A side a scale sets on its own: 0 has no length to scale.
let scaled = |x: f32, k: f32| if x == 0.0 { 0.0 } else { (x * k).clamp(1.0, 4000.0) };
// Sizes with both sides follow each other: they fit 1–4000 pt by the one factor nearest 1, worked out by hand
// for the calls `width` locked, `height` locked, `scale` 50 and `scale` 200 with a width (in that order).
let states = [
((100.0, 0.0), None),
((0.0, 100.0), None),
((5000.0, 0.0), None),
((0.0, 5000.0), None),
((100.0, 0.5), Some([(200.0, 1.0), (4000.0, 20.0), (200.0, 1.0), (200.0, 1.0)])),
((5000.0, 50.0), Some([(200.0, 2.0), (4000.0, 40.0), (2500.0, 25.0), (200.0, 2.0)])),
((100.0, 50.0), Some([(200.0, 100.0), (400.0, 200.0), (50.0, 25.0), (200.0, 100.0)])),
];
for ((w0, h0), together) in states {
let follow = |i: usize, alone: (f32, f32)| together.map_or(alone, |t: [(f32, f32); 4]| t[i]);
let cases = [
(json!({"width": 200.0, "lockAspect": false}), (200.0, h0)),
(json!({"height": 200.0, "lockAspect": false}), (w0, 200.0)),
(json!({"width": 200.0}), follow(0, (200.0, h0))),
(json!({"height": 200.0}), follow(1, (w0, 200.0))),
(json!({"scale": 50}), follow(2, (scaled(w0, 0.5), scaled(h0, 0.5)))),
(json!({"scale": 200, "width": 200.0}), follow(3, (200.0, scaled(h0, 2.0)))),
(json!({"scale": 100}), (w0, h0)),
(json!({}), (w0, h0)),
// The Size dialog: both sides sent, the height as it was.
(json!({"width": 200.0, "height": h0, "lockAspect": false}), (200.0, h0)),
(json!({"width": 200.0, "height": 300.0}), (200.0, 300.0)),
];
for (v, (ew, eh)) in cases {
with_obj(&mut s, |o| o.set_size(w0, h0)).unwrap();
s.run("picture.size", &v).unwrap();
let (w, h) = size(&s);
assert!((w - ew).abs() < 1e-3 && (h - eh).abs() < 1e-3, "{w0}×{h0} {v}: {w}×{h}, not {ew}×{eh}");
}
}
// Both sides of a picture's original size fit together, whatever size it had, even when the
// height comes out as it was: 4050×1012.5 pt is 4000×1000, not 4000×1012.5.
let data = super::super::insert::base64_encode(&png(400, 100));
s.run("insert.picture", &json!({"data": data})).unwrap();
for (w0, h0) in [(3000.0, 1012.5), (4050.0, 0.0), (100.0, 100.0)] {
with_obj(&mut s, |o| o.set_size(w0, h0)).unwrap();
s.run("picture.size", &json!({"original": true, "scale": 1350})).unwrap();
let (w, h) = size(&s);
assert!((w - 4000.0).abs() < 0.01 && (h - 1000.0).abs() < 0.01, "{w0}×{h0}: {w}×{h}");
}
// A height the original sets is set, though it is the one the picture had: 300×75 pt at
// 5400 % is 16200×4050, so with a width of 200 the height fits to 4000.
with_obj(&mut s, |o| o.set_size(100.0, 4050.0)).unwrap();
s.run("picture.size", &json!({"original": true, "scale": 5400, "width": 200.0, "lockAspect": false})).unwrap();
assert_eq!(size(&s), (200.0, 4000.0));
}
/// A crop handle drop: `picture.crop` with the new size is one edit, one undo step.
#[test]
fn picture_crop_with_size_is_one_undo_step() {
+28 -2
View File
@@ -18,7 +18,7 @@ mod styles;
mod tabs_borders;
use format::{align, asian_typography, cur, direction, line_spacing, ls, set_align, set_props, tog};
pub use format::{fmt, indent, outdent};
pub use format::{cur as selection_para, fmt, indent, outdent};
pub use lists::{ListButtons, list_buttons, manage_list, touch_all};
use lists::{list, restart_numbering};
pub(crate) use outline::{ensure_heading_style, set_heading};
@@ -183,9 +183,17 @@ pub fn specs() -> Vec<CommandSpec> {
.key(if cfg!(target_os = "macos") { "Ctrl+Shift+Right" } else { "Alt+Shift+Right" }),
CommandSpec::new("para.shading", "Shading", "Home › Paragraph", |s, v| {
let c = p::str(v, "color").and_then(Rgb::parse);
// Text selected within one paragraph is shaded itself, as in Word, so No Color takes
// off a shading a file gave the text (#4685); a caret or paragraphs shade paragraphs.
// `paragraph` (Borders and Shading › Apply to: Paragraph) shades the paragraphs anyway.
if p::bool(v, "paragraph") != Some(true) && shades_text(s) {
return super::format::apply(s, &|x| x.shading = c);
}
fmt(s, &|p| p.shading = c)
})
.params(r#"{"color": "RRGGBB" | null}"#),
.params(
r#"{"color": "RRGGBB" | null, "paragraph"?: bool} (text selected inside one paragraph is shaded itself, as in Word; `paragraph: true` shades the paragraphs instead, as Borders and Shading › Apply to: Paragraph)"#,
),
CommandSpec::new("para.borders", "Borders", "Home › Paragraph", borders).params(
r#"{"kind": "bottom|top|left|right|none|all|outside|inside|horizontalLine", "width"?: pt, "color"?: "RRGGBB", "style"?: "single|double|dotted|dashed|thick|triple|dotDash|wave", "sides"?: Sides} where Sides = {"top"|"left"|"bottom"|"right"|"between": {"style"?, "width"?: pt, "color"?: "RRGGBB"} | null}: exactly these sides (others off), instead of `kind`"#,
),
@@ -264,3 +272,21 @@ pub fn specs() -> Vec<CommandSpec> {
}),
]
}
/// Whether Shading applies to the selected text rather than to paragraphs: a selection inside one
/// paragraph, short of its mark. A selection reaching past the mark ends at the next paragraph's
/// start, so all of a paragraph's text is still text, except in the last paragraph of its story,
/// cell or box, where it stands for the paragraph with its mark. A selected picture, shape or text
/// box is its object character, not text (a field's object character is).
fn shades_text(s: &Session) -> bool {
let (a, b) = s.sel.ordered();
if a == b || a.story != b.story || a.path != b.path {
return false;
}
let Some(p) = s.doc.para_at(&a) else { return false };
if super::objects::object_selection(s).is_some() {
return false;
}
let last = s.doc.next_para(a.story, &a.path).is_none_or(|q| q.parent() != a.path.parent());
!(last && a.off == 0 && b.off >= p.len())
}
+11 -2
View File
@@ -1,7 +1,8 @@
use super::{Align, CmdError, CmdResult, LineSpacing, NumRef, ParaProps, Pos, Session, Value, json, p, sel_result};
/// Resolved paragraph props at the caret.
pub(super) fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara {
/// Resolved paragraph props of the selection: those of the paragraph the caret is in, or of the
/// last paragraph a selection covers when it ends at the next paragraph's start.
pub fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara {
// A selection ending at the next paragraph's start doesn't format that paragraph (#418), so
// its state comes from the last paragraph it does cover.
let (a, b) = s.sel.ordered();
@@ -122,8 +123,16 @@ pub(super) fn set_props(s: &mut Session, v: &Value) -> CmdResult {
for path in crate::cmd::selected_paths(s) {
let Some(para) = s.doc.para(story, &path) else { continue };
let rtl = s.doc.styles.resolve_para(&para.props.clone().overlaid(&props)).bidi;
let anchor_lock = props
.frame
.as_ref()
.filter(|frame| frame.anchor_lock.is_none())
.and_then(|_| para.props.frame.as_ref().and_then(|frame| frame.anchor_lock));
let para = s.doc.para_mut(story, &path)?;
para.props.overlay(&props);
if let (Some(anchor_lock), Some(frame)) = (anchor_lock, para.props.frame.as_mut()) {
frame.anchor_lock = Some(anchor_lock);
}
if let Some(a) = align {
para.props.align = Some(a.visual(rtl));
}
+22 -8
View File
@@ -652,19 +652,33 @@ pub(crate) fn typing_pos(s: &Session, p: &Pos) -> Pos {
if para.objects.is_empty() {
return p.clone();
}
let editable: Vec<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
let is_start = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermStart { id, .. }) if editable.contains(id));
let is_end = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermEnd { id }) if editable.contains(id));
let Some(before) = para.text.get(..p.off) else { return p.clone() };
let Some(after) = para.text.get(p.off..) else { return p.clone() };
if !before.ends_with(OBJ) && !after.starts_with(OBJ) {
return p.clone();
}
let editable: HashSet<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
// Count the preceding objects once. Each adjacent marker can then use the matching
// object directly, forwards for starts and backwards for ends, even with short storage.
let index = before.chars().filter(|c| *c == OBJ).count();
let w = OBJ.len_utf8();
let mut off = p.off;
if is_start(off) {
while is_start(off) {
off += w;
for (ch, object) in after.chars().zip(para.objects.iter().skip(index)) {
if ch != OBJ || !matches!(object, InlineObject::PermStart { id, .. } if editable.contains(id)) {
break;
}
off += w;
}
if off > p.off {
return Pos { off, ..p.clone() };
}
while off >= w && is_end(off - w) {
off -= w;
if let Some(objects) = para.objects.get(..index) {
for (ch, object) in before.chars().rev().zip(objects.iter().rev()) {
if ch != OBJ || !matches!(object, InlineObject::PermEnd { id } if editable.contains(id)) {
break;
}
off -= w;
}
}
Pos { off, ..p.clone() }
}
+156
View File
@@ -922,3 +922,159 @@ fn legacy_password_is_verified_and_unknown_forms_refused() {
assert!(e.to_string().contains(super::UNSUPPORTED_PASSWORD));
assert!(super::enforced(&s.doc));
}
/// Consecutive permission boundaries must not recount the paragraph prefix or search all
/// editable IDs once per marker. Both ends are used by typing and Enter.
#[test]
fn typing_boundary_many_permission_markers_is_not_quadratic() {
use wordcraft_doc::para::Run;
let cpu = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
let measure = |n: usize, at_end: bool| {
let mut p = wordcraft_doc::Paragraph::new();
p.text.push_str("λ prefix ");
let start = p.text.len();
for i in 0..n {
p.text.push(OBJ);
p.objects.push(InlineObject::PermStart { id: i as u32, group: "everyone".into(), editor: String::new() });
}
p.text.push_str("body");
let end = p.text.len();
for i in 0..n {
p.text.push(OBJ);
p.objects.push(InlineObject::PermEnd { id: i as u32 });
}
let after = p.text.len();
p.runs.push(Run { len: p.text.len(), props: CharProps::default() });
let mut doc = Document::new();
doc.body = vec![Block::Para(p).into()];
doc.settings.protection = Some("readOnly".into());
let s = Session::new(doc);
let at = Pos::body(0, if at_end { after } else { start });
let expected = Pos::body(0, if at_end { end } else { start + n * OBJ.len_utf8() });
let before = cpu();
for _ in 0..3 {
assert_eq!(typing_pos(std::hint::black_box(&s), &at), expected);
}
match (before, cpu()) {
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
_ => None,
}
};
for at_end in [false, true] {
let (small, large) = (measure(2_000, at_end), measure(16_000, at_end));
let (Some(a), Some(b)) = (small, large) else {
eprintln!("Typing boundary: CPU counter unavailable; semantic checks passed, timing assertion skipped");
continue;
};
eprintln!("Typing boundary end={at_end}: small={a:.9}s large={b:.9}s ratio={:.2}", b / a);
assert!(b < a * 24.0 + 0.005, "8x permission markers must not take quadratic boundary work: {a:.9}s -> {b:.9}s");
}
}
#[test]
fn typing_boundary_keeps_author_precedence_and_malformed_pairing() {
use wordcraft_doc::para::Run;
// Frozen boundary semantics from before the one-pass lookup. This oracle deliberately
// uses object_at independently of the new traversal, including malformed storage.
let original = |s: &Session, p: &Pos| {
if !matches!(s.doc.settings.protection.as_deref(), Some("readOnly" | "comments")) {
return p.clone();
}
let Some(para) = s.doc.para_at(p) else { return p.clone() };
if para.objects.is_empty() {
return p.clone();
}
let editable: Vec<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
let start = |off| matches!(para.object_at(off), Some(InlineObject::PermStart { id, .. }) if editable.contains(id));
let end = |off| matches!(para.object_at(off), Some(InlineObject::PermEnd { id }) if editable.contains(id));
let mut off = p.off;
if start(off) {
while start(off) {
off += OBJ.len_utf8();
}
} else {
while off >= OBJ.len_utf8() && end(off - OBJ.len_utf8()) {
off -= OBJ.len_utf8();
}
}
Pos { off, ..p.clone() }
};
let objects = [
InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() },
InlineObject::PermStart { id: 2, group: String::new(), editor: " ALICE ".into() },
InlineObject::PermStart { id: 3, group: String::new(), editor: "alice".into() },
InlineObject::PermEnd { id: 1 },
InlineObject::PermEnd { id: 3 },
InlineObject::BookmarkStart { name: "Original".into() },
InlineObject::PermEnd { id: 7 },
InlineObject::PermStart { id: 7, group: String::new(), editor: "Bob".into() },
];
for text in
[format!("λ{}x{}終", OBJ.to_string().repeat(4), OBJ.to_string().repeat(4)), OBJ.to_string().repeat(9), format!("λ{OBJ}{OBJ}x{OBJ}{OBJ}{OBJ}")]
{
for count in 0..=objects.len() {
for mode in [None, Some("readOnly"), Some("comments"), Some("forms"), Some("trackedChanges")] {
let mut p = wordcraft_doc::Paragraph::new();
p.text = text.clone();
p.objects = objects[..count].to_vec();
p.runs.push(Run { len: p.text.len(), props: CharProps::default() });
let mut doc = Document::new();
doc.body = vec![Block::Para(p).into()];
doc.settings.protection = mode.map(str::to_string);
let mut s = Session::new(doc);
s.author = " Alice ".into();
for off in 0..=text.len() + 1 {
let at = Pos::body(0, off);
assert_eq!(typing_pos(&s, &at), original(&s, &at), "mode={mode:?} count={count} off={off} text={text:?}");
}
}
}
}
let mut s = Session::new(Document::from_text("outside"));
s.doc.settings.protection = Some("readOnly".into());
assert_eq!(typing_pos(&s, &Pos::body(99, 0)), Pos::body(99, 0));
}
#[test]
fn typing_boundary_keeps_duplicate_ids_across_stories_and_start_precedence() {
use wordcraft_doc::para::Run;
let paragraph = |text: String, objects| wordcraft_doc::Paragraph {
runs: vec![Run { len: text.len(), props: CharProps::default() }],
text,
objects,
..Default::default()
};
let mut doc = Document::new();
doc.body = vec![
Block::Para(paragraph(
format!("{OBJ}x{OBJ}"),
vec![InlineObject::PermStart { id: 1, group: String::new(), editor: "Bob".into() }, InlineObject::PermEnd { id: 1 }],
))
.into(),
];
doc.add_part(
wordcraft_doc::PartKind::Header,
vec![
Block::Para(paragraph(
format!("{OBJ}y{OBJ}"),
vec![InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() }, InlineObject::PermEnd { id: 1 }],
))
.into(),
],
);
doc.settings.protection = Some("comments".into());
let mut s = Session::new(doc);
s.author = "Alice".into();
// Preserve the existing document-wide ID membership, even for a duplicate in another story.
assert_eq!(typing_pos(&s, &Pos::body(0, 0)), Pos::body(0, OBJ.len_utf8()));
assert_eq!(typing_pos(&s, &Pos::body(0, 7)), Pos::body(0, 4));
s.doc.body = vec![
Block::Para(paragraph(
format!("{OBJ}{OBJ}x"),
vec![InlineObject::PermEnd { id: 1 }, InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() }],
))
.into(),
];
// A start to the right wins over an end to the left.
assert_eq!(typing_pos(&s, &Pos::body(0, 3)), Pos::body(0, 6));
}
Loaded 100 of 263 files, more files were not shown because too many files have changed in this diff. Show more