mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-06 10:19:59 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfcc8c2aec | ||
|
|
64a3badfe3 | ||
|
|
edfea68101 | ||
|
|
74d3701c74 | ||
|
|
8512ac6f36 | ||
|
|
421700e890 | ||
|
|
d69cbec87f |
+1
-20
@@ -7,30 +7,11 @@ updates:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# 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.
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
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)"
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
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
|
||||
@@ -1,62 +1,10 @@
|
||||
<!-- 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
|
||||
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.
|
||||
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.
|
||||
@@ -80,7 +28,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
|
||||
## Architecture (see `plan/architecture.md`)
|
||||
| Layer | Crate | Job |
|
||||
|---|---|---|
|
||||
| L0 | `geom` | units, rects, measurement parsing |
|
||||
@@ -96,6 +44,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
|
||||
- **Everything is a command.** User-visible behaviour = a `CommandSpec` in `crates/engine/src/cmd/*.rs` (id, label, ribbon location, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. The ribbon, shortcuts, command search, CLI, control channel and MCP all dispatch by id. Programmatic calls never open dialogs.
|
||||
- **Parity is measured:** `crates/engine/src/catalog.rs` lists Word's ribbon/menu features with command ids. `cargo xtask parity` writes `docs/parity-checklist.md`; a test enforces a floor that only rises. Implement missing ids to raise it.
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin and reads `Session` state; colours come from `theme::Tokens`.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
## Quality gates
|
||||
@@ -136,5 +85,3 @@ 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 +1,71 @@
|
||||
@AGENTS.md
|
||||
# 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.
|
||||
|
||||
Generated
+20
-35
@@ -1090,10 +1090,9 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=a3e0b5a9440d18f19422a9dca13516747b7d3181#a3e0b5a9440d18f19422a9dca13516747b7d3181"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
"roxmltree",
|
||||
"tempfile",
|
||||
@@ -1158,15 +1157,6 @@ 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"
|
||||
@@ -6795,7 +6785,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -6815,9 +6805,7 @@ dependencies = [
|
||||
"open",
|
||||
"pollster 1.0.1",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"windows",
|
||||
"winit",
|
||||
"winresource",
|
||||
@@ -6827,13 +6815,12 @@ dependencies = [
|
||||
"wordcraft-engine",
|
||||
"wordcraft-fonts",
|
||||
"wordcraft-ui-egui",
|
||||
"x11rb",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-cli"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sys-locale",
|
||||
@@ -6846,11 +6833,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-control-key"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-doc"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"log",
|
||||
@@ -6864,7 +6851,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docbin"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6881,7 +6868,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-docx"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6893,7 +6880,6 @@ dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
"quick-xml 0.41.0",
|
||||
"serde_json",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
@@ -6906,7 +6892,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-engine"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
@@ -6939,7 +6925,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"harfrust 0.13.3",
|
||||
@@ -6953,7 +6939,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-formats"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
@@ -6980,7 +6966,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-geom"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -6988,7 +6974,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-layout"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"craft-proof",
|
||||
"image",
|
||||
@@ -7008,7 +6994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-mcp"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
@@ -7020,7 +7006,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-metafile"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"thiserror 2.0.21",
|
||||
@@ -7028,7 +7014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-pdf"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
@@ -7049,7 +7035,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-render"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -7063,7 +7049,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-ui-egui"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"accesskit_consumer",
|
||||
"cfb",
|
||||
@@ -7096,9 +7082,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-web"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
@@ -7116,7 +7101,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wordcraft-zotero"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -7279,7 +7264,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.15.1"
|
||||
version = "0.15.2"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -299,21 +299,10 @@ 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), 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:
|
||||
of file drag-and-drop), start it with `WORDCRAFT_WAYLAND=1`:
|
||||
|
||||
```sh
|
||||
WORDCRAFT_WAYLAND=1 wordcraft
|
||||
|
||||
+1
-79
File diff suppressed because one or more lines are too long
@@ -430,12 +430,7 @@ 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 = 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 format = arg_value(&rest, "--format");
|
||||
let paths = positionals(&rest, CONVERT_OPTIONS);
|
||||
let (i, o) = (paths.first().ok_or_else(missing_argument)?.to_string(), paths.get(1).ok_or_else(missing_argument)?.to_string());
|
||||
let markup = rest.iter().any(|a| a == "--markup");
|
||||
|
||||
@@ -75,22 +75,6 @@ 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 {
|
||||
|
||||
@@ -16,8 +16,6 @@ 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 }
|
||||
@@ -31,4 +29,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams", "CustomEvent", "CustomEventInit"] }
|
||||
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"] }
|
||||
|
||||
+107
-65
@@ -1,20 +1,14 @@
|
||||
//! 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.
|
||||
//! 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=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:
|
||||
//! - `wordcraft:ready` (to the parent): `{ version }`, sent once the app is running and
|
||||
//! listening; send a file now.
|
||||
//! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
|
||||
//! `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.
|
||||
//! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
|
||||
//! every file WordCraft would otherwise download. The parent stores it and reports failures
|
||||
//! itself. `saveAs` says whether the file becomes the document's file:
|
||||
//! - Save: `false`. It writes the opened file under its own name and format, as the desktop
|
||||
//! app does. A name the parent never sent (an untitled document, an imported .doc saved as
|
||||
//! .docx) is a new file: `true`, as for Save As.
|
||||
@@ -23,57 +17,70 @@
|
||||
//! 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` whenever unsaved changes appear or go away, `wordcraft:failed` when the
|
||||
//! app couldn't start.
|
||||
//! - `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.
|
||||
//!
|
||||
//! 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.
|
||||
//! Only the parent window on the page's own origin is heard or answered. In host mode the parent
|
||||
//! guards leaving the page, and `?author=` names the user for comments and tracked changes.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use craft_web_host::web::{FileData, Host, Message};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
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 {
|
||||
host: Rc<Host>,
|
||||
window: web_sys::Window,
|
||||
parent: web_sys::Window,
|
||||
origin: String,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// 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)?) })
|
||||
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent is
|
||||
/// on another origin: messages only go to this origin, so the browser would drop every save
|
||||
/// without an error while the document showed as saved.
|
||||
pub fn new() -> Option<Self> {
|
||||
let window = web_sys::window()?;
|
||||
let parent = window.parent().ok().flatten()?;
|
||||
if js_sys::Object::is(&parent, &window) {
|
||||
return None;
|
||||
}
|
||||
let origin = window.location().origin().ok()?;
|
||||
// An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
|
||||
if origin == "null" {
|
||||
return None;
|
||||
}
|
||||
// Reading another origin's location throws, which is `None` too.
|
||||
if parent.location().origin().ok()? != origin {
|
||||
return None;
|
||||
}
|
||||
Some(Self { window, parent, origin })
|
||||
}
|
||||
|
||||
/// Hand saved and exported files to the host instead of downloading them, report unsaved
|
||||
/// Hand saved and exported files to the parent instead of downloading them, report unsaved
|
||||
/// changes to it, and hear the files it sends.
|
||||
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
|
||||
self.listen(inbox, ctx);
|
||||
let bridge = self.clone();
|
||||
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
|
||||
// Anything else that would download goes to the host too.
|
||||
// Anything else that would download goes to the parent too.
|
||||
let bridge = self.clone();
|
||||
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false)));
|
||||
// The browser's save picker is never used, so no save finishes later.
|
||||
services.async_saves = None;
|
||||
// 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)));
|
||||
let bridge = self.clone();
|
||||
let reported: Rc<Cell<Option<bool>>> = Rc::default();
|
||||
services.on_dirty = Some(Box::new(move |dirty: bool| {
|
||||
if reported.get() != Some(dirty) && bridge.send(&[("type", "wordcraft:dirty".into()), ("dirty", dirty.into())]) {
|
||||
reported.set(Some(dirty));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// `?author=`: the host's name for the user signs comments and tracked changes.
|
||||
@@ -85,43 +92,78 @@ impl Bridge {
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
|
||||
}
|
||||
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
let _ = self.host.post_failed(error, None);
|
||||
self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
|
||||
}
|
||||
|
||||
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
self.host.post_save(&name, bytes, None, save_as)
|
||||
let buffer = js_sys::Uint8Array::from(bytes).buffer();
|
||||
let message = message(&[
|
||||
("type", "wordcraft:save".into()),
|
||||
("name", name.as_str().into()),
|
||||
("saveAs", save_as.into()),
|
||||
("bytes", buffer.clone().into()),
|
||||
])
|
||||
.ok_or_else(|| "couldn't build the message".to_string())?;
|
||||
self.parent
|
||||
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
|
||||
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
|
||||
}
|
||||
|
||||
/// Hear `wordcraft:open` and queue the file like a dropped one.
|
||||
/// Hear `wordcraft:open` from the parent and queue the file like a dropped one.
|
||||
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
|
||||
self.host.listen(move |message| {
|
||||
if let Message::Open { name, data } = message {
|
||||
deliver_open(&inbox, &ctx, &name, &data);
|
||||
let parent = self.parent.clone();
|
||||
let origin = self.origin.clone();
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
|
||||
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
|
||||
return;
|
||||
}
|
||||
let data = event.data();
|
||||
if string(&data, "type").as_deref() != Some("wordcraft:open") {
|
||||
return;
|
||||
}
|
||||
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Document.docx".to_string());
|
||||
let Some(bytes) = bytes(&data) else { return };
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
|
||||
// The listener lives as long as the page.
|
||||
on_message.forget();
|
||||
}
|
||||
}
|
||||
|
||||
/// Post a message to the parent; false if it couldn't be sent.
|
||||
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
|
||||
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
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
|
||||
}
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, array.to_vec()));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
@@ -9,10 +9,8 @@
|
||||
//! 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`). 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.
|
||||
//! document to open and stores what WordCraft saves (`host`), and `&author=` names the user for
|
||||
//! comments and tracked changes.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
|
||||
@@ -28,12 +28,9 @@ pub fn start() {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
// 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);
|
||||
// `?host=parent`: a same-origin page framing WordCraft opens documents in it and stores
|
||||
// what it saves (`host`). That page guards leaving it, so this one doesn't.
|
||||
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
|
||||
let app_bridge = bridge.clone();
|
||||
let dirty = Rc::new(Cell::new(false));
|
||||
let saves = AsyncSaves::default();
|
||||
|
||||
@@ -22,8 +22,6 @@ 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 }
|
||||
|
||||
@@ -40,14 +38,13 @@ 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; and `NSPasteboard` for the clipboard's RTF.
|
||||
# traffic lights, the window, button and view ones.
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication", "NSWindow", "NSButton", "NSControl", "NSView", "NSResponder", "NSPasteboard"] }
|
||||
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, and NSData
|
||||
# for the clipboard's RTF (`public.rtf`).
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSData"] }
|
||||
# the build through arboard, at this version; this adds only the NSString bindings.
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString"] }
|
||||
# Closing the window must quit through NSApplication rather than by dropping the NSWindow
|
||||
# inside the AppKit run loop (see `schedule_macos_terminate`).
|
||||
dispatch2 = "0.3"
|
||||
@@ -61,9 +58,6 @@ 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.
|
||||
@@ -79,9 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi
|
||||
clipboard-win = { version = "5.4", features = ["std"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# 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" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
rfd = { workspace = true }
|
||||
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
|
||||
# rfd itself already depends on it.
|
||||
|
||||
+15
-222
@@ -18,11 +18,6 @@
|
||||
//! 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;
|
||||
@@ -36,24 +31,17 @@ thread_local! {
|
||||
static WRITER: std::cell::RefCell<Option<arboard::Clipboard>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
/// Put `html` on the clipboard with `text` as its plain-text alternative, and `mathml` (a copy of
|
||||
/// one equation alone; empty for any other copy) in the platform's MathML formats. `false` when
|
||||
/// the HTML and text couldn't be set (the caller copies the text alone), including a Wayland
|
||||
/// session without the data-control protocol, where arboard would fall back to X11 and native
|
||||
/// Wayland programs might not see it. MathML that can't be added is logged: the HTML still
|
||||
/// carries it.
|
||||
pub fn set_html(html: &str, text: &str, mathml: &str) -> bool {
|
||||
if !platform::can_set() {
|
||||
return false;
|
||||
}
|
||||
let set = set_html_text(html, text);
|
||||
if set && !rtf.is_empty() {
|
||||
platform::add_rtf(rtf);
|
||||
}
|
||||
if set && !mathml.is_empty() && mathml.len() <= MAX_PASTE_BYTES {
|
||||
platform::add_mathml(mathml);
|
||||
}
|
||||
@@ -66,8 +54,7 @@ fn set_html_text(html: &str, text: &str) -> bool {
|
||||
if slot.is_none() {
|
||||
*slot = open();
|
||||
}
|
||||
let set = slot.as_mut().map(|c| if html.is_empty() { c.set_text(text) } else { c.set().html(html, Some(text)) });
|
||||
match set {
|
||||
match slot.as_mut().map(|c| c.set().html(html, Some(text))) {
|
||||
Some(Ok(())) => true,
|
||||
Some(Err(e)) => {
|
||||
log::warn!("clipboard: {e}");
|
||||
@@ -89,8 +76,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"), macOS (`public.rtf`), Wayland and X11
|
||||
/// (`text/rtf`); not offered by arboard, so read here.
|
||||
/// The clipboard's RTF: Windows ("Rich Text Format") and Wayland (`text/rtf`); not offered by
|
||||
/// arboard, so read here.
|
||||
pub fn rtf() -> Option<String> {
|
||||
let bytes = platform::rtf()?;
|
||||
if bytes.len() > MAX_PASTE_BYTES {
|
||||
@@ -189,51 +176,15 @@ 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 std::env::var_os("DISPLAY").is_some().then(|| x11::rtf(super::MAX_PASTE_BYTES)).flatten();
|
||||
return None;
|
||||
}
|
||||
let mime = rtf_target(&formats())?;
|
||||
let offered = formats();
|
||||
let mime = ["text/rtf", "application/rtf", "text/richtext"].into_iter().find(|m| offered.iter().any(|o| o == m))?;
|
||||
read(mime, super::MAX_PASTE_BYTES as u64)
|
||||
}
|
||||
|
||||
@@ -245,125 +196,6 @@ 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")]
|
||||
@@ -419,23 +251,6 @@ 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) {
|
||||
@@ -461,18 +276,14 @@ const MATHML_FORMATS: [&str; 2] = ["MathML", "MathML Presentation"];
|
||||
#[cfg(target_os = "macos")]
|
||||
mod platform {
|
||||
use objc2_app_kit::NSPasteboard;
|
||||
use objc2_foundation::{NSData, NSString};
|
||||
|
||||
/// RTF's pasteboard type (`NSPasteboardTypeRTF`, whose extern static needs unsafe to read).
|
||||
const RTF: &str = "public.rtf";
|
||||
use objc2_foundation::NSString;
|
||||
|
||||
pub fn formats() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub fn rtf() -> Option<Vec<u8>> {
|
||||
let data = NSPasteboard::generalPasteboard().dataForType(&NSString::from_str(RTF))?;
|
||||
(data.len() <= super::MAX_PASTE_BYTES).then(|| data.to_vec())
|
||||
None
|
||||
}
|
||||
|
||||
pub fn encoded_picture() -> Option<Vec<u8>> {
|
||||
@@ -483,18 +294,6 @@ 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) {
|
||||
@@ -530,11 +329,5 @@ 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) {}
|
||||
}
|
||||
|
||||
@@ -15,25 +15,9 @@ 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, 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")
|
||||
pub fn skips_indirect_validation(macos: bool, intel: bool) -> bool {
|
||||
macos && intel
|
||||
}
|
||||
|
||||
/// Leaves wgpu's indirect-call validation out of the instance eframe creates when `skip`.
|
||||
@@ -197,8 +181,7 @@ 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.
|
||||
// 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());
|
||||
assert!(skips_indirect_validation(true, true));
|
||||
// Apple silicon, Windows and Linux keep wgpu's validation.
|
||||
for (macos, intel) in [(true, false), (false, true), (false, false)] {
|
||||
assert!(!skips_indirect_validation(macos, intel), "macos={macos} intel={intel}");
|
||||
@@ -222,36 +205,6 @@ 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);
|
||||
|
||||
+14
-80
@@ -253,45 +253,13 @@ fn load_user_fonts() {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// The saved window geometry, read before the window opens (`load_prefs` runs after).
|
||||
fn saved_window() -> Option<WindowGeometry> {
|
||||
if !prefs_enabled() {
|
||||
return None;
|
||||
}
|
||||
let bytes = std::fs::read(prefs_path()?).ok()?;
|
||||
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()))
|
||||
UiState::from_json(&bytes)?.window?.sanitized()
|
||||
}
|
||||
|
||||
fn save_prefs(app: &WordApp) {
|
||||
@@ -337,7 +305,6 @@ 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.
|
||||
@@ -345,7 +312,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_rich: Some(Box::new(clipboard::set_rich)),
|
||||
set_clipboard_html: Some(Box::new(clipboard::set_html)),
|
||||
clipboard_rtf: Some(Box::new(clipboard::rtf)),
|
||||
clipboard_formats: Some(Box::new(clipboard::formats)),
|
||||
..Default::default()
|
||||
@@ -440,10 +407,6 @@ 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<_>>()) {
|
||||
@@ -523,28 +486,25 @@ 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). An x86_64
|
||||
// build under Rosetta on Apple silicon keeps them (#5199).
|
||||
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652).
|
||||
#[cfg(target_os = "macos")]
|
||||
graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64")));
|
||||
load_user_fonts();
|
||||
// Windows opened for further documents start like the first one, before its saved place.
|
||||
let window_builder = options.viewport.clone();
|
||||
let saved = saved_ui();
|
||||
let restored = saved.as_ref().and_then(|ui| ui.window?.sanitized());
|
||||
let restored = saved_window();
|
||||
if let Some(window) = restored {
|
||||
options.viewport = window.apply(options.viewport);
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
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();
|
||||
}));
|
||||
}
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; WORDCRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("WORDCRAFT_WAYLAND").is_none() {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
// Registered before the event loop starts, so it catches the Finder event that launched us as
|
||||
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
|
||||
@@ -658,32 +618,6 @@ 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]
|
||||
|
||||
+20
-116
@@ -1,21 +1,13 @@
|
||||
//! 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::printer_queues()
|
||||
let printers = craft_print::printers()
|
||||
.into_iter()
|
||||
.map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default })
|
||||
.collect();
|
||||
@@ -25,59 +17,22 @@ 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<Job>> = std::sync::Mutex::new(Vec::new());
|
||||
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = 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 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() {
|
||||
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
|
||||
for worker in workers {
|
||||
if worker.join().is_err() {
|
||||
log::error!("the print worker stopped unexpectedly during shutdown");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
@@ -93,16 +48,12 @@ pub fn submit() -> Submit {
|
||||
title: "WordCraft document".into(),
|
||||
options,
|
||||
};
|
||||
#[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);
|
||||
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
|
||||
});
|
||||
if let Ok(worker) = 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) });
|
||||
let mut jobs = JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
jobs.retain(|job| !job.is_finished());
|
||||
jobs.push(worker);
|
||||
}
|
||||
receiver
|
||||
})
|
||||
@@ -111,67 +62,20 @@ pub fn submit() -> Submit {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// A worker that waits until released, then reports that it finished.
|
||||
fn worker() -> (std::thread::JoinHandle<()>, std::sync::mpsc::Sender<()>, std::sync::mpsc::Receiver<()>) {
|
||||
#[test]
|
||||
fn shutdown_waits_for_pending_submission_workers() {
|
||||
let (release, wait) = std::sync::mpsc::channel();
|
||||
let (completed, completion) = std::sync::mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let _ = wait.recv();
|
||||
let _ = completed.send(());
|
||||
wait.recv().unwrap();
|
||||
completed.send(()).unwrap();
|
||||
});
|
||||
(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();
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,592 +0,0 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -162,8 +162,6 @@ sync_github_name = true
|
||||
|
||||
[people.maxm11]
|
||||
real_name = "Max Murphy"
|
||||
display_name = "Max Murphy"
|
||||
sync_github_name = false
|
||||
|
||||
[people.SlavenDj]
|
||||
sync_github_name = true
|
||||
@@ -194,6 +192,3 @@ sync_github_name = true
|
||||
|
||||
[people.entropybeater]
|
||||
display_name = "entropybeater"
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1e515422afdda2f317af430c1f9d68a5f83935b4
|
||||
9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
|
||||
|
||||
@@ -16,7 +16,7 @@ workspace = true
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
wordcraft-geom = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -213,9 +213,7 @@ 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;
|
||||
|
||||
@@ -609,10 +609,8 @@ 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) {
|
||||
|
||||
@@ -479,7 +479,7 @@ 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, the watermark, and the notes' numbering.
|
||||
/// picture, 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);
|
||||
@@ -492,7 +492,6 @@ pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
|
||||
parts,
|
||||
&s.page_color,
|
||||
&s.page_picture,
|
||||
&s.watermark,
|
||||
(&s.footnote_format, &s.endnote_format, &s.footnote_pr, &s.endnote_pr),
|
||||
(&doc.footnote_separators, &doc.endnote_separators),
|
||||
)) {
|
||||
|
||||
@@ -120,57 +120,14 @@ fn attr_in(tag: &str) -> Option<String> {
|
||||
Some(unescape(rest.get(..end)?))
|
||||
}
|
||||
|
||||
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
|
||||
fn unescape(s: &str) -> String {
|
||||
s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&")
|
||||
}
|
||||
|
||||
#[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="Name"/><w:ddList><w:listEntry w:val="A B	C D😀&#10;<>"'"/></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😀 <>\"'"));
|
||||
let malformed = r#"<w:ffData><w:ddList><w:listEntry w:val="���&#-1;&#xZZ;&unknown;&"/></w:ddList></w:ffData>"#;
|
||||
assert_eq!(LegacyForm::parse(malformed).unwrap().shown_text(), Some("���&#-1;&#xZZ;&unknown;&"));
|
||||
}
|
||||
|
||||
#[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>"#);
|
||||
|
||||
@@ -48,7 +48,6 @@ pub mod smart_art;
|
||||
pub mod styles;
|
||||
pub mod symbol_fonts;
|
||||
pub mod table;
|
||||
pub mod watermark;
|
||||
pub mod wordart;
|
||||
pub mod work;
|
||||
pub mod wrap;
|
||||
@@ -91,9 +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};
|
||||
pub use watermark::WatermarkPicture;
|
||||
|
||||
/// A document.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -22,29 +22,10 @@ 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/."];
|
||||
|
||||
/// 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.
|
||||
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`) from either end.
|
||||
fn strip_currency(s: &str) -> &str {
|
||||
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);
|
||||
}
|
||||
let mut s = s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c));
|
||||
if let Some((head, word)) = s.rsplit_once(SPACES)
|
||||
&& (1..=3).contains(&word.chars().count())
|
||||
&& word.chars().all(|c| c.is_alphabetic() || c == '.')
|
||||
@@ -156,7 +137,6 @@ 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.
|
||||
@@ -194,40 +174,11 @@ 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));
|
||||
|
||||
@@ -16,12 +16,6 @@ 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 {
|
||||
|
||||
@@ -769,30 +769,6 @@ 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| {
|
||||
@@ -896,28 +872,6 @@ 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 { .. }]) {
|
||||
|
||||
+30
-137
@@ -294,14 +294,6 @@ 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.
|
||||
@@ -328,7 +320,6 @@ impl Counters {
|
||||
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 {
|
||||
self.labels.insert((abs.id, lv as u8), CountedLabel { label: None, levels: Vec::new() });
|
||||
return None;
|
||||
}
|
||||
let st = self.state.entry(abs.id).or_insert([0; 9]);
|
||||
@@ -375,101 +366,42 @@ impl Counters {
|
||||
if restarts {
|
||||
*deeper = 0;
|
||||
*f = LevelFlags::default();
|
||||
self.labels.remove(&(abs.id, d as u8));
|
||||
}
|
||||
}
|
||||
let vals = *st;
|
||||
let started = *fl;
|
||||
let numeric = def.format != NumFormat::Bullet;
|
||||
let label = if numeric { render_label(numbering, num, &def, &vals, &started) } else { def.text.clone() };
|
||||
self.labels.insert((abs.id, lv as u8), CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) });
|
||||
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);
|
||||
}
|
||||
}
|
||||
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").
|
||||
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);
|
||||
}
|
||||
}
|
||||
label
|
||||
}
|
||||
|
||||
/// Longest level text (in characters) expanded into a list label.
|
||||
@@ -481,45 +413,6 @@ 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();
|
||||
|
||||
@@ -316,33 +316,19 @@ 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 { 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 };
|
||||
children
|
||||
.iter()
|
||||
.take(MAX_GROUP_CHILDREN)
|
||||
.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((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 InlineObject::Group { ch_w, ch_h, children, .. } = self else { return Vec::new() };
|
||||
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
|
||||
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
|
||||
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
|
||||
(sx, sy)
|
||||
children
|
||||
.iter()
|
||||
.take(MAX_GROUP_CHILDREN)
|
||||
.filter_map(|c| {
|
||||
let (cw, chh, _) = c.obj.frame()?;
|
||||
let r = [x + fin(c.x) * sx, y + fin(c.y) * sy, (fin(cw) * sx).clamp(0.0, 4000.0), (fin(chh) * sy).clamp(0.0, 4000.0)];
|
||||
(!matches!(c.obj, InlineObject::Group { .. })).then_some((r, &c.obj))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`), read from
|
||||
/// the `w:ffData` it keeps; `None` for anything else.
|
||||
|
||||
@@ -305,9 +305,6 @@ 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,
|
||||
@@ -362,9 +359,6 @@ 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")]
|
||||
@@ -453,7 +447,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, adjust_right_indent, tabs, shading, borders);
|
||||
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, tabs, shading, borders);
|
||||
}
|
||||
|
||||
fn overlay_pagination(&mut self, patch: &ParaProps) {
|
||||
|
||||
@@ -153,10 +153,6 @@ 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 {
|
||||
@@ -177,9 +173,6 @@ 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,
|
||||
@@ -271,7 +264,6 @@ 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),
|
||||
|
||||
@@ -115,40 +115,6 @@ 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).
|
||||
@@ -783,9 +749,6 @@ 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")]
|
||||
@@ -816,13 +779,6 @@ 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.
|
||||
@@ -853,8 +809,6 @@ 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,
|
||||
@@ -867,10 +821,6 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,20 +234,12 @@ pub struct Watermark {
|
||||
pub font: String,
|
||||
pub color: Rgb,
|
||||
pub diagonal: bool,
|
||||
/// Word's Semitransparent check box (the text 50 % transparent).
|
||||
pub semitransparent: bool,
|
||||
/// The text's transparency in percent when it is neither 0 nor 50 (see
|
||||
/// [`Watermark::transparency`]); `None` follows `semitransparent`, as older documents do.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub transparency: Option<u8>,
|
||||
/// The shape's box (its `style` width and height, points), which the text is stretched to
|
||||
/// fill (`v:textpath fitshape`); `None`: the box Word's Design › Watermark gives
|
||||
/// ([`Watermark::frame`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<[f32; 2]>,
|
||||
/// A picture watermark, drawn instead of the text (which is kept for the dialog).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub picture: Option<crate::watermark::WatermarkPicture>,
|
||||
}
|
||||
|
||||
impl Default for Watermark {
|
||||
@@ -258,35 +250,12 @@ impl Default for Watermark {
|
||||
color: Rgb(0xC0, 0xC0, 0xC0),
|
||||
diagonal: true,
|
||||
semitransparent: true,
|
||||
transparency: None,
|
||||
size: None,
|
||||
picture: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Watermark {
|
||||
/// The text's transparency in percent: 0 is opaque, 100 invisible, Semitransparent is 50.
|
||||
pub fn transparency(&self) -> u8 {
|
||||
match self.transparency {
|
||||
Some(t) => t.min(100),
|
||||
None if self.semitransparent => 50,
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the text's transparency in percent (at most 100). Semitransparent follows it.
|
||||
pub fn set_transparency(&mut self, percent: u8) {
|
||||
let t = percent.min(100);
|
||||
self.semitransparent = t > 0;
|
||||
self.transparency = (t != 0 && t != 50).then_some(t);
|
||||
}
|
||||
|
||||
/// The text's opacity, 0 to 1.
|
||||
pub fn opacity(&self) -> f32 {
|
||||
1.0 - f32::from(self.transparency()) / 100.0
|
||||
}
|
||||
|
||||
/// The box the text fills on a page `page_w` × `page_h` (points): its own size, else half the
|
||||
/// page diagonal across (half the page width when not diagonal), as high as the text's length
|
||||
/// makes it. Centred on the margins and, when diagonal, turned 45° counterclockwise (Word's
|
||||
@@ -363,30 +332,3 @@ impl Default for Settings {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod watermark_tests {
|
||||
use super::Watermark;
|
||||
|
||||
#[test]
|
||||
fn transparency_follows_semitransparent() {
|
||||
let mut wm = Watermark::default();
|
||||
assert_eq!((wm.transparency(), wm.opacity()), (50, 0.5));
|
||||
wm.semitransparent = false;
|
||||
assert_eq!(wm.transparency(), 0);
|
||||
wm.set_transparency(80);
|
||||
assert_eq!((wm.semitransparent, wm.transparency, wm.transparency()), (true, Some(80), 80));
|
||||
// Word's two values are stored as the check box alone.
|
||||
wm.set_transparency(50);
|
||||
assert_eq!((wm.semitransparent, wm.transparency), (true, None));
|
||||
wm.set_transparency(0);
|
||||
assert_eq!((wm.semitransparent, wm.transparency), (false, None));
|
||||
wm.set_transparency(255);
|
||||
assert_eq!((wm.transparency(), wm.opacity()), (100, 0.0));
|
||||
// Older documents have no transparency field.
|
||||
let old: Watermark = serde_json::from_str(r#"{"text":"X","semitransparent":false}"#).unwrap();
|
||||
assert_eq!(old.transparency(), 0);
|
||||
let hostile: Watermark = serde_json::from_str(r#"{"transparency":200}"#).unwrap();
|
||||
assert_eq!(hostile.transparency(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -966,31 +966,6 @@ 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,25 +19,6 @@ 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() {
|
||||
|
||||
@@ -102,6 +102,18 @@ 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));
|
||||
@@ -116,13 +128,18 @@ fn conversion_work_scales_with_reference_and_formatting_runs() {
|
||||
};
|
||||
convert(&small);
|
||||
convert(&big);
|
||||
// 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})");
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,18 +35,6 @@ 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;
|
||||
|
||||
@@ -63,7 +51,6 @@ 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())),
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
//! Design › Watermark › Picture watermark: a picture drawn behind the text of every page.
|
||||
//!
|
||||
//! The picture's size is shared by everything that draws or saves it. A scale is a percentage of
|
||||
//! the picture's own size (its pixels at 96 ppi). Auto is as large as fits inside the margins of
|
||||
//! the first section that names the page's header ([`Document::watermark_fit`]), the same
|
||||
//! on screen, in a PDF and in the saved file, whatever pages follow. The picture is centred in
|
||||
//! the page's own text area, so mirrored margins move it as they move the text.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use wordcraft_geom::Rect;
|
||||
|
||||
use crate::Document;
|
||||
use crate::picfx::PicFx;
|
||||
use crate::section::SectionProps;
|
||||
|
||||
/// Largest picture watermark scale, percent (the top of Word's Scale list).
|
||||
pub const MAX_SCALE: f32 = 500.0;
|
||||
|
||||
/// Smallest picture watermark scale, percent.
|
||||
pub const MIN_SCALE: f32 = 1.0;
|
||||
|
||||
/// Longest side of a placed picture watermark, points.
|
||||
const MAX_SIDE: f32 = 31_680.0;
|
||||
|
||||
/// Word's Washout as `v:imagedata` values: `gain` 0.3 and `blacklevel` 0.35, in 65536ths.
|
||||
pub const WASHOUT_GAIN: f64 = 19_661.0 / 65_536.0;
|
||||
pub const WASHOUT_BLACK_LEVEL: f64 = 22_938.0 / 65_536.0;
|
||||
|
||||
/// A picture's own size in points (its pixels at 96 ppi), from its header: nothing is decoded.
|
||||
/// None when it isn't a picture WordCraft reads.
|
||||
pub fn own_size(bytes: &[u8]) -> Option<(f32, f32)> {
|
||||
let p = wordcraft_geom::picture::probe(bytes).ok()?;
|
||||
(p.width > 0 && p.height > 0).then_some((p.width as f32 * 0.75, p.height as f32 * 0.75))
|
||||
}
|
||||
|
||||
/// A picture watermark.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct WatermarkPicture {
|
||||
/// Key into [`crate::Document::media`].
|
||||
pub media: String,
|
||||
/// Percent of the picture's own size (its pixels at 96 ppi); None for Auto, as large as fits
|
||||
/// inside the margins.
|
||||
pub scale: Option<f32>,
|
||||
/// Word's Washout: the picture faded so the text over it can be read.
|
||||
pub washout: bool,
|
||||
}
|
||||
|
||||
impl Default for WatermarkPicture {
|
||||
fn default() -> Self {
|
||||
WatermarkPicture { media: String::new(), scale: None, washout: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl WatermarkPicture {
|
||||
/// The corrections the picture is drawn with: Word's Washout, else none. The renderers draw
|
||||
/// it through the same picture path as any picture with corrections, so Washout costs no
|
||||
/// more memory than the picture itself.
|
||||
pub fn fx(&self) -> PicFx {
|
||||
if self.washout { PicFx::from_vml(WASHOUT_GAIN, WASHOUT_BLACK_LEVEL) } else { PicFx::default() }
|
||||
}
|
||||
|
||||
/// Whether `fx` (read from a file's `gain` and `blacklevel`) is Word's Washout, to within
|
||||
/// rounding of the values a file writes.
|
||||
pub fn is_washout(fx: PicFx) -> bool {
|
||||
let w = PicFx::from_vml(WASHOUT_GAIN, WASHOUT_BLACK_LEVEL);
|
||||
fx.transparency == 0 && fx.bright.abs_diff(w.bright) <= 100 && fx.contrast.abs_diff(w.contrast) <= 100
|
||||
}
|
||||
|
||||
/// The picture's size in points, for a picture `own_w` × `own_h` points: a scale's percentage
|
||||
/// of it, or at Auto as large as fits `fit` (width, height). None for an empty or unusable
|
||||
/// size.
|
||||
pub fn size(&self, own_w: f32, own_h: f32, fit: (f32, f32)) -> Option<(f32, f32)> {
|
||||
let pos = |v: f32| v.is_finite() && v > 0.0;
|
||||
if !(pos(own_w) && pos(own_h)) {
|
||||
return None;
|
||||
}
|
||||
let k = match self.scale {
|
||||
Some(s) if s.is_finite() => s.clamp(MIN_SCALE, MAX_SCALE) / 100.0,
|
||||
_ => {
|
||||
if !(pos(fit.0) && pos(fit.1)) {
|
||||
return None;
|
||||
}
|
||||
(fit.0 / own_w).min(fit.1 / own_h)
|
||||
}
|
||||
};
|
||||
let k = k.min(MAX_SIDE / own_w.max(own_h));
|
||||
let (w, h) = (own_w * k, own_h * k);
|
||||
(pos(w) && pos(h)).then_some((w, h))
|
||||
}
|
||||
|
||||
/// Where the picture goes: [`WatermarkPicture::size`], centred in `body`, the page's text
|
||||
/// area (a scale may spill past it).
|
||||
pub fn place(&self, own_w: f32, own_h: f32, fit: (f32, f32), body: Rect) -> Option<Rect> {
|
||||
let (w, h) = self.size(own_w, own_h, fit)?;
|
||||
let ok = |v: f32| v.is_finite();
|
||||
if !(ok(body.x) && ok(body.y) && ok(body.w) && ok(body.h)) {
|
||||
return None;
|
||||
}
|
||||
Some(Rect::new(body.x + (body.w - w) / 2.0, body.y + (body.h - h) / 2.0, w, h))
|
||||
}
|
||||
}
|
||||
|
||||
/// The room Auto has in a section: its text area's width and height.
|
||||
fn fit_of(s: &SectionProps) -> (f32, f32) {
|
||||
(s.text_width(), s.text_height())
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The room (width, height; points) a picture watermark at Auto fills on a page showing
|
||||
/// header `header` (None: no header, which the saved file gives a header of its own to hold
|
||||
/// the watermark): that of the first section naming the header, in document order, as the
|
||||
/// DOCX writer and reader do. A header no section names is sized by the last section.
|
||||
pub fn watermark_fit(&self, header: Option<u32>) -> (f32, f32) {
|
||||
let even = self.settings.even_odd_headers;
|
||||
// Which slots an earlier section has filled (named, or given the watermark's own header),
|
||||
// that later ones inherit.
|
||||
let mut filled = [false; 3];
|
||||
for (_, s) in self.sections() {
|
||||
let own = [s.headers.default, s.headers.first, s.headers.even];
|
||||
let wants = [true, s.title_page, even];
|
||||
let mut hit = false;
|
||||
for ((o, w), f) in own.into_iter().zip(wants).zip(filled.iter_mut()) {
|
||||
match (header, o) {
|
||||
(Some(h), Some(o)) => hit |= h == o,
|
||||
// A slot the pages show with no header of its own, nor an earlier one's.
|
||||
(None, None) => hit |= w && !*f,
|
||||
_ => {}
|
||||
}
|
||||
*f |= o.is_some() || w;
|
||||
}
|
||||
if hit {
|
||||
return fit_of(s);
|
||||
}
|
||||
}
|
||||
fit_of(&self.last_section)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Rounded to a hundredth of a point: (x, y, w, h).
|
||||
fn r(r: Option<Rect>) -> Option<(f32, f32, f32, f32)> {
|
||||
let q = |v: f32| (v * 100.0).round() / 100.0;
|
||||
r.map(|r| (q(r.x), q(r.y), q(r.w), q(r.h)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_is_placed_in_the_text_area() {
|
||||
let body = Rect::new(72.0, 72.0, 468.0, 648.0);
|
||||
let fit = (468.0, 648.0);
|
||||
// Auto: as large as fits, centred.
|
||||
let auto = WatermarkPicture::default();
|
||||
assert_eq!(r(auto.place(200.0, 100.0, fit, body)), Some((72.0, 279.0, 468.0, 234.0)));
|
||||
assert_eq!(r(auto.place(100.0, 400.0, fit, body)), Some((225.0, 72.0, 162.0, 648.0)));
|
||||
// A scale is a percentage of the picture's own size, and may spill past the margins.
|
||||
let half = WatermarkPicture { scale: Some(50.0), ..Default::default() };
|
||||
assert_eq!(r(half.place(200.0, 100.0, fit, body)), Some((256.0, 371.0, 100.0, 50.0)));
|
||||
let big = WatermarkPicture { scale: Some(500.0), ..Default::default() };
|
||||
assert_eq!(r(big.place(200.0, 100.0, fit, body)), Some((-194.0, 146.0, 1000.0, 500.0)));
|
||||
// Mirrored margins move the page's text area, and the picture with it; Auto's size is
|
||||
// still the section's.
|
||||
let mirrored = Rect::new(108.0, 72.0, 468.0, 648.0);
|
||||
assert_eq!(r(auto.place(200.0, 100.0, fit, mirrored)), Some((108.0, 279.0, 468.0, 234.0)));
|
||||
// Hostile sizes and scales.
|
||||
let wild = WatermarkPicture { scale: Some(1e30), ..Default::default() };
|
||||
assert_eq!(r(wild.place(200.0, 100.0, fit, body)).map(|r| r.2), Some(1000.0));
|
||||
let tiny = WatermarkPicture { scale: Some(-5.0), ..Default::default() };
|
||||
assert_eq!(r(tiny.place(200.0, 100.0, fit, body)).map(|r| r.2), Some(2.0));
|
||||
let nan = WatermarkPicture { scale: Some(f32::NAN), ..Default::default() };
|
||||
assert_eq!(r(nan.place(200.0, 100.0, fit, body)).map(|r| r.2), Some(468.0));
|
||||
assert!(auto.place(0.0, 100.0, fit, body).is_none());
|
||||
assert!(auto.place(f32::INFINITY, 100.0, fit, body).is_none());
|
||||
assert!(auto.place(200.0, 100.0, (-1.0, 10.0), body).is_none());
|
||||
let huge = auto.size(1e30, 1e30, (1e38, 1e38)).unwrap();
|
||||
assert!(huge.0 <= MAX_SIDE + 1.0 && huge.1 <= MAX_SIDE + 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn washout_is_the_vml_values() {
|
||||
let on = WatermarkPicture::default();
|
||||
assert_eq!(on.fx(), PicFx::from_vml(WASHOUT_GAIN, WASHOUT_BLACK_LEVEL));
|
||||
assert!(WatermarkPicture::is_washout(on.fx()) && WatermarkPicture::is_washout(PicFx::from_vml(0.3, 0.35)));
|
||||
assert!(!WatermarkPicture::is_washout(PicFx::default()));
|
||||
assert!(!WatermarkPicture::is_washout(PicFx::from_vml(0.5, 0.1)));
|
||||
assert!(WatermarkPicture { washout: false, ..on }.fx().is_identity());
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
//! 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.
|
||||
//! 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.
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
thread_local! {
|
||||
@@ -11,10 +10,9 @@ thread_local! {
|
||||
static STEPS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// Count `n` steps of a walk (nothing outside tests).
|
||||
#[inline(always)]
|
||||
pub fn steps(n: usize) {
|
||||
pub(crate) 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")))]
|
||||
|
||||
+19
-106
@@ -51,14 +51,6 @@ const MAX_PARTS: usize = 50_000;
|
||||
/// headers, notes, text boxes), whatever the file's size. The working budget is tied to the
|
||||
/// WordDocument stream (see [`read`]); this caps even a maximal stream.
|
||||
const MAX_TOTAL_CHARS: u64 = 64 << 20;
|
||||
/// 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,
|
||||
@@ -203,12 +195,6 @@ 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 {
|
||||
@@ -284,7 +270,6 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
// 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,
|
||||
@@ -300,7 +285,6 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
drawn: &[],
|
||||
sect_ends: &[],
|
||||
budget: &budget,
|
||||
paragraphs: ¶graphs,
|
||||
work: &work,
|
||||
};
|
||||
|
||||
@@ -320,7 +304,7 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
if doc.parts.len() >= MAX_PARTS || tb_stories.contains_key(&spid) {
|
||||
continue;
|
||||
}
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
tb_stories.insert(spid, doc.add_part(PartKind::TextBox, blocks));
|
||||
}
|
||||
}
|
||||
@@ -345,7 +329,7 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
0 | 1 | 4 => PartKind::Header,
|
||||
_ => PartKind::Footer,
|
||||
};
|
||||
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
if !blocks.is_empty() {
|
||||
let id = doc.add_part(kind, blocks);
|
||||
hf_ids.push((si, k, id));
|
||||
@@ -358,7 +342,7 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
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 = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
|
||||
let id = doc.add_part(PartKind::Footnote, blocks);
|
||||
note_links.push((cp, NoteKind::Footnote, id));
|
||||
@@ -366,7 +350,7 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
}
|
||||
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 = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
|
||||
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
|
||||
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
|
||||
let id = doc.add_part(PartKind::Endnote, blocks);
|
||||
note_links.push((cp, NoteKind::Endnote, id));
|
||||
@@ -409,9 +393,6 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
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();
|
||||
@@ -419,20 +400,6 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
|
||||
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)> {
|
||||
@@ -466,35 +433,10 @@ 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 {
|
||||
@@ -585,9 +527,6 @@ 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);
|
||||
@@ -597,9 +536,6 @@ 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) {
|
||||
@@ -653,7 +589,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(FieldResult::default());
|
||||
f.sep = Some(Vec::new());
|
||||
}
|
||||
cp = cp.saturating_add(1);
|
||||
continue;
|
||||
@@ -733,7 +669,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: None,
|
||||
row: table::RowInfo::default(),
|
||||
});
|
||||
}
|
||||
out
|
||||
@@ -744,28 +680,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
|
||||
struct FieldBuild {
|
||||
depth: u32,
|
||||
instr: String,
|
||||
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);
|
||||
}
|
||||
sep: Option<Vec<(char, CharProps)>>,
|
||||
}
|
||||
|
||||
/// Bytes a clone of `obj` copies: the object itself plus a picture's alt text, a group with all
|
||||
@@ -790,26 +705,24 @@ 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 chars = result.text.chars();
|
||||
for (len, props) in result.runs {
|
||||
let mut linked = props;
|
||||
let mut run: Option<CharProps> = None;
|
||||
for (c, props) in result {
|
||||
if run.as_ref() == Some(&props) {
|
||||
pb.extend(c);
|
||||
continue;
|
||||
}
|
||||
let mut linked = props.clone();
|
||||
if work.charge(cost, Cost::LinkTarget) {
|
||||
linked.link = Some(target.clone());
|
||||
}
|
||||
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());
|
||||
}
|
||||
pb.push(c, linked);
|
||||
run = Some(props);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let result_text: String = result.iter().map(|(c, _)| *c).collect();
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(InlineObject::Field { instr, result: result.text, locked: false, dirty: false, code: false, form: String::new() });
|
||||
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.
|
||||
@@ -893,7 +806,7 @@ 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),
|
||||
|
||||
@@ -83,11 +83,6 @@ 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> {
|
||||
@@ -268,8 +263,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; `None` for a paragraph outside a table.
|
||||
pub(crate) row: Option<Box<RowInfo>>,
|
||||
/// Table context from the mark's PAPX (empty default = not in a table).
|
||||
pub(crate) row: RowInfo,
|
||||
}
|
||||
|
||||
/// Group consecutive in-table paragraphs into [`Table`] blocks; the rest pass through.
|
||||
@@ -374,7 +369,9 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
}
|
||||
|
||||
for p in paras {
|
||||
let Some(info) = p.row else {
|
||||
let info = p.row;
|
||||
let is_table = info.in_table || info.ttp || info.cell_mark;
|
||||
if !is_table {
|
||||
if open {
|
||||
flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1);
|
||||
grid.clear();
|
||||
@@ -389,7 +386,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
|
||||
}
|
||||
out.push(para_block(p.para));
|
||||
continue;
|
||||
};
|
||||
}
|
||||
if !open {
|
||||
open = true;
|
||||
}
|
||||
|
||||
+3
-136
@@ -116,12 +116,6 @@ 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];
|
||||
@@ -134,13 +128,7 @@ fn text_doc_with(main: &[Piece], extra: &[Piece], spec: FibSpec, table_data: &[(
|
||||
clx.extend_from_slice(&plc);
|
||||
let mut table = vec![0u8; CLX_AT as usize];
|
||||
table.extend_from_slice(&clx);
|
||||
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 };
|
||||
let spec = FibSpec { ccp_text, ccp_hdd, pairs: vec![(33, CLX_AT, clx.len() as u32)], ..Default::default() };
|
||||
word[..fib_bytes(&spec).len()].copy_from_slice(&fib_bytes(&spec));
|
||||
cfb_file(&[("WordDocument", &word), ("0Table", &table)])
|
||||
}
|
||||
@@ -259,77 +247,6 @@ 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);
|
||||
@@ -2743,8 +2660,7 @@ 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 mut result = super::FieldResult::default();
|
||||
(0..n).for_each(|_| result.push('y', wordcraft_doc::props::CharProps::default()));
|
||||
let result = vec![('y', wordcraft_doc::props::CharProps::default()); n];
|
||||
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"http://{target}\""), sep: Some(result) };
|
||||
let work = super::Work::new(u64::MAX);
|
||||
super::finish_field(&mut super::ParaBuild::default(), f, &work);
|
||||
@@ -2757,8 +2673,7 @@ 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 mut result = super::FieldResult::default();
|
||||
(0..1000).for_each(|i| result.push('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() }));
|
||||
let result: Vec<(char, CharProps)> = (0..1000).map(|i| ('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() })).collect();
|
||||
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"{}\"", "u".repeat(100)), sep: Some(result) };
|
||||
let mut pb = super::ParaBuild::default();
|
||||
let budget = super::Work::new(10_000);
|
||||
@@ -2832,51 +2747,3 @@ 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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
wordcraft-geom = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -32,7 +32,6 @@ 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 }
|
||||
|
||||
@@ -12,7 +12,6 @@ mod props;
|
||||
mod sdt;
|
||||
mod shape_style;
|
||||
mod story;
|
||||
mod watermark;
|
||||
mod wordart;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -117,8 +116,6 @@ pub(crate) struct Reader<'p> {
|
||||
pub theme_fills: shape_style::ThemeFills,
|
||||
/// The fill of the group being read (what its members' `a:grpFill` stands for).
|
||||
pub group_fill: Option<wordcraft_doc::fill::Fill>,
|
||||
/// The document's picture watermark from the headers, if any (`watermark::plan`).
|
||||
picture_wm: watermark::Plan,
|
||||
}
|
||||
|
||||
/// The package path that internal relationship `id` points at, if it has type `kind` (an `rt`
|
||||
@@ -191,7 +188,6 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
|
||||
txbx_links: Vec::new(),
|
||||
theme_fills: Default::default(),
|
||||
group_fill: None,
|
||||
picture_wm: watermark::Plan::default(),
|
||||
chunks,
|
||||
};
|
||||
r.pc.major_font = r.doc.settings.major_font.clone();
|
||||
@@ -283,7 +279,6 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
|
||||
}
|
||||
}
|
||||
if let Some(body) = root.child("w:body") {
|
||||
r.picture_wm = watermark::plan(&r, body, &rels);
|
||||
let mut sc = StoryCtx::default();
|
||||
let mut blocks = Blocks::new();
|
||||
r.read_blocks(&mut sc, body, &rels, &mut blocks, r.chunks.depth);
|
||||
|
||||
@@ -142,7 +142,6 @@ impl Reader<'_> {
|
||||
let rels = self.pkg.rels(path);
|
||||
let mut sc = StoryCtx::default();
|
||||
sc.in_hf = true;
|
||||
sc.picture_wm = !footer && self.picture_wm_header(path);
|
||||
let mut blocks = Blocks::new();
|
||||
self.hf_depth += 1;
|
||||
self.read_blocks(&mut sc, &root, &rels, &mut blocks, self.chunks.depth);
|
||||
|
||||
@@ -33,7 +33,6 @@ 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),
|
||||
}
|
||||
@@ -334,7 +333,6 @@ 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)),
|
||||
@@ -765,26 +763,8 @@ 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) {
|
||||
|
||||
@@ -25,8 +25,6 @@ mod fields;
|
||||
mod runs;
|
||||
mod vml;
|
||||
|
||||
pub(super) use vml::{vml_spin, vml_style, vml_watermark};
|
||||
|
||||
const INK_URI: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingInk";
|
||||
const INK_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml";
|
||||
/// The `a:graphicData` URI of an Office 2016 chart (`cx:chart`).
|
||||
@@ -115,10 +113,6 @@ pub struct StoryCtx {
|
||||
pub story_depth: usize,
|
||||
/// Reading a header or footer (not a text box inside one): where Word keeps its watermark.
|
||||
pub in_hf: bool,
|
||||
/// This header's Word picture watermark is the document's watermark (see `watermark::plan`).
|
||||
pub picture_wm: bool,
|
||||
/// Reading the content of a tracked deletion: a watermark shape in it is not the document's.
|
||||
pub in_deletion: bool,
|
||||
}
|
||||
|
||||
/// Paragraph content under construction.
|
||||
@@ -164,7 +158,7 @@ fn clean_text(s: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn children_of_choice(ac: &El) -> Option<&El> {
|
||||
fn children_of_choice(ac: &El) -> Option<&El> {
|
||||
// Prefer a Choice whose requirements we understand; else the Fallback; else any Choice.
|
||||
// `m`: Office Math (a producer may guard an equation with a picture fallback, #1273).
|
||||
const KNOWN: &[&str] = &["wps", "wpg", "wpc", "wpi", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v", "m"];
|
||||
|
||||
@@ -244,11 +244,7 @@ impl Reader<'_> {
|
||||
if let Some(o) = self.read_signature_line(sc, k, rels) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
} else {
|
||||
// A watermark in a tracked deletion is not the document's: it stays a shape.
|
||||
sc.in_deletion = props.del.is_some();
|
||||
let objects = self.read_vml_all(sc, k, rels);
|
||||
sc.in_deletion = false;
|
||||
for o in objects {
|
||||
for o in self.read_vml_all(sc, k, rels) {
|
||||
self.emit_obj(sc, pb, o, props);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,7 @@ impl Reader<'_> {
|
||||
break;
|
||||
}
|
||||
if sc.in_hf
|
||||
&& !sc.in_deletion
|
||||
&& let Some(wm) = vml_watermark(e).or_else(|| self.picture_watermark(sc, e, rels))
|
||||
&& let Some(wm) = vml_watermark(e)
|
||||
{
|
||||
// The document's watermark (the first one found); drawn on every page, not as
|
||||
// a shape, so it is not one of the objects this picture contributes.
|
||||
@@ -483,7 +482,7 @@ fn vml_textbox(e: &El, depth: usize) -> Option<(&El, &El)> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(in crate::read) fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> {
|
||||
fn vml_style<'a>(style: &'a str, key: &str) -> Option<&'a str> {
|
||||
style.rsplit(';').filter_map(|d| d.split_once(':')).find(|(k, _)| k.trim().eq_ignore_ascii_case(key)).map(|(_, v)| v.trim())
|
||||
}
|
||||
|
||||
@@ -606,10 +605,10 @@ fn vml_float(shape: &El, style: &str, in_hf: bool) -> Float {
|
||||
|
||||
/// Word's text watermark (Design › Watermark): a VML shape in a header whose `v:textpath` draws
|
||||
/// its `string` as text (ECMA-376 Part 4, VML `textpath`), in the textpath's `font-family`, filled
|
||||
/// with the shape's `fillcolor`, as transparent as its `v:fill` `opacity` says (any value), and
|
||||
/// with the shape's `fillcolor`, semitransparent when its `v:fill` has an `opacity` below 1, and
|
||||
/// diagonal when the shape is turned. Only Word's watermark shape counts (its `id`), or text
|
||||
/// placed absolutely and centred both ways; other header WordArt (a logo or a title) is None.
|
||||
pub(in crate::read) fn vml_watermark(shape: &El) -> Option<wordcraft_doc::Watermark> {
|
||||
fn vml_watermark(shape: &El) -> Option<wordcraft_doc::Watermark> {
|
||||
let tp = shape.child("v:textpath")?;
|
||||
if tp.attr("on").is_some_and(|v| matches!(v, "f" | "false" | "0")) {
|
||||
return None;
|
||||
@@ -639,9 +638,7 @@ pub(in crate::read) fn vml_watermark(shape: &El) -> Option<wordcraft_doc::Waterm
|
||||
// The box the text is stretched to (`fitshape`).
|
||||
let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(4000.0));
|
||||
let size = len("width").zip(len("height")).map(|(w, h)| [w, h]);
|
||||
let mut wm = wordcraft_doc::Watermark { text, font, color, diagonal: vml_spin(style).deg != 0.0, size, ..default };
|
||||
wm.set_transparency(((1.0 - opacity) * 100.0).round().clamp(0.0, 100.0) as u8);
|
||||
Some(wm)
|
||||
Some(wordcraft_doc::Watermark { text, font, color, diagonal: vml_spin(style).deg != 0.0, semitransparent: opacity < 1.0, size })
|
||||
}
|
||||
|
||||
/// A VML `opacity`, 0..=1.
|
||||
@@ -651,7 +648,7 @@ fn vml_opacity(v: &str) -> Option<f32> {
|
||||
|
||||
/// A VML shape's turn: `rotation` in degrees, or 65536ths of one with an `fd` suffix; `flip`: `x`
|
||||
/// and/or `y`.
|
||||
pub(in crate::read) fn vml_spin(style: &str) -> wordcraft_geom::Spin {
|
||||
fn vml_spin(style: &str) -> wordcraft_geom::Spin {
|
||||
let get = |k: &str| vml_style(style, k);
|
||||
let rot = get("rotation").and_then(|v| match v.strip_suffix("fd") {
|
||||
Some(fd) => fd.trim().parse::<f32>().ok().map(|f| f / 65_536.0),
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
//! Word's picture watermark (Design › Watermark › Picture watermark): a VML picture with Word's
|
||||
//! `WordPictureWatermark` id in the headers. It becomes the document's watermark only when every
|
||||
//! page shows the same one, placed as Word places it; otherwise each stays a picture in its header,
|
||||
//! so a cover page without it, a section with another picture, or one moved by hand keep theirs.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::picfx::PicFx;
|
||||
use wordcraft_doc::watermark::{MAX_SCALE, MIN_SCALE, own_size};
|
||||
use wordcraft_doc::{SectionProps, Watermark, WatermarkPicture};
|
||||
|
||||
use super::Reader;
|
||||
use super::props::{HfRef, sectpr};
|
||||
use super::story::{StoryCtx, children_of_choice, vml_style, vml_watermark};
|
||||
use crate::package::Rels;
|
||||
use crate::units::{measure, vml_number};
|
||||
use crate::xml::{El, MAX_DEPTH};
|
||||
|
||||
/// Word's watermark picture shape id starts with this.
|
||||
const WORD_ID: &str = "WordPictureWatermark";
|
||||
|
||||
/// Most sections, and header or footer parts, looked at; past them the pictures stay put.
|
||||
const MAX_SECTIONS: usize = 10_000;
|
||||
const MAX_PARTS: usize = 64;
|
||||
|
||||
/// The document's picture watermark as the plan found it: the header parts whose Word picture
|
||||
/// watermark it is (empty: none), and the scale and Washout every one of them agrees on.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Plan {
|
||||
headers: HashSet<String>,
|
||||
scale: Option<f32>,
|
||||
washout: bool,
|
||||
}
|
||||
|
||||
/// A Word picture watermark shape as Word places it: absolutely, centred on the margins both
|
||||
/// ways, behind the text, shown, not turned or flipped, its picture carrying Word's Washout or
|
||||
/// nothing.
|
||||
struct Shape<'a> {
|
||||
rid: &'a str,
|
||||
size: Option<(f32, f32)>,
|
||||
washout: bool,
|
||||
}
|
||||
|
||||
/// `e` as Word's picture watermark shape, if it is one placed as Word places it.
|
||||
fn shape(e: &El) -> Option<Shape<'_>> {
|
||||
if e.name != "v:shape" || !e.attr("id").is_some_and(|id| id.starts_with(WORD_ID)) {
|
||||
return None;
|
||||
}
|
||||
let style = e.attr("style").unwrap_or("");
|
||||
let is = |k: &str, v: &str| vml_style(style, k).is_some_and(|x| x.eq_ignore_ascii_case(v));
|
||||
let placed = is("position", "absolute")
|
||||
&& is("mso-position-horizontal", "center")
|
||||
&& is("mso-position-horizontal-relative", "margin")
|
||||
&& is("mso-position-vertical", "center")
|
||||
&& is("mso-position-vertical-relative", "margin");
|
||||
let behind = vml_style(style, "z-index").and_then(|z| z.trim().parse::<i64>().ok()).is_some_and(|z| z < 0);
|
||||
let spin = super::story::vml_spin(style);
|
||||
if !placed || !behind || is("visibility", "hidden") || spin.deg != 0.0 || spin.flip_h || spin.flip_v {
|
||||
return None;
|
||||
}
|
||||
let data = e.child("v:imagedata")?;
|
||||
if data.attrs.iter().any(|(k, _)| !matches!(k.as_str(), "r:id" | "r:pict" | "src" | "o:title" | "gain" | "blacklevel")) {
|
||||
return None;
|
||||
}
|
||||
// Word's Washout is gain 0.3 and black level 0.35; other values stay on a header picture.
|
||||
let num = |k: &str| data.attr(k).and_then(vml_number);
|
||||
let fx = PicFx::from_vml(num("gain").unwrap_or(1.0), num("blacklevel").unwrap_or(0.0));
|
||||
let washout = if data.attr("gain").is_none() && data.attr("blacklevel").is_none() {
|
||||
false
|
||||
} else if WatermarkPicture::is_washout(fx) {
|
||||
true
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).filter(|v| v.is_finite() && *v > 0.0);
|
||||
let size = len("width").zip(len("height"));
|
||||
Some(Shape { rid: data.attr("r:id").or_else(|| data.attr("r:pict"))?, size, washout })
|
||||
}
|
||||
|
||||
/// One header's Word picture watermark: its picture, its width as a percentage of the picture's
|
||||
/// own size (pixels at 96 ppi), whether it fills its section's margins (Auto), and Washout.
|
||||
struct Seen {
|
||||
bytes: Arc<Vec<u8>>,
|
||||
percent: f32,
|
||||
fits: bool,
|
||||
washout: bool,
|
||||
}
|
||||
|
||||
/// What shape `s` shows, its picture `bytes`, in a header whose first section has room `fit`.
|
||||
/// None when the picture can't be read, or the shape has no size or is stretched out of proportion.
|
||||
fn seen(bytes: &Arc<Vec<u8>>, s: &Shape, fit: (f32, f32)) -> Option<Seen> {
|
||||
let (own_w, own_h) = own_size(bytes)?;
|
||||
let (w, h) = s.size?;
|
||||
if (h - w * own_h / own_w).abs() > 1.0 {
|
||||
return None;
|
||||
}
|
||||
let (auto_w, _) = WatermarkPicture::default().size(own_w, own_h, fit)?;
|
||||
Some(Seen { bytes: bytes.clone(), percent: w / own_w * 100.0, fits: (auto_w - w).abs() <= 1.0, washout: s.washout })
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
/// The document's watermark from a header's Word picture watermark shape `e`, when the plan
|
||||
/// made that header's one the document's watermark.
|
||||
pub(super) fn picture_watermark(&mut self, sc: &StoryCtx, e: &El, rels: &Rels) -> Option<Watermark> {
|
||||
if !sc.picture_wm {
|
||||
return None;
|
||||
}
|
||||
let s = shape(e)?;
|
||||
let media = self.media_for(rels, s.rid)?;
|
||||
let pic = WatermarkPicture { media, scale: self.picture_wm.scale, washout: self.picture_wm.washout };
|
||||
Some(Watermark { picture: Some(pic), ..Default::default() })
|
||||
}
|
||||
|
||||
/// Whether header part `path`'s Word picture watermark is the document's (see [`plan`]).
|
||||
pub(super) fn picture_wm_header(&self, path: &str) -> bool {
|
||||
self.picture_wm.headers.contains(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// The document's picture watermark, worked out from the section properties and header and
|
||||
/// footer parts before the body is read. Every page shows a header (default; first page with a
|
||||
/// title page; even pages with different odd and even pages, each linked to the section before
|
||||
/// when it has none), each header holds at most one Word picture watermark placed as Word places
|
||||
/// it and the shown ones hold one, all have the same picture and Washout, and all fill their
|
||||
/// section's margins (Auto) or all have the same scale. No header or footer has a text
|
||||
/// watermark, and no footer a Word picture watermark. Otherwise the plan is empty.
|
||||
pub(super) fn plan(r: &Reader, body: &El, rels: &Rels) -> Plan {
|
||||
let mut sects = Vec::new();
|
||||
sect_prs(body, 0, true, &mut sects);
|
||||
if let Some(s) = body.child("w:sectPr") {
|
||||
sects.push((s, true));
|
||||
}
|
||||
if sects.len() > MAX_SECTIONS {
|
||||
return Plan::default();
|
||||
}
|
||||
let parts = collect_parts(r, rels, §s);
|
||||
if !parts_viable(r, &parts) || !footers_clean(r, &parts.footers) {
|
||||
return Plan::default();
|
||||
}
|
||||
let Some(found) = read_headers(r, &parts.headers) else { return Plan::default() };
|
||||
let Some((scale, washout)) = agreed(&parts.shown, &found) else { return Plan::default() };
|
||||
Plan { headers: found.into_iter().filter(|(_, w)| w.is_some()).map(|(p, _)| p.to_string()).collect(), scale, washout }
|
||||
}
|
||||
|
||||
/// The header and footer parts the sections name, and the header each page kind shows.
|
||||
#[derive(Default)]
|
||||
struct Parts {
|
||||
/// Each header part, with the room Auto fills in the first section naming it
|
||||
/// (`Document::watermark_fit`).
|
||||
headers: HashMap<String, (f32, f32)>,
|
||||
footers: HashSet<String>,
|
||||
/// The header part (None: linked to none) of every page kind each section ending shows.
|
||||
shown: Vec<Option<String>>,
|
||||
}
|
||||
|
||||
/// Walks the sections in order, collecting the header and footer parts they name and the headers
|
||||
/// the pages show (each kind linked to the section before when a section has none).
|
||||
fn collect_parts(r: &Reader, rels: &Rels, sects: &[(&El, bool)]) -> Parts {
|
||||
let even = r.doc.settings.even_odd_headers;
|
||||
let mut parts = Parts::default();
|
||||
let mut linked: [Option<String>; 3] = Default::default();
|
||||
for (s, top) in sects {
|
||||
let (sp, refs) = sectpr(&r.pc, s);
|
||||
for h in &refs {
|
||||
note_ref(&mut parts, &mut linked, h, rels, &sp, *top);
|
||||
}
|
||||
if !*top {
|
||||
continue;
|
||||
}
|
||||
let [default, first, even_page] = &linked;
|
||||
parts.shown.push(default.clone());
|
||||
if sp.title_page {
|
||||
parts.shown.push(first.clone());
|
||||
}
|
||||
if even {
|
||||
parts.shown.push(even_page.clone());
|
||||
}
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
/// Records header or footer reference `h` of a section with properties `sp`: the part it names,
|
||||
/// and, in a section that ends one of the document's, the header kind it now links.
|
||||
fn note_ref(parts: &mut Parts, linked: &mut [Option<String>; 3], h: &HfRef, rels: &Rels, sp: &SectionProps, top: bool) {
|
||||
let Some(rel) = rels.by_id(&h.rid).filter(|x| !x.external) else { return };
|
||||
if h.footer {
|
||||
parts.footers.insert(rel.target.clone());
|
||||
return;
|
||||
}
|
||||
parts.headers.entry(rel.target.clone()).or_insert_with(|| (sp.text_width(), sp.text_height()));
|
||||
if !top {
|
||||
return;
|
||||
}
|
||||
let slot = match h.kind.as_str() {
|
||||
"first" => 1,
|
||||
"even" => 2,
|
||||
_ => 0,
|
||||
};
|
||||
if let Some(l) = linked.get_mut(slot) {
|
||||
*l = Some(rel.target.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `parts` can hold the document's watermark: not too many, every page shows a header
|
||||
/// and one of the headers is Word's.
|
||||
fn parts_viable(r: &Reader, parts: &Parts) -> bool {
|
||||
let has_word_id = |path: &str| r.pkg.get(path).is_some_and(|b| b.windows(WORD_ID.len()).any(|w| w == WORD_ID.as_bytes()));
|
||||
parts.headers.len() <= MAX_PARTS
|
||||
&& parts.footers.len() <= MAX_PARTS
|
||||
&& !parts.shown.is_empty()
|
||||
&& parts.shown.iter().all(Option::is_some)
|
||||
&& parts.headers.keys().any(|p| has_word_id(p))
|
||||
}
|
||||
|
||||
/// Whether no footer holds a watermark: a footer's text watermark is the document's watermark,
|
||||
/// and a footer's picture watermark stays where it is, so either way the headers' pictures stay.
|
||||
fn footers_clean(r: &Reader, footers: &HashSet<String>) -> bool {
|
||||
footers.iter().all(|path| {
|
||||
let Ok(Some(root)) = r.pkg.xml(path) else { return true };
|
||||
let mut shapes = Vec::new();
|
||||
word_shapes(&root, 0, "", false, false, &mut shapes) && shapes.is_empty()
|
||||
})
|
||||
}
|
||||
|
||||
/// What each header part's Word picture watermark shows (None: it has none). None when a part
|
||||
/// can't be read, has a text watermark or several pictures, or its picture isn't placed as Word
|
||||
/// places it.
|
||||
fn read_headers<'a>(r: &Reader, headers: &'a HashMap<String, (f32, f32)>) -> Option<HashMap<&'a str, Option<Seen>>> {
|
||||
let mut found = HashMap::new();
|
||||
for (path, fit) in headers {
|
||||
let root = r.pkg.xml(path).ok()??;
|
||||
let mut shapes = Vec::new();
|
||||
if !word_shapes(&root, 0, "", false, false, &mut shapes) || shapes.len() > 1 {
|
||||
return None;
|
||||
}
|
||||
let wm = match shapes.first() {
|
||||
None => None,
|
||||
Some(e) => Some(header_seen(r, path, e, *fit)?),
|
||||
};
|
||||
found.insert(path.as_str(), wm);
|
||||
}
|
||||
Some(found)
|
||||
}
|
||||
|
||||
/// What header part `path`'s watermark shape `e` shows, when it is placed as Word places it and
|
||||
/// its picture is in the package.
|
||||
fn header_seen(r: &Reader, path: &str, e: &El, fit: (f32, f32)) -> Option<Seen> {
|
||||
let hrels = r.pkg.rels(path);
|
||||
let s = shape(e)?;
|
||||
let bytes = hrels.by_id(s.rid).filter(|x| !x.external).and_then(|x| r.pkg.get_shared(&x.target))?;
|
||||
seen(bytes, &s, fit)
|
||||
}
|
||||
|
||||
/// The Scale (None: Auto) and Washout every shown header agrees on. None when a shown header has
|
||||
/// no picture, or they differ in picture, Washout, or scale.
|
||||
fn agreed(shown: &[Option<String>], found: &HashMap<&str, Option<Seen>>) -> Option<(Option<f32>, bool)> {
|
||||
if shown.iter().flatten().any(|p| found.get(p.as_str()).is_none_or(Option::is_none)) {
|
||||
return None;
|
||||
}
|
||||
let all: Vec<&Seen> = found.values().flatten().collect();
|
||||
// The first shown header's, whatever order the map holds them in.
|
||||
let first = shown.iter().flatten().find_map(|p| found.get(p.as_str())?.as_ref())?;
|
||||
if !all.iter().all(|s| s.bytes == first.bytes && s.washout == first.washout) {
|
||||
return None;
|
||||
}
|
||||
if all.iter().all(|s| s.fits) {
|
||||
return Some((None, first.washout));
|
||||
}
|
||||
let percent = (first.percent * 10.0).round() / 10.0;
|
||||
if !(MIN_SCALE..=MAX_SCALE).contains(&percent) || all.iter().any(|s| (s.percent - first.percent).abs() > 0.1) {
|
||||
return None;
|
||||
}
|
||||
Some((Some(percent), first.washout))
|
||||
}
|
||||
|
||||
/// The `w:sectPr` elements the reader reads, in order, as (element, whether it ends a section of
|
||||
/// the document): in paragraph properties through the containers `read_blocks` follows, and in
|
||||
/// table cells (which load headers but make no section).
|
||||
fn sect_prs<'a>(e: &'a El, depth: usize, top: bool, out: &mut Vec<(&'a El, bool)>) {
|
||||
if depth > MAX_DEPTH || out.len() > MAX_SECTIONS {
|
||||
return;
|
||||
}
|
||||
for k in e.els() {
|
||||
match k.name.as_str() {
|
||||
"w:p" => {
|
||||
if let Some(s) = k.child("w:pPr").and_then(|p| p.child("w:sectPr")) {
|
||||
out.push((s, top));
|
||||
}
|
||||
}
|
||||
"w:sdt" => {
|
||||
if let Some(c) = k.child("w:sdtContent") {
|
||||
sect_prs(c, depth + 1, top, out);
|
||||
}
|
||||
}
|
||||
"w:customXml" | "w:smartTag" | "w:ins" | "w:moveTo" => sect_prs(k, depth + 1, top, out),
|
||||
"mc:AlternateContent" => {
|
||||
if let Some(c) = children_of_choice(k) {
|
||||
sect_prs(c, depth + 1, top, out);
|
||||
}
|
||||
}
|
||||
"w:tbl" | "w:tr" | "w:tc" => sect_prs(k, depth + 1, false, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects the Word picture watermark shapes a header or footer would read as such (straight
|
||||
/// in a `w:pict` or `w:object`, not in a tracked deletion) into `out`. False when the part has a
|
||||
/// text watermark, or a Word picture watermark anywhere else (a group, a text box, alternate
|
||||
/// content, a deleted run).
|
||||
fn word_shapes<'a>(e: &'a El, depth: usize, parent: &str, nested: bool, deleted: bool, out: &mut Vec<&'a El>) -> bool {
|
||||
if depth > MAX_DEPTH {
|
||||
return true;
|
||||
}
|
||||
for k in e.els() {
|
||||
if k.name == "v:shape" && vml_watermark(k).is_some() {
|
||||
return false;
|
||||
}
|
||||
if k.name == "v:shape" && k.attr("id").is_some_and(|id| id.starts_with(WORD_ID)) {
|
||||
if nested || deleted || !matches!(parent, "w:pict" | "w:object") {
|
||||
return false;
|
||||
}
|
||||
out.push(k);
|
||||
continue;
|
||||
}
|
||||
let inner = nested || matches!(k.name.as_str(), "w:txbxContent" | "mc:AlternateContent" | "v:group");
|
||||
let gone = deleted || matches!(k.name.as_str(), "w:del" | "w:moveFrom");
|
||||
if !word_shapes(k, depth + 1, &k.name, inner, gone, out) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
//! [`wordcraft_doc::math`] tree → Office Math (OMML).
|
||||
|
||||
use wordcraft_doc::math::{
|
||||
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MAX_MATRIX_COLS, MAX_MATRIX_ROWS, MNode, MRun, MScr, MSty, Math, MathJc, MathProps,
|
||||
ScriptKind, parse_linear,
|
||||
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, parse_linear,
|
||||
};
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
@@ -182,10 +181,7 @@ 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 };
|
||||
// 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);
|
||||
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1);
|
||||
w.open("m:m", &[]);
|
||||
w.open("m:mPr", &[]);
|
||||
w.open("m:mcs", &[]);
|
||||
|
||||
@@ -91,17 +91,6 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
|
||||
let blocks: Blocks = doc.parts.get(&id).map(|p| p.blocks.clone()).unwrap_or_default();
|
||||
// The watermark goes in every header, at the start of its first paragraph.
|
||||
wr.pending_watermark = match &doc.settings.watermark {
|
||||
// A picture watermark is sized for the first section naming this header (or, for the
|
||||
// header written for the watermark alone, the first needing it): the same size the
|
||||
// screen and a PDF draw.
|
||||
Some(wm) if !footer && wm.picture.is_some() => watermark::picture(doc).and_then(|(pic, own)| {
|
||||
let fit = doc.watermark_fit((id != wr.wm_header).then_some(id));
|
||||
pic.size(own.0, own.1, fit)?;
|
||||
let file = wr.media_files.get(&pic.media)?;
|
||||
let rid = prels.add(rt::IMAGE, &format!("media/{file}"), false);
|
||||
wr.used_media.insert(pic.media.clone());
|
||||
watermark::picture_run(pic, own, done, fit, &rid)
|
||||
}),
|
||||
Some(wm) if !footer => watermark::run(wm, done, &doc.last_section),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -326,9 +326,6 @@ 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 {
|
||||
@@ -376,7 +373,6 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ impl Writer<'_> {
|
||||
fn watermark_headers(&mut self, s: &SectionProps) -> wordcraft_doc::section::HeaderSet {
|
||||
let mut set = s.headers;
|
||||
let linked = &mut self.linked_headers;
|
||||
if crate::write::watermark::shown(self.doc) {
|
||||
if self.doc.settings.watermark.as_ref().and_then(crate::write::watermark::text).is_some() {
|
||||
let id = self.wm_header;
|
||||
if set.default.or(linked.default).is_none() {
|
||||
set.default = Some(id);
|
||||
@@ -87,7 +87,6 @@ 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")]);
|
||||
}
|
||||
@@ -108,7 +107,14 @@ 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);
|
||||
}
|
||||
page_number_type(w, s);
|
||||
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);
|
||||
}
|
||||
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))];
|
||||
@@ -128,9 +134,6 @@ 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")] });
|
||||
}
|
||||
@@ -140,9 +143,6 @@ 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,43 +167,6 @@ 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) {
|
||||
|
||||
@@ -1,32 +1,11 @@
|
||||
//! The document's watermark in the headers: a text watermark as a VML text shape (ECMA-376
|
||||
//! Part 4, VML: `shape`, `shapetype`, `path`, `textpath`, `fill`), a picture watermark as a VML
|
||||
//! picture (`imagedata`), which is how a WordprocessingML consumer draws them behind every page.
|
||||
//! The reader (`read::story::vml_watermark`, `read::watermark`) maps them back.
|
||||
//! The document's text watermark as a VML text shape in the headers (ECMA-376 Part 4, VML:
|
||||
//! `shape`, `shapetype`, `path`, `textpath`, `fill`), which is how a WordprocessingML consumer
|
||||
//! draws text behind every page. The reader (`read::story::vml_watermark`) maps it back.
|
||||
|
||||
use wordcraft_doc::watermark::own_size;
|
||||
use wordcraft_doc::{Document, SectionProps, Watermark, WatermarkPicture};
|
||||
use wordcraft_doc::{SectionProps, Watermark};
|
||||
|
||||
use crate::xml::esc;
|
||||
|
||||
/// Word's Washout on `v:imagedata`: `gain` 0.3 and `blacklevel` 0.35, in 65536ths.
|
||||
const WASHOUT: &str = r#" gain="19661f" blacklevel="22938f""#;
|
||||
|
||||
/// The document's picture watermark and its picture's own size, when its picture can be read.
|
||||
pub(super) fn picture(doc: &Document) -> Option<(&WatermarkPicture, (f32, f32))> {
|
||||
let pic = doc.settings.watermark.as_ref()?.picture.as_ref()?;
|
||||
Some((pic, doc.media.get(&pic.media).and_then(|b| own_size(b))?))
|
||||
}
|
||||
|
||||
/// Whether the document's watermark is written: text that isn't blank, or a picture that can be
|
||||
/// read.
|
||||
pub(super) fn shown(doc: &Document) -> bool {
|
||||
match &doc.settings.watermark {
|
||||
Some(wm) if wm.picture.is_some() => picture(doc).is_some(),
|
||||
Some(wm) => text(wm).is_some(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape type the watermark shape uses: two straight subpaths, the box's top and bottom
|
||||
/// edges, between which the `v:textpath` text is stretched to fit the shape (`fitshape`). A single
|
||||
/// path gives the text no height, and Word draws nothing.
|
||||
@@ -39,18 +18,6 @@ pub(super) fn text(wm: &Watermark) -> Option<String> {
|
||||
(!t.trim().is_empty()).then_some(t)
|
||||
}
|
||||
|
||||
/// The `v:fill` `opacity` for the text's transparency: Word's `.5` for Semitransparent. None when
|
||||
/// the text is opaque.
|
||||
fn opacity(wm: &Watermark) -> Option<String> {
|
||||
let t = wm.transparency();
|
||||
if t == 0 {
|
||||
return None;
|
||||
}
|
||||
let o = format!("{:.2}", f32::from(100 - t) / 100.0);
|
||||
let o = o.trim_end_matches('0').trim_end_matches('.');
|
||||
Some(o.strip_prefix("0.").map_or_else(|| o.to_string(), |f| format!(".{f}")))
|
||||
}
|
||||
|
||||
/// A `w:r` holding the watermark shape (and its shape type), for the first paragraph of header
|
||||
/// part number `n`, in its box ([`Watermark::frame`]: its own, or Word's default for the page).
|
||||
pub(super) fn run(wm: &Watermark, n: usize, page: &SectionProps) -> Option<String> {
|
||||
@@ -78,7 +45,7 @@ pub(super) fn run(wm: &Watermark, n: usize, page: &SectionProps) -> Option<Strin
|
||||
mso-position-horizontal:center;mso-position-horizontal-relative:margin;\
|
||||
mso-position-vertical:center;mso-position-vertical-relative:margin"
|
||||
);
|
||||
let fill = opacity(wm).map(|o| format!(r#"<v:fill opacity="{o}"/>"#)).unwrap_or_default();
|
||||
let fill = if wm.semitransparent { r#"<v:fill opacity=".5"/>"# } else { "" };
|
||||
Some(format!(
|
||||
concat!(
|
||||
r#"<w:r><w:rPr><w:noProof/></w:rPr><w:pict>"#,
|
||||
@@ -97,43 +64,3 @@ pub(super) fn run(wm: &Watermark, n: usize, page: &SectionProps) -> Option<Strin
|
||||
text = esc(&text),
|
||||
))
|
||||
}
|
||||
|
||||
/// A `w:r` holding the picture watermark for header part number `n`, its picture relationship
|
||||
/// `rid`: Word's picture shape, centred on the margins, behind the text, with Word's Washout
|
||||
/// values when washed out. `fit` is the room Auto fills (see [`Document::watermark_fit`]).
|
||||
pub(super) fn picture_run(pic: &WatermarkPicture, own: (f32, f32), n: usize, fit: (f32, f32), rid: &str) -> Option<String> {
|
||||
let (w, h) = pic.size(own.0, own.1, fit)?;
|
||||
let style = format!(
|
||||
"position:absolute;margin-left:0;margin-top:0;width:{w:.3}pt;height:{h:.3}pt;z-index:-251657216;\
|
||||
mso-position-horizontal:center;mso-position-horizontal-relative:margin;\
|
||||
mso-position-vertical:center;mso-position-vertical-relative:margin"
|
||||
);
|
||||
Some(format!(
|
||||
concat!(
|
||||
r#"<w:r><w:rPr><w:noProof/></w:rPr><w:pict>"#,
|
||||
r#"<v:shapetype id="_x0000_t75" coordsize="21600,21600" o:spt="75" o:preferrelative="t" path="m0,0l0,21600,21600,21600,21600,0xe" filled="f" stroked="f">"#,
|
||||
r#"<v:path o:extrusionok="f" gradientshapeok="t" o:connecttype="rect"/><o:lock v:ext="edit" aspectratio="t"/></v:shapetype>"#,
|
||||
r##"<v:shape id="WordPictureWatermark{n}" type="#_x0000_t75" style="{style}" o:allowincell="f">"##,
|
||||
r#"<v:imagedata r:id="{rid}"{wash}/></v:shape></w:pict></w:r>"#
|
||||
),
|
||||
n = n,
|
||||
style = esc(&style),
|
||||
rid = esc(rid),
|
||||
wash = if pic.washout { WASHOUT } else { "" },
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn opacity_is_written_like_word() {
|
||||
let mut wm = Watermark::default();
|
||||
assert_eq!(opacity(&wm).as_deref(), Some(".5"));
|
||||
for (t, want) in [(0, None), (25, Some(".75")), (80, Some(".2")), (99, Some(".01")), (100, Some("0"))] {
|
||||
wm.set_transparency(t);
|
||||
assert_eq!(opacity(&wm).as_deref(), want, "{t}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,32 +80,3 @@ 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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,46 +247,6 @@ 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 =
|
||||
@@ -1381,14 +1341,7 @@ fn vml_textpath_in_header_is_the_watermark() {
|
||||
// Horizontal, centred both ways without Word's id, opacity in 65536ths.
|
||||
let flat = r##"<v:shape type="#_x0000_t136" style="position:absolute;width:300pt;height:60pt;mso-position-horizontal:center;mso-position-vertical:center" fillcolor="#336699"><v:fill opacity="32768f"/><v:textpath string="ASAP"/></v:shape>"##;
|
||||
let wm = read(flat, true).0.expect("watermark");
|
||||
assert_eq!((wm.color, wm.diagonal, wm.semitransparent, wm.transparency()), (Rgb(0x33, 0x66, 0x99), false, true, 50));
|
||||
|
||||
// Any opacity is read as a transparency percentage, not only Word's `.5`.
|
||||
for (opacity, want) in [(".25", 75), ("0", 100), ("1", 0), ("2", 0), ("-1", 100)] {
|
||||
let shape = flat.replace(r#"opacity="32768f""#, &format!(r#"opacity="{opacity}""#));
|
||||
let wm = read(&shape, true).0.expect("watermark");
|
||||
assert_eq!(wm.transparency(), want, "{opacity}");
|
||||
}
|
||||
assert_eq!((wm.color, wm.diagonal, wm.semitransparent), (Rgb(0x33, 0x66, 0x99), false, true));
|
||||
|
||||
// Not watermarks: the same shape in the body (WordArt); header WordArt that isn't centred
|
||||
// and isn't Word's watermark (a logo); a textpath turned off; a watermark-like shape inside a
|
||||
@@ -1505,281 +1458,6 @@ fn word_watermark_survives_save() {
|
||||
assert_eq!(d2.settings.watermark, None);
|
||||
}
|
||||
|
||||
/// #2954: Word's picture watermark (a VML picture with Word's `WordPictureWatermark` id in the
|
||||
/// headers) becomes the document's picture watermark: its picture, the scale from the shape's
|
||||
/// width (Auto when it fills the margins) and Washout from `gain` and `blacklevel`. The header
|
||||
/// keeps its text. Another header picture, even centred, stays a picture.
|
||||
#[test]
|
||||
fn word_picture_watermark_is_the_documents_watermark() {
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(400, 200, image::Rgba([10, 20, 30, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let read = |id: &str, size: &str, wash: &str| {
|
||||
let hdr = format!(
|
||||
r##"<w:hdr {W_NS}><w:p><w:r><w:t>Head</w:t></w:r><w:r><w:pict><v:shapetype id="_x0000_t75" coordsize="21600,21600" o:spt="75" path="m@4@5l@4@11@9@11@9@5xe" filled="f" stroked="f"/><v:shape id="{id}" o:spid="_x0000_s2049" type="#_x0000_t75" style="position:absolute;margin-left:0;margin-top:0;{size};z-index:-251657216;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin" o:allowincell="f"><v:imagedata r:id="rIdP" o:title="logo"{wash}/></v:shape></w:pict></w:r></w:p></w:hdr>"##
|
||||
);
|
||||
let doc = format!(
|
||||
r#"<w:document {W_NS}><w:body><w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr></w:body></w:document>"#
|
||||
);
|
||||
let (drels, hrels) = (rels(&[("rIdH", "header", "header1.xml")]), rels(&[("rIdP", "image", "media/logo.png")]));
|
||||
let parts: [(&str, &[u8]); 6] = [
|
||||
("_rels/.rels", ROOT_RELS.as_bytes()),
|
||||
("word/document.xml", doc.as_bytes()),
|
||||
("word/_rels/document.xml.rels", drels.as_bytes()),
|
||||
("word/header1.xml", hdr.as_bytes()),
|
||||
("word/_rels/header1.xml.rels", hrels.as_bytes()),
|
||||
("word/media/logo.png", &png),
|
||||
];
|
||||
wordcraft_docx::read(&zip(&parts)).unwrap()
|
||||
};
|
||||
let header_objects = |d: &Document| -> usize {
|
||||
let hid = d.last_section.headers.default.unwrap();
|
||||
d.parts[&hid].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.objects.len()).sum()
|
||||
};
|
||||
// 400 × 200 px is 300 × 150 pt at 96 ppi: 150 pt wide is 50 %.
|
||||
let d = read("WordPictureWatermark77", "width:150pt;height:75pt", r#" gain="19661f" blacklevel="22938f""#);
|
||||
let pic = d.settings.watermark.as_ref().and_then(|w| w.picture.clone()).expect("picture watermark");
|
||||
assert_eq!((pic.scale, pic.washout), (Some(50.0), true));
|
||||
assert_eq!(d.media.get(&pic.media).map(|b| b.as_slice()), Some(png.as_slice()));
|
||||
assert_eq!(header_objects(&d), 0);
|
||||
let hid = d.last_section.headers.default.unwrap();
|
||||
assert_eq!(d.parts[&hid].blocks[0].as_para().unwrap().plain_text(), "Head");
|
||||
// As wide as the margins of a Letter page with 1 in margins: Auto. No Washout.
|
||||
let d = read("WordPictureWatermark1", "width:468pt;height:234pt", "");
|
||||
let pic = d.settings.watermark.as_ref().and_then(|w| w.picture.clone()).expect("picture watermark");
|
||||
assert_eq!((pic.scale, pic.washout), (None, false));
|
||||
// Another centred header picture is not a watermark.
|
||||
let d = read("Logo1", "width:150pt;height:75pt", "");
|
||||
assert_eq!(d.settings.watermark, None);
|
||||
assert_eq!(header_objects(&d), 1);
|
||||
}
|
||||
|
||||
/// A 400 × 200 px PNG of one colour (300 × 150 pt at 96 ppi).
|
||||
fn solid_png(c: [u8; 4]) -> Vec<u8> {
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(400, 200, image::Rgba(c)).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
png
|
||||
}
|
||||
|
||||
/// Word's picture watermark shape: `extra` added to its style, `wash` to its `v:imagedata`.
|
||||
fn wm_shape(size: &str, extra: &str, wash: &str) -> String {
|
||||
format!(
|
||||
r##"<w:r><w:pict><v:shape id="WordPictureWatermark1" type="#_x0000_t75" style="position:absolute;margin-left:0;margin-top:0;{size};z-index:-251657216;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin{extra}" o:allowincell="f"><v:imagedata r:id="rIdP" o:title="wm"{wash}/></v:shape></w:pict></w:r>"##
|
||||
)
|
||||
}
|
||||
|
||||
/// A document of `body` (its `w:sectPr`s refer to `rH1`, `rH2`…) whose headers are `headers`:
|
||||
/// (label, runs after the label, picture file under `word/media`).
|
||||
fn headers_doc(body: &str, headers: &[(&str, String, &str)], pics: &[(&str, Vec<u8>)]) -> Document {
|
||||
parts_doc(body, headers, &[], pics)
|
||||
}
|
||||
|
||||
/// [`headers_doc`] with footers too, referred to as `rF1`, `rF2`….
|
||||
fn parts_doc(body: &str, headers: &[(&str, String, &str)], footers: &[(&str, String, &str)], pics: &[(&str, Vec<u8>)]) -> Document {
|
||||
let mut list: Vec<(String, String, &str, String, &str)> = Vec::new();
|
||||
for (kind, tag, prefix, set) in [("header", "w:hdr", "rH", headers), ("footer", "w:ftr", "rF", footers)] {
|
||||
for (i, (label, runs, pic)) in set.iter().enumerate() {
|
||||
let xml = format!(r#"<{tag} {W_NS}><w:p><w:r><w:t>{label}</w:t></w:r>{runs}</w:p></{tag}>"#);
|
||||
list.push((format!("{prefix}{}", i + 1), format!("{kind}{}.xml", i + 1), kind, xml, pic));
|
||||
}
|
||||
}
|
||||
let doc_rels = rels(&list.iter().map(|(id, name, kind, ..)| (id.as_str(), *kind, name.as_str())).collect::<Vec<_>>());
|
||||
let doc = format!(r#"<w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
|
||||
let mut parts: Vec<(String, Vec<u8>)> = vec![
|
||||
("_rels/.rels".into(), ROOT_RELS.as_bytes().to_vec()),
|
||||
("word/document.xml".into(), doc.into_bytes()),
|
||||
("word/_rels/document.xml.rels".into(), doc_rels.into_bytes()),
|
||||
];
|
||||
for (_, name, _, xml, pic) in &list {
|
||||
parts.push((format!("word/{name}"), xml.clone().into_bytes()));
|
||||
parts.push((format!("word/_rels/{name}.rels"), rels(&[("rIdP", "image", &format!("media/{pic}"))]).into_bytes()));
|
||||
}
|
||||
for (name, bytes) in pics {
|
||||
parts.push((format!("word/media/{name}"), bytes.clone()));
|
||||
}
|
||||
let entries: Vec<(&str, &[u8])> = parts.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect();
|
||||
wordcraft_docx::read(&zip(&entries)).unwrap()
|
||||
}
|
||||
|
||||
/// Pictures in each header part, by its text.
|
||||
fn header_pictures(d: &Document) -> Vec<(String, usize)> {
|
||||
let mut out: Vec<(String, usize)> = d
|
||||
.parts
|
||||
.values()
|
||||
.filter(|p| p.kind == wordcraft_doc::PartKind::Header)
|
||||
.map(|p| {
|
||||
let paras: Vec<&Paragraph> = p.blocks.iter().filter_map(|b| b.as_para()).collect();
|
||||
let text = paras.iter().map(|p| p.plain_text()).collect::<Vec<_>>().join("").replace('\u{FFFC}', "");
|
||||
let pics = paras.iter().flat_map(|p| &p.objects).filter(|o| matches!(o, InlineObject::Image { .. })).count();
|
||||
(text, pics)
|
||||
})
|
||||
.collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
const PORTRAIT: &str = r#"<w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>"#;
|
||||
const LANDSCAPE: &str = r#"<w:pgSz w:w="15840" w:h="12240" w:orient="landscape"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>"#;
|
||||
|
||||
/// #2954 review: Word's picture watermark becomes the document's watermark only when every page
|
||||
/// shows the same one, placed as Word places it. A cover page whose header lacks it, a section
|
||||
/// with another picture, a watermark moved, turned or with other picture settings: each picture
|
||||
/// stays in its header, as before, and still does after saving and reopening.
|
||||
#[test]
|
||||
fn picture_watermark_stays_in_headers_unless_every_page_shows_it() {
|
||||
let (a, b) = (solid_png([20, 60, 140, 255]), solid_png([140, 20, 20, 255]));
|
||||
let portrait_auto = wm_shape("width:468pt;height:234pt", "", r#" gain="19661f" blacklevel="22938f""#);
|
||||
let landscape_auto = wm_shape("width:648pt;height:324pt", "", r#" gain="19661f" blacklevel="22938f""#);
|
||||
let p = |label: &str, run: &str, pic: &'static str| (label.to_string(), run.to_string(), pic);
|
||||
let read = |body: &str, hs: Vec<(String, String, &str)>| {
|
||||
let hs: Vec<(&str, String, &str)> = hs.iter().map(|(l, r, p)| (l.as_str(), r.clone(), *p)).collect();
|
||||
headers_doc(body, &hs, &[("a.png", a.clone()), ("b.png", b.clone())])
|
||||
};
|
||||
let saved = |d: &Document| wordcraft_docx::read(&wordcraft_docx::write(d).unwrap()).unwrap();
|
||||
let two_sections = format!(
|
||||
r#"<w:p><w:pPr><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr></w:pPr><w:r><w:t>One</w:t></w:r></w:p><w:p><w:r><w:t>Two</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH2"/>{LANDSCAPE}</w:sectPr>"#
|
||||
);
|
||||
|
||||
// Every page: two sections, each Auto for its own page, and a title page with both headers.
|
||||
let d = read(&two_sections, vec![p("One", &portrait_auto, "a.png"), p("Two", &landscape_auto, "a.png")]);
|
||||
let pic = d.settings.watermark.as_ref().and_then(|w| w.picture.clone()).expect("the document's watermark");
|
||||
assert_eq!((pic.scale, pic.washout), (None, true));
|
||||
assert_eq!(header_pictures(&d), vec![("One".into(), 0), ("Two".into(), 0)]);
|
||||
let title = format!(
|
||||
r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/><w:headerReference w:type="first" r:id="rH2"/>{PORTRAIT}<w:titlePg/></w:sectPr>"#
|
||||
);
|
||||
let d = read(&title, vec![p("Main", &portrait_auto, "a.png"), p("Cover", &portrait_auto, "a.png")]);
|
||||
assert!(d.settings.watermark.as_ref().is_some_and(|w| w.picture.is_some()));
|
||||
|
||||
// A: the cover page's header has none.
|
||||
let d = read(&title, vec![p("Main", &portrait_auto, "a.png"), p("Cover", "", "a.png")]);
|
||||
assert_eq!(d.settings.watermark, None);
|
||||
assert_eq!(header_pictures(&d), vec![("Cover".into(), 0), ("Main".into(), 1)]);
|
||||
let d = saved(&d);
|
||||
assert_eq!(d.settings.watermark, None);
|
||||
assert_eq!(header_pictures(&d), vec![("Cover".into(), 0), ("Main".into(), 1)], "saved");
|
||||
|
||||
// B: section 2 has another picture.
|
||||
let d = read(&two_sections, vec![p("One", &portrait_auto, "a.png"), p("Two", &landscape_auto, "b.png")]);
|
||||
assert_eq!(d.settings.watermark, None);
|
||||
assert_eq!(header_pictures(&d), vec![("One".into(), 1), ("Two".into(), 1)]);
|
||||
let d2 = saved(&d);
|
||||
assert_eq!(d2.settings.watermark, None);
|
||||
assert_eq!(header_pictures(&d2), vec![("One".into(), 1), ("Two".into(), 1)], "saved");
|
||||
let media = |d: &Document| {
|
||||
let mut m: Vec<Vec<u8>> = d.media.values().map(|b| b.to_vec()).collect();
|
||||
m.sort();
|
||||
m
|
||||
};
|
||||
assert_eq!(media(&d2), media(&d), "both pictures kept");
|
||||
|
||||
// C: moved, turned, or with picture settings other than Washout; and a header without one.
|
||||
let one = format!(r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#);
|
||||
for run in [
|
||||
wm_shape("width:468pt;height:234pt", ";mso-position-horizontal:left", ""),
|
||||
wm_shape("width:468pt;height:234pt", ";rotation:30", ""),
|
||||
wm_shape("width:468pt;height:234pt", ";flip:x", ""),
|
||||
wm_shape("width:468pt;height:234pt", "", r#" gain="0.5" blacklevel="0.1""#),
|
||||
wm_shape("width:468pt;height:234pt", "", r#" grayscale="t""#),
|
||||
] {
|
||||
let d = read(&one, vec![p("Main", &run, "a.png")]);
|
||||
assert_eq!(d.settings.watermark, None, "{run}");
|
||||
assert_eq!(header_pictures(&d), vec![("Main".into(), 1)], "{run}");
|
||||
}
|
||||
let no_header = format!(
|
||||
r#"<w:p><w:pPr><w:sectPr>{PORTRAIT}</w:sectPr></w:pPr><w:r><w:t>One</w:t></w:r></w:p><w:p><w:r><w:t>Two</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#
|
||||
);
|
||||
let d = read(&no_header, vec![p("Two", &portrait_auto, "a.png")]);
|
||||
assert_eq!(d.settings.watermark, None, "the first section's pages have no header");
|
||||
}
|
||||
|
||||
/// #2954 second review: watermarks the plan must leave alone, as main did. A text watermark in
|
||||
/// a footer stays the document's watermark (the headers' pictures stay pictures), and a Word
|
||||
/// picture watermark in a footer keeps every picture where it is. A stretched shape, a scale
|
||||
/// outside 1 to 500 %, other picture corrections, in front of the text, hidden, in a group,
|
||||
/// or a section in tracked insertion with no header: all stay header pictures.
|
||||
#[test]
|
||||
fn picture_watermark_plan_leaves_other_watermarks_alone() {
|
||||
let a = solid_png([20, 60, 140, 255]);
|
||||
let pics = [("a.png", a.clone())];
|
||||
let one = format!(
|
||||
r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/><w:footerReference w:type="default" r:id="rF1"/>{PORTRAIT}</w:sectPr>"#
|
||||
);
|
||||
let auto = wm_shape("width:468pt;height:234pt", "", r#" gain="19661f" blacklevel="22938f""#);
|
||||
let text = r##"<w:r><w:pict><v:shape id="PowerPlusWaterMarkObject9" type="#_x0000_t136" style="position:absolute;width:400pt;height:100pt;rotation:315;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin" fillcolor="silver"><v:fill opacity=".5"/><v:textpath style="font-family:"Calibri"" string="DRAFT"/></v:shape></w:pict></w:r>"##;
|
||||
|
||||
// A footer's text watermark: the document's watermark, as before; the header picture stays,
|
||||
// and both survive saving.
|
||||
let d = parts_doc(&one, &[("Head", auto.clone(), "a.png")], &[("Foot", text.to_string(), "a.png")], &pics);
|
||||
let wm = d.settings.watermark.clone().expect("the footer's text watermark");
|
||||
assert_eq!((wm.text.as_str(), wm.picture.is_none()), ("DRAFT", true));
|
||||
assert_eq!(header_pictures(&d), vec![("Head".into(), 1)]);
|
||||
let d = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(d.settings.watermark.as_ref().map(|w| w.text.as_str()), Some("DRAFT"), "saved");
|
||||
assert_eq!(header_pictures(&d), vec![("Head".into(), 1)], "saved");
|
||||
|
||||
// A footer's picture watermark: nothing is promoted, nothing is drawn twice.
|
||||
let d = parts_doc(&one, &[("Head", auto.clone(), "a.png")], &[("Foot", auto.clone(), "a.png")], &pics);
|
||||
assert_eq!(d.settings.watermark, None);
|
||||
assert_eq!(header_pictures(&d), vec![("Head".into(), 1)]);
|
||||
|
||||
let only = format!(r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#);
|
||||
let group = r##"<w:r><w:pict><v:group style="position:absolute;width:468pt;height:234pt;z-index:-1" coordsize="468,234"><v:shape id="WordPictureWatermark1" type="#_x0000_t75" style="position:absolute;width:468;height:234"><v:imagedata r:id="rIdP"/></v:shape></v:group></w:pict></w:r>"##;
|
||||
for (run, why) in [
|
||||
(wm_shape("width:468pt;height:100pt", "", ""), "stretched"),
|
||||
(wm_shape("width:1800pt;height:900pt", "", ""), "600 %"),
|
||||
(wm_shape("width:2pt;height:1pt", "", ""), "under 1 %"),
|
||||
(wm_shape("width:468pt;height:234pt", "", r#" gain="79922f" blacklevel="-1966f""#), "other corrections"),
|
||||
(wm_shape("width:468pt;height:234pt", "", r#" gain="1" blacklevel="0""#), "identity values"),
|
||||
(wm_shape("width:468pt;height:234pt", ";z-index:5", ""), "in front of the text"),
|
||||
(wm_shape("width:468pt;height:234pt", ";visibility:hidden", ""), "hidden"),
|
||||
(group.to_string(), "in a group"),
|
||||
] {
|
||||
let d = headers_doc(&only, &[("Head", run, "a.png")], &pics);
|
||||
assert_eq!(d.settings.watermark, None, "{why}");
|
||||
}
|
||||
// The first section, inside a tracked insertion, has no header: its pages show none.
|
||||
let tracked = format!(
|
||||
r#"<w:ins w:id="1" w:author="A"><w:p><w:pPr><w:sectPr>{PORTRAIT}</w:sectPr></w:pPr><w:r><w:t>One</w:t></w:r></w:p></w:ins><w:p><w:r><w:t>Two</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#
|
||||
);
|
||||
let d = headers_doc(&tracked, &[("Head", auto.clone(), "a.png")], &pics);
|
||||
assert_eq!(d.settings.watermark, None, "tracked section");
|
||||
// A header that only a table cell's section property loads, with another picture.
|
||||
let cell = format!(
|
||||
r#"<w:tbl><w:tr><w:tc><w:p><w:pPr><w:sectPr><w:headerReference w:type="default" r:id="rH2"/>{PORTRAIT}</w:sectPr></w:pPr></w:p></w:tc></w:tr></w:tbl><w:p/><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#
|
||||
);
|
||||
let b = solid_png([140, 20, 20, 255]);
|
||||
let both = [("a.png", a.clone()), ("b.png", b)];
|
||||
let d = headers_doc(&cell, &[("Head", auto.clone(), "a.png"), ("Cell", auto.clone(), "b.png")], &both);
|
||||
assert_eq!(d.settings.watermark, None, "a header with another picture");
|
||||
// The same document with the plain watermark is promoted.
|
||||
let d = headers_doc(&only, &[("Head", auto, "a.png")], &pics);
|
||||
assert!(d.settings.watermark.is_some_and(|w| w.picture.is_some()));
|
||||
}
|
||||
|
||||
/// A watermark in a tracked deletion is not the document's: a text one stays the WordArt text
|
||||
/// box it is read as (not drawn on every page), and a picture one keeps the plan from making any
|
||||
/// picture the document's watermark. The same watermarks outside a deletion are the document's.
|
||||
#[test]
|
||||
fn a_watermark_in_a_tracked_deletion_is_not_the_documents() {
|
||||
let a = solid_png([20, 60, 140, 255]);
|
||||
let pics = [("a.png", a)];
|
||||
let one = format!(r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rH1"/>{PORTRAIT}</w:sectPr>"#);
|
||||
let pic = wm_shape("width:468pt;height:234pt", "", r#" gain="19661f" blacklevel="22938f""#);
|
||||
let text = r##"<w:r><w:pict><v:shape id="PowerPlusWaterMarkObject9" type="#_x0000_t136" style="position:absolute;width:400pt;height:100pt;rotation:315;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin" fillcolor="silver"><v:textpath style="font-family:"Calibri"" string="DRAFT"/></v:shape></w:pict></w:r>"##;
|
||||
let deleted = |runs: &str| format!(r#"<w:del w:id="9" w:author="A" w:date="2024-01-01T00:00:00Z">{}</w:del>"#, runs);
|
||||
for (runs, kind) in [(&pic, "picture"), (&text.to_string(), "text")] {
|
||||
let live = headers_doc(&one, &[("Head", runs.to_string(), "a.png")], &pics);
|
||||
assert!(live.settings.watermark.is_some(), "{kind}: outside a deletion it is the document's");
|
||||
let gone = headers_doc(&one, &[("Head", deleted(runs), "a.png")], &pics);
|
||||
assert_eq!(gone.settings.watermark, None, "{kind}: in a deletion it isn't");
|
||||
}
|
||||
// The picture stays in the header, marked deleted.
|
||||
let gone = headers_doc(&one, &[("Head", deleted(&pic), "a.png")], &pics);
|
||||
assert_eq!(header_pictures(&gone), vec![("Head".into(), 1)]);
|
||||
}
|
||||
|
||||
/// Issue #1775: a picture watermark in a header that starts with a table is anchored in a cell
|
||||
/// but placed on the page: any VML shape in a header (Word's picture watermark, `o:allowincell="f"`; user-checked
|
||||
/// in Word for Mac, the attribute makes no difference) or DrawingML `layoutInCell="0"`. That survives save and reopen; a shape laid out in its cell stays so.
|
||||
|
||||
@@ -29,7 +29,6 @@ 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;
|
||||
|
||||
@@ -12,9 +12,7 @@ use wordcraft_doc::props::{
|
||||
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart};
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
use wordcraft_doc::table::{Cell, Table};
|
||||
use wordcraft_doc::{
|
||||
Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Source, Watermark, WatermarkPicture, para_block,
|
||||
};
|
||||
use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, Source, Watermark, para_block};
|
||||
|
||||
fn rt(doc: &Document) -> Document {
|
||||
let bytes = wordcraft_docx::write(doc).expect("write");
|
||||
@@ -178,7 +176,6 @@ 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),
|
||||
@@ -212,7 +209,6 @@ 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,
|
||||
}),
|
||||
@@ -1563,10 +1559,7 @@ fn watermark_roundtrips() {
|
||||
diagonal: false,
|
||||
semitransparent: false,
|
||||
size: None,
|
||||
..Default::default()
|
||||
},
|
||||
// Any transparency, not only Word's two.
|
||||
Watermark { transparency: Some(80), ..Default::default() },
|
||||
// A box of its own (not Word's default for the page) is kept.
|
||||
Watermark { text: "COPY".into(), size: Some([300.0, 80.0]), ..Default::default() },
|
||||
] {
|
||||
@@ -1578,147 +1571,6 @@ fn watermark_roundtrips() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `w`×`h` px PNG of one colour.
|
||||
fn wm_png(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(w, h, image::Rgba([20, 60, 140, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
png
|
||||
}
|
||||
|
||||
/// #2954: a picture watermark round-trips through Word's VML picture shape in every header:
|
||||
/// the picture, Auto or a scale, and Washout (`gain` and `blacklevel`) on or off. The header
|
||||
/// keeps its own text and gains no picture of its own.
|
||||
#[test]
|
||||
fn picture_watermark_roundtrips() {
|
||||
let png = wm_png(40, 20);
|
||||
let mut d = doc_with(vec![Paragraph::with_text("Body", CharProps::default())]);
|
||||
let h = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]);
|
||||
d.last_section.headers.default = Some(h);
|
||||
let key = d.add_media(png.clone(), "png");
|
||||
for (scale, washout) in [(None, true), (Some(50.0), false), (Some(200.0), true)] {
|
||||
let wm = Watermark { picture: Some(WatermarkPicture { media: key.clone(), scale, washout }), ..Watermark::default() };
|
||||
d.settings.watermark = Some(wm.clone());
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let hdr = part_xml(&bytes, "word/header1.xml");
|
||||
assert_eq!(hdr.matches("<v:shape ").count(), 1);
|
||||
assert!(hdr.contains(r#"id="WordPictureWatermark1""#) && hdr.contains("mso-position-vertical-relative:margin"), "{hdr}");
|
||||
assert_eq!(hdr.contains(r#"gain="19661f" blacklevel="22938f""#), washout);
|
||||
assert!(part_xml(&bytes, "word/_rels/header1.xml.rels").contains("media/"));
|
||||
let r = wordcraft_docx::read(&bytes).unwrap();
|
||||
let back = r.settings.watermark.clone().expect("watermark");
|
||||
let pic = back.picture.clone().expect("picture");
|
||||
assert_eq!((pic.scale, pic.washout), (scale, washout));
|
||||
assert_eq!(r.media.get(&pic.media).map(|b| b.as_slice()), Some(png.as_slice()));
|
||||
let hid = r.last_section.headers.default.unwrap();
|
||||
assert_eq!(part_text(&r, Some(hid)), "Head");
|
||||
assert!(r.parts[&hid].blocks.iter().filter_map(|b| b.as_para()).all(|p| p.objects.is_empty()), "no picture left in the header");
|
||||
assert_eq!(rt(&r).settings.watermark, r.settings.watermark, "second generation");
|
||||
}
|
||||
// A watermark whose picture can't be read is not written.
|
||||
d.media.insert(key, Arc::new(b"junk".to_vec()));
|
||||
let r = rt(&d);
|
||||
assert_eq!(r.settings.watermark, None);
|
||||
assert!(!part_xml(&wordcraft_docx::write(&d).unwrap(), "word/header1.xml").contains("v:shape"));
|
||||
}
|
||||
|
||||
/// A portrait first section whose header the landscape last section links to ("link to
|
||||
/// previous"), with a wide picture watermark at Auto.
|
||||
fn portrait_then_landscape_linked(header: bool, scale: Option<f32>) -> Document {
|
||||
let mut d = Document::new();
|
||||
let mut s1 = SectionProps::default();
|
||||
if header {
|
||||
s1.headers.default = Some(d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Head", CharProps::default()))]));
|
||||
}
|
||||
let mut p1 = Paragraph::with_text("Portrait", CharProps::default());
|
||||
p1.section = Some(Box::new(s1));
|
||||
d.body = vec![para_block(p1), para_block(Paragraph::with_text("Landscape", CharProps::default()))];
|
||||
d.last_section.set_landscape(true);
|
||||
let key = d.add_media(wm_png(400, 200), "png");
|
||||
d.settings.watermark = Some(Watermark { picture: Some(WatermarkPicture { media: key, scale, washout: true }), ..Watermark::default() });
|
||||
d
|
||||
}
|
||||
|
||||
/// #2954 review: Auto is sized once per header, for the first section that names it. A portrait
|
||||
/// section before a landscape one that links to its header: the header holds the portrait size
|
||||
/// (468 pt wide), and reads back as Auto.
|
||||
#[test]
|
||||
fn auto_picture_watermark_is_sized_for_the_first_section_naming_its_header() {
|
||||
let d = portrait_then_landscape_linked(true, None);
|
||||
// The header the landscape section links to is the portrait section's.
|
||||
assert_eq!(d.watermark_fit(d.sections()[0].1.headers.default), (468.0, 648.0));
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
assert!(part_xml(&bytes, "word/header1.xml").contains("width:468.000pt;height:234.000pt"));
|
||||
let r = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(r.settings.watermark.and_then(|w| w.picture).map(|p| p.scale), Some(None), "still Auto");
|
||||
}
|
||||
|
||||
/// A document of two sections, the first with no header of its own and the last landscape: the
|
||||
/// watermark's own header serves both, sized for the first.
|
||||
#[test]
|
||||
fn auto_picture_watermark_header_written_for_the_watermark_alone() {
|
||||
let d = portrait_then_landscape_linked(false, None);
|
||||
assert_eq!(d.watermark_fit(None), (468.0, 648.0));
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
assert!(part_xml(&bytes, "word/header1.xml").contains("width:468.000pt;height:234.000pt"));
|
||||
let r = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(r.settings.watermark.and_then(|w| w.picture).map(|p| p.scale), Some(None));
|
||||
}
|
||||
|
||||
/// #2954 second review: a watermark at a fixed scale, its header shapes the same size in a
|
||||
/// portrait and a landscape section, fills one section's margins but not the other's. It is
|
||||
/// still the document's watermark at that scale after saving and reopening, washed out, and
|
||||
/// the headers gain no pictures.
|
||||
#[test]
|
||||
fn fixed_scale_watermark_across_differing_sections() {
|
||||
let mut d = Document::new();
|
||||
let h1 = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Portrait", CharProps::default()))]);
|
||||
let h2 = d.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Landscape", CharProps::default()))]);
|
||||
let mut s1 = SectionProps::default();
|
||||
s1.headers.default = Some(h1);
|
||||
let mut p1 = Paragraph::with_text("One", CharProps::default());
|
||||
p1.section = Some(Box::new(s1));
|
||||
d.body = vec![para_block(p1), para_block(Paragraph::with_text("Two", CharProps::default()))];
|
||||
d.last_section.set_landscape(true);
|
||||
d.last_section.headers.default = Some(h2);
|
||||
let key = d.add_media(wm_png(624, 312), "png");
|
||||
let wm = Watermark { picture: Some(WatermarkPicture { media: key, scale: Some(100.0), washout: true }), ..Watermark::default() };
|
||||
d.settings.watermark = Some(wm);
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
for name in ["word/header1.xml", "word/header2.xml"] {
|
||||
assert!(part_xml(&bytes, name).contains("width:468.000pt;height:234.000pt"), "{name}");
|
||||
}
|
||||
let r = wordcraft_docx::read(&bytes).unwrap();
|
||||
let pic = r.settings.watermark.clone().and_then(|w| w.picture).expect("still the document's watermark");
|
||||
assert_eq!((pic.scale, pic.washout), (Some(100.0), true));
|
||||
for id in header_ids(&r) {
|
||||
assert!(r.parts[&id].blocks.iter().filter_map(|b| b.as_para()).all(|p| p.objects.is_empty()), "no header picture");
|
||||
}
|
||||
let r2 = rt(&r);
|
||||
assert_eq!(r2.settings.watermark, r.settings.watermark, "second generation");
|
||||
}
|
||||
|
||||
/// #2954: the text watermark's transparency round-trips as `v:fill` `opacity`; Word's
|
||||
/// Semitransparent is `.5`, and an opaque watermark has no `v:fill`.
|
||||
#[test]
|
||||
fn text_watermark_transparency_roundtrips() {
|
||||
for (t, fill) in [(0, None), (25, Some(".75")), (50, Some(".5")), (80, Some(".2")), (100, Some("0"))] {
|
||||
let mut d = doc_with(vec![Paragraph::with_text("Body", CharProps::default())]);
|
||||
let mut wm = Watermark::default();
|
||||
wm.set_transparency(t);
|
||||
d.settings.watermark = Some(wm.clone());
|
||||
let bytes = wordcraft_docx::write(&d).unwrap();
|
||||
let hdr = part_xml(&bytes, "word/header1.xml");
|
||||
match fill {
|
||||
Some(o) => assert!(hdr.contains(&format!(r#"<v:fill opacity="{o}"/>"#)), "{t}: {hdr}"),
|
||||
None => assert!(!hdr.contains("v:fill"), "{t}"),
|
||||
}
|
||||
let r = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(r.settings.watermark, Some(wm), "{t}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3994: a watermark font name round-trips through the textpath's CSS-like style whatever it
|
||||
/// carries — apostrophes, semicolons, colons, quotes, backslashes. Before, those characters
|
||||
/// were deleted (the font was saved under a different name) and a trailing backslash escaped
|
||||
|
||||
@@ -29,30 +29,6 @@ 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(¶graphs[0].props).adjust_right_indent);
|
||||
assert!(d.styles.resolve_para(¶graphs[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(¶graphs[0].props).adjust_right_indent);
|
||||
assert!(d.styles.resolve_para(¶graphs[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#"
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
//! 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}");
|
||||
}
|
||||
@@ -17,27 +17,6 @@ fn tiny_png() -> Vec<u8> {
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
/// #2954: a picture watermark and a text watermark's transparency survive Word 2003 XML (the
|
||||
/// picture as `w:binData`, the same VML shapes as in DOCX).
|
||||
#[test]
|
||||
fn watermarks_round_trip_through_2003() {
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para_block(Paragraph::with_text("Body", CharProps::default()))];
|
||||
let key = d.add_media(tiny_png(), "png");
|
||||
let pic = wordcraft_doc::WatermarkPicture { media: key, scale: Some(150.0), washout: true };
|
||||
d.settings.watermark = Some(wordcraft_doc::Watermark { picture: Some(pic), ..Default::default() });
|
||||
let back = read_xml(&write_wordml2003(&d).expect("write")).expect("read");
|
||||
let got = back.settings.watermark.and_then(|w| w.picture).expect("picture watermark");
|
||||
assert_eq!((got.scale, got.washout), (Some(150.0), true));
|
||||
assert_eq!(back.media.get(&got.media).map(|b| b.as_slice()), Some(tiny_png().as_slice()));
|
||||
|
||||
let mut wm = wordcraft_doc::Watermark::default();
|
||||
wm.set_transparency(70);
|
||||
d.settings.watermark = Some(wm.clone());
|
||||
let back = read_xml(&write_wordml2003(&d).expect("write")).expect("read");
|
||||
assert_eq!(back.settings.watermark, Some(wm));
|
||||
}
|
||||
|
||||
fn paras(doc: &Document) -> Vec<&Paragraph> {
|
||||
doc.body.iter().filter_map(|b| b.as_para()).collect()
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
//! 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");
|
||||
}
|
||||
@@ -52,9 +52,6 @@ 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 }
|
||||
# The PDF side of the watermark size check (what the export draws, read back).
|
||||
wordcraft-pdf = { workspace = true, features = ["test-support"] }
|
||||
|
||||
@@ -169,9 +169,6 @@ 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) {
|
||||
@@ -189,9 +186,7 @@ pub(crate) fn delete_selection_effect(s: &mut Session) -> Result<SelectionDelete
|
||||
Ok(effect)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Delete the selection (respecting track changes); returns the collapsed position.
|
||||
pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
|
||||
delete_selection_effect(s).map(|e| e.at)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,6 @@
|
||||
//! 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};
|
||||
@@ -18,29 +14,6 @@ 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.
|
||||
@@ -74,10 +47,6 @@ 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),
|
||||
@@ -255,171 +224,9 @@ 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 {
|
||||
@@ -520,131 +327,4 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,11 +201,7 @@ 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 {
|
||||
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()
|
||||
cited.iter().filter_map(|t| doc.sources.iter().position(|x| &x.tag == t)).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()
|
||||
@@ -237,21 +233,6 @@ 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
|
||||
@@ -261,9 +242,8 @@ fn refresh_citations(s: &mut Session) -> Result<(), CmdError> {
|
||||
.filter_map(|(k, o)| match o {
|
||||
InlineObject::Field { instr, .. } => {
|
||||
let (tag, pages) = citation_field(instr)?;
|
||||
let &(i, number) = by_tag.get(tag)?;
|
||||
let src = s.doc.sources.get(i)?;
|
||||
Some((k, cite(src, st.name, number, pages)))
|
||||
let src = s.doc.sources.iter().find(|x| x.tag == tag)?;
|
||||
Some((k, cite(src, st.name, number_of(&s.doc, &order, tag), pages)))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
|
||||
@@ -785,118 +785,3 @@ 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))));
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, LineSpacing, Rgb, TextColor};
|
||||
use wordcraft_doc::watermark::{MAX_SCALE, MIN_SCALE};
|
||||
use wordcraft_doc::{Watermark, WatermarkPicture, styles};
|
||||
use wordcraft_doc::{Watermark, styles};
|
||||
|
||||
use super::sel_result;
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
@@ -72,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("design.scaleFontSizes", "Scale Font Sizes", "Design › Document Formatting", scale_font_sizes)
|
||||
.params(r#"{"delta": pt (-1638..1638, not 0) | "percent": number (-99..1000, not 0; 10 = +10%)} (exactly one)"#),
|
||||
CommandSpec::new("design.watermark", "Watermark", "Design › Page Background", watermark)
|
||||
.params(r#"{"text"?: string (a text watermark), "remove"?: bool, "diagonal"?: bool, "color"?: "RRGGBB", "transparency"?: 0-100 (percent; Word's Semitransparent is 50), "semitransparent"?: bool (50 or 0 percent), "picture"?: {"path": string} | {"data": base64} | {"media": key} (a picture watermark; at most 64 MB) | false (back to text), "scale"?: "auto" | 1-500 (percent of the picture's own size; auto fits the margins), "washout"?: bool (faded, default on)}"#),
|
||||
.params(r#"{"text"?: string, "remove"?: bool, "diagonal"?: bool, "color"?: "RRGGBB"}"#),
|
||||
CommandSpec::new("design.pageColor", "Page Color", "Design › Page Background", |s, v| {
|
||||
s.doc.settings.page_color = p::str(v, "color").and_then(Rgb::parse);
|
||||
s.doc.settings.page_picture = None;
|
||||
@@ -248,142 +247,23 @@ fn scale_font_sizes(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Design › Watermark: a text watermark (`text` switches to one), or a picture watermark
|
||||
/// (`picture`) with its scale and Washout; the text keeps its settings meanwhile, as in Word's
|
||||
/// dialog. Settings for the other kind are refused rather than silently kept.
|
||||
fn watermark(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if p::bool(v, "remove").unwrap_or(false) {
|
||||
s.doc.settings.watermark = None;
|
||||
return sel_result(s);
|
||||
}
|
||||
let mut wm = s.doc.settings.watermark.clone().unwrap_or_default();
|
||||
// Everything is checked before the document is touched (a picture added to the media).
|
||||
let scale = watermark_scale(v)?;
|
||||
let picked = match v.get("picture") {
|
||||
Some(Value::Bool(false)) => {
|
||||
wm.picture = None;
|
||||
None
|
||||
}
|
||||
Some(pic) if !pic.is_null() => Some(watermark_picture(s, pic)?),
|
||||
_ => {
|
||||
if p::str(v, "text").is_some() {
|
||||
wm.picture = None;
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
// The picture's bytes wait: it joins the document's media only after every check below.
|
||||
let pending = pick_watermark_picture(&mut wm, picked);
|
||||
if let Some(t) = p::str(v, "text") {
|
||||
wm.text = t.chars().take(200).collect();
|
||||
}
|
||||
match wm.picture.as_mut() {
|
||||
Some(pic) => apply_picture_settings(v, pic, scale)?,
|
||||
None => apply_text_settings(v, &mut wm)?,
|
||||
}
|
||||
if let (Some(bytes), Some(pic)) = (pending, wm.picture.as_mut()) {
|
||||
let ext = super::insert::image_ext(&bytes);
|
||||
pic.media = s.doc.add_media(bytes, ext);
|
||||
}
|
||||
s.doc.settings.watermark = Some(wm);
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// The watermark's `scale` param: None when absent, `Some(None)` for Auto, else the percentage in
|
||||
/// tenths, clamped.
|
||||
fn watermark_scale(v: &Value) -> Result<Option<Option<f32>>, CmdError> {
|
||||
match v.get("scale") {
|
||||
None => Ok(None),
|
||||
Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Ok(Some(None)),
|
||||
Some(_) => {
|
||||
let percent =
|
||||
p::f32(v, "scale").filter(|p| p.is_finite()).ok_or_else(|| CmdError::Params("`scale` is \"auto\" or a percentage".into()))?;
|
||||
// Tenths of a percent, as a saved file gives it back.
|
||||
Ok(Some(Some((percent.clamp(MIN_SCALE, MAX_SCALE) * 10.0).round() / 10.0)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes `picked` (bytes and an existing media key) the watermark's picture, keeping its other
|
||||
/// settings; returns the bytes still to join the document's media, if any.
|
||||
fn pick_watermark_picture(wm: &mut Watermark, picked: Option<(Vec<u8>, Option<String>)>) -> Option<Vec<u8>> {
|
||||
let (bytes, key) = picked?;
|
||||
let (media, pending) = match key {
|
||||
Some(key) => (key, None),
|
||||
None => (String::new(), Some(bytes)),
|
||||
};
|
||||
wm.picture = Some(WatermarkPicture { media, ..wm.picture.take().unwrap_or_default() });
|
||||
pending
|
||||
}
|
||||
|
||||
/// Applies a picture watermark's `scale` and `washout`; refuses a text watermark's settings.
|
||||
fn apply_picture_settings(v: &Value, pic: &mut WatermarkPicture, scale: Option<Option<f32>>) -> Result<(), CmdError> {
|
||||
if let Some(k) = ["diagonal", "color", "transparency", "semitransparent"].into_iter().find(|k| v.get(*k).is_some()) {
|
||||
return Err(CmdError::Params(format!("`{k}` is for a text watermark: give `text` or `\"picture\": false` too")));
|
||||
}
|
||||
if let Some(scale) = scale {
|
||||
pic.scale = scale;
|
||||
}
|
||||
if let Some(w) = p::bool(v, "washout") {
|
||||
pic.washout = w;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies a text watermark's layout, colour and transparency; refuses a picture's settings.
|
||||
fn apply_text_settings(v: &Value, wm: &mut Watermark) -> Result<(), CmdError> {
|
||||
if let Some(k) = ["scale", "washout"].into_iter().find(|k| v.get(*k).is_some()) {
|
||||
return Err(CmdError::Params(format!("`{k}` is for a picture watermark: give `picture` too")));
|
||||
}
|
||||
if let Some(d) = p::bool(v, "diagonal") {
|
||||
wm.diagonal = d;
|
||||
}
|
||||
if let Some(c) = p::str(v, "color").and_then(Rgb::parse) {
|
||||
wm.color = c;
|
||||
}
|
||||
if let Some(on) = p::bool(v, "semitransparent") {
|
||||
wm.set_transparency(if on { 50 } else { 0 });
|
||||
}
|
||||
// Applied after `semitransparent`, so a percentage wins when both are given.
|
||||
if let Some(t) = p::f32(v, "transparency") {
|
||||
wm.set_transparency(if t.is_finite() { t.clamp(0.0, 100.0).round() as u8 } else { 0 });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Largest watermark picture taken, bytes. A watermark is a faded backdrop, not a photograph
|
||||
/// library: far below Insert › Pictures' 200 MB, so refusing a bigger one never costs a large
|
||||
/// allocation (the base64 text is measured before it is decoded).
|
||||
const MAX_PICTURE_BYTES: usize = 64 << 20;
|
||||
|
||||
/// The picture of a watermark: a file (`path`), base64 `data`, or a picture already in the
|
||||
/// document (`media`, returned as its key). Only pictures the renderers draw are taken.
|
||||
fn watermark_picture(s: &Session, v: &Value) -> Result<(Vec<u8>, Option<String>), CmdError> {
|
||||
let unsupported = || CmdError::Failed("not a supported picture (PNG, JPEG, GIF, WebP, BMP, TIFF)".into());
|
||||
let readable = |b: &[u8]| wordcraft_doc::watermark::own_size(b).is_some();
|
||||
let too_large = || CmdError::Failed(format!("the picture is larger than {} MB", MAX_PICTURE_BYTES >> 20));
|
||||
if let Some(key) = p::str(v, "media") {
|
||||
let bytes = s.doc.media.get(key).ok_or_else(|| CmdError::Params(format!("no picture `{key}` in the document")))?;
|
||||
return if readable(bytes) { Ok((Vec::new(), Some(key.to_string()))) } else { Err(unsupported()) };
|
||||
}
|
||||
let bytes = if let Some(d) = p::str(v, "data") {
|
||||
// base64 is 4 characters for 3 bytes: measured before anything is decoded.
|
||||
if d.len() > MAX_PICTURE_BYTES / 3 * 4 + 4 {
|
||||
return Err(too_large());
|
||||
}
|
||||
super::insert::base64_decode(d).ok_or_else(|| CmdError::Params("bad base64 data".into()))?
|
||||
} else if let Some(path) = p::str(v, "path") {
|
||||
crate::io::read_file_capped(path, MAX_PICTURE_BYTES as u64, "picture").map_err(CmdError::Failed)?
|
||||
} else {
|
||||
return Err(CmdError::Params("`picture` needs `path`, `data` or `media`".into()));
|
||||
};
|
||||
if bytes.len() > MAX_PICTURE_BYTES {
|
||||
return Err(too_large());
|
||||
}
|
||||
if !readable(&bytes) {
|
||||
return Err(unsupported());
|
||||
}
|
||||
Ok((bytes, None))
|
||||
s.doc.settings.watermark = Some(Watermark { ..wm });
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Page borders of the caret's section (or every section with `applyTo: "document"`).
|
||||
@@ -408,125 +288,3 @@ fn page_borders(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::{Document, Watermark, WatermarkPicture};
|
||||
|
||||
use super::MAX_PICTURE_BYTES;
|
||||
use crate::Session;
|
||||
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut b = Vec::new();
|
||||
image::RgbaImage::from_pixel(w, h, image::Rgba([30, 60, 200, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut b), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
b
|
||||
}
|
||||
|
||||
fn wm(s: &Session) -> Watermark {
|
||||
s.doc.settings.watermark.clone().unwrap()
|
||||
}
|
||||
|
||||
/// #2954: a picture watermark from a file's bytes, its scale and Washout, then back to text;
|
||||
/// each step undoes.
|
||||
#[test]
|
||||
fn picture_watermark_and_back_to_text() {
|
||||
let mut s = Session::new(Document::new());
|
||||
let data = super::super::insert::base64_encode(&png(40, 20));
|
||||
s.run("design.watermark", &json!({"picture": {"data": data}})).unwrap();
|
||||
let pic = wm(&s).picture.unwrap();
|
||||
assert_eq!((pic.scale, pic.washout), (None, true), "Word's defaults: Auto, washed out");
|
||||
assert!(s.doc.media.contains_key(&pic.media));
|
||||
s.run("design.watermark", &json!({"scale": 150, "washout": false})).unwrap();
|
||||
assert_eq!(wm(&s).picture, Some(WatermarkPicture { media: pic.media.clone(), scale: Some(150.0), washout: false }));
|
||||
s.run("design.watermark", &json!({"scale": "AUTO"})).unwrap();
|
||||
assert_eq!(wm(&s).picture.unwrap().scale, None);
|
||||
// Clamped to Word's range.
|
||||
s.run("design.watermark", &json!({"scale": 9000})).unwrap();
|
||||
assert_eq!(wm(&s).picture.unwrap().scale, Some(500.0));
|
||||
s.run("design.watermark", &json!({"scale": 0})).unwrap();
|
||||
assert_eq!(wm(&s).picture.unwrap().scale, Some(1.0));
|
||||
s.run("design.watermark", &json!({"scale": 33.333})).unwrap();
|
||||
assert_eq!(wm(&s).picture.unwrap().scale, Some(33.3));
|
||||
// A picture already in the document; text settings are refused while it is a picture.
|
||||
s.run("design.watermark", &json!({"picture": {"media": pic.media}, "scale": 50})).unwrap();
|
||||
assert!(s.run("design.watermark", &json!({"transparency": 30})).is_err());
|
||||
assert!(s.run("design.watermark", &json!({"diagonal": false})).is_err());
|
||||
// Text (or `picture: false`) makes it a text watermark again, with its text settings.
|
||||
s.run("design.watermark", &json!({"text": "SAMPLE", "transparency": 30})).unwrap();
|
||||
let text = wm(&s);
|
||||
assert_eq!((text.picture.is_none(), text.text.as_str(), text.transparency()), (true, "SAMPLE", 30));
|
||||
assert!(s.run("design.watermark", &json!({"washout": true})).is_err());
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(wm(&s).picture.unwrap().scale, Some(50.0));
|
||||
s.run("design.watermark", &json!({"picture": false})).unwrap();
|
||||
assert_eq!(wm(&s).picture, None);
|
||||
s.run("design.watermark", &json!({"remove": true})).unwrap();
|
||||
assert_eq!(s.doc.settings.watermark, None);
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc.settings.watermark.is_some());
|
||||
}
|
||||
|
||||
/// #2954: the text's transparency in percent, kept in step with Semitransparent.
|
||||
#[test]
|
||||
fn text_watermark_transparency() {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("design.watermark", &json!({"text": "DRAFT"})).unwrap();
|
||||
assert_eq!((wm(&s).transparency(), wm(&s).semitransparent), (50, true));
|
||||
s.run("design.watermark", &json!({"transparency": 80})).unwrap();
|
||||
assert_eq!((wm(&s).transparency(), wm(&s).semitransparent), (80, true));
|
||||
s.run("design.watermark", &json!({"semitransparent": false})).unwrap();
|
||||
assert_eq!((wm(&s).transparency(), wm(&s).semitransparent), (0, false));
|
||||
s.run("design.watermark", &json!({"transparency": 250.7})).unwrap();
|
||||
assert_eq!(wm(&s).transparency(), 100);
|
||||
s.run("design.watermark", &json!({"transparency": -3})).unwrap();
|
||||
assert_eq!(wm(&s).transparency(), 0);
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(wm(&s).transparency(), 100);
|
||||
}
|
||||
|
||||
/// #2954: pictures that can't be drawn, and missing ones, are refused and change nothing.
|
||||
#[test]
|
||||
fn bad_watermark_pictures_are_refused() {
|
||||
let mut s = Session::new(Document::new());
|
||||
let text = s.doc.add_media(b"not a picture".to_vec(), "png");
|
||||
for bad in [
|
||||
json!({"picture": {}}),
|
||||
json!({"picture": {"data": "%%%"}}),
|
||||
json!({"picture": {"data": super::super::insert::base64_encode(b"plain text")}}),
|
||||
json!({"picture": {"media": "nope.png"}}),
|
||||
json!({"picture": {"media": text}}),
|
||||
json!({"picture": {"path": "/nonexistent/watermark.png"}}),
|
||||
json!({"picture": 7}),
|
||||
json!({"picture": {"data": super::super::insert::base64_encode(&png(4, 4))}, "scale": "big"}),
|
||||
] {
|
||||
assert!(s.run("design.watermark", &bad).is_err(), "{bad}");
|
||||
assert_eq!(s.doc.settings.watermark, None, "{bad}");
|
||||
assert_eq!(s.doc.media.len(), 1, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2954 review: a picture too large is refused before anything is decoded or read: the
|
||||
/// base64 text is measured first, and a file by its size.
|
||||
#[test]
|
||||
fn oversized_watermark_pictures_are_refused_without_a_large_allocation() {
|
||||
let mut s = Session::new(Document::new());
|
||||
// Longer than the largest picture's base64 text: refused by length, never decoded (the
|
||||
// decoded bytes would be as large as the cap again).
|
||||
let big = "A".repeat(MAX_PICTURE_BYTES / 3 * 4 + 8);
|
||||
let e = s.run("design.watermark", &json!({"picture": {"data": big}})).unwrap_err();
|
||||
let e = format!("{e:?}");
|
||||
assert!(e.contains("larger than"), "{e}");
|
||||
assert_eq!((s.doc.settings.watermark.clone(), s.doc.media.len()), (None, 0));
|
||||
// A file past the cap is refused by its size (a sparse file, so nothing is written).
|
||||
let path = std::env::temp_dir().join(format!("wordcraft-wm-big-{}.png", std::process::id()));
|
||||
std::fs::File::create(&path).unwrap().set_len(MAX_PICTURE_BYTES as u64 + 1).unwrap();
|
||||
let e = s.run("design.watermark", &json!({"picture": {"path": path.to_string_lossy()}})).unwrap_err();
|
||||
let e = format!("{e:?}");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(e.contains("larger than"), "{e}");
|
||||
assert_eq!((s.doc.settings.watermark.clone(), s.doc.media.len()), (None, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,6 @@ 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")]
|
||||
@@ -328,13 +325,12 @@ 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 objects.get(&pos) {
|
||||
match s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) {
|
||||
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 })
|
||||
}
|
||||
@@ -581,11 +577,10 @@ 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) = objects.get(&pos) else { continue };
|
||||
let Some(obj) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue };
|
||||
let picked = match obj.ink() {
|
||||
Some(_) if s.view.hide_ink => false,
|
||||
Some(f) => {
|
||||
@@ -776,14 +771,13 @@ 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, .. }) = objects.get(&pos) else { 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(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));
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
//! 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)
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ 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();
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
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})");
|
||||
}
|
||||
@@ -199,21 +199,16 @@ 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) 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).
|
||||
/// #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).
|
||||
pub(crate) fn copy_result(s: &mut Session) -> Value {
|
||||
(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()),
|
||||
s.clipboard_html = match &s.clipboard {
|
||||
Some(f) => wordcraft_formats::html::export_fragment(&f.to_document()),
|
||||
None => String::new(),
|
||||
};
|
||||
let mut r = json!({"text": s.clipboard_text});
|
||||
for (k, v) in [("html", &s.clipboard_html), ("rtf", &s.clipboard_rtf)] {
|
||||
if !v.is_empty() {
|
||||
r[k] = json!(v);
|
||||
}
|
||||
if !s.clipboard_html.is_empty() {
|
||||
r["html"] = json!(s.clipboard_html);
|
||||
}
|
||||
// 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
|
||||
@@ -361,7 +356,7 @@ impl ClipHistory {
|
||||
self.items.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn push(&mut self, f: &Fragment, text: &str) {
|
||||
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));
|
||||
|
||||
@@ -326,14 +326,8 @@ fn type_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
pos = p;
|
||||
changed = true;
|
||||
}
|
||||
// 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) };
|
||||
if let Some(p) = me::build_up(&mut math.nodes, &pos, latex) {
|
||||
pos = p;
|
||||
changed = true;
|
||||
}
|
||||
if changed {
|
||||
@@ -342,14 +336,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -530,112 +530,3 @@ 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+");
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
use super::filter::refresh_shown;
|
||||
use super::{CmdError, CmdResult, Session, Value, json, p};
|
||||
|
||||
/// The field separator: whichever of `,` `;` and tab the first non-blank record (outside quotes) uses most.
|
||||
/// 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.
|
||||
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();
|
||||
// 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) {
|
||||
for c in first.chars() {
|
||||
if c == '"' {
|
||||
quoted = !quoted;
|
||||
} else if !quoted {
|
||||
if c == '\n' || c == '\r' {
|
||||
break;
|
||||
}
|
||||
unquoted.push(c);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +26,6 @@ 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();
|
||||
@@ -115,23 +112,13 @@ 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") {
|
||||
recipient_csv(c, p::bool(v, "headers").unwrap_or(true))
|
||||
parse_csv(c)
|
||||
} 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()))?;
|
||||
@@ -188,7 +175,7 @@ pub(super) fn recipients(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sheet = json!(chosen);
|
||||
rows
|
||||
} else {
|
||||
recipient_csv(&decode(&bytes, v)?, p::bool(v, "headers").unwrap_or(true))
|
||||
parse_csv(&decode(&bytes, v)?)
|
||||
}
|
||||
};
|
||||
header_split(all, p::bool(v, "headers").unwrap_or(true))?
|
||||
|
||||
@@ -46,76 +46,6 @@ 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");
|
||||
|
||||
@@ -6,9 +6,6 @@ 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)),
|
||||
@@ -61,7 +58,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 insertion cursor was (#2233, #2234). Dropped on itself, nothing changes.
|
||||
/// where its dashed insertion cursor was (#2233, #2234). Dropped on itself, nothing changes.
|
||||
/// Returns the object's position afterwards.
|
||||
fn move_in_text(s: &mut Session, pos: Pos, to: &Pos) -> Result<Pos, CmdError> {
|
||||
let to = landing(s, &pos, to)?;
|
||||
@@ -164,9 +161,8 @@ 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 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) {
|
||||
// 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 {
|
||||
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()))?;
|
||||
|
||||
@@ -12,10 +12,6 @@ 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,
|
||||
@@ -55,15 +51,6 @@ 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";
|
||||
@@ -112,9 +99,7 @@ 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. 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.
|
||||
/// The first picture/shape in the selection, or just before a collapsed caret.
|
||||
pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
|
||||
selected_all(s, 1).into_iter().next()
|
||||
}
|
||||
@@ -122,9 +107,6 @@ 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 };
|
||||
@@ -193,12 +175,8 @@ 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. With a member of the canvas at `pos`
|
||||
/// picked, the member changes.
|
||||
/// Change the object at `pos`; returns it afterwards.
|
||||
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);
|
||||
|
||||
@@ -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_placeable, 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_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};
|
||||
@@ -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_placeable),
|
||||
.when(has_movable),
|
||||
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_placeable),
|
||||
.when(has_movable),
|
||||
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(|s| has_placeable(s).or_else(|| has_floating(s))),
|
||||
.when(has_floating),
|
||||
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_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),
|
||||
.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),
|
||||
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,20 +245,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
})
|
||||
.params(r#"{"value": "left|center|right"}"#)
|
||||
.when(has_placeable),
|
||||
.when(has_movable),
|
||||
CommandSpec::new("arrange.selectionPane", "Selection Pane", "Layout › Arrange", objects_list).pure(),
|
||||
CommandSpec::new("arrange.bounds", "Move or Resize", "Layout › Arrange", bounds)
|
||||
.params(r#"{"width"?: pt, "height"?: pt, "x"?: pt, "y"?: pt, "page"?: n, "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_placeable),
|
||||
.when(has_movable),
|
||||
CommandSpec::new("arrange.nudge", "Nudge", "Layout › Arrange", |s, v| {
|
||||
let d = |k| p::f32(v, k).unwrap_or(0.0).clamp(-MAX_OFFSET, MAX_OFFSET);
|
||||
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);
|
||||
|
||||
@@ -14,69 +14,6 @@ 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) {
|
||||
|
||||
@@ -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::{cur as selection_para, fmt, indent, outdent};
|
||||
pub use format::{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};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use super::{Align, CmdError, CmdResult, LineSpacing, NumRef, ParaProps, Pos, Session, Value, json, p, sel_result};
|
||||
|
||||
/// 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 {
|
||||
/// Resolved paragraph props at the caret.
|
||||
pub(super) 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();
|
||||
@@ -123,16 +122,8 @@ 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(¶.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));
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
mod fields;
|
||||
mod notes;
|
||||
mod numbers;
|
||||
mod properties;
|
||||
mod toc;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use super::numbers::{body_bookmark_paths, para_numbers, ref_number};
|
||||
use super::properties::{
|
||||
bookmark_ends_in_its_paragraph, bookmark_text, bookmark_text_at, count_text, date_picture, doc_property, doc_property_arg, new_property_value,
|
||||
set_summary_property, summary_property, text_result,
|
||||
@@ -336,9 +335,6 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
|
||||
// The document properties as the fields read and set them (`SUBJECT "New subject"`): a field
|
||||
// after one that sets a property shows the new value.
|
||||
let mut core = s.doc.core.clone();
|
||||
// Build each body lookup once, only when a supported numeric REF needs it.
|
||||
let mut numbers = None;
|
||||
let mut bookmark_paths = None;
|
||||
let paths: Vec<(StoryRef, Path)> = stories.into_iter().flat_map(|st| s.doc.para_paths(st).into_iter().map(move |p| (st, p))).collect();
|
||||
// SET <name> "<text>" (ECMA-376 §17.16.5.58): the values it gives, which REF <name> shows; a
|
||||
// REF before the SET shows the last value set, as Word's bookmark does after an update.
|
||||
@@ -378,14 +374,6 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
|
||||
let r = if name == "REF" {
|
||||
if has_switch(instr, 'p') {
|
||||
None
|
||||
} else if let Some(sw) = ['w', 'r', 'n'].into_iter().find(|c| has_switch(instr, *c)) {
|
||||
if has_switch(instr, 'd') || has_switch(instr, 't') {
|
||||
continue; // Preserve cached results for unsupported delimiter/text switches.
|
||||
}
|
||||
let numbers = numbers.get_or_insert_with(|| para_numbers(&s.doc));
|
||||
let from = (story == StoryRef::Body).then_some(&path);
|
||||
let paths = bookmark_paths.get_or_insert_with(|| body_bookmark_paths(&s.doc));
|
||||
paths.get(target).and_then(|tp| ref_number(numbers, tp, from, sw))
|
||||
} else {
|
||||
bookmark_text(&s.doc, target).or_else(|| {
|
||||
let key = target.to_lowercase();
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
//! Numbered REF results, adapted from @Alyosha-dot-shop's patch in #4533.
|
||||
|
||||
use super::{Counters, NumFormat, Path, StoryRef};
|
||||
|
||||
/// What a numbered body paragraph shows: the counter value of each level down to its own, the
|
||||
/// label each of those levels shows at that point (`["14.", "(a)"]` for 14(a)), and the levels
|
||||
/// each label's template shows (`"%1.%2"` shows levels 0 and 1).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct ParaNumber {
|
||||
/// Relative context is shared only within the same numbering instance.
|
||||
list: u32,
|
||||
values: Vec<u32>,
|
||||
/// Whether each level has counted a paragraph yet: a level the list never used (an `(a)` item
|
||||
/// with no clause above it in its list) shows no number in a reference.
|
||||
started: Vec<bool>,
|
||||
labels: Vec<Option<String>>,
|
||||
shows: Vec<Vec<usize>>,
|
||||
}
|
||||
|
||||
/// Whether the body paragraph at `path` sits in a table cell merged into the one above
|
||||
/// (`w:vMerge` continue), at any nesting depth. The layout shows nothing of such a cell, so its
|
||||
/// paragraphs take no list number.
|
||||
fn in_merged_cell(doc: &wordcraft_doc::Document, path: &Path) -> bool {
|
||||
let p = &path.0;
|
||||
let mut j = 0usize;
|
||||
// [table, row, cell, block, row, cell, block, …]: a table at p[..=j], its row and cell next.
|
||||
while j.saturating_add(3) < p.len() {
|
||||
let (Some(tp), Some(&r), Some(&c)) = (p.get(..=j), p.get(j + 1), p.get(j + 2)) else { return false };
|
||||
let merged = doc
|
||||
.table(StoryRef::Body, &Path(tp.to_vec()))
|
||||
.and_then(|t| t.rows.get(r as usize))
|
||||
.and_then(|row| row.cells.get(c as usize))
|
||||
.is_some_and(|cell| cell.props.vmerge == wordcraft_doc::props::VMerge::Continue);
|
||||
if merged {
|
||||
return true;
|
||||
}
|
||||
j = j.saturating_add(3);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Every numbered body paragraph's number, by path, walking the lists in document order as the
|
||||
/// layout does (cells merged into the one above count nothing). Bullets have no number.
|
||||
pub(super) fn para_numbers(doc: &wordcraft_doc::Document) -> std::collections::HashMap<Path, ParaNumber> {
|
||||
let mut counters = Counters::default();
|
||||
let mut out = std::collections::HashMap::new();
|
||||
for path in doc.para_paths(StoryRef::Body) {
|
||||
if in_merged_cell(doc, &path) {
|
||||
continue;
|
||||
}
|
||||
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
|
||||
let Some(n) = p.props.numbering.or_else(|| doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0) else { continue };
|
||||
let Some((_, def)) = counters.next_label(&doc.numbering, n.num, n.level) else { continue };
|
||||
if def.format == NumFormat::Bullet {
|
||||
continue;
|
||||
}
|
||||
let level = usize::from(n.level.min(8));
|
||||
let values = counters.values(&doc.numbering, n.num).unwrap_or_default();
|
||||
let started = counters.started(&doc.numbering, n.num).unwrap_or_default();
|
||||
let mut num = ParaNumber { list: n.num, ..Default::default() };
|
||||
for k in 0..=level {
|
||||
num.values.push(values.get(k).copied().unwrap_or(0));
|
||||
num.started.push(started.get(k).copied().unwrap_or(false));
|
||||
num.labels.push(counters.current_label(&doc.numbering, n.num, k as u8));
|
||||
num.shows.push(counters.current_template_levels(&doc.numbering, n.num, k as u8));
|
||||
}
|
||||
out.insert(path, num);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A paragraph number as a cross-reference shows it: without the trailing period ("14." → "14"),
|
||||
/// keeping a closing bracket ("(a)").
|
||||
fn ref_part(label: &str) -> &str {
|
||||
label.trim().trim_end_matches('.').trim_end()
|
||||
}
|
||||
|
||||
/// The number `REF \n`, `\r` or `\w` shows for the bookmarked paragraph `target` (ECMA-376 Part 1
|
||||
/// §17.16.5.51), as Word computes it:
|
||||
/// - `\n` (no context): the paragraph's own label.
|
||||
/// - `\w` (full context): the own label, with the label of each level above it put in front when
|
||||
/// no label already in the result shows that level: under `%1.` / `%1.%2` / `(%3)`, clause (a)
|
||||
/// of 1.1 is `1.1(a)`, not `1.1.1(a)`.
|
||||
/// - `\r` (relative context): like `\w`, but leaving out the leading levels whose counters the
|
||||
/// field's own paragraph (`from`) shares with the target, compared level by level within the same list. From inside clause 14, 14(a) is `(a)`; from clause 5 or an unnumbered paragraph it
|
||||
/// is `14(a)`. Context already displayed by the own or a needed ancestor label stays.
|
||||
///
|
||||
/// A level above the target that its list never used adds nothing (Word shows `(a)` for an item
|
||||
/// whose list has no clause above it, not a made-up `1.1(a)`). `None` when the target paragraph
|
||||
/// has no number.
|
||||
pub(super) fn ref_number(numbers: &std::collections::HashMap<Path, ParaNumber>, target: &Path, from: Option<&Path>, switch: char) -> Option<String> {
|
||||
let t = numbers.get(target)?;
|
||||
let level = t.values.len().checked_sub(1)?;
|
||||
let own = t.labels.get(level)?.as_deref()?;
|
||||
let mut out = ref_part(own).to_string();
|
||||
if switch == 'n' {
|
||||
return Some(out);
|
||||
}
|
||||
let used = |k: usize| t.started.get(k).copied().unwrap_or(false);
|
||||
let first = match (switch, from.and_then(|p| numbers.get(p))) {
|
||||
('r', Some(f)) if f.list == t.list => {
|
||||
(0..level).take_while(|&k| !used(k) || (f.values.get(k).is_some() && f.values.get(k) == t.values.get(k))).count()
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
// Relative context suppresses added ancestors, never text already rendered in a label.
|
||||
// Word's measured legal-template cases retain 2.2.2 even when 2 or 2.2 is shared.
|
||||
let mut shown = t.shows.get(level).cloned().unwrap_or_default();
|
||||
for k in (first..level).rev() {
|
||||
if shown.contains(&k) || !used(k) {
|
||||
continue;
|
||||
}
|
||||
if t.labels.get(k).is_some_and(Option::is_some) {
|
||||
let prefix = ref_part(t.labels.get(k)?.as_deref()?);
|
||||
// Without a delimiter, joining separate numeric labels would invent a number.
|
||||
if prefix.chars().next_back().is_some_and(char::is_alphanumeric) && out.chars().next().is_some_and(char::is_alphanumeric) {
|
||||
return None;
|
||||
}
|
||||
out.insert_str(0, prefix);
|
||||
shown.extend(t.shows.get(k).into_iter().flatten().copied());
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Index the first body paragraph for each bookmark name, retaining document-order lookup semantics.
|
||||
pub(super) fn body_bookmark_paths(doc: &wordcraft_doc::Document) -> std::collections::HashMap<String, Path> {
|
||||
let mut paths = std::collections::HashMap::new();
|
||||
for (name, pos) in doc.bookmarks() {
|
||||
if pos.story == StoryRef::Body {
|
||||
paths.entry(name).or_insert(pos.path);
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
@@ -4,16 +4,20 @@ pub(super) fn word_count(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(b) = p::bool(v, "includeTextBoxes") {
|
||||
s.prefs.count_notes = b;
|
||||
}
|
||||
let stories = s.counted_stories();
|
||||
let stories = if s.prefs.count_notes { s.doc.counted_stories() } else { vec![StoryRef::Body] };
|
||||
// A selected picture, shape or text box isn't a text selection: count the document.
|
||||
let text = if s.sel.is_collapsed() || crate::cmd::objects::object_selection(s).is_some() { s.counted_text() } else { s.selected_text() };
|
||||
let text = if s.sel.is_collapsed() || crate::cmd::objects::object_selection(s).is_some() {
|
||||
stories.iter().map(|st| s.doc.plain_text(*st)).collect::<Vec<_>>().join("\n")
|
||||
} else {
|
||||
s.selected_text()
|
||||
};
|
||||
let words = if s.sel.is_collapsed() || crate::cmd::objects::object_selection(s).is_some() {
|
||||
stories.iter().map(|st| s.doc.story_word_count(*st)).sum()
|
||||
} else {
|
||||
wordcraft_doc::count_words(&text)
|
||||
};
|
||||
let chars = wordcraft_doc::count_chars(&text);
|
||||
let chars_no_spaces = wordcraft_doc::count_chars_no_spaces(&text);
|
||||
let chars = text.chars().filter(|c| *c != '\n' && *c != '\r').count();
|
||||
let chars_no_spaces = text.chars().filter(|c| !c.is_whitespace()).count();
|
||||
let paragraphs = text.split('\n').filter(|l| !l.trim().is_empty()).count();
|
||||
let lines: usize = {
|
||||
let l = s.layout();
|
||||
|
||||
@@ -924,33 +924,6 @@ mod tests {
|
||||
#[test]
|
||||
fn numbers_read_currency_and_separators_but_not_letters() {
|
||||
assert_eq!(parse_number("$1,200", None), Some(1200.0));
|
||||
assert_eq!(parse_number("B/. 1,234.50", None), Some(1234.5), "the Panamanian balboa");
|
||||
assert_eq!(parse_number("1,234.50 B/.", None), Some(1234.5));
|
||||
assert_eq!(parse_number("(B/. 1,234.50)", None), Some(-1234.5), "an accounting negative");
|
||||
assert_eq!(parse_number("B/.1,234.50", None), None, "Word needs a space after the sign");
|
||||
assert_eq!(parse_number("1/.5", None), None, "a fraction is not a balboa amount");
|
||||
// The formula reads the cell in the document's language. With the document's default text
|
||||
// as es-PA and the editing language as pl-PL the choice is observable: `1,250.50` is a
|
||||
// number under es-PA and not under pl-PL (whose decimal separator is `,`).
|
||||
let balboa = table(&[&["B/. 1,234.50"], &["(B/. 1,234.50)"], &[""]]);
|
||||
let mut doc = wordcraft_doc::Document::new();
|
||||
std::sync::Arc::make_mut(&mut doc.styles).default_chr.lang = Some("es-PA".into());
|
||||
assert_eq!(
|
||||
evaluate_with(&balboa, Some(&doc), Some("pl-PL"), 2, 0, "=SUM(ABOVE)"),
|
||||
Ok(0.0),
|
||||
"a balboa cell sums in the document's language, and its negative in parentheses cancels it",
|
||||
);
|
||||
assert_eq!(evaluate_with(&balboa, Some(&doc), Some("pl-PL"), 2, 0, "=COUNT(ABOVE)"), Ok(2.0), "both balboa cells are numbers",);
|
||||
// And the other way around: a balboa cell written with Polish separators is read only when
|
||||
// the document language reaches the parser, not when it falls back to English.
|
||||
let mut polish = wordcraft_doc::Document::new();
|
||||
std::sync::Arc::make_mut(&mut polish.styles).default_chr.lang = Some("pl-PL".into());
|
||||
let balboa_pl = table(&[&["B/. 1 250,50"], &[""]]);
|
||||
assert_eq!(
|
||||
evaluate_with(&balboa_pl, Some(&polish), Some("en-US"), 1, 0, "=SUM(ABOVE)"),
|
||||
Ok(1250.5),
|
||||
"the cell's language selects the separators; English does not read `1 250,50`",
|
||||
);
|
||||
assert_eq!(parse_number(" 12% ", None), Some(12.0));
|
||||
assert_eq!(parse_number("(5)", None), Some(-5.0));
|
||||
assert_eq!(parse_number("x9", None), None);
|
||||
|
||||
@@ -712,11 +712,6 @@ pub fn read_file_capped(path: &str, max: u64, what: &str) -> Result<Vec<u8>, Str
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn read_file_capped(path: &str, _max: u64, _what: &str) -> Result<Vec<u8>, String> {
|
||||
read_file(path)
|
||||
}
|
||||
|
||||
/// The first `max` bytes of the file at `path` (fewer when it is shorter), inside the automation
|
||||
/// read root when there is one: enough to tell a file's format by its start. Only a regular file
|
||||
/// is opened: a named pipe would block the open until a writer appeared (#3666).
|
||||
|
||||
@@ -71,9 +71,6 @@ pub struct Session {
|
||||
/// HTML for the system clipboard beside [`Self::clipboard_text`]: the copy's formatting for
|
||||
/// programs that read HTML, equations as MathML (#816); empty when nothing was copied.
|
||||
pub clipboard_html: String,
|
||||
/// RTF for the system clipboard beside [`Self::clipboard_html`], for programs that read RTF
|
||||
/// rather than HTML; empty when nothing was copied.
|
||||
pub clipboard_rtf: String,
|
||||
/// Items collected by Copy and Cut this session, for the Clipboard pane.
|
||||
pub clip_history: crate::cmd::edit::ClipHistory,
|
||||
pub find: FindState,
|
||||
@@ -219,10 +216,6 @@ pub struct Session {
|
||||
/// More pictures and shapes selected along with the one the selection holds (Shift+click),
|
||||
/// for Group. Cleared by any edit or selection change other than adding to it.
|
||||
pub also_selected: Vec<Pos>,
|
||||
/// The member of the selected Drawing Canvas picked on its own (#2637), which Shape Format,
|
||||
/// Delete, Copy and the frame act on; see [`crate::cmd::canvas::selected_member`]. Cleared by
|
||||
/// any selection change, caret movement or undo.
|
||||
pub member: Option<crate::cmd::canvas::CanvasMember>,
|
||||
/// Column (block) selection, if one was made (see [`Session::column_segments`]).
|
||||
pub column: Option<ColumnBlock>,
|
||||
/// Column selection mode (Ctrl+Shift+F8): caret movement extends the block.
|
||||
|
||||
@@ -244,7 +244,6 @@ impl Session {
|
||||
clipboard: _,
|
||||
clipboard_text: _,
|
||||
clipboard_html: _,
|
||||
clipboard_rtf: _,
|
||||
clip_history: _,
|
||||
registry: _,
|
||||
author: _,
|
||||
@@ -276,8 +275,6 @@ impl Session {
|
||||
ime_state_exhausted: _,
|
||||
pending_ime_application_change: _,
|
||||
ime_origin: _,
|
||||
// A picked Drawing Canvas member is cleared by a switch, not kept per document.
|
||||
member: _,
|
||||
others: _,
|
||||
order: _,
|
||||
untitled_count: _,
|
||||
@@ -378,8 +375,6 @@ impl Session {
|
||||
swap(&mut self.widened, widened);
|
||||
swap(&mut self.edit_marks, edit_marks);
|
||||
swap(&mut self.go_back_step, go_back_step);
|
||||
// A picked Drawing Canvas member (#2637) belongs to the document left behind.
|
||||
self.member = None;
|
||||
}
|
||||
|
||||
/// Make document `id` active without anything else a switch by the user does.
|
||||
@@ -409,12 +404,6 @@ impl Session {
|
||||
id == self.document_id || self.others.iter().any(|d| d.document_id == id)
|
||||
}
|
||||
|
||||
/// Whether the open document `id` has changes that aren't written to a file yet: the
|
||||
/// active document's own flag, or a background document's.
|
||||
pub fn document_dirty(&self, id: u64) -> bool {
|
||||
if id == self.document_id { self.dirty } else { self.others.iter().any(|d| d.document_id == id && d.dirty) }
|
||||
}
|
||||
|
||||
/// Every open document in the order they were opened, the active one included.
|
||||
pub fn documents(&self) -> Vec<DocInfo> {
|
||||
self.order
|
||||
|
||||
@@ -98,7 +98,6 @@ impl Session {
|
||||
pending_move,
|
||||
clipboard_text: String::new(),
|
||||
clipboard_html: String::new(),
|
||||
clipboard_rtf: String::new(),
|
||||
clip_history: Default::default(),
|
||||
find,
|
||||
goal_x,
|
||||
@@ -150,7 +149,6 @@ impl Session {
|
||||
math_latex: false,
|
||||
math_normal_text: false,
|
||||
also_selected,
|
||||
member: None,
|
||||
prefs: Prefs::default(),
|
||||
system_region: Default::default(),
|
||||
keymap: crate::KeyMap::default(),
|
||||
|
||||
@@ -268,18 +268,6 @@ impl Session {
|
||||
if self.prefs.count_notes { self.doc.word_count_including_notes() } else { self.doc.word_count() }
|
||||
}
|
||||
|
||||
/// The stories the Word Count dialog and the status bar count: the body plus notes and text
|
||||
/// boxes when [`Prefs::count_notes`] is on ([`Document::counted_stories`]).
|
||||
pub fn counted_stories(&self) -> Vec<StoryRef> {
|
||||
if self.prefs.count_notes { self.doc.counted_stories() } else { vec![StoryRef::Body] }
|
||||
}
|
||||
|
||||
/// The whole document's text the counts cover (#2797): the stories [`Session::counted_stories`]
|
||||
/// names, joined with paragraph breaks, as the Word Count dialog counts them.
|
||||
pub fn counted_text(&self) -> String {
|
||||
self.counted_stories().iter().map(|st| self.doc.plain_text(*st)).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
|
||||
/// Selected plain text.
|
||||
pub fn selected_text(&self) -> String {
|
||||
// A column block or whole cells that aren't one run of the text: each piece's own text,
|
||||
|
||||
@@ -185,10 +185,7 @@ impl Session {
|
||||
let mutates = spec.mutates;
|
||||
let ime_caret_before = self.current_ime_caret_state();
|
||||
let rev_before = self.rev;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match crate::cmd::canvas::member_command(self, id, params) {
|
||||
Some(r) => r,
|
||||
None => crate::cmd::column::dispatch(self, id, mutates, run, params),
|
||||
}));
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params)));
|
||||
let mut result = match result {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
@@ -394,15 +391,6 @@ impl Session {
|
||||
if !matches!(id, "select.addObject" | "arrange.ungroup" | "shape.fill" | "shape.effects") && (spec.mutates || self.sel != *sel_before) {
|
||||
self.also_selected.clear();
|
||||
}
|
||||
// A canvas member stays picked while its canvas does and nothing else is selected.
|
||||
if id != "select.member"
|
||||
&& (self.sel != *sel_before
|
||||
|| id.starts_with("select.")
|
||||
|| id.starts_with("caret.")
|
||||
|| matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo"))
|
||||
{
|
||||
self.member = None;
|
||||
}
|
||||
self.note_typing(id, before_doc.as_ref().map(|b| &b.0), sel_before);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ impl Session {
|
||||
self.pending = None;
|
||||
self.typing = None;
|
||||
self.also_selected.clear();
|
||||
self.member = None;
|
||||
self.column = None;
|
||||
self.column_mode = false;
|
||||
self.widened = None;
|
||||
@@ -81,7 +80,6 @@ impl Session {
|
||||
pending_move: _,
|
||||
clipboard_text: _,
|
||||
clipboard_html: _,
|
||||
clipboard_rtf: _,
|
||||
clip_history: _,
|
||||
find: _,
|
||||
goal_x: _,
|
||||
@@ -156,7 +154,6 @@ impl Session {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
recovery_slot: _,
|
||||
also_selected: _,
|
||||
member: _,
|
||||
// Column selection and a widened selection's ends, like `sel`'s shape: valid only while
|
||||
// `sel` matches them.
|
||||
column: _,
|
||||
@@ -213,7 +210,6 @@ impl Session {
|
||||
self.doc = doc;
|
||||
self.sel = sel;
|
||||
self.also_selected.clear();
|
||||
self.member = None;
|
||||
self.history.truncate(history_len);
|
||||
self.redo = redo;
|
||||
self.typing_open = typing_open;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user