Merge storytold/pdfcraft main into fix/xfa-subform-minh

Conflicts:
- crates/xfa/src/tests.rs: both sides appended tests at the end of the
  file; kept this branch's row minH test and main's draw/anchor tests,
  closing the branch's test before main's block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
This commit is contained in:
mtlcl410
2026-11-02 22:21:47 +08:00
co-authored by Claude Opus 5.5
533 changed files with 69684 additions and 4748 deletions
+7
View File
@@ -1,3 +1,10 @@
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
nextest-version = "0.9.150"
# These measurements require a quiet runner. Their in-process SERIAL mutex only
# serializes cargo test; nextest gives each test its own process. Reserve all slots
# so other tests cannot charge their CPU/render-worker time to a frame budget.
[[profile.default.overrides]]
filter = 'package(pdfcraft-ui-egui) & binary(perf)'
threads-required = "num-test-threads"
+1
View File
@@ -12,6 +12,7 @@ on:
permissions:
contents: write
packages: write
concurrency:
group: release-${{ github.ref }}
+2 -1
View File
@@ -27,7 +27,8 @@ permissions:
jobs:
windows-arm64:
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
# 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/windows-arm64.yml@main
with:
app-name: pdfcraft
app-dir: PdfCraft
+1
View File
@@ -9,3 +9,4 @@ plan/
/assets/models
# A craft-fonts checkout used as the optional CRAFT_FONTS_DIR build input (CI and releases put it here).
/craft-fonts
.claude/
+11 -10
View File
@@ -2,8 +2,6 @@
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.**
Some sections link to **craftrules**, the standards shared by the Crafting Apps. That repository is internal to the storytold team, so outside contributors can't open it. Every rule a contributor here must follow is stated in this file and `CLAUDE.md`; the craftrules links give team members the background.
---
## 1. Asset policy (absolute — no exceptions)
@@ -69,9 +67,10 @@ Anything else is forbidden, including assets whose licence you cannot prove from
### 1.4 Fonts live in craft-fonts
- Font assets shared by the Crafting Apps, including every Japanese font, live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), not here. **Never commit a font file to this repository**; adding a font means adding it to craft-fonts. (The Latin UI fonts already in `assets/fonts/` — Inter, JetBrains Mono, Dancing Script — stay.) Team members: the shared rules are in [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md) (internal).
- Font assets shared by the Crafting Apps, including every Japanese font, live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), not here. **Never commit a font file to this repository**; adding a font means adding it to craft-fonts. (The Latin UI fonts already in `assets/fonts/` — Inter, JetBrains Mono, Dancing Script — stay.)
- craft-fonts is an **optional build input**, never a Cargo dependency: `CRAFT_FONTS_DIR=<checkout> cargo build` makes `crates/fonts/build.rs` embed its fonts as `pdfcraft_fonts::CRAFT_FONTS`. Without it PdfCraft builds, tests and runs, but has no Japanese face (Japanese UI text shows replacement boxes, and writing Japanese into a PDF returns a clear error). Release builds always set it (`.github/workflows/release.yml`), and `ATTRIBUTION.toml` lists it as a `[[build_input]]`.
- The desktop app may read one font **already installed on the user's machine** at runtime, as the last interface fallback for characters no embedded face has (`crates/ui-egui/src/system_fonts.rs`). That font is never embedded, shipped, committed or copied, and never appears in published screenshots (`PDFCRAFT_SYSTEM_FONTS=0`, which `cargo xtask screenshots` sets). It is not a way around §1.2: nothing from it may enter a PDF, a package or the repository.
- The page viewer may also draw a CID font of an Adobe CJK collection (Japan1, GB1, CNS1, Korea1) that a PDF does **not embed** with one face already installed on the machine (`crates/render/src/system_font.rs`), **on screen only**. `RenderConfig::system_fonts` is off by default and is enabled only for on-screen viewer and preview rasters (`RenderConfig::on_screen`, used by the engine viewer, Combine thumbnails and accessibility figure previews), so exports, printing, OCR and the automation tools never use it. Like the interface font it is read at runtime and never embedded, shipped, committed or copied; Noto CJK and Source Han are never candidates (§1.1), nor is any Adobe family; and `PDFCRAFT_SYSTEM_FONTS=0` turns it off. It is not a way around §1.2 either: nothing drawn with it may be saved or printed.
### 1.5 Procedure for adding or changing an asset
@@ -92,7 +91,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi
- Behaviour comes only from public specifications (ISO 32000-2, the Arlington model), public documentation and black-box observation.
- Never copy GPL/AGPL code. Copyleft tools run only as external oracle processes.
- Observe Acrobat only with synthetic fixtures. Never capture personal data, account information or recent files.
- Shared real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md) (internal). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- Shared real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
## 3. Agent control (automation and MCP)
@@ -105,7 +104,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi
## 4. Never crash
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; the shared standard for every Crafting App, [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md), is internal to the storytold team.
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`).
- **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.
@@ -124,17 +123,19 @@ Follow `CLAUDE.md`. It covers quality gates, layering, commit rules and how to r
## Contributor credits (About window)
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
(commit stats; never hand-edit) and `contributors/people.toml` (names people chose for
themselves). See `docs/contributors.md`.
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
(copy the template at the top of the file)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
+204 -204
View File
@@ -36,181 +36,181 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `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 |
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | 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 | 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 | 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 |
| `assets/icons/square-terminal.svg` | Lucide icon "square-terminal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-terminal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-code.svg` | Lucide icon "file-code" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-code.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-spreadsheet.svg` | Lucide icon "file-spreadsheet" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-spreadsheet.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-justify.svg` | Lucide icon "align-justify" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-justify.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-right.svg` | Lucide icon "align-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/columns-3.svg` | Lucide icon "columns-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/rows-3.svg` | Lucide icon "rows-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rows-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/flip-horizontal-2.svg` | Lucide icon "flip-horizontal-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-horizontal-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/flip-vertical-2.svg` | Lucide icon "flip-vertical-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-vertical-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/app-window.svg` | Lucide icon "app-window" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/app-window.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/arrow-left-right.svg` | Lucide icon "arrow-left-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-left-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/arrow-up-down.svg` | Lucide icon "arrow-up-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/arrow-up-right.svg` | Lucide icon "arrow-up-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/badge-check.svg` | Lucide icon "badge-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/badge-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/bandage.svg` | Lucide icon "bandage" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bandage.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/bell.svg` | Lucide icon "bell" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bell.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/blend.svg` | Lucide icon "blend" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/blend.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/bold.svg` | Lucide icon "bold" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bold.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/book-open.svg` | Lucide icon "book-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/book-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/bookmark.svg` | Lucide icon "bookmark" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/bookmark-plus.svg` | Lucide icon "bookmark-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/braces.svg` | Lucide icon "braces" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/braces.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/brush-cleaning.svg` | Lucide icon "brush-cleaning" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush-cleaning.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/brush.svg` | Lucide icon "brush" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/camera.svg` | Lucide icon "camera" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/camera.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/check-circle-2.svg` | Lucide icon "check-circle-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check-circle-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/check.svg` | Lucide icon "check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevron-down.svg` | Lucide icon "chevron-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevron-left.svg` | Lucide icon "chevron-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevron-right.svg` | Lucide icon "chevron-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevron-up.svg` | Lucide icon "chevron-up" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | 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 | 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 | 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 | 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-dashed.svg` | Lucide icon "circle-dashed" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | 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 | 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 | 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 |
| `assets/icons/circle.svg` | Lucide icon "circle" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/clock-3.svg` | Lucide icon "clock-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/clock.svg` | Lucide icon "clock" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/cloud-upload.svg` | Lucide icon "cloud-upload" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud-upload.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/cloud.svg` | Lucide icon "cloud" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/code-xml.svg` | Lucide icon "code-xml" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/code-xml.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/columns-2.svg` | Lucide icon "columns-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/compass.svg` | Lucide icon "compass" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/compass.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/contrast.svg` | Lucide icon "contrast" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/contrast.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/copy-plus.svg` | Lucide icon "copy-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/copy.svg` | Lucide icon "copy" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/crop.svg` | Lucide icon "crop" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/crop.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/droplet.svg` | Lucide icon "droplet" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/droplet.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/ellipsis-vertical.svg` | Lucide icon "ellipsis-vertical" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis-vertical.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/ellipsis.svg` | Lucide icon "ellipsis" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/eraser.svg` | Lucide icon "eraser" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eraser.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/external-link.svg` | Lucide icon "external-link" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/external-link.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/eye-off.svg` | Lucide icon "eye-off" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye-off.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/eye.svg` | Lucide icon "eye" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-check.svg` | Lucide icon "file-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-down.svg` | Lucide icon "file-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-input.svg` | Lucide icon "file-input" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-input.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-lock-2.svg` | Lucide icon "file-lock-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-lock-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-minus.svg` | Lucide icon "file-minus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-minus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-output.svg` | Lucide icon "file-output" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-output.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-pen-line.svg` | Lucide icon "file-pen-line" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-pen-line.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-plus-2.svg` | Lucide icon "file-plus-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-plus.svg` | Lucide icon "file-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-scan.svg` | Lucide icon "file-scan" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-scan.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-search.svg` | Lucide icon "file-search" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-search.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-symlink.svg` | Lucide icon "file-symlink" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-symlink.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-text.svg` | Lucide icon "file-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-x.svg` | Lucide icon "file-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/files.svg` | Lucide icon "files" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/files.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/film.svg` | Lucide icon "film" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/film.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/filter.svg` | Lucide icon "filter" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/filter.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/folder-open.svg` | Lucide icon "folder-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/folder-plus.svg` | Lucide icon "folder-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/folder.svg` | Lucide icon "folder" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/gauge.svg` | Lucide icon "gauge" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/gauge.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/git-compare.svg` | Lucide icon "git-compare" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/git-compare.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/globe.svg` | Lucide icon "globe" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/globe.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/grid-3x3.svg` | Lucide icon "grid-3x3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/grid-3x3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/hand.svg` | Lucide icon "hand" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hand.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/hash.svg` | Lucide icon "hash" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hash.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/heading.svg` | Lucide icon "heading" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/heading.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/highlighter.svg` | Lucide icon "highlighter" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/highlighter.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/house.svg` | Lucide icon "house" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/house.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/image-plus.svg` | Lucide icon "image-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/image.svg` | Lucide icon "image" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/info.svg` | Lucide icon "info" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/info.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/italic.svg` | Lucide icon "italic" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/italic.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/languages.svg` | Lucide icon "languages" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/languages.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/lasso.svg` | Lucide icon "lasso" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lasso.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/layers.svg` | Lucide icon "layers" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layers.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/layout-grid.svg` | Lucide icon "layout-grid" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layout-grid.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/link-2.svg` | Lucide icon "link-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/link.svg` | Lucide icon "link" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/list-checks.svg` | Lucide icon "list-checks" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-checks.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/list-ordered.svg` | Lucide icon "list-ordered" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-ordered.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/list.svg` | Lucide icon "list" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/lock-open.svg` | Lucide icon "lock-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/lock.svg` | Lucide icon "lock" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/maximize-2.svg` | Lucide icon "maximize-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/maximize.svg` | Lucide icon "maximize" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-circle-reply.svg` | Lucide icon "message-circle-reply" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-circle-reply.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square-text.svg` | Lucide icon "message-square-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square.svg` | Lucide icon "message-square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/messages-square.svg` | Lucide icon "messages-square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/messages-square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/mic.svg` | Lucide icon "mic" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mic.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/minimize.svg` | Lucide icon "minimize" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/minimize-2.svg` | Lucide icon "minimize-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/minus.svg` | Lucide icon "minus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/moon.svg` | Lucide icon "moon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/moon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/mouse-pointer-2.svg` | Lucide icon "mouse-pointer-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mouse-pointer-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/move.svg` | Lucide icon "move" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/navigation.svg` | Lucide icon "navigation" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/navigation.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/paint-bucket.svg` | Lucide icon "paint-bucket" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paint-bucket.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/palette.svg` | Lucide icon "palette" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/palette.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/panel-left.svg` | Lucide icon "panel-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/panel-right.svg` | Lucide icon "panel-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/panels-top-left.svg` | Lucide icon "panels-top-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panels-top-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/paperclip.svg` | Lucide icon "paperclip" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paperclip.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pen-line.svg` | Lucide icon "pen-line" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-line.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pen-tool.svg` | Lucide icon "pen-tool" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-tool.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pencil.svg` | Lucide icon "pencil" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pencil.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pipette.svg` | Lucide icon "pipette" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pipette.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/plus.svg` | Lucide icon "plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/presentation.svg` | Lucide icon "presentation" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/presentation.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/printer.svg` | Lucide icon "printer" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/printer.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/rectangle-horizontal.svg` | Lucide icon "rectangle-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rectangle-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/redo-2.svg` | Lucide icon "redo-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/redo-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/replace.svg` | Lucide icon "replace" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/replace.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/rotate-ccw.svg` | Lucide icon "rotate-ccw" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-ccw.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/rotate-cw.svg` | Lucide icon "rotate-cw" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-cw.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/ruler.svg` | Lucide icon "ruler" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ruler.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/save.svg` | Lucide icon "save" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/save.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/scan-text.svg` | Lucide icon "scan-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/scan.svg` | Lucide icon "scan" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/scissors.svg` | Lucide icon "scissors" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scissors.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/search.svg` | Lucide icon "search" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/search.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/send.svg` | Lucide icon "send" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/send.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/settings-2.svg` | Lucide icon "settings-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/settings.svg` | Lucide icon "settings" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/share-2.svg` | Lucide icon "share-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/share-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/shield-check.svg` | Lucide icon "shield-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/shield-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/signature.svg` | Lucide icon "signature" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/signature.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/sliders-horizontal.svg` | Lucide icon "sliders-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sliders-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/sparkles.svg` | Lucide icon "sparkles" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sparkles.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/spline.svg` | Lucide icon "spline" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/spline.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/square-dashed-mouse-pointer.svg` | Lucide icon "square-dashed-mouse-pointer" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed-mouse-pointer.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/square-dashed.svg` | Lucide icon "square-dashed" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/square.svg` | Lucide icon "square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/squares-subtract.svg` | Lucide icon "squares-subtract" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/squares-subtract.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/stamp.svg` | Lucide icon "stamp" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/stamp.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/star.svg` | Lucide icon "star" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/star.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/sticky-note.svg` | Lucide icon "sticky-note" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sticky-note.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/strikethrough.svg` | Lucide icon "strikethrough" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/strikethrough.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/sun.svg` | Lucide icon "sun" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sun.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/swatch-book.svg` | Lucide icon "swatch-book" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/swatch-book.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/tag.svg` | Lucide icon "tag" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/tag.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/text-cursor-input.svg` | Lucide icon "text-cursor-input" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-cursor-input.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/text-select.svg` | Lucide icon "text-select" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | 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 | 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 | 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 | 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/type.svg` | Lucide icon "type" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | 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 | 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 | 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 |
| `assets/icons/user-round.svg` | Lucide icon "user-round" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/user-round.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/wand-sparkles.svg` | Lucide icon "wand-sparkles" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wand-sparkles.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/wrench.svg` | Lucide icon "wrench" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wrench.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/x.svg` | Lucide icon "x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | 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 | 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 | 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/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 |
| `assets/icons/square-terminal.svg` | Lucide icon "square-terminal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-terminal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-code.svg` | Lucide icon "file-code" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-code.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-spreadsheet.svg` | Lucide icon "file-spreadsheet" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-spreadsheet.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-justify.svg` | Lucide icon "align-justify" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-justify.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-right.svg` | Lucide icon "align-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/columns-3.svg` | Lucide icon "columns-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/rows-3.svg` | Lucide icon "rows-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rows-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/flip-horizontal-2.svg` | Lucide icon "flip-horizontal-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-horizontal-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/flip-vertical-2.svg` | Lucide icon "flip-vertical-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-vertical-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/app-window.svg` | Lucide icon "app-window" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/app-window.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/arrow-left-right.svg` | Lucide icon "arrow-left-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-left-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/arrow-up-down.svg` | Lucide icon "arrow-up-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/arrow-up-right.svg` | Lucide icon "arrow-up-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/badge-check.svg` | Lucide icon "badge-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/badge-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/bandage.svg` | Lucide icon "bandage" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bandage.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/bell.svg` | Lucide icon "bell" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bell.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/blend.svg` | Lucide icon "blend" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/blend.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/bold.svg` | Lucide icon "bold" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bold.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/book-open.svg` | Lucide icon "book-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/book-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/bookmark.svg` | Lucide icon "bookmark" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/bookmark-plus.svg` | Lucide icon "bookmark-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/braces.svg` | Lucide icon "braces" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/braces.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/brush-cleaning.svg` | Lucide icon "brush-cleaning" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush-cleaning.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/brush.svg` | Lucide icon "brush" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/camera.svg` | Lucide icon "camera" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/camera.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/check-circle-2.svg` | Lucide icon "check-circle-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check-circle-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/check.svg` | Lucide icon "check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevron-down.svg` | Lucide icon "chevron-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevron-left.svg` | Lucide icon "chevron-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevron-right.svg` | Lucide icon "chevron-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `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-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 |
| `assets/icons/circle.svg` | Lucide icon "circle" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/clock-3.svg` | Lucide icon "clock-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/clock.svg` | Lucide icon "clock" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/cloud-upload.svg` | Lucide icon "cloud-upload" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud-upload.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/cloud.svg` | Lucide icon "cloud" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/code-xml.svg` | Lucide icon "code-xml" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/code-xml.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/columns-2.svg` | Lucide icon "columns-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/compass.svg` | Lucide icon "compass" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/compass.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/contrast.svg` | Lucide icon "contrast" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/contrast.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/copy-plus.svg` | Lucide icon "copy-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/copy.svg` | Lucide icon "copy" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/crop.svg` | Lucide icon "crop" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/crop.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/droplet.svg` | Lucide icon "droplet" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/droplet.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/ellipsis-vertical.svg` | Lucide icon "ellipsis-vertical" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis-vertical.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/ellipsis.svg` | Lucide icon "ellipsis" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/eraser.svg` | Lucide icon "eraser" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eraser.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/external-link.svg` | Lucide icon "external-link" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/external-link.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/eye-off.svg` | Lucide icon "eye-off" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye-off.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/eye.svg` | Lucide icon "eye" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-check.svg` | Lucide icon "file-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-down.svg` | Lucide icon "file-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-input.svg` | Lucide icon "file-input" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-input.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-lock-2.svg` | Lucide icon "file-lock-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-lock-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-minus.svg` | Lucide icon "file-minus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-minus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-output.svg` | Lucide icon "file-output" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-output.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-pen-line.svg` | Lucide icon "file-pen-line" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-pen-line.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-plus-2.svg` | Lucide icon "file-plus-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-plus.svg` | Lucide icon "file-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-scan.svg` | Lucide icon "file-scan" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-scan.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-search.svg` | Lucide icon "file-search" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-search.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-symlink.svg` | Lucide icon "file-symlink" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-symlink.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-text.svg` | Lucide icon "file-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-x.svg` | Lucide icon "file-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/files.svg` | Lucide icon "files" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/files.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/film.svg` | Lucide icon "film" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/film.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/filter.svg` | Lucide icon "filter" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/filter.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/folder-open.svg` | Lucide icon "folder-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/folder-plus.svg` | Lucide icon "folder-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/folder.svg` | Lucide icon "folder" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/gauge.svg` | Lucide icon "gauge" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/gauge.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/git-compare.svg` | Lucide icon "git-compare" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/git-compare.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/globe.svg` | Lucide icon "globe" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/globe.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/grid-3x3.svg` | Lucide icon "grid-3x3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/grid-3x3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/hand.svg` | Lucide icon "hand" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hand.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/hash.svg` | Lucide icon "hash" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hash.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/heading.svg` | Lucide icon "heading" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/heading.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/highlighter.svg` | Lucide icon "highlighter" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/highlighter.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/house.svg` | Lucide icon "house" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/house.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/image-plus.svg` | Lucide icon "image-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/image.svg` | Lucide icon "image" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/info.svg` | Lucide icon "info" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/info.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/italic.svg` | Lucide icon "italic" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/italic.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/languages.svg` | Lucide icon "languages" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/languages.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/lasso.svg` | Lucide icon "lasso" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lasso.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/layers.svg` | Lucide icon "layers" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layers.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/layout-grid.svg` | Lucide icon "layout-grid" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layout-grid.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/link-2.svg` | Lucide icon "link-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/link.svg` | Lucide icon "link" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/list-checks.svg` | Lucide icon "list-checks" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-checks.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/list-ordered.svg` | Lucide icon "list-ordered" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-ordered.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/list.svg` | Lucide icon "list" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/lock-open.svg` | Lucide icon "lock-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/lock.svg` | Lucide icon "lock" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/maximize-2.svg` | Lucide icon "maximize-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/maximize.svg` | Lucide icon "maximize" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-circle-reply.svg` | Lucide icon "message-circle-reply" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-circle-reply.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square-text.svg` | Lucide icon "message-square-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square.svg` | Lucide icon "message-square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/messages-square.svg` | Lucide icon "messages-square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/messages-square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/mic.svg` | Lucide icon "mic" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mic.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/minimize.svg` | Lucide icon "minimize" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/minimize-2.svg` | Lucide icon "minimize-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/minus.svg` | Lucide icon "minus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/moon.svg` | Lucide icon "moon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/moon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/mouse-pointer-2.svg` | Lucide icon "mouse-pointer-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mouse-pointer-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/move.svg` | Lucide icon "move" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/navigation.svg` | Lucide icon "navigation" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/navigation.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/paint-bucket.svg` | Lucide icon "paint-bucket" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paint-bucket.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/palette.svg` | Lucide icon "palette" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/palette.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/panel-left.svg` | Lucide icon "panel-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/panel-right.svg` | Lucide icon "panel-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/panels-top-left.svg` | Lucide icon "panels-top-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panels-top-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/paperclip.svg` | Lucide icon "paperclip" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paperclip.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pen-line.svg` | Lucide icon "pen-line" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-line.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pen-tool.svg` | Lucide icon "pen-tool" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-tool.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pencil.svg` | Lucide icon "pencil" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pencil.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pipette.svg` | Lucide icon "pipette" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pipette.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/plus.svg` | Lucide icon "plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/presentation.svg` | Lucide icon "presentation" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/presentation.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/printer.svg` | Lucide icon "printer" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/printer.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/rectangle-horizontal.svg` | Lucide icon "rectangle-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rectangle-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/redo-2.svg` | Lucide icon "redo-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/redo-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/replace.svg` | Lucide icon "replace" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/replace.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/rotate-ccw.svg` | Lucide icon "rotate-ccw" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-ccw.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/rotate-cw.svg` | Lucide icon "rotate-cw" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-cw.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/ruler.svg` | Lucide icon "ruler" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ruler.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/save.svg` | Lucide icon "save" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/save.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/scan-text.svg` | Lucide icon "scan-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/scan.svg` | Lucide icon "scan" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/scissors.svg` | Lucide icon "scissors" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scissors.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/search.svg` | Lucide icon "search" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/search.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/send.svg` | Lucide icon "send" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/send.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/settings-2.svg` | Lucide icon "settings-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/settings.svg` | Lucide icon "settings" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/share-2.svg` | Lucide icon "share-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/share-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/shield-check.svg` | Lucide icon "shield-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/shield-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/signature.svg` | Lucide icon "signature" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/signature.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/sliders-horizontal.svg` | Lucide icon "sliders-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sliders-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/sparkles.svg` | Lucide icon "sparkles" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sparkles.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/spline.svg` | Lucide icon "spline" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/spline.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/square-dashed-mouse-pointer.svg` | Lucide icon "square-dashed-mouse-pointer" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed-mouse-pointer.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/square-dashed.svg` | Lucide icon "square-dashed" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/square.svg` | Lucide icon "square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/squares-subtract.svg` | Lucide icon "squares-subtract" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/squares-subtract.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/stamp.svg` | Lucide icon "stamp" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/stamp.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/star.svg` | Lucide icon "star" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/star.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/sticky-note.svg` | Lucide icon "sticky-note" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sticky-note.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/strikethrough.svg` | Lucide icon "strikethrough" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/strikethrough.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/sun.svg` | Lucide icon "sun" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sun.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/swatch-book.svg` | Lucide icon "swatch-book" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/swatch-book.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/tag.svg` | Lucide icon "tag" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/tag.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/text-cursor-input.svg` | Lucide icon "text-cursor-input" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-cursor-input.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `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/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 |
| `assets/icons/user-round.svg` | Lucide icon "user-round" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/user-round.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/wand-sparkles.svg` | Lucide icon "wand-sparkles" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wand-sparkles.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/wrench.svg` | Lucide icon "wrench" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wrench.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `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-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 |
@@ -240,22 +240,22 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `docs/brand/artcraft-mark-black.svg` | ArtCraft brand: artcraft-mark-black | 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-mark.png` | ArtCraft brand: artcraft-mark | 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-mark.svg` | ArtCraft brand: artcraft-mark | 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. |
| `assets/app-icon/pdfcraft.svg` | PdfCraft app icon (master vector) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Master artwork; every other app-icon file is rendered from it (packaging/icons.sh) |
| `assets/app-icon/pdfcraft-small.svg` | PdfCraft app icon (light vector) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | README header and assets/app-icon/README.md |
| `assets/app-icon/pdfcraft-1024.png` | PdfCraft app icon, 1024 px on Apple's icon grid | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Runtime window/Dock icon on macOS (apps/pdfcraft/src/main.rs) |
| `assets/app-icon/pdfcraft.icns` | PdfCraft app icon (macOS .icns) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | macOS app bundle icon |
| `assets/app-icon/pdfcraft.ico` | PdfCraft app icon (Windows .ico, 16-256 px) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Embedded in pdfcraft.exe by apps/pdfcraft/build.rs (Explorer, taskbar, Start menu, Alt-Tab) |
| `assets/app-icon/pdfcraft.svg` | PdfCraft app icon (master vector) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Master artwork; every other app-icon file is rendered from it (packaging/icons.sh) |
| `assets/app-icon/pdfcraft-small.svg` | PdfCraft app icon (light vector) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | README header and assets/app-icon/README.md |
| `assets/app-icon/pdfcraft-1024.png` | PdfCraft app icon, 1024 px on Apple's icon grid | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Runtime window/Dock icon on macOS (apps/pdfcraft/src/main.rs) |
| `assets/app-icon/pdfcraft.icns` | PdfCraft app icon (macOS .icns) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | macOS app bundle icon |
| `assets/app-icon/pdfcraft.ico` | PdfCraft app icon (Windows .ico, 16-256 px) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Embedded in pdfcraft.exe by apps/pdfcraft/build.rs (Explorer, taskbar, Start menu, Alt-Tab) |
| `assets/app-icon/pdfcraft-document.svg` | PdfCraft PDF document icon (master vector) | Lucide Icons and Contributors (document outline); PdfCraft contributors (PDF badge, lettering and composition) | ISC AND (MIT OR Apache-2.0) | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg); contributor-original composition (assets/app-icon/README.md) | Master artwork for the Windows PDF file type icon; rendered by packaging/icons.sh, without fonts or external images |
| `assets/app-icon/pdfcraft-document.ico` | PdfCraft PDF document icon (Windows .ico, 16-256 px) | Lucide Icons and Contributors (document outline); PdfCraft contributors (PDF badge, lettering and composition) | ISC AND (MIT OR Apache-2.0) | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg); contributor-original composition (assets/app-icon/README.md) | Embedded in pdfcraft.exe as resource 2; the Windows installer uses it for PdfCraft.Document and Applications/pdfcraft.exe PDF file associations |
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 16 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 24 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 32 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 48 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 64 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 128 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 256 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop); runtime window icon on Windows and Linux (apps/pdfcraft/src/main.rs) |
| `assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 512 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg` | PdfCraft app icon (Linux scalable) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 16 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 24 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 32 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 48 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 64 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 128 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 256 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop); runtime window icon on Windows and Linux (apps/pdfcraft/src/main.rs) |
| `assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 512 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg` | PdfCraft app icon (Linux scalable) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `docs/images/pdfcraft-bookmarks.png` | PdfCraft screenshot: Bookmarks | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: navigation. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
| `docs/images/pdfcraft-dark.png` | PdfCraft screenshot: Dark theme | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: themes. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
| `docs/images/pdfcraft-find.png` | PdfCraft screenshot: Find text | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: search. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
@@ -269,31 +269,31 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `docs/images/pdfcraft-tools.png` | PdfCraft screenshot: All tools | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: tool catalogue. Shows PdfCraft UI, Lucide icons, the showcase PDF built from the [[fetched]] OFL fonts, and the ArtCraft mark (Storyteller trademark from docs/brand/, not covered by this licence) |
| `docs/images/pdfcraft-twoup.png` | PdfCraft screenshot: Two-up reading | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: layouts. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
| `docs/images/pdfcraft-viewer.png` | PdfCraft screenshot: Viewer with comments | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README hero. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
| `assets/icons/pin.svg` | Lucide icon "pin" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pin.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/move-right.svg` | Lucide icon "move-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/reply.svg` | Lucide icon "reply" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/reply.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/thumbs-up.svg` | Lucide icon "thumbs-up" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | 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 | 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 | 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 | 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/pin.svg` | Lucide icon "pin" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pin.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/move-right.svg` | Lucide icon "move-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/reply.svg` | Lucide icon "reply" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/reply.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `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 |
| `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 | 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 | 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 |
| `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 |
| `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): a signature PdfCraft did not make |
| `assets/icons/clipboard-paste.svg` | Lucide icon "clipboard-paste" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/clipboard-paste.svg` | Lucide icon "clipboard-paste" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/fonts/DancingScript.ttf` | Dancing Script (variable) | The Dancing Script Project Authors (Pablo Impallari) | OFL-1.1 | https://github.com/google/fonts/blob/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/dancingscript/DancingScript%5Bwght%5D.ttf | Fill & Sign typed signatures and initials: the typed name's glyph outlines, drawn as filled paths (the font file itself is not embedded in PDFs) |
| `packaging/macos/dmg/background.svg` | PdfCraft macOS DMG window background | @XusBadia | MIT OR Apache-2.0 | Original work for this repository, derived from the PdfCraft app icon (assets/app-icon/pdfcraft.svg, referenced via an SVG image element, not copied) | Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py |
| `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 | 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 |
| `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 | 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 | 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 | 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/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | 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 |
| `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/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 |
+222 -222
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -4,7 +4,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
## 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 follow craftrules `standards/progress-docs.md`: status line, revision history).
- 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.
@@ -13,7 +13,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
## 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; team members: [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md), internal). 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.
- **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/`).
@@ -22,6 +22,8 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
- **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.
@@ -30,7 +32,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
- 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 (and, for team members, the internal `craftrules/standards/never-crash.md`).
- 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.
@@ -56,3 +58,8 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
## 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.
Generated
+77 -60
View File
@@ -229,7 +229,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -1178,17 +1178,17 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -1211,7 +1211,7 @@ dependencies = [
[[package]]
name = "craft-keymap"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
dependencies = [
"egui",
"serde",
@@ -1221,7 +1221,7 @@ dependencies = [
[[package]]
name = "craft-menu"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
dependencies = [
"craft-keymap",
"craft-quit",
@@ -1234,9 +1234,10 @@ dependencies = [
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
dependencies = [
"craft-winprint-gdi",
"log",
"miniz_oxide 0.9.1",
"roxmltree 0.21.2",
"tempfile",
@@ -1247,7 +1248,7 @@ dependencies = [
[[package]]
name = "craft-quit"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
dependencies = [
"dispatch2",
"egui",
@@ -1258,7 +1259,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -1279,13 +1280,14 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
dependencies = [
"chrono",
"dunce",
"egui",
"flate2",
"getrandom 0.3.4",
"image",
"log",
"plist",
"semver",
@@ -2015,7 +2017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2731,6 +2733,7 @@ version = "0.3.0"
dependencies = [
"fearless_simd",
"hayro-ccitt",
"image",
]
[[package]]
@@ -4114,7 +4117,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4205,7 +4208,7 @@ dependencies = [
[[package]]
name = "pdfcraft"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"chrono",
"craft-update",
@@ -4229,7 +4232,7 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -4241,7 +4244,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -4252,7 +4255,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -4263,7 +4266,7 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"base64 0.23.1",
"flate2",
@@ -4285,7 +4288,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cli"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"base64 0.23.1",
"pdfcraft-audit",
@@ -4303,11 +4306,11 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
version = "0.14.0"
version = "0.17.0"
[[package]]
name = "pdfcraft-content"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -4315,7 +4318,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cos"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"chrono",
"hayro-syntax",
@@ -4330,14 +4333,17 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"encoding_rs",
"hayro-ccitt",
"hayro-syntax",
"image",
"pdfcraft-cos",
"pdfcraft-filters",
"pdfcraft-fonts",
"pdfcraft-model",
"pdfcraft-xfa",
"png 0.18.1",
"thiserror 2.0.21",
"tiff",
@@ -4346,7 +4352,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"aes",
"md-5",
@@ -4358,7 +4364,7 @@ dependencies = [
[[package]]
name = "pdfcraft-edit"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"hayro-syntax",
"kurbo 0.13.1",
@@ -4367,16 +4373,20 @@ dependencies = [
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-model",
"pdfcraft-render",
"proptest",
"sha2 0.11.1",
"thiserror 2.0.21",
"unicode-bidi",
]
[[package]]
name = "pdfcraft-engine"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"chrono",
"craft-docpath",
"getrandom 0.4.3",
"icu_properties",
"idna",
"image",
@@ -4384,10 +4394,12 @@ dependencies = [
"pdfcraft-annot",
"pdfcraft-audit",
"pdfcraft-compare",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-create",
"pdfcraft-edit",
"pdfcraft-export",
"pdfcraft-filters",
"pdfcraft-fonts",
"pdfcraft-forms",
"pdfcraft-geom",
@@ -4414,16 +4426,19 @@ dependencies = [
[[package]]
name = "pdfcraft-export"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"fax",
"flate2",
"hayro-ccitt",
"hayro-jbig2",
"proptest",
"thiserror 2.0.21",
"weezl 0.2.1",
@@ -4431,7 +4446,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -4441,7 +4456,7 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4454,11 +4469,11 @@ dependencies = [
[[package]]
name = "pdfcraft-geom"
version = "0.14.0"
version = "0.17.0"
[[package]]
name = "pdfcraft-js"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"boa_engine",
"chrono",
@@ -4466,7 +4481,7 @@ dependencies = [
[[package]]
name = "pdfcraft-measure"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4479,14 +4494,14 @@ dependencies = [
[[package]]
name = "pdfcraft-model"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"ocrs",
"pdfcraft-fonts",
@@ -4497,7 +4512,7 @@ dependencies = [
[[package]]
name = "pdfcraft-optimize"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"image",
"pdfcraft-annot",
@@ -4506,17 +4521,18 @@ dependencies = [
"pdfcraft-filters",
"pdfcraft-model",
"pdfcraft-render",
"proptest",
"thiserror 2.0.21",
"zune-jpeg",
]
[[package]]
name = "pdfcraft-oracle"
version = "0.14.0"
version = "0.17.0"
[[package]]
name = "pdfcraft-organize"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4527,7 +4543,7 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"async-channel",
"futures-lite",
@@ -4537,18 +4553,18 @@ dependencies = [
[[package]]
name = "pdfcraft-preflight"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"flate2",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-model",
"roxmltree 0.20.0",
"roxmltree 0.21.2",
]
[[package]]
name = "pdfcraft-print"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"craft-print",
"pdfcraft-content",
@@ -4560,12 +4576,13 @@ dependencies = [
[[package]]
name = "pdfcraft-redact"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-edit",
"pdfcraft-filters",
"pdfcraft-fonts",
"pdfcraft-forms",
"pdfcraft-model",
@@ -4576,7 +4593,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"bytemuck",
"flate2",
@@ -4595,7 +4612,7 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"aes",
"aws-lc-rs",
@@ -4631,11 +4648,11 @@ dependencies = [
[[package]]
name = "pdfcraft-testkit"
version = "0.14.0"
version = "0.17.0"
[[package]]
name = "pdfcraft-ui-egui"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"arboard",
"base64 0.23.1",
@@ -4659,6 +4676,7 @@ dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-engine",
"pdfcraft-fonts",
@@ -4669,6 +4687,7 @@ dependencies = [
"plist",
"png 0.18.1",
"pollster 1.0.1",
"proptest",
"rfd",
"rustix 1.1.5",
"serde",
@@ -4687,7 +4706,7 @@ dependencies = [
[[package]]
name = "pdfcraft-web"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"eframe",
"egui",
@@ -4699,27 +4718,27 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"base64 0.23.1",
"image",
"pdfcraft-cos",
"pdfcraft-fonts",
"png 0.18.1",
"roxmltree 0.20.0",
"roxmltree 0.21.2",
"sha2 0.11.1",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-xfdf"
version = "0.14.0"
version = "0.17.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
"pdfcraft-forms",
"pdfcraft-model",
"roxmltree 0.20.0",
"roxmltree 0.21.2",
"thiserror 2.0.21",
]
@@ -5258,7 +5277,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5795,7 +5814,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5865,7 +5884,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6755,7 +6774,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7350,8 +7369,6 @@ dependencies = [
[[package]]
name = "vello_cpu"
version = "0.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0"
dependencies = [
"bytemuck",
"glifo 0.1.1",
@@ -7868,7 +7885,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8595,9 +8612,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.1.0"
version = "9.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704"
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
dependencies = [
"crc32fast",
"flate2",
+48 -41
View File
@@ -5,7 +5,7 @@ exclude = ["vendor"]
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
[workspace.package]
version = "0.14.0"
version = "0.17.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -13,51 +13,52 @@ repository = "https://github.com/storytold/pdfcraft"
[workspace.dependencies]
# Shared frontend components; engines remain independent of egui.
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", features = ["serde"] }
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:`.
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
# 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 = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", default-features = false }
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 }
# Shared system spooler; document composition remains in pdfcraft-print.
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
# 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 = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", features = ["egui"] }
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"] }
# internal
pdfcraft-geom = { path = "crates/geom", version = "0.14.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.14.0" }
pdfcraft-cos = { path = "crates/cos", version = "0.14.0" }
pdfcraft-crypt = { path = "crates/crypt", version = "0.14.0" }
pdfcraft-organize = { path = "crates/organize", version = "0.14.0" }
pdfcraft-measure = { path = "crates/measure", version = "0.14.0" }
pdfcraft-annot = { path = "crates/annot", version = "0.14.0" }
pdfcraft-audit = { path = "crates/audit", version = "0.14.0" }
pdfcraft-fonts = { path = "crates/fonts", version = "0.14.0" }
pdfcraft-model = { path = "crates/model", version = "0.14.0" }
pdfcraft-content = { path = "crates/content", version = "0.14.0" }
pdfcraft-redact = { path = "crates/redact", version = "0.14.0" }
pdfcraft-print = { path = "crates/print", version = "0.14.0" }
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.14.0" }
pdfcraft-sign = { path = "crates/sign", version = "0.14.0" }
pdfcraft-optimize = { path = "crates/optimize", version = "0.14.0" }
pdfcraft-a11y = { path = "crates/a11y", version = "0.14.0" }
pdfcraft-oracle = { path = "crates/oracle", version = "0.14.0" }
pdfcraft-testkit = { path = "crates/testkit", version = "0.14.0" }
pdfcraft-ocr = { path = "crates/ocr", version = "0.14.0" }
pdfcraft-js = { path = "crates/js", version = "0.14.0" }
pdfcraft-compare = { path = "crates/compare", version = "0.14.0" }
pdfcraft-preflight = { path = "crates/preflight", version = "0.14.0" }
pdfcraft-export = { path = "crates/export", version = "0.14.0" }
pdfcraft-edit = { path = "crates/edit", version = "0.14.0" }
pdfcraft-create = { path = "crates/create", version = "0.14.0" }
pdfcraft-forms = { path = "crates/forms", version = "0.14.0" }
pdfcraft-xfa = { path = "crates/xfa", version = "0.14.0" }
pdfcraft-render = { path = "crates/render", version = "0.14.0" }
pdfcraft-engine = { path = "crates/engine", version = "0.14.0" }
pdfcraft-platform = { path = "crates/platform", version = "0.14.0" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.14.0" }
pdfcraft-automation = { path = "crates/automation", version = "0.14.0", default-features = false }
pdfcraft-geom = { path = "crates/geom", version = "0.17.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.17.0" }
pdfcraft-cos = { path = "crates/cos", version = "0.17.0" }
pdfcraft-crypt = { path = "crates/crypt", version = "0.17.0" }
pdfcraft-organize = { path = "crates/organize", version = "0.17.0" }
pdfcraft-measure = { path = "crates/measure", version = "0.17.0" }
pdfcraft-annot = { path = "crates/annot", version = "0.17.0" }
pdfcraft-audit = { path = "crates/audit", version = "0.17.0" }
pdfcraft-fonts = { path = "crates/fonts", version = "0.17.0" }
pdfcraft-model = { path = "crates/model", version = "0.17.0" }
pdfcraft-content = { path = "crates/content", version = "0.17.0" }
pdfcraft-redact = { path = "crates/redact", version = "0.17.0" }
pdfcraft-print = { path = "crates/print", version = "0.17.0" }
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.17.0" }
pdfcraft-sign = { path = "crates/sign", version = "0.17.0" }
pdfcraft-optimize = { path = "crates/optimize", version = "0.17.0" }
pdfcraft-a11y = { path = "crates/a11y", version = "0.17.0" }
pdfcraft-oracle = { path = "crates/oracle", version = "0.17.0" }
pdfcraft-testkit = { path = "crates/testkit", version = "0.17.0" }
pdfcraft-ocr = { path = "crates/ocr", version = "0.17.0" }
pdfcraft-js = { path = "crates/js", version = "0.17.0" }
pdfcraft-compare = { path = "crates/compare", version = "0.17.0" }
pdfcraft-preflight = { path = "crates/preflight", version = "0.17.0" }
pdfcraft-export = { path = "crates/export", version = "0.17.0" }
pdfcraft-edit = { path = "crates/edit", version = "0.17.0" }
pdfcraft-create = { path = "crates/create", version = "0.17.0" }
pdfcraft-forms = { path = "crates/forms", version = "0.17.0" }
pdfcraft-xfa = { path = "crates/xfa", version = "0.17.0" }
pdfcraft-render = { path = "crates/render", version = "0.17.0" }
pdfcraft-engine = { path = "crates/engine", version = "0.17.0" }
pdfcraft-platform = { path = "crates/platform", version = "0.17.0" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.17.0" }
pdfcraft-automation = { path = "crates/automation", version = "0.17.0", default-features = false }
# pdf
hayro = "0.7.1"
@@ -65,6 +66,11 @@ hayro-syntax = "0.7.2"
lopdf = "0.45"
flate2 = { version = "1.1", default-features = false, features = ["rust_backend"] }
weezl = "0.2"
# CCITT Group 4 encoding (pdfcraft-filters); already in the tree through tiff.
fax = "0.2.7"
# Independent CCITT and JBIG2 codecs, already in the tree through hayro.
hayro-ccitt = "0.3"
hayro-jbig2 = "0.3"
# foundation
thiserror = "2"
@@ -128,6 +134,7 @@ hayro-postscript = { path = "vendor/hayro-postscript" }
hayro-jbig2 = { path = "vendor/hayro-jbig2" }
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).
# Only the retained `vendor/winit` and `vendor/egui-wgpu` copies are provenance for the app's
+159
View File
@@ -0,0 +1,159 @@
# Stage 1 source documents only. Destinations are logical names, not packaging instructions.
# licences lists audited full-text identifiers; omission grants no bundle applicability.
[[document]]
source = "LICENSE-APACHE"
licences = ["Apache-2.0"]
destination = "LICENSE-APACHE"
sha256 = "0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594"
[[document]]
source = "LICENSE-MIT"
licences = ["MIT"]
destination = "LICENSE-MIT"
sha256 = "cd7032ea33b71427f188e34ffb4aee9d6868a57fd196037c5c8b2be03a2a0352"
[[document]]
source = "NOTICE"
destination = "NOTICE"
sha256 = "9f854f730719af962cd840529324d089e05e68a30bd245c50e33f96e009b4314"
[[document]]
source = "assets/app-icon/LICENSE-document.txt"
destination = "assets/app-icon/LICENSE-document.txt"
sha256 = "64a3137e3d5cc41e5b78d2ad8dd9ddddd6588283f9f7b3128aa6a8a50852fb9a"
[[document]]
source = "assets/app-icon/LICENSE.txt"
destination = "assets/app-icon/LICENSE.txt"
sha256 = "b768acafe6cbad8247eedf86b5b5381feefcd4ee0f8c7808b86811bc687cf37b"
[[document]]
source = "assets/fonts/OFL-DancingScript.txt"
destination = "assets/fonts/OFL-DancingScript.txt"
sha256 = "6f090277c00af96651ce6dbcc38ff1591047a3bffef486e80b6a32e8276a8201"
[[document]]
source = "assets/fonts/OFL-Inter.txt"
destination = "assets/fonts/OFL-Inter.txt"
sha256 = "262481e844521b326f5ecd053e59b98c8b2da78c8ee1bdbb6e8174305e54935a"
[[document]]
source = "assets/fonts/OFL-JetBrainsMono.txt"
destination = "assets/fonts/OFL-JetBrainsMono.txt"
sha256 = "a76abf002c49097d146e86740a3105a5d00450b1592e820a1109a8c5680cd697"
[[document]]
source = "assets/icons/LICENSE-lucide.txt"
destination = "assets/icons/LICENSE-lucide.txt"
sha256 = "b495047bd93a9b06913511076f504daba17d5bbeb3e0650f3bb53a4220329c57"
[[document]]
source = "docs/brand/LICENSE-brand.txt"
destination = "docs/brand/LICENSE-brand.txt"
sha256 = "0f33f48e7a96cd359d4e4e5b11424b13cebeb59cc70c77928186695e4173585c"
[[document]]
source = "licenses/CMap-LICENSE.txt"
licences = ["BSD-3-Clause"]
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/egui-Hack-Regular.txt"
licences = ["MIT", "Bitstream-Vera"]
destination = "licenses/egui-Hack-Regular.txt"
sha256 = "47c0cccbeec7e8614548cc485588b28149e7874188df5f41b36efebcee285c87"
origin = "https://raw.githubusercontent.com/emilk/egui/0.36.2/crates/epaint_default_fonts/fonts/Hack-Regular.txt"
[[document]]
source = "licenses/egui-OFL.txt"
licences = ["OFL-1.1"]
destination = "licenses/egui-OFL.txt"
sha256 = "6a73f9541c2de74158c0e7cf6b0a58ef774f5a780bf191f2d7ec9cc53efe2bf2"
origin = "https://raw.githubusercontent.com/emilk/egui/0.36.2/crates/epaint_default_fonts/fonts/OFL.txt"
[[document]]
source = "licenses/egui-UFL.txt"
licences = ["Ubuntu-font-1.0"]
destination = "licenses/egui-UFL.txt"
sha256 = "2f0015108d68627bd788d313f529c21ff4da2c2c42a5e1f3883acc83480f9002"
origin = "https://raw.githubusercontent.com/emilk/egui/0.36.2/crates/epaint_default_fonts/fonts/UFL.txt"
[[document]]
source = "licenses/egui-emoji-icon-font-mit-license.txt"
licences = ["MIT"]
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 = "vendor/hayro-interpret/LICENSE-APACHE"
licences = ["Apache-2.0"]
destination = "vendor/hayro-interpret/LICENSE-APACHE"
sha256 = "0cec06e0e55fbc3dc5cee4fca9b607f66cb8f4e4dbcf3b3c013594dd156732e9"
[[document]]
source = "vendor/hayro-interpret/LICENSE-MIT"
licences = ["MIT"]
destination = "vendor/hayro-interpret/LICENSE-MIT"
sha256 = "51b50e6120c344dcc146bbd6375ca1c78e079d97c8bea76e67a53d6ff0efebac"
[[document]]
source = "vendor/hayro-interpret/assets/CGATS_LICENSE.txt"
destination = "vendor/hayro-interpret/assets/CGATS_LICENSE.txt"
sha256 = "469d87b5fa48f8aaf69e25f4fcac3a309c97dbb7d561b917638abdb640e004d9"
[[document]]
source = "vendor/hayro-interpret/assets/LICENSE_FOXIT"
destination = "vendor/hayro-interpret/assets/LICENSE_FOXIT"
sha256 = "b578cdd2345840ada550bd12519533812320d5f1d21cf4c1c7e1b1b0a31c98b7"
[[document]]
source = "vendor/hayro-cmap/assets/LICENSE.txt"
licences = ["BSD-3-Clause"]
destination = "vendor/hayro-cmap/assets/LICENSE.txt"
sha256 = "742665db9c8e1bc72603c6d319ca3e90b83bd6d95202f0cd4ef11068a07a9c29"
[[document]]
source = "vendor/vello_common/LICENSE-MIT"
licences = ["MIT"]
destination = "vendor/vello_common/LICENSE-MIT"
sha256 = "adf157330f722fe7cfd5964a2a6974eff851b718228c6735724a11706e6f4dec"
[[bundled]]
crate = "epaint_default_fonts"
path = "fonts/Ubuntu-Light.ttf"
licence = "Ubuntu-font-1.0"
documents = ["licenses/egui-UFL.txt"]
[[bundled]]
crate = "epaint_default_fonts"
path = "fonts/Hack-Regular.ttf"
licence = "MIT AND Bitstream-Vera"
documents = ["licenses/egui-Hack-Regular.txt"]
[[bundled]]
crate = "epaint_default_fonts"
path = "fonts/NotoEmoji-Regular.ttf"
licence = "OFL-1.1"
documents = ["licenses/egui-OFL.txt"]
[[bundled]]
crate = "epaint_default_fonts"
path = "fonts/emoji-icon-font.ttf"
licence = "MIT"
documents = ["licenses/egui-emoji-icon-font-mit-license.txt"]
[[bundled]]
crate = "epaint_default_fonts"
path = "fonts/egui-icons.ttf"
licence = "MIT"
documents = ["licenses/egui-emoji-icon-font-mit-license.txt"]
[[bundled]]
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"]
+1 -1
View File
@@ -12,7 +12,7 @@ Third-party material
This product bundles third-party material under its own licenses, listed below. The per-file
list (author, source, license) is in ATTRIBUTION.md.
- Lucide icons (assets/icons/*.svg) — ISC License. See assets/icons/LICENSE-lucide.txt.
- Lucide icons (assets/icons/*.svg) — ISC and MIT licences (Feather-derived icons). See assets/icons/LICENSE-lucide.txt.
The Windows PDF file icon (assets/app-icon/pdfcraft-document.svg and .ico) adapts Lucide's
file-text outline and combines it with contributor-original artwork. See
assets/app-icon/LICENSE-document.txt (ISC AND (MIT OR Apache-2.0)), packaged as
+17 -6
View File
@@ -9,7 +9,7 @@
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png" width="128" height="128" alt="PdfCraft icon: an engraved lion on a teal tile">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png" width="128" height="128" alt="PdfCraft icon: an engraved lion on a red tile">
</p>
<h1 align="center">PdfCraft</h1>
@@ -21,10 +21,10 @@
</p>
<p align="center">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-12a58a?style=flat-square">
<img alt="Written in Rust" src="https://img.shields.io/badge/written%20in-Rust-0a7563?style=flat-square">
<img alt="Platforms: macOS, Windows, Linux, FreeBSD, web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20web-12a58a?style=flat-square">
<img alt="No account, no telemetry" src="https://img.shields.io/badge/no%20account-no%20telemetry-0a7563?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-b70228?style=flat-square">
<img alt="Written in Rust" src="https://img.shields.io/badge/written%20in-Rust-8a011e?style=flat-square">
<img alt="Platforms: macOS, Windows, Linux, FreeBSD, web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20web-b70228?style=flat-square">
<img alt="No account, no telemetry" src="https://img.shields.io/badge/no%20account-no%20telemetry-8a011e?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_pdfcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/pdfcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
@@ -140,6 +140,8 @@ Search the whole document as you type, step through matches with <kbd>⌘G</kbd>
## Navigate long documents
Launch with `pdfcraft --two-paged file.pdf` (or `pdfcraft file.pdf --two-paged`) to open each command-line PDF in noncontinuous two-page view. This optional launch override does not change saved default-layout preferences or PDFs restored from the previous session. An explicit `--layout` option takes precedence on the active document regardless of argument order; `--default-layout` retains its existing behavior for subsequently opened documents.
Right-click a PDF tab to close it, reopen the last closed file-backed tab, or reveal its file. Closing still asks about unsaved changes. Reopening reads the saved file from disk; it does not restore discarded edits. Reveal selects the file in Explorer or Finder. On Linux and FreeBSD it requests `org.freedesktop.FileManager1.ShowItems`, falling back to opening the containing directory with `xdg-open` when that service is unavailable. Reopen and Reveal are unavailable on the web; Reveal is also unavailable for pathless documents.
Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state.
@@ -545,9 +547,18 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | **Reading, organizing and protecting PDFs · you are here** | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
+87 -8
View File
@@ -1,7 +1,25 @@
# PdfCraft roadmap
> **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)
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 review follow-up: preserve image crop/tag/stream ownership, resize in page orientation, harden clipboard and text formatting, and reload installed fonts; estimates unchanged)
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: GBK rasters compared with explicit Unicode and independently selected installed faces; wrong-glyph and wrong-family negative controls fail; estimates unchanged)
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: integrate main fa5f9f8c; retain CJK viewer and upstream raster regressions, restore explicit display_config imports; estimates unchanged)
> **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** patch (M2, PR #4172: integrate main 5af10276; retain both roadmap histories and viewer-only CJK font policy; estimates unchanged)
> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M2, PR #4172: integrate main 1dd2737b; preserve viewer-only CJK fallback and regression coverage across engine, renderer and Combine module splits; estimates unchanged)
> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M2, PR #4172: integrate main 9a9813bf and preserve viewer-only CJK fallback through the engine module split; estimates unchanged)
> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: document installed-font resolution helpers and regression contracts; behavior and estimates unchanged)
> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: print preview excludes installed CJK fonts and isolates its font cache from the viewer; estimates unchanged)
> **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)
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
> **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: 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 (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)
@@ -21,6 +39,7 @@
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged)
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M4: initial-view options resolve indirect boolean preferences; estimates unchanged)
> **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M7 #2517: a partial rewrite keeps its marker around a kept row, and off the next marked region; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
@@ -95,8 +114,8 @@ and add a line to the [progress log](#progress-log); when a gap closes, remove i
| | Value | Kind |
|---|---|---|
| **Feature breadth** | **≈ 67%** tier-weighted (56.9% unweighted: 423 shipped + 74 partial of 808; P0 94.2%) | measured, `parity/acrobat-features.toml` |
| Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.2% unweighted; P0 95.3%) | measured ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) |
| **Feature breadth** | **≈ 67%** tier-weighted (57.0% unweighted: 423 shipped + 75 partial of 808; P0 94.2%) | measured, `parity/acrobat-features.toml` |
| Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.3% unweighted; P0 95.3%) | measured ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) |
| **Ready for real work**, full Acrobat Pro | **≈ 50%** (45–53%) | estimated, weighted sum over [dimensions](#by-dimension) |
| **Ready for real work**, mainstream practitioner | **≈ 55%** (50–60%) | estimated ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) |
| **Ready for real work**, essentials user | **≈ 66%** (60–70%) | estimated ([method](docs/target-app-parity.md#essentials-user)) |
@@ -151,7 +170,7 @@ Hours are calibrated as in [Effort and calibration](docs/target-app-parity.md#ef
| H Digital signatures | 62.2% | 45% | 40–70 |
| I Scan and OCR | 23.1% | 15% | 40–75 |
| J Create and export | 39.7% | 25% | 55–100 |
| K Optimize and standards | 34.5% | 20% | 65–115 |
| K Optimize and standards | 35.3% | 20% | 65–115 |
| L Print | 39.7% | 35% | 30–55 |
| M Accessibility | 63.1% | 30% | 40–70 |
| N Automation, preferences, misc | 30.0% | 35% | 25–45 |
@@ -200,11 +219,46 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **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.
- **2026-11-01 (M5/M14, customizable hotbar, freehand highlight, and moveable navigation rail):** Added Freehand-Highlight (`/Subtype /Ink`, `/IT /FreehandHighlight`, Multiply blending, 100 pt max line width) inside the standard Highlight flyout and headless `comment_add`; added a customizable floating or side-docked hotbar with a full-side chrome strip mode, settings gear icon anchored at the bar end, and 24–64 px sizing; and added Left/Right docking for the navigation rail (`rail_side`). Focused unit and integration tests pass across `annot`, `automation`, and `ui-egui`. Estimates unchanged.
- 2026-11-01: PR #2155 further review preserves inherited Form text state and soft-mask chronology during Arrange, composes crop-group moves in the proper scope, and uses affine/press-relative image resize plus a shared rotation pivot. No-op script buttons preserve hidden source text; paste coordinates, visible selection ownership, and Snapshot/Export permissions have dedicated regressions. Candidate only; estimates unchanged.
- 2026-11-01: PR #2155 latest follow-up handles explicit image-paste events without release-time guesses, refreshes the font-size field when a selection becomes mixed, and specifies mixed-script normalization with exact Undo restoration. Candidate only; estimates unchanged.
- 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-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.
- **2026-10-31 (M2, PR #4172):** Integrated main fa5f9f8c, retaining both roadmap histories and raster regression groups. Restored display_config and thumbnail_config imports after the upstream explicit-import migration. Validation: 422 renderer unit tests, engine/UI all-target checks, scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Existing optional-patch/WASM warnings and parity notes remain; no full engine/UI runtime test rerun. Display-only font policy and estimates unchanged.
- **2026-10-28 (M2, PR #4172):** Integrated main 5af10276, preserving both roadmap histories and reviewing auto-merged export tests and accessibility UI changes. Validation: 488 scoped tests passed (18 engine, 412 renderer unit, 43 renderer integration, 15 Combine; one existing renderer integration test ignored); scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Viewer-only CJK fallback and print/output font policy remain unchanged; estimates unchanged.
- **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 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-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-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-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.
- **2026-10-24 (M11, more black-and-white images):** The optimizer re-stores Group 3 fax images as Group 4 when that is smaller, instead of leaving any CCITT image it doesn't resample as it is. Black-and-white images stored as eight-bit RGB (as some phone and scanner apps write them) become one-bit grey, and indexed images that use only their first two palette entries become one bit with their palette, so Monochrome Images compresses and downsamples them too.
- **2026-10-24 (M11, monochrome downsampling):** The PDF Optimizer and Reduce File Size average black-and-white images above 450 ppi down to 300 ppi (Monochrome Images ▸ Downsample to), Group 4 scans included, so a 600 ppi text page goes from 79 KB to 40 KB. CCITT decoding moved into `pdfcraft-filters` (`decode_ccitt`), shared by Export ▸ Images and the optimizer through `Document::decoded_image`.
- **2026-10-24 (M11, black-and-white pictures):** A scan saved as a 1-bit PNG became an eight-bit grey image when made into a PDF, larger than the PNG; Create PDF now stores pictures of only black and white at one bit (Group 4 when smaller), the optimizer packs eight-bit black-and-white grey images to one bit unless the grey settings make them smaller, as they can a dithered scan, soft mask counted, and Export ▸ Images reads CCITT fax images.
- **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 (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.
@@ -220,8 +274,11 @@ 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-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 (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.
- **2026-10-23 (M2, #234, continuation of #1567):** Reconciled installed CJK viewer fallback with current rendering changes; Windows preserves installed Song/Hei/Kai families and bounds font reads. GBK-EUC-H CIDFontType2 regression coverage includes text extraction; viewer fallback also survives reload with retained preferences. System fonts remain display-only. No overall readiness estimate changed.
- **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.
@@ -300,6 +357,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-19 (M3, #3360):** Switching between Read and the docking workspace retains the current document’s scroll position at fixed zoom, including within-page position; separate tabs retain their own offsets. Estimates unchanged.
- **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged.
- **2026-10-17 (M4, #2997):** Dragging page thumbnails in the Organize grid with Ctrl held (Option on macOS) drops copies at the gap instead of moving the pages: a "+" badge sits by the pointer, the copies land at the gap selected, and one undo step ("Duplicate pages") takes them out again. `page_duplicate` takes `at` (1-based, where the first copy goes; left out, after the last of them) so agents can drive the same thing, and the UI control channel's `ui.drag` modifiers now reach gestures that read the input state. Estimates unchanged.
- **2026-10-16 (M7, Add Background):** A background added behind a page that paints its own opaque white backdrop (browsers' Save as PDF, print drivers) was hidden completely, so the tool looked broken. The dialog now detects such pages and offers to replace the backdrop (on by default): the one fill becomes `n` in a per-page copy of its stream, and Remove background turns the fill back on, after edits and redactions too, unless the operators before it changed or it would no longer be painted first; then it says which pages keep it off. The copy's tag holds no reference to the original stream, which had kept text a redaction or Remove Hidden Information removed in the saved file. Scanned pages are warned about, not touched. New `doc_backdrops` tool; `doc_background` takes `replace_backdrop`. The default colour is a visible light yellow and the preview shows text over it. Acrobat's own behaviour on such pages is unobserved. 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.
- **2026-10-15 (M6, #1917 refresh):** Integrated upstream `99941daa` while retaining the XML/XFDF attribute whitespace correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
- **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
@@ -340,6 +398,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design.
- **2026-10-12 (M10, Edit on a scanned page):** Turning Edit on (its tab, Edit text & images or Add text) for a scanned page (an image covering at least half of a page with no text of its own; comments and text boxes over it do not count) recognizes the page first, like Acrobat's "Converting scanned page to Editable Text and Images", so its words open for editing. Once per page per window, one undoable "Recognize text" step, with the language of the Recognize Text dialog (English only for now, gap 7). Pages with real text (a flyer with a background picture), read-only files and other tools are left alone. The photographs on a scan are still part of the one page image.
- **2026-10-12 (M2, installed CJK fonts):** A CID font of an Adobe CJK collection (Japan1, GB1, CNS1, Korea1) that a PDF doesn't embed, such as the Kozuka Gothic of an Acrobat text box or the SongtiTC of a Pages export, drew nothing unless craft-fonts had a Japanese face. After craft-fonts, it now draws with a face already installed on the machine (PingFang, Heiti, Microsoft JhengHei / YaHei, Malgun and the like; read at runtime, never bundled, and used only for the on-screen viewer, never for exports, printing or OCR; Noto CJK and Source Han excluded; `PDFCRAFT_SYSTEM_FONTS=0` turns it off). The face of a collection with several regions (PingFang SC, TC) is chosen by region. Checked on macOS with a synthetic Traditional Chinese, Simplified Chinese and Korean page; the Windows and Linux font lists are untested, and a machine with none of the listed faces still draws nothing.
- **2026-10-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values.
- **2026-10-13 (M4, indirect page rotation):** Rotation queries and edits resolve indirect integer values on pages and their page-tree ancestors. Turning a page already at 270° by 90° now saves it at 0°, including when its rotation is inherited. Synthetic full/incremental save-and-reopen tests cover nested inheritance, sibling preservation, negative/large angles and invalid-value fallback. Estimates unchanged.
- **2026-10-14 (M1, Flate fallback):** Raw deflate whose first bytes resemble a zlib header no longer loses a decoding-limit error during fallback. Strict and tolerant filter regressions and a COS stream regression cover refusal at the caller limit and successful decoding within it. No parity status or estimate changes.
@@ -449,6 +509,7 @@ 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-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 (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.
@@ -511,10 +572,10 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-10 (L Print, #756):** Windows prints. The Print dialog lists the system's printers and its default from `Win32_Printer` (through PowerShell's CIM; `wmic` is gone from Windows 11), and Print spools the print-ready PDF: the in-box Windows.Data.Pdf renders each sheet at the printer's own resolution and System.Drawing.Printing carries copies, collation, duplex and colour as the job's settings — both reached through `powershell`, so the Rust code touches no Win32 API and the machine needs no PDF reader. This closes #756 and the "printer not showing" duplicates #160, #244, #293, #465 and #644, using the CIM approach community-tested in #452; the tests render the job's sheets through the whole WinRT path on CI (dry run, no printer needed) and, where a printer can be added, catch a real job in its paused queue. Web printing is still missing; milestone and effort estimates are unchanged.
- **2026-10-10 (M3, #740):** Column select, as in Acrobat: holding Alt (Option on macOS) when a drag starts selects only the text inside the rectangle, with the Select tool, the text markup tools and Redact; Edit ▸ Column select does it with a plain drag, for Linux desktops whose window manager takes Alt-drag (Xfce, Cinnamon, MATE, KDE Plasma 5 by default). Copy gives rows top to bottom with tab-separated cells (right to left in right-to-left rows, reading order for vertical text); `text_extract` takes the same rectangle as `rect`. The new label is in all 15 catalogs. Also repaired `parity/acrobat-features.toml`, which #650 had left unparseable (duplicate keys on `form.field-list`). Still missing: column selection across pages and ⇧-click extension. Estimates unchanged.
- **2026-10-10 (progress docs, hours per audience):** Each readiness number gets its own hours to ≈ 95%: full ≈ 49% / 780–1,400 h, mainstream ≈ 55% / 500–900 h, essentials ≈ 66% / 180–330 h. The full number is now the standard's additive weighted sum over the dimensions (written weights), 47% → 49%: method aligned with the standard, no new evidence. Beta ≈ 26 points, ≈ 400–700 h.
- **2026-10-10 (progress docs, hours per audience):** Each readiness number gets its own hours to ≈ 95%: full ≈ 49% / 780–1,400 h, mainstream ≈ 55% / 500–900 h, essentials ≈ 66% / 180–330 h. The full number is now the additive weighted sum over the dimensions (written weights), 47% → 49%; no new evidence. Beta ≈ 26 points, ≈ 400–700 h.
- **2026-10-10 (progress docs, gate consistency check):** Re-judged the convert-and-share row with the rule applied across all Crafting Apps (a workflow blocks only when it can't be completed at all; lossy exchange and missing sub-cases are beta items, as for FilmCraft's .prproj or VectorCraft's .ai). Unencrypted sharing works, encryption interop (#774) and Office→PDF are sub-cases, Word export is lossy rather than absent: partial, not blocking. **Stage pre-alpha → alpha**; numbers unchanged (47%, mainstream 55%; beta ≈ 400–700 h). Added an essentials-user score: ≈ 66% for someone who only opens, reads, comments, fills, signs, prints and rearranges pages.
- **2026-10-10 (progress docs, second look and alpha gate):** At the owner's request, after practitioners praised PdfCraft, re-examined the numbers. User sentiment: 7,595 stars, 177k + 39k downloads of 0.4.0/0.5.0, 23 user issues with explicit praise and switching reports (#179, #232, #273), issue themes concentrated in core paths (launch/GPU, rendering, editing, CJK, signing, printing). The checklist is not niche-dominated (497 mainstream vs 311 specialist rows; P0 95% / 91%). The first pass's 9-point cross-cutting deduction double-counted Windows printing, interop and glyphs: **ready for real work 40% → 47%**, and **≈ 55% for the mainstream practitioner** (new). The new core-workflow gate (craftrules progress-docs) fails on convert and share (#774, no Office import, #773): **stage alpha → pre-alpha**, ≈ 40–70 h from alpha; beta still ≈ 400–700 h.
- **2026-10-10 (progress docs, full re-measure):** Re-measured against Acrobat Pro 26.002.21931 (installed bundle inspected: document types, localizations, plug-in and framework names). Checklist: 423 shipped + 74 partial of 808 (56.9%; P0 94.2%), ≈ 67% tier-weighted. Ready for real work ≈ 40%; stage **alpha**; ≈ 400–700 h to beta, ≈ 780–1,400 h to full parity (up from 600–1,100 h: the first public releases surfaced Acrobat interop of encrypted output, Windows printing, Chinese UI glyphs, Word-export layout and a 245-issue backlog). This file restructured to the craftrules progress-docs standard; the honest assessment moved to `docs/target-app-parity.md`, the gap list to `docs/gaps.md`, the milestones (duplicated rows merged) to `docs/roadmap.md`; new `docs/architecture.md`, `docs/localization-parity.md`, `docs/file-format-parity.md`, `docs/pdf-spec-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`.
- **2026-10-10 (progress docs, second look and alpha gate):** At the owner's request, after practitioners praised PdfCraft, re-examined the numbers. User sentiment: 7,595 stars, 177k + 39k downloads of 0.4.0/0.5.0, 23 user issues with explicit praise and switching reports (#179, #232, #273), issue themes concentrated in core paths (launch/GPU, rendering, editing, CJK, signing, printing). The checklist is not niche-dominated (497 mainstream vs 311 specialist rows; P0 95% / 91%). The first pass's 9-point cross-cutting deduction double-counted Windows printing, interop and glyphs: **ready for real work 40% → 47%**, and **≈ 55% for the mainstream practitioner** (new). The new core-workflow gate fails on convert and share (#774, no Office import, #773): **stage alpha → pre-alpha**, ≈ 40–70 h from alpha; beta still ≈ 400–700 h.
- **2026-10-10 (progress docs, full re-measure):** Re-measured against Acrobat Pro 26.002.21931 (installed bundle inspected: document types, localizations, plug-in and framework names). Checklist: 423 shipped + 74 partial of 808 (56.9%; P0 94.2%), ≈ 67% tier-weighted. Ready for real work ≈ 40%; stage **alpha**; ≈ 400–700 h to beta, ≈ 780–1,400 h to full parity (up from 600–1,100 h: the first public releases surfaced Acrobat interop of encrypted output, Windows printing, Chinese UI glyphs, Word-export layout and a 245-issue backlog). This file restructured; the honest assessment moved to `docs/target-app-parity.md`, the gap list to `docs/gaps.md`, the milestones (duplicated rows merged) to `docs/roadmap.md`; new `docs/architecture.md`, `docs/localization-parity.md`, `docs/file-format-parity.md`, `docs/pdf-spec-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`.
- **2026-10-10 (M8, redact word lists):** Find text and redact gains "Multiple words or phrases": a list with one word or phrase per line, typed or imported from a `.txt` file, whose every match is marked in one undo step. Lists are trimmed, deduplicated and bounded (1,000 entries of 256 characters, files up to 1 MB). `redact_mark` takes `words` and reports how many marks each made, so an agent sees which found nothing. Tests cover the list parsing, the tool end to end (mark, undo, apply, invalid lists) and the dialog with an imported list; the new labels are translated in all 13 UI catalogs. `protect.redact-word-lists` is shipped; whole-word and case-sensitive matching and exporting the list are still missing. Estimates unchanged (about 30–35%).
- **2026-10-10 (M4, bookmarks from structure):** Bookmark options ▸ New bookmarks from structure (command `bookmark.from_structure`, tool `bookmark_from_structure`) turns a tagged document's headings into bookmarks: H1–H6 through the role map, a plain H by its section nesting, nested by level under a new first "Untitled" bookmark as in Acrobat, each linked to its structure element (/SE). Titles come from /ActualText, the marked text on the page (decoded through the font's ToUnicode or encoding), /Alt or /T. The walk is bounded (depth, visits, heading and title caps, cycle-safe), an untagged document gets a clear error, and the edit is one undo step. Tests cover role maps, nesting, ToUnicode text, a self-referencing heading, the tool, undo and the panel. Still missing: Acrobat's element-type choice and destinations at the heading's position. The M4 row no longer lists Replace pages as missing (it shipped earlier). Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M9, signing encrypted documents):** Digital signatures, certification and document timestamps work on password-protected PDFs instead of failing with "not supported: signing encrypted documents". `pdfcraft-cos` no longer encrypts or decrypts a signature dictionary's `/Contents` (§7.6.2; by `/Type /Sig|/DocTimeStamp` or `/Contents` + `/ByteRange`), so the placeholder is patched after hashing as in a plain file and existing signatures in encrypted files read their real CMS (which also fixes their DSS `/VRI` keys and keeps them intact in a full rewrite). Opened with the user password, the signer applies the document's permissions (fill-forms for existing fields and timestamps, annotate + modify for new fields, modify for certifying) and names the permissions password when it refuses. Synthetic tests cover all four algorithms, counter-signing, tampering, permissions and the automation tools. Overall estimate unchanged (about 30–35%).
@@ -684,6 +745,8 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
| 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-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-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. |
@@ -722,11 +785,11 @@ Newest first: the date, what moved, and the new overall estimate.
| 2026-10-11 | minor | M7: #594 paragraph-drag candidate and source regressions prepared; subsequent Linux validation passed all eight CI gates. Estimates unchanged. |
| 2026-10-10 | minor | Recorded the remaining System appearance repair after #881; estimates unchanged |
| 2026-10-11 | minor | Contributor fork: document-font selection and subset completion, CFF, style ranges, contextual/bidi save/reopen and all-page Chinese keyboard acceptance; curve/hole hit testing (shared clips stay fixed, as on main). Scope remains bounded; percentages unchanged |
| 2026-10-10 | minor | Readiness table with hours per audience; full number restated as the additive weighted sum over dimensions (47% → 49%; method aligned with the standard, no new evidence) |
| 2026-10-10 | minor | Readiness table with hours per audience; full number restated as the additive weighted sum over dimensions (47% → 49%; no new evidence) |
| 2026-10-10 | minor | Essentials-user score added (≈ 66%) |
| 2026-10-10 | minor | Gate consistency check: convert and share re-judged partial, not blocking; stage pre-alpha → alpha |
| 2026-10-10 | minor | Second look: ready 40% → 47% (cross-cutting double count removed), mainstream-practitioner numbers and user sentiment added; core-workflow alpha gate applied, stage alpha → pre-alpha |
| 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; assessment, gaps and milestones moved to `docs/`; log sorted newest first and a stray merged entry dated |
| 2026-10-10 | major | Progress docs restructured (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; assessment, gaps and milestones moved to `docs/`; log sorted newest first and a stray merged entry dated |
| 2026-10-07 | minor | 806 tracked features, 51.0% shipped, effort-weighted ≈ 30–35%, 600–1,100 h |
| 2026-10-05 | major | Honest assessment and "Where we're lacking" added |
| 2026-10-12 | minor | M8: cleanup removes replacement descriptions only from changed marked-content spans, preserving surviving accessibility text. Estimates unchanged. |
@@ -748,3 +811,19 @@ Newest first: the date, what moved, and the new overall estimate.
- **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 (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.
- **2026-10-23 (M2, PR #4172):** Combine thumbnails and accessibility figure previews use the viewer's display-only CJK fallback. Windows family matching includes FangSong and subset-prefixed Founder names. Export, print, OCR and automation remain off by default; estimates unchanged.
- **2026-10-26 (M2, PR #4172 print-preview review):** Print-preview requests disable installed CJK fallback and use an isolated cache so preview font availability matches output. Viewer fallback survives switching in either direction. Synthetic GBK regressions cover inline and single-worker rendering, full pages and tiles; estimates unchanged.
- **2026-10-26 (M2, PR #4172 documentation review):** Added function documentation for installed-font script selection, bounded candidate reads, platform paths, resolver precedence and regression contracts. No runtime behavior or estimate change.
- **2026-10-27 (M2, PR #4172 conflict resolution):** Integrated main `9a9813bf` and its engine module split. Opening, layer changes and session renderer rebuilds retain display-only CJK fallback; output configuration and print-preview cache isolation are preserved. Validation passed: 16 scoped engine regressions, 412 renderer unit tests, 43 renderer integration tests (one existing corpus case ignored), 15 Combine tests, scoped Clippy, formatting, layers/assets/parity and renderer/engine WASM checks. Estimates unchanged.
## Pages sidebar keyboard refresh (2026-10-30)
PR #2133 retains document-local Pages sidebar focus for Ctrl/Cmd+A and Escape after the UI module split. The selection does not navigate. The focused regression is in `crates/ui-egui/tests/it/view.rs`; current-head hosted validation is pending. Feature counts and estimates are unchanged.
## Button export-value refresh (2026-10-30)
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.
+36
View File
@@ -243,4 +243,40 @@ mod page_argument_tests {
assert_eq!(std::fs::read(&source).unwrap(), bytes);
}
}
#[test]
fn text_clips_glyphs_outside_the_crop_box() {
let dir = PageDir::new();
let path = dir.0.join("cropped.pdf");
let body = "BT /F1 12 Tf 20 260 Td (Outside header) Tj 0 -110 Td (Visible body) Tj 0 -120 Td (Outside footer) Tj ET";
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /CropBox [0 100 200 200] /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_vec(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(),
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
pdf.extend_from_slice(o);
pdf.extend_from_slice(b"\nendobj\n");
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
std::fs::write(&path, pdf).unwrap();
for args in [vec![], vec!["--page", "1"]] {
let out = Command::new(BIN).arg("text").arg(&path).args(&args).output().unwrap();
assert!(out.status.success(), "{args:?}: {out:?}");
assert!(out.stderr.is_empty(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
assert_eq!(String::from_utf8(out.stdout).unwrap().trim(), "Visible body", "{args:?}");
}
}
}
+22 -30
View File
@@ -1,6 +1,8 @@
//! PdfCraft desktop app.
//!
//! Usage: `pdfcraft [options] [files…]` (`-h` / `--help` prints a short version of this, [`USAGE`]).
//! `--two-paged` opens each command-line PDF in noncontinuous two-page view, without changing defaults.
//! An explicit `--layout` takes precedence for the active document, regardless of argument order.
//! `--create-images [images…]` stages the images in one PDF and asks for the page DPI.
//!
//! View options (applied after the files open; also the seed of the UI control channel):
@@ -8,7 +10,7 @@
//! --theme light|dark|system --language auto|<code> --mode all|read|edit|convert|sign --tool <catalogue id> --left open|closed
//! --organize on --fields on --dialog properties|shortcuts|about --palette <query> --home on
//! --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
//! --page-gaps on|off --default-page-gaps on|off --recent-files 0..50
//! --combine-view grid|list --combine-zoom 60..200`
//!
//! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files
@@ -40,6 +42,7 @@ mod motion;
mod reopen;
mod settings;
mod single_instance;
mod startup;
mod text_scale;
#[cfg(test)]
#[path = "windows_manifest.rs"]
@@ -63,6 +66,7 @@ Usage: pdfcraft [options] [files…]
Opens the files in the PdfCraft desktop app.
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
--control <file> enable the UI control channel (pdfcraft-cli ui --control <file> …)
@@ -196,7 +200,7 @@ fn rejoin_split_paths(args: Vec<String>, exists: impl Fn(&str) -> bool) -> Vec<S
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::PDFCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Some(result) = craft_update::self_update::finish(&pdfcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so the panic hook and every start-up warning are recorded (`logging`).
@@ -218,28 +222,16 @@ fn main() -> eframe::Result {
let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("pdfcraft: {report}\n"));
}
}));
let mut files = Vec::new();
let mut options: Vec<(String, String)> = Vec::new();
let mut control_file: Option<String> = None;
let mut create_images = false;
let mut new_window = false;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--version" => return print_and_exit(&format!("pdfcraft {}", pdfcraft_engine::build_info::version())),
// Before any window or log file, so a scripted call returns (#4076).
"-h" | "--help" => return print_and_exit(USAGE),
"--control" => control_file = args.next(),
"--create-images" => create_images = true,
"--new-window" => new_window = true,
flag if flag.starts_with("--") => {
let value = args.next().unwrap_or_default();
options.push((flag.trim_start_matches("--").to_string(), value));
}
_ => files.push(path_from_arg(a)),
}
let mut startup = startup::Arguments::parse(std::env::args().skip(1));
if startup.version {
return print_and_exit(&format!("pdfcraft {}", pdfcraft_engine::build_info::version()));
}
let files = rejoin_split_paths(files, |p| std::path::Path::new(p).is_file());
if startup.help {
return print_and_exit(USAGE);
}
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 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() };
@@ -260,7 +252,6 @@ fn main() -> eframe::Result {
// Explorer double-click, a file manager or `xdg-open`) hands them to the PdfCraft already
// running and exits (#282, #317, #1726). Any option means the caller wants this launch's own
// window.
let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images;
// Absolute for this instance too: links relative to a document resolve against its folder,
// never the working directory.
let files = single_instance::absolute(&files);
@@ -270,7 +261,7 @@ fn main() -> eframe::Result {
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 };
let launch = Launch { files, options, control_file, create_images, 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")]
@@ -380,6 +371,7 @@ struct Launch {
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>,
two_paged: bool,
}
/// Whether the window is a native Wayland one (not X11 or XWayland).
@@ -400,7 +392,7 @@ fn app_creator<'a>(
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 } = launch;
let Launch { files, options, control_file, create_images, integrated, window_controls, two_paged } = launch;
Box::new(move |cc| {
started.set(true);
let mut app = PdfCraftApp::new();
@@ -467,6 +459,7 @@ fn app_creator<'a>(
&[("folder", &w.folder.display().to_string()), ("error", &w.error)],
);
}
let defer_layout = two_paged && !files.is_empty() && !create_images;
if create_images {
if let Err(e) = app.begin_image_import_paths(&files) {
app.notify(e);
@@ -475,12 +468,11 @@ fn app_creator<'a>(
// With the preference on, last session's files come back first; files named on
// the command line open after them, in front (#442).
app.reopen_last_files(&files);
for f in files {
app.open_path(&f);
}
startup::open_files(&mut app, &files, two_paged);
}
for (k, v) in options {
if let Err(e) = app.set_option(&k, &v) {
let result = if defer_layout && k == "layout" { app.set_launch_layout(&v) } else { app.set_option(&k, &v) };
if let Err(e) = result {
log::warn!("--{k} {v}: {e}");
}
}
+244
View File
@@ -0,0 +1,244 @@
//! Native launch arguments and per-file view overrides (#1354).
use pdfcraft_ui_egui::PdfCraftApp;
#[derive(Default)]
pub(super) struct Arguments {
pub files: Vec<String>,
pub options: Vec<(String, String)>,
pub control_file: Option<String>,
pub create_images: bool,
pub new_window: bool,
pub two_paged: bool,
pub version: bool,
pub help: bool,
}
impl Arguments {
pub fn may_hand_off(&self) -> bool {
!self.new_window && !self.files.is_empty() && self.options.is_empty() && self.control_file.is_none() && !self.create_images && !self.two_paged
}
pub fn parse(args: impl IntoIterator<Item = String>) -> Self {
let mut parsed = Self::default();
let mut args = args.into_iter();
while let Some(a) = args.next() {
match a.as_str() {
"--version" => {
parsed.version = true;
break;
}
"-h" | "--help" => {
parsed.help = true;
break;
}
"--control" => parsed.control_file = args.next(),
"--create-images" => parsed.create_images = true,
"--new-window" => parsed.new_window = true,
"--two-paged" => parsed.two_paged = true,
flag if flag.starts_with("--two-paged=") => {
// It has no value: do not consume the following filename on malformed use.
log::warn!("{flag}: use --two-paged without a value");
}
flag if flag.starts_with("--") => {
let value = args.next().unwrap_or_default();
parsed.options.push((flag.trim_start_matches("--").to_string(), value));
}
_ => parsed.files.push(super::path_from_arg(a)),
}
}
parsed
}
}
pub(super) fn open_files(app: &mut PdfCraftApp, files: &[String], two_paged: bool) {
for path in files {
if two_paged {
// The per-file override wins over initial view without changing saved defaults.
app.open_path_with_display(path, pdfcraft_ui_egui::canvas::PageLayout::TwoUp, false);
} else {
app.open_path(path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Arguments {
Arguments::parse(args.iter().map(|s| s.to_string()))
}
#[test]
fn standalone_flag_keeps_files_in_either_position() {
for args in
[["--two-paged", "first.pdf", "second.pdf"], ["first.pdf", "--two-paged", "second.pdf"], ["first.pdf", "second.pdf", "--two-paged"]]
{
let parsed = parse(&args);
assert!(parsed.two_paged);
assert_eq!(parsed.files, ["first.pdf", "second.pdf"]);
assert!(parsed.options.is_empty());
assert!(!parsed.may_hand_off());
}
let empty = parse(&["--two-paged"]);
assert!(empty.two_paged && empty.files.is_empty());
let normal = parse(&["first.pdf", "second.pdf"]);
assert!(!normal.two_paged);
assert!(normal.may_hand_off());
for args in [
&["--new-window", "first.pdf"][..],
&["--layout", "two-page", "first.pdf"],
&["--control", "control.json", "first.pdf"],
&["--create-images", "first.png"],
] {
assert!(!parse(args).may_hand_off());
}
}
#[test]
fn existing_layout_options_are_preserved_in_either_order() {
for args in [["--two-paged", "--layout", "single", "file.pdf"], ["--layout", "single", "--two-paged", "file.pdf"]] {
let parsed = parse(&args);
assert!(parsed.two_paged);
assert_eq!(parsed.files, ["file.pdf"]);
assert_eq!(parsed.options, [("layout".into(), "single".into())]);
}
let parsed = parse(&["--two-paged", "--default-layout", "two-up", "file.pdf"]);
assert_eq!(parsed.options, [("default-layout".into(), "two-up".into())]);
}
#[test]
fn newer_options_and_version_are_preserved() {
let parsed = parse(&["--two-paged", "--new-window", "--grid-zoom", "150", "--default-mode", "read", "file:///tmp/a%20b.pdf"]);
assert!(parsed.two_paged && parsed.new_window);
assert_eq!(parsed.files, ["/tmp/a b.pdf"]);
assert_eq!(parsed.options, [("grid-zoom".into(), "150".into()), ("default-mode".into(), "read".into())]);
assert!(!parsed.may_hand_off());
let version = parse(&["--two-paged", "--version", "ignored.pdf"]);
assert!(version.version && version.files.is_empty());
}
#[test]
fn help_keeps_the_headless_early_exit() {
for flag in ["-h", "--help"] {
let args = parse(&["--two-paged", flag, "ignored.pdf"]);
assert!(args.help && args.files.is_empty());
}
}
#[test]
fn malformed_boolean_does_not_eat_a_file_or_option() {
let args = parse(&["--two-paged=false", "first.pdf", "--layout", "single", "second.pdf"]);
assert!(!args.two_paged);
assert_eq!(args.files, ["first.pdf", "second.pdf"]);
assert_eq!(args.options, [("layout".into(), "single".into())]);
}
#[test]
fn startup_override_is_per_file_and_does_not_persist() {
use std::io::Write as _;
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let path = std::env::temp_dir().join(format!("pdfcraft-two-paged-{}-{nonce}.pdf", std::process::id()));
let mut file = std::fs::OpenOptions::new().create_new(true).write(true).open(&path).unwrap();
file.write_all(include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap();
drop(file);
let second_path = path.with_extension("second.pdf");
std::fs::copy(&path, &second_path).unwrap();
let text = path.to_str().unwrap();
let second = second_path.to_str().unwrap();
for enabled in [false, true] {
let mut app = PdfCraftApp::new();
app.set_option("default-layout", "single").unwrap();
app.open_bytes("restored.pdf", None, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf").to_vec()).unwrap();
let restored = app.views[0].id;
open_files(&mut app, &[text.into(), second.into()], enabled);
assert_eq!(app.views.len(), 3);
assert_eq!(app.views[0].id, restored);
assert_eq!(app.views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::Single);
for view in app.views.iter().skip(1) {
assert_eq!(
view.layout,
if enabled { pdfcraft_ui_egui::canvas::PageLayout::TwoUp } else { pdfcraft_ui_egui::canvas::PageLayout::Single }
);
assert!(!view.continuous());
}
let persisted: serde_json::Value = serde_json::from_str(&app.persist()).unwrap();
assert_eq!(persisted["default_layout"], "single");
assert!(persisted.get("two_paged").is_none());
// Existing --layout applies after the flag to the active document only.
if enabled {
app.set_launch_layout("continuous").unwrap();
} else {
app.set_option("layout", "continuous").unwrap();
}
assert_eq!(app.views[2].layout, pdfcraft_ui_egui::canvas::PageLayout::Single);
assert!(app.views[2].continuous());
app.set_option("default-layout", "two-up").unwrap();
assert_eq!(app.view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::TwoUp);
assert!(app.view_defaults.continuous);
open_files(&mut app, &[], enabled);
assert_eq!(app.views.len(), 3);
}
std::fs::remove_file(path).unwrap();
std::fs::remove_file(second_path).unwrap();
}
#[test]
fn explicit_launch_layout_preserves_argument_order_and_active_file() {
use pdfcraft_ui_egui::canvas::PageLayout;
let path = std::env::temp_dir().join(format!("pdfcraft-explicit-layout-{}.pdf", std::process::id()));
std::fs::write(&path, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap();
let second_path = path.with_extension("second.pdf");
std::fs::copy(&path, &second_path).unwrap();
let names = [path.to_str().unwrap(), second_path.to_str().unwrap()];
for prefix in [["--two-paged", "--layout", "single"], ["--layout", "single", "--two-paged"]] {
for count in [1, 2] {
let args = Arguments::parse(prefix.into_iter().chain(names.iter().take(count).copied()).map(str::to_owned));
let mut app = PdfCraftApp::new();
app.set_option("default-layout", "continuous").unwrap();
app.open_bytes("restored.pdf", None, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf").to_vec()).unwrap();
open_files(&mut app, &args.files, args.two_paged);
for (key, value) in args.options {
assert_eq!(key, "layout");
app.set_launch_layout(&value).unwrap();
}
assert_eq!(app.active, Some(count));
assert!(app.views[0].continuous());
for view in app.views.iter().skip(1).take(count - 1) {
assert_eq!(view.layout, PageLayout::TwoUp);
}
assert_eq!(app.views[count].layout, PageLayout::Single);
assert!(!app.views[count].continuous());
assert!(app.view_defaults.continuous);
}
}
std::fs::remove_file(path).unwrap();
std::fs::remove_file(second_path).unwrap();
}
}
#[cfg(test)]
mod repeated_file_tests {
use super::*;
#[test]
fn a_repeated_file_reuses_its_tab_and_receives_the_launch_override() {
use pdfcraft_ui_egui::canvas::PageLayout;
let path = std::env::temp_dir().join(format!("pdfcraft-repeated-startup-{}.pdf", std::process::id()));
std::fs::write(&path, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap();
let name = path.to_str().unwrap().to_string();
let mut app = PdfCraftApp::new();
app.set_option("default-layout", "continuous").unwrap();
app.open_path(&name);
let original = app.views[0].id;
open_files(&mut app, &[name.clone(), name], true);
assert_eq!(app.views.len(), 1);
assert_eq!(app.views[0].id, original);
assert_eq!(app.views[0].layout, PageLayout::TwoUp);
assert!(!app.views[0].continuous());
app.set_launch_layout("single").unwrap();
assert_eq!(app.views[0].layout, PageLayout::Single);
assert!(app.view_defaults.continuous);
std::fs::remove_file(path).unwrap();
}
}
+4 -5
View File
@@ -1,6 +1,6 @@
# PdfCraft app icon
<img src="pdfcraft-small.svg" alt="PdfCraft app icon: an engraved lion's head on green" width="128">
<img src="pdfcraft-small.svg" alt="PdfCraft app icon: an engraved lion's head on red" width="128">
**Creature:** a lion, in a frontal head-and-shoulders portrait, mane running off the bottom of the tile.
@@ -13,15 +13,14 @@ a full-bleed colour field, no frame or roundel, the animal looking at the viewer
|---|---|---|
| Ink | `#0b0b0c` | line work and the figure's contour |
| Paper | `#efe9dc` | the figure (the lion's silhouette) |
| PdfCraft green (app colour) | `#12a58a` | the full-bleed field |
| PdfCraft red (app colour) | `#b70228` | the full-bleed field |
**Tile:** `viewBox="0 0 512 512"`, a rounded square with `rx=112` that clips everything. Windows and Linux
icons use the full-bleed tile. macOS icons put it on Apple's grid (an 824 px body centred on a transparent
1024 px canvas).
**Provenance:** the project owner's original drawing, made in ArtCraft (2880 px, keyed to the palette), then
vectorised with craftrules `assets/logo-options/_tools/vectorize_tile.py` (potrace; no filtering or
warping). The source drawing is kept in craftrules at `assets/app-icons/pdfcraft/source.png`, not here.
vectorised (potrace; no filtering or warping).
Licence: [LICENSE.txt](LICENSE.txt) (`MIT OR Apache-2.0`, like the repo).
## Files
@@ -33,7 +32,7 @@ Licence: [LICENSE.txt](LICENSE.txt) (`MIT OR Apache-2.0`, like the repo).
| `pdfcraft-1024.png` | 1024 px on Apple's grid; also the runtime Dock icon on macOS |
| `pdfcraft.icns` | macOS icon (16–1024 px) |
| `pdfcraft.ico` | Windows icon (16–256 px), embedded in `pdfcraft.exe` by `apps/pdfcraft/build.rs` |
| `pdfcraft-document.svg` | Windows PDF file icon master: a Lucide document outline, green badge and outlined PDF lettering; no font dependency |
| `pdfcraft-document.svg` | Windows PDF file icon master: a Lucide document outline, red badge and outlined PDF lettering; no font dependency |
| `pdfcraft-document.ico` | Windows PDF file icon (16–256 px), embedded as resource 2 in `pdfcraft.exe` |
| `hicolor/<n>x<n>/apps/ai.storyteller.pdfcraft.png` | Linux hicolor theme, 16–512 px; the 256 px one is the runtime icon on Windows and Linux |
| `hicolor/scalable/apps/ai.storyteller.pdfcraft.svg` | Linux scalable icon (copy of the master) |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 695 B

After

Width:  |  Height:  |  Size: 723 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 451 KiB

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+3 -3
View File
@@ -4,10 +4,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<g transform="translate(-2 -2) scale(2.8)" stroke="#0b0b0c" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<path fill="#efe9dc" d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
<path fill="#c8ece4" d="M14 2v5a1 1 0 0 0 1 1h5" />
<path fill="none" stroke="#12a58a" d="M8 12h8M8 15h5" />
<path fill="#f3cfd6" d="M14 2v5a1 1 0 0 0 1 1h5" />
<path fill="none" stroke="#b70228" d="M8 12h8M8 15h5" />
</g>
<rect x="3" y="34" width="56" height="23" rx="4" fill="#087f6b" />
<rect x="3" y="34" width="56" height="23" rx="4" fill="#8a011e" />
<g fill="none" stroke="#fff" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 50V41h5a3 3 0 0 1 0 6h-5" />
<path d="M27 41v9h3a4.5 4.5 0 0 0 0-9z" />

Before

Width:  |  Height:  |  Size: 1023 B

After

Width:  |  Height:  |  Size: 1023 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 451 KiB

After

Width:  |  Height:  |  Size: 451 KiB

+8 -5
View File
@@ -1,13 +1,10 @@
# Contributor names for the About window (craftrules standards/contributors.md).
# Contributor names for the About window.
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
# Copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
@@ -180,3 +177,9 @@ sync_github_name = true
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
[people.Combiler01]
sync_github_name = true
+6 -3
View File
@@ -188,7 +188,10 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
// Where each id's last glyph ended, in user space.
let mut ends: HashMap<i64, (f64, f64)> = HashMap::new();
let mut gs = TextState::default();
let mut saved: Vec<TextState> = Vec::new();
// With each saved state, the text and text line matrices: inside a text object `q`/`Q`
// save and restore them too (ISO 32000-2 §9.4.1, as corrected by the PDF Association's
// errata, issue 368), as the renderer does.
let mut saved: Vec<(TextState, Matrix, Matrix)> = Vec::new();
let mut unsaved = 0usize;
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
for op in pdfcraft_content::parse(&page_content(doc, page)).ops {
@@ -209,12 +212,12 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
b"EMC" => {
stack.pop();
}
b"q" if saved.len() < MAX_SAVED => saved.push(gs.clone()),
b"q" if saved.len() < MAX_SAVED => saved.push((gs.clone(), tm, tlm)),
b"q" => unsaved += 1,
b"Q" if unsaved > 0 => unsaved -= 1,
b"Q" => {
if let Some(saved) = saved.pop() {
gs = saved;
(gs, tm, tlm) = saved;
}
}
b"cm" => {
+13
View File
@@ -1235,6 +1235,19 @@ fn headings_drawn_glyph_by_glyph_keep_their_words() {
);
}
/// Inside a text object `q`/`Q` save and restore the text matrices (ISO 32000-2 §9.4.1, as
/// corrected by the PDF Association's errata, issue 368), as the renderer does: a word whose
/// glyphs continue after a `Q` stays one word.
#[test]
fn q_inside_a_text_object_restores_the_text_matrices() {
let doc = heading_page(&[
"BT /F1 20 Tf 72 700 Td (Chap) Tj q 0 -100 Td Q (ter One) Tj ET".into(),
"BT /F1 20 Tf 72 650 Td (Chap) Tj q 1 0 0 1 300 300 Tm Q (ter Two) Tj ET".into(),
]);
let titles: Vec<String> = headings(&doc).into_iter().map(|h| h.title).collect();
assert_eq!(titles, ["Chapter One", "Chapter Two"]);
}
#[test]
fn an_untagged_document_has_no_headings() {
let mut objs = headings_doc();
+5 -1
View File
@@ -392,9 +392,13 @@ struct AppearanceDrawing {
}
impl AppearanceDrawing {
/// Initialize an appearance drawing buffer and configure its graphics state for opacity and blend mode.
fn new(context: &AppearanceContext<'_>) -> Self {
let mut drawing = Self::default();
if let Some(gs) = ext_gstate(context.opacity, context.subtype == b"Highlight") {
if let Some(gs) = ext_gstate(
context.opacity,
context.subtype == b"Highlight" || (context.subtype == b"Ink" && context.dict.name(b"IT") == Some(b"FreehandHighlight")),
) {
drawing.resources.set(b"ExtGState".to_vec(), Object::Dict(gs));
drawing.content.push_str("/GS0 gs\n");
}
+17 -9
View File
@@ -33,6 +33,16 @@ pub(crate) fn grow(r: [f64; 4], by: f64) -> [f64; 4] {
[r[0] - by, r[1] - by, r[2] + by, r[3] + by]
}
/// Tight bounding box of ink strokes, including Bezier control points for strokes with three or
/// more points (matching [`appearance::smooth_segments`]).
pub(crate) fn ink_bounds(strokes: &[Vec<[f64; 2]>]) -> Option<[f64; 4]> {
let controls = strokes.iter().filter(|s| s.len() > 2).flat_map(|s| {
let pts: Vec<(f64, f64)> = s.iter().map(|p| (p[0], p[1])).collect();
appearance::smooth_segments(&pts).into_iter().flat_map(|[a, b, _]| [[a.0, a.1], [b.0, b.1]])
});
bounds(strokes.iter().flatten().copied().chain(controls))
}
pub(crate) fn ending_draws(ending: LineEnding) -> bool {
ending != LineEnding::None
}
@@ -87,17 +97,11 @@ pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotEr
let pad = half + if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 };
grow(bounds([*from, *to].into_iter()).unwrap_or_default(), pad + 1.0)
}
Shape::Ink { strokes } | Shape::Signature { strokes } => {
Shape::Ink { strokes } | Shape::FreehandHighlight { strokes } | Shape::Signature { strokes } => {
if strokes.iter().all(Vec::is_empty) || !strokes.iter().flatten().all(|p| finite(p)) {
return Err(bad("drawing (no points)"));
}
// Strokes of three or more points are drawn as curves, which stay within their
// points and control points.
let controls = strokes.iter().filter(|s| s.len() > 2).flat_map(|s| {
let pts: Vec<(f64, f64)> = s.iter().map(|p| (p[0], p[1])).collect();
appearance::smooth_segments(&pts).into_iter().flat_map(|[a, b, _]| [[a.0, a.1], [b.0, b.1]])
});
grow(bounds(strokes.iter().flatten().copied().chain(controls)).unwrap_or_default(), half + 1.0)
grow(ink_bounds(strokes).unwrap_or_default(), half + 1.0)
}
Shape::Polygon { vertices, cloud } => {
let b = bounds(vertices.iter().copied())
@@ -164,6 +168,7 @@ fn subject(shape: &Shape) -> &'static str {
Shape::Oval { .. } => "Oval",
Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Arrow",
Shape::Line { .. } => "Line",
Shape::FreehandHighlight { .. } => "Freehand Highlight",
Shape::Ink { .. } => "Pencil",
Shape::TextBox { .. } => "Text Box",
Shape::Typewriter { .. } => "Typewriter",
@@ -292,10 +297,13 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
}
}
}
Shape::Ink { strokes } | Shape::Signature { strokes } => {
Shape::Ink { strokes } | Shape::FreehandHighlight { strokes } | Shape::Signature { strokes } => {
d.set(b"C".to_vec(), rgb(style.color));
let list = strokes.iter().filter(|s| !s.is_empty()).map(|s| num_array(&s.concat())).collect();
d.set(b"InkList".to_vec(), Object::Array(list));
if matches!(&new.shape, Shape::FreehandHighlight { .. }) {
d.set(b"IT".to_vec(), Object::name("FreehandHighlight"));
}
border(&mut d);
}
Shape::Polygon { vertices, cloud } => {
+5 -3
View File
@@ -33,11 +33,13 @@ pub use info::{Props, Summary, is_comment_subtype, 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, page_refs, picture_rotation, set_annots, signature_image};
pub use lookup::{
has_visible_fill_sign, is_fill_sign, isolate_annotation, isolate_annotations, page_refs, picture_rotation, set_annots, signature_image,
};
pub(crate) use modify::n;
pub use modify::{
add_reply, add_text_replacement, delete_annotation, erase_ink, fillable, goes_with, 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, set_contents,
set_fill, set_font_size, set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups,
};
pub use types::{
AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape,
+24 -7
View File
@@ -226,16 +226,33 @@ pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result<i64
/// over the page while it is dragged. `false`, with `doc` unchanged, when the annotation has no
/// normal appearance (`/AP /N`) for that render to draw.
pub fn isolate_annotation(doc: &mut Document, page: usize, index: usize) -> Result<bool, AnnotError> {
isolate_annotations(doc, page, &[index])
}
/// [`isolate_annotation`] for several annotations of one page: those of them with a normal
/// appearance stay, in their order on the page. `false`, with `doc` unchanged, when none has one.
/// An index that isn't one of the page's annotations is an error even when the others are, and
/// leaves `doc` unchanged too.
pub fn isolate_annotations(doc: &mut Document, page: usize, indices: &[usize]) -> Result<bool, AnnotError> {
let p = page_ref(doc, page)?;
let entry = annots(doc, p).get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?;
let has_normal = {
let obj = doc.resolve(&entry);
obj.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N")))
};
if !has_normal {
let list = annots(doc, p);
let mut kept = Vec::new();
for (index, entry) in list.iter().enumerate() {
if !indices.contains(&index) {
continue;
}
let obj = doc.resolve(entry);
if obj.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) {
kept.push(entry.clone());
}
}
if let Some(&index) = indices.iter().find(|&&i| i >= list.len()) {
return Err(AnnotError::NoSuchAnnotation { page, index });
}
if kept.is_empty() {
return Ok(false);
}
set_annots(doc, p, vec![entry])?;
set_annots(doc, p, kept)?;
doc.update_dict(p, |d| {
d.remove(b"Contents");
})?;
+76 -10
View File
@@ -1,5 +1,5 @@
use super::create::{
add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, normalize, num_array, rect_for, rgb,
add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, rect_for, rgb,
};
use super::draw::{draw_appearance, redraw_or_restore, set_appearance};
use super::info::{line_endings_of, text_value};
@@ -70,20 +70,53 @@ pub(crate) fn touch(d: &mut Dict, meta: &Meta) {
}
/// Delete a comment together with its pop-up and its replies (and theirs), as Acrobat does.
/// Refused while it, or a reply that would go with it, is locked.
pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> {
let p = page_ref(doc, page)?;
let list = annots(doc, p);
let target = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?;
deletable(doc, page, index)?;
lift_annotation(doc, page, index)
}
/// Whether [`delete_annotation`] deletes the annotation at `(page, index)`, or why not, without
/// changing anything: what a Delete command asks before it is offered. It is refused while the
/// annotation is locked, or a reply that would go with it is.
pub fn deletable(doc: &Document, page: usize, index: usize) -> Result<(), AnnotError> {
let list = annots(doc, page_ref(doc, page)?);
let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
if let Some(r) = target.as_ref() {
unlocked(doc, r)?;
}
// An inline comment has no reference to collect; the pop-up it names still goes.
let seed = target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup")));
let doomed = with_replies_and_popups(doc, &list, seed);
let kept: Vec<Object> = list.into_iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e).collect();
let doomed = with_replies_and_popups(doc, &list, seed(target));
// A locked reply can't be deleted on its own, so it doesn't go with the comment or reply it
// answers either. Pop-ups follow their comment.
for (_, e) in list.iter().enumerate().filter(|(i, e)| *i != index && goes_with(e, &doomed)) {
let obj = doc.resolve(e);
if let Some(d) = obj.as_dict().filter(|d| d.name(b"Subtype") != Some(b"Popup".as_slice()))
&& annotation_flags(doc, d) & FLAG_LOCKED != 0
{
return Err(AnnotError::Invalid("a reply to it is locked".into()));
}
}
Ok(())
}
/// Take the annotation at `(page, index)` out of its page with its pop-up and replies, as
/// [`delete_annotation`] does but whatever their locks: for drawing the page beneath it on a
/// copy of the document (a stamp or signature being dragged), which changes nothing.
pub fn lift_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> {
let p = page_ref(doc, page)?;
let list = annots(doc, p);
let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
let doomed = with_replies_and_popups(doc, &list, seed(target));
let kept: Vec<Object> = list.iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e.clone()).collect();
Ok(set_annots(doc, p, kept)?)
}
/// What deleting the `/Annots` entry `target` starts from: its reference, or for an inline
/// comment (which has none to collect) the pop-up it names, which still goes.
fn seed(target: &Object) -> Option<ObjRef> {
target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup")))
}
/// Whether the `/Annots` entry `entry` goes with `going` (from [`with_replies_and_popups`]): it is
/// one of them, or it is written inline (a file can, though the standard asks for references) and
/// replies to (`/IRT`) or pops up for (`/Parent`) one of them. An inline entry has no reference
@@ -496,7 +529,33 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne
}
fn border_width_of(d: &Dict) -> f64 {
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).max(0.0)
appearance::border_width(d)
}
/// Recompute `/Rect` for an `Ink` annotation when its stroke width changes from `old_w` to `w`.
/// Preserves any baseline margin beyond `old_w / 2.0` (at least `1.0` pt) without accumulating
/// padding across repeated width increases and decreases.
fn ink_rect_for_width(d: &Dict, old_w: f64, w: f64) -> Option<[f64; 4]> {
let list = d.get(b"InkList")?.as_array()?;
let mut strokes: Vec<Vec<[f64; 2]>> = Vec::with_capacity(list.len());
for s in list {
let pts: Vec<f64> = s.as_array()?.iter().map(Object::as_f64).collect::<Option<_>>()?;
strokes.push(pts.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect());
}
let inner = ink_bounds(&strokes)?;
let vals: Vec<f64> = d.get(b"Rect").and_then(Object::as_array).map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default();
let Some(old_rect) = four(&vals).map(normalize) else {
return Some(grow(inner, w / 2.0 + 1.0));
};
let old_half = old_w / 2.0;
let new_half = w / 2.0;
let base = [
(inner[0] - old_rect[0] - old_half).max(1.0),
(inner[1] - old_rect[1] - old_half).max(1.0),
(old_rect[2] - inner[2] - old_half).max(1.0),
(old_rect[3] - inner[3] - old_half).max(1.0),
];
Some([inner[0] - base[0] - new_half, inner[1] - base[1] - new_half, inner[2] + base[2] + new_half, inner[3] + base[3] + new_half])
}
/// Change a comment's colour, opacity, line width and/or line endings, and redraw it.
@@ -543,10 +602,17 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha
}
}
if let Some(w) = width {
let old_w = border_width_of(d);
let w = w.max(0.0);
let mut bs = d.get(b"BS").and_then(|b| b.as_dict()).cloned().unwrap_or_default();
bs.set(b"W".to_vec(), Object::Real(w.max(0.0)));
bs.set(b"W".to_vec(), Object::Real(w));
d.set(b"BS".to_vec(), Object::Dict(bs));
d.remove(b"Border");
if d.name(b"Subtype") == Some(b"Ink")
&& let Some(rect) = ink_rect_for_width(d, old_w, w)
{
d.set(b"Rect".to_vec(), num_array(&rect));
}
}
if let Some(ends) = endings {
if ends.len() == 1 {
+84
View File
@@ -229,6 +229,34 @@ fn delete_takes_popup_and_reply_chain() {
assert_eq!(delete_annotation(&mut doc, 0, 9), Err(AnnotError::NoSuchAnnotation { page: 0, index: 9 }));
}
/// A locked reply can't be deleted, so it doesn't go with what it answers either: deleting the
/// reply above it, or the comment, is refused and changes nothing; once it is unlocked they go.
#[test]
fn a_locked_reply_keeps_what_it_answers() {
let mut doc = fixture();
let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [100.0, 100.0], icon: NoteIcon::Comment }), &meta("n")).unwrap();
let reply = add_reply(&mut doc, 0, note, "first", "Bob", &meta("r1")).unwrap();
let answer = add_reply(&mut doc, 0, reply, "locked answer", "Ada", &meta("r2")).unwrap();
set_locked(&mut doc, 0, answer, true).unwrap();
let before = list(&doc, 0).len();
assert_eq!(delete_annotation(&mut doc, 0, reply), Err(AnnotError::Invalid("a reply to it is locked".into())));
assert_eq!(delete_annotation(&mut doc, 0, note), Err(AnnotError::Invalid("a reply to it is locked".into())));
assert_eq!(list(&doc, 0).len(), before, "nothing went");
// Asked first, read-only, as a Delete command does: the same answer, and the answer's own
// lock is its own.
assert_eq!(deletable(&doc, 0, note), Err(AnnotError::Invalid("a reply to it is locked".into())));
assert_eq!(deletable(&doc, 0, answer), Err(AnnotError::Invalid("the comment is locked".into())));
// Lifted on a copy, to draw the page beneath, the locks don't count.
let mut copy = doc.clone();
lift_annotation(&mut copy, 0, note).unwrap();
assert!(list(&copy, 0).len() < before, "the thread is lifted off the copy");
set_locked(&mut doc, 0, answer, false).unwrap();
assert_eq!(deletable(&doc, 0, reply), Ok(()));
delete_annotation(&mut doc, 0, reply).unwrap();
let left: Vec<String> = list(&doc, 0).iter().map(|d| text(d, b"Contents")).collect();
assert!(!left.iter().any(|t| t == "first" || t == "locked answer"), "the reply and its answer went: {left:?}");
}
/// A reply or pop-up written inline in `/Annots` (a file can, though the standard asks for
/// references) goes with the comment it points at, and so does a pop-up an inline reply or an
/// inline comment names. An unrelated inline comment stays until it is deleted itself.
@@ -2166,3 +2194,59 @@ fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() {
assert!(long.contains(&format!("({}) Tj", "w".repeat(256))), "cut to 256");
assert!(!long.contains(&"w".repeat(257)));
}
#[test]
fn freehand_highlight_is_compatible_ink_with_marker_blending() {
let shape = Shape::FreehandHighlight { strokes: vec![vec![[20.0, 100.0], [120.0, 100.0]]] };
let mut annotation = new(0, shape.clone());
annotation.style = Style::default_for(&shape);
let mut doc = fixture();
let index = add_annotation(&mut doc, &annotation, &meta("marker")).unwrap();
let doc = reopen(&doc);
let ink = &list(&doc, 0)[index];
assert_eq!(ink.name(b"Subtype"), Some(&b"Ink"[..]));
assert_eq!(ink.name(b"IT"), Some(&b"FreehandHighlight"[..]));
assert_eq!(ink.get(b"CA").and_then(Object::as_f64), Some(0.35));
let ap = ink.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap();
let resources = doc.get(ap).as_dict().unwrap().get(b"Resources").cloned().unwrap();
let blend = resources.as_dict().unwrap().get(b"ExtGState").unwrap().as_dict().unwrap().get(b"GS0").unwrap().as_dict().unwrap();
assert_eq!(blend.name(b"BM"), Some(&b"Multiply"[..]));
let mut doc = doc;
let rect_of = |doc: &Document, idx: usize| -> Vec<f64> {
list(doc, 0)[idx].get(b"Rect").unwrap().as_array().unwrap().iter().filter_map(Object::as_f64).collect()
};
let rect_before = rect_of(&doc, index);
set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(34.0), endings: None }, &meta("wider")).unwrap();
let rect_after = rect_of(&doc, index);
assert!((rect_after[0] - (rect_before[0] - 10.0)).abs() < 1e-4);
assert!((rect_after[2] - (rect_before[2] + 10.0)).abs() < 1e-4);
// Repeated increase/decrease cycles must keep bounds stable and never accumulate padding.
set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(14.0), endings: None }, &meta("narrower")).unwrap();
let rect_restored = rect_of(&doc, index);
for (a, b) in rect_restored.iter().zip(rect_before.iter()) {
assert!((a - b).abs() < 1e-4, "expected {rect_before:?}, got {rect_restored:?}");
}
set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(34.0), endings: None }, &meta("wider-again")).unwrap();
let rect_second_wide = rect_of(&doc, index);
for (a, b) in rect_second_wide.iter().zip(rect_after.iter()) {
assert!((a - b).abs() < 1e-4, "expected {rect_after:?}, got {rect_second_wide:?}");
}
// Imported ink whose width is specified only by `/Border [0 0 20]` must use 20 pt as its
// previous width (shrinking by 5 pt on a decrease to 10 pt rather than expanding as if 1 pt).
let (_, r) = lookup::annot_ref(&mut doc, 0, index).unwrap();
doc.update_dict(r, |d| {
d.remove(b"BS");
d.set(b"Border".to_vec(), Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(20.0)]));
d.set(b"Rect".to_vec(), create::num_array(&[5.0, 85.0, 135.0, 115.0]));
})
.unwrap();
set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(10.0), endings: None }, &meta("border-shrink")).unwrap();
let rect_from_border = rect_of(&doc, index);
for (a, b) in rect_from_border.iter().zip([10.0, 90.0, 130.0, 110.0]) {
assert!((a - b).abs() < 1e-4, "expected [10, 90, 130, 110], got {rect_from_border:?}");
}
}
+7 -1
View File
@@ -355,6 +355,10 @@ pub enum Shape {
start: LineEnding,
end: LineEnding,
},
/// Translucent freehand highlighting, stored as ink with a distinct intent.
FreehandHighlight {
strokes: Vec<Vec<[f64; 2]>>,
},
/// Freehand strokes (Draw tool).
Ink {
strokes: Vec<Vec<[f64; 2]>>,
@@ -474,6 +478,7 @@ pub fn rect_quad(r: [f64; 4]) -> [f64; 8] {
}
impl Shape {
/// Return the PDF `/Subtype` name for this annotation shape.
pub fn subtype(&self) -> &'static str {
match self {
Shape::Note { .. } => "Text",
@@ -481,7 +486,7 @@ impl Shape {
Shape::Rectangle { .. } => "Square",
Shape::Oval { .. } => "Circle",
Shape::Line { .. } => "Line",
Shape::Ink { .. } | Shape::Signature { .. } => "Ink",
Shape::Ink { .. } | Shape::FreehandHighlight { .. } | Shape::Signature { .. } => "Ink",
Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. } => "FreeText",
Shape::Mark { .. } | Shape::Stamp { .. } | Shape::CustomStamp { .. } | Shape::TypedSignature { .. } => "Stamp",
Shape::Redact { .. } => "Redact",
@@ -610,6 +615,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::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),
+41
View File
@@ -21,6 +21,9 @@ them with the image's current orientation. Full turns retain its appearance, rec
and flips. Angles that are not multiples of 90 are refused without changing the document or
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.
`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
@@ -181,6 +184,12 @@ working document unchanged.
`content_list` or `object_list`. It removes only that item in a read-only preview and is
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
the PNG alone.
## Existing text and document fonts
@@ -193,6 +202,19 @@ 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).
### Superscript and subscript normalization
`text_script` changes characters to their Unicode superscript or subscript forms; it does not
change the font size or baseline. For characters that have such forms, uniformly plain text
toggles to the requested script, and uniformly scripted text toggles back to plain text.
Characters without a matching form stay unchanged and do not determine the selection's state.
A selection mixing plain and target-script characters normalizes to the requested script.
For example, subscript changes `H₂2` to `H₂₂`, then to `H22` on the next call; superscript
changes `Q²2` to `Q²²`, then to `Q22`. A second toggle does not reconstruct the original
mixture. Each call is one undoable text edit: `edit_undo` restores the exact prior text,
including its original mixture. Save/reopen preserves the resulting normalized characters.
## Opening local GoToR links
`link_open {doc, page, index, password?}` follows a local PDF link using `link_list`
@@ -214,6 +236,18 @@ unapplied with a warning.
folders. The CLI also exposes it as `pdfcraft-cli batch`. See the
[batch guide](../../docs/cli-batch.md) for examples, output protection, errors and limits.
## Editing button export values
Use `form_set_props` with `field`, `export_value` and optional `widget`
(1-based, default 1) to change a checkbox or radio button export value.
`form_fields` returns `export_values` in widget order. The selected/default
appearance states are preserved; the operation is undoable and survives saving.
Widgets sharing an appearance state keep one export value. Bulk edits are not
supported for this widget-specific property. Empty values, `Off`, conflicting
choices and values over 4096 UTF-8 bytes return an error.
Example: `form_set_props doc=1 field=agree export_value=Accepted widget=1`.
## Viewer navigation history
A running PdfCraft instance launched with `--control FILE` exposes its per-tab history through
@@ -229,3 +263,10 @@ behavior. Back/Forward
returns to page tops, does not restore zoom, does not switch documents, and is not saved across
restarts. Standalone engine/MCP sessions have no viewport, so they do not have a separate
navigation-history tool; use the opt-in UI control channel to navigate the displayed document.
## Fill & Sign text appearance
`fill_sign_add` accepts `font_size` (4–300 pt, default 10) and `color`
(`#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.
+2 -2
View File
@@ -5,7 +5,7 @@ impl Automation {
let path = self.resolve(a.str("path")?, true)?;
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 or rtf)")))?;
.ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html, rtf or md)")))?;
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).
@@ -15,7 +15,7 @@ impl Automation {
if !export.truncated_pages.is_empty() {
use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS};
out["warnings"] = json!([format!(
"forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them"
"pictures past what one export may hold or decode, or forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them"
)]);
}
Ok(out)
+10 -3
View File
@@ -17,14 +17,21 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
}
}
/// 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 {
json!({ "message": m.text, "title": m.title, "icon": m.icon.name(), "buttons": m.buttons.name() })
}
impl Automation {
/// `js_run`: run `script` in the document, or as button `field`'s script, and report its
/// 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 reqs: Vec<Value> = o.requests.iter().map(request_json).collect();
Ok(
json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }),
)
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 }))
}
pub(crate) fn js_document_scripts(&self, a: &Args) -> Result<Value> {
+8 -2
View File
@@ -17,6 +17,7 @@ const MAX_INPUT: u64 = 512 * 1024 * 1024;
struct Recipe<'a> {
steps: Vec<&'a str>,
images: optimize::ImageSettings,
mono: optimize::MonoSettings,
ocr: ocr::OcrSettings,
}
@@ -38,12 +39,17 @@ impl<'a> Recipe<'a> {
}
}
let mut images = optimize::ImageSettings::REDUCE;
let mut mono = optimize::MonoSettings::REDUCE;
if let Some(ppi) = args.opt_num("ppi")? {
if !(9.0..=1600.0).contains(&ppi) {
return Err(Args::wrong("ppi", "9 to 1600"));
}
images.target_ppi = ppi;
images.above_ppi = ppi * 1.5;
// Black-and-white images keep Reduce File Size's higher resolution, at which text
// stays legible, and are never taken below a higher `ppi` asked for.
mono.target_ppi = mono.target_ppi.max(ppi);
mono.above_ppi = mono.target_ppi * 1.5;
}
if let Some(q) = args.opt_int("quality")? {
if !(1..=100).contains(&q) {
@@ -54,7 +60,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, ocr: auto.ocr_settings(args)? })
Ok(Self { steps, images, mono, ocr: auto.ocr_settings(args)? })
}
fn accepts(&self, path: &Path) -> bool {
@@ -88,7 +94,7 @@ impl<'a> Recipe<'a> {
}
"optimize" => {
let id = session.open(name, None, bytes.clone(), None).map_err(failed)?;
let settings = optimize::Settings { color: self.images, gray: self.images, ..Default::default() };
let settings = optimize::Settings { color: self.images, gray: self.images, mono: self.mono, ..Default::default() };
let (candidate, _) = session.optimized_bytes(id, &settings, &[])?;
session.close(id);
if candidate.len() < bytes.len() {
+11 -3
View File
@@ -183,6 +183,7 @@ impl Automation {
"page": c.page + 1,
"index": c.index + 1,
"type": c.subtype,
"intent": c.intent,
"author": c.author,
"contents": c.contents,
"modified": c.modified,
@@ -328,7 +329,7 @@ impl Automation {
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 }
}
"ink" => {
"ink" | "freehand_highlight" => {
let wrong = || ToolError::InvalidArgs("strokes must be an array of arrays of [x, y] points".into());
let strokes = a.get("strokes").ok_or_else(|| ToolError::InvalidArgs("ink needs `strokes`".into()))?;
let strokes = strokes
@@ -346,7 +347,7 @@ impl Automation {
.collect::<Result<Vec<_>>>()
})
.collect::<Result<Vec<_>>>()?;
Shape::Ink { strokes }
if kind == "freehand_highlight" { Shape::FreehandHighlight { strokes } } else { Shape::Ink { strokes } }
}
"polygon" | "cloud" | "polyline" => {
let wrong = || ToolError::InvalidArgs(format!("{kind} needs `points`: an array of [x, y] points"));
@@ -502,6 +503,10 @@ impl Automation {
if sensitivity.is_some() && !scan {
return Err(ToolError::InvalidArgs("sensitivity is only for scan: true".into()));
}
let requested_color = a.color("color")?;
if requested_color.is_some() && !matches!(kind, "text" | "date") {
return Err(ToolError::InvalidArgs("color is only for type text or date".into()));
}
let requested_size = a.opt_num("font_size")?;
if requested_size.is_some() && !matches!(kind, "text" | "date") {
return Err(ToolError::InvalidArgs("font_size is only for type text or date".into()));
@@ -579,7 +584,10 @@ impl Automation {
(Shape::Mark { rect: mark.rect_at(at, i64::from(info.rotation)), mark }, String::new())
}
};
let style = Style::default_for(&shape);
let mut style = Style::default_for(&shape);
if let Some(color) = requested_color {
style.color = color;
}
self.apply(a, Edit::AddAnnotation(NewAnnotation { page, shape, style, contents, author }))
}
+43 -9
View File
@@ -53,13 +53,13 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> {
t.color = parse_color(c)?;
}
if let Some(al) = a.opt_str("align")? {
t.align = match al {
"left" => TextAlign::Left,
"center" | "centre" => TextAlign::Center,
"right" => TextAlign::Right,
"justify" => TextAlign::Justify,
other => return Err(bad(format!("unknown align {other:?}"))),
};
t.align = parse_align(al)?;
// The whole box: no paragraph keeps an alignment of its own unless `aligns` gives it one.
t.aligns.clear();
}
if let Some(aligns) = opt_aligns(a)? {
let count = pdfcraft_engine::paragraph_align::count(&t.text);
t.aligns = pdfcraft_engine::paragraph_align::tidy(aligns.into_iter().take(count).collect(), t.align);
}
if let Some(c) = a.opt_num("char_spacing")? {
if !(-ADDED_CHAR_SPACING_LIMIT..=ADDED_CHAR_SPACING_LIMIT).contains(&c) {
@@ -70,6 +70,38 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> {
Ok(())
}
pub(crate) fn parse_align(al: &str) -> Result<TextAlign> {
Ok(match al {
"left" => TextAlign::Left,
"center" | "centre" => TextAlign::Center,
"right" => TextAlign::Right,
"justify" => TextAlign::Justify,
other => return Err(bad(format!("unknown align {other:?}"))),
})
}
/// Most entries `aligns` takes: one per paragraph, and no text has this many.
const MAX_ALIGNS: usize = 4096;
/// `aligns`: each paragraph's (line's) own alignment, or inherit/null where it follows `align`.
pub(crate) fn opt_aligns(a: &Args) -> Result<Option<Vec<Option<TextAlign>>>> {
let Some(v) = a.get("aligns") else { return Ok(None) };
let items = v.as_array().ok_or_else(|| bad("aligns must be an array of left, center, right, justify, inherit or null"))?;
if items.len() > MAX_ALIGNS {
return Err(bad(format!("aligns takes at most {MAX_ALIGNS} entries")));
}
items
.iter()
.map(|v| match v {
Value::Null => Ok(None),
Value::String(s) if s == "inherit" => Ok(None),
Value::String(s) => parse_align(s).map(Some),
_ => Err(bad("each entry of aligns is left, center, right, justify, inherit or null")),
})
.collect::<Result<Vec<_>>>()
.map(Some)
}
impl Automation {
pub(crate) fn content_list(&self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
@@ -160,6 +192,8 @@ impl Automation {
match &mut content {
AddedContent::Text(t) => {
if let Some(s) = a.opt_str("text")? {
// Each paragraph keeps its own alignment through the retyping.
t.aligns = pdfcraft_engine::paragraph_align::remap(&t.text, s, &t.aligns);
t.text = s.to_string();
}
style(a, t)?;
@@ -171,8 +205,8 @@ impl Automation {
if a.get("text").is_some() {
return Err(bad("images have no text"));
}
if ["font", "size", "bold", "italic", "color", "align", "char_spacing"].iter().any(|k| a.get(k).is_some()) {
return Err(bad("font, size, bold, italic, color, align and char_spacing apply to 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 let Some(deg) = a.opt_int("rotate")? {
if deg.rem_euclid(90) != 0 {
+4 -4
View File
@@ -1,7 +1,7 @@
//! Shared command/inspection/preview entry points, using the existing confined tool path.
use serde_json::{Value, json};
use crate::{Args, Automation, Content, Result, ToolError, encode_png, failed, info, tools};
use crate::{Args, Automation, Content, Result, ToolError, failed, info, rendered_content, tools};
use pdfcraft_engine::commands;
use pdfcraft_render::{RenderRequest, RequestKind};
@@ -108,7 +108,8 @@ impl Automation {
Ok(json!({"documents":self.session.docs().iter().map(info).collect::<Vec<_>>()}))
}
pub(crate) fn render_preview(&mut self, args: &Args) -> Result<Content> {
/// A bounded PNG of the page, with its render warnings after it as `page_render` gives them.
pub(crate) fn render_preview(&mut self, args: &Args) -> Result<Vec<Content>> {
let id = if args.opt_int("doc")?.is_some() {
self.doc(args)?.id
} else {
@@ -134,7 +135,6 @@ impl Automation {
if let Some(error) = out.error {
return Err(failed(error));
}
let data = encode_png(out.width, out.height, &out.rgba)?;
Ok(Content::Png { data, width: out.width, height: out.height })
rendered_content(&out, page)
}
}
+149 -18
View File
@@ -1,7 +1,7 @@
use super::error::{Result, ToolError, failed};
use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary};
use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary, text_style_levels};
use super::paths::{read_input, write_atomic};
use super::{Arc, Automation, Edit, Value, block_overlapping, comments, json, tools};
use super::{Arc, Automation, Edit, Value, block_overlapping, comments, content, json, tools};
/// One piece of a tool's result.
#[derive(Clone, Debug, PartialEq)]
@@ -12,6 +12,35 @@ pub enum Content {
Png { data: Vec<u8>, width: u32, height: u32 },
}
/// `image_edit` `transform`: a matrix `[a b c d e f]` acting on top-left-origin points (a point
/// moves to `(a·x + c·y + e, b·x + d·y + f)`) → the same motion in PDF user space, which is
/// what `ImageEdit::Transform` applies after the image's placement. Both spaces are related by
/// an affine map, so three corners of the crop box pin the result down.
fn view_transform_to_user(info: &pdfcraft_render::PageInfo, m: [f64; 6]) -> Result<[f64; 6]> {
let [a, b, c, d, e, f] = m;
let det = a * d - b * c;
if !m.iter().all(|v| v.is_finite()) || !det.is_finite() || det.abs() < 1e-9 {
return Err(ToolError::InvalidArgs("transform needs a finite matrix that keeps an area (a·d − b·c must not be 0)".into()));
}
let moved = |x: f32, y: f32| {
let [vx, vy] = info.user_to_view(x, y).map(f64::from);
info.view_to_user((a * vx + c * vy + e) as f32, (b * vx + d * vy + f) as f32).map(f64::from)
};
let [x0, y0, x1, y1] = info.crop;
let (w, h) = (f64::from(x1 - x0), f64::from(y1 - y0));
if w.abs() < 1e-3 || h.abs() < 1e-3 {
return Err(ToolError::InvalidArgs("the page has no area to transform in".into()));
}
let (o, px, py) = (moved(x0, y0), moved(x1, y0), moved(x0, y1));
let (ua, ub, uc, ud) = ((px[0] - o[0]) / w, (px[1] - o[1]) / w, (py[0] - o[0]) / h, (py[1] - o[1]) / h);
let (x0, y0) = (f64::from(x0), f64::from(y0));
let user = [ua, ub, uc, ud, o[0] - ua * x0 - uc * y0, o[1] - ub * x0 - ud * y0];
if !user.iter().all(|v| v.is_finite()) {
return Err(ToolError::InvalidArgs("the transform moves the image too far".into()));
}
Ok(user)
}
impl Automation {
/// Run the tool `name` with JSON `args` (an object; `null` means no arguments).
pub fn call(&mut self, name: &str, args: &Value) -> Result<Vec<Content>> {
@@ -38,7 +67,7 @@ impl Automation {
let edit = Edit::SetInfo { key: a.str("key")?.into(), value: a.str("value")?.into() };
self.apply(&a, edit)?
}
"page_render" => return self.page_render(&a).map(|c| vec![c]),
"page_render" => return self.page_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)?,
@@ -142,7 +171,7 @@ impl Automation {
"command_run" => return self.command_run(&a),
"command_batch" => self.command_batch(&a)?,
"doc_inspect" => self.doc_inspect(&a)?,
"render_preview" => return self.render_preview(&a).map(|c| vec![c]),
"render_preview" => return self.render_preview(&a),
"page_number" => {
use pdfcraft_organize::LabelStyle as L;
let n = self.doc(&a)?.info.pages.len();
@@ -234,6 +263,34 @@ impl Automation {
out["next"] = listing["next"].clone();
out
}
"text_styles" => {
// The samples are the document's text, as text_lines is.
let doc = self.doc_to_copy(&a)?;
let styles: Vec<Value> = doc
.text_styles()
.into_iter()
.map(|u| {
json!({
"font": u.style.font,
"size": u.style.size,
"bold": u.style.bold,
"italic": u.style.italic,
"lines": u.lines,
"pages": u.pages,
"sample": u.sample,
})
})
.collect();
json!({ "styles": styles })
}
"bookmark_from_text_styles" => {
let levels = text_style_levels(&a)?;
let mut out = self.apply(&a, Edit::BookmarksFromTextStyles { levels })?;
let listing = self.bookmark_listing(&a)?;
out["bookmarks"] = listing["bookmarks"].clone();
out["next"] = listing["next"].clone();
out
}
"doc_protect" => self.doc_protect(&a)?,
"page_replace" => {
let pages = self.pages(&a, "pages")?;
@@ -266,13 +323,13 @@ impl Automation {
let id = self.doc(&a)?.id;
let before = self.doc(&a)?.bytes.len();
let path = self.resolve(a.str("path")?, true)?;
let (bytes, merged) = self.session.reduced_bytes(id).map_err(failed)?;
let (bytes, merged, images_refused) = self.session.reduced_bytes(id).map_err(failed)?;
// As in the app, a copy that isn't smaller is not written (#490).
let written = bytes.len() < before;
if written {
write_atomic(&path, &bytes)?;
}
json!({ "path": path.to_string_lossy(), "written": written, "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged })
json!({ "path": path.to_string_lossy(), "written": written, "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged, "images_refused": images_refused })
}
"doc_optimize" => self.doc_optimize(&a)?,
"doc_initial_view" => self.doc_initial_view(&a)?,
@@ -329,7 +386,7 @@ impl Automation {
let (u, v) = (info.user_to_view(rect[0] as f32, rect[1] as f32), info.user_to_view(rect[2] as f32, rect[3] as f32));
[r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))]
});
json!({ "visible_rect": visible, "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } })
json!({ "visible_rect": visible, "has_editor_crop": im.has_editor_crop(), "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } })
})
.collect();
json!({ "page": page + 1, "count": list.len(), "images": list })
@@ -367,6 +424,16 @@ impl Automation {
let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32));
ImageEdit::Move([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])
}
"transform" => {
let m: Vec<f64> = a
.get("matrix")
.and_then(Value::as_array)
.and_then(|x| x.iter().map(Value::as_f64).collect())
.ok_or_else(|| ToolError::InvalidArgs("transform needs matrix: 6 numbers [a, b, c, d, e, f]".into()))?;
let m = <[f64; 6]>::try_from(m)
.map_err(|_| ToolError::InvalidArgs("transform needs matrix: 6 numbers [a, b, c, d, e, f]".into()))?;
ImageEdit::Transform(view_transform_to_user(&self.doc(&a)?.info.pages[page], m)?)
}
"rotate_by" => ImageEdit::RotateBy(
a.get("degrees")
.and_then(Value::as_f64)
@@ -382,9 +449,18 @@ impl Automation {
let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32));
ImageEdit::Crop([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])
}
"flip_horizontal" => ImageEdit::Flip { horizontal: true },
"flip_vertical" => ImageEdit::Flip { horizontal: false },
action @ ("flip_horizontal" | "flip_vertical") => {
let horizontal = action == "flip_horizontal";
let info = self.doc(&a)?.info.pages.get(page).ok_or_else(|| ToolError::InvalidArgs("the page does not exist".into()))?;
// The tool names displayed axes; the engine names PDF user-space axes.
ImageEdit::Flip { horizontal: horizontal ^ (info.rotation % 180 == 90) }
}
"delete" => ImageEdit::Delete,
"bring_to_front" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringToFront),
"send_to_back" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendToBack),
"bring_forward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringForward),
"send_backward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendBackward),
"remove_crop" => ImageEdit::RemoveCrop,
"replace" => {
let path = self.resolve(a.str("path")?, false)?;
let bytes = read_input(&path)?;
@@ -510,7 +586,15 @@ impl Automation {
};
let index = self.paragraph_index(id, page, k, &blocks)?;
let block = blocks.get(index).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?;
let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text);
let width = a.opt_num("width")?;
// Explicit row alignments keep the editor's row boundaries; a width-only
// reflow rejoins soft wraps while retaining typed newlines and spaces.
let text = match a.opt_str("text")? {
Some(text) => text.to_owned(),
None if a.get("aligns").is_some() => block.editor_text,
None if width.is_some() => block.reflow_text.clone(),
None => block.text,
};
let mut style = pdfcraft_engine::BlockStyle {
ranges: parse_style_ranges(a.get("ranges"))?,
resource: a.opt_str("font_resource")?.map(str::to_owned),
@@ -520,7 +604,7 @@ impl Automation {
line_spacing: a.opt_num("line_spacing")?,
char_spacing: a.opt_num("char_spacing")?,
scale: a.opt_num("scale")?,
width: a.opt_num("width")?,
width,
..Default::default()
};
let (dx, dy) = (a.opt_num("dx")?, a.opt_num("dy")?);
@@ -540,13 +624,10 @@ impl Automation {
style.color = Some(comments::parse_color(c)?);
}
if let Some(al) = a.opt_str("align")? {
style.align = Some(match al {
"left" => pdfcraft_engine::TextAlign::Left,
"center" => pdfcraft_engine::TextAlign::Center,
"right" => pdfcraft_engine::TextAlign::Right,
"justify" => pdfcraft_engine::TextAlign::Justify,
other => return Err(ToolError::InvalidArgs(format!("unknown align {other:?}"))),
});
style.align = Some(content::parse_align(al)?);
}
if let Some(aligns) = content::opt_aligns(&a)? {
style.aligns = aligns;
}
// Where the paragraph is drawn after the edit: moved by dx, dy (up is +dy).
let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]);
@@ -591,6 +672,7 @@ impl Automation {
}
out
}
"text_script" => self.text_script(&a)?,
"doc_audit_space" => {
let rows: Vec<Value> = self
.doc(&a)?
@@ -608,6 +690,7 @@ impl Automation {
}
"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_backdrops" => self.backdrops(&a)?,
"doc_unprotect" => {
let mut out = self.apply(&a, Edit::RemoveProtection)?;
out["security"] = security(self.doc(&a)?);
@@ -781,4 +864,52 @@ impl Automation {
};
Ok(vec![Content::Json(out)])
}
/// `text_script`: Format text ▸ Superscript or Subscript on a paragraph, or on a byte range
/// of its editor text, written as `text_edit` writes the paragraph.
fn text_script(&mut self, a: &Args) -> Result<Value> {
use pdfcraft_engine::text_script::{Script, toggle};
let name = a.str("script")?;
let script = match name {
"superscript" => Script::Super,
"subscript" => Script::Sub,
other => return Err(ToolError::InvalidArgs(format!("unknown script {other:?} (superscript, subscript)"))),
};
let page = self.page(a)?;
let k = a.int("paragraph")?;
let text = {
let doc = self.doc(a)?;
let blocks = doc.text_blocks(page);
let index = self.paragraph_index(doc.id, page, k, &blocks)?;
blocks.get(index).map(|b| b.editor_text.clone()).ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?
};
let range = match a.opt_ints("range")? {
None => None,
Some(r) => {
let bounds = match r.as_slice() {
[start, end] => usize::try_from(*start).ok().zip(usize::try_from(*end).ok()),
_ => None,
};
let range = bounds
.map(|(start, end)| start..end)
.filter(|r| r.start < r.end && r.end <= text.len() && text.is_char_boundary(r.start) && text.is_char_boundary(r.end));
let Some(range) = range else {
return Err(ToolError::InvalidArgs(format!(
"range must be [start, end]: UTF-8 byte offsets of the paragraph's editor_text (from text_paragraphs) with start < end <= {}, on character boundaries",
text.len()
)));
};
Some(range)
}
};
let (toggled, _) = toggle(&text, range.as_ref(), script)
.ok_or_else(|| ToolError::InvalidArgs(format!("nothing to change: no character of the text has a {name} form")))?;
let args = json!({ "doc": a.get("doc"), "page": a.get("page"), "paragraph": k, "text": toggled });
let mut out = match self.call("text_edit", &args)?.into_iter().next() {
Some(Content::Json(out)) => out,
_ => json!({}),
};
out["script"] = json!(name);
Ok(out)
}
}
+61 -2
View File
@@ -279,7 +279,8 @@ impl Automation {
}
pub(crate) fn doc_optimize(&mut self, a: &Args) -> Result<Value> {
use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset};
use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, MonoCompression, Preset};
const MONO_KEYS: [&str; 4] = ["downsample", "ppi", "above_ppi", "compression"];
let id = self.doc(a)?.id;
let before = self.doc(a)?.bytes.len();
let path = self.resolve(a.str("path")?, true)?;
@@ -317,6 +318,38 @@ impl Automation {
};
settings.color = images("color", settings.color)?;
settings.gray = images("gray", settings.gray)?;
if let Some(mono) = a.get("mono") {
let unknown = |c: &Value| ToolError::InvalidArgs(format!("unknown mono compression {c} (ccitt_g4, jbig2, retain)"));
let mono = mono.as_object().ok_or_else(|| {
ToolError::InvalidArgs("mono must be an object: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain }".into())
})?;
// A misspelled key would otherwise leave the preset's setting in place unnoticed.
if let Some(key) = mono.keys().find(|k| !MONO_KEYS.contains(&k.as_str())) {
return Err(ToolError::InvalidArgs(format!("unknown mono key {key:?} ({})", MONO_KEYS.join(", "))));
}
let m = &mut settings.mono;
if let Some(d) = mono.get("downsample") {
m.downsample = d.as_bool().ok_or_else(|| ToolError::InvalidArgs(format!("mono downsample must be true or false, not {d}")))?;
}
for (key, ppi) in [("ppi", &mut m.target_ppi), ("above_ppi", &mut m.above_ppi)] {
if let Some(p) = mono.get(key) {
*ppi = p
.as_f64()
.filter(|p| p.is_finite())
.ok_or_else(|| ToolError::InvalidArgs(format!("mono {key} must be a number, not {p}")))?
.clamp(9.0, 2400.0);
}
}
m.above_ppi = m.above_ppi.max(m.target_ppi);
if let Some(c) = mono.get("compression") {
m.compression = match c.as_str() {
Some("ccitt_g4" | "ccitt" | "g4") => MonoCompression::CcittG4,
Some("jbig2") => MonoCompression::Jbig2,
Some("retain") => MonoCompression::Retain,
_ => return Err(unknown(c)),
};
}
}
for (key, flag) in [
("merge_fragments", &mut settings.merge_fragments),
("discard_thumbnails", &mut settings.discard_thumbnails),
@@ -356,7 +389,9 @@ impl Automation {
let pw = self.doc(a).ok().and_then(|d| d.password.as_deref());
let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), pw).ok()?;
let root = doc.root()?;
doc.get(root).as_dict()?.name(b"Version").and_then(valid_version).map(str::to_owned)
// Through a reference too, as the optimizer reads it.
let version = doc.get(root).as_dict()?.get(b"Version")?.clone();
doc.resolve(&version).as_name().and_then(valid_version).map(str::to_owned)
})
.flatten();
let header_version = bytes.strip_prefix(b"%PDF-").and_then(|b| b.get(..3)).and_then(valid_version);
@@ -375,6 +410,7 @@ impl Automation {
"images": o.images,
"images_resampled": o.images_resampled,
"images_recompressed": o.images_recompressed,
"images_refused": o.images_refused,
"image_bytes_before": o.image_bytes_before,
"image_bytes_after": o.image_bytes_after,
"thumbnails": o.thumbnails,
@@ -416,6 +452,28 @@ impl Automation {
Ok(out)
}
/// Pages whose own backdrop hides a background (see `pdfcraft_edit::backdrop`).
pub(crate) fn backdrops(&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 doc = self.doc(a)?;
let found: Vec<Value> = pages
.iter()
.zip(doc.page_backdrops(&pages))
.filter_map(|(p, found)| {
let kind = match found? {
pdfcraft_engine::Backdrop::Fill => "fill",
pdfcraft_engine::Backdrop::Image => "image",
};
Some(json!({ "page": p + 1, "backdrop": kind }))
})
.collect();
Ok(json!({ "pages": found }))
}
pub(crate) fn marks(&mut self, tool: &str, a: &Args) -> Result<Value> {
use pdfcraft_engine::{Background, HeaderFooter, MarkKind, Watermark};
let n = self.doc(a)?.info.pages.len();
@@ -483,6 +541,7 @@ impl Automation {
color: color("color", [1.0; 3])?,
opacity: a.opt_num("opacity")?.unwrap_or(1.0),
scale: a.opt_num("scale")?.unwrap_or(1.0).clamp(0.01, 1.0),
replace_backdrop: a.opt_bool("replace_backdrop")?.unwrap_or(true),
..Background::default()
},
replace,
+130 -33
View File
@@ -1,7 +1,7 @@
//! Form tools: list fields, fill them (several at once, one undo step), clear the form, and
//! prepare a form (add fields one at a time or as a table, change and delete them).
use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, Validate};
use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, NOTATION_NOT_STORABLE, Validate, notation_storable};
use pdfcraft_engine::{CalculationIssue, Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags};
use serde_json::{Value, json};
@@ -94,13 +94,20 @@ fn look_arg(v: &Value) -> Result<pdfcraft_engine::FieldLookPatch> {
})
}
/// `{"min": 0, "max": 100}` or `"none"`.
/// `{"min": 0, "max": 100}` or `"none"`. A null bound is no bound, as `form_fields` shows it.
fn validate_arg(v: &Value) -> Result<Validate> {
if v.as_str() == Some("none") {
return Ok(Validate::None);
}
let o = v.as_object().ok_or_else(|| bad("validate must be {\"min\": …, \"max\": …} or \"none\""))?;
let (min, max) = (o.get("min").and_then(Value::as_f64), o.get("max").and_then(Value::as_f64));
if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "min" | "max")) {
return Err(bad(format!("unknown key {k:?} in validate (min, max)")));
}
let bound = |k: &str| match o.get(k) {
None | Some(Value::Null) => Ok(None),
Some(x) => x.as_f64().map(Some).ok_or_else(|| bad(format!("validate {k} must be a number"))),
};
let (min, max) = (bound("min")?, bound("max")?);
if min.is_none() && max.is_none() {
return Err(bad("validate needs min, max or both"));
}
@@ -113,25 +120,67 @@ fn calculate_arg(v: &Value) -> Result<Calculate> {
return Ok(Calculate::None);
}
let o = v.as_object().ok_or_else(|| bad("calculate must be {\"op\": …, \"fields\": […]}, {\"notation\": …} or \"none\""))?;
if let Some(n) = o.get("notation").and_then(Value::as_str) {
// Before anything else is read, so a script given beside a notation isn't dropped unnoticed.
if o.contains_key("script") {
return Err(bad("a custom calculate script is set with form_set_script (event: calculate)"));
}
if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "op" | "fields" | "notation")) {
return Err(bad(format!("unknown key {k:?} in calculate (op and fields, or notation)")));
}
// A null is no value, as for the tool's own arguments.
let get = |k: &str| o.get(k).filter(|v| !v.is_null());
if let Some(n) = get("notation") {
if get("op").is_some() || get("fields").is_some() {
return Err(bad("calculate takes a notation, or op and fields, not both"));
}
let n = n.as_str().ok_or_else(|| bad("notation must be a string such as \"Price * Qty\""))?;
// Refused rather than stored as a different formula.
if !notation_storable(n) {
return Err(bad(NOTATION_NOT_STORABLE));
}
return Ok(Calculate::Notation(n.to_string()));
}
let op = match o.get("op").and_then(Value::as_str).unwrap_or("") {
"sum" => CalcOp::Sum,
"product" => CalcOp::Product,
"average" => CalcOp::Average,
"min" | "minimum" => CalcOp::Minimum,
"max" | "maximum" => CalcOp::Maximum,
x => return Err(bad(format!("unknown op {x:?} (sum, product, average, min, max)"))),
let op = get("op").ok_or_else(|| bad("calculate needs op and fields, or a notation"))?;
let op = match op.as_str().ok_or_else(|| bad("op must be one of sum, product, average, min, max"))? {
"minimum" => CalcOp::Minimum,
"maximum" => CalcOp::Maximum,
x => {
CalcOp::ALL.into_iter().find(|&op| op_name(op) == x).ok_or_else(|| bad(format!("unknown op {x:?} (sum, product, average, min, max)")))?
}
};
let fields: Vec<String> =
o.get("fields").and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_owned)).collect()).unwrap_or_default();
let fields = get("fields").and_then(Value::as_array).ok_or_else(|| bad("calculate fields must be a list of field names"))?;
let fields: Vec<String> = fields
.iter()
.map(|x| x.as_str().map(str::to_owned).ok_or_else(|| bad("calculate fields must be field names (strings)")))
.collect::<Result<_>>()?;
if fields.is_empty() {
return Err(bad("calculate needs fields"));
}
Ok(Calculate::Simple { op, fields })
}
/// An operation's name in `calculate`, as `form_set_props` takes it and the listings show it.
fn op_name(op: CalcOp) -> &'static str {
match op {
CalcOp::Sum => "sum",
CalcOp::Product => "product",
CalcOp::Average => "average",
CalcOp::Minimum => "min",
CalcOp::Maximum => "max",
}
}
/// A field's calculation as `form_fields` and `form_calculation_order` show it, in
/// `form_set_props`'s shape, or `{"script": …}` for a custom Calculate script; `None` without one.
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::None, None) => None,
}
}
fn format_json(f: &Format) -> Value {
match f {
Format::None => Value::Null,
@@ -220,18 +269,15 @@ impl Automation {
if let Validate::Range { min, max } = &f.actions.validate {
o.insert("validate".into(), json!({ "min": min, "max": max }));
}
match &f.actions.calculate {
Calculate::Simple { op, fields } => {
o.insert("calculate".into(), json!({ "op": op.code().to_lowercase(), "fields": fields }));
}
Calculate::Notation(n) => {
o.insert("calculate".into(), json!({ "notation": n }));
}
Calculate::None => {}
if let Some(c) = calculate_json(f) {
o.insert("calculate".into(), c);
}
if !f.actions.unsupported.is_empty() {
o.insert("unsupported_scripts".into(), json!(f.actions.unsupported));
}
if matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio) {
o.insert("export_values".into(), json!((0..f.widgets.len()).map(|i| f.export_of(i)).collect::<Vec<_>>()));
}
match f.kind {
FormFieldKind::PushButton => {
o.insert("caption".into(), json!(w.and_then(|w| w.caption.as_deref()).unwrap_or_default()));
@@ -418,8 +464,8 @@ impl Automation {
}
pub(crate) fn form_set_props(&mut self, a: &Args) -> Result<Value> {
if a.get("widget").is_some() && a.get("caption").is_none() {
return Err(bad("widget requires caption"));
if a.get("widget").is_some() && a.get("caption").is_none() && a.get("export_value").is_none() {
return Err(bad("widget requires caption or export_value"));
}
let single = a.opt_str("field")?;
let bulk = a.get("fields").is_some();
@@ -438,7 +484,7 @@ impl Automation {
return Err(bad(format!("field {name:?} is listed more than once")));
}
}
for key in ["name", "rect", "rotation", "caption", "widget"] {
for key in ["name", "rect", "rotation", "caption", "export_value", "widget"] {
if a.get(key).is_some() {
return Err(bad(format!("{key} applies to one field; use field instead of fields")));
}
@@ -502,6 +548,15 @@ impl Automation {
};
Ok(FieldProps {
rect,
export_value: a
.opt_str("export_value")?
.map(|value| -> Result<_> {
let index = a.opt_int("widget")?.unwrap_or(1);
let index =
usize::try_from(index).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| bad("widget must be a positive 1-based index"))?;
Ok((index, value.to_owned()))
})
.transpose()?,
name: a.opt_str("name")?.map(str::to_owned),
tooltip: a.opt_str("tooltip")?.map(str::to_owned),
caption: a
@@ -600,17 +655,23 @@ impl Automation {
};
let mut out = self.apply(a, Edit::MoveInTabOrder { name: field.to_owned(), earlier })?;
out["tab_order"] = self.tab_sequence(a)?;
out["page_tabs"] = self.page_tabs(a)?;
return Ok(out);
}
let order = match a.str("order")? {
"row" | "rows" => pdfcraft_engine::TabOrder::Row,
"column" | "columns" => pdfcraft_engine::TabOrder::Column,
"structure" => pdfcraft_engine::TabOrder::Structure,
"annotations" | "unspecified" => pdfcraft_engine::TabOrder::Annotations,
o => return Err(bad(format!("unknown order {o:?} (row, column, structure, annotations)"))),
let order = match a.opt_str("order")? {
None => None,
Some("row" | "rows") => Some(pdfcraft_engine::TabOrder::Row),
Some("column" | "columns") => Some(pdfcraft_engine::TabOrder::Column),
Some("structure") => Some(pdfcraft_engine::TabOrder::Structure),
Some("annotations" | "unspecified") => Some(pdfcraft_engine::TabOrder::Annotations),
Some(o) => return Err(bad(format!("unknown order {o:?} (row, column, structure, annotations)"))),
};
let mut out = match order {
Some(order) => self.apply(a, Edit::SetTabOrder { pages, order })?,
None => json!({}),
};
let mut out = self.apply(a, Edit::SetTabOrder { pages, order })?;
out["tab_order"] = self.tab_sequence(a)?;
out["page_tabs"] = self.page_tabs(a)?;
Ok(out)
}
@@ -647,15 +708,28 @@ impl Automation {
};
let order = self.doc(a)?.calculation_order();
let issue = |i: &CalculationIssue| match i {
CalculationIssue::MissingField { field } => json!({ "issue": "missing_field", "field": field }),
CalculationIssue::RunsTooEarly { field } => json!({ "issue": "runs_too_early", "field": field }),
CalculationIssue::Cycle { fields } => json!({ "issue": "cycle", "fields": fields }),
CalculationIssue::NotInOrder => json!({ "issue": "not_in_order" }),
CalculationIssue::BuiltNames => json!({ "issue": "built_names" }),
};
// By field object, not name: two calculated fields can share a name in a malformed form.
let doc = self.doc(a)?;
let by_obj: std::collections::HashMap<_, &FormField> = doc.form.iter().map(|f| (f.obj, f)).collect();
out["calculation_order"] = json!(order.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>());
out["fields"] = order
.fields
.iter()
.map(|f| json!({ "name": f.name, "reads": f.reads, "listed": f.listed, "issues": f.issues.iter().map(issue).collect::<Vec<_>>() }))
.map(|f| {
json!({
"name": f.name,
"formula": by_obj.get(&f.obj).and_then(|x| calculate_json(x)),
"reads": f.reads,
"listed": f.listed,
"issues": f.issues.iter().map(issue).collect::<Vec<_>>(),
})
})
.collect();
out["extra"] = json!(order.extra);
out["fixable"] = json!(order.fixable());
@@ -671,12 +745,35 @@ impl Automation {
Ok(json!(seq.iter().map(|x| x.1).collect::<Vec<_>>()))
}
/// Each page's tab order (Page properties ▸ Tab order), in page order.
fn page_tabs(&self, a: &Args) -> Result<Value> {
let names: Vec<&str> = self
.doc(a)?
.tab_orders()
.into_iter()
.map(|o| match o {
pdfcraft_engine::TabOrder::Row => "row",
pdfcraft_engine::TabOrder::Column => "column",
pdfcraft_engine::TabOrder::Structure => "structure",
pdfcraft_engine::TabOrder::Annotations => "annotations",
})
.collect();
Ok(json!(names))
}
pub(crate) fn form_delete_field(&mut self, a: &Args) -> Result<Value> {
let name = a.str("field")?.to_owned();
if !self.doc(a)?.form.iter().any(|f| f.name == name) {
return Err(failed(format!("there is no field named {name:?} (see form_fields)")));
}
self.apply(a, Edit::DeleteField { name })
let Some(widget) = a.opt_int("widget")? else { return self.apply(a, Edit::DeleteField { name }) };
let count = self.doc(a)?.form.iter().find(|f| f.name == name).map_or(0, |f| f.widgets.len());
let widget = usize::try_from(widget)
.ok()
.and_then(|w| w.checked_sub(1))
.filter(|w| *w < count)
.ok_or_else(|| bad(format!("widget must be 1 to {count}: {name} has {count} widgets")))?;
self.apply(a, Edit::DeleteWidget { name, widget })
}
pub(crate) fn doc_export_data(&mut self, a: &Args) -> Result<Value> {
+30
View File
@@ -7,6 +7,36 @@ const MAX_BOOKMARK_POSITION: f64 = 1_000_000.0;
// ---- JSON helpers ----------------------------------------------------------------------------
/// The most bookmark levels `bookmark_from_text_styles` takes.
const MAX_STYLE_LEVELS: usize = 9;
/// bookmark_from_text_styles' `levels`: one style per bookmark level, the first level 1, each
/// `{ font, size, bold, italic }` as text_styles lists them.
pub(crate) fn text_style_levels(a: &Args) -> Result<Vec<pdfcraft_engine::text_style_bookmarks::TextStyle>> {
let levels =
a.get("levels").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("levels must list a style for each bookmark level".into()))?;
if levels.is_empty() || levels.len() > MAX_STYLE_LEVELS {
return Err(ToolError::InvalidArgs(format!("levels must list 1 to {MAX_STYLE_LEVELS} styles")));
}
levels
.iter()
.enumerate()
.map(|(i, v)| {
let bad = |what: &str| ToolError::InvalidArgs(format!("levels[{i}]: {what}"));
let font =
v.get("font").and_then(Value::as_str).filter(|f| !f.is_empty()).ok_or_else(|| bad("font must be a font name from text_styles"))?;
let size =
v.get("size").and_then(Value::as_f64).filter(|s| s.is_finite() && *s > 0.0).ok_or_else(|| bad("size must be a positive number"))?;
let flag = |key: &str| match v.get(key) {
None | Some(Value::Null) => Ok(false),
Some(Value::Bool(b)) => Ok(*b),
Some(_) => Err(bad(&format!("{key} must be true or false"))),
};
Ok(pdfcraft_engine::text_style_bookmarks::TextStyle { font: font.to_string(), size, bold: flag("bold")?, italic: flag("italic")? })
})
.collect()
}
pub(crate) fn parse_style_ranges(value: Option<&Value>) -> Result<Vec<pdfcraft_engine::RangeStyle>> {
let Some(value) = value else { return Ok(Vec::new()) };
let values = value.as_array().filter(|v| v.len() <= 1024).ok_or_else(|| Args::wrong("ranges", "an array of at most 1024 style objects"))?;
+1 -1
View File
@@ -62,7 +62,7 @@ use error::{Result, failed};
pub(crate) use json::destination;
pub use json::view_rect;
use json::{Args, info, summary};
use pages::{DEFAULT_DPI, MAX_DPI, encode_png};
use pages::{DEFAULT_DPI, MAX_DPI, encode_png, rendered_content};
#[cfg(test)]
use pages::{FIND_FIRST_BATCH, FIND_MAX_WARNING_BYTES, FIND_MAX_WARNINGS, extract_parallel};
#[cfg(test)]
+27 -3
View File
@@ -65,7 +65,9 @@ impl Automation {
Ok(&mut entry.1)
}
pub(crate) fn page_render(&mut self, a: &Args) -> Result<Content> {
/// The page as a PNG, followed by a JSON part listing the render warnings when there are any,
/// so a caller can tell that the picture is incomplete, as `pdfcraft-cli render` does (#4340).
pub(crate) fn page_render(&mut self, a: &Args) -> Result<Vec<Content>> {
let id = self.doc(a)?.id;
let page = self.page(a)?;
let dpi = a.opt_num("dpi")?.unwrap_or(DEFAULT_DPI);
@@ -98,8 +100,7 @@ impl Automation {
if let Some(e) = out.error {
return Err(failed(format!("page {} could not be rendered: {e}", page + 1)));
}
let data = encode_png(out.width, out.height, &out.rgba)?;
Ok(Content::Png { data, width: out.width, height: out.height })
rendered_content(&out, page)
}
/// A page as RGBA pixels, without encoding a PNG. `page` is 1-based and `scale` is pixels
@@ -324,6 +325,29 @@ pub(crate) fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Resul
pdfcraft_engine::export::encode_png(width, height, premultiplied).map_err(failed)
}
/// The output of a page-rendering tool: the PNG of the 0-based `page`, followed by a JSON part
/// listing its render warnings when there are any. Without warnings it is the PNG alone, as it
/// always was.
pub(crate) fn rendered_content(out: &pdfcraft_render::RenderedPage, page: usize) -> Result<Vec<Content>> {
let data = encode_png(out.width, out.height, &out.rgba)?;
let mut content = vec![Content::Png { data, width: out.width, height: out.height }];
if !out.warnings.is_empty() {
// Sentences, like the other tools' `warnings`, so command_run can add its own to the list.
let warnings: Vec<&str> = out.warnings.iter().map(|&w| render_warning_text(w)).collect();
content.push(Content::Json(json!({ "page": page.saturating_add(1), "warnings": warnings })));
}
Ok(content)
}
/// What a render warning means, in the words `pdfcraft-cli render` prints for it.
fn render_warning_text(warning: pdfcraft_render::RenderWarning) -> &'static str {
use pdfcraft_render::RenderWarning;
match warning {
RenderWarning::ContentTruncated => "content past the page's safety budget was skipped",
RenderWarning::ColourFallback => "a colour that could not be computed was painted in a fallback colour",
}
}
/// Extract the text of `pages` (0-based) with one renderer per worker thread.
pub(crate) fn extract_parallel(bytes: &Arc<Vec<u8>>, password: Option<Arc<str>>, pages: &[usize]) -> PageTexts {
let extract = |r: &mut PageRenderer, p: usize| {
+76 -24
View File
@@ -121,7 +121,7 @@ pub fn tools() -> Vec<ToolDef> {
let save_out = json!({ "type": "string", "description": "File to write. Omit to open the result as a new unsaved document instead." });
let open = json!({ "type": "boolean", "description": "Also open the result as a new document (default: only when out is omitted)." });
vec![
t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.")
t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi; black-and-white images go to the larger of ppi and 300) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.")
.with(schema(json!({
"paths": {"type":"array","items":{"type":"string"},"minItems":1,"maxItems":1000},
"out_dir": {"type":"string"},
@@ -167,7 +167,7 @@ pub fn tools() -> Vec<ToolDef> {
)),
t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.")
.with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])),
t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).")
t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600). When the picture is incomplete or approximate (content cut at the page's safety budget, a colour painted in a fallback colour), a JSON part follows the image: { page, warnings: [sentence, ...] }.")
.ro()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 },
"hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." },
@@ -175,7 +175,7 @@ pub fn tools() -> Vec<ToolDef> {
}), &["doc", "page"])),
t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.")
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])),
t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Clipping and vertical text are refused. A bad/stale reference refuses the entire move.")
t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Text used as a clipping path and vertical text are refused. A bad/stale reference refuses the entire move.")
.with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},
"objects":{"type":"array","minItems":1,"maxItems":1000,"uniqueItems":true,"items":{
"type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}},
@@ -347,6 +347,39 @@ pub fn tools() -> Vec<ToolDef> {
"Make bookmarks from the document's tagged headings (H, H1-H6), nested by level under a new first bookmark titled \"Untitled\". Fails if the document has no tagged headings. Returns the first page of the bookmark tree (see bookmark_list). Undoable.",
)
.with(schema(json!({ "doc": doc() }), &["doc"])),
t(
"text_styles",
"List text styles",
"The styles the document's text is set in, the largest first: font (without its subset tag), the size it is shown at in points, bold, italic, how many lines and pages use it, and the first line in it as a sample. For choosing the styles bookmark_from_text_styles makes bookmarks from (headings are usually the larger, rarer styles). Reads the first 5000 pages.",
)
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t(
"bookmark_from_text_styles",
"New bookmarks from text styles",
"Make bookmarks from the lines set in chosen text styles, for documents whose headings are not tagged: levels lists one style per bookmark level ({font, size, bold, italic} as text_styles gives them; sizes within a quarter point match), the first level 1, the next nested under the bookmark before it, and so on. A heading set on several lines is one bookmark, and each goes to the top of its heading. They go under a new first bookmark titled \"Untitled\". Fails if no text is set in the styles. Returns the first page of the bookmark tree (see bookmark_list). Undoable.",
)
.with(schema(
json!({
"doc": doc(),
"levels": {
"type": "array",
"minItems": 1,
"maxItems": 9,
"items": {
"type": "object",
"properties": {
"font": { "type": "string", "minLength": 1 },
"size": { "type": "number", "exclusiveMinimum": 0 },
"bold": { "type": "boolean" },
"italic": { "type": "boolean" }
},
"required": ["font", "size"]
}
}
}),
&["doc", "levels"],
)),
t("page_number", "Number pages", "Label a range of pages (e.g. i, ii, iii for front matter, or A-1, A-2 for an appendix). Later pages keep their labels. Undoable.").with(schema(
json!({
"doc": doc(),
@@ -358,7 +391,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "from", "to"],
)),
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).")
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. A calculated field includes calculate: {op, fields} or {notation} as form_set_props takes them, or {script} for a custom Calculate script (set with form_set_script). Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t(
@@ -442,8 +475,9 @@ pub fn tools() -> Vec<ToolDef> {
"fields": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 1000, "uniqueItems": true, "description": "Apply the same changes to these fields in one atomic edit. Use either field or fields." },
"name": { "type": "string" },
"tooltip": { "type": "string" },
"export_value": { "type": "string", "description": "Checkbox/radio export value; preserves appearance states and selection. Requires a single field." },
"caption": { "type": "string", "description": "Normal push-button label; an empty string clears it. Requires a single field." },
"widget": { "type": "integer", "minimum": 1, "description": "Widget whose caption changes (1-based, default 1). Requires caption. See form_fields.captions." },
"widget": { "type": "integer", "minimum": 1, "description": "Widget whose caption or export value changes (1-based, default 1). Requires caption or export_value. See form_fields.captions and export_values." },
"read_only": { "type": "boolean" },
"required": { "type": "boolean" },
"multiline": { "type": "boolean" },
@@ -459,17 +493,17 @@ pub fn tools() -> Vec<ToolDef> {
"rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. Changing between horizontal and vertical swaps the rectangle around its center." },
"appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (0–12 points; 1 thin, 2 medium, 3 thick), style (solid, dashed, beveled, inset, underline), font (helvetica, times, courier)." },
"format": { "description": "Format tab: {\"type\": \"number\", \"decimals\": 2, \"currency\": \"$\", \"separator\": 0-4, \"negative\": 0-3}, {\"type\": \"percent\"}, {\"type\": \"date\"|\"time\", \"pattern\": \"mm/dd/yyyy\"}, {\"type\": \"zip\"|\"zip4\"|\"phone\"|\"ssn\"}, {\"type\": \"mask\", \"mask\": \"AA-9999\"}, or \"none\"." },
"validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out) or \"none\"." },
"validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out, or null) or \"none\"." },
"calculate": { "description": "Calculate tab: {\"op\": \"sum\"|\"product\"|\"average\"|\"min\"|\"max\", \"fields\": [\"a\", \"b\"]}, {\"notation\": \"Price * Qty\"} (simplified field notation), or \"none\"." },
}),
&["doc"],
)),
t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right), column, structure, or annotations (unspecified). Or order tabs manually: `field` with `move` earlier|later moves that field one place on its page. Returns the resulting order of fields. Undoable.")
t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right; right to left in a document bound on the right), column, structure, or annotations (unspecified). Or order tabs manually: `field` with `move` earlier|later moves that field one place on its page. Without `order` or `field` it only reads. Returns the resulting order of fields (`tab_order`) and each page's tab order (`page_tabs`, as Page properties ▸ Tab Order shows it). Undoable.")
.with(schema(
json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }),
&["doc"],
)),
t("form_calculation_order", "Read or set the calculation order", "The calculated fields in the order they calculate (Acrobat: Set Field Calculation Order), each with the fields its formula reads (`reads`; a script's names built at run time aren't known) and its `issues`: runs_too_early (it reads `field`, which calculates after it, so it shows a stale value), cycle (it reads `fields`, which read it back: no order fixes that, a formula has to change) and not_in_order (the PDF's order leaves it out, so other viewers may not calculate it). `extra` lists entries of the PDF's order that calculate nothing. Without a change it only reads. Changes, one at a time: `order` (these fields first, in this order, then the other calculated fields in their order), `field` with `move` earlier|later (one place), or `fix` true (only what must move moves, so every field calculates after the fields it reads, and the extra entries go). A change recalculates the form. Undoable.")
t("form_calculation_order", "Read or set the calculation order", "The calculated fields in the order they calculate (Acrobat: Set Field Calculation Order), each with its `formula` (as form_fields shows calculate), the fields it reads (`reads`; a script's names built at run time aren't known) and its `issues`: missing_field (it reads `field`, which the form doesn't have, so it gets no value there), runs_too_early (it reads `field`, which calculates after it, so it shows a stale value), cycle (it reads `fields`, which read it back: no order fixes that, a formula has to change) and not_in_order (the PDF's order leaves it out, so other viewers may not calculate it) and built_names (its script also reads fields by names it builds at run time, which aren't checked). `extra` lists entries of the PDF's order that calculate nothing. Without a change it only reads. Changes, one at a time: `order` (these fields first, in this order, then the other calculated fields in their order), `field` with `move` earlier|later (one place), or `fix` true (only what must move moves, so every field calculates after the fields it reads, and the extra entries go). A change recalculates the form. Undoable.")
.cmd("form.calculation_order")
.with(schema(
json!({ "doc": doc(), "order": { "type": "array", "items": { "type": "string" } }, "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] }, "fix": { "type": "boolean" } }),
@@ -478,11 +512,11 @@ pub fn tools() -> Vec<ToolDef> {
t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). For an XFA form, .xml is its data alone (rooted at the form's root data node, as Acrobat exports it) and .xdp an XML Data Package holding the whole datasets. what: all (default), comments, fields.")
.destructive()
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "what": { "type": "string", "enum": ["all", "comments", "fields"] } }), &["doc", "path"])),
t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.")
t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content), or the comments of another PDF, each onto the page with the same number (comments on pages past this document's last page are left out). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.")
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
t("form_delete_field", "Delete a field", "Delete a form field and all its widgets. Undoable.")
t("form_delete_field", "Delete a field", "Delete a form field and all its widgets, or with widget only that one: one radio button of a group (widget n is the nth of the radio group's options in form_fields), or one copy of a duplicated field. A field's last widget takes the field with it. Undoable.")
.destructive()
.with(schema(json!({ "doc": doc(), "field": { "type": "string" } }), &["doc", "field"])),
.with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "widget": { "type": "integer", "minimum": 1, "description": "Delete only this widget (1-based)." } }), &["doc", "field"])),
t(
"redact_mark",
"Mark for redaction",
@@ -603,6 +637,7 @@ pub fn tools() -> Vec<ToolDef> {
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
"aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." },
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
}),
&["doc", "page", "text"],
@@ -625,6 +660,7 @@ pub fn tools() -> Vec<ToolDef> {
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
"aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." },
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
"rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" },
"crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.95 }, "minItems": 4, "maxItems": 4, "description": "Fractions trimmed: left, bottom, right, top. Left and right together, and bottom and top together, trim at most 0.95." },
@@ -718,7 +754,7 @@ pub fn tools() -> Vec<ToolDef> {
json!({
"doc": doc(),
"page": { "type": "integer", "minimum": 1 },
"type": { "type": "string", "enum": ["note", "highlight", "underline", "strikeout", "squiggly", "replace", "rectangle", "oval", "line", "arrow", "ink", "textbox", "stamp", "polygon", "cloud", "polyline", "callout", "caret", "attachment"] },
"type": { "type": "string", "enum": ["note", "highlight", "underline", "strikeout", "squiggly", "replace", "rectangle", "oval", "line", "arrow", "ink", "freehand_highlight", "textbox", "stamp", "polygon", "cloud", "polyline", "callout", "caret", "attachment"] },
"path": { "type": "string", "description": "attachment: the file to attach." },
"points": { "type": "array", "items": point(), "minItems": 2 },
"knee": point(),
@@ -886,7 +922,7 @@ pub fn tools() -> Vec<ToolDef> {
)),
t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.")
.ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])),
t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])),
t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies, or one reply (its id, or page and index, from comment_list) with its pop-up and the replies to it; the comment and its other replies stay. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])),
t(
"doc_protect",
"Protect with passwords",
@@ -996,12 +1032,16 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc"],
)),
t("doc_background", "Add background", "Fill page backgrounds with a colour, or a picture from `file` (an image, or page `file_page` of a PDF) fitted at `scale` (default 1), behind the content. Undoable.")
t("doc_background", "Add background", "Fill page backgrounds with a colour, or a picture from `file` (an image, or page `file_page` of a PDF) fitted at `scale` (default 1), behind the content. A page that paints its own opaque white backdrop would hide it, so that backdrop is replaced unless replace_backdrop is false (removing the background turns it back on where that is still safe, and doc_remove_marks warns about the pages where it isn't); a scanned page's picture still hides it (doc_backdrops lists both). Undoable.")
.cmd("edit.background")
.with(schema(
json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "file": { "type": "string" }, "file_page": { "type": "integer", "minimum": 1 }, "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" } }),
json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "file": { "type": "string" }, "file_page": { "type": "integer", "minimum": 1 }, "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" }, "replace_backdrop": { "type": "boolean", "description": "Replace a white backdrop the page paints itself, which would hide the background. Default true." } }),
&["doc"],
)),
t("doc_backdrops", "List page backdrops", "Pages that paint their own backdrop, which hides a background added behind the content: `fill` (an opaque white fill doc_background can replace) or `image` (a page-sized picture, e.g. a scan, that still hides it). Pages are 1-based; pages without one are left out.")
.ro()
.cmd("edit.background")
.with(schema(json!({ "doc": doc(), "pages": pages("to check (default: all)") }), &["doc"])),
t("doc_remove_marks", "Remove header & footer, watermark or background", "Remove every header and footer, watermark or background PdfCraft (or a compatible tool) added. Undoable.")
.destructive()
.with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background"] } }), &["doc", "kind"])),
@@ -1135,7 +1175,7 @@ pub fn tools() -> Vec<ToolDef> {
t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.")
.cmd("form.merge_data")
.with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])),
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit), error, and the errors of document-level scripts that failed (the rest still ran).")
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the script's alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output, requests (print, page, url, submit), error, and the errors of document-level scripts that failed (the rest still ran).")
.cmd("tools.js_console")
.with(schema(
json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }),
@@ -1154,7 +1194,7 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
&["doc", "field", "event"],
)),
t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit are listed in truncated_pages, with a warning; the text and images of the forms left out are missing.")
t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit, or that have pictures past what one export may hold or decode, are listed in truncated_pages, with a warning; what was left out is missing.")
.cmd("export.docx")
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.")
@@ -1241,7 +1281,7 @@ pub fn tools() -> Vec<ToolDef> {
.ro()
.cmd("ocr.recognize")
.with(schema(json!({}), &[])),
t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`<name>_Page_<n>_Image_<k>.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2, CCITT, separations) are listed under skipped. Includes unsaved edits.")
t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`<name>_Page_<n>_Image_<k>.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, separations), or past what one export may decode or hold, are listed under skipped, with the reason. Includes unsaved edits.")
.cmd("export.all_images")
.with(schema(
json!({
@@ -1258,7 +1298,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"fill_sign_add",
"Fill & Sign: type text or place a mark",
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).",
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).",
)
.cmd("sign.fill.text")
.with(schema(
@@ -1269,6 +1309,7 @@ pub fn tools() -> Vec<ToolDef> {
"at": point(),
"text": { "type": "string", "minLength": 1 },
"font_size": { "type": "number", "minimum": 4, "maximum": 300, "description": "For type text or date (default 10 pt)." },
"color": color(),
"path": { "type": "string", "description": "PNG or JPEG for signature/initials; pass either path or text." },
"scan": { "type": "boolean", "description": "With path: scan a photo of a signature on paper (default false)." },
"sensitivity": { "type": "integer", "minimum": 0, "maximum": 100, "description": "With scan: how faint ink may be, 0–100 (default 50)." },
@@ -1299,7 +1340,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["from"],
)),
t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. If the copy wouldn't be smaller, nothing is written and `written` is false. The open document is unchanged.")
t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: colour and grey images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), black-and-white images averaged down to 300 ppi above 450 ppi and each stored as CCITT Group 4 (or 1-bit Flate, once resampled or packed from more bits) whenever that is smaller, thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. If the copy wouldn't be smaller, nothing is written and `written` is false. Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. The open document is unchanged.")
.cmd("optimize.reduce")
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
t("doc_initial_view", "Initial view", "Read or change how the document opens (Document Properties ▸ Initial View) and its reading options: navigation (page, bookmarks, pages, attachments, layers), layout (default, single, continuous, two_up, two_up_continuous, two_up_cover, two_up_continuous_cover), magnification (default, actual, fit_page, fit_width, fit_height, fit_visible, or a percentage), page, window options (fit_window, center_window, full_screen, display_title), interface options (hide_menubar, hide_toolbar, hide_window_ui), language and binding (left, right). Only the given ones change; returns the result. Undoable.").with(schema(
@@ -1337,17 +1378,18 @@ pub fn tools() -> Vec<ToolDef> {
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), remove_crop (remove a crop created by PdfCraft while preserving clipping paths from the original document), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), transform (matrix: 6 numbers [a, b, c, d, e, f] in top-left-origin points, applied after the current placement so a point (x, y) of the image moves to (a·x + c·y + e, b·x + d·y + f); finite, with a·d − b·c not 0; the box in page_images becomes the transformed image's bounding box), flip_horizontal (reflect left/right on the displayed page), flip_vertical (reflect top/bottom on the displayed page; both flip actions follow the page rotation), replace (path: an image file, drawn in the same place), delete, or arrange: bring_to_front, send_to_back, bring_forward, send_backward (reorder stacking order among content on the page; errors when the image is already at the requested limit; arranging an added image converts it into an ordinary page image). Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
.cmd("edit.edit_text")
.with(schema(
json!({
"doc": doc(),
"page": { "type": "integer", "minimum": 1 },
"image": { "type": "integer", "minimum": 1 },
"action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "flip_horizontal", "flip_vertical", "replace", "delete"] },
"action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "transform", "flip_horizontal", "flip_vertical", "replace", "delete", "bring_to_front", "send_to_back", "bring_forward", "send_backward", "remove_crop"] },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"quarters": { "type": "integer" },
"degrees": { "type": "number" },
"matrix": { "type": "array", "items": { "type": "number" }, "minItems": 6, "maxItems": 6 },
"path": { "type": "string" },
}),
&["doc", "page", "image", "action"],
@@ -1387,6 +1429,7 @@ pub fn tools() -> Vec<ToolDef> {
"size": { "type": "number", "exclusiveMinimum": 0 },
"color": { "type": "string", "description": "#rrggbb" },
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
"aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." },
"underline": { "type": "boolean" },
"line_spacing": { "type": "number", "description": "Multiple of the font size (1.2 is ordinary)." },
"dx": { "type": "number", "description": "Paragraph only: move right by this many points (negative: left)." },
@@ -1396,11 +1439,19 @@ pub fn tools() -> Vec<ToolDef> {
"scale": { "type": "number", "description": "Horizontal scale in percent." } }),
&["doc", "page"],
)),
t("text_script", "Superscript or subscript", "Format text ▸ Superscript or Subscript on one paragraph (paragraph, from text_paragraphs), or on range [start, end): UTF-8 byte offsets of its editor_text. The characters take the Unicode superscript or subscript forms (2 → ², 2 → ₂; the font must have those glyphs, and extracted text contains them); text already in that script goes back to plain characters. Characters Unicode has no such form for (q, a space) stay unchanged and do not determine the selection's script state. Mixed plain and target-script characters normalize to that script, then to plain text on the next call (H₂2 → H₂₂ → H22); a second toggle does not reconstruct the original mixture. Use Undo to restore the exact prior text. The paragraph is then rewritten as text_edit rewrites it, with the same result fields. Refused when no character would change. Undoable. Mathematical minus U+2212 has its own script mapping; ASCII hyphen-minus stays unchanged.")
.cmd("edit.edit_text")
.with(schema(
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 },
"script": { "type": "string", "enum": ["superscript", "subscript"] },
"range": { "type": "array", "items": { "type": "integer", "minimum": 0 }, "minItems": 2, "maxItems": 2, "description": "UTF-8 byte offsets [start, end) of the paragraph's editor_text; the whole paragraph when omitted." } }),
&["doc", "page", "paragraph", "script"],
)),
t("doc_revisions", "List revisions", "List the document's saved revisions (oldest first): each incremental update is one. Returns revision number, where it ends in the file and its size, and which signatures sign exactly that revision.")
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.")
.with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])),
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
.cmd("optimize.advanced")
.with(schema(
json!({
@@ -1411,6 +1462,7 @@ pub fn tools() -> Vec<ToolDef> {
"merge_fragments": { "type": "boolean" },
"color": { "type": "object" },
"gray": { "type": "object" },
"mono": { "type": "object" },
"discard_thumbnails": { "type": "boolean" },
"discard_alternate_images": { "type": "boolean" },
"discard_tags": { "type": "boolean" },
@@ -1445,7 +1497,7 @@ pub fn tools() -> Vec<ToolDef> {
.destructive().with(schema(json!({"steps":{"type":"array","items":schema(json!({"id":{"type":"string"},"params":{"type":"object"}}), &["id"])},"stop_on_error":{"type":"boolean"}}), &["steps"])),
t("doc_inspect", "Inspect open documents", "With doc, return doc_info; otherwise return information for every open document (empty documents array when none are open).")
.ro().with(schema(json!({"doc":doc()}), &[])),
t("render_preview", "Render a preview", "Render a PNG bounded by max_side (default 1024), without changing or saving the document. Page defaults to 1; doc may be omitted when exactly one document is open.")
t("render_preview", "Render a preview", "Render a PNG bounded by max_side (default 1024), without changing or saving the document. Page defaults to 1; doc may be omitted when exactly one document is open. Render warnings follow the image as page_render gives them.")
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"max_side":{"type":"integer","minimum":1,"maximum":4096}}), &[])),
]
}
File diff suppressed because it is too large Load Diff
+269
View File
@@ -0,0 +1,269 @@
//! Add Background on pages that paint their own backdrop, end to end through the tools: what
//! `page_render` draws, `doc_backdrops`, save and reopen, and Remove putting the page back.
use std::path::PathBuf;
use pdfcraft_automation::{Automation, Content};
use serde_json::{Value, json};
/// Two 200 × 100 pt pages saying "Hi": page 1 on a white fill of its own (as browsers write),
/// page 2 on bare paper; a third page is a "scan" (a page-sized grey picture).
fn fixture() -> Vec<u8> {
let text = "0 g BT /F1 24 Tf 20 40 Td (Hi) Tj ET";
let contents = [format!("q 1 1 1 rg 0 0 200 100 re f Q {text}"), text.to_string(), format!("q 200 0 0 100 0 0 cm /Im0 Do Q {text}")];
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [4 0 R 6 0 R 8 0 R] /Count 3 /MediaBox [0 0 200 100] >>".into(),
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{80}\nendstream"
.into(),
];
for (i, c) in contents.iter().enumerate() {
objs.push(format!(
"<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /Im0 3 0 R >> >> >>",
5 + 2 * i
));
objs.push(format!("<< /Length {} >>\nstream\n{c}\nendstream", c.len()));
}
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
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{x}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn workdir() -> PathBuf {
named_workdir("main")
}
/// A folder of its own per test: the tests of this binary run at the same time.
fn named_workdir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("pdfcraft-automation-backdrop-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.pdf"), fixture()).unwrap();
dir
}
fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
match a.call(tool, &args) {
Ok(mut c) => match c.remove(0) {
Content::Json(v) => v,
other => panic!("{tool}: expected JSON, got {other:?}"),
},
Err(e) => panic!("{tool} {args}: {e}"),
}
}
/// The RGB of a blank spot (top-right corner area) of a page rendered at 72 dpi.
fn blank_spot(a: &mut Automation, doc: u64, page: u64) -> [u8; 3] {
let rendered = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 72 })).unwrap();
let Content::Png { data, width, .. } = &rendered[0] else { panic!("expected a PNG") };
let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap();
let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut pixels).unwrap();
let channels = info.color_type.samples();
let at = (10 * *width as usize + 180) * channels;
[pixels[at], pixels[at + 1], pixels[at + 2]]
}
const RED: [u8; 3] = [255, 0, 0];
const WHITE: [u8; 3] = [255, 255, 255];
#[test]
fn a_background_shows_on_a_page_that_paints_its_own_white_backdrop() {
let dir = workdir();
let mut a = Automation::new().with_root(&dir).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(
ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"],
json!([{ "page": 1, "backdrop": "fill" }, { "page": 3, "backdrop": "image" }])
);
// Not replacing: the page's own white hides the background, as it always did.
ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000", "replace_backdrop": false }));
assert_eq!(blank_spot(&mut a, doc, 1), WHITE);
assert_eq!(blank_spot(&mut a, doc, 2), RED);
ok(&mut a, "edit_undo", json!({ "doc": doc }));
// The default replaces it: red on both pages; the scan still hides it.
ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000" }));
assert_eq!(blank_spot(&mut a, doc, 1), RED);
assert_eq!(blank_spot(&mut a, doc, 2), RED);
assert_ne!(blank_spot(&mut a, doc, 3), RED);
assert_eq!(ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], json!([{ "page": 3, "backdrop": "image" }]));
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" }));
let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(blank_spot(&mut a, saved, 1), RED, "after save and reopen");
ok(&mut a, "doc_remove_marks", json!({ "doc": saved, "kind": "background" }));
assert_eq!(blank_spot(&mut a, saved, 1), WHITE);
assert_eq!(
ok(&mut a, "doc_backdrops", json!({ "doc": saved, "pages": [1] }))["pages"],
json!([{ "page": 1, "backdrop": "fill" }]),
"the backdrop is back"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// One 200 × 100 pt page on a white fill of its own: a line to redact, invisible (`3 Tr`) text
/// that Remove Hidden Information removes, and a line that stays (`0 Tr` again: the render mode
/// outlives `ET`). With `tag`, the content is a
/// backdrop copy carrying that tag, under a background.
fn secret_pdf(tag: Option<&str>) -> Vec<u8> {
let fill = if tag.is_some() { "n" } else { "f" };
let content = format!(
"1 1 1 rg 0 0 200 100 re {fill} 0 g BT /F1 12 Tf 10 70 Td (SECRET-LINE) Tj ET BT 3 Tr /F1 12 Tf 10 45 Td (HIDDEN-LINE) Tj ET BT 0 Tr /F1 12 Tf 10 20 Td (KEEP) Tj ET"
);
let bg = "q 1 0 0 rg 0 0 200 100 re f Q";
let (contents, extra) = match tag {
Some(t) => (
"[5 0 R 6 0 R]",
vec![
format!("<< /PCMark /Background /Length {} >>\nstream\n{bg}\nendstream", bg.len()),
format!("<< /PCBackdrop {t} /Length {} >>\nstream\n{content}\nendstream", content.len()),
],
),
None => ("[5 0 R]", vec![format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())]),
};
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 100] >>".into(),
format!("<< /Type /Page /Parent 2 0 R /Contents {contents} /Resources << /Font << /F1 4 0 R >> >> >>"),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
];
objs.extend(extra);
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
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{x}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// Every stream of a saved file, decoded, joined: what anyone reading the file can recover.
fn every_stream(path: &std::path::Path) -> String {
let doc = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap();
doc.scan_objects()
.filter_map(|(_, o)| match &*o {
pdfcraft_cos::Object::Stream(s) => Some(String::from_utf8_lossy(&s.decoded().unwrap_or_default()).into_owned()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n")
}
/// Text a redaction or Remove Hidden Information took off a page whose backdrop a background
/// replaced is gone from the saved file. The backdrop copy's tag used to keep a reference to the
/// page's original content stream, and with it the removed text. Removing the background
/// afterwards still turns the page's white fill back on.
#[test]
fn removed_text_leaves_the_file_when_a_background_replaced_the_pages_backdrop() {
for (remove, gone) in [
(vec![("redact_mark", json!({ "find": "SECRET-LINE" })), ("redact_apply", json!({}))], "SECRET-LINE"),
(vec![("doc_remove_hidden", json!({ "categories": ["hidden-text"] }))], "HIDDEN-LINE"),
] {
let dir = named_workdir(gone);
std::fs::write(dir.join("s.pdf"), secret_pdf(None)).unwrap();
let mut a = Automation::new().with_root(&dir).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "s.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000" }));
for (tool, args) in &remove {
let mut args = args.clone();
args["doc"] = json!(doc);
ok(&mut a, tool, args);
}
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true }));
let file = every_stream(&dir.join("out.pdf"));
assert!(file.contains("(KEEP)"), "{gone}: the page's other text is still there: {file}");
assert!(!file.contains(gone), "{gone} is still in the file: {file}");
let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].as_u64().unwrap();
let removed = ok(&mut a, "doc_remove_marks", json!({ "doc": saved, "kind": "background" }));
assert!(removed.get("warnings").is_none(), "{gone}: {removed}");
assert_eq!(
ok(&mut a, "doc_backdrops", json!({ "doc": saved }))["pages"],
json!([{ "page": 1, "backdrop": "fill" }]),
"{gone}: the fill is back"
);
assert_eq!(blank_spot(&mut a, saved, 1), WHITE);
let _ = std::fs::remove_dir_all(&dir);
}
}
/// When the backdrop can't be turned back on safely, Remove background says which pages keep it
/// off.
#[test]
fn remove_background_warns_about_pages_whose_own_backdrop_stays_off() {
let dir = named_workdir("left-off");
// A tag whose hash doesn't match the bytes before the fill: they changed since.
let tag = format!("<< /Index 2 /Op (f) /Before <{}> >>", "00".repeat(32));
std::fs::write(dir.join("t.pdf"), secret_pdf(Some(&tag))).unwrap();
let mut a = Automation::new().with_root(&dir).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "t.pdf" }))["doc"].as_u64().unwrap();
let removed = ok(&mut a, "doc_remove_marks", json!({ "doc": doc, "kind": "background" }));
let warnings = removed["warnings"].as_array().unwrap_or_else(|| panic!("{removed}"));
assert_eq!(warnings.len(), 1, "{removed}");
assert!(warnings[0].as_str().unwrap().starts_with("page 1 changed after the background was added"), "{removed}");
assert_eq!(ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], json!([]), "the fill stays off");
let _ = std::fs::remove_dir_all(&dir);
}
/// Two 200 × 100 pt pages whose full-page `1 g` fill is not a plain white backdrop (#2815
/// review): page 1 paints it half transparent, through a `gs` that sets `ca` and a later one
/// that only sets the line width; page 2 sends it through a transfer function that maps 1 to 0.
fn graphics_state_pdf() -> Vec<u8> {
let invert = "<< /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>";
let resources = format!("<< /ExtGState << /Transparent << /ca 0.5 >> /LineOnly << /LW 2 >> /Invert << /TR {invert} >> >> >>");
let contents = ["/Transparent gs /LineOnly gs 1 g 0 0 200 100 re f", "/Invert gs 1 g 0 0 200 100 re f"];
let mut objs: Vec<String> =
vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 /MediaBox [0 0 200 100] >>".into()];
for (i, c) in contents.iter().enumerate() {
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources {resources} >>", 4 + 2 * i));
objs.push(format!("<< /Length {} >>\nstream\n{c}\nendstream", c.len()));
}
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
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{x}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// #2815 review: a translucent fill (its `ca` left in force by a later `gs`) and a fill through
/// a transfer function are not backdrops, so a red background goes under them and the
/// translucent white still lightens it.
#[test]
fn fills_the_graphics_state_changes_are_not_replaced() {
let dir = named_workdir("graphics-state");
std::fs::write(dir.join("gs.pdf"), graphics_state_pdf()).unwrap();
let mut a = Automation::new().with_root(&dir).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "gs.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], json!([]), "neither fill is a backdrop");
ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000" }));
let [r, g, b] = blank_spot(&mut a, doc, 1);
assert_eq!(r, 255, "{:?}", [r, g, b]);
assert!((100..=160).contains(&g) && (100..=160).contains(&b), "half-transparent white over red, not red: {:?}", [r, g, b]);
let _ = std::fs::remove_dir_all(&dir);
}
+23
View File
@@ -151,3 +151,26 @@ fn bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically() {
assert_eq!(bytes(&a, id), before);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn button_export_edit_is_one_undo_step_and_survives_save() {
let (mut a, id, dir) = setup("button-export");
tool(&mut a, "form_add_field", json!({"doc": id, "page": 1, "type": "checkbox", "name": "agree", "rect": [20, 20, 40, 40]}));
tool(&mut a, "form_fill", json!({"doc": id, "values": {"agree": true}}));
let before = bytes(&a, id);
call(&mut a, json!({"doc": id, "field": "agree", "export_value": "Accepted", "widget": 1}));
let f = tool(&mut a, "form_fields", json!({"doc": id}));
let check = f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "agree").unwrap();
assert_eq!(check["export_values"], json!(["Accepted"]));
assert_eq!(check["value"], true);
tool(&mut a, "edit_undo", json!({"doc": id}));
assert_eq!(bytes(&a, id), before);
tool(&mut a, "edit_redo", json!({"doc": id}));
tool(&mut a, "doc_save", json!({"doc": id, "path": "saved.pdf"}));
let reopened = tool(&mut a, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap();
let f = tool(&mut a, "form_fields", json!({"doc": reopened}));
let check = f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "agree").unwrap();
assert_eq!(check["export_values"], json!(["Accepted"]));
assert_eq!(check["value"], true);
std::fs::remove_dir_all(dir).unwrap();
}
+4
View File
@@ -3,8 +3,12 @@
mod attachment_key;
mod automation;
mod automation_roots;
mod backdrop;
mod batch;
mod button_caption;
mod form_bulk;
mod hostile_args;
mod optimize_content_boundaries;
mod redact_after_stray_q_form;
mod redact_text_after_q;
mod render_warnings;
@@ -0,0 +1,67 @@
//! Search-and-redact of page text drawn after a form XObject with more `Q` than `q`. Find's boxes
//! come from the renderer's text and redaction measures the content itself, so the two must agree
//! that the form's stray `Q`s do not undo the page's `cm` (vendored hayro-interpret patch 68).
use pdfcraft_automation::{Automation, Content};
use serde_json::{Value, json};
const CONTENT: &str = "q 1 0 0 1 20 -100 cm BT /F1 12 Tf 72 700 Td (AAAA) Tj ET /X Do \
BT /F1 12 Tf 72 680 Td (SECRET) Tj ET Q";
const FORM: &str = "BT /F1 12 Tf 200 0 Td ET Q Q Q";
fn pdf() -> Vec<u8> {
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_owned(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << \
/F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /X 5 0 R >> >> >>"
.to_owned(),
format!("<< /Length {} >>\nstream\n{CONTENT}\nendstream", CONTENT.len()),
format!("<< /Type /XObject /Subtype /Form /BBox [0 0 612 792] /Length {} >>\nstream\n{FORM}\nendstream", FORM.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for offset in offsets {
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
out
}
fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
match a.call(tool, &args) {
Ok(mut c) => match c.remove(0) {
Content::Json(v) => v,
other => panic!("{tool}: expected JSON, got {other:?}"),
},
Err(e) => panic!("{tool} {args}: {e}"),
}
}
fn text(a: &mut Automation, doc: &Value) -> String {
ok(a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap_or_default().to_owned()
}
#[test]
fn find_and_redact_removes_text_drawn_after_a_form_with_stray_q() {
let dir = std::env::temp_dir().join(format!("pdfcraft-redact-stray-q-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("in.pdf"), pdf()).unwrap();
let mut a = Automation::new().with_root(&dir).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "in.pdf" }))["doc"].clone();
assert_eq!(ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "SECRET" }))["marked"], 1);
ok(&mut a, "redact_apply", json!({ "doc": doc }));
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true }));
let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].clone();
let after = text(&mut a, &saved);
assert!(!after.contains("SECRET"), "{after:?}");
assert!(after.contains("AAAA"), "the text before the mark stays: {after:?}");
let _ = std::fs::remove_dir_all(&dir);
}
@@ -0,0 +1,77 @@
//! Search-and-redact of text that follows a `q`/`Q` inside a text object. `Q` restores the text
//! matrices there (ISO 32000-2 §9.4.1 as corrected by the PDF Association's errata, issue 368), so
//! "SECRET" is drawn from the pen position before `q`, over the "B". Find's boxes come from the
//! renderer's text, which places it there; redaction measures the content itself, and while it
//! carried the pen across `Q` the two disagreed and `redact_apply` refused to change anything.
use pdfcraft_automation::{Automation, Content};
use serde_json::{Value, json};
/// The content, the text that stays after SECRET is redacted, and the text that goes with it.
const CASES: [(&str, &str, &str); 3] = [
// A larger font inside `q`/`Q`: the font and the pen are both restored, so SECRET lies over
// the B and the B goes with it.
("BT /F1 12 Tf 72 700 Td (AAAA) Tj q /F2 24 Tf (B) Tj Q (SECRET) Tj ET", "AAAA", "B"),
// A move inside `q`/`Q` is undone.
("BT /F1 12 Tf 72 700 Td (AA) Tj q 100 0 Td Q (SECRET) Tj ET", "AA", "SECRET"),
// The text line matrix is restored too: `T*` starts the next line from the line before `q`.
("BT /F1 12 Tf 14 TL 72 700 Td (AA) Tj q 100 0 Td Q T* (SECRET) Tj ET", "AA", "SECRET"),
];
fn pdf(content: &str) -> Vec<u8> {
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_owned(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << \
/F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> \
/F2 << /Type /Font /Subtype /Type1 /BaseFont /Courier >> >> >> >>"
.to_owned(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
];
let mut out = b"%PDF-2.0\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for offset in offsets {
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
out
}
fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
match a.call(tool, &args) {
Ok(mut c) => match c.remove(0) {
Content::Json(v) => v,
other => panic!("{tool}: expected JSON, got {other:?}"),
},
Err(e) => panic!("{tool} {args}: {e}"),
}
}
fn text(a: &mut Automation, doc: &Value) -> String {
ok(a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap_or_default().to_owned()
}
#[test]
fn find_and_redact_removes_text_after_a_q_q_inside_its_text_object() {
for (case, (content, kept, gone)) in CASES.iter().enumerate() {
let dir = std::env::temp_dir().join(format!("pdfcraft-redact-after-q-{}-{case}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("in.pdf"), pdf(content)).unwrap();
let mut a = Automation::new().with_root(&dir).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "in.pdf" }))["doc"].clone();
assert_eq!(ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "SECRET" }))["marked"], 1, "{content}");
ok(&mut a, "redact_apply", json!({ "doc": doc }));
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true }));
let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].clone();
let after = text(&mut a, &saved);
assert!(!after.contains("SECRET") && !after.contains(gone), "{content}: {after:?}");
assert!(after.contains(kept), "{content}: the text before the mark stays: {after:?}");
let _ = std::fs::remove_dir_all(&dir);
}
}
@@ -0,0 +1,119 @@
//! `page_render` reports the renderer's warnings next to the PNG, so a headless caller can tell
//! that a picture is incomplete or approximate, as `pdfcraft-cli render` can (#4340).
use std::path::PathBuf;
use pdfcraft_automation::{Automation, Content};
use serde_json::{Value, json};
/// The warning a colour that falls back gives, worded as `pdfcraft-cli render` prints it.
const FALLBACK: &str = "a colour that could not be computed was painted in a fallback colour";
/// A one-page 200 × 200 pt PDF that draws `content`, with the colour space `CS1` defined as
/// `[/DeviceN [/Spot1 /Spot2] /DeviceRGB 5 0 R]` and object 5 a calculator function `program`.
fn devicen_pdf(content: &str, program: &str) -> Vec<u8> {
format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /ColorSpace << /CS1 [/DeviceN [/Spot1 /Spot2] /DeviceRGB 5 0 R] >> >> /Contents 4 0 R >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1] /Length {} >> stream\n{program}\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
content.len(),
program.len()
)
.into_bytes()
}
/// A page whose colour falls back: the tint transform pops more than it is given, so it can't be
/// evaluated (the renderer's own test of the warning uses the same function).
fn fallback_pdf() -> Vec<u8> {
devicen_pdf("/CS1 cs 0.5 0.5 scn 0 0 100 100 re f", "{ pop pop pop }")
}
/// The same page with a tint transform that works: nothing to warn about.
fn clean_pdf() -> Vec<u8> {
devicen_pdf("/CS1 cs 0.5 0.5 scn 0 0 100 100 re f", "{ pop pop 0 0 0 }")
}
/// A fresh folder holding `fallback.pdf` and `clean.pdf`, and an automation rooted there.
fn setup(test: &str) -> (PathBuf, Automation) {
let dir = std::env::temp_dir().join(format!("pdfcraft-render-warnings-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("fallback.pdf"), fallback_pdf()).unwrap();
std::fs::write(dir.join("clean.pdf"), clean_pdf()).unwrap();
let a = Automation::new().with_root(&dir).unwrap();
(dir, a)
}
fn open(a: &mut Automation, path: &str) -> u64 {
match a.call("doc_open", &json!({ "path": path })).unwrap().remove(0) {
Content::Json(v) => v["doc"].as_u64().unwrap(),
other => panic!("expected JSON: {other:?}"),
}
}
#[test]
fn page_render_lists_the_warnings_after_the_png() {
let (dir, mut a) = setup("fallback");
let doc = open(&mut a, "fallback.pdf");
let out = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
assert_eq!(out.len(), 2, "the PNG and the warnings: {out:?}");
assert!(matches!(out[0], Content::Png { width: 200, height: 200, .. }), "the image comes first, as before");
let Content::Json(report) = &out[1] else { panic!("expected the warnings as JSON: {:?}", out[1]) };
assert_eq!(report["page"], 1, "the page is 1-based, like the argument");
assert_eq!(report["warnings"], json!([FALLBACK]));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn render_preview_lists_the_warnings_after_the_png_too() {
let (dir, mut a) = setup("preview");
let doc = open(&mut a, "fallback.pdf");
let out = a.call("render_preview", &json!({ "doc": doc, "max_side": 64 })).unwrap();
assert!(matches!(out.first(), Some(Content::Png { width: 64, height: 64, .. })), "{out:?}");
let Some(Content::Json(report)) = out.get(1) else { panic!("expected the warnings as JSON: {out:?}") };
assert_eq!(report["page"], 1, "the page defaults to the first");
assert_eq!(report["warnings"], json!([FALLBACK]));
let clean = open(&mut a, "clean.pdf");
assert_eq!(a.call("render_preview", &json!({ "doc": clean })).unwrap().len(), 1, "a clean page gives the PNG alone");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_clean_page_renders_to_the_png_alone() {
let (dir, mut a) = setup("clean");
let fallback = open(&mut a, "fallback.pdf");
let clean = open(&mut a, "clean.pdf");
// The warning of one render doesn't carry over to the next, nor to another document.
a.call("page_render", &json!({ "doc": fallback, "page": 1, "dpi": 72 })).unwrap();
let out = a.call("page_render", &json!({ "doc": clean, "page": 1, "dpi": 72 })).unwrap();
assert_eq!(out.len(), 1, "no warnings, no extra part: {out:?}");
assert!(matches!(out[0], Content::Png { .. }));
let again = a.call("page_render", &json!({ "doc": fallback, "page": 1, "dpi": 72 })).unwrap();
assert_eq!(again.len(), 2, "a cached renderer still reports the page's warning: {again:?}");
let _ = std::fs::remove_dir_all(dir);
}
/// Over MCP the warnings are a text block after the image and the call's `structuredContent`.
#[cfg(feature = "mcp")]
#[test]
fn mcp_page_render_carries_the_warnings_as_structured_content() {
use pdfcraft_automation::mcp::McpServer;
let (dir, a) = setup("mcp");
let mut s = McpServer::new(a);
let mut rpc = |id: u64, method: &str, params: Value| -> Value {
let line = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string();
serde_json::from_str(&s.handle_line(&line).expect("a reply")).unwrap()
};
rpc(1, "initialize", json!({}));
rpc(2, "tools/call", json!({ "name": "doc_open", "arguments": { "path": "fallback.pdf" } }));
let reply = rpc(3, "tools/call", json!({ "name": "page_render", "arguments": { "doc": 1, "page": 1 } }));
let result = &reply["result"];
assert_eq!(result["isError"], false, "{reply}");
assert_eq!(result["content"][0]["type"], "image");
assert_eq!(result["content"][1]["type"], "text");
assert_eq!(result["structuredContent"]["warnings"], json!([FALLBACK]));
let _ = std::fs::remove_dir_all(dir);
}
+5 -2
View File
@@ -730,11 +730,14 @@ impl Matrix {
pub fn invert(&self) -> Option<Matrix> {
let [a, b, c, d, e, f] = self.0;
let det = a * d - b * c;
if det.abs() < 1e-12 || !det.is_finite() {
if det == 0.0 || !det.is_finite() || det.abs() <= 1e-12 * ((a * d).abs() + (b * c).abs()) {
return None;
}
let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det);
Some(Matrix([ia, ib, ic, id, -(e * ia + f * ic), -(e * ib + f * id)]))
let inverse = [ia, ib, ic, id, -(e * ia + f * ic), -(e * ib + f * id)];
// A determinant that underflows toward zero can still pass the relative check above,
// and dividing by it overflows: such a matrix has no usable inverse.
inverse.iter().all(|v| v.is_finite()).then_some(Matrix(inverse))
}
/// The bounding box of a rectangle `[x0 y0 x1 y1]` after this transform.
+14
View File
@@ -552,3 +552,17 @@ fn inline_images_end_where_the_renderer_ends_them() {
assert_eq!((parsed.skipped, parsed.ambiguous_images), (0, 0), "{}", String::from_utf8_lossy(data));
}
}
#[test]
fn invert_accepts_small_scale_and_rejects_singular() {
let m = Matrix([1e-7, 0., 0., 1e-7, 0., 0.]);
let inv = m.invert().expect("a uniform 1e-7 scale is invertible");
let id = m.then(&inv).0;
for (got, want) in id.iter().zip(Matrix::IDENTITY.0) {
assert!((got - want).abs() < 1e-9, "{id:?}");
}
assert!(Matrix([1., 2., 2., 4., 0., 0.]).invert().is_none());
assert!(Matrix([f64::NAN, 0., 0., 1., 0., 0.]).invert().is_none());
// A subnormal scale passes the relative check but its inverse overflows (CodeRabbit on #5703).
assert!(Matrix([1e-310, 0., 0., 1., 0., 0.]).invert().is_none());
}
+11 -2
View File
@@ -24,6 +24,9 @@ let obj = doc.get(ObjRef { num: 12, gen: 0 }); // load (and cache): Arc<Ob
let real = doc.resolve(&obj); // follow one indirect reference
let d = doc.dict(&obj); // Option<Dict>, resolving first
let data = stream.decoded()?; // filters applied on demand
let data = doc.with_direct_parms(&stream).decoded()?; // /DecodeParms given by reference too
let px = doc.decoded_image(&stream)?; // an image's samples, a CCITT fax image's too
let fax = doc.fax_params(&stream); // a CCITT fax image's /DecodeParms, Table 11 defaults
doc.set(r, Object::Dict(d)); // edit: lands in the overlay
let r = doc.add(obj); // append a new object
@@ -87,5 +90,11 @@ recognisable in a diff and byte-stable when re-serialized.
- Linearized output, Fast Web View (`core.linearization`, M11).
- Structural validation against the Arlington model (`core.arlington-validation`), which will live
in the reserved `arlington` crate.
- Image codecs. `pdfcraft-filters` carries the non-image filters; DCT, JPX, JBIG2 and CCITT are
still decoded through the bootstrap renderer (`core.image-filters`, M2.6).
- Image codecs. `pdfcraft-filters` carries the non-image filters and CCITT fax and JBIG2 decoding
(`Document::decoded_image`, a JBIG2 image's `/JBIG2Globals` within `MAX_JBIG2_GLOBALS`, 32 MiB,
their filters undone once a document for the images that share them, up to 64 MiB kept, while
their segments are decoded for every image, and `ImageBudget` for an operation over many images,
which share one allowance of decoding, filters, decoders and pixels alike, and are refused past
it: `IMAGE_WORK`, 2^34 steps, or 2 to 5 minutes of JBIG2 and about 1,000 pages scanned at
300 dpi, plus `IMAGE_WORK_PER_BYTE`, 2^13 steps for each byte of the file);
DCT and JPX are still decoded through the bootstrap renderer (`core.image-filters`, M2.6).
+612
View File
@@ -6,16 +6,218 @@ use pdfcraft_filters::FilterError;
use crate::{CosError, Document, ObjRef, Object, Stream};
/// The steps of decoding an operation over a document's images may take whatever the file's size.
/// A step of JBIG2 decoding ([`hayro_jbig2::Image::decode_within`]) is about a pixel, and takes 6 to
/// 16 ns, so this is 2 to 5 minutes, and about as long again for what an operation does with the
/// pixels. A page scanned at 300 dpi takes about 17 million, 8.4 for its pixels and that again to
/// decode them, so this alone reads about 1,000 of them. A byte into or out of a filter is a step
/// too, and takes less: 3 ns at most, or up to 19 ns through a predictor on samples of a bit.
const IMAGE_WORK: u64 = 1 << 34;
/// The steps each byte of the file adds to [`IMAGE_WORK`], so that a long scan is read whole: such a
/// page pays for itself with about 2 KB of the file, about the smallest a page of text is stored
/// in, and a stream of any filter this reads can be decoded about 8 times at Flate's best, 1,032 to
/// one. So what a hostile file can ask for grows with its length, by about 14 minutes for 10 MB.
const IMAGE_WORK_PER_BYTE: u64 = 1 << 13;
/// Decoded bytes one page's content walk may produce in all: its `/Contents` streams and every
/// form XObject it enters, each occurrence counted again.
pub const MAX_PAGE_DECODED: usize = 256 << 20;
/// Form XObjects one page's content walk may enter, each entry counted again.
pub const MAX_PAGE_FORMS: usize = 50_000;
/// The steps a sample of a JPEG costs: a few bytes can hold a page of one grey, and decoding,
/// resampling and storing it took about 20 to 45 ns a sample.
const JPEG_WORK: u64 = 2;
/// The steps a byte of a JPEG costs to read: its tables took 24 ns a byte before a size was known,
/// and images can share one, so reading it again for each has to count.
const JPEG_READ_WORK: u64 = 4;
/// A stream decode within a byte bound, with the bytes its filter stages decoded: the data or
/// the error, each with that count.
type Decoder = fn(&Stream, usize) -> Result<(Vec<u8>, usize), (FilterError, usize)>;
/// What one operation over a document's images may still spend decoding them: exporting them,
/// optimizing them, and storing LZW streams with Flate as it does, clearing them for redaction.
///
/// An image has its own limit, which doesn't bound a file of thousands of images made to reach it:
/// a few hundred bytes of JBIG2 can ask for seconds of decoding, 4 KB of Flate inside Flate for a
/// gigabyte, and an image that shares `/JBIG2Globals` decodes them again. So images share this
/// allowance, whatever they do with what they decode and whether or not it works, and past it they
/// aren't decoded. A caller then treats them as it does an image it can't read, and
/// [`ImageBudget::refused`] says how many there were.
///
/// An image also pays for its pixels once decoded, as what reads it goes through them one by one
/// however few bytes coded them: blank rows of fax or Flate take almost none. It pays for no more
/// than its data holds, a bit a pixel at least, so an image declaring a size its data doesn't fill
/// costs what a smaller one would, not the run's allowance. A JPEG pays for its samples when
/// it is decoded ([`ImageBudget::pay_jpeg`]), and is otherwise not charged, as JPEG 2000 isn't:
/// their decoders don't run here.
#[derive(Debug)]
pub struct ImageBudget {
work: u64,
refused: usize,
}
impl ImageBudget {
/// The allowance for an operation on `doc`, which a longer file makes larger. The streams made
/// or changed since it was opened count as the file's bytes do, so a document built in memory
/// (combined, created, or given pages of scans) gets what it would once saved and opened again.
pub fn new(doc: &Document) -> Self {
let length = |n: usize| u64::try_from(n).unwrap_or(u64::MAX);
let edited = doc
.scan_objects_subset(doc.modified_objects())
.filter_map(|(_, object)| match &*object.ok()? {
Object::Stream(s) => Some(length(s.raw.len())),
_ => None,
})
.fold(0u64, u64::saturating_add);
let bytes = length(doc.bytes().len()).saturating_add(edited);
Self::with_work(IMAGE_WORK.saturating_add(bytes.saturating_mul(IMAGE_WORK_PER_BYTE)))
}
/// An allowance of `work` steps of decoding.
pub fn with_work(work: u64) -> Self {
Self { work, refused: 0 }
}
/// The samples of image `s`, decoded and charged to the allowance: the one way to decode an
/// image outside the renderer, so that an operation over many images can't decode them without
/// one.
///
/// They go through its filters ([`Stream::decoded`]), or, for a CCITT fax or JBIG2 image (its
/// last filter), through the filters before it and then [`pdfcraft_filters::decode_ccitt_within`]
/// with its [`Document::fax_params`], or [`pdfcraft_filters::decode_jbig2_within`] with its
/// `/JBIG2Globals`, as `CCITTFaxDecode` and `JBIG2Decode` give them (ISO 32000-2 §7.4.6,
/// §7.4.7). Parameters given by reference count, for every filter
/// ([`Document::with_direct_parms`]). A fax image's `/Columns` has to be its `/Width`, and a
/// JBIG2 image's page its `/Width` × `/Height`: PdfCraft's renderer draws the whole page into
/// the image. Up to its `/Height` rows are decoded, whatever a fax image's `/Rows` says; damaged
/// fax data gives fewer, so a caller checks it has the rows it needs. Damaged JBIG2 data, and
/// globals that can't be read (within [`MAX_JBIG2_GLOBALS`](crate::MAX_JBIG2_GLOBALS)), are an
/// error.
///
/// No stage, the filters before a fax or JBIG2 decoder included, may produce more than `max`
/// bytes, nor more than [`MAX_DECODED`](crate::MAX_DECODED). A JBIG2 image's `/JBIG2Globals`
/// are another stream, shared between images, and are decoded within `MAX_JBIG2_GLOBALS`
/// whatever `max` is. Each filter as it runs, the globals when they have to be read, and the fax
/// or JBIG2 decoder spend from the allowance, and the image then pays for its pixels; with
/// nothing left an image is refused before anything is read, its globals included.
///
/// ```
/// # let doc = pdfcraft_cos::Document::new_empty();
/// # let s = pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), Vec::new());
/// let samples = pdfcraft_cos::ImageBudget::new(&doc).decode(&doc, &s, 1 << 20);
/// assert_eq!(samples.unwrap(), b"");
/// ```
///
/// `Document` has no way of its own to decode an image:
///
/// ```compile_fail
/// # let doc = pdfcraft_cos::Document::new_empty();
/// # let s = pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), Vec::new());
/// let _ = doc.decoded_image(&s);
/// ```
///
/// ```compile_fail
/// # let doc = pdfcraft_cos::Document::new_empty();
/// # let s = pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), Vec::new());
/// let _ = doc.decoded_image_within(&s, 1 << 20);
/// ```
pub fn decode(&mut self, doc: &Document, s: &Stream, max: usize) -> Result<Vec<u8>, CosError> {
let decoded = doc.decoded_image_spending(s, max, &mut self.work);
self.count_refused(s, &decoded);
self.pay_size(s, decoded?)
}
/// Image `s` through its filters alone, charged to the allowance: [`Stream::decoded_within`]
/// with its `/DecodeParms` given by reference read too ([`Document::with_direct_parms`]), for an
/// image that has no fax or JBIG2 decoder to go through.
pub fn decode_filters(&mut self, doc: &Document, s: &Stream, max: usize) -> Result<Vec<u8>, CosError> {
let decoded = doc.with_direct_parms(s).decoded_spending(max, &mut self.work);
self.count_refused(s, &decoded);
let decoded = decoded?;
if s.filters().iter().all(|(f, _)| f.is_decodable()) { self.pay_size(s, decoded) } else { Ok(decoded) }
}
/// `data` through `chain`, any corruption being an error ([`pdfcraft_filters::decode_within`]),
/// for a stream a run rewrites rather than an image it reads, charged to the allowance.
pub fn decode_chain(
&mut self,
chain: &[(pdfcraft_filters::Filter, pdfcraft_filters::Params)],
data: &[u8],
max: usize,
) -> Result<Vec<u8>, FilterError> {
pdfcraft_filters::decode_within(chain, data, max, &mut self.work)
}
/// Pays `steps` of other work on decoded data, or spends all there is and says so.
pub fn spend(&mut self, steps: u64) -> bool {
let left = self.work.checked_sub(steps);
self.work = left.unwrap_or(0);
left.is_some()
}
/// Pays for `pixels` a decode goes on to work through, or spends all there is, and the image
/// is refused.
pub fn pay_pixels(&mut self, pixels: u64) -> bool {
let paid = self.spend(pixels);
self.refused += usize::from(!paid);
paid
}
/// Pays for reading JPEG `data` and decoding its `samples`, as [`ImageBudget::pay_pixels`] does.
/// A caller pays before its tables are parsed, which can be most of the file.
pub fn pay_jpeg(&mut self, samples: u64, data: &[u8]) -> bool {
let reading = (data.len() as u64).saturating_mul(JPEG_READ_WORK);
self.pay_pixels(samples.saturating_mul(JPEG_WORK).saturating_add(reading))
}
/// Pays for the pixels of image `s` that its `decoded` data holds, those of its `/Width` and
/// `/Height` but no more than a bit of the data each, unless it is JBIG2, whose decoder pays for
/// its page, or isn't decoded here. What can't be paid for refuses the image.
fn pay_size(&mut self, s: &Stream, decoded: Vec<u8>) -> Result<Vec<u8>, CosError> {
if !charged(s) || matches!(s.filters().last(), Some((pdfcraft_filters::Filter::Jbig2, _))) {
return Ok(decoded);
}
let side = |key: &[u8]| s.dict.int(key).and_then(|v| u64::try_from(v).ok()).unwrap_or(0);
let held = u64::try_from(decoded.len()).unwrap_or(u64::MAX).saturating_mul(8);
if self.pay_pixels(side(b"Width").saturating_mul(side(b"Height")).min(held)) {
Ok(decoded)
} else {
Err(CosError::Filter(FilterError::Spent.to_string()))
}
}
/// An image that failed with nothing left was refused, unless it has a filter that costs
/// nothing here, which fails whatever is left.
fn count_refused(&mut self, s: &Stream, decoded: &Result<Vec<u8>, CosError>) {
if decoded.is_err() && self.work == 0 && charged(s) {
self.refused += 1;
}
}
/// The decodes refused for lack of allowance: an image read twice over is counted twice.
pub fn refused(&self) -> usize {
self.refused
}
/// The steps of decoding still allowed.
pub fn work_left(&self) -> u64 {
self.work
}
}
/// Whether `s` can be decoded here and so charged: general filters, a fax or JBIG2 decoder last at
/// most. Those decoders anywhere else can't run.
fn charged(s: &Stream) -> bool {
use pdfcraft_filters::Filter;
s.readable_filters().is_ok_and(|chain| {
chain.split_last().is_none_or(|((last, _), before)| {
(last.is_decodable() || matches!(last, Filter::Jbig2 | Filter::CcittFax)) && before.iter().all(|(f, _)| f.is_decodable())
})
})
}
/// What one operation may still decode and enter across all the streams it reads.
///
/// [`MAX_DECODED`](crate::MAX_DECODED) bounds one decode. It does not bound a page whose
@@ -143,6 +345,401 @@ impl DecodeBudget {
}
}
#[cfg(test)]
mod image_tests {
use super::*;
use crate::Dict;
fn jbig2(parms: Option<Object>) -> Stream {
let mut dict = Dict::new();
dict.set(b"Width".to_vec(), Object::Int(16));
dict.set(b"Height".to_vec(), Object::Int(2));
dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
if let Some(parms) = parms {
dict.set(b"DecodeParms".to_vec(), parms);
}
Stream::from_raw(dict, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap())
}
/// Each image spends what it took from one allowance, and those past it are refused and
/// counted, whatever their filters. What can't be decoded here costs nothing, and fails
/// without being one of these.
#[test]
fn the_images_of_an_operation_share_its_allowance() {
let doc = Document::new_empty();
let image = jbig2(None);
let mut budget = ImageBudget::with_work(u64::MAX);
let samples = budget.decode(&doc, &image, 1 << 20).unwrap();
let cost = u64::MAX - budget.work_left();
assert!(cost > 0);
let mut budget = ImageBudget::with_work(3 * cost);
for left in [2, 1, 0] {
assert_eq!(budget.decode(&doc, &image, 1 << 20).unwrap(), samples);
assert_eq!(budget.work_left(), left * cost);
}
assert_eq!(budget.refused(), 0, "the allowance held exactly three");
for refused in [1, 2] {
assert!(budget.decode(&doc, &image, 1 << 20).is_err());
assert_eq!(budget.refused(), refused);
}
assert!(budget.decode(&doc, &Stream::flate(Dict::new(), b"samples"), 1 << 20).is_err());
assert!(budget.decode(&doc, &Stream::from_raw(Dict::new(), b"samples".to_vec()), 1 << 20).is_err());
assert_eq!(budget.refused(), 4, "other images share it");
for name in ["NoSuchFilter", "DCTDecode", "JPXDecode"] {
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name(name));
assert!(budget.decode(&doc, &Stream::from_raw(dict, b"data".to_vec()), 1 << 20).is_err());
assert_eq!(budget.refused(), 4, "{name} isn't one of these");
}
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::Int(5));
assert!(budget.decode(&doc, &Stream::from_raw(dict, b"data".to_vec()), 1 << 20).is_err());
assert_eq!(budget.refused(), 4, "a /Filter that can't be read isn't either");
}
/// What a file of images each inflating a megabyte asks for: 4 KB of Flate inside Flate can ask
/// for a gigabyte, and these aren't JBIG2, so without the allowance nothing would stop them.
#[test]
fn filter_bombs_stop_at_the_allowance() {
let doc = Document::new_empty();
let samples = vec![0; 1 << 20];
let inner = pdfcraft_filters::encode_flate(&samples);
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("FlateDecode")]));
let bomb = Stream::from_raw(dict, pdfcraft_filters::encode_flate(&inner));
let cost = (bomb.raw.len() + inner.len() + samples.len()) as u64;
let mut budget = ImageBudget::with_work(2 * cost + 1);
for left in [cost + 1, 1] {
assert_eq!(budget.decode(&doc, &bomb, 1 << 20).unwrap(), samples);
assert_eq!(budget.work_left(), left);
}
assert!(budget.decode(&doc, &bomb, 1 << 20).is_err());
assert_eq!((budget.work_left(), budget.refused()), (0, 1), "the first it can't pay for ends it");
}
/// Filters in front of a JBIG2 decoder pay as they run, on top of what the decoder takes, and
/// one that fails has still paid: nothing about the JBIG2 data is known by then, and each image
/// of a file can do this.
#[test]
fn filters_in_front_of_jbig2_pay_whether_or_not_they_work() {
let doc = Document::new_empty();
let alone = jbig2(None);
let mut budget = ImageBudget::with_work(u64::MAX);
let samples = budget.decode(&doc, &alone, 1 << 20).unwrap();
let decoding = u64::MAX - budget.work_left();
let behind = |raw: Vec<u8>| {
let mut dict = alone.dict.clone();
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("JBIG2Decode")]));
Stream::from_raw(dict, raw)
};
let image = behind(pdfcraft_filters::encode_flate(&alone.raw));
let mut budget = ImageBudget::with_work(u64::MAX);
assert_eq!(budget.decode(&doc, &image, 1 << 20).unwrap(), samples);
assert_eq!(u64::MAX - budget.work_left(), decoding + (image.raw.len() + alone.raw.len()) as u64);
// Past this image's limit: an error, with what finding out took paid and the rest left.
let bomb = behind(pdfcraft_filters::encode_flate(&vec![0; 1 << 16]));
let mut budget = ImageBudget::with_work(u64::MAX);
assert!(budget.decode(&doc, &bomb, 1000).is_err());
assert_eq!(u64::MAX - budget.work_left(), bomb.raw.len() as u64 + 1000);
assert_eq!(budget.refused(), 0, "that image's limit isn't the run's");
// A small allowance is a limit too: it stops there, and the image is refused.
let mut budget = ImageBudget::with_work(bomb.raw.len() as u64 + 10);
assert!(budget.decode(&doc, &bomb, 1 << 20).is_err());
assert_eq!((budget.work_left(), budget.refused()), (0, 1));
}
/// Globals that are read again for every image pay every time, and fail having paid. Kept
/// ones have no filters left to undo, so they pay for being read and nothing more.
#[test]
fn globals_pay_for_their_filters_each_time_they_are_decoded() {
let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1];
let raw = pdfcraft_filters::encode_flate(&skipped);
let stream = |doc: &Document, r: ObjRef| match &*doc.get(r) {
Object::Stream(s) => s.clone(),
_ => panic!("not a stream"),
};
let spent = |doc: &Document, s: &Stream| {
let mut budget = ImageBudget::with_work(u64::MAX);
budget.decode(doc, s, 1 << 20).unwrap();
u64::MAX - budget.work_left()
};
let mut doc = Document::new_empty();
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let globals = doc.add(Object::Stream(Stream::from_raw(dict, raw.clone())));
let mut parms = Dict::new();
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
let image = doc.add(Object::Stream(jbig2(Some(Object::Dict(parms)))));
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"Image".to_vec(), Object::Ref(image))).unwrap();
// Added in memory, so not kept: the filters are undone for each read.
let s = stream(&doc, image);
let first = spent(&doc, &s);
assert_eq!(spent(&doc, &s), first, "not kept, so paid for again");
// Read from a file, so kept: the second read has no filters to undo.
let file = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap();
let doc = Document::open(std::sync::Arc::new(file)).unwrap();
let image = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Image").unwrap();
let s = stream(&doc, image);
let filters = (raw.len() + skipped.len()) as u64;
assert_eq!(spent(&doc, &s), first, "the first read undoes them");
assert_eq!(spent(&doc, &s), first - filters, "kept");
// Globals that fail pay too, with the image not refused: these go past their own limit.
let over = pdfcraft_filters::encode_flate(&vec![0; crate::MAX_JBIG2_GLOBALS + 1]);
let mut doc = Document::new_empty();
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let globals = doc.add(Object::Stream(Stream::from_raw(dict, over.clone())));
let mut parms = Dict::new();
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
let image = jbig2(Some(Object::Dict(parms)));
let mut budget = ImageBudget::with_work(u64::MAX);
assert!(budget.decode(&doc, &image, 1 << 20).unwrap_err().to_string().contains("globals"));
assert_eq!(u64::MAX - budget.work_left(), (over.len() + crate::MAX_JBIG2_GLOBALS) as u64);
assert_eq!(budget.refused(), 0);
}
/// A fax image pays for what comes before its decoder and for the rows it asks for, and past
/// the allowance it is refused like any other.
#[test]
fn fax_images_share_the_allowance() {
let doc = Document::new_empty();
let (w, h) = (4096u32, 64u32);
let coded = pdfcraft_filters::encode_g4(&vec![0xFF; 512 * h as usize], w, h).unwrap();
let mut parms = Dict::new();
parms.set(b"K".to_vec(), Object::Int(-1));
parms.set(b"Columns".to_vec(), Object::Int(i64::from(w)));
let mut dict = Dict::new();
dict.set(b"Width".to_vec(), Object::Int(i64::from(w)));
dict.set(b"Height".to_vec(), Object::Int(i64::from(h)));
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("CCITTFaxDecode")]));
dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(parms)]));
let image = Stream::from_raw(dict, pdfcraft_filters::encode_flate(&coded));
let pixels = u64::from(w) * u64::from(h);
let cost = (image.raw.len() + coded.len() + 4 * coded.len() + 512 * h as usize) as u64 + pixels;
let mut budget = ImageBudget::with_work(cost);
assert_eq!(budget.decode(&doc, &image, 1 << 20).unwrap().len(), 512 * h as usize);
assert_eq!(budget.work_left(), 0, "exactly that");
assert!(budget.decode(&doc, &image, 1 << 20).is_err());
assert_eq!(budget.refused(), 1);
}
/// An image pays for its pixels however few bytes hold them, or a blank page of fax or Flate
/// would cost a run a second for a few bytes' worth of allowance; a JBIG2 image doesn't pay
/// twice, and one this doesn't decode pays nothing. Past what is left it isn't read.
#[test]
fn an_image_pays_for_its_pixels() {
let doc = Document::new_empty();
let blank = |filter: Option<&str>, data: Vec<u8>| {
let mut dict = Dict::new();
dict.set(b"Width".to_vec(), Object::Int(4096));
dict.set(b"Height".to_vec(), Object::Int(1024));
if let Some(filter) = filter {
dict.set(b"Filter".to_vec(), Object::name(filter));
}
Stream::from_raw(dict, data)
};
let rows = vec![0xFFu8; 512 * 1024];
let flate = blank(Some("FlateDecode"), pdfcraft_filters::encode_flate(&rows));
let cost = (flate.raw.len() + rows.len()) as u64 + 4096 * 1024;
let mut budget = ImageBudget::with_work(cost);
assert_eq!(budget.decode(&doc, &flate, 1 << 24).unwrap(), rows);
assert_eq!((budget.work_left(), budget.refused()), (0, 0), "exactly that");
// Its filters alone pay the same.
let mut budget = ImageBudget::with_work(cost);
assert_eq!(budget.decode_filters(&doc, &flate, 1 << 24).unwrap(), rows);
assert_eq!(budget.work_left(), 0);
// Short of its pixels it is refused, having paid for its filters.
let mut budget = ImageBudget::with_work(4096 * 1024 - 1);
assert!(budget.decode(&doc, &flate, 1 << 24).unwrap_err().to_string().contains("more image decoding than"));
assert_eq!((budget.work_left(), budget.refused()), (0, 1));
let mut budget = ImageBudget::with_work(4096 * 1024 - 1);
assert!(budget.decode_filters(&doc, &flate, 1 << 24).is_err());
assert_eq!((budget.work_left(), budget.refused()), (0, 1));
// What isn't decoded here pays for nothing, and fails as it would.
let mut budget = ImageBudget::with_work(10);
assert!(budget.decode(&doc, &blank(Some("DCTDecode"), b"data".to_vec()), 1 << 24).is_err());
assert_eq!((budget.work_left(), budget.refused()), (10, 0));
// Unfiltered data pays too, and a size that isn't one is no size.
let mut budget = ImageBudget::with_work(u64::MAX);
budget.decode(&doc, &blank(None, rows.clone()), 1 << 24).unwrap();
assert_eq!(u64::MAX - budget.work_left(), rows.len() as u64 + 4096 * 1024);
let mut odd = Dict::new();
odd.set(b"Width".to_vec(), Object::Int(-4));
odd.set(b"Height".to_vec(), Object::Int(9));
let mut budget = ImageBudget::with_work(u64::MAX);
budget.decode(&doc, &Stream::from_raw(odd, vec![1, 2]), 1 << 24).unwrap();
assert_eq!(u64::MAX - budget.work_left(), 2);
// A JPEG pays twice over for each sample, and four times for each byte it is read from.
let mut budget = ImageBudget::with_work(30);
assert!(budget.pay_jpeg(9, b"ab"));
assert_eq!((budget.work_left(), budget.refused()), (4, 0));
assert!(!budget.pay_jpeg(0, b"ab"));
assert_eq!((budget.work_left(), budget.refused()), (0, 1));
}
/// An image pays for the pixels its data holds, not those its size declares: one claiming
/// 131072 by 131072 pixels with a few bytes of Flate costs what those bytes give, and the
/// images after it are decoded as they would be without it.
#[test]
fn an_image_pays_for_no_more_pixels_than_its_data_holds() {
let doc = Document::new_empty();
let sized = |side: i64, data: &[u8]| {
let mut dict = Dict::new();
dict.set(b"Width".to_vec(), Object::Int(side));
dict.set(b"Height".to_vec(), Object::Int(side));
Stream::flate(dict, data)
};
let claims = sized(131_072, &[0; 16]);
let image = sized(64, &[7; 64 * 64]);
let cost = |s: &Stream| {
let mut budget = ImageBudget::with_work(u64::MAX);
budget.decode(&doc, s, 1 << 24).unwrap();
u64::MAX - budget.work_left()
};
assert_eq!(cost(&claims), (claims.raw.len() + 16 + 16 * 8) as u64, "its bytes, and a pixel a bit of them");
assert_eq!(cost(&image), (image.raw.len() + 64 * 64) as u64 + 64 * 64, "its bytes, and its pixels");
for decode in [ImageBudget::decode, ImageBudget::decode_filters] {
let mut budget = ImageBudget::with_work(cost(&claims) + cost(&image));
assert_eq!(decode(&mut budget, &doc, &claims, 1 << 24).unwrap(), [0; 16]);
assert_eq!(decode(&mut budget, &doc, &image, 1 << 24).unwrap(), [7; 64 * 64], "the image after it is decoded");
assert_eq!((budget.work_left(), budget.refused()), (0, 0));
}
}
/// A stream a run rewrites is read strictly and charged, and what the caller does with it after
/// comes out of the same allowance: what can't be paid for spends all of it.
#[test]
fn a_rewritten_stream_shares_the_allowance() {
use pdfcraft_filters::{Filter, FilterError, Params};
let samples = vec![7u8; 1000];
let raw = pdfcraft_filters::encode_flate(&samples);
let chain = [(Filter::Flate, Params::default())];
let cost = (raw.len() + samples.len()) as u64;
let mut budget = ImageBudget::with_work(cost + 10);
assert_eq!(budget.decode_chain(&chain, &raw, 1 << 20).unwrap(), samples);
assert_eq!(budget.work_left(), 10);
assert!(budget.spend(4));
assert_eq!(budget.work_left(), 6);
assert!(budget.spend(6), "exactly what is left");
assert_eq!(budget.work_left(), 0);
assert!(budget.spend(0));
let mut budget = ImageBudget::with_work(5);
assert!(!budget.spend(6));
assert_eq!(budget.work_left(), 0, "spends all of it");
assert!(matches!(budget.decode_chain(&chain, &raw, 1 << 20), Err(FilterError::Spent)));
let cut = raw.get(..raw.len() / 2).unwrap();
assert!(matches!(ImageBudget::with_work(u64::MAX).decode_chain(&chain, cut, 1 << 20), Err(FilterError::Corrupt { .. })), "strictly");
}
/// An image read through its filters alone reads parameters given by reference, is charged, and
/// is refused like the others. It isn't given to a decoder.
#[test]
fn filters_alone_are_charged_too() {
let mut doc = Document::new_empty();
let p = pdfcraft_filters::Params { predictor: 12, columns: 4, ..pdfcraft_filters::Params::default() };
let rows = vec![7u8; 400];
let mut parms = Dict::new();
parms.set(b"Predictor".to_vec(), Object::Int(12));
parms.set(b"Columns".to_vec(), Object::Int(4));
let parms = doc.add(Object::Dict(parms));
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
dict.set(b"DecodeParms".to_vec(), Object::Ref(parms));
let raw = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, &p, &rows).unwrap();
let image = Stream::from_raw(dict, raw);
let mut budget = ImageBudget::with_work(u64::MAX);
assert_eq!(budget.decode_filters(&doc, &image, 1 << 20).unwrap(), rows);
let cost = u64::MAX - budget.work_left();
// The tag byte of each of its 100 rows counts, which the predictor takes off.
assert_eq!(cost, (image.raw.len() + rows.len() + 100) as u64);
let mut budget = ImageBudget::with_work(cost - 1);
assert!(budget.decode_filters(&doc, &image, 1 << 20).is_err());
assert_eq!((budget.work_left(), budget.refused()), (0, 1));
let mut budget = ImageBudget::with_work(u64::MAX);
assert!(budget.decode_filters(&doc, &jbig2(None), 1 << 20).is_err());
assert_eq!(budget.work_left(), u64::MAX, "no decoder here, so nothing was read");
// Nor for a fax image, nor for a decoder that isn't last, however large they say they are.
for chain in [vec!["CCITTFaxDecode"], vec!["CCITTFaxDecode", "FlateDecode"], vec!["JBIG2Decode", "FlateDecode"]] {
let mut dict = Dict::new();
dict.set(b"Width".to_vec(), Object::Int(1000));
dict.set(b"Height".to_vec(), Object::Int(1000));
dict.set(b"Filter".to_vec(), Object::Array(chain.iter().map(|f| Object::name(f)).collect()));
let image = Stream::from_raw(dict, b"data".to_vec());
let mut budget = ImageBudget::with_work(u64::MAX);
assert!(budget.decode_filters(&doc, &image, 1 << 20).is_err());
assert_eq!(budget.work_left(), u64::MAX, "{chain:?}");
}
// Through `decode` a decoder that isn't last pays nothing either; one that is does.
let mut dict = Dict::new();
dict.set(b"Width".to_vec(), Object::Int(1000));
dict.set(b"Height".to_vec(), Object::Int(1000));
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("JBIG2Decode"), Object::name("FlateDecode")]));
let mut budget = ImageBudget::with_work(u64::MAX);
assert!(budget.decode(&doc, &Stream::from_raw(dict, b"data".to_vec()), 1 << 20).is_err());
assert_eq!(budget.work_left(), u64::MAX);
}
/// The images of a file share its globals, so an image refused doesn't read them: this one's
/// can't be read, which is how to tell.
#[test]
fn an_image_past_the_allowance_does_not_read_its_globals() {
let mut doc = Document::new_empty();
let mut unreadable = Dict::new();
unreadable.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
let globals = doc.add(Object::Stream(Stream::from_raw(unreadable, b"data".to_vec())));
let mut parms = Dict::new();
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
let image = jbig2(Some(Object::Dict(parms)));
let read = ImageBudget::with_work(u64::MAX).decode(&doc, &image, 1 << 20).unwrap_err().to_string();
assert!(read.contains("globals"), "{read}");
let refused = ImageBudget::with_work(0).decode(&doc, &image, 1 << 20).unwrap_err().to_string();
assert!(refused.contains("more image decoding than"), "{refused}");
}
/// A longer file gets more, by its length, and an empty one the fixed part.
#[test]
fn a_longer_file_allows_more_decoding() {
assert_eq!(ImageBudget::new(&Document::new_empty()).work_left(), IMAGE_WORK);
let pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF".to_vec();
let len = pdf.len() as u64;
let doc = Document::open(std::sync::Arc::new(pdf)).unwrap();
assert_eq!(ImageBudget::new(&doc).work_left(), IMAGE_WORK + len * IMAGE_WORK_PER_BYTE);
}
/// A document made in memory, as Combine, Create and inserted scans make one, gets for the
/// streams it was given what it would get for them once saved: what isn't a stream adds nothing.
#[test]
fn streams_made_in_memory_allow_more_decoding_too() {
let mut doc = Document::new_empty();
doc.add(Object::Stream(Stream::from_raw(Dict::new(), vec![0; 3000])));
doc.add(Object::Dict(Dict::new()));
doc.add(Object::Stream(Stream::flate(Dict::new(), &[1; 100])));
let flate = pdfcraft_filters::encode_flate(&[1; 100]).len() as u64;
assert_eq!(ImageBudget::new(&doc).work_left(), IMAGE_WORK + (3000 + flate) * IMAGE_WORK_PER_BYTE);
// A stream of an opened file changed in memory counts with the file.
let pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF".to_vec();
let len = pdf.len() as u64;
let mut doc = Document::open(std::sync::Arc::new(pdf)).unwrap();
doc.add(Object::Stream(Stream::from_raw(Dict::new(), vec![0; 500])));
assert_eq!(ImageBudget::new(&doc).work_left(), IMAGE_WORK + (len + 500) * IMAGE_WORK_PER_BYTE);
}
// What the constants' comments promise, for a page scanned at 300 dpi: about 1,000 of them
// whatever the file's size, and a page paying for itself with 2 KB of the file.
const PAGE: u64 = 17_000_000;
const _: () = assert!(IMAGE_WORK >= 1_000 * PAGE);
const _: () = assert!(IMAGE_WORK_PER_BYTE * 2_100 >= PAGE);
// And that a stream can be decoded about 8 times at Flate's best, 1,032 to one, and the byte in.
const _: () = assert!(IMAGE_WORK_PER_BYTE >= 7 * 1_033);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -198,6 +795,21 @@ mod tests {
assert_eq!(budget.remaining(), (10 << 20) - partial);
}
/// A stream that fails its reading as zlib, and then as raw deflate after decoding all of it,
/// gives nothing back, and each reference still pays for what it decoded.
#[test]
fn a_decode_that_gives_nothing_back_still_pays() {
let mut data = vec![0x78, 0x01, 0x06, 0xFE, 0xF9];
data.extend(vec![0u8; 0x0601]);
data.push(0x07);
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let stream = Stream::from_raw(dict, data);
let mut budget = DecodeBudget::new(10 * 0x0601, 1);
assert!(budget.decode(&stream).unwrap().is_empty());
assert_eq!(budget.remaining(), 9 * 0x0601);
}
/// A chain whose later stage shrinks the data pays for every stage's output: a Flate stage
/// that inflates to hex text, which ASCIIHex then halves, charges both.
#[test]
+5 -1
View File
@@ -5,11 +5,12 @@
//! Edits never touch the original bytes; the writer appends them (incremental save) or rewrites
//! the file (full save).
use std::borrow::Cow;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::{Arc, Mutex, Weak};
use crate::CosError;
use crate::object::{Dict, ObjRef, Object};
use crate::object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, ObjRef, Object, Stream};
use crate::parser::{EndstreamIndex, Lexer, is_whitespace, parse_indirect, parse_indirect_shared};
mod objects;
@@ -48,6 +49,9 @@ pub struct Document {
repair_log: Arc<Vec<String>>,
cache: Arc<Mutex<HashMap<u32, Arc<Object>>>>,
objstms: Arc<Mutex<HashMap<u32, Arc<ObjStm>>>>,
/// What `/JBIG2Globals` streams of the file decode to, by object number: the images of a scan
/// share them, and each would otherwise decode them again (see `Document::jbig2_globals`).
jbig2_globals: Arc<Mutex<HashMap<u32, Arc<Vec<u8>>>>>,
stream_limit: usize,
overlay: BTreeMap<u32, Slot>,
next_num: u32,
+257 -1
View File
@@ -1,7 +1,13 @@
use super::reader::find;
use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, objstm_member, owned_bytes};
use super::xref::{Revision, XrefEntry};
use super::{Arc, CosError, Dict, Document, Lexer, ObjRef, Object, is_whitespace, parse_indirect_shared};
use super::{
Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, MAX_JBIG2_GLOBALS, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared,
};
/// The most decoded `/JBIG2Globals` a document keeps for its images to share (see
/// `Document::jbig2_globals`): two streams at [`MAX_JBIG2_GLOBALS`].
const JBIG2_GLOBALS_HELD: usize = 2 * MAX_JBIG2_GLOBALS;
impl Document {
pub fn bytes(&self) -> &Arc<Vec<u8>> {
@@ -158,11 +164,224 @@ impl Document {
}
}
/// `s` with the `/DecodeParms` its filters use made direct, so [`Stream::decoded`] reads them:
/// an indirect dictionary resolved, and of an array (itself perhaps indirect), the entries at
/// the filters' places (`/Crypt` counts, ISO 32000-2 §7.4.1) resolved, the rest dropped. Of
/// each dictionary only the entries the decoders read are kept, numbers and flags given by
/// reference (`/Predictor 7 0 R`) resolved; `/JBIG2Globals` stays a reference. A `/Crypt`
/// entry, which no decoder reads ([`Stream::filters`] leaves it out), is null. `s` itself
/// when none of those is indirect, or when the other filters are more than any decoder takes
/// ([`pdfcraft_filters::MAX_CHAIN`]). Only small values are copied, so a file can't have a
/// large object copied once for each key and filter.
pub fn with_direct_parms<'s>(&self, s: &'s Stream) -> Cow<'s, Stream> {
let Some(p) = s.dict.get(b"DecodeParms") else { return Cow::Borrowed(s) };
let crypt = |f: &Object| matches!(f.as_name(), Some(b"Crypt"));
let filters: Vec<bool> = match s.dict.get(b"Filter") {
Some(Object::Array(a)) => a.iter().map(crypt).collect(),
Some(f) => vec![crypt(f)],
None => Vec::new(),
};
if filters.iter().filter(|c| !**c).count() > pdfcraft_filters::MAX_CHAIN {
return Cow::Borrowed(s);
}
let mut changed = matches!(p, Object::Ref(_));
// Its numbers and flags kept, resolved; anything else is no filter's parameters (null).
let one = |e: &Object, changed: &mut bool| -> Object {
*changed |= matches!(e, Object::Ref(_));
let Object::Dict(d) = &*self.held(e) else { return Object::Null };
let mut made = Dict::new();
for key in DECODE_PARMS_KEYS {
let Some(v) = d.get(key) else { continue };
*changed |= matches!(v, Object::Ref(_));
let v = self.held(v);
if matches!(*v, Object::Int(_) | Object::Real(_) | Object::Bool(_)) {
made.set(key.to_vec(), (*v).clone());
}
}
if let Some(g @ Object::Ref(_)) = d.get(b"JBIG2Globals") {
made.set(b"JBIG2Globals".to_vec(), g.clone());
}
Object::Dict(made)
};
let made = match &*self.held(p) {
Object::Array(a) => {
Object::Array(a.iter().zip(&filters).map(|(e, crypt)| if *crypt { Object::Null } else { one(e, &mut changed) }).collect())
}
_ => one(p, &mut changed),
};
if !changed {
return Cow::Borrowed(s);
}
let mut s = s.clone();
s.dict.set(b"DecodeParms".to_vec(), made);
Cow::Owned(s)
}
/// `o`, or the object it refers to, without copying either.
fn held<'o>(&self, o: &'o Object) -> Held<'o> {
match o {
Object::Ref(_) => Held::Resolved(self.resolve(o)),
o => Held::Direct(o),
}
}
/// Resolve and return a dictionary (a stream's dictionary counts).
pub fn dict(&self, o: &Object) -> Option<Dict> {
self.resolve(o).as_dict().cloned()
}
/// The CCITT parameters of image `s` when its last filter is `CCITTFaxDecode`: its
/// `/DecodeParms` (resolved; the dictionary of a lone filter, or its entry in an array), with the
/// defaults of ISO 32000-2 Table 11 for what that leaves out. `None` for another image, and for
/// one whose `/Filter` isn't a name or an array of names.
pub fn fax_params(&self, s: &Stream) -> Option<pdfcraft_filters::CcittParams> {
let chain = s.readable_filters().ok()?;
if !matches!(chain.last(), Some((pdfcraft_filters::Filter::CcittFax, _))) {
return None;
}
let parms = self.last_filter_parms(s);
let int = |key: &[u8], default: i64| parms.get(key).and_then(|v| self.resolve(v).as_int()).unwrap_or(default);
let flag = |key: &[u8], default: bool| match parms.get(key).map(|v| self.resolve(v)).as_deref() {
Some(Object::Bool(b)) => *b,
_ => default,
};
let d = pdfcraft_filters::CcittParams::default();
Some(pdfcraft_filters::CcittParams {
k: int(b"K", d.k),
columns: int(b"Columns", d.columns),
rows: int(b"Rows", d.rows),
end_of_line: flag(b"EndOfLine", d.end_of_line),
encoded_byte_align: flag(b"EncodedByteAlign", d.encoded_byte_align),
end_of_block: flag(b"EndOfBlock", d.end_of_block),
black_is_1: flag(b"BlackIs1", d.black_is_1),
})
}
/// The `/DecodeParms` dictionary of stream `s`'s last filter, resolved: that of a lone filter,
/// or its entry in an array. Empty when there is none.
fn last_filter_parms(&self, s: &Stream) -> Dict {
// Its place in /Filter, /Crypt included, pairs it with its /DecodeParms entry (§7.4.1): the
// last filter but a /Crypt, which `filters` leaves out wherever it stands.
let index = match s.dict.get(b"Filter") {
Some(Object::Array(a)) => a.iter().rposition(|f| f.as_name() != Some(b"Crypt".as_slice())).unwrap_or(0),
_ => 0,
};
match s.dict.get(b"DecodeParms").map(|p| self.resolve(p)).as_deref() {
Some(Object::Array(a)) => a.get(index).and_then(|p| self.dict(p)),
Some(p) if index == 0 => p.as_dict().cloned(),
_ => None,
}
.unwrap_or_default()
}
/// [`Document::decoded_image_spending`] with no limit on the work, for tests of what an image
/// decodes to: everything else decodes images through an [`ImageBudget`](crate::ImageBudget).
#[cfg(test)]
pub(crate) fn decoded_image(&self, s: &Stream) -> Result<Vec<u8>, CosError> {
self.decoded_image_within(s, MAX_DECODED)
}
/// [`Document::decoded_image`] within `max` bytes.
#[cfg(test)]
pub(crate) fn decoded_image_within(&self, s: &Stream, max: usize) -> Result<Vec<u8>, CosError> {
let mut unbounded = u64::MAX;
self.decoded_image_spending(s, max, &mut unbounded)
}
/// The samples of image `s`, decoded within `max` bytes ([`MAX_DECODED`] at most), spending from
/// `work`: see [`ImageBudget::decode`](crate::ImageBudget::decode), the way in from outside this
/// crate, which says what this decodes and how.
pub(crate) fn decoded_image_spending(&self, s: &Stream, max: usize, work: &mut u64) -> Result<Vec<u8>, CosError> {
let max = max.min(MAX_DECODED);
let s = &*self.with_direct_parms(s);
let filter = |e: pdfcraft_filters::FilterError| CosError::Filter(e.to_string());
let chain = s.readable_filters().map_err(filter)?;
// The filters before the image codec, such as ASCII85Decode, are undone first.
let undo = |before: &[(pdfcraft_filters::Filter, pdfcraft_filters::Params)], work: &mut u64| match before {
[] => Ok(Cow::Borrowed(&s.raw[..])),
_ => pdfcraft_filters::decode_tolerant_within(before, &s.raw, max, work).map(Cow::Owned).map_err(filter),
};
// `/Width` and `/Height` are read as given directly, as every caller reads them first: one
// given by reference has the image refused there, before it gets here.
let height = s.dict.int(b"Height").and_then(|h| u32::try_from(h).ok());
if let Some(((pdfcraft_filters::Filter::Jbig2, _), before)) = chain.split_last() {
let (Some(columns), Some(height)) = (s.dict.int(b"Width").and_then(|w| u32::try_from(w).ok()), height) else {
return Err(CosError::Filter("a JBIG2 image's /Width or /Height isn't a size".into()));
};
// Nothing to read for what can't be decoded: the images of a file share its globals.
let globals = if *work == 0 { None } else { self.jbig2_globals(s, work)? };
let data = if *work == 0 { Cow::Borrowed(&[][..]) } else { undo(before, work)? };
return pdfcraft_filters::decode_jbig2_within(&data, globals.as_deref().map(Vec::as_slice), columns, height, max, work).map_err(filter);
}
let Some(p) = self.fax_params(s) else { return s.decoded_spending(max, work) };
// As many rows as the image has: a /Rows below /Height would cut the picture short, and
// rows past /Height aren't drawn.
let p = pdfcraft_filters::CcittParams { rows: 0, ..p };
if s.dict.int(b"Width") != Some(p.columns) {
return Err(CosError::Filter("a fax image's /Columns aren't its /Width".into()));
}
let stride = usize::try_from(p.columns).unwrap_or(usize::MAX).div_ceil(8).max(1);
let rows = u32::try_from(max / stride).unwrap_or(u32::MAX).min(height.unwrap_or(0));
let data = undo(chain.split_last().map_or(&[][..], |(_, before)| before), work)?;
pdfcraft_filters::decode_ccitt_within(&data, &p, rows, work).map_err(filter)
}
/// The `/JBIG2Globals` stream a JBIG2 image `s` names by reference in its last filter's
/// `/DecodeParms`: `None` for a stream whose last filter isn't `JBIG2Decode`, or one without
/// globals. Redaction finds with it the other images that share a redacted image's globals.
pub fn jbig2_globals_ref(&self, s: &Stream) -> Option<ObjRef> {
let chain = s.readable_filters().ok()?;
if !matches!(chain.last(), Some((pdfcraft_filters::Filter::Jbig2, _))) {
return None;
}
self.last_filter_parms(s).get(b"JBIG2Globals").and_then(Object::as_ref)
}
/// What the `/JBIG2Globals` stream of JBIG2 image `s` (in its last filter's `/DecodeParms`)
/// decodes to, within [`MAX_JBIG2_GLOBALS`]; `None` without one (a reference to no object is
/// one to null, ISO 32000-2 §7.3.10, which is an absent entry, §7.3.7). The image is then read
/// without them, as the renderer draws it, so an image that needed them mostly fails to decode
/// and can decode to other pixels. A stream that is there and can't be read is an error, rather
/// than the image decoded without it.
///
/// The images of a scan share one globals stream, so a stream of the file, not edited since
/// it was read (the same in every clone) and with its `/DecodeParms` direct (by reference they
/// are another object, which can change while this one doesn't), has its filters undone once a
/// document and is kept, up to [`JBIG2_GLOBALS_HELD`] in all; others are read each time. That
/// saves the filters only: the segments it holds are decoded again for every image. Undoing
/// the filters spends from `work`, so globals read for every image pay every time.
fn jbig2_globals(&self, s: &Stream, work: &mut u64) -> Result<Option<Arc<Vec<u8>>>, CosError> {
let Some(entry) = self.last_filter_parms(s).get(b"JBIG2Globals").cloned() else { return Ok(None) };
let held = match entry {
Object::Ref(r) if !self.overlay.contains_key(&r.num) => Some(r.num),
_ => None,
};
if let Some(hit) = held.and_then(|num| self.jbig2_globals.lock().ok()?.get(&num).cloned()) {
return Ok(Some(hit));
}
let Object::Stream(g) = &*self.resolve(&entry) else { return Ok(None) };
let readable = self.with_direct_parms(g);
let held = held.filter(|_| matches!(readable, Cow::Borrowed(_)));
let mut data = readable.decoded_spending(MAX_JBIG2_GLOBALS, work).map_err(|e| {
let detail = match e {
CosError::Filter(detail) => detail,
e => e.to_string(),
};
CosError::Filter(format!("the JBIG2 globals can't be read: {detail}"))
})?;
// What is kept counts by its length, so that's all it may hold on to.
data.shrink_to_fit();
let data = Arc::new(data);
// A poisoned lock only means nothing is kept.
if let (Some(num), Ok(mut kept)) = (held, self.jbig2_globals.lock()) {
let total = kept.values().map(|g| g.len()).fold(data.len(), usize::saturating_add);
if total <= JBIG2_GLOBALS_HELD {
kept.insert(num, data.clone());
}
}
Ok(Some(data))
}
pub fn try_get(&self, num: u32) -> Result<Arc<Object>, CosError> {
match self.overlay.get(&num) {
Some(Slot::Set(_, o)) => return Ok(o.clone()),
@@ -570,3 +789,40 @@ impl Document {
matches!(self.try_get(r.num).as_deref(), Ok(Object::Dict(d)) if d.name(b"Type") == Some(b"Catalog") || d.contains(b"Pages"))
}
}
/// The `/DecodeParms` entries the decoders read (ISO 32000-2 Tables 8, 11 and 13: the Flate and
/// LZW predictors, CCITT, DCT's colour transform): numbers and flags a file may give by
/// reference, which [`Dict::int`] doesn't follow.
const DECODE_PARMS_KEYS: [&[u8]; 13] = [
b"Predictor",
b"Colors",
b"BitsPerComponent",
b"Columns",
b"EarlyChange",
b"K",
b"Rows",
b"EndOfLine",
b"EncodedByteAlign",
b"EndOfBlock",
b"BlackIs1",
b"DamagedRowsBeforeError",
b"ColorTransform",
];
/// An object read in place, or the object a reference names as the document holds it: either way
/// nothing is copied ([`Document::held`]).
enum Held<'o> {
Direct(&'o Object),
Resolved(Arc<Object>),
}
impl std::ops::Deref for Held<'_> {
type Target = Object;
fn deref(&self) -> &Object {
match self {
Held::Direct(o) => o,
Held::Resolved(o) => o,
}
}
}
+4 -3
View File
@@ -1,4 +1,4 @@
use super::reader::{find, generated_id};
use super::reader::find;
use super::state::Credentials;
use super::xref::XrefIndex;
use super::{Arc, BTreeMap, CosError, Dict, Document, ObjRef, Object};
@@ -19,6 +19,7 @@ impl Document {
repair_log: Arc::new(Vec::new()),
cache: Arc::default(),
objstms: Arc::default(),
jbig2_globals: Arc::default(),
stream_limit: crate::object::MAX_DECODED,
overlay: BTreeMap::new(),
next_num: 1,
@@ -102,6 +103,7 @@ impl Document {
repair_log: Arc::new(Vec::new()),
cache: Arc::default(),
objstms: Arc::default(),
jbig2_globals: Arc::default(),
stream_limit: stream_limit.min(crate::object::MAX_DECODED),
overlay: BTreeMap::new(),
next_num: 1,
@@ -271,8 +273,7 @@ impl Document {
let id0 = match self.trailer.get(b"ID") {
Some(Object::Array(a)) if a.len() == 2 => a[0].as_string().map(|s| s.bytes.clone()).unwrap_or_default(),
_ => {
let mut id = generated_id(&params.seed);
id.truncate(16);
let id = pdfcraft_crypt::file_id(&params.seed);
let s = Object::String(crate::PdfString { bytes: id.clone(), hex: true });
self.trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s]));
id
-12
View File
@@ -818,18 +818,6 @@ pub(super) fn rfind(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
(from..=hay.len() - needle.len()).rev().find(|&i| &hay[i..i + needle.len()] == needle)
}
/// A file identifier derived from entropy supplied by the caller.
pub(super) fn generated_id(seed: &[u8; 32]) -> Vec<u8> {
use std::hash::{Hash, Hasher};
let mut out = Vec::new();
for i in 0..2u8 {
let mut h = std::collections::hash_map::DefaultHasher::new();
(seed, i, b"pdfcraft id").hash(&mut h);
out.extend_from_slice(&h.finish().to_be_bytes());
}
out
}
/// The object number a classic xref subsection really starts at. A common producer bug heads the
/// table `1 n` and then writes object 0's free entry (`0000000000 65535 f`) as its first row, so
/// every row would be read one number too high and object 1 as free. Such a subsection is read
+416
View File
@@ -1,5 +1,421 @@
use super::*;
/// /DecodeParms given by reference, whole, as array entries or as the numbers inside, are read
/// once made direct: here a Flate predictor, which the stream alone doesn't see. Only the entries the filters use are
/// resolved, and nothing is for a chain longer than a decoder takes.
#[test]
fn decode_parms_given_by_reference_are_read_once_direct() {
let rows = [1u8, 2, 3, 4, 5, 6, 7, 8];
let params = pdfcraft_filters::Params { predictor: 12, colors: 1, bits_per_component: 8, columns: 4, early_change: 1 };
let coded = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, &params, &rows).unwrap();
let mut parms = Dict::new();
parms.set(b"Predictor".to_vec(), Object::Int(12));
parms.set(b"Columns".to_vec(), Object::Int(4));
let mut doc = Document::new_empty();
let r = doc.add(Object::Dict(parms.clone()));
let array = doc.add(Object::Array(vec![Object::Ref(r)]));
let stream = |filters: usize, p: Object| {
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"); filters]));
d.set(b"DecodeParms".to_vec(), p);
Stream::from_raw(d, coded.clone())
};
for p in [Object::Ref(r), Object::Array(vec![Object::Ref(r)]), Object::Ref(array)] {
let s = stream(1, p.clone());
assert_ne!(s.decoded().unwrap(), rows, "{p:?} read as it is");
assert_eq!(doc.with_direct_parms(&s).decoded().unwrap(), rows, "{p:?}");
}
// A number inside given by reference, which `Dict::int` doesn't follow: in the dictionary
// itself, and in an array's entry.
let twelve = doc.add(Object::Int(12));
let mut inner = parms.clone();
inner.set(b"Predictor".to_vec(), Object::Ref(twelve));
for p in [Object::Dict(inner.clone()), Object::Array(vec![Object::Dict(inner)])] {
let s = stream(1, p.clone());
assert_ne!(s.decoded().unwrap(), rows, "{p:?} read as it is");
assert_eq!(doc.with_direct_parms(&s).decoded().unwrap(), rows, "{p:?}");
}
// Only numbers and flags are copied: a key naming a big array, or an entry that is no
// dictionary, is left out (a file could name one big object from every key and filter).
let big = doc.add(Object::Array(vec![Object::Int(1); 100_000]));
let mut hostile = Dict::new();
hostile.set(b"Predictor".to_vec(), Object::Ref(big));
hostile.set(b"Columns".to_vec(), Object::Ref(big));
hostile.set(b"Colors".to_vec(), Object::Ref(twelve));
let entry = doc.add(Object::Dict(hostile));
let two = stream(2, Object::Array(vec![Object::Ref(entry), Object::Ref(big)]));
let made = doc.with_direct_parms(&two);
let made = made.dict.get(b"DecodeParms").and_then(Object::as_array).unwrap();
let first = made[0].as_dict().unwrap();
assert_eq!((first.contains(b"Predictor"), first.contains(b"Columns"), first.int(b"Colors")), (false, false, Some(12)));
assert!(matches!(made[1], Object::Null), "{:?}", made[1]);
let direct = stream(1, Object::Dict(parms));
assert!(matches!(doc.with_direct_parms(&direct), Cow::Borrowed(_)), "nothing to resolve");
assert_eq!(direct.decoded().unwrap(), rows);
// Entries past the filters' are left out, unresolved; so is everything past the chain limit.
let many = stream(1, Object::Array(vec![Object::Ref(r); 1000]));
let made = doc.with_direct_parms(&many);
assert_eq!(made.dict.get(b"DecodeParms").and_then(Object::as_array).map(Vec::len), Some(1));
let long = stream(pdfcraft_filters::MAX_CHAIN + 1, Object::Array(vec![Object::Ref(r); 9]));
assert!(matches!(doc.with_direct_parms(&long), Cow::Borrowed(_)));
// A chain as long as a decoder takes is resolved, and a `/Crypt` in it doesn't count, as
// the decoders leave it out: ASCIIHex as often as the limit allows, then the predictor's
// Flate, its parameters by reference.
let hex = |b: &[u8]| b.iter().flat_map(|x| format!("{x:02X}").into_bytes()).chain(*b">").collect::<Vec<u8>>();
let wrapped = (1..pdfcraft_filters::MAX_CHAIN).fold(coded.clone(), |data, _| hex(&data));
for crypt in [false, true] {
let mut names = vec![Object::name("ASCIIHexDecode"); pdfcraft_filters::MAX_CHAIN - 1];
names.push(Object::name("FlateDecode"));
if crypt {
names.insert(0, Object::name("Crypt"));
}
let mut entries = vec![Object::Null; names.len() - 1];
entries.push(Object::Ref(r));
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::Array(names));
d.set(b"DecodeParms".to_vec(), Object::Array(entries));
let s = Stream::from_raw(d, wrapped.clone());
assert_eq!(doc.with_direct_parms(&s).decoded().unwrap(), rows, "with /Crypt: {crypt}");
}
}
/// A CCITT fax image reads through its /DecodeParms, given directly, in an array or by
/// reference (its numbers too), with /BlackIs1, and behind other filters; one whose /Columns
/// aren't its /Width is refused, /Height rows are decoded whatever /Rows says, and other images
/// decode through their filters.
#[test]
fn fax_images_decode_with_their_parameters() {
// 10 × 3: the first five samples of each row white, the rest black.
let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0];
let coded = pdfcraft_filters::encode_g4(&bits, 10, 3).unwrap();
let mut doc = Document::new_empty();
let mut parms = Dict::new();
parms.set(b"K".to_vec(), Object::Int(-1));
parms.set(b"Columns".to_vec(), Object::Int(10));
let indirect = doc.add(Object::Dict(parms.clone()));
let image = |doc: &Document, parms: Option<Object>, width: i64, height: i64| {
let mut d = Dict::new();
d.set(b"Width".to_vec(), Object::Int(width));
d.set(b"Height".to_vec(), Object::Int(height));
d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
if let Some(p) = parms {
d.set(b"DecodeParms".to_vec(), p);
}
doc.decoded_image(&Stream::from_raw(d, coded.clone()))
};
for p in [
Object::Dict(parms.clone()),
Object::Array(vec![Object::Dict(parms.clone())]),
Object::Ref(indirect),
Object::Array(vec![Object::Ref(indirect)]),
] {
assert_eq!(image(&doc, Some(p.clone()), 10, 3).unwrap(), bits, "{p:?}");
}
let mut inverted = parms.clone();
inverted.set(b"BlackIs1".to_vec(), Object::Bool(true));
assert_eq!(
image(&doc, Some(Object::Dict(inverted)), 10, 3).unwrap(),
[0b0000_0111, 0b1100_0000, 0b0000_0111, 0b1100_0000, 0b0000_0111, 0b1100_0000]
);
assert_eq!(image(&doc, Some(Object::Dict(parms.clone())), 10, 2).unwrap(), bits[..4], "at most /Height rows");
// Within a bound: no more rows than it holds.
let mut bounded = Dict::new();
bounded.set(b"Width".to_vec(), Object::Int(10));
bounded.set(b"Height".to_vec(), Object::Int(3));
bounded.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
bounded.set(b"DecodeParms".to_vec(), Object::Dict(parms.clone()));
assert_eq!(doc.decoded_image_within(&Stream::from_raw(bounded, coded.clone()), 5).unwrap(), bits[..4], "two rows of two bytes in five");
let mut by_reference = Dict::new();
by_reference.set(b"K".to_vec(), Object::Ref(doc.add(Object::Int(-1))));
by_reference.set(b"Columns".to_vec(), Object::Ref(doc.add(Object::Int(10))));
assert_eq!(image(&doc, Some(Object::Dict(by_reference)), 10, 3).unwrap(), bits, "/K and /Columns by reference");
let mut understated = parms.clone();
understated.set(b"Rows".to_vec(), Object::Int(1));
assert_eq!(image(&doc, Some(Object::Dict(understated.clone())), 10, 3).unwrap(), bits, "/Height rows, whatever /Rows says");
// The parameters as the file gives them, /Rows included, and Table 11's defaults.
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
d.set(b"DecodeParms".to_vec(), Object::Dict(understated));
let p = doc.fax_params(&Stream::from_raw(d.clone(), Vec::new())).unwrap();
assert_eq!((p.k, p.columns, p.rows, p.end_of_block, p.black_is_1), (-1, 10, 1, true, false));
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
assert!(doc.fax_params(&Stream::from_raw(d, Vec::new())).is_none(), "not a fax image");
assert!(matches!(image(&doc, Some(Object::Dict(parms.clone())), 12, 3), Err(CosError::Filter(_))), "/Columns other than /Width");
assert!(matches!(image(&doc, None, 10, 3), Err(CosError::Filter(_))), "the default 1728 /Columns");
let mut plain = Dict::new();
plain.set(b"Filter".to_vec(), Object::name("FlateDecode"));
assert_eq!(doc.decoded_image(&Stream::from_raw(plain, pdfcraft_filters::encode_flate(&bits))).unwrap(), bits);
// Behind other filters, its parameters at its own place in the array (/Crypt counts).
let behind = |filters: &[Object], parms: &[Object], data: Vec<u8>| {
let mut d = Dict::new();
d.set(b"Width".to_vec(), Object::Int(10));
d.set(b"Height".to_vec(), Object::Int(3));
d.set(b"Filter".to_vec(), Object::Array(filters.to_vec()));
d.set(b"DecodeParms".to_vec(), Object::Array(parms.to_vec()));
doc.decoded_image(&Stream::from_raw(d, data))
};
let (fax, null, own) = (Object::name("CCITTFaxDecode"), Object::Null, Object::Dict(parms.clone()));
let hex = coded.iter().map(|b| format!("{b:02x}")).chain([">".to_string()]).collect::<String>().into_bytes();
let flated = pdfcraft_filters::encode_flate(&coded);
let ahx = [Object::name("AHx"), fax.clone()];
assert_eq!(behind(&ahx, &[null.clone(), Object::Ref(indirect)], hex.clone()).unwrap(), bits, "behind ASCIIHexDecode");
assert_eq!(
behind(&[Object::name("FlateDecode"), Object::name("CCF")], &[null.clone(), own.clone()], flated.clone()).unwrap(),
bits,
"behind Flate"
);
assert_eq!(behind(&[Object::name("Crypt"), fax.clone()], &[null.clone(), own.clone()], coded.clone()).unwrap(), bits, "after /Crypt");
// A /Crypt belongs first (§7.4.10); one after the fax filter leaves it its own entry.
assert_eq!(behind(&[fax.clone(), Object::name("Crypt")], &[own.clone(), null.clone()], coded.clone()).unwrap(), bits, "before /Crypt");
assert!(behind(&ahx, &[own.clone(), null.clone()], hex).is_err(), "the first entry is the other filter's");
assert!(behind(&[fax.clone(), Object::name("FlateDecode")], &[own.clone(), null.clone()], flated).is_err(), "CCITT, then Flate");
assert!(behind(&[Object::Int(5), fax], &[null, own], coded.clone()).is_err(), "a /Filter entry that isn't a name");
}
/// Images that share a `/JBIG2Globals` stream of the file decode it once a document: it is kept
/// for the next, in every clone. A globals stream edited since is read afresh (the kept one is the
/// file's), and objects added in memory aren't kept.
#[test]
fn shared_jbig2_globals_are_read_once() {
let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0];
let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap();
let mut doc = Document::new_empty();
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1];
let globals = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(&skipped))));
let image = |doc: &mut Document| {
let mut d = Dict::new();
d.set(b"Width".to_vec(), Object::Int(10));
d.set(b"Height".to_vec(), Object::Int(3));
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
let mut parms = Dict::new();
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
d.set(b"DecodeParms".to_vec(), Object::Dict(parms));
doc.add(Object::Stream(Stream::from_raw(d, coded.clone())))
};
let (a, b) = (image(&mut doc), image(&mut doc));
let stream = |doc: &Document, r: ObjRef| match &*doc.get(r) {
Object::Stream(s) => s.clone(),
_ => panic!("not a stream"),
};
assert_eq!(doc.decoded_image(&stream(&doc, a)).unwrap(), bits);
assert!(doc.jbig2_globals.lock().unwrap().is_empty(), "an object added in memory isn't kept");
// The same objects read from a file, the catalog holding on to the images (a full save may
// number them anew).
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"Images".to_vec(), Object::Array(vec![Object::Ref(a), Object::Ref(b)]))).unwrap();
let file = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap();
let mut doc = Document::open(Arc::new(file)).unwrap();
let images = doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Images").unwrap().as_array().unwrap().clone();
let (a, b) = (images[0].as_ref().unwrap(), images[1].as_ref().unwrap());
let parms = stream(&doc, a).dict.get(b"DecodeParms").unwrap().as_dict().unwrap().clone();
let globals = parms.reference(b"JBIG2Globals").unwrap();
let clone = doc.clone();
assert_eq!(doc.decoded_image(&stream(&doc, a)).unwrap(), bits);
assert_eq!(doc.jbig2_globals.lock().unwrap().len(), 1, "kept");
let kept = doc.jbig2_globals.lock().unwrap()[&globals.num].clone();
assert_eq!(clone.decoded_image(&stream(&clone, b)).unwrap(), bits, "the clone's other image");
let shared = clone.jbig2_globals.lock().unwrap()[&globals.num].clone();
assert!(Arc::ptr_eq(&kept, &shared), "the clone uses the one kept, not a second decode");
doc.set(globals, Object::Stream(Stream::from_raw(Dict::new(), b"not JBIG2".to_vec())));
assert!(doc.decoded_image(&stream(&doc, b)).is_err(), "the edited globals are read afresh");
assert_eq!(clone.decoded_image(&stream(&clone, b)).unwrap(), bits, "the clone keeps the file's");
}
/// A JBIG2 image's size is read as given directly, as every caller reads it first, and an image
/// with neither is an error, not an image of no rows.
#[test]
fn a_jbig2_image_needs_its_size_given_directly() {
let coded = pdfcraft_filters::encode_jbig2_generic(&[0xFF, 0xFF], 8, 2).unwrap();
let mut doc = Document::new_empty();
let size = doc.add(Object::Int(2));
let image = |width: Object, height: Object| {
let mut d = Dict::new();
d.set(b"Width".to_vec(), width);
d.set(b"Height".to_vec(), height);
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
doc.decoded_image(&Stream::from_raw(d, coded.clone()))
};
assert!(image(Object::Int(8), Object::Int(2)).is_ok());
assert!(image(Object::Ref(size), Object::Int(2)).is_err(), "/Width by reference");
assert!(image(Object::Int(8), Object::Ref(size)).is_err(), "/Height by reference");
}
/// A document that kept globals of 64 MiB keeps no more, and the images past that still read:
/// three streams of 22 MiB, shared by three images, are two kept and one decoded each time.
#[test]
fn kept_jbig2_globals_are_bounded_in_total() {
let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0];
let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap();
let length = 22 * 1024 * 1024;
let mut skipped = vec![0, 0, 0, 0, 0x3E, 0, 0];
skipped.extend_from_slice(&u32::try_from(4 + length).unwrap().to_be_bytes());
skipped.extend_from_slice(&[0, 0, 0, 1]);
skipped.resize(skipped.len() + length, 0);
let mut doc = Document::new_empty();
let mut images = Vec::new();
for _ in 0..3 {
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let globals = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(&skipped))));
let mut d = Dict::new();
d.set(b"Width".to_vec(), Object::Int(10));
d.set(b"Height".to_vec(), Object::Int(3));
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
let mut parms = Dict::new();
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
d.set(b"DecodeParms".to_vec(), Object::Dict(parms));
images.push(Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, coded.clone())))));
}
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"Images".to_vec(), Object::Array(images))).unwrap();
let doc = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap();
let images = doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Images").unwrap().as_array().unwrap().clone();
for image in &images {
let Object::Stream(s) = &*doc.get(image.as_ref().unwrap()) else { panic!("not a stream") };
assert_eq!(doc.decoded_image(s).unwrap(), bits, "every image reads");
}
let kept = doc.jbig2_globals.lock().unwrap();
assert_eq!(kept.len(), 2, "two fit in 64 MiB, a third doesn't");
assert!(kept.values().all(|g| g.capacity() == g.len()), "what is kept holds no more than it counts");
}
/// Globals whose `/DecodeParms` are given by reference aren't kept: that's another object, so
/// editing it changes what they decode to while the stream itself, the key kept by, stays the same.
#[test]
fn jbig2_globals_with_parameters_by_reference_are_read_afresh() {
let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0];
let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap();
let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1];
let params = pdfcraft_filters::Params { predictor: 12, colors: 1, bits_per_component: 8, columns: 15, early_change: 1 };
let mut doc = Document::new_empty();
let mut parms = Dict::new();
parms.set(b"Predictor".to_vec(), Object::Int(12));
parms.set(b"Columns".to_vec(), Object::Int(15));
let parms = doc.add(Object::Dict(parms));
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
d.set(b"DecodeParms".to_vec(), Object::Ref(parms));
let predicted = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, &params, &skipped).unwrap();
let globals = doc.add(Object::Stream(Stream::from_raw(d, predicted)));
let mut d = Dict::new();
d.set(b"Width".to_vec(), Object::Int(10));
d.set(b"Height".to_vec(), Object::Int(3));
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
let mut image_parms = Dict::new();
image_parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
d.set(b"DecodeParms".to_vec(), Object::Dict(image_parms));
let image = doc.add(Object::Stream(Stream::from_raw(d, coded)));
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"Image".to_vec(), Object::Ref(image))).unwrap();
let mut doc = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap();
let image = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Image").unwrap();
let stream = |doc: &Document| match &*doc.get(image) {
Object::Stream(s) => s.clone(),
_ => panic!("not a stream"),
};
assert_eq!(doc.decoded_image(&stream(&doc)).unwrap(), bits);
assert!(doc.jbig2_globals.lock().unwrap().is_empty(), "not kept");
let globals = stream(&doc).dict.get(b"DecodeParms").unwrap().as_dict().unwrap().reference(b"JBIG2Globals").unwrap();
let Object::Stream(g) = &*doc.get(globals) else { panic!("not a stream") };
let parms = g.dict.reference(b"DecodeParms").unwrap();
doc.set(parms, Object::Dict(Dict::new()));
assert!(doc.decoded_image(&stream(&doc)).is_err(), "without the predictor they read otherwise");
}
/// A JBIG2 image reads through its decoder, behind other filters too, with the `/JBIG2Globals` of
/// its own `/DecodeParms` (a dictionary or its place in an array, /Crypt counting, by reference
/// too): globals that can't be read are an error, and ones at another filter's place aren't its
/// own, nor are those of a lone dictionary when other filters come first. Its page has to be its
/// /Width × /Height, and fit the bound.
#[test]
fn jbig2_images_decode_with_their_globals() {
// 10 × 3: the first five samples of each row white, the rest black.
let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0];
let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap();
let hex = coded.iter().map(|b| format!("{b:02x}")).chain([">".to_string()]).collect::<String>().into_bytes();
let mut doc = Document::new_empty();
// An extension segment (ISO/IEC 14492 7.4.14), which a decoder skips, Flate-encoded; and data
// that isn't JBIG2.
let globals = |doc: &mut Document, data: &[u8]| {
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let globals = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(data))));
let mut parms = Dict::new();
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
Object::Dict(parms)
};
let (good, bad) = (globals(&mut doc, &[0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]), globals(&mut doc, b"not JBIG2"));
let indirect = Object::Ref(doc.add(bad.clone()));
let image = |filters: &[&str], parms: Option<Object>, data: &[u8], (width, height): (i64, i64), max: usize| {
let mut d = Dict::new();
d.set(b"Width".to_vec(), Object::Int(width));
d.set(b"Height".to_vec(), Object::Int(height));
d.set(b"Filter".to_vec(), Object::Array(filters.iter().map(|f| Object::name(f)).collect()));
if let Some(p) = parms {
d.set(b"DecodeParms".to_vec(), p);
}
doc.decoded_image_within(&Stream::from_raw(d, data.to_vec()), max)
};
let jbig2 = ["JBIG2Decode"];
let full = (10, 3);
assert_eq!(image(&jbig2, None, &coded, full, MAX_DECODED).unwrap(), bits);
assert_eq!(image(&jbig2, Some(good.clone()), &coded, full, MAX_DECODED).unwrap(), bits, "globals it skips");
for parms in [bad.clone(), Object::Array(vec![bad.clone()]), indirect.clone(), Object::Array(vec![indirect])] {
let e = image(&jbig2, Some(parms.clone()), &coded, full, MAX_DECODED).unwrap_err();
assert!(e.to_string().contains("JBIG2"), "{parms:?}: {e}");
}
let behind = ["ASCIIHexDecode", "JBIG2Decode"];
assert_eq!(image(&behind, None, &hex, full, MAX_DECODED).unwrap(), bits, "behind ASCIIHexDecode");
assert!(image(&behind, Some(Object::Array(vec![Object::Null, bad.clone()])), &hex, full, MAX_DECODED).is_err(), "its own globals");
assert_eq!(image(&behind, Some(Object::Array(vec![bad.clone(), Object::Null])), &hex, full, MAX_DECODED).unwrap(), bits, "another's");
assert_eq!(image(&behind, Some(bad.clone()), &hex, full, MAX_DECODED).unwrap(), bits, "a lone dictionary, the first filter's");
let after_crypt = ["Crypt", "JBIG2Decode"];
assert!(image(&after_crypt, Some(Object::Array(vec![Object::Null, bad.clone()])), &coded, full, MAX_DECODED).is_err(), "after /Crypt");
assert_eq!(image(&after_crypt, Some(Object::Array(vec![bad, Object::Null])), &coded, full, MAX_DECODED).unwrap(), bits, "/Crypt's");
for size in [(11, 3), (9, 3), (10, 2), (10, 4)] {
assert!(image(&jbig2, None, &coded, size, MAX_DECODED).is_err(), "a page other than {size:?}");
}
assert_eq!(image(&jbig2, None, &coded, full, 6).unwrap(), bits, "the page within the bound");
assert!(image(&jbig2, None, &coded, full, 5).is_err(), "the page past the bound");
}
#[test]
fn a_missing_file_id_is_derived_from_the_encryption_seed() {
let mut doc = Document::new_empty();
let seed = [9u8; 32];
doc.set_encryption(&pdfcraft_crypt::NewEncryption {
algorithm: pdfcraft_crypt::Algorithm::Aes256,
user_password: "user",
owner_password: "owner",
permissions: -4,
encrypt_metadata: true,
seed,
})
.unwrap();
let id = doc.trailer().get(b"ID").and_then(|o| o.as_array()).and_then(|a| a.first()).and_then(|o| o.as_string()).map(|s| s.bytes.clone());
assert_eq!(id.as_deref(), Some(pdfcraft_crypt::file_id(&seed).as_slice()));
let mut kept = Document::new_empty();
let fixed = Object::String(crate::PdfString { bytes: b"0123456789abcdef".to_vec(), hex: true });
kept.trailer_mut().set(b"ID".to_vec(), Object::Array(vec![fixed.clone(), fixed]));
kept.set_encryption(&pdfcraft_crypt::NewEncryption {
algorithm: pdfcraft_crypt::Algorithm::Aes256,
user_password: "user",
owner_password: "owner",
permissions: -4,
encrypt_metadata: true,
seed,
})
.unwrap();
let still = kept.trailer().get(b"ID").and_then(|o| o.as_array()).and_then(|a| a.first()).and_then(|o| o.as_string()).map(|s| s.bytes.clone());
assert_eq!(still.as_deref(), Some(b"0123456789abcdef".as_slice()));
}
#[test]
fn a_read_state_matches_a_clone_and_no_changed_document() {
let mut doc = Document::new_empty();
+2 -2
View File
@@ -22,10 +22,10 @@ mod parser;
mod security;
mod writer;
pub use budget::{DecodeBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS};
pub use budget::{DecodeBudget, ImageBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS};
pub use bytes::Bytes;
pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry};
pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream};
pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream};
pub use parser::{Lexer, is_whitespace, parse_indirect};
pub use pdfcraft_crypt::{
Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, effective_password, pdfdoc_byte, pdfdoc_char,
+15 -1
View File
@@ -213,6 +213,12 @@ pub struct Stream {
/// Upper bound for a single decoded stream (decompression-bomb defence).
pub const MAX_DECODED: usize = 1 << 30;
/// The most an image's `/JBIG2Globals` stream may decode to: the symbol and pattern dictionaries an
/// encoder shares between pages, themselves coded, so far less than any stream may decode to
/// ([`MAX_DECODED`]). They are decoded before the image's JBIG2 page is read, so the page's size
/// can't bound them: they have a limit of their own.
pub const MAX_JBIG2_GLOBALS: usize = 32 << 20;
impl Stream {
/// A new stream from already-encoded bytes (`/Length` is set at write time).
pub fn from_raw(dict: Dict, raw: Vec<u8>) -> Self {
@@ -261,7 +267,7 @@ impl Stream {
/// [`Stream::filters`], or an error when `/Filter` is present but is not a name or an
/// array of names (an indirect reference, say): the data is not unfiltered, so it must not
/// read as decoded.
fn readable_filters(&self) -> Result<Vec<(Filter, Params)>, pdfcraft_filters::FilterError> {
pub(crate) fn readable_filters(&self) -> Result<Vec<(Filter, Params)>, pdfcraft_filters::FilterError> {
let readable = match self.dict.get(b"Filter") {
None | Some(Object::Null | Object::Name(_)) => true,
Some(Object::Array(a)) => a.iter().all(|f| f.as_name().is_some()),
@@ -300,6 +306,14 @@ impl Stream {
self.decode_chain(max, decode)
}
/// [`Stream::decoded_within`] spending from `work`, what an operation may still do across a
/// document's images ([`pdfcraft_filters::decode_tolerant_within`]).
pub(crate) fn decoded_spending(&self, max: usize, work: &mut u64) -> Result<Vec<u8>, CosError> {
let filter = |e: pdfcraft_filters::FilterError| CosError::Filter(e.to_string());
let chain = self.readable_filters().map_err(filter)?;
pdfcraft_filters::decode_tolerant_within(&chain, &self.raw, max.min(MAX_DECODED), work).map_err(filter)
}
/// The stream decoded through its filters by `decode`, or copied when it has none, within
/// `max` and [`MAX_DECODED`].
fn decode_chain(
+1 -1
View File
@@ -245,7 +245,7 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
if let Some(p) = prev {
trailer.set(b"Prev".to_vec(), Object::Int(p as i64));
}
let size = doc.next_num().max(trailer.int(b"Size").unwrap_or(0) as u32);
let size = doc.next_num().max(trailer.int(b"Size").map_or(0, |n| n.clamp(0, i64::from(u32::MAX)) as u32));
if as_stream {
let rows = offsets.iter().map(|(n, (o, g, used))| (*n, if *used { Row::InFile(*o, *g) } else { Row::Free(*g) })).collect();
write_xref_stream(&mut out, base, &mut trailer, rows, size);
+10
View File
@@ -228,6 +228,16 @@ fn incremental_save_keeps_a_trailer_only_edit() {
assert_qpdf_clean(&saved);
}
#[test]
fn incremental_save_ignores_a_negative_trailer_size() {
let mut doc = Document::open(Arc::new(classic_table())).unwrap();
doc.trailer_mut().set(b"Size".to_vec(), Object::Int(-1));
let next = doc.add(Object::Int(5)).num + 1;
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
let back = Document::open(Arc::new(saved)).unwrap();
assert_eq!(back.trailer().int(b"Size"), Some(i64::from(next)));
}
/// What one edit added and a later one unlinked (a deleted comment, a cleared redaction mark) is
/// not written: the file never held it, and writing it would keep what was removed. Additions the
/// update still refers to, from the trailer or a changed object and through other additions, are.
+6
View File
@@ -10,8 +10,11 @@ workspace = true
[dependencies]
pdfcraft-cos = { workspace = true }
pdfcraft-filters = { workspace = true }
pdfcraft-model = { workspace = true }
pdfcraft-fonts = { workspace = true }
# For `image::bmp_with_pixel_offset`, shared with XFA pictures.
pdfcraft-xfa = { workspace = true }
png = "0.18"
tiff = "0.11"
image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] }
@@ -22,5 +25,8 @@ thiserror = { workspace = true }
encoding_rs = "0.8"
[dev-dependencies]
# An independent CCITT decoder, to check Create PDF's Group 4 images.
hayro-ccitt.workspace = true
image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] }
hayro-syntax = { workspace = true }
pdfcraft-filters = { workspace = true }
+17 -1
View File
@@ -6,7 +6,9 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2:
- `from_images(&[(name, bytes)])`: one page per image, sized from the image's resolution
(PNG `pHYs`, JPEG JFIF density; 72 dpi when absent). JPEG data is embedded as is
(`/DCTDecode`, grey, RGB or Adobe-inverted CMYK); PNG is decoded and stored with Flate,
with transparency as a soft mask. An ICC profile in the file (JPEG `APP2 ICC_PROFILE`, PNG
with transparency as a soft mask. A picture of only pure black and white (a 1-bit PNG or TIFF,
eight-bit grey, a two-colour palette, RGB without a profile) is stored at one bit per pixel, as
CCITT Group 4 or Flate, whichever is smaller as stored (`pdfcraft_filters::encode_g4`). An ICC profile in the file (JPEG `APP2 ICC_PROFILE`, PNG
`iCCP`) that matches the image's colour space is kept as an `/ICCBased` colour space with
the device space as `/Alternate`; images with the same profile share one profile object.
JPEG metadata scanning skips marker fill bytes and reads application segments after the
@@ -25,9 +27,23 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2:
Every function returns a `pdfcraft_cos::Document`; the caller writes it.
Image export and Save Image As apply each lossless image's Decode pair before converting its color components.
JBIG2 images and masks are decoded through `pdfcraft_cos::ImageBudget` (`pdfcraft_filters::decode_jbig2_within`), behind
other filters too, with the `/JBIG2Globals` of their own `/DecodeParms` entry, as `JBIG2Decode` gives them: black as 0. An image
whose JBIG2 page isn't its size (PdfCraft's renderer draws the whole page into the image; the page is refused before it is decoded),
or whose globals can't be read or decode to more than 32 MiB (they are decoded before the page's size is known, so they have a
limit of their own), is skipped, and such a mask is left out and the image exported opaque, as JPX and CCITT masks are.
Past what one export may decode, which its images share, filters, decoders and pixels alike (`ImageBudget`; a JPEG when it has to be
decoded, as a mask, to apply one, or to undo a CMYK file's inversion, and not when it is written as it is), an image is skipped, and so is one whose mask is past it, which would otherwise come out showing what the mask hides. An export also stops at 2 GiB of files held, or twice the document if that
is more, since they stay in memory until it ends.
Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples.
Missing, null or malformed Decode arrays keep the default mapping.
Soft-mask Decode intervals map samples to opacity before the mask is resized, including reversed ranges and indirect entries.
The existing 16-bit path still uses each sample's high byte.
JPEG image Decode handling remains limited.
ICC transforms and calibrated color conversion remain outside this export path.
CCITT fax images (Group 3 and 4) are decoded for export through `pdfcraft_cos::Document::decoded_image` with their
`/DecodeParms` (`/Columns` has to be the image's width; up to `/Height` rows, whatever `/Rows` says), behind other filters such as
`ASCII85Decode` too. Byte-aligned Group 3 rows are read by their end-of-line codes, whether the fill starts each code on a byte or
ends it on one, when the data starts with one (T.4 sends one before the first row), and on bytes otherwise, whatever `/EndOfLine`
says; data that comes up short is reported. Data with codes but none before the first row is read on bytes, and is reported or,
at `/K 0`, may come out wrong. JPEG 2000 images are still reported, not exported.
+124 -29
View File
@@ -1,12 +1,13 @@
//! Export a PDF ▸ Image ▸ Export all images, and Edit ▸ Save image as: the images that pages
//! use, as files. JPEG images are written unchanged; other images, and JPEG images with
//! transparency or with CMYK samples a viewer would read inverted, are decoded and written as PNG with their soft mask, stencil mask or colour
//! key as alpha. Images PdfCraft can't decode yet (JPEG 2000, JBIG2, CCITT, separations) are
//! key as alpha. Images PdfCraft can't decode yet (JPEG 2000, separations) are
//! reported, never silently left out.
use std::collections::HashSet;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use pdfcraft_cos::{Dict, Document, ImageBudget, MAX_DECODED, ObjRef, Object, Stream};
use pdfcraft_filters::FilterError;
/// One image, ready to write.
#[derive(Clone, Debug, PartialEq)]
@@ -31,12 +32,29 @@ pub struct ImageExport {
/// Images larger than this many pixels are skipped (memory).
const MAX_PIXELS: u64 = 100_000_000;
/// What one export may hold of the files it makes: they are all in memory until it ends, and a file
/// of a few KB can make gigabytes of PNG. Images past it are left out, with the reason.
const MAX_EXPORT_BYTES: usize = 2 << 30;
/// What an export of a file of `file_bytes` may hold: twice the file if that is more than
/// [`MAX_EXPORT_BYTES`], so that a file of large images is exported whole.
pub(crate) fn export_bytes(file_bytes: usize) -> usize {
MAX_EXPORT_BYTES.max(file_bytes.saturating_mul(2))
}
/// The images of `pages` (0-based; each image once, on the first page using it), skipping
/// those with fewer than `min_side` pixels on their shorter side.
pub fn extract_images(doc: &Document, pages: &[usize], min_side: u32) -> ImageExport {
extract_images_within(doc, pages, min_side, &mut ImageBudget::new(doc), export_bytes(doc.bytes().len()))
}
/// [`extract_images`] within `budget`, and holding `max_bytes` of files. An image past either is
/// left out, with the reason.
pub(crate) fn extract_images_within(doc: &Document, pages: &[usize], min_side: u32, budget: &mut ImageBudget, max_bytes: usize) -> ImageExport {
let all = pdfcraft_model::pages(doc);
let mut out = ImageExport::default();
let mut seen = HashSet::new();
let mut held = 0usize;
for &p in pages {
let Some(page) = all.get(p) else { continue };
let mut refs = Vec::new();
@@ -53,8 +71,15 @@ pub fn extract_images(doc: &Document, pages: &[usize], min_side: u32) -> ImageEx
if w <= 0 || h <= 0 || (w.min(h) as u64) < min_side as u64 {
continue;
}
match image(doc, s) {
Ok((extension, data)) => out.images.push(ExtractedImage { page: p, object: r, width: w as u32, height: h as u32, extension, data }),
if held >= max_bytes {
out.skipped.push((p, r, "more images than one export can hold".into()));
continue;
}
match image(doc, s, budget) {
Ok((extension, data)) => {
held = held.saturating_add(data.len());
out.images.push(ExtractedImage { page: p, object: r, width: w as u32, height: h as u32, extension, data });
}
Err(why) => out.skipped.push((p, r, why)),
}
}
@@ -64,8 +89,13 @@ pub fn extract_images(doc: &Document, pages: &[usize], min_side: u32) -> ImageEx
/// One image XObject as a file: JPEG as is, else PNG ("Save image as").
pub fn image_file(doc: &Document, image: ObjRef) -> Result<(&'static str, Vec<u8>), String> {
image_file_within(doc, image, &mut ImageBudget::new(doc))
}
/// [`image_file`] within `budget`, for a caller making a file of many images.
pub fn image_file_within(doc: &Document, image: ObjRef, budget: &mut ImageBudget) -> Result<(&'static str, Vec<u8>), String> {
match &*doc.get(image) {
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => self::image(doc, s),
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => self::image(doc, s, budget),
_ => Err("not an image".into()),
}
}
@@ -100,16 +130,24 @@ fn filters(s: &Stream) -> Vec<Vec<u8>> {
}
}
/// The samples of image `s`, decoded ([`ImageBudget::decode`]): through its filters, with a
/// `/DecodeParms` dictionary given by reference, or as an array's entry, or with numbers inside by
/// reference, read too (a predictor given so would otherwise be lost); a CCITT fax or JBIG2 image
/// through its decoder too, behind other filters as well, a JBIG2 image with its `/JBIG2Globals`.
/// A JBIG2 page has to be the image's size, which the caller checked against the export's limit,
/// before the page is decoded.
fn decoded(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result<Vec<u8>, String> {
budget.decode(doc, s, MAX_DECODED).map_err(|e| e.to_string())
}
/// The file for one image: its JPEG data as is, or a PNG. An image with transparency (a soft
/// mask, a stencil `/Mask` or a colour-key `/Mask`) is always a PNG with that transparency as
/// alpha, JPEG images included, so cut-out images keep their transparent background.
fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String> {
fn image(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result<(&'static str, Vec<u8>), String> {
let f = filters(s);
let jpeg = matches!(f.last().map(Vec::as_slice), Some(b"DCTDecode" | b"DCT")) && f.len() == 1;
match f.last().map(Vec::as_slice) {
Some(b"JPXDecode") => return Err("JPEG 2000 images can't be exported yet".into()),
Some(b"JBIG2Decode") => return Err("JBIG2 images can't be exported yet".into()),
Some(b"CCITTFaxDecode" | b"CCF") => return Err("CCITT fax images can't be exported yet".into()),
Some(b"DCTDecode" | b"DCT") if !jpeg => return Err("JPEG images inside other filters can't be exported yet".into()),
_ => {}
}
@@ -124,14 +162,24 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
};
let mask = s.dict.get(b"ImageMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Bool(true)));
// A stencil mask (`ImageMask true`) has no /SMask or /Mask of its own (8.9.6.2).
let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) };
let refused = budget.refused();
let mut alpha = if mask { None } else { soft_mask(doc, s, w, h, budget).or_else(|| stencil_mask(doc, s, w, h, budget)) };
// Without its mask the image would come out opaque, showing what the mask hides.
if budget.refused() > refused {
return Err(FilterError::Spent.to_string());
}
let key = if mask { None } else { colour_key(doc, s) };
// Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images.
let endpoint = |value: Option<&Object>| value.map(|v| doc.resolve(v)).and_then(|v| v.as_f64()).filter(|v| v.is_finite());
let inverted = (jpeg || mask)
&& s.dict
.get(b"Decode")
.map(|d| doc.resolve(d))
.and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64)))
// Both ends have to be finite numbers, as for lossless images, or the default mapping stands.
.and_then(|d| {
let a = d.as_array()?;
Some(endpoint(a.first())? > endpoint(a.get(1))?)
})
.unwrap_or(false);
// In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image
// viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as
@@ -142,13 +190,16 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
}
let mut space = if mask { Space::Gray } else { space(doc, s.dict.get(b"ColorSpace"))? };
let (samples, bpc) = if jpeg {
if !budget.pay_jpeg((w as u64).saturating_mul(h as u64).saturating_mul(space.components() as u64), &s.raw) {
return Err(FilterError::Spent.to_string());
}
// Grey, RGB or CMYK samples (YCCK is converted); a JPEG that doesn't decode, or an
// indexed one (not valid), is written as is, without its transparency.
let Some((samples, decoded)) = jpeg_samples(&s.raw, &space, w, h) else { return as_is() };
space = decoded;
(samples, 8)
} else {
let data = s.decoded().map_err(|e| e.to_string())?;
let data = decoded(doc, s, budget)?;
let bpc = if mask { 1 } else { s.dict.int(b"BitsPerComponent").unwrap_or(8) };
let bpc = match bpc {
1 => 1,
@@ -239,6 +290,32 @@ fn size(w: i64, h: i64) -> Option<(usize, usize)> {
(w > 0 && h > 0 && pixels <= MAX_PIXELS).then_some((w, h))
}
#[cfg(test)]
thread_local! {
/// JPEGs decoded whole, for the tests that keep one from being decoded for nothing.
pub(crate) static JPEGS_DECODED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The size a JPEG's header gives, which is what decoding it goes by, whatever its dictionary
/// says: an image of one pixel can hold a header of 500 megapixels.
fn jpeg_size(data: &[u8]) -> Option<(usize, usize)> {
use zune_jpeg::zune_core::{bytestream::ZCursor, options::DecoderOptions};
let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX));
let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options);
dec.decode_headers().ok()?;
dec.dimensions()
}
/// JPEG `data`, decoded, if its header says it is `w` × `h`.
fn jpeg_of_size(data: &[u8], w: usize, h: usize) -> Option<image::DynamicImage> {
if jpeg_size(data)? != (w, h) {
return None;
}
#[cfg(test)]
JPEGS_DECODED.with(|n| n.set(n.get() + 1));
image::load_from_memory_with_format(data, image::ImageFormat::Jpeg).ok()
}
/// A JPEG's samples (8 bits): grey for a grey image, CMYK as stored for a CMYK one, else RGB,
/// with the space they are in.
fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec<u8>, Space)> {
@@ -247,10 +324,7 @@ fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec<u
Space::Cmyk => return cmyk_jpeg_samples(data, w, h).map(|px| (px, Space::Cmyk)),
_ => {}
}
let img = image::load_from_memory_with_format(data, image::ImageFormat::Jpeg).ok()?;
if img.width() as usize != w || img.height() as usize != h {
return None;
}
let img = jpeg_of_size(data, w, h)?;
Some(match space {
Space::Gray => (img.into_luma8().into_raw(), Space::Gray),
_ => (img.into_rgb8().into_raw(), Space::Rgb),
@@ -403,9 +477,11 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result<Space, String> {
};
let table = match a.get(3).map(|t| doc.resolve(t)).as_deref() {
Some(Object::String(s)) => s.bytes.clone(),
Some(Object::Stream(t)) => {
t.decoded_within(MAX_LOOKUP).map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())?
}
// Through its parameters given by reference too, as the image's samples.
Some(Object::Stream(t)) => doc
.with_direct_parms(t)
.decoded_within(MAX_LOOKUP)
.map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())?,
_ => return Err("an indexed colour space without a lookup table".into()),
};
Ok(Space::Indexed(Box::new(base), hival, table))
@@ -449,34 +525,37 @@ fn cmyk(c: u8, m: u8, y: u8, k: u8) -> [u8; 3] {
}
/// The soft mask (`/SMask`) as 8-bit alpha, scaled to the image's size when it has another.
fn soft_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option<Vec<u8>> {
fn soft_mask(doc: &Document, s: &Stream, w: usize, h: usize, budget: &mut ImageBudget) -> Option<Vec<u8>> {
let r = s.dict.get(b"SMask")?.as_ref()?;
let obj = doc.get(r);
let Object::Stream(m) = &*obj else { return None };
gray_plane(doc, m, w, h)
gray_plane(doc, m, w, h, budget)
}
/// A stencil `/Mask` (an image mask stream, 8.9.6.3) as 8-bit alpha: its 1 samples (0 with
/// `/Decode [1 0]`) hide the image.
fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option<Vec<u8>> {
fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize, budget: &mut ImageBudget) -> Option<Vec<u8>> {
let r = s.dict.get(b"Mask")?.as_ref()?;
let obj = doc.get(r);
let Object::Stream(m) = &*obj else { return None };
Some(gray_plane(doc, m, w, h)?.into_iter().map(|v| 255 - v).collect())
Some(gray_plane(doc, m, w, h, budget)?.into_iter().map(|v| 255 - v).collect())
}
/// A one-component mask image as 8-bit values (its `/Decode` interval applied), scaled to
/// `w` × `h`. `None` when it can't be decoded (JPEG 2000, JBIG2 and CCITT masks).
fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>> {
/// `w` × `h`. `None` when it can't be decoded (JPEG 2000 masks).
fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize, budget: &mut ImageBudget) -> Option<Vec<u8>> {
let (mw, mh) = size(m.dict.int(b"Width")?, m.dict.int(b"Height")?)?;
let stencil = m.dict.get(b"ImageMask").is_some_and(|v| matches!(&*doc.resolve(v), Object::Bool(true)));
let f = filters(m);
let mut px = match f.last().map(Vec::as_slice) {
Some(b"DCTDecode" | b"DCT") if f.len() == 1 => {
let img = image::load_from_memory_with_format(&m.raw, image::ImageFormat::Jpeg).ok()?.into_luma8();
(img.width() as usize == mw && img.height() as usize == mh).then(|| img.into_raw())?
// Past the allowance the image goes too: `image` sees this one refused.
if !budget.pay_jpeg((mw as u64).saturating_mul(mh as u64), &m.raw) {
return None;
}
jpeg_of_size(&m.raw, mw, mh)?.into_luma8().into_raw()
}
Some(b"DCTDecode" | b"DCT" | b"JPXDecode" | b"JBIG2Decode" | b"CCITTFaxDecode" | b"CCF") => return None,
Some(b"DCTDecode" | b"DCT" | b"JPXDecode") => return None,
_ => {
let bpc = if stencil { 1 } else { m.dict.int(b"BitsPerComponent").unwrap_or(8) };
let bpc = match bpc {
@@ -488,7 +567,7 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>>
_ => return None,
};
let max = (1u32 << bpc.min(8)) - 1;
unpack(&m.decoded().ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect()
unpack(&decoded(doc, m, budget).ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect()
}
};
if let Some([lo, hi]) = decode(doc, m, 1).and_then(|pairs| pairs.first().copied()) {
@@ -501,7 +580,23 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>>
return Some(px);
}
let img = image::GrayImage::from_raw(mw as u32, mh as u32, px)?;
Some(image::imageops::resize(&img, w as u32, h as u32, image::imageops::FilterType::Triangle).into_raw())
Some(scaled(&img, w as u32, h as u32).into_raw())
}
/// `img` scaled to `w` × `h`. Scaling goes down first, through a buffer of `img`'s width by `h` at
/// 16 bytes a pixel, which a mask of other proportions than its image can make larger than any
/// machine: 100,000 × 1 on an image of 1 × 100,000 asks for 160 GB, and that aborts the process.
/// Such a mask is scaled across first, which holds no more pixels than one of the two has. Past a
/// quarter over the larger of them, so that rounding leaves a mask of its image's shape as it was.
pub(crate) fn scaled(img: &image::GrayImage, w: u32, h: u32) -> image::GrayImage {
use image::imageops::{FilterType, resize};
let (mw, mh) = (u64::from(img.width()), u64::from(img.height()));
let largest = (mw * mh).max(u64::from(w) * u64::from(h));
if (mw * u64::from(h)).saturating_mul(4) > largest.saturating_mul(5) {
resize(&resize(img, w, img.height(), FilterType::Triangle), w, h, FilterType::Triangle)
} else {
resize(img, w, h, FilterType::Triangle)
}
}
fn png(w: u32, h: u32, rgb: &[u8], alpha: Option<&[u8]>) -> Result<Vec<u8>, String> {
+74 -5
View File
@@ -2,11 +2,11 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream, serialize};
mod extract;
mod upright_image;
pub use extract::{ExtractedImage, ImageExport, extract_images, image_file};
pub use extract::{ExtractedImage, ImageExport, extract_images, image_file, image_file_within};
use pdfcraft_fonts::{literal, win_ansi, wrap};
#[derive(Debug, thiserror::Error, PartialEq)]
@@ -108,9 +108,42 @@ fn jpeg_icc(mut chunks: Vec<(u8, u8, &[u8])>) -> Option<Vec<u8>> {
complete.then(|| chunks.iter().flat_map(|c| c.2.iter().copied()).collect())
}
/// The samples of a black-and-white image packed one bit per pixel (1 for white, each row
/// starting on a byte): eight-bit grey (`n` 1) or RGB (`n` 3) whose every pixel is pure black or
/// pure white, or one-bit grey, already packed (a bilevel TIFF). `None` for anything else, found
/// at its first other pixel.
fn bilevel(data: &[u8], (w, h): (u32, u32), n: usize, bits: Option<i64>) -> Option<Vec<u8>> {
let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?);
let stride = w.div_ceil(8);
let size = stride.checked_mul(h)?;
match (bits, n) {
(Some(1), 1) => data.get(..size).map(<[u8]>::to_vec),
(Some(8), 1 | 3) if w > 0 => {
let samples = data.get(..w.checked_mul(h)?.checked_mul(n)?)?;
let mut packed = vec![0u8; size];
for (i, px) in samples.chunks_exact(n).enumerate() {
let white = match px {
[0] | [0, 0, 0] => false,
[255] | [255, 255, 255] => true,
_ => return None,
};
if white && let Some(b) = packed.get_mut((i / w) * stride + (i % w) / 8) {
*b |= 0x80 >> (i % w % 8);
}
}
Some(packed)
}
_ => None,
}
}
/// Add `img` to `doc` as an image XObject. An ICC profile becomes an `/ICCBased` colour space
/// (with the device space as `/Alternate`); `profiles` shares one object between images that
/// carry the same profile.
///
/// A black-and-white image is stored at one bit per pixel, as CCITT Group 4 when that is smaller
/// than Flate: losslessly, and several times smaller than the eight bits a scan saved as a 1-bit
/// PNG decodes to. RGB keeps its colour space when it has a colour profile.
fn add_image(doc: &mut Document, img: Embedded, profiles: &mut Vec<(Vec<u8>, ObjRef)>) -> ObjRef {
let mut d = img.dict;
d.set(b"Type".to_vec(), Object::name("XObject"));
@@ -118,12 +151,48 @@ fn add_image(doc: &mut Document, img: Embedded, profiles: &mut Vec<(Vec<u8>, Obj
d.set(b"Width".to_vec(), Object::Int(img.px.0 as i64));
d.set(b"Height".to_vec(), Object::Int(img.px.1 as i64));
let device = d.name(b"ColorSpace").map(<[u8]>::to_vec);
let n = match device.as_deref() {
let mut n = match device.as_deref() {
Some(b"DeviceGray") => 1,
Some(b"DeviceRGB") => 3,
Some(b"DeviceCMYK") => 4,
_ => 0,
};
let (mut data, mut filtered) = (img.data, img.filtered);
let profiled_rgb = n == 3 && img.icc.is_some();
if !filtered
&& !profiled_rgb
&& let Some(packed) = bilevel(&data, img.px, n, d.int(b"BitsPerComponent"))
{
if n == 3 {
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
n = 1;
}
d.set(b"BitsPerComponent".to_vec(), Object::Int(1));
// The smaller as stored, dictionary included: Group 4's parameters outweigh its saving on
// a tiny image.
let stored = |d: &Dict, data: &[u8]| {
let mut out = Vec::new();
serialize(&Object::Dict(d.clone()), &mut out);
out.len().saturating_add(data.len())
};
let mut flate_dict = d.clone();
flate_dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let flate = pdfcraft_filters::encode_flate(&packed);
(d, data) = match pdfcraft_filters::encode_g4(&packed, img.px.0, img.px.1) {
Ok(g4) => {
let mut parms = Dict::new();
parms.set(b"K".to_vec(), Object::Int(-1));
parms.set(b"Columns".to_vec(), Object::Int(i64::from(img.px.0)));
parms.set(b"Rows".to_vec(), Object::Int(i64::from(img.px.1)));
let mut g4_dict = d.clone();
g4_dict.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
g4_dict.set(b"DecodeParms".to_vec(), Object::Dict(parms));
if stored(&g4_dict, &g4) < stored(&flate_dict, &flate) { (g4_dict, g4) } else { (flate_dict, flate) }
}
Err(_) => (flate_dict, flate),
};
filtered = true;
}
if let (Some(icc), Some(device)) = (img.icc.and_then(|p| icc_for(p, n)), device) {
let r = match profiles.iter().find(|(p, _)| *p == icc) {
Some((_, r)) => *r,
@@ -142,7 +211,7 @@ fn add_image(doc: &mut Document, img: Embedded, profiles: &mut Vec<(Vec<u8>, Obj
let mr = doc.add(Object::Stream(Stream::flate(m, &alpha)));
d.set(b"SMask".to_vec(), Object::Ref(mr));
}
let stream = if img.filtered { Stream::from_raw(d, img.data) } else { Stream::flate(d, &img.data) };
let stream = if filtered { Stream::from_raw(d, data) } else { Stream::flate(d, &data) };
doc.add(Object::Stream(stream))
}
@@ -562,7 +631,7 @@ fn embed(name: &str, bytes: &[u8], wanted: usize) -> Result<Vec<Embedded>, Creat
} else if is_tiff(bytes) {
tiff_pages(name, bytes, wanted)
} else if bytes.starts_with(b"BM") {
Ok(vec![decoded(name, bytes, image::ImageFormat::Bmp)?])
Ok(vec![decoded(name, &pdfcraft_xfa::image::bmp_with_pixel_offset(bytes), image::ImageFormat::Bmp)?])
} else if is_gif(bytes) {
Ok(vec![decoded(name, bytes, image::ImageFormat::Gif)?])
} else if bytes.starts_with(JP2_SIGNATURE) || bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51]) {
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -634,12 +634,20 @@ fn expand(seed: &[u8; 32], label: &[u8], n: usize) -> Vec<u8> {
h.update(label);
h.update(counter.to_le_bytes());
out.extend_from_slice(&h.finalize());
counter += 1;
counter = counter.saturating_add(1);
}
out.truncate(n);
out
}
/// Sixteen bytes of file identifier from `seed` (SHA-256 in counter mode, label `id`).
///
/// `/ID` is written in the clear. Deriving it the same way as the file key keeps that public
/// value from being an unkeyed hash of the seed.
pub fn file_id(seed: &[u8; 32]) -> Vec<u8> {
expand(seed, b"id", 16)
}
/// Build the `/Encrypt` values and an authenticated (owner) handler for a new encryption.
pub fn create(params: &NewEncryption, id0: &[u8]) -> Result<SecurityHandler, CryptError> {
// Bits 7–8 and 13–32 must be 1; bits 1–2 must be 0 (§7.6.4.2).
+10
View File
@@ -4,6 +4,16 @@ fn hex(s: &str) -> Vec<u8> {
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
}
#[test]
fn file_id_is_a_one_way_16_byte_identifier() {
let seed = [7u8; 32];
let id = file_id(&seed);
assert_eq!(id.len(), 16);
assert_eq!(id, file_id(&seed));
assert_ne!(id, seed[..16]);
assert_ne!(id, file_id(&[8u8; 32]));
}
#[test]
fn rc4_reference_vectors() {
assert_eq!(rc4(b"Key", b"Plaintext"), hex("bbf316e8d940af0ad3"));
+4
View File
@@ -15,6 +15,8 @@ pdfcraft-content = { workspace = true }
pdfcraft-fonts = { workspace = true }
pdfcraft-model = { workspace = true }
thiserror = { workspace = true }
# A backdrop copy's tag records a hash of the operators before the fill it turned off (backdrop.rs).
sha2 = "0.11"
kurbo = { workspace = true }
# Display order of right-to-left lines in added text, and reading and writing order in Edit text;
# already in the tree via the interface.
@@ -22,3 +24,5 @@ unicode-bidi = "0.3"
[dev-dependencies]
hayro-syntax = { workspace = true }
pdfcraft-render = { workspace = true }
proptest = { workspace = true }
+18
View File
@@ -67,5 +67,23 @@ impl Content {
}
}
fn align_code(a: Align) -> i64 {
match a {
Align::Left => 0,
Align::Center => 1,
Align::Right => 2,
Align::Justify => 3,
}
}
fn align_from(code: Option<i64>) -> Align {
match code {
Some(1) => Align::Center,
Some(2) => Align::Right,
Some(3) => Align::Justify,
_ => Align::Left,
}
}
#[cfg(test)]
mod arabic_tests;
+2 -2
View File
@@ -24,7 +24,7 @@ fn cluster(id: u32, advance: f64) -> ShapedCluster {
fn line(clusters: Vec<ShapedCluster>, rtl: bool) -> LaidLine {
let text = clusters.iter().map(|c| c.text.as_str()).collect();
let width = clusters.iter().map(|c| c.advance).sum::<f64>() * item().size;
(text, rtl, vec![Piece::Shaped(clusters)], width)
(text, rtl, 0, vec![Piece::Shaped(clusters)], width)
}
/// A glyph outline: a box on the baseline, `advance` em wide.
@@ -147,7 +147,7 @@ fn an_item_below_the_old_limit_is_drawn_exactly_as_before() {
let space = ShapedCluster { script: ShapedScript::Arabic, glyphs: vec![(6, [0.0, 0.0])], advance: 0.25, text: " ".into() };
let ligature = ShapedCluster { script: ShapedScript::Arabic, glyphs: vec![(4, [0.0, 0.0]), (5, [-0.1, 0.2])], advance: 0.6, text: "لا".into() };
let mixed: LaidLine =
("Hi ب".into(), false, vec![Piece::Latin("Hi ".into()), Piece::Shaped(vec![cluster(1, 0.5), cluster(2, 0.5), space, ligature])], 4.0);
("Hi ب".into(), false, 0, vec![Piece::Latin("Hi ".into()), Piece::Shaped(vec![cluster(1, 0.5), cluster(2, 0.5), space, ligature])], 4.0);
let rtl = line(vec![cluster(2, 0.5), cluster(9, 0.75)], true);
let laid = vec![mixed, rtl];
let mut doc = Document::new_empty();
+38 -36
View File
@@ -1,10 +1,10 @@
use super::layout::{
Piece, arabic_error, hebrew_error, is_arabic, is_hebrew, is_thai, latin_width, lines, norm, shaped_layout, text_rect, thai_error, wrapped,
Piece, arabic_error, hebrew_error, is_arabic, is_hebrew, is_thai, latin_width, norm, shaped_layout, text_rect, thai_error, wrapped,
};
use super::types::{AddedText, Align, Family};
use super::{
Content, Dict, Document, EditError, GlyphError, GlyphOutline, HashMap, HashSet, ObjRef, Object, PdfString, ShapedCluster, ShapedScript, Stream,
literal, matrix_number, n, shaped_glyph, win_ansi,
align_code, literal, matrix_number, n, shaped_glyph, win_ansi,
};
fn font_name(base: &str) -> String {
@@ -16,8 +16,26 @@ fn font_name(base: &str) -> String {
pub(super) const GLYPHS_PER_FONT: usize = 240;
/// A line of a shaped-text item after layout: its text, whether its paragraph runs right to left,
/// its pieces in drawing order and its width at the item's size.
pub(super) type LaidLine = (String, bool, Vec<Piece>, f64);
/// its paragraph, its pieces in drawing order and its width at the item's size.
pub(super) type LaidLine = (String, bool, usize, Vec<Piece>, f64);
/// Where line `i` (`w` wide unstretched, with `spaces` spaces) starts in the box `r`, and its word
/// spacing. `paragraphs` holds each line's paragraph: a line takes its paragraph's alignment, and
/// every line of a justified paragraph but its last is stretched to the box's width.
fn placed(t: &AddedText, r: [f64; 4], paragraphs: &[usize], i: usize, w: f64, spaces: usize, rtl: bool) -> (f64, f64) {
let paragraph = paragraphs.get(i).copied().unwrap_or(0);
let align = t.paragraph_align(paragraph);
let ends_paragraph = paragraphs.get(i + 1) != Some(&paragraph);
let tw = if align == Align::Justify && !ends_paragraph && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
let x = match align {
// A right-to-left paragraph's unstretched last line ends at the right edge.
Align::Justify if rtl => r[2] - w - tw * spaces as f64,
Align::Left | Align::Justify => r[0],
Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
Align::Right => r[2] - w,
};
(x, tw)
}
/// Draw a text item that has Arabic, Hebrew or Thai in it. Each line is laid out in display order; their glyphs
/// come from Type 3 fonts drawn from the craft-fonts face's outlines (no font file is embedded),
@@ -25,7 +43,7 @@ pub(super) type LaidLine = (String, bool, Vec<Piece>, f64);
fn draw_shaped(doc: &mut Document, t: &AddedText, taken: &Dict, fonts: &mut Dict, out: &mut Vec<u8>) -> Result<(), EditError> {
let laid = wrapped(t)
.into_iter()
.map(|(line, rtl)| shaped_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, pieces, w)))
.map(|(line, rtl, paragraph)| shaped_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, paragraph, pieces, w)))
.collect::<Result<Vec<_>, _>>()?;
draw_laid_shaped(doc, t, &laid, taken, fonts, out, shaped_glyph)
}
@@ -47,7 +65,7 @@ pub(super) fn draw_laid_shaped(
// one of the fonts, so copy and search get each character exactly once.
let mut keys: Vec<&ShapedCluster> = Vec::new();
let mut index: HashMap<ClusterKey, usize> = HashMap::new();
for c in laid.iter().flat_map(|(_, _, pieces, _)| pieces).flat_map(|p| match p {
for c in laid.iter().flat_map(|(_, _, _, pieces, _)| pieces).flat_map(|p| match p {
Piece::Shaped(clusters) => clusters.as_slice(),
Piece::Latin(_) => &[],
}) {
@@ -92,21 +110,14 @@ pub(super) fn draw_laid_shaped(
if t.char_spacing != 0.0 {
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
}
for (i, (line, rtl, pieces, w)) in laid.iter().enumerate() {
let paragraphs: Vec<usize> = laid.iter().map(|(_, _, p, ..)| *p).collect();
for (i, (line, rtl, _, pieces, w)) in laid.iter().enumerate() {
// Shaped glyphs are painted in visual order. Keep the original line's typing order
// explicitly, including marks, ligatures, digits and mixed-script punctuation.
let logical: String = line.encode_utf16().map(|u| format!("{u:04X}")).collect();
out.extend(format!("/Span <</ActualText <FEFF{logical}>>> BDC\n").bytes());
let spaces = line.matches(' ').count();
// Justified: the spaces of every line but the last stretch to fill the box.
let tw = if t.align == Align::Justify && i + 1 < laid.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
let x = match t.align {
// A right-to-left paragraph's unstretched last line ends at the right edge.
Align::Justify if *rtl => r[2] - w - tw * spaces as f64,
Align::Left | Align::Justify => r[0],
Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
Align::Right => r[2] - w,
};
let (x, tw) = placed(t, r, &paragraphs, i, *w, spaces, *rtl);
let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size;
let mut pen = x;
let mut current: Option<&str> = None;
@@ -287,20 +298,14 @@ pub(super) fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict
if t.char_spacing != 0.0 {
out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes());
}
let all = lines(t);
for (i, line) in all.iter().enumerate() {
let all = wrapped(t);
let paragraphs: Vec<usize> = all.iter().map(|(_, _, p)| *p).collect();
for (i, (line, ..)) in all.iter().enumerate() {
let w = latin_width(t, line);
let x = match t.align {
Align::Left | Align::Justify => r[0],
Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
Align::Right => r[2] - w,
};
// Baseline: 0.8 em below the line top.
let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size;
// Justified: word spacing makes every line but the last fill the box.
let spaces = line.matches(' ').count();
let tw =
if t.align == Align::Justify && i + 1 < all.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
// Justified: word spacing makes every line of a paragraph but its last fill the box.
let (x, tw) = placed(t, r, &paragraphs, i, w, line.matches(' ').count(), false);
out.extend(format!("1 0 0 1 {} {} Tm {} Tw ", n(x), n(y), n(tw)).bytes());
out.extend(literal(&win_ansi(line)));
out.extend_from_slice(b" Tj\n");
@@ -350,15 +355,12 @@ pub(super) fn params(c: &Content) -> Dict {
d.set(b"Font".to_vec(), Object::name(t.family.base_font(t.bold, t.italic)));
d.set(b"Size".to_vec(), Object::Real(t.size));
d.set(b"Color".to_vec(), arr(&t.color));
d.set(
b"Align".to_vec(),
Object::Int(match t.align {
Align::Left => 0,
Align::Center => 1,
Align::Right => 2,
Align::Justify => 3,
}),
);
d.set(b"Align".to_vec(), Object::Int(align_code(t.align)));
// Each paragraph's own alignment, or null where it follows /Align.
let aligns = crate::paragraph_align::tidy(t.aligns.clone(), t.align);
if !aligns.is_empty() {
d.set(b"Aligns".to_vec(), Object::Array(aligns.iter().map(|a| a.map_or(Object::Null, |a| Object::Int(align_code(a)))).collect()));
}
d.set(b"Rect".to_vec(), arr(&text_rect(t)));
if t.char_spacing != 0.0 {
d.set(b"CharSpacing".to_vec(), Object::Real(t.char_spacing));
+8 -8
View File
@@ -20,7 +20,7 @@ pub(super) fn latin_width(t: &AddedText, s: &str) -> f64 {
/// The lines of a text item after wrapping to its box width.
pub fn lines(t: &AddedText) -> Vec<String> {
wrapped(t).into_iter().map(|(line, _)| line).collect()
wrapped(t).into_iter().map(|(line, ..)| line).collect()
}
#[cfg(test)]
@@ -30,13 +30,13 @@ thread_local! {
}
/// [`lines`], each with whether its paragraph runs right to left (its first strong character
/// does).
pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
/// does) and which paragraph it is in.
pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool, usize)> {
#[cfg(test)]
WRAP_CALLS.with(|c| c.set(c.get().saturating_add(1)));
let width = (t.rect[2] - t.rect[0]).max(t.size);
let mut out = Vec::new();
for para in t.text.split('\n') {
for (index, para) in t.text.split('\n').enumerate() {
// Arabic or Hebrew: laid out with the bidirectional algorithm and shaped.
let rtl_script = para.chars().any(|c| is_arabic(c) || is_hebrew(c));
let thai = para.chars().any(is_thai);
@@ -44,7 +44,7 @@ pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
&& !rtl_script
&& let Ok(lines) = thai_lines(t, para, width)
{
out.extend(lines.into_iter().map(|line| (line, false)));
out.extend(lines.into_iter().map(|line| (line, false, index)));
continue;
}
let rtl = rtl_script && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl;
@@ -60,7 +60,7 @@ pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
let candidate_w =
if rtl_script || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) };
if !line.is_empty() && candidate_w > width {
out.push((std::mem::take(&mut line), rtl));
out.push((std::mem::take(&mut line), rtl, index));
line = word.to_string();
line_w = measure(word);
} else {
@@ -68,7 +68,7 @@ pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
line_w = candidate_w;
}
}
out.push((line, rtl));
out.push((line, rtl, index));
}
out
}
@@ -142,7 +142,7 @@ pub fn first_undrawable(t: &AddedText) -> Option<char> {
return t.text.split('\n').find_map(pdfcraft_fonts::first_non_win_ansi);
}
// What `shaped_layout` leaves to the standard font, exactly as `draw_shaped` splits it.
wrapped(t).into_iter().find_map(|(line, rtl)| {
wrapped(t).into_iter().find_map(|(line, rtl, _)| {
let (pieces, _) = shaped_layout(t, &line, rtl).ok()?;
pieces.iter().find_map(|p| match p {
Piece::Latin(s) => pdfcraft_fonts::first_non_win_ansi(s),
+16 -9
View File
@@ -1,7 +1,7 @@
use super::draw::{draw, params};
use super::layout::{first_undrawable, is_thai, nums, shaped_arabic, thai_error};
use super::types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG};
use super::{Content, Dict, Document, EditError, GlyphError, MarkKind, ObjRef, Object, Stream, check, contents, page_list, place_tagged};
use super::types::{Added, AddedImage, AddedText, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG};
use super::{Content, Dict, Document, EditError, GlyphError, MarkKind, ObjRef, Object, Stream, align_from, check, contents, page_list, place_tagged};
fn parse(doc: &Document, d: &Dict) -> Option<Content> {
let r = nums(doc, d.get(b"Rect"));
@@ -10,20 +10,27 @@ fn parse(doc: &Document, d: &Dict) -> Option<Content> {
b"Text" => {
let (family, bold, italic) = Family::from_base(d.name(b"Font").unwrap_or(b"Helvetica"));
let c = nums(doc, d.get(b"Color"));
let text = d.get(b"Text").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default();
let align = align_from(d.int(b"Align"));
// At most one entry per paragraph, whatever the file says.
let aligns = d
.get(b"Aligns")
.and_then(|a| {
doc.resolve(a)
.as_array()
.map(|a| a.iter().take(crate::paragraph_align::count(&text)).map(|v| v.as_int().map(|c| align_from(Some(c)))).collect())
})
.unwrap_or_default();
Some(Content::Text(AddedText {
rect,
text: d.get(b"Text").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(),
text,
family,
bold,
italic,
size: d.get(b"Size").and_then(Object::as_f64).unwrap_or(12.0),
color: if c.len() == 3 { [c[0], c[1], c[2]] } else { [0.0; 3] },
align: match d.int(b"Align") {
Some(1) => Align::Center,
Some(2) => Align::Right,
Some(3) => Align::Justify,
_ => Align::Left,
},
align,
aligns: crate::paragraph_align::tidy(aligns, align),
// Items written before character spacing have none.
char_spacing: d.get(b"CharSpacing").and_then(Object::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0),
}))
+11
View File
@@ -104,6 +104,16 @@ pub struct AddedText {
pub align: Align,
/// Extra space after every character, in points: the PDF's character spacing (`Tc`).
pub char_spacing: f64,
/// Paragraphs (lines typed with Enter) aligned other than [`AddedText::align`], by index;
/// `None` and paragraphs past the end follow `align` ([`crate::paragraph_align`]).
pub aligns: Vec<Option<Align>>,
}
impl AddedText {
/// Paragraph `i`'s alignment.
pub fn paragraph_align(&self, i: usize) -> Align {
crate::paragraph_align::align_of(&self.aligns, i, self.align)
}
}
impl Default for AddedText {
@@ -117,6 +127,7 @@ impl Default for AddedText {
size: 12.0,
color: [0.0; 3],
align: Align::Left,
aligns: Vec::new(),
char_spacing: 0.0,
}
}
+42 -6
View File
@@ -5,11 +5,27 @@ use super::place::{
END, Isolation, add_page_resources, add_resources, begin, check, check_on_top, contents, free_font_name, isolation, page_fonts, page_list, place,
place_inside_wrapper, stream_tag, text_op, undrawable, wrapper_role,
};
use super::{Document, EditError, Object, added, helvetica_width};
use super::{Document, EditError, Object, added, backdrop, helvetica_width};
/// Commit one complete mark operation only after every selected page succeeds. The snapshot
/// shares original bytes, cross-references, caches and unchanged objects; only the overlay table
/// and document metadata are copied. Dropping a failed stage also drops removed marks, changed
/// page/resources/backdrop dictionaries and new objects, including the allocation cursor.
fn apply_mark_transaction(doc: &mut Document, apply: impl FnOnce(&mut Document) -> Result<(), EditError>) -> Result<(), EditError> {
let mut staged = doc.clone();
apply(&mut staged)?;
*doc = staged;
Ok(())
}
/// Add a header and footer to `pages` (0-based). With `replace`, existing headers and footers on
/// those pages are removed first (Acrobat's Replace Existing).
/// those pages are removed first (Acrobat's Replace Existing). The whole call is atomic:
/// on error, the document (including earlier selected pages) is unchanged.
pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, replace: bool, cx: &Context) -> Result<(), EditError> {
apply_mark_transaction(doc, |staged| add_header_footer_in_place(staged, pages, hf, replace, cx))
}
fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, replace: bool, cx: &Context) -> Result<(), EditError> {
let all = page_list(doc);
check(pages, all.len())?;
if hf.text.iter().all(|t| t.trim().is_empty()) {
@@ -121,8 +137,13 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter,
Ok(())
}
/// Add a text watermark to `pages`.
/// Add a text or picture watermark to `pages`. All selected pages, including mark replacement,
/// form one transaction; on error, the document is unchanged.
pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replace: bool) -> Result<(), EditError> {
apply_mark_transaction(doc, |staged| add_watermark_in_place(staged, pages, wm, replace))
}
fn add_watermark_in_place(doc: &mut Document, pages: &[usize], wm: &Watermark, replace: bool) -> Result<(), EditError> {
let all = page_list(doc);
check(pages, all.len())?;
let text = wm.text.trim();
@@ -199,8 +220,15 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac
Ok(())
}
/// Add a solid-colour background behind the content of `pages`.
/// Add a solid-colour background behind the content of `pages`. With `bg.replace_backdrop`, a
/// white backdrop a page paints itself is replaced first, so the background shows (see
/// [`backdrop`]); a picture backdrop (a scan) still hides it. All selected pages, including
/// replacement and backdrop edits, form one transaction; on error, the document is unchanged.
pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, replace: bool) -> Result<(), EditError> {
apply_mark_transaction(doc, |staged| add_background_in_place(staged, pages, bg, replace))
}
fn add_background_in_place(doc: &mut Document, pages: &[usize], bg: &Background, replace: bool) -> Result<(), EditError> {
let all = page_list(doc);
check(pages, all.len())?;
if replace {
@@ -208,7 +236,10 @@ pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, repl
}
let opacity = if bg.opacity.is_finite() { bg.opacity.clamp(0.0, 1.0) } else { 1.0 };
for &i in pages {
let page = page_list(doc)[i].clone();
let mut page = page_list(doc)[i].clone();
if bg.replace_backdrop && backdrop::replace(doc, &page)? {
page = page_list(doc)[i].clone();
}
let (w, h) = page.display_size(doc);
let mut content = begin(MarkKind::Background, "Background", page.view_matrix(doc));
match &bg.source {
@@ -231,13 +262,18 @@ pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, repl
/// Remove marks of `kind` from `pages`; returns how many were removed. Wrappers left with
/// nothing after them go too, restoring the original content list; a wrapper that content
/// after it still relies on (another tool's appended stream) stays.
/// after it still relies on (another tool's appended stream) stays. Removing backgrounds also
/// turns a backdrop [`add_background`] replaced back on where that is still safe; the pages where
/// it isn't are [`backdrops_left_off`].
pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Result<usize, EditError> {
let all = page_list(doc);
check(pages, all.len())?;
let mut removed = 0;
for &i in pages {
let page = &all[i];
if kind == MarkKind::Background {
backdrop::restore(doc, page)?;
}
let list = contents(doc, page)?;
let before = list.len();
let mut kept: Vec<Object> = list.into_iter().filter(|o| stream_tag(doc, o).as_deref() != Some(kind.tag())).collect();
+584
View File
@@ -0,0 +1,584 @@
//! A page's own backdrop: an opaque white fill (or a picture) covering the whole visible page
//! before anything else is drawn. Many writers paint one (browsers' Save as PDF, print drivers;
//! scans are a page-sized picture), and it hides anything placed behind the content, so a
//! background added behind such a page never shows.
//!
//! A white fill can be replaced: the one fill operator becomes `n` (end the path without
//! painting), which looks the same on white paper. The page then gets a copy of the content
//! stream that held it, tagged `/PCBackdrop << /Index n /Op (f) /Before <SHA-256> /Was [num gen] >>`:
//! how many of the copy's operators come before the `n`, the operator it stands in for, a hash of
//! those operators (their values, not their spacing: redaction writes a stream out again with
//! other whitespace), and the number of the stream it was copied from. The stream itself is left
//! alone (other pages may share it). [`restore`] turns the fill back on when the background is
//! removed, even after the page was edited or redacted, as long as the operators before the fill
//! are unchanged and the fill would again be the first thing painted, covering the page.
//! Otherwise the fill stays off, and [`backdrops_left_off`] names those pages. A picture can't be
//! replaced without losing it, so it is only reported.
//!
//! The tag holds no page content and no reference that keeps another object alive (`/Was` is two
//! numbers, not a reference): a reference to the original stream would keep text in the file
//! that a redaction, Remove Hidden Information or Edit Text removed from the copy.
//!
//! Detection is deliberately strict: only one rectangle (`re`), in DeviceGray, DeviceRGB or
//! DeviceCMYK, opaque, unmasked, normally blended and with the identity transfer function as the
//! graphics state stands after every `gs` so far (each sets only its own keys), with an
//! axis-aligned transform, filled before any other painting, and covering the crop box.
//! Anything else is real content and stays.
use pdfcraft_content::{Matrix, Op, Pieces};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString, Stream};
use sha2::{Digest, Sha256};
use crate::{EditError, MarkKind};
/// The dictionary key of a backdrop copy's tag.
const TAG: &[u8] = b"PCBackdrop";
/// The fill operators a backdrop can be painted with, and so the only ones a tag may restore.
const FILLS: [&[u8]; 3] = [b"f", b"F", b"f*"];
/// How much of a page's content is read looking for a backdrop: it has to come first.
const SCAN_BYTES: usize = 64 * 1024;
/// How many operators are walked before giving up.
const SCAN_OPS: usize = 4096;
/// How far (in points) a backdrop may fall short of the crop box and still count as covering it.
const COVER_EPS: f64 = 1.0;
/// Components at least this close to white (per channel) count as white: a near-white fill
/// (`0.99 g`) is a backdrop too, and replacing it changes the page by at most 2%.
const WHITE: f64 = 0.98;
/// What paints a page's backdrop.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Backdrop {
/// An opaque white fill: replaceable.
Fill,
/// A picture covering the page (a scan): a background behind it stays hidden.
Image,
}
/// Where a replaceable fill is: the content streams (references in `/Contents` order, untagged
/// ones only), their joined bytes, where each starts in them, and the fill operator's joined
/// byte range.
struct Found {
refs: Vec<(usize, ObjRef)>,
pieces: Pieces,
starts: Vec<usize>,
op: std::ops::Range<usize>,
}
impl Found {
/// The piece holding the fill operator, and the operator's offset in it.
fn place(&self) -> Option<(usize, usize)> {
let piece = self.pieces.piece_of(self.op.start);
Some((piece, self.op.start.checked_sub(*self.starts.get(piece)?)?))
}
}
#[derive(Clone)]
struct State {
ctm: Matrix,
/// Clip in default user space, when it is a known rectangle; `None` = unknown shape.
clip: Option<[f64; 4]>,
/// The fill colour is white.
white: bool,
/// Non-stroking colour space components (1, 3, 4), or 0 for a space we don't read.
space: usize,
paint: Paint,
}
/// The graphics-state parameters (ExtGState, ISO 32000-2 §8.4.5) that decide whether a fill
/// paints its colour as given. A `gs` sets only the keys its dictionary has, so each is kept on
/// its own: a later dictionary that leaves one out keeps the earlier value.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Paint {
/// Non-stroking alpha (`ca`) is 1.
full_alpha: bool,
/// No soft mask (`SMask` is `/None`).
no_soft_mask: bool,
/// The blend mode (`BM`) is Normal or Compatible.
normal_blend: bool,
/// The transfer function (`TR`, or `TR2`, which takes precedence) is the identity: a
/// function could turn white into any colour.
identity_transfer: bool,
}
impl Paint {
const INITIAL: Paint = Paint { full_alpha: true, no_soft_mask: true, normal_blend: true, identity_transfer: true };
/// A fill paints its colour exactly, opaque.
fn plain(self) -> bool {
self.full_alpha && self.no_soft_mask && self.normal_blend && self.identity_transfer
}
}
/// The backdrop `page` (0-based) paints, if any. Finding the page walks the page tree, so to
/// check many pages use [`page_backdrops`], which walks it once.
pub fn page_backdrop(doc: &Document, page: usize) -> Result<Option<Backdrop>, EditError> {
let all = pdfcraft_model::pages(doc);
let p = all.get(page).ok_or(EditError::NoSuchPage(page))?;
Ok(scan(doc, p, None).map(|(b, _)| b))
}
/// The backdrop each of `pages` (0-based) paints, in the same order, walking the page tree once.
/// A page past the end has none, and doesn't hide what the others paint: a page count taken
/// elsewhere (the viewer's) can disagree with the page tree's on a malformed document.
pub fn page_backdrops(doc: &Document, pages: &[usize]) -> Vec<Option<Backdrop>> {
let all = pdfcraft_model::pages(doc);
pages.iter().map(|&p| all.get(p).and_then(|page| scan(doc, page, None)).map(|(b, _)| b)).collect()
}
/// Pages (0-based, ascending) whose own white backdrop [`replace`] turned off and [`restore`]
/// couldn't turn back on: a tagged copy is still in their content, with no background over it.
/// They look the same on white paper.
pub fn backdrops_left_off(doc: &Document) -> Vec<usize> {
let tag_of = |o: &Object| crate::place::stream_tag(doc, o);
let copy = |o: &Object| doc.resolve(o).as_dict().is_some_and(|d| d.get(TAG).is_some());
pdfcraft_model::pages(doc)
.iter()
.enumerate()
.filter(|(_, p)| {
let list: Vec<Object> = match p.dict.get(b"Contents").map(|c| doc.resolve(c)).as_deref() {
Some(Object::Array(a)) => a.clone(),
Some(_) => p.dict.get(b"Contents").cloned().into_iter().collect(),
None => Vec::new(),
};
list.iter().any(copy) && !list.iter().any(|o| tag_of(o).as_deref() == Some(MarkKind::Background.tag()))
})
.map(|(i, _)| i)
.collect()
}
/// Replace `page`'s white backdrop fill, if it has one (see the module doc). Returns whether it
/// did; when it did, `page` no longer matches the document. A picture backdrop is left alone.
pub(crate) fn replace(doc: &mut Document, page: &pdfcraft_model::Page) -> Result<bool, EditError> {
let Some((Backdrop::Fill, Some(found))) = scan(doc, page, None) else { return Ok(false) };
let Some((piece, at)) = found.place() else { return Ok(false) };
let op = found.pieces.data().get(found.op.clone()).unwrap_or_default().to_vec();
let edited = found.pieces.splice([(found.op.clone(), b"n".to_vec())]);
let (Some(&(_, original)), Some(bytes)) = (found.refs.get(piece), edited.get(piece)) else { return Ok(false) };
// The `n` among the copy's own operators. A copy whose fill can't be found that way again is
// not made: there would be no safe way back.
let parsed = pdfcraft_content::parse(head(bytes));
let Some(index) = parsed.ops.iter().position(|o| o.span.start == at && o.op == b"n") else { return Ok(false) };
let Some(before) = parsed.ops.get(..index) else { return Ok(false) };
let mut note = Dict::new();
note.set(b"Index".to_vec(), Object::Int(i64::try_from(index).unwrap_or(i64::MAX)));
note.set(b"Op".to_vec(), Object::String(PdfString::literal(op)));
note.set(b"Before".to_vec(), Object::String(PdfString { bytes: ops_hash(before), hex: true }));
note.set(b"Was".to_vec(), Object::Array(vec![Object::Int(original.num.into()), Object::Int(original.generation.into())]));
// The copy keeps the original's dictionary (unknown entries included) less what describes
// the old encoding; `scan` refused streams whose bytes live in an external file (`/F`).
let Object::Stream(source) = &*doc.get(original) else { return Ok(false) };
let mut dict = source.dict.clone();
for key in [&b"Length"[..], b"DL", TAG] {
dict.remove(key);
}
dict.set(TAG.to_vec(), Object::Dict(note));
let copy = doc.add(Object::Stream(Stream::flate(dict, bytes)));
// scan accepts only one execution of this stream on the page. Other pages keep the
// original stream; ambiguous repeated executions are never rewritten.
let mut list = crate::place::contents(doc, page)?;
let mut replaced = false;
for entry in list.iter_mut().filter(|o| matches!(o, Object::Ref(r) if *r == original)) {
*entry = Object::Ref(copy);
replaced = true;
}
if replaced {
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?;
}
Ok(replaced)
}
/// Turn the fills [`replace`] turned off on `page` back on, where [`restored_copy`] finds that
/// safe; a copy listed in several slots is restored in each. Returns how many slots it restored.
/// A copy it can't restore stays as it is, tag and all, so [`backdrops_left_off`] names its page.
pub(crate) fn restore(doc: &mut Document, page: &pdfcraft_model::Page) -> Result<usize, EditError> {
let mut list = crate::place::contents(doc, page)?;
// Per copy: the stream that takes its place, or `None` when it stays.
let mut done: Vec<(ObjRef, Option<ObjRef>)> = Vec::new();
let mut restored = 0;
for entry in list.iter_mut() {
let Object::Ref(copy) = *entry else { continue };
let with = match done.iter().find(|(c, _)| *c == copy) {
Some(&(_, with)) => with,
None => {
let with = restored_copy(doc, page, copy).map(|(bytes, dict, was)| {
// The stream it was copied from, while that is still here with these bytes
// (other pages may share it): the page is then exactly as it was. `/Was` is
// only a number, and a full save renumbers objects, so the bytes decide.
let same = |w: &ObjRef| match &*doc.get(*w) {
Object::Stream(s) => {
[TAG, &b"PCMark"[..], b"Subtype"].iter().all(|k| s.dict.get(k).is_none())
&& DecodeBudget::new(SCAN_BYTES, 0).decode_strict(s).ok().as_deref() == Some(bytes.as_slice())
}
_ => false,
};
match was.filter(same) {
Some(w) => w,
None => doc.add(Object::Stream(Stream::flate(dict, &bytes))),
}
});
done.push((copy, with));
with
}
};
if let Some(with) = with {
*entry = Object::Ref(with);
restored += 1;
}
}
if restored > 0 {
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?;
}
Ok(restored)
}
/// `copy` (a stream [`replace`] wrote for `page`) with its fill turned back on: the bytes, its
/// dictionary without the tag, and the stream it was copied from. `None` when it isn't such a
/// copy, or when turning the fill on could paint over something: the operators before the fill
/// changed since (their hash no longer matches), or the page would no longer paint the fill
/// first, covering it. What comes after the fill (an edit, a redaction) is kept as it is.
fn restored_copy(doc: &Document, page: &pdfcraft_model::Page, copy: ObjRef) -> Option<(Vec<u8>, Dict, Option<ObjRef>)> {
let obj = doc.get(copy);
let Object::Stream(s) = &*obj else { return None };
let note = s.dict.get(TAG).map(|n| doc.resolve(n))?;
let note = note.as_dict()?;
let index = usize::try_from(note.get(b"Index")?.as_int()?).ok()?;
let op = note.get(b"Op")?.as_string()?.bytes.as_slice();
if !FILLS.contains(&op) {
return None;
}
let hash = note.get(b"Before")?.as_string()?;
let bytes = DecodeBudget::new(SCAN_BYTES, 0).decode_strict(s).ok()?;
let parsed = pdfcraft_content::parse(head(&bytes));
// The `n` standing in for the fill, after exactly the operators that came before it.
let fill = parsed.ops.get(index)?;
if fill.op != b"n" || !fill.operands.is_empty() || ops_hash(parsed.ops.get(..index)?) != hash.bytes {
return None;
}
let mut out = Vec::with_capacity(bytes.len().saturating_add(op.len()));
out.extend_from_slice(bytes.get(..fill.span.start)?);
out.extend_from_slice(op);
out.extend_from_slice(bytes.get(fill.span.end..)?);
let (Backdrop::Fill, Some(found)) = scan(doc, page, Some((copy, &out)))? else { return None };
let (piece, offset) = found.place()?;
if found.refs.get(piece).map(|&(_, r)| r) != Some(copy) || offset != fill.span.start || found.op.len() != op.len() {
return None;
}
let mut dict = s.dict.clone();
for key in [&b"Length"[..], b"DL", TAG] {
dict.remove(key);
}
let was = match note.get(b"Was").and_then(Object::as_array).map(Vec::as_slice) {
Some([num, generation]) => Some(ObjRef { num: u32::try_from(num.as_int()?).ok()?, generation: u16::try_from(generation.as_int()?).ok()? }),
_ => None,
};
Some((out, dict, was))
}
/// As much of a content stream as a backdrop could be in: it has to come first.
fn head(bytes: &[u8]) -> &[u8] {
bytes.get(..bytes.len().min(SCAN_BYTES)).unwrap_or_default()
}
/// A SHA-256 of what `ops` are, not how they are written: keywords and operand values, with
/// numbers in one form (`1`, `1.0` and `1.00000` are the same), so a stream written out again
/// with other spacing or number formatting still matches.
fn ops_hash(ops: &[Op]) -> Vec<u8> {
let mut h = Sha256::new();
for op in ops {
for o in &op.operands {
canonical(o, &mut h, 0);
}
if let Some((dict, data)) = &op.inline {
canonical_dict(dict, &mut h, 0);
bytes(b'i', data, &mut h);
}
bytes(b'k', &op.op, &mut h);
}
h.finalize().to_vec()
}
/// How deep [`canonical`] follows nested arrays and dictionaries; deeper ones count as equal.
const CANONICAL_DEPTH: usize = 32;
fn canonical(o: &Object, h: &mut Sha256, depth: usize) {
if depth > CANONICAL_DEPTH {
h.update(b"~");
return;
}
match o {
Object::Null => h.update(b"z"),
Object::Bool(b) => h.update(if *b { b"T" } else { b"F" }),
Object::Int(i) => number(&i.to_string(), h),
Object::Real(r) if *r == 0.0 => number("0", h),
Object::Real(r) => number(&format!("{r:.5}"), h),
Object::String(s) => bytes(b's', &s.bytes, h),
Object::Name(n) => bytes(b'/', n, h),
Object::Array(a) => {
h.update(b"[");
for x in a {
canonical(x, h, depth.saturating_add(1));
}
h.update(b"]");
}
Object::Dict(d) => canonical_dict(d, h, depth),
Object::Stream(_) | Object::Ref(_) => h.update(b"?"),
}
}
fn canonical_dict(d: &Dict, h: &mut Sha256, depth: usize) {
h.update(b"<");
for (k, v) in d.iter() {
bytes(b'/', k, h);
canonical(v, h, depth.saturating_add(1));
}
h.update(b">");
}
/// A number written with up to five decimals, without trailing zeros.
fn number(text: &str, h: &mut Sha256) {
let t = if text.contains('.') { text.trim_end_matches('0').trim_end_matches('.') } else { text };
bytes(b'#', t.as_bytes(), h);
}
/// A typed, length-prefixed value, so no two different sequences hash the same way.
fn bytes(kind: u8, b: &[u8], h: &mut Sha256) {
h.update([kind]);
h.update(u64::try_from(b.len()).unwrap_or(u64::MAX).to_le_bytes());
h.update(b);
}
/// Find the backdrop, and for a fill, where it is. With `swap`, that stream reads as those bytes
/// instead of its own (to check a change before making it).
fn scan(doc: &Document, p: &pdfcraft_model::Page, swap: Option<(ObjRef, &[u8])>) -> Option<(Backdrop, Option<Found>)> {
let crop = p.crop(doc);
let c = p.dict.get(b"Contents")?;
let list: Vec<Object> = match &*doc.resolve(c) {
Object::Array(a) if a.len() <= SCAN_OPS => a.clone(),
Object::Array(_) => return None,
_ => vec![c.clone()],
};
// The page's own content: untagged streams (marks and the q/Q wrapper carry /PCMark). Read
// only as far as a backdrop could be.
let mut refs = Vec::new();
let mut data = Vec::new();
let mut starts = Vec::new();
let mut total = 0usize;
let mut budget = DecodeBudget::new(SCAN_BYTES, 0);
for (slot, o) in list.iter().enumerate() {
if total >= SCAN_BYTES {
break;
}
let Object::Ref(r) = o else { return None };
let obj = doc.get(*r);
let Object::Stream(s) = &*obj else { continue };
if s.dict.get(b"PCMark").is_some() {
continue;
}
// Bytes in an external file (`/F`) replace the stream's own: what it draws is unknown here.
if s.dict.get(b"F").is_some() {
return None;
}
let bytes = match swap {
Some((with, b)) if with == *r => {
if b.len() > SCAN_BYTES.saturating_sub(total) {
return None;
}
budget.decode_strict(&Stream::from_raw(Dict::new(), b.to_vec())).ok()?
}
// Tolerant decoding can return an empty or partial stream after corruption.
// That cannot prove which paint preceded the candidate backdrop.
_ => budget.decode_strict(s).ok()?,
};
if bytes.len() > SCAN_BYTES.saturating_sub(total).saturating_sub(1) {
return None;
}
starts.push(total);
total += bytes.len() + 1;
refs.push((slot, *r));
data.push(bytes);
}
let pieces = Pieces::join(&data);
let parsed = pdfcraft_content::parse(head(pieces.data()));
let res = resources(doc, &p.dict);
let found = walk(doc, &res, crop, &parsed.ops);
// A match proves the graphics state for one execution only. Repeated references may
// paint real artwork under a different color or transform: leave them untouched.
if let Some((Backdrop::Fill, Some(op))) = &found {
let piece = pieces.piece_of(op.start);
let (_, reference) = refs.get(piece)?;
if list.iter().filter(|o| matches!(o, Object::Ref(r) if r == reference)).count() != 1 {
return None;
}
}
found.map(|(b, op)| match (b, op) {
(Backdrop::Fill, Some(op)) => (b, Some(Found { refs, pieces, starts, op })),
_ => (b, None),
})
}
fn resources(doc: &Document, page: &Dict) -> Dict {
page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default()
}
fn sub(doc: &Document, res: &Dict, kind: &[u8], name: &[u8]) -> Option<std::sync::Arc<Object>> {
let d = res.get(kind).map(|d| doc.resolve(d))?;
let o = d.as_dict()?.get(name)?.clone();
Some(doc.resolve(&o))
}
/// Walk the operators until the first one that paints; a backdrop is that one, or nothing.
fn walk(doc: &Document, res: &Dict, crop: [f64; 4], ops: &[Op]) -> Option<(Backdrop, Option<std::ops::Range<usize>>)> {
let mut st = State { ctm: Matrix::IDENTITY, clip: Some([f64::MIN, f64::MIN, f64::MAX, f64::MAX]), white: false, space: 1, paint: Paint::INITIAL };
let mut saved: Vec<State> = Vec::new();
// Current path: its rectangles in user space, or `None` once it is anything else.
let mut path: Option<Vec<[f64; 4]>> = Some(Vec::new());
let mut clip_next = false;
for op in ops.iter().take(SCAN_OPS) {
match op.op.as_slice() {
b"q" => {
if saved.len() >= 64 {
return None;
}
saved.push(st.clone());
}
b"Q" => st = saved.pop()?,
b"cm" => {
let m = Matrix::from_operands(op.operands.get(op.operands.len().checked_sub(6)?..)?)?;
if !m.0.iter().all(|v| v.is_finite()) {
return None;
}
st.ctm = m.then(&st.ctm);
}
b"gs" => apply_state(doc, res, op.name(0)?, &mut st.paint)?,
b"g" => set_colour(&mut st, 1, op),
b"rg" => set_colour(&mut st, 3, op),
b"k" => set_colour(&mut st, 4, op),
b"cs" => {
st.space = match op.name(0)? {
b"DeviceGray" => 1,
b"DeviceRGB" => 3,
b"DeviceCMYK" => 4,
_ => 0,
};
// The initial colour of these spaces is black.
st.white = false;
}
b"sc" | b"scn" => {
let n = st.space;
set_colour(&mut st, n, op)
}
b"re" => {
let [x, y, w, h] = op.nums::<4>()?;
let finite = [x, y, w, h].iter().all(|v| v.is_finite());
match (&mut path, finite && axis_aligned(&st.ctm)) {
(Some(rects), true) => rects.push(st.ctm.bbox([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)])),
_ => path = None,
}
}
b"m" | b"l" | b"c" | b"v" | b"y" | b"h" => path = None,
b"W" | b"W*" => clip_next = true,
b"n" => {
if clip_next {
st.clip = clipped(st.clip, path.as_deref());
}
path = Some(Vec::new());
clip_next = false;
}
b"f" | b"F" | b"f*" => {
// One rectangle: with two, a fill rule can cut a hole where content shows.
let covers = match path.as_deref() {
Some([r]) => st.clip.and_then(|c| intersect(*r, c)).is_some_and(|r| pdfcraft_content::contains(r, crop, COVER_EPS)),
_ => false,
};
return (covers && st.white && st.paint.plain() && !clip_next).then(|| (Backdrop::Fill, Some(op.span.clone())));
}
b"Do" => {
let xo = sub(doc, res, b"XObject", op.name(0)?)?;
let Object::Stream(s) = &*xo else { return None };
// A stencil or masked picture lets what is behind it show.
let masked = [&b"SMask"[..], b"Mask"].iter().any(|k| s.dict.get(k).is_some())
|| s.dict.get(b"ImageMask").is_some_and(|m| matches!(*doc.resolve(m), Object::Bool(true)));
let image = s.dict.name(b"Subtype") == Some(b"Image") && !masked;
return covers_unit_square(&st, crop, image).then_some((Backdrop::Image, None));
}
b"BI" => {
let masked = op
.inline
.as_ref()
.is_some_and(|(d, _)| [&b"IM"[..], b"ImageMask"].iter().any(|k| d.get(k).is_some_and(|m| matches!(m, Object::Bool(true)))));
return covers_unit_square(&st, crop, !masked).then_some((Backdrop::Image, None));
}
// Anything else that paints comes first: no backdrop.
b"S" | b"s" | b"B" | b"B*" | b"b" | b"b*" | b"sh" | b"Tj" | b"TJ" | b"'" | b"\"" | b"EI" | b"d0" | b"d1" => return None,
_ => {}
}
}
None
}
/// A picture placed by the CTM (onto the unit square) covering the crop box, opaque, unclipped.
fn covers_unit_square(st: &State, crop: [f64; 4], image: bool) -> bool {
image
&& st.paint.plain()
&& axis_aligned(&st.ctm)
&& st.clip.and_then(|c| intersect(st.ctm.bbox([0.0, 0.0, 1.0, 1.0]), c)).is_some_and(|r| pdfcraft_content::contains(r, crop, COVER_EPS))
}
fn set_colour(st: &mut State, components: usize, op: &Op) {
st.space = components;
let vals: Vec<f64> = op.operands.iter().filter_map(Object::as_f64).collect();
st.white = vals.len() == components
&& match components {
1 | 3 => vals.iter().all(|v| *v >= WHITE),
4 => vals.iter().all(|v| *v <= 1.0 - WHITE),
_ => false,
};
}
/// Apply the named ExtGState's keys that [`Paint`] tracks; the keys it doesn't have leave
/// `paint` as it was. `None` when the dictionary or one of those values can't be read.
fn apply_state(doc: &Document, res: &Dict, name: &[u8], paint: &mut Paint) -> Option<()> {
let gs = sub(doc, res, b"ExtGState", name)?;
let gs = gs.as_dict()?;
if let Some(alpha) = gs.get(b"ca") {
paint.full_alpha = doc.resolve(alpha).as_f64()? >= 0.999;
}
if let Some(mask) = gs.get(b"SMask") {
paint.no_soft_mask = doc.resolve(mask).as_name() == Some(b"None");
}
if let Some(blend) = gs.get(b"BM") {
paint.normal_blend = matches!(doc.resolve(blend).as_name(), Some(b"Normal" | b"Compatible"));
}
// TR2 takes precedence over TR in the same dictionary (§8.4.5, Table 57). Only the names
// are read: a function (or one per colourant) is taken to change white.
if let Some(transfer) = gs.get(b"TR2") {
paint.identity_transfer = matches!(doc.resolve(transfer).as_name(), Some(b"Identity" | b"Default"));
} else if let Some(transfer) = gs.get(b"TR") {
paint.identity_transfer = doc.resolve(transfer).as_name() == Some(b"Identity");
}
Some(())
}
/// Rotations by multiples of 90° and scales only: rectangles stay rectangles.
fn axis_aligned(m: &Matrix) -> bool {
let [a, b, c, d, ..] = m.0;
(b.abs() < 1e-9 && c.abs() < 1e-9) || (a.abs() < 1e-9 && d.abs() < 1e-9)
}
fn intersect(a: [f64; 4], b: [f64; 4]) -> Option<[f64; 4]> {
let r = [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])];
(r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1]).then_some(r)
}
/// The clip after intersecting with a path: known only when the path is one rectangle.
fn clipped(clip: Option<[f64; 4]>, path: Option<&[[f64; 4]]>) -> Option<[f64; 4]> {
match path {
Some([r]) => intersect(clip?, *r).or(Some([0.0, 0.0, 0.0, 0.0])),
_ => None,
}
}
#[cfg(test)]
#[path = "backdrop_tests.rs"]
mod tests;
+607
View File
@@ -0,0 +1,607 @@
use std::sync::Arc;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full, write_incremental};
use super::*;
use crate::{Background, MarkKind, add_background, remove_marks};
const TEXT: &str = "BT /F1 24 Tf 40 90 Td (Hello) Tj ET";
/// A document from its objects (object 1 is the catalog, 2 the page tree).
fn build(objs: &[String]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
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{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
fn stream(dict: &str, data: &str) -> String {
format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len())
}
/// One 300 × 200 page drawing `pieces` (objects 5…), with a font, a page-sized image `/Im0`
/// (object 4) and two graphics states, `/Half` (ca 0.5) and `/Full` (ca 1).
/// The graphics states [`page`] offers. `INVERT` is a transfer function that maps 1 to 0, so
/// `1 g` through it paints black (#2815 review).
const GRAPHICS_STATES: &str = concat!(
"/Half << /ca 0.5 >> /Full << /ca 1 /CA 1 >> /LineOnly << /LW 2 >> ",
"/Masked << /SMask << /S /Luminosity /G 4 0 R >> >> /Unmasked << /SMask /None >> ",
"/Multiply << /BM /Multiply >> /NormalBlend << /BM /Normal >> ",
"/Invert << /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> ",
"/Invert2 << /TR2 << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> ",
"/IdentityTR << /TR /Identity >> /DefaultTR2 << /TR2 /Default >> ",
"/InvertButTR2Default << /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> /TR2 /Default >> ",
"/IdentityButTR2Invert << /TR /Identity /TR2 << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >>",
);
fn page(pieces: &[&str]) -> Document {
let refs: Vec<String> = (0..pieces.len()).map(|i| format!("{} 0 R", i + 5)).collect();
let mut objs = vec![
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents [{}] /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /Im0 4 0 R >> /ExtGState << {GS} >> >> >>",
refs.join(" "),
GS = GRAPHICS_STATES
),
stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "\u{80}"),
];
objs.extend(pieces.iter().map(|p| stream("", p)));
build(&objs)
}
/// The decoded content of a page, stream by stream.
fn streams(doc: &Document) -> Vec<String> {
let p = &pdfcraft_model::pages(doc)[0];
let list = match p.dict.get(b"Contents").map(|c| doc.resolve(c)).as_deref() {
Some(Object::Array(a)) => a.clone(),
_ => vec![],
};
list.iter()
.map(|o| match &*doc.resolve(o) {
Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(),
_ => String::new(),
})
.collect()
}
fn reopen(doc: &Document) -> Document {
let bytes = write_incremental(doc, &SaveOptions::default()).unwrap();
Document::open(Arc::new(bytes)).unwrap()
}
fn red() -> Background {
Background { color: [1.0, 0.0, 0.0], ..Background::default() }
}
#[test]
fn a_white_fill_backdrop_is_replaced_and_put_back_when_the_background_goes() {
let content = format!("q 1 1 1 rg 0 0 300 200 re f Q {TEXT}");
let mut doc = page(&[&content]);
assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill));
let original = streams(&doc);
add_background(&mut doc, &[0], &red(), false).unwrap();
let s = streams(&reopen(&doc));
assert_eq!(s.len(), 2, "{s:?}");
assert!(s[0].contains("/PCMark /Background"));
assert_eq!(s[1], format!("q 1 1 1 rg 0 0 300 200 re n Q {TEXT}"), "only the fill operator changed");
assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "nothing hides the background now");
// The original stream object is untouched; the page points at a copy.
let Object::Stream(orig) = &*doc.get(ObjRef { num: 5, generation: 0 }) else { panic!("object 5 is the content") };
assert_eq!(String::from_utf8_lossy(&orig.decoded().unwrap()), content);
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(), 1);
assert_eq!(streams(&reopen(&doc)), original, "exactly the original content");
assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill));
}
#[test]
fn updating_the_background_replaces_the_backdrop_once_and_removing_still_restores() {
let mut doc = page(&[&format!("1 1 1 rg 0 0 300 200 re f 0 g {TEXT}")]);
let original = streams(&doc);
add_background(&mut doc, &[0], &red(), false).unwrap();
add_background(&mut doc, &[0], &Background { color: [0.0, 0.0, 1.0], ..Background::default() }, true).unwrap();
let s = streams(&doc);
assert_eq!(s.len(), 2, "one background, one copy: {s:?}");
assert!(s[1].contains("re n"));
remove_marks(&mut doc, &[0], MarkKind::Background).unwrap();
assert_eq!(streams(&doc), original);
}
#[test]
fn a_backdrop_split_across_content_streams_is_replaced_in_the_piece_holding_the_operator() {
let mut doc = page(&["1 1 1 rg 0 0 300 200 re", &format!("f* {TEXT}")]);
let original = streams(&doc);
assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill));
add_background(&mut doc, &[0], &red(), false).unwrap();
let s = streams(&doc);
assert_eq!(s[1..], ["1 1 1 rg 0 0 300 200 re".to_string(), format!("n {TEXT}")], "{s:?}");
remove_marks(&mut doc, &[0], MarkKind::Background).unwrap();
assert_eq!(streams(&doc), original);
}
#[test]
fn backdrops_writers_paint_are_recognised() {
for content in [
// Browsers: a flipped, scaled coordinate system and a page clip first.
"q 0.5 0 0 -0.5 0 200 cm 0 0 600 400 re W n 1 1 1 rg 0 0 600 400 re f Q",
// Larger than the page, in CMYK, through an opaque graphics state.
"/Full gs 0 0 0 0 k -10 -10 320 220 re f",
"/DeviceRGB cs 1 1 1 sc 0 0 300 200 re F",
"0.99 g 0 0 300 200 re f",
// Turned a quarter: still a rectangle.
"0 1 -1 0 300 0 cm 1 g 0 0 200 300 re f",
] {
let doc = page(&[&format!("{content} 0 g {TEXT}")]);
assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill), "{content}");
}
}
#[test]
fn content_that_is_not_a_page_backdrop_is_left_alone() {
for content in [
// Not the whole page.
"1 g 0 0 300 150 re f",
"q 0 0 100 100 re W n 1 g 0 0 300 200 re f Q",
// Not white, or not opaque.
"0.5 g 0 0 300 200 re f",
"1 0 0 rg 0 0 300 200 re f",
"0 0 0 0.5 k 0 0 300 200 re f",
"/Half gs 1 g 0 0 300 200 re f",
"/Missing gs 1 g 0 0 300 200 re f",
"/Pattern cs /P0 scn 0 0 300 200 re f",
// The initial colour is black.
"0 0 300 200 re f",
// Something else is painted first.
"BT /F1 9 Tf (x) Tj ET 1 g 0 0 300 200 re f",
"0 g 0 0 1 1 re f 1 g 0 0 300 200 re f",
"1 g 0 0 300 200 re S 1 g 0 0 300 200 re f",
// Not a single rectangle: a hole, a turned square, a fill-and-stroke.
"1 g 0 0 300 200 re 50 50 10 10 re f*",
"1 g 0 0 m 300 0 l 300 200 l 0 200 l h f",
"0.7 0.7 -0.7 0.7 150 -100 cm 1 g 0 0 400 400 re f",
"1 g 0 0 300 200 re B",
// Malformed.
"Q 1 g 0 0 300 200 re f",
"1 g 0 0 300 re f",
"1 g 0 0 1e400 200 re f",
] {
let mut doc = page(&[&format!("{content} {TEXT}")]);
assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "{content}");
let before = streams(&doc);
add_background(&mut doc, &[0], &red(), false).unwrap();
assert_eq!(streams(&doc)[1..], before[..], "{content}: the page's content is unchanged");
}
}
#[test]
fn a_scanned_page_is_reported_and_its_picture_kept() {
let mut doc = page(&[&format!("q 300 0 0 200 0 0 cm /Im0 Do Q {TEXT}")]);
assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Image));
let before = streams(&doc);
add_background(&mut doc, &[0], &red(), false).unwrap();
assert_eq!(streams(&doc)[1..], before[..]);
// A small picture is content, not a backdrop.
let doc = page(&["q 30 0 0 20 0 0 cm /Im0 Do Q"]);
assert_eq!(page_backdrop(&doc, 0).unwrap(), None);
}
#[test]
fn replacing_can_be_turned_off() {
let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]);
let before = streams(&doc);
add_background(&mut doc, &[0], &Background { replace_backdrop: false, ..red() }, false).unwrap();
assert_eq!(streams(&doc)[1..], before[..]);
}
/// Rewrite the page's backdrop copy (the stream after the background) as another tool would:
/// Edit text and redaction keep its dictionary and change its bytes.
fn rewrite_copy(doc: &mut Document, bytes: &str) -> ObjRef {
let p = &pdfcraft_model::pages(doc)[0];
let Some(Object::Array(list)) = p.dict.get(b"Contents").cloned() else { panic!("contents") };
let Object::Ref(copy) = list[1] else { panic!("a reference") };
let Object::Stream(s) = (*doc.get(copy)).clone() else { panic!("a stream") };
assert!(s.dict.get(b"PCBackdrop").is_some(), "the copy");
doc.set(copy, Object::Stream(Stream::flate(s.dict.clone(), bytes.as_bytes())));
copy
}
#[test]
fn an_edit_after_the_fill_is_kept_and_the_backdrop_still_comes_back() {
let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]);
add_background(&mut doc, &[0], &red(), false).unwrap();
rewrite_copy(&mut doc, "1 g 0 0 300 200 re n BT /F1 24 Tf 40 90 Td (Edited) Tj ET");
remove_marks(&mut doc, &[0], MarkKind::Background).unwrap();
assert_eq!(streams(&doc), ["1 g 0 0 300 200 re f BT /F1 24 Tf 40 90 Td (Edited) Tj ET"], "the edit survives, under the fill");
assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill));
assert_eq!(backdrops_left_off(&doc), Vec::<usize>::new());
}
#[test]
fn a_change_before_the_fill_leaves_it_off_and_names_the_page() {
for edited in [
// A different colour before the fill: the hash of what comes before it no longer matches.
"0.99 g 0 0 300 200 re n BT /F1 24 Tf 40 90 Td (Edited) Tj ET",
// Something painted before it.
"BT /F1 9 Tf (x) Tj ET 1 g 0 0 300 200 re n",
// The fill is gone.
"BT /F1 24 Tf 40 90 Td (Edited) Tj ET",
] {
let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]);
add_background(&mut doc, &[0], &red(), false).unwrap();
assert_eq!(backdrops_left_off(&doc), Vec::<usize>::new(), "a background is over it");
rewrite_copy(&mut doc, edited);
remove_marks(&mut doc, &[0], MarkKind::Background).unwrap();
assert_eq!(streams(&doc), [edited.to_string()], "{edited}: the copy stays as it is");
assert_eq!(backdrops_left_off(&doc), [0], "{edited}");
}
}
#[test]
fn a_fill_that_would_cover_content_painted_before_it_stays_off() {
let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]);
add_background(&mut doc, &[0], &red(), false).unwrap();
// A tool puts a stream of its own before the copy, untagged: turning the fill back on would
// now paint white over that text.
let over = doc.add(Object::Stream(Stream::flate(pdfcraft_cos::Dict::new(), b"BT /F1 9 Tf 10 10 Td (Over) Tj ET")));
let p = pdfcraft_model::pages(&doc)[0].clone();
let Some(Object::Array(mut list)) = p.dict.get(b"Contents").cloned() else { panic!("contents") };
list.insert(1, Object::Ref(over));
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list))).unwrap();
remove_marks(&mut doc, &[0], MarkKind::Background).unwrap();
let s = streams(&doc);
assert_eq!(s.len(), 2, "{s:?}");
assert!(s[1].contains("re n") && !s[1].contains("re f"), "nothing white goes over the text: {s:?}");
assert_eq!(backdrops_left_off(&doc), [0]);
}
#[test]
fn the_tag_holds_no_reference_and_a_full_save_keeps_no_second_copy_of_the_page() {
let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]);
add_background(&mut doc, &[0], &red(), false).unwrap();
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
// Every stream in the file that holds the page's text: only the copy the page draws. A
// reference from the tag to the original stream kept a second, which a redaction or Remove
// Hidden Information on the copy left in the file.
let holding: Vec<(ObjRef, Dict)> = saved
.scan_objects()
.filter_map(|(r, o)| match &*o {
Object::Stream(s) if String::from_utf8_lossy(&s.decoded().unwrap_or_default()).contains("(Hello)") => Some((r, s.dict.clone())),
_ => None,
})
.collect();
assert_eq!(holding.len(), 1, "{holding:?}");
let Some(Object::Dict(note)) = holding[0].1.get(b"PCBackdrop").map(|n| (*saved.resolve(n)).clone()) else { panic!("the tag") };
fn refs(o: &Object) -> bool {
match o {
Object::Ref(_) => true,
Object::Array(a) => a.iter().any(refs),
Object::Dict(d) => d.iter().any(|(_, v)| refs(v)),
_ => false,
}
}
assert!(!note.iter().any(|(_, v)| refs(v)), "{note:?}");
assert_eq!(note.get(b"Op").and_then(Object::as_string).map(|s| s.bytes.clone()), Some(b"f".to_vec()));
assert_eq!(note.get(b"Before").and_then(Object::as_string).map(|s| s.bytes.len()), Some(32), "a SHA-256");
// The original is gone, so Remove writes the fill back into a stream of its own.
let mut saved = saved;
remove_marks(&mut saved, &[0], MarkKind::Background).unwrap();
assert_eq!(streams(&saved), [format!("1 g 0 0 300 200 re f {TEXT}")]);
assert_eq!(page_backdrop(&saved, 0).unwrap(), Some(Backdrop::Fill));
}
#[test]
fn a_copy_written_out_again_with_other_spacing_still_comes_back() {
// Redaction writes the whole stream out again: one operator a line, numbers its own way.
let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]);
add_background(&mut doc, &[0], &red(), false).unwrap();
rewrite_copy(&mut doc, "1.0 g\n0 0 300.000 200 re\nn\nBT\n/F1 24 Tf\n40 90 Td\n[-6505] TJ\nET");
remove_marks(&mut doc, &[0], MarkKind::Background).unwrap();
assert_eq!(streams(&doc), ["1.0 g\n0 0 300.000 200 re\nf\nBT\n/F1 24 Tf\n40 90 Td\n[-6505] TJ\nET"], "the fill is back, the rest as written");
assert_eq!(backdrops_left_off(&doc), Vec::<usize>::new());
}
#[test]
fn a_tag_that_does_not_add_up_is_left_alone() {
let content = format!("1 g 0 0 300 200 re f {TEXT}");
let copied = format!("1 g 0 0 300 200 re n {TEXT}");
let good = ops_hash(&pdfcraft_content::parse(b"1 g 0 0 300 200 re").ops);
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
for tag in [
// Not a fill operator, a wrong hash, an index that isn't the `n` or runs past the end.
format!("<< /Index 2 /Op (B) /Before <{}> >>", hex(&good)),
format!("<< /Index 2 /Op (f) /Before <{}> >>", hex(&[0; 32])),
format!("<< /Index 1 /Op (f) /Before <{}> >>", hex(&good)),
format!("<< /Index 99999 /Op (f) /Before <{}> >>", hex(&good)),
format!("<< /Index -1 /Op (f) /Before <{}> >>", hex(&good)),
// Missing entries, wrong types, and the reference earlier test builds wrote.
"<< /Index 2 /Op (f) >>".to_string(),
"<< /Index (2) /Op /f /Before 3 >>".to_string(),
"<< /Original 5 0 R /At 19 /Len 1 >>".to_string(),
"(not a dictionary)".to_string(),
] {
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents [4 0 R] >>".into(),
stream(&format!("/PCBackdrop {tag}"), &copied),
stream("", &content),
]);
remove_marks(&mut doc, &[0], MarkKind::Background).unwrap();
assert_eq!(streams(&doc), std::slice::from_ref(&copied), "{tag}");
assert_eq!(backdrops_left_off(&doc), [0], "{tag}");
}
// The same page with an honest tag comes back.
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents [4 0 R] >>".into(),
stream(&format!("/PCBackdrop << /Index 2 /Op (f) /Before <{}> >>", hex(&good)), &copied),
]);
remove_marks(&mut doc, &[0], MarkKind::Background).unwrap();
assert_eq!(streams(&doc), [content]);
}
#[test]
fn operators_hash_by_value_not_by_spelling() {
let h = |s: &[u8]| ops_hash(&pdfcraft_content::parse(s).ops);
assert_eq!(h(b"1 g 0 0 300 200 re"), h(b"1.0 g\n0 0 300.00000 200 re"));
assert_eq!(h(b"0 g"), h(b"-0.0 g"));
assert_ne!(h(b"1 g 0 0 300 200 re"), h(b"0.99 g 0 0 300 200 re"));
assert_ne!(h(b"(ab) Tj"), h(b"(a) (b) Tj"), "operand boundaries count");
assert_ne!(h(b"/a /b BDC"), h(b"/ab BDC"));
assert_ne!(h(b""), h(b"q"));
// Nesting is bounded: deep arrays hash without running out of stack.
let deep = format!("{}{} Tj", "[".repeat(1000), "]".repeat(1000));
let _ = h(deep.as_bytes());
}
#[test]
fn a_content_stream_shared_by_two_pages_is_copied_for_the_one_page() {
let content = format!("1 g 0 0 300 200 re f {TEXT}");
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 200] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(),
stream("", &content),
]);
add_background(&mut doc, &[0], &red(), false).unwrap();
assert_eq!(page_backdrop(&doc, 0).unwrap(), None);
assert_eq!(page_backdrop(&doc, 1).unwrap(), Some(Backdrop::Fill), "the other page keeps its backdrop");
}
#[test]
fn pages_without_content_or_with_odd_contents_have_no_backdrop() {
let doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 200] >>".into(),
"<< /Type /Page /Parent 2 0 R >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents [5 0 R 99 0 R] >>".into(),
"<< /Not /AStream >>".into(),
]);
assert_eq!(page_backdrop(&doc, 0).unwrap(), None);
assert_eq!(page_backdrop(&doc, 1).unwrap(), None);
assert!(matches!(page_backdrop(&doc, 7), Err(EditError::NoSuchPage(7))));
}
/// `pages` 300 × 200 pages in a flat tree; even pages paint a white backdrop, odd ones don't.
fn many_pages(pages: usize) -> Document {
let kids: Vec<String> = (0..pages).map(|i| format!("{} 0 R", i + 5)).collect();
let mut objs = vec![
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 300 200] >>", kids.join(" ")),
stream("", &format!("1 g 0 0 300 200 re f 0 g {TEXT}")),
stream("", TEXT),
];
objs.extend((0..pages).map(|i| format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R >>", 3 + i % 2)));
build(&objs)
}
#[test]
fn checking_many_pages_walks_the_page_tree_once() {
// The Add Background dialog checks every page of a long document. Looking each page up on
// its own walked the whole tree per page, so checking them all grew with the square of the
// page count (minutes at 20,000 pages); one walk keeps it in proportion.
const PAGES: usize = 4000;
let doc = many_pages(PAGES);
let all: Vec<usize> = (0..PAGES).collect();
let start = std::time::Instant::now();
let found = page_backdrops(&doc, &all);
let took = start.elapsed();
assert_eq!(found.len(), PAGES);
for (i, f) in found.iter().enumerate() {
assert_eq!(*f, (i % 2 == 0).then_some(Backdrop::Fill), "page {i}");
}
assert!(took < std::time::Duration::from_secs(5), "checking {PAGES} pages took {took:?}");
}
#[test]
fn a_batch_answers_in_the_order_asked_and_a_missing_page_hides_nothing() {
let doc = many_pages(5);
assert_eq!(page_backdrops(&doc, &[3, 0, 0]), [None, Some(Backdrop::Fill), Some(Backdrop::Fill)]);
assert_eq!(page_backdrops(&doc, &[]), []);
// The viewer can count more pages than the page tree holds: the others still answer.
assert_eq!(page_backdrops(&doc, &[2, 5, usize::MAX]), [Some(Backdrop::Fill), None, None]);
assert_eq!(page_backdrop(&doc, 4).unwrap(), Some(Backdrop::Fill));
assert!(matches!(page_backdrop(&doc, 5), Err(EditError::NoSuchPage(5))));
}
#[test]
fn a_background_over_many_pages_replaces_each_backdrop_and_removing_restores_them() {
// Pages share their content streams, so each replaced page gets its own copy and the rest
// are checked against the document as it is after the earlier ones.
let mut doc = many_pages(4);
let all = [0, 1, 2, 3];
let before: Vec<Object> = pdfcraft_model::pages(&doc).iter().filter_map(|p| p.dict.get(b"Contents").cloned()).collect();
add_background(&mut doc, &all, &red(), true).unwrap();
assert_eq!(page_backdrops(&doc, &all), [None; 4]);
// Update: the backdrops are gone already, so nothing is copied again.
add_background(&mut doc, &all, &red(), true).unwrap();
assert_eq!(page_backdrops(&doc, &all), [None; 4]);
remove_marks(&mut doc, &all, MarkKind::Background).unwrap();
let after: Vec<Object> = pdfcraft_model::pages(&doc).iter().filter_map(|p| p.dict.get(b"Contents").cloned()).collect();
let refs = |list: &[Object]| -> Vec<Vec<Object>> {
list.iter()
.map(|c| match &*doc.resolve(c) {
Object::Array(a) => a.clone(),
_ => vec![c.clone()],
})
.collect()
};
assert_eq!(refs(&after), refs(&before), "every page points at its original stream again");
assert_eq!(page_backdrops(&doc, &all), [Some(Backdrop::Fill), None, Some(Backdrop::Fill), None]);
}
#[test]
fn a_content_stream_whose_bytes_live_in_an_external_file_is_left_alone() {
let objs = vec![
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents [4 0 R] >>".into(),
stream("/F (content.bin)", &format!("1 g 0 0 300 200 re f {TEXT}")),
];
let mut doc = build(&objs);
assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "the inline bytes are not what the page draws");
let before = streams(&doc);
add_background(&mut doc, &[0], &red(), false).unwrap();
assert_eq!(streams(&doc)[1..], before[..]);
}
#[test]
fn the_backdrop_copy_keeps_the_original_dictionary_less_its_old_encoding() {
let objs = vec![
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents [4 0 R] >>".into(),
stream("/Vendor (keep me) /DL 9999", &format!("1 g 0 0 300 200 re f {TEXT}")),
];
let mut doc = build(&objs);
add_background(&mut doc, &[0], &red(), false).unwrap();
let doc = reopen(&doc);
let p = &pdfcraft_model::pages(&doc)[0];
let Some(Object::Array(list)) = p.dict.get(b"Contents").map(|c| (*doc.resolve(c)).clone()) else { panic!("contents") };
let Object::Stream(copy) = &*doc.resolve(&list[1]) else { panic!("a stream") };
assert!(copy.dict.get(b"Vendor").is_some(), "unknown entries survive");
assert!(copy.dict.get(b"DL").is_none(), "the old decoded length no longer holds");
assert!(copy.dict.get(b"PCBackdrop").is_some());
assert_eq!(String::from_utf8_lossy(&copy.decoded().unwrap()), format!("1 g 0 0 300 200 re n {TEXT}"));
}
#[test]
fn a_stream_listed_twice_is_left_untouched_as_ambiguous() {
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents [4 0 R 4 0 R] >>".into(),
stream("", &format!("q 1 g 0 0 300 200 re f Q {TEXT}")),
]);
let original = streams(&doc);
add_background(&mut doc, &[0], &red(), false).unwrap();
let s = streams(&doc);
assert_eq!(s.len(), 3, "{s:?}");
assert_eq!(s[1..], original[..], "repeated executions are not qualified independently");
remove_marks(&mut doc, &[0], MarkKind::Background).unwrap();
assert_eq!(streams(&doc), original);
}
/// #2815 review: a `gs` sets only the keys its dictionary has, so a translucent, masked,
/// blended or transferred fill stays that way through a later `gs` that leaves the key out,
/// and a transfer function that isn't the identity can turn `1 g` into any colour.
#[test]
fn graphics_state_a_later_gs_leaves_out_still_keeps_the_fill() {
for state in [
"/Half gs /LineOnly gs",
"/Masked gs /LineOnly gs",
"/Multiply gs /LineOnly gs",
"/Invert gs /LineOnly gs",
"/Invert2 gs /LineOnly gs",
"/Invert gs",
"/Invert2 gs",
"/IdentityButTR2Invert gs",
"/Invert gs q /LineOnly gs Q",
] {
let mut doc = page(&[&format!("{state} 1 g 0 0 300 200 re f {TEXT}")]);
assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "{state}");
let before = streams(&doc);
add_background(&mut doc, &[0], &red(), false).unwrap();
assert_eq!(streams(&doc)[1..], before[..], "{state}: the page's content is unchanged");
}
}
/// The other side of the same rule: a later `gs` that does set the key back, `TR2` naming the
/// default over a `TR` function, and `Q` restoring what `q` saved still leave a plain white fill.
#[test]
fn graphics_state_set_back_before_the_fill_still_finds_the_backdrop() {
for state in [
"/Half gs /Full gs",
"/Masked gs /Unmasked gs",
"/Multiply gs /NormalBlend gs",
"/Invert gs /IdentityTR gs",
"/Invert2 gs /DefaultTR2 gs",
"/InvertButTR2Default gs",
"/LineOnly gs",
"q /Half gs Q",
"q /Invert gs Q",
] {
let doc = page(&[&format!("{state} 1 g 0 0 300 200 re f {TEXT}")]);
assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill), "{state}");
}
}
#[test]
fn an_undecodable_stream_before_a_white_fill_keeps_the_backdrop_unknown() {
let objs = vec![
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents [4 0 R 5 0 R] >>".into(),
stream("/Filter /FlateDecode", "not a deflate stream"),
stream("", &format!("1 g 0 0 300 200 re f {TEXT}")),
];
let doc = build(&objs);
assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "unknown earlier paint must not be treated as an empty stream");
}
#[test]
fn shared_fill_used_as_later_artwork_is_not_rewritten() {
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents [4 0 R 5 0 R 6 0 R 5 0 R 7 0 R] >>".into(),
stream("", "1 g"),
stream("", "0 0 300 200 re f"),
stream("", "q 0 g 0.25 0 0 0.25 20 20 cm"),
stream("", "Q"),
]);
assert_eq!(page_backdrop(&doc, 0).unwrap(), None);
let original = streams(&doc);
add_background(&mut doc, &[0], &red(), false).unwrap();
assert_eq!(streams(&doc)[1..], original[..], "the later black artwork keeps its fill");
let mut saved = reopen(&doc);
assert_eq!(streams(&saved)[1..], original[..]);
remove_marks(&mut saved, &[0], MarkKind::Background).unwrap();
assert_eq!(streams(&saved), original);
assert_eq!(streams(&reopen(&saved)), original);
}
#[test]
fn compressed_backdrop_content_is_refused_before_expanding_past_the_scan_budget() {
let mut doc = page(&["1 g 0 0 300 200 re f"]);
let mut bytes = b"1 g 0 0 300 200 re f\n".to_vec();
bytes.resize(2 * 1024 * 1024, b' ');
let source = doc.add(Object::Stream(Stream::flate(Dict::new(), &bytes)));
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(source))).unwrap();
assert_eq!(page_backdrop(&doc, 0).unwrap(), None);
add_background(&mut doc, &[0], &red(), false).unwrap();
let Object::Stream(unchanged) = &*doc.get(source) else { panic!("stream") };
assert_eq!(unchanged.decoded_strict().unwrap(), bytes, "refusal preserves the source");
}
+7 -2
View File
@@ -98,8 +98,13 @@ pub fn page_background(doc: &Document, page: usize) -> Result<Option<Rgb>, EditE
}
b"sc" | b"scn" => st.fill = colour(st.space, op),
b"gs" => {
if let Some(g) = op.name(0).and_then(|n| gstates.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()) {
apply_gstate(doc, &g, &mut st);
let mut held = None;
let gstate = match op.name(0).and_then(|n| gstates.get(n)) {
Some(g) => crate::text::dict_in_place(doc, g, &mut held),
None => None,
};
if let Some(g) = gstate {
apply_gstate(doc, g, &mut st);
}
}
b"re" => match op.nums::<4>() {
+25
View File
@@ -16,6 +16,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use crate::{EditError, check, n, page_list, place_tagged};
const HIDDEN: i64 = 2;
const NO_ROTATE: i64 = 16;
const NO_VIEW: i64 = 32;
fn nums(doc: &Document, o: Option<&Object>) -> Option<Vec<f64>> {
@@ -74,6 +75,28 @@ fn placement(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option<[f64; 6]> {
Some([sx, 0.0, 0.0, sy, rect[0] - x0 * sx, rect[1] - y0 * sy])
}
/// `m` followed by a turn of `degrees` (the page's /Rotate) about the upper-left corner of
/// `rect`: a NoRotate annotation stays upright on a rotated page (ISO 32000-2 §12.5.3), its
/// upper-left corner where it was, which is how MuPDF and Poppler draw it.
fn upright(m: [f64; 6], degrees: i64, rect: [f64; 4]) -> [f64; 6] {
let (sin, cos) = match degrees.rem_euclid(360) {
90 => (1.0, 0.0),
180 => (0.0, -1.0),
270 => (-1.0, 0.0),
_ => return m,
};
let (px, py) = (rect[0], rect[3]);
let t = [cos, sin, -sin, cos, px - px * cos + py * sin, py - px * sin - py * cos];
[
m[0] * t[0] + m[1] * t[2],
m[0] * t[1] + m[1] * t[3],
m[2] * t[0] + m[3] * t[2],
m[2] * t[1] + m[3] * t[3],
m[4] * t[0] + m[5] * t[2] + t[4],
m[4] * t[1] + m[5] * t[3] + t[5],
]
}
#[derive(Clone, Copy)]
enum Which {
/// Every comment and/or every form field.
@@ -194,6 +217,7 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
let mut gone_inline: HashSet<usize> = HashSet::new();
// Selected, but nothing was drawn for it: it stays, pop-up and reply handling included.
let mut undrawn: HashSet<ObjRef> = HashSet::new();
let rotation = page.dict.get(b"Rotate").map(|r| doc.resolve(r)).and_then(|r| r.as_int()).unwrap_or(0);
// A viewer draws many comments from their dictionary even when the file stores no
// appearance (notes, FreeText, ink, stamps…). Build the appearance it would show, so
// flattening keeps a comment that still has ink on screen instead of deleting it.
@@ -230,6 +254,7 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
if let (Some(ap), Some(rect)) = (appearance(doc, d), rect) {
let form = doc.get(ap).as_dict().cloned().unwrap_or_default();
if let Some(m) = placement(doc, &form, rect) {
let m = if flags & NO_ROTATE != 0 { upright(m, rotation, rect) } else { m };
let name = xobject_name(&existing_xo, &xobjects, &mut name_n);
content.push_str(&format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])));
xobjects.set(name.into_bytes(), Object::Ref(ap));
+1317 -148
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -1,6 +1,11 @@
//! pdfcraft-edit — page content editing (L4). Today: page marks, Acrobat's Header & footer,
//! Watermark, Background and Bates numbering (execution plan M7.6).
//!
//! **Atomic page-mark operations.** Adding or replacing headers/footers, watermarks and backgrounds
//! stages the whole call on a document snapshot and commits only after every selected page succeeds.
//! An error preserves existing marks, content, resources, pending edits and allocated objects. The
//! snapshot shares original PDF bytes and unchanged objects; it copies the overlay table and metadata.
//!
//! **How marks are stored.** Each mark is one content stream appended to (or, for content behind
//! the page, inserted after the backgrounds at the start of) the page's `/Contents`, drawn in
//! display space (after `/Rotate`) and wrapped in a marked-content artifact (§14.8.2.2) so
@@ -33,6 +38,9 @@ pub enum EditError {
pub type Rgb = [f64; 3];
pub mod backdrop;
pub use backdrop::{Backdrop, backdrops_left_off, page_backdrop, page_backdrops};
pub use text::strip_subset_tag;
mod background;
pub use background::page_background;
pub mod objects;
@@ -41,10 +49,11 @@ pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, ed
mod flatten;
pub use flatten::{flatten, flatten_fill_sign};
pub mod images;
pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees};
pub use images::{ArrangeOrder, ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees};
pub mod bidi;
pub mod indic;
mod linebreak;
pub mod paragraph_align;
pub mod text;
pub use text::{
BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping,
+211
View File
@@ -0,0 +1,211 @@
//! The public mark APIs either commit the complete operation or leave the document untouched.
use super::{cx, fixture, reopen, streams};
use crate::place::MARK_PLACEMENT_FAILURE;
use crate::*;
use pdfcraft_cos::{ObjRef, SaveOptions, write_incremental};
const SELECTED: &[usize] = &[0, 1, 2];
struct PlacementFailure;
impl PlacementFailure {
fn after_two_pages(kind: MarkKind) -> Self {
MARK_PLACEMENT_FAILURE.with(|failure| {
assert!(failure.get().is_none());
failure.set(Some((kind, 2, 0)));
});
Self
}
fn completed(&self) -> usize {
MARK_PLACEMENT_FAILURE.with(|failure| failure.get().unwrap().2)
}
}
impl Drop for PlacementFailure {
fn drop(&mut self) {
MARK_PLACEMENT_FAILURE.with(|failure| failure.set(None));
}
}
/// Four pages share an original stream and resources, both inherited and directly referenced.
/// All have all three existing marks, including a picture background with a replaced white fill.
fn marked_document() -> (Document, MarkSource) {
let mut doc = fixture();
let parent = ObjRef::new(2, 0);
let extra = doc.add(Object::Dict(Dict::from_iter([
(b"Type".to_vec(), Object::name("Page")),
(b"Parent".to_vec(), Object::Ref(parent)),
(b"Contents".to_vec(), Object::Ref(ObjRef::new(7, 0))),
])));
doc.update_dict(parent, |d| {
d.set(b"Kids".to_vec(), Object::Array([3, 4, 5].map(|n| Object::Ref(ObjRef::new(n, 0))).into_iter().chain([Object::Ref(extra)]).collect()));
d.set(b"Count".to_vec(), Object::Int(4));
})
.unwrap();
doc.set(ObjRef::new(7, 0), Object::Stream(Stream::from_raw(Dict::new(), b"1 g 0 0 600 800 re f 0 g BT /F1 9 Tf (Original page) Tj ET".to_vec())));
let picture = |doc: &mut Document, gray| {
let image = doc.add(Object::Stream(Stream::from_raw(
Dict::from_iter([
(b"Type".to_vec(), Object::name("XObject")),
(b"Subtype".to_vec(), Object::name("Image")),
(b"Width".to_vec(), Object::Int(1)),
(b"Height".to_vec(), Object::Int(1)),
(b"ColorSpace".to_vec(), Object::name("DeviceGray")),
(b"BitsPerComponent".to_vec(), Object::Int(8)),
]),
vec![gray],
)));
MarkSource { xobject: image, size: (1.0, 1.0), image: true }
};
let old_picture = picture(&mut doc, 170);
let replacement_picture = picture(&mut doc, 80);
let all = [0, 1, 2, 3];
let hf = HeaderFooter {
text: ["Old header".into(), String::new(), String::new(), String::new(), String::new(), String::new()],
font: added::Family::Times,
..Default::default()
};
add_header_footer(&mut doc, &all, &hf, false, &cx()).unwrap();
add_watermark(&mut doc, &all, &Watermark { text: "Old watermark".into(), opacity: 0.45, ..Default::default() }, false).unwrap();
add_background(&mut doc, &all, &Background { source: Some(old_picture), opacity: 0.6, replace_backdrop: true, ..Default::default() }, false)
.unwrap();
let pages = page_list(&doc);
let shared = ObjRef::new(6, 0);
let resources = (*doc.resolve(pages[0].dict.get(b"Resources").unwrap())).clone();
doc.set(shared, resources);
for (i, page) in pages.iter().enumerate() {
doc.update_dict(page.obj, |d| {
if i % 2 == 0 {
d.set(b"Resources".to_vec(), Object::Ref(shared));
} else {
d.remove(b"Resources");
}
})
.unwrap();
}
let doc = reopen(&doc);
assert!(!doc.is_modified());
let pages = page_list(&doc);
assert_eq!(pages.len(), 4);
assert!(doc.get(pages[1].obj).as_dict().unwrap().get(b"Resources").is_none(), "a selected page inherits its resources");
assert_eq!(pages[0].dict.get(b"Resources").and_then(Object::as_ref), Some(shared));
assert_eq!(pages[2].dict.get(b"Resources").and_then(Object::as_ref), Some(shared));
for i in 0..4 {
for kind in [MarkKind::HeaderFooter, MarkKind::Watermark, MarkKind::Background] {
assert_eq!(mark_streams(&doc, i, kind).len(), 1, "page {i} has existing {kind:?}");
}
}
(doc, replacement_picture)
}
fn mark_streams(doc: &Document, page: usize, kind: MarkKind) -> Vec<String> {
streams(doc, page).into_iter().filter(|s| s.contains(&format!("/PCMark /{}", kind.tag()))).collect()
}
fn apply_replacement(doc: &mut Document, kind: MarkKind, source: MarkSource, replace: bool) -> Result<(), EditError> {
match kind {
MarkKind::HeaderFooter => add_header_footer(
doc,
SELECTED,
&HeaderFooter {
text: ["New header".into(), String::new(), String::new(), String::new(), String::new(), String::new()],
font: added::Family::Courier,
shrink: true,
..Default::default()
},
replace,
&cx(),
),
MarkKind::Watermark => add_watermark(doc, SELECTED, &Watermark { source: Some(source), opacity: 0.25, ..Default::default() }, replace),
MarkKind::Background => {
add_background(doc, SELECTED, &Background { color: [0.1, 0.3, 0.7], replace_backdrop: true, ..Default::default() }, replace)
}
}
}
fn unchanged_after_later_page_failure(kind: MarkKind) {
// Replacement and addition share the same operation boundary; exercise replacement first.
for (replace, pending_edit) in [(true, false), (true, true), (false, false), (false, true)] {
let (mut doc, source) = marked_document();
if pending_edit {
let pending = doc.add(Object::String(pdfcraft_cos::PdfString::literal(b"unsaved caller edit".to_vec())));
doc.update_dict(page_list(&doc)[0].obj, |d| d.set(b"CallerEdit".to_vec(), Object::Ref(pending))).unwrap();
}
let modified = doc.is_modified();
let before = write_incremental(&doc, &SaveOptions::default()).unwrap();
let objects = doc.object_numbers().len();
let mut allocation_probe = doc.clone();
let next_object = allocation_probe.add(Object::Null);
let failure = PlacementFailure::after_two_pages(kind);
let result = apply_replacement(&mut doc, kind, source, replace);
assert_eq!(result, Err(EditError::Invalid("injected mark placement failure".into())));
assert_eq!(failure.completed(), 2, "the error followed two real page placements");
assert_eq!(doc.object_numbers().len(), objects, "{kind:?}, replace={replace}: no allocated objects remain");
assert_eq!(doc.is_modified(), modified, "{kind:?}, replace={replace}: the dirty state is preserved");
assert!(write_incremental(&doc, &SaveOptions::default()).unwrap() == before, "{kind:?}, replace={replace}: every byte stays unchanged");
assert_eq!(doc.add(Object::Null), next_object, "the allocation cursor is unchanged too");
}
}
#[test]
fn header_footer_changes_are_atomic_after_a_later_page_failure() {
unchanged_after_later_page_failure(MarkKind::HeaderFooter);
}
#[test]
fn watermark_changes_are_atomic_after_a_later_page_failure() {
unchanged_after_later_page_failure(MarkKind::Watermark);
}
#[test]
fn background_changes_are_atomic_after_a_later_page_failure() {
unchanged_after_later_page_failure(MarkKind::Background);
}
#[test]
fn replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_resources() {
for kind in [MarkKind::HeaderFooter, MarkKind::Watermark, MarkKind::Background] {
let (mut doc, source) = marked_document();
let before = doc.clone();
let expected = match kind {
MarkKind::HeaderFooter => "(New header)".to_string(),
MarkKind::Watermark => format!("/PCPic{} Do", source.xobject.num),
MarkKind::Background => "0.1 0.3 0.7 rg".to_string(),
};
apply_replacement(&mut doc, kind, source, true).unwrap();
let doc = reopen(&doc);
assert_eq!(streams(&doc, 3), streams(&before, 3), "the unselected page keeps its content");
let unselected = page_list(&before)[3].obj;
assert_eq!(doc.get(unselected), before.get(unselected), "the unselected page keeps its resources and entries");
for reference in [ObjRef::new(6, 0), ObjRef::new(7, 0)] {
assert_eq!(
format!("{:?}", doc.get(reference)),
format!("{:?}", before.get(reference)),
"shared resources and original content stay unchanged"
);
}
for page in SELECTED {
let marks = mark_streams(&doc, *page, kind);
assert_eq!(marks.len(), 1, "one replacement on selected page {page}");
assert!(marks[0].contains(&expected), "the new mark is present: {}", marks[0]);
let old_page = &page_list(&before)[*page];
let new_page = &page_list(&doc)[*page];
let old_resources = before.resolve(old_page.dict.get(b"Resources").unwrap());
let new_resources = doc.resolve(new_page.dict.get(b"Resources").unwrap());
for category in [&b"Font"[..], &b"ExtGState"[..], &b"XObject"[..]] {
let old = before.resolve(old_resources.as_dict().unwrap().get(category).unwrap());
let new = doc.resolve(new_resources.as_dict().unwrap().get(category).unwrap());
for (name, value) in old.as_dict().unwrap().iter() {
assert_eq!(new.as_dict().unwrap().get(name), Some(value), "old resources remain usable on page {page}");
assert_eq!(doc.resolve(value), before.resolve(value), "referenced resource contents are unchanged on page {page}");
}
}
for other in [MarkKind::HeaderFooter, MarkKind::Watermark, MarkKind::Background] {
if other != kind {
assert_eq!(mark_streams(&doc, *page, other), mark_streams(&before, *page, other), "unrelated {other:?} on page {page}");
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More