Merge branch 'main' into fix/sec-mark-source-permissions

# Conflicts:
#	crates/engine/src/tests.rs
This commit is contained in:
Dylan M. Taylor committed 2026-11-11 08:55:32 +00:00
commit 3a716440f7
1024 files changed
+217712 -107504

No files matched your search

+5
View File
@@ -1,2 +1,7 @@
[alias]
xtask = "run -p xtask --"
# WebAssembly SIMD (every current browser has it; there's no runtime detection on wasm). The OCR
# engine's kernels (rten-simd) and other vectorised code only use it when compiled in.
[target.wasm32-unknown-unknown]
rustflags = ["-C", "target-feature=+simd128"]
+3
View File
@@ -3,6 +3,9 @@
# signatures) and assets whose SHA-256 ATTRIBUTION.toml records (cargo xtask assets).
* -text
# The craft-i18n pin: CI and the build read it as one SHA, so it stays LF everywhere (readers trim it too).
craft-i18n.rev text eol=lf
# Not text at all: no textual diffs or merges either.
*.pdf binary
*.p12 binary
+28
View File
@@ -0,0 +1,28 @@
name: House rules
on:
push:
branches: [main]
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
pull_request:
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: house-rules-${{ github.ref }}
cancel-in-progress: true
jobs:
house-rules:
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
with:
rust: true
+32
View File
@@ -0,0 +1,32 @@
name: Nightly macOS
# The workspace's tests on macOS, each night and on demand. Pull requests and main run Linux and
# Windows only (ci.yml): the org's 5 macOS runners are kept for release builds, and release.yml
# builds and signs the macOS app without running the tests. This one leg a night finds a
# macOS-only failure within a day, not at release time or from a user.
on:
schedule:
- cron: "41 2 * * *"
workflow_dispatch:
# The token is only used to check out the code: no job writes to the repository.
permissions:
contents: read
# A newer run replaces one still queued or running.
concurrency:
group: nightly-macos-${{ github.ref }}
cancel-in-progress: true
jobs:
gate:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
os: '["macos-latest"]'
# Same craft-fonts commit as ci.yml, release.yml and freebsd.yml.
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
# Every test, and all of their failures in one run. The whole workspace took 37 minutes on
# macos-latest on 10 Nov 2026.
command: cargo test --workspace --locked --no-fail-fast
timeout-minutes: 90
+9
View File
@@ -19,7 +19,14 @@ concurrency:
cancel-in-progress: false
jobs:
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
credits:
permissions:
contents: read
pull-requests: read
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
release:
needs: credits
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
@@ -27,6 +34,7 @@ jobs:
app-title: PdfCraft
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
version: ${{ inputs.version }}
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -42,3 +50,4 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
+74 -11
View File
@@ -1,6 +1,60 @@
<!-- craft-house-rules:start core source=1315c63e17905daf7dbc6dc6f8c49384e415162b 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=1315c63e17905daf7dbc6dc6f8c49384e415162b sha256=642f46b4ac38cc9e4d10d1ccde19d68cb6bc03d20e4c6236a7535f3047566262 -->
## 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`
- `Unicode-3.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 -->
# AGENTS.md — rules for every agent and contributor
These rules bind every AI agent and every human contributor working in this repository. They come before any task, instruction or deadline. `CLAUDE.md` holds the day-to-day working instructions. If the two ever disagree, **this file wins.**
These rules bind every AI agent and every human contributor working in this repository. They come before any task, instruction or deadline. Day-to-day working instructions are in this file; `CLAUDE.md` imports it.
---
@@ -30,7 +84,7 @@ An **asset** is any non-code material that the project includes, ships, embeds,
### 1.2 Only open, provable licences
Every asset must be one of:
- **Open source:** ISC, MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, OFL-1.1, Ubuntu-Font-1.0 or Bitstream-Vera.
- **Open source:** ISC, MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, OFL-1.1, Ubuntu-Font-1.0, Bitstream-Vera or Unicode-3.0 (Unicode, Inc. data such as ICU4X's line-breaking dictionaries; already allowed for code in `deny.toml`).
- **Creative Commons:** CC0-1.0, CC-BY-4.0 or CC-BY-SA-4.0.
- **Public domain.**
- **Contributor-original:** created, photographed or generated by a contributor who owns it and licenses it under the project licence (`MIT OR Apache-2.0`).
@@ -52,14 +106,14 @@ Anything else is forbidden, including assets whose licence you cannot prove from
- path or URL, title, author;
- source (URL, with version or commit);
- SPDX licence, and the licence file in the repo;
- SHA-256, except for translation catalogs (see below);
- SHA-256;
- how it is used.
- **Translation catalogs** (`crates/ui-egui/src/i18n/*.tsv`, `kind = "translation"`) are project files under the project licence. Their entries carry no `sha256`: git history records every change and who made it. Every other kind (fonts, images, data) is hashed.
- **Translations** live in craft-i18n (`locales/<code>/common.tsv` and `pdfcraft.tsv`), embedded at build time from the checkout pinned in `craft-i18n.rev`; PdfCraft keeps no catalogs. `ATTRIBUTION.toml` records them as one `[[build_input]]` (`kind = "translation"`); craft-i18n's own `ATTRIBUTION.md` credits the translators. Every `[[asset]]` (fonts, images, data) is hashed.
- **`ATTRIBUTION.md`** is generated from it (`cargo xtask assets --write`). Never edit it by hand.
- **`NOTICE`** keeps the licence notices that the licences require.
- **`cargo xtask assets`** runs in `cargo xtask ci`. It fails if:
- an asset file in the repository has no entry;
- an entry's hash is missing or does not match (translation catalogs carry none);
- an entry's hash is missing or does not match;
- a licence is not on the allowlist;
- a licence file is missing;
- an image, icon or font names Adobe as author or source, except a font licensed under OFL-1.1 alone.
@@ -85,7 +139,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi
---
## 2. Clean-room rules (summary; details in `CLAUDE.md` and `plan/adr/0001`)
## 2. Clean-room rules (summary; `plan/adr/0001` is an optional local note)
- Never read, disassemble or copy anything inside the Acrobat bundle. Only names and listings are allowed, and **never** open `Contents/Resources/JavaScripts/`.
- Behaviour comes only from public specifications (ISO 32000-2, the Arlington model), public documentation and black-box observation.
@@ -96,7 +150,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi
## 3. Agent control (automation and MCP)
- **Everything is reachable headlessly.** Every user-facing feature must be callable without the GUI: an engine API, then a tool in `crates/automation` (`pdfcraft-cli run` and MCP use the same table). A feature is not done until its tool exists and has an end-to-end test (`crates/automation/README.md`, "Adding a tool").
- **The UI control channel is opt-in too.** It runs only when the app is launched with `--control <file>`. It binds to loopback, uses a fresh random token per launch, and writes it to an owner-only file. Never enable it by default, widen the bind address, or log the token.
- **The UI control channel is opt-in too.** It runs only when the app is launched with `--control <file>`. It binds to loopback, uses a fresh random token per launch, and writes it to an owner-only file (mode 0600 on Unix; on Windows an access list of the user and SYSTEM only, which `pdfcraft_platform::owner_only` sets and checks, and which the CLI checks again before reading the token). Never enable it by default, widen the bind address, or log the token.
- **The MCP server is opt-in.** Never make the app, the CLI or an installer start it, register it with an agent, or listen on a port by default. It runs only when a user (or an agent the user configured) launches `pdfcraft-cli mcp`. Any future network transport must be off by default, bound to loopback and authenticated with a token.
- **Safe by default.** Tools must never discard unsaved work silently, must write atomically, must honour `--root`, and must return errors an agent can act on (say what was wrong and what is allowed).
- **Track parity honestly.** Every user-facing feature has an entry in `parity/acrobat-features.toml`. Mark it `shipped` only when it works end to end and the entry cites a passing test (`evidence`); `cargo xtask parity` (part of `xtask ci`) rejects claims whose commands, tools or tests don't exist.
@@ -106,7 +160,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi
PdfCraft opens files from strangers, and people trust it with their work. A malformed PDF, a bad tool argument, a corrupt settings file or a full disk must produce an error the user (or agent) can act on, never a crash and never lost work. **This outranks feature work:** don't ship a feature by adding a panic path, and fix a crash before building on top of it. The rules below are the ones that bind this repository.
- **Fail with `Result`.** Non-test code returns errors through the crate's error type and `?`. Where readers are lenient by design, fall back gracefully and record the repair (see Fidelity in `CLAUDE.md`).
- **Fail with `Result`.** Non-test code returns errors through the crate's error type and `?`. Where readers are lenient by design, fall back gracefully and record the repair (see Fidelity below).
- **No panicking shortcuts.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!` outside tests, unless the call is provably infallible (e.g. writing to a `Vec`), with a comment saying why. `assert!` only for internal invariants that input cannot reach.
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. If a platform API needs it, use a maintained safe wrapper crate; a real exception needs the owner's sign-off.
- **Treat every input-derived number as hostile.** Use `get()` instead of `[i]` or `[a..b]`. Slice strings only at char boundaries. Use checked/saturating arithmetic for lengths, offsets and counts. Guard against division by zero and NaN/inf casts, and never cast a negative value to `usize`. Cap allocations sized by the file.
@@ -115,10 +169,19 @@ PdfCraft opens files from strangers, and people trust it with their work. A malf
- **Prove it.** Every crash fix comes with a small synthetic regression test that panicked before the fix. Turn fuzzer findings (`cargo xtask fuzz`) into such tests before fixing them.
- **Enforced by clippy.** Root `clippy.toml` lets tests use `unwrap`/`expect`/`panic!`. A crate that is clean carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it.
## 5. Everything else
## 5. Page interaction, fidelity and gates
Follow `CLAUDE.md`. It covers quality gates, layering, commit rules and how to run and look at the app.
- **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd. `cos`, `filters`, `crypt` and `arlington` stay standalone.
- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state. `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it.
- **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim`, `crates/ui-egui/src/canvas/page_cursor.rs`) and never set it themselves. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it.
- **Page presses.** A page module decides what is under a point in one hit test (`Hit` in `crates/ui-egui/src/canvas/page_router.rs`) and acts on a press only through that router. Modules never read the router; they are told. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it.
- **Page keys.** Page code (everything under `canvas/` and `tools/`, `app/keys.rs`, `app/clipboard.rs` and the Pages panel's `panels/pages.rs`) reads keys and clipboard events through `PageKeys` (`crates/ui-egui/src/canvas/page_keys.rs`), which has none while something is over the pages (a dialog, the palette, a popup, a menu: `PdfCraftApp::over_pages`), never egui's raw key state. The functions `NOT_PAGE_KEYS` lists, each with why, read raw: a window's or a field's own keys, which `PageKeys` would hide, and the few page reads it can't make (chords that wait for the menu bar instead, Escape ending what the pages hold, any key stopping a glide, the clipboard's hand-off to text fields). `page_pointer::tests::page_modules_read_keys_only_through_page_keys` enforces it.
- **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it.
- **Fonts** are the optional build input `CRAFT_FONTS_DIR`. Code using `pdfcraft_fonts::CRAFT_FONTS` must work when it is empty.
- **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check. New workspace crates include `workspace = true` under `[lints]`. Scope `cargo clippy -p <crate> --all-targets -- -D warnings` and `cargo test -p <crate>` to the crates you touched; the workspace sweep is CI's job.
- **Review at four zooms** as you go: the clause, the function, the module, and the project (layering, wasm, undo, save fidelity, a matching automation tool, an honest `parity/` entry). Prove a test fails before the fix by reverting the fix once.
- `plan/` is an optional gitignored notebook. Do not ask for it and do not stop when it is absent. Public status is [`ROADMAP.md`](ROADMAP.md), [`docs/gaps.md`](docs/gaps.md) and [`docs/roadmap.md`](docs/roadmap.md). "Shipped" in `parity/` means exists and tested, not parity with Acrobat.
- `cargo run -p pdfcraft -- <file.pdf>` opens the app. `pdfcraft-cli run` drives the engine headlessly. `cargo xtask fuzz` findings stay in `fuzz-out/` and become synthetic tests. For UI work, look at a screenshot from `--control` or the `shot` example.
## Contributor credits (About window)
+15 -44
View File
@@ -2,40 +2,13 @@
<!-- Generated from ATTRIBUTION.toml by `cargo xtask assets --write`. Do not edit by hand. -->
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset but the translation catalogs, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
## In this repository (289)
## In this repository (258)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/fa.tsv` | Persian (Farsi) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed | Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `docs/images/pdfcraft-ocr-verify.png` | PdfCraft screenshot: OCR Verify screen | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: OCR review. Shows only PdfCraft UI, its Lucide icons and a synthetic scanned page built from the embedded fonts, with a real recognition (the OCR models) loaded into the Verify screen |
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
@@ -72,7 +45,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/icons/chevron-up.svg` | Lucide icon "chevron-up" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-up.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevrons-left.svg` | Lucide icon "chevrons-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevrons-right.svg` | Lucide icon "chevrons-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-check.svg` | Lucide icon "circle-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-check.svg` | Lucide icon "circle-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers |
| `assets/icons/circle-dashed.svg` | Lucide icon "circle-dashed" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dashed.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-dot.svg` | Lucide icon "circle-dot" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dot.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-help.svg` | Lucide icon "circle-help" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-help.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
@@ -201,7 +174,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/icons/text-select.svg` | Lucide icon "text-select" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-select.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/trash-2.svg` | Lucide icon "trash-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/trash.svg` | Lucide icon "trash" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/triangle-alert.svg` | Lucide icon "triangle-alert" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/triangle-alert.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/triangle-alert.svg` | Lucide icon "triangle-alert" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/triangle-alert.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers |
| `assets/icons/type.svg` | Lucide icon "type" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/type.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/underline.svg` | Lucide icon "underline" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/underline.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/undo-2.svg` | Lucide icon "undo-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/undo-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
@@ -211,7 +184,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/icons/x.svg` | Lucide icon "x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/zoom-in.svg` | Lucide icon "zoom-in" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-in.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/zoom-out.svg` | Lucide icon "zoom-out" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-out.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/fonts/Inter-Regular.ttf` | Inter Regular | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths |
| `assets/fonts/Inter-Regular.ttf` | Inter Regular | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths; embedded into displayed signature status captions beyond WinAnsi by crates/sign/src/status_layers.rs |
| `assets/fonts/Inter-Medium.ttf` | Inter Medium | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels |
| `assets/fonts/Inter-SemiBold.ttf` | Inter SemiBold | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono Regular | The JetBrains Mono Project Authors | OFL-1.1 | https://github.com/JetBrains/JetBrainsMono | UI monospace font; demo PDF code |
@@ -231,6 +204,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
| `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) |
| `crates/fonts/src/std14_metrics.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. Data only: the code that reads these tables lives in std14.rs, so that ordinary edits do not churn this file's hash. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. |
| `crates/prc/src/acof/table.rs` | PRC Double prefix-code table (as a Rust table) | Kristof Ralovich (prc-rs); the code table itself is defined by the PRC format (ISO 14739-1, working draft clause 11.17.2) | MIT | Rows taken from prc-rs src/double.rs at commit 287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d (https://github.com/ralovich/prc-rs), mechanically reformatted into (code length, code, entry) tuples; checked to be a complete prefix code by the tests in crates/prc/src/acof.rs. Data only: the code that reads the table lives in acof.rs. The MIT notice is kept in the file header. | Decodes every PRC Double (coordinates, transforms, colours) in 3D annotations' PRC streams. Non-visual data. |
| `vendor/hayro-cmap/assets/cmaps.brotli` | Adobe CMap resources (predefined CJK CMaps) | Adobe | BSD-3-Clause | https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap 0.1.0 assets/) | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1 |
| `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
@@ -275,7 +249,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/icons/thumbs-up.svg` | Lucide icon "thumbs-up" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-up.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/thumbs-down.svg` | Lucide icon "thumbs-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square-plus.svg` | Lucide icon "message-square-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-x.svg` | Lucide icon "circle-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-x.svg` | Lucide icon "circle-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers |
| `crates/ui-egui/tests/data/form.pdf` | One-page AcroForm test fixture (text, check box, radio group, combo box) | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects, no third-party content | Form-filling UI tests (crates/ui-egui/tests/it/forms.rs, commands.rs) |
| `assets/icons/pentagon.svg` | Lucide icon "pentagon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square-quote.svg` | Lucide icon "message-square-quote" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
@@ -286,19 +260,14 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile |
| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format |
| `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file |
| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/a-large-small.svg` | Lucide icon "a-large-small" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/a-large-small.svg) | UI icon, embedded via crates/ui-egui/src/widgets/icon_data.rs |
| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs |
## Compiled in through dependencies (6)
## Compiled in through dependencies (7)
| Crate | File | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|---|
@@ -306,8 +275,9 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `epaint_default_fonts` 0.37.0 | `fonts/Hack-Regular.ttf` | Hack Regular | Source Foundry Authors (derived from Bitstream Vera / DejaVu) | MIT AND Bitstream-Vera | https://github.com/source-foundry/Hack | egui fallback fonts (eframe feature default_fonts) |
| `epaint_default_fonts` 0.37.0 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui monochrome fallback font (egui feature monochrome_emoji_fonts) |
| `epaint_default_fonts` 0.37.0 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui monochrome fallback font (egui feature monochrome_emoji_fonts) |
| `epaint_default_fonts` 0.37.0 | `fonts/egui-icons.ttf` | egui icons (monochrome emoji-icon-font subset) | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE. |
| `epaint_default_fonts` 0.37.0 | `fonts/egui-icons.ttf` | egui icons (monochrome emoji-icon-font subset) | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/725a39fca940f77149ffbb9c58eef405a16997bc/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE. |
| `hayro-interpret` 0.7.0 | `src/font/generated/metrics.rs` | Standard-14 font metrics and encodings (as Rust tables) (non-visual data, AGENTS.md §1.1) | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | MIT OR Apache-2.0 | ISO 32000-2 Annex D; Adobe Core14 AFM files (via vendored hayro-interpret) | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1 |
| `icu_segmenter_data` 2.3.0 | `data/segmenter_dictionary_extended_v1.rs.data` | Southeast Asian word dictionaries for line breaking (Thai, Lao, Khmer, Burmese) | The ICU4X Project Developers; word lists from the ICU project (Unicode, Inc.) | Unicode-3.0 | https://github.com/unicode-org/icu4x (via icu_segmenter_data) | Word boundaries for wrapping Thai text in Edit text and Add text (crates/edit/src/linebreak.rs). Non-visual data; the line segmenter loads only these dictionaries, not the Chinese and Japanese one |
## Downloaded at build time (22)
@@ -338,10 +308,11 @@ Fonts are fetched by `cargo xtask demo-pdf` into `target/demo-fonts/`, OCR model
| `text-detection.onnx` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) |
| `text-recognition.onnx` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) |
## Optional build inputs (1)
## Optional build inputs (2)
Not in this repository and never downloaded by it: compiled in only when the build sets the option (official releases do). Each input attributes its own files.
| Input | Option | Title | Author | Licence | Source | Attribution | Used for |
|---|---|---|---|---|---|---|---|
| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1 |
| `craft-i18n` | `CRAFT_I18N_DIR` | Interface translations: the craft-i18n catalogs (locales/<code>/common.tsv and pdfcraft.tsv) of the 34 languages PdfCraft offers besides English | The craft-i18n translators, including every translator of PdfCraft's former in-repo catalogs (crates/ui-egui/src/i18n/*.tsv, migrated at pdfcraft@ecdd1e6) | MIT OR Apache-2.0 | https://github.com/storytold/craft-i18n (pinned by commit in craft-i18n.rev, which CI, releases and the flatpak manifests read) | https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md | Interface text in languages other than English, embedded by crates/ui-egui/build.rs and looked up through craft-libs' craft-i18n crate; a build without CRAFT_I18N_DIR is English only. Clean-room translations from PdfCraft's English interface labels |
| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); status captions of named-layer signatures in Arabic, Japanese, Thai and Devanagari, drawn as filled outlines into the display copy by crates/sign/src/status_layers.rs; the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1 |
+59 -344
View File
@@ -1,295 +1,25 @@
[[asset]]
path = "crates/ui-egui/src/i18n/el.tsv"
title = "Greek interface translation catalog"
author = "Camthalion77 and contributors"
source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n.rs"
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
#
# [[asset]] a file in this repository (committed or vendored); `sha256` must match the file.
# Translation catalogs (kind = "translation", crates/ui-egui/src/i18n/*.tsv) are project files
# under the project licence and carry no `sha256`: git history records their provenance.
# [[bundled]] a file compiled into PdfCraft by a Cargo dependency (its crate version must match Cargo.lock).
# [[fetched]] downloaded at build time by xtask (never committed), pinned by `sha256`.
# [[build_input]] an optional build input outside this repository (e.g. craft-fonts via CRAFT_FONTS_DIR);
# [[build_input]] an optional build input outside this repository (craft-fonts via CRAFT_FONTS_DIR, craft-i18n via CRAFT_I18N_DIR);
# never committed or downloaded here, its own repository attributes each file.
#
# licence: SPDX expression from the allowlist in AGENTS.md §1.2. adobe_data = true marks the closed list of
# Adobe-authored non-visual technical data allowed by AGENTS.md §1.1.
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hans.tsv"
title = "Simplified Chinese interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
path = "docs/images/pdfcraft-ocr-verify.png"
title = "PdfCraft screenshot: OCR Verify screen"
author = "PdfCraft contributors (generated by `cargo xtask screenshots`)"
source = "this repository"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
title = "Traditional Chinese (Taiwan) interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
title = "Czech interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
title = "Brazilian Portuguese interface translation catalog"
author = "André Cardoso (andrecards), PdfCraft contributors"
source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
title = "Spanish interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
title = "French interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
title = "German interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/lo.tsv"
title = "Lao interface translation catalog"
author = "@bounkirdni-2025"
source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
title = "Telugu interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
title = "Italian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/pl.tsv"
title = "Polish interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
title = "Russian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
title = "Hungarian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
title = "Bulgarian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/kk.tsv"
title = "Kazakh interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
title = "Japanese interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
title = "Ukrainian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
title = "Arabic interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/ckb.tsv"
title = "Central Kurdish (Sorani) interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/fa.tsv"
title = "Persian (Farsi) interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/tr.tsv"
title = "Turkish interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/lt.tsv"
title = "Lithuanian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/hi.tsv"
title = "Hindi interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/id.tsv"
title = "Indonesian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/ko.tsv"
title = "Korean interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/ro.tsv"
title = "Romanian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/fi.tsv"
title = "Finnish interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
kind = "image"
usage = "README: OCR review. Shows only PdfCraft UI, its Lucide icons and a synthetic scanned page built from the embedded fonts, with a real recognition (the OCR models) loaded into the Verify screen"
sha256 = "ea7485b151e27ad3fd2a2dac079436ab11745d42032201a029bb20f819c6af8f"
[[asset]]
path = "assets/icons/accessibility.svg"
@@ -695,7 +425,7 @@ source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icon
licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers"
sha256 = "201e790f3a05c65b2c4b0f952c9b9e1f35cd1c5b21698b3f4be131883fe90bc8"
[[asset]]
@@ -2114,7 +1844,7 @@ source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icon
licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers"
sha256 = "55aa37e0b73e7f8cd906952dbdeb96283c6595ca64aba240a17f3455cb989114"
[[asset]]
@@ -2224,7 +1954,7 @@ source = "https://github.com/rsms/inter"
licence = "OFL-1.1"
licence_file = "assets/fonts/OFL-Inter.txt"
kind = "font"
usage = "UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths"
usage = "UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths; embedded into displayed signature status captions beyond WinAnsi by crates/sign/src/status_layers.rs"
sha256 = "40d692fce188e4471e2b3cba937be967878f631ad3ebbbdcd587687c7ebe0c82"
[[asset]]
@@ -2437,6 +2167,17 @@ adobe_data = true
usage = "Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree."
sha256 = "f225e0b044875b8fa6375b6b5ad935f0d99fcc1ad03ecf9f0baae2cedbfca4d2"
[[asset]]
path = "crates/prc/src/acof/table.rs"
title = "PRC Double prefix-code table (as a Rust table)"
author = "Kristof Ralovich (prc-rs); the code table itself is defined by the PRC format (ISO 14739-1, working draft clause 11.17.2)"
source = "Rows taken from prc-rs src/double.rs at commit 287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d (https://github.com/ralovich/prc-rs), mechanically reformatted into (code length, code, entry) tuples; checked to be a complete prefix code by the tests in crates/prc/src/acof.rs. Data only: the code that reads the table lives in acof.rs. The MIT notice is kept in the file header."
licence = "MIT"
licence_file = "licenses/prc-rs-LICENSE.txt"
kind = "data"
usage = "Decodes every PRC Double (coordinates, transforms, colours) in 3D annotations' PRC streams. Non-visual data."
sha256 = "8e705c5cde6a3ef778e8aca69759e38845b228b3f7f12949e587b19855d2114c"
[[bundled]]
crate = "epaint_default_fonts"
version = "0.37.0"
@@ -2494,7 +2235,7 @@ author = "John Slegers"
source = "https://github.com/jslegers/emoji-icon-font"
licence = "MIT"
kind = "font"
usage = "Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE."
usage = "Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/725a39fca940f77149ffbb9c58eef405a16997bc/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE."
sha256 = "122806555c73df7cef55aa1d945289ae27fbc4dbbd31abbb4204ee0cda829cc8"
[[asset]]
@@ -2522,6 +2263,18 @@ adobe_data = true
usage = "Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1"
sha256 = "2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41"
[[bundled]]
crate = "icu_segmenter_data"
version = "2.3.0"
path = "data/segmenter_dictionary_extended_v1.rs.data"
title = "Southeast Asian word dictionaries for line breaking (Thai, Lao, Khmer, Burmese)"
author = "The ICU4X Project Developers; word lists from the ICU project (Unicode, Inc.)"
source = "https://github.com/unicode-org/icu4x (via icu_segmenter_data)"
licence = "Unicode-3.0"
kind = "data"
usage = "Word boundaries for wrapping Thai text in Edit text and Add text (crates/edit/src/linebreak.rs). Non-visual data; the line segmenter loads only these dictionaries, not the Chinese and Japanese one"
sha256 = "cad3821ca44fdb88ed856ff3336444aa200afe7b9d19e34ac28ae6a4459d4c6d"
[[fetched]]
file = "PlayfairDisplay[wght].ttf"
url = "https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/playfairdisplay/PlayfairDisplay%5Bwght%5D.ttf"
@@ -3263,7 +3016,7 @@ source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icon
licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs; drawn as PDF path data by crates/sign/src/status_layers.rs into displayed signature status layers"
sha256 = "ef1fec17d88794ba6c34c5cf63bdadbd234b3d4dffbcc3d55468b47e3f398472"
[[asset]]
@@ -3381,6 +3134,17 @@ kind = "model"
usage = "Scan & OCR: reads the words it finds (cargo xtask models)"
sha256 = "86c145c2edb96c8caed5b1ebb8f44d706408922211451c309c625157dd6061c5"
[[build_input]]
name = "craft-i18n"
option = "CRAFT_I18N_DIR"
title = "Interface translations: the craft-i18n catalogs (locales/<code>/common.tsv and pdfcraft.tsv) of the 34 languages PdfCraft offers besides English"
author = "The craft-i18n translators, including every translator of PdfCraft's former in-repo catalogs (crates/ui-egui/src/i18n/*.tsv, migrated at pdfcraft@ecdd1e6)"
source = "https://github.com/storytold/craft-i18n (pinned by commit in craft-i18n.rev, which CI, releases and the flatpak manifests read)"
licence = "MIT OR Apache-2.0"
attribution = "https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md"
kind = "translation"
usage = "Interface text in languages other than English, embedded by crates/ui-egui/build.rs and looked up through craft-libs' craft-i18n crate; a build without CRAFT_I18N_DIR is English only. Clean-room translations from PdfCraft's English interface labels"
[[build_input]]
name = "craft-fonts"
option = "CRAFT_FONTS_DIR"
@@ -3390,7 +3154,7 @@ source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/
licence = "OFL-1.1"
attribution = "https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md"
kind = "font"
usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1"
usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); status captions of named-layer signatures in Arabic, Japanese, Thai and Devanagari, drawn as filled outlines into the display copy by crates/sign/src/status_layers.rs; the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1"
[[asset]]
path = "crates/sign/tests/data/x509-rsa-sha1.pdf"
@@ -3414,26 +3178,6 @@ kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428"
[[asset]]
path = "crates/ui-egui/src/i18n/vi.tsv"
title = "Vietnamese interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/sl.tsv"
title = "Slovene interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "vendor/vello_common/assets/probe.png"
@@ -3446,16 +3190,6 @@ kind = "image"
usage = "Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file"
sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697"
[[asset]]
path = "crates/ui-egui/src/i18n/nb.tsv"
title = "Norwegian Bokmål interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "assets/icons/superscript.svg"
title = "Lucide icon \"superscript\""
@@ -3489,6 +3223,17 @@ kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "d344d9f9222f6c1b698c0c82a753a2212d9bd7785f06c00cd6e4910fe2fee8f8"
[[asset]]
path = "assets/icons/a-large-small.svg"
title = "Lucide icon \"a-large-small\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/a-large-small.svg)"
licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/widgets/icon_data.rs"
sha256 = "d4985fc71174d108e322c3b25cb6abe2837a115fe3f239a1918645db436743ec"
[[asset]]
path = "assets/icons/move-horizontal.svg"
title = "Lucide icon \"move-horizontal\""
@@ -3499,33 +3244,3 @@ licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7"
[[asset]]
path = "crates/ui-egui/src/i18n/sq.tsv"
title = "Albanian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/th.tsv"
title = "Thai interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs"
[[asset]]
path = "crates/ui-egui/src/i18n/ca.tsv"
title = "Catalan interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs"
+1 -65
View File
@@ -1,65 +1 @@
# PdfCraft — instructions for agents
PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Adobe Acrobat Pro parity. It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. It is the sibling of `../photocraft` (a Photoshop-class editor) and follows the same conventions.
## Start every session here
1. Read `plan/STATUS.md`: the current milestone, the next unchecked task and any blockers. `ROADMAP.md` is the one-page summary (stage, numbers, progress log) and `docs/roadmap.md` the milestone table; at the end of every session update the milestone row and add a line to the ROADMAP.md progress log.
- Read `docs/gaps.md` (the ranked gaps: Acrobat interop, the open-issue backlog, the renderer and a fidelity harness, editing existing content, Pro workflows, 1.0 polish) and `docs/target-app-parity.md` (the numbers and how they are measured) before choosing work. Prefer the gaps over new P2/P3 features, and keep both documents true when things change (`docs/` progress docs carry a status line and a revision history).
- "Shipped" in `parity/` means "exists and tested", not "as good as Acrobat". Don't mark a feature shipped on a generic test, and say in its notes what is still missing.
2. Read that task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README of the crate you're touching.
3. Follow the **autonomous operation protocol** in `plan/execution-plan.md` §7 (orient → plan → implement + test → verify → record → commit). Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local-only, like PhotoCraft). The machine-readable parity checklist lives in `parity/` (committed).
## Non-negotiables
- **Assets: read `AGENTS.md` §1 before adding or showing any icon, image, font or document.** No assets from Adobe products, ever. Only openly licensed or contributor-original assets are allowed, each with an entry in `ATTRIBUTION.toml`. `cargo xtask assets` enforces this. `AGENTS.md` overrides this file.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts).** Never commit font files here (`AGENTS.md` §1.4). It is the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts && CRAFT_FONTS_DIR=../craft-fonts cargo test --workspace` embeds the Japanese fonts (UI fallback, Japanese text in edited PDFs) and runs their tests, which otherwise skip. Code using `pdfcraft_fonts::CRAFT_FONTS` must work when it is empty.
- **Clean-room.**
- Never read, disassemble or copy anything inside the Acrobat bundle (names and listings only). **Never open `Contents/Resources/JavaScripts/`.**
- Behaviour comes from public docs, specs (ISO 32000-2, the Arlington model) and black-box observation (`plan/acrobat/`).
- Never copy GPL/AGPL code. MuPDF, Ghostscript, Poppler, veraPDF and DSS run only as external oracle processes.
- See `plan/README.md` §Clean-room and `plan/adr/0001`.
- **Privacy.** When observing Acrobat, use synthetic fixtures only. Never capture the Home view, recent files or account info. Capture by window id (`plan/acrobat/tools/`). Never commit Acrobat outputs, corpus files or personal data.
- **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone.
- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it.
- **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim(Layer::…, icon)`, `crates/ui-egui/src/canvas/page_cursor.rs`), never set it (`set_cursor_icon`, `cursor_icon =`, `on_hover_cursor`); `document_area` sets it once, from the claim on the highest layer the active tool reaches. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it.
- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the router says the press is its own (`view.router.owner(page)`), never by testing the press point again or by asking another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_modules_take_presses_from_the_router_without_repeating_hit_tests` guards the centralized hit calls and captured-owner handlers; runtime routing tests cover gesture behavior.
- **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it.
- **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code:
- No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why.
- No indexing or slicing with input-derived positions (use `get`, and slice strings only at char boundaries).
- Use checked or saturating arithmetic on input-derived numbers, and guard against division by zero and NaN/inf casts.
- Cap allocations sized by input, and bound recursion with depth limits or seen-sets.
- Handle lock poisoning.
- No `unsafe` (`unsafe_code = "forbid"`).
- Every crash fix gets a synthetic regression test. See `AGENTS.md` §4.
- **Rust only** in the product and build (`xtask`). No handwritten JS/TS.
- **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).
- **Review every update as you make it, at four zooms.** Not an end-of-task pass — a per-update habit, so a defect never gets built on. **Clause:** checked arithmetic, `get()` not `[i]`, char-boundary slicing, the right error through `?`, `<` against `<=`, the 1-based/0-based seam, capped allocations. **Function:** is it total — name an input that panics; walk empty, zero, max, malformed, cyclic; is the return type honest. **File/module:** two sources of truth, dead or duplicated code, is the public surface still smallest and the doc comment still true. **Project:** layering, wasm, undo/redo, save fidelity, encryption and permissions, a matching `automation` tool or registry entry, and whether a `parity/` status or note just became untrue. Scale the zoom to the change, and fix what a level turns up before widening.
- Tests mirror the zooms: property → unit → crate integration → end-to-end.
- **Prove a test fails before the fix by actually reverting the fix.** A test nobody has seen fail is not evidence. This has caught two worthless tests in this repo: one confinement test passed with path resolution switched off entirely, because the tools failed on a nonexistent document id before reaching the path.
- **Rebasing is a review step, not a mechanical one.** Read every hunk you resolve, and the auto-merged hunks in the files you touched too — `main` moves fast enough that a file can change by hundreds of lines under a branch.
- **External PRs are held to safety, security and completeness before style.** Provenance first (no Adobe-derived asset, no committed font, no copied GPL/AGPL code, behaviour from the spec and not from reading another implementation), then no new panic path or `unsafe`, then `--root` confinement on every path including ones reached by nested dispatch, opt-in-only MCP and control channel, no new outbound network, then a headless `automation` tool, tests at the right zoom and an honest `parity/` entry. Provenance is the part a contributor cannot be expected to know, and the only part that cannot be undone after merge.
- **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check once `xtask ci` exists (M0).
- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them.
- **Scope builds and tests to the crates you touched** — `cargo clippy -p <crate> -j 2 --all-targets` and `cargo test -p <crate> -j 2`, under a `timeout`. A full `--workspace` build has locked up a development machine's CPU; it is CI's job and CI runs it on every PR. Run the workspace sweep locally only when you have a reason to and the machine is free.
- **Check for existing work before starting a fix or feature.** Search the issues and PRs, open and closed, with `gh issue list -R storytold/pdfcraft --state all --search "<terms>"` and the same with `gh pr list` (try a few phrasings), and check whether a merged fix is already on `main` (`git merge-base --is-ancestor <commit> origin/main`). If one is, the report is likely from an older build: say so rather than fixing it again. If an open PR covers it, review or build on that PR instead of opening a competing one. If an issue exists, reference it in the PR (`Fixes #123`).
- **Commits and PRs:** `main` is the only integration branch and is **never pushed to directly**. Branch from an up-to-date `main` as `<type>/<slug>` (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), one task id per commit (e.g. `M1.4: xref stream reader`), commit only green states, and open a PR into `main` using `.github/pull_request_template.md`. Merge only when CI is green.
- **`main` moves fast enough that PRs rot.** It took 396 commits in the three days to 2026-10-12, and most open PRs are conflicting at any given time. Rebase your own branch the same day `main` moves under it, and ask the author to rebase theirs — never rewrite a contributor's branch. Conflict risk tracks how *contested* the files are, not how large the change is: tooling, tests and docs are nearly free, while `crates/fonts`, `ui-egui` and the i18n catalogues are where several people collide at once.
## Running and looking at the app
- `cargo run -p pdfcraft -- <file.pdf>` opens the desktop app.
- `cargo run -p pdfcraft-cli -- run --script steps.json --root DIR` drives the engine headlessly through the automation tools (`pdfcraft-cli tools` lists them). Use it, together with `page_render`, to check engine changes. `pdfcraft-cli mcp` is the opt-in MCP server (AGENTS.md §3).
- `cargo xtask fuzz --time 300` mutation-fuzzes open/render/edit/save in child processes. Findings land in `fuzz-out/findings/` (git-ignored; never commit corpus-derived files). Turn every real finding into a small synthetic regression test before fixing it.
- `cargo xtask parity [--partial]` reports Acrobat-parity progress from `parity/acrobat-features.toml`. Update the entry when a feature ships.
- `cargo xtask demo-pdf` builds `dist/demo/pdfcraft-showcase.pdf` (needs Chrome) for visual checks.
- For UI work, **look at the result**. Either launch `pdfcraft --control FILE doc.pdf` and use `pdfcraft-cli ui --control FILE screenshot --out x.png` (plus `inspect`, `click`, `key`, `type`, `command`, `set`), or take a headless shot with `cargo run -p pdfcraft-ui-egui --example shot`. Compare against `plan/acrobat/02-ui-ux.md`. Control-channel tests use kittest (`crates/ui-egui/tests/control.rs`).
- Parallel agents: use a separate `CARGO_TARGET_DIR` per agent and separate git worktrees.
## Current bootstrap debt (tracked in STATUS.md)
- `pdfcraft-render` renders through the `hayro` crate directly and inspects documents through `lopdf`. Both get replaced by `cos` / `model` / the DisplayList device (M1–M2, ADR-0004).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
@AGENTS.md
Generated
+238 -68
View File
@@ -143,6 +143,15 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "aligned-vec"
version = "0.6.4"
@@ -1234,14 +1243,14 @@ dependencies = [
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
source = "git+https://github.com/storytold/craft-libs?rev=60ede99985ef1e5df87a2aca66ed538eb23dec99#60ede99985ef1e5df87a2aca66ed538eb23dec99"
dependencies = [
"craft-winprint-gdi",
"log",
"miniz_oxide 0.9.1",
"roxmltree 0.21.2",
"tempfile",
"winprint",
"winprint 0.2.1 (git+https://github.com/storytold/winprint.rs?rev=bc06a7c48b768eb27a828c7986f6d2868b245ee0)",
"winsafe",
]
@@ -1280,7 +1289,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"chrono",
"dunce",
@@ -1303,10 +1312,30 @@ dependencies = [
"zip",
]
[[package]]
name = "craft-web-api"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"js-sys",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "craft-winprint-gdi"
version = "0.1.0"
source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312"
source = "git+https://github.com/storytold/winprint.rs?rev=64e7afd4d1483512385012c340ecaaaf0f3beb7f#64e7afd4d1483512385012c340ecaaaf0f3beb7f"
dependencies = [
"windows-sys 0.61.2",
]
@@ -1667,7 +1696,7 @@ dependencies = [
[[package]]
name = "ecolor"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
dependencies = [
"bytemuck",
"emath",
@@ -1696,10 +1725,14 @@ dependencies = [
"zeroize",
]
[[package]]
name = "edit-session"
version = "0.1.0-alpha.2"
[[package]]
name = "eframe"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
dependencies = [
"ahash",
"bytemuck",
@@ -1737,7 +1770,7 @@ dependencies = [
[[package]]
name = "egui"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
dependencies = [
"accesskit",
"ahash",
@@ -1758,7 +1791,7 @@ dependencies = [
[[package]]
name = "egui-wgpu"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
dependencies = [
"ahash",
"bytemuck",
@@ -1777,7 +1810,7 @@ dependencies = [
[[package]]
name = "egui-winit"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
dependencies = [
"accesskit_winit",
"arboard",
@@ -1804,7 +1837,7 @@ dependencies = [
[[package]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
dependencies = [
"ahash",
"egui",
@@ -1818,7 +1851,7 @@ dependencies = [
[[package]]
name = "egui_glow"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
dependencies = [
"bytemuck",
"egui",
@@ -1831,7 +1864,7 @@ dependencies = [
[[package]]
name = "egui_kittest"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
dependencies = [
"eframe",
"egui",
@@ -1874,7 +1907,7 @@ dependencies = [
[[package]]
name = "emath"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
dependencies = [
"bytemuck",
"serde",
@@ -1955,7 +1988,7 @@ dependencies = [
[[package]]
name = "epaint"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
dependencies = [
"ahash",
"bytemuck",
@@ -1982,7 +2015,7 @@ dependencies = [
[[package]]
name = "epaint_default_fonts"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
[[package]]
name = "equator"
@@ -3104,6 +3137,28 @@ dependencies = [
"zerovec",
]
[[package]]
name = "icu_segmenter"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db"
dependencies = [
"icu_collections",
"icu_locale_fallback",
"icu_provider",
"icu_segmenter_data",
"potential_utf",
"smallvec",
"utf8_iter",
"zerovec",
]
[[package]]
name = "icu_segmenter_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
[[package]]
name = "idna"
version = "1.1.0"
@@ -4208,7 +4263,7 @@ dependencies = [
[[package]]
name = "pdfcraft"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"chrono",
"craft-update",
@@ -4217,6 +4272,7 @@ dependencies = [
"fmv-macos-events",
"log",
"pdfcraft-engine",
"pdfcraft-platform",
"pdfcraft-ui-egui",
"pollster 1.0.1",
"raw-window-handle",
@@ -4232,11 +4288,10 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-filters",
"pdfcraft-fonts",
"pdfcraft-model",
"thiserror 2.0.21",
@@ -4244,18 +4299,19 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-geom",
"pdfcraft-model",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-audit"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -4266,7 +4322,7 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"base64 0.23.1",
"flate2",
@@ -4277,6 +4333,8 @@ dependencies = [
"pdfcraft-model",
"pdfcraft-organize",
"pdfcraft-platform",
"pdfcraft-prc",
"pdfcraft-prc-render",
"pdfcraft-render",
"pdfcraft-xfa",
"png 0.18.1",
@@ -4288,7 +4346,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cli"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"base64 0.23.1",
"pdfcraft-audit",
@@ -4306,11 +4364,11 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
version = "0.18.0"
version = "0.18.1"
[[package]]
name = "pdfcraft-content"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -4318,10 +4376,11 @@ dependencies = [
[[package]]
name = "pdfcraft-cos"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"chrono",
"hayro-syntax",
"js-sys",
"pdfcraft-crypt",
"pdfcraft-filters",
"pdfcraft-oracle",
@@ -4329,11 +4388,12 @@ dependencies = [
"proptest",
"serde_json",
"thiserror 2.0.21",
"wasm-bindgen",
]
[[package]]
name = "pdfcraft-create"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"encoding_rs",
"hayro-ccitt",
@@ -4352,7 +4412,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"aes",
"md-5",
@@ -4360,13 +4420,15 @@ dependencies = [
"sha2 0.11.1",
"stringprep",
"thiserror 2.0.21",
"zeroize",
]
[[package]]
name = "pdfcraft-edit"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"hayro-syntax",
"icu_segmenter",
"kurbo 0.13.1",
"pdfcraft-annot",
"pdfcraft-content",
@@ -4382,7 +4444,7 @@ dependencies = [
[[package]]
name = "pdfcraft-engine"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"chrono",
"craft-docpath",
@@ -4426,14 +4488,14 @@ dependencies = [
[[package]]
name = "pdfcraft-export"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"fax",
"flate2",
@@ -4446,7 +4508,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -4456,7 +4518,7 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4469,11 +4531,11 @@ dependencies = [
[[package]]
name = "pdfcraft-geom"
version = "0.18.0"
version = "0.18.1"
[[package]]
name = "pdfcraft-js"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"boa_engine",
"chrono",
@@ -4481,7 +4543,7 @@ dependencies = [
[[package]]
name = "pdfcraft-measure"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4494,25 +4556,39 @@ dependencies = [
[[package]]
name = "pdfcraft-model"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"ocrs",
"pdfcraft-fonts",
"png 0.18.1",
"rten",
"rten-imageproc",
"serde_json",
"sha2 0.11.1",
"tempfile",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-ocr-worker"
version = "0.18.1"
dependencies = [
"js-sys",
"pdfcraft-ocr",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "pdfcraft-optimize"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"image",
"pdfcraft-annot",
@@ -4523,16 +4599,17 @@ dependencies = [
"pdfcraft-render",
"proptest",
"thiserror 2.0.21",
"thread-priority",
"zune-jpeg",
]
[[package]]
name = "pdfcraft-oracle"
version = "0.18.0"
version = "0.18.1"
[[package]]
name = "pdfcraft-organize"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4543,17 +4620,36 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"async-channel",
"futures-lite",
"log",
"rustix 1.1.5",
"zbus",
]
[[package]]
name = "pdfcraft-prc"
version = "0.18.1"
dependencies = [
"flate2",
"proptest",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-prc-render"
version = "0.18.1"
dependencies = [
"flate2",
"pdfcraft-prc",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-preflight"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"flate2",
"pdfcraft-content",
@@ -4564,20 +4660,24 @@ dependencies = [
[[package]]
name = "pdfcraft-print"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"craft-print",
"log",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-model",
"pdfcraft-render",
"skrifa 0.44.0",
"thiserror 2.0.21",
"winprint 0.2.1 (git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49)",
]
[[package]]
name = "pdfcraft-redact"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"aho-corasick",
"pdfcraft-annot",
"pdfcraft-content",
"pdfcraft-cos",
@@ -4585,15 +4685,21 @@ dependencies = [
"pdfcraft-filters",
"pdfcraft-fonts",
"pdfcraft-forms",
"pdfcraft-geom",
"pdfcraft-model",
"pdfcraft-render",
"pdfcraft-testkit",
"pdfcraft-xfa",
"regex",
"roxmltree 0.21.2",
"sha2 0.11.1",
"thiserror 2.0.21",
"unicode-normalization",
]
[[package]]
name = "pdfcraft-render"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"bytemuck",
"flate2",
@@ -4603,16 +4709,21 @@ dependencies = [
"kurbo 0.13.1",
"log",
"lopdf",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-geom",
"pdfcraft-testkit",
"serde",
"skrifa 0.44.0",
"thiserror 2.0.21",
"unicode-bidi",
"url",
]
[[package]]
name = "pdfcraft-sign"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"aes",
"aws-lc-rs",
@@ -4632,9 +4743,11 @@ dependencies = [
"p521",
"pbkdf2",
"pdfcraft-annot",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-model",
"pdfcraft-testkit",
"rc2",
"ripemd",
"rsa",
@@ -4644,30 +4757,34 @@ dependencies = [
"sha2 0.11.1",
"sha3",
"thiserror 2.0.21",
"zeroize",
]
[[package]]
name = "pdfcraft-testkit"
version = "0.18.0"
version = "0.18.1"
[[package]]
name = "pdfcraft-ui-egui"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"arboard",
"base64 0.23.1",
"block2 0.6.2",
"bytemuck",
"chrono",
"craft-i18n",
"craft-keymap",
"craft-menu",
"craft-quit",
"craft-ui",
"craft-update",
"edit-session",
"eframe",
"egui",
"egui_extras",
"egui_kittest",
"flate2",
"fontconfig-parser",
"getrandom 0.4.3",
"image",
@@ -4681,6 +4798,7 @@ dependencies = [
"pdfcraft-engine",
"pdfcraft-fonts",
"pdfcraft-model",
"pdfcraft-ocr",
"pdfcraft-platform",
"pdfcraft-render",
"pdfcraft-xfa",
@@ -4702,23 +4820,27 @@ dependencies = [
"web-time",
"winsafe",
"xattr",
"zeroize",
]
[[package]]
name = "pdfcraft-web"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"craft-web-api",
"craft-web-host",
"eframe",
"egui",
"js-sys",
"pdfcraft-ui-egui",
"serde_json",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "pdfcraft-xfa"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"base64 0.23.1",
"image",
@@ -4732,12 +4854,14 @@ dependencies = [
[[package]]
name = "pdfcraft-xfdf"
version = "0.18.0"
version = "0.18.1"
dependencies = [
"base64 0.23.1",
"pdfcraft-annot",
"pdfcraft-cos",
"pdfcraft-forms",
"pdfcraft-model",
"proptest",
"roxmltree 0.21.2",
"thiserror 2.0.21",
]
@@ -5460,6 +5584,29 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
@@ -6832,6 +6979,20 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "thread-priority"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d2e834949be5111506bb252643498af1514f600d9e1dceedaa42afae155b67f"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"libc",
"log",
"rustversion",
"windows",
]
[[package]]
name = "tiff"
version = "0.11.3"
@@ -8180,6 +8341,28 @@ dependencies = [
"xml",
]
[[package]]
name = "winprint"
version = "0.2.1"
source = "git+https://github.com/storytold/winprint.rs?rev=bc06a7c48b768eb27a828c7986f6d2868b245ee0#bc06a7c48b768eb27a828c7986f6d2868b245ee0"
dependencies = [
"flate2",
"fmt-derive",
"reqwest",
"scopeguard",
"sigstore-trust-root",
"sigstore-types",
"sigstore-verify",
"strum",
"tar",
"thiserror 2.0.21",
"windows",
"windows-core",
"windows-implement",
"windows-numerics",
"xml",
]
[[package]]
name = "winreg"
version = "0.55.0"
@@ -8624,7 +8807,6 @@ dependencies = [
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
@@ -8639,18 +8821,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.5.3"
@@ -8710,14 +8880,14 @@ dependencies = [
[[patch.unused]]
name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
[[patch.unused]]
name = "egui_system_font_provider"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
+68 -52
View File
@@ -5,13 +5,15 @@ exclude = ["vendor"]
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
[workspace.package]
version = "0.18.0"
version = "0.18.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
repository = "https://github.com/storytold/pdfcraft"
[workspace.dependencies]
# Small, revision-bound UI drafts; the existing engine remains the owner of PDF edits/history.
edit-session = { path = "vendor/edit-session", version = "=0.1.0-alpha.2" }
# Shared frontend components; engines remain independent of egui.
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["serde"] }
# Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`.
@@ -19,52 +21,62 @@ craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0
# One menu definition for the native macOS menu bar and the in-window bar elsewhere.
craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", default-features = false }
# Interface translations: lookup, fallback, CLDR plurals and language names over the craft-i18n
# catalogs that `crates/ui-egui/build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, at
# the commit in `craft-i18n.rev`). Already in the tree through craft-ui.
craft-i18n = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
# Shared system spooler; document composition remains in pdfcraft-print.
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "60ede99985ef1e5df87a2aca66ed538eb23dec99" }
# Whether a path stored in a document may be opened (GoToR link targets).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
# internal
pdfcraft-geom = { path = "crates/geom", version = "0.18.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.18.0" }
pdfcraft-cos = { path = "crates/cos", version = "0.18.0" }
pdfcraft-crypt = { path = "crates/crypt", version = "0.18.0" }
pdfcraft-organize = { path = "crates/organize", version = "0.18.0" }
pdfcraft-measure = { path = "crates/measure", version = "0.18.0" }
pdfcraft-annot = { path = "crates/annot", version = "0.18.0" }
pdfcraft-audit = { path = "crates/audit", version = "0.18.0" }
pdfcraft-fonts = { path = "crates/fonts", version = "0.18.0" }
pdfcraft-model = { path = "crates/model", version = "0.18.0" }
pdfcraft-content = { path = "crates/content", version = "0.18.0" }
pdfcraft-redact = { path = "crates/redact", version = "0.18.0" }
pdfcraft-print = { path = "crates/print", version = "0.18.0" }
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.18.0" }
pdfcraft-sign = { path = "crates/sign", version = "0.18.0" }
pdfcraft-optimize = { path = "crates/optimize", version = "0.18.0" }
pdfcraft-a11y = { path = "crates/a11y", version = "0.18.0" }
pdfcraft-oracle = { path = "crates/oracle", version = "0.18.0" }
pdfcraft-testkit = { path = "crates/testkit", version = "0.18.0" }
pdfcraft-ocr = { path = "crates/ocr", version = "0.18.0" }
pdfcraft-js = { path = "crates/js", version = "0.18.0" }
pdfcraft-compare = { path = "crates/compare", version = "0.18.0" }
pdfcraft-preflight = { path = "crates/preflight", version = "0.18.0" }
pdfcraft-export = { path = "crates/export", version = "0.18.0" }
pdfcraft-edit = { path = "crates/edit", version = "0.18.0" }
pdfcraft-create = { path = "crates/create", version = "0.18.0" }
pdfcraft-forms = { path = "crates/forms", version = "0.18.0" }
pdfcraft-xfa = { path = "crates/xfa", version = "0.18.0" }
pdfcraft-render = { path = "crates/render", version = "0.18.0" }
pdfcraft-engine = { path = "crates/engine", version = "0.18.0" }
pdfcraft-platform = { path = "crates/platform", version = "0.18.0" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.18.0" }
pdfcraft-automation = { path = "crates/automation", version = "0.18.0", default-features = false }
pdfcraft-geom = { path = "crates/geom", version = "0.18.1" }
pdfcraft-filters = { path = "crates/filters", version = "0.18.1" }
pdfcraft-cos = { path = "crates/cos", version = "0.18.1" }
pdfcraft-crypt = { path = "crates/crypt", version = "0.18.1" }
pdfcraft-organize = { path = "crates/organize", version = "0.18.1" }
pdfcraft-measure = { path = "crates/measure", version = "0.18.1" }
pdfcraft-annot = { path = "crates/annot", version = "0.18.1" }
pdfcraft-audit = { path = "crates/audit", version = "0.18.1" }
pdfcraft-fonts = { path = "crates/fonts", version = "0.18.1" }
pdfcraft-model = { path = "crates/model", version = "0.18.1" }
pdfcraft-content = { path = "crates/content", version = "0.18.1" }
pdfcraft-redact = { path = "crates/redact", version = "0.18.1" }
pdfcraft-print = { path = "crates/print", version = "0.18.1" }
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.18.1" }
pdfcraft-sign = { path = "crates/sign", version = "0.18.1" }
pdfcraft-optimize = { path = "crates/optimize", version = "0.18.1" }
pdfcraft-a11y = { path = "crates/a11y", version = "0.18.1" }
pdfcraft-oracle = { path = "crates/oracle", version = "0.18.1" }
pdfcraft-testkit = { path = "crates/testkit", version = "0.18.1" }
pdfcraft-ocr = { path = "crates/ocr", version = "0.18.1" }
pdfcraft-js = { path = "crates/js", version = "0.18.1" }
pdfcraft-compare = { path = "crates/compare", version = "0.18.1" }
pdfcraft-preflight = { path = "crates/preflight", version = "0.18.1" }
pdfcraft-export = { path = "crates/export", version = "0.18.1" }
pdfcraft-edit = { path = "crates/edit", version = "0.18.1" }
pdfcraft-create = { path = "crates/create", version = "0.18.1" }
pdfcraft-forms = { path = "crates/forms", version = "0.18.1" }
pdfcraft-xfa = { path = "crates/xfa", version = "0.18.1" }
pdfcraft-render = { path = "crates/render", version = "0.18.1" }
pdfcraft-engine = { path = "crates/engine", version = "0.18.1" }
pdfcraft-platform = { path = "crates/platform", version = "0.18.1" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.18.1" }
pdfcraft-automation = { path = "crates/automation", version = "0.18.1", default-features = false }
pdfcraft-prc = { path = "crates/prc", version = "0.18.1" }
pdfcraft-prc-render = { path = "crates/prc-render", version = "0.18.1" }
# pdf
hayro = "0.7.1"
hayro-syntax = "0.7.2"
lopdf = "0.45"
flate2 = { version = "1.1", default-features = false, features = ["rust_backend"] }
# zlib-rs: miniz_oxide took about 1.7 µs for every fixed-Huffman block, empty or not
# (`pdfcraft_filters::FLATE_READ_WORK`). Other dependencies still build flate2's default
# miniz_oxide backend; flate2 prefers zlib-rs when both are built, as the Windows build already did
# through craft-update. `flate::tests::a_copy_from_before_the_start_is_damage` shows which runs.
flate2 = { version = "1.1", default-features = false, features = ["zlib-rs"] }
weezl = "0.2"
# CCITT Group 4 encoding (pdfcraft-filters); already in the tree through tiff.
fax = "0.2.7"
@@ -74,6 +86,10 @@ hayro-jbig2 = "0.3"
# foundation
thiserror = "2"
zeroize = "1"
# Linear-time matching (no backtracking) for caller-supplied redaction patterns. `perf-dfa` gives
# the lazy-DFA cache that `RegexBuilder::dfa_size_limit` bounds (the limit is inert without it).
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf-dfa"] }
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
log = "0.4"
@@ -136,22 +152,22 @@ lopdf = { path = "vendor/lopdf" }
vello_common = { path = "vendor/vello_common" }
vello_cpu = { path = "vendor/vello_cpu" }
# The egui and winit crates below are pinned to immutable storytold git revisions (egui
# 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc).
# 725a39fca940f77149ffbb9c58eef405a16997bc, winit a517b3846ee8457d84ee4975110bfda6797c44dc).
# Only the retained `vendor/winit` and `vendor/egui-wgpu` copies are provenance for the app's
# earlier patches and their source guards, not path patches; the other `vendor/` entries above
# remain real path patches. See vendor/README.md and the migration plan.
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
emath = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
egui = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+33 -1
View File
@@ -16,7 +16,19 @@ sha256 = "cd7032ea33b71427f188e34ffb4aee9d6868a57fd196037c5c8b2be03a2a0352"
[[document]]
source = "NOTICE"
destination = "NOTICE"
sha256 = "9f854f730719af962cd840529324d089e05e68a30bd245c50e33f96e009b4314"
sha256 = "a7620386409bc691c7e223917db186ec447362f655fd81a1196d9853dcac1330"
[[document]]
source = "vendor/edit-session/LICENSE-MIT"
licences = ["MIT"]
destination = "vendor/edit-session/LICENSE-MIT"
sha256 = "665a942f9317a358930b470ad522a22a58ff6c346db0395463ec84dd3e19d775"
[[document]]
source = "vendor/edit-session/LICENSE-APACHE"
licences = ["Apache-2.0"]
destination = "vendor/edit-session/LICENSE-APACHE"
sha256 = "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30"
[[document]]
source = "assets/app-icon/LICENSE-document.txt"
@@ -60,6 +72,13 @@ destination = "licenses/CMap-LICENSE.txt"
sha256 = "742665db9c8e1bc72603c6d319ca3e90b83bd6d95202f0cd4ef11068a07a9c29"
origin = "https://static.crates.io/crates/hayro-cmap/hayro-cmap-0.1.0.crate#assets/LICENSE.txt"
[[document]]
source = "licenses/ICU4X-LICENSE.txt"
licences = ["Unicode-3.0"]
destination = "licenses/ICU4X-LICENSE.txt"
sha256 = "f367c1b8e1aa262435251e442901da4607b4650e0e63a026f5044473ecfb90f2"
origin = "https://static.crates.io/crates/icu_segmenter_data/icu_segmenter_data-2.3.0.crate#LICENSE"
[[document]]
source = "licenses/egui-Hack-Regular.txt"
licences = ["MIT", "Bitstream-Vera"]
@@ -88,6 +107,13 @@ destination = "licenses/egui-emoji-icon-font-mit-license.txt"
sha256 = "b9d2c1d909aa149996fd4c91dcb92b2362a04431640c1d200959da94caf8cde1"
origin = "https://raw.githubusercontent.com/emilk/egui/0.36.2/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt"
[[document]]
source = "licenses/prc-rs-LICENSE.txt"
licences = ["MIT"]
destination = "licenses/prc-rs-LICENSE.txt"
sha256 = "ddb846513d92153d8fa16b18ed3e7c86953a582f0d2f49f02b29f1bd569dac58"
origin = "https://raw.githubusercontent.com/ralovich/prc-rs/287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d/LICENSE"
[[document]]
source = "vendor/hayro-interpret/LICENSE-APACHE"
licences = ["Apache-2.0"]
@@ -157,3 +183,9 @@ crate = "hayro-interpret"
path = "src/font/generated/metrics.rs"
licence = "MIT OR Apache-2.0"
documents = ["vendor/hayro-interpret/LICENSE-MIT", "vendor/hayro-interpret/LICENSE-APACHE"]
[[bundled]]
crate = "icu_segmenter_data"
path = "data/segmenter_dictionary_extended_v1.rs.data"
licence = "Unicode-3.0"
documents = ["licenses/ICU4X-LICENSE.txt"]
+31
View File
@@ -9,6 +9,8 @@ docs/brand/LICENSE-brand.txt.
Third-party material
- edit-session 0.1.0-alpha.2, Copyright (c) 2026 PerchAnton. See vendor/edit-session/LICENSE-MIT and vendor/edit-session/LICENSE-APACHE.
This product bundles third-party material under its own licenses, listed below. The per-file
list (author, source, license) is in ATTRIBUTION.md.
@@ -36,6 +38,9 @@ list (author, source, license) is in ATTRIBUTION.md.
- egui default fonts (epaint_default_fonts, via eframe) — Ubuntu Light (Ubuntu Font Licence 1.0),
Hack (MIT / Bitstream Vera licence), Noto Emoji (SIL OFL 1.1), emoji-icon-font (MIT).
- Colour profile in hayro-interpret — CGATS001Compat-v2-micro.icc (CC0-1.0, Clinton Ingram).
- ICU4X line-breaking data (icu_segmenter_data, via crates.io) — Unicode-3.0. Its Thai, Lao, Khmer
and Burmese word dictionaries are compiled in for wrapping Thai text; notice below, full licence in
licenses/ICU4X-LICENSE.txt.
Required notices:
@@ -48,6 +53,32 @@ Required notices:
(https://github.com/adobe-type-tools/cmap-resources/blob/master/LICENSE.txt). Neither the name of
Adobe nor the names of its contributors are used to endorse or promote this product.
ICU4X segmenter data (bundled by icu_segmenter_data 2.3.0; word lists, not visual assets):
Copyright © 2020-2024 Unicode, Inc. Portions adapted from ICU4C and/or ICU4J:
ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others.
Permission is hereby granted, free of charge, to any person obtaining a copy of data files and any
associated documentation (the "Data Files") or software and any associated documentation (the
"Software") to deal in the Data Files or Software without restriction, including without
limitation the rights to use, copy, modify, merge, publish, distribute, and/or sell copies of the
Data Files or Software, and to permit persons to whom the Data Files or Software are furnished to
do so, provided that either (a) this copyright and permission notice appear with all copies of
the Data Files or Software, or (b) this copyright and permission notice appear in associated
Documentation.
THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS.
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM,
OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF
USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA FILES OR SOFTWARE.
Except as contained in this notice, the name of a copyright holder shall not be used in
advertising or otherwise to promote the sale, use or other dealings in these Data Files or
Software without prior written authorization of the copyright holder.
Showcase PDF (built by `cargo xtask demo-pdf`, not committed): fonts are downloaded at build time from
google/fonts at a pinned commit, verified by SHA-256, all under the SIL Open Font License 1.1 — see the
"Downloaded at build time" table in ATTRIBUTION.md.
+25 -4
View File
@@ -94,7 +94,7 @@ Real-world typography: world scripts, vertical Japanese, colour emoji, gradients
<td width="33%" valign="top">
### Fearless
Every save appends your changes and leaves the original bytes untouched. Writes are atomic, undo runs deep, and nothing is lost if you close by mistake.
Ordinary saves append your changes and leave the original bytes untouched. Edits to damaged objects whose xref and header generations disagree use a full rewrite, keeping both accepted references connected to the same repaired object. Writes are atomic, undo runs deep, and nothing is lost if you close by mistake.
</td>
<td width="33%" valign="top">
@@ -253,6 +253,18 @@ Documents restricted by their author show a clear notice, and PdfCraft honours t
</tr>
</table>
## Scan pages, then read the words back before trusting them
**Scan & OCR ▸ Recognize text** turns a scan into searchable text — the current page, a range, or a folder of files written as searchable copies — with optional cleanup before reading (auto-rotate, deskew, denoise, binarize; word boxes map back to the original scan) and a second engine: an installed Tesseract joins the built-in one, merged per your strategy. A searchable result applies as one undoable step; the **Editable text** output opens the **OCR Verify screen** instead, where each word carries the engine's confidence as a colour, suspects are one keypress away, and corrections, a user dictionary and Re-OCR of a region or a page happen before Accept writes the new document. **Scan & OCR ▸ Correct recognized text** reviews any recognition there before it is applied.
<p align="center">
<img src="docs/images/pdfcraft-ocr-verify.png" alt="The OCR Verify screen: a scanned note with each recognized word boxed in its confidence colour, the info strip reporting a 95% average confidence and one low-confidence word, and the confidence legend with its thresholds below" width="100%">
<br>
<sub>The OCR Verify screen: words coloured by confidence — the legend spells the thresholds out — reviewed and corrected before anything is applied.</sub>
</p>
---
## Every tool, one keystroke away
Press <kbd>⌘K</kbd> to search every tool and command, or browse the **All tools** catalogue. Tools that are still in development are marked with the milestone that will ship them.
@@ -319,7 +331,7 @@ Edits stay in memory, undoable, until `doc_save`. Saving to the same file append
Start the desktop app with `pdfcraft --control ~/.pdfcraft-control.json` and an agent can see and operate the real interface: the widget tree with labels and positions (from the accessibility tree), clicks, typing, keys, commands, view options and screenshots. This is also off by default. It listens only on loopback, and every connection must present the random token written to that file, which only you can read.
Keep the control file in a folder only you can write, not a shared one such as `/tmp`: another user could create the file there first and receive your commands. `pdfcraft-cli ui` refuses a control file that is a symbolic link, and on macOS, Linux and FreeBSD one that another user owns or can read or write. The app doesn't start if it can't write the file.
Keep the control file in a folder only you can write, not a shared one such as `/tmp`: another user could create the file there first and receive your commands. `pdfcraft-cli ui` refuses a control file that is a symbolic link, or one that another user owns, or that another user or a group such as Administrators can read or write: on macOS, Linux and FreeBSD by its owner and mode (the app writes it with mode 600), on Windows by its owner and access list (the app gives it one with only you and SYSTEM on it). The app doesn't start if it can't write the file. On Windows, cmd.exe doesn't expand `~`: use `%USERPROFILE%\.pdfcraft-control.json` there.
```sh
pdfcraft-cli ui --control ~/.pdfcraft-control.json inspect query=rotate # find widgets
@@ -460,11 +472,14 @@ Installers and executables are code-signed.
The portable zip runs from any folder, a USB stick included. Its `portable.txt` keeps the settings,
logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so nothing is written to
`%APPDATA%`; delete that file to use the normal per-user folders.
Windows doesn't offer the portable copy as a default PDF app on its own, since it registers nothing;
`portable.txt` has the PowerShell lines that register it for your user only, and how to undo them.
The MSI installs for your account only by default, without administrator privileges. Choose
"Everyone on this computer" on its first page, or pass `ALLUSERS=1` from an elevated terminal, to install
for all users. It adds "Edit with PdfCraft"
to the right-click menu of PDF files and "Create PDF with PdfCraft…" to the right-click menu of
and "Combine files in PdfCraft" (select several PDFs: they land in the Combine files tab of the
running PdfCraft) to the right-click menu of PDF files and "Create PDF with PdfCraft…" to the right-click menu of
images (.png .jpg .jpeg .tif .tiff .gif .bmp .jp2 .j2k .jpx) in File Explorer (on Windows 11, under
"Show more options"), without changing which app opens them by default. For unattended deployment
without a desktop shortcut (add `ALLUSERS=1`, from an elevated terminal, for an all-users install):
@@ -476,7 +491,7 @@ msiexec /i "pdfcraft-<ver>-windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCU
| Property | `=0` leaves out |
|---|---|
| `INSTALLDESKTOPSHORTCUT` | the desktop shortcut |
| `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" on PDF files |
| `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" and "Combine files in PdfCraft" on PDF files |
| `INSTALLIMAGECONTEXTMENU` | "Create PDF with PdfCraft…" on images |
All three are on for a first install. An upgrade keeps what the installed version has: a shortcut
@@ -487,6 +502,12 @@ didn't have a menu yet (0.6.0 and earlier for the PDF menu, 0.2.1 and earlier fo
adds it, as a first install would. Use the MSI for your architecture. Per-user installation
overrides are not supported.
To install somewhere other than the default `ArtCraft\PdfCraft` folder, change the folder on the
welcome screen (type it, or Browse…) or pass it on the command line:
`INSTALLFOLDER="D:\Apps\PdfCraft"`. It is installed for whoever the first screen chose (`ALLUSERS=1`
on the command line for everyone). An upgrade keeps the folder the installed version uses unless
the command line gives another one.
**Pin the checksum, not just the version.** Release candidates of a version share its MSI version
(MSI versions are numbers only, so 1.2.3-rc.1 and 1.2.3 are both 1.2.3), and the installer lets an
MSI of the same version replace the installed one without asking. Two different `0.7.1` MSIs can't
+185 -15
View File
@@ -1,5 +1,28 @@
- 2026-11-04 (M6, PR #5699 group/cache/MK/button review): Expand calculation groups/duplicates with streaming bounded lookup; retain one canonical Field object per exposed identity and prune stale roots. Protect outside shared-MK owners and carry exact button IDs through native clicks and js_run, with regression and persistence coverage.
- 2026-11-04 (M6, PR #5699 early reset/duplicate identities): Enforce the shared 64-call reset budget before argument conversion/scanning, with sticky atomic rejection and Open/event budget sharing. Preserve PDF object identity in Calculate order, live targets and value writeback; regress duplicate names, reversed order, native completion and persistence.
- 2026-11-04 (M6, PR #5699 independent review): Preserve unknown defaults through the JS reset shadow; bound menu/response text before copies. Cover native/console reset persistence, inherited defaults, decode-budget exhaustion and exact Unicode dialog boundaries.
- 2026-11-04 (M6, PR #5699 memory/widget bounds): Count reset widgets and bound escaped UTF-8 text conversion before copies; add negative and Unicode boundary regressions.
- 2026-11-04 (M6, PR #5699 reset estimate): Charge expanded-name scans and field-name copy/comparison bytes; add three negative regressions and byte-exact refusal checks.
# PdfCraft roadmap
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M6, PR #4166/#4088: integrate main f137a289 at 0.16.1; retain automatic-field Print suppression, explicit button/console printing and document-error provenance; 761 scoped tests and verification gates passed; 13-step CLI exercise and render inspection passed; existing warnings retained; estimates unchanged)
> **Last reviewed:** 2026-11-09 | **Last updated:** 2026-11-09 | **Change:** patch (M10 #5130: preserve manual duplex defaults; validate DocumentDuplex review fix and CI formatting; estimates unchanged)
>
> **Last reviewed:** 2026-11-08 | **Last updated:** 2026-11-08 | **Change:** patch (M10 #6514: refresh craft-print to 7ee35a9f, PDF loader to 51934db3 and public GDI kept at 64e7afd4 after #605 merged; preserve crash fix and stream-test repairs; new-graph CI pending; estimates unchanged)
> **Last reviewed:** 2026-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M10: file-backed Windows PDF loading without whole-job buffering or per-chunk async waits; approved dependency chain retains #5006; physical output still unqualified; estimates unchanged)
> **Last reviewed:** 2026-11-05 | **Last updated:** 2026-11-05 | **Change:** patch (M7 #2964: directly edit Thai source text inside supported Form XObjects; estimates unchanged)
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged)
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged)
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** minor (M3/M4, #5330: Crop pages is directly available in the document quick toolbar; estimates unchanged)
> **2026-11-04 (M6, PR #5699 reset budget):** Reject reset amplification before request copies, preview and PDF mutation; cap reset/selector counts and aggregate estimated work. Add native completion, boundary, flood and large-form regressions; preserve ordered under-budget resets.
> **2026-11-04 (M6, PR #5699 dependent reset):** Reuse form reset on an isolated comparison document so unnamed built-in calculated text/combo fields are included; retain normal post-write calculation. Extend persisted-default and typed-response regressions.
> **2026-11-04 (M6, PR #5699 reset ordering):** Reset live field values synchronously, commit resets before final script deltas using a reset baseline, and cover Doc Open reinitialization, save/reopen, Undo/Redo and replay-sensitive UI counters.
> **2026-11-04 (M6, PR #5699 review):** Bound worker capacity and synchronous deadlines, isolate expired/pending realms before edits, keep script prompts above other dialogs, and cover block ASI, control-position regex literals, event routing and complete undo state. Validation is reported in the PR; parity remains partial.
> **2026-11-03 (M6, PR #5699 follow-up):** Accept independent top-level statements separated by automatic semicolon insertion while retaining continuation-chain limits; assert cyclic calculation results and preserve upstream multiline Fill & Sign coverage.
> **2026-11-02 (M6, PR #5699 second review):** Count active expression chains instead of whole-source tokens; align native button events and automation schemas, block document input under script prompts, and validate prompt dismissal and idle repaint scheduling.
> **2026-11-02 (M6, PR #5699 review):** Cache per-edit script geometry/calculation plans, expose live document metadata, and bound persistent worker stacks to 64 MiB; add operation-count, lifecycle and isolated hostile-input/address-space regressions.
> **2026-11-02 (M6, #5211/#269):** Integrate persistent native AcroForm realms, synchronous calculation, page boxes, appearance/geometry/icon writeback and native button prompt continuation. Synthetic workflow and cancellation tests added; validation details accompany the PR. Remaining JavaScript parity stays partial.
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged)
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 latest comments: explicit image-paste events, mixed-size field refresh, and documented mixed-script normalization with Undo; estimates unchanged)
@@ -16,11 +39,28 @@
> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: reconcile current main while retaining display-only CJK fallback and upstream Japanese font selection; estimates unchanged)
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M2, PR #4172: extend on-screen CJK fallback to Combine thumbnails and figure previews; recognise FangSong and subset-prefixed Founder names; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (typed small Edit font sizes and Fill & Sign text/date colour, #2444/#2496; estimates unchanged)
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (M7, #2750: Single-style Keep source fonts edits retain source glyphs and batch only missing glyphs in fallback fonts. Per-rewrite character eligibility and widths are cached; source and fallback edge spaces stay extractable. Estimates unchanged.)
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** patch (M1, #1143: repaired aliases survive preflight, snapshots and normalized saves; retained materialized revisions report generation disagreements; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M1, #1143: explicit live xref/header generation repair policy and bounded diagnostics; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: refresh object-generation reference identity against current upstream; estimates unchanged)
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: vector printing preserves annotation optional-content membership; estimates unchanged)
> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M6, PR #4166/#4088: preserve automatic-field Print suppression, explicit script printing and document-error provenance on upstream 0.15; retain the integrated craft-libs revision; estimates unchanged)
>
>
> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged)
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: vector-print appearance transforms retain small coefficients; estimates unchanged)
> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M2: scans keep their strokes at screen size and are smoothed when zoomed; M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged)
> **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged)
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged)
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (web: same-document host mode on the canvas, `data-host="page"`; estimates unchanged)
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M10: vector printing resolves annotation rectangle coordinates; estimates unchanged)
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M10: vector printing resolves annotation rectangles and appearance-state names; estimates unchanged)
> **Last reviewed:** 2026-10-26 | **Last updated:** 2026-10-26 | **Change:** minor (M10: vector printing follows bounded annotation-value reference chains; estimates unchanged)
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged)
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged)
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged)
@@ -179,27 +219,28 @@ What's strong and missing in each: [target-app-parity.md](docs/target-app-parity
## Languages
Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese, Brazilian
Portuguese and Spanish vs the current union). Detail and script notes: [localization-parity.md](docs/localization-parity.md).
Twelve key languages: the share of the 2,897 strings in the i18n report's denominator (the interface's strings plus every catalog key), from
`cargo xtask i18n-report` on 2026-11-03. Detail and script notes: [localization-parity.md](docs/localization-parity.md).
| Language | Code | Strings | Status |
|---|---|---|---|
| English | `en` | 100% | full |
| Simplified Chinese | `zh-hans` | 97.4% | partial (native LXGW WenKai Lite; no Hans face in the web build, #826) |
| Spanish | `es` | 100% of current union | partial (dialogs / tooltips; no help) |
| Hindi | `hi` | 0% | none |
| Arabic | `ar` | 100% | partial (logical catalog, explicit RTL labels, mirrored stock chrome; native/browser/IME workflows unqualified) |
| French | `fr` | 99.3% | partial |
| Portuguese (Brazil) | `pt-br` | 100% of current union | partial |
| Indonesian | `id` | 0% | none |
| Japanese | `ja` | 99.1% | partial |
| German | `de` | 99.3% | partial |
| Korean | `ko` | 0% | none |
| Vietnamese | `vi` | 100% of current union | partial (dialogs / tooltips; no help) |
| Simplified Chinese | `zh-hans` | 88.4% | partial (native LXGW WenKai Lite; no Hans face in the web build, #826) |
| Spanish | `es` | 98.4% | partial (dialogs / tooltips; no help) |
| Hindi | `hi` | 97.5% | partial (Devanagari rendering unqualified) |
| Arabic | `ar` | 97.9% | partial (logical catalog, explicit RTL labels, mirrored stock chrome; native/browser/IME workflows unqualified) |
| French | `fr` | 98.4% | partial |
| Portuguese (Brazil) | `pt-br` | 98.4% | partial |
| Indonesian | `id` | 97.5% | partial |
| Japanese | `ja` | 89.6% | partial |
| German | `de` | 89.7% | partial |
| Korean | `ko` | 97.5% | partial (no Hangul UI face) |
| Vietnamese | `vi` | 98.4% | partial (dialogs / tooltips; no help) |
Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian,
Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian, Romanian and Czech: **15 more catalogs**
(24 interface languages including English).
Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian, Finnish, Albanian, Catalan, Romanian,
Norwegian Bokmål, Persian, Central Kurdish, Thai, Lao and Czech: **23 more catalogs**
(35 interface languages including English).
## Upcoming
@@ -218,9 +259,55 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
## Progress log
- **2026-11-09 (M10, #5130):** Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Both Windows submission paths preserve DLL guards and current spooler fixes. Review follow-up validates both duplex scopes through craft-print 9fe50c94 and corrects CI formatting. Estimates unchanged.
- **2026-10-27 (M10, #5005):** Vector printing retains each annotation's optional-content membership when drawing its appearance.
Shared appearances and existing layer state remain independent; estimates unchanged.
- **2026-10-29 (M10, #5029):** Vector printing also honours the implicit NoRotate behavior of existing Text appearances when the flag is clear.
Print eligibility, source dictionaries and shared appearance streams remain unchanged.
- **2026-10-27 (M10, #5029):** Vector printing retains the orientation and upper-left anchor of existing NoRotate annotation appearances on rotated source pages.
Crop origins, UserUnit and later sheet transforms remain effective; estimates unchanged.
- **2026-10-27 (M10, annotation print precision):** Vector printing preserves small placement coefficients for existing form, comment and stamp appearances with large bounding boxes or matrices.
Synthetic render and save/reopen regressions cover the change; estimates unchanged.
- **2026-10-31 (M6, PR #4166/#4088 integration):** Integrated main f137a289 (0.16.1), preserving upstream progress entries and automatic-field Print suppression with its console diagnostic. ShowPanel/SaveAs filtering, explicit button/console printing, field effects and document-error provenance remain intact, together with all three engine regressions and the headless automation regression. Manifests, lockfile and CI match incoming main. Windows x64 / Rust 1.95 validation: 761 engine/automation tests passed, zero failed and four existing ignored; formatting, scoped all-target Clippy with -D warnings, layers/assets/parity and the engine/automation WebAssembly check passed. A fresh CLI build and the 13-step synthetic form exercise passed; the inspected render shows Qty=4 and Total=8, and explicit button/console Print requests are retained. Main's 100 headless parity notices, five renderer WebAssembly warnings and three optional egui patch notices remain. Estimates unchanged.
- **2026-10-29 (M6, PR #4166/#4088 integration):** Retained automatic-field Print suppression, ShowPanel/SaveAs filtering, explicit button/console printing and document-error provenance while integrating upstream 0.15. All three engine regressions and the headless regression remain in their current test modules, with matching parity evidence. Dependency manifests retain main's integrated craft-libs revision, which includes #366. Estimates unchanged.
- **2026-10-27 (M10, annotation print precision):** Vector printing preserves small placement coefficients for existing form, comment and stamp appearances with large bounding boxes or matrices.
Synthetic render and save/reopen regressions cover the change; estimates unchanged.
- **2026-10-13 (M8, redact by regex):** `redact_mark` takes `regex` (caller expression, linear-time engine, length/size/nesting caps, empty-matching refused) with optional `validate` (luhn, mod97, iban). Headless only; issue #1886.
- **2026-11-08 (M5, comment exchange):** Comments keep their formatting and appearance through XFDF and FDF. XFDF export and import carry rich text, default style, border width, style and dash pattern, rotation, justification, fringe, line leaders and captions, the caret symbol and redaction overlay text as XFDF 3.0 (ISO 19444-1) names them, and a stamp's appearance as `<appearance>` (the DICT/STREAM/DATA encoding, bounded); copy and paste carries every comment's appearance. FDF export keeps a stamp's /AP with its forms and images, as XFDF does, and FDF import keeps any /AP the file carries (ISO 32000-2 §12.7.8.3.4). The intent goes out as both `intent` and `IT`. An unreadable XFDF appearance is left out, said in the import report and the repair log, and the comment is drawn by PdfCraft; an FDF past the import budget is refused whole. A redaction mark is always drawn by PdfCraft.
- **2026-11-07 (M2, #6449):** A non-embedded Times New Roman set as a TrueType CID font (`TimesNewRoman,Bold`, `TimesNewRomanPSMT` and the other faces) draws the glyphs its codes name, as the non-embedded Arial does: its first 258 glyphs follow the same standard Macintosh order, which the embedded Times New Roman subsets of a public French form confirm in 941 of 942 `/ToUnicode` entries (vendored hayro-interpret patch 88). That form drew "Salarié(e) du régime général" as "Salari²*g+f w't²i lo g'ì²p²tcn" and "changement" as "ej angement". Times and Times New Roman MT Std are not read this way.
- **2026-11-05 (M2, #5889):** A non-embedded Arial set as a TrueType CID font (`CIDFontType2`, `Identity-H`, as Word and PDFMaker write it) draws the glyphs its codes name. Those codes are glyph indexes in the author's Arial, whose first 258 glyphs follow the standard Macintosh order, so the standard substitute now draws each glyph by name (vendored hayro-interpret patch 76). Before, it took each code as one of its own glyph indexes, and "Low Power" drew as "@ck 'Dck Yf". Arial Narrow, Black and Unicode MS, and `CIDFontType0` fonts, are not read this way. Copying or searching those runs still needs a `/ToUnicode`.
- **2026-11-07 (M7, #2964 / #3185):** Supported Form text can be prepared for direct editing; groups left grouped (unsafe compositing, text nested past eight levels) are reported. Legacy Thai keeps the original font programs, Regular/Bold and text settings, also across same-face subsets; the narrow THSarabunPSK map repair is checked glyph by glyph against the font program; cross-family replacement is explicit. Builds on #3067 and #2811. Unsafe groups and mixed contextual styles remain partial; estimates unchanged.
- **2026-10-28 (M10, #4119 partial):** Scan & OCR ▸ Enhance scanned file cleans scanned pages in place: background removal (level 0–100, as the camera clean-up), edge shadow removal (book gutters, copier borders) and despeckle. Each scan image is decoded, cleaned and written back into its own object at the same size and colour space (JPEG stays JPEG, others lossless, 1-bit stays 1-bit), so the OCR text layer, comments and links stay; one undo step, shared images once, unsupported images (CMYK, indexed, JPEG 2000, JBIG2, CCITT, broken data) reported. `page_enhance_scan` drives it headlessly. Not yet an option of Recognize text. Estimates unchanged.
- **2026-11-07 (M10, `ocr.ocr-web`, port onto #977):** Web OCR rebuilt on main's engines and preprocessing (#977). `OcrJob::run` is now `OcrJob::steps` a page at a time: the page renders, crops to the region and cleans each page, a Web Worker runs ocrs on the result, and `OcrShot::place` maps the words back through the region and preprocessing. Every strategy reads like this when only one engine is present, and the web build has only ocrs. The downloaded models are installed into the engine cache (`ocr::install`), so the Verify screen and the engine-status checks see them too. The batch keeps main's skipped-files bucket, low-confidence notes and cancel. The translations moved to craft-i18n (storytold/craft-i18n#141). The Verify screen starts the model download itself and runs once the models land; before, on a fresh page it failed with the desktop's "set PDFCRAFT_MODELS" message. Known limits: the cleaning steps, and the Verify screen's Run OCR and re-reads, run on the page. Estimates unchanged.
- **2026-11-03 (M11, shared JBIG2 dictionary):** With Monochrome Images set to JBIG2, Optimize has up to 16 JBIG2 images share one symbol dictionary (`/JBIG2Globals`), the letters found on more than one page stored once, when that is smaller (`pdfcraft_filters::encode_jbig2_shared`). Eight pages of 10 pt text rendered at 300 dpi take 41% less in Helvetica and 42% in Times than coded alone, 10% in Courier; groups of 16 save 45%, of 32 only 47%. Only images redaction can store again without the dictionary share one (image masks and DeviceGray named directly, at most 64 Mi pixels), and redacting one stores the rest of its group so, which the group bounds to 15.
- **2026-11-03 (M5, #5850 review):** Resolve indirect IT when recognizing typewriter contents and Fill & Sign marks. Comment listing and property lookup retain their existing direct-Subtype behavior. A real double-click Edit composer preserves the typewriter’s text as written; ordinary comment edits keep trimming. Regressions cover direct Subtype with indirect intent, save/reopen, raw posted contents and same-index comments on different pages. The existing live-movement test now commits its setup with an outside click rather than Enter. Estimates unchanged.
- **2026-11-03 (M5, #5850):** Fill & Sign Enter inserts a line break in a growing editor; clicking outside commits and Escape cancels. Nonempty text keeps leading, internal and trailing line breaks. The headless fill_sign_add path sizes its typewriter box for the same wrapped lines so saved appearances show every row. Production-order keyboard and engine save/reopen/render regressions exercise the UI and headless callers. Estimates unchanged.
- **2026-11-03 (community, M7, Thai word breaking):** Thai rows end between words, found with ICU4X's dictionary line segmenter (`icu_segmenter` 2.3, compiled dictionary data, no LSTM model; Unicode-3.0), instead of only at spaces when rewrapping and at any cluster in Add text. A row never starts with a following vowel, tone or other mark, Mai Yamok or Paiyannoi, nor ends with a leading vowel; a word wider than the row still breaks between clusters, and the spaces where an Add text row ends are dropped as in Latin text. Rewrapping existing text works out the breaks once per run of non-space characters. Rewriting a paragraph into Thai still needs a source font with the Thai glyphs (there is no Thai fallback face for it yet). The compiled dictionaries (Thai, Lao, Khmer, Burmese) add about 1.8 MB to a stripped release binary. Estimates unchanged.
- **2026-11-03 (M7, #5835):** In image crop mode, a right-click on the image, its crop box or its handles keeps the crop and opens no menu, so the menu no longer switches as the pointer moves. A right-click elsewhere clears the selection and opens the page menu. UI tests cover all four page rotations, overlapping images, a 45° image, and a check that the Done/Cancel/Remove Crop pills at a page edge still keep the crop.
- **2026-11-03 (M14 docs, #5805):** `docs/localization-parity.md` and the Languages table here count every catalog with `cargo xtask i18n-report`: the share of the 2,897 strings in its denominator, on `14ae839a`. The catalog-union measure it replaces had grown to 2,895 keys and left four catalogs at "100%" that were 44 keys short. Hindi, Indonesian and Korean are listed with their catalogs (97.5% each) instead of 0, and Thai, Lao and Central Kurdish join the table. The ≈ 68% localization score is not re-scored.
- **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer.
- **2026-11-02 (M3, #5708):** Pages shorter than the window sit in its middle, one page or row at a time and when scrolling continuously with every page in view.
- **2026-11-02 (M3, #5716):** A snapshot is drawn at 300 ppi, or at the zoom if that is finer, so one taken zoomed out stays readable; an area too large for one 4096-pixel tile comes out as fine as a tile allows (about 147 ppi for 2000 pt). A choice of resolution and a selection that stays active remain.
- **2026-11-02 (M14, #5737, #5460):** Preferences can turn off the update check at start. It stays on by default, and Help ▸ Check for updates still asks.
- **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged.
- **2026-11-01 (M14, #5572):** Preferences ▸ Documents and view sets how many recent files to keep, from 0 to 50 with 12 by default. At 0 Home has no Recent section and File ▸ Open Recent is disabled. The list is cut when Preferences closes, so the number can pass through 0 on its way to another.
@@ -232,7 +319,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- 2026-11-01: PR #2155 review follow-up preserves editor crops through group moves and keeps arranged artwork outside added-item streams; unsafe nested-tag extraction is refused. Image handles and flips follow page orientation, added images accept general transforms, and clipboard/text/Home/font-discovery edge cases have focused regressions. Candidate only; estimates unchanged.
- 2026-11-01: PR #2155 maintainer edits remove redundant image/text helpers and add regressions for crop ownership, arrange scope/state fidelity, clipboard precedence, crop-drag bounds, and font scanning. Transform and Unicode-script tools complete the headless paths.
- **2026-10-31 (M3/M4, #5330):** The document quick toolbar offers Crop pages directly through the existing page.crop command, with the command’s assembly-permission check and an active-tool highlight. The existing crop, undo and headless page_set_box paths are unchanged. No toolbar customization or new shortcut. Estimates unchanged.
- **2026-10-30 (M6):** Button scripts can read and change the normal push-button caption with `buttonGetCaption` and `buttonSetCaption`. The caption is drawn and retained through undo/redo and save/reopen; unsupported face indices are refused. Estimates unchanged.
- **2026-10-18 (M3, #1354, local candidate):** Integrated --two-paged with current main, retaining upstream modal/picker ownership and single-tab reuse. Native launch/opening/password regressions, negative checks against the old wrapper, visual inspection, formatting, strict scoped Clippy, layers, assets, parity and scoped WASM compilation pass. Three existing socket tests are unavailable in this workspace; one existing network test remains ignored. No push or GitHub merge; estimates unchanged.
- **2026-10-31 (M2, PR #4172 review):** Added same-machine pixel oracles for GBK Song, Hei, Kai and FangSong, including all four subset-prefixed Founder aliases. Reference PDFs use explicit Unicode mappings and independently selected installed faces; font data and rasters remain in memory. All eight cases ran; wrong-glyph and wrong-family production mutations each failed the new assertion and were restored. Validation: 422 renderer unit tests, renderer all-target Clippy with -D warnings, formatting, layers/assets/parity and renderer WASM checks passed; existing optional-patch/WASM warnings and parity notes remain. Estimates unchanged.
@@ -242,15 +331,28 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-27 (M2, PR #4172 module-split conflicts):** Integrated main 1dd2737b, retaining viewer-only CJK fallback in split session modules and Combine thumbnail workers, and moving renderer regressions to the new test module. Validation: 798 scoped tests passed (371 engine, 412 renderer, 15 Combine; two existing engine tests ignored); scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Estimates unchanged.
- **2026-10-27 (M10, `ocr.ocr-web`, rebase fix):** The web build loads OCR models only when their SHA-256 matches `ATTRIBUTION.toml`. With ONNX loading now on, rten's parser spins forever on some bytes that aren't a model (a corrupt or wrong download), so a bad file now fails at once instead of hanging a worker.
- **2026-10-27 (M3, #3425):** A file named with open parameters, as help systems start a PDF reader (`manual.pdf#nameddest=Intro`, `#page=12` or `#Intro`), opens at that destination instead of failing to read `manual.pdf#…`: at launch, when another launch hands it over, and in its tab when it is already open. Other parameters are ignored. The app and `link_open` now also apply a GoToR link's named destination, looked up in the target's /Names /Dests or catalog /Dests, instead of warning that it wasn't applied; the app looks names up off the UI thread. `doc_open` reports the destination of a path with open parameters. Estimates unchanged.
- **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence.
- **2026-10-26 (M2, PR #4172):** Reconciled current main, preserving its Japanese serif/weight selection and both branches' progress records. Installed CJK viewer and preview fallback remains display-only; estimates unchanged.
- **2026-10-29 (M7, #2517):** Widening a paragraph rejoins a wrap with the number of spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split. The count survives an incremental save and a full rewrite, and a width-only `text_edit` uses it. Estimates unchanged.
- **2026-10-29 (M2):** Scanned pages look like the paper again. An image that doesn't ask for /Interpolate was shrunk by nearest-neighbour (since #635), which kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes; it is now area-averaged to whole device pixels (on a 13-page 300 dpi scan every sample at 96–192 dpi is within 1 of the exact area average; it was up to 255 off). Enlarged, an image of page-scan size is smoothed bilinearly, so a zoomed scan has no staircases; icons, QR codes and pixel art keep hard pixels. Estimates unchanged.
- **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged.
- **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged.
- **2026-10-25 (M7, #2750):** Single-style Keep source fonts edits retain source glyphs and batch only missing glyphs in fallback fonts. Per-rewrite character eligibility and widths are cached; source and fallback edge spaces stay extractable. Estimates unchanged.
- **2026-10-25 (web, same-document host mode):** A page that loads the PdfCraft web module into its own document (no iframe) can opt into the host bridge with `data-host="page"` on the app's canvas — the exact value `page`; any other value keeps the normal app with downloads, unless a same-origin framed `?host=parent` selects the parent bridge (unframed or cross-origin, `?host=parent` also keeps the normal app). It is the `?host=parent` bridge with the transport swapped: the same five messages travel as non-bubbling `CustomEvent`s on the canvas (`detail` holds the parent messages' fields without `type`; the host answers with a `pdfcraft:open` event), so saves reach the host instead of downloading and dirty and start-up-failure reporting are unchanged. Unit tests cover the opt-in rule; no parity or estimate changes.
- **2026-10-25 (M10):** Vector printing keeps field and comment artwork whose rectangle coordinates are indirect, requiring exactly four finite resolved values.
- **2026-10-26 (M10):** Vector printing follows up to 64 indirect references for annotation rectangles, coordinates and appearance-state names, rejecting cycles and longer chains without changing source objects.
- **2026-10-25 (M10):** Vector printing keeps field and comment artwork whose rectangle coordinates or appearance-state names are indirect, requiring exactly four finite resolved rectangle values and a usable state name for state dictionaries.
Synthetic CLI and source regressions cover the behavior.
Estimates unchanged.
- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
- **2026-10-26 (M10, `ocr.ocr-web`, review fix):** On the web, Edit on a scanned page starts the OCR model download, and the page is read once the models load; it was taken for no scan and left unread until the document changed.
- **2026-10-24 (M10, `ocr.ocr-web`, review fix):** On the web, single-file and multiple-file OCR running at once no longer lose a page: the workers' answers go to the run that sent each page, instead of to whichever run collected first, and a cancelled run's late answers are let go. Regression tests cover both start orders and cancellation. Files chosen for Recognize Text in Multiple Files before the models have downloaded now wait for them instead of having to be chosen again, and idle workers close on time even when nothing is happening on the page, a full minute after their last page comes back rather than after it was handed out (checked by hand in Firefox); estimates unchanged.
- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.
- **2026-10-22 (M7, #2517):** A partial rewrite of the last paragraph closes its marker after that paragraph's last `ET`, so a row left in the source stays in the paragraph. When the next paragraph shares the text object, the marker closes before that neighbour instead of after the shared `ET`. The extra marker is not added across two marked-content regions. Estimates unchanged.
- **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. An explicit `TL` is the measured baseline gap. Without one, three or more rows at a repeated step stay one paragraph, and a single wider gap stays a break. Adding a row, even when an earlier row is left in the source, does not absorb the next paragraph on a later edit. Estimates unchanged.
@@ -261,6 +363,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-23 (M11, JBIG2):** The PDF Optimizer can store black-and-white images as lossless JBIG2 (a generic region, written from the JBIG2 draft standard and checked against its arithmetic-coder test sequence), about a third smaller than Group 4 on 300 dpi text, and Export ▸ Images reads JBIG2 images. JBIG2 symbol coding, where its largest gains on text are, is next.
- **2026-10-23 (M11, monochrome compression):** The PDF Optimizer and Reduce File Size store black-and-white images (scans, image masks) losslessly as CCITT Group 4 when that is smaller, with a Monochrome Images setting in the Optimizer and `doc_optimize` `mono`. JBIG2 and monochrome downsampling are next.
- **2026-10-23 (M6, calculation checks):** The calculation order now flags a formula that reads a field the form doesn't have (a field deleted from under a sum, a typo in notation, a script's `getField`) and a script that builds the names it reads at run time. Prepare a form ▸ Calculation order marks both on the row and in a card that names the broken formulas, in place of its all-clear. `form_calculation_order` gives each field's formula, and `form_fields` shows a custom Calculate script.
- **2026-10-23 (M4, #3923):** A recovered document's original path is only a suggestion: the first Save after recovering opens Save As at that path instead of writing it, since anyone who can write the recovery folder (a shared portable `PdfCraftData`, for example) can name any file in a sidecar. The file chosen is the document's file from then on. Sidecars are read through a 64 KiB cap; a longer one is not read, and its snapshot recovers without metadata. Estimates unchanged.
- **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged.
- **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported.
- **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged.
@@ -276,7 +379,15 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
The regression checks two distinct appearances, inherited resources, print/reopen pixels and unchanged source data.
Estimates unchanged.
- **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged.
- **2026-10-22 (M6, #4088):** Automatic AcroForm Keystroke, Validate, Calculate and Format scripts cannot open the print dialog; ignored requests are reported in the JavaScript console. Explicit button and console printing remain available, including when they trigger automatic calculations. Engine and headless automation regressions cover the boundary. Estimates unchanged.
- **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged.
- **2026-10-22 (M10, #3132):** Windows printing no longer swaps the letters of embedded TrueType subsets. A subset renumbers its glyphs (ReportLab's Arial space is glyph 11, the installed Arial's is 3), and the Windows print path drew those numbers with the installed font of the same name: `Revisión` printed as `Wj{nxn`, the bold `z` as `Ä`, on Brother queues and the Adobe PDF printer alike. The copy sent to the Windows spooler now names every embedded TrueType/OpenType program `PdfCraftPrint<n>`, in its `name` table and in the PDF; glyphs, encodings and `/ToUnicode` are untouched and a synthetic test renders the page identically before and after. Not yet checked on a physical Windows printer. Estimates unchanged.
- **2026-10-24 (M5, border shape):** Text boxes and Fill & Sign's typed text take an oval or a circle for their border (Comment Properties ▸ Border shape, Fill & Sign's toolbar): the text box keeps its place and the shape goes around it, the text centred, a circle snug around the text (its box no wider than the text needs); stored as /RD and /PCBorderShape and drawn in the appearance. comment_add / comment_edit / fill_sign_add `border_shape`. One new label in every catalog. Synthetic annot, UI and automation regressions. Estimates unchanged.
- **2026-10-24 (M5, Escape in Fill & Sign):** Escape leaves a Fill & Sign tool as it leaves a comment tool: the first press closes the open type box, the next goes back to Select, so the Text tool no longer opens a box at every click with no way out by the keyboard. A synthetic UI regression covers the Text and Checkmark tools. Estimates unchanged.
- **2026-10-23 (M5, ortho):** Shift while drawing holds a line or arrow level or plumb, whichever is nearer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before; dragging an end or corner with Shift is held the same way (it turned to 45° steps). `measure::snap::ortho`; `comment_add ortho`. Estimates unchanged.
- **2026-10-24 (M3, arrow keys on a wide page):** → / ← scroll a page wider than the window (a plan zoomed in) sideways a line at a time and turn the page at its edge, as ↓ / ↑ do in single-page view; ⌘→ / ⌘← turn it at once, and a page that fits across turns as before (#185). A synthetic viewer regression covers both. Estimates unchanged.
- **2026-10-24 (M5, part opacity):** Comment Properties sets a text box's text, border and fill, and a shape's line and fill, each at an opacity of its own (a see-through fill with an opaque line on a plan); stored as /CA, /ca (PDF 2.0) and /PCTextOpacity and drawn with separate graphics states, so other viewers show it. Make Properties Default and the settings carry it; comment_add / comment_edit `fill_opacity`, `text_opacity`. Four new labels in every catalog. Synthetic annot, UI and automation regressions. Estimates unchanged.
- **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen.
- **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged.
@@ -284,6 +395,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged.
- **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged.
- **2026-10-20 (M9 #79 review, M2, M8):** text extraction reads a marked-content /ActualText that marks no glyph where it paints (paths and images, cut to what is visible, along the direction they are drawn), so words drawn as outlines can be found, copied and exported; OCR and scan field detection still count such a page as having no text of its own. Redaction strips /ActualText, /Alt, /E and /T from inline marked content around paint it clips, as it already did around paint it removed, so a partly redacted outlined word leaves no name in the file. Named-layer recognition also needs the layer form to draw n1 and n3. Estimates unchanged.
- **2026-10-20 (M4, initial view):** Window and interface options read indirect boolean ViewerPreferences values. Synthetic regressions cover true/false, direct/indirect preference dictionaries and save/reopen; malformed entries retain false defaults. Estimates unchanged.
- **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged.
- **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged.
@@ -298,6 +410,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-20 (M11, #3507):** Image merging keeps trailing comments, split operands and graphics state across content streams. Synthetic tool tests check saved text, rendered regions and exact image rows.
- **2026-10-17 (M8, gap 6, #2662):** A filled cover over a long line counts when it hides half of any one word, not only 40% of the line's words or box, so a black box over one number in a long line — or an unapplied redaction mark over it — no longer audits clean while the number is still extractable; a sliver under half of every word it touches and under 40% of the line is still not flagged
- **2026-10-13 (M10, OCR review, batch and two engines):** Scan & OCR gains a review step, a second engine and Unicode output. Recognition can clean the raster first (opt-in auto-rotate, deskew, denoise, binarize; word boxes map back to the scan) and read with the built-in ocrs engine, an installed tesseract program run as a fenced external process (a validated location that fails closed instead of falling back to the PATH, allow-listed languages, a private temp directory, timeout and output caps), or both: Automatic asks tesseract when the ocrs reading is weak or of unknown confidence, and a ROVER merge keeps the more confident word (an unscored ocrs word yields only to a tesseract word of 70 or more). The OCR Verify screen (Scan & OCR ▸ Correct recognized text) reviews a recognition before it is applied: confidence-coloured word boxes, corrections, a per-language user dictionary (a plain-text file in the preferences folder, so a word added from any document stays on disk after it closes), Re-OCR of a page or a region, Accept/Reject per page or whole. Pages accepted one by one each apply once; a region re-read moves the review marks of the words it keeps, and a whole-page re-read clears that page's marks (undoable in the panel). Output is searchable (an invisible layer, one undo step) or a NEW editable document of visible text. Words beyond WinAnsi are drawn in Type3 faces from craft-fonts with a ToUnicode map in the editable output (characters no bundled face has appear as '?'); the searchable layer writes them as Type3 fonts with empty character programs and a ToUnicode map, so extraction reads them back with no fonts and no substitutions; redaction removes that layer like any other text (tests in pdfcraft-redact; the end-to-end engine test needs the craft-fonts build input and is skipped without it). Batch runs skip pages that already have text unless forced, and skip whole files that have text only with the opt-in skip-files flag (off by default; Force OCR overrides both); they refuse two inputs of the same file name and existing outputs before reading anything, and report low-confidence pages at the settings' threshold. Headless: ocr_recognize (engine, strategy, preprocessing, region; the editable output goes to an `out` that must not exist yet, and `out` is refused for a searchable run), ocr_words, ocr_review_apply, ocr_dictionary, ocr_status and ocr_recognize_files. All 21 interface catalogs carry the new strings; the Lithuanian ones were added without native review. Reading with every cleaning step off passes the render through untouched, as on main (the 300 dpi normalization runs only when a cleaning step is on: enlarging a 72 dpi render 4x on its own made the models read "due: €250" as "ue: ?250"). Still missing: the deep scan clean-up options (descreen, background and edge-shadow removal), scan images in the editable output, matching the scanned typeface, and recognition quality beyond the tested English set.
- **2026-10-13 (M10, #1757):** Office export keeps each dotted-leader contents entry in one paragraph, with its page number, instead of inferring table cells from its separate text objects.
- **2026-10-22 (M6, calculation scripts):** The Calculate tab and a rename read simplified field notation only from a comment, where Acrobat writes it: a string that spells `BVCALC … EVCALC` is no longer taken for the notation, or rewritten by a rename. A `/` after the condition of `if`, `while`, `for` or `with` starts a regular expression, so a rename no longer rewrites a name inside one, and a quote inside one no longer hides the `getField` calls after it. Both found in review of #3550. A `//` comment ends at `\r`, U+2028 or U+2029 as well as `\n`, so in a script with `\r` line endings the code after one is no longer hidden from a rename, or taken for the notation. Estimates unchanged.
- **2026-10-20 (M6, calculation order page):** Prepare a form ▸ Calculation order, a page of its own, lists the calculated fields in the order they calculate with their formulas and values, moved with Up/Down arrows (as Acrobat's Set Field Calculation Order), Alt+↑/↓ or by dragging. A card says what is wrong with the order and holds Fix order; a loop has a card of its own that opens one of its formulas. On the page, each calculated field shows its step, and arrows show what the field in focus reads and what reads it. Estimates unchanged.
@@ -308,6 +421,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-20 (M12, #3673):** Character encoding accepts Identity-H/V fonts with Adobe CJK collections and recognizes predefined non-Identity CMaps independently. Unknown CMaps and foreign registries remain refused; synthetic checker and read-only automation regressions passed after failing before the fix. Per-glyph mapping validation and embedded-font extraction remain separate. Estimates unchanged.
- **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged.
- **2026-10-19 (M6, text field table):** Prepare a form ▸ Text field table drags out an area and adds rows and columns of named text fields as one undoable edit, laid out on the page as displayed, with a live preview, per-column name patterns and widths, and refusal before anything is added when a cell would be too small or a name taken; `form_add_field_table` does the same headlessly. `form.multiple-copies` is partial: copying an existing field into a grid is still missing. Estimates unchanged.
- **2026-10-17 (research):** Added a [PDF workflow research reference](docs/research/pdf-workflows/README.md): dated public-source locators, 20 workflow research domains, a pinned feature mapping and interaction evidence. Reference documentation; no feature statuses, milestone priorities or estimates changed.
- **2026-10-16 (M7, #2516):** A paragraph number from `text_paragraphs` keeps naming the paragraph it listed after edits regroup the page: `text_edit`, `text_edit_layout` and `page_render`'s `hide_text_block` find it again by its text and box, and refuse one that was merged into a neighbour (the edited paragraph included) instead of rewriting the paragraph that took its number (`stale_paragraphs` lists the numbers gone). The inline editor's switch-paragraph click holds the same key. No stable identity across save and reopen yet (#2508).
- **2026-10-19 (M7, #3591):** A background added to a page that already has one is painted over the old one instead of going under it and staying hidden. Behind the content, backgrounds come first, in the order added, and then behind-content watermarks, so a watermark added after a background no longer disappears. Synthetic regressions in `crates/edit` check the order. Estimates unchanged.
@@ -319,9 +433,15 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-20 (M3, #3541/#3620):** Comment hover follows the individual marked lines instead of the enclosing multiline rectangle, and overlapping comments follow paint order. Synthetic UI regressions cover all four text-markup kinds, page/view rotations, neighboring highlights, legacy rectangle fallback and unchanged PDF bytes. Estimates unchanged.
- **2026-10-20 (M10, #3532 partial):** Vector printing retains existing widget artwork when its appearance lacks Subtype. A print-owned copy adds Form dispatch metadata. The source stream and all explicit subtypes stay unchanged. Synthetic source/output raster checks cover resources, selected/shared appearances, placement, flags and content modes. Appearance regeneration and physical printer validation remain outside this fix. Estimates unchanged.
- **2026-10-20 (M5, several comments):** Comment Properties opened on one of several selected comments, the context menu's Colour and the quick bar's colour change all of them as one undo step, each where the property applies (a font size to text boxes, a fill to shapes, a width to lines and shapes); one that stays locked keeps its look. Copied Fill & Sign ticks, crosses, dots and lines hang on the pointer as themselves rather than as empty boxes, and copied text boxes show their text (`FillMark::strokes` is shared by the appearance and the outline). Estimates unchanged.
- **2026-10-19 (M5, comments between documents):** Comments copy and paste, within and between documents: ⌘C with comments selected copies them as XFDF (each with its replies) to the app's and the system clipboard, ⌘V / Edit ▸ Paste comments / the page menu pastes them onto the current page as new comments under names of their own (`Edit::PasteComments`, `pdfcraft_xfdf::import_comments` with a target page and fresh names, so nothing is replaced and pasting twice gives two sets; `export_xfdf_comments` writes a selection with its replies). Several comments select at once (⌘-click, ⇧-click, on the page and in the panel); Delete removes them as one step and a drag moves them together. Connected lines, polygons and clouds finish from the right-click menu's Complete (Undo last point, Cancel drawing beside it), as other editors do. Tools `comments_copy` / `comments_paste`. Estimates unchanged.
- **2026-10-20 (M5, arrow keys move comments):** With a comment selected, the arrow keys move it a screen pixel and ⌘/Ctrl or Shift and an arrow ten, instead of scrolling the view or turning the page; up is up on screen at any zoom and rotation, and each press is one undo step. Text markup and locked comments stay put. Estimates unchanged.
- **2026-10-20 (M8, #3647):** Apply prunes fully removed tiling patterns, then checks retained pattern resources for text, images and unreadable content before reporting success. Shared patterns, Form-owned and inherited patterns, and cells kept by another page cause an atomic refusal when safe removal cannot be shown. Synthetic saved-file and automation regressions cover refusal, preserved marks and undo, and vector-only controls. Estimates unchanged.
- **2026-10-20 (M1, PR #1143 review):** Repair-aware dictionary/preflight edits and snapshot restoration preserve both accepted generations of a damaged live slot. Such edits use the existing full-save fallback; classic and packed rewrites coalesce aliases so page-tree parent links retain one object identity. Explicit generation replacement remains single-generation. Materialized xref diagnostics inspect retained primary and hybrid rows before folding, retaining the 256-object cap plus summary and discarding abandoned-index notes. Synthetic red/green controls cover preflight, undo restoration, equal values, explicit narrowing, encryption packing and four save variants. Estimates unchanged.
- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
- **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged.
@@ -366,6 +486,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged.
- **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged.
- **2026-10-19 (M7, tracked text):** Editable paragraphs and object labels distinguish repeated glyph tracking from word gaps in TJ arrays and separately positioned glyphs. Synthetic regressions cover character spacing, horizontal scaling, rotated baselines, larger word gaps, and rows of one-glyph words or cells (inferred tracking is capped at half an em, so `a b c` and digit or Y/N columns keep their spaces). Estimates unchanged.
- **2026-10-16 (M9, #79):** a signature appearance that follows the named-layer convention (its /N draws one Form that holds and draws n1 and n3) shows the signature's real state: n1, n3 and n4 are redrawn in the display copy with the panel's icons and a caption in the interface language (Helvetica; else shaped in embedded Inter, or in a craft-fonts face for Arabic, Japanese and Telugu; else English, as Chinese always is). Exports, both print modes and the CLI's render show the redrawn state; saves never do. Every signature keeps its badge, since a document can clip or cover its own layers. Estimates unchanged.
- **2026-10-16 (M9):** the page validation overlay moves to the widget's bottom-right corner as it lies on the page (so it turns with the page and the view) and takes a quarter of the widget's shorter side, at most 48 screen points, so it annotates the signer's artwork from a corner instead of covering the middle of it; a field too small for a legible mark keeps its outline, a rectangle the screen cannot place is not drawn at all, and the whole widget answers the pointer. Estimates unchanged.
- **2026-10-16 (M5, #2669, item 5):** The Comments panel groups replies by name and counts all page roots once per frame. Estimates unchanged.
- **2026-10-16 (M7, #2140):** Header & footer (and Bates numbering, which uses the same dialog) can shrink the page content so the header and footer don't cover it: the original content is scaled evenly into the band they leave free, centred across, also on rotated pages; Remove and Update take the scaling away, and margins that leave no room are refused. Dialog checkbox with a preview of the band, `shrink` on `doc_header_footer`, all catalogs. Annotations and added text and images are not moved yet. Estimates unchanged.
- **2026-10-20 (M14, Spanish catalog gaps):** `es.tsv` gains the 143 `(context, English)` keys the other catalogs carried and it was missing, so Spanish covers the whole bundled key union like Lithuanian and Vietnamese: calendar months and weekdays, the `certificate` context, X.509 key usages, signature-revision and digital-ID lines, password validation, eight plural messages, the combine-from-folder strings, the rendering and stale-file notices and the shortcut-spelled tooltips. Counts in `localization-parity.md` re-measured against the current union (`vi` restated against it for the same reason). Status stays `partial`: no translated help, and OS details stay outside the catalog. Estimates unchanged.
@@ -374,8 +496,11 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-16 (M11, #1201):** Optimize decodes a JPEG with its colour transform: an Adobe APP14 marker's flag, else the filter's `/ColorTransform` (ISO 32000-2 §7.4.8). An RGB JPEG stored untransformed (`/ColorTransform 0`, no marker) used to be converted from YCbCr anyway and came out in other colours, recompressed or merged from strips; it now keeps them. A JPEG whose marker and `/ColorTransform` disagree, or whose components are named R, G and B without `/ColorTransform 0`, is left as it is, since readers differ on it. Estimates unchanged.
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
- **2026-10-15 (M14, gap 16, #1683):** Split view. View ▸ Split (⌘\\ / Ctrl+\\) and a tab's Split right / Split down show two panes, each with its own tab strip, scroll and zoom; a tab dropped on the right half of the page area splits right, and the divider drags. One document in both panes is one document with one Undo; closing one of its views keeps it open. The pane last clicked is the one menus, keys and panels act on. Nothing about the panes is kept across restarts. `view.split-window` planned → shipped. Multiple windows stay planned. Estimates unchanged.
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `<occur>` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged.
- **2026-10-19 (M12, XFA, #2574):** The Acrobat observations behind the line above were first made on a public real-world form, which the Privacy rule does not allow; they are replaced by ones made on a synthetic form (three `breakBefore pageArea` sections, then a page area with `occur min="1" max="1"`, with and without `relevant="-print"`). Acrobat Pro shows the closing page last in both and prints it in both, blank only with `relevant="-print"`: confirmed. The synthetic form also shows a difference not fixed yet: Acrobat opens it as 5 pages, with an empty first body page before the first section, where PdfCraft lays out 4. Two more synthetic forms narrow it down: with `breakBefore targetType="pageArea"` on the first section, with or without `startNew`, Acrobat opens an empty body page first; without a break on the first section it lays out 4, as PdfCraft does (so `startNew` is not the cause). Not fixed yet: `layout.rs` skips a break while the page is still empty. Estimates unchanged.
- **2026-10-15 (M10, print a selected area, #2051):** The area Take a Snapshot copies stays outlined with a small menu under it, Print area… and Save as image… (PNG). Print area… opens Print with Selected area (page N, size) under Pages to Print: the area prints alone, as a page of that size, with Fit, Actual size, Shrink or a custom scale, clipped to the area; Multiple, Booklet, Poster and Label stay for pages. `doc_print` takes `area`. New parity entry `print.selected-area` (shipped). Estimates unchanged.
- **2026-10-15 (M7, find and replace in Edit text):** In Edit text mode the floating Find box gains a Replace with row (Edit ▸ Replace…, ⇧⌘H / Ctrl+Shift+H): Replace changes the highlighted match, Replace all every match as one undo step. Each match is rewritten inside its own paragraph through the paragraph rewrite, so fonts, sizes and colours stay and the paragraph rewraps; matches that span paragraphs, sit in OCR text, need another font or lie outside editable paragraphs are left alone and counted. `text_replace` drives the same plan. `edit.find-replace` planned → partial. Estimates unchanged.
- **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged.
- **2026-10-15 (M6, CSV tab-containing headers):** CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Synthetic export/import and incremental/full save regressions cover header tabs, quotes and line breaks, plus ordinary TSV controls. Formula-cell policy is unchanged. Estimates unchanged.
- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged.
@@ -384,7 +509,12 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-14 (M12, reading colours, #2059):** Pending page backgrounds use the document's reading colour immediately, preventing white flashes while scrolling to uncached pages. A headless canvas regression covers three pending pages in every preset and a custom background. Estimates unchanged.
- **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged.
- **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged.
- **2026-10-15 (M14, Combine files from File Explorer, #585):** The Windows MSI adds "Combine files in PdfCraft" to PDF files' right-click menu (with the Edit verb, same switch); each selected file starts `pdfcraft --combine`, and the single-instance hand-off (`"combine": true`, `OsEvent::Combine`) collects them into the running PdfCraft's Combine files tab. Tests: the request round trip and hand-off in `single_instance.rs`, the OS event in `tests/ui.rs`, the verb rows in `test-msi.ps1`. Estimates unchanged.
- **2026-10-15 (M14, MSI install folder, #1139):** The Windows installer's welcome dialog has an "Install to" row with a native folder browser, starting on the folder chosen for the per-user or per-machine scope (the recorded one on an update). `test-msi.ps1` checks the dialog wiring: both Install buttons set the folder before the dialog closes, and Browse's OK and Cancel. Estimates unchanged.
- **2026-10-15 (M8, #2448):** Applying redactions also removes the document information entries and the document's and pages' XMP metadata that repeat the removed text, so a redacted word can't survive in `/Title`, `/Keywords` or the XMP that mirrors them. Estimates unchanged.
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
- **2026-10-18 (M1, PR #1143 repair policy):** A live in-file reference resolves when its generation matches the xref row or a same-number header at that indexed offset. Neither-match and free slots remain null, including when old object bytes remain. Opening records bounded xref/header disagreements without parsing object bodies or expanding compact xref rows. Synthetic controls cover read order, clones, overlays, dictionary edits, full classic/packed saves and an independent reader of incremental output. **Correction to the 2026-10-15 note below:** its async-opening-test sentence was not evidenced by this COS change; no fresh async-opening or GUI validation is claimed. The full-reference stream-length recursion key is defensive hardening, not an independently isolated regression fix. Estimates unchanged.
- **2026-10-15 (M1, PR #1143 refresh):** COS reference lookups and full saves distinguish complete object references, so stale generations cannot expose replacement content. Snapshot restoration also keeps edits to objects with nonzero generations before incremental saves. Synthetic regressions cover caches, overlays, snapshots, stream lengths and classic/packed saves. Current upstream async-opening test corrections are included. Estimates unchanged.
- **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are unchanged.
- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged.
- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor).
@@ -452,6 +582,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732.
- **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off.
- **2026-10-14 (M10, #2001):** The Print dialog remembers two-sided, grayscale, collate, page sizing (with its custom percent) and Comments & forms across restarts, under the names `doc_print` uses; copies, pages, layout and orientation still start fresh for each job. The option names now live in one place (`pdfcraft_print::names`) for the tool and the settings file.
- **2026-10-14 (M2, #623):** A page whose content was cut at the renderer's limits no longer looks complete: a notice names it (and how many), `ui.state` lists the affected pages under `active.cut_pages`, and `text_extract` marks its text `truncated`, since the cut part's text can't be found or copied. The renderer already reported the cut; nothing showed it.
- **2026-10-14 (M5, comment pop-ups; reworked 2026-11-01):** A double-click on a comment opens its pop-up window on the page: a title bar with the comment's kind, author and date; its text and its replies' typed in place, the replies as panels each inside the one it answers, a Reply button under each; the window keeps its `/Rect` size on screen as the page zooms, scrolls its thread, drags by its title bar, resizes by its corner and closes with ×, each an undo step saved in the file's `/Popup` so other viewers agree. A faint wedge in the comment's colour joins the comment to its open window on roll-over or selection. `comment_popup` does the same headlessly and `comment_list` reports the pop-up.
- **2026-10-15 (M6, #1741):** Custom stamps can be dynamic: Create Custom Stamp adds an optional line under the picture, a template with {name}, {date} and {time} filled in when the stamp is placed (the built-in dynamic stamps' format), kept in the stamp library; `stamp_custom` takes the same `line`.
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
- **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged.
- **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged.
@@ -511,7 +645,12 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
Newest first: the date, what moved, and the new overall estimate.
- **2026-11-10 (M4, #4594):** Reconcile current main and keep the original draft-safety entry in this progress log instead of duplicating it in the shared roadmap header. Preserve inactive password cleanup and the Properties guard; behavior and estimates unchanged.
- **2026-11-09 (M4, #4594):** Reconcile upstream changes in the license ledger, roadmap and UI test registry; retain the vendored draft guard, local interaction replay and upstream split-view tests. Behavior and estimates unchanged; maintainer review remains pending.
- **2026-10-28 (M10, #1595):** Export to Markdown: `doc_export_office` with a `.md` or `.markdown` path writes CommonMark with GitHub pipe tables (headings, paragraphs with bold and italic, tables with their first row as the header, a rule between pages); text that Markdown would read as markup is escaped. Images, lists and links are not in the export's layout model and are left out. No menu item yet. Estimates unchanged.
- **2026-10-12 (M10, `ocr.ocr-web`):** OCR in the browser, partly. The web zip carries the text recognition models in `models/` beside `index.html`; the first time Recognize Text is needed the web build downloads them (about 12 MB, with a spinner, an error and a retry). Pages are rendered on the page and read in Web Workers (up to two at once, so the page keeps responding), built with WebAssembly SIMD: 8.5 s a page instead of 20.5 s in wasmtime, still about 17× native. Where workers can't start, the page reads the pages itself. No automated browser test yet. Estimates unchanged.
- **2026-10-12 (M10, `ocr.ocr-web`):** Web OCR shipped: reading scanned pages in Web Workers was checked by hand in a browser (4-page synthetic scan). Still no automated browser test, and a page reads about 17× slower than native.
- **2026-10-12 (M3, GNOME title bar, #553):** On GNOME's Wayland session, whose compositor draws no window frames (it offers no `zxdg_decoration_manager_v1`), PdfCraft no longer shows winit's fallback title bar: it turns the frame off and the tab strip becomes the header bar, as in GNOME's own apps. Minimize, maximize/restore and close are drawn in Adwaita's style, in the order and on the side GNOME's `button-layout` names (Fedora's default shows only close); the window's edges and corners resize it, and a hairline outlines it while it isn't maximized. Preferences ▸ Use system title bar (shown only there) gives the window back winit's frame at once, without a restart (part of #1001), and `PDFCRAFT_TITLEBAR=app|system` overrides the choice. KDE, Sway, Hyprland, COSMIC and X11 keep the frame their desktop draws. Not done: right-clicking the header bar doesn't open GNOME's window menu (egui can't ask for it), the window has no shadow or rounded corners, the resize edges lie just inside the window rather than outside it, and Windows still uses its system frame (the rest of #1001). Estimates unchanged.
- **2026-10-12 (M0, build label):** A local or patched build can carry a label, set at compile time with `PDFCRAFT_BUILD_LABEL`: `pdfcraft --version`, `pdfcraft-cli --version`, About and the log show `0.7.1 (titlebar)`, and `packaging/linux/package.sh` names the files `pdfcraft-0.7.1-titlebar-…` and makes the .rpm/.deb release `1.titlebar`, which upgrades over the release of the same version (and the next release over it). The version itself is unchanged, so the update check still compares it with published releases. Unlabelled builds are as before. Estimates unchanged.
- **2026-10-11 (M6, #1208):** Dropdown and list-box Field Properties expose the existing Left/Center/Right alignment picker. Existing alignment stays intact until changed; cancel, undo/redo and save/reopen are covered through the real UI without changing options, values or flags. Estimates unchanged.
@@ -739,17 +878,41 @@ Newest first: the date, what moved, and the new overall estimate.
- M13: common command, batch, inspection and bounded preview tools share the existing confined automation path. MCP tools carry complete hints and reject unknown top-level keys as invalid parameters.
- **2026-10-10 (UI docking):** Tools and Inspector can be regrouped, split beside the PDF, or floated inside the app window. Existing toggles reopen panels at their saved locations; UI commands arrange/reset panels, and preferences retain their layout. Read/full-screen modes preserve the focused document view. No milestone percentage change.
- **2026-10-25 (M4, #4594 review):** Documented the session bridge and replay adapter; the stale notice now participates in translation-gap scanning, with a regression test. The original proposal depended on a shared release exception. The revised proposal vendors the Rust source and keeps the Python replay local. Maintainer review is pending.
- **2026-10-25 (M4):** Document Properties drafts now reject changed document identities/revisions, including reload and Undo/Redo, through pinned edit-session; real UI JSONL scenarios replay with interaction-check. Broader dialog invalidation remains separate (#3917); estimates unchanged.
## Revision history
**2026-10-27 · minor:** M10 #5005: vector printing preserves annotation optional-content membership.
Source resources and shared appearances remain unchanged.
**2026-10-31 · patch:** M6/PR #4166 integrated main f137a289 (0.16.1), preserving both roadmap histories. Automatic Keystroke, Validate, Calculate and Format scripts still suppress Print alongside ShowPanel/SaveAs; explicit button and console Print requests, field effects and document-error provenance are retained. The three engine regressions and headless automation regression remain in their current modules. Manifests, lockfile and CI match incoming main. Windows x64 / Rust 1.95 validation: 761 engine/automation tests passed, zero failed and four existing ignored; formatting, scoped all-target Clippy with -D warnings, layers/assets/parity and the engine/automation WebAssembly check passed. A fresh CLI build and the 13-step synthetic form exercise passed; the inspected render shows Qty=4 and Total=8, and explicit button/console Print requests are retained. Main's 100 headless parity notices, five renderer WebAssembly warnings and three optional egui patch notices remain. Estimates unchanged.
**2026-10-29 · minor:** M6/PR #4166 refreshed against upstream 0.15. Automatic field scripts still suppress Print with a console diagnostic alongside ShowPanel/SaveAs filtering, and explicit button/console scripts retain their requests and document-error provenance. The three engine regressions, headless regression and parity evidence remain in the current modules. Dependency manifests retain main's integrated craft-libs revision, including #366. Estimates unchanged.
**2026-10-27 · minor:** M10: vector printing retains small appearance placement coefficients.
Synthetic rendering and save/reopen regressions cover the change.
Estimates unchanged.
**2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged.
**2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged.
| Date | Change | Summary |
|---|---|---|
| 2026-11-09 | patch | M10 #5130: Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Estimates unchanged. |
| 2026-11-08 | patch | M10 #6514/#636: retain main's public GDI Git revision (64e7afd4) to preserve downstream Rust type identity, update the internal PDF loader independently, and refresh the application dependency/lockfile. New-graph CI pending; estimates unchanged. |
| 2026-11-08 | patch | M10 #6514: refresh the craft-print and winprint/GDI pins after #605 merged; retain the Direct2D crash fix and stream-test cleanup repairs, record single-page HUAWEI output with its diagnostic limits, and rerun CI for the new dependency graph. Estimates unchanged. |
| 2026-11-07 | patch | M10 #6514: load Windows PDF print jobs on demand through a read-only file stream, avoiding StorageFile access denial, whole-file buffering and per-chunk async waits; approved dependencies retain the #5006 crash fix. Regression tests pass; physical output remains unqualified; estimates unchanged. |
| 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. |
| 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. |
| 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. |
| 2026-10-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and estimates unchanged. |
| 2026-10-28 | minor | M10: Recognize text widens each found line by fractions of its word height before reading it, so the first and last letters are no longer clipped at 300 dpi (word errors on synthetic 150–400 dpi pages 3.8 % → 0.3 %); word boxes keep their size. Estimates unchanged. |
| 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. |
| 2026-10-20 | minor | M9 #79 review, M2, M8: /ActualText that marks no glyph extracts where it paints, as far as it is visible; redaction strips /ActualText around paint it clips; named-layer recognition needs the layer form to draw n1 and n3. Estimates unchanged. |
| 2026-10-25 | minor | M10: vector printing resolves annotation rectangle coordinates; synthetic regression evidence, estimates unchanged. |
| 2026-10-26 | minor | M10: annotation rectangles, coordinates and appearance-state names follow up to 64 indirect references with cycle detection; standalone-object save/reopen and malformed-chain regressions, estimates unchanged. |
| 2026-10-25 | minor | M10: vector printing resolves annotation rectangles and appearance-state names; synthetic save/reopen and print-content-mode evidence, estimates unchanged. |
| 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. |
| 2026-10-23 | minor | M11: image downsampling measures physical resolution after page-local UserUnit scaling, including shared nested forms; synthetic measurement and optimization regressions. Estimates unchanged. |
| 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. |
@@ -761,8 +924,11 @@ Newest first: the date, what moved, and the new overall estimate.
| 2026-10-20 | minor | M6, #3502: form detection preserves comments across content streams; estimates unchanged. |
| 2026-10-20 | minor | M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged. |
| 2026-10-20 | minor | M11 #3507: image merging preserves comments and state across content streams. Estimates unchanged. |
| 2026-10-18 | minor | M1, #1143: a live reference may match the xref generation or a same-number header at its indexed offset; neither-match and free slots remain null. Bounded repair diagnostics and synthetic cache, free-slot, edit and classic/packed save regressions; estimates unchanged. |
| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
| 2026-10-16 | minor | M9 #79: named-layer signature appearances show the signature's real state in the display copy, exports and both print modes, never in saves; captions follow the interface language. Estimates unchanged. |
| 2026-10-16 | minor | M9: the page validation overlay moves to the widget's bottom-right corner as it lies on the page and stops growing past 48 screen points, so it annotates the signer's artwork from a corner instead of covering the middle of it; a field under 32 screen points keeps its outline. Estimates unchanged. |
| 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. |
| 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. |
| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. |
@@ -771,6 +937,7 @@ Newest first: the date, what moved, and the new overall estimate.
| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. |
| 2026-10-16 | minor | M14: Czech catalog completed (2,674 entries) and "tag" terminology unified. Estimates unchanged. |
| 2026-10-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged |
| 2026-10-15 | minor | M1: refresh object-generation reference identity, retaining current upstream progress and regression tests. Estimates unchanged. |
| 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. |
| 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. |
| 2026-10-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. Estimates unchanged. |
@@ -811,6 +978,7 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-21 (M4, #3865):** A new bookmark records where the reader is: the top-left of the selected text, or the top of the window, as `/XYZ left top null` (zoom kept), instead of the page top (`Edit::AddBookmark` takes `left` and `top`; `bookmark_add` takes `top` and `left`; `PageInfo::xyz_for_view`).
- **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged.
- **2026-10-22 (M3, #3530, builds on PR #3547):** A find step keeps its match in the middle of the window after zooming: a zoom pending in the same frame no longer puts the page-top jump off to the next frame, and the rest of an eased ⌘/Ctrl-wheel zoom anchors on the match instead of the pointer. Synthetic UI regressions step through matches on pages larger and smaller than the window at 10–3200% and in every page display. Estimates unchanged.
- **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged.
@@ -829,3 +997,5 @@ PR #2133 retains document-local Pages sidebar focus for Ctrl/Cmd+A and Escape af
PR #1952 retains per-widget checkbox/radio export-value editing in the split form-authoring and Prepare modules. Caption editing remains supported. Tests cover preserved appearances and selection, invalid/locked refusals, data-format round trips, and Apply/Cancel/Undo. Current-head hosted validation is pending; feature estimates are unchanged.
- **2026-10-31 (#1577):** Reconciled the page-shadow preference with split viewer, organizer, settings and control modules, preserving upstream page-gap shadow suppression. CI validation pending.
- 2026-11-04: AcroForm review follow-up covers field-instance retirement across history, rejected-writeback realm invalidation, mixed widget icons, shared calculation budgets and multi-select/checkbox consistency, frozen-global events, exact image imports, comparison-half scripts, focus reveal and explicit busy/timeout feedback. Synthetic regressions and elapsed UI deadlines accompany the fixes.
+13
View File
@@ -6,12 +6,25 @@
//! cross-compile from macOS or Linux still links, unless `PDFCRAFT_REQUIRE_WINRES=1` turns it
//! into an error (for release builds).
/// The in-box DLLs only printing uses (Windows.Data.Pdf draws the job, prntvpt reads the
/// driver's print capabilities). Trimmed Windows installations (NTLite) can lack them, and a
/// load-time import of a missing DLL stops the program before `main` (#2630). Delay-loaded, they
/// load on the first print; `pdfcraft_print::spool` checks they exist before calling in.
/// Same list in `apps/pdfcraft{,-cli}/build.rs`, `crates/print/build.rs` and `crates/print/src/spool.rs`.
const DELAY_LOADED: [&str; 2] = ["windows.data.pdf.dll", "prntvpt.dll"];
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-env-changed=PDFCRAFT_REQUIRE_WINRES");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
for dll in DELAY_LOADED {
println!("cargo:rustc-link-arg-bins=/DELAYLOAD:{dll}");
}
println!("cargo:rustc-link-arg-bins=delayimp.lib");
}
let mut res = winresource::WindowsResource::new();
res.set("ProductName", "PdfCraft")
.set("FileDescription", "PdfCraft command-line tool")
+29 -8
View File
@@ -43,7 +43,18 @@ pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> {
let path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?;
let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?;
let start = Instant::now();
let bytes = read(path)?;
// A file that can't be read (past the input cap, say) is this file's record, not a crash of the child.
let bytes = match read(path) {
Ok(bytes) => bytes,
Err(e) => {
let status = if e.contains("larger than") { "input-too-large" } else { "read-error" };
let line = serde_json::json!({ "file": path, "status": status, "pages": 0, "failed_pages": 0, "warnings": 0,
"ms": start.elapsed().as_millis() as u64, "detail": e.chars().take(400).collect::<String>() });
stdout_line(format_args!("{line}"))?;
// (the record is the answer `check` reads; the error and exit status are for a direct run)
return Err(e.into());
}
};
let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) {
Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0),
Ok(info) => {
@@ -65,7 +76,7 @@ pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> {
// `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net,
// so a panic in cos shows up as a crash of this process.
if args.iter().any(|a| a == "--edit") {
edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?);
edit_round_trip(&read(path)?);
}
let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings,
"ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::<String>() });
@@ -76,13 +87,19 @@ pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> {
/// Open with cos, touch every object, edit the catalog, save incrementally and in full (both
/// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs.
fn edit_round_trip(bytes: &[u8]) {
use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental};
use pdfcraft_cos::{DecodeBudget, Document, Object, SaveOptions, write_full, write_incremental};
let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return };
// One allowance for every stream, charged for what each decode did (failed ones too), so
// many small bombs can't each inflate to the per-stream limit.
let mut budget = DecodeBudget::page();
for num in doc.object_numbers().into_iter().take(20_000) {
if budget.exhausted() {
break;
}
if let Ok(o) = doc.try_get(num)
&& let Object::Stream(s) = &*o
{
let _ = s.decoded();
let _ = budget.decode(s);
}
}
if let Some(root) = doc.root() {
@@ -113,7 +130,7 @@ fn collect(paths: &[&str]) -> Vec<PathBuf> {
let identity = match p.canonicalize() {
Ok(identity) => identity,
Err(error) => {
eprintln!("{}: could not resolve directory: {error}", p.display());
eprintln!("{}: could not resolve directory: {error}", p.display().to_string().escape_debug());
continue;
}
};
@@ -158,12 +175,16 @@ pub(crate) fn check(args: &[String]) -> Result<(), CliError> {
let status = r["status"].as_str().unwrap_or("?").to_string();
*counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1;
if status != "ok" {
// One record per line: a file name or detail with a line break or a terminal control in
// it is written escaped, so it can't forge another record (the JSON report keeps both).
let detail: String = r["detail"].as_str().unwrap_or("").chars().take(160).collect();
eprintln!(
"[{}/{}] {status:<28} {} {}",
"[{}/{}] {:<28} {} {}",
n + 1,
files.len(),
f.display(),
r["detail"].as_str().unwrap_or("").chars().take(160).collect::<String>()
status.escape_debug().to_string(),
f.display().to_string().escape_debug(),
detail.escape_debug()
);
}
results.push(r);
+68 -20
View File
@@ -4,10 +4,24 @@ use super::options::{
};
use super::{Arc, CliError, ImageFormat, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Write, inspect, stdout_line};
/// Largest input file read into memory (512 MiB, as automation's `doc_open` reads).
pub(crate) const MAX_INPUT: u64 = 512 * 1024 * 1024;
/// The file at `path`, read no further than [`MAX_INPUT`]: a name that leads to something
/// endless (a device, a pipe nobody closes) fails at the cap instead of filling memory.
pub(crate) fn read(path: &str) -> Result<Arc<Vec<u8>>, String> {
std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}"))
use std::io::Read as _;
let mut bytes = Vec::new();
std::fs::File::open(path).and_then(|f| f.take(MAX_INPUT + 1).read_to_end(&mut bytes)).map_err(|e| format!("{path}: {e}"))?;
if bytes.len() as u64 > MAX_INPUT {
return Err(format!("{path}: the file is larger than 512 MiB"));
}
Ok(Arc::new(bytes))
}
/// `info FILE`: a JSON summary of the document as the app opens it (pages, fields, metadata, the
/// standards it declares, and the notes about damage repaired while opening it), with none of
/// its scripts run.
pub(crate) fn info(args: &[String]) -> Result<(), CliError> {
validate_options("info", args, INFO_OPTIONS)?;
let path = *positional(args, INFO_OPTIONS).first().ok_or("info: missing file")?;
@@ -18,24 +32,33 @@ pub(crate) fn info(args: &[String]) -> Result<(), CliError> {
let mut info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?;
let pdf_pages = info.pages.len();
let acroform_fields = info.fields.len();
// The document as the app opens it: the notes about damage repaired while opening, which the
// app shows in Document Properties, and for a dynamic XFA form the laid-out pages and fields.
// Inspection runs none of the document's scripts: an XFA form's initialize and calculate
// scripts, which the app runs on opening, are left alone, as with JavaScript turned off.
let mut session = pdfcraft_engine::Session::new();
session.set_javascript(false);
let opened = session.open("info.pdf", None, bytes.clone(), password).ok().and_then(|id| session.get(id));
// Dynamic XFA forms: say what laying the template out gives (what the app shows).
let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic))
.then(|| {
let mut s = pdfcraft_engine::Session::new();
let id = s.open("info.pdf", None, bytes.clone(), password).ok()?;
let d = s.get(id)?;
d.xfa.as_ref().map(|x| {
info = d.info.clone();
// The in-memory layout is an appended revision, not the file being inspected.
info.file_size = bytes.len();
serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })
})
let xfa_layout = opened.filter(|_| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)).and_then(|d| {
d.xfa.as_ref().map(|x| {
info = d.info.clone();
// The in-memory layout is an appended revision, not the file being inspected.
info.file_size = bytes.len();
serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })
})
.flatten();
// What the document declares as a standard, read the way the Standards panel reads it, so
// `info` and the app agree. A file that declares nothing leaves these null and empty.
let declared =
pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default();
});
// What the document declares as a standard, as the Standards panel reads it, and the notes
// Document Properties shows, which include what the app repaired after reading the objects (a
// page tree rebuilt from the pages found by scanning), both from the session's own reading. A
// file the app cannot open is read once more for what the object reader declares and repaired.
// A file that declares nothing leaves the standards null and empty.
let (declared, repairs) = match opened {
Some(d) => (d.standards(), d.repair_log()),
None => pdfcraft_cos::Document::open_with_password(bytes.clone(), password)
.map(|cos| (pdfcraft_preflight::declared(&cos), cos.repair_log().to_vec()))
.unwrap_or_default(),
};
let json = serde_json::json!({
"file": path,
"pdf_version": info.pdf_version,
@@ -56,6 +79,7 @@ pub(crate) fn info(args: &[String]) -> Result<(), CliError> {
"custom_properties": info.custom.len(),
"page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::<Vec<_>>(),
"warnings": info.warnings,
"repairs": repairs,
});
stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?;
Ok(())
@@ -366,13 +390,22 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> {
Some("pam") => None,
_ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()),
};
// The app renders a display copy that draws comments missing their /AP streams; the CLI
// must agree with it (and with independent viewers), while keeping the file it reads intact.
// The app renders a display copy that draws comments missing their /AP streams and the state
// of named-layer signatures (here with no trusted certificates); the CLI must agree with it
// (and with independent viewers), while keeping the file it reads intact.
let bytes = read(path)?;
let password = flag(args, "--password");
// The renderer takes only the user password of R2–R4 files; `Session::open` and the display
// copy take either.
let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
// A page image is an image taken out of the document, as a snapshot or Export is in the app:
// like `text`, it needs the document to allow copying (or the permissions password).
let mut session = pdfcraft_engine::Session::new();
if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) {
doc.check_copying().map_err(
|_| "render: the document's permissions don't allow copying its pages as images; pass the owner password with --password to render it",
)?;
}
let display = if inspect(bytes.clone(), render_password.as_deref()).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) {
let mut session = pdfcraft_engine::Session::new();
let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?;
@@ -401,12 +434,27 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> {
for warning in &p.warnings {
match warning {
pdfcraft_render::RenderWarning::ContentTruncated => {
let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped");
let _ = writeln!(
std::io::stderr().lock(),
"warning: page {page}: content past the page's safety budget, or a picture whose JBIG2 mask is past a decoder limit, was skipped"
);
}
pdfcraft_render::RenderWarning::ColourFallback => {
let _ =
writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour");
}
pdfcraft_render::RenderWarning::MalformedIndexedImage => {
let _ = writeln!(
std::io::stderr().lock(),
"warning: page {page}: an image with a malformed Indexed colour table may be drawn wrongly or not at all"
);
}
pdfcraft_render::RenderWarning::Type3WidthsMissing => {
let _ = writeln!(
std::io::stderr().lock(),
"warning: page {page}: a Type 3 font has no /Widths; its glyphs were spaced by the widths their glyph procedures give"
);
}
}
}
Ok(())
+72 -15
View File
@@ -50,18 +50,23 @@ pub(crate) fn ui(args: &[String]) -> Result<(), CliError> {
/// Most a control file may hold. The app writes under 100 bytes.
const CONTROL_FILE_MAX: u64 = 64 * 1024;
#[cfg(not(windows))]
const CONTROL_FILE_ADVICE: &str =
"Start the app with a control file in a folder only you can write, such as `pdfcraft --control ~/.pdfcraft-control.json`";
// cmd.exe doesn't expand `~` (#3922).
#[cfg(windows)]
const CONTROL_FILE_ADVICE: &str =
r"Start the app with a control file in a folder only you can write, such as `pdfcraft --control %USERPROFILE%\.pdfcraft-control.json`";
/// Read the file written by `pdfcraft --control FILE`.
///
/// It names the port that receives the token and every command, typed text included, so it must
/// be the user's own. In a shared folder such as `/tmp`, another local user can create the file
/// first; the app then can't write it, and the CLI would talk to the other user's listener.
/// Refused: a symbolic link, anything but a regular file, and on Unix a file owned by someone
/// else or open to other users (the app writes it with mode 600).
/// Refused: a symbolic link, anything but a regular file, and a file owned by someone else or
/// open to other users: on Unix by its owner and mode (the app writes it with mode 600), on
/// Windows by its owner and access list (the app gives it one of its user and SYSTEM, #3922).
fn read_control_file(file: &str) -> Result<serde_json::Value, String> {
use std::io::Read as _;
let at_path = std::fs::symlink_metadata(file).map_err(|e| format!("{file}: {e}"))?;
if at_path.file_type().is_symlink() {
return Err(format!("{file}: is a symbolic link, which could point at another user's file. {CONTROL_FILE_ADVICE}"));
@@ -69,21 +74,41 @@ fn read_control_file(file: &str) -> Result<serde_json::Value, String> {
if !at_path.is_file() {
return Err(format!("{file}: not a regular file. {CONTROL_FILE_ADVICE}"));
}
let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
// Checked on the open file, so nothing swapped in after `symlink_metadata` gets through.
let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?;
if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) {
return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}"));
#[cfg(not(windows))]
let text = {
use std::io::Read as _;
let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
// Checked on the open file, so nothing swapped in after `symlink_metadata` gets through.
let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?;
if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) {
return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}"));
}
if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) {
return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}"));
}
}
if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) {
let mut text = String::new();
f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?;
text
};
// Windows: the same owner and access check, on the security descriptor of the very handle the
// contents are read through, so nothing swapped in at the path gets through (#3922). A file
// planted by another user, or one other users can read, is refused; if the check itself can't
// run, the file isn't trusted either.
#[cfg(windows)]
let text = {
use pdfcraft_platform::owner_only;
let me = owner_only::current_user_sid().map_err(|e| format!("{file}: can't check who owns it: {e}. {CONTROL_FILE_ADVICE}"))?;
let (sddl, bytes) = owner_only::read_with_sddl(std::path::Path::new(file), CONTROL_FILE_MAX)
.map_err(|e| format!("{file}: can't check who owns it: {e}. {CONTROL_FILE_ADVICE}"))?;
if let Some(problem) = owner_only::foreign_access(&sddl, &me) {
return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}"));
}
}
let mut text = String::new();
f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?;
String::from_utf8(bytes).map_err(|e| format!("{file}: {e}"))?
};
if u64::try_from(text.len()).unwrap_or(u64::MAX) > CONTROL_FILE_MAX {
return Err(format!("{file}: too large for a control file (over {} KiB)", CONTROL_FILE_MAX / 1024));
}
@@ -112,6 +137,38 @@ fn current_uid() -> u32 {
mod tests {
use super::control_file_problem;
/// #3922: on Windows the CLI checks the control file's owner and access list. A file other
/// users can read is refused; the same file restricted to its owner is accepted.
#[cfg(windows)]
#[test]
fn on_windows_a_control_file_other_users_can_read_is_refused() {
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-control-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let users = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap();
assert!(users.status.success());
let file = dir.join("ctl.json");
std::fs::write(&file, r#"{"port":4242,"token":"t0ken"}"#).unwrap();
let path = file.to_string_lossy().into_owned();
let refused = super::read_control_file(&path).unwrap_err();
assert!(refused.contains("another account") && refused.contains("%USERPROFILE%"), "{refused}");
pdfcraft_platform::owner_only::restrict_to_owner(&file, &pdfcraft_platform::owner_only::current_user_sid().unwrap()).unwrap();
assert_eq!(super::read_control_file(&path).unwrap()["token"].as_str(), Some("t0ken"));
// The contents come with the check, capped: exactly the limit is read, one byte more is refused.
let padded = |len: usize| {
let json = r#"{"port":4242,"token":"t0ken"}"#;
json.to_string() + &" ".repeat(len - json.len())
};
std::fs::write(&file, padded(64 * 1024)).unwrap();
assert_eq!(super::read_control_file(&path).unwrap()["port"].as_u64(), Some(4242));
std::fs::write(&file, padded(64 * 1024 + 1)).unwrap();
assert!(super::read_control_file(&path).unwrap_err().contains("too large"));
// Valid JSON once the byte is replaced by U+FFFD, so only a strict UTF-8 read refuses it.
std::fs::write(&file, b"{\"port\":4242,\"token\":\"\xFF\"}").unwrap();
assert!(super::read_control_file(&path).is_err(), "not UTF-8");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_control_file_must_be_yours_and_private() {
// Regular file type bits (0o100000) don't matter, only the owner and the permission bits.
+111
View File
@@ -0,0 +1,111 @@
//! The exit codes the CLI has today: `ExitCode::SUCCESS` (0) or `ExitCode::FAILURE` (1).
//!
//! Pinned here so that the exit-code contract is a visible, reviewed change when it comes: a
//! failed file, an outside-root path and a usage error all exit 1 today.
use serde_json::Value;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
/// A fresh folder for one test, so the tests can run in parallel and leave nothing behind.
fn workdir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-exit-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// `batch` with `args`, run from the working directory `dir`.
fn batch(dir: &Path, args: &[&str]) -> Output {
Command::new(BIN).current_dir(dir).arg("batch").args(args).output().unwrap()
}
/// `run --script --report` from the working directory `dir`: one JSON document for the run.
fn run_report(dir: &Path, script: &Path) -> Output {
Command::new(BIN).current_dir(dir).args(["run", "--script"]).arg(script).arg("--report").output().unwrap()
}
/// The one JSON document a command prints on stdout.
fn reported(output: &Output) -> Value {
serde_json::from_slice(&output.stdout).unwrap()
}
#[test]
fn batch_exits_0_when_every_file_succeeds() {
let dir = workdir("batch-ok");
std::fs::write(dir.join("page.txt"), "Exit code page").unwrap();
let output = batch(&dir, &["page.txt", "--steps", "convert", "--out-dir", "out"]);
assert_eq!(output.status.code(), Some(0), "{}", String::from_utf8_lossy(&output.stderr));
let report = reported(&output);
assert_eq!(report["completed"], 1, "{report}");
assert_eq!(report["failed"], 0, "{report}");
assert!(dir.join("out/page.pdf").exists(), "the converted page is written");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn batch_exits_1_when_one_file_fails() {
let dir = workdir("batch-fail");
std::fs::write(dir.join("bad.pdf"), "broken").unwrap();
let output = batch(&dir, &["bad.pdf", "--steps", "convert", "--out-dir", "out"]);
assert_eq!(output.status.code(), Some(1), "{}", String::from_utf8_lossy(&output.stderr));
let report = reported(&output);
assert_eq!(report["failed"], 1, "{report}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn batch_usage_error_exits_1_and_creates_nothing() {
let dir = workdir("batch-usage");
std::fs::write(dir.join("page.txt"), "Exit code page").unwrap();
// Baseline: a usage error exits 1 today. A later change to the exit-code contract will
// update this assertion on purpose.
let output = batch(&dir, &["page.txt", "--out-dir", "out"]);
assert_eq!(output.status.code(), Some(1), "{}", String::from_utf8_lossy(&output.stderr));
assert!(output.stdout.is_empty(), "a usage error prints to stderr, not a report on stdout");
assert!(!output.stderr.is_empty(), "a usage error says what is wrong");
assert!(!dir.join("out").exists(), "a refused batch creates no output");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn batch_outside_root_exits_1_and_creates_nothing() {
let dir = workdir("batch-outside-root");
let output = Command::new(BIN)
.current_dir(&dir)
.args(["batch", "../outside.pdf", "--steps", "convert", "--out-dir", "out", "--root"])
.arg(&dir)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1), "{}", String::from_utf8_lossy(&output.stderr));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("is outside the allowed directory"), "the input is refused by the root: {stderr}");
assert!(output.stdout.is_empty(), "a refused batch prints no report");
assert!(!dir.join("out").exists(), "a refused batch creates no output");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn run_script_report_exits_0_when_ok_and_1_when_a_step_fails() {
let dir = workdir("run-report");
let script = dir.join("steps.json");
let blank = serde_json::json!({ "tool": "doc_create", "args": { "from": "blank", "width": 100, "height": 100 } });
let steps = serde_json::json!([blank.clone(), { "tool": "no_such_tool", "args": {} }]);
std::fs::write(&script, steps.to_string()).unwrap();
let failing = run_report(&dir, &script);
assert_eq!(failing.status.code(), Some(1), "{}", String::from_utf8_lossy(&failing.stderr));
let report = reported(&failing);
assert_eq!(report["ok"], false, "{report}");
assert_eq!(report["steps"][1]["status"], "error", "{report}");
std::fs::write(&script, serde_json::json!([blank]).to_string()).unwrap();
let good = run_report(&dir, &script);
assert_eq!(good.status.code(), Some(0), "{}", String::from_utf8_lossy(&good.stderr));
let report = reported(&good);
assert_eq!(report["ok"], true, "{report}");
assert_eq!(report["steps"][0]["status"], "ok", "{report}");
std::fs::remove_dir_all(&dir).unwrap();
}
+15
View File
@@ -129,3 +129,18 @@ fn a_bad_timeout_or_dpi_is_refused_before_scanning() {
);
}
}
/// A failing file's name goes to stderr escaped: a line break in it can't start a second,
/// forged record. The JSON report keeps the name as it is.
#[cfg(unix)]
#[test]
fn a_file_name_cannot_forge_a_stderr_record() {
let dir = Workdir::new("escaped-name");
let name = "bad\n[9] ok forged.pdf";
std::fs::write(dir.0.join("input").join(name), b"not a pdf").unwrap();
let out = Command::new(BIN).arg("check").arg(dir.0.join("input")).args(["--dpi", "36", "--timeout", "5"]).output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
let records: Vec<&str> = stderr.lines().filter(|l| l.starts_with('[')).collect();
assert_eq!(records.len(), 1, "{stderr}");
assert!(records[0].contains("bad\\n[9] ok forged.pdf"), "{stderr}");
}
@@ -0,0 +1,75 @@
//! `info` lists the notes about damage repaired while opening, as the app shows them in Document
//! Properties and as `doc_info` reports them in `repairs`: a rebuilt cross-reference index, and a
//! page tree rebuilt from the pages found by scanning (a note the app adds after reading).
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
/// A PDF with `pages` pages, each drawing a rectangle (a page found by scanning has content), and
/// `catalog` as object 1; `startxref` points at its cross-reference table, or at `bad_startxref`.
fn fixture(catalog: &str, pages: usize, bad_startxref: Option<usize>) -> Vec<u8> {
let content = "0 0 0 rg 10 10 40 20 re f";
let kids: String = (0..pages).map(|i| format!("{} 0 R ", 4 + i)).collect();
let mut objs = vec![
catalog.to_string(),
format!("<< /Type /Pages /Kids [{kids}] /Count {pages} >>"),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
];
objs.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 60] /Contents 3 0 R >>".to_string()));
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for off in &offsets {
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
let start = bad_startxref.unwrap_or(xref);
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
const CATALOG: &str = "<< /Type /Catalog /Pages 2 0 R >>";
fn info_of(bytes: &[u8], name: &str) -> serde_json::Value {
let dir = std::env::temp_dir().join(format!("pdfcraft-info-repairs-{}-{name}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("f.pdf");
std::fs::write(&path, bytes).unwrap();
let out = Command::new(BIN).args(["info", path.to_str().unwrap()]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let json = serde_json::from_slice(&out.stdout).expect("info prints JSON");
let _ = std::fs::remove_dir_all(&dir);
json
}
fn repairs(json: &serde_json::Value) -> Vec<String> {
json["repairs"].as_array().unwrap_or_else(|| panic!("no repairs array: {json}")).iter().map(|l| l.as_str().unwrap().to_string()).collect()
}
#[test]
fn info_lists_a_rebuilt_cross_reference_index() {
// `startxref` points into the middle of an object, so the index is rebuilt from the headers.
let json = info_of(&fixture(CATALOG, 1, Some(20)), "xref");
assert_eq!(json["pages"], serde_json::json!(1), "{json}");
let lines = repairs(&json);
assert!(lines.iter().any(|l| l.contains("rebuilt cross-reference index")), "{lines:?}");
}
#[test]
fn info_lists_a_page_tree_the_app_rebuilt_by_scanning() {
// The catalog names a page tree object the file does not have (#1656).
let json = info_of(&fixture("<< /Type /Catalog /Pages 9 0 R >>", 2, None), "pagetree");
let lines = repairs(&json);
assert!(lines.iter().any(|l| l.contains("page tree is missing")), "{lines:?}");
}
#[test]
fn info_lists_no_repairs_for_a_sound_file() {
let json = info_of(&fixture(CATALOG, 1, None), "sound");
assert_eq!(json["repairs"], serde_json::json!([]), "{json}");
}
+2
View File
@@ -3,10 +3,12 @@
mod audit;
mod automation_roots;
mod batch;
mod batch_exit;
mod check;
mod edit;
mod extract;
mod help;
mod info_repairs;
mod info_standards;
mod mcp_compact;
mod options;
+11
View File
@@ -113,3 +113,14 @@ fn an_argument_that_is_not_utf8_is_an_error_not_a_panic() {
assert!(!stderr.contains("panicked"), "{stderr}");
}
}
/// A file `check-one` cannot read is a record on stdout with its own status and the reason, which
/// `check` relays, not an empty stdout that reads as a crash.
#[test]
fn check_one_reports_an_unreadable_file_as_a_record() {
let out = Command::new(BIN).args(["check-one", "missing.pdf"]).output().expect("run cli");
let stdout = String::from_utf8_lossy(&out.stdout);
let record: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{e}: {stdout:?}"));
assert_eq!(record["status"], "read-error");
assert!(record["detail"].as_str().is_some_and(|d| d.contains("missing.pdf")), "{record}");
}
@@ -130,6 +130,12 @@ fn render_accepts_the_owner_password() {
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
if pw == "user" {
// The user password opens it, but a page image is a copy the permissions forbid.
assert!(!out.status.success() && stderr.contains("don't allow copying"), "{alg:?} with {pw:?}: {stderr}");
assert!(!png.exists(), "{alg:?}: no image is written");
continue;
}
assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}");
assert_rendered_page(&std::fs::read(&png).unwrap(), DPI, &format!("{alg:?} with {pw:?}"));
}
+18
View File
@@ -86,6 +86,24 @@ fn write_private(path: &Path, text: &str) {
opts.mode(0o600);
}
opts.open(path).unwrap().write_all(text.as_bytes()).unwrap();
// On Windows the file inherits its folder's access list, as the app's would before it
// restricts it (#3922).
#[cfg(windows)]
{
use pdfcraft_platform::owner_only;
owner_only::restrict_to_owner(path, &owner_only::current_user_sid().unwrap()).unwrap();
// The file the CLI is handed must already pass its check, or a refusal below would be
// the helper's fault, not the CLI's.
let sddl = owner_only::sddl_of(path).unwrap();
let listing = |p: &Path| Command::new("icacls").arg(p).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned());
let folder = path.parent().map(listing);
assert_eq!(
owner_only::foreign_access(&sddl, &owner_only::current_user_sid().unwrap()),
None,
"restricted file: {sddl}; icacls: {:?}; folder: {folder:?}",
listing(path)
);
}
}
fn ui(control: &Path, method: &str) -> Output {
+41 -2
View File
@@ -7,6 +7,11 @@ use std::sync::Arc;
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
fn fixture(test: &str) -> PathBuf {
fixture_with(test, "")
}
/// The two-page form, with `event` inside its `name` field.
fn fixture_with(test: &str, event: &str) -> PathBuf {
let xdp = r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
@@ -15,11 +20,12 @@ fn fixture(test: &str) -> PathBuf {
<medium short="200pt" long="300pt"/>
</pageArea></pageSet>
<draw w="160pt" h="20pt"><value><text>First XFA page</text></value></draw>
<field name="name" w="160pt" h="20pt"><ui><textEdit/></ui><value><text>Ada</text></value></field>
<field name="name" w="160pt" h="20pt"><ui><textEdit/></ui><value><text>Ada</text></value>{event}</field>
<subform name="last" layout="tb"><breakBefore targetType="pageArea"/>
<draw w="160pt" h="20pt"><value><text>Second XFA page</text></value></draw>
</subform>
</subform></template></xdp:xdp>"#;
</subform></template></xdp:xdp>"#
.replace("{event}", event);
let objects = [
"<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
@@ -67,6 +73,21 @@ fn info_reports_the_laid_out_pages_and_fields() {
assert_eq!(std::fs::read(path).unwrap(), original, "inspection must not write the layout to the input");
}
/// `info` inspects an untrusted file: a form's initialize script, which the app runs on opening
/// and which here would change the field's value, does not run.
#[test]
fn info_runs_none_of_the_forms_scripts() {
let script = r#"<event activity="initialize"><script contentType="application/x-javascript">this.rawValue = "Changed";</script></event>"#;
let path = fixture_with("info-script", script);
let output = Command::new(BIN).arg("info").arg(&path).output().unwrap();
success(&output);
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(info["xfa_layout"]["pages"], 2, "the form is still laid out: {info}");
let warnings = info["warnings"].as_array().unwrap();
assert!(!warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("scripts changed it on opening"))), "{warnings:?}");
assert_eq!(info["repairs"], serde_json::json!([]), "{info}");
}
#[test]
fn text_reads_the_layout_including_a_page_absent_from_the_placeholder() {
let path = fixture("text");
@@ -134,3 +155,21 @@ fn a_laid_out_form_keeps_the_original_copy_permissions() {
assert_eq!(info["file_size"], original.len());
assert_eq!(std::fs::read(path).unwrap(), original);
}
/// A damaged dynamic XFA form: `info` lists the cross-reference index rebuilt while opening it,
/// as Document Properties does after the form is laid out as one more revision.
#[test]
fn info_lists_the_repairs_of_a_laid_out_form() {
let path = fixture("info-damaged");
let bytes = std::fs::read(&path).unwrap();
let at = bytes.windows(10).rposition(|w| w == b"startxref\n").unwrap() + 10;
let mut damaged = bytes[..at].to_vec();
damaged.extend_from_slice(b"20\n%%EOF\n");
std::fs::write(&path, &damaged).unwrap();
let output = Command::new(BIN).arg("info").arg(&path).output().unwrap();
success(&output);
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(info["xfa_layout"]["pages"], 2, "the form is still laid out: {info}");
let repairs = info["repairs"].as_array().unwrap();
assert!(repairs.iter().any(|l| l.as_str().is_some_and(|l| l.contains("rebuilt cross-reference index"))), "{repairs:?}");
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "pdfcraft-ocr-worker"
description = "PdfCraft in the browser: the Web Worker that recognises text (wasm32; trunk builds it with pdfcraft-web)"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[lints]
workspace = true
[dependencies]
pdfcraft-ocr = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["DedicatedWorkerGlobalScope", "MessageEvent"] }
+101
View File
@@ -0,0 +1,101 @@
//! PdfCraft in the browser: the Web Worker that recognises text, so reading a scanned page doesn't
//! stop the page responding and several pages are read at once. Trunk builds it alongside
//! `pdfcraft-web` (see apps/pdfcraft-web/index.html); the page starts and feeds the workers
//! (crates/ui-egui/src/workflows/ocr_ui.rs).
//!
//! Messages are plain objects with a `type`:
//!
//! - `ready` (to the page): the worker is listening; send the models now.
//! - `models` (from the page): `{ detection, recognition }`, each a `Uint8Array` holding a model
//! file. Answered with `loaded`, or `failed` with an `error`.
//! - `page` (from the page): `{ id, width, height, pixels }`, the RGBA pixels of a rendered page
//! as a `Uint8Array`. Answered with `lines` `{ id, lines }`, the recognised lines as
//! `pdfcraft_ocr::lines_to_json` writes them, or `failed` `{ id, error }`.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
fn main() {
use wasm_bindgen::{JsCast, closure::Closure};
let Ok(scope) = js_sys::global().dyn_into::<web_sys::DedicatedWorkerGlobalScope>() else {
return;
};
let reply_to = scope.clone();
let mut ocr = None;
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |e: web_sys::MessageEvent| {
let _ = reply_to.post_message(&worker::handle(&mut ocr, &e.data()));
});
scope.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
// The handler lives as long as the worker.
on_message.forget();
let _ = scope.post_message(&worker::message(&[("type", "ready".into())]));
}
#[cfg(not(target_arch = "wasm32"))]
fn main() {
eprintln!("pdfcraft-ocr-worker runs in a browser: trunk builds it with pdfcraft-web");
}
#[cfg(target_arch = "wasm32")]
mod worker {
use pdfcraft_ocr::Ocr;
use wasm_bindgen::{JsCast, JsValue};
/// The models are about 12 MB together.
const MAX_MODEL_BYTES: u32 = 64 << 20;
/// The renderer keeps pages far smaller; anything larger isn't a page.
const MAX_PIXEL_BYTES: u64 = 1 << 30;
pub fn message(fields: &[(&str, JsValue)]) -> JsValue {
let o = js_sys::Object::new();
for (k, v) in fields {
let _ = js_sys::Reflect::set(&o, &(*k).into(), v);
}
o.into()
}
/// Answer one message from the page.
pub fn handle(ocr: &mut Option<Ocr>, data: &JsValue) -> JsValue {
let get = |k: &str| js_sys::Reflect::get(data, &k.into()).unwrap_or(JsValue::UNDEFINED);
let id = get("id");
let reply = match get("type").as_string().as_deref() {
Some("models") => load(&get("detection"), &get("recognition")).map(|o| {
*ocr = Some(o);
message(&[("type", "loaded".into())])
}),
Some("page") => match ocr.as_ref() {
None => Err("the text recognition models aren't loaded".into()),
Some(o) => recognize(o, &get("width"), &get("height"), &get("pixels"))
.map(|lines| message(&[("type", "lines".into()), ("id", id.clone()), ("lines", lines.into())])),
},
_ => Err("unknown message".into()),
};
reply.unwrap_or_else(|error| message(&[("type", "failed".into()), ("id", id), ("error", error.into())]))
}
fn bytes(v: &JsValue, max: u64) -> Result<Vec<u8>, String> {
let a = v.dyn_ref::<js_sys::Uint8Array>().ok_or("expected a Uint8Array")?;
if u64::from(a.length()) > max {
return Err(format!("{} bytes is too large", a.length()));
}
Ok(a.to_vec())
}
fn load(detection: &JsValue, recognition: &JsValue) -> Result<Ocr, String> {
let max = u64::from(MAX_MODEL_BYTES);
Ocr::from_bytes(bytes(detection, max)?, bytes(recognition, max)?).map_err(|e| e.to_string())
}
fn recognize(ocr: &Ocr, width: &JsValue, height: &JsValue, pixels: &JsValue) -> Result<String, String> {
let side = |v: &JsValue| v.as_f64().filter(|v| v.fract() == 0.0 && (1.0..=f64::from(u32::MAX)).contains(v)).map(|v| v as u32);
let (Some(w), Some(h)) = (side(width), side(height)) else {
return Err("the page size is not a positive whole number".into());
};
let px = bytes(pixels, MAX_PIXEL_BYTES)?;
if u64::from(w).checked_mul(u64::from(h)).and_then(|n| n.checked_mul(4)) != Some(px.len() as u64) {
return Err("the pixels don't match the page size".into());
}
ocr.recognize(&px, w, h).map(|lines| pdfcraft_ocr::lines_to_json(&lines)).map_err(|e| e.to_string())
}
}
+6 -1
View File
@@ -11,10 +11,15 @@ workspace = true
[dependencies]
pdfcraft-ui-egui = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
# The page command API (storytold/craft-libs#413): window.pdfcraft over the UI control channel.
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] }
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
serde_json = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget", "CustomEvent", "CustomEventInit"] }
js-sys = "0.3"
+7
View File
@@ -9,3 +9,10 @@ filehash = true
[serve]
addresses = ["127.0.0.1"]
port = 8766
# Trunk leaves the OCR worker's files unhashed and unnamed in index.html: hash them and name the
# worker for the page (xtask/src/web.rs).
[[hooks]]
stage = "post_build"
command = "cargo"
command_arguments = ["xtask", "web-worker-link"]
+2
View File
@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PdfCraft</title>
<link data-trunk rel="rust" data-wasm-opt="z" />
<!-- Recognises text off the page's thread (crates/ui-egui/src/workflows/ocr_web.rs). -->
<link data-trunk rel="rust" href="../pdfcraft-ocr-worker/Cargo.toml" data-type="worker" data-loader-shim data-bindgen-target="web" data-wasm-opt="3" />
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #f1f1f3; }
canvas { width: 100%; height: 100%; display: block; outline: none; }
+127
View File
@@ -0,0 +1,127 @@
//! `window.pdfcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the app's
//! UI control channel (`pdfcraft_ui_egui::control`), opt-in with `?control`.
//!
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
//! `screenshot`, as promises with a timeout). This module is PdfCraft's part: a [`Request`] that
//! sends each call through the app's [`ControlClient`] and waits for the reply, and the table that
//! names PdfCraft's methods: its control channel drives the UI, so `commands` is `ui.commands`,
//! `execute` runs a registry command with `ui.command`, `docInspect` is `ui.state` and `uiInspect`
//! is `ui.inspect`.
//!
//! The channel keeps the accessibility tree of every frame, so it is off unless the page asks for it
//! (`?control`), as FigCraft's is. PdfCraft's control requests cannot be withdrawn, so a call the page
//! gave up on may still run (or be rejected) when the app reaches it, as it does for a desktop client that timed out.
use serde_json::{Value, json};
/// The reply of a control request as the protocol's `{ok, result}` / `{ok: false, error}` object.
fn reply_object(reply: Result<Value, String>) -> Value {
match reply {
Ok(result) => json!({"ok": true, "result": result}),
Err(error) => json!({"ok": false, "error": error}),
}
}
/// The params of `ui.command`. `execute(command, params)` sends `{command, params}`; `ui.command`
/// takes `{id}` (and `params` for `ui.panel`). A request that already names an `id` is the page's own
/// `request("ui.command", {id})` and goes through unchanged.
fn command_params(params: Value) -> Value {
match params.get("command").and_then(Value::as_str) {
Some(command) if params.get("id").is_none() => json!({"id": command, "params": params.get("params").cloned().unwrap_or(Value::Null)}),
_ => params,
}
}
#[cfg(target_arch = "wasm32")]
pub use web::install;
#[cfg(target_arch = "wasm32")]
mod web {
use std::rc::Rc;
use std::sync::mpsc::{Receiver, TryRecvError};
use craft_web_api::methods::Methods;
use craft_web_api::page::{PageApi, Request};
use pdfcraft_ui_egui::control::{ControlClient, Reply};
use serde_json::{Value, json};
use wasm_bindgen_futures::JsFuture;
use super::{command_params, reply_object};
/// The app id in `window.<app>`.
const APP: &str = "pdfcraft";
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
const POLL_MS: i32 = 16;
/// PdfCraft's control methods behind each function.
fn methods() -> Methods {
Methods {
commands: Some("ui.commands"),
execute: Some("ui.command"),
doc_inspect: Some("ui.state"),
ui_inspect: Some("ui.inspect"),
screenshot: Some("ui.screenshot"),
}
}
/// Put `window.pdfcraft` on the page, sending its calls through `client`. An existing
/// `window.pdfcraft` keeps the functions it already has.
pub fn install(client: ControlClient) {
let request: Request = Rc::new(move |method, params| {
let params = if method == "ui.command" { command_params(params) } else { params };
Box::pin(wait(client.send(&method, params)))
});
if let Err(e) = Rc::new(PageApi::new(APP, methods(), request)).install() {
eframe::web_sys::console::warn_1(&format!("PdfCraft: couldn't install window.{APP}: {e:?}").into());
}
}
/// The app's reply, polled about once a frame.
async fn wait(reply: Receiver<Reply>) -> Value {
loop {
match reply.try_recv() {
Ok(reply) => return reply_object(reply),
Err(TryRecvError::Empty) => sleep(POLL_MS).await,
Err(TryRecvError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}),
}
}
}
async fn sleep(ms: i32) {
let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(window) = web_sys::window() {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
}
});
let _ = JsFuture::from(promise).await;
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn execute_becomes_a_ui_command_by_id() {
assert_eq!(command_params(json!({"command": "file.word_count", "params": {"a": 1}})), json!({"id": "file.word_count", "params": {"a": 1}}));
assert_eq!(command_params(json!({"command": "file.save", "params": {}})), json!({"id": "file.save", "params": {}}));
}
#[test]
fn a_request_that_names_an_id_goes_through_unchanged() {
let own = json!({"id": "file.save"});
assert_eq!(command_params(own.clone()), own);
let both = json!({"id": "ui.panel", "command": "x", "params": {"reset": true}});
assert_eq!(command_params(both.clone()), both, "an id wins over a command");
assert_eq!(command_params(json!({})), json!({}));
}
#[test]
fn replies_become_the_protocols_objects() {
assert_eq!(reply_object(Ok(json!({"n": 1}))), json!({"ok": true, "result": {"n": 1}}));
assert_eq!(reply_object(Err("boom".to_string())), json!({"ok": false, "error": "boom"}));
}
}
+90 -111
View File
@@ -3,20 +3,16 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(any(target_arch = "wasm32", test))]
mod api;
#[cfg(target_arch = "wasm32")]
fn main() {
use eframe::wasm_bindgen::JsCast;
use pdfcraft_ui_egui::PdfCraftApp;
let options = eframe::WebOptions { renderer: eframe::Renderer::Glow, ..Default::default() };
wasm_bindgen_futures::spawn_local(async move {
let Some(canvas) = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.get_element_by_id("pdfcraft"))
.and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
else {
return;
};
let Some(canvas) = app_canvas() else { return };
let started = eframe::WebRunner::new()
.start(
canvas,
@@ -52,13 +48,13 @@ fn main() {
});
}
// eframe never raises a close request on a browser reload or tab close, so `guard_quit` never
// prompts; ask the browser to confirm instead while a document has unsaved work (#812).
// prompts; ask the browser to confirm instead while a document has unsaved work or an OCR review is open (#812).
warn_before_unload(app.unsaved_flag.clone());
// `?host=parent`: a same-origin page embedding PdfCraft in an iframe (such
// as the Nextcloud app) passes files in and takes saves back (`host` below).
if query_param("host").as_deref() == Some("parent")
&& let Some(bridge) = host::Bridge::new()
{
// Host modes (`host` below, over `craft-web-host`): `?host=parent`, a
// same-origin page embedding PdfCraft in an iframe (such as the Nextcloud
// app), or `data-host="page"` on the canvas, a host that runs the module in
// its own document.
if let Some(bridge) = app_canvas().as_ref().and_then(host::Bridge::connect) {
// `?author=`: the host's name for the user signs their comments,
// unless they chose a name in PdfCraft (the web default is "Guest").
if let Some(author) = query_param("author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty())
@@ -75,15 +71,18 @@ fn main() {
&"PdfCraft: ?host=parent needs a framing page on the same origin; saves download instead".into(),
);
}
// `?control`: `window.pdfcraft`, the page's command API (`api`). Opt-in: the control
// channel keeps the accessibility tree of every frame.
if query_param("control").is_some() {
api::install(app.attach_control(&cc.egui_ctx));
}
Ok(Box::new(app))
}),
)
.await;
if let Err(e) = started {
eframe::web_sys::console::error_1(&e);
if query_param("host").as_deref() == Some("parent")
&& let Some(bridge) = host::Bridge::new()
{
if let Some(bridge) = app_canvas().as_ref().and_then(host::Bridge::connect) {
bridge.post_failed(&e.as_string().unwrap_or_else(|| format!("{e:?}")));
}
}
@@ -123,136 +122,116 @@ async fn fetch_bytes(url: &str) -> Result<Vec<u8>, String> {
Ok(js_sys::Uint8Array::new(&buf).to_vec())
}
/// The `postMessage` bridge to a same-origin parent page. Messages are plain objects with a
/// `type`:
/// The app's canvas: the one element `index.html` and every embedding page must provide, with
/// the id the app looks up.
#[cfg(target_arch = "wasm32")]
fn app_canvas() -> Option<web_sys::HtmlCanvasElement> {
use eframe::wasm_bindgen::JsCast;
web_sys::window()?.document()?.get_element_by_id("pdfcraft").and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
}
/// Host modes: how the page embedding PdfCraft passes files in and takes saves back. 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 `pdfcraft:` prefix. This module is PdfCraft's part of it.
///
/// - `pdfcraft:ready` (to the parent): the app is listening; send files now.
/// - `pdfcraft:open` (from the parent): `{ name, bytes }`, where `bytes` is an `ArrayBuffer` or
/// `Uint8Array` holding a PDF. It opens in a tab, like a `?file=` URL.
/// - `pdfcraft:save` (to the parent): `{ name, saveAs, bytes }` when the user saves; `bytes` is
/// a transferred `ArrayBuffer`. The parent stores the file (and reports its own errors).
/// - `pdfcraft:dirty` (to the parent): `{ dirty }` whenever unsaved work appears or goes away.
/// - `pdfcraft:failed` (to the parent): `{ error }` when the app couldn't start.
///
/// Only the parent window on the app's own origin is heard or answered. With a parent on
/// another origin there is no bridge: the app runs as if unframed and saves download.
/// - `?host=parent` is the `postMessage` bridge for a same-origin page that embeds PdfCraft in an
/// iframe (such as the Nextcloud app). With a parent on another origin there is no bridge: the
/// app runs as if unframed and saves download (#2167). `data-host="page"` on the app's canvas
/// is for a host that loads the module into its own document, with no iframe: the same messages
/// travel as DOM `CustomEvent`s on that canvas. `?host=parent` wins when both are present.
/// - `pdfcraft:ready` once the app is listening; send files now. PdfCraft reads bytes, so it
/// says `file: false` and a host sends `bytes`.
/// - `pdfcraft:open` (from the host): `{ name, bytes }` holding a PDF. It opens in a tab, like a
/// `?file=` URL; a file over [`host::MAX_OPEN_BYTES`] is refused before it is copied.
/// - `pdfcraft:save` (to the host): `{ name, saveAs, bytes }` when the user saves. The host stores
/// the file (and reports its own errors).
/// - `pdfcraft:dirty` whenever unsaved work appears or goes away, `pdfcraft:failed` when the app
/// couldn't start.
#[cfg(target_arch = "wasm32")]
mod host {
use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure};
use std::rc::Rc;
use craft_web_host::web::{FileData, Host, Message};
/// The app id in the host messages (`pdfcraft:ready`).
const APP: &str = "pdfcraft";
/// The name a file gets when the host sent none (the crate says `document` for a blank name).
const DEFAULT_NAME: &str = "document.pdf";
/// The largest file the host may hand over to open, checked before it is copied (the app's own readers have their own limits).
const MAX_OPEN_BYTES: u64 = 512 << 20;
#[derive(Clone)]
pub struct Bridge {
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
host: Rc<Host>,
}
impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent
/// is on another origin: messages only go to this origin, so the browser would drop
/// every save without an error while the document showed as saved (#2167).
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()?;
// Reading another origin's location throws, which is `None` too.
if parent.location().origin().ok()? != origin {
return None;
}
Some(Self { window, parent, origin })
}
fn post(&self, message: &js_sys::Object, transfer: Option<&JsValue>) -> Result<(), String> {
let sent = match transfer {
Some(t) => self.parent.post_message_with_transfer(message, &self.origin, &js_sys::Array::of1(t)),
None => self.parent.post_message(message, &self.origin),
};
sent.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
/// The bridge for this page, or `None` when PdfCraft isn't hosted: no `?host=parent` and
/// no `data-host="page"` on the canvas, an unframed `?host=parent` page, or a parent 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, #2167).
pub fn connect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
Some(Self { host: Rc::new(Host::connect(APP, canvas)?) })
}
pub fn post_ready(&self) {
if let Some(m) = message(&[("type", "pdfcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]) {
let _ = self.post(&m, None);
}
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
}
/// The app couldn't start (no WebGL, for example): let the parent say so.
/// The app couldn't start (no WebGL, for example): let the host say so.
pub fn post_failed(&self, error: &str) {
if let Some(m) = message(&[("type", "pdfcraft:failed".into()), ("error", error.into())]) {
let _ = self.post(&m, None);
}
let _ = self.host.post_failed(error, None);
}
/// Hear `pdfcraft:open` from the parent and queue the file like a `?file=` download.
/// Hear `pdfcraft:open` and queue the file like a `?file=` download.
pub fn listen(&self, inbox: pdfcraft_ui_egui::Inbox, ctx: egui::Context) {
let parent = self.parent.clone();
let origin = self.origin.clone();
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
self.host.listen(move |message| {
if let Message::Open { name, data } = message {
queue_open(&name, &data, &inbox, &ctx);
}
let data = event.data();
if string(&data, "type").as_deref() != Some("pdfcraft:open") {
return;
}
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "document.pdf".to_string());
let Ok(bytes) = js_sys::Reflect::get(&data, &JsValue::from_str("bytes")) else { return };
let bytes = if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
array.to_vec()
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
js_sys::Uint8Array::new(&bytes).to_vec()
} else {
return;
};
if let Ok(mut q) = inbox.lock() {
q.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();
}
}
pub fn save_fn(&self) -> pdfcraft_ui_egui::HostSaveFn {
let bridge = self.clone();
Box::new(move |name: &str, bytes: &[u8], save_as: bool| {
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let m =
message(&[("type", "pdfcraft:save".into()), ("name", name.into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())])
.ok_or_else(|| "couldn't build the message".to_string())?;
bridge.post(&m, Some(&buffer))
})
let host = self.host.clone();
Box::new(move |name: &str, bytes: &[u8], save_as: bool| host.post_save(name, bytes, None, save_as))
}
pub fn dirty_fn(&self) -> pdfcraft_ui_egui::HostDirtyFn {
let bridge = self.clone();
let host = self.host.clone();
// A host starts out clean: the first report is a change only when it is dirty.
let mut last = false;
Box::new(move |dirty: bool| {
if dirty != last {
last = dirty;
if let Some(m) = message(&[("type", "pdfcraft:dirty".into()), ("dirty", dirty.into())]) {
let _ = bridge.post(&m, None);
}
host.post_dirty(dirty);
}
})
}
}
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()?;
/// The `pdfcraft:open` payload, `{ name, bytes }` where `bytes` holds a PDF: queue it like a
/// `?file=` download and wake the app. The crate has cleaned the name (and cut it to 255
/// characters); a blank one comes as `document` and becomes `document.pdf`.
fn queue_open(name: &str, data: &FileData, inbox: &pdfcraft_ui_egui::Inbox, ctx: &egui::Context) {
let name = if name == "document" { DEFAULT_NAME } else { name }.to_string();
let FileData::Bytes(view) = data else {
eframe::web_sys::console::warn_1(&format!("PdfCraft: host file {name} came as a File; PdfCraft reads bytes only; not opened").into());
return;
};
// A file from the host is untrusted input: refuse an oversized one before copying it into the app's memory.
if data.size() > MAX_OPEN_BYTES as f64 {
eframe::web_sys::console::warn_1(
&format!("PdfCraft: host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20).into(),
);
return;
}
Some(m)
}
fn string(data: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
if let Ok(mut q) = inbox.lock() {
q.push((name, view.to_vec()));
}
ctx.request_repaint();
}
}
+6
View File
@@ -14,6 +14,8 @@ workspace = true
pdfcraft-ui-egui = { workspace = true }
# `build_info`: the version and compile-time build label for `--version` and the log.
pdfcraft-engine = { workspace = true }
# `owner_only`: the --control file is restricted to its user on Windows (#3922).
pdfcraft-platform = { workspace = true }
# wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or
# driver (#461, #435, #392), and can be chosen with PDFCRAFT_RENDERER=gl.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links", "clipboard"] }
@@ -62,6 +64,10 @@ windows = { version = "0.62", default-features = false, features = ["std", "UI_V
# socket. `std` has no Unix sockets on Windows, and calling Winsock directly needs unsafe, which
# our workspace forbids. Already in the lockfile through zbus; pinned exactly, review before bumping.
uds_windows = "=1.2.1"
# Full-resolution taskbar and Alt+Tab icon (`ICON_BIG`, issue #3818). Set both window icons
# directly so eframe cannot replace ICON_BIG with a system-sized copy. Already in the tree
# through eframe and covered by the workspace's winit patch.
winit = { version = "0.30", default-features = false }
[build-dependencies]
# Icon + VERSIONINFO for pdfcraft.exe; build.rs only uses it when targeting Windows.
+13
View File
@@ -9,6 +9,13 @@
#[path = "src/windows_manifest.rs"]
mod windows_manifest;
/// The in-box DLLs only printing uses (Windows.Data.Pdf draws the job, prntvpt reads the
/// driver's print capabilities). Trimmed Windows installations (NTLite) can lack them, and a
/// load-time import of a missing DLL stops the program before `main` (#2630). Delay-loaded, they
/// load on the first print; `pdfcraft_print::spool` checks they exist before calling in.
/// Same list in `apps/pdfcraft{,-cli}/build.rs`, `crates/print/build.rs` and `crates/print/src/spool.rs`.
const DELAY_LOADED: [&str; 2] = ["windows.data.pdf.dll", "prntvpt.dll"];
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=../../assets/app-icon/pdfcraft.ico");
@@ -18,6 +25,12 @@ fn main() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
for dll in DELAY_LOADED {
println!("cargo:rustc-link-arg-bins=/DELAYLOAD:{dll}");
}
println!("cargo:rustc-link-arg-bins=delayimp.lib");
}
let mut res = winresource::WindowsResource::new();
res.set_icon("../../assets/app-icon/pdfcraft.ico")
.set_icon_with_id("../../assets/app-icon/pdfcraft-document.ico", "2")
+5 -18
View File
@@ -102,25 +102,12 @@ pub fn timestamp(t: SystemTime) -> String {
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
}
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
///
/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
/// reproducing the old `civil_from_days` result exactly without hand-rolled day math. 1970-01-01
/// is `num_days_from_ce` 719_163.
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day): `pdfcraft_engine::civil_from_days`,
/// which keeps the date past chrono's range by whole 400-year cycles. `days` is a `u64` second
/// count over 86_400, at most about 2.1e14, so it always fits an `i64`.
fn civil_from_days(days: u64) -> (i64, u32, u32) {
use chrono::Datelike;
let (cycles, rest) = (days / 146_097, days % 146_097);
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
}
None => (1970, 1, 1), // unreachable
}
// The fallback is unreachable: `days` is at most `u64::MAX / 86_400`, far below `i64::MAX`.
pdfcraft_engine::civil_from_days(i64::try_from(days).unwrap_or(i64::MAX))
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
+154 -34
View File
@@ -12,6 +12,8 @@
//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page|<percent> --always-default-view on|off
//! --page-gaps on|off --default-page-gaps on|off --recent-files 0..50
//! --combine-view grid|list --combine-zoom 60..200`
//! `--combine files…` adds the files to the Combine files tab (File Explorer's "Combine files in
//! PdfCraft", #585), of the PdfCraft already running when there is one.
//!
//! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files
//! hands them to the PdfCraft already running, where they open as tabs (`single_instance`).
@@ -69,6 +71,7 @@ Options:
--two-paged open command-line PDFs in noncontinuous two-page view
--new-window open a window of its own instead of passing the files to a running PdfCraft
--create-images [images…] put the images in one PDF and ask for the page DPI
--combine files… add the files to the Combine files tab, of a running PdfCraft if there is one
--control <file> enable the UI control channel (pdfcraft-cli ui --control <file> …)
--page N, --zoom 150, --layout continuous, --theme dark, …
view settings applied once the files are open
@@ -231,7 +234,7 @@ fn main() -> eframe::Result {
}
startup.files = rejoin_split_paths(startup.files, |p| std::path::Path::new(p).is_file());
let may_hand_off = startup.may_hand_off();
let startup::Arguments { files, options, control_file, create_images, two_paged, .. } = startup;
let startup::Arguments { files, options, control_file, create_images, combine, two_paged, .. } = startup;
let integrated = cfg!(target_os = "macos");
// GNOME on Wayland leaves the window frame to the app (src/decorations.rs).
let window_controls = if cfg!(any(target_os = "macos", target_os = "windows")) { None } else { decorations::app_drawn() };
@@ -255,13 +258,13 @@ fn main() -> eframe::Result {
// Absolute for this instance too: links relative to a document resolve against its folder,
// never the working directory.
let files = single_instance::absolute(&files);
let instance = match single_instance::claim(&files, may_hand_off) {
let instance = match single_instance::claim(&files, may_hand_off, combine) {
single_instance::Claim::HandedOff => return Ok(()),
single_instance::Claim::Primary(server) => Some(server),
single_instance::Claim::Alone => None,
};
let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref());
let launch = Launch { files, options, control_file, create_images, integrated, window_controls, two_paged };
let launch = Launch { files, options, control_file, create_images, combine, integrated, window_controls, two_paged };
// Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one
// that launched us as well as later ones. Lives until the event loop returns.
#[cfg(target_os = "macos")]
@@ -333,11 +336,19 @@ fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer)
let mut viewport = egui::ViewportBuilder::default()
.with_title("PdfCraft")
.with_inner_size([1440.0, 920.0])
.with_min_inner_size([820.0, 520.0])
.with_min_inner_size(pdfcraft_ui_egui::MIN_WINDOW_SIZE)
.with_drag_and_drop(true)
// Wayland app id: matches packaging/linux/ai.storyteller.pdfcraft.desktop.
.with_app_id(APP_ID);
// eframe replaces ICON_BIG with a system-sized icon on its first active frame.
// An explicitly empty icon disables that setter (None selects eframe's default).
// Windows installs both sizes directly through winit in app_creator instead.
#[cfg(target_os = "windows")]
{
viewport = viewport.with_icon(egui::IconData::default());
}
// Dock, taskbar, Alt-Tab and launcher icon when running unbundled.
#[cfg(not(target_os = "windows"))]
match eframe::icon_data::from_png_bytes(APP_ICON_PNG) {
Ok(icon) => viewport = viewport.with_icon(icon),
Err(e) => log::warn!("app icon: {e}"),
@@ -368,6 +379,8 @@ struct Launch {
options: Vec<(String, String)>,
control_file: Option<String>,
create_images: bool,
/// `--combine`: the files go to the Combine files tab.
combine: bool,
integrated: bool,
/// The window buttons the app draws, when the desktop leaves the frame to it.
window_controls: Option<pdfcraft_ui_egui::window_controls::Layout>,
@@ -386,13 +399,55 @@ fn native_wayland(_: &eframe::CreationContext<'_>) -> bool {
false
}
/// Decode `png` as a [`winit::window::Icon`] for the Windows taskbar and Alt+Tab (`ICON_BIG`).
#[cfg(target_os = "windows")]
fn taskbar_icon(png: &[u8]) -> Option<winit::window::Icon> {
let icon = match eframe::icon_data::from_png_bytes(png) {
Ok(icon) => icon,
Err(e) => {
log::warn!("taskbar icon: {e}");
return None;
}
};
match winit::window::Icon::from_rgba(icon.rgba, icon.width, icon.height) {
Ok(icon) => Some(icon),
Err(e) => {
log::warn!("taskbar icon: {e}");
None
}
}
}
/// Install the decoded [`winit::window::Icon`] from `png` via `set` when decoding succeeds.
#[cfg(target_os = "windows")]
fn install_taskbar_icon(png: &[u8], set: impl FnOnce(winit::window::Icon)) {
if let Some(icon) = taskbar_icon(png) {
set(icon);
}
}
/// Install both window icons before the first frame. eframe's icon setter is disabled in
/// `native_options`, so it cannot replace the full-resolution taskbar/Alt+Tab icon (#3818).
#[cfg(target_os = "windows")]
fn set_taskbar_icon(window: &winit::window::Window) {
use winit::platform::windows::{IconExtWindows as _, WindowExtWindows as _};
install_taskbar_icon(APP_ICON_PNG, |icon| window.set_taskbar_icon(Some(icon)));
// The title bar's icon: the small image drawn for this scale in the exe's own icon (build.rs).
let side = (16.0 * window.scale_factor()).round().clamp(16.0, 256.0) as u32;
match winit::window::Icon::from_resource(1, Some(winit::dpi::PhysicalSize::new(side, side))) {
Ok(icon) => window.set_window_icon(Some(icon)),
Err(e) => log::warn!("window icon: {e}"),
}
}
/// Build the [`eframe::AppCreator`] closure that initializes [`PdfCraftApp`] once the window and renderer are ready.
fn app_creator<'a>(
launch: Launch,
started: Rc<Cell<bool>>,
instance: Option<&'a single_instance::Server>,
#[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents,
) -> eframe::AppCreator<'a> {
let Launch { files, options, control_file, create_images, integrated, window_controls, two_paged } = launch;
let Launch { files, options, control_file, create_images, combine, integrated, window_controls, two_paged } = launch;
Box::new(move |cc| {
started.set(true);
let mut app = PdfCraftApp::new();
@@ -413,10 +468,14 @@ fn app_creator<'a>(
// it is set to On or Off.
#[cfg(target_os = "windows")]
{
if let Some(window) = cc.winit_window() {
set_taskbar_icon(window);
}
app.motion.source = Some(motion::source());
}
app.set_startup_update_check(true);
app.printer_source = Some(pdfcraft_ui_egui::system_printers());
app.two_sided_source = Some(pdfcraft_ui_egui::system_two_sided());
app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows"));
#[cfg(target_os = "macos")]
{
@@ -439,6 +498,8 @@ fn app_creator<'a>(
if let Some(dir) = pdfcraft_ui_egui::RecoveryStore::default_dir() {
app.enable_recovery(pdfcraft_ui_egui::RecoveryStore::new(dir));
}
// The OCR Verify screen stores its per-language dictionary beside recovery data.
app.ocr_verify.dictionary_dir = pdfcraft_ui_egui::default_dictionary_dir();
if let Some(state) = &cc.wgpu_render_state {
let adapter = state.adapter.get_info();
let max_texture_dimension_2d = state.device.limits().max_texture_dimension_2d;
@@ -464,6 +525,9 @@ fn app_creator<'a>(
if let Err(e) = app.begin_image_import_paths(&files) {
app.notify(e);
}
} else if combine {
app.reopen_last_files(&[]);
app.combine_paths(&files);
} else {
// With the preference on, last session's files come back first; files named on
// the command line open after them, in front (#442).
@@ -504,7 +568,9 @@ fn open_control_channel(file: &str, endpoint: std::io::Result<pdfcraft_ui_egui::
Ok(())
}
Err(e) => {
let message = format!("--control {file}: {e}. Use a control file in a folder only you can write, such as ~/.pdfcraft-control.json");
// cmd.exe doesn't expand `~` (#3922).
let example = if cfg!(windows) { r"%USERPROFILE%\.pdfcraft-control.json" } else { "~/.pdfcraft-control.json" };
let message = format!("--control {file}: {e}. Use a control file in a folder only you can write, such as {example}");
log::error!("{message}");
Err(message)
}
@@ -513,15 +579,25 @@ fn open_control_channel(file: &str, endpoint: std::io::Result<pdfcraft_ui_egui::
/// Write the control endpoint so that only the current user can read the token.
///
/// The JSON goes to a new file next to `path`, created fresh (owner-only on Unix), which then
/// replaces `path`. Opening `path` itself would follow a link planted there and truncate whatever
/// it points at; a rename replaces the link and leaves its target alone. A reader polling for the
/// file also never sees it half-written.
/// The JSON goes to a new file, created fresh and owner-only, which then replaces `path`. On Unix
/// the file sits next to `path` with mode 0600. On Windows it sits in a new folder next to `path`
/// whose access list, of this user and SYSTEM only, it inherits when it is created, so no other
/// account can open it at any time (#3922; review on #3956). Opening `path` itself would follow a
/// link planted there and truncate whatever it points at; a rename replaces the link and leaves
/// its target alone. A reader polling for the file also never sees it half-written.
fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()> {
use std::io::{ErrorKind, Write};
use std::io::ErrorKind;
let json = serde_json::json!({ "port": port, "token": token, "pid": std::process::id() }).to_string();
let path = std::path::Path::new(path);
let name = path.file_name().ok_or_else(|| std::io::Error::new(ErrorKind::InvalidInput, "not a file name"))?;
// Windows: the user's SID, read once for the private folder and for the file in it, since each
// lookup starts `whoami` (review on #3956).
#[cfg(windows)]
let me = pdfcraft_platform::owner_only::current_user_sid()?;
#[cfg(windows)]
let restrict = |file: &std::path::Path| pdfcraft_platform::owner_only::restrict_to_owner(file, &me);
#[cfg(not(windows))]
let restrict = |_: &std::path::Path| Ok::<(), std::io::Error>(());
// Unpredictable suffixes, so the name can't be planted in advance. `RandomState` is keyed from
// the operating system's random source.
let random = std::hash::RandomState::new();
@@ -530,35 +606,79 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()>
staged.push(name);
staged.push(format!(".{:016x}.tmp", std::hash::BuildHasher::hash_one(&random, i)));
let staged = path.with_file_name(staged);
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let file = match opts.open(&staged) {
Ok(f) => f,
Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
// Windows refuses `create_new` on a folder with "access denied"; the name is taken all
// the same. A folder we can't write to, with nothing at the name, still fails here.
Err(e) if e.kind() == ErrorKind::PermissionDenied && staged.symlink_metadata().is_ok() => continue,
Err(e) => return Err(e),
// Windows: a file created next to `path` would inherit that folder's access list, which may
// let other users read it, and restricting it afterwards leaves a window in which one of
// them opens a handle; Windows checks access only when a handle is opened, so a later
// change of the list doesn't revoke it. The file is created in a private folder instead,
// and that folder is removed once the file has left it.
#[cfg(windows)]
let (staging, staged) = {
match pdfcraft_platform::owner_only::create_private_dir(&staged, &me) {
Ok(()) => {}
Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
// As for a file below: something at the name we may not open still takes it.
Err(e) if e.kind() == ErrorKind::PermissionDenied && staged.symlink_metadata().is_ok() => continue,
Err(e) => return Err(e),
}
let file = staged.join(name);
(staged, file)
};
// The block closes the file before it is renamed.
let written = {
let mut file = file;
file.write_all(json.as_bytes())
};
let done = written.and_then(|()| std::fs::rename(&staged, path));
if done.is_err() {
let _ = std::fs::remove_file(&staged);
let done = stage_control_file(&staged, path, &json, &restrict);
#[cfg(windows)]
let _ = std::fs::remove_dir(&staging);
match done {
Some(done) => return done,
None => continue,
}
return done;
}
Err(std::io::Error::new(ErrorKind::AlreadyExists, "no free name for the control file's temporary copy"))
}
/// Create `staged` fresh and owner-only, `restrict` it, write `json` to it and rename it to `path`.
/// `None` when something already has the name, so the caller tries another. On any other failure
/// the new file is removed and nothing is left at `path`'s side.
fn stage_control_file(
staged: &std::path::Path,
path: &std::path::Path,
json: &str,
restrict: &dyn Fn(&std::path::Path) -> std::io::Result<()>,
) -> Option<std::io::Result<()>> {
use std::io::{ErrorKind, Write};
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let file = match opts.open(staged) {
Ok(f) => f,
Err(e) if e.kind() == ErrorKind::AlreadyExists => return None,
// Windows refuses `create_new` on a folder with "access denied"; the name is taken all
// the same. A folder we can't write to, with nothing at the name, still fails here.
Err(e) if e.kind() == ErrorKind::PermissionDenied && staged.symlink_metadata().is_ok() => return None,
Err(e) => return Some(Err(e)),
};
// Windows: the file inherited the private folder's list. It also gets a list of its own, so it
// stays private wherever the rename puts it, and that is checked before the token is written;
// nothing is written if that fails (#3922). Unix has mode 0600 above, and `restrict` does nothing.
if let Err(e) = restrict(staged) {
drop(file);
let _ = std::fs::remove_file(staged);
return Some(Err(e));
}
// The block closes the file before it is renamed.
let written = {
let mut file = file;
file.write_all(json.as_bytes())
};
let done = written.and_then(|()| std::fs::rename(staged, path));
if done.is_err() {
let _ = std::fs::remove_file(staged);
}
Some(done)
}
/// How wgpu finds a GPU. Each choice yields to its wgpu environment variable.
///
/// - Draw on the integrated GPU unless `WGPU_POWER_PREF` says otherwise. A PDF viewer has no use
+132 -39
View File
@@ -4,8 +4,9 @@
//! Outlook attachments, Explorer double-clicks and Open With all start `pdfcraft.exe "<file>"`, and
//! a file manager or `xdg-open` starts `pdfcraft <file>` the same way, so until now every file got
//! its own process and window. The first PdfCraft listens on a Unix domain socket. On Windows (10
//! 1803 and later) it is in `%LOCALAPPDATA%\PdfCraft`, or in the data folder of a portable copy,
//! and is named after the Windows session (`instance-Console.sock`), so two sign-ins of the same
//! 1803 and later) it is in `%LOCALAPPDATA%\PdfCraft` (a portable copy's too, as
//! `portable-instance-…` and never in its own folder, #889) and is named after the Windows
//! session (`instance-s1.sock` for session 1), so two sign-ins of the same
//! user never open files in each other's windows. On Linux and the BSDs it is in
//! `$XDG_RUNTIME_DIR/pdfcraft` and is named after the display (`instance-wayland-0.sock`), so a
//! launch on another display or seat opens its own window there. Either way it lives in a folder
@@ -13,7 +14,9 @@
//! intercept a launch or open files in this user's window. Inside a Flatpak, `$XDG_RUNTIME_DIR` is
//! the app's own and is shared by every run of it, so the handoff works there too. The exchange is
//! one line each way, a JSON request naming absolute paths and `ok`, and the running app opens the
//! paths exactly as File ▸ Open would.
//! paths exactly as File ▸ Open would. A `--combine` launch (File Explorer's "Combine files in
//! PdfCraft", #585, one launch per selected file) hands them over the same way with
//! `"combine": true`, and the running app adds them to its Combine files tab instead.
//!
//! Every failure falls back to the old behaviour: a launch that can't hand its files over within
//! a few seconds opens its own window. `--new-window`, any other option, or no files at all also
@@ -57,7 +60,10 @@ pub enum Claim {
/// Files that later launches handed over, and how to wake the UI for them.
#[derive(Default)]
struct Inbox {
paths: Vec<String>,
/// The requests in the order they arrived: [`OsEvent::Open`] or, for `--combine`,
/// [`OsEvent::Combine`]. One queue, so the UI handles an Open that came after a Combine after
/// it, and that Open comes to the front instead of opening behind the Combine files tab.
events: Vec<OsEvent>,
wake: Option<Box<dyn Fn() + Send>>,
}
@@ -73,11 +79,20 @@ impl Server {
Self { inbox: Arc::default() }
}
/// Queue files from another launch and wake the UI.
/// Queue files from another launch (to open, or to combine) and wake the UI.
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
fn deliver(&self, paths: Vec<String>) {
fn deliver(&self, paths: Vec<String>, combine: bool) {
if paths.is_empty() {
return;
}
let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner);
inbox.paths.extend(paths);
// A run of requests of one kind (File Explorer starts one launch per selected file) stays
// one event; a request of the other kind starts a new one after it.
match (inbox.events.last_mut(), combine) {
(Some(OsEvent::Combine(queued)), true) | (Some(OsEvent::Open(queued)), false) => queued.extend(paths),
(_, true) => inbox.events.push(OsEvent::Combine(paths)),
(_, false) => inbox.events.push(OsEvent::Open(paths)),
}
if let Some(wake) = &inbox.wake {
wake();
}
@@ -94,8 +109,8 @@ impl Server {
let inbox = Arc::clone(&self.inbox);
let ctx = ctx.clone();
Box::new(move || {
let paths = std::mem::take(&mut inbox.lock().unwrap_or_else(PoisonError::into_inner).paths);
if paths.is_empty() {
let events = std::mem::take(&mut inbox.lock().unwrap_or_else(PoisonError::into_inner).events);
if events.is_empty() {
return Vec::new();
}
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
@@ -103,48 +118,70 @@ impl Server {
if native_wayland {
ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational));
}
vec![OsEvent::Open(paths)]
events
})
}
}
/// The request a launch sends: `{"open":[…]}` and a newline.
/// The request a launch sends: `{"open":[…]}` (with `"combine":true` for `--combine`) and a
/// newline.
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
pub fn request(paths: &[String]) -> String {
let mut line = serde_json::json!({ "open": paths }).to_string();
pub fn request(paths: &[String], combine: bool) -> String {
let mut value = serde_json::json!({ "open": paths });
if combine {
value["combine"] = serde_json::Value::Bool(true);
}
let mut line = value.to_string();
line.push('\n');
line
}
/// The paths a request names, or `None` when it isn't a request: anything but one JSON object
/// whose `open` is a non-empty list of non-empty strings.
/// The paths a request names and whether they are for Combine files, or `None` when it isn't a
/// request: anything but one JSON object whose `open` is a non-empty list of non-empty strings
/// (`combine`, when there, must be a boolean).
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
pub fn parse_request(line: &str) -> Option<Vec<String>> {
pub fn parse_request(line: &str) -> Option<(Vec<String>, bool)> {
let value: serde_json::Value = serde_json::from_str(line.trim_end()).ok()?;
let open = value.get("open")?.as_array()?;
let paths = open.iter().map(|p| p.as_str().filter(|s| !s.is_empty()).map(str::to_string)).collect::<Option<Vec<String>>>()?;
(!paths.is_empty()).then_some(paths)
let combine = match value.get("combine") {
None => false,
Some(c) => c.as_bool()?,
};
(!paths.is_empty()).then_some((paths, combine))
}
/// File arguments made absolute against this launch's working folder, which the running app
/// doesn't share. A path that can't be made absolute is sent as it is.
/// doesn't share. A path that can't be made absolute is sent as it is. Of a file named with open
/// parameters (`manual.pdf#nameddest=Intro`, #3425), only the file is made absolute and the
/// parameters are sent as given: as part of the path, `//` and `/./` in a destination name were
/// folded, and on Windows `/` became `\`, `..` took the file name with it and a trailing dot went.
pub fn absolute(paths: &[String]) -> Vec<String> {
paths.iter().map(|p| std::path::absolute(p).map_or_else(|_| p.clone(), |a| a.to_string_lossy().into_owned())).collect()
let made = |p: &str| std::path::absolute(p).map_or_else(|_| p.to_string(), |a| a.to_string_lossy().into_owned());
paths
.iter()
.map(|p| match pdfcraft_engine::open_parameters::split(p, |f| std::path::Path::new(f).is_file()) {
// `file` is what comes before one of `p`'s `#`, so the rest starts at that `#`.
Some((file, _)) => format!("{}{}", made(&file), p.get(file.len()..).unwrap_or_default()),
None => made(p),
})
.collect()
}
/// Decide what this launch does. `files` are absolute ([`absolute`]); `may_hand_off` is false
/// when the launch asked for anything besides opening files, so it gets its own window.
pub fn claim(files: &[String], may_hand_off: bool) -> Claim {
/// when the launch asked for anything besides opening (or, with `combine`, combining) files, so
/// it gets its own window.
pub fn claim(files: &[String], may_hand_off: bool, combine: bool) -> Claim {
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
{
match socket_path() {
Some(path) => claim_at(&path, files, may_hand_off),
Some(path) => claim_at(&path, files, may_hand_off, combine),
None => Claim::Alone,
}
}
#[cfg(not(any(windows, all(unix, not(target_os = "macos")))))]
{
let _ = (files, may_hand_off);
let _ = (files, may_hand_off, combine);
Claim::Alone
}
}
@@ -238,7 +275,7 @@ fn election(socket: &std::path::Path) -> Option<std::fs::File> {
/// [`claim`] with the socket at `path`.
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Claim {
fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool, combine: bool) -> Claim {
// Released when this function returns: by then this launch listens, or has handed over.
let _election = election(path);
// Two launches can start together: the one that loses the race to bind hands over to the
@@ -249,7 +286,7 @@ fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Cla
if !may_hand_off {
return Claim::Alone;
}
return match hand_off(&stream, files) {
return match hand_off(&stream, files, combine) {
Ok(()) => Claim::HandedOff,
Err(e) => {
log::warn!("couldn't hand the files to the running PdfCraft ({e}); opening a new window");
@@ -295,12 +332,12 @@ fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Cla
/// Send `files` over `stream` and wait for the running app's `ok`.
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
fn hand_off(stream: &UnixStream, files: &[String]) -> std::io::Result<()> {
fn hand_off(stream: &UnixStream, files: &[String], combine: bool) -> std::io::Result<()> {
use std::io::{BufRead, Read, Write};
stream.set_read_timeout(Some(TIMEOUT))?;
stream.set_write_timeout(Some(TIMEOUT))?;
let mut writer = stream;
writer.write_all(request(files).as_bytes())?;
writer.write_all(request(files, combine).as_bytes())?;
let mut reply = String::new();
std::io::BufReader::new(stream.take(16)).read_line(&mut reply)?;
if reply == "ok\n" { Ok(()) } else { Err(std::io::Error::other("the running PdfCraft didn't confirm")) }
@@ -342,8 +379,8 @@ fn receive(stream: &UnixStream, server: &Server) -> std::io::Result<()> {
if line.is_empty() {
return Ok(());
}
let paths = parse_request(&line).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "not an open request"))?;
server.deliver(paths);
let (paths, combine) = parse_request(&line).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "not an open request"))?;
server.deliver(paths, combine);
let mut writer = stream;
writer.write_all(b"ok\n")
}
@@ -355,16 +392,31 @@ mod tests {
#[test]
fn a_request_names_the_paths_and_round_trips() {
let paths = vec![r"C:\Users\me\Documents\Quote.pdf".to_string(), r"D:\Scans\Plan „A“ 1.pdf".to_string()];
let line = request(&paths);
let line = request(&paths, false);
assert!(line.ends_with('\n') && !line.trim_end().contains('\n'), "one line");
assert_eq!(parse_request(&line), Some(paths));
assert_eq!(parse_request(&line), Some((paths.clone(), false)));
assert!(!line.contains("combine"), "an open request says nothing about combining");
let line = request(&paths, true);
assert_eq!(parse_request(&line), Some((paths, true)), "a --combine launch's request");
}
#[test]
fn anything_but_a_list_of_paths_is_refused() {
for line in ["", "ok\n", "{}", r#"{"open":[]}"#, r#"{"open":[""]}"#, r#"{"open":["a.pdf",3]}"#, r#"{"open":"a.pdf"}"#, "[\"a.pdf\"]"] {
for line in [
"",
"ok\n",
"{}",
r#"{"open":[]}"#,
r#"{"open":[""]}"#,
r#"{"open":["a.pdf",3]}"#,
r#"{"open":"a.pdf"}"#,
"[\"a.pdf\"]",
r#"{"open":["a.pdf"],"combine":"yes"}"#,
r#"{"open":["a.pdf"],"combine":1}"#,
] {
assert_eq!(parse_request(line), None, "{line:?}");
}
assert_eq!(parse_request(r#"{"open":["a.pdf"],"combine":false}"#), Some((vec!["a.pdf".to_string()], false)));
}
#[test]
@@ -375,6 +427,26 @@ mod tests {
assert_eq!(absolute(std::slice::from_ref(&already)), vec![already]);
}
/// #3425: the open parameters after a file reach the running app as they were given. Made
/// absolute with the file, `a//b/./c` became `a/b/c` (`a\b\c` on Windows), and on Windows
/// `x/../y` lost the file name and `Intro.` its dot.
#[test]
fn only_the_file_before_open_parameters_is_made_absolute() {
let dir = std::env::temp_dir().join(format!("pdfcraft-instance-{}-open-parameters", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (manual, notes) = (dir.join("manual.pdf"), dir.join("notes#2.pdf"));
std::fs::write(&manual, b"%PDF-1.7").unwrap();
std::fs::write(&notes, b"%PDF-1.7").unwrap();
let made = |p: &std::path::Path| std::path::absolute(p).unwrap().to_string_lossy().into_owned();
for parameters in ["#nameddest=a//b/./c", "#nameddest=x/../y", "#Intro.", "#page=2"] {
let given = format!("{}{parameters}", manual.to_string_lossy());
assert_eq!(absolute(&[given]), vec![format!("{}{parameters}", made(&manual))], "{parameters}");
}
// A file with `#` in its name is made absolute whole, as before.
assert_eq!(absolute(&[notes.to_string_lossy().into_owned()]), vec![made(&notes)]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn each_windows_session_gets_its_own_socket_name() {
// The session id decides: #3338, a launch from Task Scheduler (no SESSIONNAME) in the
@@ -397,13 +469,31 @@ mod tests {
fn delivered_files_reach_the_ui_once_wake_it_and_bring_the_window_forward() {
let server = Server::new();
// Before the window exists: queued, nothing to wake yet.
server.deliver(vec!["a.pdf".into()]);
server.deliver(vec!["a.pdf".into()], false);
let ctx = egui::Context::default();
let mut poll = server.connect(&ctx, false);
server.deliver(vec!["b.pdf".into()]);
server.deliver(vec!["b.pdf".into()], false);
let events = poll();
assert!(matches!(events.as_slice(), [OsEvent::Open(p)] if p == &["a.pdf", "b.pdf"]));
assert!(poll().is_empty(), "each file is delivered once");
// Files to combine (one launch each, from File Explorer) arrive as one Combine event,
// apart from files to open.
server.deliver(vec!["c.pdf".into()], true);
server.deliver(vec!["e.pdf".into()], true);
let events = poll();
assert!(matches!(events.as_slice(), [OsEvent::Combine(c)] if c == &["c.pdf", "e.pdf"]), "{events:?}");
// #2503 review: requests reach the UI in the order they arrived, so an Open after a
// Combine is handled after it and comes to the front instead of opening behind the
// Combine files tab.
server.deliver(vec!["f.pdf".into()], true);
server.deliver(vec!["g.pdf".into()], false);
server.deliver(vec!["h.pdf".into()], true);
let events = poll();
assert!(
matches!(events.as_slice(), [OsEvent::Combine(f), OsEvent::Open(g), OsEvent::Combine(h)] if f == &["f.pdf"] && g == &["g.pdf"] && h == &["h.pdf"]),
"{events:?}"
);
assert!(poll().is_empty());
}
#[test]
@@ -441,12 +531,15 @@ mod tests {
#[test]
fn a_second_launch_hands_its_files_to_the_first() {
let path = socket("hand-off");
let Claim::Primary(first) = claim_at(&path, &[], true) else { panic!("the first launch listens") };
let Claim::Primary(first) = claim_at(&path, &[], true, false) else { panic!("the first launch listens") };
let mut poll = first.connect(&egui::Context::default(), false);
assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true), Claim::HandedOff));
assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true, false), Claim::HandedOff));
assert!(matches!(poll().as_slice(), [OsEvent::Open(p)] if p == &["C:\\a.pdf"]));
// A --combine launch hands its file over for the Combine files tab (#585).
assert!(matches!(claim_at(&path, &["C:\\c.pdf".into()], true, true), Claim::HandedOff));
assert!(matches!(poll().as_slice(), [OsEvent::Combine(p)] if p == &["C:\\c.pdf"]));
// A launch with options keeps its own window and doesn't take over the socket.
assert!(matches!(claim_at(&path, &["C:\\b.pdf".into()], false), Claim::Alone));
assert!(matches!(claim_at(&path, &["C:\\b.pdf".into()], false, false), Claim::Alone));
assert!(poll().is_empty());
}
@@ -461,7 +554,7 @@ mod tests {
let (path, barrier) = (path.clone(), Arc::clone(&barrier));
std::thread::spawn(move || {
barrier.wait();
claim_at(&path, &[format!("C:\\{i}.pdf")], true)
claim_at(&path, &[format!("C:\\{i}.pdf")], true, false)
})
})
.collect();
@@ -482,6 +575,6 @@ mod tests {
}));
// `symlink_metadata`, not `exists`, which follows the socket's reparse point and can fail.
assert!(std::fs::symlink_metadata(&path).is_ok(), "the socket file stays behind");
assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true), Claim::Primary(_)));
assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true, false), Claim::Primary(_)));
}
}
+5
View File
@@ -7,6 +7,8 @@ pub(super) struct Arguments {
pub options: Vec<(String, String)>,
pub control_file: Option<String>,
pub create_images: bool,
/// `--combine`: the files go to the Combine files tab.
pub combine: bool,
pub new_window: bool,
pub two_paged: bool,
pub version: bool,
@@ -33,6 +35,9 @@ impl Arguments {
}
"--control" => parsed.control_file = args.next(),
"--create-images" => parsed.create_images = true,
// File Explorer's "Combine files in PdfCraft" (#585): the files go to the Combine
// files tab, of the running PdfCraft when there is one.
"--combine" => parsed.combine = true,
"--new-window" => parsed.new_window = true,
"--two-paged" => parsed.two_paged = true,
flag if flag.starts_with("--two-paged=") => {
+84
View File
@@ -137,6 +137,31 @@ fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() {
assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false));
}
#[test]
fn the_window_and_windows_taskbar_icons_load_from_the_bundled_png() {
let icon = eframe::icon_data::from_png_bytes(super::APP_ICON_PNG).expect("the bundled PNG decodes as the window icon");
assert!(icon.width > 0 && icon.height > 0);
assert_eq!(icon.rgba.len(), (icon.width as usize) * (icon.height as usize) * 4);
#[cfg(not(target_os = "windows"))]
assert_eq!(super::native_options(false, false, eframe::Renderer::Glow).viewport.icon.as_deref(), Some(&icon));
#[cfg(target_os = "windows")]
{
// None would load eframe's default icon; only the explicit empty sentinel prevents
// its first-frame setter from overwriting our full-resolution ICON_BIG.
for renderer in [eframe::Renderer::Glow, eframe::Renderer::Wgpu] {
let viewport = super::native_options(false, false, renderer).viewport;
assert_eq!(viewport.icon.as_deref(), Some(&egui::IconData::default()));
}
assert_eq!((icon.width, icon.height), (256, 256));
let mut installed = None;
super::install_taskbar_icon(super::APP_ICON_PNG, |icon| installed = Some(icon));
assert!(installed.is_some(), "issue #3818: ICON_BIG loads and installs from APP_ICON_PNG");
let mut bad = None;
super::install_taskbar_icon(b"not a png", |icon| bad = Some(icon));
assert!(bad.is_none());
}
}
#[test]
fn a_software_renderer_is_shown_to_the_user() {
use eframe::wgpu::DeviceType;
@@ -654,6 +679,42 @@ fn the_control_file_replaces_the_one_a_previous_run_left() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #3922: a control file created in a folder that lets every user read what's in it is still
/// readable by its user and SYSTEM only, like mode 0600 on Unix.
#[cfg(windows)]
#[test]
fn the_control_file_is_owner_only_in_a_folder_other_users_can_read() {
use pdfcraft_platform::owner_only;
let dir = scratch();
let users = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap();
assert!(users.status.success());
// What the folder hands down, for comparison: a plain file there is readable by Users.
std::fs::write(dir.join("plain.txt"), "x").unwrap();
let me = owner_only::current_user_sid().unwrap();
assert!(owner_only::foreign_access(&owner_only::sddl_of(&dir.join("plain.txt")).unwrap(), &me).is_some());
super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap();
let sddl = owner_only::sddl_of(&dir.join("ctl.json")).unwrap();
assert_eq!(owner_only::foreign_access(&sddl, &me), None, "{sddl}");
let (json, names) = written(&dir);
assert_eq!(json["token"].as_str(), Some("t0ken"));
assert_eq!(names, ["ctl.json", "plain.txt"], "no private staging folder left behind");
let _ = std::fs::remove_dir_all(&dir);
}
/// Review on #3956: writing the control file reads the user's SID once, for both the private
/// folder and the file in it, rather than starting `whoami` for each.
#[cfg(windows)]
#[test]
fn writing_the_control_file_looks_up_the_users_sid_once() {
use pdfcraft_platform::owner_only::sid_lookups_on_this_thread;
let dir = scratch();
let before = sid_lookups_on_this_thread();
super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap();
assert_eq!(sid_lookups_on_this_thread().saturating_sub(before), 1, "whoami runs once per control file");
assert_eq!(written(&dir).0["token"].as_str(), Some("t0ken"));
let _ = std::fs::remove_dir_all(&dir);
}
/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
/// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
#[test]
@@ -663,6 +724,29 @@ fn the_desktop_entry_names_the_window_class() {
assert_eq!(class, Some(super::APP_ID));
}
/// Both flatpak manifests build with the craft-i18n commit `craft-i18n.rev` pins: CI and
/// releases read the file, and the manifests have to name the commit themselves. The pin may
/// have a trailing newline or CRLF (a Windows checkout), so it is trimmed, as every reader does.
#[test]
fn the_flatpak_manifests_pin_the_craft_i18n_rev() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let rev = std::fs::read_to_string(root.join("craft-i18n.rev")).unwrap();
let rev = rev.trim();
assert!(rev.len() == 40 && rev.bytes().all(|b| b.is_ascii_hexdigit()), "craft-i18n.rev holds one full SHA: {rev:?}");
for manifest in ["ai.storyteller.pdfcraft.yml", "ai.storyteller.pdfcraft.bundle.yml"] {
let text = std::fs::read_to_string(root.join("packaging/linux/flatpak").join(manifest)).unwrap();
let source =
text.split("url: https://github.com/storytold/craft-i18n").nth(1).unwrap_or_else(|| panic!("{manifest}: a craft-i18n git source"));
let source = source.split("- type:").next().unwrap_or(source);
let commit = source
.lines()
.find_map(|l| l.trim().strip_prefix("commit:"))
.unwrap_or_else(|| panic!("{manifest}: the craft-i18n source pins a commit"));
assert_eq!(commit.trim().trim_matches(['\'', '"']), rev, "{manifest}");
assert!(text.contains("CRAFT_I18N_DIR: /run/build/pdfcraft/craft-i18n"), "{manifest}: the build names the catalogs");
}
}
// Regression test for #246: `eprintln!` panics when standard error is a broken pipe (a GUI
// launch on Windows), and a panic inside the panic hook aborts the process. The start-up
// path and the hook must write to standard error without it.
+1 -1
View File
@@ -49,7 +49,7 @@ fn assert_resolved(name: &str, version: &str, url: &str, rev: &str) {
#[test]
fn vendor_provenance_window_stack_graph_is_resolved() {
assert_resolved("winit", "0.30.13", "https://github.com/storytold/winit.git", "a517b3846ee8457d84ee4975110bfda6797c44dc");
assert_resolved("egui-wgpu", "0.37.0", "https://github.com/storytold/egui.git", "6d518fc799681571963842f95b55a6b68431b15a");
assert_resolved("egui-wgpu", "0.37.0", "https://github.com/storytold/egui.git", "725a39fca940f77149ffbb9c58eef405a16997bc");
// Retained provenance: the audited copies and their recorded W1–W13 / E2+E3 rows.
let readme = std::fs::read_to_string(root().join("vendor/README.md")).expect("vendor/README.md");
for marker in ["W1–W13", "surface_fit", "catch_errors", "e9809ef54b18499bb4f2cac945719ecc2a61061b"] {
+18
View File
@@ -0,0 +1,18 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-a-large-small"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m15 16 2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16" />
<path d="M15.697 14h5.606" />
<path d="m2 16 4.039-9.69a.5.5 0 0 1 .923 0L11 16" />
<path d="M3.304 13h6.392" />
</svg>

After

Width:  |  Height:  |  Size: 475 B

+11
View File
@@ -183,3 +183,14 @@ sync_github_name = true
[people.Combiler01]
sync_github_name = true
[people.helenkwok]
real_name = "Helen Kwok"
[people.vangel76]
real_name = "Valentin Klein"
display_name = "vangel76"
sync_github_name = false
[people.enriquemelzer-bit]
sync_github_name = true
+1
View File
@@ -0,0 +1 @@
575cfdef63227b14daf173b8cc87928bfdfa325e
-1
View File
@@ -14,6 +14,5 @@ workspace = true
pdfcraft-cos = { workspace = true }
pdfcraft-model = { workspace = true }
pdfcraft-content = { workspace = true }
pdfcraft-filters = { workspace = true }
pdfcraft-fonts = { workspace = true }
thiserror.workspace = true
+81 -21
View File
@@ -25,6 +25,8 @@ pub enum AltError {
NotAFigure(u32),
#[error("the page has more content than can be edited")]
TooLarge,
#[error("the figure's content may be in a damaged or unreadable content stream, or after the damage, so nothing was changed")]
Damaged,
#[error("{0}")]
Cos(#[from] pdfcraft_cos::CosError),
}
@@ -52,6 +54,8 @@ pub enum ArtifactError {
ParentTree(String),
#[error("the page has more content than can be edited")]
TooLarge,
#[error("the tag's marked content may be in a damaged or unreadable content stream, or after the damage, so nothing was changed")]
Damaged,
#[error(transparent)]
Cos(#[from] pdfcraft_cos::CosError),
}
@@ -102,7 +106,13 @@ pub(crate) fn elements(doc: &Document) -> Vec<Element> {
if !seen.insert(r) {
continue;
}
let Some(d) = doc.get(r).as_dict().cloned() else { continue };
let target = doc.get(r);
if matches!(&*target, Object::Array(_) | Object::Ref(_)) {
// An indirect /K array is a container, not an element or a parent.
stack.push(((*target).clone(), pg, parent, depth));
continue;
}
let Some(d) = target.as_dict().cloned() else { continue };
if d.contains(b"S") {
let pg = d.get(b"Pg").and_then(Object::as_ref).or(pg);
if let Some(kids) = d.get(b"K") {
@@ -372,7 +382,19 @@ fn content_entries(doc: &Document, page: &Dict) -> Vec<Object> {
}
}
type DecodedStream = (Object, Option<(Stream, Vec<u8>)>);
/// How much of a content stream decoded.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Read {
Whole,
/// Damaged partway: the data is what decoded before the damage. What follows it is unknown,
/// the syntax the later streams start in too.
Damaged,
/// Not at all (a filter this reader lacks, say): the data is empty.
Unread,
}
/// A `/Contents` entry and, for a stream, the stream, its decoded data and how much of it decoded.
type DecodedStream = (Object, Option<(Stream, Vec<u8>, Read)>);
/// Decode the page's content streams within the page decode budget. `None` when a stream is
/// rejected because the budget is exhausted.
@@ -383,14 +405,14 @@ fn decode_page_streams(doc: &Document, page: &Dict) -> Option<Vec<DecodedStream>
for entry in entries {
let decoded = match &*doc.resolve(&entry) {
Object::Stream(s) => {
let data = match budget.decode(s) {
Ok(data) => data,
let (data, read) = match budget.decode_with_damage(doc, s, usize::MAX) {
Ok((data, damaged)) => (data, if damaged { Read::Damaged } else { Read::Whole }),
// Content past the budget may hold more of the tag: editing the rest
// would leave that part marked as a tag the tree no longer has.
Err(_) if budget.exhausted() => return None,
Err(_) => Vec::new(),
Err(_) => (Vec::new(), Read::Unread),
};
Some((s.clone(), data))
Some((s.clone(), data, read))
}
_ => None,
};
@@ -403,6 +425,18 @@ fn marked_id(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &Hash
(op.op == b"BDC").then(|| bdc_mcid(doc, props, op)).flatten().filter(|n| ids.contains(n))
}
/// What [`artifact_marked_content`] did.
#[derive(Debug, PartialEq)]
enum Marked {
/// The streams holding the content were rewritten.
Done,
/// None of the content is on the page, or with `require_all` not all of it: nothing changed.
NotFound,
/// Some of it is, or may be, in a stream damaged partway or one that doesn't decode, neither of
/// which can be rewritten without losing what wasn't read, or after the damage: nothing changed.
Damaged,
}
/// Turn the tag's marked-content blocks into artifacts, rewriting only the streams that
/// contain them. Other content streams, and unknown entries on a rewritten stream, stay.
/// With `require_all`, every id in `ids` must appear on the page before any stream is rewritten.
@@ -412,25 +446,45 @@ fn artifact_marked_content(
streams: Vec<DecodedStream>,
ids: &HashSet<i64>,
require_all: bool,
) -> Result<bool, pdfcraft_cos::CosError> {
) -> Result<Marked, pdfcraft_cos::CosError> {
let props = page_properties(doc, &page.dict);
let decoded: Vec<&[u8]> = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, bytes)| bytes.as_slice())).collect();
// The pieces are the streams, in order.
let decoded: Vec<&[u8]> = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, bytes, _)| bytes.as_slice())).collect();
let read: Vec<Read> = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, _, read)| *read)).collect();
let damage = read.iter().position(|r| *r == Read::Damaged);
let pieces = pdfcraft_content::Pieces::join(&decoded);
let mut found = HashSet::new();
let mut edits = Vec::new();
for op in pieces.parse().ops {
if let Some(id) = marked_id(doc, &props, &op, ids) {
// What follows the damage is unknown: a string or comment the damage left open runs
// on into the next stream, so what reads as an operator after it may not be one. An
// operator whose operands start before a stream not read whole and whose keyword comes
// after it takes them from around what wasn't read: that stream may end them itself.
let (first, last) = pieces.pieces_of(&op);
if damage.is_some_and(|d| last > d) || read.get(first..last).is_some_and(|r| r.iter().any(|r| *r != Read::Whole)) {
return Ok(Marked::Damaged);
}
found.insert(id);
edits.push((op.span, b"/Artifact BMC".to_vec()));
}
}
if if require_all { found != *ids } else { found.is_empty() } {
return Ok(false);
// Content not found may be past the damage, or in a stream that doesn't decode: whether it
// is on the page isn't known.
if found != *ids && read.iter().any(|r| *r != Read::Whole) {
return Ok(Marked::Damaged);
}
let mut spliced = pieces.splice(edits).into_iter();
if if require_all { found != *ids } else { found.is_empty() } {
return Ok(Marked::NotFound);
}
let spliced = pieces.splice(edits);
if read.iter().zip(&decoded).zip(&spliced).any(|((read, old), new)| *read != Read::Whole && *old != new.as_slice()) {
return Ok(Marked::Damaged);
}
let mut spliced = spliced.into_iter();
let mut next = Vec::with_capacity(streams.len());
for (entry, stream) in streams {
let Some((stream, old)) = stream else {
let Some((stream, old, _)) = stream else {
next.push(entry);
continue;
};
@@ -456,7 +510,7 @@ fn artifact_marked_content(
_ => Object::Array(next),
};
doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), contents))?;
Ok(true)
Ok(Marked::Done)
}
/// Decorative figure: its marked content on the page becomes an artifact, and the element
@@ -468,11 +522,15 @@ pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError>
let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|e| e.object == obj).and_then(|e| e.page));
if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) {
let streams = decode_page_streams(doc, &page.dict).ok_or(AltError::TooLarge)?;
// The parent tree no longer points those ids at the figure.
if artifact_marked_content(doc, page, streams, &ids, false)?
&& let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int)
{
clear_parent_tree(doc, key, obj)?;
match artifact_marked_content(doc, page, streams, &ids, false)? {
// The parent tree no longer points those ids at the figure.
Marked::Done => {
if let Some(key) = page.dict.get(b"StructParents").and_then(|o| doc.resolve(o).as_int()) {
clear_parent_tree(doc, key, obj)?;
}
}
Marked::NotFound => {}
Marked::Damaged => return Err(AltError::Damaged),
}
}
// Out of its parent's kids.
@@ -513,10 +571,12 @@ pub fn mark_tag_artifact(doc: &mut Document, obj: ObjRef) -> Result<(), Artifact
let parent = element.dict.get(b"P").and_then(Object::as_ref).ok_or(ArtifactError::NoParent)?;
let replacement = replacement_kids(doc, parent, obj)?;
let streams = decode_page_streams(doc, &page.dict).ok_or(ArtifactError::TooLarge)?;
if !artifact_marked_content(doc, page, streams, &ids, true)? {
return Err(ArtifactError::ContentNotFound);
match artifact_marked_content(doc, page, streams, &ids, true)? {
Marked::Done => {}
Marked::NotFound => return Err(ArtifactError::ContentNotFound),
Marked::Damaged => return Err(ArtifactError::Damaged),
}
if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) {
if let Some(key) = page.dict.get(b"StructParents").and_then(|o| doc.resolve(o).as_int()) {
clear_parent_tree(doc, key, obj).map_err(|e| ArtifactError::ParentTree(e.to_string()))?;
}
doc.update_dict(parent, |p| p.set(b"K".to_vec(), replacement))?;
+198 -106
View File
@@ -1,14 +1,21 @@
//! Create a valid, reviewable structure tree for PDFs whose painted content is text only.
//! Create a valid, reviewable structure tree for untagged PDFs.
//!
//! The writer refuses mixed content and existing tags. Each text object becomes a paragraph,
//! which can then be retagged with the existing tag-property tool after human review.
//! Each text object becomes a paragraph, which can then be retagged with the existing
//! tag-property tool after human review. Painted paths, shadings and images outside text objects
//! are wrapped in `/Artifact BMC … EMC` so the resulting page passes the tagged-content check.
use pdfcraft_content::{Op, parse, serialize_ops};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use std::collections::{HashMap, HashSet};
use pdfcraft_content::{Op, Pieces, serialize_ops};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream};
const MAX_PAGES: usize = 1_000;
const MAX_PAGE_BYTES: usize = 16 * 1024 * 1024;
const MAX_OPERATORS: usize = 100_000;
/// What all pages together may decode and keep: pages can share one stream, so the per-page limits
/// alone let a small file retain a plan for every page. Real documents stay far under these.
const MAX_TOTAL_BYTES: usize = 128 * 1024 * 1024;
const MAX_TOTAL_OPERATORS: usize = 2_000_000;
const MAX_TEXT_OBJECTS: usize = 4_096;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -37,42 +44,58 @@ struct Plan {
page: ObjRef,
ops: Vec<Op>,
spans: Vec<(usize, usize)>,
artifacts: HashMap<usize, usize>,
}
fn unsupported(page: usize, reason: impl Into<String>) -> AutoTagError {
AutoTagError::Unsupported { page: page + 1, reason: reason.into() }
}
fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> Result<Vec<u8>, AutoTagError> {
let Some(contents) = page.dict.get(b"Contents") else { return Ok(Vec::new()) };
fn content_pieces(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> Result<Pieces, AutoTagError> {
let Some(contents) = page.dict.get(b"Contents") else { return Ok(Pieces::join::<&[u8]>(&[])) };
let resolved = doc.resolve(contents);
let pieces: Vec<Object> = match &*resolved {
let items: Vec<Object> = match &*resolved {
Object::Array(items) => items.clone(),
Object::Stream(_) => vec![contents.clone()],
_ => return Err(unsupported(index, "contents are not a stream or array of streams")),
};
let mut all = Vec::new();
for item in pieces {
let mut pieces = Vec::with_capacity(items.len());
let mut total = 0usize;
for item in items {
let value = doc.resolve(&item);
let Object::Stream(stream) = &*value else { return Err(unsupported(index, "contents include a non-stream object")) };
let remaining = MAX_PAGE_BYTES.saturating_sub(all.len().saturating_add(1));
let remaining = MAX_PAGE_BYTES.saturating_sub(total.saturating_add(1));
if remaining == 0 {
return Err(unsupported(index, "decoded content exceeds 16 MiB"));
return Err(unsupported(index, "decoding the content takes more than 16 MiB"));
}
let chain = stream.filters();
let decoded = if chain.is_empty() {
if stream.raw.len() > remaining {
return Err(unsupported(index, "decoded content exceeds 16 MiB"));
// Strictly, and with `/DecodeParms` given by reference: the content is written back from it.
let mut budget = DecodeBudget::new(remaining, 0);
let decoded = budget.decode_strict(doc, stream).map_err(|e| {
// The allowance counts what every filter stage decodes, as a page's does.
if budget.remaining() == 0 {
unsupported(index, "decoding the content takes more than 16 MiB")
} else {
// The decoder's own reason: `CosError::Filter`'s text would say again that the data
// couldn't be decoded.
let why = match e {
pdfcraft_cos::CosError::Filter(why) => why,
other => other.to_string(),
};
unsupported(index, format!("content cannot be decoded strictly: {why}"))
}
stream.raw.to_vec()
} else {
pdfcraft_filters::decode(&chain, &stream.raw, remaining)
.map_err(|e| unsupported(index, format!("content cannot be decoded strictly: {e}")))?
};
all.extend_from_slice(&decoded);
all.push(b' ');
})?;
total = total.saturating_add(decoded.len()).saturating_add(1);
pieces.push(decoded);
}
Ok(all)
Ok(Pieces::join(&pieces))
}
fn page_xobjects(doc: &Document, page: &Dict) -> Dict {
page.get(b"Resources")
.map(|r| doc.resolve(r))
.and_then(|r| r.as_dict().and_then(|r| r.get(b"XObject").map(|x| doc.resolve(x))))
.and_then(|x| x.as_dict().cloned())
.unwrap_or_default()
}
fn harmless_outside(op: &[u8]) -> bool {
@@ -100,9 +123,35 @@ fn harmless_outside(op: &[u8]) -> bool {
| b"sc"
| b"SCN"
| b"scn"
| b"m"
| b"l"
| b"c"
| b"v"
| b"y"
| b"h"
| b"re"
| b"W"
| b"W*"
| b"n"
)
}
fn painted_outside(doc: &Document, xobjects: &Dict, op: &Op) -> bool {
match op.op.as_slice() {
b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s" | b"sh" | b"BI" => true,
b"Do" => op
.name(0)
.and_then(|n| xobjects.get(n))
.map(|o| doc.resolve(o))
.and_then(|o| match &*o {
Object::Stream(s) => s.dict.name(b"Subtype").map(<[u8]>::to_vec),
_ => None,
})
.is_some_and(|st| st == b"Image"),
_ => false,
}
}
fn harmless_inside(op: &[u8]) -> bool {
matches!(
op,
@@ -132,17 +181,38 @@ fn harmless_inside(op: &[u8]) -> bool {
)
}
/// The `(BT, ET)` operator index pairs of each text object that shows text, or why the page cannot be tagged.
fn text_spans(ops: &[Op], index: usize) -> Result<Vec<(usize, usize)>, AutoTagError> {
let mut start = None;
let mut has_text = false;
let mut spans = Vec::new();
for (at, op) in ops.iter().enumerate() {
/// `totals` is the decoded bytes and operators of the pages planned so far, bounded by
/// `MAX_TOTAL_BYTES` and `MAX_TOTAL_OPERATORS`.
fn plan_page(doc: &Document, page: &pdfcraft_model::Page, index: usize, totals: &mut (usize, usize)) -> Result<Plan, AutoTagError> {
if page.dict.contains(b"StructParents") {
return Err(unsupported(index, "page already has a structure parent key"));
}
if page.dict.get(b"Annots").is_some_and(|a| match &*doc.resolve(a) {
Object::Array(items) => !items.is_empty(),
_ => true,
}) {
return Err(unsupported(index, "annotations need separate structure tags"));
}
let pieces = content_pieces(doc, page, index)?;
totals.0 = totals.0.saturating_add(pieces.data().len());
let parsed = pieces.parse();
if parsed.skipped > 0 || parsed.ambiguous_images > 0 || parsed.ops.len() > MAX_OPERATORS {
return Err(unsupported(index, "content has malformed or too many operators"));
}
totals.1 = totals.1.saturating_add(parsed.ops.len());
if totals.0 > MAX_TOTAL_BYTES || totals.1 > MAX_TOTAL_OPERATORS {
return Err(unsupported(index, "the document has too much content to auto-tag in one edit"));
}
let xobjects = page_xobjects(doc, &page.dict);
let (mut start, mut has_text, mut spans, mut artifacts) = (None, false, Vec::new(), HashMap::new());
// Marked-content boundaries must enclose the whole path, not just its paint operator.
let mut path = None;
for (at, op) in parsed.ops.iter().enumerate() {
if path.is_some() && matches!(op.op.as_slice(), b"BT" | b"Do" | b"BI" | b"sh") {
return Err(unsupported(index, "painted content interrupts an unfinished path"));
}
match op.op.as_slice() {
b"BT" if start.is_none() => {
start = Some(at);
has_text = false;
}
b"BT" if start.is_none() => (start, has_text) = (Some(at), false),
b"ET" => {
let Some(first) = start.take() else { return Err(unsupported(index, "unmatched ET operator")) };
if has_text {
@@ -154,14 +224,31 @@ fn text_spans(ops: &[Op], index: usize) -> Result<Vec<(usize, usize)>, AutoTagEr
}
b"Tj" | b"TJ" | b"'" | b"\"" if start.is_some() => has_text = true,
name if start.is_some() && harmless_inside(name) => {}
name if start.is_none() && harmless_outside(name) => {}
name if start.is_none() && harmless_outside(name) => match name {
b"m" | b"re" => {
path.get_or_insert(at);
}
b"n" => path = None,
_ => {}
},
_ if start.is_none() && painted_outside(doc, &xobjects, op) => {
let first = if matches!(op.op.as_slice(), b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s") {
path.take().unwrap_or(at)
} else {
at
};
artifacts.insert(first, at);
}
_ => return Err(unsupported(index, format!("unsupported or painted operator {}", String::from_utf8_lossy(&op.op)))),
}
}
if start.is_some() {
return Err(unsupported(index, "unterminated BT text object"));
}
Ok(spans)
if path.is_some() {
return Err(unsupported(index, "unterminated path object"));
}
Ok(Plan { page: page.obj, ops: parsed.ops, spans, artifacts })
}
fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
@@ -180,24 +267,11 @@ fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
}
let mut out = Vec::with_capacity(pages.len());
let mut total = 0usize;
let mut totals = (0usize, 0usize);
for (index, page) in pages.iter().enumerate() {
if page.dict.contains(b"StructParents") {
return Err(unsupported(index, "page already has a structure parent key"));
}
if page.dict.get(b"Annots").is_some_and(|a| match &*doc.resolve(a) {
Object::Array(items) => !items.is_empty(),
_ => true,
}) {
return Err(unsupported(index, "annotations need separate structure tags"));
}
let bytes = content_bytes(doc, page, index)?;
let parsed = parse(&bytes);
if parsed.skipped > 0 || parsed.ops.len() > MAX_OPERATORS {
return Err(unsupported(index, "content has malformed or too many operators"));
}
let spans = text_spans(&parsed.ops, index)?;
total = total.saturating_add(spans.len());
out.push(Plan { page: page.obj, ops: parsed.ops, spans });
let planned = plan_page(doc, page, index, &mut totals)?;
total = total.saturating_add(planned.spans.len());
out.push(planned);
}
if total == 0 {
return Err(AutoTagError::NoText);
@@ -205,62 +279,36 @@ fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
Ok(out)
}
/// Tag every text object in an untagged, text-only PDF as a paragraph. The full structure tree,
/// parent tree and marked-content IDs are written together. Mixed content is refused before any
/// mutation. On success the caller can use tag-property edits to assign reviewed heading roles.
pub fn autotag_text(doc: &mut Document) -> Result<AutoTagResult, AutoTagError> {
let plans = plan(doc)?;
let mut next = doc.clone();
let catalog = next.root().ok_or(AutoTagError::NoCatalog)?;
let root = next.add(Object::Null);
let document = next.add(Object::Null);
let mut children = Vec::new();
let mut numbers = Vec::new();
let mut tagged_pages = 0usize;
let mut paragraphs = 0usize;
for page in plans {
if page.spans.is_empty() {
continue;
fn mark_ops(ops: Vec<Op>, starts: &HashMap<usize, usize>, ends: &HashSet<usize>, artifacts: &HashMap<usize, usize>) -> Result<Vec<Op>, AutoTagError> {
let artifact_ends: HashSet<usize> = artifacts.values().copied().collect();
let extra = starts.len().saturating_add(artifacts.len()).saturating_mul(2);
let mut marked = Vec::with_capacity(ops.len().saturating_add(extra));
for (at, op) in ops.into_iter().enumerate() {
let is_artifact = artifacts.contains_key(&at);
if let Some(mcid) = starts.get(&at) {
let mut properties = Dict::new();
properties.set(b"MCID".to_vec(), Object::Int(i64::try_from(*mcid).map_err(|_| AutoTagError::TooManyPages)?));
marked.push(Op::new("BDC", vec![Object::name("P"), Object::Dict(properties)]));
} else if is_artifact {
marked.push(Op::new("BMC", vec![Object::name("Artifact")]));
}
let key = i64::try_from(tagged_pages).map_err(|_| AutoTagError::TooManyPages)?;
let mut parents = Vec::with_capacity(page.spans.len());
let mut starts = std::collections::HashMap::new();
let mut ends = std::collections::HashSet::new();
for (mcid, (first, last)) in page.spans.iter().enumerate() {
let mut element = Dict::new();
element.set(b"Type".to_vec(), Object::name("StructElem"));
element.set(b"S".to_vec(), Object::name("P"));
element.set(b"P".to_vec(), Object::Ref(document));
element.set(b"Pg".to_vec(), Object::Ref(page.page));
element.set(b"K".to_vec(), Object::Int(i64::try_from(mcid).map_err(|_| AutoTagError::TooManyPages)?));
let reference = next.add(element);
parents.push(Object::Ref(reference));
children.push(Object::Ref(reference));
starts.insert(*first, mcid);
ends.insert(*last);
paragraphs = paragraphs.saturating_add(1);
marked.push(op);
if ends.contains(&at) || artifact_ends.contains(&at) {
marked.push(Op::new("EMC", Vec::new()));
}
let mut marked = Vec::with_capacity(page.ops.len().saturating_add(page.spans.len().saturating_mul(2)));
for (at, op) in page.ops.into_iter().enumerate() {
if let Some(mcid) = starts.get(&at) {
let mut properties = Dict::new();
properties.set(b"MCID".to_vec(), Object::Int(i64::try_from(*mcid).map_err(|_| AutoTagError::TooManyPages)?));
marked.push(Op::new("BDC", vec![Object::name("P"), Object::Dict(properties)]));
}
marked.push(op);
if ends.contains(&at) {
marked.push(Op::new("EMC", Vec::new()));
}
}
let stream = next.add(Stream::flate(Dict::new(), &serialize_ops(&marked)));
next.update_dict(page.page, |d| {
d.set(b"Contents".to_vec(), Object::Ref(stream));
d.set(b"StructParents".to_vec(), Object::Int(key));
})?;
numbers.push(Object::Int(key));
numbers.push(Object::Array(parents));
tagged_pages += 1;
}
Ok(marked)
}
fn write_structure(
next: &mut Document,
catalog: ObjRef,
root: ObjRef,
document: ObjRef,
children: Vec<Object>,
numbers: Vec<Object>,
tagged_pages: usize,
) -> Result<(), AutoTagError> {
let mut tree = Dict::new();
tree.set(b"Nums".to_vec(), Object::Array(numbers));
let parent_tree = next.add(tree);
@@ -283,6 +331,50 @@ pub fn autotag_text(doc: &mut Document) -> Result<AutoTagResult, AutoTagError> {
d.set(b"StructTreeRoot".to_vec(), Object::Ref(root));
d.set(b"MarkInfo".to_vec(), Object::Dict(mark_info));
})?;
Ok(())
}
/// Tag every text object in an untagged PDF as a paragraph and mark painted paths, shadings and
/// images outside text objects as artifacts. The full structure tree, parent tree and
/// marked-content IDs are written together. Unsupported operators are refused before any
/// mutation. On success the caller can use tag-property edits to assign reviewed heading roles.
pub fn autotag_text(doc: &mut Document) -> Result<AutoTagResult, AutoTagError> {
let plans = plan(doc)?;
let mut next = doc.clone();
let catalog = next.root().ok_or(AutoTagError::NoCatalog)?;
let (root, document) = (next.add(Object::Null), next.add(Object::Null));
let (mut children, mut numbers, mut tagged_pages, mut paragraphs) = (Vec::new(), Vec::new(), 0usize, 0usize);
for page in plans {
if page.spans.is_empty() && page.artifacts.is_empty() {
continue;
}
let (mut starts, mut ends) = (HashMap::new(), HashSet::new());
if !page.spans.is_empty() {
let key = i64::try_from(tagged_pages).map_err(|_| AutoTagError::TooManyPages)?;
let mut parents = Vec::with_capacity(page.spans.len());
for (mcid, (first, last)) in page.spans.iter().enumerate() {
let mut element = Dict::new();
element.set(b"Type".to_vec(), Object::name("StructElem"));
element.set(b"S".to_vec(), Object::name("P"));
element.set(b"P".to_vec(), Object::Ref(document));
element.set(b"Pg".to_vec(), Object::Ref(page.page));
element.set(b"K".to_vec(), Object::Int(i64::try_from(mcid).map_err(|_| AutoTagError::TooManyPages)?));
let reference = next.add(element);
parents.push(Object::Ref(reference));
children.push(Object::Ref(reference));
starts.insert(*first, mcid);
ends.insert(*last);
paragraphs = paragraphs.saturating_add(1);
}
next.update_dict(page.page, |d| d.set(b"StructParents".to_vec(), Object::Int(key)))?;
numbers.extend([Object::Int(key), Object::Array(parents)]);
tagged_pages += 1;
}
let marked = mark_ops(page.ops, &starts, &ends, &page.artifacts)?;
let stream = next.add(Stream::flate(Dict::new(), &serialize_ops(&marked)));
next.update_dict(page.page, |d| d.set(b"Contents".to_vec(), Object::Ref(stream)))?;
}
write_structure(&mut next, catalog, root, document, children, numbers, tagged_pages)?;
*doc = next;
Ok(AutoTagResult { pages: tagged_pages, paragraphs })
}
+157 -20
View File
@@ -10,6 +10,23 @@ fn reference(object: Option<&Object>, what: &str) -> Result<ObjRef, String> {
object.and_then(Object::as_ref).ok_or_else(|| format!("the {what} must be an indirect reference"))
}
fn root_document_ref(doc: &Document, k: Option<&Object>) -> Result<ObjRef, String> {
let Some(k) = k else {
return Err("the structure tree root K must be an indirect reference".into());
};
if let Some(r) = k.as_ref()
&& doc.get(r).as_dict().is_some()
{
return Ok(r);
}
if let Some([item]) = doc.resolve(k).as_array().map(Vec::as_slice)
&& let Some(r) = item.as_ref()
{
return Ok(r);
}
Err("the structure tree root K must be an indirect reference".into())
}
fn nums(doc: &Document, parent_tree: ObjRef) -> Result<Vec<Object>, String> {
let tree = doc.get(parent_tree);
let tree = tree.as_dict().ok_or("the structure parent tree is invalid")?;
@@ -31,22 +48,32 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
let root = doc.root().ok_or("the PDF has no catalog")?;
let catalog_object = doc.get(root);
let catalog = catalog_object.as_dict().ok_or("the PDF catalog is invalid")?;
if !catalog.contains(b"AcroForm") {
if !catalog.get(b"AcroForm").is_some_and(|v| !matches!(*doc.resolve(v), Object::Null)) {
return Err("the PDF has no AcroForm fields".into());
}
let existing_root = catalog.get(b"StructTreeRoot").cloned();
let existing_root = catalog.get(b"StructTreeRoot").filter(|v| !matches!(*doc.resolve(v), Object::Null)).cloned();
let mark_info = catalog.get(b"MarkInfo").map(|v| doc.resolve(v)).as_deref().and_then(Object::as_dict).cloned().unwrap_or_default();
let pages = pdfcraft_model::pages(doc);
let mut widgets = Vec::new();
// A widget listed in more than one page's /Annots is tagged once (one key, one /Form).
let mut seen = std::collections::HashSet::new();
// A shared indirect /Annots array is scanned for the first page that lists it: its widgets
// are tagged once anyway, so scanning it again for every page only repeats the work.
let mut arrays = std::collections::HashSet::new();
for page in &pages {
if let Some(array) = page.dict.get(b"Annots").and_then(Object::as_ref)
&& !arrays.insert(array)
{
continue;
}
let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).as_deref().and_then(Object::as_array).cloned().unwrap_or_default();
for annot in annots {
let Some(reference) = annot.as_ref() else { continue };
let resolved = doc.get(reference);
let Some(dict) = resolved.as_dict() else { continue };
if dict.name(b"Subtype") == Some(b"Widget") && !dict.contains(b"StructParent") && seen.insert(reference) {
let is_widget = dict.get(b"Subtype").map(|s| doc.resolve(s)).as_deref().and_then(Object::as_name) == Some(b"Widget");
let has_parent = dict.get(b"StructParent").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).is_some_and(|k| k >= 0);
if is_widget && !has_parent && seen.insert(reference) {
widgets.push((page.obj, reference));
}
}
@@ -54,16 +81,29 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
if widgets.is_empty() {
return Err("the AcroForm has no untagged page widgets".into());
}
let count = i64::try_from(widgets.len()).map_err(|_| "too many form widgets")?;
let (struct_root, document_ref, parent_tree, mut parent_nums, first_key, new_tree) = if let Some(existing_root) = existing_root {
let (struct_root, document_ref, mut existing_kids, parent_tree, mut parent_nums, first_key, new_tree) = if let Some(existing_root) = existing_root
{
let struct_root = reference(Some(&existing_root), "structure tree root")?;
let structure_object = doc.get(struct_root);
let structure = structure_object.as_dict().ok_or("the structure tree root is invalid")?;
let document_ref = reference(structure.get(b"K"), "structure tree root K")?;
let document = doc.get(document_ref);
if document.as_dict().is_none_or(|d| d.name(b"S") != Some(b"Document")) {
let document_ref = root_document_ref(doc, structure.get(b"K"))?;
let document_obj = doc.get(document_ref);
let Some(document_dict) = document_obj.as_dict() else {
return Err("the structure tree root must point directly to a Document element".into());
};
if document_dict.get(b"S").map(|s| doc.resolve(s)).as_deref().and_then(Object::as_name) != Some(b"Document") {
return Err("the structure tree root must point directly to a Document element".into());
}
let existing_kids = match document_dict.get(b"K") {
None => Vec::new(),
Some(k) => match &*doc.resolve(k) {
Object::Null => Vec::new(),
Object::Array(items) => items.clone(),
_ => vec![k.clone()],
},
};
let parent_tree = reference(structure.get(b"ParentTree"), "structure parent tree")?;
let parent_nums = nums(doc, parent_tree)?;
if parent_nums.len() % 2 != 0 {
@@ -71,14 +111,26 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
}
let mut greatest = -1_i64;
for pair in parent_nums.as_chunks::<2>().0 {
let Some(key) = pair[0].as_int() else { return Err("the structure parent tree has a non-integer key".into()) };
let Some(key) = doc.resolve(&pair[0]).as_int() else {
return Err("the structure parent tree has a non-integer key".into());
};
if key < 0 {
return Err("the structure parent tree has a negative key".into());
}
greatest = greatest.max(key);
}
let first_key = greatest.checked_add(1).ok_or("the structure parent tree has no available key")?;
(struct_root, document_ref, parent_tree, parent_nums, first_key, false)
let mut first_key = greatest.checked_add(1).ok_or("the structure parent tree has no available key")?;
if let Some(next_obj) = structure.get(b"ParentTreeNextKey") {
let resolved_next = doc.resolve(next_obj);
if !matches!(*resolved_next, Object::Null) {
let next_key = resolved_next.as_int().ok_or("the structure parent tree next key is invalid")?;
if next_key < 0 {
return Err("the structure parent tree next key is negative".into());
}
first_key = first_key.max(next_key);
}
}
(struct_root, document_ref, existing_kids, parent_tree, parent_nums, first_key, false)
} else {
let mut root_dict = Dict::new();
root_dict.set(b"Type".to_vec(), Object::Name(b"StructTreeRoot".to_vec()));
@@ -88,9 +140,10 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
document.set(b"P".to_vec(), Object::Ref(struct_root));
let document_ref = doc.add(Object::Dict(document));
let parent_tree = doc.add(Object::Dict(Dict::new()));
(struct_root, document_ref, parent_tree, Vec::new(), 0, true)
(struct_root, document_ref, Vec::new(), parent_tree, Vec::new(), 0, true)
};
let mut kids = Vec::with_capacity(widgets.len());
// ISO 32000-1 14.7.4.4: reserve the next unused key before adding any parent-tree entries.
let next_key = first_key.checked_add(count).ok_or("the structure parent tree has no available next key")?;
for (offset, (page, widget)) in widgets.iter().copied().enumerate() {
let key = first_key.checked_add(i64::try_from(offset).map_err(|_| "too many form widgets")?).ok_or("too many form widgets")?;
let mut objr = Dict::new();
@@ -105,16 +158,10 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
let form_ref = doc.add(Object::Dict(form));
doc.update_dict(widget, |d| d.set(b"StructParent".to_vec(), Object::Int(key))).map_err(|e| e.to_string())?;
parent_nums.extend([Object::Int(key), Object::Ref(form_ref)]);
kids.push(Object::Ref(form_ref));
existing_kids.push(Object::Ref(form_ref));
}
doc.update_dict(document_ref, |d| {
let old = d.remove(b"K").unwrap_or(Object::Array(Vec::new()));
let mut all = match old {
Object::Array(items) => items,
item => vec![item],
};
all.extend(kids);
d.set(b"K".to_vec(), Object::Array(all));
d.set(b"K".to_vec(), Object::Array(existing_kids));
})
.map_err(|e| e.to_string())?;
doc.update_dict(parent_tree, |d| d.set(b"Nums".to_vec(), Object::Array(parent_nums))).map_err(|e| e.to_string())?;
@@ -122,6 +169,12 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
doc.update_dict(struct_root, |d| {
d.set(b"K".to_vec(), Object::Ref(document_ref));
d.set(b"ParentTree".to_vec(), Object::Ref(parent_tree));
d.set(b"ParentTreeNextKey".to_vec(), Object::Int(next_key));
})
.map_err(|e| e.to_string())?;
} else {
doc.update_dict(struct_root, |d| {
d.set(b"ParentTreeNextKey".to_vec(), Object::Int(next_key));
})
.map_err(|e| e.to_string())?;
}
@@ -136,3 +189,87 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
.map_err(|e| e.to_string())?;
Ok(widgets.len())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::tags;
fn pdf(bodies: &[String]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, b) in bodies.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
fn existing_tree(next: &str) -> Document {
pdf(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> /StructTreeRoot 5 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R] >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] >>".into(),
format!("<< /Type /StructTreeRoot /K 6 0 R /ParentTree 7 0 R {next} >>"),
"<< /S /Document /P 5 0 R /K [] >>".into(),
"<< /Nums [7 []] >>".into(),
"null".into(),
])
}
#[test]
fn extending_tree_creates_missing_next_key() {
for next in ["", "/ParentTreeNextKey null", "/ParentTreeNextKey 8 0 R"] {
let mut doc = existing_tree(next);
assert_eq!(tag_form_fields(&mut doc).unwrap(), 1);
assert_eq!(doc.get(ObjRef::new(4, 0)).as_dict().unwrap().int(b"StructParent"), Some(8));
assert_eq!(doc.get(ObjRef::new(5, 0)).as_dict().unwrap().int(b"ParentTreeNextKey"), Some(9));
}
}
#[test]
fn exhausted_next_key_rejects_before_mutation() {
let mut doc = existing_tree(&format!("/ParentTreeNextKey {}", i64::MAX));
let before = doc.get(ObjRef::new(4, 0));
let objects = doc.object_numbers().len();
assert!(tag_form_fields(&mut doc).is_err());
assert_eq!(doc.get(ObjRef::new(4, 0)), before);
assert_eq!(doc.object_numbers().len(), objects);
}
#[test]
fn extends_existing_tree_with_array_root_k_indirect_document_kids_and_indirect_nums_keys() {
let mut doc = pdf(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] /StructParents 0 >>".into(),
"<< /Length 0 >>\nstream\n\nendstream".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] /StructParent null >>".into(),
"<< /Type /StructTreeRoot /K [7 0 R] /ParentTree 8 0 R /ParentTreeNextKey 5 >>".into(),
"<< /S /Document /P 6 0 R /K 10 0 R >>".into(),
"<< /Nums [11 0 R [9 0 R]] >>".into(),
"<< /S /P /P 7 0 R /Pg 3 0 R /K 0 >>".into(),
"[9 0 R]".into(),
"0".into(),
]);
assert_eq!(tag_form_fields(&mut doc).unwrap(), 1);
// Existing paragraph (9 0 R) and new Form tag remain flat siblings under /Document (7 0 R).
let doc_k = doc.get(ObjRef::new(7, 0)).as_dict().unwrap().get(b"K").unwrap().as_array().unwrap().clone();
assert_eq!(doc_k.len(), 2);
assert_eq!(doc_k[0].as_ref(), Some(ObjRef::new(9, 0)));
let listed: Vec<_> = tags(&doc).into_iter().map(|t| t.kind).collect();
assert_eq!(listed, ["Document", "P", "Form"]);
// Widget got key 5 from /ParentTreeNextKey, and /ParentTreeNextKey advanced to 6.
assert_eq!(doc.get(ObjRef::new(5, 0)).as_dict().unwrap().int(b"StructParent"), Some(5));
assert_eq!(doc.get(ObjRef::new(6, 0)).as_dict().unwrap().int(b"ParentTreeNextKey"), Some(6));
}
}
+14 -9
View File
@@ -73,7 +73,8 @@ impl Walk<'_> {
// Marked-content stack: whether each level is tagged or an artifact.
let mut stack: Vec<bool> = Vec::new();
let mut untagged = 0;
for op in pdfcraft_content::parse(data).ops {
// One operator at a time: the walk keeps counts, never the stream's operators.
for op in pdfcraft_content::ops(data) {
let inside = covered || stack.last().copied().unwrap_or(false);
match op.op.as_slice() {
b"BMC" => stack.push(inside || op.name(0) == Some(b"Artifact")),
@@ -261,11 +262,13 @@ fn annotation_rect(doc: &Document, annot: &Dict) -> Option<[f64; 4]> {
pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usize], scan: &Scan, tagged: bool) -> Vec<Finding> {
let mut out = Vec::new();
let annots = |p: &Page| -> Vec<(ObjRef, Dict)> {
// The annotation objects are shared, never deep-copied: a reference repeated across a page's
// Annots array (or its pages) must not copy the dictionary each time.
let annots = |p: &Page| -> Vec<(ObjRef, std::sync::Arc<Object>)> {
let a = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
a.iter()
.filter_map(|x| x.as_ref().and_then(|r| doc.get(r).as_dict().cloned().map(|d| (r, d))))
.filter(|(_, d)| d.name(b"Subtype") != Some(b"Popup"))
.filter_map(|x| x.as_ref().map(|r| (r, doc.get(r))))
.filter(|(_, o)| o.as_dict().is_some_and(|d| d.name(b"Subtype") != Some(b"Popup")))
.collect()
};
match rule {
@@ -283,7 +286,8 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
Rule::TaggedAnnotations | Rule::TaggedMultimedia | Rule::TaggedFormFields => {
for &p in list {
let Some(page) = pages.get(p) else { continue };
for (_, a) in annots(page) {
for (_, obj) in annots(page) {
let Some(a) = obj.as_dict() else { continue };
let st = a.name(b"Subtype").unwrap_or(b"");
let applies = match rule {
Rule::TaggedFormFields => st == b"Widget",
@@ -298,11 +302,11 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
let pattern = if st == b"Widget" {
"Form field on page {p} isn't tagged"
} else {
values.push(("{}", subtype_name(&a)));
values.push(("{}", subtype_name(a)));
"{} annotation on page {p} isn't tagged"
};
let mut finding = Finding::worded(Some(p), pattern, values);
finding.bbox = annotation_rect(doc, &a);
finding.bbox = annotation_rect(doc, a);
out.push(finding);
}
}
@@ -344,7 +348,8 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
let mut seen = HashSet::new();
for &p in list {
let Some(page) = pages.get(p) else { continue };
for (r, a) in annots(page) {
for (r, obj) in annots(page) {
let Some(a) = obj.as_dict() else { continue };
if a.name(b"Subtype") != Some(b"Widget") {
continue;
}
@@ -361,7 +366,7 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
"Field \"{}\" on page {p} has no description (tooltip)",
vec![("{}", full_name(doc, field)), ("{p}", (p + 1).to_string())],
);
finding.bbox = annotation_rect(doc, &a);
finding.bbox = annotation_rect(doc, a);
out.push(finding);
}
}
+29 -4
View File
@@ -37,9 +37,16 @@ struct Walker<'a> {
visits: usize,
/// Per page: the text each MCID shows (read once per page).
texts: HashMap<usize, HashMap<i64, String>>,
/// What is left of [`MAX_TITLE_SOURCE_BYTES`].
title_left: usize,
out: Vec<Heading>,
}
/// Bytes of `/ActualText`, `/Alt` and `/T` strings the headings of one document decode in all.
/// Titles are cut to [`MAX_TITLE`] characters, but many headings can name one large string, and
/// each would otherwise be decoded in full first.
const MAX_TITLE_SOURCE_BYTES: usize = 32 << 20;
/// The headings, in document order. Headings without text or a page are left out.
pub fn headings(doc: &Document) -> Vec<Heading> {
let Some(root) = tree_root(doc) else { return Vec::new() };
@@ -52,6 +59,7 @@ pub fn headings(doc: &Document) -> Vec<Heading> {
seen: HashSet::new(),
visits: 0,
texts: HashMap::new(),
title_left: MAX_TITLE_SOURCE_BYTES,
out: Vec::new(),
};
if let Some(k) = root.get(b"K") {
@@ -131,17 +139,34 @@ impl Walker<'_> {
self.marks(kids, pg, depth + 1, &mut marks);
}
let Some(page) = pg.or_else(|| marks.iter().find_map(|m| m.0)).and_then(|p| self.page_index.get(&p).copied()) else { return };
let doc = self.doc;
let attr = |key: &[u8]| d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())).filter(|t| !t.trim().is_empty());
let title = match attr(b"ActualText") {
let title = match self.attr(d, b"ActualText") {
Some(t) => Some(t),
None => self.marked_text(&marks, page).or_else(|| attr(b"Alt")).or_else(|| attr(b"T")),
None => match self.marked_text(&marks, page) {
Some(t) => Some(t),
None => self.attr(d, b"Alt").or_else(|| self.attr(d, b"T")),
},
};
let Some(title) = title else { return };
let title: String = title.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(MAX_TITLE).collect();
self.out.push(Heading { obj, level, title, page });
}
/// The non-blank text of the string `key` holds, charged against the title allowance by the
/// longer of its raw and decoded size (a string that does not fit is not decoded).
fn attr(&mut self, d: &Dict, key: &[u8]) -> Option<String> {
let o = self.doc.resolve(d.get(key)?);
let s = o.as_string()?;
// A text string decodes to at most three bytes per raw byte, so one whose worst case does
// not fit the remaining allowance is never decoded: the raw check alone let a string at
// the raw limit decode to three times it before the allowance was reduced.
if s.bytes.len().saturating_mul(3) > self.title_left {
return None;
}
let text = s.to_text();
self.title_left = self.title_left.saturating_sub(text.len().max(s.bytes.len()));
Some(text).filter(|t| !t.trim().is_empty())
}
/// The text the marked content shows (`page`: where marks without a page of their own are).
fn marked_text(&mut self, marks: &[(Option<ObjRef>, i64)], page: usize) -> Option<String> {
let mut parts = Vec::new();
+359
View File
@@ -0,0 +1,359 @@
//! A layout table as plain blocks: a table used only to place content (label and value pairs, a
//! letterhead grid) is read as a data table, with headers and cell navigation it doesn't have. One
//! edit turns the table, its row groups and rows into Div elements and its cells into P elements
//! (Div when a cell holds tags of its own), keeping the content, its order and everything inside
//! the cells, and drops the Table attributes (summary, scope, spans) they no longer have (#4105).
use std::collections::HashSet;
use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object};
use crate::alt;
/// The most structure elements one table may have: past it the edit is refused.
const MAX_ELEMENTS: usize = 100_000;
/// The deepest nesting of direct (inline) elements followed.
const MAX_DEPTH: usize = 256;
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum LayoutTableError {
#[error("object {0} is not a reachable indirect structure tag")]
NotATag(ObjRef),
#[error("the tag is not a table")]
NotATable,
#[error("the table has too many tags, or tags nested too deeply")]
TooLarge,
#[error(transparent)]
Cos(#[from] CosError),
}
/// What a table part becomes; `None` for anything else (which keeps its type and contents).
fn block_type(ty: &[u8], has_tags: bool, root: bool) -> Option<&'static str> {
match ty {
b"Table" if root => Some("Div"),
b"THead" | b"TBody" | b"TFoot" | b"TR" => Some("Div"),
b"TH" | b"TD" | b"Caption" => Some(if has_tags { "Div" } else { "P" }),
_ => None,
}
}
struct Retag<'a> {
doc: &'a Document,
role_map: Dict,
/// Indirect elements under the table still to look at.
queue: Vec<ObjRef>,
/// How many elements changed.
changed: usize,
}
impl Retag<'_> {
fn standard(&self, d: &Dict) -> Option<Vec<u8>> {
Some(alt::standard(self.doc, &self.role_map, d.name(b"S")?))
}
/// Whether children `k` include structure elements (indirect or direct, also in an indirect
/// array of children).
fn has_tags(&self, k: &Object, depth: usize) -> bool {
if depth > MAX_DEPTH {
return false;
}
match k {
Object::Array(items) => items.iter().any(|i| self.has_tags(i, depth + 1)),
Object::Ref(r) => match &*self.doc.get(*r) {
Object::Dict(d) => d.contains(b"S"),
array @ Object::Array(_) => self.has_tags(array, depth + 1),
_ => false,
},
Object::Dict(d) => d.contains(b"S"),
_ => false,
}
}
/// Element `d` as a block when it is a part of the table (`root`: the table itself), with its
/// direct children converted the same way and its indirect ones queued; `None` otherwise.
fn convert(&mut self, d: &Dict, root: bool, depth: usize) -> Result<Option<Dict>, LayoutTableError> {
if depth > MAX_DEPTH {
return Err(LayoutTableError::TooLarge);
}
let Some(ty) = self.standard(d) else { return Ok(None) };
// As written: a single child is often a reference to it, which must stay one.
let kids = d.get(b"K").cloned();
let has_tags = kids.as_ref().is_some_and(|k| self.has_tags(k, 0));
let Some(block) = block_type(&ty, has_tags, root) else { return Ok(None) };
let mut out = d.clone();
out.set(b"S".to_vec(), Object::name(block));
self.drop_table_attributes(&mut out);
if let Some(kids) = kids {
let kids = self.convert_kids(&kids, depth + 1)?;
out.set(b"K".to_vec(), kids);
}
self.changed = self.changed.saturating_add(1);
Ok(Some(out))
}
/// Children `k` with direct table parts converted and indirect elements queued (references
/// stay references; an indirect array of children is written out only when a direct table
/// part in it changed).
fn convert_kids(&mut self, k: &Object, depth: usize) -> Result<Object, LayoutTableError> {
if depth > MAX_DEPTH {
return Err(LayoutTableError::TooLarge);
}
Ok(match k {
Object::Array(items) => Object::Array(items.iter().map(|i| self.convert_kids(i, depth)).collect::<Result<_, _>>()?),
Object::Ref(r) => match &*self.doc.get(*r) {
Object::Dict(d) if d.contains(b"S") => {
self.queue.push(*r);
k.clone()
}
array @ Object::Array(_) => {
let converted = self.convert_kids(array, depth + 1)?;
if converted == *array { k.clone() } else { converted }
}
_ => k.clone(),
},
Object::Dict(d) if d.contains(b"S") => match self.convert(d, false, depth)? {
Some(block) => Object::Dict(block),
None => k.clone(),
},
_ => k.clone(),
})
}
/// Remove the Table attribute objects (`/O /Table`) from `d`'s `/A`, keeping the others and
/// their revision numbers; an `/A` left empty goes.
fn drop_table_attributes(&self, d: &mut Dict) {
let Some(a) = d.get(b"A").cloned() else { return };
let is_table = |o: &Object| self.doc.resolve(o).as_dict().is_some_and(|x| x.name(b"O") == Some(b"Table"));
let resolved = self.doc.resolve(&a);
let Object::Array(items) = &*resolved else {
if is_table(&a) {
d.remove(b"A");
}
return;
};
let mut kept = Vec::with_capacity(items.len());
let mut dropping = false;
for item in items {
match item {
// A revision number goes with the attribute object before it.
Object::Int(_) if dropping => {}
_ => {
dropping = is_table(item);
if !dropping {
kept.push(item.clone());
}
}
}
}
if kept.is_empty() {
d.remove(b"A");
} else if kept.len() != items.len() {
d.set(b"A".to_vec(), Object::Array(kept));
}
}
}
/// Turn layout table `table` (a reachable indirect tag whose type is Table after the role map) into
/// Div and P elements as the module documentation says; returns how many elements changed. Tables
/// nested in its cells stay tables.
pub fn layout_table_to_blocks(doc: &mut Document, table: ObjRef) -> Result<usize, LayoutTableError> {
let Some(element) = alt::elements(doc).into_iter().find(|e| e.object == table) else {
return Err(LayoutTableError::NotATag(table));
};
let role_map = alt::tree_root(doc).and_then(|r| r.get(b"RoleMap").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
let mut retag = Retag { doc: &*doc, role_map, queue: Vec::new(), changed: 0 };
if retag.standard(&element.dict).as_deref() != Some(b"Table".as_slice()) {
return Err(LayoutTableError::NotATable);
}
// Read everything first, then write: a refused table changes nothing.
let mut updates: Vec<(ObjRef, Dict)> = Vec::new();
let mut seen = HashSet::new();
if let Some(block) = retag.convert(&element.dict, true, 0)? {
seen.insert(table);
updates.push((table, block));
}
while let Some(r) = retag.queue.pop() {
if !seen.insert(r) {
continue;
}
if seen.len() > MAX_ELEMENTS {
return Err(LayoutTableError::TooLarge);
}
let Some(d) = retag.doc.get(r).as_dict().cloned() else { continue };
if let Some(block) = retag.convert(&d, false, 0)? {
updates.push((r, block));
}
}
let changed = retag.changed;
for (r, block) in updates {
doc.update_dict(r, |d| *d = block)?;
}
Ok(changed)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use pdfcraft_cos::{Document, ObjRef, Object};
use super::*;
/// A tagged document: the catalog (1), the structure tree root (2, `root` its other entries)
/// over the Document element (3), then `objects` from 4 on.
fn doc(root: &str, objects: &[&str]) -> Document {
let mut bodies = vec!["<< /Type /Catalog /StructTreeRoot 2 0 R >>".to_string(), format!("<< /Type /StructTreeRoot /K 3 0 R {root} >>")];
bodies.extend(objects.iter().map(|s| s.to_string()));
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, b) in bodies.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
fn r(n: u32) -> ObjRef {
ObjRef::new(n, 0)
}
fn dict(doc: &Document, n: u32) -> Dict {
doc.get(r(n)).as_dict().cloned().unwrap()
}
fn ty(doc: &Document, n: u32) -> Vec<u8> {
dict(doc, n).name(b"S").unwrap().to_vec()
}
#[test]
fn a_label_and_value_table_becomes_divs_and_paragraphs() {
let mut d = doc(
"",
&[
"<< /S /Document /K [4 0 R] >>",
// 4: the table, with a layout attribute and a summary (revision 1).
"<< /S /Table /K [5 0 R 8 0 R] /A [ << /O /Layout /Placement /Block >> << /O /Table /Summary (s) >> 1 ] >>",
"<< /S /TR /K [6 0 R 7 0 R] >>",
"<< /S /TH /K 0 /A << /O /Table /Scope /Row >> >>",
"<< /S /TD /K 1 >>",
"<< /S /TR /K [9 0 R 10 0 R] >>",
"<< /S /TD /K 2 >>",
"<< /S /TD /K 3 /A [ << /O /Table /ColSpan 1 >> << /O /Layout /TextAlign /End >> ] >>",
],
);
let before: Vec<ObjRef> = crate::tags(&d).iter().map(|t| t.object).collect();
assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(7));
for (n, want) in [(4, "Div"), (5, "Div"), (6, "P"), (7, "P"), (8, "Div"), (9, "P"), (10, "P")] {
assert_eq!(ty(&d, n), want.as_bytes(), "object {n}");
}
// The content and the order stay; Table attributes go, layout ones stay.
assert_eq!(crate::tags(&d).iter().map(|t| t.object).collect::<Vec<_>>(), before);
assert_eq!(dict(&d, 6).get(b"K"), Some(&Object::Int(0)));
assert_eq!(dict(&d, 6).get(b"A"), None);
let Some(Object::Array(a)) = dict(&d, 4).get(b"A").cloned() else { panic!("{:?}", dict(&d, 4)) };
assert_eq!(a.len(), 1, "the summary and its revision number go: {a:?}");
assert_eq!(a[0].as_dict().and_then(|x| x.name(b"O")), Some(&b"Layout"[..]));
let Some(Object::Array(a)) = dict(&d, 10).get(b"A").cloned() else { panic!() };
assert_eq!(a.len(), 1);
}
#[test]
fn what_the_cells_hold_stays_and_a_table_in_a_cell_stays_a_table() {
let mut d = doc(
"",
&[
"<< /S /Document /K [4 0 R] >>",
"<< /S /Table /K [5 0 R] >>",
"<< /S /TR /K [6 0 R 7 0 R] >>",
// 6: a cell holding a paragraph; 7: a cell holding a table of its own.
"<< /S /TD /K [8 0 R] >>",
"<< /S /TD /K [9 0 R] >>",
"<< /S /P /K 0 >>",
"<< /S /Table /K [10 0 R] >>",
"<< /S /TR /K [11 0 R] >>",
"<< /S /TD /K 1 >>",
],
);
layout_table_to_blocks(&mut d, r(4)).unwrap();
for (n, want) in [(4, "Div"), (5, "Div"), (6, "Div"), (7, "Div"), (8, "P"), (9, "Table"), (10, "TR"), (11, "TD")] {
assert_eq!(ty(&d, n), want.as_bytes(), "object {n}");
}
}
#[test]
fn direct_rows_and_cells_are_converted_in_place() {
let mut d = doc(
"/RoleMap << /Grid /Table /Row /TR >>",
&[
"<< /S /Document /K [4 0 R] >>",
"<< /S /Grid /K [ << /S /Row /K [ << /S /TD /K 0 >> << /S /TD /K [5 0 R] >> ] >> ] >>",
"<< /S /Span /K 1 >>",
],
);
assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(4));
let table = dict(&d, 4);
assert_eq!(table.name(b"S"), Some(&b"Div"[..]), "the role map makes /Grid a table");
let Some(Object::Array(rows)) = table.get(b"K").cloned() else { panic!() };
let row = rows[0].as_dict().unwrap();
assert_eq!(row.name(b"S"), Some(&b"Div"[..]));
let Some(Object::Array(cells)) = row.get(b"K").cloned() else { panic!() };
assert_eq!(cells[0].as_dict().and_then(|c| c.name(b"S")), Some(&b"P"[..]));
assert_eq!(cells[1].as_dict().and_then(|c| c.name(b"S")), Some(&b"Div"[..]), "it holds a Span tag");
assert_eq!(ty(&d, 5), b"Span");
}
#[test]
fn only_a_reachable_table_is_changed() {
let mut d = doc("", &["<< /S /Document /K [4 0 R] >>", "<< /S /P /K 0 >>", "<< /S /Table >>"]);
assert_eq!(layout_table_to_blocks(&mut d, r(4)), Err(LayoutTableError::NotATable));
assert_eq!(layout_table_to_blocks(&mut d, r(5)), Err(LayoutTableError::NotATag(r(5))), "not in the structure tree");
assert_eq!(layout_table_to_blocks(&mut d, ObjRef::new(4, 1)), Err(LayoutTableError::NotATag(ObjRef::new(4, 1))));
assert_eq!(ty(&d, 5), b"Table");
}
#[test]
fn a_single_child_written_as_a_reference_stays_its_own_object() {
// /K 5 0 R: the table's one row, the row's one cell; 8: a row whose cells are an indirect
// array (object 9) holding a direct cell and a reference.
let mut d = doc(
"",
&[
"<< /S /Document /K [4 0 R 7 0 R] >>",
"<< /S /Table /K 5 0 R >>",
"<< /S /TR /K 6 0 R >>",
"<< /S /TD /K 0 >>",
"<< /S /Table /K 8 0 R >>",
"<< /S /TR /K 9 0 R >>",
"[ << /S /TD /K 1 >> 10 0 R ]",
"<< /S /TD /K 2 >>",
],
);
assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(3));
assert_eq!(dict(&d, 4).get(b"K"), Some(&Object::Ref(r(5))), "the row stays a reference");
assert_eq!(dict(&d, 5).get(b"K"), Some(&Object::Ref(r(6))));
for (n, want) in [(4, "Div"), (5, "Div"), (6, "P")] {
assert_eq!(ty(&d, n), want.as_bytes(), "object {n}");
}
assert!(crate::tags(&d).iter().any(|t| t.object == r(6)), "the cell is still listed");
assert_eq!(layout_table_to_blocks(&mut d, r(7)), Ok(4));
let Some(Object::Array(cells)) = dict(&d, 8).get(b"K").cloned() else { panic!("{:?}", dict(&d, 8)) };
assert_eq!(cells[0].as_dict().and_then(|c| c.name(b"S")), Some(&b"P"[..]));
assert_eq!(cells[1], Object::Ref(r(10)));
assert_eq!(ty(&d, 10), b"P");
}
#[test]
fn rows_that_point_at_each_other_end() {
// A row listed twice and a cell that lists its own row: each is converted once.
let mut d =
doc("", &["<< /S /Document /K [4 0 R] >>", "<< /S /Table /K [5 0 R 5 0 R] >>", "<< /S /TR /K [6 0 R] >>", "<< /S /TD /K [5 0 R] >>"]);
assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(3));
assert_eq!((ty(&d, 5), ty(&d, 6)), (b"Div".to_vec(), b"Div".to_vec()));
}
}
+2
View File
@@ -17,6 +17,7 @@ mod autotag;
mod autotag_fields;
mod content;
mod headings;
mod layout_table;
mod report;
mod structure;
mod tags;
@@ -41,6 +42,7 @@ pub use alt::{AltError, ArtifactError, Figure, figures, mark_decorative, mark_ta
pub use autotag::{AutoTagError, AutoTagResult, autotag_text};
pub use autotag_fields::tag_form_fields;
pub use headings::{Heading, headings};
pub use layout_table::{LayoutTableError, layout_table_to_blocks};
pub use report::{REPORT_WORDS, report_html};
pub use tags::{TagContentItem, TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tag_content, tags};
+267 -28
View File
@@ -101,6 +101,8 @@ pub enum TagError {
NotSiblings,
#[error("the parent must list each tag exactly once in its /K array")]
AmbiguousChildren,
#[error("the tag has too much or too deeply nested marked content")]
ContentLimit,
#[error("the tag is already at the requested position")]
AlreadyThere,
#[error("{0} applies only to {1} tags")]
@@ -118,7 +120,13 @@ pub fn move_tag(doc: &mut Document, object: ObjRef, anchor: ObjRef, placement: T
return Err(TagError::NotSiblings);
}
let reachable = alt::elements(doc);
if !reachable.iter().any(|e| e.object == object) || !reachable.iter().any(|e| e.object == anchor) {
let Some(obj_elem) = reachable.iter().find(|e| e.object == object) else {
return Err(TagError::NotSiblings);
};
let Some(anc_elem) = reachable.iter().find(|e| e.object == anchor) else {
return Err(TagError::NotSiblings);
};
if obj_elem.parent != anc_elem.parent {
return Err(TagError::NotSiblings);
}
let parent_of = |r| doc.get(r).as_dict().and_then(|d| d.get(b"P").and_then(Object::as_ref));
@@ -126,8 +134,23 @@ pub fn move_tag(doc: &mut Document, object: ObjRef, anchor: ObjRef, placement: T
if parent_of(anchor) != Some(parent) {
return Err(TagError::NotSiblings);
}
let valid_parent = match obj_elem.parent {
Some(expected) => parent == expected,
None => doc
.root()
.and_then(|r| doc.get(r).as_dict().and_then(|c| c.get(b"StructTreeRoot").and_then(Object::as_ref)))
.is_some_and(|root_ref| parent == root_ref),
};
if !valid_parent {
return Err(TagError::NotSiblings);
}
let owner = doc.get(parent);
let Some(children) = owner.as_dict().and_then(|d| d.get(b"K").and_then(Object::as_array)) else {
let mut k_obj = owner.as_dict().and_then(|d| d.get(b"K")).cloned();
for _ in 0..16 {
let Some(Object::Ref(r)) = k_obj else { break };
k_obj = Some((*doc.get(r)).clone());
}
let Some(children) = k_obj.and_then(|k| k.as_array().cloned()) else {
return Err(TagError::AmbiguousChildren);
};
let one_position = |child| {
@@ -135,7 +158,7 @@ pub fn move_tag(doc: &mut Document, object: ObjRef, anchor: ObjRef, placement: T
matches.next().and_then(|(index, _)| matches.next().is_none().then_some(index))
};
let (Some(from), Some(to)) = (one_position(object), one_position(anchor)) else { return Err(TagError::AmbiguousChildren) };
let mut reordered = children.clone();
let mut reordered = children;
let moved = reordered.remove(from);
let target = if from < to { to - 1 } else { to };
let destination = target + usize::from(placement == TagPlacement::After);
@@ -151,6 +174,10 @@ fn text(doc: &Document, d: &pdfcraft_cos::Dict, key: &[u8]) -> Option<String> {
d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(PdfString::to_text))
}
fn tag_type_name(doc: &Document, d: &Dict) -> Option<Vec<u8>> {
d.get(b"S").map(|s| doc.resolve(s)).and_then(|s| s.as_name().map(<[u8]>::to_vec))
}
/// List reachable, indirect tags in structure-tree order.
pub fn tags(doc: &Document) -> Vec<TagInfo> {
let pages = pdfcraft_model::pages(doc);
@@ -162,7 +189,7 @@ pub fn tags(doc: &Document) -> Vec<TagInfo> {
object: e.object,
parent: e.parent,
depth: e.depth,
kind: String::from_utf8_lossy(e.dict.name(b"S")?).into_owned(),
kind: String::from_utf8_lossy(&tag_type_name(doc, &e.dict)?).into_owned(),
title: text(doc, &e.dict, b"T"),
actual_text: text(doc, &e.dict, b"ActualText"),
alt: text(doc, &e.dict, b"Alt"),
@@ -176,13 +203,16 @@ pub fn tags(doc: &Document) -> Vec<TagInfo> {
}
/// List marked-content references directly owned by an indirect tag.
/// Returns `ContentLimit` rather than a partial list beyond 65,536 marks or 512 child/reference
/// levels. Child structure elements do not count as direct marks.
pub fn tag_content(doc: &Document, object: ObjRef) -> Result<Vec<TagContentItem>, TagError> {
let Some(element) = alt::elements(doc).into_iter().find(|e| e.object == object) else { return Err(TagError::NotATag(object)) };
let pages = pdfcraft_model::pages(doc);
let page_index: std::collections::HashMap<ObjRef, usize> = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
let mut marks = Vec::new();
let mut seen = std::collections::HashSet::new();
if let Some(kids) = element.dict.get(b"K") {
direct_marks(doc, kids, element.page, &mut marks);
direct_marks(doc, kids, element.page, &mut seen, &mut marks)?;
}
// Each page's content is parsed once, however many of its MCIDs the tag owns.
let mut texts: std::collections::HashMap<usize, std::collections::HashMap<i64, String>> = std::collections::HashMap::new();
@@ -206,25 +236,63 @@ pub fn tag_content(doc: &Document, object: ObjRef) -> Result<Vec<TagContentItem>
.collect())
}
fn direct_marks(doc: &Document, item: &Object, page: Option<ObjRef>, out: &mut Vec<(Option<ObjRef>, i64)>) {
match item {
Object::Int(mcid) => out.push((page, *mcid)),
Object::Array(items) => items.iter().for_each(|item| direct_marks(doc, item, page, out)),
Object::Dict(dict) if dict.name(b"Type") == Some(b"MCR") => {
if let Some(mcid) = dict.get(b"MCID").and_then(Object::as_int) {
out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid));
}
/// Bound both graph work and the queued array entries. Following references never uses the
/// call stack; the depth limit also rejects pathologically nested direct content explicitly.
fn direct_marks(
doc: &Document,
item: &Object,
page: Option<ObjRef>,
seen: &mut std::collections::HashSet<ObjRef>,
out: &mut Vec<(Option<ObjRef>, i64)>,
) -> Result<(), TagError> {
const MAX_DEPTH: usize = 512;
const MAX_MARKS: usize = 65_536;
// Account for the root `/K` container separately so an array of 65,536 entries passes.
let mut remaining = MAX_MARKS + 1;
let mut stack = vec![(std::sync::Arc::new(item.clone()), 0usize)];
while let Some((item, depth)) = stack.pop() {
remaining = remaining.checked_sub(1).ok_or(TagError::ContentLimit)?;
if depth > MAX_DEPTH {
return Err(TagError::ContentLimit);
}
Object::Ref(reference) => {
if let Some(dict) = doc.get(*reference).as_dict()
&& dict.name(b"Type") == Some(b"MCR")
&& let Some(mcid) = dict.get(b"MCID").and_then(Object::as_int)
{
out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid));
match &*item {
Object::Int(mcid) => {
if out.len() >= MAX_MARKS {
return Err(TagError::ContentLimit);
}
out.push((page, *mcid));
}
Object::Array(items) => {
if items.len() > remaining.saturating_sub(stack.len()) {
return Err(TagError::ContentLimit);
}
stack.extend(items.iter().rev().map(|item| (std::sync::Arc::new(item.clone()), depth + 1)));
}
Object::Dict(dict) if dict.get(b"Type").map(|t| doc.resolve(t)).as_deref().and_then(Object::as_name) == Some(b"MCR") => {
if let Some(mcid) = dict.get(b"MCID").map(|m| doc.resolve(m)).and_then(|m| m.as_int()) {
if out.len() >= MAX_MARKS {
return Err(TagError::ContentLimit);
}
out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid));
}
}
Object::Ref(reference) if seen.insert(*reference) => {
let target = doc.get(*reference);
// Resolved non-MCR dictionaries are child structure elements, not direct content,
// so do not push them onto the stack or charge them a second time.
let is_non_mcr_dict = matches!(
&*target,
Object::Dict(dict)
if dict.get(b"Type").map(|t| doc.resolve(t)).as_deref().and_then(Object::as_name) != Some(b"MCR")
);
if !is_non_mcr_dict {
stack.push((target, depth + 1));
}
}
_ => {}
}
_ => {}
}
Ok(())
}
/// The attribute objects of a tag's `/A` entry (one object, or an array of them each optionally
@@ -236,8 +304,8 @@ fn attribute_entries(doc: &Document, d: &Dict) -> (Vec<(Object, Option<Object>)>
let Object::Array(items) = &*resolved else { return (vec![(a.clone(), None)], false) };
let mut out: Vec<(Object, Option<Object>)> = Vec::new();
for item in items {
match (item, out.last_mut()) {
(Object::Int(_), Some((_, revision @ None))) => *revision = Some(item.clone()),
match (doc.resolve(item).as_int(), out.last_mut()) {
(Some(_), Some((_, revision @ None))) => *revision = Some(item.clone()),
_ => out.push((item.clone(), None)),
}
}
@@ -245,13 +313,18 @@ fn attribute_entries(doc: &Document, d: &Dict) -> (Vec<(Object, Option<Object>)>
}
/// Whether attribute object `o` (resolved) is a Table attribute object.
fn is_table_attributes(o: &Object) -> bool {
o.as_dict().is_some_and(|d| d.name(b"O") == Some(b"Table"))
fn is_table_attributes(doc: &Document, o: &Object) -> bool {
o.as_dict().and_then(|d| d.get(b"O")).map(|v| doc.resolve(v)).as_deref().and_then(Object::as_name) == Some(b"Table")
}
/// The value of `key` in the first Table attribute object of tag `d` that has it.
fn table_attribute(doc: &Document, d: &Dict, key: &[u8]) -> Option<Object> {
attribute_entries(doc, d).0.iter().map(|(o, _)| doc.resolve(o)).filter(|o| is_table_attributes(o)).find_map(|o| o.as_dict()?.get(key).cloned())
attribute_entries(doc, d)
.0
.iter()
.map(|(o, _)| doc.resolve(o))
.filter(|o| is_table_attributes(doc, o))
.find_map(|o| o.as_dict()?.get(key).map(|v| (*doc.resolve(v)).clone()).filter(|v| !matches!(v, Object::Null)))
}
/// Set `key` in the Table attribute object of `object`'s `/A` entry that holds it (else the first
@@ -266,11 +339,12 @@ fn set_table_attribute(doc: &mut Document, object: ObjRef, key: &[u8], value: Op
let mut entries: Vec<(Object, Option<Object>, Option<Dict>)> = entries
.into_iter()
.map(|(o, r)| {
let table = Some(doc.resolve(&o)).filter(|a| is_table_attributes(a)).and_then(|a| a.as_dict().cloned());
let table = Some(doc.resolve(&o)).filter(|a| is_table_attributes(doc, a)).and_then(|a| a.as_dict().cloned());
(o, r, table)
})
.collect();
let holding = |e: &(Object, Option<Object>, Option<Dict>)| e.2.as_ref().is_some_and(|d| d.contains(key));
let holding =
|e: &(Object, Option<Object>, Option<Dict>)| e.2.as_ref().and_then(|d| d.get(key)).is_some_and(|v| !matches!(*doc.resolve(v), Object::Null));
match value {
Some(v) => match entries.iter().position(holding).or_else(|| entries.iter().position(|e| e.2.is_some())) {
Some(i) => {
@@ -331,7 +405,7 @@ pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagPropert
if let Some(owner) = property.table_attribute_of() {
let role_map =
alt::tree_root(doc).and_then(|r| r.get(b"RoleMap").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
let ty = element.dict.name(b"S").map(|s| alt::standard(doc, &role_map, s)).unwrap_or_default();
let ty = tag_type_name(doc, &element.dict).map(|s| alt::standard(doc, &role_map, &s)).unwrap_or_default();
if ty != owner.as_bytes() {
let name = if property == TagProperty::Summary { "summary" } else { "scope" };
return Err(TagError::WrongTagType(name, owner));
@@ -357,3 +431,168 @@ pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagPropert
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
fn pdf(bodies: &[String]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, b) in bodies.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
#[test]
fn tag_content_and_table_attributes_resolve_indirect_entries() {
let content = "/P << /MCID 0 >> BDC BT /F1 12 Tf (Hello) Tj ET EMC /P << /MCID 1 >> BDC BT /F1 12 Tf (World) Tj ET EMC";
let doc = pdf(&[
"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /StructTreeRoot /K 6 0 R >>".into(),
"<< /S /Document /P 5 0 R /K [7 0 R 8 0 R] >>".into(),
"<< /S /Table /P 6 0 R /Pg 3 0 R /A << /O 11 0 R /Summary 12 0 R >> /K 9 0 R >>".into(),
"<< /S /TH /P 6 0 R /Pg 3 0 R /A [ << /O 11 0 R /Scope 13 0 R >> 14 0 R ] /K 10 0 R >>".into(),
"[15 0 R << /Type /MCR /MCID 16 0 R >>]".into(),
"15 0 R".into(),
"/Table".into(),
"(Indirect summary)".into(),
"/Column".into(),
"1".into(),
"0".into(),
"1".into(),
]);
let listed = tags(&doc);
let table = listed.iter().find(|t| t.object == ObjRef::new(7, 0)).unwrap();
let th = listed.iter().find(|t| t.object == ObjRef::new(8, 0)).unwrap();
assert_eq!(table.summary.as_deref(), Some("Indirect summary"));
assert_eq!(th.scope.as_deref(), Some("Column"));
let table_marks = tag_content(&doc, ObjRef::new(7, 0)).unwrap();
assert_eq!(
table_marks,
[TagContentItem { page: Some(0), mcid: 0, text: "Hello".into() }, TagContentItem { page: Some(0), mcid: 1, text: "World".into() },]
);
let th_marks = tag_content(&doc, ObjRef::new(8, 0)).unwrap();
assert_eq!(th_marks, [TagContentItem { page: Some(0), mcid: 0, text: "Hello".into() }]);
}
#[test]
fn move_tag_resolves_indirect_array_children_and_cycles() {
let mut doc = pdf(&[
"<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(),
"<< /Type /StructTreeRoot /K 3 0 R >>".into(),
"<< /S /Document /P 2 0 R /K 7 0 R >>".into(),
"<< /S /P /P 3 0 R >>".into(),
"<< /S /P /P 3 0 R >>".into(),
"[4 0 R 6 0 R 5 0 R]".into(),
"6 0 R".into(),
]);
let first = ObjRef::new(4, 7);
let second = ObjRef::new(5, 9);
doc.set(first, (*doc.get(ObjRef::new(4, 0))).clone());
doc.set(second, (*doc.get(ObjRef::new(5, 0))).clone());
doc.set(ObjRef::new(6, 0), Object::Array(vec![Object::Ref(first), Object::Ref(ObjRef::new(6, 0)), Object::Ref(second)]));
assert_eq!(tags(&doc).iter().map(|t| t.object).collect::<Vec<_>>(), [ObjRef::new(3, 0), first, second]);
move_tag(&mut doc, second, first, TagPlacement::Before).unwrap();
assert_eq!(tags(&doc).iter().map(|t| t.object).collect::<Vec<_>>(), [ObjRef::new(3, 0), second, first]);
}
#[test]
fn tag_content_rejects_excessive_indirect_depth() {
let mut doc =
pdf(&["<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), "<< /Type /StructTreeRoot /K 3 0 R >>".into(), "<< /S /P /P 2 0 R >>".into()]);
let mut kid = doc.add(Object::Int(42));
for _ in 0..128 {
kid = doc.add(Object::Ref(kid));
}
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Ref(kid))).unwrap();
assert_eq!(tag_content(&doc, ObjRef::new(3, 0)).unwrap().iter().map(|m| m.mcid).collect::<Vec<_>>(), [42]);
for _ in 0..1024 {
kid = doc.add(Object::Ref(kid));
}
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Ref(kid))).unwrap();
assert_eq!(tag_content(&doc, ObjRef::new(3, 0)), Err(TagError::ContentLimit));
}
#[test]
fn tag_content_rejects_excessive_mark_count() {
let mut doc =
pdf(&["<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), "<< /Type /StructTreeRoot /K 3 0 R >>".into(), "<< /S /P /P 2 0 R >>".into()]);
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Array(vec![Object::Int(1); 65_537]))).unwrap();
assert_eq!(tag_content(&doc, ObjRef::new(3, 0)), Err(TagError::ContentLimit));
}
#[test]
fn tag_content_accepts_full_mark_limit() {
let mut doc =
pdf(&["<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), "<< /Type /StructTreeRoot /K 3 0 R >>".into(), "<< /S /P /P 2 0 R >>".into()]);
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Array(vec![Object::Int(1); 65_536]))).unwrap();
assert_eq!(tag_content(&doc, ObjRef::new(3, 0)).unwrap().len(), 65_536);
}
#[test]
fn tag_content_skips_many_child_elements_without_spending_mark_budget() {
let mut doc =
pdf(&["<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), "<< /Type /StructTreeRoot /K 3 0 R >>".into(), "<< /S /P /P 2 0 R >>".into()]);
let mut kids = Vec::new();
for _ in 0..33_000 {
let mut child = Dict::new();
child.set(b"S".to_vec(), Object::name("P"));
kids.push(Object::Ref(doc.add(Object::Dict(child))));
}
kids.push(Object::Int(42));
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Array(kids))).unwrap();
assert_eq!(tag_content(&doc, ObjRef::new(3, 0)).unwrap().iter().map(|m| m.mcid).collect::<Vec<_>>(), [42]);
}
#[test]
fn tag_content_keeps_order_and_stops_indirect_cycles() {
let doc = pdf(&[
"<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(),
"<< /Type /StructTreeRoot /K 3 0 R >>".into(),
"<< /S /P /P 2 0 R /K 4 0 R >>".into(),
"[1 5 0 R 4 0 R 3]".into(),
"<< /Type /MCR /MCID 2 >>".into(),
]);
assert_eq!(tag_content(&doc, ObjRef::new(3, 0)).unwrap().iter().map(|m| m.mcid).collect::<Vec<_>>(), [1, 2, 3]);
}
#[test]
fn move_tag_rejects_non_sibling_tags_sharing_stale_parent_ref() {
// 8 0 R is a child of 6 0 R, while 9 0 R is a child of 7 0 R, even though both point /P at 6 0 R.
let mut doc = pdf(&[
"<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(),
"<< /Type /StructTreeRoot /K 3 0 R >>".into(),
"<< /S /Document /P 2 0 R /K [6 0 R 7 0 R] >>".into(),
"<< /S /P >>".into(),
"<< /S /P >>".into(),
"<< /S /Sect /P 3 0 R /K [8 0 R 9 0 R] >>".into(),
"<< /S /Sect /P 3 0 R /K [9 0 R] >>".into(),
"<< /S /P /P 6 0 R >>".into(),
"<< /S /P /P 6 0 R >>".into(),
]);
// Give 6 0 R only [8 0 R] in the reachable tree, and put [8 0 R 9 0 R] on an unreachable object 4 0 R.
doc.update_dict(ObjRef::new(6, 0), |d| d.set(b"K".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))).unwrap();
doc.update_dict(ObjRef::new(4, 0), |d| {
d.set(b"K".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0)), Object::Ref(ObjRef::new(9, 0))]));
})
.unwrap();
doc.update_dict(ObjRef::new(8, 0), |d| d.set(b"P".to_vec(), Object::Ref(ObjRef::new(4, 0)))).unwrap();
doc.update_dict(ObjRef::new(9, 0), |d| d.set(b"P".to_vec(), Object::Ref(ObjRef::new(4, 0)))).unwrap();
assert_eq!(move_tag(&mut doc, ObjRef::new(9, 0), ObjRef::new(8, 0), TagPlacement::Before), Err(TagError::NotSiblings));
}
}
+415 -2
View File
@@ -79,18 +79,183 @@ fn autotag_text_writes_marked_content_and_parent_tree_across_pages() {
assert_eq!(status(&check(&reopened, &Options::default()), Rule::TaggedContent), Status::Passed);
}
/// Page content behind a predictor whose `/DecodeParms` are given by reference is read through
/// them: autotagging writes the text back as it was, marked, not with the predictor's tag bytes in
/// it (a `\0` every eight bytes, which split `24` into `2 4`).
#[test]
fn autotag_text_refuses_paint_and_does_not_modify_the_document() {
fn autotag_text_reads_content_with_parameters_given_by_reference() {
use pdfcraft_cos::{Dict, ObjRef};
let mut content = b"BT /F1 24 Tf 72 700 Td (Hello World) Tj ET".to_vec();
content.resize(content.len().div_ceil(8) * 8, b' ');
// PNG rows of eight bytes, each tagged 0 (no prediction).
let rows: Vec<u8> = content.chunks(8).flat_map(|row| std::iter::once(0).chain(row.iter().copied())).collect();
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
stream(""),
FONT.into(),
],
"",
);
let parms: Dict = [(b"Predictor".to_vec(), Object::Int(12)), (b"Columns".to_vec(), Object::Int(8))].into_iter().collect();
let parms = doc.add(Object::Dict(parms));
let mut predicted = pdfcraft_cos::Stream::flate(Dict::new(), &rows);
predicted.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms));
doc.set(ObjRef::new(4, 0), predicted);
assert_eq!(autotag_text(&mut doc).unwrap(), AutoTagResult { pages: 1, paragraphs: 1 });
let page = &pdfcraft_model::pages(&doc)[0];
let written = doc.resolve(page.dict.get(b"Contents").unwrap());
let Object::Stream(written) = &*written else { panic!("page content") };
let text = String::from_utf8_lossy(&written.decoded_strict().unwrap()).into_owned();
assert!(text.contains("/F1 24 Tf") && text.contains("(Hello World) Tj"), "{text}");
}
/// Autotagging refuses a page whose content it can't read whole, and says why: content past
/// 16 MiB, or content that doesn't decode strictly, with the decoder's reason right after.
#[test]
fn autotag_text_says_why_it_cannot_read_a_page() {
use pdfcraft_cos::{Dict, ObjRef, Stream};
let reason = |content: Stream| {
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
stream(""),
FONT.into(),
],
"",
);
doc.set(ObjRef::new(4, 0), content);
match autotag_text(&mut doc) {
Err(AutoTagError::Unsupported { reason, .. }) => reason,
other => panic!("{other:?}"),
}
};
assert_eq!(reason(Stream::from_raw(Dict::new(), vec![b' '; 17 << 20])), "decoding the content takes more than 16 MiB");
let mut corrupt = Stream::from_raw(Dict::new(), b"not zlib data".to_vec());
corrupt.dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let why = reason(corrupt);
assert!(why.starts_with("content cannot be decoded strictly: FlateDecode: "), "{why}");
}
#[test]
fn autotag_text_marks_vector_graphics_and_images_as_artifacts() {
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R 7 0 R] /Count 2 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im1 6 0 R >> >> >>".into(),
stream("0 0 200 200 m 200 0 l 200 200 l 0 200 l h f 10 10 50 50 re B BT /F1 12 Tf 20 100 Td (Hello) Tj ET q 20 0 0 20 80 80 cm /Im1 Do Q"),
FONT.into(),
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 8 0 R >>".into(),
stream("0 0 100 100 re S"),
],
"",
);
let result = autotag_text(&mut doc).unwrap();
assert_eq!(result, AutoTagResult { pages: 1, paragraphs: 1 });
let report = check(&doc, &Options { rules: [Rule::TaggedPdf, Rule::TaggedContent].into(), pages: None });
assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed);
assert_eq!(status(&report, Rule::TaggedContent), Status::Passed);
let bytes = pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
let reopened = Document::open(Arc::new(bytes)).unwrap();
let reopened_report = check(&reopened, &Options { rules: [Rule::TaggedPdf, Rule::TaggedContent].into(), pages: None });
assert_eq!(status(&reopened_report, Rule::TaggedPdf), Status::Passed);
assert_eq!(status(&reopened_report, Rule::TaggedContent), Status::Passed);
}
#[test]
fn autotag_artifacts_wrap_complete_paths_and_leave_unpainted_clips_outside() {
let content = "0 0 m 10 0 l 10 10 l h f 0 0 20 20 re W B 0 0 50 50 re W* n /Shade sh BT /F1 12 Tf (Text) Tj ET 3 3 5 5 re S";
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
stream("BT /F1 12 Tf 20 100 Td (Hello) Tj ET 0 0 50 50 re f"),
stream(content),
FONT.into(),
],
"",
);
autotag_text(&mut doc).unwrap();
let bytes = pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
let doc = Document::open(Arc::new(bytes)).unwrap();
let page = pdfcraft_model::pages(&doc).remove(0);
let content = doc.resolve(page.dict.get(b"Contents").unwrap());
let Object::Stream(content) = &*content else { panic!("page stream") };
let ops = pdfcraft_content::parse(&content.decoded_strict().unwrap()).ops;
let names: Vec<&[u8]> = ops.iter().map(|op| op.op.as_slice()).collect();
assert_eq!(
names,
vec![
b"BMC".as_slice(),
b"m",
b"l",
b"l",
b"h",
b"f",
b"EMC",
b"BMC",
b"re",
b"W",
b"B",
b"EMC",
b"re",
b"W*",
b"n",
b"BMC",
b"sh",
b"EMC",
b"BDC",
b"BT",
b"Tf",
b"Tj",
b"ET",
b"EMC",
b"BMC",
b"re",
b"S",
b"EMC",
]
);
}
#[test]
fn autotag_rejects_text_interleaved_with_an_unfinished_path_atomically() {
for tail in ["10 10 l S", "n"] {
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
stream(&format!("0 0 m BT /F1 12 Tf (Text) Tj ET {tail}")),
FONT.into(),
],
"",
);
let before = pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
assert!(matches!(autotag_text(&mut doc), Err(AutoTagError::Unsupported { .. })));
assert_eq!(pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap(), before);
}
}
#[test]
fn autotag_text_refuses_form_xobjects_and_does_not_modify_the_document() {
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R >> >> >>".into(),
stream("BT /F1 12 Tf 20 100 Td (Hello) Tj ET /Fm1 Do"),
FONT.into(),
"<< /Type /XObject /Subtype /Form /BBox [0 0 50 50] /Length 12 >>\nstream\n0 0 10 10 re f\nendstream".into(),
],
"",
);
assert!(matches!(autotag_text(&mut doc), Err(AutoTagError::Unsupported { .. })));
assert!(!doc.get(doc.root().unwrap()).as_dict().unwrap().contains(b"StructTreeRoot"));
assert_eq!(autotag_text(&mut pdf(&good(), "")), Err(AutoTagError::AlreadyTagged));
@@ -1087,6 +1252,185 @@ fn mark_tag_artifact_validates_parent_and_all_mcids_before_mutating_document() {
assert_eq!(*absent.get(ObjRef::new(9, 0)), before_pt);
}
/// zlib data that reads as `readable` and is then damaged, with more of the stream after the
/// damage: a stored block, then a block of the reserved type (RFC 1951 §3.2.3), then `rest`.
fn damaged_flate(readable: &[u8], rest: &[u8]) -> pdfcraft_cos::Stream {
let len = u16::try_from(readable.len()).unwrap();
let mut raw = vec![0x78, 0x01, 0x00];
raw.extend_from_slice(&len.to_le_bytes());
raw.extend_from_slice(&(!len).to_le_bytes());
raw.extend_from_slice(readable);
raw.push(0x07);
raw.extend_from_slice(rest);
let mut dict = pdfcraft_cos::Dict::new();
dict.set(b"Filter".to_vec(), pdfcraft_cos::Object::name("FlateDecode"));
pdfcraft_cos::Stream::from_raw(dict, raw)
}
/// A tag's content in a stream damaged partway stays tagged: rewriting the stream from what
/// decoded would lose what follows the damage. Nothing changes, the structure tree and the stream
/// included. Content in a whole stream before a damaged one is marked, and the damaged one kept
/// as stored.
#[test]
fn a_tags_content_in_a_stream_damaged_partway_is_not_rewritten() {
use pdfcraft_cos::{ObjRef, Object};
let content = b"/Figure << /MCID 0 >> BDC q 40 0 0 20 10 150 cm /Im Do Q EMC /Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC\n";
let damaged = damaged_flate(content, b"0 0 1 rg 0 0 10 10 re f");
let mut doc = figures_doc();
doc.set(ObjRef::new(4, 0), damaged.clone());
let contents = |doc: &Document| doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned();
let state = |doc: &Document| (contents(doc), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)), doc.get(ObjRef::new(4, 0)));
let before = state(&doc);
assert_eq!(figures(&doc).len(), 2, "read up to the damage");
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged));
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged));
assert_eq!(state(&doc), before);
let mut doc = figures_doc();
let other = doc.add(damaged_flate(b"0 g\n", b"0 0 1 rg 0 0 10 10 re f"));
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(other)])))
.unwrap();
mark_tag_artifact(&mut doc, ObjRef::new(7, 0)).unwrap();
let Some(Object::Array(list)) = contents(&doc) else { panic!("contents {:?}", contents(&doc)) };
assert_ne!(list.first(), Some(&Object::Ref(ObjRef::new(4, 0))), "the tag's stream is rewritten");
assert_eq!(list.get(1), Some(&Object::Ref(other)), "kept as stored");
}
/// What follows the damage is unknown, the syntax the next stream starts in too, so a tag's
/// content in a stream after a damaged one stays tagged. Here a comment the damage leaves open
/// runs on into the next stream, up to its first end of line, and a string starting there reads
/// as operators, among them what looks like the figure's content: rewritten, it would change the
/// string. Nothing changes, the structure tree included.
#[test]
fn a_tags_content_after_a_damaged_stream_is_not_rewritten() {
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
let in_a_string = b"(a note\n/Figure << /MCID 0 >> BDC 0 g EMC\n) pop /Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC";
for (damaged, after) in [(b"0 g\n".as_slice(), None), (b"0 g % a comment".as_slice(), Some(in_a_string.as_slice()))] {
let mut doc = figures_doc();
if let Some(after) = after {
doc.set(ObjRef::new(4, 0), Stream::from_raw(Dict::new(), after.to_vec()));
}
let first = doc.add(damaged_flate(damaged, b"\n0 0 1 rg 0 0 10 10 re f"));
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(ObjRef::new(4, 0))])))
.unwrap();
let state = |doc: &Document| {
(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)))
};
let before = state(&doc);
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged), "{damaged:?}");
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged), "{damaged:?}");
assert_eq!(state(&doc), before, "{damaged:?}");
}
}
/// A page may split its content between an operator's operands and its keyword. When the operands
/// of the figure's `BDC` end the stream before one damaged from its start or one that doesn't
/// decode, the `BDC` the page reads is in what wasn't read: joined without it, those operands and
/// the next tag's read as one `BDC`, which reaches into the stream after it. Neither marking
/// changes anything, the structure tree included.
#[test]
fn a_tags_operands_before_a_stream_not_read_whole_are_not_rewritten() {
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
let rest = b" BDC q 40 0 0 20 10 150 cm /Im Do Q EMC";
for damaged in [true, false] {
let mut doc = figures_doc();
doc.set(ObjRef::new(4, 0), Stream::from_raw(Dict::new(), b"/Figure << /MCID 0 >>".to_vec()));
let hidden = if damaged {
damaged_flate(b"", rest)
} else {
let filter = doc.add(Object::name("FlateDecode"));
let mut s = Stream::flate(Dict::new(), rest);
s.dict.set(b"Filter".to_vec(), Object::Ref(filter));
s
};
let hidden = doc.add(hidden);
let after = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"/Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC".to_vec())));
let listed = Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(hidden), Object::Ref(after)]);
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), listed)).unwrap();
let state = |doc: &Document| {
(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)))
};
let before = state(&doc);
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged), "damaged: {damaged}");
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged), "damaged: {damaged}");
assert_eq!(state(&doc), before, "damaged: {damaged}");
}
}
/// A tag's content not found on the page may be in a stream that doesn't decode: it isn't known
/// to be off the page, so neither marking changes anything, the structure tree included.
#[test]
fn a_tags_content_not_found_beside_a_stream_that_does_not_decode_is_not_taken_for_absent() {
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
let mut doc = figures_doc();
doc.update_dict(ObjRef::new(7, 0), |d| d.set(b"K".to_vec(), Object::Int(2))).unwrap();
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
let other = doc.add(Object::Stream(Stream::from_raw(dict, b"/Figure << /MCID 2 >> BDC 0 g EMC".to_vec())));
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(other), Object::Ref(ObjRef::new(4, 0))])))
.unwrap();
let state = |doc: &Document| {
(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)))
};
let before = state(&doc);
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged));
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged));
assert_eq!(state(&doc), before);
}
/// A tag's content not found before the damage in a damaged stream may be past it: it isn't known
/// to be off the page, so neither marking changes anything.
#[test]
fn a_tags_content_past_the_damage_is_not_taken_for_absent() {
use pdfcraft_cos::ObjRef;
let damaged =
damaged_flate(b"/Photo << /MCID 1 >> BDC 100 100 50 30 re f EMC\n", b"/Figure << /MCID 0 >> BDC q 40 0 0 20 10 150 cm /Im Do Q EMC");
let mut doc = figures_doc();
doc.set(ObjRef::new(4, 0), damaged);
let contents = |doc: &Document| doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned();
let before = (contents(&doc), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0)));
assert_eq!(mark_decorative(&mut doc, ObjRef::new(7, 0)), Err(AltError::Damaged));
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::Damaged));
assert_eq!((contents(&doc), doc.get(ObjRef::new(9, 0)), doc.get(ObjRef::new(6, 0))), before);
}
/// A page's `/StructParents` given by reference is read through it: the parent tree no longer
/// points the marked content at the tag.
#[test]
fn struct_parents_given_by_reference_are_read() {
use pdfcraft_cos::{ObjRef, Object};
let by_reference = || {
let mut doc = figures_doc();
let key = doc.add(Object::Int(0));
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"StructParents".to_vec(), Object::Ref(key))).unwrap();
doc
};
let nums = |doc: &Document| doc.get(ObjRef::new(9, 0)).as_dict().and_then(|d| d.get(b"Nums").cloned());
let mut doc = by_reference();
mark_decorative(&mut doc, ObjRef::new(7, 0)).unwrap();
assert_eq!(nums(&doc), Some(Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Null, Object::Ref(ObjRef::new(8, 0))])])));
let mut doc = by_reference();
mark_tag_artifact(&mut doc, ObjRef::new(8, 0)).unwrap();
assert_eq!(nums(&doc), Some(Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Null])])));
}
/// A content stream that doesn't decode at all is kept as stored, and a tag in the page's other
/// streams is still marked: only running past the decode budget refuses.
#[test]
fn a_tag_beside_a_stream_that_does_not_decode_is_marked() {
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
let mut doc = figures_doc();
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
let other = doc.add(Object::Stream(Stream::from_raw(dict, b"0 g".to_vec())));
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(other), Object::Ref(ObjRef::new(4, 0))])))
.unwrap();
mark_tag_artifact(&mut doc, ObjRef::new(7, 0)).unwrap();
let contents = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned();
let Some(Object::Array(list)) = contents else { panic!("contents {contents:?}") };
assert_eq!(list.first(), Some(&Object::Ref(other)), "kept as stored");
}
fn ref_(n: u32) -> Option<pdfcraft_cos::ObjRef> {
Some(pdfcraft_cos::ObjRef::new(n, 0))
}
@@ -1318,6 +1662,24 @@ fn q_inside_a_text_object_restores_the_text_matrices() {
assert_eq!(titles, ["Chapter One", "Chapter Two"]);
}
#[test]
fn headings_naming_one_large_string_stop_decoding_it_at_the_allowance() {
let count = 100;
let kids = (0..count).map(|i| format!("{} 0 R", 8 + i)).collect::<Vec<_>>().join(" ");
let mut objects = vec![
"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".to_string(),
stream("BT ET"),
"<< /Type /StructTreeRoot /K 6 0 R >>".to_string(),
format!("<< /S /Document /P 5 0 R /K [{kids}] >>"),
format!("({})", "a".repeat(512 << 10)),
];
objects.extend((0..count).map(|_| "<< /S /H1 /P 6 0 R /Pg 3 0 R /ActualText 7 0 R >>".to_string()));
let found = headings(&pdf(&objects, ""));
assert!(!found.is_empty() && found.len() < count, "{}", found.len());
}
#[test]
fn an_untagged_document_has_no_headings() {
let mut objs = headings_doc();
@@ -1504,3 +1866,54 @@ fn marking_a_tag_artifact_refuses_content_past_the_decode_budget() {
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::TooLarge));
assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents"), Some(&contents), "the page keeps its content");
}
#[test]
fn autotag_text_refuses_pages_that_share_content_past_the_total_operator_allowance() {
// Each page is just under the per-page limit; sharing one stream makes the total 21 times it.
let content = format!("BT (a) Tj ET {}", "q Q ".repeat(49_990));
let pages = 21;
let kids: Vec<String> = (0..pages).map(|i| format!("{} 0 R", 4 + i)).collect();
let mut bodies = vec![
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 200 200] >>", kids.join(" ")),
stream(&content),
];
bodies.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R /Contents 3 0 R >>".to_string()));
let mut doc = pdf(&bodies, "");
let Err(AutoTagError::Unsupported { reason, .. }) = autotag_text(&mut doc) else { panic!("expected a refusal") };
assert!(reason.contains("too much content"), "{reason}");
}
#[test]
fn autotag_form_fields_tags_widgets_of_a_shared_annots_array_once() {
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(),
"<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >>".into(),
"<< /Length 0 >>\nstream\n\nendstream".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >>".into(),
"[5 0 R 5 0 R]".into(),
],
"",
);
assert_eq!(tag_form_fields(&mut doc).unwrap(), 1);
}
#[test]
fn checker_reports_each_untagged_annotation_without_copying_shared_dictionaries() {
let doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R 4 0 R 5 0 R] >>".into(),
"<< /Type /Annot /Subtype /Text /Rect [20 60 120 80] >>".into(),
"<< /Type /Annot /Subtype /Popup /Rect [20 60 120 80] >>".into(),
],
"",
);
let pages = pdfcraft_model::pages(&doc);
let found = content::page_rule(&doc, Rule::TaggedAnnotations, &pages, &[0], &Default::default(), false);
assert_eq!(found.len(), 2, "the popup is skipped, each listed text annotation reported");
}
+2
View File
@@ -11,6 +11,8 @@ workspace = true
[dependencies]
pdfcraft-cos = { workspace = true }
pdfcraft-fonts = { workspace = true }
# The usable-quad rule `/QuadPoints` readers share.
pdfcraft-geom = { workspace = true }
pdfcraft-model = { workspace = true }
thiserror = { workspace = true }
+18 -8
View File
@@ -6,13 +6,19 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`,
- **Builders** for the comment types Acrobat's commenting tools create: sticky note (`Text` +
`Popup`), highlight, underline, strikethrough, squiggly, rectangle (`Square`), oval (`Circle`),
line and arrow (`Line` with `/LE`), freehand drawing (`Ink`) and text box (`FreeText`).
line and arrow (`Line` with `/LE`), freehand drawing (`Ink`), text box and callout
(`FreeText`), polygon (including cloudy borders), polyline, caret, rubber and custom stamp,
and file attachment.
- **Appearance streams** (`appearance::build`) generated from the annotation dictionary, so new
and restyled comments look the same in every viewer. Highlights multiply (`/BM /Multiply`).
A line's leader lines (`/LL`, `/LLE`, `/LLO`) and caption (`/Cap`, `/CP`, `/CO`) are drawn as
ISO 32000-2 Table 178 places them, the caption in black Helvetica 10.
Note icons are PdfCraft's own drawings. Text boxes use standard Helvetica (WinAnsi), with
line breaking by approximate Helvetica proportions (no font program is bundled). Text it can't
line breaking by Helvetica's standard-14 metrics. Thai in a text box, callout or Fill & Sign
text is shaped and drawn from the craft-fonts Thai face with Add text's writer
(`pdfcraft_fonts::shaped_text`: Type 3 glyphs with a ToUnicode map), the box sized to the ink
of its stacked marks and descenders; a build without a Thai face refuses it
(`AnnotError::Undrawable`) instead of drawing `?`. Other text it can't
draw (in a text box, callout, Fill & Sign text, line caption, measurement label or dynamic
stamp's by-line; `appearance::undrawable_text`) is kept as typed and drawn as `?`, and the comment is marked with
the first such character (`pdfcraft_fonts::UNDRAWN_MARK`) so the engine reports the edit and
@@ -21,7 +27,9 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`,
comment another program drew with a font that has those characters, whose appearance is kept
rather than redrawn with `?` (new text for it is taken).
- **Edits:** reply (`/IRT`), set review status (a `/State` reply, as Acrobat's "Set status"
does), change text, move, resize, restyle, delete (with pop-up and replies).
does), change text, move, resize, restyle, delete (with pop-up and replies), and open, close
or move the pop-up window (`set_popup`: `/Popup` `/Open` and `/Rect`, created beside a comment
that has none; a note's own `/Open` follows). `summaries` report each comment's pop-up.
## API sketch
@@ -43,8 +51,8 @@ deterministic; the engine supplies them.
- Unknown keys are kept. Inline annotation dictionaries are promoted to indirect objects only
when an edit needs a reference to them.
- `set_style` refuses (and changes nothing) when it cannot redraw the comment, e.g. stamps,
cloudy borders or callouts, instead of leaving a stale appearance.
- `set_style` refuses when it cannot redraw the comment, e.g. a stamp
whose appearance cannot be regenerated, instead of leaving a stale appearance.
- Replies get the parent's `/Rect` and an empty appearance: they appear in comment lists but
never paint a second icon.
- Resizing a stamp changes its `/Rect` and preserves its original appearance and resources;
@@ -59,7 +67,9 @@ deterministic; the engine supplies them.
(vendored hayro-interpret patch 37), and `Summary::no_rotate` tells the UI where the icon is
drawn.
## Not yet
## Rich text and remaining limits
Callouts, polygons and polylines, clouds, carets (insert/replace text), stamps, file
attachments, rich text (`/RC`, `/DS`), and the shared named-appearance cache Acrobat writes.
Text-box text and colour edits regenerate `/RC` (XHTML) and `/DS` from the plain
`/Contents`, `/DA` and `/Q`, so rich-text editors show the same text and style. These edits
replace imported rich-text formatting. The shared named-appearance cache Acrobat writes
is not implemented.
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,4 +1,4 @@
use super::{Dict, Object, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi};
use super::{Dict, Object, PartOpacity, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi};
/// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp
/// another program wrote without an appearance. Names are matched byte for byte, as PDF names
@@ -51,7 +51,7 @@ pub(crate) fn display_stamp(d: &Dict) -> Option<Stream> {
// A malformed `/C` falls back to the stamp's own colour rather than hiding the stamp.
let stroke = color(d, b"C").flatten();
let mut res = Dict::new();
if let Some(gs) = ext_gstate(opacity, false) {
if let Some(gs) = ext_gstate(PartOpacity { line: opacity, fill: opacity, text: opacity }, false) {
res.set(b"ExtGState".to_vec(), Object::Dict(gs));
}
// A stamp whose intent (`/IT`, PDF 2.0) is an image or a snapshot has no `/Name` to default.
+184 -31
View File
@@ -1,6 +1,6 @@
use super::draw::draw_appearance;
use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots};
use super::modify::{n, rich_text};
use super::lookup::{annot_dict, annot_ref, annots, change_annots, fill_sign_shape, page_ref};
use super::modify::{free_text_da, n, refuse_undrawable_thai, rich_text, set_part_keys, shape_text_box};
use super::types::{
FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style,
};
@@ -103,12 +103,8 @@ pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotEr
}
grow(ink_bounds(strokes).unwrap_or_default(), half + 1.0)
}
Shape::Polygon { vertices, cloud } => {
let b = bounds(vertices.iter().copied())
.filter(|b| vertices.len() >= 3 && vertices.iter().all(|p| finite(p)) && b[2] - b[0] >= 1.0 && b[3] - b[1] >= 1.0);
let b = b.ok_or_else(|| bad("polygon (it needs three points)"))?;
grow(b, half + 1.0 + if *cloud { 1.5 * appearance::cloud_radius(style.width) } else { 0.0 })
}
// A new cloud has intensity 1 (`/BE /I 1`).
Shape::Polygon { vertices, cloud } => polygon_rect(vertices, style.width, cloud.then_some(1.0))?,
Shape::PolyLine { vertices, start, end } => {
let b = bounds(vertices.iter().copied())
.filter(|b| vertices.len() >= 2 && vertices.iter().all(|p| finite(p)) && (b[2] - b[0]).max(b[3] - b[1]) >= 1.0);
@@ -135,6 +131,19 @@ pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotEr
Ok(r)
}
/// Validate a polygon's corners and compute its `/Rect`: room for half the line, and with a
/// cloud of `cloud` intensity, for the farthest its bumps reach (`appearance::cloud_reach`).
pub(crate) fn polygon_rect(vertices: &[[f64; 2]], width: f64, cloud: Option<f64>) -> Result<[f64; 4], AnnotError> {
let b = bounds(vertices.iter().copied())
.filter(|b| vertices.len() >= 3 && vertices.iter().all(|p| finite(p)) && b[2] - b[0] >= 1.0 && b[3] - b[1] >= 1.0);
let b = b.ok_or_else(|| AnnotError::Invalid("invalid polygon (it needs three points)".into()))?;
let reach = cloud.map_or(0.0, |intensity| {
let perimeter: f64 = vertices.iter().zip(vertices.iter().cycle().skip(1)).map(|(a, b)| (b[0] - a[0]).hypot(b[1] - a[1])).sum();
appearance::cloud_reach(perimeter, appearance::cloud_bump(width, intensity))
});
Ok(grow(b, width.max(0.0) / 2.0 + 1.0 + reach))
}
pub(crate) fn base_dict(subtype: &str, rect: [f64; 4], page: ObjRef, contents: &str, author: &str, meta: &Meta) -> Dict {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("Annot"));
@@ -195,14 +204,110 @@ fn subject(shape: &Shape) -> &'static str {
pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] {
let [x0, y0, x1, y1] = normalize(rect);
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
if knee[0] < x0 {
[x0, cy]
} else if knee[0] > x1 {
[x1, cy]
} else if knee[1] > y1 {
[cx, y1]
match callout_side(rect, knee) {
Side::Left => [x0, cy],
Side::Right => [x1, cy],
Side::Top => [cx, y1],
Side::Bottom => [cx, y0],
}
}
/// A side of a callout's text box.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Side {
Left,
Right,
Top,
Bottom,
}
impl Side {
/// Whether a knee line on this side is horizontal (it meets the left or right side). Acrobat
/// lets a knee line be resized along its own direction only (Acrobat DC Help, 11/28/2019,
/// p. 357).
pub fn horizontal(self) -> bool {
matches!(self, Side::Left | Side::Right)
}
}
/// The side of the text box `rect` that faces `knee`: the one [`callout_attach`] attaches to.
///
/// Every position has one side (review on #4094):
/// - left or right of the box (`x < x0`, `x > x1`): that side, whatever `y` is, so a knee off a
/// corner diagonally, or on the line of the top or bottom edge past the box, is beside it;
/// - otherwise above or below it (`y > y1`, `y < y0`): that side, so a knee on the line of the
/// left or right edge past the box is above or below it;
/// - on the box's outline, or within `CALLOUT_SLACK` (0.01 pt) inside it, since the page hands
/// positions back as `f32` values: the side whose edge it lies on, the left or right edge
/// first, so a corner takes the left or right side as a knee beside the box does. The line
/// stays out of the box (it was sent across the box to the bottom side);
/// - inside the box, or with a coordinate that isn't a number: the bottom side.
pub fn callout_side(rect: [f64; 4], knee: [f64; 2]) -> Side {
let [x0, y0, x1, y1] = normalize(rect);
let [x, y] = knee;
// Before any comparison: one with NaN is false, so the other coordinate alone would decide.
if x.is_nan() || y.is_nan() {
return Side::Bottom;
}
if x < x0 {
Side::Left
} else if x > x1 {
Side::Right
} else if y > y1 {
Side::Top
} else if y < y0 {
Side::Bottom
} else if x - x0 < CALLOUT_SLACK {
Side::Left
} else if x1 - x < CALLOUT_SLACK {
Side::Right
} else if y1 - y < CALLOUT_SLACK {
Side::Top
} else {
[cx, y0]
Side::Bottom
}
}
/// A callout's knee line when Acrobat lays it out: 12 pt, out from the middle of the side. Seen in
/// Acrobat Pro DC, a 2021 Continuous release: a new callout's knee line, and one laid out again
/// after its knee was dragged into the box or the side it meets was moved, are all 12 pt, and the
/// knee can't come nearer the box than that.
pub const CALLOUT_KNEE_LINE: f64 = 12.0;
/// Where a knee goes on `side` of the text box `rect`: [`CALLOUT_KNEE_LINE`] out from the middle
/// of that side, so the knee line is level (left, right) or upright (top, bottom).
pub fn callout_knee_on(rect: [f64; 4], side: Side) -> [f64; 2] {
let [x0, y0, x1, y1] = normalize(rect);
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
match side {
Side::Left => [x0 - CALLOUT_KNEE_LINE, cy],
Side::Right => [x1 + CALLOUT_KNEE_LINE, cy],
Side::Top => [cx, y1 + CALLOUT_KNEE_LINE],
Side::Bottom => [cx, y0 - CALLOUT_KNEE_LINE],
}
}
/// A new callout's knee: on the side of its text box `rect` that faces the arrow `point`
/// ([`callout_knee_on`]).
pub fn callout_knee(rect: [f64; 4], point: [f64; 2]) -> [f64; 2] {
callout_knee_on(rect, callout_side(rect, point))
}
/// How far apart, in points, two positions of a callout's box or line may be and still count as the
/// same. The page hands a box back as `f32` values, which moves a coordinate such as 323.05 by a few
/// hundred-thousandths of a point (review on #4094).
pub(crate) const CALLOUT_SLACK: f64 = 0.01;
/// Whether `end` lies on `side` of the text box `rect`: the leader line can stay where it meets it.
pub(crate) fn on_side(rect: [f64; 4], side: Side, end: [f64; 2]) -> bool {
let [x0, y0, x1, y1] = normalize(rect);
let near = |a: f64, b: f64| (a - b).abs() < CALLOUT_SLACK;
let within = |v: f64, lo: f64, hi: f64| v > lo - CALLOUT_SLACK && v < hi + CALLOUT_SLACK;
match side {
Side::Left => near(end[0], x0) && within(end[1], y0, y1),
Side::Right => near(end[0], x1) && within(end[1], y0, y1),
Side::Top => near(end[1], y1) && within(end[0], x0, x1),
Side::Bottom => near(end[1], y0) && within(end[0], x0, x1),
}
}
@@ -214,8 +319,13 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
let rect = rect_for(&new.shape, style)?;
let mut d = base_dict(new.shape.subtype(), rect, page, &new.contents, &new.author, meta);
d.set(b"Subj".to_vec(), PdfString::text(subject(&new.shape)));
let opacity = style.opacity.clamp(0.0, 1.0);
set_non_opaque_style(&mut d, opacity);
if style.fill_opacity.into_iter().chain(style.text_opacity).any(|o| !o.is_finite()) {
return Err(AnnotError::Invalid("invalid opacity".into()));
}
let line = style.opacity.clamp(0.0, 1.0);
let fill = style.fill_opacity.map_or(line, |o| o.clamp(0.0, 1.0));
let text = style.text_opacity.map_or(fill, |o| o.clamp(0.0, 1.0));
set_part_keys(&mut d, appearance::PartOpacity { line, fill, text });
let border = |d: &mut Dict| {
let mut bs = Dict::new();
bs.set(b"W".to_vec(), Object::Real(style.width.max(0.0)));
@@ -282,15 +392,20 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
let list = contours.iter().filter(|c| c.len() > 2).map(|c| num_array(&c.iter().flat_map(|p| [p[0], p[1]]).collect::<Vec<_>>())).collect();
d.set(b"PCOutline".to_vec(), Object::Array(list));
}
Shape::CustomStamp { name, picture, image, size, .. } => {
Shape::CustomStamp { name, picture, image, size, line, .. } => {
let clean: String = name.chars().filter(char::is_ascii_alphanumeric).collect();
d.set(b"Name".to_vec(), Object::name(&format!("PCCustom{clean}")));
d.set(b"PCPicture".to_vec(), Object::Ref(*picture));
d.set(b"PCPictureImage".to_vec(), Object::Bool(*image));
d.set(b"PCPictureSize".to_vec(), num_array(&[size.0, size.1]));
let lined = line.as_deref().map(str::trim).filter(|l| !l.is_empty());
if let Some(l) = lined {
d.set(b"PCByLine".to_vec(), PdfString::text(l));
}
// An image signature's rectangle is in user space; on a turned page its appearance is
// turned back (see `appearance::build`), so it reads upright as displayed.
if *image && matches!(clean.as_str(), "Signature" | "Initials") {
// turned back (see `appearance::build`), so it reads upright as displayed. A stamp
// with a line is laid out in the displayed box, so it needs the turn too.
if lined.is_some() || (*image && matches!(clean.as_str(), "Signature" | "Initials")) {
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
if turn != 0 {
d.set(b"PCPictureRotate".to_vec(), Object::Int(turn));
@@ -380,20 +495,23 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
d.set(b"IT".to_vec(), Object::name("FreeTextTypeWriter"));
}
let size = if font_size.is_finite() && *font_size > 0.0 { font_size.clamp(1.0, 400.0) } else { 12.0 };
let [r, g, b] = style.color.map(|x| x.clamp(0.0, 1.0));
d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes()));
d.set(b"DA".to_vec(), free_text_da(style.color, style.border_color, size));
d.set(b"Q".to_vec(), Object::Int(0));
if let Some(f) = style.fill {
d.set(b"C".to_vec(), rgb(f));
}
border(&mut d);
rich_text(&mut d);
// The text is laid out for the page as displayed, like a stamp's label.
if !matches!(new.shape, Shape::Callout { .. }) {
// The text is laid out for the page as displayed, like a stamp's label.
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
if matches!(turn, 90 | 180 | 270) {
d.set(b"PCTextRotate".to_vec(), Object::Int(turn));
}
let rect = fit_thai_ink(rect, turn, &new.contents, size, 2.0 + appearance::border_width(&d));
// An oval or a circle goes around the text box, its text wrapped as displayed:
// after `/PCTextRotate`, which swaps the box's sides on a quarter-turned page.
shape_text_box(&mut d, rect, style.border_shape, style.width);
}
}
_ => {}
@@ -410,18 +528,43 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
{
return Err(AnnotError::Undrawable(pdfcraft_fonts::undrawable_message(what, c)));
}
refuse_undrawable_thai(d.name(b"Subtype") == Some(b"FreeText"), &new.contents)?;
let r = doc.add(Object::Dict(d.clone()));
draw_appearance(doc, r)?;
let mut list = annots(doc, page);
let index = list.len();
list.push(Object::Ref(r));
add_popup(doc, r, popup, &mut list)?;
set_annots(doc, page, list)?;
change_annots(doc, page, list)?;
Ok(index)
}
/// Text box `tb` (user space; its text upright on a page turned by `turn`), grown down as displayed
/// until the ink of `text`'s Thai (stacked marks above its first line, descenders below its last)
/// fits inside the padding `pad`, as the appearance lays it out at `size`. Other text, and a box
/// tall enough already, keep the box they were given.
fn fit_thai_ink(tb: [f64; 4], turn: i64, text: &str, size: f64, pad: f64) -> [f64; 4] {
if !text.chars().any(pdfcraft_fonts::shaped_text::is_thai) {
return tb;
}
let [x0, y0, x1, y1] = normalize(tb);
let (w, h) = if matches!(turn, 90 | 270) { (y1 - y0, x1 - x0) } else { (x1 - x0, y1 - y0) };
let need = appearance::text_lines_height(&appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)), size) + 2.0 * pad + 2.0;
let by = need - h;
if !(by.is_finite() && by > 0.0) {
return tb;
}
// Displayed down is user -y unturned, +x on a page turned 90°, +y at 180° and -x at 270°.
match turn {
90 => [x0, y0, x1 + by, y1],
180 => [x0, y0, x1, y1 + by],
270 => [x0 - by, y0, x1, y1],
_ => [x0, y0 - by, x1, y1],
}
}
fn validate_style(style: &Style) -> Result<(), AnnotError> {
if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() {
if !finite(&style.color) || style.border_color.is_some_and(|c| !finite(&c)) || !style.opacity.is_finite() || !style.width.is_finite() {
return Err(AnnotError::Invalid("invalid style".into()));
}
Ok(())
@@ -443,12 +586,6 @@ fn set_redaction_properties(dict: &mut Dict, style: &Style, quads: &[[f64; 8]],
}
}
fn set_non_opaque_style(dict: &mut Dict, opacity: f64) {
if opacity < 1.0 {
dict.set(b"CA".to_vec(), Object::Real(opacity));
}
}
fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots: &mut Vec<Object>) -> Result<(), AnnotError> {
let Some(rect) = rect else { return Ok(()) };
let mut popup = Dict::new();
@@ -464,6 +601,22 @@ fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots:
Ok(())
}
/// The share of a custom stamp's height its text line takes (the rest is the picture).
pub const CUSTOM_LINE_BAND: f64 = 0.22;
/// A custom stamp line's text height as a share of its band.
pub const CUSTOM_LINE_TEXT: f64 = 0.62;
/// The natural size of a custom stamp whose picture is `picture` points (#1741): with `line`,
/// the band under the picture is added, and the stamp is as wide as the line at its usual size
/// when that is wider than the picture.
pub fn custom_stamp_size(picture: (f64, f64), line: Option<&str>) -> (f64, f64) {
let Some(line) = line else { return picture };
let h = picture.1 / (1.0 - CUSTOM_LINE_BAND);
let text = appearance::text_width(line, h * CUSTOM_LINE_BAND * CUSTOM_LINE_TEXT) + 8.0;
(picture.0.max(text), h)
}
/// Counterrotate a newly placed picture stamp's appearance into displayed-page axes.
/// The picture may be an image or a PDF page. The normal appearance's transformed bounding
/// box is fitted to `/Rect` by PDF viewers.
+254 -29
View File
@@ -33,14 +33,70 @@ pub(crate) fn redraw_or_restore(doc: &mut Document, r: ObjRef, before: Dict) ->
pub(crate) fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> {
let d = annot_dict(doc, r);
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
let Some(stream) = build_appearance(doc, &d) else { return Err(AnnotError::Unsupported(subtype)) };
apply_appearance(doc, r, &d, stream)
let page = appearance::is_drawn_from_points(&d).then(|| page_box_listing(doc, r)).flatten();
let direct = with_direct_values(doc, &d);
let Some(stream) = build_appearance(doc, &d, &direct, page) else { return Err(AnnotError::Unsupported(subtype)) };
let built = Built::new(stream, &direct);
apply_appearance(doc, r, &d, built)
}
/// Draw the appearance for an annotation dictionary: the expensive half of [`set_appearance`].
/// Pure from the dictionary and the objects it points at, so [`AppearanceCache`] can reuse it.
fn build_appearance(doc: &Document, d: &Dict) -> Option<Stream> {
let mut stream = appearance::build(d)?;
/// Whether [`set_appearance`] can draw the annotation `d`: read as the display reads it, with
/// references followed and the same fallback. Edits check this before changing anything.
pub(crate) fn can_draw(doc: &Document, d: &Dict) -> bool {
build_appearance(doc, d, &with_direct_values(doc, d), None).is_some()
}
/// The visible region (`Page::crop`) of the page that lists `r` among its annotations: the page
/// its `/P` names when that page lists it, read up its own `/Parent` chain, else the first page
/// of the tree that lists it. An annotation is listed by one page only (ISO 32000-2 §12.5.2), so
/// both find the same page, and the first reads one page instead of walking the whole tree for
/// every comment redrawn.
fn page_box_listing(doc: &Document, r: ObjRef) -> Option<[f64; 4]> {
let named = annot_dict(doc, r).reference(b"P").filter(|p| annots(doc, *p).iter().any(|a| a.as_ref() == Some(r)));
if let Some(page) = named {
return Some(page_with_inherited_boxes(doc, page).crop(doc));
}
pdfcraft_model::pages(doc).into_iter().find(|p| annots(doc, p.obj).iter().any(|a| a.as_ref() == Some(r))).map(|p| p.crop(doc))
}
/// The page `page` with the `/MediaBox` and `/CropBox` it inherits from its ancestors filled in
/// (ISO 32000-2 §7.7.3.4), enough for [`pdfcraft_model::Page::crop`]. Follows at most 64
/// `/Parent` links and stops at a loop.
fn page_with_inherited_boxes(doc: &Document, page: ObjRef) -> pdfcraft_model::Page {
let mut dict = doc.get(page).as_dict().cloned().unwrap_or_default();
let mut seen = std::collections::HashSet::from([page]);
let mut up = dict.reference(b"Parent");
while let Some(node) = up {
if seen.len() > 64 || !seen.insert(node) {
break;
}
let obj = doc.get(node);
let Some(d) = obj.as_dict() else { break };
for key in [&b"MediaBox"[..], b"CropBox"] {
if !dict.contains(key)
&& let Some(v) = d.get(key)
{
dict.set(key.to_vec(), v.clone());
}
}
up = d.reference(b"Parent");
}
pdfcraft_model::Page { obj: page, dict }
}
/// Draw the appearance for an annotation dictionary on a page whose visible region is `page`
/// (used by annotations drawn through points of their own, [`appearance::build_on_page`]): the
/// expensive half of [`set_appearance`]. `direct` is `d` with references followed
/// ([`with_direct_values`]). Pure from the dictionary, the objects it points at and the page, so
/// [`AppearanceCache`] can reuse it.
fn build_appearance(doc: &Document, d: &Dict, direct: &Dict, page: Option<[f64; 4]>) -> Option<Stream> {
// References followed first. Where that turns up something the builder doesn't draw (a
// cloudy `/BE` on a square), the drawing made without them stays, rather than none.
let mut stream = match appearance::build_on_page(doc, direct, page) {
Some(stream) => stream,
None if !std::ptr::eq(direct, d) => appearance::build_on_page(doc, d, page)?,
None => return None,
};
// Picture-stamp restyling must retain the placement's page-axis correction.
if d.get(b"PCPicture").is_some() {
let matrix = d.get(b"AP").and_then(|ap| {
@@ -56,10 +112,172 @@ fn build_appearance(doc: &Document, d: &Dict) -> Option<Stream> {
Some(stream)
}
/// Entries that link the annotation to other objects rather than describe how it looks: left as
/// references by [`with_direct_values`].
const LINK_KEYS: [&[u8]; 5] = [b"P", b"Popup", b"IRT", b"Parent", b"AP"];
/// How deep inside an entry [`with_direct_values`] follows references: `/InkList` is an array of
/// arrays, `/Border` may end in a dash array.
const INLINE_DEPTH: usize = 4;
/// Values [`with_direct_values`] copies out of referenced arrays and dictionaries, or walks
/// before finding one too large to copy, for one annotation, so a file can't have a large object
/// copied or scanned for every entry that points at it.
pub(crate) const INLINE_BUDGET: usize = 1 << 16;
/// Values copied or walked for all the annotations one display pass draws
/// ([`with_missing_appearances_cached`]), so a large object that many annotations point at is
/// not copied, or scanned and found too large, for each of them without bound. Past it,
/// references are left as they are.
pub(crate) const PASS_BUDGET: usize = 1 << 22;
/// `d` with the references among the values [`appearance::build`] reads followed. Any value may be
/// given by reference (ISO 32000-2 §7.3.10): a border style dictionary (`/BS 5 0 R`, Table 168), a
/// colour, `/RD`, `/CL`, a `/W` inside `/BS`. The builder reads only direct values (apart from
/// `/QuadPoints`, which it resolves itself), so it draws from this copy. The entries in
/// [`LINK_KEYS`] and streams stay references; past [`INLINE_DEPTH`] levels or [`INLINE_BUDGET`]
/// copied values a reference is left as it is, which the builder treats as unreadable. `d` itself
/// when nothing is a reference.
pub(crate) fn with_direct_values<'d>(doc: &Document, d: &'d Dict) -> std::borrow::Cow<'d, Dict> {
with_direct_values_within(doc, d, &mut INLINE_BUDGET.clone())
}
/// [`with_direct_values`], copying at most [`INLINE_BUDGET`] values and no more than `pass` has
/// left, which is reduced by what was copied.
pub(crate) fn with_direct_values_within<'d>(doc: &Document, d: &'d Dict, pass: &mut usize) -> std::borrow::Cow<'d, Dict> {
let allowed = INLINE_BUDGET.min(*pass);
let mut budget = allowed;
let mut out: Option<Dict> = None;
for (key, value) in d.iter() {
if LINK_KEYS.contains(&key.as_slice()) {
continue;
}
if let Some(direct) = direct_value(doc, value, INLINE_DEPTH, &mut budget) {
out.get_or_insert_with(|| d.clone()).set(key.clone(), direct);
}
}
*pass = pass.saturating_sub(allowed.saturating_sub(budget));
out.map_or(std::borrow::Cow::Borrowed(d), std::borrow::Cow::Owned)
}
/// The number of values in `o`, counting into its arrays and dictionaries (not through
/// references, which are charged when followed), or, past `limit`, `Err` with the values it
/// visited or queued before finding that out (at least one), so a rejected walk is charged too.
/// Walks at most `limit` values and holds no more than `limit` waiting.
fn weight(o: &Object, limit: usize) -> Result<usize, usize> {
let mut count = 0usize;
let mut stack = vec![o];
while let Some(o) = stack.pop() {
let spent = count.saturating_add(stack.len()).max(1);
count = match count.checked_add(1).filter(|c| *c <= limit) {
Some(c) => c,
None => return Err(spent),
};
let inner = match o {
Object::Array(items) => items.len(),
Object::Dict(entries) => entries.len(),
_ => continue,
};
// Every value waiting is counted later, so more than `limit` in all is over it already.
if count.saturating_add(stack.len()).saturating_add(inner) > limit {
return Err(spent.saturating_add(1));
}
match o {
Object::Array(items) => stack.extend(items.iter()),
Object::Dict(entries) => stack.extend(entries.iter().map(|(_, v)| v)),
_ => {}
}
}
Ok(count)
}
/// `o` with references followed `depth` levels into arrays and dictionaries, or `None` when there
/// is nothing to follow (or it may not be: a stream, an over-budget object, a chain of references).
fn direct_value(doc: &Document, o: &Object, depth: usize, budget: &mut usize) -> Option<Object> {
let held;
let (base, followed) = match o {
Object::Ref(_) => {
// A spent budget follows nothing more, not even to find out what is there.
if *budget == 0 {
return None;
}
held = doc.resolve(o);
if matches!(&*held, Object::Stream(_) | Object::Ref(_)) {
return None;
}
// Everything copied counts, inner arrays and dictionaries too, and so does the walk
// that finds an object too large: many references to one would otherwise each walk
// it again for free.
match weight(&held, *budget) {
Ok(n) => *budget = budget.checked_sub(n)?,
Err(spent) => {
*budget = budget.saturating_sub(spent);
return None;
}
}
(&*held, true)
}
other => (other, false),
};
let Some(next) = depth.checked_sub(1) else { return followed.then(|| base.clone()) };
match base {
Object::Array(items) => {
let mut changed: Option<Vec<Object>> = None;
for (i, item) in items.iter().enumerate() {
if let Some(direct) = direct_value(doc, item, next, budget) {
let copy = changed.get_or_insert_with(|| items.clone());
if let Some(slot) = copy.get_mut(i) {
*slot = direct;
}
}
}
changed.map(Object::Array).or_else(|| followed.then(|| base.clone()))
}
Object::Dict(entries) => {
let mut changed: Option<Dict> = None;
for (key, value) in entries.iter() {
if let Some(direct) = direct_value(doc, value, next, budget) {
changed.get_or_insert_with(|| entries.clone()).set(key.clone(), direct);
}
}
changed.map(Object::Dict).or_else(|| followed.then(|| base.clone()))
}
_ => followed.then(|| base.clone()),
}
}
/// A built appearance and what storing it needs from the dictionary it was drawn from, read
/// with references followed, so a cached one is stored without following them again.
#[derive(Clone, Debug)]
struct Built {
stream: Stream,
/// The first character the standard fonts can't draw (it came out as `?`).
missing: Option<char>,
/// A note's or attachment's `/Rect`, as written.
rect: Option<Vec<f64>>,
/// Points (/QuadPoints, /L, /Vertices, /InkList) past /Rect: its appearance covers them, so
/// /Rect does too.
markup_rect: Option<[f64; 4]>,
}
impl Built {
fn new(stream: Stream, direct: &Dict) -> Self {
let missing = appearance::undrawable_text(direct).map(|(_, c)| c);
let rect = matches!(direct.name(b"Subtype"), Some(b"Text" | b"FileAttachment"))
.then(|| direct.get(b"Rect")?.as_array()?.iter().map(|o| o.as_f64()).collect::<Option<Vec<f64>>>())
.flatten();
let markup_rect = appearance::widened_rect(direct, &stream);
Built { stream, missing, rect, markup_rect }
}
}
/// Store a built appearance on the annotation: the cheap half of [`set_appearance`].
fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> Result<(), AnnotError> {
// Characters the standard fonts can't draw came out as `?`: mark the annotation with the first.
let missing = appearance::undrawable_text(d).map(|(_, c)| c);
fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, built: Built) -> Result<(), AnnotError> {
// `missing`: characters the standard fonts can't draw came out as `?`; the annotation is
// marked with the first.
let Built { mut stream, missing, rect, markup_rect } = built;
// A text box's Thai glyphs are streams of their own.
pdfcraft_fonts::shaped_text::store_streams(doc, &mut stream.dict);
let ap = doc.add(Object::Stream(stream));
// A copy (a shared /AP is left alone) that keeps unknown entries; the old down and rollover
// appearances would show the previous look on press or hover, so they go with the old /N.
@@ -70,17 +288,13 @@ fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) ->
// A note icon has a fixed size (`NOTE_SIZE`); viewers draw it at the upper-left corner of `/Rect`.
// The appearance's box is mapped onto `/Rect`, so any other `/Rect` (a large one, or one with
// no width, as LaTeX `pdfcomment` writes) would stretch the icon or shrink it to nothing.
let icon_rect = matches!(d.name(b"Subtype"), Some(b"Text" | b"FileAttachment"))
.then(|| d.get(b"Rect")?.as_array()?.iter().map(|o| o.as_f64()).collect::<Option<Vec<f64>>>())
.flatten()
.filter(|q| q.len() == 4 && finite(q))
.map(|q| {
let (x, top) = (q[0].min(q[2]), q[1].max(q[3]));
[x, top - NOTE_SIZE, x + NOTE_SIZE, top]
});
let icon_rect = rect.filter(|q| q.len() == 4 && finite(q)).map(|q| {
let (x, top) = (q[0].min(q[2]), q[1].max(q[3]));
[x, top - NOTE_SIZE, x + NOTE_SIZE, top]
});
doc.update_dict(r, |d| {
d.set(b"AP".to_vec(), Object::Dict(apd));
if let Some(rect) = icon_rect {
if let Some(rect) = icon_rect.or(markup_rect) {
d.set(b"Rect".to_vec(), num_array(&rect));
}
d.remove(b"AS");
@@ -101,7 +315,9 @@ fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) ->
/// refreshes) and keep it for content-only ones.
#[derive(Clone, Debug, Default)]
pub struct AppearanceCache {
entries: std::collections::HashMap<ObjRef, Stream>,
/// Each appearance with the page region it was drawn for (see [`appearance::build_on_page`]):
/// a changed crop box draws it again.
entries: std::collections::HashMap<ObjRef, (Option<[f64; 4]>, Built)>,
bytes: usize,
hits: u64,
builds: u64,
@@ -116,20 +332,21 @@ impl AppearanceCache {
*self = Self::default();
}
fn get(&mut self, r: ObjRef) -> Option<Stream> {
let stream = self.entries.get(&r)?.clone();
fn get(&mut self, r: ObjRef, crop: Option<[f64; 4]>) -> Option<Built> {
let built = self.entries.get(&r).filter(|(drawn_for, _)| *drawn_for == crop)?.1.clone();
self.hits += 1;
Some(stream)
Some(built)
}
fn insert(&mut self, r: ObjRef, stream: &Stream) {
fn insert(&mut self, r: ObjRef, crop: Option<[f64; 4]>, built: &Built) {
let stream = &built.stream;
self.builds += 1;
if self.entries.len() >= 1_000_000 {
self.clear();
}
if self.bytes.saturating_add(stream.raw.len()) <= Self::MAX_BYTES {
self.bytes += stream.raw.len();
self.entries.insert(r, stream.clone());
self.entries.insert(r, (crop, built.clone()));
}
// Else over budget: keep serving the entries already cached instead of growing.
}
@@ -163,6 +380,9 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac
// reads `doc`, which never gets the appearance, so every repeat used to add another stream to
// the copy, and the display bytes grew with the number of listings.
let mut seen = std::collections::HashSet::new();
// Pages' visible regions, read once and only when an annotation drawn through its points is.
let mut crops: Option<std::collections::HashMap<ObjRef, [f64; 4]>> = None;
let mut budget = PASS_BUDGET;
for page in page_refs(doc).ok()? {
for r in annots(doc, page).iter().filter_map(Object::as_ref) {
if !seen.insert(r) {
@@ -176,9 +396,12 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac
if has_normal {
continue;
}
if let Some(stream) = cache.get(r) {
let crop = appearance::is_drawn_from_points(&d)
.then(|| crops.get_or_insert_with(|| pdfcraft_model::pages(doc).iter().map(|p| (p.obj, p.crop(doc))).collect()).get(&page).copied())
.flatten();
if let Some(built) = cache.get(r, crop) {
// Fails only when `r` isn't a dictionary, which the cached build ruled out.
apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok();
apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, built).ok();
continue;
}
// Another program's stamp gets a drawing for display only (ISO 32000-2 Table 184). Its
@@ -188,10 +411,12 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac
bare.remove(b"AP");
appearance::display_stamp(&bare)
};
let Some(stream) = build_appearance(doc, &d).or_else(other_stamp) else { continue };
cache.insert(r, &stream);
let direct = with_direct_values_within(doc, &d, &mut budget);
let Some(stream) = build_appearance(doc, &d, &direct, crop).or_else(other_stamp) else { continue };
let built = Built::new(stream, &direct);
cache.insert(r, crop, &built);
// Fails only when `r` isn't a dictionary, which the build just ruled out.
apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok();
apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, built).ok();
}
}
copy
+173 -28
View File
@@ -1,8 +1,9 @@
use super::create::finite;
use super::draw::set_appearance;
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs};
use super::modify::{fillable, touch, unlocked};
use super::types::{FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb};
use super::modify::{callout_line, callout_text_box, fillable, touch, unlocked};
use super::text_style;
use super::types::{BorderShape, FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb};
use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance};
// ── reading ─────────────────────────────────────────────────────────────────────────────────
@@ -23,7 +24,8 @@ pub struct Summary {
pub in_reply_to: Option<String>,
/// The page and `/Annots` position `/IRT` points at, by reference (`/NM` need not be unique).
pub parent: Option<(usize, usize)>,
/// `/RT /Group`: grouped with the `/IRT` annotation (Replace Text's caret), not a reply to it.
/// `/RT /Group`: with an `/IRT` reference, a subordinate of the group whose primary that is
/// ([`is_group_subordinate`]), not a reply to it.
pub grouped: bool,
pub rect: [f32; 4],
pub color: Option<[f32; 3]>,
@@ -35,9 +37,66 @@ pub struct Summary {
pub fill_sign: bool,
/// The NoRotate flag (`/F` bit 5), as `pdfcraft_render::Annotation::no_rotate`.
pub no_rotate: bool,
/// A line's two ends (`/L`), or the corners of connected lines, a polygon or a cloud
/// (`/Vertices`), in user space; empty for other comments.
/// A line's two ends (`/L`), the corners of connected lines, a polygon or a cloud
/// (`/Vertices`), or a callout's arrow point and knee (`/CL` without its end, which follows
/// the text box), in user space; empty for other comments.
pub points: Vec<[f32; 2]>,
/// A callout's text box (`/Rect` inset by `/RD`), in user space; `None` for other comments.
pub text_box: Option<[f32; 4]>,
/// The comment's pop-up window (`/Popup`), when it has one.
pub popup: Option<PopupInfo>,
}
impl Summary {
/// A subordinate of a group (`/RT /Group` with an `/IRT` to a listed comment, ISO 32000-2
/// §12.5.6.2): part of its primary's comment, not a reply. As
/// `pdfcraft_render::Annotation::is_group_member`, which also drops a looping `/IRT`.
pub fn is_group_member(&self) -> bool {
self.grouped && self.parent.is_some()
}
}
/// A comment's pop-up window (ISO 32000-2 §12.5.6.14): where it sits on the page and whether it
/// is shown open.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PopupInfo {
/// `/Rect` of the `/Popup` annotation, in user space.
pub rect: [f32; 4],
/// `/Open` of the `/Popup` annotation.
pub open: bool,
}
/// The pop-up the comment `annot` (the object `own`, when it is one of its own) names in
/// `/Popup`, when it is the comment's: a pop-up annotation whose `/Parent`, if it has one, is a
/// reference to the comment. A `/Popup` naming another annotation, or a pop-up of another
/// comment, isn't. ISO 32000-2:2020 §12.5.6.14, Table 186: `/Parent` is "(Optional; shall be
/// an indirect reference)".
pub(crate) fn own_popup(doc: &Document, own: Option<ObjRef>, annot: &Dict) -> Option<ObjRef> {
let pr = annot.reference(b"Popup")?;
let is_own = doc.get(pr).as_dict().is_some_and(|pd| {
pd.name(b"Subtype") == Some(b"Popup")
&& pd
.get(b"Parent")
.is_none_or(|parent| parent.as_ref().is_some_and(|parent| own.is_some_and(|own| final_ref(doc, parent) == final_ref(doc, own))))
});
is_own.then_some(pr)
}
/// The comment's own pop-up (see [`own_popup`]), as [`PopupInfo`]: `None` when it has none or it
/// is unreadable.
pub(crate) fn popup_info(doc: &Document, own: Option<ObjRef>, annot: &Dict) -> Option<PopupInfo> {
// Another annotation's rectangle and open state aren't this comment's (review on #2137).
let r = own_popup(doc, own, annot)?;
let p = doc.get(r);
let p = p.as_dict()?;
let rect: Vec<f32> = doc.resolve(p.get(b"Rect")?).as_array()?.iter().filter_map(|v| doc.resolve(v).as_f64().map(|x| x as f32)).collect();
// Four numbers that are finite as `f32` (a huge one becomes infinite), corners in any order.
let [x0, y0, x1, y1] = rect[..] else { return None };
if !rect.iter().all(|v| v.is_finite()) {
return None;
}
let open = p.get(b"Open").map(|o| doc.resolve(o)).is_some_and(|o| matches!(&*o, Object::Bool(true)));
Some(PopupInfo { rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)], open })
}
pub(crate) fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
@@ -47,8 +106,12 @@ pub(crate) fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option<String>
Object::Name(n) => String::from_utf8_lossy(n).into_owned(),
_ => return None,
};
let s = s.trim_matches('\0').trim().to_string();
(!s.is_empty()).then_some(s)
let s = s.trim_matches('\0');
// Typewriter outer whitespace belongs to the text (keep in step with render's inspector).
let name_is = |key: &[u8], expected: &[u8]| d.get(key).is_some_and(|o| doc.resolve(o).as_name() == Some(expected));
let keep_breaks = key == b"Contents" && name_is(b"Subtype", b"FreeText") && name_is(b"IT", b"FreeTextTypeWriter");
let s = if keep_breaks { s } else { s.trim() };
(!s.trim().is_empty()).then(|| s.to_owned())
}
/// Whether an annotation subtype is a comment. Links, form widgets, pop-ups and the non-markup
@@ -60,7 +123,7 @@ pub fn is_comment_subtype(subtype: &str) -> bool {
/// Where a reference to a reference (`5 0 obj 6 0 R endobj`) ends, as `pdfcraft_render::inspect`
/// follows it, within a few steps (a cycle stops there).
fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef {
pub(crate) fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef {
for _ in 0..8 {
match &*doc.get(r) {
Object::Ref(next) => r = *next,
@@ -70,6 +133,15 @@ fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef {
r
}
/// Whether an annotation is a subordinate of a group rather than a reply: `/RT /Group` with an
/// `/IRT` reference (ISO 32000-2 §12.5.6.2, Table 172). The group's primary is the `/IRT`
/// annotation, and the subordinate's own text, author, colour, dates and pop-up are ignored.
/// `/RT` is `R` by default, and a value other than `Group` reads as `R` too. Keep in step with
/// `pdfcraft_render::inspect`.
pub fn is_group_subordinate(d: &Dict) -> bool {
d.name(b"RT") == Some(b"Group") && d.reference(b"IRT").is_some()
}
/// Every comment (see [`is_comment_subtype`]), ordered by page and then top edge.
pub fn summaries(doc: &Document) -> Vec<Summary> {
let mut out = Vec::new();
@@ -101,7 +173,10 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
let r = nums(b"Rect");
let rect = if r.len() == 4 { [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] } else { [0.0; 4] };
let c = nums(b"C");
let mut color = (c.len() == 3).then(|| [c[0], c[1], c[2]]);
let rgb = (c.len() == 3).then(|| [c[0], c[1], c[2]]);
// Other comments report the stroke colour as it is drawn (grey, CMYK, a null entry
// read as 0), like `props`; three numbers, some given by reference, are still read.
let mut color = if subtype == "FreeText" { rgb } else { summary_color(doc, d).or(rgb) };
if subtype == "FreeText"
&& let Some(da) = text_value(doc, d, b"DA")
{
@@ -113,16 +188,39 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
color = Some([f(i - 3), f(i - 2), f(i - 1)]);
}
}
let quads = nums(b"QuadPoints").as_chunks::<8>().0.to_vec();
// Keep in step with `pdfcraft_render::inspect`.
// Only usable quads, by the rule redaction apply reads them with, so a mark's outline
// is the area apply removes (and with none, the `/Rect` both fall back to).
let quads: Vec<[f32; 8]> = d
.get(b"QuadPoints")
.map(|o| doc.resolve(o))
.map(|a| {
let entries = a.as_array().into_iter().flatten().map(|x| doc.resolve(x).as_f64());
pdfcraft_geom::quad_points::usable_quads(entries).into_iter().map(|q| q.map(|v| v as f32)).collect()
})
.unwrap_or_default();
// Keep in step with `pdfcraft_render::inspect`: a callout gives its points only with a
// readable text box, without which they can't be changed either.
let callout = (subtype == "FreeText").then(|| callout_line(doc, d).zip(callout_text_box(doc, d))).flatten();
let points = match subtype.as_str() {
"Line" => Some(nums(b"L")).filter(|l| l.len() == 4).map(|l| l.as_chunks::<2>().0.to_vec()).unwrap_or_default(),
"PolyLine" | "Polygon" => nums(b"Vertices").as_chunks::<2>().0.to_vec(),
"FreeText" => callout
.as_ref()
.and_then(|(line, _)| line.split_last())
.map(|(_, lead)| lead.iter().map(|p| [p[0] as f32, p[1] as f32]).collect())
.unwrap_or_default(),
_ => Vec::new(),
};
let text_box = callout.map(|(_, t)| t.map(|v| v as f32));
let irt = d.get(b"IRT").map(|o| o.as_ref().map_or_else(|| doc.resolve(o), |r| doc.get(final_ref(doc, r))));
let in_reply_to = irt.and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM"));
let parent = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| slots.get(&final_ref(doc, r)).copied());
// A group is one page's, as Acrobat reads it: a subordinate whose primary is on
// another page is a comment of its own (keep in step with
// `pdfcraft_render::link_replies`).
let grouped = d.name(b"RT") == Some(b"Group");
let cross_page = grouped && parent.is_some_and(|(p, _)| p != page);
let (parent, in_reply_to) = if cross_page { (None, None) } else { (parent, in_reply_to) };
out.push(Summary {
page,
index,
@@ -135,16 +233,24 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
name: text_value(doc, d, b"NM"),
in_reply_to,
parent,
grouped: d.name(b"RT") == Some(b"Group"),
grouped,
rect,
color,
state: text_value(doc, d, b"State"),
// A reply that gives only `/StateModel` has the model's default state (ISO 32000-2
// Table 175), as `last_state_reply` and the inspector read it.
state: text_value(doc, d, b"State").or_else(|| match text_value(doc, d, b"StateModel").as_deref() {
Some("Marked") => Some("Unmarked".into()),
Some("Review") => Some("None".into()),
_ => None,
}),
quads,
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())),
fill_sign: is_fill_sign(doc, d),
no_rotate: drawn_upright(doc, d),
points,
text_box,
popup: popup_info(doc, entry.as_ref().map(|r| final_ref(doc, r)), d),
});
}
}
@@ -213,6 +319,15 @@ pub struct Props {
pub fill: Option<Rgb>,
/// Text boxes, typewriter text and callouts: the `/DA` font size in points.
pub font_size: Option<f64>,
/// Text boxes, typewriter text and callouts: the border's own colour (`None`: the text's).
pub border_color: Option<Rgb>,
/// Text boxes and typewriter text: the border's shape (callouts have none to choose).
pub border_shape: Option<BorderShape>,
/// How opaque the fill is drawn, for a comment with a fill control (`opacity` is then its
/// line's or border's).
pub fill_opacity: Option<f64>,
/// How opaque a text box's, typewriter text's or callout's text is drawn.
pub text_opacity: Option<f64>,
}
/// The current properties of the comment at `(page, index)`.
@@ -220,13 +335,23 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
let p = page_ref(doc, page).ok()?;
let entry = annots(doc, p).get(index).cloned()?;
let obj = doc.resolve(&entry);
let d = obj.as_dict()?;
// Values given by reference (`/C 7 0 R`, `/BS 5 0 R`, ISO 32000-2 §7.3.10) read as the display draws them.
let raw = obj.as_dict()?;
let direct = crate::draw::with_direct_values(doc, raw);
let d = direct.as_ref();
let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned();
let c: Vec<f64> = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default();
let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) };
if !is_comment_subtype(&subtype) {
return None;
}
// A text box reports the colour and size it is drawn in (`/DA`, or `/DS` where it sets one).
let style = (subtype == "FreeText").then(|| text_style::text_style(d, None));
let color = match &style {
Some(s) => Some(s.color),
None => drawn_color(d, b"C"),
};
let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| {
if subtype == "FreeText" {
if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) }
appearance::border_width(d)
} else {
d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0)
}
@@ -235,30 +360,50 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
author: text_value(doc, d, b"T").unwrap_or_default(),
subject: text_value(doc, d, b"Subj").unwrap_or_default(),
color,
opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0),
opacity: appearance::part_opacity(d).line,
width,
icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)),
modified: text_value(doc, d, b"M"),
restylable: appearance::build(d).is_some(),
restylable: crate::draw::can_draw(doc, raw),
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
fillable: fillable(&subtype),
fill: fill_of(d),
font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1),
font_size: style.map(|s| s.size),
border_color: (subtype == "FreeText").then(|| appearance::da_border(d)).flatten(),
border_shape: (subtype == "FreeText" && !d.contains(b"CL")).then(|| BorderShape::of(d)),
fill_opacity: fillable(&subtype).then(|| appearance::part_opacity(d).fill),
text_opacity: (subtype == "FreeText").then(|| appearance::part_opacity(d).text),
subtype,
endings: line_endings_of(d),
})
}
/// A shape’s `/IC` or FreeText `/C` as RGB. Grey and CMYK fills are converted.
/// A shape’s `/IC` or FreeText `/C` as RGB, read as it is drawn. Grey and CMYK fills are converted.
fn fill_of(d: &Dict) -> Option<Rgb> {
let key: &[u8] = if d.name(b"Subtype") == Some(b"FreeText") { b"C" } else { b"IC" };
let c: Vec<f64> = d.get(key)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect();
let rgb = match c.as_slice() {
[g] => [*g; 3],
[r, g, b] => [*r, *g, *b],
[c, m, y, k] => [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)],
_ => return None,
};
drawn_color(d, key)
}
/// `/C` as [`drawn_color`] reads it, the array itself followed when it is given by reference.
/// Only an array of at most four entries is copied out of the reference.
fn summary_color(doc: &Document, d: &Dict) -> Option<[f32; 3]> {
let rgb = match d.get(b"C") {
Some(o @ Object::Ref(_)) => {
let held = doc.resolve(o);
let entries = held.as_array().filter(|a| a.len() <= 4)?;
let mut direct = Dict::new();
direct.set(b"C".to_vec(), Object::Array(entries.clone()));
drawn_color(&direct, b"C")
}
_ => drawn_color(d, b"C"),
}?;
Some(rgb.map(|x| x as f32))
}
/// The colour array at `key` as RGB, read as the appearance builder draws it: grey and CMYK are
/// converted, and a four-entry array with a non-number in it is CMYK with that entry 0.
fn drawn_color(d: &Dict, key: &[u8]) -> Option<Rgb> {
let rgb = appearance::color(d, key).flatten()?;
finite(&rgb).then(|| rgb.map(|x| x.clamp(0.0, 1.0)))
}
+21 -6
View File
@@ -22,32 +22,41 @@ mod create;
mod draw;
mod info;
mod lookup;
mod model3d;
mod modify;
mod threed;
mod types;
#[cfg(test)]
use create::num_array;
pub use create::{add_annotation, callout_attach, orient_image_stamp};
pub use create::{
CALLOUT_KNEE_LINE, CUSTOM_LINE_BAND, CUSTOM_LINE_TEXT, Side, add_annotation, callout_attach, callout_knee, callout_knee_on, callout_side,
custom_stamp_size, orient_image_stamp,
};
pub use draw::{AppearanceCache, set_appearance, with_missing_appearances, with_missing_appearances_cached};
pub use info::{Props, Summary, is_comment_subtype, props, set_info, summaries};
pub use info::{PopupInfo, Props, Summary, is_comment_subtype, is_group_subordinate, props, set_info, summaries};
#[cfg(test)]
use lookup::{ANNOTS_ENTRIES_COPIED, PageWalk, annot_dict, annot_ref};
use lookup::{annots, page_ref};
pub use lookup::{
has_visible_fill_sign, is_fill_sign, isolate_annotation, isolate_annotations, page_refs, picture_rotation, set_annots, signature_image,
};
pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations};
pub(crate) use modify::n;
pub use modify::{
add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents,
set_fill, set_font_size, set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups,
add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, release_members,
set_border_color, set_border_shape, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_popup,
set_rect, set_review_state, set_style, with_replies_and_popups,
};
pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model};
pub use types::{
AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape,
StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad,
AttachIcon, BorderShape, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb,
Shape, StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad,
};
pub mod appearance;
pub mod links;
pub mod text_style;
#[cfg(test)]
mod tests;
@@ -70,6 +79,12 @@ pub enum AnnotError {
/// with a font that has those characters. The message names the character.
#[error("{0}")]
Undrawable(String),
/// A 3D model file that is neither U3D (starts with `U3D` and a zero byte) nor PRC (starts `PRC`).
#[error("not a 3D model: the file must be U3D (ECMA-363) or PRC (ISO 14739-1)")]
NotAModel,
/// A 3D model larger than `MAX_MODEL_BYTES`.
#[error("the 3D model is {size} bytes; the largest accepted is {max}")]
ModelTooLarge { size: usize, max: usize },
#[error("{0}")]
Cos(#[from] pdfcraft_cos::CosError),
}
+165 -16
View File
@@ -8,13 +8,77 @@ use crate::{AnnotError, annots, page_ref, page_refs, set_annots};
/// Where a link goes.
#[derive(Clone, Debug, PartialEq)]
pub enum LinkAction {
/// A page of this document (0-based), shown fitting the window (`/Fit`).
Page(usize),
/// A page of this document (0-based), shown at `zoom`.
Page {
page: usize,
zoom: LinkZoom,
},
Uri(String),
/// Something PdfCraft doesn't edit yet (named destination, JavaScript…): kept as is.
Other(String),
}
/// How a go-to-page link shows its page: Acrobat's Go to a page view zoom, written as an
/// explicit destination (ISO 32000-2 §12.3.2.2, Table 149). Positions are written `null`, so the
/// reader keeps its position in that direction, as a new bookmark does.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum LinkZoom {
/// `/Fit`: the whole page in the window.
FitPage,
/// `/FitH null`: the page's width across the window.
FitWidth,
/// `/FitV null`: the page's height down the window.
FitHeight,
/// `/FitB`: the page's content (its bounding box) in the window.
FitVisible,
/// `/XYZ null null null`: the reader's zoom is kept.
Inherit,
/// `/XYZ null null z` with z = percent / 100; 100 is Actual Size. A new zoom is written
/// within 1–6400%; a link rewritten with the zoom it has keeps its own factor.
Percent(f64),
}
impl LinkZoom {
/// The zoom an explicit destination array (`[page /XYZ …]`, `[page /Fit]`, …) asks for. The
/// boxed variants read as their unboxed ones, and a `/FitR` rectangle or a missing or unknown
/// type as Fit Page, the view links had before they carried a zoom. A link whose page and zoom
/// are unchanged is not rewritten, so its position is kept; one given a new page keeps how its
/// zoom is written (`write_action`) but not its position.
fn of_dest(doc: &Document, dest: &[Object]) -> Self {
let kind = dest.get(1).map(|k| doc.resolve(k)).and_then(|k| k.as_name().map(<[u8]>::to_vec));
match kind.as_deref() {
Some(b"FitB") => LinkZoom::FitVisible,
Some(b"FitH" | b"FitBH") => LinkZoom::FitWidth,
Some(b"FitV" | b"FitBV") => LinkZoom::FitHeight,
Some(b"XYZ") => match dest.get(4).and_then(|z| doc.resolve(z).as_f64()) {
// Finite even for a factor near f64::MAX. A link given a new page with this zoom
// keeps the factor itself, however far outside 1–6400% (`write_action`).
Some(z) if z.is_finite() && z > 0.0 => LinkZoom::Percent((z * 100.0).min(f64::MAX)),
// A zoom of 0 or null keeps the reader's (Table 149).
_ => LinkZoom::Inherit,
},
_ => LinkZoom::FitPage,
}
}
/// The destination's elements after the page reference.
fn operands(self) -> Result<Vec<Object>, AnnotError> {
Ok(match self {
LinkZoom::FitPage => vec![Object::name("Fit")],
LinkZoom::FitWidth => vec![Object::name("FitH"), Object::Null],
LinkZoom::FitHeight => vec![Object::name("FitV"), Object::Null],
LinkZoom::FitVisible => vec![Object::name("FitB")],
LinkZoom::Inherit => vec![Object::name("XYZ"), Object::Null, Object::Null, Object::Null],
LinkZoom::Percent(p) => {
if !(p.is_finite() && p > 0.0) {
return Err(AnnotError::Invalid("the link's zoom must be a positive percentage".into()));
}
vec![Object::name("XYZ"), Object::Null, Object::Null, Object::Real((p / 100.0).clamp(0.01, 64.0))]
}
})
}
}
/// Acrobat's Link Properties ▸ Appearance.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LinkStyle {
@@ -87,17 +151,18 @@ fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
}
fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize) -> LinkAction {
let dest_page = |dest: &Object| -> Option<usize> {
let page_view = |dest: &Object| -> Option<LinkAction> {
let a = doc.resolve(dest);
let first = a.as_array()?.first()?.clone();
match first {
Object::Ref(r) => pages.iter().position(|p| *p == r),
Object::Int(i) => usize::try_from(i).ok(),
let a = a.as_array()?;
let page = match a.first()? {
Object::Ref(r) => pages.iter().position(|p| p == r),
Object::Int(i) => usize::try_from(*i).ok(),
_ => None,
}
}?;
Some(LinkAction::Page { page, zoom: LinkZoom::of_dest(doc, a) })
};
if let Some(dest) = d.get(b"Dest") {
return dest_page(dest).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page);
return page_view(dest).unwrap_or_else(|| LinkAction::Other("named destination".into()));
}
let Some(a) = d.get(b"A").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()) else { return LinkAction::Other("no action".into()) };
match a.name(b"S") {
@@ -115,7 +180,7 @@ fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize)
None => LinkAction::Other("web address too long".into()),
}
}
Some(b"GoTo") => a.get(b"D").and_then(dest_page).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page),
Some(b"GoTo") => a.get(b"D").and_then(page_view).unwrap_or_else(|| LinkAction::Other("named destination".into())),
Some(s) => LinkAction::Other(String::from_utf8_lossy(s).into_owned()),
None => LinkAction::Other("no action".into()),
}
@@ -214,12 +279,52 @@ fn uri_bytes(u: &str) -> Vec<u8> {
out
}
/// The explicit destination array link `d` goes to now: its `/Dest`, or a GoTo action's `/D`.
fn current_dest(doc: &Document, d: &Dict) -> Option<Vec<Object>> {
let dest = match d.get(b"Dest") {
Some(dest) => doc.resolve(dest),
None => {
let action = doc.resolve(d.get(b"A")?);
let go_to = action.as_dict().filter(|a| a.name(b"S") == Some(b"GoTo"))?;
doc.resolve(go_to.get(b"D")?)
}
};
dest.as_array().cloned()
}
fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), AnnotError> {
match a {
LinkAction::Page(p) => {
let target = page_ref(doc, *p)?;
LinkAction::Page { page, zoom } => {
let target = page_ref(doc, *page)?;
let mut operands = zoom.operands()?;
let current = current_dest(doc, d).filter(|c| LinkZoom::of_dest(doc, c) == *zoom);
if let Some(c) = current {
// The page and zoom the link has (`link_edit` naming its own page): the
// destination is left as it is, position on the page and GoTo form included.
let same_page = match c.first() {
Some(Object::Ref(r)) => *r == target,
Some(Object::Int(i)) => usize::try_from(*i).ok() == Some(*page),
_ => false,
};
if same_page {
return Ok(());
}
// A new page with the zoom the link has keeps how that zoom is written: a boxed
// fit (`/FitBH`, `/FitBV`) and its own `/XYZ` factor, even one outside the 1–6400%
// a new zoom is written within. Positions belong to the old page and are dropped.
let (slot, own) = match zoom {
LinkZoom::Percent(_) => (operands.get_mut(3), c.get(4)),
LinkZoom::FitWidth | LinkZoom::FitHeight => (operands.get_mut(0), c.get(1)),
_ => (None, None),
};
if let (Some(slot), Some(own)) = (slot, own) {
*slot = doc.resolve(own).as_ref().clone();
}
}
let mut dest = vec![Object::Ref(target)];
dest.extend(operands);
d.remove(b"A");
d.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(target), Object::name("Fit")]));
d.set(b"Dest".to_vec(), Object::Array(dest));
}
LinkAction::Uri(u) => {
let u = u.trim();
@@ -398,10 +503,24 @@ fn email_at(chars: &[char], start: usize) -> Option<(usize, String)> {
Some((end, format!("mailto:{address}")))
}
/// The longest web address [`find_urls`] reads, in characters (browsers and servers commonly
/// stop near 2,000). Each address it might be is read this far at most, so text whose brackets
/// never close (`https://a/(https://a/(…`, without a space) costs this much per address in it,
/// not a pass over the rest of the page each.
const MAX_URL_CHARS: usize = 2048;
/// Find web addresses in text: `http(s)://…`, `www.…` and bare e-mail addresses become
/// `mailto:`. Returns character ranges and the URI for each.
///
/// No other scheme is ever found, and a `www.` or e-mail address that is part of one written with
/// another scheme (`ftp://www.example.com`, `smb://admin@example.org`) is not taken out of it:
/// it would open with a scheme the text doesn't say. Brackets inside a web address belong to it
/// when they pair up (`…/wiki/Mercury_(planet)`); an unpaired one ends it.
pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range<usize>, String)> {
let url_char = |c: char| !c.is_whitespace() && !"<>\"'()[]{}".contains(c);
// Characters found inside an address but not between words: a `www.` after one is part of
// a longer address, and so is an e-mail address after a slash.
let inside_address = |i: usize, set: &str| i > 0 && chars.get(i - 1).is_some_and(|c| set.contains(*c));
// Case-insensitive prefix test that never steps past the end. Comparing against the original
// characters keeps every offset in `chars`'s own index space: case-folding a copy can change
// its length (U+0130 becomes "i" plus a combining dot), so a folded copy must never supply
@@ -412,11 +531,38 @@ pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range<usize>, String)> {
let mut i = 0;
while i < chars.len() {
let boundary = i == 0 || chars.get(i - 1).is_some_and(|c| !c.is_alphanumeric());
if boundary && (starts_with(i, "http://") || starts_with(i, "https://") || starts_with(i, "www.")) {
let www = starts_with(i, "www.") && !inside_address(i, "/\\:@.-_~%+=&#");
if boundary && (starts_with(i, "http://") || starts_with(i, "https://") || www) {
let mut j = i;
while chars.get(j).is_some_and(|c| url_char(*c)) {
// Brackets pair up inside the address: `depth` are open, the outermost at `opened`.
let (mut depth, mut opened) = (0usize, i);
let mut too_long = false;
loop {
if j.saturating_sub(i) >= MAX_URL_CHARS {
too_long = true;
break;
}
match chars.get(j) {
Some('(') => {
if depth == 0 {
opened = j;
}
depth = depth.saturating_add(1);
}
Some(')') if depth > 0 => depth -= 1,
Some(c) if url_char(*c) => {}
_ => break,
}
j += 1;
}
// A bracket left open isn't the address's: it ends before it. A run of address
// characters longer than any address is none, and the search goes on after it.
if depth > 0 {
j = opened;
} else if too_long {
i = j;
continue;
}
// Trailing punctuation belongs to the sentence.
while j > i && chars.get(j - 1).is_some_and(|c| ".,;:!?".contains(*c)) {
j -= 1;
@@ -429,7 +575,10 @@ pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range<usize>, String)> {
continue;
}
}
if boundary && let Some((end, uri)) = email_at(chars, i) {
if boundary
&& !inside_address(i, "/\\")
&& let Some((end, uri)) = email_at(chars, i)
{
out.push((i..end, uri));
i = end;
continue;
+66 -9
View File
@@ -1,4 +1,4 @@
use super::info::text_value;
use super::info::{is_comment_subtype, text_value};
use super::types::{FLAG_HIDDEN, FLAG_NO_ROTATE, FLAG_NO_VIEW, Shape};
use super::{AnnotError, Dict, Document, ObjRef, Object, appearance};
@@ -89,6 +89,56 @@ thread_local! {
pub(crate) static ANNOTS_ENTRIES_COPIED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Apple Markup's private copy of a page's markup: on the page and on each of its annotations.
const APPLE_PAGE_MARKUP: [&[u8]; 2] = [b"AAPL:PPK", b"AAPL:PPKHash"];
const APPLE_ANNOT_MARKUP: &[u8] = b"AAPL:AKExtras";
/// Drop Apple Markup's private copy of the page's markup once the annotation list changes. iOS
/// draws Markup annotations from that copy, which no longer matches `/Annots`: iOS 26 showed a
/// Markup signature in its own place and again over a signature added here. Without it, Apple
/// draws every annotation from its appearance, as other viewers do. The drop is recorded in the
/// document's repair log (iOS can no longer re-edit those strokes as live ink).
fn drop_apple_markup(doc: &mut Document, page: ObjRef, list: &mut [Object]) -> Result<(), AnnotError> {
let mut dropped = false;
if doc.get(page).as_dict().is_some_and(|d| APPLE_PAGE_MARKUP.iter().any(|k| d.contains(k))) {
doc.update_dict(page, |d| {
for k in APPLE_PAGE_MARKUP {
d.remove(k);
}
})?;
dropped = true;
}
for entry in list {
match entry {
Object::Dict(d) if d.contains(APPLE_ANNOT_MARKUP) => {
d.remove(APPLE_ANNOT_MARKUP);
dropped = true;
}
Object::Ref(r) if doc.get(*r).as_dict().is_some_and(|d| d.contains(APPLE_ANNOT_MARKUP)) => {
doc.update_dict(*r, |d| {
d.remove(APPLE_ANNOT_MARKUP);
})?;
dropped = true;
}
_ => {}
}
}
if dropped {
doc.note_repair(
"dropped Apple Markup's private copy of a page's annotations after the page changed; iOS draws them from their appearance instead of as editable ink"
.into(),
);
}
Ok(())
}
/// Replace a page's `/Annots` after an annotation was added, removed or answered: the point at
/// which Apple Markup's private copy of the page goes stale (see [`drop_apple_markup`]).
pub(crate) fn change_annots(doc: &mut Document, page: ObjRef, mut list: Vec<Object>) -> Result<(), AnnotError> {
drop_apple_markup(doc, page, &mut list)?;
Ok(set_annots(doc, page, list)?)
}
/// Replace a page's `/Annots`, writing into the shared array object when it is indirect. An empty
/// list removes a direct `/Annots`.
pub fn set_annots(doc: &mut Document, page: ObjRef, list: Vec<Object>) -> Result<(), pdfcraft_cos::CosError> {
@@ -106,16 +156,23 @@ pub fn set_annots(doc: &mut Document, page: ObjRef, list: Vec<Object>) -> Result
Ok(())
}
/// The annotation at `(page, index)` as an indirect object (inline dictionaries are promoted).
/// Whether the annotation dictionary `d` has a comment `/Subtype` ([`is_comment_subtype`]).
pub(crate) fn is_comment_dict(d: &Dict) -> bool {
d.name(b"Subtype").is_some_and(|s| is_comment_subtype(&String::from_utf8_lossy(s)))
}
/// The comment annotation at `(page, index)` as an indirect object (inline dictionaries are promoted).
pub(crate) fn annot_ref(doc: &mut Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> {
let p = page_ref(doc, page)?;
let mut list = annots(doc, p);
let entry = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?;
let r = match entry {
Object::Ref(r) if doc.get(r).as_dict().is_some() => r,
Object::Dict(d) => {
Object::Ref(r) if doc.get(r).as_dict().is_some_and(is_comment_dict) => r,
Object::Dict(d) if is_comment_dict(&d) => {
let r = doc.add(Object::Dict(d));
list[index] = Object::Ref(r);
if let Some(slot) = list.get_mut(index) {
*slot = Object::Ref(r);
}
set_annots(doc, p, list)?;
r
}
@@ -137,11 +194,11 @@ pub fn is_fill_sign(doc: &Document, d: &Dict) -> bool {
if matches!(d.get(b"PCFillSign").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) {
return true;
}
let subtype = d.name(b"Subtype").unwrap_or_default();
if subtype == b"FreeText" && d.name(b"IT") == Some(b"FreeTextTypeWriter") {
let name_is = |key: &[u8], expected: &[u8]| d.get(key).is_some_and(|o| doc.resolve(o).as_name() == Some(expected));
if name_is(b"Subtype", b"FreeText") && name_is(b"IT", b"FreeTextTypeWriter") {
return true;
}
if subtype == b"Stamp" {
if name_is(b"Subtype", b"Stamp") {
if matches!(d.name(b"Name"), Some(b"PCCheck" | b"PCCross" | b"PCDot" | b"PCLine" | b"PCTypedSignature")) {
return true;
}
@@ -151,7 +208,7 @@ pub fn is_fill_sign(doc: &Document, d: &Dict) -> bool {
return true;
}
}
subtype == b"Ink" && text_value(doc, d, b"Subj").as_deref() == Some("Signature")
name_is(b"Subtype", b"Ink") && text_value(doc, d, b"Subj").as_deref() == Some("Signature")
}
pub(crate) fn annotation_flags(doc: &Document, d: &Dict) -> i64 {
+77
View File
@@ -0,0 +1,77 @@
//! Reading the model a 3D annotation shows (ISO 32000-2 §13.6.3 and §13.6.4): its `/3DD` is a
//! 3D stream, or a 3D reference dictionary naming the stream another annotation shares.
use super::lookup::{annots, page_ref};
use super::threed::MAX_MODEL_BYTES;
use super::{AnnotError, Dict, Document, Object};
/// The format of a 3D stream, from its `/Subtype`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Model3dKind {
/// Product Representation Compact (ISO 14739-1).
Prc,
/// Universal 3D (ECMA-363).
U3d,
}
/// A 3D annotation's model: its format and the decoded stream.
#[derive(Debug, Clone, PartialEq)]
pub struct Model3d {
pub kind: Model3dKind,
pub data: Vec<u8>,
}
/// The `/Annots` positions of the 3D annotations on 0-based `page`, in order.
///
/// # Errors
/// [`AnnotError::NoPageTree`] or [`AnnotError::NoSuchPage`].
pub fn model_3d_annotations(doc: &Document, page: usize) -> Result<Vec<usize>, AnnotError> {
let list = annots(doc, page_ref(doc, page)?);
Ok(list.iter().enumerate().filter(|(_, a)| doc.dict(a).is_some_and(|d| d.name(b"Subtype") == Some(b"3D"))).map(|(i, _)| i).collect())
}
/// The model of the 3D annotation at `/Annots` position `index` on 0-based `page`, decoded.
///
/// # Errors
/// [`AnnotError::NoSuchAnnotation`] when there is no annotation there; [`AnnotError::Invalid`]
/// when it is not a 3D annotation, has no readable 3D stream, names a format other than PRC or
/// U3D, or decodes past [`MAX_MODEL_BYTES`].
pub fn model_3d(doc: &Document, page: usize, index: usize) -> Result<Model3d, AnnotError> {
let list = annots(doc, page_ref(doc, page)?);
let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
let annot = doc.dict(entry).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
let at = || format!("annotation {} on page {}", index.saturating_add(1), page.saturating_add(1));
if annot.name(b"Subtype") != Some(b"3D") {
return Err(AnnotError::Invalid(format!("{} is not a 3D annotation", at())));
}
let stream = stream_of(doc, &annot).ok_or_else(|| AnnotError::Invalid(format!("{} has no 3D stream", at())))?;
let Object::Stream(s) = stream.as_ref() else {
return Err(AnnotError::Invalid(format!("{} has no 3D stream", at())));
};
let kind = match s.dict.name(b"Subtype") {
Some(b"PRC") => Model3dKind::Prc,
Some(b"U3D") => Model3dKind::U3d,
other => {
let name = other.map_or_else(|| "none".to_owned(), |n| String::from_utf8_lossy(n).into_owned());
return Err(AnnotError::Invalid(format!("{}'s 3D stream is of an unknown format ({name})", at())));
}
};
let data = doc
.with_direct_parms(s)
.decoded_within(MAX_MODEL_BYTES)
.map_err(|e| AnnotError::Invalid(format!("{}'s 3D stream could not be decoded: {e}", at())))?;
Ok(Model3d { kind, data })
}
/// What an annotation's `/3DD` names: the 3D stream itself, or the `/3D` entry of a 3D reference
/// dictionary. A reference is followed once; the caller refuses anything that is not a stream.
fn stream_of(doc: &Document, annot: &Dict) -> Option<std::sync::Arc<Object>> {
let obj = doc.resolve(annot.get(b"3DD")?);
match obj.as_ref() {
Object::Dict(d) if d.name(b"Type") == Some(b"3DRef") => Some(doc.resolve(d.get(b"3D")?)),
_ => Some(obj),
}
}
#[cfg(test)]
mod tests;
+88
View File
@@ -0,0 +1,88 @@
use std::sync::Arc;
use super::*;
/// A one-page document whose page has `annots` as its `/Annots`, plus `extra` objects from 4 on.
fn doc(annots: &str, extra: &[Vec<u8>]) -> Document {
let mut objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".to_vec(),
format!("<< /Type /Page /Parent 2 0 R /Annots {annots} >>").into_bytes(),
];
objs.extend(extra.iter().cloned());
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).expect("opens")
}
fn stream(dict: &str, body: &[u8]) -> Vec<u8> {
let mut s = format!("<< {dict} /Length {} >>\nstream\n", body.len()).into_bytes();
s.extend_from_slice(body);
s.extend_from_slice(b"\nendstream");
s
}
fn annot(dd: &str) -> Vec<u8> {
format!("<< /Type /Annot /Subtype /3D /Rect [0 0 100 100] /3DD {dd} >>").into_bytes()
}
#[test]
fn a_plain_3d_stream_is_read_through_its_filters() {
let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /PRC /Filter /ASCIIHexDecode", b"505243>")]);
assert_eq!(model_3d(&d, 0, 0), Ok(Model3d { kind: Model3dKind::Prc, data: b"PRC".to_vec() }));
}
#[test]
fn a_3dref_leads_to_the_stream_it_names() {
let d = doc("[4 0 R]", &[annot("<< /Type /3DRef /3D 5 0 R >>"), stream("/Type /3D /Subtype /PRC", b"PRC")]);
assert_eq!(model_3d(&d, 0, 0).map(|m| m.data), Ok(b"PRC".to_vec()));
}
#[test]
fn a_u3d_stream_is_read_and_says_so() {
let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /U3D", b"U3D")]);
assert_eq!(model_3d(&d, 0, 0).map(|m| m.kind), Ok(Model3dKind::U3d));
}
#[test]
fn a_3d_annotation_without_a_stream_is_invalid() {
let d = doc("[<< /Type /Annot /Subtype /3D /Rect [0 0 1 1] >>]", &[]);
assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream")));
}
#[test]
fn an_unknown_format_is_invalid() {
let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /STL", b"x")]);
assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("unknown format (STL)")));
}
#[test]
fn a_3dref_is_followed_once_and_only_to_a_stream() {
// A reference naming another reference, or one that names itself, is damage: no stream.
let d = doc("[4 0 R]", &[annot("5 0 R"), b"<< /Type /3DRef /3D 6 0 R >>".to_vec(), b"<< /Type /3DRef /3D 5 0 R >>".to_vec()]);
assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream")));
// The stream is under `/3D`; a `/3DD` inside the reference is not the standard's key.
let d = doc("[4 0 R]", &[annot("<< /Type /3DRef /3DD 5 0 R >>"), stream("/Type /3D /Subtype /PRC", b"PRC")]);
assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream")));
}
#[test]
fn another_annotation_is_not_a_3d_one() {
let d = doc("[<< /Type /Annot /Subtype /Square /Rect [0 0 1 1] >> 4 0 R]", &[annot("5 0 R"), stream("/Subtype /PRC", b"PRC")]);
assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("not a 3D annotation")));
assert_eq!(model_3d(&d, 0, 2), Err(AnnotError::NoSuchAnnotation { page: 0, index: 2 }));
assert_eq!(model_3d_annotations(&d, 0), Ok(vec![1]));
assert_eq!(model_3d_annotations(&d, 1), Err(AnnotError::NoSuchPage(1)));
}
+776 -100
View File
File diff suppressed because it is too large. Load diff
+3404 -35
View File
File diff suppressed because it is too large. Load diff
+480
View File
@@ -0,0 +1,480 @@
//! The face, size and colour a text box's text is drawn in when PdfCraft draws its appearance:
//! `/DA` (ISO 32000-2 Table 177; the font named there is looked up in the interactive form's
//! `/DR` font resources, §12.7.4.3) and `/DS`, the default style string (Table 177), which is
//! read for its CSS `font`, `font-family`, `font-size`, `font-weight`, `font-style` and `color`.
//!
//! Only the twelve standard text faces are drawn (Helvetica, Times and Courier, each regular,
//! bold, italic and bold italic, in WinAnsiEncoding): a font that isn't one of them is drawn in
//! Helvetica, as before. Symbol and ZapfDingbats are not text faces for this purpose.
use pdfcraft_cos::{Dict, Document, Object};
use pdfcraft_fonts::{Std14, Std14Match as Match};
use crate::Rgb;
use crate::appearance::parse_da;
/// `/DS` past this many bytes is read only this far (a style string is a line or two).
const MAX_DS: usize = 4096;
/// Font sizes a text box is drawn at, as for `/DA`.
const MAX_SIZE: f64 = 400.0;
/// How a text box's text is drawn.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TextStyle {
pub face: Std14,
pub size: f64,
pub color: Rgb,
}
/// The style of `d`'s text: `/DA`, with what `/DS` sets taking its place. `dr_face` is the face
/// the `/DA` font resource names in the form's `/DR` ([`dr_face`]); `None` when it isn't there,
/// and the common resource names (`/Helv`, `/Cour`, `/TiRo`, …) are read instead.
pub fn text_style(d: &Dict, dr_face: Option<Std14>) -> TextStyle {
let (da_color, da_size) = parse_da(d);
let da_face = dr_face.or_else(|| da_font_name(d).as_deref().and_then(resource_face)).unwrap_or(Std14::Helvetica);
let mut style = TextStyle { face: da_face, size: da_size, color: da_color };
let Some(ds) = d.get(b"DS").and_then(Object::as_string).map(|s| s.to_text()) else { return style };
let ds = parse_ds(&ds);
if let Some(size) = ds.size {
style.size = size;
}
// `/DS` colours are 8-bit (`#RRGGBB`): one that rounds to the same as `/DA`'s keeps `/DA`'s.
if let Some(color) = ds.color
&& bytes(color) != bytes(da_color)
{
style.color = color;
}
let base = ds.face.unwrap_or(style.face);
style.face = styled(base, ds.bold.unwrap_or(is_bold(base)), ds.italic.unwrap_or(is_italic(base)));
style
}
/// The face the `/DA` font of `d` names in the document's `/AcroForm /DR /Font` (§12.7.4.3):
/// `Some` when that resource is there — the standard text face its `/BaseFont` names, or
/// Helvetica for any other font — and `None` when it isn't.
pub fn dr_face(doc: &Document, d: &Dict) -> Option<Std14> {
let name = da_font_name(d)?;
let root = doc.trailer().get(b"Root").map(|r| doc.resolve(r))?;
let form = root.as_dict()?.get(b"AcroForm").map(|f| doc.resolve(f))?;
let dr = form.as_dict()?.get(b"DR").map(|r| doc.resolve(r))?;
let fonts = dr.as_dict()?.get(b"Font").map(|f| doc.resolve(f))?;
let font = fonts.as_dict()?.get(name.as_bytes()).map(|f| doc.resolve(f))?;
let font = font.as_dict()?;
let base = font.name(b"BaseFont").map(String::from_utf8_lossy);
Some(base.as_deref().and_then(base_font_face).unwrap_or(Std14::Helvetica))
}
/// The font resource name of the last `Tf` in `/DA` (without the slash). `Tf` takes a name and
/// a size; a name given without its size (`/TiRo Tf`) is still read, and the size then falls back
/// as [`parse_da`] says.
pub fn da_font_name(d: &Dict) -> Option<String> {
let da = d.get(b"DA").and_then(Object::as_string)?;
let da = String::from_utf8_lossy(&da.bytes);
let toks: Vec<&str> = da.split_whitespace().collect();
let tf = toks.iter().rposition(|t| *t == "Tf")?;
let name = match toks.get(..tf)? {
[.., name, size] if !size.starts_with('/') => name,
[.., name] => name,
[] => return None,
};
let name = name.strip_prefix('/')?;
(!name.is_empty()).then(|| name.to_string())
}
/// The face a font resource name stands for: the names PDF writers commonly give the standard
/// fonts in `/DR` (`Helv`, `HeBo`, `Cour`, `TiRo`, …), or a standard face's own name.
pub fn resource_face(name: &str) -> Option<Std14> {
Some(match name {
"Helv" => Std14::Helvetica,
"HeBo" => Std14::HelveticaBold,
"HeOb" | "HeIt" => Std14::HelveticaOblique,
"HeBO" | "HeBI" => Std14::HelveticaBoldOblique,
"Cour" => Std14::Courier,
"CoBo" => Std14::CourierBold,
"CoOb" | "CoIt" => Std14::CourierOblique,
"CoBO" | "CoBI" => Std14::CourierBoldOblique,
"TiRo" => Std14::TimesRoman,
"TiBo" => Std14::TimesBold,
"TiIt" => Std14::TimesItalic,
"TiBI" => Std14::TimesBoldItalic,
_ => return base_font_face(name),
})
}
/// The standard text face a `/BaseFont` (or family) name asks for: the spec's names and their
/// usual aliases (Arial, Times New Roman, Courier New), not a guess from a family hint.
fn base_font_face(name: &str) -> Option<Std14> {
match Std14::recognise(name)? {
(Std14::Symbol | Std14::ZapfDingbats, _) | (_, Match::FamilyHint) => None,
(face, Match::Alias) => Some(face),
}
}
/// `CSS` family names: the generic ones, else a standard face's name or alias.
fn family_face(name: &str) -> Option<Std14> {
let name = name.trim().trim_matches(|c| c == '"' || c == '\'').trim();
match name.to_ascii_lowercase().as_str() {
"serif" => Some(Std14::TimesRoman),
"monospace" => Some(Std14::Courier),
"sans-serif" => Some(Std14::Helvetica),
_ => base_font_face(name),
}
}
fn is_bold(f: Std14) -> bool {
matches!(
f,
Std14::HelveticaBold
| Std14::HelveticaBoldOblique
| Std14::TimesBold
| Std14::TimesBoldItalic
| Std14::CourierBold
| Std14::CourierBoldOblique
)
}
fn is_italic(f: Std14) -> bool {
matches!(
f,
Std14::HelveticaOblique
| Std14::HelveticaBoldOblique
| Std14::TimesItalic
| Std14::TimesBoldItalic
| Std14::CourierOblique
| Std14::CourierBoldOblique
)
}
/// `base`'s family in the given weight and slant.
fn styled(base: Std14, bold: bool, italic: bool) -> Std14 {
use Std14::*;
match (base, bold, italic) {
(TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, false, false) => TimesRoman,
(TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, true, false) => TimesBold,
(TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, false, true) => TimesItalic,
(TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, true, true) => TimesBoldItalic,
(Courier | CourierBold | CourierOblique | CourierBoldOblique, false, false) => Courier,
(Courier | CourierBold | CourierOblique | CourierBoldOblique, true, false) => CourierBold,
(Courier | CourierBold | CourierOblique | CourierBoldOblique, false, true) => CourierOblique,
(Courier | CourierBold | CourierOblique | CourierBoldOblique, true, true) => CourierBoldOblique,
(_, false, false) => Helvetica,
(_, true, false) => HelveticaBold,
(_, false, true) => HelveticaOblique,
(_, true, true) => HelveticaBoldOblique,
}
}
/// The resource name an appearance stream gives `face` (the usual `/DR` names).
pub fn resource_name(face: Std14) -> &'static str {
match face {
Std14::HelveticaBold => "HeBo",
Std14::HelveticaOblique => "HeOb",
Std14::HelveticaBoldOblique => "HeBO",
Std14::TimesRoman => "TiRo",
Std14::TimesBold => "TiBo",
Std14::TimesItalic => "TiIt",
Std14::TimesBoldItalic => "TiBI",
Std14::Courier => "Cour",
Std14::CourierBold => "CoBo",
Std14::CourierOblique => "CoOb",
Std14::CourierBoldOblique => "CoBO",
_ => "Helv",
}
}
/// `face`'s `CSS` family, weight and style, for writing `/DS` and `/RC`.
pub fn css(face: Std14) -> (&'static str, &'static str, &'static str) {
let family = match styled(face, false, false) {
Std14::TimesRoman => "Times",
Std14::Courier => "Courier",
_ => "Helvetica",
};
(family, if is_bold(face) { "bold" } else { "normal" }, if is_italic(face) { "italic" } else { "normal" })
}
fn bytes(c: Rgb) -> [u8; 3] {
// Clamped to 0..=1 first, so the product is 0..=255 and the cast exact.
c.map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8)
}
/// What a `/DS` string sets.
#[derive(Debug, Default, PartialEq)]
struct Ds {
face: Option<Std14>,
bold: Option<bool>,
italic: Option<bool>,
size: Option<f64>,
color: Option<Rgb>,
}
/// Read a default style string: `;`-separated `CSS` declarations. Unknown or malformed ones are
/// skipped; later ones win.
fn parse_ds(ds: &str) -> Ds {
let end = ds.char_indices().map(|(i, _)| i).find(|i| *i >= MAX_DS).unwrap_or(ds.len());
let ds = ds.get(..end).unwrap_or_default();
let mut out = Ds::default();
for decl in ds.split(';') {
let Some((prop, value)) = decl.split_once(':') else { continue };
let value = value.trim();
match prop.trim().to_ascii_lowercase().as_str() {
"font" => font_shorthand(value, &mut out),
"font-family" => {
if let Some(face) = value.split(',').find_map(family_face) {
out.face = Some(face);
}
}
"font-size" => {
if let Some(size) = font_size(value, true) {
out.size = Some(size);
}
}
"font-weight" => {
if let Some(bold) = weight(value) {
out.bold = Some(bold);
}
}
"font-style" => {
if let Some(italic) = slant(value) {
out.italic = Some(italic);
}
}
"color" => {
if let Some(c) = css_color(value) {
out.color = Some(c);
}
}
_ => {}
}
}
out
}
/// `font: [style] [weight] size[/line-height] family[, family…]`, read leniently: the size is
/// the token with a unit, keywords set weight and slant, and the rest is the family list (some
/// writers put the family first).
fn font_shorthand(value: &str, out: &mut Ds) {
let mut family = Vec::new();
for tok in value.split_whitespace() {
if let Some(size) = font_size(tok, false) {
out.size = Some(size);
} else if let Some(bold) = weight(tok).filter(|_| !tok.eq_ignore_ascii_case("normal")) {
out.bold = Some(bold);
} else if let Some(italic) = slant(tok).filter(|_| !tok.eq_ignore_ascii_case("normal")) {
out.italic = Some(italic);
} else if !matches!(tok.to_ascii_lowercase().as_str(), "normal" | "small-caps") {
family.push(tok);
}
}
if let Some(face) = family.join(" ").split(',').find_map(family_face) {
out.face = Some(face);
}
}
/// A size in points: `14pt` (with a line height, `14pt/16pt`), or a bare number where
/// `unitless` allows it. Positive and finite only; capped as `/DA` sizes are.
fn font_size(tok: &str, unitless: bool) -> Option<f64> {
let tok = tok.split('/').next()?.trim();
let num = match tok.strip_suffix("pt").or_else(|| tok.strip_suffix("PT")) {
Some(n) => n,
None if unitless => tok,
None => return None,
};
num.parse::<f64>().ok().filter(|s| s.is_finite() && *s > 0.0).map(|s| s.min(MAX_SIZE))
}
fn weight(v: &str) -> Option<bool> {
match v.to_ascii_lowercase().as_str() {
"bold" | "bolder" | "600" | "700" | "800" | "900" => Some(true),
"normal" | "lighter" | "100" | "200" | "300" | "400" | "500" => Some(false),
_ => None,
}
}
fn slant(v: &str) -> Option<bool> {
match v.to_ascii_lowercase().as_str() {
"italic" | "oblique" => Some(true),
"normal" => Some(false),
_ => None,
}
}
/// `#RRGGBB`, `#RGB` or `rgb(r, g, b)` (0–255 or percentages).
fn css_color(v: &str) -> Option<Rgb> {
let v = v.trim();
if let Some(hex) = v.strip_prefix('#') {
let digit = |c: char| c.to_digit(16).map(f64::from);
let ds: Vec<f64> = hex.chars().map(digit).collect::<Option<_>>()?;
return match ds.as_slice() {
[r, g, b] => Some([r * 17.0 / 255.0, g * 17.0 / 255.0, b * 17.0 / 255.0]),
[r1, r0, g1, g0, b1, b0] => Some([(r1 * 16.0 + r0) / 255.0, (g1 * 16.0 + g0) / 255.0, (b1 * 16.0 + b0) / 255.0]),
_ => None,
};
}
let lower = v.to_ascii_lowercase();
let inner = lower.strip_prefix("rgb(")?.strip_suffix(')')?;
let parts: Vec<f64> = inner
.split(',')
.map(|p| {
let p = p.trim();
let x = match p.strip_suffix('%') {
Some(pc) => pc.trim().parse::<f64>().ok()? / 100.0,
None => p.parse::<f64>().ok()? / 255.0,
};
x.is_finite().then(|| x.clamp(0.0, 1.0))
})
.collect::<Option<_>>()?;
match parts.as_slice() {
[r, g, b] => Some([*r, *g, *b]),
_ => None,
}
}
#[cfg(test)]
mod tests {
use pdfcraft_cos::PdfString;
use super::*;
fn dict(da: &str, ds: Option<&str>) -> Dict {
let mut d = Dict::new();
d.set(b"DA".to_vec(), PdfString::literal(da.as_bytes().to_vec()));
if let Some(ds) = ds {
d.set(b"DS".to_vec(), PdfString::text(ds));
}
d
}
#[test]
fn da_resource_names_pick_the_standard_faces() {
assert_eq!(text_style(&dict("0 g /Cour 12 Tf", None), None).face, Std14::Courier);
assert_eq!(text_style(&dict("1 0 0 rg /TiRo 16 Tf", None), None).face, Std14::TimesRoman);
assert_eq!(text_style(&dict("/HeBo 9 Tf 0 g", None), None).face, Std14::HelveticaBold);
assert_eq!(text_style(&dict("/Courier-Bold 9 Tf", None), None).face, Std14::CourierBold);
// Unknown, symbolic and family-hint-only names keep Helvetica.
for da in ["/F1 12 Tf", "/ZaDb 12 Tf", "/Symbol 12 Tf", "/MySerif 12 Tf", "12 Tf", "", "/ Tf"] {
assert_eq!(text_style(&dict(da, None), None).face, Std14::Helvetica, "{da:?}");
}
// A `/DR` lookup outranks the name.
assert_eq!(text_style(&dict("/Cour 12 Tf", None), Some(Std14::TimesRoman)).face, Std14::TimesRoman);
}
#[test]
fn ds_font_size_and_colour_win_over_da() {
let s = text_style(&dict("0 g /Helv 12 Tf", Some("font: Times 14pt; color: #008000")), None);
assert_eq!((s.face, s.size), (Std14::TimesRoman, 14.0));
assert!((s.color[1] - 128.0 / 255.0).abs() < 1e-9 && s.color[0] == 0.0 && s.color[2] == 0.0, "{:?}", s.color);
let s = text_style(
&dict(
"1 0 0 rg /Cour 9 Tf",
Some("font-family: 'Helvetica', sans-serif; font-size: 20; font-weight: bold; font-style: italic; color: rgb(0, 0, 255)"),
),
None,
);
assert_eq!((s.face, s.size, s.color), (Std14::HelveticaBoldOblique, 20.0, [0.0, 0.0, 1.0]));
// CSS order, a line height, a #RGB colour and a generic family.
let s = text_style(&dict("0 g /Helv 12 Tf", Some("font: italic bold 10pt/12pt serif; color:#f00")), None);
assert_eq!((s.face, s.size, s.color), (Std14::TimesBoldItalic, 10.0, [1.0, 0.0, 0.0]));
// Bold from the style keeps the DA family.
assert_eq!(text_style(&dict("/Cour 12 Tf", Some("font-weight:700")), None).face, Std14::CourierBold);
}
/// PdfCraft writes `/DS` from `/DA` (8-bit colour): `/DA`'s exact colour, size and face stay.
#[test]
fn pdfcraft_written_ds_matches_da() {
let s = text_style(&dict("0.1 0.2 0.3 rg /Helv 12.5 Tf", Some("font: Helvetica 12.5pt; text-align:left; color:#1A334D")), None);
assert_eq!(s, TextStyle { face: Std14::Helvetica, size: 12.5, color: [0.1, 0.2, 0.3] });
}
#[test]
fn malformed_ds_falls_back_to_da() {
let da = TextStyle { face: Std14::Courier, size: 9.0, color: [1.0, 0.0, 0.0] };
let huge = format!("color: #00ff00;{}", "x".repeat(1 << 20));
let past_cap = format!("{}; font-size: 30pt", " ".repeat(MAX_DS));
for ds in [
"font-size: NaN",
"font-size: inf",
"font-size: -3pt",
"font-size: 0",
"font: NaNpt Zzz",
"font: 1e999pt",
"color: #12",
"color: #zzzzzz",
"color: rgb(1, 2)",
"color: rgb(NaN, 0, 0)",
"color: #ff0000é",
":::;;;",
"font-family: Wingdings, 'Comic Sans'",
"é: ü; ;",
past_cap.as_str(),
] {
assert_eq!(text_style(&dict("1 0 0 rg /Cour 9 Tf", Some(ds)), None), da, "{ds:?}");
}
// Read up to the cap: what comes first still counts, and nothing past it is scanned.
assert_eq!(text_style(&dict("1 0 0 rg /Cour 9 Tf", Some(&huge)), None).color, [0.0, 1.0, 0.0]);
// Sizes are capped as /DA's are.
assert_eq!(text_style(&dict("/Helv 9 Tf", Some("font-size: 1e300pt")), None).size, MAX_SIZE);
}
/// `/DA` strings as files give them: a `Tf` without a size, a zero size (auto-size in a form
/// field, which a text box doesn't have), and hostile operands. None panics, each keeps what it
/// can read, and nothing that isn't a finite number reaches the style.
#[test]
fn hostile_da_strings_keep_what_they_can() {
let style = |da: &str| text_style(&dict(da, None), None);
let black12 = |face| TextStyle { face, size: 12.0, color: [0.0; 3] };
for (da, want) in [
// A missing size keeps the named face at the default size.
("/TiRo Tf", black12(Std14::TimesRoman)),
("0 g /Cour Tf", black12(Std14::Courier)),
// Zero, negative, NaN, infinite or not a number: the default size.
("/Cour 0 Tf", black12(Std14::Courier)),
("/Cour -0 Tf", black12(Std14::Courier)),
("/Cour -12 Tf", black12(Std14::Courier)),
("/Cour NaN Tf", black12(Std14::Courier)),
("/Cour inf Tf", black12(Std14::Courier)),
("/Cour -inf Tf", black12(Std14::Courier)),
("/Cour 1e999 Tf", black12(Std14::Courier)),
("/Cour twelve Tf", black12(Std14::Courier)),
// No Tf at all, or one with nothing before it.
("1 0 0 rg", TextStyle { color: [1.0, 0.0, 0.0], ..black12(Std14::Helvetica) }),
("Tf", black12(Std14::Helvetica)),
("Tf Tf Tf", black12(Std14::Helvetica)),
("", black12(Std14::Helvetica)),
// Colour operands that aren't finite numbers leave the colour black.
("NaN NaN NaN rg /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }),
("NaN g /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }),
("inf 0 0 rg /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }),
("rg g /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }),
// Out-of-range colour components are clamped.
("2 -1 0.5 rg /Helv 9 Tf", TextStyle { size: 9.0, color: [1.0, 0.0, 0.5], ..black12(Std14::Helvetica) }),
// Huge sizes are capped.
("/Helv 1e300 Tf", TextStyle { size: MAX_SIZE, ..black12(Std14::Helvetica) }),
("/Helv 100000 Tf", TextStyle { size: MAX_SIZE, ..black12(Std14::Helvetica) }),
// Garbage around a valid Tf: the last Tf is read.
("\u{0} ] [ << /Helv 9 Tf >> /Cour 14 Tf )", TextStyle { size: 14.0, ..black12(Std14::Courier) }),
] {
assert_eq!(style(da), want, "{da:?}");
let s = style(da);
assert!(s.size.is_finite() && s.size > 0.0 && s.color.iter().all(|c| (0.0..=1.0).contains(c)), "{da:?}: {s:?}");
}
// A font name that is no standard face and that no `/DR` lists keeps Helvetica.
assert_eq!(style("0 g /F9 11 Tf"), TextStyle { size: 11.0, ..black12(Std14::Helvetica) });
// Binary bytes that aren't UTF-8 don't panic.
let mut d = Dict::new();
d.set(b"DA".to_vec(), PdfString::literal(vec![0xFF, 0xFE, b' ', b'/', 0xC3, b' ', b'9', b' ', b'T', b'f']));
assert_eq!(text_style(&d, None).size, 9.0);
assert_eq!(da_font_name(&dict("/TiRo Tf", None)).as_deref(), Some("TiRo"));
assert_eq!(da_font_name(&dict("12 Tf", None)), None);
assert_eq!(da_font_name(&dict("0 g Tf", None)), None);
}
#[test]
fn css_round_trips_through_the_parser() {
for face in [Std14::Helvetica, Std14::TimesBold, Std14::CourierOblique, Std14::HelveticaBoldOblique, Std14::TimesBoldItalic] {
let (family, weight, style) = css(face);
let ds = format!("font-family:{family};font-weight:{weight};font-style:{style}");
assert_eq!(text_style(&dict("/Helv 9 Tf", Some(&ds)), None).face, face);
}
}
}
+292
View File
@@ -0,0 +1,292 @@
//! 3D annotations (ISO 32000-2 §13.6.2): a U3D or PRC model shown in a rectangle on a page,
//! with a poster (`/AP /N`) for printing, for readers without 3D, and until it is activated.
use super::create::{finite, normalize, num_array};
use super::lookup::{annots, page_ref, set_annots};
use super::types::{FLAG_PRINT, Meta};
use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, Stream, appearance, n};
/// The largest model accepted, in bytes: well past real U3D/PRC files, and bounded because the
/// model is held and compressed in memory.
pub const MAX_MODEL_BYTES: usize = 256 << 20;
/// What the poster says when there is no picture.
const PLACEHOLDER_LABEL: &str = "3D model";
/// A 3D stream's data format (§13.6.3): ECMA-363 U3D or ISO 14739-1 PRC.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ModelFormat {
U3d,
Prc,
}
impl ModelFormat {
/// The format a model file's first bytes declare: U3D files start `U3D\0` (ECMA-363 file
/// header block type), PRC files start `PRC`.
///
/// # Errors
///
/// [`AnnotError::NotAModel`] for anything else.
pub fn detect(bytes: &[u8]) -> Result<Self, AnnotError> {
if bytes.starts_with(b"U3D\0") {
Ok(Self::U3d)
} else if bytes.starts_with(b"PRC") {
Ok(Self::Prc)
} else {
Err(AnnotError::NotAModel)
}
}
/// The 3D stream's `/Subtype`.
pub fn subtype(self) -> &'static str {
match self {
Self::U3d => "U3D",
Self::Prc => "PRC",
}
}
/// The PDF version a document holding it needs (§13.6): a 3D annotation is 1.6, a PRC stream
/// 1.7 under Adobe's extension, declared by [`add_3d_model`] (PDF 2.0 takes PRC as standard).
pub fn version(self) -> &'static str {
match self {
Self::U3d => "1.6",
Self::Prc => "1.7",
}
}
}
/// When a reader turns the poster into the live model (§13.6.2, the activation dictionary's
/// `/A`). The standard's default is [`Self::Click`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Activation {
/// `/XA`: when the user clicks it; it prints as its poster.
#[default]
Click,
/// `/PO`: when the page opens.
PageOpen,
/// `/PV`: when the page becomes visible.
PageVisible,
}
impl Activation {
/// Every activation, with the word a tool argument names it by.
pub const ALL: [(Self, &'static str); 3] = [(Self::Click, "click"), (Self::PageOpen, "page_open"), (Self::PageVisible, "page_visible")];
/// The `/A` name.
pub fn name(self) -> &'static str {
match self {
Self::Click => "XA",
Self::PageOpen => "PO",
Self::PageVisible => "PV",
}
}
}
/// A picture already in the document (an image XObject, or a form XObject whose `/Matrix` maps it
/// to `size` points) drawn into `at`, a box inside the annotation's rectangle.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PosterPicture {
pub xobject: ObjRef,
pub image: bool,
pub size: (f64, f64),
pub at: [f64; 4],
}
/// A 3D model to add on `page` (0-based) in `rect` (user space).
#[derive(Clone, Debug, PartialEq)]
pub struct NewModel3d<'a> {
pub page: usize,
pub rect: [f64; 4],
pub model: &'a [u8],
pub activation: Activation,
/// The poster's picture; `None` draws a labelled frame.
pub poster: Option<PosterPicture>,
/// `/Contents`: the annotation's alternate description (§12.5.2).
pub contents: &'a str,
}
/// Check a model before anything is added: its size and its format.
///
/// # Errors
///
/// [`AnnotError::ModelTooLarge`] past [`MAX_MODEL_BYTES`], else as [`ModelFormat::detect`].
pub fn check_model(model: &[u8]) -> Result<ModelFormat, AnnotError> {
check_model_size(model, MAX_MODEL_BYTES)?;
ModelFormat::detect(model)
}
fn check_model_size(model: &[u8], max: usize) -> Result<(), AnnotError> {
if model.len() > max {
return Err(AnnotError::ModelTooLarge { size: model.len(), max });
}
Ok(())
}
/// Add a 3D annotation: the model in a Flate-compressed 3D stream referenced from `/3DD` (so the
/// annotation has its own instance, §13.6.2), `/3DA` stating only `/A`, and the poster as `/AP /N`.
/// No view (`/3DV`, `/VA`) is written: readers open on the model's own default view. A document
/// older than [`ModelFormat::version`] is raised to it. Returns its index in the page's `/Annots`.
///
/// # Errors
///
/// As [`check_model`]; [`AnnotError::NoSuchPage`]; [`AnnotError::Invalid`] for a rectangle with no
/// area or a poster box outside it.
pub fn add_3d_model(doc: &mut Document, new: &NewModel3d<'_>, meta: &Meta) -> Result<usize, AnnotError> {
let format = check_model(new.model)?;
let page = page_ref(doc, new.page)?;
let rect = normalize(new.rect);
if !finite(&new.rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
return Err(AnnotError::Invalid("invalid rectangle for the 3D model (each side at least 1 pt)".into()));
}
let poster = match &new.poster {
Some(p) => picture_poster(rect, p)?,
None => placeholder_poster(rect),
};
let mut stream = Dict::new();
stream.set(b"Type".to_vec(), Object::name("3D"));
stream.set(b"Subtype".to_vec(), Object::name(format.subtype()));
let stream = doc.add(Object::Stream(Stream::flate(stream, new.model)));
let poster = doc.add(Object::Stream(poster));
let d = annotation_dict(new, rect, page, stream, poster, meta);
let r = doc.add(Object::Dict(d));
let mut list = annots(doc, page);
let index = list.len();
list.push(Object::Ref(r));
set_annots(doc, page, list)?;
at_least_version(doc, format.version())?;
if format == ModelFormat::Prc && !at_least(doc, "2.0") {
declare_prc_extension(doc)?;
}
Ok(index)
}
/// Whether the header or the catalog `/Version` says `min` or later (§7.7.2).
fn at_least(doc: &Document, min: &str) -> bool {
let reached = |v: &[u8]| v.len() >= 3 && v >= min.as_bytes();
let catalog = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned()));
let catalog = catalog.and_then(|v| doc.resolve(&v).as_name().map(<[u8]>::to_vec));
reached(doc.version().as_bytes()) || catalog.as_deref().is_some_and(reached)
}
/// PRC came before PDF 2.0 as an Adobe extension to 1.7: such a file declares
/// `/Extensions /ADBE` with ExtensionLevel 1 or later (§7.12). Other extensions and an ADBE entry
/// that already has a level are kept; one that is not a dictionary (an array of them) is left alone.
fn declare_prc_extension(doc: &mut Document) -> Result<(), AnnotError> {
let Some(root) = doc.root() else { return Ok(()) };
let catalog = doc.get(root);
// `None` when present but not a dictionary; an empty one when absent.
let dict_or_new = |o: Option<&Object>| match o.map(|o| doc.resolve(o)) {
None => Some(Dict::new()),
Some(o) => o.as_dict().cloned(),
};
let Some(mut extensions) = dict_or_new(catalog.as_dict().and_then(|c| c.get(b"Extensions"))) else { return Ok(()) };
let Some(mut adbe) = dict_or_new(extensions.get(b"ADBE")) else { return Ok(()) };
if adbe.get(b"ExtensionLevel").map(|l| doc.resolve(l)).and_then(|l| l.as_int()).is_some_and(|l| l >= 1) {
return Ok(());
}
adbe.set(b"BaseVersion".to_vec(), Object::name("1.7"));
adbe.set(b"ExtensionLevel".to_vec(), Object::Int(1));
extensions.set(b"ADBE".to_vec(), Object::Dict(adbe));
doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(extensions)))?;
Ok(())
}
/// A document whose header and catalog `/Version` both say less than `min` gets `/Version min` in
/// its catalog (§7.7.2: the later of the two applies).
fn at_least_version(doc: &mut Document, min: &str) -> Result<(), AnnotError> {
let Some(root) = doc.root() else { return Ok(()) };
if !at_least(doc, min) {
doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name(min)))?;
}
Ok(())
}
fn annotation_dict(new: &NewModel3d<'_>, rect: [f64; 4], page: ObjRef, stream: ObjRef, poster: ObjRef, meta: &Meta) -> Dict {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("Annot"));
d.set(b"Subtype".to_vec(), Object::name("3D"));
d.set(b"Rect".to_vec(), num_array(&rect));
d.set(b"P".to_vec(), Object::Ref(page));
d.set(b"F".to_vec(), Object::Int(FLAG_PRINT));
if !new.contents.is_empty() {
d.set(b"Contents".to_vec(), PdfString::text(new.contents));
}
if !meta.id.is_empty() {
d.set(b"NM".to_vec(), PdfString::text(&meta.id));
}
if let Some(date) = &meta.date {
d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec()));
}
d.set(b"3DD".to_vec(), Object::Ref(stream));
let mut activation = Dict::new();
activation.set(b"A".to_vec(), Object::name(new.activation.name()));
d.set(b"3DA".to_vec(), Object::Dict(activation));
let mut ap = Dict::new();
ap.set(b"N".to_vec(), Object::Ref(poster));
d.set(b"AP".to_vec(), Object::Dict(ap));
d
}
/// The poster drawing `p` into its box, which must lie within `rect`.
fn picture_poster(rect: [f64; 4], p: &PosterPicture) -> Result<Stream, AnnotError> {
let at = normalize(p.at);
// A box fitted to `rect` in floating point can end a few ulps outside it: allow that, then clamp.
let slack = rect.iter().fold(1.0_f64, |m, v| m.max(v.abs())) * 1e-9;
let near = finite(&p.at) && at[0] >= rect[0] - slack && at[1] >= rect[1] - slack && at[2] <= rect[2] + slack && at[3] <= rect[3] + slack;
let at = [at[0].max(rect[0]), at[1].max(rect[1]), at[2].min(rect[2]), at[3].min(rect[3])];
if !(near && at[2] > at[0] && at[3] > at[1]) {
return Err(AnnotError::Invalid("the poster must fit inside the 3D model's rectangle".into()));
}
let place = appearance::picture_place(at, p.image, Some(p.size)).ok_or_else(|| AnnotError::Invalid("the poster picture has no size".into()))?;
let mut xobjects = Dict::new();
xobjects.set(b"Pic".to_vec(), Object::Ref(p.xobject));
let mut resources = Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
Ok(appearance::form(rect, format!("q {place} /Pic Do Q\n").as_bytes(), resources))
}
/// A light frame with "3D model" centred in it, shrunk to fit (left out when even 2 pt won't).
fn placeholder_poster(rect: [f64; 4]) -> Stream {
let [x0, y0, x1, y1] = rect;
let (w, h) = (x1 - x0, y1 - y0);
let mut c =
format!("0.94 g {} {} {} {} re f\n0.45 G 1 w {} {} {} {} re S\n", n(x0), n(y0), n(w), n(h), n(x0 + 0.5), n(y0 + 0.5), n(w - 1.0), n(h - 1.0));
let mut resources = Dict::new();
let unit = appearance::text_width(PLACEHOLDER_LABEL, 1.0);
let size = if unit > 0.0 { (w * 0.8 / unit).min(h * 0.4).min(14.0) } else { 0.0 };
if size >= 2.0 {
let (tx, ty) = (x0 + (w - unit * size) / 2.0, y0 + (h - size * 0.7) / 2.0);
c.push_str(&format!("BT 0.3 g /Helv {} Tf {} {} Td ({PLACEHOLDER_LABEL}) Tj ET\n", n(size), n(tx), n(ty)));
resources.set(b"Font".to_vec(), appearance::helvetica_resource());
}
appearance::form(rect, c.as_bytes(), resources)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn model_size_is_capped() {
assert_eq!(check_model_size(&[0; 8], 8), Ok(()));
assert_eq!(check_model_size(&[0; 9], 8), Err(AnnotError::ModelTooLarge { size: 9, max: 8 }));
}
#[test]
fn the_format_is_read_from_the_header() {
assert_eq!(ModelFormat::detect(b"U3D\0rest"), Ok(ModelFormat::U3d));
assert_eq!(ModelFormat::detect(b"PRC8800"), Ok(ModelFormat::Prc));
for junk in [&b""[..], b"U3D", b"U3DX", b"PR", b"%PDF-1.7", b"prc"] {
assert_eq!(ModelFormat::detect(junk), Err(AnnotError::NotAModel), "{junk:?}");
}
}
#[test]
fn the_placeholder_label_shrinks_to_fit_and_is_dropped_when_it_cannot() {
let wide = String::from_utf8(placeholder_poster([0.0, 0.0, 300.0, 200.0]).decoded().unwrap().to_vec()).unwrap();
assert!(wide.contains("/Helv 14 Tf") && wide.contains("(3D model) Tj"), "{wide}");
let tiny = placeholder_poster([0.0, 0.0, 4.0, 4.0]);
assert!(!String::from_utf8(tiny.decoded().unwrap().to_vec()).unwrap().contains("Tj"));
assert!(tiny.dict.get(b"Resources").and_then(Object::as_dict).is_none_or(|r| r.get(b"Font").is_none()));
}
}
+137 -6
View File
@@ -1,5 +1,5 @@
use super::create::normalize;
use super::{ObjRef, appearance};
use super::{Dict, ObjRef, appearance};
pub type Rgb = [f64; 3];
@@ -124,6 +124,28 @@ impl FillMark {
let (w, h) = if matches!(rotation.rem_euclid(360), 90 | 270) { (h, w) } else { (w, h) };
[at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0]
}
/// The mark whose `/Name` this is.
pub fn from_name(n: &[u8]) -> Option<Self> {
[FillMark::Check, FillMark::Cross, FillMark::Dot, FillMark::Line].into_iter().find(|m| m.name().as_bytes() == n)
}
/// The pen strokes the mark is drawn with in `rect` (user space): what its appearance strokes,
/// and what a copy of it shows while it is being placed. The dot is a filled circle (radius
/// 0.3 × the shorter side, centred) and has none.
pub fn strokes(self, rect: [f64; 4]) -> Vec<Vec<[f64; 2]>> {
let [x0, y0, x1, y1] = rect;
let (w, h) = (x1 - x0, y1 - y0);
match self {
FillMark::Check => vec![vec![[x0 + w * 0.15, y0 + h * 0.5], [x0 + w * 0.4, y0 + h * 0.2], [x0 + w * 0.88, y0 + h * 0.85]]],
FillMark::Cross => vec![
vec![[x0 + w * 0.18, y0 + h * 0.18], [x1 - w * 0.18, y1 - h * 0.18]],
vec![[x0 + w * 0.18, y1 - h * 0.18], [x1 - w * 0.18, y0 + h * 0.18]],
],
FillMark::Dot => Vec::new(),
FillMark::Line => vec![vec![[x0, y0 + h / 2.0], [x1, y0 + h / 2.0]]],
}
}
}
/// The stamps of Acrobat's stamp palette (drawn in PdfCraft's own style).
@@ -395,13 +417,17 @@ pub enum Shape {
contours: Vec<Vec<[f64; 2]>>,
},
/// A custom stamp: a picture already in the document (an image XObject, or a form XObject
/// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`.
/// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`. With `line`, a
/// line of text under the picture (#1741): a dynamic stamp's name, date and time, filled in
/// when it was placed; the picture then fills the rectangle above it
/// ([`CUSTOM_LINE_BAND`](crate::CUSTOM_LINE_BAND)).
CustomStamp {
rect: [f64; 4],
name: String,
picture: ObjRef,
image: bool,
size: (f64, f64),
line: Option<String>,
},
/// A redaction mark (§12.5.6.23) over quadrilaterals (text) or one rectangle as a quad
/// (areas, pages). `overlay` is the text shown on the box once applied, drawn with `look`.
@@ -566,6 +592,16 @@ pub struct Style {
pub width: f64,
/// Interior fill for rectangles and ovals (`/IC`), background for text boxes.
pub fill: Option<Rgb>,
/// A text box's, typewriter text's or callout's border colour where it differs from the
/// text's (`None`: the text colour), the stroking colour in its `/DA`.
pub border_color: Option<Rgb>,
/// A text box's or typewriter text's border shape (a callout's is always a rectangle).
pub border_shape: BorderShape,
/// The fill's own opacity, 0–1 (`/ca`), where it differs from `opacity` (which is then the
/// line's or border's); `None`: as `opacity`.
pub fill_opacity: Option<f64>,
/// A text box's text's own opacity, 0–1 (`/PCTextOpacity`); `None`: as the fill's.
pub text_opacity: Option<f64>,
}
/// The style properties changed by one annotation edit. `None` leaves a property unchanged.
@@ -597,7 +633,102 @@ pub struct TextReplacement<'a> {
impl Default for Style {
fn default() -> Self {
Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None }
Self {
color: [1.0, 0.82, 0.0],
opacity: 1.0,
width: 1.0,
fill: None,
fill_opacity: None,
text_opacity: None,
border_color: None,
border_shape: BorderShape::Rectangle,
}
}
}
/// The shape of a text box's or typewriter text's border (PdfCraft's `/PCBorderShape`, absent for
/// a rectangle). An oval or a circle goes around the text box, which keeps its place: `/Rect`
/// grows to hold it, centred on the text box, and `/RD` says where the text box is inside
/// (§12.5.6.6), as for a callout. Other apps that redraw the comment draw a rectangle.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum BorderShape {
#[default]
Rectangle,
Oval,
Circle,
}
impl BorderShape {
pub const ALL: [BorderShape; 3] = [BorderShape::Rectangle, BorderShape::Oval, BorderShape::Circle];
pub fn name(self) -> &'static str {
match self {
BorderShape::Rectangle => "Rectangle",
BorderShape::Oval => "Oval",
BorderShape::Circle => "Circle",
}
}
/// `rectangle`, `oval` or `circle`, in any case.
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|b| b.name().eq_ignore_ascii_case(s))
}
/// The border shape of FreeText `d`: always a rectangle for a callout.
pub fn of(d: &Dict) -> Self {
if d.contains(b"CL") {
return BorderShape::Rectangle;
}
match d.name(b"PCBorderShape") {
Some(b"Oval") => BorderShape::Oval,
Some(b"Circle") => BorderShape::Circle,
_ => BorderShape::Rectangle,
}
}
/// The rectangle this shape, with a border `width` wide, takes around text box `tb`, centred on
/// it: an oval through the text box's corners (`√2` its size), a point clear of the border; a
/// circle around the text's lines (`text`: the widest line's width and the lines' height, when
/// known), a point clear of the border and touching the text box's longer sides at least
/// (its corners, padding only, may lie outside), else through the box's corners; the text box
/// itself for a rectangle.
pub fn around(self, tb: [f64; 4], width: f64, text: Option<[f64; 2]>) -> [f64; 4] {
let tb = normalize(tb);
let (cx, cy, hx, hy) = ((tb[0] + tb[2]) / 2.0, (tb[1] + tb[3]) / 2.0, (tb[2] - tb[0]) / 2.0, (tb[3] - tb[1]) / 2.0);
let gap = width.max(0.0) + 1.0;
let (ax, ay) = match self {
BorderShape::Rectangle => return tb,
BorderShape::Oval => (hx * std::f64::consts::SQRT_2 + gap, hy * std::f64::consts::SQRT_2 + gap),
BorderShape::Circle => {
let r = match text {
Some([w, h]) => ((w / 2.0).hypot(h / 2.0) + gap).max(hx).max(hy),
None => hx.hypot(hy) + gap,
};
(r, r)
}
};
[cx - ax, cy - ay, cx + ax, cy + ay]
}
/// The text box inside `outer` for this shape when none is recorded: the inverse of
/// [`Self::around`] for an oval, a box through a circle's corners for a circle, keeping the
/// proportions of `was` (the one it had).
pub(crate) fn inside(self, outer: [f64; 4], width: f64, was: [f64; 4]) -> [f64; 4] {
let o = normalize(outer);
let (cx, cy, hx, hy) = ((o[0] + o[2]) / 2.0, (o[1] + o[3]) / 2.0, (o[2] - o[0]) / 2.0, (o[3] - o[1]) / 2.0);
let gap = width.max(0.0) + 1.0;
let (ix, iy) = match self {
BorderShape::Rectangle => return o,
BorderShape::Oval => ((hx - gap) / std::f64::consts::SQRT_2, (hy - gap) / std::f64::consts::SQRT_2),
BorderShape::Circle => {
let w = normalize(was);
let (wx, wy) = ((w[2] - w[0]) / 2.0, (w[3] - w[1]) / 2.0);
let k = (hx.min(hy) - gap) / wx.hypot(wy).max(1e-6);
(wx * k, wy * k)
}
};
let (ix, iy) = (ix.max(0.5), iy.max(0.5));
[cx - ix, cy - iy, cx + ix, cy + iy]
}
}
@@ -615,7 +746,7 @@ impl Style {
}
Shape::Callout { .. } => ([0.0, 0.0, 0.0], 1.0),
Shape::Caret { .. } | Shape::Attachment { .. } => ([0.0, 0.47, 0.84], 1.0),
Shape::FreehandHighlight { .. } => return Self { color: [1.0, 0.94, 0.0], opacity: 0.35, width: 14.0, fill: None },
Shape::FreehandHighlight { .. } => return Self { color: [1.0, 0.94, 0.0], opacity: 0.35, width: 14.0, ..Self::default() },
Shape::Ink { .. } => ([0.0, 0.4, 0.87], 2.0),
Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0),
Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5),
@@ -624,9 +755,9 @@ impl Style {
Shape::CustomStamp { .. } => ([0.0, 0.0, 0.0], 0.0),
Shape::TypedSignature { .. } => ([0.0, 0.0, 0.0], 0.0),
// Red outline while marked; a black box once applied.
Shape::Redact { .. } => return Self { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: Some([0.0, 0.0, 0.0]) },
Shape::Redact { .. } => return Self { color: [0.89, 0.13, 0.13], fill: Some([0.0, 0.0, 0.0]), ..Self::default() },
};
Self { color, opacity: 1.0, width, fill: None }
Self { color, width, ..Self::default() }
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
Layer L4 (may use `edit`): verification tooling, the faux-redaction audit.
```rust
let findings: Vec<Finding> = audit_redactions(&doc)?; // page, kind, rect, covered_text
let findings: Vec<AuditFinding> = audit_redactions(&doc)?; // page, kind, rect, covered_text
```
It reports two kinds of finding:
+21 -20
View File
@@ -15,7 +15,7 @@
//! the joined stream)`; rectangles inside a form XObject carry the `Do` operator's position.
//! Positions compare lexicographically in paint order.
use pdfcraft_content::{Matrix, Op, Pieces, parse};
use pdfcraft_content::{Matrix, Op, Pieces, ops};
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
use crate::AuditError;
@@ -123,12 +123,13 @@ impl<'a> Walker<'a> {
Ok(())
}
/// Walk one operator slice with the given graphics state. `at_do` is the `Do` position to
/// Walk one stream's operators, read one at a time (a stream of millions of short operators
/// is never held whole), with the given graphics state. `at_do` is the `Do` position to
/// stamp on rectangles found inside a form XObject, or `None` at page level (where each
/// fill operator's own position is used).
fn walk_ops(
&mut self,
ops: &[Op],
ops: impl Iterator<Item = Op>,
gs: &mut Gs,
stack: &mut Vec<Gs>,
resources: &Dict,
@@ -136,8 +137,9 @@ impl<'a> Walker<'a> {
at_do: Option<(usize, usize)>,
) -> Result<(), AuditError> {
let mut path = Path::Empty;
for (i, op) in ops.iter().enumerate() {
for (i, op) in ops.enumerate() {
self.bump()?;
let op = &op;
// Paint position of this operator: its own `(piece, joined index)` at page level,
// the enclosing `Do`'s position inside a form XObject.
let pos = match at_do {
@@ -257,8 +259,9 @@ impl<'a> Walker<'a> {
/// changes inside never leak out; every rectangle found is stamped with the `Do`
/// position `pos`.
fn walk_xobject(&mut self, name: &[u8], gs: &Gs, resources: &Dict, depth: usize, pos: (usize, usize)) -> Result<(), AuditError> {
let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
let Some(entry) = xobjects.get(name) else { return Ok(()) };
// Borrowed, not copied: a `Do` must not cost the size of the whole XObject table.
let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o));
let Some(entry) = xobjects.as_deref().and_then(Object::as_dict).and_then(|d| d.get(name)) else { return Ok(()) };
let resolved = self.doc.resolve(entry);
let Object::Stream(stream) = &*resolved else { return Ok(()) };
let dict = &stream.dict;
@@ -274,18 +277,18 @@ impl<'a> Walker<'a> {
(v.len() == 6 && v.iter().all(|x| x.is_finite())).then(|| Matrix([v[0], v[1], v[2], v[3], v[4], v[5]]))
})
.unwrap_or(Matrix::IDENTITY);
let sub_resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
let (sub_resources, empty) = (dict.get(b"Resources").map(|o| self.doc.resolve(o)), Dict::new());
let sub_resources = sub_resources.as_deref().and_then(Object::as_dict).unwrap_or(&empty);
if !self.budget.enter_form() {
return Err(AuditError::TooManyForms);
}
// A form stream that cannot be decoded fails the audit: it might hide a cover.
let data = self.budget.decode_strict(stream)?;
let ops = parse(&data).ops;
let data = self.budget.decode_strict(self.doc, stream)?;
// The XObject matrix maps form space into the user space active at `Do`.
let mut sub_gs = gs.clone();
sub_gs.ctm = matrix.then(&gs.ctm);
let mut sub_stack = Vec::new();
self.walk_ops(&ops, &mut sub_gs, &mut sub_stack, &sub_resources, depth + 1, Some(pos))
self.walk_ops(ops(&data), &mut sub_gs, &mut sub_stack, sub_resources, depth + 1, Some(pos))
}
}
@@ -298,13 +301,10 @@ fn user_point(gs: &Gs, op: &Op) -> Option<[f64; 2]> {
/// The non-stroking alpha (`/ca`) of a named ExtGState, defaulting to opaque.
fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 {
let alpha = resources
.get(b"ExtGState")
.map(|o| doc.resolve(o))
.and_then(|o| o.as_dict().cloned())
.and_then(|d| d.get(name).map(|o| doc.resolve(o)))
.and_then(|o| o.as_dict().cloned())
.and_then(|d| d.get(b"ca").map(|o| doc.resolve(o)).and_then(|o| o.as_f64()));
// Borrowed, not copied: a `gs` must not cost the size of the whole ExtGState table.
let states = resources.get(b"ExtGState").map(|o| doc.resolve(o));
let state = states.as_deref().and_then(Object::as_dict).and_then(|d| d.get(name)).map(|o| doc.resolve(o));
let alpha = state.as_deref().and_then(Object::as_dict).and_then(|d| d.get(b"ca").map(|o| doc.resolve(o)).and_then(|o| o.as_f64()));
alpha.filter(|a| a.is_finite()).map(|a| a.clamp(0.0, 1.0)).unwrap_or(1.0)
}
@@ -326,14 +326,15 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result<Vec<FilledRect>, Audi
let mut streams = Vec::with_capacity(contents.len());
for c in &contents {
if let Object::Stream(s) = &*doc.resolve(c) {
streams.push(budget.decode_strict(s)?);
streams.push(budget.decode_strict(doc, s)?);
}
}
let pieces = Pieces::join(&streams);
let parsed = pieces.parse();
let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0, budget };
let mut gs = Gs::default();
let mut stack = Vec::new();
w.walk_ops(&parsed.ops, &mut gs, &mut stack, &resources, 0, None)?;
// Read one operator at a time: the joined stream can hold millions of them within the
// decode budget, and the walk only ever needs the current one.
w.walk_ops(ops(pieces.data()), &mut gs, &mut stack, &resources, 0, None)?;
Ok(w.out)
}
+38 -19
View File
@@ -99,6 +99,9 @@ const COVER_FRACTION: f64 = 0.4;
const WORD_FRACTION: f64 = 0.5;
/// WCAG contrast ratio below which the text is unreadable against the cover.
const COVER_CONTRAST: f64 = 3.0;
/// Bytes of a hidden line's text read (copied or trimmed) that cost one comparison: a byte scan
/// is far cheaper than a box comparison, but a long padded line under many covers adds up.
const TEXT_BYTES_PER_COMPARISON: usize = 16;
/// Audit every page for faux redactions and unapplied redaction marks. Fails closed: any
/// content that cannot be decoded, or that needs more work or report than the audit's bounds, is
@@ -157,15 +160,24 @@ impl Budget {
cover: &[f64; 4],
hides: impl Fn(&TextLine) -> bool,
) -> Result<Option<String>, AuditError> {
self.comparisons = self.comparisons.saturating_add(self.page_cost);
if self.comparisons > self.limits.comparisons {
return Err(AuditError::TooManyComparisons);
}
self.spend(self.page_cost)?;
if !copying {
return Ok(lines.iter().any(|line| hides(line) && !line.text.trim().is_empty()).then(String::new));
let mut hidden = false;
for line in lines.iter().filter(|l| hides(l)) {
// Trimming walks the line's text (padding and all): it costs its bytes.
self.spend(line.text.len() / TEXT_BYTES_PER_COMPARISON)?;
if !line.text.trim().is_empty() {
hidden = true;
break;
}
}
return Ok(hidden.then(String::new));
}
let mut covered = String::new();
for line in lines.iter().filter(|l| hides(l)) {
// Copying and trimming the line walks its text once per cover: a long padded
// line under many covers would otherwise be read far more than its runs suggest.
self.spend(line.text.len() / TEXT_BYTES_PER_COMPARISON)?;
if !covered.is_empty() {
covered.push(' ');
}
@@ -174,11 +186,21 @@ impl Budget {
Ok((!covered.trim().is_empty()).then_some(covered))
}
/// `n` units of work done against the comparison bound.
fn spend(&mut self, n: usize) -> Result<(), AuditError> {
self.comparisons = self.comparisons.saturating_add(n);
if self.comparisons > self.limits.comparisons {
return Err(AuditError::TooManyComparisons);
}
Ok(())
}
/// The text a finding reports for `covered`: none when copying is withheld. Charges what it
/// reports against the output bound.
/// reports, and the finding's own record, against the output bound: a finding with no text
/// (copying withheld, or a shared annotation list on many pages) still takes memory.
fn report_text(&mut self, covered: String, copying: bool) -> Result<String, AuditError> {
let reported = if copying { covered } else { String::new() };
self.covered_text = self.covered_text.saturating_add(reported.len());
self.covered_text = self.covered_text.saturating_add(reported.len()).saturating_add(std::mem::size_of::<AuditFinding>());
if self.covered_text > self.limits.covered_text {
return Err(AuditError::TooMuchOutput);
}
@@ -321,17 +343,14 @@ enum AnnotationCover {
fn annots(doc: &Document, page: &Dict) -> Result<Vec<AnnotationCover>, AuditError> {
let Some(a) = page.get(b"Annots") else { return Ok(Vec::new()) };
let resolved = doc.resolve(a);
let items: Vec<pdfcraft_cos::Object> = match &*resolved {
pdfcraft_cos::Object::Array(items) => items.clone(),
// A missing /Annots means no marks; a malformed one fails the audit.
_ => return Err(AuditError::BadAnnots),
};
// A missing /Annots means no marks; a malformed one fails the audit.
let Some(items) = resolved.as_array() else { return Err(AuditError::BadAnnots) };
let mut out = Vec::new();
for a in &items {
let dict = match &*doc.resolve(a) {
pdfcraft_cos::Object::Dict(d) => d.clone(),
_ => continue,
};
for a in items {
// Borrowed from the resolved object: one large dictionary listed many times is not
// copied once per listing.
let resolved = doc.resolve(a);
let Some(dict) = resolved.as_dict() else { continue };
let v: Vec<f64> = match dict.get(b"Rect") {
None => continue,
Some(r) => doc.resolve(r).as_array().map(|arr| arr.iter().filter_map(|o| doc.resolve(o).as_f64()).collect()).unwrap_or_default(),
@@ -340,7 +359,7 @@ fn annots(doc: &Document, page: &Dict) -> Result<Vec<AnnotationCover>, AuditErro
continue;
}
let rect = [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])];
match subtype(doc, &dict)?.as_deref() {
match subtype(doc, dict)?.as_deref() {
Some(b"Redact") => out.push(AnnotationCover::Redact(rect)),
Some(b"Square") => {
let flags = dict.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
@@ -349,7 +368,7 @@ fn annots(doc: &Document, page: &Dict) -> Result<Vec<AnnotationCover>, AuditErro
}
let alpha = dict.get(b"CA").map(|o| doc.resolve(o)).and_then(|o| o.as_f64()).unwrap_or(1.0);
if alpha >= 1.0
&& let Some(rgb) = annotation_color(doc, &dict, b"IC")
&& let Some(rgb) = annotation_color(doc, dict, b"IC")
{
out.push(AnnotationCover::Square { rect, rgb });
}
+60 -5
View File
@@ -720,9 +720,11 @@ fn runs_without_words_count_against_the_comparison_bound() {
let doc = open(one_page(&content, ""));
let lines = pdfcraft_edit::glyph_text_lines(&doc, &pdfcraft_model::pages(&doc)[0]).expect("text");
assert_eq!(lines.iter().map(|l| (l.ink.len(), l.runs().count())).collect::<Vec<_>>(), [(1, 51)]);
let limits = crate::Limits { comparisons: 50, covered_text: usize::MAX };
// Plus the hidden line's text, read once to copy it.
let text = lines[0].text.len() / crate::TEXT_BYTES_PER_COMPARISON;
let limits = crate::Limits { comparisons: 50 + text, covered_text: usize::MAX };
assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons)));
let limits = crate::Limits { comparisons: 51, covered_text: usize::MAX };
let limits = crate::Limits { comparisons: 51 + text, covered_text: usize::MAX };
assert_eq!(crate::audit(&doc, limits).expect("within the budget").len(), 1);
}
@@ -746,14 +748,18 @@ fn copy_restricted_documents_withhold_covered_text() {
let findings = audit_redactions(&user).expect("audit must run");
assert_eq!(findings.len(), 1, "the cover is still reported: {findings:?}");
assert_eq!(findings[0].covered_text, "");
// The output bound counts the text reported, so withheld text costs nothing.
let nothing = crate::Limits { comparisons: usize::MAX, covered_text: 0 };
// The output bound counts the text reported and the finding's record, so withheld text
// costs only the record.
let record = std::mem::size_of::<AuditFinding>();
let nothing = crate::Limits { comparisons: usize::MAX, covered_text: record };
assert_eq!(crate::audit(&user, nothing).expect("withheld text is not output").len(), 1);
let none = crate::Limits { covered_text: record - 1, ..nothing };
assert!(matches!(crate::audit(&user, none), Err(crate::AuditError::TooMuchOutput)));
let owner = Document::open_with_password(bytes, Some("owner")).expect("open");
let findings = audit_redactions(&owner).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].covered_text, "SECRET DATA 123");
let exact = crate::Limits { comparisons: usize::MAX, covered_text: "SECRET DATA 123".len() };
let exact = crate::Limits { comparisons: usize::MAX, covered_text: "SECRET DATA 123".len() + record };
assert!(crate::audit(&owner, exact).is_ok());
let short = crate::Limits { covered_text: exact.covered_text - 1, ..exact };
assert!(matches!(crate::audit(&owner, short), Err(crate::AuditError::TooMuchOutput)));
@@ -820,3 +826,52 @@ fn form_entries_past_the_page_allowance_fail_the_audit() {
let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect();
assert!(matches!(crate::cover::filled_rects(&doc, &page), Err(crate::AuditError::TooManyForms)));
}
/// Copying a covered line's text walks all of it, padding included, so a long padded line under
/// many covers costs its bytes once per cover, not one run per cover (CodeRabbit).
#[test]
fn covered_text_bytes_count_against_the_comparison_bound() {
// One run: 20 KiB of spaces around a word, which trims to the word alone.
let mut content = b"BT /F1 24 Tf 100 700 Td (".to_vec();
content.extend_from_slice(&[b' '; 10_000]);
content.extend_from_slice(b"SECRET");
content.extend_from_slice(&[b' '; 10_000]);
content.extend_from_slice(b") Tj ET\n0 g\n");
for _ in 0..10 {
content.extend_from_slice(b"-1 688 1000000 32 re f\n");
}
let doc = open(one_page(&content, ""));
let lines = pdfcraft_edit::glyph_text_lines(&doc, &pdfcraft_model::pages(&doc)[0]).expect("text");
assert_eq!(lines.len(), 1);
let bytes = lines[0].text.len();
assert!(bytes > 20_000, "{bytes} bytes of text");
// Each cover: one comparison for the line, plus its bytes when it is hidden.
let exact = crate::Limits { comparisons: 10 * (1 + bytes / crate::TEXT_BYTES_PER_COMPARISON), covered_text: usize::MAX };
let findings = crate::audit(&doc, exact).expect("within the budget");
assert_eq!(findings.len(), 10, "{findings:?}");
assert!(findings.iter().all(|f| f.covered_text == "SECRET"), "{findings:?}");
let short = crate::Limits { comparisons: exact.comparisons - 1, ..exact };
assert!(matches!(crate::audit(&doc, short), Err(crate::AuditError::TooManyComparisons)));
}
/// One annotation list shared by many pages makes a finding on each: every finding's record
/// counts against the output bound, text or no text (CodeRabbit).
#[test]
fn every_finding_record_counts_against_the_output_bound() {
let content = b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n";
let mut b = Builder::new();
b.add("<< /Type /Catalog /Pages 2 0 R >>");
b.add("<< /Type /Pages /Kids [3 0 R 7 0 R 8 0 R] /Count 3 >>");
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots 6 0 R >>");
b.add_stream(content);
b.add(FONT);
b.add("[<< /Type /Annot /Subtype /Redact /Rect [95 685 200 725] >>]");
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots 6 0 R >>");
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots 6 0 R >>");
let doc = open(b.build());
let one = "SECRET".len() + std::mem::size_of::<AuditFinding>();
let exact = crate::Limits { comparisons: usize::MAX, covered_text: 3 * one };
assert_eq!(crate::audit(&doc, exact).expect("within the budget").len(), 3);
let short = crate::Limits { covered_text: 3 * one - 1, ..exact };
assert!(matches!(crate::audit(&doc, short), Err(crate::AuditError::TooMuchOutput)));
}
+2
View File
@@ -14,6 +14,8 @@ pdfcraft-cos = { workspace = true }
pdfcraft-render = { workspace = true }
pdfcraft-organize = { workspace = true }
pdfcraft-platform = { workspace = true }
pdfcraft-prc = { workspace = true }
pdfcraft-prc-render = { workspace = true }
serde_json = { workspace = true }
sha2 = "0.11"
tempfile = "3.27"
+97 -4
View File
@@ -5,6 +5,8 @@ Agent control for PdfCraft: a headless tool table over the engine, and an opt-in
- **Layer:** L7 (architecture §13), but headless. It depends on `engine`, `render` and `organize`, never on a UI toolkit.
- **Status:** over a hundred tools; `pdfcraft-cli tools` lists the current set with their schemas (the count isn't repeated here, so it can't go stale). They cover inspection, rendering, text, page edits and organizing, page labels, bookmarks, comments and their review, form filling and authoring (fields, properties, scripts, data exchange, detection), redaction and sanitizing, password protection and signatures, calibrated 2D measurements and CSV export, adding text, images, links and stamps, optimizing, OCR, printing, export, metadata, undo/redo, save, combine, extract and split. Comment tools take geometry in the same top-left-origin points as everything else, and `comment_add` can mark text by searching for it (`find`). The MCP server also serves the open documents as resources, page images included. The running app's UI is driven separately, through the `ui.*` control channel in `pdfcraft-ui-egui`.
`fill_sign_add` text accepts line breaks, including blank lines. Its typewriter box grows to hold all wrapped rows, and saving/reopening preserves the contents and appearance.
## API
```rust
@@ -24,6 +26,11 @@ its undo/redo history.
`page_add_text`, `content_update` and `text_edit` take per-line `aligns`. An `"inherit"` entry
follows the box-level `align`; legacy `null` entries remain accepted with the same meaning.
`page_add_text` and `content_update` take one of `color` (`#RRGGBB` or a name, painted in
DeviceRGB), `gray` (0 black to 1 white, DeviceGray `g`) and `cmyk` (`[c, m, y, k]`, each 0 to 1,
DeviceCMYK `k`), so text added to a print file can be 100 K black. `content_list` reports `gray` or
`cmyk` for such text.
`Content` is `Json(Value)` or `Png { data, width, height }`. Errors are `UnknownTool`, `InvalidArgs` (the call didn't match the schema) or `Failed` (a readable message for the agent).
## Conventions
@@ -54,7 +61,8 @@ do not keep an old PDF byte buffer alive.
This bounds retained text-cache data, not total process memory: active extraction batches,
renderers, document buffers and the requested output have separate lifetimes. Search still reads
growing batches (8 to 128 pages), with one lookahead match to determine continuation.
growing batches (8 to 128 pages), with one lookahead match to determine continuation, unless
`stop_at_limit` is set (below), which starts at one page and looks no further than its limit.
`text_find` keeps matches from readable pages when another reached page fails extraction. It
returns `warnings` with 1-based `page`, `error` and `error_truncated`, plus `warning_count` and
@@ -68,6 +76,16 @@ that reaches the end with warnings therefore has `complete: false` **without** `
should follow `next` when present and separately inspect warnings. A capped search can return
both. The existing `query`, `count`, `matches` and rectangle semantics are unchanged.
That lookahead means a search with exactly `limit` matches reads on to the end of the document to
learn that no further match exists. `stop_at_limit: true` skips it: the search ends at the
`limit`th match. It reads one page first, then batches of 2, 4, … up to 128 pages, so nothing past
the batch holding that match is read or can add warnings (a match on the first page reads only that
page). The answer carries
`stopped_at_limit`. When that is `true`, `complete` is `false` and `next` is only the position just
past the last match, not a promise of another one; following it may return no matches and
`complete: true`. When it is `false`, the search ran out of pages first and `complete` means what
it always does.
`text_extract`, comment placement and redaction keep strict all-requested-pages-must-succeed
semantics; they never silently act on partial text.
@@ -139,7 +157,10 @@ claude mcp add pdfcraft -- pdfcraft-cli mcp --root <dir>
`object_list {doc, page}` inventories recognised paragraphs, Image/Form artwork and added items
with generation-scoped kind/index references. `object_move {doc, page, objects, offset, generation?}`
moves 1–1000 references atomically in displayed points (right/down), preserving source glyph bytes
and fonts. A refusal changes nothing; success is one undo step. See
and fonts. A refusal changes nothing; success is one undo step. `all: true` takes every object on the
page instead, as ⌘A does. `object_copy {doc, page, objects | all, cut?}` copies them with every
resource they use and `object_paste {doc, page}` pastes them as page content that draws the same,
into the same or another open document, stepped as the app steps a paste. See
[the selection and movement guide](../../docs/editing-multiple-objects.md) for limits and examples.
## Accessibility review record
@@ -186,8 +207,9 @@ mutually exclusive with `hide_text_block`.
When the renderer reports a warning for the page, `page_render` and `render_preview` return a
JSON part after the PNG, `{ "page": 1, "warnings": [...] }`, with the sentences
`pdfcraft-cli render` prints: "content past the page's safety budget was skipped" and "a colour
that could not be computed was painted in a fallback colour". A page without warnings returns
`pdfcraft-cli render` prints, such as "content past the page's safety budget, or a picture whose
JBIG2 mask is past a decoder limit, was skipped" and "a colour that could not be computed was
painted in a fallback colour". A page without warnings returns
the PNG alone.
@@ -202,6 +224,30 @@ undoable and survive saving/reopening. `image_hit` shares the UI's curve and hol
The bounds, limits and font requirements are in [existing-text editing](../../docs/editing-existing-text.md)
and [image editing](../../docs/editing-images.md).
`text_fonts` also lists `builtin:thai` when the build includes the bundled Noto Sans Thai face
(the optional craft-fonts input). Passing it as `text_edit.font_resource` explicitly replaces a
paragraph or range with that face; the source font is never swapped for it silently. A build
without the face never lists it, and `text_edit` refuses it there. A page whose own fonts fill
the listing's 32 MiB preview budget can leave it out too; `text_edit` still accepts it then.
### Preparing grouped text
`text_prepare {doc, page}` makes supported grouped Form text editable on one page, as one click
on grouped text in the GUI's Edit text does. It keeps the group's original glyph operators and shared source
objects, records preparation and map repairs in the repair log, and is one undoable step. List
`text_paragraphs` afterwards; its indices belong to the prepared page. Besides the document
summary it returns what this edit did:
| Field | Type | Meaning |
| --- | --- | --- |
| `prepared_groups` | integer | Form placements drawn in place, so their text is now the page's own. |
| `repaired_fonts` | integer | Page fonts whose damaged legacy THSarabunPSK Thai map was repaired on a page-owned copy. |
| `refused_groups` | array of strings | One `name: reason` per group left as it was: unsafe compositing, tags, malformed content, or text grouped more than eight levels deep. |
The page's own fonts with that damaged map are repaired too, whether their text was grouped or
not. It fails, changing nothing, when the page has nothing to prepare or repair (the error gives
the first reason a group was left as it was) or the page is out of range.
### Superscript and subscript normalization
`text_script` changes characters to their Unicode superscript or subscript forms; it does not
@@ -248,6 +294,22 @@ choices and values over 4096 UTF-8 bytes return an error.
Example: `form_set_props doc=1 field=agree export_value=Accepted widget=1`.
## Rendering a 3D model
`model_render` draws a page's 3D model (a 3D annotation's PRC stream, directly or through a 3D
reference) to a PNG, without the viewer. `model` picks the page's Nth 3D model (default 1);
`width` and `height` are 1 to 4096 (default 512); `direction` is the view direction from the eye
(default `[-1, 1, -1]`, with Z up unless the view looks along Z); `perspective` defaults to true.
The model is framed to fill the image and shaded by a headlight on white. `orbit`, instead of
`direction`, frames it as a viewer's orbit control would: `{view, up, front, turn, zoom,
zoom_at, pan}`, all optional. `view` is `iso` (default), `front`, `right`, `top` or `back`
about the `up` axis (default `+z`) and the `front` axis (perpendicular to `up`); `turn` is
`[yaw, pitch]` in degrees, pitch down; `zoom` is a positive factor, the result held within 0.05 to 50, at `zoom_at` `[right, up]`
in picture heights from the centre, where the point under it stays still; `pan` `[right, up]`
moves the target, in model radii, so the model moves the other way. Every view frames the model
at the same size, whatever its direction. U3D models are refused
with an error, and the annotation's own views, lighting and render modes are not applied yet.
## Viewer navigation history
A running PdfCraft instance launched with `--control FILE` exposes its per-tab history through
@@ -270,3 +332,34 @@ navigation-history tool; use the opt-in UI control channel to navigate the displ
(`#RRGGBB`, default black) for `text` and `date`. The colour is saved in the
FreeText appearance and survives reopening. Other mark/signature types reject
`color`; their appearance controls are separate.
## AcroForm script interactions
`js_run` accepts optional `answers` (strings or null, at most 32), consumed in popup, response and alert order. Missing responses cancel menus/input; explicit viewer requests remain data in automation. See [the runtime contract](../../docs/acroform-script-workflows.md).
For distinct PDF fields sharing a name, form_fields exposes a document-local integer field_id. Pass it with field to js_run to bind event.target to that exact field (including ordered answers); omit it for existing first-match behavior. Mismatched names/IDs, missing names and non-integer/out-of-range IDs are refused before execution.
`form_set_image` accepts the optional document-local `field_id` from `form_fields` alongside the matching `field` name, selecting an exact duplicate-name image button. Omit it for the legacy first-name match. IDs are unsigned 48-bit integers and must be rediscovered after structural edits or reopening.
## Reviewing OCR headlessly
`ocr_words {doc, retain_review: true}` reads words without editing the document and returns
`review`, plus pages and word boxes/confidences. Page and word positions are 1-based.
`region` can restrict recognition to one named page, using displayed-page points. Without
`retain_review`, word inspection remains stateless.
Apply reviewed text with `ocr_review_apply {doc, review, corrections: [{page: 1, word: 1,
text: "Corrected"}]}`. Omitted words keep their text; empty text removes a word. Geometry
comes from the retained recognition, never caller input. The captured document guard rejects
stale/foreign results. Successful application consumes the handle and is one undoable step.
Use `output_mode: "editable", out: "reviewed.pdf"` to write a separate editable document,
or `discard: true` to release the result without applying it. Failed validation preserves the
review for correction or discard. A new retained recognition replaces that document's prior
handle; closing the document releases it. At most eight documents may retain reviews, with
65,536-word/4-MiB input bounds and a 65,536-page cap.
`ocr_dictionary {path: "ocr-dictionary-en.txt", add: ["Quartz"], remove: ["old"]}` atomically
updates the same one-word-per-UTF-8-line format OCR Verify uses. Omit add/remove to read it.
The explicit path honors `--root`, including symlink confinement; choose the appropriate
per-language file used by the UI. Entries are case-insensitive and duplicate-free. Invalid
or oversized inputs fail before writing (65,536 words, 4 MiB, at most 1,024 bytes per word).
+55 -16
View File
@@ -6,10 +6,10 @@ use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use crate::{Args, Automation, Result, ToolError, child, failed, unused, write_atomic};
use crate::{Args, Automation, Result, ToolError, child, failed, write_atomic};
mod documents;
mod form_tools;
pub(crate) mod form_tools;
mod ocr;
fn status_id(s: Status) -> &'static str {
@@ -213,25 +213,25 @@ const MAX_TAG_PAGE: usize = 1000;
impl Automation {
pub(crate) fn accessibility_autotag_apply(&mut self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
self.session.apply(id, pdfcraft_engine::Edit::AutoTagText).map_err(failed)?;
let warnings = self.apply_with_warnings(id, pdfcraft_engine::Edit::AutoTagText)?;
let tags = self.accessibility_tags(a)?;
Ok(json!({
let out = json!({
"tags": tags["tags"], "count": tags["count"],
"note": "Text objects were tagged as paragraphs. Review heading roles, reading order and remaining accessibility findings manually.",
}))
});
Ok(with_warnings(out, warnings))
}
pub(crate) fn accessibility_autotag_form_fields(&mut self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
self.session.apply(id, pdfcraft_engine::Edit::AutoTagFormFields).map_err(failed)?;
let warnings = self.apply_with_warnings(id, pdfcraft_engine::Edit::AutoTagFormFields)?;
let report = self
.doc(a)?
.accessibility_check(&Options { rules: [Rule::TaggedFormFields].into(), pages: None })
.ok_or_else(|| failed("the document can't be read"))?;
let result = report.result(Rule::TaggedFormFields).ok_or_else(|| failed("the form field check is unavailable"))?;
Ok(
json!({ "status": status_id(result.status), "remaining_findings": result.findings.len(), "note": "Only form widgets were tagged. Review text, figures, links, reading order, and other document content separately." }),
)
let out = json!({ "status": status_id(result.status), "remaining_findings": result.findings.len(), "note": "Only form widgets were tagged. Review text, figures, links, reading order, and other document content separately." });
Ok(with_warnings(out, warnings))
}
pub(crate) fn accessibility_autotag_preview(&self, a: &Args) -> Result<Value> {
@@ -432,10 +432,11 @@ impl Automation {
let rule = Rule::from_id(id).ok_or_else(|| ToolError::InvalidArgs(format!("unknown rule {id:?}")))?;
let edit = self.doc(a)?.accessibility_fix(rule, a.opt_str("value")?).map_err(failed)?;
let doc = self.doc(a)?.id;
self.session.apply(doc, edit).map_err(failed)?;
let warnings = self.apply_with_warnings(doc, edit)?;
let r =
self.doc(a)?.accessibility_check(&Options { rules: [rule].into(), pages: None }).ok_or_else(|| failed("the document can't be read"))?;
Ok(json!({ "rule": id, "status": status_id(r.results.iter().find(|x| x.rule == rule).map_or(Status::Skipped, |x| x.status)) }))
let out = json!({ "rule": id, "status": status_id(r.results.iter().find(|x| x.rule == rule).map_or(Status::Skipped, |x| x.status)) });
Ok(with_warnings(out, warnings))
}
pub(crate) fn accessibility_figures(&self, a: &Args) -> Result<Value> {
@@ -466,8 +467,8 @@ impl Automation {
pdfcraft_engine::Edit::SetAltText { figure, alt: a.opt_str("alt")?.map(str::to_owned) }
};
let id = self.doc(a)?.id;
self.session.apply(id, edit).map_err(failed)?;
self.accessibility_figures(a)
let warnings = self.apply_with_warnings(id, edit)?;
Ok(with_warnings(self.accessibility_figures(a)?, warnings))
}
/// The structure tags, one page at a time (`offset`, `limit`), optionally only some types, pages
@@ -551,7 +552,22 @@ impl Automation {
let generation = u16::try_from(a.int("generation")?)
.map_err(|_| ToolError::InvalidArgs("generation must be a PDF object generation from accessibility_tags".into()))?;
let id = self.doc(a)?.id;
self.session.apply(id, pdfcraft_engine::Edit::MarkTagArtifact { object: pdfcraft_cos::ObjRef::new(object, generation) }).map_err(failed)?;
let warnings =
self.apply_with_warnings(id, pdfcraft_engine::Edit::MarkTagArtifact { object: pdfcraft_cos::ObjRef::new(object, generation) })?;
Ok(with_warnings(self.accessibility_tags(a)?, warnings))
}
pub(crate) fn accessibility_layout_table_to_blocks(&mut self, a: &Args) -> Result<Value> {
let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?;
if object == 0 {
return Err(ToolError::InvalidArgs("object must be a positive tag object number".into()));
}
let generation = u16::try_from(a.int("generation")?)
.map_err(|_| ToolError::InvalidArgs("generation must be a PDF object generation from accessibility_tags".into()))?;
let id = self.doc(a)?.id;
self.session
.apply(id, pdfcraft_engine::Edit::LayoutTableToBlocks { object: pdfcraft_cos::ObjRef::new(object, generation) })
.map_err(failed)?;
self.accessibility_tags(a)
}
@@ -573,9 +589,32 @@ impl Automation {
other => return Err(ToolError::InvalidArgs(format!("placement must be before or after, not {other:?}"))),
};
let id = self.doc(a)?.id;
self.session.apply(id, pdfcraft_engine::Edit::MoveTag { object, anchor, placement }).map_err(failed)?;
self.accessibility_tags(a)
let warnings = self.apply_with_warnings(id, pdfcraft_engine::Edit::MoveTag { object, anchor, placement })?;
Ok(with_warnings(self.accessibility_tags(a)?, warnings))
}
/// Apply `edit` to document `id` and take out the errors it added to the document's script
/// output, such as the values of an XFA form that could not be written to its data.
fn apply_with_warnings(&mut self, id: DocId, edit: pdfcraft_engine::Edit) -> Result<Vec<String>> {
let ((), out) = self.session.with_own_js_output(id, |s| s.apply(id, edit)).map_err(failed)?;
Ok(out.errors)
}
}
/// `out` with `warnings` added to its `warnings` list (made when it has none).
fn with_warnings(mut out: Value, warnings: Vec<String>) -> Value {
if warnings.is_empty() {
return out;
}
if let Some(map) = out.as_object_mut() {
match map.get_mut("warnings").and_then(Value::as_array_mut) {
Some(list) => list.extend(warnings.into_iter().map(Value::from)),
None => {
map.insert("warnings".into(), json!(warnings));
}
}
}
out
}
#[cfg(test)]
+19 -18
View File
@@ -1,4 +1,4 @@
use super::{Args, Automation, Result, ToolError, Value, child, failed, json, unused, write_atomic};
use super::{Args, Automation, Result, ToolError, Value, child, failed, json, with_warnings, write_atomic};
impl Automation {
pub(crate) fn doc_export_office(&self, a: &Args) -> Result<Value> {
@@ -6,6 +6,13 @@ impl Automation {
let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default();
let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext)
.ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html, rtf or md)")))?;
let html_mode = a.opt_str("html_mode")?.unwrap_or("rich");
let format = match (format, html_mode) {
(pdfcraft_engine::compare::OfficeFormat::Html, "rich") => pdfcraft_engine::compare::OfficeFormat::Html,
(pdfcraft_engine::compare::OfficeFormat::Html, "simple") => pdfcraft_engine::compare::OfficeFormat::HtmlSimple,
(_, "rich") => format,
(_, _) => return Err(ToolError::InvalidArgs("html_mode is only available for HTML exports (rich or simple)".into())),
};
let export = self.doc_to_copy(a)?.export_office_checked(format);
write_atomic(&path, &export.bytes)?;
// Text left out to keep a hostile page bounded is said, not silent (#3120).
@@ -96,8 +103,8 @@ impl Automation {
pub(crate) fn pdfa_convert(&mut self, a: &Args) -> Result<Value> {
let level = self.pdfa_level(a)?;
let id = self.doc(a)?.id;
self.session.apply(id, pdfcraft_engine::Edit::ConvertPdfA { level }).map_err(failed)?;
self.pdfa_verify(a)
let warnings = self.apply_with_warnings(id, pdfcraft_engine::Edit::ConvertPdfA { level })?;
Ok(with_warnings(self.pdfa_verify(a)?, warnings))
}
pub(crate) fn action_list(&self) -> Result<Value> {
@@ -111,6 +118,9 @@ impl Automation {
Ok(json!({ "actions": actions, "steps": steps }))
}
/// `action_run`: run a named action or a list of steps on each file in `paths`, writing the
/// results into `folder`; each file is processed in its own session that keeps this
/// session's JavaScript preference.
pub(crate) fn action_run(&mut self, a: &Args) -> Result<Value> {
use pdfcraft_engine::actions::{Action, Step, builtin, run_on};
let action = if let Some(name) = a.opt_str("action")? {
@@ -131,6 +141,8 @@ impl Automation {
};
let folder = self.resolve(a.str("folder")?, true)?;
std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?;
// Each file runs in its own session; it keeps this one's JavaScript preference.
let javascript = self.session.javascript();
let mut out = Vec::new();
for p in a.strs("paths")? {
let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into());
@@ -138,21 +150,10 @@ impl Automation {
.resolve(p, false)
.map_err(|e| e.to_string())
.and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string()))
.and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {}));
.and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), javascript, |_, _| {}));
out.push(match result {
Ok(r) => {
let target = child(&folder, &name);
let target = if target.exists() {
let stem =
std::path::Path::new(&name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into());
unused(&folder, &stem)
} else {
target
};
if target.exists() {
return Err(failed(format!("{}: no unused output name available", target.display())));
}
write_atomic(&target, &r.bytes)?;
let target = crate::batch::publish(&child(&folder, &name), &r.bytes)?;
json!({ "path": p, "output": target.to_string_lossy(), "log": r.log })
}
Err(e) => json!({ "path": p, "error": e }),
@@ -213,7 +214,7 @@ impl Automation {
pub(crate) fn doc_compare_mark(&mut self, a: &Args) -> Result<Value> {
let (old, new) = self.compare_ids(a)?;
let n = self.session.mark_differences(old, new).map_err(failed)?;
Ok(json!({ "comments": n }))
let (n, out) = self.session.with_own_js_output(new, |s| s.mark_differences(old, new)).map_err(failed)?;
Ok(with_warnings(json!({ "comments": n }), out.errors))
}
}
+82 -14
View File
@@ -1,7 +1,7 @@
use super::{Args, Automation, Result, ToolError, Value, failed, json, write_atomic};
use super::{Args, Automation, Result, ToolError, Value, failed, json, with_warnings, write_atomic};
/// A script's request to the viewer as the JSON that running a script reports; a side panel
/// goes by its fixed id.
/// A script's request to the viewer as the JSON that running a script reports (page numbers
/// 1-based); a side panel goes by its fixed id.
fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
use pdfcraft_engine::js::Request as R;
match r {
@@ -11,15 +11,19 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
R::LaunchUrl(u) => json!({ "url": u }),
R::Submit(u) => json!({ "submit": u }),
R::Focus(f) => json!({ "focus": f }),
R::FocusField { name, object_id } => json!({ "focus": name, "field_id": object_id }),
R::Zoom(z) => json!({"zoom":z}),
R::Beep => json!({ "beep": true }),
R::Highlight(on) => json!({ "highlight": on }),
R::SaveAs => json!({ "save_as": true }),
R::OpenAttachment { name, open } => json!({ "attachment": name, "open": open }),
R::ShowPanel(p) => json!({ "panel": p.id() }),
}
}
/// A message a script showed, as `js_run` reports it: its text, title (null when it gave none),
/// icon and buttons.
fn alert_json(m: &pdfcraft_engine::js::Message) -> Value {
pub(crate) fn alert_json(m: &pdfcraft_engine::js::Message) -> Value {
json!({ "message": m.text, "title": m.title, "icon": m.icon.name(), "buttons": m.buttons.name() })
}
@@ -28,10 +32,38 @@ impl Automation {
/// messages, console lines, requests, error, the failing document scripts' errors and result.
pub(crate) fn js_run(&mut self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?;
let answers = match a.0.get("answers") {
None => Vec::new(),
Some(Value::Array(values)) if values.len() <= 32 => values
.iter()
.map(|v| match v {
Value::Null => Ok(None),
Value::String(s) if s.len() <= 4096 => Ok(Some(s.clone())),
_ => Err(ToolError::InvalidArgs("answers must contain strings or null".into())),
})
.collect::<Result<Vec<_>>>()?,
_ => return Err(ToolError::InvalidArgs("answers must be an array of at most 32 strings or null".into())),
};
let identity =
a.0.get("field_id")
.map(|v| {
v.as_u64()
.filter(|id| *id < 1 << 48)
.ok_or_else(|| ToolError::InvalidArgs("field_id must be an integer between 0 and 281474976710655".into()))
})
.transpose()?;
let (script, field) = (a.str("script")?, a.opt_str("field")?);
let (mut o, edit) =
self.session.with_own_js_output(id, |s| s.run_javascript_answers_target(id, script, field, identity, &answers)).map_err(failed)?;
o.alerts.extend(edit.alerts);
o.console.extend(edit.console);
o.requests.extend(edit.requests);
let reqs: Vec<Value> = o.requests.iter().map(request_json).collect();
let alerts: Vec<Value> = o.alerts.iter().map(alert_json).collect();
Ok(json!({ "alerts": alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }))
Ok(with_warnings(
json!({ "alerts": alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }),
edit.errors,
))
}
pub(crate) fn js_document_scripts(&self, a: &Args) -> Result<Value> {
@@ -39,13 +71,17 @@ impl Automation {
Ok(json!({ "scripts": list }))
}
/// `js_set_document_script`: set the document-level script `name` to `script`, or remove it
/// when `script` is left out, as one undoable edit; returns the document's scripts.
pub(crate) fn js_set_document_script(&mut self, a: &Args) -> Result<Value> {
let edit = pdfcraft_engine::Edit::SetDocumentScript { name: a.str("name")?.into(), script: a.opt_str("script")?.map(str::to_string) };
let id = self.doc(a)?.id;
self.session.apply(id, edit).map_err(failed)?;
self.js_document_scripts(a)
let warnings = self.apply_with_warnings(id, edit)?;
Ok(with_warnings(self.js_document_scripts(a)?, warnings))
}
/// `form_set_script`: set field `field`'s JavaScript for `event` to `script`, or remove it when
/// `script` is left out, as one undoable edit; returns the console output and errors.
pub(crate) fn form_set_script(&mut self, a: &Args) -> Result<Value> {
let edit = pdfcraft_engine::Edit::SetFieldScript {
name: a.str("field")?.into(),
@@ -53,11 +89,41 @@ impl Automation {
script: a.opt_str("script")?.map(str::to_string),
};
let id = self.doc(a)?.id;
self.session.apply(id, edit).map_err(failed)?;
let out = self.session.take_js_output(id);
let ((), out) = self.session.with_own_js_output(id, |s| s.apply(id, edit)).map_err(failed)?;
Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors }))
}
/// `form_focus`: field `field` (its 1-based `widget`) gains (`enter`) or loses (`exit`)
/// focus, running its XFA scripts for that event; returns whether the form changed and what
/// the scripts showed or asked for.
pub(crate) fn form_focus(&mut self, a: &Args) -> Result<Value> {
use pdfcraft_engine::js::FieldFocus;
let focus = match a.str("event")? {
"enter" => FieldFocus::Enter,
"exit" => FieldFocus::Exit,
other => return Err(ToolError::InvalidArgs(format!("event must be enter or exit, not {other:?}"))),
};
let name = a.str("field")?;
let doc = self.doc(a)?;
let field = doc.form.iter().find(|f| f.name == name).ok_or_else(|| failed(format!("there is no field named {name:?} (see form_fields)")))?;
// A field with no widget (one only scripts or calculations use) has nothing to focus.
let widgets = field.widgets.len();
let widget = a.opt_int("widget")?.unwrap_or(1);
let widget = usize::try_from(widget).ok().and_then(|w| w.checked_sub(1)).filter(|w| *w < widgets).ok_or_else(|| match widgets {
0 => ToolError::InvalidArgs(format!("{name:?} has no widget to focus")),
n => ToolError::InvalidArgs(format!("widget must be 1 to {n} for {name:?}")),
})?;
let id = doc.id;
let changed = self.session.field_focus(id, name, widget, focus).map_err(failed)?;
let out = self.session.take_js_output(id);
let reqs: Vec<Value> = out.requests.iter().map(request_json).collect();
Ok(
json!({ "changed": changed, "alerts": out.alerts.iter().map(alert_json).collect::<Vec<_>>(), "console": out.console, "requests": reqs, "errors": out.errors }),
)
}
/// `form_merge_data`: merge the form data files at `paths` into one CSV at `path`, a row per
/// file; returns the path and the row and column counts.
pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result<Value> {
let mut files = Vec::new();
for p in a.strs("paths")? {
@@ -90,10 +156,12 @@ impl Automation {
v
})
.collect();
let mut warnings = Vec::new();
if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() {
self.session.auto_detect_fields(id, &pages).map_err(failed)?;
let (_, out) = self.session.with_own_js_output(id, |s| s.auto_detect_fields(id, &pages)).map_err(failed)?;
warnings = out.errors;
}
Ok(json!({ "fields": list }))
Ok(with_warnings(json!({ "fields": list }), warnings))
}
pub(crate) fn form_actions(&self, a: &Args) -> Result<Value> {
@@ -157,8 +225,8 @@ impl Automation {
let edit =
pdfcraft_engine::Edit::SetFieldProps { name, props: Box::new(pdfcraft_engine::FieldProps { actions: Some(acts), ..Default::default() }) };
let id = self.doc(a)?.id;
self.session.apply(id, edit).map_err(failed)?;
self.form_actions(a)
let warnings = self.apply_with_warnings(id, edit)?;
Ok(with_warnings(self.form_actions(a)?, warnings))
}
pub(crate) fn js_enabled(&mut self, a: &Args) -> Result<Value> {
+484 -37
View File
@@ -1,98 +1,545 @@
use super::{Args, Automation, Result, ToolError, Value, child, failed, json, write_atomic};
use super::{Args, Automation, Result, ToolError, Value, child, failed, json, with_warnings, write_atomic};
impl Automation {
/// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused
/// unless the models read it) and `skip_text_pages`, each left at its default when absent.
/// The settings every OCR tool shares: dpi, language, the engine/strategy choice, the four
/// preprocessing flags, the skip flags and the output mode — each named exactly as its tool
/// schema says. An unknown engine, strategy or mode is an honest error, never a silent
/// fallback: a tool run must not quietly do something other than what it was asked.
pub(crate) fn ocr_settings(&self, a: &Args) -> Result<pdfcraft_engine::ocr::OcrSettings> {
use pdfcraft_engine::ocr::{EngineChoice, MergeStrategy, OutputMode};
let mut settings = pdfcraft_engine::ocr::OcrSettings::default();
if let Some(d) = a.opt_num("dpi")? {
settings.dpi = d.clamp(72.0, 600.0) as f32;
}
if let Some(l) = a.opt_str("language")? {
if !pdfcraft_engine::ocr::LANGUAGES.iter().any(|x| x.0 == l) {
return Err(ToolError::InvalidArgs(format!("unsupported language {l:?}")));
return Err(ToolError::InvalidArgs(format!(
"unsupported language {l:?} (supported: {})",
pdfcraft_engine::ocr::LANGUAGES.iter().map(|(c, _)| *c).collect::<Vec<_>>().join(", ")
)));
}
settings.language = l.into();
}
if let Some(s) = a.opt_bool("skip_text_pages")? {
settings.skip_text_pages = s;
if let Some(e) = a.opt_str("engine")? {
settings.engine = match e.trim().to_ascii_lowercase().as_str() {
"ocrs" => EngineChoice::Ocrs,
"tesseract" => EngineChoice::Tesseract,
"auto" | "automatic" | "ensemble" => EngineChoice::Auto,
other => return Err(ToolError::InvalidArgs(format!("unsupported engine {other:?} (ocrs, tesseract or auto)"))),
};
}
settings.strategy = match a.opt_str("strategy")? {
Some(s) => match s.trim().to_ascii_lowercase().as_str() {
"primary" | "primary-only" | "primary_only" => MergeStrategy::PrimaryOnly,
"confidence" | "confidence-weighted" | "confidence_weighted" => MergeStrategy::ConfidenceWeighted,
"rover" | "rover-vote" | "rover_vote" | "vote" => MergeStrategy::RoverVote,
other => return Err(ToolError::InvalidArgs(format!("unsupported strategy {other:?} (primary, confidence or rover)"))),
},
// Unset: the engine choice decides (auto brings in the second engine when the
// first reading is weak; a named engine reads alone).
None => settings.engine.default_strategy(),
};
if let Some(m) = a.opt_str("output_mode")? {
settings.output_mode = match m.trim().to_ascii_lowercase().as_str() {
"searchable" | "searchable-image" | "searchable_image" => OutputMode::Searchable,
"editable" | "editable-text" | "editable_text" => OutputMode::EditableText,
other => return Err(ToolError::InvalidArgs(format!("unsupported output mode {other:?} (searchable or editable)"))),
};
}
for (key, flag) in [
("auto_rotate", &mut settings.auto_rotate),
("deskew", &mut settings.deskew),
("denoise", &mut settings.denoise),
("binarize", &mut settings.binarize),
] {
if let Some(on) = a.opt_bool(key)? {
*flag = on;
}
}
for (key, flag) in [
("skip_text_pages", &mut settings.skip_text_pages),
("skip_text_files", &mut settings.skip_text_files),
("force_ocr", &mut settings.force_ocr),
] {
if let Some(on) = a.opt_bool(key)? {
*flag = on;
}
}
Ok(settings)
}
/// Recognizes text in each of `paths` and writes the result under the same file name in
/// `folder`, reporting per file its output, word count and skipped pages, or its error. A
/// path outside the root refuses the whole call before any work starts.
/// The optional `region` argument ([x0, y0, x1, y1] in points from the top-left of the
/// displayed page) as the job's pixel crop, at the resolution that page is really read at.
/// A region reads exactly one page.
fn ocr_region(
&self,
a: &Args,
id: pdfcraft_engine::DocId,
page: usize,
settings: &pdfcraft_engine::ocr::OcrSettings,
) -> Result<Option<[f32; 4]>> {
const BAD: &str = "region must be [x0, y0, x1, y1] in points";
let Some(v) = a.get("region") else { return Ok(None) };
let arr = v.as_array().ok_or_else(|| ToolError::InvalidArgs(BAD.into()))?;
if arr.len() != 4 {
return Err(ToolError::InvalidArgs(BAD.into()));
}
let mut pts = [0.0f64; 4];
for (i, x) in arr.iter().enumerate() {
pts[i] = x.as_f64().filter(|f| f.is_finite()).ok_or_else(|| ToolError::InvalidArgs(BAD.into()))?;
}
let scale = f64::from(
self.session.ocr_job(id, &[page], settings.clone()).ok_or_else(|| failed("the document could not be read"))?.render_scale(page),
);
let px: Vec<f32> = pts.iter().map(|p| scale * p).map(|p| if p.is_finite() { p } else { 0.0 }).map(|p| p as f32).collect();
Ok(Some([px[0], px[1], px[2], px[3]]))
}
/// The per-page result entries the OCR tools return: recognised text or why the page was
/// passed over. The text itself is only echoed when the document allows copying; the
/// counts and metadata always are.
fn ocr_pages_json(found: &[pdfcraft_engine::ocr::OcrPage], include_text: bool) -> Vec<Value> {
found
.iter()
.map(|p| match &p.skipped {
Some(why) => json!({ "page": p.page + 1, "skipped": why }),
None => {
let mut page = json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page });
// The editable output's substitution warnings (characters no font could
// show) are part of the result: reported per page, never dropped.
if !p.notes.is_empty() {
page["notes"] = json!(p.notes);
}
if include_text {
page["text"] = json!(p.text());
}
page
}
})
.collect()
}
pub(crate) fn ocr_recognize_files(&mut self, a: &Args) -> Result<Value> {
let settings = self.ocr_settings(a)?;
let folder = self.resolve(a.str("folder")?, true)?;
// A path that leaves the root refuses the whole call before any work starts, like the
// other tools (the write check passes missing files, which stay per-file errors below).
// A path that leaves the read root refuses the whole call before any work starts;
// a file that is merely missing stays a per-file error below.
let paths = a.strs("paths")?;
for p in &paths {
self.resolve_for(p, crate::file_access::Access::Read, true)?;
}
// Two inputs that would write the same output file overwrite each other while both report
// success, so the whole batch is refused here: before the folder is created, anything is
// read, or the OCR engines load.
// Two inputs of one file name (from different folders) would write the same output:
// the second would replace the first while both report success. The whole batch is
// refused up front — before the folder is created, anything is read or the engines
// load — naming the file, the way the default filesystem compares names.
let targets: Vec<std::path::PathBuf> = paths.iter().map(|p| child(&folder, &input_file_name(p))).collect();
let shared = shared_file_names(&targets);
if !shared.is_empty() {
return Err(ToolError::InvalidArgs(format!(
"more than one input would write to the same output file, so each would replace the one before it. Colliding file names: {}. Give each input a different file name, or run them in separate batches with different folders.",
"several paths share a file name, so their outputs in folder would overwrite each other (rename or run them separately): {}",
shared.join(", ")
)));
}
std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?;
let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?;
let names: Vec<String> = paths.iter().map(|p| input_file_name(p)).collect();
// Collision pre-flight: a searchable copy never silently overwrites. Every target is
// checked before the first page is read; a collision aborts the whole batch with the
// names in the error.
let mut collisions = std::collections::BTreeSet::new();
for name in names {
if child(&folder, &name).exists() {
collisions.insert(name);
}
}
if !collisions.is_empty() {
let mut list: Vec<String> = collisions.iter().take(5).cloned().collect();
if collisions.len() > list.len() {
list.push(format!("and {} more", collisions.len() - list.len()));
}
return Err(failed(format!(
"the output folder already holds {} (delete or move them, or choose another folder): {}",
if collisions.len() == 1 { "a file of that name" } else { "files of those names" },
list.join(", ")
)));
}
// The engines are built (and named if they cannot be) before the first file is read.
let recognizers = pdfcraft_engine::ocr::recognizers(&settings).map_err(failed)?;
// Each file opens in its own session; it keeps this one's JavaScript preference.
let javascript = self.session.javascript();
let mut out = Vec::new();
let (mut ok, mut skipped_files, mut words) = (0usize, 0usize, 0usize);
for p in paths {
let name = input_file_name(p);
let result =
self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())).and_then(
|bytes| pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true),
|bytes| {
pdfcraft_engine::ocr::recognize_file(
&name,
std::sync::Arc::new(bytes),
None,
settings.clone(),
&recognizers,
javascript,
|_, _| true,
)
},
);
out.push(match result {
match result {
Ok(r) => {
let skipped_pages: Vec<usize> = r.pages.iter().filter(|p| p.skipped.is_some()).map(|p| p.page + 1).collect();
if r.skipped() {
// A file passed over by the skip-files option is its own bucket: its
// original is left alone and nothing is written.
skipped_files += 1;
out.push(json!({ "path": p, "skipped": r.file_skipped, "skipped_pages": skipped_pages }));
continue;
}
let target = child(&folder, &name);
write_atomic(&target, &r.bytes)?;
let skipped: Vec<usize> = r.pages.iter().filter(|p| p.skipped.is_some()).map(|p| p.page + 1).collect();
json!({ "path": p, "output": target.to_string_lossy(), "words": r.words(), "skipped_pages": skipped })
ok += 1;
words += r.words();
let mut entry = json!({ "path": p, "output": target.to_string_lossy(), "words": r.words(), "skipped_pages": skipped_pages });
// The batch's low-confidence note: at or under the settings' threshold (60
// by default), on 1-based sorted pages.
let threshold = settings.low_confidence_threshold;
if let Some((count, pages)) = pdfcraft_engine::ocr::low_confidence_pages(&r.pages, threshold) {
entry["low_confidence"] = json!({ "count": count, "pages": pages, "threshold": threshold });
}
out.push(entry);
}
Err(e) => json!({ "path": p, "error": e }),
});
Err(e) => out.push(json!({ "path": p, "error": e })),
}
}
Ok(json!({ "files": out }))
Ok(json!({ "files": out, "recognized": ok, "words": words, "skipped_files": skipped_files }))
}
/// Recognizes text on the open document's `pages` (every page when absent) and adds it,
/// reporting the total word count and, per page, why it was skipped or what it found.
pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result<Value> {
let settings = self.ocr_settings(a)?;
use pdfcraft_engine::ocr::OutputMode;
let mut settings = self.ocr_settings(a)?;
let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() };
let doc = self.doc(a)?;
// Recognizing needs only permission to change the document; the words it found are
// returned only when copying is allowed too.
let (id, copy) = (doc.id, doc.allows_copying());
let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?;
let (id, copy) = {
let doc = self.doc(a)?;
(doc.id, doc.allows_copying())
};
// The optional region reads exactly one page.
if a.get("region").is_some() {
if pages.len() != 1 {
return Err(ToolError::InvalidArgs("region reads one page: name it in pages".into()));
}
settings.region = self.ocr_region(a, id, pages[0], &settings)?;
}
// The editable output's destination is checked before anything runs: a NEW file, never
// the open source and never an existing file replaced silently.
let out = match settings.output_mode {
OutputMode::Searchable => {
// `out` belongs to the editable output; a searchable run writes into the open
// document, so an `out` here would be silently ignored (and never checked
// against the root).
if a.get("out").is_some() {
return Err(ToolError::InvalidArgs("out is only used with output_mode editable; remove it, or choose editable".into()));
}
None
}
OutputMode::EditableText => {
// The editable output writes the recognized text out of the document: a
// document that forbids copying refuses here, before anything runs.
self.doc_to_copy(a)?;
// A write: the file does not exist yet, and write_atomic makes its folder.
let out = self.resolve(a.str("out")?, true)?;
if let Some(source) = self.session.get(id).and_then(|d| d.path.clone())
&& let (Ok(a), Ok(b)) = (std::fs::canonicalize(&out), std::fs::canonicalize(&source))
&& a == b
{
return Err(failed("out is the source document; the editable output is a new file, so choose another path"));
}
if out.exists() {
return Err(failed(format!(
"out already exists ({}); the editable output is a new file, so choose a path that does not exist yet",
out.to_string_lossy()
)));
}
Some(out)
}
};
// Honest up front: the engines are built (and named if they cannot be) before anything
// is read or written.
let recognizers = pdfcraft_engine::ocr::recognizers(&settings).map_err(failed)?;
match out {
None => {
let (found, out) = self.session.with_own_js_output(id, |s| s.recognize_text(id, &pages, settings)).map_err(failed)?;
let warnings = out.errors;
let words = found.iter().map(|p| p.words.len()).sum::<usize>();
Ok(with_warnings(json!({ "words": words, "pages": Self::ocr_pages_json(&found, copy) }), warnings))
}
Some(out) => {
let job = self.session.ocr_job(id, &pages, settings).ok_or_else(|| failed("the document could not be read"))?;
let found = job.run(&recognizers, |_, _| true);
let bytes = self.session.editable_ocr_bytes(id, &found).map_err(failed)?;
// recognition can take minutes: a file created meanwhile is refused, not replaced
if let Some(folder) = out.parent() {
std::fs::create_dir_all(folder).map_err(failed)?;
}
crate::bates::publish_new(&out, &bytes)?;
Ok(json!({
"out": out.to_string_lossy(),
"words": found.iter().map(|p| p.words.len()).sum::<usize>(),
"pages": Self::ocr_pages_json(&found, copy),
}))
}
}
}
/// The words of a recognition, for reviewing without the GUI: text, box (points from the
/// top-left of the displayed page), confidence, band, and which words are suspects.
/// Changes nothing; needs the engines (ocr_status).
pub(crate) fn ocr_words(&mut self, a: &Args) -> Result<Value> {
let mut settings = self.ocr_settings(a)?;
let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() };
// The word list is the document's text, word by word: refused when the document
// forbids copying it out.
let id = self.doc_to_copy(a)?.id;
if a.get("region").is_some() {
if pages.len() != 1 {
return Err(ToolError::InvalidArgs("region reads one page: name it in pages".into()));
}
settings.region = self.ocr_region(a, id, pages[0], &settings)?;
}
let retain = a.opt_bool("retain_review")?.unwrap_or(false);
if retain && !self.ocr_reviews.contains_key(&id) && self.ocr_reviews.len() >= 8 {
return Err(failed("at most 8 OCR reviews may be retained; discard one first"));
}
let recognizers = pdfcraft_engine::ocr::recognizers(&settings).map_err(failed)?;
let job = self.session.ocr_job(id, &pages, settings.clone()).ok_or_else(|| failed("the document could not be read"))?;
let infos: std::collections::HashMap<usize, pdfcraft_render::PageInfo> =
self.session.get(id).map(|d| d.info.pages.clone()).unwrap_or_default().into_iter().enumerate().collect();
let guard = job.guard.clone();
let found = job.run(&recognizers, |_, _| true);
if found.len() > 65536 {
return Err(failed("at most 65536 OCR review pages are allowed"));
}
pdfcraft_engine::ocr::validate_ocr_words(found.iter().flat_map(|p| &p.words)).map_err(failed)?;
let band_name = |band: pdfcraft_engine::ocr::ConfidenceBand| match band {
pdfcraft_engine::ocr::ConfidenceBand::High => "high",
pdfcraft_engine::ocr::ConfidenceBand::Medium => "medium",
pdfcraft_engine::ocr::ConfidenceBand::Low => "low",
};
let mut suspects = 0usize;
let list: Vec<Value> = found
.iter()
.map(|p| match &p.skipped {
Some(why) => json!({ "page": p.page + 1, "skipped": why }),
None if copy => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }),
None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page }),
.map(|p| {
let info = infos.get(&p.page);
let words: Vec<Value> = p
.words
.iter()
.map(|w| {
let suspect = pdfcraft_engine::ocr::is_suspect(w.confidence);
if suspect {
suspects += 1;
}
// The word's corners in view space, as the box [x, y, w, h] in points
// from the top-left of the displayed page.
let view = |pt: [f64; 2]| info.map(|i| i.user_to_view(pt[0] as f32, pt[1] as f32));
let corners = [
view(w.origin),
view([w.origin[0] + w.across[0], w.origin[1] + w.across[1]]),
view([w.origin[0] + w.up[0], w.origin[1] + w.up[1]]),
view([w.origin[0] + w.across[0] + w.up[0], w.origin[1] + w.across[1] + w.up[1]]),
];
let xs: Vec<f32> = corners.iter().flatten().map(|v| v[0]).collect();
let ys: Vec<f32> = corners.iter().flatten().map(|v| v[1]).collect();
let (min_x, min_y) = (xs.iter().cloned().fold(f32::INFINITY, f32::min), ys.iter().cloned().fold(f32::INFINITY, f32::min));
let (w_pt, h_pt) = (
xs.iter().cloned().fold(f32::NEG_INFINITY, f32::max) - min_x,
ys.iter().cloned().fold(f32::NEG_INFINITY, f32::max) - min_y,
);
json!({
"text": w.text,
"box": [min_x, min_y, w_pt, h_pt],
"confidence": w.confidence.map(|c| (c * 10.0).round() / 10.0),
"band": w.confidence.map(|c| band_name(pdfcraft_engine::ocr::band_for(c))),
"suspect": suspect,
"source": w.source,
})
})
.collect();
let mut page =
json!({ "page": p.page + 1, "already_on_page": p.already_on_page, "words": words, "mean_confidence": p.mean_confidence().map(|c| (c * 10.0).round() / 10.0) });
if let Some(why) = &p.skipped {
page["skipped"] = json!(why);
}
page
})
.collect();
Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::<usize>(), "pages": list }))
let mut out = json!({ "words": found.iter().map(|p| p.words.len()).sum::<usize>(), "suspects": suspects, "pages": list });
if retain {
self.next_ocr_review =
self.next_ocr_review.checked_add(1).filter(|n| *n <= i64::MAX as u64).ok_or_else(|| failed("OCR review handles exhausted"))?;
out["review"] = json!(self.next_ocr_review);
self.ocr_reviews.insert(id, crate::OcrReview { handle: self.next_ocr_review, guard, pages: found });
}
Ok(out)
}
/// Apply only caller corrections to retained original boxes, through the stored guard.
pub(crate) fn ocr_review_apply(&mut self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
let handle = a.int("review")?;
let review = self
.ocr_reviews
.get(&id)
.filter(|r| i64::try_from(r.handle).ok() == Some(handle))
.ok_or_else(|| failed("unknown OCR review for this document; call ocr_words with retain_review"))?;
if a.opt_bool("discard")?.unwrap_or(false) {
if a.get("corrections").is_some() {
return Err(ToolError::InvalidArgs("discard cannot include corrections".into()));
}
self.ocr_reviews.remove(&id);
return Ok(json!({ "discarded": handle }));
}
if let Some(why) = self.session.stale_reason(&review.guard) {
return Err(failed(why));
}
let corrections = a.get("corrections").map(|v| v.as_array().ok_or_else(|| Args::wrong("corrections", "an array"))).transpose()?;
if corrections.is_some_and(|c| c.len() > 65536) {
return Err(failed("at most 65536 OCR corrections are allowed"));
}
// Prevalidate and cap every replacement before cloning the retained result.
let mut edits = Vec::new();
let mut bytes = 0usize;
let mut seen = std::collections::BTreeSet::new();
let page_indices: std::collections::BTreeMap<_, _> = review.pages.iter().enumerate().map(|(i, p)| (p.page, i)).collect();
for item in corrections.into_iter().flatten() {
if !item.is_object() || item.as_object().is_some_and(|o| o.keys().any(|k| !matches!(k.as_str(), "page" | "word" | "text"))) {
return Err(ToolError::InvalidArgs("corrections accept only page, word and text".into()));
}
let c = Args(item);
let page =
usize::try_from(c.int("page")?).ok().and_then(|p| p.checked_sub(1)).ok_or_else(|| Args::wrong("page", "a 1-based position"))?;
let word =
usize::try_from(c.int("word")?).ok().and_then(|w| w.checked_sub(1)).ok_or_else(|| Args::wrong("word", "a 1-based position"))?;
let text = c.str("text")?;
bytes = bytes.checked_add(text.len()).filter(|n| *n <= 4 * 1024 * 1024).ok_or_else(|| failed("OCR corrections exceed 4 MiB"))?;
if !seen.insert((page, word)) {
return Err(failed("duplicate OCR word correction"));
}
let pi = page_indices.get(&page).copied().ok_or_else(|| failed("page is not in this OCR review"))?;
if review.pages.get(pi).and_then(|p| p.words.get(word)).is_none() {
return Err(failed("word is not in this OCR review"));
}
edits.push((pi, word, text.to_owned()));
}
let mut pages = review.pages.clone();
for (pi, wi, text) in edits {
if let Some(w) = pages.get_mut(pi).and_then(|p| p.words.get_mut(wi)) {
w.text = text;
}
}
pdfcraft_engine::ocr::validate_ocr_words(pages.iter().flat_map(|p| &p.words)).map_err(failed)?;
for page in &mut pages {
page.words.retain(|w| !w.text.trim().is_empty());
}
if pages.iter().all(|p| p.words.is_empty()) {
return Err(failed("the OCR review has no words to apply"));
}
let out = match a.opt_str("output_mode")?.unwrap_or("searchable") {
"searchable" => {
if a.get("out").is_some() {
return Err(ToolError::InvalidArgs("out is only for editable output".into()));
}
let count = self.session.apply_ocr_checked(id, &pages, &review.guard).map_err(failed)?;
json!({ "applied": count, "review": handle })
}
"editable" => {
// The editable output writes the review's words out of the document: a
// document that forbids copying refuses here (the review is kept).
self.doc_to_copy(a)?;
let target = self.resolve(a.str("out")?, true)?;
if self
.session
.get(id)
.and_then(|d| d.path.as_deref())
.and_then(|p| std::fs::canonicalize(p).ok())
.as_ref()
.is_some_and(|source| std::fs::canonicalize(&target).as_ref().ok() == Some(source))
{
return Err(failed("out is the source document; choose a new file"));
}
// A NEW file: an existing one is never replaced silently (the review is kept).
if target.exists() {
return Err(failed(format!("out already exists ({}); choose a path that does not exist yet", target.to_string_lossy())));
}
let bytes = self.session.editable_ocr_bytes(id, &pages).map_err(failed)?;
write_atomic(&target, &bytes)?;
json!({ "out": target.to_string_lossy(), "review": handle, "words": pages.iter().map(|p| p.words.len()).sum::<usize>() })
}
_ => return Err(ToolError::InvalidArgs("output_mode must be searchable or editable".into())),
};
self.ocr_reviews.remove(&id);
Ok(out)
}
/// Read/update the same per-language dictionary file used by OCR Verify.
pub(crate) fn ocr_dictionary(&mut self, a: &Args) -> Result<Value> {
use pdfcraft_engine::ocr::dictionary;
use std::io::Read as _;
for key in ["add", "remove"] {
if let Some(value) = a.get(key) {
let values = value.as_array().ok_or_else(|| Args::wrong(key, "an array of strings"))?;
if values.len() > 65536 {
return Err(failed("at most 65536 dictionary words are allowed"));
}
}
}
let add = if a.get("add").is_some() { a.strs("add")? } else { Vec::new() };
let remove = if a.get("remove").is_some() { a.strs("remove")? } else { Vec::new() };
let writing = a.get("add").is_some() || a.get("remove").is_some();
// Updating sorts, lower-cases and rewrites the file, so this tool names dictionary files only:
// pointed at `.env` or a README it would corrupt it.
let file = std::path::Path::new(a.str("path")?).file_name().and_then(|n| n.to_str()).unwrap_or("");
let plain = file.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
if file.len() > 64 || !plain || !file.starts_with("ocr-dictionary") || !file.ends_with(".txt") {
return Err(failed("path must name a dictionary file: ocr-dictionary-<language>.txt (letters, digits, '-', '_' and '.' only)"));
}
let path = self.resolve(a.str("path")?, writing)?;
let mut input = String::new();
match std::fs::File::open(&path) {
Ok(file) => {
file.take((dictionary::MAX_BYTES + 1) as u64).read_to_string(&mut input).map_err(|e| failed(format!("OCR dictionary: {e}")))?;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound && writing => {}
Err(e) => return Err(failed(format!("OCR dictionary: {e}"))),
}
let text = dictionary::update(&input, &add, &remove).map_err(failed)?;
if writing {
write_atomic(&path, text.as_bytes())?;
}
let words = dictionary::words(&text).map_err(failed)?;
Ok(json!({ "path": path.to_string_lossy(), "count": words.len(), "words": words }))
}
/// Whether text recognition's models are installed, the directories searched for them (in
/// order), and the languages it reads, each as a code and a name.
pub(crate) fn ocr_status(&self) -> Result<Value> {
use pdfcraft_engine::ocr;
let dirs: Vec<String> = ocr::Models::search_dirs().iter().map(|d| d.to_string_lossy().into_owned()).collect();
let langs: Vec<Value> = ocr::LANGUAGES.iter().map(|(c, n)| json!({ "code": c, "name": n })).collect();
Ok(json!({ "available": ocr::available(), "search_dirs": dirs, "languages": langs }))
let engines: Vec<Value> = ocr::engines_status()
.iter()
.map(|e| {
let mut v = json!({ "id": e.id, "available": e.available });
if let Some(p) = &e.program {
v["path"] = json!(p);
}
if let Some(vv) = &e.version {
v["version"] = json!(vv);
}
if let Some(why) = &e.why_not {
v["unavailable"] = json!(why);
}
v
})
.collect();
Ok(json!({ "available": ocr::available(), "search_dirs": dirs, "languages": langs, "engines": engines }))
}
}
+46 -12
View File
@@ -36,8 +36,9 @@ impl Automation {
}
(None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())),
};
// The page font is Helvetica/WinAnsi: text it would turn into `?` is refused (with
// its line) by `pdfcraft_create::from_text`, as for every other caller.
// The page font is Helvetica/WinAnsi, or the bundled Inter face for Latin, Greek and
// Cyrillic letters WinAnsi lacks: text neither can draw is refused (with its line)
// by `pdfcraft_create::from_text`, as for every other caller.
let bytes = self.session.create_from_text(&title, &text).map_err(|e| match e {
pdfcraft_engine::EditError::Create(pdfcraft_engine::CreateError::Invalid(m)) => ToolError::InvalidArgs(m),
e => failed(e),
@@ -238,6 +239,8 @@ impl Automation {
Ok(json!({ "files": out }))
}
/// `page_extract`: the chosen pages as one new PDF, or with `separate` one file per page in
/// `out_dir`; with `delete` the pages are then removed from the original.
pub(crate) fn page_extract(&mut self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let (id, name) = (doc.id, format!("{} (extract)", doc.name));
@@ -245,16 +248,8 @@ impl Automation {
let pages = self.pages(a, "pages")?;
let mut out = if a.opt_bool("separate")?.unwrap_or(false) {
// Each page as its own file.
let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("separate files need out_dir".into()))?, true)?;
std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?;
let mut files = Vec::new();
for &p in &pages {
let bytes = self.session.extract(id, &[p]).map_err(failed)?;
let path = child(&dir, &format!("{stem}-page{}.pdf", p + 1));
write_atomic(&path, &bytes)?;
files.push(path.to_string_lossy().into_owned());
}
json!({ "files": files })
let out_dir = a.str("out_dir").map_err(|_| ToolError::InvalidArgs("separate files need out_dir".into()))?;
json!({ "files": self.extract_separate(id, &pages, out_dir, &stem)? })
} else {
let bytes = self.session.extract(id, &pages).map_err(failed)?;
self.deliver(a, &name, bytes)?
@@ -266,6 +261,45 @@ impl Automation {
Ok(out)
}
/// Each of `pages` as its own `<stem>-page<n>.pdf` in `out_dir`, extracted and written one at a
/// time, so only one page's PDF is held at once. The first page is extracted before `out_dir` is
/// resolved and made, so a refusal (the permissions refuse every page alike, #5081) leaves nothing
/// behind; if a later page fails, the files this call created, and the directory if it made it,
/// are removed.
fn extract_separate(&self, id: pdfcraft_engine::DocId, pages: &[usize], out_dir: &str, stem: &str) -> Result<Vec<String>> {
let Some(&first) = pages.first() else { return Ok(Vec::new()) };
let mut first_bytes = Some(self.session.extract(id, &[first]).map_err(failed)?);
let dir = self.resolve(out_dir, true)?;
let made_dir = !dir.exists();
std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?;
let (mut files, mut created) = (Vec::new(), Vec::new());
let outcome = pages.iter().try_for_each(|&p| -> Result<()> {
let bytes = match first_bytes.take() {
Some(bytes) => bytes,
None => self.session.extract(id, &[p]).map_err(failed)?,
};
let path = child(&dir, &format!("{stem}-page{}.pdf", p.saturating_add(1)));
let existed = path.exists();
write_atomic(&path, &bytes)?;
if !existed {
created.push(path.clone());
}
files.push(path.to_string_lossy().into_owned());
Ok(())
});
if let Err(e) = outcome {
// Best effort: whatever can't be removed stays, and the error reported is the extraction's.
for path in &created {
let _ = std::fs::remove_file(path);
}
if made_dir {
let _ = std::fs::remove_dir(&dir);
}
return Err(e);
}
Ok(files)
}
pub(crate) fn doc_combine(&mut self, a: &Args) -> Result<Value> {
let paths = a.strs("paths")?;
if paths.len() < 2 {
+62 -6
View File
@@ -19,9 +19,13 @@ struct Recipe<'a> {
images: optimize::ImageSettings,
mono: optimize::MonoSettings,
ocr: ocr::OcrSettings,
/// The calling session's JavaScript preference, carried into each file's own session.
javascript: bool,
}
impl<'a> Recipe<'a> {
/// Read and validate `doc_batch`'s steps and settings, taking the JavaScript preference from
/// `auto`'s session.
fn parse(auto: &Automation, args: &'a Args) -> Result<Self> {
let steps = args.strs("steps")?;
if steps.is_empty() || steps.len() > 3 {
@@ -60,7 +64,7 @@ impl<'a> Recipe<'a> {
if args.opt_num("dpi")?.is_some_and(|dpi| !(72.0..=600.0).contains(&dpi)) {
return Err(Args::wrong("dpi", "72 to 600"));
}
Ok(Self { steps, images, mono, ocr: auto.ocr_settings(args)? })
Ok(Self { steps, images, mono, ocr: auto.ocr_settings(args)?, javascript: auto.session.javascript() })
}
fn accepts(&self, path: &Path) -> bool {
@@ -68,9 +72,13 @@ impl<'a> Recipe<'a> {
extension == "pdf" || (self.steps.contains(&"convert") && pdfcraft_engine::CONVERTIBLE.contains(&extension.as_str()))
}
/// Run the recipe's steps on the file at `path` in a fresh session; returns the output bytes
/// and a report entry per step.
fn process(&self, path: &Path) -> Result<(Arc<Vec<u8>>, Vec<Value>)> {
// A fresh session per file bounds retained document state and never touches caller tabs.
// It keeps the caller's JavaScript preference, set before any open runs open-time scripts.
let mut session = Session::new();
session.set_javascript(self.javascript);
let mut input = Vec::new();
std::fs::File::open(path).map_err(failed)?.take(MAX_INPUT + 1).read_to_end(&mut input).map_err(failed)?;
if input.len() as u64 > MAX_INPUT {
@@ -84,8 +92,9 @@ impl<'a> Recipe<'a> {
match *step {
"convert" => bytes = session.convert_to_pdf(name, &bytes)?.1,
"ocr" => {
let engine = ocr::engine().map_err(failed)?;
let result = ocr::recognize_file(name, bytes, None, self.ocr.clone(), &engine, |_, _| true).map_err(failed)?;
let recognizers = ocr::recognizers(&self.ocr).map_err(failed)?;
let result =
ocr::recognize_file(name, bytes, None, self.ocr.clone(), &recognizers, self.javascript, |_, _| true).map_err(failed)?;
log.push(
json!({"step":"ocr","words":result.words(),"skipped_pages":result.pages.iter().filter(|p| p.skipped.is_some()).count()}),
);
@@ -185,6 +194,7 @@ impl Automation {
let recipe = Recipe::parse(self, args)?;
let recursive = args.opt_bool("recursive")?.unwrap_or(false);
let stop = args.opt_bool("stop_on_error")?.unwrap_or(false);
let dry_run = args.opt_bool("dry_run")?.unwrap_or(false);
let paths = args.strs("paths")?;
if paths.is_empty() || paths.len() > 1000 {
return Err(Args::wrong("paths", "1 to 1000 files or folders"));
@@ -211,11 +221,41 @@ impl Automation {
let mut files = discovery.skipped;
let (mut completed, mut failed_count) = (0, 0);
let mut stopped = false;
let mut planned_outputs: HashSet<PathBuf> = HashSet::new();
for (path, relative) in discovery.files {
if stopped || !recipe.accepts(&path) {
files.push(json!({"path":path.to_string_lossy(),"status":"skipped","reason":if stopped {"stopped after error"} else {"unsupported input extension"}}));
continue;
}
if dry_run {
let planned: Result<PathBuf> = (|| {
let target = out.join(&relative).with_extension("pdf");
let target = self.resolve(target.to_str().ok_or_else(|| failed("output path is not UTF-8"))?, true)?;
if !target.starts_with(&out) {
return Err(failed("output path leaves out_dir"));
}
let dir = target.parent().ok_or_else(|| failed("output has no parent"))?;
let stem = target.file_stem().ok_or_else(|| failed("output has no stem"))?.to_string_lossy().into_owned();
for n in 1..=10_000 {
let candidate = if n == 1 { target.clone() } else { dir.join(format!("{stem} ({n}).pdf")) };
if candidate.symlink_metadata().is_ok() || planned_outputs.contains(&candidate) {
continue;
}
planned_outputs.insert(candidate.clone());
return Ok(candidate);
}
Err(failed("all 10000 output names are occupied"))
})();
match planned {
Ok(target) => files.push(json!({"path":path.to_string_lossy(),"status":"planned","output":target.to_string_lossy().replace('\\', "/"),"steps":recipe.steps})),
Err(e) => {
failed_count += 1;
files.push(json!({"path":path.to_string_lossy(),"status":"error","error":e.to_string()}));
stopped = stop;
}
}
continue;
}
// Recheck confinement and links before reading a discovered file.
let result: Result<_> = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let current = self.resolve(path.to_str().ok_or_else(|| failed("input path is not UTF-8"))?, false)?;
@@ -244,13 +284,13 @@ impl Automation {
}
}
}
let skipped = files.len().saturating_sub(completed + failed_count);
Ok(json!({"completed":completed,"failed":failed_count,"skipped":skipped,"stopped":stopped,"files":files}))
let skipped = files.iter().filter(|file| file["status"] == "skipped").count();
Ok(json!({"dry_run":dry_run,"completed":completed,"failed":failed_count,"skipped":skipped,"stopped":stopped,"files":files}))
}
}
/// Publish atomically without replacing any existing name, even if another writer wins a race.
fn publish(target: &Path, bytes: &[u8]) -> Result<PathBuf> {
pub(crate) fn publish(target: &Path, bytes: &[u8]) -> Result<PathBuf> {
let dir = target.parent().ok_or_else(|| failed("output has no parent"))?;
std::fs::create_dir_all(dir).map_err(failed)?;
let mut temp = tempfile::NamedTempFile::new_in(dir).map_err(failed)?;
@@ -279,4 +319,20 @@ mod tests {
discovery.visited = 0;
assert!(discovery.walk(Path::new("absent"), Path::new(""), Path::new("output"), true, MAX_DEPTH + 1).unwrap_err().to_string().contains("64"));
}
#[test]
fn doc_batch_dry_run_lists_outputs_without_writing() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("input")).unwrap();
let pdf = Session::new().create_from_text("Doc", "Hello").unwrap();
std::fs::write(dir.path().join("input/a.pdf"), &*pdf).unwrap();
let auto = Automation::new().with_root(dir.path()).unwrap();
let report = auto.doc_batch(&Args(&json!({"paths":["input"],"steps":["optimize"],"out_dir":"out","dry_run":true}))).unwrap();
assert_eq!(report["dry_run"], true, "{report}");
assert_eq!(report["completed"], 0, "{report}");
assert_eq!(report["failed"], 0, "{report}");
assert_eq!(report["files"][0]["status"], "planned", "{report}");
assert!(report["files"][0]["output"].as_str().unwrap().ends_with("out/a.pdf"), "{report}");
assert!(!dir.path().join("out").exists());
}
}
+434
View File
@@ -0,0 +1,434 @@
use std::io::Write;
use pdfcraft_engine::{Edit, HeaderFooter};
use serde_json::{Value, json};
use super::error::{Result, ToolError, failed};
use super::json::Args;
use super::paths::read_input;
use super::{Arc, Automation, Path, PathBuf};
const MAX_BATES_BYTES: u64 = 512 * 1024 * 1024;
struct FilePlan {
input: String,
source: PathBuf,
password: Option<String>,
output: PathBuf,
pages: usize,
first: u64,
last: u64,
}
impl Automation {
pub(crate) fn doc_bates_multi(&mut self, args: &Args) -> Result<Value> {
let files = args.0.get("files").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("files must be an array".into()))?;
if files.is_empty() || files.len() > 1000 {
return Err(ToolError::InvalidArgs("files must contain 1 to 1000 entries".into()));
}
let start = match args.0.get("start_number") {
None | Some(Value::Null) => 1,
Some(value) => {
value.as_u64().filter(|n| *n >= 1).ok_or_else(|| ToolError::InvalidArgs("start_number must be an integer of at least 1".into()))?
}
};
let digits = args.opt_int("digits")?.unwrap_or(6);
let digits =
u8::try_from(digits).ok().filter(|d| (1..=12).contains(d)).ok_or_else(|| ToolError::InvalidArgs("digits must be from 1 to 12".into()))?;
let prefix = args.opt_str("prefix")?.unwrap_or("");
let suffix = args.opt_str("suffix")?.unwrap_or("");
if prefix.chars().count() > 128
|| suffix.chars().count() > 128
|| prefix.contains('#')
|| suffix.contains('#')
|| prefix.contains(">>")
|| suffix.contains(">>")
{
return Err(ToolError::InvalidArgs("prefix and suffix must be at most 128 characters and cannot contain # or >>".into()));
}
let dry_run = args.opt_bool("dry_run")?.unwrap_or(false);
// Resolve and reserve the complete path list before opening or writing anything.
let mut sources = Vec::with_capacity(files.len());
let mut targets = Vec::with_capacity(files.len());
let mut passwords = Vec::with_capacity(files.len());
for entry in files {
let input = entry
.get("input")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.ok_or_else(|| ToolError::InvalidArgs("each file needs a non-empty input path".into()))?;
let output = entry
.get("output")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.ok_or_else(|| ToolError::InvalidArgs("each file needs a non-empty output path".into()))?;
let source = self.resolve(input, false)?;
let target = self.resolve(output, true)?;
if target.exists() {
return Err(failed(format!("output already exists; refusing to replace it: {output}")));
}
let parent = target.parent().ok_or_else(|| failed(format!("output has no parent directory: {output}")))?;
if !parent.is_dir() {
return Err(failed(format!("output directory does not exist: {}", parent.display())));
}
sources.push((input.to_string(), source));
targets.push((output.to_string(), target));
passwords.push(entry.get("password").and_then(Value::as_str).map(str::to_owned));
}
check_unique_outputs(&targets, dry_run)?;
let mut plans = Vec::with_capacity(files.len());
let mut next = start;
let mut input_bytes = 0u64;
for (index, ((input_name, input_path), (_, output_path))) in sources.iter().zip(&targets).enumerate() {
let size = std::fs::metadata(input_path).map_err(failed)?.len();
input_bytes = input_bytes
.checked_add(size)
.filter(|total| *total <= MAX_BATES_BYTES)
.ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?;
let bytes = read_input(input_path)?;
input_bytes = input_bytes
.saturating_sub(size)
.checked_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX))
.filter(|total| *total <= MAX_BATES_BYTES)
.ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?;
let display_name = input_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let password = passwords.get(index).and_then(Option::as_deref);
let id = self.session.open(display_name, Some(input_path.to_string_lossy().into_owned()), Arc::new(bytes), password).map_err(failed)?;
let pages =
self.session.get(id).map(|doc| doc.info.pages.len()).ok_or_else(|| failed("opened document disappeared during Bates preflight"))?;
self.session.close(id);
if pages == 0 {
return Err(failed(format!("{input_name} has no pages to number")));
}
let count = u64::try_from(pages).map_err(|_| failed(format!("page count is too large for {input_name}")))?;
let last_offset = count.checked_sub(1).ok_or_else(|| failed(format!("{input_name} has no pages to number")))?;
let last = next.checked_add(last_offset).ok_or_else(|| failed(format!("Bates number range overflows for {input_name}")))?;
plans.push(FilePlan {
input: input_name.clone(),
source: input_path.clone(),
password: passwords.get(index).cloned().flatten(),
output: output_path.clone(),
pages,
first: next,
last,
});
if index + 1 < files.len() {
next = last.checked_add(1).ok_or_else(|| failed(format!("Bates sequence overflows after {input_name}")))?;
}
}
let intervals: Vec<Value> = plans
.iter()
.zip(&targets)
.map(|(p, (name, _))| json!({"input":p.input,"output":name,"pages":p.pages,"first":p.first,"last":p.last}))
.collect();
if dry_run {
return Ok(json!({"dry_run":true,"files":intervals}));
}
let mut staged = Vec::with_capacity(plans.len());
let mut results = Vec::with_capacity(plans.len());
let mut output_bytes = 0u64;
for plan in &plans {
let source_bytes = match read_input(&plan.source) {
Ok(bytes) => bytes,
Err(error) => {
results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()}));
continue;
}
};
let name = plan.source.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let id = match self.session.open(name, Some(plan.source.to_string_lossy().into_owned()), Arc::new(source_bytes), plan.password.as_deref())
{
Ok(id) => id,
Err(error) => {
results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()}));
continue;
}
};
if self.session.get(id).is_none_or(|doc| doc.info.pages.len() != plan.pages) {
self.session.close(id);
results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"input changed after preflight; page count differs"}));
continue;
}
let template = format!("<<Bates Number#{digits}#{}#{prefix}#{suffix}>>", plan.first);
let mut settings = HeaderFooter::default();
settings.text[4] = template;
let pages: Vec<usize> = (0..plan.pages).collect();
let applied = self.session.apply(id, Edit::AddHeaderFooter { pages, settings, replace: false });
let result = applied.and_then(|()| self.session.save_full_bytes(id));
match result {
Ok(bytes) => {
let candidate = output_bytes
.checked_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX))
.filter(|total| *total <= MAX_BATES_BYTES);
if let Some(total) = candidate {
output_bytes = total;
staged.push((plan.output.clone(), bytes));
} else {
results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"combined Bates output exceeds the 512 MiB safety limit"}));
}
}
Err(error) => results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})),
}
self.session.close(id);
}
for (path, bytes) in staged {
match publish_new(&path, &bytes) {
Ok(()) => {
let plan = plans.iter().find(|p| p.output == path);
if let Some(plan) = plan {
results.push(json!({"input":plan.input,"output":path,"status":"ok","first":plan.first,"last":plan.last,"pages":plan.pages}));
}
}
Err(error) => {
let plan = plans.iter().find(|p| p.output == path);
if let Some(plan) = plan {
results.push(json!({"input":plan.input,"output":path,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()}));
}
}
}
}
results.sort_by_key(|result| plans.iter().position(|plan| result["output"] == json!(plan.output)).unwrap_or(usize::MAX));
let completed = results.iter().filter(|r| r["status"] == "ok").count();
let failed_count = results.len().saturating_sub(completed);
Ok(json!({"dry_run":false,"completed":completed,"failed":failed_count,"files":results}))
}
}
fn check_unique_outputs(targets: &[(String, PathBuf)], dry_run: bool) -> Result<()> {
if targets.len() < 2 {
return Ok(());
}
// Probe exact basenames in a directory under each canonical parent, so the filesystem applies its own name rules.
let mut groups: std::collections::HashMap<PathBuf, (PathBuf, Vec<(String, std::ffi::OsString)>)> = std::collections::HashMap::new();
for (name, target) in targets {
let parent = target.parent().ok_or_else(|| failed(format!("output has no parent directory: {name}")))?;
let identity = parent.canonicalize().map_err(failed)?;
let probe_name = output_probe_name(target).map_err(failed)?;
groups.entry(identity.clone()).or_insert_with(|| (identity, Vec::new())).1.push((name.clone(), probe_name));
}
if dry_run {
// Dry runs cannot probe the volume: Windows uses a conservative case-insensitive comparison,
// while Unix preserves case, so unusual case-sensitive Windows or case-insensitive Unix volumes can differ.
for (_, (_, names)) in groups {
for (index, (name, candidate)) in names.iter().enumerate() {
if names.iter().take(index).any(|(_, prior)| same_output_name(prior, candidate)) {
return Err(failed(format!("two files use the same output path: {name}")));
}
}
}
return Ok(());
}
for (_, (parent, names)) in groups {
match tempfile::tempdir_in(&parent) {
Ok(probes) => {
for (name, probe_name) in names {
let probe = probes.path().join(probe_name);
match std::fs::OpenOptions::new().write(true).create_new(true).open(&probe) {
Ok(file) => drop(file),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
return Err(failed(format!("two files use the same output path: {name}")));
}
Err(error) => return Err(failed(format!("could not validate output path {name}: {error}"))),
}
}
}
#[cfg(windows)]
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => probe_files_for_resolved_outputs(&parent, names)?,
Err(error) => return Err(failed(error)),
}
}
Ok(())
}
#[cfg(windows)]
fn probe_files_for_resolved_outputs(parent: &Path, names: Vec<(String, std::ffi::OsString)>) -> Result<()> {
use std::os::windows::fs::OpenOptionsExt;
const FILE_FLAG_DELETE_ON_CLOSE: u32 = 0x0400_0000;
let mut probes = Vec::with_capacity(names.len());
for (name, probe_name) in names {
let probe = parent.join(probe_name);
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true).share_mode(0).custom_flags(FILE_FLAG_DELETE_ON_CLOSE);
match options.open(&probe) {
Ok(file) => probes.push(file),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
return Err(failed(format!("two files use the same output path: {name}")));
}
Err(error) => return Err(failed(format!("could not validate output path {name}: {error}"))),
}
}
drop(probes);
Ok(())
}
fn same_output_name(left: &std::ffi::OsStr, right: &std::ffi::OsStr) -> bool {
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
fn key(name: &std::ffi::OsStr) -> Vec<u32> {
std::char::decode_utf16(name.encode_wide())
.map(|decoded| match decoded {
Ok(character) => {
let mut uppercase = character.to_uppercase();
match (uppercase.next(), uppercase.next()) {
(Some(single), None) => single as u32,
_ => character as u32,
}
}
Err(error) => 0x11_0000 + u32::from(error.unpaired_surrogate()),
})
.collect()
}
key(left) == key(right)
}
#[cfg(not(windows))]
{
left == right
}
}
fn output_probe_name(path: &Path) -> std::io::Result<std::ffi::OsString> {
let name = path.file_name().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "output has no file name"))?;
#[cfg(windows)]
{
use std::os::windows::ffi::{OsStrExt, OsStringExt};
let is_verbatim = path.components().any(|component| matches!(component, std::path::Component::Prefix(prefix) if matches!(prefix.kind(), std::path::Prefix::Verbatim(_) | std::path::Prefix::VerbatimUNC(_, _) | std::path::Prefix::VerbatimDisk(_))));
let mut wide: Vec<u16> = name.encode_wide().collect();
if !is_verbatim {
while wide.last().is_some_and(|unit| *unit == u16::from(b'.') || *unit == u16::from(b' ')) {
wide.pop();
}
}
Ok(std::ffi::OsString::from_wide(&wide))
}
#[cfg(not(windows))]
{
Ok(name.to_os_string())
}
}
/// Publish a PDF atomically while refusing a target created by another process after preflight.
pub(super) fn publish_new(path: &Path, bytes: &[u8]) -> Result<()> {
let parent = path.parent().ok_or_else(|| failed("output has no parent directory"))?;
let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(failed)?;
temp.write_all(bytes).map_err(failed)?;
temp.as_file().sync_all().map_err(failed)?;
temp.persist_noclobber(path).map(|_| ()).map_err(|error| {
if error.error.kind() == std::io::ErrorKind::AlreadyExists {
failed(format!("output appeared after preflight; refusing to replace it: {}", path.display()))
} else {
failed(error.error)
}
})
}
#[cfg(test)]
mod tests {
use super::check_unique_outputs;
#[test]
fn dry_run_path_check_creates_no_probe_files() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("same.pdf");
let targets = vec![("first.pdf".into(), path.clone()), ("second.pdf".into(), path)];
assert!(check_unique_outputs(&targets, true).is_err());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
#[cfg(unix)]
#[test]
fn dry_run_detects_output_parent_aliases_without_writing() {
let dir = tempfile::tempdir().unwrap();
let alias = dir.path().join("alias");
std::os::unix::fs::symlink(dir.path(), &alias).unwrap();
let first = dir.path().join("same.pdf");
let second = alias.join("same.pdf");
let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)];
assert!(check_unique_outputs(&targets, true).is_err());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
}
#[test]
fn long_output_names_fit_in_probe_directory() {
let dir = tempfile::tempdir().unwrap();
let first = dir.path().join(format!("{}.pdf", "a".repeat(220)));
let second = dir.path().join(format!("{}.pdf", "b".repeat(220)));
let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)];
assert!(check_unique_outputs(&targets, false).is_ok());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
// Not on macOS: APFS keeps only UTF-8 file names, so no output there can have such a name, and
// creating the probe fails before the check runs.
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn output_probe_paths_preserve_non_utf8_names() {
use std::os::unix::ffi::OsStringExt;
let dir = tempfile::tempdir().unwrap();
let first = dir.path().join(std::ffi::OsString::from_vec(vec![b'o', 0xff]));
let second = dir.path().join(std::ffi::OsString::from_vec(vec![b'o', 0xfe]));
let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)];
assert!(check_unique_outputs(&targets, false).is_ok());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
#[cfg(windows)]
#[test]
fn windows_probe_names_trim_only_non_verbatim_paths() {
use std::ffi::OsString;
use std::path::Path;
let normal = Path::new(r"C:\root\report.pdf. ");
assert_eq!(super::output_probe_name(normal).unwrap(), OsString::from("report.pdf"));
let verbatim = Path::new(r"\\?\C:\root\report.pdf. ");
assert_eq!(super::output_probe_name(verbatim).unwrap(), OsString::from("report.pdf. "));
}
#[cfg(windows)]
#[test]
fn dry_run_detects_case_only_output_collisions_without_writing() {
let dir = tempfile::tempdir().unwrap();
let first = dir.path().join("Case.pdf");
let second = dir.path().join("CASE.pdf");
let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)];
assert!(check_unique_outputs(&targets, true).is_err());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
#[cfg(windows)]
#[test]
fn permission_denied_child_directory_uses_delete_on_close_file_probes() {
use std::process::Command;
let dir = tempfile::tempdir().unwrap();
let identity = Command::new("whoami.exe").args(["/user", "/fo", "csv", "/nh"]).output().unwrap();
assert!(identity.status.success());
let identity = String::from_utf8(identity.stdout).unwrap();
let sid = identity.split('"').nth(3).unwrap();
let acl = Command::new("icacls.exe").arg(dir.path()).arg("/deny").arg(format!("*{sid}:(AD)")).status().unwrap();
assert!(acl.success());
// Where the deny entry does not bind the test's own account (it did not on CI's Windows image),
// there is no denied folder to probe.
if !matches!(tempfile::tempdir_in(dir.path()), Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied) {
return;
}
let direct_probe = dir.path().join("direct-probe");
let file = std::fs::OpenOptions::new().write(true).create_new(true).open(&direct_probe).unwrap();
drop(file);
std::fs::remove_file(&direct_probe).unwrap();
let targets = vec![("first.pdf".into(), dir.path().join("first.pdf")), ("second.pdf".into(), dir.path().join("second.pdf"))];
assert!(check_unique_outputs(&targets, false).is_ok());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
}
+209
View File
@@ -0,0 +1,209 @@
//! Bookmark trees in and out of documents: `bookmark_export`, `bookmark_import`, `bookmark_copy`.
//!
//! The portable form is JSON: `{"bookmarks": [node, …]}`, where a node is `title`, `page`
//! (1-based, or `null` for one that goes nowhere), `view` (`{"type", "values"}`: a destination
//! type and its values in PDF user space, as the file stores them), `color`, `bold`, `italic`,
//! `open` and `children`. What export writes, import takes back unchanged. A file also carries
//! `"truncated": true` when the export left out bookmarks nested past the listing limit.
use crate::{Args, Automation, Result, ToolError, failed};
use pdfcraft_engine::{DocId, Edit, OutlineNode};
use serde_json::{Value, json};
/// Deepest nesting a bookmark tree may have (the listing reaches 33 levels).
const MAX_DEPTH: usize = 33;
/// The keys a node may have: the ones [`node_json`] writes.
const NODE_KEYS: [&str; 8] = ["title", "page", "view", "color", "bold", "italic", "open", "children"];
impl Automation {
pub(crate) fn bookmark_export(&self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
let path = a.opt_path("path")?.unwrap_or_default();
let tree = self.outline_of(id, &path)?;
let mut out = json!({ "bookmarks": tree.nodes.iter().map(node_json).collect::<Vec<_>>(), "truncated": tree.truncated });
if let Some(file) = a.opt_str("out")? {
let target = self.resolve(file, true)?;
let mut saved = json!({ "bookmarks": out["bookmarks"] });
if tree.truncated {
saved["truncated"] = json!(true);
}
let text = serde_json::to_vec_pretty(&saved).map_err(failed)?;
crate::write_atomic(&target, &text)?;
out["path"] = json!(target);
}
Ok(out)
}
pub(crate) fn bookmark_import(&mut self, a: &Args) -> Result<Value> {
let (nodes, truncated) = match (a.get("bookmarks"), a.opt_str("file")?) {
(Some(list), None) => (nodes_of(list)?, false),
(None, Some(file)) => {
let path = self.resolve(file, false)?;
let value: Value = serde_json::from_slice(&crate::read_input(&path)?).map_err(|e| failed(format!("not a bookmark file: {e}")))?;
let list = value.get("bookmarks").ok_or_else(|| failed("not a bookmark file: it has no \"bookmarks\" list"))?;
(nodes_of(list)?, value.get("truncated") == Some(&Value::Bool(true)))
}
_ => return Err(ToolError::InvalidArgs("give either bookmarks or file".into())),
};
let mut out = self.write_tree(a, nodes)?;
out["truncated"] = json!(truncated);
Ok(out)
}
pub(crate) fn bookmark_copy(&mut self, a: &Args) -> Result<Value> {
let from = a.int("from")?;
let from = DocId(u64::try_from(from).map_err(|_| ToolError::InvalidArgs("from must be positive".into()))?);
let path = a.opt_path("path")?.unwrap_or_default();
let tree = self.outline_of(from, &path)?;
let mut out = self.write_tree(a, tree.nodes)?;
out["truncated"] = json!(tree.truncated);
Ok(out)
}
fn outline_of(&self, id: DocId, path: &[usize]) -> Result<pdfcraft_engine::OutlineTree> {
match self.session.outline_tree(id, path) {
// The engine names the path 0-based; the caller gave it 1-based.
Some(tree) => tree.map_err(|e| match e {
pdfcraft_organize::OutlineError::NoSuchBookmark(_) => failed(format!("there is no bookmark at {:?}", one_based(path))),
e => failed(e),
}),
None => Err(failed(format!("document {} is not open, or its bookmarks can't be read for editing", id.0))),
}
}
/// Write `nodes` into `doc` under `parent` at `position` (or in place of the outline with
/// `replace`), and say which of them went nowhere because the document lacks their page.
fn write_tree(&mut self, a: &Args, bookmarks: Vec<OutlineNode>) -> Result<Value> {
let parent = a.opt_path("parent")?.unwrap_or_default();
let index = a.opt_int("position")?.map_or(usize::MAX, |p| usize::try_from(p.max(1) - 1).unwrap_or(usize::MAX));
let replace = a.opt_bool("replace")?.unwrap_or(false);
let count = self.doc(a)?.info.pages.len();
let unmapped = pdfcraft_engine::outline_unmapped(&bookmarks, count);
let mut out = self.apply(a, Edit::ImportBookmarks { parent, index, bookmarks, replace })?;
out["unmapped"] = json!(unmapped.iter().map(|p| one_based(p)).collect::<Vec<_>>());
Ok(out)
}
}
fn one_based(path: &[usize]) -> Vec<usize> {
path.iter().map(|i| i.saturating_add(1)).collect()
}
/// A node as JSON, leaving out what is at its default (no view, black, regular, no children).
fn node_json(n: &OutlineNode) -> Value {
let mut v = json!({ "title": n.title, "page": n.page.map(|p| p.saturating_add(1)), "open": n.open });
if let Some((kind, values)) = &n.view {
v["view"] = json!({ "type": String::from_utf8_lossy(kind), "values": values });
}
if let Some(c) = n.color {
v["color"] = json!(c);
}
if n.bold {
v["bold"] = json!(true);
}
if n.italic {
v["italic"] = json!(true);
}
if !n.children.is_empty() {
v["children"] = json!(n.children.iter().map(node_json).collect::<Vec<_>>());
}
v
}
fn nodes_of(list: &Value) -> Result<Vec<OutlineNode>> {
let mut count = 0;
list_of(list, &[], &mut count)
}
/// The nodes of one level; `at` is the 1-based path of their parent, for messages.
fn list_of(list: &Value, at: &[usize], count: &mut usize) -> Result<Vec<OutlineNode>> {
let items = list.as_array().ok_or_else(|| bad(at, "children must be a list of bookmarks"))?;
if at.len() >= MAX_DEPTH && !items.is_empty() {
return Err(bad(at, &format!("bookmarks may nest at most {MAX_DEPTH} levels deep")));
}
*count = count.saturating_add(items.len());
if *count > pdfcraft_engine::MAX_IMPORT_BOOKMARKS {
return Err(ToolError::InvalidArgs(format!("at most {} bookmarks can be imported at once", pdfcraft_engine::MAX_IMPORT_BOOKMARKS)));
}
let mut out = Vec::with_capacity(items.len());
for (i, item) in items.iter().enumerate() {
let mut path = at.to_vec();
path.push(i.saturating_add(1));
out.push(node_of(item, &path, count)?);
}
Ok(out)
}
/// One node and, through [`list_of`], its children (recursion bounded by `MAX_DEPTH`).
fn node_of(v: &Value, at: &[usize], count: &mut usize) -> Result<OutlineNode> {
let o = v.as_object().ok_or_else(|| bad(at, "must be an object"))?;
if let Some(k) = o.keys().find(|k| !NODE_KEYS.contains(&k.as_str())) {
return Err(bad(at, &format!("unknown key {k:?} (a bookmark has {})", NODE_KEYS.join(", "))));
}
let field = |k: &str| o.get(k).filter(|x| !x.is_null());
// An empty title is kept: files have them, and export writes them as "".
let title = field("title").and_then(Value::as_str).ok_or_else(|| bad(at, "needs a title"))?.to_string();
let page = match field("page") {
None => None,
Some(p) => Some(p.as_u64().filter(|p| *p >= 1).and_then(|p| usize::try_from(p - 1).ok()).ok_or_else(|| bad(at, "page must be 1 or more"))?),
};
let flag = |k: &str| field(k).map_or(Ok(None), |x| x.as_bool().map(Some).ok_or_else(|| bad(at, &format!("{k} must be true or false"))));
let color = match field("color") {
None => None,
Some(c) => {
let rgb: Option<Vec<f32>> = c.as_array().and_then(|c| c.iter().map(|x| x.as_f64().map(|x| x as f32)).collect());
match rgb.as_deref() {
Some(&[r, g, b]) if [r, g, b].iter().all(|c| (0.0..=1.0).contains(c)) => Some([r, g, b]),
_ => return Err(bad(at, "color must be [r, g, b], each from 0 to 1")),
}
}
};
let children = match field("children") {
None => Vec::new(),
Some(list) => list_of(list, at, count)?,
};
Ok(OutlineNode {
title,
page,
view: field("view").map(|x| view_of(x, at)).transpose()?,
color,
italic: flag("italic")?.unwrap_or(false),
bold: flag("bold")?.unwrap_or(false),
open: flag("open")?.unwrap_or(true),
children,
})
}
/// `{"type": "XYZ", "values": [left, top, zoom]}`: a destination type with exactly its values.
fn view_of(v: &Value, at: &[usize]) -> Result<(Vec<u8>, Vec<Option<f64>>)> {
let kind = v.get("type").and_then(Value::as_str).ok_or_else(|| bad(at, "view needs a type such as XYZ or Fit"))?;
let values = match v.get("values").filter(|x| !x.is_null()) {
None => Vec::new(),
Some(list) => {
let list = list.as_array().filter(|l| l.len() <= 4).ok_or_else(|| bad(at, "view values must be a list of at most 4 numbers or nulls"))?;
list.iter()
.map(|x| match x {
Value::Null => Ok(None),
x => x.as_f64().map(Some).ok_or_else(|| bad(at, "view values must be numbers or null")),
})
.collect::<Result<_>>()?
}
};
let arity = pdfcraft_engine::outline_view_arity(kind.as_bytes()).ok_or_else(|| bad(at, VIEW_TYPES))?;
if values.len() != arity {
return Err(bad(at, &format!("a {kind} view takes {arity} values (numbers, or null to keep the reader's)")));
}
if kind == "FitR" && values.iter().any(Option::is_none) {
return Err(bad(at, "a FitR view needs all four values: left, bottom, right, top"));
}
Ok((kind.as_bytes().to_vec(), values))
}
const VIEW_TYPES: &str = "view type must be XYZ (3 values: left, top, zoom), Fit, FitB (none), FitH, FitV, FitBH, FitBV (1) or FitR (4)";
fn bad(at: &[usize], what: &str) -> ToolError {
if at.is_empty() {
return ToolError::InvalidArgs(format!("bookmarks: {what}"));
}
ToolError::InvalidArgs(format!("bookmark {at:?}: {what}"))
}
+225 -39
View File
@@ -5,7 +5,9 @@
use std::collections::HashMap;
use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort};
use pdfcraft_engine::{
BorderShape, Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort,
};
use pdfcraft_render::{Annotation, PageInfo};
use serde_json::{Value, json};
@@ -75,7 +77,7 @@ fn to_user(p: &PageInfo, x: f64, y: f64) -> [f64; 2] {
[ux as f64, uy as f64]
}
fn rect_to_user(p: &PageInfo, r: [f64; 4]) -> [f64; 4] {
pub(crate) fn rect_to_user(p: &PageInfo, r: [f64; 4]) -> [f64; 4] {
let (a, b) = (to_user(p, r[0], r[1]), to_user(p, r[2], r[3]));
[a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]
}
@@ -177,7 +179,21 @@ impl Automation {
let doc = self.doc(a)?;
let only = a.opt_int("page")?.map(|p| (p.max(1) - 1) as usize);
let all = self.comments(a)?;
// A group (`/RT /Group`, ISO 32000-2 §12.5.6.2) lists as its primary, with the primary's
// attributes: the subordinates are neither comments nor replies, only its `group`.
let mut members: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new();
for (m, p) in all.iter().zip(pdfcraft_render::group_primaries(&all)) {
if let Some(p) = p.and_then(|p| all.get(p)) {
members.entry((p.page, p.index)).or_default().push(m);
}
}
let view = |c: &Annotation| -> Value {
let group: Vec<Value> = members
.get(&(c.page, c.index))
.into_iter()
.flatten()
.map(|m| json!({ "id": m.name, "page": m.page + 1, "index": m.index + 1, "type": m.subtype }))
.collect();
let mut v = json!({
"id": c.name,
"page": c.page + 1,
@@ -189,9 +205,31 @@ impl Automation {
"modified": c.modified,
"rect": crate::view_rect(&doc.info, c.page, c.shown_rect(doc.info.pages.get(c.page).map_or(0, |p| p.rotation))),
"color": c.color.map(hex),
"group": group,
// Unchanged on a page the viewer could not read, as `rect` is.
"popup": c.popup.map(|w| json!({ "rect": crate::view_rect(&doc.info, c.page, w.rect), "open": w.open })),
});
// A line's ends, or the corners of connected lines, a polygon or a cloud, as
// comment_edit's `points` takes them.
// A text box's or typed text's oval or circle (a rectangle is not reported).
if c.subtype == "FreeText"
&& let Some(shape) = doc.comment_props(c.page, c.index).and_then(|p| p.border_shape).filter(|s| *s != BorderShape::Rectangle)
{
v["border_shape"] = json!(shape.name().to_ascii_lowercase());
}
// How opaque it is drawn: as a whole, or its fill and text each at their own.
// Not for replies, which are drawn with the comment they answer.
if !c.is_reply()
&& let Some(p) = doc.comment_props(c.page, c.index)
{
v["opacity"] = json!(p.opacity);
if let Some(f) = p.fill_opacity {
v["fill_opacity"] = json!(f);
}
if let Some(t) = p.text_opacity {
v["text_opacity"] = json!(t);
}
}
// A line's ends, the corners of connected lines, a polygon or a cloud, or a callout's
// arrow point and knee, as comment_edit's `points` takes them.
// Unchanged on a page the viewer could not read, as `rect` is.
if !c.points.is_empty() {
let round = |v: f32| (v * 100.0).round() / 100.0;
@@ -199,28 +237,36 @@ impl Automation {
v["points"] =
json!(c.points.iter().map(|q| page.map_or([q[0], q[1]], |p| p.user_to_view(q[0], q[1])).map(round)).collect::<Vec<_>>());
}
// A callout's text box, as comment_edit's `rect` takes it (`rect` also holds the line).
if let Some(tb) = c.text_box {
v["text_box"] = json!(crate::view_rect(&doc.info, c.page, tb));
}
v
};
// Every reply under the comment its thread starts from, at any depth, in document order:
// a reply to a reply keeps its `/IRT` on the direct parent, so following one level would
// drop it. Parents are found by reference, as `/NM` need not be unique. A status or
// checkmark belongs to the annotation its `/IRT` names (ISO 32000-2 §12.5.6.2), as in the
// Comments panel: one set on a reply is not the comment's.
// drop it. Parents are found by reference, as `/NM` need not be unique. Status and
// checkmark replies are read through `review` instead. A group subordinate is in no
// thread, and a reply to one is the group's, its primary's.
let tops = pdfcraft_render::thread_tops(&all);
let mut threads: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new();
for r in &all {
for r in all.iter().filter(|r| r.state.is_none() && !r.is_group_member()) {
let Some(&top) = tops.get(&(r.page, r.index)) else { continue };
let key = match r.parent {
Some(parent) if r.state.is_some() => parent,
_ => top,
};
threads.entry(key).or_default().push(r);
threads.entry(top).or_default().push(r);
}
// An annotation's review status and checkmark: the latest of each set on it.
// An annotation's review states. A state reply's `/IRT` refers to the annotation whose
// state it sets, and a reviewer's later change replies to their previous one (ISO 32000-2
// §12.5.6.3), so a status on a reply is that reply's, as in the Comments panel. `status`
// is the latest set by anyone; `statuses` has each reviewer's latest (a state is "for the
// specified user"). Checkmarks are each reviewer's own too: `marked_by` names who has one
// on, and `marked` whether anyone has.
let review = pdfcraft_render::ReviewStates::new(&all);
let states = |c: &Annotation, v: &mut Value| {
let set = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]);
v["status"] = json!(set.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()));
v["marked"] = json!(set.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked")));
v["status"] = json!(review.latest_status(c));
v["statuses"] = json!(review.statuses(c).iter().map(|(who, state)| json!({ "author": who, "status": state })).collect::<Vec<_>>());
let marked_by = review.marked(c);
v["marked"] = json!(!marked_by.is_empty());
v["marked_by"] = json!(marked_by);
};
let roots: Vec<Value> = all
.iter()
@@ -232,7 +278,6 @@ impl Automation {
let thread = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]);
v["replies"] = thread
.iter()
.filter(|r| r.state.is_none())
.map(|r| {
let mut reply = view(r);
states(r, &mut reply);
@@ -253,6 +298,12 @@ impl Automation {
if endings.is_some() && !matches!(kind, "line" | "arrow" | "polyline" | "callout") {
return Err(ToolError::InvalidArgs("endings apply to a line, arrow, polyline or callout".into()));
}
// Shift while drawing: each end or corner held level or plumb with the one before.
let ortho = a.opt_bool("ortho")?.unwrap_or(false);
if ortho && !matches!(kind, "line" | "arrow" | "polygon" | "cloud" | "polyline") {
return Err(ToolError::InvalidArgs("ortho applies to a line, arrow, polygon, cloud or polyline".into()));
}
let held = |last: [f64; 2], p: [f64; 2]| if ortho { pdfcraft_engine::measure::snap::ortho(last, p) } else { p };
let markup = match kind {
"highlight" => Some(Markup::Highlight),
"underline" => Some(Markup::Underline),
@@ -325,7 +376,8 @@ impl Automation {
Shape::TextBox { rect: rect_to_user(&info, a.need::<4>("rect", "a text box")?), font_size }
}
"line" | "arrow" => {
let (f, t) = (a.need::<2>("from", "a line")?, a.need::<2>("to", "a line")?);
let f = a.need::<2>("from", "a line")?;
let t = held(f, a.need::<2>("to", "a line")?);
let [start, end] = ending_pair(kind, endings.as_deref())?;
Shape::Line { from: to_user(&info, f[0], f[1]), to: to_user(&info, t[0], t[1]), start, end }
}
@@ -352,10 +404,15 @@ impl Automation {
"polygon" | "cloud" | "polyline" => {
let wrong = || ToolError::InvalidArgs(format!("{kind} needs `points`: an array of [x, y] points"));
let pts = a.get("points").and_then(|p| p.as_array()).ok_or_else(wrong)?;
let mut last = None;
let vertices = pts
.iter()
.map(|p| match p.as_array().map(|p| p.iter().map(|v| v.as_f64()).collect::<Vec<_>>()).as_deref() {
Some([Some(x), Some(y)]) => Ok(to_user(&info, *x, *y)),
Some([Some(x), Some(y)]) => {
let p = last.map_or([*x, *y], |l| held(l, [*x, *y]));
last = Some(p);
Ok(to_user(&info, p[0], p[1]))
}
_ => Err(wrong()),
})
.collect::<Result<Vec<_>>>()?;
@@ -373,18 +430,8 @@ impl Automation {
let point = to_user(&info, t[0], t[1]);
let knee = match a.nums::<2>("knee")? {
Some(k) => to_user(&info, k[0], k[1]),
// Halfway between the point and the box, level with the box's middle.
None => {
let mid = (rect[1] + rect[3]) / 2.0;
let side = if point[0] < rect[0] {
rect[0]
} else if point[0] > rect[2] {
rect[2]
} else {
(rect[0] + rect[2]) / 2.0
};
[(point[0] + side) / 2.0, mid]
}
// Acrobat's: 12 pt out from the middle of the side facing the point.
None => pdfcraft_engine::callout_knee(rect, point),
};
Shape::Callout {
rect,
@@ -425,9 +472,28 @@ impl Automation {
if let Some(o) = a.opt_num("opacity")? {
style.opacity = o.clamp(0.0, 1.0);
}
// The fill's and a text box's text's own opacities (the line keeps `opacity`).
let (fill_opacity, text_opacity) = (a.opt_num("fill_opacity")?, a.opt_num("text_opacity")?);
let texts = matches!(shape, Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. });
if fill_opacity.is_some() && !(texts || matches!(shape, Shape::Rectangle { .. } | Shape::Oval { .. } | Shape::Polygon { .. })) {
return Err(ToolError::InvalidArgs(
"fill_opacity applies to a rectangle, oval, polygon, cloud, text box, typewriter text or callout".into(),
));
}
if text_opacity.is_some() && !texts {
return Err(ToolError::InvalidArgs("text_opacity applies to a text box, typewriter text or callout".into()));
}
style.fill_opacity = fill_opacity.map(|o| o.clamp(0.0, 1.0));
style.text_opacity = text_opacity.map(|o| o.clamp(0.0, 1.0));
if let Some(w) = a.opt_num("width")? {
style.width = w.max(0.0);
}
if let Some(b) = border_shape(a)? {
if !matches!(shape, Shape::TextBox { .. } | Shape::Typewriter { .. }) {
return Err(ToolError::InvalidArgs("border_shape is for a text box or typewriter text".into()));
}
style.border_shape = b;
}
let contents = a.opt_str("contents")?.unwrap_or_default().to_string();
let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
// Replace Text: the struck-out text and a grouped caret holding `contents`.
@@ -438,8 +504,12 @@ impl Automation {
if contents.trim().is_empty() {
return Err(ToolError::InvalidArgs("replace needs `contents`: the replacement text".into()));
}
// `/C` is a group attribute, taken from the primary (ISO 32000-2 §12.5.6.2), so the
// caret carries the colour too. Only the colour: opacity and the rest are the
// caret's own.
let mut caret = Style::default_for(&Shape::Caret { rect: [0.0; 4] });
caret.color = style.color;
let strike = style;
let caret = Style::default_for(&Shape::Caret { rect: [0.0; 4] });
let mut out = self.apply(a, Edit::ReplaceText { page, quads, text: contents, author, strike, caret })?;
out["comment"] = json!({ "page": page + 1, "type": "replace" });
return Ok(out);
@@ -488,6 +558,7 @@ impl Automation {
}
pub(crate) fn fill_sign_add(&mut self, a: &Args) -> Result<Value> {
let border_shape = border_shape(a)?;
use pdfcraft_engine::FillMark;
let page = self.page(a)?;
let info = self.doc(a)?.info.pages[page].clone();
@@ -515,6 +586,25 @@ impl Automation {
if !(4.0..=300.0).contains(&size) {
return Err(ToolError::InvalidArgs("font_size must be 4–300 pt".into()));
}
let requested_border = a.opt_num("border")?;
if requested_border.is_some() && !matches!(kind, "text" | "date") {
return Err(ToolError::InvalidArgs("border is only for type text or date".into()));
}
let border = requested_border.unwrap_or(0.0);
if !(0.0..=12.0).contains(&border) {
return Err(ToolError::InvalidArgs("border must be 0–12 pt".into()));
}
let border_color = a.opt_str("border_color")?.map(parse_color).transpose()?;
if border_color.is_some() && border <= 0.0 {
return Err(ToolError::InvalidArgs("border_color needs a border".into()));
}
if border_shape.is_some() && !matches!(kind, "text" | "date") {
return Err(ToolError::InvalidArgs("border_shape is only for type text or date".into()));
}
// Without a border an oval or a circle would grow the box around nothing drawn.
if border_shape.is_some_and(|s| s != BorderShape::Rectangle) && border <= 0.0 {
return Err(ToolError::InvalidArgs("border_shape needs a border".into()));
}
if let Some(path) = a.opt_str("path")? {
if !matches!(kind, "signature" | "initials") || a.opt_str("text")?.is_some() {
return Err(ToolError::InvalidArgs("path is only for an image signature or initials; pass either path or text".into()));
@@ -538,11 +628,16 @@ impl Automation {
let edit = image.edit(page, &info, at, kind == "initials", &author).ok_or_else(|| ToolError::InvalidArgs("at must be finite".into()))?;
return self.apply(a, edit);
}
// A circle goes around the text, so its box is no wider than the text needs.
let min_width = if border_shape == Some(BorderShape::Circle) { 0.0 } else { 20.0 };
let text_at = |t: &str| {
let w = (pdfcraft_engine::annot_text::text_width(t, size) + 8.0).clamp(20.0, 600.0);
let h = size * 1.2 + 6.0;
let rect = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]);
Shape::Typewriter { rect, font_size: size }
let longest = t.lines().map(|line| pdfcraft_engine::annot_text::text_width(line, size)).fold(0.0, f64::max);
let w = (longest + 8.0).clamp(min_width, 600.0);
let rows = pdfcraft_engine::annot_text::wrap(t, size, w - 4.0).len().max(1);
let h = rows as f64 * size * 1.2 + 6.0;
let [x0, y0, x1, y1] = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]);
// A border goes around the box, so the text sits where it would without one.
Shape::Typewriter { rect: [x0 - border, y0 - border, x1 + border, y1 + border], font_size: size }
};
let (shape, contents) = match kind {
"text" => {
@@ -588,6 +683,11 @@ impl Automation {
if let Some(color) = requested_color {
style.color = color;
}
if matches!(shape, Shape::Typewriter { .. }) {
style.width = border;
style.border_color = border_color;
style.border_shape = border_shape.unwrap_or_default();
}
self.apply(a, Edit::AddAnnotation(NewAnnotation { page, shape, style, contents, author }))
}
@@ -612,6 +712,17 @@ impl Automation {
self.apply(a, Edit::MarkAnnotation { page, index, marked, author })
}
pub(crate) fn comment_popup(&mut self, a: &Args) -> Result<Value> {
let (page, index) = self.comment_target(a)?;
let info = self.doc(a)?.info.pages[page].clone();
let open = a.opt_bool("open")?;
let rect = a.nums::<4>("rect")?.map(|r| rect_to_user(&info, r));
if open.is_none() && rect.is_none() {
return Err(ToolError::InvalidArgs("nothing to change: pass open and/or rect".into()));
}
self.apply(a, Edit::SetAnnotationPopup { page, index, rect, open })
}
pub(crate) fn comment_lock(&mut self, a: &Args) -> Result<Value> {
let (page, index) = self.comment_target(a)?;
let locked = a.opt_bool("locked")?.unwrap_or(true);
@@ -654,6 +765,21 @@ impl Automation {
let fill = if f.eq_ignore_ascii_case("none") { None } else { Some(parse_color(f)?) };
edits.push(Edit::FillAnnotation { page, index, fill });
}
if let Some(c) = a.opt_str("border_color")? {
let color = if c.eq_ignore_ascii_case("text") { None } else { Some(parse_color(c)?) };
edits.push(Edit::BorderColorAnnotation { page, index, color });
}
if let Some(shape) = border_shape(a)? {
edits.push(Edit::BorderShapeAnnotation { page, index, shape });
}
// After `opacity`, which sets the whole comment's.
let (fill_opacity, text_opacity) = (a.opt_num("fill_opacity")?, a.opt_num("text_opacity")?);
if fill_opacity.into_iter().chain(text_opacity).any(|o| !(0.0..=1.0).contains(&o)) {
return Err(ToolError::InvalidArgs("fill_opacity and text_opacity are 0–1".into()));
}
if fill_opacity.is_some() || text_opacity.is_some() {
edits.push(Edit::PartOpacity { page, index, fill: fill_opacity, text: text_opacity });
}
if let Some(size) = a.opt_num("font_size")? {
if !(1.0..=400.0).contains(&size) {
return Err(ToolError::InvalidArgs("font_size must be 1–400 pt".into()));
@@ -687,7 +813,7 @@ impl Automation {
}
if edits.is_empty() {
return Err(ToolError::InvalidArgs(
"nothing to change: pass contents, color, opacity, width, fill, font_size, rect, move or points".into(),
"nothing to change: pass contents, color, opacity, fill_opacity, text_opacity, width, fill, border_color, border_shape, font_size, rect, move or points".into(),
));
}
let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } };
@@ -698,6 +824,49 @@ impl Automation {
let (page, index) = self.comment_target(a)?;
self.apply(a, Edit::DeleteAnnotation { page, index })
}
/// The XFDF of the listed comments and their replies (`comments`: 1-based [page, index] pairs).
pub(crate) fn comments_copy(&self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
let all = self.comments(a)?;
let shape = || ToolError::InvalidArgs("comments: [[page, index], …] (1-based, as comment_list reports)".into());
let list = a.get("comments").and_then(Value::as_array).ok_or_else(shape)?;
let mut which = Vec::with_capacity(list.len());
for item in list {
let pair = item.as_array().filter(|p| p.len() == 2).and_then(|p| Some((p.first()?.as_u64()?, p.get(1)?.as_u64()?)));
let Some((page, index)) = pair.filter(|(p, i)| *p >= 1 && *i >= 1) else { return Err(shape()) };
let (page, index) = (usize::try_from(page - 1).map_err(|_| shape())?, usize::try_from(index - 1).map_err(|_| shape())?);
if !all.iter().any(|c| c.page == page && c.index == index) {
return Err(failed(format!("there is no comment {} on page {} (see comment_list)", index + 1, page + 1)));
}
which.push((page, index));
}
let xfdf = self.session.export_comments(id, &which).map_err(failed)?;
Ok(json!({ "xfdf": xfdf, "comments": which.len() }))
}
/// Add the comments of `xfdf` (from `comments_copy`) to `page` as new comments.
pub(crate) fn comments_paste(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let xfdf = a.str("xfdf")?.to_owned();
if !pdfcraft_engine::is_xfdf_comments(&xfdf) {
return Err(ToolError::InvalidArgs("xfdf: the text comments_copy returned (XFDF with <annots>)".into()));
}
let offset = match a.nums::<2>("at")? {
Some([x, y]) => {
let info = self.doc(a)?.info.pages.get(page).cloned().ok_or_else(|| failed(format!("no page {}", page + 1)))?;
let [cx, cy] = to_user(&info, x, y);
let b =
pdfcraft_engine::comments_bounds(&xfdf).ok_or_else(|| ToolError::InvalidArgs("xfdf: no comment with a rect to place".into()))?;
Some([cx - (b[0] + b[2]) / 2.0, cy - (b[1] + b[3]) / 2.0])
}
None => None,
};
let before = self.comments(a)?.len();
let mut out = self.apply(a, Edit::PasteComments { xfdf, page, offset })?;
out["comments"] = json!(self.comments(a)?.len().saturating_sub(before));
Ok(out)
}
}
impl Automation {
@@ -727,6 +896,7 @@ impl Automation {
let bytes = crate::read_input(&path)?;
let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let name = a.opt_str("name")?.map(str::to_owned).unwrap_or_else(|| file.rsplit_once('.').map_or(file.clone(), |(s, _)| s.to_owned()));
let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
let edit = Edit::AddCustomStamp {
page,
rect,
@@ -736,12 +906,28 @@ impl Automation {
bytes: std::sync::Arc::new(bytes),
page: a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1,
},
author: a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string(),
line: None,
author: author.clone(),
};
// A dynamic line (#1741): `{name}`, `{date}` and `{time}` filled in now.
let edit = match (edit, a.opt_str("line")?) {
(Edit::AddCustomStamp { page, rect, name, file, author, .. }, Some(t)) if !t.trim().is_empty() => {
let line = Some(self.session().stamp_line(t, &author));
Edit::AddCustomStamp { page, rect, name, file, author, line }
}
(edit, _) => edit,
};
self.apply(a, edit)
}
}
/// `border_shape`: `rectangle`, `oval` or `circle`.
fn border_shape(a: &Args) -> Result<Option<BorderShape>> {
a.opt_str("border_shape")?
.map(|s| BorderShape::parse(s).ok_or_else(|| ToolError::InvalidArgs(format!("unknown border_shape {s:?}: rectangle, oval or circle"))))
.transpose()
}
#[cfg(test)]
mod tests {
use super::*;
+101 -17
View File
@@ -2,7 +2,7 @@
//! move/resize/retype/restyle it, delete it. Geometry is in points from the top-left of the
//! displayed page (y down), like every other tool.
use pdfcraft_engine::{ADDED_CHAR_SPACING_LIMIT, AddedContent, AddedText, Edit, FontFamily, TextAlign};
use pdfcraft_engine::{ADDED_CHAR_SPACING_LIMIT, AddedContent, AddedText, Edit, FontFamily, TextAlign, TextInk};
use serde_json::{Value, json};
use crate::comments::parse_color;
@@ -21,7 +21,7 @@ fn to_view(r: [f64; 4], ph: f64) -> [f64; 4] {
[r[0], ph - r[3], r[2], ph - r[1]]
}
fn rect_arg(a: &Args, key: &str) -> Result<Option<[f64; 4]>> {
pub(crate) fn rect_arg(a: &Args, key: &str) -> Result<Option<[f64; 4]>> {
a.get(key)
.map(|v| {
let r: Vec<f64> = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
@@ -30,6 +30,27 @@ fn rect_arg(a: &Args, key: &str) -> Result<Option<[f64; 4]>> {
.transpose()
}
const INK_ARGS: &str = "give one of color (#RRGGBB or a name), gray (0 black to 1 white) and cmyk ([c, m, y, k], each 0 to 1)";
/// `gray` or `cmyk`: the text painted in DeviceGray or DeviceCMYK rather than RGB. At most one of
/// them and `color`.
fn ink_arg(a: &Args) -> Result<Option<TextInk>> {
if ["color", "gray", "cmyk"].iter().filter(|k| a.get(k).is_some()).count() > 1 {
return Err(bad(INK_ARGS));
}
let unit = |v: Option<f64>, key: &str| v.filter(|v| (0.0..=1.0).contains(v)).ok_or_else(|| bad(format!("{key} takes numbers from 0 to 1")));
if let Some(g) = a.get("gray") {
return Ok(Some(TextInk::Gray(unit(g.as_f64(), "gray")?)));
}
let Some(c) = a.get("cmyk") else { return Ok(None) };
let parts = c.as_array().filter(|p| p.len() == 4).ok_or_else(|| bad("cmyk must be [c, m, y, k]"))?;
let mut cmyk = [0.0; 4];
for (out, v) in cmyk.iter_mut().zip(parts) {
*out = unit(v.as_f64(), "cmyk")?;
}
Ok(Some(TextInk::Cmyk(cmyk)))
}
/// Apply the style arguments to `t`.
fn style(a: &Args, t: &mut AddedText) -> Result<()> {
if let Some(s) = a.opt_num("size")? {
@@ -49,8 +70,12 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> {
if let Some(i) = a.opt_bool("italic")? {
t.italic = i;
}
if let Some(c) = a.opt_str("color")? {
t.color = parse_color(c)?;
if let Some(ink) = ink_arg(a)? {
t.set_ink(Some(ink));
} else if let Some(c) = a.opt_str("color")? {
// An RGB colour paints in RGB, even one that is the ink's look.
t.set_ink(None);
t.set_color(parse_color(c)?);
}
if let Some(al) = a.opt_str("align")? {
t.align = parse_align(al)?;
@@ -124,6 +149,12 @@ impl Automation {
v["bold"] = json!(t.bold);
v["italic"] = json!(t.italic);
v["char_spacing"] = json!(t.char_spacing);
// Text painted in a print colour space says so; RGB text has neither.
match t.painted_ink() {
Some(TextInk::Gray(g)) => v["gray"] = json!(g),
Some(TextInk::Cmyk(c)) => v["cmyk"] = json!(c),
None => {}
}
}
AddedContent::Image(_) => v["type"] = json!("image"),
}
@@ -205,8 +236,8 @@ impl Automation {
if a.get("text").is_some() {
return Err(bad("images have no text"));
}
if ["font", "size", "bold", "italic", "color", "align", "aligns", "char_spacing"].iter().any(|k| a.get(k).is_some()) {
return Err(bad("font, size, bold, italic, color, align, aligns and char_spacing apply to text"));
if ["font", "size", "bold", "italic", "color", "gray", "cmyk", "align", "aligns", "char_spacing"].iter().any(|k| a.get(k).is_some()) {
return Err(bad("font, size, bold, italic, color, gray, cmyk, align, aligns and char_spacing apply to text"));
}
if let Some(deg) = a.opt_int("rotate")? {
if deg.rem_euclid(90) != 0 {
@@ -323,14 +354,32 @@ impl Automation {
Ok(json!({"page":page+1,"generation":doc.edit_generation(),"count":items.len(),"objects":items}))
}
pub(crate) fn object_move(&mut self, a: &Args) -> Result<Value> {
/// The references `objects` names (from object_list), or with `all` every object on `page`,
/// as ⌘A selects them in Edit a PDF; refused when `generation` is not the document's.
fn object_targets(&self, a: &Args, page: usize) -> Result<Vec<pdfcraft_engine::ObjectTarget>> {
use pdfcraft_engine::{MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget};
let page = self.page(a)?;
let doc = self.doc(a)?;
if let Some(g) = a.get("generation") {
let g = g.as_u64().ok_or_else(|| bad("generation must be a non-negative integer from object_list"))?;
if g != doc.edit_generation() {
return Err(failed("the document changed; call object_list again"));
}
}
if a.opt_bool("all")?.unwrap_or(false) {
if a.get("objects").is_some() {
return Err(bad("give objects or all, not both"));
}
let all: Vec<_> = doc.editable_objects(page).map_err(failed)?.into_iter().map(|o| o.target).collect();
if all.is_empty() || all.len() > MAX_MOVE_OBJECTS {
return Err(failed(format!("page {} has {} objects; select 1–1000", page + 1, all.len())));
}
return Ok(all);
}
let rows = a
.get("objects")
.and_then(Value::as_array)
.filter(|r| !r.is_empty() && r.len() <= MAX_MOVE_OBJECTS)
.ok_or_else(|| bad("objects must contain 1–1000 references from object_list"))?;
.ok_or_else(|| bad("objects must contain 1–1000 references from object_list (or pass all: true)"))?;
let mut targets = Vec::with_capacity(rows.len());
for row in rows {
let row = row.as_object().ok_or_else(|| bad("each object must contain kind and index"))?;
@@ -351,6 +400,12 @@ impl Automation {
.ok_or_else(|| bad("object index must be a positive integer from object_list"))?;
targets.push(ObjectTarget { kind, index });
}
Ok(targets)
}
pub(crate) fn object_move(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let targets = self.object_targets(a, page)?;
let delta = a
.get("offset")
.and_then(Value::as_array)
@@ -358,17 +413,46 @@ impl Automation {
.and_then(|v| Some([v.first()?.as_f64()?, v.get(1)?.as_f64()?]))
.filter(|v| v.iter().all(|n| n.is_finite() && n.abs() <= 1e9))
.ok_or_else(|| bad("offset must contain two finite numbers [dx, dy] in displayed points"))?;
let doc = self.doc(a)?;
if let Some(g) = a.get("generation") {
let g = g.as_u64().ok_or_else(|| bad("generation must be a non-negative integer from object_list"))?;
if g != doc.edit_generation() {
return Err(failed("the document changed; call object_list again before moving"));
}
}
let offset = doc.object_move_offset(page, delta).map_err(failed)?;
let offset = self.doc(a)?.object_move_offset(page, delta).map_err(failed)?;
let count = targets.len();
let mut result = self.apply(a, Edit::MoveObjects { page, objects: targets, offset })?;
result["moved"] = json!(count);
Ok(result)
}
/// Edit ▸ Copy (or with `cut`, Cut) of objects selected together, kept for object_paste into
/// any open document. A cut then deletes them as one undo step.
pub(crate) fn object_copy(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let targets = self.object_targets(a, page)?;
let cut = a.opt_bool("cut")?.unwrap_or(false);
let doc = self.doc(a)?;
if cut && !doc.allows_modification() {
return Err(failed(pdfcraft_engine::EditError::NotPermitted("changes")));
}
let clip = doc.copy_objects(page, &targets).map_err(failed)?;
let out = json!({ "copied": clip.objects().len(), "text": clip.text() });
self.objects_clipboard = Some(pdfcraft_engine::ObjectClipboard::new(clip));
if !cut {
return Ok(out);
}
let delete = Edit::delete_objects(page, targets).ok_or_else(|| failed("nothing to cut"))?;
let mut result = self.apply(a, delete)?;
result["copied"] = out["copied"].clone();
result["text"] = out["text"].clone();
Ok(result)
}
/// Edit ▸ Paste of the objects object_copy copied: onto `page`, where they were, or on the
/// page they came from (and on each further paste onto a page) 18 points down and right.
pub(crate) fn object_paste(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let mut clipboard = self.objects_clipboard.take().ok_or_else(|| failed("copy objects first (object_copy)"))?;
let count = clipboard.clip().objects().len();
let edit = self.doc(a).and_then(|doc| clipboard.paste(doc, page).map_err(failed));
self.objects_clipboard = Some(clipboard);
let mut result = self.apply(a, edit?)?;
result["pasted"] = json!(count);
Ok(result)
}
}
+102 -6
View File
@@ -53,6 +53,7 @@ impl Automation {
tools::check_args(def, args)?;
let a = Args(args);
let out = match name {
"doc_bates_multi" => self.doc_bates_multi(&a)?,
"doc_open" => self.doc_open(&a)?,
"doc_batch" => self.doc_batch(&a)?,
"doc_list" => json!({ "documents": self.session.docs().iter().map(summary).collect::<Vec<_>>() }),
@@ -68,10 +69,12 @@ impl Automation {
self.apply(&a, edit)?
}
"page_render" => return self.page_render(&a),
"model_render" => return self.model_render(&a),
"comment_image_preview" => return self.comment_image_preview(&a),
"text_extract" => self.text_extract(&a)?,
"text_word_count" => self.text_word_count(&a)?,
"text_find" => self.text_find(&a)?,
"text_replace" => self.text_replace(&a)?,
"page_rotate" => {
let degrees = a.int("degrees")?;
if degrees % 90 != 0 {
@@ -135,6 +138,7 @@ impl Automation {
out["detected"] = json!(given.is_none());
out
}
"page_enhance_scan" => self.page_enhance_scan(&a)?,
"page_delete" => {
let pages = self.pages(&a, "pages")?;
self.apply(&a, Edit::DeletePages { pages })?
@@ -195,6 +199,9 @@ impl Automation {
out
}
"bookmark_list" => self.bookmark_listing(&a)?,
"bookmark_export" => self.bookmark_export(&a)?,
"bookmark_import" => self.bookmark_import(&a)?,
"bookmark_copy" => self.bookmark_copy(&a)?,
"bookmark_add" => {
let page = self.page(&a)?;
let parent = a.opt_path("parent")?.unwrap_or_default();
@@ -461,6 +468,7 @@ impl Automation {
"bring_forward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringForward),
"send_backward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendBackward),
"remove_crop" => ImageEdit::RemoveCrop,
"ungroup" => ImageEdit::Ungroup,
"replace" => {
let path = self.resolve(a.str("path")?, false)?;
let bytes = read_input(&path)?;
@@ -474,6 +482,10 @@ impl Automation {
self.apply(&a, Edit::EditPageImage { page, index, change })?
}
}
"text_prepare" => {
let page = self.page(&a)?;
self.prepare_text(&a, page)?
}
"text_paragraphs" => {
let page = self.page(&a)?;
let doc = self.doc_to_copy(&a)?;
@@ -689,7 +701,7 @@ impl Automation {
summary(self.session.get(new).ok_or_else(|| failed("the document vanished"))?)
}
"doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?,
"doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?,
"doc_header_footer" | "doc_watermark" | "doc_background" | "doc_printer_marks" | "doc_remove_marks" => self.marks(name, &a)?,
"doc_backdrops" => self.backdrops(&a)?,
"doc_unprotect" => {
let mut out = self.apply(&a, Edit::RemoveProtection)?;
@@ -698,11 +710,8 @@ impl Automation {
}
"form_fields" => self.form_fields(&a)?,
"form_fill" => self.form_fill(&a)?,
"form_set_image" => {
let path = self.resolve(a.str("path")?, false)?;
let bytes = read_input(&path)?;
self.apply(&a, Edit::SetFieldImage { name: a.str("field")?.to_owned(), image: Arc::new(bytes) })?
}
"form_focus" => self.form_focus(&a)?,
"form_set_image" => self.form_set_image(&a)?,
"form_reset" => self.form_reset(&a)?,
"form_add_field" => self.form_add_field(&a)?,
"form_add_field_table" => self.form_add_field_table(&a)?,
@@ -718,6 +727,7 @@ impl Automation {
"doc_hidden_info" => self.doc_hidden_info(&a)?,
"printers" => self.printers()?,
"printer_options" => self.printer_options(&a)?,
"printer_papers" => self.printer_papers(&a)?,
"link_list" => self.link_list(&a)?,
"link_open" => self.link_open(&a)?,
"link_add" => self.link_add(&a)?,
@@ -727,6 +737,8 @@ impl Automation {
"links_remove" => self.links_remove(&a)?,
"object_list" => self.object_list(&a)?,
"object_move" => self.object_move(&a)?,
"object_copy" => self.object_copy(&a)?,
"object_paste" => self.object_paste(&a)?,
"content_list" => self.content_list(&a)?,
"page_add_text" => self.page_add_text(&a)?,
"page_add_image" => self.page_add_image(&a)?,
@@ -734,6 +746,7 @@ impl Automation {
"content_delete" => self.content_delete(&a)?,
"content_copy" => self.content_copy(&a)?,
"doc_print" => self.doc_print(&a)?,
"doc_fix_hairlines" => self.fix_hairlines(&a)?,
"attachment_add" => {
let path = self.resolve(a.str("path")?, false)?;
let bytes = read_input(&path)?;
@@ -753,6 +766,49 @@ impl Automation {
let edit = Edit::DeleteAttachment { key: a.str("name")?.into() };
self.apply(&a, edit)?
}
"doc_convert_colours" => self.convert_colours(&a)?,
"attachment_edit" => {
let key = a.opt_str("key")?;
let name = a.opt_str("name")?;
if key.is_some() == name.is_some() {
return Err(ToolError::InvalidArgs("provide exactly one of key or name".into()));
}
let keys: Vec<String> = self
.doc(&a)?
.info
.attachments
.iter()
.filter_map(|attachment| match &attachment.source {
pdfcraft_render::AttachmentSource::Document { key: attachment_key }
if key.is_some_and(|key| key == attachment_key) || name.is_some_and(|name| name == attachment.name) =>
{
Some(attachment_key.clone())
}
pdfcraft_render::AttachmentSource::Document { .. } => None,
pdfcraft_render::AttachmentSource::Annotation { .. } => None,
})
.collect();
let key = match keys.as_slice() {
[key] => key.clone(),
[] if key.is_some() => {
return Err(ToolError::InvalidArgs(format!("there is no document attachment with key {:?}", key.unwrap_or_default())));
}
[] => {
return Err(ToolError::InvalidArgs(format!(
"there is no document attachment named {:?}; use a name from doc_info",
name.unwrap_or_default()
)));
}
_ => {
return Err(ToolError::InvalidArgs(format!(
"more than one document attachment is named {:?}; provide its key from doc_info",
name.unwrap_or_default()
)));
}
};
let edit = Edit::EditAttachmentDescription { key, description: Some(a.str("description")?.into()) };
self.apply(&a, edit)?
}
"doc_remove_hidden" => self.doc_remove_hidden(&a)?,
"doc_audit_redactions" => self.doc_audit_redactions(&a)?,
"fill_sign_add" => self.fill_sign_add(&a)?,
@@ -768,6 +824,7 @@ impl Automation {
"comment_list" => self.comment_list(&a)?,
"comment_add" => self.comment_add(&a)?,
"stamp_custom" => self.stamp_custom(&a)?,
"model_add" => self.model_add(&a)?,
"comment_reply" => self.comment_reply(&a)?,
"comment_set_status" => self.comment_set_status(&a)?,
"sign_list" => self.sign_list(&a)?,
@@ -805,6 +862,9 @@ impl Automation {
"form_set_actions" => self.form_set_actions(&a)?,
"ocr_status" => self.ocr_status()?,
"ocr_recognize_files" => self.ocr_recognize_files(&a)?,
"ocr_words" => self.ocr_words(&a)?,
"ocr_review_apply" => self.ocr_review_apply(&a)?,
"ocr_dictionary" => self.ocr_dictionary(&a)?,
"accessibility_report" => self.a11y_report(&a)?,
"accessibility_review_record" => self.a11y_review_record(&a)?,
"accessibility_review_save" => self.a11y_review_save(&a)?,
@@ -817,6 +877,7 @@ impl Automation {
"accessibility_tag_content" => self.accessibility_tag_content(&a)?,
"accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?,
"accessibility_mark_tag_artifact" => self.accessibility_mark_tag_artifact(&a)?,
"accessibility_layout_table_to_blocks" => self.accessibility_layout_table_to_blocks(&a)?,
"accessibility_move_tag" => self.accessibility_move_tag(&a)?,
"sign_id_create" => self.sign_id_create(&a)?,
"sign_document" => self.sign_document(&a)?,
@@ -854,12 +915,15 @@ impl Automation {
"sign_trust" => self.sign_trust(&a)?,
"comment_mark" => self.comment_mark(&a)?,
"comment_lock" => self.comment_lock(&a)?,
"comment_popup" => self.comment_popup(&a)?,
"comments_hide" => self.comments_hide(&a)?,
"layer_set" => self.layer_set(&a)?,
"view_reading_colors" => self.view_reading_colors(&a)?,
"comments_summarize" => self.comments_summarize(&a)?,
"comment_edit" => self.comment_edit(&a)?,
"comment_delete" => self.comment_delete(&a)?,
"comments_copy" => self.comments_copy(&a)?,
"comments_paste" => self.comments_paste(&a)?,
other => return Err(ToolError::UnknownTool(other.into())),
};
Ok(vec![Content::Json(out)])
@@ -913,3 +977,35 @@ impl Automation {
Ok(out)
}
}
impl Automation {
/// `page_enhance_scan`: Enhance ▸ Scanned document on the chosen pages (all by default).
fn page_enhance_scan(&mut self, a: &Args) -> Result<Value> {
let pages = match a.opt_ints("pages")? {
Some(_) => self.pages(a, "pages")?,
None => Vec::new(),
};
let level = match a.opt_int("level")? {
None => pdfcraft_engine::scan_clean::ScanCleanSettings::default().level,
Some(l @ 0..=100) => l as u8,
Some(l) => return Err(ToolError::InvalidArgs(format!("level must be between 0 and 100, not {l}"))),
};
let mut settings = pdfcraft_engine::scan_clean::ScanCleanSettings::at(level);
if let Some(on) = a.opt_bool("edge_shadows")? {
settings.edge_shadows = on;
}
if let Some(on) = a.opt_bool("despeckle")? {
settings.despeckle = on;
}
let id = self.doc(a)?.id;
let report = self.session.enhance_scanned_pages(id, &pages, settings).map_err(failed)?;
let numbered = |p: &[usize]| p.iter().map(|p| p + 1).collect::<Vec<_>>();
let skipped: Vec<Value> = report.skipped.iter().map(|k| json!({ "pages": numbered(&k.pages), "name": k.name, "reason": k.reason })).collect();
let mut out = summary(self.doc(a)?);
out["pages_cleaned"] = json!(numbered(&report.pages));
out["images"] = json!(report.images);
out["not_scanned"] = json!(numbered(&report.not_scanned));
out["skipped"] = json!(skipped);
Ok(out)
}
}
+133 -12
View File
@@ -1,11 +1,15 @@
use super::error::{Result, ToolError, failed};
use super::json::{Args, nest_rows, security, summary};
use super::paths::{child, read_input, write_atomic};
use super::{Arc, Automation, BlockKey, DocId, Document, Edit, Path, PathBuf, TextBlock, Value, comments, file_access, json};
use super::{Arc, Automation, BlockKey, DocId, Document, Edit, Path, TextBlock, Value, comments, json};
/// How far a watermark may be moved from the page centre, in points (a page is at most 14,400).
const WATERMARK_MAX_OFFSET: f64 = 20_000.0;
/// `doc_optimize` with `target_bytes`: (max target ppi, max JPEG quality) per extra attempt, mildest first.
/// The quality applies only to images the caller left on JPEG; zip and retain keep their encoding.
const TARGET_LADDER: [(f64, u8); 5] = [(110.0, 45), (96.0, 35), (72.0, 28), (54.0, 20), (36.0, 12)];
/// Bookmarks in one page of the bookmark list, by default and at most.
const BOOKMARK_PAGE: usize = 100;
@@ -100,12 +104,27 @@ impl Automation {
}
pub(crate) fn doc_open(&mut self, a: &Args) -> Result<Value> {
let path = self.resolve(a.str("path")?, false)?;
let requested = a.str("path")?;
// `manual.pdf#nameddest=Intro`, as a help system starts a PDF reader (#3425): the file under
// --root, and where it opens. A path that names a file is never split.
let parameters = pdfcraft_engine::open_parameters::split(requested, |p| self.resolve(p, false).is_ok_and(|p| p.is_file()));
let (requested, destination) = match &parameters {
Some((file, destination)) => (file.as_str(), Some(destination.clone())),
None => (requested, None),
};
let path = self.resolve(requested, false)?;
let bytes = read_input(&path)?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?;
let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?;
Ok(summary(doc))
let mut value = summary(doc);
if let Some(destination) = destination {
let (applied, destination, warning) = crate::links::destination_report(doc, destination);
value["destination_applied"] = json!(applied);
value["destination"] = destination;
value["warning"] = json!(warning);
}
Ok(value)
}
pub(crate) fn doc_close(&mut self, a: &Args) -> Result<Value> {
@@ -120,6 +139,7 @@ impl Automation {
self.texts.remove(&id);
self.listed.retain(|(d, _), _| *d != id);
self.evicted.remove(&id);
self.ocr_reviews.remove(&id);
Ok(json!({ "closed": id.0 }))
}
@@ -130,18 +150,18 @@ impl Automation {
pub(crate) fn doc_save_with(&mut self, a: &Args, write: impl FnOnce(&Path, &[u8]) -> Result<()>) -> Result<Value> {
let doc = self.doc(a)?;
let id = doc.id;
let target = match a.opt_str("path")? {
let explicit = a.opt_str("path")?;
let target = match explicit {
Some(p) => self.resolve(p, true)?,
None => {
// Saving in place writes where the file was opened from, which the read root
// allows and the write root may not.
// allows and the write root may not. It also goes through --root again: a
// folder on the remembered path may have become a link out since (#1791).
let own = doc.path.clone().ok_or_else(|| failed("the document has never been saved: pass a path"))?;
let own_path = PathBuf::from(&own);
self.check_roots(&own, &own_path, file_access::Access::Write)?;
own_path
self.resolve(&own, true)?
}
};
let same_file = doc.path.as_deref().is_some_and(|p| Path::new(p) == target);
let same_file = explicit.is_none() || doc.path.as_deref().is_some_and(|p| Path::new(p) == target);
// Saving to a new file is a full rewrite unless asked otherwise, like Save As.
let full = a.opt_bool("full")?.unwrap_or(!same_file);
let flatten_fill_sign = a.opt_bool("flatten_fill_sign")?.unwrap_or(false);
@@ -355,12 +375,15 @@ impl Automation {
("discard_thumbnails", &mut settings.discard_thumbnails),
("discard_alternate_images", &mut settings.discard_alternate_images),
("discard_tags", &mut settings.discard_tags),
("discard_search_index", &mut settings.discard_search_index),
("discard_print_settings", &mut settings.discard_print_settings),
("discard_form_actions", &mut settings.discard_form_actions),
("flate_unencoded", &mut settings.flate_unencoded),
("object_streams", &mut settings.object_streams),
("lzw_to_flate", &mut settings.lzw_to_flate),
("remove_invalid_links", &mut settings.remove_invalid_links),
("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests),
("discard_external_refs", &mut settings.discard_external_refs),
] {
if let Some(b) = a.opt_bool(key)? {
*flag = b;
@@ -379,7 +402,45 @@ impl Automation {
})
.collect::<Result<_>>()?,
};
let (bytes, r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?;
let target = match a.opt_int("target_bytes")? {
None => None,
Some(t) if t >= 1 => Some(usize::try_from(t).map_err(|_| ToolError::InvalidArgs(format!("target_bytes {t} is too large")))?),
Some(t) => return Err(ToolError::InvalidArgs(format!("target_bytes must be at least 1, not {t}"))),
};
let (mut bytes, mut r) = self.session.optimized_bytes(id, &settings, &discard).map_err(failed)?;
let mut attempts = 1;
let mut step_used = 0;
// Settings already optimized with; a rung that changes nothing is not run again.
let mut tried = vec![settings.clone()];
if let Some(t) = target {
// Stronger image settings, one rung per attempt, until the copy fits. Every attempt
// starts from the open document, so a failed one changes nothing; the number of
// attempts is fixed. The smallest copy is kept if none fits.
for (k, &(ppi, quality)) in TARGET_LADDER.iter().enumerate() {
if bytes.len() <= t {
break;
}
let mut s = settings.clone();
for img in [&mut s.color, &mut s.gray] {
img.downsample = true;
img.target_ppi = img.target_ppi.min(ppi);
img.above_ppi = img.above_ppi.min(ppi * 1.5).max(img.target_ppi);
// Only a caller who chose JPEG gets a lower quality; zip and retain stay as asked.
if let Compression::Jpeg(q) = img.compression {
img.compression = Compression::Jpeg(q.min(quality));
}
}
if tried.contains(&s) {
continue;
}
let (b2, r2) = self.session.optimized_bytes(id, &s, &discard).map_err(failed)?;
tried.push(s);
attempts += 1;
if b2.len() < bytes.len() {
(bytes, r, step_used) = (b2, r2, k + 1);
}
}
}
write_atomic(&path, &bytes)?;
fn valid_version(v: &[u8]) -> Option<&str> {
if matches!(v, [b'1'..=b'9', b'.', b'0'..=b'9']) { std::str::from_utf8(v).ok() } else { None }
@@ -399,7 +460,19 @@ impl Automation {
// entry). Both are "d.d", so text order is version order.
let pdf_version = catalog_version.as_deref().into_iter().chain(header_version).max();
let o = &r.optimize;
Ok(json!({
let mut extra = json!({});
if let Some(t) = target {
let (ppi, quality) = step_used.checked_sub(1).and_then(|k| TARGET_LADDER.get(k)).copied().unzip();
let lossy = matches!(settings.color.compression, Compression::Jpeg(_)) || matches!(settings.gray.compression, Compression::Jpeg(_));
let quality = quality.filter(|_| lossy);
extra = json!({
"target_bytes": t,
"target_reached": bytes.len() <= t,
"attempts": attempts,
"target_settings": { "step": step_used, "max_ppi": ppi, "max_jpeg_quality": quality },
});
}
let mut out = json!({
"path": path.to_string_lossy(),
"pdf_version": pdf_version,
"object_streams": settings.effective_object_streams(),
@@ -415,16 +488,25 @@ impl Automation {
"image_bytes_after": o.image_bytes_after,
"thumbnails": o.thumbnails,
"alternate_images": o.alternate_images,
"search_indexes": o.search_indexes,
"tags_removed": o.tags_removed,
"print_settings": o.print_settings,
"form_actions": o.form_actions,
"streams_compressed": o.streams_compressed,
"lzw_streams": o.lzw_streams,
"invalid_links": o.invalid_links,
"invalid_bookmarks": o.invalid_bookmarks,
"unreferenced_dests": o.unreferenced_dests,
"external_links": o.external_links,
"external_bookmarks": o.external_bookmarks,
"unused_xobjects": o.unused_xobjects,
"merged_objects": r.merged,
"discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::<Vec<_>>(),
}))
});
if let (Some(o), Some(e)) = (out.as_object_mut(), extra.as_object()) {
o.extend(e.iter().map(|(k, v)| (k.clone(), v.clone())));
}
Ok(out)
}
/// A watermark or background picture: `file` (an image or a PDF) and `file_page` (1-based).
@@ -443,6 +525,23 @@ impl Automation {
pub(crate) fn apply(&mut self, a: &Args, edit: Edit) -> Result<Value> {
let id = self.doc(a)?.id;
self.session.apply(id, edit).map_err(failed)?;
self.applied(a)
}
/// `text_prepare`: make a page's grouped text editable, with what that edit itself prepared,
/// repaired and left grouped.
pub(crate) fn prepare_text(&mut self, a: &Args, page: usize) -> Result<Value> {
let id = self.doc(a)?.id;
let prepared = self.session.prepare_text(id, page).map_err(failed)?;
let mut out = self.applied(a)?;
out["prepared_groups"] = json!(prepared.expanded);
out["repaired_fonts"] = json!(prepared.repaired_fonts);
out["refused_groups"] = json!(prepared.refused);
Ok(out)
}
/// The document's summary after an edit, with what it stored as typed but drew with `?`.
fn applied(&mut self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let mut out = summary(doc);
// Text the edit stored as typed but drew with `?` (its font can't draw it): said, not silent.
@@ -547,11 +646,33 @@ impl Automation {
replace,
file: self.mark_file(a)?,
},
"doc_printer_marks" => {
use pdfcraft_engine::PrinterMarks;
let flag = |k: &str, default: bool| -> Result<bool> { Ok(a.opt_bool(k)?.unwrap_or(default)) };
let (y, m, d) = self.session.today();
let settings = PrinterMarks {
trim: flag("trim", true)?,
bleed: flag("bleed", true)?,
registration: flag("registration", true)?,
color_bars: flag("color_bars", true)?,
page_info: flag("page_info", true)?,
line_weight: a.opt_num("line_weight")?.unwrap_or(0.25),
make_room: match a.opt_num("make_room")? {
Some(r) if r < 0.0 => None,
Some(r) => Some(r),
None => Some(36.0),
},
label: a.opt_str("label")?.map(str::to_string).unwrap_or_else(|| self.doc(a).map(|d| d.name.clone()).unwrap_or_default()),
date: a.opt_str("date")?.map(str::to_string).unwrap_or_else(|| format!("{y:04}-{m:02}-{d:02}")),
};
Edit::AddPrinterMarks { pages, settings, replace: a.opt_bool("replace")?.unwrap_or(true) }
}
_ => {
let kind = match a.str("kind")? {
"header_footer" => MarkKind::HeaderFooter,
"watermark" => MarkKind::Watermark,
"background" => MarkKind::Background,
"printer_marks" => MarkKind::PrinterMarks,
other => return Err(ToolError::InvalidArgs(format!("unknown kind {other:?}"))),
};
Edit::RemoveMarks { kind }
+180 -54
View File
@@ -5,22 +5,24 @@
//! holds the two folders. Every path a tool reads is checked with [`Access::Read`] and every path
//! it writes with [`Access::Write`], in `Automation::resolve`, which all of them go through:
//!
//! - The path is made absolute against the working directory, as the file system reads it. Its
//! links are followed: for a file that doesn't exist yet, the links of its nearest existing
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
//! is a link that leads nowhere).
//! - The path is made absolute against the working directory, then followed the way the file
//! system reads it, one name at a time from the top, its links followed. A name is looked up
//! only while the folder it is in is the root, inside it, or one of the root's own parent
//! folders: a path that leaves those is refused before anything beyond is touched (looking up
//! `/net/<host>` mounts it), and every such refusal reads the same, so it can't tell what exists
//! out there. Inside the root, a name that doesn't exist yet ends the walk and the rest is taken
//! as plain names (`..` below a missing folder is refused, and so is a link that leads nowhere).
//! - A path on another network share or in a device namespace (`\\host\share`, `\\.\pipe\x`)
//! than the root's is refused by name, before anything is resolved: resolving it would contact
//! the host.
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
//! `/work`), without regard to case on Windows.
//! - The result must lie inside the root, compared by whole canonical components (`/work2` isn't
//! inside `/work`), with the drive letter compared without regard to case on Windows.
//! - Read and write access are separate: a root given alone grants only its own kind of access.
//!
//! Without roots nothing is checked. A path is checked, then opened: a process that can change the
//! folders in between (swapping in a link) can still win that race. The roots keep an agent to the
//! files it was given; they don't guard against other programs on the computer.
use std::ffi::OsString;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
@@ -100,11 +102,10 @@ impl AutomationRoots {
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {shown}")),
};
};
let absolute = absolute(shown, target)?;
// Another network share or a device is refused by name, as `--root` refuses it: even
// resolving the path would contact the host (#4003).
if !foreign_share(&absolute, folder) && inside(&resolve(shown, &absolute)?, folder) {
return Ok(());
match follow(shown, &absolute(shown, target)?, folder) {
Toward::Inside => return Ok(()),
Toward::Refused(why) => return Err(why),
Toward::Outside => {}
}
let root = root_shown.display();
match access {
@@ -141,60 +142,85 @@ fn absolute(shown: &str, path: &Path) -> Result<PathBuf, String> {
std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))
}
/// `absolute` with its links followed; for a path that doesn't exist (yet), its nearest existing
/// folder's links followed and the rest appended.
fn resolve(shown: &str, absolute: &Path) -> Result<PathBuf, String> {
// The names below the nearest existing folder, last first.
let mut missing: Vec<OsString> = vec![];
let mut at = absolute;
loop {
match std::fs::canonicalize(at) {
Ok(mut found) => {
found.extend(missing.iter().rev());
return Ok(found);
/// Where a followed path ends up.
pub(crate) enum Toward {
/// Inside the root, with its links followed (a missing tail taken as names).
Inside,
/// Anywhere else. Said the same way whatever is out there.
Outside,
/// Inside the root, but it can't be followed, and why.
Refused(String),
}
/// Follow the absolute `path` the way the file system reads it, one name at a time from the top,
/// looking a name up only while the folder it is in is `root`, inside it, or one of `root`'s own
/// parent folders (all with their links followed already, so `..` means what it means to the file
/// system).
pub(crate) fn follow(shown: &str, path: &Path, root: &Path) -> Toward {
// Another network share or a device is refused by name, as `--root` refuses it: even looking
// it up would contact the host (#4003).
if foreign_share(path, root) {
return Toward::Outside;
}
let near = |at: &Path| inside(at, root) || inside(root, at);
let mut parts = path.components().peekable();
let top: PathBuf = std::iter::from_fn(|| parts.next_if(|c| matches!(c, Component::Prefix(_) | Component::RootDir))).collect();
let mut at = match std::fs::canonicalize(&top) {
Ok(at) if near(&at) => at,
_ => return Toward::Outside,
};
while let Some(part) = parts.next() {
let name = match part {
Component::Normal(name) => name,
Component::CurDir => continue,
Component::ParentDir => {
at.pop();
continue;
}
Err(e) => match std::fs::symlink_metadata(at) {
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {shown}")),
// There, yet its real path can't be had (no permission): nothing to compare.
Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
},
}
let parent = at.parent();
match (at.components().next_back(), parent) {
(Some(Component::Normal(name)), Some(parent)) => {
missing.push(name.to_os_string());
at = parent;
}
(Some(Component::ParentDir), _) => {
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
}
// Down to the root (or drive) and it doesn't exist either.
_ => {
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
return Err(format!("automation path rejected: cannot resolve {shown}: {e}"));
Component::Prefix(_) | Component::RootDir => return Toward::Outside,
};
let next = at.join(name);
match std::fs::canonicalize(&next) {
Ok(found) if near(&found) => at = found,
Ok(_) => return Toward::Outside,
// Not even there yet: only inside the root does it matter why.
Err(_) if !inside(&at, root) => return Toward::Outside,
Err(e) => {
return match std::fs::symlink_metadata(&next) {
Ok(m) if m.file_type().is_symlink() => Toward::Refused(format!("automation path rejected: a link that leads nowhere: {shown}")),
// There, yet its real path can't be had (no permission): nothing to compare.
Ok(_) => Toward::Refused(format!("automation path rejected: cannot resolve {shown}: {e}")),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if parts.any(|part| !matches!(part, Component::Normal(_) | Component::CurDir)) {
return Toward::Refused(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
}
Toward::Inside
}
Err(e) => Toward::Refused(format!("automation path rejected: cannot resolve {shown}: {e}")),
};
}
}
}
if inside(&at, root) { Toward::Inside } else { Toward::Outside }
}
/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on
/// Windows, whose file systems ignore it)?
/// Is `path` (resolved) `root` or inside it, component by component (the drive letter without
/// regard to case on Windows)?
fn inside(path: &Path, root: &Path) -> bool {
let mut path = path.components();
root.components().all(|r| path.next().is_some_and(|p| same_component(p, r)))
}
fn same_component(a: Component, b: Component) -> bool {
use std::path::Prefix;
if a == b {
return true;
}
if !cfg!(windows) {
return false;
}
match (a.as_os_str().to_str(), b.as_os_str().to_str()) {
(Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(),
match (a, b) {
(Component::Prefix(a), Component::Prefix(b)) => match (a.kind(), b.kind()) {
(Prefix::Disk(a) | Prefix::VerbatimDisk(a), Prefix::Disk(b) | Prefix::VerbatimDisk(b)) => a.eq_ignore_ascii_case(&b),
_ => false,
},
_ => false,
}
}
@@ -306,6 +332,62 @@ mod tests {
let _ = std::fs::remove_dir_all(base);
}
/// Outside the root, the refusal comes before anything out there is looked up, so it can't
/// tell whether a folder out there exists.
#[test]
fn paths_out_of_the_root_are_refused_alike_without_looking() {
let (base, roots) = folders("follow");
let sep = std::path::MAIN_SEPARATOR;
let root = s(base.join("in"));
let refusal = |p: &str| roots.check(p, Access::Read).unwrap_err().replace(p, "<path>");
// Through a folder outside that exists, and through one that doesn't.
let there = format!("{root}{sep}..{sep}outside{sep}..{sep}x.pdf");
let missing = format!("{root}{sep}..{sep}nowhere{sep}..{sep}x.pdf");
assert_eq!(refusal(&there), refusal(&missing));
assert!(refusal(&missing).starts_with("automation path rejected: outside the read root"), "{}", refusal(&missing));
// Out through a missing folder and back in. (Windows folds `..` by name when it makes a path
// absolute, as it does when it opens one: that path is `in\a.pdf` there.)
#[cfg(not(windows))]
assert!(refusal(&format!("{root}/../nowhere/../in/a.pdf")).contains("outside the read root"));
// A `\\?\` path is taken as written, `..` and all, so there the walk itself refuses it.
#[cfg(windows)]
{
let verbatim = |rest: &str| format!(r"\\?\{root}\{rest}");
assert_eq!(refusal(&verbatim(r"..\outside\..\x.pdf")), refusal(&verbatim(r"..\nowhere\..\x.pdf")));
assert!(
refusal(&verbatim(r"..\nowhere\x.pdf")).starts_with("automation path rejected: outside the read root"),
"{}",
refusal(&verbatim(r"..\nowhere\x.pdf"))
);
}
let _ = std::fs::remove_dir_all(base);
}
/// The root can be named as it was given or as it resolves (`/var/…` and `/private/var/…` on
/// macOS, where the temporary folder is reached through a link).
#[test]
fn a_root_may_be_named_as_given_or_as_resolved() {
let (base, roots) = folders("given");
let resolved = std::fs::canonicalize(base.join("in")).unwrap();
assert_eq!(roots.check(&s(base.join("in/a.pdf")), Access::Read), Ok(()));
assert_eq!(roots.check(&s(resolved.join("a.pdf")), Access::Read), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
/// A link beside the root, in one of its parent folders, is followed: to the root it leads in,
/// anywhere else it leads out.
#[cfg(unix)]
#[test]
fn links_beside_the_root_are_followed() {
let (base, roots) = folders("beside");
std::os::unix::fs::symlink(base.join("in"), base.join("to-in")).unwrap();
std::os::unix::fs::symlink(base.join("outside"), base.join("to-outside")).unwrap();
assert_eq!(roots.check(&s(base.join("to-in/a.pdf")), Access::Read), Ok(()));
assert_eq!(roots.check(&s(base.join("to-in/sub/new.pdf")), Access::Read), Ok(()));
assert!(roots.check(&s(base.join("to-outside/secret.pdf")), Access::Read).unwrap_err().contains("outside the read root"));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn relative_paths_resolve_against_the_working_directory() {
let (base, roots) = folders("relative");
@@ -385,6 +467,37 @@ mod tests {
let _ = std::fs::remove_dir_all(base);
}
/// #4003: canonical path components are compared as the file system gave them, with only the
/// Windows drive letter matched ASCII case-insensitively, so a sibling whose name `to_lowercase`
/// folds onto the root's (`K` U+212A → `k`) or a differently-cased canonical component is not
/// taken as inside the root.
#[test]
fn canonical_components_are_compared_exactly() {
let (base, _) = folders("canonical-exact");
std::fs::create_dir_all(base.join("ink")).unwrap();
let kelvin = base.join("in\u{212A}");
std::fs::create_dir_all(&kelvin).unwrap();
std::fs::write(kelvin.join("secret.pdf"), "x").unwrap();
// U+212A folds to K. A case-insensitive volume stores both names as one directory, so
// `check` would be asked to reject a path inside its own root. The component comparison
// itself does not need that volume.
let distinct = base.join("ink").canonicalize().ok() != kelvin.canonicalize().ok();
if distinct {
let roots = AutomationRoots::new(Some(&base.join("ink")), None).unwrap().unwrap();
let e = roots.check(&s(kelvin.join("secret.pdf")), Access::Read).unwrap_err();
assert!(e.starts_with("automation path rejected: outside the read root"), "{e}");
}
assert!(!inside(Path::new("/work/in\u{212A}/secret.pdf"), Path::new("/work/ink")));
assert!(!inside(Path::new(r"\\?\C:\work\IN\a.pdf"), Path::new(r"\\?\C:\work\in")));
#[cfg(windows)]
{
assert!(inside(Path::new(r"\\?\c:\work\in\a.pdf"), Path::new(r"\\?\C:\work\in")));
assert!(inside(Path::new(r"c:\work\in\a.pdf"), Path::new(r"\\?\C:\work\in")));
assert!(!inside(Path::new(r"\\?\D:\work\in\a.pdf"), Path::new(r"\\?\C:\work\in")));
}
let _ = std::fs::remove_dir_all(base);
}
/// Every file a tool reads or writes goes through `Automation::resolve`, which holds it to the
/// roots. A function that reaches for the file system without resolving a path first would
/// bypass them, so each use of `std::fs` or `File::open` must come after a `resolve` call in
@@ -393,9 +506,22 @@ mod tests {
fn tools_do_not_touch_the_file_system_without_resolving_the_path() {
// Helpers that are only called with a resolved path. `doc_batch` (batch.rs) resolves its
// inputs and out_dir, walks only inside them (skipping links) and resolves every
// discovered file again before `process` reads it or `publish` writes it.
const RESOLVED_BY_CALLER: &[&str] =
&["write_atomic", "write_atomic_with", "read_input", "read_input_bytes", "read_review_sidecar", "process", "linked", "walk", "publish"];
// discovered file again before `process` reads it or `publish` writes it. `doc_bates_multi`
// resolves its paths before checking output collisions and using the probe helpers.
const RESOLVED_BY_CALLER: &[&str] = &[
"write_atomic",
"write_atomic_with",
"read_input",
"read_input_bytes",
"read_model",
"read_review_sidecar",
"process",
"linked",
"walk",
"publish",
"check_unique_outputs",
"probe_files_for_resolved_outputs",
];
let mut dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
let mut files = vec![];
while let Some(dir) = dirs.pop() {
+102 -2
View File
@@ -176,7 +176,7 @@ fn calculate_json(f: &FormField) -> Option<Value> {
match (&f.actions.calculate, &f.actions.scripts.calculate) {
(Calculate::Simple { op, fields }, _) => Some(json!({ "op": op_name(*op), "fields": fields })),
(Calculate::Notation(n), _) => Some(json!({ "notation": n })),
(Calculate::None, Some(js)) => Some(json!({ "script": js })),
(Calculate::Custom(js), _) | (Calculate::None, Some(js)) => Some(json!({ "script": js })),
(Calculate::None, None) => None,
}
}
@@ -208,13 +208,58 @@ fn kind_name(k: FormFieldKind) -> &'static str {
}
}
/// A JSON number as the radio option it names: its integer spelling when it is a whole number
/// (`1`, `1.0` and `1e0` are all `1`; `-0` is `0`), otherwise JSON's own spelling (`1.5`), which
/// names only an option exported as exactly that text. A whole number at or past 2^53, where a
/// float no longer holds every integer, keeps JSON's spelling too (`1e300`), so it names no option
/// by accident.
fn number_option(n: &serde_json::Number) -> String {
// Integers as parsed (u64, i64) print exactly.
if n.is_u64() || n.is_i64() {
return n.to_string();
}
const EXACT: f64 = 9_007_199_254_740_992.0; // 2^53
match n.as_f64() {
// Finite, whole and below 2^53 in magnitude, so the cast is exact and in range.
Some(x) if x.is_finite() && x.fract() == 0.0 && x.abs() < EXACT => (x as i64).to_string(),
_ => n.to_string(),
}
}
/// A JSON value → the value for this field (booleans for check boxes, arrays for lists…).
fn value_for(f: &FormField, v: &Value) -> Result<FieldValue> {
let wrong = |what: &str| ToolError::InvalidArgs(format!("{}: {what}", f.name));
Ok(match (f.kind, v) {
(FormFieldKind::CheckBox, Value::Bool(b)) => FieldValue::Check(*b),
// {"widget": n} turns on button n (1-based, as form_fields numbers them) and never turns it
// off: radio buttons that share an on state stay exclusive (ISO 32000-2 Table 229).
(FormFieldKind::CheckBox | FormFieldKind::Radio, Value::Object(o)) => {
let n = o.get("widget").and_then(Value::as_u64).filter(|&n| n >= 1).ok_or_else(|| wrong("use {\"widget\": n} with n from 1"))?;
let i = usize::try_from(n - 1).map_err(|_| wrong("there is no such widget"))?;
if f.widgets.get(i).and_then(|w| w.on_state.as_ref()).is_none() {
return Err(wrong(&format!("there is no button {n} with an on state (the field has {})", f.widgets.len())));
}
FieldValue::Widget(i)
}
(FormFieldKind::Radio, Value::Null) => FieldValue::Radio(None),
(FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(s.clone())),
// An option as `form_fields` lists it (an export value) names its button's on state. The
// export values are matched first and the on-state names after them
// (`Field::state_for_export`): with `/Opt [(1) (2)]` and states `/0` `/1`, `1` is the first
// button, not the second one's state. A value matching neither is refused when written,
// with the options listed.
(FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(f.state_for_export(s).unwrap_or(s).to_owned())),
// A number is the same option written without quotes (`1` for the export value `1`).
// Below 2^53 it is compared as a number with the export values first, so `1.0` names the button
// exported as `1.0` (`/Opt [(1.0) (2.0)]`, states `/0 /1`) and not the one whose state is
// `/1`; then as text, as a string would be.
(FormFieldKind::Radio, Value::Number(n)) => {
if let Some(state) = n.as_f64().and_then(|x| f.state_for_number(x)) {
FieldValue::Radio(Some(state.to_owned()))
} else {
let s = number_option(n);
FieldValue::Radio(Some(f.state_for_export(&s).map_or_else(|| s.clone(), str::to_owned)))
}
}
(FormFieldKind::Combo | FormFieldKind::List, Value::Array(a)) => FieldValue::Choice(
a.iter().map(|x| x.as_str().map(str::to_owned).ok_or_else(|| wrong("list values must be strings"))).collect::<Result<_>>()?,
),
@@ -227,6 +272,27 @@ fn value_for(f: &FormField, v: &Value) -> Result<FieldValue> {
}
impl Automation {
/// Import an image using an optional exact field ID, preserving the legacy name-only tool.
pub(crate) fn form_set_image(&mut self, a: &Args) -> Result<Value> {
let name = a.str("field")?.to_owned();
let object = a
.0
.get("field_id")
.map(|value| -> Result<pdfcraft_cos::ObjRef> {
let key =
value.as_u64().filter(|key| *key < 1u64 << 48).ok_or_else(|| bad("field_id must be an integer between 0 and 281474976710655"))?;
Ok(pdfcraft_cos::ObjRef::new((key >> 16) as u32, key as u16))
})
.transpose()?;
let path = self.resolve(a.str("path")?, false)?;
let image = std::sync::Arc::new(crate::paths::read_input(&path)?);
let edit = match object {
Some(object) => Edit::SetFieldImageTarget { name, object, image },
None => Edit::SetFieldImage { name, image },
};
self.apply(a, edit)
}
pub(crate) fn form_fields(&self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let out: Vec<Value> = doc
@@ -241,6 +307,7 @@ impl Automation {
});
let mut v = json!({
"name": f.name,
"field_id": pdfcraft_engine::js::field_object_id(f.obj),
"type": kind_name(f.kind),
"value": match f.kind {
FormFieldKind::CheckBox => json!(!f.value.is_empty()),
@@ -294,9 +361,41 @@ impl Automation {
}
FormFieldKind::Radio => {
o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::<Vec<_>>()));
// Buttons sharing an on state have the same value: which is on (1-based).
o.insert(
"on_widgets".into(),
json!((0..f.widgets.len()).filter(|&i| f.widget_on(i)).map(|i| i.saturating_add(1)).collect::<Vec<_>>()),
);
}
// A check box whose kids have different on states (ISO 32000-2 §12.7.5.2.3):
// which one is ticked, and the names form_fill takes to tick each. One option
// per distinct on state: kids sharing a state tick together (EXAMPLE 2), so
// their `/Opt` exports are not separate choices. Exports name the options
// unless two states share one (Table 230 allows it) or an export would tick
// another state (form_fill reads a name as an on state first); then the
// states do.
FormFieldKind::CheckBox => {
let mut seen = std::collections::HashSet::new();
let states: Vec<&str> = f.widgets.iter().filter_map(|w| w.on_state.as_deref()).filter(|s| seen.insert(*s)).collect();
if states.len() > 1 {
let mut exports = std::collections::HashSet::new();
let by_export = states.iter().all(|s| {
let e = f.export_for_state(s);
exports.insert(e) && f.state_for(e) == Some(*s)
});
let name = |s: &str| if by_export { f.export_for_state(s).to_owned() } else { s.to_owned() };
// The ticked kid is the one whose `/AS` is its on state: §12.7.5.2.3
// makes `/AS` decide where it and `/V` differ.
let on = (0..f.widgets.len()).find(|&i| f.widget_on(i)).and_then(|i| f.widgets.get(i)?.on_state.as_deref());
o.insert("options".into(), json!(states.iter().map(|s| name(s)).collect::<Vec<_>>()));
o.insert("checked".into(), json!(on.map(name)));
}
}
FormFieldKind::Combo | FormFieldKind::List => {
o.insert("options".into(), f.options.iter().map(|(e, d)| json!({ "value": e, "label": d })).collect());
// The selected options as 1-based positions in `options`: two options can share
// an export value, and `value` alone does not tell them apart.
o.insert("selected".into(), json!(f.selected.iter().map(|i| i.saturating_add(1)).collect::<Vec<_>>()));
o.insert("multi_select".into(), json!(f.has(field_flags::MULTI_SELECT)));
o.insert("editable".into(), json!(f.has(field_flags::EDIT)));
}
@@ -568,6 +667,7 @@ impl Automation {
Ok((wi, caption.to_owned()))
})
.transpose()?,
caption_all: None,
read_only: a.opt_bool("read_only")?,
required: a.opt_bool("required")?,
multiline: a.opt_bool("multiline")?,
+40
View File
@@ -0,0 +1,40 @@
//! Print production ▸ Fix hairlines as a tool: strokes narrower than a threshold on the page get a
//! width (`doc_fix_hairlines`).
use pdfcraft_engine::{Edit, Hairlines};
use serde_json::{Value, json};
use crate::{Args, Automation, Result, ToolError};
impl Automation {
/// Strokes thinner than `narrower_than` points on the page (after every transformation;
/// zero-width lines always) are given `width` points; Type 3 glyphs and tiling patterns on
/// request. Nothing to fix is not an error: `fixed` is 0 and no edit is made. `elsewhere`
/// counts the glyphs and patterns changed that something outside `pages` also uses.
pub(crate) fn fix_hairlines(&mut self, a: &Args) -> Result<Value> {
let n = self.doc(a)?.info.pages.len();
let pages = match a.opt_ints("pages")? {
Some(_) => self.pages(a, "pages")?,
None => (0..n).collect(),
};
let settings = Hairlines {
narrower_than: a.opt_num("narrower_than")?.unwrap_or(0.003 * 72.0),
width: a.opt_num("width")?.unwrap_or(0.01 * 72.0),
type3: a.opt_bool("type3")?.unwrap_or(false),
patterns: a.opt_bool("patterns")?.unwrap_or(false),
};
if !settings.in_range() {
return Err(ToolError::InvalidArgs(format!(
"narrower_than must be 0 or more and width above 0, both at most {} (points)",
Hairlines::MAX_POINTS
)));
}
let id = self.doc(a)?.id;
let found = self.session.count_hairlines(id, &pages, &settings);
if found.lines == 0 {
return Ok(json!({ "fixed": 0, "pages": 0, "elsewhere": 0 }));
}
let r = self.apply(a, Edit::FixHairlines { pages, settings })?;
Ok(json!({ "fixed": found.lines, "pages": found.pages, "elsewhere": found.elsewhere, "undo": r["undo"] }))
}
}
+66 -31
View File
@@ -256,7 +256,7 @@ pub(crate) fn security(d: &Document) -> Value {
"method": s.method,
"pending": s.pending,
"printing": if !p.print() { "none" } else if p.print_high_quality() { "high" } else { "low" },
"modify": p.modify(), "assemble": p.assemble(), "copy": p.copy(), "annotate": p.annotate(),
"modify": p.modify(), "assemble": p.assemble(), "extract_pages": p.extract_pages(), "copy": p.copy(), "annotate": p.annotate(),
"fill_forms": p.fill_forms(), "accessibility": p.extract_for_accessibility(),
})
}
@@ -279,6 +279,8 @@ pub(crate) fn summary(d: &Document) -> Value {
})
}
/// What `doc_info` returns for an open document: its metadata, security, pages, bookmarks,
/// comments, fields, links, layers, transitions, attachments, fonts, warnings and repairs.
pub(crate) fn info(d: &Document) -> Value {
let i = &d.info;
let page1 = |p: usize| p + 1;
@@ -313,7 +315,7 @@ pub(crate) fn info(d: &Document) -> Value {
"pages": i.pages.iter().enumerate().map(|(n, p)| json!({
"page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation,
})).collect::<Vec<_>>(),
"outline": outline(&i.outline),
"outline": outline(i, &i.outline),
"outline_more": i.outline_more,
// Rectangles use the tools' convention (top-left of the displayed page, like
// comment_list and link_list), not raw PDF user space, so they can be fed back to
@@ -329,34 +331,15 @@ pub(crate) fn info(d: &Document) -> Value {
})).collect::<Vec<_>>(),
"links": i.links.iter().map(|l| json!({
"page": page1(l.page), "rect": view_rect(i, l.page, l.rect),
"target": match &l.target {
pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }),
pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }),
pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({
"layers": changes.iter().map(|(op, ocg)| json!({
"layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()),
"state": match op {
pdfcraft_render::LayerOp::On => "on",
pdfcraft_render::LayerOp::Off => "off",
pdfcraft_render::LayerOp::Toggle => "toggle",
},
})).collect::<Vec<_>>(),
"preserve_rb": preserve_rb,
}),
pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({
"action": if *launch { "Launch" } else { "GoToR" },
"remote_document": file,
"destination": match destination {
pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }),
pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }),
pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }),
},
"new_window": new_window,
}),
pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }),
},
"target": link_target(i, &l.target),
})).collect::<Vec<_>>(),
"layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::<Vec<_>>(),
// The Layers panel's rows, the default configuration's /Order (ISO 32000-2 Table 99): a
// layer by its 1-based position in `layers`, or a label; `depth` 0 at the top.
"layer_order": i.layer_order.iter().map(|r| match &r.item {
pdfcraft_render::LayerItem::Layer(n) => json!({ "depth": r.depth, "layer": n.saturating_add(1), "name": i.layers.get(*n).map(|l| l.name.as_str()) }),
pdfcraft_render::LayerItem::Label(s) => json!({ "depth": r.depth, "label": s }),
}).collect::<Vec<_>>(),
// Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with
// a `/Trans` or `/Dur` appear; `style` is null for a display time alone.
"transitions": i.transitions.iter().map(|t| json!({
@@ -364,7 +347,12 @@ pub(crate) fn info(d: &Document) -> Value {
"horizontal": t.horizontal, "inward": t.inward, "direction": t.direction, "scale": t.scale, "opaque": t.opaque,
"display": t.display,
})).collect::<Vec<_>>(),
"attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::<Vec<_>>(),
"attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "key": match &a.source { pdfcraft_render::AttachmentSource::Document { key } => Some(key), pdfcraft_render::AttachmentSource::Annotation { .. } => None }, "description": a.description, "size": a.size })).collect::<Vec<_>>(),
// A PDF portfolio (`/Collection`, ISO 32000-2 §7.11.6): its pages are a cover sheet and its
// documents are `attachments`; `initial` names the one presented first, null for the cover.
"portfolio": i.portfolio.as_ref().map(|p| json!({
"initial": p.initial.and_then(|n| i.attachments.get(n)).map(|a| &a.name), "view": p.view.name(),
})),
// `substitute` names the face standing in for a program the document does not embed, and
// `substitution` says why. Both are null for an embedded font: nothing stands in. A null
// `substitute` with a non-null `substitution` means no face was available at all, so
@@ -375,6 +363,9 @@ pub(crate) fn info(d: &Document) -> Value {
})).collect::<Vec<_>>(),
"warnings": i.warnings,
"repairs": d.repair_log(),
// Objects that could not be read (each reads as null): nothing was repaired, what they held is
// lost. Last, so it includes what reading the fields above failed on.
"unreadable_objects": d.unreadable_objects(),
})
}
@@ -386,6 +377,50 @@ pub fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [
i.pages.get(page).map_or(rect, |p| comments::rect_to_view(p, rect))
}
fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value {
Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect())
/// Bookmarks with the page each goes to, or the action it runs instead (as a link's `target`).
fn outline(i: &pdfcraft_render::DocInfo, items: &[pdfcraft_render::OutlineItem]) -> Value {
Value::Array(
items
.iter()
.map(|o| {
json!({
"title": o.title, "page": o.page.map(|p| p + 1),
"action": o.action.as_ref().map(|a| link_target(i, a)), "children": outline(i, &o.children),
})
})
.collect(),
)
}
/// Where a link goes, or what a bookmark's action does: a 1-based page, a web address, layer
/// changes, another PDF, or the name of an action that isn't run.
fn link_target(i: &pdfcraft_render::DocInfo, target: &pdfcraft_render::LinkTarget) -> Value {
// A remote page number is the file's, however large.
let page1 = |p: usize| p.saturating_add(1);
match target {
pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }),
pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }),
pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({
"layers": changes.iter().map(|(op, ocg)| json!({
"layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()),
"state": match op {
pdfcraft_render::LayerOp::On => "on",
pdfcraft_render::LayerOp::Off => "off",
pdfcraft_render::LayerOp::Toggle => "toggle",
},
})).collect::<Vec<_>>(),
"preserve_rb": preserve_rb,
}),
pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({
"action": if *launch { "Launch" } else { "GoToR" },
"remote_document": file,
"destination": match destination {
pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }),
pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }),
pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }),
},
"new_window": new_window,
}),
pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }),
}
}
+20 -2
View File
@@ -16,16 +16,20 @@
mod a11y;
mod batch;
mod bates;
mod comments;
mod content;
mod conventions;
pub mod file_access;
mod forms;
mod hairlines;
mod layers;
mod links;
#[cfg(feature = "mcp")]
pub mod mcp;
mod measure;
mod model3d;
mod model_render;
mod printing;
mod reading;
mod redact;
@@ -45,10 +49,10 @@ use serde_json::{Value, json};
pub use pdfcraft_render::Pixels;
pub use tools::{ToolDef, tools};
use file_access::foreign_share;
use text_cache::TextCache;
mod assemble;
mod bookmark_io;
mod dispatch;
mod documents;
mod error;
@@ -67,11 +71,17 @@ use pages::{DEFAULT_DPI, MAX_DPI, encode_png, rendered_content};
use pages::{FIND_FIRST_BATCH, FIND_MAX_WARNING_BYTES, FIND_MAX_WARNINGS, extract_parallel};
#[cfg(test)]
pub(crate) use paths::MAX_INPUT;
use paths::{child, lexical, unused, write_atomic};
use paths::{child, lexical, write_atomic};
pub(crate) use paths::{read_input, read_input_bytes};
#[cfg(test)]
use paths::{write_atomic_synced, write_atomic_with};
struct OcrReview {
handle: u64,
guard: pdfcraft_engine::ocr::OcrGuard,
pages: Vec<pdfcraft_engine::ocr::OcrPage>,
}
/// A headless PdfCraft session driven by tool calls.
pub struct Automation {
session: Session,
@@ -88,6 +98,11 @@ pub struct Automation {
/// Documents that lost a listing to the cap. A page of one with no listing is refused rather
/// than read as "a number is a position", which the page may no longer match.
evicted: HashSet<DocId>,
/// Review sessions held for `ocr_words` (a guard and the pages read), by document.
ocr_reviews: HashMap<DocId, OcrReview>,
next_ocr_review: u64,
/// What object_copy copied, for object_paste into any open document.
objects_clipboard: Option<pdfcraft_engine::ObjectClipboard>,
}
impl Default for Automation {
@@ -106,6 +121,9 @@ impl Automation {
texts: TextCache::default(),
listed: HashMap::new(),
evicted: HashSet::new(),
ocr_reviews: HashMap::new(),
next_ocr_review: 0,
objects_clipboard: None,
}
}
+160 -31
View File
@@ -2,7 +2,7 @@
//! all links. Rectangles are points from the top-left of the displayed page.
use pdfcraft_engine::{
Edit, LinkAction, LinkHighlight, LinkStyle, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path,
Edit, LinkAction, LinkHighlight, LinkStyle, LinkZoom, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path,
};
use pdfcraft_render::{DestView, LinkTarget, RemoteDestination};
use serde_json::{Value, json};
@@ -11,6 +11,26 @@ use std::path::Path;
use crate::comments::parse_color;
use crate::{Args, Automation, Result, ToolError, failed};
/// Where `destination` puts `doc`, a document just opened from a link or with open parameters:
/// whether it applies, the destination as reported, and a warning when it doesn't. A named
/// destination is looked up in the document, as the app does.
pub(crate) fn destination_report(doc: &pdfcraft_engine::Document, destination: RemoteDestination) -> (bool, Value, Option<&'static str>) {
let (name, page, view) = match destination {
RemoteDestination::Default => return (true, json!({ "kind": "default" }), None),
RemoteDestination::Page { page, view } => (None, page, view),
RemoteDestination::Named(name) => match pdfcraft_render::named_destination(doc.bytes.clone(), doc.password.as_deref(), &name) {
Some((page, view)) => (Some(name), page, view),
None => return (false, json!({ "kind": "named", "name": name }), Some("The PDF opened, but the named destination was not applied.")),
},
};
let mut value = json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) });
if let Some(name) = name {
value["kind"] = json!("named");
value["name"] = json!(name);
}
if page < doc.info.pages.len() { (true, value, None) } else { (false, value, Some("The PDF opened, but the page destination is out of range.")) }
}
fn bad(m: impl Into<String>) -> ToolError {
ToolError::InvalidArgs(m.into())
}
@@ -25,18 +45,30 @@ impl Automation {
Ok(Some([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]))
}
fn link_action(&self, a: &Args) -> Result<Option<LinkAction>> {
/// The action `url`, `to_page` and `zoom` ask for, if any. `current` is the link's action now
/// (link_edit): a new page keeps the link's zoom, and a zoom alone keeps its page.
fn link_action(&self, a: &Args, current: Option<&LinkAction>) -> Result<Option<LinkAction>> {
let zoom = link_zoom(a)?;
let (current_page, current_zoom) = match current {
Some(LinkAction::Page { page, zoom }) => (Some(*page), Some(*zoom)),
_ => (None, None),
};
match (a.opt_str("url")?, a.opt_int("to_page")?) {
(Some(_), Some(_)) => Err(bad("pass url or to_page, not both")),
(Some(_), None) if zoom.is_some() => Err(bad("zoom goes with to_page, not url")),
(Some(u), None) => Ok(Some(LinkAction::Uri(u.to_string()))),
(None, Some(p)) => {
let n = self.doc(a)?.info.pages.len() as i64;
if p < 1 || p > n {
return Err(bad(format!("to_page must be 1–{n}")));
}
Ok(Some(LinkAction::Page(p as usize - 1)))
Ok(Some(LinkAction::Page { page: p as usize - 1, zoom: zoom.or(current_zoom).unwrap_or(LinkZoom::FitPage) }))
}
(None, None) => Ok(None),
(None, None) => match (zoom, current_page) {
(None, _) => Ok(None),
(Some(zoom), Some(page)) => Ok(Some(LinkAction::Page { page, zoom })),
(Some(_), None) => Err(bad("zoom needs to_page: the link doesn't go to a page")),
},
}
}
@@ -65,7 +97,8 @@ impl Automation {
pub(crate) fn link_list(&self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let targets: std::collections::HashMap<(usize, usize), &LinkTarget> = doc.info.links.iter().map(|t| ((t.page, t.index), &t.target)).collect();
let viewed: std::collections::HashMap<(usize, usize), &pdfcraft_render::Link> =
doc.info.links.iter().map(|t| ((t.page, t.index), t)).collect();
let items: Vec<Value> = doc
.links()
.iter()
@@ -73,30 +106,104 @@ impl Automation {
// The link's page may be one the viewer could not read (#2660): rect unchanged then.
let rect = crate::view_rect(&doc.info, l.page, l.rect.map(|x| x as f32));
let mut v = json!({ "page": l.page + 1, "index": l.index + 1, "rect": rect, "visible": l.style.visible });
let link = viewed.get(&(l.page, l.index));
// Where the link is active when that isn't its whole rect: its /QuadPoints
// (ISO 32000-2 Table 176), as the bounding box of each quadrilateral.
if let Some(link) = link.filter(|k| !k.quads.is_empty()) {
let areas: Vec<Value> = link
.quads
.iter()
.map(|q| {
let (xs, ys) = ([q[0], q[2], q[4], q[6]], [q[1], q[3], q[5], q[7]]);
let fold = |v: [f32; 4], f: fn(f32, f32) -> f32, start: f32| v.into_iter().fold(start, f);
let box_ = [
fold(xs, f32::min, f32::MAX),
fold(ys, f32::min, f32::MAX),
fold(xs, f32::max, f32::MIN),
fold(ys, f32::max, f32::MIN),
];
json!(crate::view_rect(&doc.info, l.page, box_))
})
.collect();
v["active_areas"] = json!(areas);
}
// Where an in-document link goes, as the viewer resolves it: named destinations
// included, with the position on the page.
let target = targets.get(&(l.page, l.index)).and_then(|t| match t {
let target = link.and_then(|t| match &t.target {
LinkTarget::Page(page, view) => Some((*page, view)),
_ => None,
});
match (&l.action, target) {
(LinkAction::Uri(u), _) => v["url"] = json!(u),
(LinkAction::Uri(u), _) => {
v["url"] = json!(u);
// A relative address, resolved against the catalog's /URI /Base
// (ISO 32000-2 Table 211): what the viewer offers to open. That is the
// address inspection kept, which stays raw once its text budget is spent;
// resolve afresh only for a link inspection did not list.
let opened = match link.map(|t| &t.target) {
Some(LinkTarget::Uri(kept)) => Some(kept.clone()),
Some(_) => None,
None => pdfcraft_render::resolve_uri(doc.info.uri_base.as_deref(), u),
};
if let Some(opened) = opened.filter(|o| o != u) {
v["resolved_url"] = json!(opened);
}
}
(action, Some((page, view))) => {
v["to_page"] = json!(page + 1);
if let Some(d) = doc.info.pages.get(page).and_then(|p| crate::destination(view, p)) {
v["destination"] = d;
}
if matches!(action, LinkAction::Other(_)) {
v["named"] = json!(true);
match action {
LinkAction::Page { zoom, .. } => v["zoom"] = zoom_value(*zoom),
LinkAction::Other(_) => v["named"] = json!(true),
LinkAction::Uri(_) => {}
}
}
(LinkAction::Page(p), None) => v["to_page"] = json!(p + 1),
(LinkAction::Page { page, zoom }, None) => {
v["to_page"] = json!(page + 1);
v["zoom"] = zoom_value(*zoom);
}
(LinkAction::Other(o), None) => v["other"] = json!(o),
}
v
})
.collect();
Ok(json!({ "count": items.len(), "links": items }))
let mut out = json!({ "count": items.len(), "links": items });
// Addresses written as plain page text, which the viewer makes clickable (#4207). Reading
// the pages' text costs more than the list above, so only on request.
if a.opt_bool("text_links")?.unwrap_or(false) {
let id = doc.id;
// One share of the pages at a time (`MAX_TEXT_LINK_PAGES_PER_SCAN`), from the 1-based
// `text_links_from`; `text_links_next_page` says where the next share starts.
let pages = doc.info.pages.len().max(1);
let from = match a.opt_int("text_links_from")? {
None => 0,
Some(p) => usize::try_from(p)
.ok()
.filter(|p| (1..=pages).contains(p))
.map(|p| p - 1)
.ok_or_else(|| bad(format!("text_links_from must be a page from 1 to {pages}")))?,
};
let scan = self.session.text_links(id, from);
if let Some(next) = scan.next_page {
out["text_links_next_page"] = json!(next.saturating_add(1));
}
let found: Vec<Value> = scan
.links
.into_iter()
.map(|(page, l)| {
let mut v = json!({ "page": page + 1, "rects": l.rects, "url": l.uri });
// What the viewer does on a click: the same check every document link gets.
if let Err(e) = pdfcraft_engine::links::document_url(&l.uri) {
v["blocked"] = json!(e.to_string());
}
v
})
.collect();
out["text_links"] = json!(found);
}
Ok(out)
}
pub(crate) fn link_open(&mut self, a: &Args) -> Result<Value> {
@@ -135,20 +242,7 @@ impl Automation {
let id =
self.session.open(name, Some(path.to_string_lossy().into_owned()), std::sync::Arc::new(bytes), a.opt_str("password")?).map_err(failed)?;
let opened = self.session.get(id).ok_or_else(|| failed("the linked document vanished"))?;
let (destination_applied, destination_value, warning) = match destination {
RemoteDestination::Default => (true, json!({ "kind": "default" }), None),
RemoteDestination::Named(name) => {
(false, json!({ "kind": "named", "name": name }), Some("The PDF opened, but the named destination was not applied."))
}
RemoteDestination::Page { page, view } if page < opened.info.pages.len() => {
(true, json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }), None)
}
RemoteDestination::Page { page, view } => (
false,
json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }),
Some("The PDF opened, but the link's page destination is out of range."),
),
};
let (destination_applied, destination_value, warning) = destination_report(opened, destination);
let mut display_path = path.to_string_lossy().into_owned();
if let Some(root) = &self.root
&& let Ok(relative) = path.strip_prefix(root)
@@ -168,12 +262,13 @@ impl Automation {
pub(crate) fn link_add(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let rect = self.view_rect(a, page)?.ok_or_else(|| bad("link_add needs rect"))?;
let action = self.link_action(a)?.ok_or_else(|| bad("pass url or to_page"))?;
let action = self.link_action(a, None)?.ok_or_else(|| bad("pass url or to_page"))?;
let style = self.link_style(a, LinkStyle::default())?;
self.apply(a, Edit::AddLink { page, rect, action, style })
}
fn link_index(&self, a: &Args) -> Result<(usize, usize, LinkStyle)> {
/// The link `page` and `index` name: its page, index in `/Annots`, appearance and action.
fn link_index(&self, a: &Args) -> Result<(usize, usize, LinkStyle, LinkAction)> {
let page = self.page(a)?;
let index = a.int("index")?;
let doc = self.doc(a)?;
@@ -182,19 +277,19 @@ impl Automation {
.iter()
.find(|l| l.page == page && l.index as i64 + 1 == index)
.ok_or_else(|| failed(format!("page {} has no link {index} (see link_list)", page + 1)))?;
Ok((page, l.index, l.style))
Ok((page, l.index, l.style, l.action.clone()))
}
pub(crate) fn link_edit(&mut self, a: &Args) -> Result<Value> {
let (page, index, style) = self.link_index(a)?;
let (page, index, style, current) = self.link_index(a)?;
let rect = self.view_rect(a, page)?;
let action = self.link_action(a)?;
let action = self.link_action(a, Some(&current))?;
let new_style = self.link_style(a, style)?;
self.apply(a, Edit::SetLink { page, index, rect, action, style: (new_style != style).then_some(new_style) })
}
pub(crate) fn link_delete(&mut self, a: &Args) -> Result<Value> {
let (page, index, _) = self.link_index(a)?;
let (page, index, ..) = self.link_index(a)?;
self.apply(a, Edit::DeleteLink { page, index })
}
@@ -225,6 +320,40 @@ impl Automation {
}
}
/// The zoom a `zoom` argument names: fit_page, fit_width, fit_height, fit_visible, actual
/// (100%), inherit (keep the reader's zoom), or a percentage, kept within 1–6400.
fn link_zoom(a: &Args) -> Result<Option<LinkZoom>> {
match a.get("zoom") {
None | Some(Value::Null) => Ok(None),
Some(Value::Number(p)) => match p.as_f64() {
Some(p) if p.is_finite() && p > 0.0 => Ok(Some(LinkZoom::Percent(p.clamp(1.0, 6400.0)))),
_ => Err(bad("zoom must be a positive percentage")),
},
Some(Value::String(z)) => Ok(Some(match z.as_str() {
"fit_page" => LinkZoom::FitPage,
"fit_width" => LinkZoom::FitWidth,
"fit_height" => LinkZoom::FitHeight,
"fit_visible" => LinkZoom::FitVisible,
"actual" => LinkZoom::Percent(100.0),
"inherit" => LinkZoom::Inherit,
o => return Err(bad(format!("unknown zoom {o:?}"))),
})),
Some(_) => Err(bad("zoom is a name or a percentage")),
}
}
/// How `link_list` reports a link's zoom: the names `link_zoom` reads, a percentage as a number.
fn zoom_value(zoom: LinkZoom) -> Value {
match zoom {
LinkZoom::FitPage => json!("fit_page"),
LinkZoom::FitWidth => json!("fit_width"),
LinkZoom::FitHeight => json!("fit_height"),
LinkZoom::FitVisible => json!("fit_visible"),
LinkZoom::Inherit => json!("inherit"),
LinkZoom::Percent(p) => json!(p),
}
}
fn dest_view_value(view: DestView) -> Value {
match view {
DestView::Top => json!({ "kind": "top" }),
+1 -1
View File
@@ -557,7 +557,7 @@ fn tool_json(t: &crate::ToolDef) -> Value {
"title": t.title,
"description": t.description,
"inputSchema": t.input_schema,
"annotations": { "title": t.title, "readOnlyHint": t.read_only, "destructiveHint": t.destructive, "idempotentHint": t.read_only || matches!(t.name, "doc_save" | "doc_optimize" | "doc_export_data" | "measure_export" | "doc_export_images" | "doc_export_office" | "doc_export_all_images" | "doc_export_text" | "image_save"), "openWorldHint": false },
"annotations": { "title": t.title, "readOnlyHint": t.read_only, "destructiveHint": t.destructive, "idempotentHint": t.read_only || matches!(t.name, "doc_save" | "doc_optimize" | "doc_export_data" | "measure_export" | "doc_export_images" | "doc_export_office" | "doc_export_all_images" | "doc_export_text" | "image_save" | "bookmark_export"), "openWorldHint": false },
})
}
+1 -1
View File
@@ -147,7 +147,7 @@ impl Automation {
Ok(json!({"point":point,"kind":s.kind,"distance":measure::distance(at,point)}))
})
.transpose()?;
Ok(json!({"snap":snap,"truncated":geometry.truncated,"intersection_limited":intersection_limited,"segments":geometry.segments.len()}))
Ok(json!({"snap":snap,"truncated":geometry.truncated,"intersection_limited":intersection_limited,"segments":geometry.segment_count()}))
}
pub(crate) fn measurement_export(&self, a: &Args) -> Result<Value> {
let all = self.doc(a)?.measurements().map_err(failed)?;
+114
View File
@@ -0,0 +1,114 @@
//! 3D model tools (Acrobat: Rich Media ▸ Add 3D, AGENTS.md §3).
use std::path::Path;
use pdfcraft_engine::{Activation3d, Edit, MAX_3D_MODEL_BYTES, MarkFile, Model3dFormat};
use serde_json::Value;
use crate::comments::rect_to_user;
use crate::{Args, Automation, Result, ToolError, failed};
/// The largest side of a 3D model's box, in points: the largest page PDF allows (ISO 32000-2 Annex C).
const MAX_MODEL_SIDE: f64 = 14_400.0;
impl Automation {
/// A U3D or PRC model in `rect` on `page`, its poster the picture at `poster` (a PDF page or an
/// image) fitted inside the box, or a labelled frame.
pub(crate) fn model_add(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let info = self.doc(a)?.info.pages.get(page).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("page {} does not exist", page + 1)))?;
let r = a.nums::<4>("rect")?.ok_or_else(|| ToolError::InvalidArgs("missing argument rect".into()))?;
let (w, h) = ((r[2] - r[0]).abs(), (r[3] - r[1]).abs());
if !((1.0..=MAX_MODEL_SIDE).contains(&w) && (1.0..=MAX_MODEL_SIDE).contains(&h)) {
return Err(ToolError::InvalidArgs(format!(
"rect must be [x0, y0, x1, y1] with each side at least 1 pt and at most {MAX_MODEL_SIDE} pt"
)));
}
let activation = match a.opt_str("activation")? {
None => Activation3d::default(),
Some(word) => Activation3d::ALL
.iter()
.find(|(_, w)| *w == word)
.map(|(act, _)| *act)
.ok_or_else(|| ToolError::InvalidArgs(format!("activation must be one of: {}", Activation3d::ALL.map(|(_, w)| w).join(", "))))?,
};
let path = self.resolve(a.str("path")?, false)?;
let model = read_model(&path, MAX_3D_MODEL_BYTES)?;
let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let name = a.opt_str("name")?.map(str::to_owned).unwrap_or_else(|| file.rsplit_once('.').map_or(file.clone(), |(s, _)| s.to_owned()));
let poster = match a.opt_str("poster")? {
None => None,
Some(p) => {
let path = self.resolve(p, false)?;
let bytes = crate::read_input(&path)?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let page = usize::try_from(a.opt_int("poster_page")?.unwrap_or(1).max(1)).unwrap_or(1).saturating_sub(1);
Some(MarkFile { name, bytes: std::sync::Arc::new(bytes), page })
}
};
let edit = Edit::AddModel3d { page, rect: rect_to_user(&info, r), model: std::sync::Arc::new(model), poster, activation, name };
self.apply(a, edit)
}
}
/// Read a resolved model file of at most `max` bytes. Its size (from the file system) and its first
/// bytes are checked before the rest is read, so a large file that is not a model is never loaded.
fn read_model(path: &Path, max: usize) -> Result<Vec<u8>> {
use std::io::Read as _;
let io = |e: std::io::Error| failed(format!("{}: {e}", path.display()));
let too_large = |size: u64| failed(format!("the 3D model is {size} bytes; the largest accepted is {max}"));
let cap = u64::try_from(max).unwrap_or(u64::MAX);
let mut file = std::fs::File::open(path).map_err(io)?;
if let Ok(meta) = file.metadata()
&& meta.is_file()
&& meta.len() > cap
{
return Err(too_large(meta.len()));
}
let mut bytes = Vec::new();
(&mut file).take(4).read_to_end(&mut bytes).map_err(io)?;
Model3dFormat::detect(&bytes).map_err(failed)?;
let rest = cap.saturating_add(1).saturating_sub(bytes.len() as u64);
file.take(rest).read_to_end(&mut bytes).map_err(io)?;
if bytes.len() > max {
return Err(failed(format!("the 3D model is more than {max} bytes, the largest accepted")));
}
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
fn file(dir: &Path, name: &str, bytes: &[u8]) -> std::path::PathBuf {
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
path
}
#[test]
fn a_model_is_read_whole_when_it_fits() {
let dir = tempfile::tempdir().unwrap();
let mut prc = b"PRC".to_vec();
prc.extend_from_slice(&[7; 40]);
assert_eq!(read_model(&file(dir.path(), "m.prc", &prc), 64).unwrap(), prc);
assert_eq!(read_model(&file(dir.path(), "m.u3d", b"U3D\0x"), 6).unwrap(), b"U3D\0x");
}
#[test]
fn a_model_past_the_cap_is_refused_by_its_size_before_it_is_read() {
let dir = tempfile::tempdir().unwrap();
let path = file(dir.path(), "big.prc", &[b'P', b'R', b'C', 0, 0, 0, 0, 0, 0, 0]);
let err = read_model(&path, 4).unwrap_err().to_string();
assert!(err.contains("is 10 bytes") && err.contains("largest accepted is 4"), "the file's own size, from the file system: {err}");
}
#[test]
fn a_file_that_is_not_a_model_is_refused_by_its_header() {
let dir = tempfile::tempdir().unwrap();
for junk in [&b"%PDF-1.7 a page"[..], b"PR", b"", b"U3DX and more"] {
let err = read_model(&file(dir.path(), "junk.bin", junk), 64).unwrap_err().to_string();
assert!(err.contains("not a 3D model"), "{junk:?}: {err}");
}
}
}
+171
View File
@@ -0,0 +1,171 @@
//! `model_render`: a page's 3D model (ISO 32000-2 §13.6) drawn to a PNG, without a viewer.
use pdfcraft_engine::model3d::Model3dKind;
use pdfcraft_prc::PrcFile;
use pdfcraft_prc_render::nav::{Axis, NamedView, Orbit, Upright, reach};
use pdfcraft_prc_render::{Bounds, Camera, RenderOptions, WorldMesh, assemble, render};
use serde_json::Value;
use super::Automation;
use super::dispatch::Content;
use super::error::{Result, ToolError, failed};
use super::json::Args;
use super::pages::encode_png;
/// Default and largest side of the image, in pixels.
pub(crate) const DEFAULT_SIDE: u32 = 512;
pub(crate) const MAX_SIDE: u32 = 4096;
/// The default view: from the front, right and above, as an isometric view looks.
const DEFAULT_DIRECTION: [f64; 3] = [-1.0, 1.0, -1.0];
impl Automation {
pub(crate) fn model_render(&mut self, a: &Args) -> Result<Vec<Content>> {
let page = self.page(a)?;
let width = side(a, "width")?;
let height = side(a, "height")?;
let perspective = a.opt_bool("perspective")?.unwrap_or(true);
let doc = self.doc(a)?;
let found = doc.model_3d_annotations(page).map_err(failed)?;
let index = match a.opt_int("model")? {
None => *found.first().ok_or_else(|| failed(format!("page {} has no 3D model", page.saturating_add(1))))?,
Some(n) => {
let pos = usize::try_from(n).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| Args::wrong("model", "a positive integer"))?;
*found
.get(pos)
.ok_or_else(|| ToolError::InvalidArgs(format!("page {} has {} 3D models, not {n}", page.saturating_add(1), found.len())))?
}
};
let model = doc.model_3d(page, index).map_err(failed)?;
if model.kind == Model3dKind::U3d {
return Err(failed("this 3D model is in U3D, which can't be drawn yet; only PRC models can"));
}
let prc = PrcFile::parse(&model.data).map_err(|e| failed(format!("the 3D model could not be read: {e}")))?;
let meshes = assemble(&prc).map_err(failed)?;
let aspect = f64::from(width) / f64::from(height);
let camera = match a.get("orbit") {
Some(_) if a.get("direction").is_some() => return Err(Args::wrong("orbit", "left out when direction is given")),
Some(moves) => orbit_camera(moves, &meshes, perspective, aspect)?,
None => {
let direction = direction(a)?;
Camera::fit_meshes(&meshes, direction, up_for(direction), perspective, aspect).map_err(failed)?
}
};
let image = render(&meshes, &camera, &RenderOptions { width, height, ..RenderOptions::default() }).map_err(failed)?;
let data = encode_png(image.width, image.height, &premultiplied(image.rgba))?;
Ok(vec![Content::Png { data, width: image.width, height: image.height }])
}
}
fn side(a: &Args, key: &str) -> Result<u32> {
match a.opt_int(key)? {
None => Ok(DEFAULT_SIDE),
Some(n) => u32::try_from(n).ok().filter(|n| (1..=MAX_SIDE).contains(n)).ok_or_else(|| Args::wrong(key, &format!("between 1 and {MAX_SIDE}"))),
}
}
/// `direction`: three finite numbers, not all zero, from the eye towards the model.
fn direction(a: &Args) -> Result<[f64; 3]> {
let Some(v) = a.get("direction") else { return Ok(DEFAULT_DIRECTION) };
let wrong = || Args::wrong("direction", "three numbers [x, y, z], not all zero");
let items = v.as_array().filter(|x| x.len() == 3).ok_or_else(wrong)?;
let mut out = [0.0; 3];
for (o, x) in out.iter_mut().zip(items) {
*o = x.as_f64().filter(|f| f.is_finite()).ok_or_else(wrong)?;
}
if out.iter().all(|c| *c == 0.0) {
return Err(wrong());
}
Ok(out)
}
/// The keys `orbit` takes.
const ORBIT_KEYS: [&str; 7] = ["view", "up", "front", "turn", "zoom", "zoom_at", "pan"];
/// The camera `orbit` names: a named view about an up axis, then a turn, a zoom and a pan, as a
/// viewer's orbit control would apply them.
fn orbit_camera(moves: &Value, meshes: &[WorldMesh], perspective: bool, aspect: f64) -> Result<Camera> {
let wrong = |what: &str| Args::wrong("orbit", what);
let moves = moves.as_object().ok_or_else(|| wrong("an object"))?;
if let Some(key) = moves.keys().find(|k| !ORBIT_KEYS.contains(&k.as_str())) {
return Err(wrong(&format!("an object without {key}; its keys are {}", ORBIT_KEYS.join(", "))));
}
let token = |key: &str| moves.get(key).map(|v| v.as_str().ok_or_else(|| wrong(&format!("{key} as a string"))));
let axis = |key: &str| token(key).map(|t| Axis::from_token(t?).ok_or_else(|| wrong(&format!("{key} as one of +x, +y, +z, -x, -y, -z"))));
let mut upright = Upright::default();
if let Some(up) = axis("up") {
upright = upright.with_up(up?);
}
if let Some(front) = axis("front") {
let front = front?;
if !upright.up().perpendicular(front) {
return Err(wrong("front perpendicular to up"));
}
upright = upright.with_front(front);
}
let view = match token("view") {
None => NamedView::Isometric,
Some(t) => NamedView::from_token(t?).ok_or_else(|| wrong("view as one of iso, front, right, top, back"))?,
};
let mut orbit = Orbit::named(view, perspective, upright);
let bounds = Bounds::of(meshes).ok_or_else(|| failed("the model has no vertex"))?;
let reach = reach(meshes, &bounds);
if let Some(v) = moves.get("turn") {
let [yaw, pitch] = pair(v).ok_or_else(|| wrong("turn as [yaw, pitch] in degrees"))?;
orbit.turn(yaw.to_radians(), pitch.to_radians());
}
if let Some(v) = moves.get("zoom") {
let factor = v.as_f64().filter(|f| f.is_finite() && *f > 0.0).ok_or_else(|| wrong("zoom as a positive number"))?;
let at = match moves.get("zoom_at") {
None => [0.0, 0.0],
Some(v) => pair(v).ok_or_else(|| wrong("zoom_at as [right, up] in picture heights"))?,
};
orbit.zoom_at(&bounds, reach, aspect, factor, at);
} else if moves.contains_key("zoom_at") {
return Err(wrong("an object with zoom when it has zoom_at"));
}
if let Some(v) = moves.get("pan") {
orbit.pan_by(pair(v).ok_or_else(|| wrong("pan as [right, up] in model radii"))?);
}
orbit.camera(&bounds, reach, aspect).map_err(failed)
}
/// Two finite numbers.
fn pair(v: &Value) -> Option<[f64; 2]> {
match v.as_array()?.as_slice() {
[a, b] => Some([a.as_f64().filter(|f| f.is_finite())?, b.as_f64().filter(|f| f.is_finite())?]),
_ => None,
}
}
/// Z is up, as CAD models are drawn, unless the view looks along it.
fn up_for([x, y, z]: [f64; 3]) -> [f64; 3] {
if x.hypot(y) <= z.abs() * 1e-6 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] }
}
/// Straight RGBA to the premultiplied RGBA `encode_png` takes.
fn premultiplied(mut rgba: Vec<u8>) -> Vec<u8> {
for [r, g, b, alpha] in rgba.as_chunks_mut::<4>().0 {
let a = u16::from(*alpha);
for c in [r, g, b] {
*c = u8::try_from((u16::from(*c) * a + 127) / 255).unwrap_or(u8::MAX);
}
}
rgba
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn premultiplying_scales_colour_by_alpha() {
assert_eq!(premultiplied(vec![200, 100, 0, 255, 200, 100, 0, 128, 9, 9, 9, 0]), vec![200, 100, 0, 255, 100, 50, 0, 128, 0, 0, 0, 0]);
}
#[test]
fn a_view_along_z_keeps_y_up() {
assert_eq!(up_for([0.0, 0.0, -1.0]), [0.0, 1.0, 0.0]);
assert_eq!(up_for(DEFAULT_DIRECTION), [0.0, 0.0, 1.0]);
}
}
Loaded 100 of 1024 files, more files were not shown because too many files have changed in this diff. Show more