diff --git a/.config/nextest.toml b/.config/nextest.toml
index 2a4cd2cb93..11ec94cc1a 100644
--- a/.config/nextest.toml
+++ b/.config/nextest.toml
@@ -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"
diff --git a/.gitattributes b/.gitattributes
index 5db32cda22..a0543090cc 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -3,6 +3,9 @@
# signatures) and assets whose SHA-256 ATTRIBUTION.toml records (cargo xtask assets).
* -text
+# The craft-i18n pin: CI and the build read it as one SHA, so it stays LF everywhere (readers trim it too).
+craft-i18n.rev text eol=lf
+
# Not text at all: no textual diffs or merges either.
*.pdf binary
*.p12 binary
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a5c2ba7f7f..9403a1e044 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -12,6 +12,7 @@ on:
permissions:
contents: write
+ packages: write
concurrency:
group: release-${{ github.ref }}
@@ -41,3 +42,4 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+ LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml
index 838af4524c..f3e2b20c7e 100644
--- a/.github/workflows/windows-arm64.yml
+++ b/.github/workflows/windows-arm64.yml
@@ -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
diff --git a/.gitignore b/.gitignore
index 7834b668cb..f7922460c7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/AGENTS.md b/AGENTS.md
index 2ecfe99a02..3ab900aa95 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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)
@@ -54,14 +52,14 @@ Anything else is forbidden, including assets whose licence you cannot prove from
- path or URL, title, author;
- source (URL, with version or commit);
- SPDX licence, and the licence file in the repo;
- - SHA-256, except for translation catalogs (see below);
+ - SHA-256;
- how it is used.
-- **Translation catalogs** (`crates/ui-egui/src/i18n/*.tsv`, `kind = "translation"`) are project files under the project licence. Their entries carry no `sha256`: git history records every change and who made it. Every other kind (fonts, images, data) is hashed.
+- **Translations** live in craft-i18n (`locales//common.tsv` and `pdfcraft.tsv`), embedded at build time from the checkout pinned in `craft-i18n.rev`; PdfCraft keeps no catalogs. `ATTRIBUTION.toml` records them as one `[[build_input]]` (`kind = "translation"`); craft-i18n's own `ATTRIBUTION.md` credits the translators. Every `[[asset]]` (fonts, images, data) is hashed.
- **`ATTRIBUTION.md`** is generated from it (`cargo xtask assets --write`). Never edit it by hand.
- **`NOTICE`** keeps the licence notices that the licences require.
- **`cargo xtask assets`** runs in `cargo xtask ci`. It fails if:
- an asset file in the repository has no entry;
- - an entry's hash is missing or does not match (translation catalogs carry none);
+ - an entry's hash is missing or does not match;
- a licence is not on the allowlist;
- a licence file is missing;
- an image, icon or font names Adobe as author or source, except a font licensed under OFL-1.1 alone.
@@ -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= 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.]` 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.
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 23ebcbbe82..e8fa4adac6 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -2,215 +2,187 @@
-Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset but the translation catalogs, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
+Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
-## In this repository (289)
+## In this repository (257)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
-| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/fa.tsv` | Persian (Farsi) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed | Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
-| `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 |
@@ -231,6 +203,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
| `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) |
| `crates/fonts/src/std14_metrics.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. Data only: the code that reads these tables lives in std14.rs, so that ordinary edits do not churn this file's hash. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. |
+| `crates/prc/src/acof/table.rs` | PRC Double prefix-code table (as a Rust table) | Kristof Ralovich (prc-rs); the code table itself is defined by the PRC format (ISO 14739-1, working draft clause 11.17.2) | MIT | Rows taken from prc-rs src/double.rs at commit 287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d (https://github.com/ralovich/prc-rs), mechanically reformatted into (code length, code, entry) tuples; checked to be a complete prefix code by the tests in crates/prc/src/acof.rs. Data only: the code that reads the table lives in acof.rs. The MIT notice is kept in the file header. | Decodes every PRC Double (coordinates, transforms, colours) in 3D annotations' PRC streams. Non-visual data. |
| `vendor/hayro-cmap/assets/cmaps.brotli` | Adobe CMap resources (predefined CJK CMaps) | Adobe | BSD-3-Clause | https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap 0.1.0 assets/) | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1 |
| `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
@@ -240,22 +213,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,34 +242,29 @@ 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 |
-| `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 |
+| `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 |
| `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 |
-| `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 |
+| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+| `assets/icons/a-large-small.svg` | Lucide icon "a-large-small" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/a-large-small.svg) | UI icon, embedded via crates/ui-egui/src/widgets/icon_data.rs |
+| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
## Compiled in through dependencies (6)
@@ -338,10 +306,11 @@ Fonts are fetched by `cargo xtask demo-pdf` into `target/demo-fonts/`, OCR model
| `text-detection.onnx` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) |
| `text-recognition.onnx` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) |
-## Optional build inputs (1)
+## Optional build inputs (2)
Not in this repository and never downloaded by it: compiled in only when the build sets the option (official releases do). Each input attributes its own files.
| Input | Option | Title | Author | Licence | Source | Attribution | Used for |
|---|---|---|---|---|---|---|---|
+| `craft-i18n` | `CRAFT_I18N_DIR` | Interface translations: the craft-i18n catalogs (locales//common.tsv and pdfcraft.tsv) of the 34 languages PdfCraft offers besides English | The craft-i18n translators, including every translator of PdfCraft's former in-repo catalogs (crates/ui-egui/src/i18n/*.tsv, migrated at pdfcraft@ecdd1e6) | MIT OR Apache-2.0 | https://github.com/storytold/craft-i18n (pinned by commit in craft-i18n.rev, which CI, releases and the flatpak manifests read) | https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md | Interface text in languages other than English, embedded by crates/ui-egui/build.rs and looked up through craft-libs' craft-i18n crate; a build without CRAFT_I18N_DIR is English only. Clean-room translations from PdfCraft's English interface labels |
| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1 |
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index 489247559d..f998b6fc3d 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -1,302 +1,21 @@
-[[asset]]
-path = "crates/ui-egui/src/i18n/el.tsv"
-title = "Greek interface translation catalog"
-author = "Camthalion77 and contributors"
-source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
#
# [[asset]] a file in this repository (committed or vendored); `sha256` must match the file.
-# Translation catalogs (kind = "translation", crates/ui-egui/src/i18n/*.tsv) are project files
-# under the project licence and carry no `sha256`: git history records their provenance.
# [[bundled]] a file compiled into PdfCraft by a Cargo dependency (its crate version must match Cargo.lock).
# [[fetched]] downloaded at build time by xtask (never committed), pinned by `sha256`.
-# [[build_input]] an optional build input outside this repository (e.g. craft-fonts via CRAFT_FONTS_DIR);
+# [[build_input]] an optional build input outside this repository (craft-fonts via CRAFT_FONTS_DIR, craft-i18n via CRAFT_I18N_DIR);
# never committed or downloaded here, its own repository attributes each file.
#
# licence: SPDX expression from the allowlist in AGENTS.md §1.2. adobe_data = true marks the closed list of
# Adobe-authored non-visual technical data allowed by AGENTS.md §1.1.
-[[asset]]
-path = "crates/ui-egui/src/i18n/zh-hans.tsv"
-title = "Simplified Chinese interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/zh-hant.tsv"
-title = "Traditional Chinese (Taiwan) interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/cs.tsv"
-title = "Czech interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/pt-br.tsv"
-title = "Brazilian Portuguese interface translation catalog"
-author = "André Cardoso (andrecards), PdfCraft contributors"
-source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/es.tsv"
-title = "Spanish interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/fr.tsv"
-title = "French interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "French UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/de.tsv"
-title = "German interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "German UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/lo.tsv"
-title = "Lao interface translation catalog"
-author = "@bounkirdni-2025"
-source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/te.tsv"
-title = "Telugu interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/it.tsv"
-title = "Italian interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/pl.tsv"
-title = "Polish interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/ru.tsv"
-title = "Russian interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/hu.tsv"
-title = "Hungarian interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/bg.tsv"
-title = "Bulgarian interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/kk.tsv"
-title = "Kazakh interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/ja.tsv"
-title = "Japanese interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/uk.tsv"
-title = "Ukrainian interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/ar.tsv"
-title = "Arabic interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/ckb.tsv"
-title = "Central Kurdish (Sorani) interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/fa.tsv"
-title = "Persian (Farsi) interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/tr.tsv"
-title = "Turkish interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/lt.tsv"
-title = "Lithuanian interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/hi.tsv"
-title = "Hindi interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-[[asset]]
-path = "crates/ui-egui/src/i18n/id.tsv"
-title = "Indonesian interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-[[asset]]
-path = "crates/ui-egui/src/i18n/ko.tsv"
-title = "Korean interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/ro.tsv"
-title = "Romanian interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/fi.tsv"
-title = "Finnish interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
[[asset]]
path = "assets/icons/accessibility.svg"
title = "Lucide icon \"accessibility\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -307,7 +26,7 @@ path = "assets/icons/align-left.svg"
title = "Lucide icon \"align-left\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -318,7 +37,7 @@ path = "assets/icons/align-center.svg"
title = "Lucide icon \"align-center\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -329,7 +48,7 @@ path = "assets/icons/square-terminal.svg"
title = "Lucide icon \"square-terminal\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-terminal.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -340,7 +59,7 @@ path = "assets/icons/file-code.svg"
title = "Lucide icon \"file-code\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-code.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -351,7 +70,7 @@ path = "assets/icons/file-spreadsheet.svg"
title = "Lucide icon \"file-spreadsheet\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-spreadsheet.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -362,7 +81,7 @@ path = "assets/icons/align-justify.svg"
title = "Lucide icon \"align-justify\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-justify.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -373,7 +92,7 @@ path = "assets/icons/align-right.svg"
title = "Lucide icon \"align-right\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-right.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -384,7 +103,7 @@ path = "assets/icons/columns-3.svg"
title = "Lucide icon \"columns-3\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-3.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -395,7 +114,7 @@ path = "assets/icons/rows-3.svg"
title = "Lucide icon \"rows-3\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rows-3.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -406,7 +125,7 @@ path = "assets/icons/flip-horizontal-2.svg"
title = "Lucide icon \"flip-horizontal-2\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-horizontal-2.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -417,7 +136,7 @@ path = "assets/icons/flip-vertical-2.svg"
title = "Lucide icon \"flip-vertical-2\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-vertical-2.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -428,7 +147,7 @@ path = "assets/icons/app-window.svg"
title = "Lucide icon \"app-window\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/app-window.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -439,7 +158,7 @@ path = "assets/icons/arrow-left-right.svg"
title = "Lucide icon \"arrow-left-right\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-left-right.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -450,7 +169,7 @@ path = "assets/icons/arrow-up-down.svg"
title = "Lucide icon \"arrow-up-down\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-down.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -461,7 +180,7 @@ path = "assets/icons/arrow-up-right.svg"
title = "Lucide icon \"arrow-up-right\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-right.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -472,7 +191,7 @@ path = "assets/icons/badge-check.svg"
title = "Lucide icon \"badge-check\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/badge-check.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -483,7 +202,7 @@ path = "assets/icons/bandage.svg"
title = "Lucide icon \"bandage\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bandage.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -494,7 +213,7 @@ path = "assets/icons/bell.svg"
title = "Lucide icon \"bell\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bell.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -505,7 +224,7 @@ path = "assets/icons/blend.svg"
title = "Lucide icon \"blend\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/blend.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -516,7 +235,7 @@ path = "assets/icons/bold.svg"
title = "Lucide icon \"bold\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bold.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -527,7 +246,7 @@ path = "assets/icons/book-open.svg"
title = "Lucide icon \"book-open\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/book-open.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -538,7 +257,7 @@ path = "assets/icons/bookmark.svg"
title = "Lucide icon \"bookmark\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -549,7 +268,7 @@ path = "assets/icons/bookmark-plus.svg"
title = "Lucide icon \"bookmark-plus\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark-plus.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -560,7 +279,7 @@ path = "assets/icons/braces.svg"
title = "Lucide icon \"braces\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/braces.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -571,7 +290,7 @@ path = "assets/icons/brush-cleaning.svg"
title = "Lucide icon \"brush-cleaning\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush-cleaning.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -582,7 +301,7 @@ path = "assets/icons/brush.svg"
title = "Lucide icon \"brush\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -593,7 +312,7 @@ path = "assets/icons/camera.svg"
title = "Lucide icon \"camera\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/camera.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -604,7 +323,7 @@ path = "assets/icons/check-circle-2.svg"
title = "Lucide icon \"check-circle-2\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check-circle-2.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -615,7 +334,7 @@ path = "assets/icons/check.svg"
title = "Lucide icon \"check\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -626,7 +345,7 @@ path = "assets/icons/chevron-down.svg"
title = "Lucide icon \"chevron-down\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-down.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -637,7 +356,7 @@ path = "assets/icons/chevron-left.svg"
title = "Lucide icon \"chevron-left\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-left.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -648,7 +367,7 @@ path = "assets/icons/chevron-right.svg"
title = "Lucide icon \"chevron-right\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-right.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -659,7 +378,7 @@ path = "assets/icons/chevron-up.svg"
title = "Lucide icon \"chevron-up\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-up.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -670,7 +389,7 @@ path = "assets/icons/chevrons-left.svg"
title = "Lucide icon \"chevrons-left\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-left.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -681,7 +400,7 @@ path = "assets/icons/chevrons-right.svg"
title = "Lucide icon \"chevrons-right\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-right.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -692,7 +411,7 @@ path = "assets/icons/circle-check.svg"
title = "Lucide icon \"circle-check\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-check.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -703,7 +422,7 @@ path = "assets/icons/circle-dashed.svg"
title = "Lucide icon \"circle-dashed\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dashed.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -714,7 +433,7 @@ path = "assets/icons/circle-dot.svg"
title = "Lucide icon \"circle-dot\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dot.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -725,7 +444,7 @@ path = "assets/icons/circle-help.svg"
title = "Lucide icon \"circle-help\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-help.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -736,7 +455,7 @@ path = "assets/icons/circle.svg"
title = "Lucide icon \"circle\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -747,7 +466,7 @@ path = "assets/icons/clock-3.svg"
title = "Lucide icon \"clock-3\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock-3.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -758,7 +477,7 @@ path = "assets/icons/clock.svg"
title = "Lucide icon \"clock\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -769,7 +488,7 @@ path = "assets/icons/cloud-upload.svg"
title = "Lucide icon \"cloud-upload\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud-upload.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -780,7 +499,7 @@ path = "assets/icons/cloud.svg"
title = "Lucide icon \"cloud\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -791,7 +510,7 @@ path = "assets/icons/code-xml.svg"
title = "Lucide icon \"code-xml\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/code-xml.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -802,7 +521,7 @@ path = "assets/icons/columns-2.svg"
title = "Lucide icon \"columns-2\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-2.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -813,7 +532,7 @@ path = "assets/icons/compass.svg"
title = "Lucide icon \"compass\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/compass.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -824,7 +543,7 @@ path = "assets/icons/contrast.svg"
title = "Lucide icon \"contrast\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/contrast.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -835,7 +554,7 @@ path = "assets/icons/copy-plus.svg"
title = "Lucide icon \"copy-plus\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy-plus.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -846,7 +565,7 @@ path = "assets/icons/copy.svg"
title = "Lucide icon \"copy\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -857,7 +576,7 @@ path = "assets/icons/crop.svg"
title = "Lucide icon \"crop\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/crop.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -868,7 +587,7 @@ path = "assets/icons/droplet.svg"
title = "Lucide icon \"droplet\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/droplet.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -879,7 +598,7 @@ path = "assets/icons/ellipsis-vertical.svg"
title = "Lucide icon \"ellipsis-vertical\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis-vertical.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -890,7 +609,7 @@ path = "assets/icons/ellipsis.svg"
title = "Lucide icon \"ellipsis\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -901,7 +620,7 @@ path = "assets/icons/eraser.svg"
title = "Lucide icon \"eraser\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eraser.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -912,7 +631,7 @@ path = "assets/icons/external-link.svg"
title = "Lucide icon \"external-link\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/external-link.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -923,7 +642,7 @@ path = "assets/icons/eye-off.svg"
title = "Lucide icon \"eye-off\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye-off.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -934,7 +653,7 @@ path = "assets/icons/eye.svg"
title = "Lucide icon \"eye\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -945,7 +664,7 @@ path = "assets/icons/file-check.svg"
title = "Lucide icon \"file-check\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-check.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -956,7 +675,7 @@ path = "assets/icons/file-down.svg"
title = "Lucide icon \"file-down\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-down.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -967,7 +686,7 @@ path = "assets/icons/file-input.svg"
title = "Lucide icon \"file-input\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-input.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -978,7 +697,7 @@ path = "assets/icons/file-lock-2.svg"
title = "Lucide icon \"file-lock-2\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-lock-2.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -989,7 +708,7 @@ path = "assets/icons/file-minus.svg"
title = "Lucide icon \"file-minus\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-minus.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1000,7 +719,7 @@ path = "assets/icons/file-output.svg"
title = "Lucide icon \"file-output\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-output.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1011,7 +730,7 @@ path = "assets/icons/file-pen-line.svg"
title = "Lucide icon \"file-pen-line\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-pen-line.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1022,7 +741,7 @@ path = "assets/icons/file-plus-2.svg"
title = "Lucide icon \"file-plus-2\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus-2.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1033,7 +752,7 @@ path = "assets/icons/file-plus.svg"
title = "Lucide icon \"file-plus\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1044,7 +763,7 @@ path = "assets/icons/file-scan.svg"
title = "Lucide icon \"file-scan\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-scan.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1055,7 +774,7 @@ path = "assets/icons/file-search.svg"
title = "Lucide icon \"file-search\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-search.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1066,7 +785,7 @@ path = "assets/icons/file-symlink.svg"
title = "Lucide icon \"file-symlink\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-symlink.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1077,7 +796,7 @@ path = "assets/icons/file-text.svg"
title = "Lucide icon \"file-text\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1088,7 +807,7 @@ path = "assets/icons/file-x.svg"
title = "Lucide icon \"file-x\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-x.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1099,7 +818,7 @@ path = "assets/icons/files.svg"
title = "Lucide icon \"files\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/files.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1110,7 +829,7 @@ path = "assets/icons/film.svg"
title = "Lucide icon \"film\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/film.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1121,7 +840,7 @@ path = "assets/icons/filter.svg"
title = "Lucide icon \"filter\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/filter.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1132,7 +851,7 @@ path = "assets/icons/folder-open.svg"
title = "Lucide icon \"folder-open\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-open.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1143,7 +862,7 @@ path = "assets/icons/folder-plus.svg"
title = "Lucide icon \"folder-plus\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-plus.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1154,7 +873,7 @@ path = "assets/icons/folder.svg"
title = "Lucide icon \"folder\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1165,7 +884,7 @@ path = "assets/icons/gauge.svg"
title = "Lucide icon \"gauge\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/gauge.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1176,7 +895,7 @@ path = "assets/icons/git-compare.svg"
title = "Lucide icon \"git-compare\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/git-compare.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1187,7 +906,7 @@ path = "assets/icons/globe.svg"
title = "Lucide icon \"globe\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/globe.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1198,7 +917,7 @@ path = "assets/icons/grid-3x3.svg"
title = "Lucide icon \"grid-3x3\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/grid-3x3.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1209,7 +928,7 @@ path = "assets/icons/hand.svg"
title = "Lucide icon \"hand\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hand.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1220,7 +939,7 @@ path = "assets/icons/hash.svg"
title = "Lucide icon \"hash\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hash.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1231,7 +950,7 @@ path = "assets/icons/heading.svg"
title = "Lucide icon \"heading\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/heading.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1242,7 +961,7 @@ path = "assets/icons/highlighter.svg"
title = "Lucide icon \"highlighter\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/highlighter.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1253,7 +972,7 @@ path = "assets/icons/house.svg"
title = "Lucide icon \"house\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/house.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1264,7 +983,7 @@ path = "assets/icons/image-plus.svg"
title = "Lucide icon \"image-plus\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image-plus.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1275,7 +994,7 @@ path = "assets/icons/image.svg"
title = "Lucide icon \"image\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1286,7 +1005,7 @@ path = "assets/icons/info.svg"
title = "Lucide icon \"info\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/info.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1297,7 +1016,7 @@ path = "assets/icons/italic.svg"
title = "Lucide icon \"italic\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/italic.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1308,7 +1027,7 @@ path = "assets/icons/languages.svg"
title = "Lucide icon \"languages\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/languages.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1319,7 +1038,7 @@ path = "assets/icons/lasso.svg"
title = "Lucide icon \"lasso\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lasso.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1330,7 +1049,7 @@ path = "assets/icons/layers.svg"
title = "Lucide icon \"layers\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layers.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1341,7 +1060,7 @@ path = "assets/icons/layout-grid.svg"
title = "Lucide icon \"layout-grid\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layout-grid.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1352,7 +1071,7 @@ path = "assets/icons/link-2.svg"
title = "Lucide icon \"link-2\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link-2.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1363,7 +1082,7 @@ path = "assets/icons/link.svg"
title = "Lucide icon \"link\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1374,7 +1093,7 @@ path = "assets/icons/list-checks.svg"
title = "Lucide icon \"list-checks\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-checks.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1385,7 +1104,7 @@ path = "assets/icons/list-ordered.svg"
title = "Lucide icon \"list-ordered\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-ordered.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1396,7 +1115,7 @@ path = "assets/icons/list.svg"
title = "Lucide icon \"list\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1407,7 +1126,7 @@ path = "assets/icons/lock-open.svg"
title = "Lucide icon \"lock-open\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock-open.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1418,7 +1137,7 @@ path = "assets/icons/lock.svg"
title = "Lucide icon \"lock\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1429,7 +1148,7 @@ path = "assets/icons/maximize-2.svg"
title = "Lucide icon \"maximize-2\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize-2.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1440,7 +1159,7 @@ path = "assets/icons/maximize.svg"
title = "Lucide icon \"maximize\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1451,7 +1170,7 @@ path = "assets/icons/message-circle-reply.svg"
title = "Lucide icon \"message-circle-reply\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-circle-reply.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1462,7 +1181,7 @@ path = "assets/icons/message-square-text.svg"
title = "Lucide icon \"message-square-text\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-text.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1473,7 +1192,7 @@ path = "assets/icons/message-square.svg"
title = "Lucide icon \"message-square\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1484,7 +1203,7 @@ path = "assets/icons/messages-square.svg"
title = "Lucide icon \"messages-square\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/messages-square.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1495,7 +1214,7 @@ path = "assets/icons/mic.svg"
title = "Lucide icon \"mic\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mic.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1506,7 +1225,7 @@ path = "assets/icons/minimize.svg"
title = "Lucide icon \"minimize\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1517,7 +1236,7 @@ path = "assets/icons/minimize-2.svg"
title = "Lucide icon \"minimize-2\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize-2.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1528,7 +1247,7 @@ path = "assets/icons/minus.svg"
title = "Lucide icon \"minus\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minus.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1539,7 +1258,7 @@ path = "assets/icons/moon.svg"
title = "Lucide icon \"moon\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/moon.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1550,7 +1269,7 @@ path = "assets/icons/mouse-pointer-2.svg"
title = "Lucide icon \"mouse-pointer-2\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mouse-pointer-2.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1561,7 +1280,7 @@ path = "assets/icons/move.svg"
title = "Lucide icon \"move\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1572,7 +1291,7 @@ path = "assets/icons/navigation.svg"
title = "Lucide icon \"navigation\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/navigation.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1583,7 +1302,7 @@ path = "assets/icons/paint-bucket.svg"
title = "Lucide icon \"paint-bucket\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paint-bucket.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1594,7 +1313,7 @@ path = "assets/icons/palette.svg"
title = "Lucide icon \"palette\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/palette.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1605,7 +1324,7 @@ path = "assets/icons/panel-left.svg"
title = "Lucide icon \"panel-left\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-left.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1616,7 +1335,7 @@ path = "assets/icons/panel-right.svg"
title = "Lucide icon \"panel-right\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-right.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1627,7 +1346,7 @@ path = "assets/icons/panels-top-left.svg"
title = "Lucide icon \"panels-top-left\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panels-top-left.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1638,7 +1357,7 @@ path = "assets/icons/paperclip.svg"
title = "Lucide icon \"paperclip\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paperclip.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1649,7 +1368,7 @@ path = "assets/icons/pen-line.svg"
title = "Lucide icon \"pen-line\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-line.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1660,7 +1379,7 @@ path = "assets/icons/pen-tool.svg"
title = "Lucide icon \"pen-tool\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-tool.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1671,7 +1390,7 @@ path = "assets/icons/pencil.svg"
title = "Lucide icon \"pencil\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pencil.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1682,7 +1401,7 @@ path = "assets/icons/pipette.svg"
title = "Lucide icon \"pipette\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pipette.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1693,7 +1412,7 @@ path = "assets/icons/plus.svg"
title = "Lucide icon \"plus\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/plus.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1704,7 +1423,7 @@ path = "assets/icons/presentation.svg"
title = "Lucide icon \"presentation\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/presentation.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1715,7 +1434,7 @@ path = "assets/icons/printer.svg"
title = "Lucide icon \"printer\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/printer.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1726,7 +1445,7 @@ path = "assets/icons/rectangle-horizontal.svg"
title = "Lucide icon \"rectangle-horizontal\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rectangle-horizontal.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1737,7 +1456,7 @@ path = "assets/icons/redo-2.svg"
title = "Lucide icon \"redo-2\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/redo-2.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1748,7 +1467,7 @@ path = "assets/icons/replace.svg"
title = "Lucide icon \"replace\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/replace.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1759,7 +1478,7 @@ path = "assets/icons/rotate-ccw.svg"
title = "Lucide icon \"rotate-ccw\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-ccw.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1770,7 +1489,7 @@ path = "assets/icons/rotate-cw.svg"
title = "Lucide icon \"rotate-cw\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-cw.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1781,7 +1500,7 @@ path = "assets/icons/ruler.svg"
title = "Lucide icon \"ruler\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ruler.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1792,7 +1511,7 @@ path = "assets/icons/save.svg"
title = "Lucide icon \"save\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/save.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1803,7 +1522,7 @@ path = "assets/icons/scan-text.svg"
title = "Lucide icon \"scan-text\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan-text.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1814,7 +1533,7 @@ path = "assets/icons/scan.svg"
title = "Lucide icon \"scan\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1825,7 +1544,7 @@ path = "assets/icons/scissors.svg"
title = "Lucide icon \"scissors\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scissors.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1836,7 +1555,7 @@ path = "assets/icons/search.svg"
title = "Lucide icon \"search\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/search.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1847,7 +1566,7 @@ path = "assets/icons/send.svg"
title = "Lucide icon \"send\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/send.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1858,7 +1577,7 @@ path = "assets/icons/settings-2.svg"
title = "Lucide icon \"settings-2\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings-2.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1869,7 +1588,7 @@ path = "assets/icons/settings.svg"
title = "Lucide icon \"settings\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1880,7 +1599,7 @@ path = "assets/icons/share-2.svg"
title = "Lucide icon \"share-2\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/share-2.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1891,7 +1610,7 @@ path = "assets/icons/shield-check.svg"
title = "Lucide icon \"shield-check\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/shield-check.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1902,7 +1621,7 @@ path = "assets/icons/signature.svg"
title = "Lucide icon \"signature\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/signature.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1913,7 +1632,7 @@ path = "assets/icons/sliders-horizontal.svg"
title = "Lucide icon \"sliders-horizontal\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sliders-horizontal.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1924,7 +1643,7 @@ path = "assets/icons/sparkles.svg"
title = "Lucide icon \"sparkles\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sparkles.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1935,7 +1654,7 @@ path = "assets/icons/spline.svg"
title = "Lucide icon \"spline\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/spline.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1946,7 +1665,7 @@ path = "assets/icons/square-dashed-mouse-pointer.svg"
title = "Lucide icon \"square-dashed-mouse-pointer\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed-mouse-pointer.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1957,7 +1676,7 @@ path = "assets/icons/square-dashed.svg"
title = "Lucide icon \"square-dashed\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1968,7 +1687,7 @@ path = "assets/icons/square.svg"
title = "Lucide icon \"square\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1979,7 +1698,7 @@ path = "assets/icons/squares-subtract.svg"
title = "Lucide icon \"squares-subtract\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/squares-subtract.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -1990,7 +1709,7 @@ path = "assets/icons/stamp.svg"
title = "Lucide icon \"stamp\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/stamp.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -2001,7 +1720,7 @@ path = "assets/icons/star.svg"
title = "Lucide icon \"star\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/star.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -2012,7 +1731,7 @@ path = "assets/icons/sticky-note.svg"
title = "Lucide icon \"sticky-note\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sticky-note.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -2023,7 +1742,7 @@ path = "assets/icons/strikethrough.svg"
title = "Lucide icon \"strikethrough\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/strikethrough.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -2034,7 +1753,7 @@ path = "assets/icons/sun.svg"
title = "Lucide icon \"sun\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sun.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -2045,7 +1764,7 @@ path = "assets/icons/swatch-book.svg"
title = "Lucide icon \"swatch-book\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/swatch-book.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -2056,7 +1775,7 @@ path = "assets/icons/tag.svg"
title = "Lucide icon \"tag\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/tag.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -2067,7 +1786,7 @@ path = "assets/icons/text-cursor-input.svg"
title = "Lucide icon \"text-cursor-input\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-cursor-input.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -2078,7 +1797,7 @@ path = "assets/icons/text-select.svg"
title = "Lucide icon \"text-select\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-select.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -2089,7 +1808,7 @@ path = "assets/icons/trash-2.svg"
title = "Lucide icon \"trash-2\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash-2.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -2100,7 +1819,7 @@ path = "assets/icons/trash.svg"
title = "Lucide icon \"trash\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -2111,7 +1830,7 @@ path = "assets/icons/triangle-alert.svg"
title = "Lucide icon \"triangle-alert\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/triangle-alert.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -2122,7 +1841,7 @@ path = "assets/icons/type.svg"
title = "Lucide icon \"type\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/type.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -2133,7 +1852,7 @@ path = "assets/icons/underline.svg"
title = "Lucide icon \"underline\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/underline.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -2144,7 +1863,7 @@ path = "assets/icons/undo-2.svg"
title = "Lucide icon \"undo-2\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/undo-2.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -2155,7 +1874,7 @@ path = "assets/icons/user-round.svg"
title = "Lucide icon \"user-round\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/user-round.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -2166,7 +1885,7 @@ path = "assets/icons/wand-sparkles.svg"
title = "Lucide icon \"wand-sparkles\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wand-sparkles.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -2177,7 +1896,7 @@ path = "assets/icons/wrench.svg"
title = "Lucide icon \"wrench\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wrench.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -2188,7 +1907,7 @@ path = "assets/icons/x.svg"
title = "Lucide icon \"x\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/x.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -2199,7 +1918,7 @@ path = "assets/icons/zoom-in.svg"
title = "Lucide icon \"zoom-in\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-in.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -2210,7 +1929,7 @@ path = "assets/icons/zoom-out.svg"
title = "Lucide icon \"zoom-out\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-out.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -2437,6 +2156,17 @@ adobe_data = true
usage = "Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree."
sha256 = "f225e0b044875b8fa6375b6b5ad935f0d99fcc1ad03ecf9f0baae2cedbfca4d2"
+[[asset]]
+path = "crates/prc/src/acof/table.rs"
+title = "PRC Double prefix-code table (as a Rust table)"
+author = "Kristof Ralovich (prc-rs); the code table itself is defined by the PRC format (ISO 14739-1, working draft clause 11.17.2)"
+source = "Rows taken from prc-rs src/double.rs at commit 287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d (https://github.com/ralovich/prc-rs), mechanically reformatted into (code length, code, entry) tuples; checked to be a complete prefix code by the tests in crates/prc/src/acof.rs. Data only: the code that reads the table lives in acof.rs. The MIT notice is kept in the file header."
+licence = "MIT"
+licence_file = "licenses/prc-rs-LICENSE.txt"
+kind = "data"
+usage = "Decodes every PRC Double (coordinates, transforms, colours) in 3D annotations' PRC streams. Non-visual data."
+sha256 = "8e705c5cde6a3ef778e8aca69759e38845b228b3f7f12949e587b19855d2114c"
+
[[bundled]]
crate = "epaint_default_fonts"
version = "0.37.0"
@@ -2873,57 +2603,57 @@ sha256 = "f87af81b6fa411da8820eed2bfdcc54c0ee9cd453b4e868c64f56c69db142d0e"
[[asset]]
path = "assets/app-icon/pdfcraft.svg"
title = "PdfCraft app icon (master vector)"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Master artwork; every other app-icon file is rendered from it (packaging/icons.sh)"
-sha256 = "52b384a10a2a7fdd3145d9822972cfd568ef0cb7312549bd4399addd89d45a6d"
+sha256 = "27745c021830f82c033b0f15f2d8d9f8e099138743297887ff7e2c9e7ed069da"
[[asset]]
path = "assets/app-icon/pdfcraft-small.svg"
title = "PdfCraft app icon (light vector)"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "README header and assets/app-icon/README.md"
-sha256 = "b036304c11ef5dcad36636d06ca0bf331b51e47650d25d3c916a0e1eabf544c7"
+sha256 = "b524df1a583f8034a5a23836955e4fb423e0149429d695e80d95e5056f408678"
[[asset]]
path = "assets/app-icon/pdfcraft-1024.png"
title = "PdfCraft app icon, 1024 px on Apple's icon grid"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Runtime window/Dock icon on macOS (apps/pdfcraft/src/main.rs)"
-sha256 = "da577d3f21fbcf5c739067787a9e683be2b064d76813b5d5fec9074a7a106538"
+sha256 = "68ac6730b8990d07503351d32eacea81e9ec171a7a6f09daf52d6555d8245c8a"
[[asset]]
path = "assets/app-icon/pdfcraft.icns"
title = "PdfCraft app icon (macOS .icns)"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "macOS app bundle icon"
-sha256 = "2425148222873de241d267aba667a8b234ecdd0149f2ca8bb6e79443b743b1e8"
+sha256 = "fca7e7a0e34cc5c5ecae5bca97ee896aac68c4a34b088bc3a0b3271484f23974"
[[asset]]
path = "assets/app-icon/pdfcraft.ico"
title = "PdfCraft app icon (Windows .ico, 16-256 px)"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Embedded in pdfcraft.exe by apps/pdfcraft/build.rs (Explorer, taskbar, Start menu, Alt-Tab)"
-sha256 = "56daaaae9ae5389794909570885809adf5e1d9183bef2d9669ed2f9bba3ed6f5"
+sha256 = "709271d66e07710a3f785ea21b0c85c5228eaf8bebe5058bc0f71f9ef20749eb"
[[asset]]
path = "assets/app-icon/pdfcraft-document.svg"
@@ -2934,7 +2664,7 @@ licence = "ISC AND (MIT OR Apache-2.0)"
licence_file = "assets/app-icon/LICENSE-document.txt"
kind = "icon"
usage = "Master artwork for the Windows PDF file type icon; rendered by packaging/icons.sh, without fonts or external images"
-sha256 = "8da8278c2b89362be0a960879d453ff879d7544d6d58e47d5389574e8dfff77b"
+sha256 = "c129f1b538ce16e3c3fa0d89d3301038c51249ba5d61bfd98c46e69cfdf5c316"
[[asset]]
path = "assets/app-icon/pdfcraft-document.ico"
@@ -2945,106 +2675,106 @@ licence = "ISC AND (MIT OR Apache-2.0)"
licence_file = "assets/app-icon/LICENSE-document.txt"
kind = "icon"
usage = "Embedded in pdfcraft.exe as resource 2; the Windows installer uses it for PdfCraft.Document and Applications/pdfcraft.exe PDF file associations"
-sha256 = "ba3f9128b368fc6d17d376cf62dbdbc22455451c5c8c4779210d7a2a7409b9e6"
+sha256 = "910be5e0ecdc94ce00368077ca1bef449bd5d62a75516feeb37884296c6fac4d"
[[asset]]
path = "assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png"
title = "PdfCraft app icon, 16 px"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "9b77095cbe75dcba6b74a1fdcd4064812850efaf7a12edebefc9ded5eabb49da"
+sha256 = "660d2aace45ec1a28bc7e3708eab71ffee5cb1203f57a6256f7bd7c727193567"
[[asset]]
path = "assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png"
title = "PdfCraft app icon, 24 px"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "a4cd466687f6c5cb59eef3fc564bdba1c8f0d9e324754eeebab82f18823efae7"
+sha256 = "1989441bdcc8d1df0d73cfe7eb836e9211abaa0fd7fd6ec7e4634bfcd472995d"
[[asset]]
path = "assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png"
title = "PdfCraft app icon, 32 px"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "e22279013a5ed5c02571e1e4e9bc4cef9aa0847fcb733a285b2916fabd16bf78"
+sha256 = "7fd1ac555f7f6b13ef3fba2696dfb86900cfcb7f7b669aece38590bf2306cf2e"
[[asset]]
path = "assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png"
title = "PdfCraft app icon, 48 px"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "55daf2d5575f6c4e4c4975c9f1c74231178f1801963a8b19f68dfa9bc50cb22f"
+sha256 = "784ef3bd9f9e479cb2df22a0aee259a441a9c358390e646639c3a0ed79d41288"
[[asset]]
path = "assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png"
title = "PdfCraft app icon, 64 px"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "2975c200f341e247b5fa18f568fb27d390e3fa01e9b2ad37acb48d0e1cc8a124"
+sha256 = "deb1a9f849549d3f0652fb6d3c30b6eb6ea54498eaea8c1007d901bbafe7067c"
[[asset]]
path = "assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png"
title = "PdfCraft app icon, 128 px"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "6ef927ca252b40bc9b5d8843d84137334362f8aa1acbb1bf8c15a02affd25b2b"
+sha256 = "1933bbeaea290497facd328e917dafcc433d0c0caa9f1e207aa45ac1e430898c"
[[asset]]
path = "assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png"
title = "PdfCraft app icon, 256 px"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop); runtime window icon on Windows and Linux (apps/pdfcraft/src/main.rs)"
-sha256 = "cde7ee8c5ee38f9fc40a429fd9e6d99a035bcd321701fce331bb13bac126614d"
+sha256 = "47094c09fb77eb35d465eee46445626a4d78c7ef50629cc7d04a602de3e144e9"
[[asset]]
path = "assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png"
title = "PdfCraft app icon, 512 px"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "5ede733e93b96f4eb8d94299232cf2f9605fa19b0d913f95fe9828c316a2d066"
+sha256 = "dea8010a8b41011aba054d3bfb8b790da57bc958c60869f508e1bbd288d3f280"
[[asset]]
path = "assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg"
title = "PdfCraft app icon (Linux scalable)"
-author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)"
+author = "PdfCraft contributors (drawn by the project owner in ArtCraft)"
source = "Contributor-original (see assets/app-icon/README.md)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "icon"
usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)"
-sha256 = "52b384a10a2a7fdd3145d9822972cfd568ef0cb7312549bd4399addd89d45a6d"
+sha256 = "27745c021830f82c033b0f15f2d8d9f8e099138743297887ff7e2c9e7ed069da"
[[asset]]
path = "docs/images/pdfcraft-bookmarks.png"
@@ -3194,7 +2924,7 @@ path = "assets/icons/pin.svg"
title = "Lucide icon \"pin\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pin.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -3205,7 +2935,7 @@ path = "assets/icons/move-right.svg"
title = "Lucide icon \"move-right\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-right.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -3216,7 +2946,7 @@ path = "assets/icons/reply.svg"
title = "Lucide icon \"reply\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/reply.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -3227,7 +2957,7 @@ path = "assets/icons/thumbs-up.svg"
title = "Lucide icon \"thumbs-up\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-up.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -3238,7 +2968,7 @@ path = "assets/icons/thumbs-down.svg"
title = "Lucide icon \"thumbs-down\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -3249,7 +2979,7 @@ path = "assets/icons/message-square-plus.svg"
title = "Lucide icon \"message-square-plus\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -3260,7 +2990,7 @@ path = "assets/icons/circle-x.svg"
title = "Lucide icon \"circle-x\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -3282,7 +3012,7 @@ path = "assets/icons/pentagon.svg"
title = "Lucide icon \"pentagon\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -3293,7 +3023,7 @@ path = "assets/icons/message-square-quote.svg"
title = "Lucide icon \"message-square-quote\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -3315,7 +3045,7 @@ path = "assets/icons/clipboard-paste.svg"
title = "Lucide icon \"clipboard-paste\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -3341,7 +3071,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "image"
usage = "Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py"
-sha256 = "e8b9ea9d5e64b7b741c977543c1693825e77d5c4c579eeb674bf5dcd3764cd8a"
+sha256 = "182663e4d1c4fabbb7ab2e1f0d4e991a8e0859b4a8dee6934d3e25d77ea020af"
[[asset]]
@@ -3353,7 +3083,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "image"
usage = "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"
-sha256 = "f46881724a6621f3686a612a79dbdce4f7dbe9e2025610e44c9a094341334c2c"
+sha256 = "bedf1b23707b9e34157440e83f65464ff314fa9f6b1d39c7a8930d1645c2f8be"
[[fetched]]
file = "text-detection.onnx"
@@ -3381,6 +3111,17 @@ kind = "model"
usage = "Scan & OCR: reads the words it finds (cargo xtask models)"
sha256 = "86c145c2edb96c8caed5b1ebb8f44d706408922211451c309c625157dd6061c5"
+[[build_input]]
+name = "craft-i18n"
+option = "CRAFT_I18N_DIR"
+title = "Interface translations: the craft-i18n catalogs (locales//common.tsv and pdfcraft.tsv) of the 34 languages PdfCraft offers besides English"
+author = "The craft-i18n translators, including every translator of PdfCraft's former in-repo catalogs (crates/ui-egui/src/i18n/*.tsv, migrated at pdfcraft@ecdd1e6)"
+source = "https://github.com/storytold/craft-i18n (pinned by commit in craft-i18n.rev, which CI, releases and the flatpak manifests read)"
+licence = "MIT OR Apache-2.0"
+attribution = "https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md"
+kind = "translation"
+usage = "Interface text in languages other than English, embedded by crates/ui-egui/build.rs and looked up through craft-libs' craft-i18n crate; a build without CRAFT_I18N_DIR is English only. Clean-room translations from PdfCraft's English interface labels"
+
[[build_input]]
name = "craft-fonts"
option = "CRAFT_FONTS_DIR"
@@ -3408,32 +3149,12 @@ path = "assets/icons/monitor.svg"
title = "Lucide icon \"monitor\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428"
-[[asset]]
-path = "crates/ui-egui/src/i18n/vi.tsv"
-title = "Vietnamese interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/sl.tsv"
-title = "Slovene interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
[[asset]]
path = "vendor/vello_common/assets/probe.png"
@@ -3446,22 +3167,12 @@ kind = "image"
usage = "Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file"
sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697"
-[[asset]]
-path = "crates/ui-egui/src/i18n/nb.tsv"
-title = "Norwegian Bokmål interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
[[asset]]
path = "assets/icons/superscript.svg"
title = "Lucide icon \"superscript\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -3472,7 +3183,7 @@ path = "assets/icons/subscript.svg"
title = "Lucide icon \"subscript\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
@@ -3483,49 +3194,31 @@ path = "assets/icons/pilcrow.svg"
title = "Lucide icon \"pilcrow\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "d344d9f9222f6c1b698c0c82a753a2212d9bd7785f06c00cd6e4910fe2fee8f8"
+[[asset]]
+path = "assets/icons/a-large-small.svg"
+title = "Lucide icon \"a-large-small\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/a-large-small.svg)"
+licence = "ISC AND MIT"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/widgets/icon_data.rs"
+sha256 = "d4985fc71174d108e322c3b25cb6abe2837a115fe3f239a1918645db436743ec"
+
[[asset]]
path = "assets/icons/move-horizontal.svg"
title = "Lucide icon \"move-horizontal\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg)"
-licence = "ISC"
+licence = "ISC AND MIT"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7"
-[[asset]]
-path = "crates/ui-egui/src/i18n/sq.tsv"
-title = "Albanian interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/th.tsv"
-title = "Thai interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs"
-
-[[asset]]
-path = "crates/ui-egui/src/i18n/ca.tsv"
-title = "Catalan interface translation catalog"
-author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
-licence = "MIT OR Apache-2.0"
-licence_file = "LICENSE-MIT"
-kind = "translation"
-usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs"
diff --git a/CLAUDE.md b/CLAUDE.md
index a37cec4ca6..5c7034b7a4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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 press is its own or, for the active tool, nobody's: the page view tells it which (`PagePress`: its own, nobody's, or another page's), and it never tests the press point again or asks 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_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it.
- **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it.
- **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.
diff --git a/Cargo.lock b/Cargo.lock
index 4f8e8ac8cd..573b215b7c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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.18.1"
dependencies = [
"chrono",
"craft-update",
@@ -4229,7 +4232,7 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -4241,7 +4244,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -4252,7 +4255,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -4263,7 +4266,7 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"base64 0.23.1",
"flate2",
@@ -4274,6 +4277,8 @@ dependencies = [
"pdfcraft-model",
"pdfcraft-organize",
"pdfcraft-platform",
+ "pdfcraft-prc",
+ "pdfcraft-prc-render",
"pdfcraft-render",
"pdfcraft-xfa",
"png 0.18.1",
@@ -4285,7 +4290,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cli"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"base64 0.23.1",
"pdfcraft-audit",
@@ -4303,11 +4308,11 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
-version = "0.14.0"
+version = "0.18.1"
[[package]]
name = "pdfcraft-content"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -4315,7 +4320,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cos"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"chrono",
"hayro-syntax",
@@ -4330,14 +4335,17 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
-version = "0.14.0"
+version = "0.18.1"
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 +4354,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"aes",
"md-5",
@@ -4358,7 +4366,7 @@ dependencies = [
[[package]]
name = "pdfcraft-edit"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"hayro-syntax",
"kurbo 0.13.1",
@@ -4367,16 +4375,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.18.1"
dependencies = [
"chrono",
"craft-docpath",
+ "getrandom 0.4.3",
"icu_properties",
"idna",
"image",
@@ -4384,10 +4396,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 +4428,19 @@ dependencies = [
[[package]]
name = "pdfcraft-export"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
+ "fax",
"flate2",
+ "hayro-ccitt",
+ "hayro-jbig2",
"proptest",
"thiserror 2.0.21",
"weezl 0.2.1",
@@ -4431,7 +4448,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -4441,7 +4458,7 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4454,11 +4471,11 @@ dependencies = [
[[package]]
name = "pdfcraft-geom"
-version = "0.14.0"
+version = "0.18.1"
[[package]]
name = "pdfcraft-js"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"boa_engine",
"chrono",
@@ -4466,7 +4483,7 @@ dependencies = [
[[package]]
name = "pdfcraft-measure"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4479,14 +4496,14 @@ dependencies = [
[[package]]
name = "pdfcraft-model"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"ocrs",
"pdfcraft-fonts",
@@ -4497,7 +4514,7 @@ dependencies = [
[[package]]
name = "pdfcraft-optimize"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"image",
"pdfcraft-annot",
@@ -4506,17 +4523,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.18.1"
[[package]]
name = "pdfcraft-organize"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4527,7 +4545,7 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"async-channel",
"futures-lite",
@@ -4535,20 +4553,38 @@ dependencies = [
"zbus",
]
+[[package]]
+name = "pdfcraft-prc"
+version = "0.18.1"
+dependencies = [
+ "flate2",
+ "proptest",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "pdfcraft-prc-render"
+version = "0.18.1"
+dependencies = [
+ "flate2",
+ "pdfcraft-prc",
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "pdfcraft-preflight"
-version = "0.14.0"
+version = "0.18.1"
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.18.1"
dependencies = [
"craft-print",
"pdfcraft-content",
@@ -4560,12 +4596,13 @@ dependencies = [
[[package]]
name = "pdfcraft-redact"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-edit",
+ "pdfcraft-filters",
"pdfcraft-fonts",
"pdfcraft-forms",
"pdfcraft-model",
@@ -4576,7 +4613,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"bytemuck",
"flate2",
@@ -4595,7 +4632,7 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"aes",
"aws-lc-rs",
@@ -4631,17 +4668,18 @@ dependencies = [
[[package]]
name = "pdfcraft-testkit"
-version = "0.14.0"
+version = "0.18.1"
[[package]]
name = "pdfcraft-ui-egui"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"arboard",
"base64 0.23.1",
"block2 0.6.2",
"bytemuck",
"chrono",
+ "craft-i18n",
"craft-keymap",
"craft-menu",
"craft-quit",
@@ -4659,6 +4697,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 +4708,7 @@ dependencies = [
"plist",
"png 0.18.1",
"pollster 1.0.1",
+ "proptest",
"rfd",
"rustix 1.1.5",
"serde",
@@ -4687,7 +4727,7 @@ dependencies = [
[[package]]
name = "pdfcraft-web"
-version = "0.14.0"
+version = "0.18.1"
dependencies = [
"eframe",
"egui",
@@ -4699,27 +4739,27 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
-version = "0.14.0"
+version = "0.18.1"
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.18.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
"pdfcraft-forms",
"pdfcraft-model",
- "roxmltree 0.20.0",
+ "roxmltree 0.21.2",
"thiserror 2.0.21",
]
@@ -5258,7 +5298,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5795,7 +5835,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -5865,7 +5905,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
- "windows-sys 0.59.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6755,7 +6795,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 +7390,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 +7906,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 +8633,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",
diff --git a/Cargo.toml b/Cargo.toml
index f86ebaa15f..bf6a4e3146 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,7 +5,7 @@ exclude = ["vendor"]
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
[workspace.package]
-version = "0.14.0"
+version = "0.18.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -13,51 +13,58 @@ 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 }
+# Interface translations: lookup, fallback, CLDR plurals and language names over the craft-i18n
+# catalogs that `crates/ui-egui/build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, at
+# the commit in `craft-i18n.rev`). Already in the tree through craft-ui.
+craft-i18n = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
# Shared system spooler; document composition remains in pdfcraft-print.
-craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "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.18.1" }
+pdfcraft-filters = { path = "crates/filters", version = "0.18.1" }
+pdfcraft-cos = { path = "crates/cos", version = "0.18.1" }
+pdfcraft-crypt = { path = "crates/crypt", version = "0.18.1" }
+pdfcraft-organize = { path = "crates/organize", version = "0.18.1" }
+pdfcraft-measure = { path = "crates/measure", version = "0.18.1" }
+pdfcraft-annot = { path = "crates/annot", version = "0.18.1" }
+pdfcraft-audit = { path = "crates/audit", version = "0.18.1" }
+pdfcraft-fonts = { path = "crates/fonts", version = "0.18.1" }
+pdfcraft-model = { path = "crates/model", version = "0.18.1" }
+pdfcraft-content = { path = "crates/content", version = "0.18.1" }
+pdfcraft-redact = { path = "crates/redact", version = "0.18.1" }
+pdfcraft-print = { path = "crates/print", version = "0.18.1" }
+pdfcraft-xfdf = { path = "crates/xfdf", version = "0.18.1" }
+pdfcraft-sign = { path = "crates/sign", version = "0.18.1" }
+pdfcraft-optimize = { path = "crates/optimize", version = "0.18.1" }
+pdfcraft-a11y = { path = "crates/a11y", version = "0.18.1" }
+pdfcraft-oracle = { path = "crates/oracle", version = "0.18.1" }
+pdfcraft-testkit = { path = "crates/testkit", version = "0.18.1" }
+pdfcraft-ocr = { path = "crates/ocr", version = "0.18.1" }
+pdfcraft-js = { path = "crates/js", version = "0.18.1" }
+pdfcraft-compare = { path = "crates/compare", version = "0.18.1" }
+pdfcraft-preflight = { path = "crates/preflight", version = "0.18.1" }
+pdfcraft-export = { path = "crates/export", version = "0.18.1" }
+pdfcraft-edit = { path = "crates/edit", version = "0.18.1" }
+pdfcraft-create = { path = "crates/create", version = "0.18.1" }
+pdfcraft-forms = { path = "crates/forms", version = "0.18.1" }
+pdfcraft-xfa = { path = "crates/xfa", version = "0.18.1" }
+pdfcraft-render = { path = "crates/render", version = "0.18.1" }
+pdfcraft-engine = { path = "crates/engine", version = "0.18.1" }
+pdfcraft-platform = { path = "crates/platform", version = "0.18.1" }
+pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.18.1" }
+pdfcraft-automation = { path = "crates/automation", version = "0.18.1", default-features = false }
+pdfcraft-prc = { path = "crates/prc", version = "0.18.1" }
+pdfcraft-prc-render = { path = "crates/prc-render", version = "0.18.1" }
# pdf
hayro = "0.7.1"
@@ -65,6 +72,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 +140,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
diff --git a/LICENSES.toml b/LICENSES.toml
new file mode 100644
index 0000000000..0617ca5d57
--- /dev/null
+++ b/LICENSES.toml
@@ -0,0 +1,166 @@
+# 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 = "licenses/prc-rs-LICENSE.txt"
+licences = ["MIT"]
+destination = "licenses/prc-rs-LICENSE.txt"
+sha256 = "ddb846513d92153d8fa16b18ed3e7c86953a582f0d2f49f02b29f1bd569dac58"
+origin = "https://raw.githubusercontent.com/ralovich/prc-rs/287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d/LICENSE"
+
+[[document]]
+source = "vendor/hayro-interpret/LICENSE-APACHE"
+licences = ["Apache-2.0"]
+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"]
diff --git a/NOTICE b/NOTICE
index 63a8fe672c..e8f31ade2f 100644
--- a/NOTICE
+++ b/NOTICE
@@ -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
diff --git a/README.md b/README.md
index 1766e26ebb..f9a41cb3ca 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-
+
PdfCraft
@@ -21,10 +21,10 @@
-
-
-
-
+
+
+
+
@@ -94,7 +94,7 @@ Real-world typography: world scripts, vertical Japanese, colour emoji, gradients
### Fearless
-Every save appends your changes and leaves the original bytes untouched. Writes are atomic, undo runs deep, and nothing is lost if you close by mistake.
+Ordinary saves append your changes and leave the original bytes untouched. Edits to damaged objects whose xref and header generations disagree use a full rewrite, keeping both accepted references connected to the same repaired object. Writes are atomic, undo runs deep, and nothing is lost if you close by mistake.
@@ -140,6 +140,8 @@ Search the whole document as you type, step through matches with ⌘G
## 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.
| | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
-| | **PdfCraft** | **Reading, organizing and protecting PDFs · you are here** | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
+| | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
+| | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
+| | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
+| | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
+| | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
+| | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
+| | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
+| | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
+| | **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) |
+| | **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.
diff --git a/ROADMAP.md b/ROADMAP.md
index 91e8bf1ece..64d5d8316d 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,7 +1,34 @@
# PdfCraft roadmap
+> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged)
+
+> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged)
+
+> **Last reviewed:** 2026-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)
+
+> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** patch (M1, #1143: repaired aliases survive preflight, snapshots and normalized saves; retained materialized revisions report generation disagreements; estimates unchanged)
+
+> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M1, #1143: explicit live xref/header generation repair policy and bounded diagnostics; estimates unchanged)
+
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: refresh object-generation reference identity against current upstream; estimates unchanged)
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
+> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M2: scans keep their strokes at screen size and are smoothed when zoomed; M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged)
> **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged)
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged)
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (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)
@@ -22,6 +49,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)
@@ -96,8 +124,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)) |
@@ -152,7 +180,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 |
@@ -202,11 +230,61 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-10-28 (M10, #4119 partial):** Scan & OCR ▸ Enhance scanned file cleans scanned pages in place: background removal (level 0–100, as the camera clean-up), edge shadow removal (book gutters, copier borders) and despeckle. Each scan image is decoded, cleaned and written back into its own object at the same size and colour space (JPEG stays JPEG, others lossless, 1-bit stays 1-bit), so the OCR text layer, comments and links stay; one undo step, shared images once, unsupported images (CMYK, indexed, JPEG 2000, JBIG2, CCITT, broken data) reported. `page_enhance_scan` drives it headlessly. Not yet an option of Recognize text. Estimates unchanged.
+- **2026-11-03 (M11, shared JBIG2 dictionary):** With Monochrome Images set to JBIG2, Optimize has up to 16 JBIG2 images share one symbol dictionary (`/JBIG2Globals`), the letters found on more than one page stored once, when that is smaller (`pdfcraft_filters::encode_jbig2_shared`). Eight pages of 10 pt text rendered at 300 dpi take 41% less in Helvetica and 42% in Times than coded alone, 10% in Courier; groups of 16 save 45%, of 32 only 47%. Only images redaction can store again without the dictionary share one (image masks and DeviceGray named directly, at most 64 Mi pixels), and redacting one stores the rest of its group so, which the group bounds to 15.
+
+- **2026-11-03 (M5, #5850 review):** Resolve indirect IT when recognizing typewriter contents and Fill & Sign marks. Comment listing and property lookup retain their existing direct-Subtype behavior. A real double-click Edit composer preserves the typewriter’s text as written; ordinary comment edits keep trimming. Regressions cover direct Subtype with indirect intent, save/reopen, raw posted contents and same-index comments on different pages. The existing live-movement test now commits its setup with an outside click rather than Enter. Estimates unchanged.
+
+- **2026-11-03 (M5, #5850):** Fill & Sign Enter inserts a line break in a growing editor; clicking outside commits and Escape cancels. Nonempty text keeps leading, internal and trailing line breaks. The headless fill_sign_add path sizes its typewriter box for the same wrapped lines so saved appearances show every row. Production-order keyboard and engine save/reopen/render regressions exercise the UI and headless callers. Estimates unchanged.
+
+- **2026-11-03 (M7, #5835):** In image crop mode, a right-click on the image, its crop box or its handles keeps the crop and opens no menu, so the menu no longer switches as the pointer moves. A right-click elsewhere clears the selection and opens the page menu. UI tests cover all four page rotations, overlapping images, a 45° image, and a check that the Done/Cancel/Remove Crop pills at a page edge still keep the crop.
+
+- **2026-11-02 (M12, #1297):** `model_add` embeds a U3D or PRC file, recognised by its header, as a 3D annotation with a chosen activation and a poster (an image or PDF page fitted in the box, or a labelled frame); an older file's catalog `/Version` is raised to what the model needs. Views, a rendered poster and a UI command are later steps. Estimates unchanged.
+
+- **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer.
+
+- **2026-11-02 (M14, #5737, #5460):** Preferences can turn off the update check at start. It stays on by default, and Help ▸ Check for updates still asks.
+
+- **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged.
+
+- **2026-11-01 (M14, #5572):** Preferences ▸ Documents and view sets how many recent files to keep, from 0 to 50 with 12 by default. At 0 Home has no Recent section and File ▸ Open Recent is disabled. The list is cut when Preferences closes, so the number can pass through 0 on its way to another.
+
+- **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-29 (M2):** Scanned pages look like the paper again. An image that doesn't ask for /Interpolate was shrunk by nearest-neighbour (since #635), which kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes; it is now area-averaged to whole device pixels (on a 13-page 300 dpi scan every sample at 96–192 dpi is within 1 of the exact area average; it was up to 255 off). Enlarged, an image of page-scan size is smoothed bilinearly, so a zoomed scan has no staircases; icons, QR codes and pixel art keep hard pixels. Estimates unchanged.
+- **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged.
+- **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged.
- **2026-10-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.
@@ -222,8 +300,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.
@@ -265,6 +346,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-20 (M5, arrow keys move comments):** With a comment selected, the arrow keys move it a screen pixel and ⌘/Ctrl or Shift and an arrow ten, instead of scrolling the view or turning the page; up is up on screen at any zoom and rotation, and each press is one undo step. Text markup and locked comments stay put. Estimates unchanged.
+- **2026-10-20 (M1, PR #1143 review):** Repair-aware dictionary/preflight edits and snapshot restoration preserve both accepted generations of a damaged live slot. Such edits use the existing full-save fallback; classic and packed rewrites coalesce aliases so page-tree parent links retain one object identity. Explicit generation replacement remains single-generation. Materialized xref diagnostics inspect retained primary and hybrid rows before folding, retaining the 256-object cap plus summary and discarding abandoned-index notes. Synthetic red/green controls cover preflight, undo restoration, equal values, explicit narrowing, encryption packing and four save variants. Estimates unchanged.
+
- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
- **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged.
@@ -302,6 +385,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.
@@ -327,6 +411,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged.
- **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged.
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
+- **2026-10-18 (M1, PR #1143 repair policy):** A live in-file reference resolves when its generation matches the xref row or a same-number header at that indexed offset. Neither-match and free slots remain null, including when old object bytes remain. Opening records bounded xref/header disagreements without parsing object bodies or expanding compact xref rows. Synthetic controls cover read order, clones, overlays, dictionary edits, full classic/packed saves and an independent reader of incremental output. **Correction to the 2026-10-15 note below:** its async-opening-test sentence was not evidenced by this COS change; no fresh async-opening or GUI validation is claimed. The full-reference stream-length recursion key is defensive hardening, not an independently isolated regression fix. Estimates unchanged.
+- **2026-10-15 (M1, PR #1143 refresh):** COS reference lookups and full saves distinguish complete object references, so stale generations cannot expose replacement content. Snapshot restoration also keeps edits to objects with nonzero generations before incremental saves. Synthetic regressions cover caches, overlays, snapshots, stream lengths and classic/packed saves. Current upstream async-opening test corrections are included. Estimates unchanged.
- **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are unchanged.
- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged.
- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor).
@@ -342,6 +428,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.
@@ -451,6 +539,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.
@@ -513,10 +602,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%).
@@ -686,6 +775,10 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
+| 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. |
+| 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. |
+| 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. |
+| 2026-10-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and estimates unchanged. |
| 2026-10-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. |
@@ -698,6 +791,7 @@ Newest first: the date, what moved, and the new overall estimate.
| 2026-10-20 | minor | M6, #3502: form detection preserves comments across content streams; estimates unchanged. |
| 2026-10-20 | minor | M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged. |
| 2026-10-20 | minor | M11 #3507: image merging preserves comments and state across content streams. Estimates unchanged. |
+| 2026-10-18 | minor | M1, #1143: a live reference may match the xref generation or a same-number header at its indexed offset; neither-match and free slots remain null. Bounded repair diagnostics and synthetic cache, free-slot, edit and classic/packed save regressions; estimates unchanged. |
| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
| 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. |
@@ -708,6 +802,7 @@ Newest first: the date, what moved, and the new overall estimate.
| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. |
| 2026-10-16 | minor | M14: Czech catalog completed (2,674 entries) and "tag" terminology unified. Estimates unchanged. |
| 2026-10-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged |
+| 2026-10-15 | minor | M1: refresh object-generation reference identity, retaining current upstream progress and regression tests. Estimates unchanged. |
| 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. |
| 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. |
| 2026-10-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. Estimates unchanged. |
@@ -724,11 +819,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. |
@@ -750,3 +845,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.
diff --git a/apps/pdfcraft-cli/tests/it/text.rs b/apps/pdfcraft-cli/tests/it/text.rs
index 3d39a9e284..b7fb2511b7 100644
--- a/apps/pdfcraft-cli/tests/it/text.rs
+++ b/apps/pdfcraft-cli/tests/it/text.rs
@@ -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![
+ 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:?}");
+ }
+ }
}
diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs
index f8e4e5b0d2..5bc5c5c372 100644
--- a/apps/pdfcraft/src/main.rs
+++ b/apps/pdfcraft/src/main.rs
@@ -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| --mode all|read|edit|convert|sign --tool --left open|closed
//! --organize on --fields on --dialog properties|shortcuts|about --palette --home on
//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page| --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 enable the UI control channel (pdfcraft-cli ui --control …)
@@ -196,7 +200,7 @@ fn rejoin_split_paths(args: Vec, exists: impl Fn(&str) -> bool) -> Vec 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::>()) {
+ if let Some(result) = craft_update::self_update::finish(&pdfcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) {
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 = 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,
+ 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}");
}
}
diff --git a/apps/pdfcraft/src/startup.rs b/apps/pdfcraft/src/startup.rs
new file mode 100644
index 0000000000..be25da45b6
--- /dev/null
+++ b/apps/pdfcraft/src/startup.rs
@@ -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,
+ pub options: Vec<(String, String)>,
+ pub control_file: Option,
+ 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- ) -> 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();
+ }
+}
diff --git a/apps/pdfcraft/src/tests.rs b/apps/pdfcraft/src/tests.rs
index c6e6f36518..3512f46b36 100644
--- a/apps/pdfcraft/src/tests.rs
+++ b/apps/pdfcraft/src/tests.rs
@@ -663,6 +663,29 @@ fn the_desktop_entry_names_the_window_class() {
assert_eq!(class, Some(super::APP_ID));
}
+/// Both flatpak manifests build with the craft-i18n commit `craft-i18n.rev` pins: CI and
+/// releases read the file, and the manifests have to name the commit themselves. The pin may
+/// have a trailing newline or CRLF (a Windows checkout), so it is trimmed, as every reader does.
+#[test]
+fn the_flatpak_manifests_pin_the_craft_i18n_rev() {
+ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
+ let rev = std::fs::read_to_string(root.join("craft-i18n.rev")).unwrap();
+ let rev = rev.trim();
+ assert!(rev.len() == 40 && rev.bytes().all(|b| b.is_ascii_hexdigit()), "craft-i18n.rev holds one full SHA: {rev:?}");
+ for manifest in ["ai.storyteller.pdfcraft.yml", "ai.storyteller.pdfcraft.bundle.yml"] {
+ let text = std::fs::read_to_string(root.join("packaging/linux/flatpak").join(manifest)).unwrap();
+ let source =
+ text.split("url: https://github.com/storytold/craft-i18n").nth(1).unwrap_or_else(|| panic!("{manifest}: a craft-i18n git source"));
+ let source = source.split("- type:").next().unwrap_or(source);
+ let commit = source
+ .lines()
+ .find_map(|l| l.trim().strip_prefix("commit:"))
+ .unwrap_or_else(|| panic!("{manifest}: the craft-i18n source pins a commit"));
+ assert_eq!(commit.trim().trim_matches(['\'', '"']), rev, "{manifest}");
+ assert!(text.contains("CRAFT_I18N_DIR: /run/build/pdfcraft/craft-i18n"), "{manifest}: the build names the catalogs");
+ }
+}
+
// Regression test for #246: `eprintln!` panics when standard error is a broken pipe (a GUI
// launch on Windows), and a panic inside the panic hook aborts the process. The start-up
// path and the hook must write to standard error without it.
diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md
index 10fb2d4cf3..7c17ad0045 100644
--- a/assets/app-icon/README.md
+++ b/assets/app-icon/README.md
@@ -1,6 +1,6 @@
# PdfCraft app icon
-
+
**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/x/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) |
diff --git a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png
index 20f4ca4393..b4ac96bc47 100644
Binary files a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png
index be76e69b86..c3f4315212 100644
Binary files a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png
index a00f8f9501..5ae21c7ded 100644
Binary files a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png
index be413a715a..766624ec8c 100644
Binary files a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png
index f9e2ef2b6e..ab6e8e9312 100644
Binary files a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png
index 73971ee5f9..55256b293d 100644
Binary files a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png
index 1d9ff4d11c..eb3699f9b8 100644
Binary files a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png
index 14444f01bc..9bba411e39 100644
Binary files a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png differ
diff --git a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg
index 3a1de9f076..5a8017ec4f 100644
--- a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg
+++ b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/app-icon/pdfcraft-1024.png b/assets/app-icon/pdfcraft-1024.png
index 3c79b60279..d6cf213765 100644
Binary files a/assets/app-icon/pdfcraft-1024.png and b/assets/app-icon/pdfcraft-1024.png differ
diff --git a/assets/app-icon/pdfcraft-document.ico b/assets/app-icon/pdfcraft-document.ico
index 79967a800a..ec1ea4568e 100644
Binary files a/assets/app-icon/pdfcraft-document.ico and b/assets/app-icon/pdfcraft-document.ico differ
diff --git a/assets/app-icon/pdfcraft-document.svg b/assets/app-icon/pdfcraft-document.svg
index 603a740157..b9d2fbf3c9 100644
--- a/assets/app-icon/pdfcraft-document.svg
+++ b/assets/app-icon/pdfcraft-document.svg
@@ -4,10 +4,10 @@
-
-
+
+
-
+
diff --git a/assets/app-icon/pdfcraft-small.svg b/assets/app-icon/pdfcraft-small.svg
index fb35ea2912..a8d6517b54 100644
--- a/assets/app-icon/pdfcraft-small.svg
+++ b/assets/app-icon/pdfcraft-small.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/app-icon/pdfcraft.icns b/assets/app-icon/pdfcraft.icns
index 08369d5dd0..c41d1d10f0 100644
Binary files a/assets/app-icon/pdfcraft.icns and b/assets/app-icon/pdfcraft.icns differ
diff --git a/assets/app-icon/pdfcraft.ico b/assets/app-icon/pdfcraft.ico
index 6e1971d4a5..eedf34883a 100644
Binary files a/assets/app-icon/pdfcraft.ico and b/assets/app-icon/pdfcraft.ico differ
diff --git a/assets/app-icon/pdfcraft.svg b/assets/app-icon/pdfcraft.svg
index 3a1de9f076..5a8017ec4f 100644
--- a/assets/app-icon/pdfcraft.svg
+++ b/assets/app-icon/pdfcraft.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/icons/a-large-small.svg b/assets/icons/a-large-small.svg
new file mode 100644
index 0000000000..7716db3cf2
--- /dev/null
+++ b/assets/icons/a-large-small.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/contributors/people.toml b/contributors/people.toml
index dd75a07314..fecfb68c90 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -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
diff --git a/craft-i18n.rev b/craft-i18n.rev
new file mode 100644
index 0000000000..77b59ea817
--- /dev/null
+++ b/craft-i18n.rev
@@ -0,0 +1 @@
+575cfdef63227b14daf173b8cc87928bfdfa325e
diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs
index 720b8a6a1e..fde1048e47 100644
--- a/crates/a11y/src/alt.rs
+++ b/crates/a11y/src/alt.rs
@@ -125,9 +125,9 @@ pub(crate) fn elements(doc: &Document) -> Vec {
}
/// The marked-content ids an element owns directly.
-fn mcids(d: &Dict) -> Vec {
+fn mcids(doc: &Document, d: &Dict) -> Vec {
let mut out = Vec::new();
- let mut push = |o: &Object| match o {
+ let mut push = |o: &Object| match &*doc.resolve(o) {
Object::Int(n) => out.push(*n),
Object::Dict(m) if m.name(b"Type") == Some(b"MCR") => {
if let Some(n) = m.get(b"MCID").and_then(Object::as_int) {
@@ -323,7 +323,7 @@ pub fn figures(doc: &Document) -> Vec {
let mut wanted: HashMap> = HashMap::new();
for (e, page) in &figures {
if let Some(p) = page {
- wanted.entry(*p).or_default().extend(mcids(&e.dict));
+ wanted.entry(*p).or_default().extend(mcids(doc, &e.dict));
}
}
let bounds: HashMap> =
@@ -333,7 +333,7 @@ pub fn figures(doc: &Document) -> Vec {
.map(|(e, page)| {
let alt = e.dict.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text()));
let on_page = page.and_then(|p| bounds.get(&p));
- let bbox = on_page.and_then(|b| mcids(&e.dict).iter().filter_map(|id| b.get(id)).fold(None, |acc, b| Some(union(acc, *b))));
+ let bbox = on_page.and_then(|b| mcids(doc, &e.dict).iter().filter_map(|id| b.get(id)).fold(None, |acc, b| Some(union(acc, *b))));
Figure { obj: e.object, page, alt, bbox }
})
.collect()
@@ -363,52 +363,73 @@ pub fn set_alt(doc: &mut Document, obj: ObjRef, alt: Option<&str>) -> Result<(),
}
/// The page's content entries: one stream, or the elements of a contents array.
-fn content_entries(doc: &Document, page: &Dict) -> Option> {
- let c = page.get(b"Contents")?;
- Some(match &*doc.resolve(c) {
+fn content_entries(doc: &Document, page: &Dict) -> Vec {
+ let Some(c) = page.get(b"Contents") else { return Vec::new() };
+ match &*doc.resolve(c) {
Object::Array(a) => a.clone(),
Object::Stream(_) => vec![c.clone()],
- _ => return None,
- })
+ _ => Vec::new(),
+ }
}
-fn marked(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &HashSet) -> bool {
- op.op == b"BDC" && bdc_mcid(doc, props, op).is_some_and(|n| ids.contains(&n))
-}
+type DecodedStream = (Object, Option<(Stream, Vec)>);
-/// Turn the figure's marked-content blocks into artifacts, rewriting only the streams that
-/// contain them. Other content streams, and unknown entries on a rewritten stream, stay.
-fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: &HashSet) -> Result {
- let Some(entries) = content_entries(doc, &page.dict) else { return Ok(false) };
- let props = page_properties(doc, &page.dict);
- let mut streams: Vec)>> = Vec::with_capacity(entries.len());
+/// Decode the page's content streams within the page decode budget. `None` when a stream is
+/// rejected because the budget is exhausted.
+fn decode_page_streams(doc: &Document, page: &Dict) -> Option> {
+ let entries = content_entries(doc, page);
+ let mut streams = Vec::with_capacity(entries.len());
let mut budget = DecodeBudget::page();
- for entry in &entries {
- match &*doc.resolve(entry) {
+ for entry in entries {
+ let decoded = match &*doc.resolve(&entry) {
Object::Stream(s) => {
let data = match budget.decode(s) {
Ok(data) => data,
- // Content past the budget may hold more of the figure: editing the rest
- // would leave that part marked as a figure the tree no longer has.
- Err(_) if budget.exhausted() => return Err(AltError::TooLarge),
+ // Content past the budget may hold more of the tag: editing the rest
+ // would leave that part marked as a tag the tree no longer has.
+ Err(_) if budget.exhausted() => return None,
Err(_) => Vec::new(),
};
- streams.push(Some((s.clone(), data)));
+ Some((s.clone(), data))
}
- _ => streams.push(None),
+ _ => None,
+ };
+ streams.push((entry, decoded));
+ }
+ Some(streams)
+}
+
+fn marked_id(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &HashSet) -> Option {
+ (op.op == b"BDC").then(|| bdc_mcid(doc, props, op)).flatten().filter(|n| ids.contains(n))
+}
+
+/// Turn the tag's marked-content blocks into artifacts, rewriting only the streams that
+/// contain them. Other content streams, and unknown entries on a rewritten stream, stay.
+/// With `require_all`, every id in `ids` must appear on the page before any stream is rewritten.
+fn artifact_marked_content(
+ doc: &mut Document,
+ page: &pdfcraft_model::Page,
+ streams: Vec,
+ ids: &HashSet,
+ require_all: bool,
+) -> Result {
+ let props = page_properties(doc, &page.dict);
+ let decoded: Vec<&[u8]> = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, bytes)| bytes.as_slice())).collect();
+ let pieces = pdfcraft_content::Pieces::join(&decoded);
+ let mut found = HashSet::new();
+ let mut edits = Vec::new();
+ for op in pieces.parse().ops {
+ if let Some(id) = marked_id(doc, &props, &op, ids) {
+ found.insert(id);
+ edits.push((op.span, b"/Artifact BMC".to_vec()));
}
}
- let decoded: Vec<&[u8]> = streams.iter().flatten().map(|(_, bytes)| bytes.as_slice()).collect();
- let pieces = pdfcraft_content::Pieces::join(&decoded);
- let edits: Vec<_> =
- pieces.parse().ops.into_iter().filter(|op| marked(doc, &props, op, ids)).map(|op| (op.span, b"/Artifact BMC".to_vec())).collect();
- if edits.is_empty() {
+ if if require_all { found != *ids } else { found.is_empty() } {
return Ok(false);
}
let mut spliced = pieces.splice(edits).into_iter();
- let mut next = Vec::with_capacity(entries.len());
- let mut changed = false;
- for (entry, stream) in entries.into_iter().zip(streams) {
+ let mut next = Vec::with_capacity(streams.len());
+ for (entry, stream) in streams {
let Some((stream, old)) = stream else {
next.push(entry);
continue;
@@ -421,7 +442,6 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids:
next.push(entry);
continue;
}
- changed = true;
let mut dict = stream.dict;
dict.remove(b"Length");
dict.remove(b"Filter");
@@ -431,28 +451,27 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids:
// appear elsewhere in this array or on another page, where it must stay unchanged.
next.push(Object::Ref(doc.add(stream)));
}
- if changed {
- let contents = match next.as_slice() {
- [one] => one.clone(),
- _ => Object::Array(next),
- };
- doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), contents))?;
- }
- Ok(changed)
+ let contents = match next.as_slice() {
+ [one] => one.clone(),
+ _ => Object::Array(next),
+ };
+ doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), contents))?;
+ Ok(true)
}
/// Decorative figure: its marked content on the page becomes an artifact, and the element
/// leaves the structure tree (and the parent tree).
pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError> {
let d = check_figure(doc, obj)?;
- let ids: HashSet = mcids(&d).into_iter().collect();
+ let ids: HashSet = mcids(doc, &d).into_iter().collect();
let pages = pdfcraft_model::pages(doc);
let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|e| e.object == obj).and_then(|e| e.page));
- if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty())
- && artifact_marked_content(doc, page, &ids)?
- {
+ if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) {
+ let streams = decode_page_streams(doc, &page.dict).ok_or(AltError::TooLarge)?;
// The parent tree no longer points those ids at the figure.
- if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) {
+ if artifact_marked_content(doc, page, streams, &ids, false)?
+ && let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int)
+ {
clear_parent_tree(doc, key, obj)?;
}
}
@@ -484,54 +503,23 @@ pub fn mark_tag_artifact(doc: &mut Document, obj: ObjRef) -> Result<(), Artifact
if has_child_structure(doc, &element.dict) {
return Err(ArtifactError::HasChildren);
}
- let ids: HashSet = mcids(&element.dict).into_iter().collect();
+ let ids: HashSet = mcids(doc, &element.dict).into_iter().collect();
if ids.is_empty() {
return Err(ArtifactError::NoMarkedContent);
}
let pages = pdfcraft_model::pages(doc);
let page_ref = element.page.ok_or(ArtifactError::NoPage)?;
let page = pages.iter().find(|page| page.obj == page_ref).ok_or(ArtifactError::NoPage)?;
- let mut content = Vec::new();
- if let Some(c) = page.dict.get(b"Contents") {
- let mut budget = DecodeBudget::page();
- for data in budget.decode_contents(doc, c) {
- match data {
- Ok(data) => {
- content.extend_from_slice(&data);
- content.push(b' ');
- }
- // The page's /Contents is replaced with what was decoded: content past the
- // budget would be dropped from the page.
- Err(_) if budget.exhausted() => return Err(ArtifactError::TooLarge),
- Err(_) => {}
- }
- }
- }
- let mut ops = pdfcraft_content::parse(&content).ops;
- let mut found = HashSet::new();
- for op in &mut ops {
- let id = (op.op == b"BDC")
- .then(|| match op.operands.get(1) {
- Some(Object::Dict(mark)) => mark.get(b"MCID").and_then(Object::as_int),
- _ => None,
- })
- .flatten()
- .filter(|id| ids.contains(id));
- if let Some(id) = id {
- *op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]);
- found.insert(id);
- }
- }
- if found != ids {
+ let parent = element.dict.get(b"P").and_then(Object::as_ref).ok_or(ArtifactError::NoParent)?;
+ let replacement = replacement_kids(doc, parent, obj)?;
+ let streams = decode_page_streams(doc, &page.dict).ok_or(ArtifactError::TooLarge)?;
+ if !artifact_marked_content(doc, page, streams, &ids, true)? {
return Err(ArtifactError::ContentNotFound);
}
- let stream = doc.add(Object::Stream(Stream::flate(Dict::new(), &pdfcraft_content::serialize_ops(&ops))));
- doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), Object::Ref(stream)))?;
if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) {
clear_parent_tree(doc, key, obj).map_err(|e| ArtifactError::ParentTree(e.to_string()))?;
}
- let parent = element.dict.get(b"P").and_then(Object::as_ref).ok_or(ArtifactError::NoParent)?;
- remove_from_parent(doc, parent, obj)?;
+ doc.update_dict(parent, |p| p.set(b"K".to_vec(), replacement))?;
// Keep the retired object valid if another object holds an out-of-tree reference to it.
doc.update_dict(obj, |d| {
d.remove(b"P");
@@ -551,20 +539,18 @@ fn has_child_structure(doc: &Document, tag: &Dict) -> bool {
tag.get(b"K").is_some_and(|items| child(doc, items))
}
-fn remove_from_parent(doc: &mut Document, parent: ObjRef, obj: ObjRef) -> Result<(), ArtifactError> {
+fn replacement_kids(doc: &Document, parent: ObjRef, obj: ObjRef) -> Result {
let kids = doc.get(parent).as_dict().and_then(|p| p.get(b"K").cloned()).ok_or(ArtifactError::NoParent)?;
- let replacement = match kids {
+ match kids {
Object::Array(items) => {
if items.iter().filter(|item| item.as_ref() == Some(obj)).count() != 1 {
return Err(ArtifactError::AmbiguousParent);
}
- Object::Array(items.into_iter().filter(|item| item.as_ref() != Some(obj)).collect())
+ Ok(Object::Array(items.into_iter().filter(|item| item.as_ref() != Some(obj)).collect()))
}
- Object::Ref(r) if r == obj => Object::Array(Vec::new()),
- _ => return Err(ArtifactError::NoParent),
- };
- doc.update_dict(parent, |p| p.set(b"K".to_vec(), replacement))?;
- Ok(())
+ Object::Ref(r) if r == obj => Ok(Object::Array(Vec::new())),
+ _ => Err(ArtifactError::NoParent),
+ }
}
/// Replace `obj` by null in the page's parent-tree array (`key`), in a flat /Nums tree.
diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs
index 41c749ed3c..0fa31ccbb1 100644
--- a/crates/a11y/src/headings.rs
+++ b/crates/a11y/src/headings.rs
@@ -188,7 +188,10 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap {
// Where each id's last glyph ended, in user space.
let mut ends: HashMap = HashMap::new();
let mut gs = TextState::default();
- let mut saved: Vec = 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 {
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" => {
diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs
index a6dcdba199..3024319ec3 100644
--- a/crates/a11y/src/tests.rs
+++ b/crates/a11y/src/tests.rs
@@ -1017,6 +1017,76 @@ fn named_property_lists_are_found_and_marked_decorative_like_inline_ones() {
assert!(props.contains(b"P0") && props.contains(b"P1") && props.contains(b"Other"), "resources are left as they were");
}
+#[test]
+fn mark_tag_artifact_finds_named_property_lists_and_indirect_mcrs() {
+ use pdfcraft_cos::{Dict, ObjRef, Object};
+ let mut doc = named_figures_doc();
+ let mcr = doc.add(Object::Dict([(b"Type".to_vec(), Object::name("MCR")), (b"MCID".to_vec(), Object::Int(0))].into_iter().collect::()));
+ doc.update_dict(ObjRef::new(7, 0), |d| d.set(b"K".to_vec(), Object::Ref(mcr))).unwrap();
+ assert_eq!(figures(&doc)[0].bbox, Some([10.0, 150.0, 50.0, 170.0]), "indirect MCR is resolved for figure bounds");
+
+ for (tag, gone) in [(ObjRef::new(7, 0), "/P0"), (ObjRef::new(8, 0), "/P1")] {
+ mark_tag_artifact(&mut doc, tag).unwrap();
+ let pages = pdfcraft_model::pages(&doc);
+ let c = doc.resolve(pages[0].dict.get(b"Contents").unwrap());
+ let Object::Stream(c) = &*c else { panic!("one content stream") };
+ let text = String::from_utf8(c.decoded().unwrap()).unwrap();
+ assert!(text.contains("/Artifact BMC") && !text.contains(gone), "{text}");
+ }
+ assert_eq!(tags(&doc).into_iter().map(|t| t.object.num).collect::>(), [6]);
+ let nums = doc.get(ObjRef::new(9, 0)).as_dict().and_then(|d| d.get(b"Nums").cloned());
+ assert_eq!(nums, Some(Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Null, Object::Null])])));
+ let r = check(&doc, &Options { rules: [Rule::FiguresAltText, Rule::TaggedContent].into(), pages: None });
+ assert_eq!(failed(&r), Vec::::new(), "{:?}", r.results);
+}
+
+#[test]
+fn mark_tag_artifact_validates_parent_and_all_mcids_before_mutating_document() {
+ use pdfcraft_cos::{ObjRef, Object};
+
+ // Missing /P on the tag must fail before touching /Contents or /ParentTree.
+ let mut no_parent = figures_doc();
+ let before_contents = no_parent.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned();
+ let before_pt = no_parent.get(ObjRef::new(9, 0)).as_ref().clone();
+ no_parent
+ .update_dict(ObjRef::new(7, 0), |d| {
+ d.remove(b"P");
+ })
+ .unwrap();
+ assert_eq!(mark_tag_artifact(&mut no_parent, ObjRef::new(7, 0)), Err(ArtifactError::NoParent));
+ assert_eq!(no_parent.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents);
+ assert_eq!(*no_parent.get(ObjRef::new(9, 0)), before_pt);
+
+ // Duplicate entry in parent's /K must fail with AmbiguousParent before touching /Contents or /ParentTree.
+ let mut dup_parent = figures_doc();
+ dup_parent
+ .update_dict(ObjRef::new(6, 0), |d| {
+ d.set(b"K".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0)), Object::Ref(ObjRef::new(7, 0))]));
+ })
+ .unwrap();
+ assert_eq!(mark_tag_artifact(&mut dup_parent, ObjRef::new(7, 0)), Err(ArtifactError::AmbiguousParent));
+ assert_eq!(dup_parent.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents);
+ assert_eq!(*dup_parent.get(ObjRef::new(9, 0)), before_pt);
+
+ // Partial MCID match (MCID 0 present, MCID 99 absent) must fail with ContentNotFound in
+ // mark_tag_artifact without mutating /Contents, while mark_decorative still converts MCID 0.
+ let mut partial = figures_doc();
+ partial.update_dict(ObjRef::new(7, 0), |d| d.set(b"K".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(99)]))).unwrap();
+ assert_eq!(mark_tag_artifact(&mut partial, ObjRef::new(7, 0)), Err(ArtifactError::ContentNotFound));
+ assert_eq!(partial.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents);
+ assert_eq!(*partial.get(ObjRef::new(9, 0)), before_pt);
+ mark_decorative(&mut partial, ObjRef::new(7, 0)).unwrap();
+ assert_ne!(partial.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents);
+ assert_ne!(*partial.get(ObjRef::new(9, 0)), before_pt);
+
+ // When none of a figure's MCIDs appear on the page, mark_decorative leaves /Contents and /ParentTree untouched.
+ let mut absent = figures_doc();
+ absent.update_dict(ObjRef::new(7, 0), |d| d.set(b"K".to_vec(), Object::Int(99))).unwrap();
+ mark_decorative(&mut absent, ObjRef::new(7, 0)).unwrap();
+ assert_eq!(absent.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents);
+ assert_eq!(*absent.get(ObjRef::new(9, 0)), before_pt);
+}
+
fn ref_(n: u32) -> Option {
Some(pdfcraft_cos::ObjRef::new(n, 0))
}
@@ -1235,6 +1305,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 = 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();
diff --git a/crates/a11y/tests/cross_stream.rs b/crates/a11y/tests/cross_stream.rs
index ad613a2939..d0a2f48204 100644
--- a/crates/a11y/tests/cross_stream.rs
+++ b/crates/a11y/tests/cross_stream.rs
@@ -99,14 +99,20 @@ fn shown_strings(ops: &[Op]) -> Vec {
#[test]
fn mark_decorative_leaves_the_other_stream_untouched() {
- let mut doc = tagged_figure_two_streams("0 0 1 rg 300 600 100 50 re f", "BT /F1 12 Tf 72 500 Td (After) Tj ET");
- let before = doc.get(ObjRef::new(11, 0)).clone();
- mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds");
- assert!(figures(&doc).is_empty());
- assert_eq!(*doc.get(ObjRef::new(11, 0)), *before, "the second stream holds no figure and keeps its object");
- let page = doc.get(ObjRef::new(3, 0));
- let contents = page.as_dict().unwrap().get(b"Contents").unwrap().as_array().unwrap();
- assert_eq!(contents[1].as_ref(), Some(ObjRef::new(11, 0)), "the untouched stream must not be replaced by a re-encoded copy");
+ for use_artifact in [false, true] {
+ let mut doc = tagged_figure_two_streams("0 0 1 rg 300 600 100 50 re f", "BT /F1 12 Tf 72 500 Td (After) Tj ET");
+ let before = doc.get(ObjRef::new(11, 0)).clone();
+ if use_artifact {
+ mark_tag_artifact(&mut doc, ObjRef::new(8, 0)).expect("mark tag artifact succeeds");
+ } else {
+ mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds");
+ }
+ assert!(figures(&doc).is_empty());
+ assert_eq!(*doc.get(ObjRef::new(11, 0)), *before, "the second stream holds no figure and keeps its object");
+ let page = doc.get(ObjRef::new(3, 0));
+ let contents = page.as_dict().unwrap().get(b"Contents").unwrap().as_array().unwrap();
+ assert_eq!(contents[1].as_ref(), Some(ObjRef::new(11, 0)), "the untouched stream must not be replaced by a re-encoded copy");
+ }
}
/// A and B supply different operands to R's BDC. Reusing R must not let the first
diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs
index abc746c0dd..89c0f70f48 100644
--- a/crates/annot/src/appearance.rs
+++ b/crates/annot/src/appearance.rs
@@ -278,7 +278,7 @@ fn ext_gstate(opacity: f64, multiply: bool) -> Option {
Some(res)
}
-fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream {
+pub(crate) fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Form"));
@@ -288,6 +288,18 @@ fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream {
Stream::flate(d, content)
}
+/// The `cm` that draws a picture into `at`: an image (its unit square) or a form XObject whose
+/// `/Matrix` maps it to `size` points (`None`, or a side that is not positive, draws nothing).
+pub(crate) fn picture_place(at: [f64; 4], image: bool, size: Option<(f64, f64)>) -> Option {
+ let [x0, y0, x1, y1] = at;
+ let (w, h) = (x1 - x0, y1 - y0);
+ if image {
+ return Some(format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)));
+ }
+ let (sw, sh) = size.filter(|(sw, sh)| *sw > 0.0 && *sh > 0.0)?;
+ Some(format!("{} 0 0 {} {} {} cm", n(w / sw), n(h / sh), n(x0), n(y0)))
+}
+
/// The page `/Rotate` an image signature's picture is turned back by (`/PCPictureRotate`, written
/// when the signature is added to a turned page): 0, 90, 180 or 270.
pub(crate) fn picture_turn(d: &Dict) -> i64 {
@@ -325,7 +337,7 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> {
}
/// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding.
-fn helvetica_resource() -> Object {
+pub(crate) fn helvetica_resource() -> Object {
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
@@ -392,9 +404,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");
}
@@ -782,15 +798,12 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -
// the standard counter-rotation as its form `/Matrix`: viewers fit the turned
// bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed.
if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) {
- let [x0, y0, x1, y1] = rect;
- let (w, h) = (x1 - x0, y1 - y0);
let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true)));
- let place = if image {
- format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0))
- } else {
- let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?;
- format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0))
- };
+ let size = nums(d, b"PCPictureSize").and_then(|s| match s.as_slice() {
+ [w, h] if *w > 0.0 && *h > 0.0 => Some((*w, *h)),
+ _ => None,
+ });
+ let place = picture_place(rect, image, size)?;
c.push_str(&format!("q {place} /Pic Do Q\n"));
let mut xo = Dict::new();
xo.set(b"Pic".to_vec(), Object::Ref(pic));
diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs
index a6db5b940b..1904745e95 100644
--- a/crates/annot/src/create.rs
+++ b/crates/annot/src/create.rs
@@ -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 } => {
diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs
index b56e0de20d..1a54dbc077 100644
--- a/crates/annot/src/info.rs
+++ b/crates/annot/src/info.rs
@@ -47,8 +47,12 @@ pub(crate) fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option
Object::Name(n) => String::from_utf8_lossy(n).into_owned(),
_ => return None,
};
- let s = s.trim_matches('\0').trim().to_string();
- (!s.is_empty()).then_some(s)
+ let s = s.trim_matches('\0');
+ // Typewriter outer whitespace belongs to the text (keep in step with render's inspector).
+ let name_is = |key: &[u8], expected: &[u8]| d.get(key).is_some_and(|o| doc.resolve(o).as_name() == Some(expected));
+ let keep_breaks = key == b"Contents" && name_is(b"Subtype", b"FreeText") && name_is(b"IT", b"FreeTextTypeWriter");
+ let s = if keep_breaks { s } else { s.trim() };
+ (!s.trim().is_empty()).then(|| s.to_owned())
}
/// Whether an annotation subtype is a comment. Links, form widgets, pop-ups and the non-markup
diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs
index e9e0359ce0..4f24d04b08 100644
--- a/crates/annot/src/lib.rs
+++ b/crates/annot/src/lib.rs
@@ -22,7 +22,9 @@ mod create;
mod draw;
mod info;
mod lookup;
+mod model3d;
mod modify;
+mod threed;
mod types;
#[cfg(test)]
@@ -33,12 +35,16 @@ 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 use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations};
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 threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model};
pub use types::{
AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape,
StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad,
@@ -68,6 +74,12 @@ pub enum AnnotError {
/// with a font that has those characters. The message names the character.
#[error("{0}")]
Undrawable(String),
+ /// A 3D model file that is neither U3D (starts with `U3D` and a zero byte) nor PRC (starts `PRC`).
+ #[error("not a 3D model: the file must be U3D (ECMA-363) or PRC (ISO 14739-1)")]
+ NotAModel,
+ /// A 3D model larger than `MAX_MODEL_BYTES`.
+ #[error("the 3D model is {size} bytes; the largest accepted is {max}")]
+ ModelTooLarge { size: usize, max: usize },
#[error("{0}")]
Cos(#[from] pdfcraft_cos::CosError),
}
diff --git a/crates/annot/src/lookup.rs b/crates/annot/src/lookup.rs
index c81ec78ef4..9af83b0542 100644
--- a/crates/annot/src/lookup.rs
+++ b/crates/annot/src/lookup.rs
@@ -137,11 +137,11 @@ pub fn is_fill_sign(doc: &Document, d: &Dict) -> bool {
if matches!(d.get(b"PCFillSign").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) {
return true;
}
- let subtype = d.name(b"Subtype").unwrap_or_default();
- if subtype == b"FreeText" && d.name(b"IT") == Some(b"FreeTextTypeWriter") {
+ let name_is = |key: &[u8], expected: &[u8]| d.get(key).is_some_and(|o| doc.resolve(o).as_name() == Some(expected));
+ if name_is(b"Subtype", b"FreeText") && name_is(b"IT", b"FreeTextTypeWriter") {
return true;
}
- if subtype == b"Stamp" {
+ if name_is(b"Subtype", b"Stamp") {
if matches!(d.name(b"Name"), Some(b"PCCheck" | b"PCCross" | b"PCDot" | b"PCLine" | b"PCTypedSignature")) {
return true;
}
@@ -151,7 +151,7 @@ pub fn is_fill_sign(doc: &Document, d: &Dict) -> bool {
return true;
}
}
- subtype == b"Ink" && text_value(doc, d, b"Subj").as_deref() == Some("Signature")
+ name_is(b"Subtype", b"Ink") && text_value(doc, d, b"Subj").as_deref() == Some("Signature")
}
pub(crate) fn annotation_flags(doc: &Document, d: &Dict) -> i64 {
@@ -226,16 +226,33 @@ pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result Result {
+ 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 {
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");
})?;
diff --git a/crates/annot/src/model3d.rs b/crates/annot/src/model3d.rs
new file mode 100644
index 0000000000..b50342898e
--- /dev/null
+++ b/crates/annot/src/model3d.rs
@@ -0,0 +1,77 @@
+//! Reading the model a 3D annotation shows (ISO 32000-2 §13.6.3 and §13.6.4): its `/3DD` is a
+//! 3D stream, or a 3D reference dictionary naming the stream another annotation shares.
+
+use super::lookup::{annots, page_ref};
+use super::threed::MAX_MODEL_BYTES;
+use super::{AnnotError, Dict, Document, Object};
+
+/// The format of a 3D stream, from its `/Subtype`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Model3dKind {
+ /// Product Representation Compact (ISO 14739-1).
+ Prc,
+ /// Universal 3D (ECMA-363).
+ U3d,
+}
+
+/// A 3D annotation's model: its format and the decoded stream.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Model3d {
+ pub kind: Model3dKind,
+ pub data: Vec,
+}
+
+/// The `/Annots` positions of the 3D annotations on 0-based `page`, in order.
+///
+/// # Errors
+/// [`AnnotError::NoPageTree`] or [`AnnotError::NoSuchPage`].
+pub fn model_3d_annotations(doc: &Document, page: usize) -> Result, AnnotError> {
+ let list = annots(doc, page_ref(doc, page)?);
+ Ok(list.iter().enumerate().filter(|(_, a)| doc.dict(a).is_some_and(|d| d.name(b"Subtype") == Some(b"3D"))).map(|(i, _)| i).collect())
+}
+
+/// The model of the 3D annotation at `/Annots` position `index` on 0-based `page`, decoded.
+///
+/// # Errors
+/// [`AnnotError::NoSuchAnnotation`] when there is no annotation there; [`AnnotError::Invalid`]
+/// when it is not a 3D annotation, has no readable 3D stream, names a format other than PRC or
+/// U3D, or decodes past [`MAX_MODEL_BYTES`].
+pub fn model_3d(doc: &Document, page: usize, index: usize) -> Result {
+ let list = annots(doc, page_ref(doc, page)?);
+ let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
+ let annot = doc.dict(entry).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
+ let at = || format!("annotation {} on page {}", index.saturating_add(1), page.saturating_add(1));
+ if annot.name(b"Subtype") != Some(b"3D") {
+ return Err(AnnotError::Invalid(format!("{} is not a 3D annotation", at())));
+ }
+ let stream = stream_of(doc, &annot).ok_or_else(|| AnnotError::Invalid(format!("{} has no 3D stream", at())))?;
+ let Object::Stream(s) = stream.as_ref() else {
+ return Err(AnnotError::Invalid(format!("{} has no 3D stream", at())));
+ };
+ let kind = match s.dict.name(b"Subtype") {
+ Some(b"PRC") => Model3dKind::Prc,
+ Some(b"U3D") => Model3dKind::U3d,
+ other => {
+ let name = other.map_or_else(|| "none".to_owned(), |n| String::from_utf8_lossy(n).into_owned());
+ return Err(AnnotError::Invalid(format!("{}'s 3D stream is of an unknown format ({name})", at())));
+ }
+ };
+ let data = doc
+ .with_direct_parms(s)
+ .decoded_within(MAX_MODEL_BYTES)
+ .map_err(|e| AnnotError::Invalid(format!("{}'s 3D stream could not be decoded: {e}", at())))?;
+ Ok(Model3d { kind, data })
+}
+
+/// What an annotation's `/3DD` names: the 3D stream itself, or the `/3D` entry of a 3D reference
+/// dictionary. A reference is followed once; the caller refuses anything that is not a stream.
+fn stream_of(doc: &Document, annot: &Dict) -> Option> {
+ let obj = doc.resolve(annot.get(b"3DD")?);
+ match obj.as_ref() {
+ Object::Dict(d) if d.name(b"Type") == Some(b"3DRef") => Some(doc.resolve(d.get(b"3D")?)),
+ _ => Some(obj),
+ }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/annot/src/model3d/tests.rs b/crates/annot/src/model3d/tests.rs
new file mode 100644
index 0000000000..90addb1bb6
--- /dev/null
+++ b/crates/annot/src/model3d/tests.rs
@@ -0,0 +1,88 @@
+use std::sync::Arc;
+
+use super::*;
+
+/// A one-page document whose page has `annots` as its `/Annots`, plus `extra` objects from 4 on.
+fn doc(annots: &str, extra: &[Vec]) -> Document {
+ let mut objs: Vec> = vec![
+ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".to_vec(),
+ format!("<< /Type /Page /Parent 2 0 R /Annots {annots} >>").into_bytes(),
+ ];
+ objs.extend(extra.iter().cloned());
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offsets.push(out.len());
+ out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
+ out.extend_from_slice(o);
+ out.extend_from_slice(b"\nendobj\n");
+ }
+ let xref = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offsets {
+ out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+ Document::open(Arc::new(out)).expect("opens")
+}
+
+fn stream(dict: &str, body: &[u8]) -> Vec {
+ let mut s = format!("<< {dict} /Length {} >>\nstream\n", body.len()).into_bytes();
+ s.extend_from_slice(body);
+ s.extend_from_slice(b"\nendstream");
+ s
+}
+
+fn annot(dd: &str) -> Vec {
+ format!("<< /Type /Annot /Subtype /3D /Rect [0 0 100 100] /3DD {dd} >>").into_bytes()
+}
+
+#[test]
+fn a_plain_3d_stream_is_read_through_its_filters() {
+ let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /PRC /Filter /ASCIIHexDecode", b"505243>")]);
+ assert_eq!(model_3d(&d, 0, 0), Ok(Model3d { kind: Model3dKind::Prc, data: b"PRC".to_vec() }));
+}
+
+#[test]
+fn a_3dref_leads_to_the_stream_it_names() {
+ let d = doc("[4 0 R]", &[annot("<< /Type /3DRef /3D 5 0 R >>"), stream("/Type /3D /Subtype /PRC", b"PRC")]);
+ assert_eq!(model_3d(&d, 0, 0).map(|m| m.data), Ok(b"PRC".to_vec()));
+}
+
+#[test]
+fn a_u3d_stream_is_read_and_says_so() {
+ let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /U3D", b"U3D")]);
+ assert_eq!(model_3d(&d, 0, 0).map(|m| m.kind), Ok(Model3dKind::U3d));
+}
+
+#[test]
+fn a_3d_annotation_without_a_stream_is_invalid() {
+ let d = doc("[<< /Type /Annot /Subtype /3D /Rect [0 0 1 1] >>]", &[]);
+ assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream")));
+}
+
+#[test]
+fn an_unknown_format_is_invalid() {
+ let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /STL", b"x")]);
+ assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("unknown format (STL)")));
+}
+
+#[test]
+fn a_3dref_is_followed_once_and_only_to_a_stream() {
+ // A reference naming another reference, or one that names itself, is damage: no stream.
+ let d = doc("[4 0 R]", &[annot("5 0 R"), b"<< /Type /3DRef /3D 6 0 R >>".to_vec(), b"<< /Type /3DRef /3D 5 0 R >>".to_vec()]);
+ assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream")));
+ // The stream is under `/3D`; a `/3DD` inside the reference is not the standard's key.
+ let d = doc("[4 0 R]", &[annot("<< /Type /3DRef /3DD 5 0 R >>"), stream("/Type /3D /Subtype /PRC", b"PRC")]);
+ assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream")));
+}
+
+#[test]
+fn another_annotation_is_not_a_3d_one() {
+ let d = doc("[<< /Type /Annot /Subtype /Square /Rect [0 0 1 1] >> 4 0 R]", &[annot("5 0 R"), stream("/Subtype /PRC", b"PRC")]);
+ assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("not a 3D annotation")));
+ assert_eq!(model_3d(&d, 0, 2), Err(AnnotError::NoSuchAnnotation { page: 0, index: 2 }));
+ assert_eq!(model_3d_annotations(&d, 0), Ok(vec![1]));
+ assert_eq!(model_3d_annotations(&d, 1), Err(AnnotError::NoSuchPage(1)));
+}
diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs
index fc81348075..a615b8e013 100644
--- a/crates/annot/src/modify.rs
+++ b/crates/annot/src/modify.rs
@@ -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 = 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 = 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 {
+ 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::with_capacity(list.len());
+ for s in list {
+ let pts: Vec = s.as_array()?.iter().map(Object::as_f64).collect::>()?;
+ strokes.push(pts.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect());
+ }
+ let inner = ink_bounds(&strokes)?;
+ let vals: Vec = 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 {
diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs
index eb8d1a0f15..2ff781a3a5 100644
--- a/crates/annot/src/tests.rs
+++ b/crates/annot/src/tests.rs
@@ -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(©, 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 = 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,218 @@ 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 {
+ 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:?}");
+ }
+}
+
+/// A 3D annotation is PDF 1.6 and a PRC model 1.7 (ISO 32000-2 §13.6): an older document's
+/// catalog says so, and a newer one is left as it is.
+#[test]
+fn a_3d_model_raises_an_older_document_to_its_version() {
+ let catalog_version = |doc: &Document| {
+ let root = doc.root().unwrap();
+ doc.get(root).as_dict().unwrap().name(b"Version").map(<[u8]>::to_vec)
+ };
+ let add = |doc: &mut Document, model: &[u8]| {
+ let new = NewModel3d { page: 0, rect: [100.0, 100.0, 300.0, 300.0], model, activation: Activation3d::default(), poster: None, contents: "" };
+ add_3d_model(doc, &new, &meta("m")).unwrap();
+ };
+ let mut doc = fixture();
+ add(&mut doc, b"U3D\0rest");
+ assert_eq!(catalog_version(&reopen(&doc)), None, "a 1.7 file needs nothing for U3D");
+ for (model, version) in [(&b"U3D\0rest"[..], &b"1.6"[..]), (b"PRC rest", b"1.7")] {
+ let mut doc = fixture();
+ doc.set_version("1.4");
+ add(&mut doc, model);
+ assert_eq!(catalog_version(&reopen(&doc)).as_deref(), Some(version));
+ }
+ let mut doc = fixture();
+ doc.set_version("2.0");
+ add(&mut doc, b"PRC rest");
+ assert_eq!(catalog_version(&reopen(&doc)), None, "a 2.0 file takes PRC as it is");
+}
+
+/// Before 2.0, PRC is an Adobe extension to PDF 1.7 (§7.12): a PRC model declares it, keeping
+/// other extensions and a later ADBE level; U3D and a 2.0 file need nothing.
+#[test]
+fn a_prc_model_declares_the_adobe_extension_before_pdf_2() {
+ let adbe = |doc: &Document| {
+ let root = doc.root().unwrap();
+ let ext = doc.get(root).as_dict().unwrap().get(b"Extensions").map(|e| doc.resolve(e).as_dict().unwrap().clone());
+ ext.map(|e| {
+ let adbe = e.get(b"ADBE").map(|a| a.as_dict().unwrap().clone());
+ (e.get(b"XYZ").is_some(), adbe.map(|a| (a.name(b"BaseVersion").map(<[u8]>::to_vec), a.get(b"ExtensionLevel").and_then(Object::as_int))))
+ })
+ };
+ let add = |doc: &mut Document, model: &[u8]| {
+ let new = NewModel3d { page: 0, rect: [100.0, 100.0, 300.0, 300.0], model, activation: Activation3d::default(), poster: None, contents: "" };
+ add_3d_model(doc, &new, &meta("m")).unwrap();
+ };
+ let mut doc = fixture();
+ add(&mut doc, b"PRC rest");
+ assert_eq!(adbe(&reopen(&doc)), Some((false, Some((Some(b"1.7".to_vec()), Some(1))))));
+ let mut doc = fixture();
+ add(&mut doc, b"U3D rest");
+ assert_eq!(adbe(&reopen(&doc)), None, "U3D is in ISO 32000-1");
+ let mut doc = fixture();
+ doc.set_version("2.0");
+ add(&mut doc, b"PRC rest");
+ assert_eq!(adbe(&reopen(&doc)), None, "PRC is in ISO 32000-2");
+
+ let mut doc = fixture();
+ let root = doc.root().unwrap();
+ let mut level3 = Dict::new();
+ level3.set(b"BaseVersion".to_vec(), Object::name("1.7"));
+ level3.set(b"ExtensionLevel".to_vec(), Object::Int(3));
+ let mut ext = Dict::new();
+ ext.set(b"ADBE".to_vec(), Object::Dict(level3));
+ ext.set(b"XYZ".to_vec(), Object::Dict(Dict::new()));
+ doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(ext.clone()))).unwrap();
+ add(&mut doc, b"PRC rest");
+ assert_eq!(adbe(&reopen(&doc)), Some((true, Some((Some(b"1.7".to_vec()), Some(3))))), "a later level is kept");
+ ext.remove(b"ADBE");
+ let mut doc = fixture();
+ doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(ext))).unwrap();
+ add(&mut doc, b"PRC rest");
+ assert_eq!(adbe(&reopen(&doc)), Some((true, Some((Some(b"1.7".to_vec()), Some(1))))), "other extensions are kept");
+
+ let mut doc = fixture();
+ let mut ext = Dict::new();
+ ext.set(b"ADBE".to_vec(), Object::Array(vec![Object::Dict(Dict::new())]));
+ let ext = doc.add(Object::Dict(ext));
+ doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Ref(ext))).unwrap();
+ add(&mut doc, b"PRC rest");
+ let doc = reopen(&doc);
+ let ext = doc.resolve(doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Extensions").unwrap());
+ assert!(ext.as_dict().unwrap().get(b"ADBE").unwrap().as_array().is_some(), "an array of ADBE extensions is left alone");
+}
+
+#[test]
+fn a_picture_is_placed_by_its_box_and_a_form_needs_a_positive_size() {
+ let at = [10.0, 20.0, 110.0, 70.0];
+ assert_eq!(appearance::picture_place(at, true, None).as_deref(), Some("100 0 0 50 10 20 cm"), "an image fills its unit square");
+ assert_eq!(appearance::picture_place(at, false, Some((200.0, 25.0))).as_deref(), Some("0.5 0 0 2 10 20 cm"));
+ for size in [None, Some((0.0, 25.0)), Some((200.0, -1.0))] {
+ assert_eq!(appearance::picture_place(at, false, size), None, "{size:?}");
+ }
+}
+
+/// The poster's box must lie in the rectangle, and a form poster needs a size to scale by.
+#[test]
+fn a_3d_poster_outside_its_rectangle_or_without_a_size_is_refused() {
+ let picture = |at: [f64; 4], size: (f64, f64)| PosterPicture { xobject: ObjRef { num: 6, generation: 0 }, image: false, size, at };
+ for (poster, why) in [
+ (picture([50.0, 100.0, 250.0, 300.0], (10.0, 10.0)), "starts left of the rectangle"),
+ (picture([100.0, 100.0, 300.0, 301.0], (10.0, 10.0)), "ends above it"),
+ (picture([100.0, 100.0, 300.0, 300.0], (0.0, 10.0)), "has no width"),
+ (picture([100.0 - 2e-7, 150.0, 100.0 - 1e-7, 200.0], (10.0, 10.0)), "lies outside within the rounding slack"),
+ ] {
+ let mut doc = fixture();
+ let new = NewModel3d {
+ page: 0,
+ rect: [100.0, 100.0, 300.0, 300.0],
+ model: b"PRC rest",
+ activation: Activation3d::default(),
+ poster: Some(poster),
+ contents: "",
+ };
+ assert!(matches!(add_3d_model(&mut doc, &new, &meta("m")), Err(AnnotError::Invalid(_))), "{why}");
+ assert_eq!(list(&doc, 0).len(), 1, "{why}: nothing added");
+ }
+}
+
+/// A poster box fitted to the rectangle in floating point ends an ulp outside it: a 7×3 picture
+/// centred in [0 100 3.6 110.8] spans -2.2e-16 to 3.6000000000000005. It is taken, inside the
+/// rectangle.
+#[test]
+fn a_3d_poster_fitted_to_its_rectangle_with_rounding_is_taken() {
+ let mut doc = fixture();
+ let at = [-2.220446049250313e-16, 104.62857142857143, 3.6000000000000005, 106.17142857142858];
+ let poster = PosterPicture { xobject: ObjRef { num: 6, generation: 0 }, image: false, size: (7.0, 3.0), at };
+ let new = NewModel3d {
+ page: 0,
+ rect: [0.0, 100.0, 3.6, 110.8],
+ model: b"PRC rest",
+ activation: Activation3d::default(),
+ poster: Some(poster),
+ contents: "",
+ };
+ assert_eq!(add_3d_model(&mut doc, &new, &meta("m")), Ok(1));
+}
+
+#[test]
+fn indirect_typewriter_names_keep_outer_whitespace_in_summaries() {
+ let mut doc = fixture();
+ let intent = Object::Ref(doc.add(Object::name("FreeTextTypeWriter")));
+ let page = page_refs(&doc).unwrap()[0];
+ let mut entries = annots(&doc, page);
+ let Object::Dict(annotation) = &mut entries[0] else { panic!("fixture annotation is inline") };
+ annotation.set(b"Subtype".to_vec(), Object::name("FreeText"));
+ annotation.set(b"IT".to_vec(), intent);
+ let contents = " \nAda\n\nLovelace\n\n ";
+ annotation.set(b"Contents".to_vec(), PdfString::text(contents));
+ set_annots(&mut doc, page, entries).unwrap();
+ let check = |doc: &Document| {
+ let list = summaries(doc);
+ let summary = list.iter().find(|a| a.page == 0 && a.index == 0).expect("direct Subtype is listed");
+ assert_eq!(summary.contents.as_deref(), Some(contents), "indirect typewriter intent preserves outer whitespace");
+ assert_eq!(summary.subtype, "FreeText");
+ assert_eq!(summary.intent.as_deref(), Some("FreeTextTypeWriter"));
+ assert!(summary.fill_sign, "indirect intent still identifies a Fill & Sign mark");
+ };
+ check(&doc);
+ check(&reopen(&doc));
+}
diff --git a/crates/annot/src/threed.rs b/crates/annot/src/threed.rs
new file mode 100644
index 0000000000..cf828388c0
--- /dev/null
+++ b/crates/annot/src/threed.rs
@@ -0,0 +1,292 @@
+//! 3D annotations (ISO 32000-2 §13.6.2): a U3D or PRC model shown in a rectangle on a page,
+//! with a poster (`/AP /N`) for printing, for readers without 3D, and until it is activated.
+
+use super::create::{finite, normalize, num_array};
+use super::lookup::{annots, page_ref, set_annots};
+use super::types::{FLAG_PRINT, Meta};
+use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, Stream, appearance, n};
+
+/// The largest model accepted, in bytes: well past real U3D/PRC files, and bounded because the
+/// model is held and compressed in memory.
+pub const MAX_MODEL_BYTES: usize = 256 << 20;
+
+/// What the poster says when there is no picture.
+const PLACEHOLDER_LABEL: &str = "3D model";
+
+/// A 3D stream's data format (§13.6.3): ECMA-363 U3D or ISO 14739-1 PRC.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ModelFormat {
+ U3d,
+ Prc,
+}
+
+impl ModelFormat {
+ /// The format a model file's first bytes declare: U3D files start `U3D\0` (ECMA-363 file
+ /// header block type), PRC files start `PRC`.
+ ///
+ /// # Errors
+ ///
+ /// [`AnnotError::NotAModel`] for anything else.
+ pub fn detect(bytes: &[u8]) -> Result {
+ if bytes.starts_with(b"U3D\0") {
+ Ok(Self::U3d)
+ } else if bytes.starts_with(b"PRC") {
+ Ok(Self::Prc)
+ } else {
+ Err(AnnotError::NotAModel)
+ }
+ }
+
+ /// The 3D stream's `/Subtype`.
+ pub fn subtype(self) -> &'static str {
+ match self {
+ Self::U3d => "U3D",
+ Self::Prc => "PRC",
+ }
+ }
+
+ /// The PDF version a document holding it needs (§13.6): a 3D annotation is 1.6, a PRC stream
+ /// 1.7 under Adobe's extension, declared by [`add_3d_model`] (PDF 2.0 takes PRC as standard).
+ pub fn version(self) -> &'static str {
+ match self {
+ Self::U3d => "1.6",
+ Self::Prc => "1.7",
+ }
+ }
+}
+
+/// When a reader turns the poster into the live model (§13.6.2, the activation dictionary's
+/// `/A`). The standard's default is [`Self::Click`].
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum Activation {
+ /// `/XA`: when the user clicks it; it prints as its poster.
+ #[default]
+ Click,
+ /// `/PO`: when the page opens.
+ PageOpen,
+ /// `/PV`: when the page becomes visible.
+ PageVisible,
+}
+
+impl Activation {
+ /// Every activation, with the word a tool argument names it by.
+ pub const ALL: [(Self, &'static str); 3] = [(Self::Click, "click"), (Self::PageOpen, "page_open"), (Self::PageVisible, "page_visible")];
+
+ /// The `/A` name.
+ pub fn name(self) -> &'static str {
+ match self {
+ Self::Click => "XA",
+ Self::PageOpen => "PO",
+ Self::PageVisible => "PV",
+ }
+ }
+}
+
+/// A picture already in the document (an image XObject, or a form XObject whose `/Matrix` maps it
+/// to `size` points) drawn into `at`, a box inside the annotation's rectangle.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct PosterPicture {
+ pub xobject: ObjRef,
+ pub image: bool,
+ pub size: (f64, f64),
+ pub at: [f64; 4],
+}
+
+/// A 3D model to add on `page` (0-based) in `rect` (user space).
+#[derive(Clone, Debug, PartialEq)]
+pub struct NewModel3d<'a> {
+ pub page: usize,
+ pub rect: [f64; 4],
+ pub model: &'a [u8],
+ pub activation: Activation,
+ /// The poster's picture; `None` draws a labelled frame.
+ pub poster: Option,
+ /// `/Contents`: the annotation's alternate description (§12.5.2).
+ pub contents: &'a str,
+}
+
+/// Check a model before anything is added: its size and its format.
+///
+/// # Errors
+///
+/// [`AnnotError::ModelTooLarge`] past [`MAX_MODEL_BYTES`], else as [`ModelFormat::detect`].
+pub fn check_model(model: &[u8]) -> Result {
+ check_model_size(model, MAX_MODEL_BYTES)?;
+ ModelFormat::detect(model)
+}
+
+fn check_model_size(model: &[u8], max: usize) -> Result<(), AnnotError> {
+ if model.len() > max {
+ return Err(AnnotError::ModelTooLarge { size: model.len(), max });
+ }
+ Ok(())
+}
+
+/// Add a 3D annotation: the model in a Flate-compressed 3D stream referenced from `/3DD` (so the
+/// annotation has its own instance, §13.6.2), `/3DA` stating only `/A`, and the poster as `/AP /N`.
+/// No view (`/3DV`, `/VA`) is written: readers open on the model's own default view. A document
+/// older than [`ModelFormat::version`] is raised to it. Returns its index in the page's `/Annots`.
+///
+/// # Errors
+///
+/// As [`check_model`]; [`AnnotError::NoSuchPage`]; [`AnnotError::Invalid`] for a rectangle with no
+/// area or a poster box outside it.
+pub fn add_3d_model(doc: &mut Document, new: &NewModel3d<'_>, meta: &Meta) -> Result {
+ let format = check_model(new.model)?;
+ let page = page_ref(doc, new.page)?;
+ let rect = normalize(new.rect);
+ if !finite(&new.rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
+ return Err(AnnotError::Invalid("invalid rectangle for the 3D model (each side at least 1 pt)".into()));
+ }
+ let poster = match &new.poster {
+ Some(p) => picture_poster(rect, p)?,
+ None => placeholder_poster(rect),
+ };
+ let mut stream = Dict::new();
+ stream.set(b"Type".to_vec(), Object::name("3D"));
+ stream.set(b"Subtype".to_vec(), Object::name(format.subtype()));
+ let stream = doc.add(Object::Stream(Stream::flate(stream, new.model)));
+ let poster = doc.add(Object::Stream(poster));
+ let d = annotation_dict(new, rect, page, stream, poster, meta);
+ let r = doc.add(Object::Dict(d));
+ let mut list = annots(doc, page);
+ let index = list.len();
+ list.push(Object::Ref(r));
+ set_annots(doc, page, list)?;
+ at_least_version(doc, format.version())?;
+ if format == ModelFormat::Prc && !at_least(doc, "2.0") {
+ declare_prc_extension(doc)?;
+ }
+ Ok(index)
+}
+
+/// Whether the header or the catalog `/Version` says `min` or later (§7.7.2).
+fn at_least(doc: &Document, min: &str) -> bool {
+ let reached = |v: &[u8]| v.len() >= 3 && v >= min.as_bytes();
+ let catalog = doc.root().and_then(|root| doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned()));
+ let catalog = catalog.and_then(|v| doc.resolve(&v).as_name().map(<[u8]>::to_vec));
+ reached(doc.version().as_bytes()) || catalog.as_deref().is_some_and(reached)
+}
+
+/// PRC came before PDF 2.0 as an Adobe extension to 1.7: such a file declares
+/// `/Extensions /ADBE` with ExtensionLevel 1 or later (§7.12). Other extensions and an ADBE entry
+/// that already has a level are kept; one that is not a dictionary (an array of them) is left alone.
+fn declare_prc_extension(doc: &mut Document) -> Result<(), AnnotError> {
+ let Some(root) = doc.root() else { return Ok(()) };
+ let catalog = doc.get(root);
+ // `None` when present but not a dictionary; an empty one when absent.
+ let dict_or_new = |o: Option<&Object>| match o.map(|o| doc.resolve(o)) {
+ None => Some(Dict::new()),
+ Some(o) => o.as_dict().cloned(),
+ };
+ let Some(mut extensions) = dict_or_new(catalog.as_dict().and_then(|c| c.get(b"Extensions"))) else { return Ok(()) };
+ let Some(mut adbe) = dict_or_new(extensions.get(b"ADBE")) else { return Ok(()) };
+ if adbe.get(b"ExtensionLevel").map(|l| doc.resolve(l)).and_then(|l| l.as_int()).is_some_and(|l| l >= 1) {
+ return Ok(());
+ }
+ adbe.set(b"BaseVersion".to_vec(), Object::name("1.7"));
+ adbe.set(b"ExtensionLevel".to_vec(), Object::Int(1));
+ extensions.set(b"ADBE".to_vec(), Object::Dict(adbe));
+ doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(extensions)))?;
+ Ok(())
+}
+
+/// A document whose header and catalog `/Version` both say less than `min` gets `/Version min` in
+/// its catalog (§7.7.2: the later of the two applies).
+fn at_least_version(doc: &mut Document, min: &str) -> Result<(), AnnotError> {
+ let Some(root) = doc.root() else { return Ok(()) };
+ if !at_least(doc, min) {
+ doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name(min)))?;
+ }
+ Ok(())
+}
+
+fn annotation_dict(new: &NewModel3d<'_>, rect: [f64; 4], page: ObjRef, stream: ObjRef, poster: ObjRef, meta: &Meta) -> Dict {
+ let mut d = Dict::new();
+ d.set(b"Type".to_vec(), Object::name("Annot"));
+ d.set(b"Subtype".to_vec(), Object::name("3D"));
+ d.set(b"Rect".to_vec(), num_array(&rect));
+ d.set(b"P".to_vec(), Object::Ref(page));
+ d.set(b"F".to_vec(), Object::Int(FLAG_PRINT));
+ if !new.contents.is_empty() {
+ d.set(b"Contents".to_vec(), PdfString::text(new.contents));
+ }
+ if !meta.id.is_empty() {
+ d.set(b"NM".to_vec(), PdfString::text(&meta.id));
+ }
+ if let Some(date) = &meta.date {
+ d.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec()));
+ }
+ d.set(b"3DD".to_vec(), Object::Ref(stream));
+ let mut activation = Dict::new();
+ activation.set(b"A".to_vec(), Object::name(new.activation.name()));
+ d.set(b"3DA".to_vec(), Object::Dict(activation));
+ let mut ap = Dict::new();
+ ap.set(b"N".to_vec(), Object::Ref(poster));
+ d.set(b"AP".to_vec(), Object::Dict(ap));
+ d
+}
+
+/// The poster drawing `p` into its box, which must lie within `rect`.
+fn picture_poster(rect: [f64; 4], p: &PosterPicture) -> Result {
+ let at = normalize(p.at);
+ // A box fitted to `rect` in floating point can end a few ulps outside it: allow that, then clamp.
+ let slack = rect.iter().fold(1.0_f64, |m, v| m.max(v.abs())) * 1e-9;
+ let near = finite(&p.at) && at[0] >= rect[0] - slack && at[1] >= rect[1] - slack && at[2] <= rect[2] + slack && at[3] <= rect[3] + slack;
+ let at = [at[0].max(rect[0]), at[1].max(rect[1]), at[2].min(rect[2]), at[3].min(rect[3])];
+ if !(near && at[2] > at[0] && at[3] > at[1]) {
+ return Err(AnnotError::Invalid("the poster must fit inside the 3D model's rectangle".into()));
+ }
+ let place = appearance::picture_place(at, p.image, Some(p.size)).ok_or_else(|| AnnotError::Invalid("the poster picture has no size".into()))?;
+ let mut xobjects = Dict::new();
+ xobjects.set(b"Pic".to_vec(), Object::Ref(p.xobject));
+ let mut resources = Dict::new();
+ resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
+ Ok(appearance::form(rect, format!("q {place} /Pic Do Q\n").as_bytes(), resources))
+}
+
+/// A light frame with "3D model" centred in it, shrunk to fit (left out when even 2 pt won't).
+fn placeholder_poster(rect: [f64; 4]) -> Stream {
+ let [x0, y0, x1, y1] = rect;
+ let (w, h) = (x1 - x0, y1 - y0);
+ let mut c =
+ format!("0.94 g {} {} {} {} re f\n0.45 G 1 w {} {} {} {} re S\n", n(x0), n(y0), n(w), n(h), n(x0 + 0.5), n(y0 + 0.5), n(w - 1.0), n(h - 1.0));
+ let mut resources = Dict::new();
+ let unit = appearance::text_width(PLACEHOLDER_LABEL, 1.0);
+ let size = if unit > 0.0 { (w * 0.8 / unit).min(h * 0.4).min(14.0) } else { 0.0 };
+ if size >= 2.0 {
+ let (tx, ty) = (x0 + (w - unit * size) / 2.0, y0 + (h - size * 0.7) / 2.0);
+ c.push_str(&format!("BT 0.3 g /Helv {} Tf {} {} Td ({PLACEHOLDER_LABEL}) Tj ET\n", n(size), n(tx), n(ty)));
+ resources.set(b"Font".to_vec(), appearance::helvetica_resource());
+ }
+ appearance::form(rect, c.as_bytes(), resources)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn model_size_is_capped() {
+ assert_eq!(check_model_size(&[0; 8], 8), Ok(()));
+ assert_eq!(check_model_size(&[0; 9], 8), Err(AnnotError::ModelTooLarge { size: 9, max: 8 }));
+ }
+
+ #[test]
+ fn the_format_is_read_from_the_header() {
+ assert_eq!(ModelFormat::detect(b"U3D\0rest"), Ok(ModelFormat::U3d));
+ assert_eq!(ModelFormat::detect(b"PRC8800"), Ok(ModelFormat::Prc));
+ for junk in [&b""[..], b"U3D", b"U3DX", b"PR", b"%PDF-1.7", b"prc"] {
+ assert_eq!(ModelFormat::detect(junk), Err(AnnotError::NotAModel), "{junk:?}");
+ }
+ }
+
+ #[test]
+ fn the_placeholder_label_shrinks_to_fit_and_is_dropped_when_it_cannot() {
+ let wide = String::from_utf8(placeholder_poster([0.0, 0.0, 300.0, 200.0]).decoded().unwrap().to_vec()).unwrap();
+ assert!(wide.contains("/Helv 14 Tf") && wide.contains("(3D model) Tj"), "{wide}");
+ let tiny = placeholder_poster([0.0, 0.0, 4.0, 4.0]);
+ assert!(!String::from_utf8(tiny.decoded().unwrap().to_vec()).unwrap().contains("Tj"));
+ assert!(tiny.dict.get(b"Resources").and_then(Object::as_dict).is_none_or(|r| r.get(b"Font").is_none()));
+ }
+}
diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs
index bd3409b5ae..522995b9f9 100644
--- a/crates/annot/src/types.rs
+++ b/crates/annot/src/types.rs
@@ -355,6 +355,10 @@ pub enum Shape {
start: LineEnding,
end: LineEnding,
},
+ /// Translucent freehand highlighting, stored as ink with a distinct intent.
+ FreehandHighlight {
+ strokes: Vec>,
+ },
/// Freehand strokes (Draw tool).
Ink {
strokes: Vec>,
@@ -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),
diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs
index 041ba9ddd8..bb5adf7ff8 100644
--- a/crates/audit/src/cover.rs
+++ b/crates/audit/src/cover.rs
@@ -15,7 +15,7 @@
//! the joined stream)`; rectangles inside a form XObject carry the `Do` operator's position.
//! Positions compare lexicographically in paint order.
-use pdfcraft_content::{Matrix, Op, Pieces, parse};
+use pdfcraft_content::{Matrix, Op, Pieces, ops};
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
use crate::AuditError;
@@ -123,12 +123,13 @@ impl<'a> Walker<'a> {
Ok(())
}
- /// Walk one operator slice with the given graphics state. `at_do` is the `Do` position to
+ /// Walk one stream's operators, read one at a time (a stream of millions of short operators
+ /// is never held whole), with the given graphics state. `at_do` is the `Do` position to
/// stamp on rectangles found inside a form XObject, or `None` at page level (where each
/// fill operator's own position is used).
fn walk_ops(
&mut self,
- ops: &[Op],
+ ops: impl Iterator- ,
gs: &mut Gs,
stack: &mut Vec
,
resources: &Dict,
@@ -136,8 +137,9 @@ impl<'a> Walker<'a> {
at_do: Option<(usize, usize)>,
) -> Result<(), AuditError> {
let mut path = Path::Empty;
- for (i, op) in ops.iter().enumerate() {
+ for (i, op) in ops.enumerate() {
self.bump()?;
+ let op = &op;
// Paint position of this operator: its own `(piece, joined index)` at page level,
// the enclosing `Do`'s position inside a form XObject.
let pos = match at_do {
@@ -257,8 +259,9 @@ impl<'a> Walker<'a> {
/// changes inside never leak out; every rectangle found is stamped with the `Do`
/// position `pos`.
fn walk_xobject(&mut self, name: &[u8], gs: &Gs, resources: &Dict, depth: usize, pos: (usize, usize)) -> Result<(), AuditError> {
- let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
- let Some(entry) = xobjects.get(name) else { return Ok(()) };
+ // Borrowed, not copied: a `Do` must not cost the size of the whole XObject table.
+ let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o));
+ let Some(entry) = xobjects.as_deref().and_then(Object::as_dict).and_then(|d| d.get(name)) else { return Ok(()) };
let resolved = self.doc.resolve(entry);
let Object::Stream(stream) = &*resolved else { return Ok(()) };
let dict = &stream.dict;
@@ -274,18 +277,18 @@ impl<'a> Walker<'a> {
(v.len() == 6 && v.iter().all(|x| x.is_finite())).then(|| Matrix([v[0], v[1], v[2], v[3], v[4], v[5]]))
})
.unwrap_or(Matrix::IDENTITY);
- let sub_resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
+ let (sub_resources, empty) = (dict.get(b"Resources").map(|o| self.doc.resolve(o)), Dict::new());
+ let sub_resources = sub_resources.as_deref().and_then(Object::as_dict).unwrap_or(&empty);
if !self.budget.enter_form() {
return Err(AuditError::TooManyForms);
}
// A form stream that cannot be decoded fails the audit: it might hide a cover.
let data = self.budget.decode_strict(stream)?;
- let ops = parse(&data).ops;
// The XObject matrix maps form space into the user space active at `Do`.
let mut sub_gs = gs.clone();
sub_gs.ctm = matrix.then(&gs.ctm);
let mut sub_stack = Vec::new();
- self.walk_ops(&ops, &mut sub_gs, &mut sub_stack, &sub_resources, depth + 1, Some(pos))
+ self.walk_ops(ops(&data), &mut sub_gs, &mut sub_stack, sub_resources, depth + 1, Some(pos))
}
}
@@ -298,13 +301,10 @@ fn user_point(gs: &Gs, op: &Op) -> Option<[f64; 2]> {
/// The non-stroking alpha (`/ca`) of a named ExtGState, defaulting to opaque.
fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 {
- let alpha = resources
- .get(b"ExtGState")
- .map(|o| doc.resolve(o))
- .and_then(|o| o.as_dict().cloned())
- .and_then(|d| d.get(name).map(|o| doc.resolve(o)))
- .and_then(|o| o.as_dict().cloned())
- .and_then(|d| d.get(b"ca").map(|o| doc.resolve(o)).and_then(|o| o.as_f64()));
+ // Borrowed, not copied: a `gs` must not cost the size of the whole ExtGState table.
+ let states = resources.get(b"ExtGState").map(|o| doc.resolve(o));
+ let state = states.as_deref().and_then(Object::as_dict).and_then(|d| d.get(name)).map(|o| doc.resolve(o));
+ let alpha = state.as_deref().and_then(Object::as_dict).and_then(|d| d.get(b"ca").map(|o| doc.resolve(o)).and_then(|o| o.as_f64()));
alpha.filter(|a| a.is_finite()).map(|a| a.clamp(0.0, 1.0)).unwrap_or(1.0)
}
@@ -330,10 +330,11 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result, Audi
}
}
let pieces = Pieces::join(&streams);
- let parsed = pieces.parse();
let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0, budget };
let mut gs = Gs::default();
let mut stack = Vec::new();
- w.walk_ops(&parsed.ops, &mut gs, &mut stack, &resources, 0, None)?;
+ // Read one operator at a time: the joined stream can hold millions of them within the
+ // decode budget, and the walk only ever needs the current one.
+ w.walk_ops(ops(pieces.data()), &mut gs, &mut stack, &resources, 0, None)?;
Ok(w.out)
}
diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs
index f2bcf63358..97429d4c40 100644
--- a/crates/audit/src/lib.rs
+++ b/crates/audit/src/lib.rs
@@ -99,6 +99,9 @@ const COVER_FRACTION: f64 = 0.4;
const WORD_FRACTION: f64 = 0.5;
/// WCAG contrast ratio below which the text is unreadable against the cover.
const COVER_CONTRAST: f64 = 3.0;
+/// Bytes of a hidden line's text read (copied or trimmed) that cost one comparison: a byte scan
+/// is far cheaper than a box comparison, but a long padded line under many covers adds up.
+const TEXT_BYTES_PER_COMPARISON: usize = 16;
/// Audit every page for faux redactions and unapplied redaction marks. Fails closed: any
/// content that cannot be decoded, or that needs more work or report than the audit's bounds, is
@@ -157,15 +160,24 @@ impl Budget {
cover: &[f64; 4],
hides: impl Fn(&TextLine) -> bool,
) -> Result, AuditError> {
- self.comparisons = self.comparisons.saturating_add(self.page_cost);
- if self.comparisons > self.limits.comparisons {
- return Err(AuditError::TooManyComparisons);
- }
+ self.spend(self.page_cost)?;
if !copying {
- return Ok(lines.iter().any(|line| hides(line) && !line.text.trim().is_empty()).then(String::new));
+ let mut hidden = false;
+ for line in lines.iter().filter(|l| hides(l)) {
+ // Trimming walks the line's text (padding and all): it costs its bytes.
+ self.spend(line.text.len() / TEXT_BYTES_PER_COMPARISON)?;
+ if !line.text.trim().is_empty() {
+ hidden = true;
+ break;
+ }
+ }
+ return Ok(hidden.then(String::new));
}
let mut covered = String::new();
for line in lines.iter().filter(|l| hides(l)) {
+ // Copying and trimming the line walks its text once per cover: a long padded
+ // line under many covers would otherwise be read far more than its runs suggest.
+ self.spend(line.text.len() / TEXT_BYTES_PER_COMPARISON)?;
if !covered.is_empty() {
covered.push(' ');
}
@@ -174,11 +186,21 @@ impl Budget {
Ok((!covered.trim().is_empty()).then_some(covered))
}
+ /// `n` units of work done against the comparison bound.
+ fn spend(&mut self, n: usize) -> Result<(), AuditError> {
+ self.comparisons = self.comparisons.saturating_add(n);
+ if self.comparisons > self.limits.comparisons {
+ return Err(AuditError::TooManyComparisons);
+ }
+ Ok(())
+ }
+
/// The text a finding reports for `covered`: none when copying is withheld. Charges what it
- /// reports against the output bound.
+ /// reports, and the finding's own record, against the output bound: a finding with no text
+ /// (copying withheld, or a shared annotation list on many pages) still takes memory.
fn report_text(&mut self, covered: String, copying: bool) -> Result {
let reported = if copying { covered } else { String::new() };
- self.covered_text = self.covered_text.saturating_add(reported.len());
+ self.covered_text = self.covered_text.saturating_add(reported.len()).saturating_add(std::mem::size_of::());
if self.covered_text > self.limits.covered_text {
return Err(AuditError::TooMuchOutput);
}
@@ -321,17 +343,14 @@ enum AnnotationCover {
fn annots(doc: &Document, page: &Dict) -> Result, AuditError> {
let Some(a) = page.get(b"Annots") else { return Ok(Vec::new()) };
let resolved = doc.resolve(a);
- let items: Vec = match &*resolved {
- pdfcraft_cos::Object::Array(items) => items.clone(),
- // A missing /Annots means no marks; a malformed one fails the audit.
- _ => return Err(AuditError::BadAnnots),
- };
+ // A missing /Annots means no marks; a malformed one fails the audit.
+ let Some(items) = resolved.as_array() else { return Err(AuditError::BadAnnots) };
let mut out = Vec::new();
- for a in &items {
- let dict = match &*doc.resolve(a) {
- pdfcraft_cos::Object::Dict(d) => d.clone(),
- _ => continue,
- };
+ for a in items {
+ // Borrowed from the resolved object: one large dictionary listed many times is not
+ // copied once per listing.
+ let resolved = doc.resolve(a);
+ let Some(dict) = resolved.as_dict() else { continue };
let v: Vec = match dict.get(b"Rect") {
None => continue,
Some(r) => doc.resolve(r).as_array().map(|arr| arr.iter().filter_map(|o| doc.resolve(o).as_f64()).collect()).unwrap_or_default(),
@@ -340,7 +359,7 @@ fn annots(doc: &Document, page: &Dict) -> Result, AuditErro
continue;
}
let rect = [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])];
- match subtype(doc, &dict)?.as_deref() {
+ match subtype(doc, dict)?.as_deref() {
Some(b"Redact") => out.push(AnnotationCover::Redact(rect)),
Some(b"Square") => {
let flags = dict.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
@@ -349,7 +368,7 @@ fn annots(doc: &Document, page: &Dict) -> Result, AuditErro
}
let alpha = dict.get(b"CA").map(|o| doc.resolve(o)).and_then(|o| o.as_f64()).unwrap_or(1.0);
if alpha >= 1.0
- && let Some(rgb) = annotation_color(doc, &dict, b"IC")
+ && let Some(rgb) = annotation_color(doc, dict, b"IC")
{
out.push(AnnotationCover::Square { rect, rgb });
}
diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs
index e5fb9ab683..074eb39f66 100644
--- a/crates/audit/src/tests.rs
+++ b/crates/audit/src/tests.rs
@@ -720,9 +720,11 @@ fn runs_without_words_count_against_the_comparison_bound() {
let doc = open(one_page(&content, ""));
let lines = pdfcraft_edit::glyph_text_lines(&doc, &pdfcraft_model::pages(&doc)[0]).expect("text");
assert_eq!(lines.iter().map(|l| (l.ink.len(), l.runs().count())).collect::>(), [(1, 51)]);
- let limits = crate::Limits { comparisons: 50, covered_text: usize::MAX };
+ // Plus the hidden line's text, read once to copy it.
+ let text = lines[0].text.len() / crate::TEXT_BYTES_PER_COMPARISON;
+ let limits = crate::Limits { comparisons: 50 + text, covered_text: usize::MAX };
assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons)));
- let limits = crate::Limits { comparisons: 51, covered_text: usize::MAX };
+ let limits = crate::Limits { comparisons: 51 + text, covered_text: usize::MAX };
assert_eq!(crate::audit(&doc, limits).expect("within the budget").len(), 1);
}
@@ -746,14 +748,18 @@ fn copy_restricted_documents_withhold_covered_text() {
let findings = audit_redactions(&user).expect("audit must run");
assert_eq!(findings.len(), 1, "the cover is still reported: {findings:?}");
assert_eq!(findings[0].covered_text, "");
- // The output bound counts the text reported, so withheld text costs nothing.
- let nothing = crate::Limits { comparisons: usize::MAX, covered_text: 0 };
+ // The output bound counts the text reported and the finding's record, so withheld text
+ // costs only the record.
+ let record = std::mem::size_of::();
+ let nothing = crate::Limits { comparisons: usize::MAX, covered_text: record };
assert_eq!(crate::audit(&user, nothing).expect("withheld text is not output").len(), 1);
+ let none = crate::Limits { covered_text: record - 1, ..nothing };
+ assert!(matches!(crate::audit(&user, none), Err(crate::AuditError::TooMuchOutput)));
let owner = Document::open_with_password(bytes, Some("owner")).expect("open");
let findings = audit_redactions(&owner).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].covered_text, "SECRET DATA 123");
- let exact = crate::Limits { comparisons: usize::MAX, covered_text: "SECRET DATA 123".len() };
+ let exact = crate::Limits { comparisons: usize::MAX, covered_text: "SECRET DATA 123".len() + record };
assert!(crate::audit(&owner, exact).is_ok());
let short = crate::Limits { covered_text: exact.covered_text - 1, ..exact };
assert!(matches!(crate::audit(&owner, short), Err(crate::AuditError::TooMuchOutput)));
@@ -820,3 +826,52 @@ fn form_entries_past_the_page_allowance_fail_the_audit() {
let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect();
assert!(matches!(crate::cover::filled_rects(&doc, &page), Err(crate::AuditError::TooManyForms)));
}
+
+/// Copying a covered line's text walks all of it, padding included, so a long padded line under
+/// many covers costs its bytes once per cover, not one run per cover (CodeRabbit).
+#[test]
+fn covered_text_bytes_count_against_the_comparison_bound() {
+ // One run: 20 KiB of spaces around a word, which trims to the word alone.
+ let mut content = b"BT /F1 24 Tf 100 700 Td (".to_vec();
+ content.extend_from_slice(&[b' '; 10_000]);
+ content.extend_from_slice(b"SECRET");
+ content.extend_from_slice(&[b' '; 10_000]);
+ content.extend_from_slice(b") Tj ET\n0 g\n");
+ for _ in 0..10 {
+ content.extend_from_slice(b"-1 688 1000000 32 re f\n");
+ }
+ let doc = open(one_page(&content, ""));
+ let lines = pdfcraft_edit::glyph_text_lines(&doc, &pdfcraft_model::pages(&doc)[0]).expect("text");
+ assert_eq!(lines.len(), 1);
+ let bytes = lines[0].text.len();
+ assert!(bytes > 20_000, "{bytes} bytes of text");
+ // Each cover: one comparison for the line, plus its bytes when it is hidden.
+ let exact = crate::Limits { comparisons: 10 * (1 + bytes / crate::TEXT_BYTES_PER_COMPARISON), covered_text: usize::MAX };
+ let findings = crate::audit(&doc, exact).expect("within the budget");
+ assert_eq!(findings.len(), 10, "{findings:?}");
+ assert!(findings.iter().all(|f| f.covered_text == "SECRET"), "{findings:?}");
+ let short = crate::Limits { comparisons: exact.comparisons - 1, ..exact };
+ assert!(matches!(crate::audit(&doc, short), Err(crate::AuditError::TooManyComparisons)));
+}
+
+/// One annotation list shared by many pages makes a finding on each: every finding's record
+/// counts against the output bound, text or no text (CodeRabbit).
+#[test]
+fn every_finding_record_counts_against_the_output_bound() {
+ let content = b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n";
+ let mut b = Builder::new();
+ b.add("<< /Type /Catalog /Pages 2 0 R >>");
+ b.add("<< /Type /Pages /Kids [3 0 R 7 0 R 8 0 R] /Count 3 >>");
+ b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots 6 0 R >>");
+ b.add_stream(content);
+ b.add(FONT);
+ b.add("[<< /Type /Annot /Subtype /Redact /Rect [95 685 200 725] >>]");
+ b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots 6 0 R >>");
+ b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots 6 0 R >>");
+ let doc = open(b.build());
+ let one = "SECRET".len() + std::mem::size_of::();
+ let exact = crate::Limits { comparisons: usize::MAX, covered_text: 3 * one };
+ assert_eq!(crate::audit(&doc, exact).expect("within the budget").len(), 3);
+ let short = crate::Limits { covered_text: 3 * one - 1, ..exact };
+ assert!(matches!(crate::audit(&doc, short), Err(crate::AuditError::TooMuchOutput)));
+}
diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml
index a6e43de1f0..a825b1fe52 100644
--- a/crates/automation/Cargo.toml
+++ b/crates/automation/Cargo.toml
@@ -14,6 +14,8 @@ pdfcraft-cos = { workspace = true }
pdfcraft-render = { workspace = true }
pdfcraft-organize = { workspace = true }
pdfcraft-platform = { workspace = true }
+pdfcraft-prc = { workspace = true }
+pdfcraft-prc-render = { workspace = true }
serde_json = { workspace = true }
sha2 = "0.11"
tempfile = "3.27"
diff --git a/crates/automation/README.md b/crates/automation/README.md
index d17084aa30..d218fbcda6 100644
--- a/crates/automation/README.md
+++ b/crates/automation/README.md
@@ -5,6 +5,8 @@ Agent control for PdfCraft: a headless tool table over the engine, and an opt-in
- **Layer:** L7 (architecture §13), but headless. It depends on `engine`, `render` and `organize`, never on a UI toolkit.
- **Status:** over a hundred tools; `pdfcraft-cli tools` lists the current set with their schemas (the count isn't repeated here, so it can't go stale). They cover inspection, rendering, text, page edits and organizing, page labels, bookmarks, comments and their review, form filling and authoring (fields, properties, scripts, data exchange, detection), redaction and sanitizing, password protection and signatures, calibrated 2D measurements and CSV export, adding text, images, links and stamps, optimizing, OCR, printing, export, metadata, undo/redo, save, combine, extract and split. Comment tools take geometry in the same top-left-origin points as everything else, and `comment_add` can mark text by searching for it (`find`). The MCP server also serves the open documents as resources, page images included. The running app's UI is driven separately, through the `ui.*` control channel in `pdfcraft-ui-egui`.
+`fill_sign_add` text accepts line breaks, including blank lines. Its typewriter box grows to hold all wrapped rows, and saving/reopening preserves the contents and appearance.
+
## API
```rust
@@ -21,6 +23,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 +186,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 +204,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 +238,27 @@ 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`.
+
+## Rendering a 3D model
+
+`model_render` draws a page's 3D model (a 3D annotation's PRC stream, directly or through a 3D
+reference) to a PNG, without the viewer. `model` picks the page's Nth 3D model (default 1);
+`width` and `height` are 1 to 4096 (default 512); `direction` is the view direction from the eye
+(default `[-1, 1, -1]`, with Z up unless the view looks along Z); `perspective` defaults to true.
+The model is framed to fill the image and shaded by a headlight on white. U3D models are refused
+with an error, and the annotation's own views, lighting and render modes are not applied yet.
+
## Viewer navigation history
A running PdfCraft instance launched with `--control FILE` exposes its per-tab history through
@@ -229,3 +274,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.
diff --git a/crates/automation/src/a11y/documents.rs b/crates/automation/src/a11y/documents.rs
index f2c9f6b77d..5ae3d4fe46 100644
--- a/crates/automation/src/a11y/documents.rs
+++ b/crates/automation/src/a11y/documents.rs
@@ -5,7 +5,14 @@ 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 html_mode = a.opt_str("html_mode")?.unwrap_or("rich");
+ let format = match (format, html_mode) {
+ (pdfcraft_engine::compare::OfficeFormat::Html, "rich") => pdfcraft_engine::compare::OfficeFormat::Html,
+ (pdfcraft_engine::compare::OfficeFormat::Html, "simple") => pdfcraft_engine::compare::OfficeFormat::HtmlSimple,
+ (_, "rich") => format,
+ (_, _) => return Err(ToolError::InvalidArgs("html_mode is only available for HTML exports (rich or simple)".into())),
+ };
let export = self.doc_to_copy(a)?.export_office_checked(format);
write_atomic(&path, &export.bytes)?;
// Text left out to keep a hostile page bounded is said, not silent (#3120).
@@ -15,7 +22,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)
diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs
index 7272a8d669..2420a44372 100644
--- a/crates/automation/src/a11y/form_tools.rs
+++ b/crates/automation/src/a11y/form_tools.rs
@@ -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 {
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 = 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 = 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 {
diff --git a/crates/automation/src/batch.rs b/crates/automation/src/batch.rs
index 47cf714ec9..decb18d2b7 100644
--- a/crates/automation/src/batch.rs
+++ b/crates/automation/src/batch.rs
@@ -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() {
diff --git a/crates/automation/src/bates.rs b/crates/automation/src/bates.rs
new file mode 100644
index 0000000000..3cd1d7849e
--- /dev/null
+++ b/crates/automation/src/bates.rs
@@ -0,0 +1,214 @@
+use std::io::Write;
+
+use pdfcraft_engine::{Edit, HeaderFooter};
+use serde_json::{Value, json};
+
+use super::error::{Result, ToolError, failed};
+use super::json::Args;
+use super::paths::read_input;
+use super::{Arc, Automation, Path, PathBuf};
+
+struct FilePlan {
+ input: String,
+ source: PathBuf,
+ password: Option,
+ output: PathBuf,
+ pages: usize,
+ first: u64,
+ last: u64,
+}
+
+impl Automation {
+ pub(crate) fn doc_bates_multi(&mut self, args: &Args) -> Result {
+ let files = args.0.get("files").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("files must be an array".into()))?;
+ if files.is_empty() || files.len() > 1000 {
+ return Err(ToolError::InvalidArgs("files must contain 1 to 1000 entries".into()));
+ }
+ let start = match args.0.get("start_number") {
+ None | Some(Value::Null) => 1,
+ Some(value) => value
+ .as_u64()
+ .filter(|n| *n >= 1)
+ .ok_or_else(|| ToolError::InvalidArgs("start_number must be a whole number of at least 1".into()))?,
+ };
+ let digits = args.opt_int("digits")?.unwrap_or(6);
+ let digits =
+ u8::try_from(digits).ok().filter(|d| (1..=12).contains(d)).ok_or_else(|| ToolError::InvalidArgs("digits must be from 1 to 12".into()))?;
+ let prefix = args.opt_str("prefix")?.unwrap_or("");
+ let suffix = args.opt_str("suffix")?.unwrap_or("");
+ // `#` separates the token's fields and `>>` ends it: either would cut the token short.
+ if prefix.len() > 128 || suffix.len() > 128 || [prefix, suffix].iter().any(|s| s.contains('#') || s.contains(">>")) {
+ return Err(ToolError::InvalidArgs("prefix and suffix must be at most 128 bytes and cannot contain # or >>".into()));
+ }
+ let dry_run = args.opt_bool("dry_run")?.unwrap_or(false);
+
+ // Resolve and reserve the complete path list before opening or writing anything.
+ let mut sources = Vec::with_capacity(files.len());
+ let mut targets = Vec::with_capacity(files.len());
+ let mut passwords = Vec::with_capacity(files.len());
+ for entry in files {
+ let input = entry
+ .get("input")
+ .and_then(Value::as_str)
+ .filter(|s| !s.is_empty())
+ .ok_or_else(|| ToolError::InvalidArgs("each file needs a non-empty input path".into()))?;
+ let output = entry
+ .get("output")
+ .and_then(Value::as_str)
+ .filter(|s| !s.is_empty())
+ .ok_or_else(|| ToolError::InvalidArgs("each file needs a non-empty output path".into()))?;
+ let source = self.resolve(input, false)?;
+ let target = self.resolve(output, true)?;
+ if target.exists() {
+ return Err(failed(format!("output already exists; refusing to replace it: {output}")));
+ }
+ let parent = target.parent().ok_or_else(|| failed(format!("output has no parent directory: {output}")))?;
+ if !parent.is_dir() {
+ return Err(failed(format!("output directory does not exist: {}", parent.display())));
+ }
+ sources.push((input.to_string(), source));
+ targets.push((output.to_string(), target));
+ passwords.push(entry.get("password").and_then(Value::as_str).map(str::to_owned));
+ }
+ let input_paths: std::collections::HashSet<_> = sources.iter().map(|(_, path)| path.clone()).collect();
+ let mut output_paths = std::collections::HashSet::new();
+ for (name, path) in &targets {
+ if input_paths.contains(path) {
+ return Err(failed(format!("output {name} names an input file; choose a separate output path")));
+ }
+ if !output_paths.insert(path.clone()) {
+ return Err(failed(format!("two files use the same output path: {name}")));
+ }
+ }
+
+ let mut plans = Vec::with_capacity(files.len());
+ let mut next = start;
+ let mut input_bytes = 0u64;
+ for (index, ((input_name, input_path), (_, output_path))) in sources.iter().zip(&targets).enumerate() {
+ let size = std::fs::metadata(input_path).map_err(failed)?.len();
+ input_bytes = input_bytes
+ .checked_add(size)
+ .filter(|total| *total <= 512 * 1024 * 1024)
+ .ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?;
+ let bytes = read_input(input_path)?;
+ input_bytes = input_bytes
+ .saturating_sub(size)
+ .checked_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX))
+ .filter(|total| *total <= 512 * 1024 * 1024)
+ .ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?;
+ let display_name = input_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
+ let password = passwords.get(index).and_then(Option::as_deref);
+ let id = self.session.open(display_name, Some(input_path.to_string_lossy().into_owned()), Arc::new(bytes), password).map_err(failed)?;
+ let pages =
+ self.session.get(id).map(|doc| doc.info.pages.len()).ok_or_else(|| failed("opened document disappeared during Bates preflight"))?;
+ self.session.close(id);
+ if pages == 0 {
+ return Err(failed(format!("{input_name} has no pages to number")));
+ }
+ let count = u64::try_from(pages).map_err(|_| failed(format!("page count is too large for {input_name}")))?;
+ let last = next.checked_add(count.saturating_sub(1)).ok_or_else(|| failed(format!("Bates number range overflows for {input_name}")))?;
+ plans.push(FilePlan {
+ input: input_name.clone(),
+ source: input_path.clone(),
+ password: passwords.get(index).cloned().flatten(),
+ output: output_path.clone(),
+ pages,
+ first: next,
+ last,
+ });
+ if index + 1 < sources.len() {
+ next = last.checked_add(1).ok_or_else(|| failed(format!("Bates sequence overflows after {input_name}")))?;
+ }
+ }
+
+ let intervals: Vec = plans
+ .iter()
+ .zip(&targets)
+ .map(|(p, (name, _))| json!({"input":p.input,"output":name,"pages":p.pages,"first":p.first,"last":p.last}))
+ .collect();
+ if dry_run {
+ return Ok(json!({"dry_run":true,"files":intervals}));
+ }
+
+ let mut staged = Vec::with_capacity(plans.len());
+ let mut results = Vec::with_capacity(plans.len());
+ let mut output_bytes = 0u64;
+ for plan in &plans {
+ let source_bytes = match read_input(&plan.source) {
+ Ok(bytes) => bytes,
+ Err(error) => {
+ results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()}));
+ continue;
+ }
+ };
+ let name = plan.source.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
+ let id = match self.session.open(name, Some(plan.source.to_string_lossy().into_owned()), Arc::new(source_bytes), plan.password.as_deref())
+ {
+ Ok(id) => id,
+ Err(error) => {
+ results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()}));
+ continue;
+ }
+ };
+ if self.session.get(id).is_none_or(|doc| doc.info.pages.len() != plan.pages) {
+ self.session.close(id);
+ results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"input changed after preflight; page count differs"}));
+ continue;
+ }
+ let template = format!("<>", plan.first);
+ let mut settings = HeaderFooter::default();
+ settings.text[4] = template;
+ let pages: Vec = (0..plan.pages).collect();
+ let applied = self.session.apply(id, Edit::AddHeaderFooter { pages, settings, replace: false });
+ let result = applied.and_then(|()| self.session.save_full_bytes(id));
+ match result {
+ Ok(bytes) => {
+ let total = output_bytes.saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
+ if total > 512 * 1024 * 1024 {
+ results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"combined Bates output exceeds the 512 MiB safety limit"}));
+ } else {
+ output_bytes = total;
+ staged.push((plan.output.clone(), bytes));
+ }
+ }
+ Err(error) => results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})),
+ }
+ self.session.close(id);
+ }
+ for (path, bytes) in staged {
+ match publish_new(&path, &bytes) {
+ Ok(()) => {
+ let plan = plans.iter().find(|p| p.output == path);
+ if let Some(plan) = plan {
+ results.push(json!({"input":plan.input,"output":path,"status":"ok","first":plan.first,"last":plan.last,"pages":plan.pages}));
+ }
+ }
+ Err(error) => {
+ let plan = plans.iter().find(|p| p.output == path);
+ if let Some(plan) = plan {
+ results.push(json!({"input":plan.input,"output":path,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()}));
+ }
+ }
+ }
+ }
+ results.sort_by_key(|result| plans.iter().position(|plan| result["output"] == json!(plan.output)).unwrap_or(usize::MAX));
+ let completed = results.iter().filter(|r| r["status"] == "ok").count();
+ let failed_count = results.len().saturating_sub(completed);
+ Ok(json!({"dry_run":false,"completed":completed,"failed":failed_count,"files":results}))
+ }
+}
+
+/// Publish a PDF atomically while refusing a target created by another process after preflight.
+fn publish_new(path: &Path, bytes: &[u8]) -> Result<()> {
+ let parent = path.parent().ok_or_else(|| failed("output has no parent directory"))?;
+ let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(failed)?;
+ temp.write_all(bytes).map_err(failed)?;
+ temp.as_file().sync_all().map_err(failed)?;
+ temp.persist_noclobber(path).map(|_| ()).map_err(|error| {
+ if error.error.kind() == std::io::ErrorKind::AlreadyExists {
+ failed(format!("output appeared after preflight; refusing to replace it: {}", path.display()))
+ } else {
+ failed(error.error)
+ }
+ })
+}
diff --git a/crates/automation/src/bookmark_io.rs b/crates/automation/src/bookmark_io.rs
new file mode 100644
index 0000000000..2a06d912f4
--- /dev/null
+++ b/crates/automation/src/bookmark_io.rs
@@ -0,0 +1,209 @@
+//! Bookmark trees in and out of documents: `bookmark_export`, `bookmark_import`, `bookmark_copy`.
+//!
+//! The portable form is JSON: `{"bookmarks": [node, …]}`, where a node is `title`, `page`
+//! (1-based, or `null` for one that goes nowhere), `view` (`{"type", "values"}`: a destination
+//! type and its values in PDF user space, as the file stores them), `color`, `bold`, `italic`,
+//! `open` and `children`. What export writes, import takes back unchanged. A file also carries
+//! `"truncated": true` when the export left out bookmarks nested past the listing limit.
+use crate::{Args, Automation, Result, ToolError, failed};
+use pdfcraft_engine::{DocId, Edit, OutlineNode};
+use serde_json::{Value, json};
+
+/// Deepest nesting a bookmark tree may have (the listing reaches 33 levels).
+const MAX_DEPTH: usize = 33;
+
+/// The keys a node may have: the ones [`node_json`] writes.
+const NODE_KEYS: [&str; 8] = ["title", "page", "view", "color", "bold", "italic", "open", "children"];
+
+impl Automation {
+ pub(crate) fn bookmark_export(&self, a: &Args) -> Result {
+ let id = self.doc(a)?.id;
+ let path = a.opt_path("path")?.unwrap_or_default();
+ let tree = self.outline_of(id, &path)?;
+ let mut out = json!({ "bookmarks": tree.nodes.iter().map(node_json).collect::>(), "truncated": tree.truncated });
+ if let Some(file) = a.opt_str("out")? {
+ let target = self.resolve(file, true)?;
+ let mut saved = json!({ "bookmarks": out["bookmarks"] });
+ if tree.truncated {
+ saved["truncated"] = json!(true);
+ }
+ let text = serde_json::to_vec_pretty(&saved).map_err(failed)?;
+ crate::write_atomic(&target, &text)?;
+ out["path"] = json!(target);
+ }
+ Ok(out)
+ }
+
+ pub(crate) fn bookmark_import(&mut self, a: &Args) -> Result {
+ let (nodes, truncated) = match (a.get("bookmarks"), a.opt_str("file")?) {
+ (Some(list), None) => (nodes_of(list)?, false),
+ (None, Some(file)) => {
+ let path = self.resolve(file, false)?;
+ let value: Value = serde_json::from_slice(&crate::read_input(&path)?).map_err(|e| failed(format!("not a bookmark file: {e}")))?;
+ let list = value.get("bookmarks").ok_or_else(|| failed("not a bookmark file: it has no \"bookmarks\" list"))?;
+ (nodes_of(list)?, value.get("truncated") == Some(&Value::Bool(true)))
+ }
+ _ => return Err(ToolError::InvalidArgs("give either bookmarks or file".into())),
+ };
+ let mut out = self.write_tree(a, nodes)?;
+ out["truncated"] = json!(truncated);
+ Ok(out)
+ }
+
+ pub(crate) fn bookmark_copy(&mut self, a: &Args) -> Result {
+ let from = a.int("from")?;
+ let from = DocId(u64::try_from(from).map_err(|_| ToolError::InvalidArgs("from must be positive".into()))?);
+ let path = a.opt_path("path")?.unwrap_or_default();
+ let tree = self.outline_of(from, &path)?;
+ let mut out = self.write_tree(a, tree.nodes)?;
+ out["truncated"] = json!(tree.truncated);
+ Ok(out)
+ }
+
+ fn outline_of(&self, id: DocId, path: &[usize]) -> Result {
+ match self.session.outline_tree(id, path) {
+ // The engine names the path 0-based; the caller gave it 1-based.
+ Some(tree) => tree.map_err(|e| match e {
+ pdfcraft_organize::OutlineError::NoSuchBookmark(_) => failed(format!("there is no bookmark at {:?}", one_based(path))),
+ e => failed(e),
+ }),
+ None => Err(failed(format!("document {} is not open, or its bookmarks can't be read for editing", id.0))),
+ }
+ }
+
+ /// Write `nodes` into `doc` under `parent` at `position` (or in place of the outline with
+ /// `replace`), and say which of them went nowhere because the document lacks their page.
+ fn write_tree(&mut self, a: &Args, bookmarks: Vec) -> Result {
+ let parent = a.opt_path("parent")?.unwrap_or_default();
+ let index = a.opt_int("position")?.map_or(usize::MAX, |p| usize::try_from(p.max(1) - 1).unwrap_or(usize::MAX));
+ let replace = a.opt_bool("replace")?.unwrap_or(false);
+ let count = self.doc(a)?.info.pages.len();
+ let unmapped = pdfcraft_engine::outline_unmapped(&bookmarks, count);
+ let mut out = self.apply(a, Edit::ImportBookmarks { parent, index, bookmarks, replace })?;
+ out["unmapped"] = json!(unmapped.iter().map(|p| one_based(p)).collect::>());
+ Ok(out)
+ }
+}
+
+fn one_based(path: &[usize]) -> Vec {
+ path.iter().map(|i| i.saturating_add(1)).collect()
+}
+
+/// A node as JSON, leaving out what is at its default (no view, black, regular, no children).
+fn node_json(n: &OutlineNode) -> Value {
+ let mut v = json!({ "title": n.title, "page": n.page.map(|p| p.saturating_add(1)), "open": n.open });
+ if let Some((kind, values)) = &n.view {
+ v["view"] = json!({ "type": String::from_utf8_lossy(kind), "values": values });
+ }
+ if let Some(c) = n.color {
+ v["color"] = json!(c);
+ }
+ if n.bold {
+ v["bold"] = json!(true);
+ }
+ if n.italic {
+ v["italic"] = json!(true);
+ }
+ if !n.children.is_empty() {
+ v["children"] = json!(n.children.iter().map(node_json).collect::>());
+ }
+ v
+}
+
+fn nodes_of(list: &Value) -> Result> {
+ let mut count = 0;
+ list_of(list, &[], &mut count)
+}
+
+/// The nodes of one level; `at` is the 1-based path of their parent, for messages.
+fn list_of(list: &Value, at: &[usize], count: &mut usize) -> Result> {
+ let items = list.as_array().ok_or_else(|| bad(at, "children must be a list of bookmarks"))?;
+ if at.len() >= MAX_DEPTH && !items.is_empty() {
+ return Err(bad(at, &format!("bookmarks may nest at most {MAX_DEPTH} levels deep")));
+ }
+ *count = count.saturating_add(items.len());
+ if *count > pdfcraft_engine::MAX_IMPORT_BOOKMARKS {
+ return Err(ToolError::InvalidArgs(format!("at most {} bookmarks can be imported at once", pdfcraft_engine::MAX_IMPORT_BOOKMARKS)));
+ }
+ let mut out = Vec::with_capacity(items.len());
+ for (i, item) in items.iter().enumerate() {
+ let mut path = at.to_vec();
+ path.push(i.saturating_add(1));
+ out.push(node_of(item, &path, count)?);
+ }
+ Ok(out)
+}
+
+/// One node and, through [`list_of`], its children (recursion bounded by `MAX_DEPTH`).
+fn node_of(v: &Value, at: &[usize], count: &mut usize) -> Result {
+ let o = v.as_object().ok_or_else(|| bad(at, "must be an object"))?;
+ if let Some(k) = o.keys().find(|k| !NODE_KEYS.contains(&k.as_str())) {
+ return Err(bad(at, &format!("unknown key {k:?} (a bookmark has {})", NODE_KEYS.join(", "))));
+ }
+ let field = |k: &str| o.get(k).filter(|x| !x.is_null());
+ // An empty title is kept: files have them, and export writes them as "".
+ let title = field("title").and_then(Value::as_str).ok_or_else(|| bad(at, "needs a title"))?.to_string();
+ let page = match field("page") {
+ None => None,
+ Some(p) => Some(p.as_u64().filter(|p| *p >= 1).and_then(|p| usize::try_from(p - 1).ok()).ok_or_else(|| bad(at, "page must be 1 or more"))?),
+ };
+ let flag = |k: &str| field(k).map_or(Ok(None), |x| x.as_bool().map(Some).ok_or_else(|| bad(at, &format!("{k} must be true or false"))));
+ let color = match field("color") {
+ None => None,
+ Some(c) => {
+ let rgb: Option> = c.as_array().and_then(|c| c.iter().map(|x| x.as_f64().map(|x| x as f32)).collect());
+ match rgb.as_deref() {
+ Some(&[r, g, b]) if [r, g, b].iter().all(|c| (0.0..=1.0).contains(c)) => Some([r, g, b]),
+ _ => return Err(bad(at, "color must be [r, g, b], each from 0 to 1")),
+ }
+ }
+ };
+ let children = match field("children") {
+ None => Vec::new(),
+ Some(list) => list_of(list, at, count)?,
+ };
+ Ok(OutlineNode {
+ title,
+ page,
+ view: field("view").map(|x| view_of(x, at)).transpose()?,
+ color,
+ italic: flag("italic")?.unwrap_or(false),
+ bold: flag("bold")?.unwrap_or(false),
+ open: flag("open")?.unwrap_or(true),
+ children,
+ })
+}
+
+/// `{"type": "XYZ", "values": [left, top, zoom]}`: a destination type with exactly its values.
+fn view_of(v: &Value, at: &[usize]) -> Result<(Vec, Vec>)> {
+ let kind = v.get("type").and_then(Value::as_str).ok_or_else(|| bad(at, "view needs a type such as XYZ or Fit"))?;
+ let values = match v.get("values").filter(|x| !x.is_null()) {
+ None => Vec::new(),
+ Some(list) => {
+ let list = list.as_array().filter(|l| l.len() <= 4).ok_or_else(|| bad(at, "view values must be a list of at most 4 numbers or nulls"))?;
+ list.iter()
+ .map(|x| match x {
+ Value::Null => Ok(None),
+ x => x.as_f64().map(Some).ok_or_else(|| bad(at, "view values must be numbers or null")),
+ })
+ .collect::>()?
+ }
+ };
+ let arity = pdfcraft_engine::outline_view_arity(kind.as_bytes()).ok_or_else(|| bad(at, VIEW_TYPES))?;
+ if values.len() != arity {
+ return Err(bad(at, &format!("a {kind} view takes {arity} values (numbers, or null to keep the reader's)")));
+ }
+ if kind == "FitR" && values.iter().any(Option::is_none) {
+ return Err(bad(at, "a FitR view needs all four values: left, bottom, right, top"));
+ }
+ Ok((kind.as_bytes().to_vec(), values))
+}
+
+const VIEW_TYPES: &str = "view type must be XYZ (3 values: left, top, zoom), Fit, FitB (none), FitH, FitV, FitBH, FitBV (1) or FitR (4)";
+
+fn bad(at: &[usize], what: &str) -> ToolError {
+ if at.is_empty() {
+ return ToolError::InvalidArgs(format!("bookmarks: {what}"));
+ }
+ ToolError::InvalidArgs(format!("bookmark {at:?}: {what}"))
+}
diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs
index 0150001096..135d0868ee 100644
--- a/crates/automation/src/comments.rs
+++ b/crates/automation/src/comments.rs
@@ -75,7 +75,7 @@ fn to_user(p: &PageInfo, x: f64, y: f64) -> [f64; 2] {
[ux as f64, uy as f64]
}
-fn rect_to_user(p: &PageInfo, r: [f64; 4]) -> [f64; 4] {
+pub(crate) fn rect_to_user(p: &PageInfo, r: [f64; 4]) -> [f64; 4] {
let (a, b) = (to_user(p, r[0], r[1]), to_user(p, r[2], r[3]));
[a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]
}
@@ -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::>>()
})
.collect::>>()?;
- 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()));
@@ -534,8 +539,10 @@ impl Automation {
return self.apply(a, edit);
}
let text_at = |t: &str| {
- let w = (pdfcraft_engine::annot_text::text_width(t, size) + 8.0).clamp(20.0, 600.0);
- let h = size * 1.2 + 6.0;
+ let longest = t.lines().map(|line| pdfcraft_engine::annot_text::text_width(line, size)).fold(0.0, f64::max);
+ let w = (longest + 8.0).clamp(20.0, 600.0);
+ let rows = pdfcraft_engine::annot_text::wrap(t, size, w - 4.0).len().max(1);
+ let h = rows as f64 * size * 1.2 + 6.0;
let rect = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]);
Shape::Typewriter { rect, font_size: size }
};
@@ -579,7 +586,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 }))
}
diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs
index c860298d98..6b25c31f61 100644
--- a/crates/automation/src/content.rs
+++ b/crates/automation/src/content.rs
@@ -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 {
+ 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>>> {
+ 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::>>()
+ .map(Some)
+}
+
impl Automation {
pub(crate) fn content_list(&self, a: &Args) -> Result {
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 {
diff --git a/crates/automation/src/conventions.rs b/crates/automation/src/conventions.rs
index ac670109fb..a84c6488a4 100644
--- a/crates/automation/src/conventions.rs
+++ b/crates/automation/src/conventions.rs
@@ -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::>()}))
}
- pub(crate) fn render_preview(&mut self, args: &Args) -> Result {
+ /// 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> {
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)
}
}
diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs
index b8dfd36388..c3203f355e 100644
--- a/crates/automation/src/dispatch.rs
+++ b/crates/automation/src/dispatch.rs
@@ -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, 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> {
@@ -24,6 +53,7 @@ impl Automation {
tools::check_args(def, args)?;
let a = Args(args);
let out = match name {
+ "doc_bates_multi" => self.doc_bates_multi(&a)?,
"doc_open" => self.doc_open(&a)?,
"doc_batch" => self.doc_batch(&a)?,
"doc_list" => json!({ "documents": self.session.docs().iter().map(summary).collect::>() }),
@@ -38,7 +68,8 @@ 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),
+ "model_render" => return self.model_render(&a),
"comment_image_preview" => return self.comment_image_preview(&a),
"text_extract" => self.text_extract(&a)?,
"text_word_count" => self.text_word_count(&a)?,
@@ -171,7 +202,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();
@@ -195,6 +226,9 @@ impl Automation {
out
}
"bookmark_list" => self.bookmark_listing(&a)?,
+ "bookmark_export" => self.bookmark_export(&a)?,
+ "bookmark_import" => self.bookmark_import(&a)?,
+ "bookmark_copy" => self.bookmark_copy(&a)?,
"bookmark_add" => {
let page = self.page(&a)?;
let parent = a.opt_path("parent")?.unwrap_or_default();
@@ -263,6 +297,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 = 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")?;
@@ -295,13 +357,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)?,
@@ -358,7 +420,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 })
@@ -396,6 +458,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 = 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)
@@ -411,9 +483,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)?;
@@ -539,7 +620,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),
@@ -549,7 +638,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")?);
@@ -569,13 +658,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]);
@@ -620,6 +706,7 @@ impl Automation {
}
out
}
+ "text_script" => self.text_script(&a)?,
"doc_audit_space" => {
let rows: Vec = self
.doc(&a)?
@@ -637,6 +724,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)?);
@@ -714,6 +802,7 @@ impl Automation {
"comment_list" => self.comment_list(&a)?,
"comment_add" => self.comment_add(&a)?,
"stamp_custom" => self.stamp_custom(&a)?,
+ "model_add" => self.model_add(&a)?,
"comment_reply" => self.comment_reply(&a)?,
"comment_set_status" => self.comment_set_status(&a)?,
"sign_list" => self.sign_list(&a)?,
@@ -810,4 +899,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 {
+ 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)
+ }
}
diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs
index 476c451028..979b041a73 100644
--- a/crates/automation/src/documents.rs
+++ b/crates/automation/src/documents.rs
@@ -279,7 +279,8 @@ impl Automation {
}
pub(crate) fn doc_optimize(&mut self, a: &Args) -> Result {
- 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 {
+ 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 = 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 {
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,
diff --git a/crates/automation/src/file_access.rs b/crates/automation/src/file_access.rs
index 067a593888..dab2760f5d 100644
--- a/crates/automation/src/file_access.rs
+++ b/crates/automation/src/file_access.rs
@@ -394,8 +394,18 @@ mod tests {
// Helpers that are only called with a resolved path. `doc_batch` (batch.rs) resolves its
// inputs and out_dir, walks only inside them (skipping links) and resolves every
// discovered file again before `process` reads it or `publish` writes it.
- const RESOLVED_BY_CALLER: &[&str] =
- &["write_atomic", "write_atomic_with", "read_input", "read_input_bytes", "read_review_sidecar", "process", "linked", "walk", "publish"];
+ const RESOLVED_BY_CALLER: &[&str] = &[
+ "write_atomic",
+ "write_atomic_with",
+ "read_input",
+ "read_input_bytes",
+ "read_model",
+ "read_review_sidecar",
+ "process",
+ "linked",
+ "walk",
+ "publish",
+ ];
let mut dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
let mut files = vec![];
while let Some(dir) = dirs.pop() {
diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs
index 71fbc67b3b..adf0bd0884 100644
--- a/crates/automation/src/forms.rs
+++ b/crates/automation/src/forms.rs
@@ -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 {
})
}
-/// `{"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 {
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 {
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 =
- 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 = fields
+ .iter()
+ .map(|x| x.as_str().map(str::to_owned).ok_or_else(|| bad("calculate fields must be field names (strings)")))
+ .collect::>()?;
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 {
+ 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::>()));
+ }
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 {
- 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::>());
out["fields"] = order
.fields
.iter()
- .map(|f| json!({ "name": f.name, "reads": f.reads, "listed": f.listed, "issues": f.issues.iter().map(issue).collect::>() }))
+ .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::>(),
+ })
+ })
.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::>()))
}
+ /// Each page's tab order (Page properties ▸ Tab order), in page order.
+ fn page_tabs(&self, a: &Args) -> Result {
+ 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 {
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 {
diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs
index 6f0884226a..9e93d4c347 100644
--- a/crates/automation/src/json.rs
+++ b/crates/automation/src/json.rs
@@ -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> {
+ 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> {
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"))?;
diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs
index e1ae0141c4..520d836b01 100644
--- a/crates/automation/src/lib.rs
+++ b/crates/automation/src/lib.rs
@@ -16,6 +16,7 @@
mod a11y;
mod batch;
+mod bates;
mod comments;
mod content;
mod conventions;
@@ -26,6 +27,8 @@ mod links;
#[cfg(feature = "mcp")]
pub mod mcp;
mod measure;
+mod model3d;
+mod model_render;
mod printing;
mod reading;
mod redact;
@@ -49,6 +52,7 @@ use file_access::foreign_share;
use text_cache::TextCache;
mod assemble;
+mod bookmark_io;
mod dispatch;
mod documents;
mod error;
@@ -62,7 +66,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)]
diff --git a/crates/automation/src/mcp.rs b/crates/automation/src/mcp.rs
index 84cc21b258..b033871867 100644
--- a/crates/automation/src/mcp.rs
+++ b/crates/automation/src/mcp.rs
@@ -557,7 +557,7 @@ fn tool_json(t: &crate::ToolDef) -> Value {
"title": t.title,
"description": t.description,
"inputSchema": t.input_schema,
- "annotations": { "title": t.title, "readOnlyHint": t.read_only, "destructiveHint": t.destructive, "idempotentHint": t.read_only || matches!(t.name, "doc_save" | "doc_optimize" | "doc_export_data" | "measure_export" | "doc_export_images" | "doc_export_office" | "doc_export_all_images" | "doc_export_text" | "image_save"), "openWorldHint": false },
+ "annotations": { "title": t.title, "readOnlyHint": t.read_only, "destructiveHint": t.destructive, "idempotentHint": t.read_only || matches!(t.name, "doc_save" | "doc_optimize" | "doc_export_data" | "measure_export" | "doc_export_images" | "doc_export_office" | "doc_export_all_images" | "doc_export_text" | "image_save" | "bookmark_export"), "openWorldHint": false },
})
}
diff --git a/crates/automation/src/model3d.rs b/crates/automation/src/model3d.rs
new file mode 100644
index 0000000000..2d89e4ae60
--- /dev/null
+++ b/crates/automation/src/model3d.rs
@@ -0,0 +1,114 @@
+//! 3D model tools (Acrobat: Rich Media ▸ Add 3D, AGENTS.md §3).
+
+use std::path::Path;
+
+use pdfcraft_engine::{Activation3d, Edit, MAX_3D_MODEL_BYTES, MarkFile, Model3dFormat};
+use serde_json::Value;
+
+use crate::comments::rect_to_user;
+use crate::{Args, Automation, Result, ToolError, failed};
+
+/// The largest side of a 3D model's box, in points: the largest page PDF allows (ISO 32000-2 Annex C).
+const MAX_MODEL_SIDE: f64 = 14_400.0;
+
+impl Automation {
+ /// A U3D or PRC model in `rect` on `page`, its poster the picture at `poster` (a PDF page or an
+ /// image) fitted inside the box, or a labelled frame.
+ pub(crate) fn model_add(&mut self, a: &Args) -> Result {
+ let page = self.page(a)?;
+ let info = self.doc(a)?.info.pages.get(page).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("page {} does not exist", page + 1)))?;
+ let r = a.nums::<4>("rect")?.ok_or_else(|| ToolError::InvalidArgs("missing argument rect".into()))?;
+ let (w, h) = ((r[2] - r[0]).abs(), (r[3] - r[1]).abs());
+ if !((1.0..=MAX_MODEL_SIDE).contains(&w) && (1.0..=MAX_MODEL_SIDE).contains(&h)) {
+ return Err(ToolError::InvalidArgs(format!(
+ "rect must be [x0, y0, x1, y1] with each side at least 1 pt and at most {MAX_MODEL_SIDE} pt"
+ )));
+ }
+ let activation = match a.opt_str("activation")? {
+ None => Activation3d::default(),
+ Some(word) => Activation3d::ALL
+ .iter()
+ .find(|(_, w)| *w == word)
+ .map(|(act, _)| *act)
+ .ok_or_else(|| ToolError::InvalidArgs(format!("activation must be one of: {}", Activation3d::ALL.map(|(_, w)| w).join(", "))))?,
+ };
+ let path = self.resolve(a.str("path")?, false)?;
+ let model = read_model(&path, MAX_3D_MODEL_BYTES)?;
+ let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
+ let name = a.opt_str("name")?.map(str::to_owned).unwrap_or_else(|| file.rsplit_once('.').map_or(file.clone(), |(s, _)| s.to_owned()));
+ let poster = match a.opt_str("poster")? {
+ None => None,
+ Some(p) => {
+ let path = self.resolve(p, false)?;
+ let bytes = crate::read_input(&path)?;
+ let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
+ let page = usize::try_from(a.opt_int("poster_page")?.unwrap_or(1).max(1)).unwrap_or(1).saturating_sub(1);
+ Some(MarkFile { name, bytes: std::sync::Arc::new(bytes), page })
+ }
+ };
+ let edit = Edit::AddModel3d { page, rect: rect_to_user(&info, r), model: std::sync::Arc::new(model), poster, activation, name };
+ self.apply(a, edit)
+ }
+}
+
+/// Read a resolved model file of at most `max` bytes. Its size (from the file system) and its first
+/// bytes are checked before the rest is read, so a large file that is not a model is never loaded.
+fn read_model(path: &Path, max: usize) -> Result> {
+ use std::io::Read as _;
+ let io = |e: std::io::Error| failed(format!("{}: {e}", path.display()));
+ let too_large = |size: u64| failed(format!("the 3D model is {size} bytes; the largest accepted is {max}"));
+ let cap = u64::try_from(max).unwrap_or(u64::MAX);
+ let mut file = std::fs::File::open(path).map_err(io)?;
+ if let Ok(meta) = file.metadata()
+ && meta.is_file()
+ && meta.len() > cap
+ {
+ return Err(too_large(meta.len()));
+ }
+ let mut bytes = Vec::new();
+ (&mut file).take(4).read_to_end(&mut bytes).map_err(io)?;
+ Model3dFormat::detect(&bytes).map_err(failed)?;
+ let rest = cap.saturating_add(1).saturating_sub(bytes.len() as u64);
+ file.take(rest).read_to_end(&mut bytes).map_err(io)?;
+ if bytes.len() > max {
+ return Err(failed(format!("the 3D model is more than {max} bytes, the largest accepted")));
+ }
+ Ok(bytes)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn file(dir: &Path, name: &str, bytes: &[u8]) -> std::path::PathBuf {
+ let path = dir.join(name);
+ std::fs::write(&path, bytes).unwrap();
+ path
+ }
+
+ #[test]
+ fn a_model_is_read_whole_when_it_fits() {
+ let dir = tempfile::tempdir().unwrap();
+ let mut prc = b"PRC".to_vec();
+ prc.extend_from_slice(&[7; 40]);
+ assert_eq!(read_model(&file(dir.path(), "m.prc", &prc), 64).unwrap(), prc);
+ assert_eq!(read_model(&file(dir.path(), "m.u3d", b"U3D\0x"), 6).unwrap(), b"U3D\0x");
+ }
+
+ #[test]
+ fn a_model_past_the_cap_is_refused_by_its_size_before_it_is_read() {
+ let dir = tempfile::tempdir().unwrap();
+ let path = file(dir.path(), "big.prc", &[b'P', b'R', b'C', 0, 0, 0, 0, 0, 0, 0]);
+ let err = read_model(&path, 4).unwrap_err().to_string();
+ assert!(err.contains("is 10 bytes") && err.contains("largest accepted is 4"), "the file's own size, from the file system: {err}");
+ }
+
+ #[test]
+ fn a_file_that_is_not_a_model_is_refused_by_its_header() {
+ let dir = tempfile::tempdir().unwrap();
+ for junk in [&b"%PDF-1.7 a page"[..], b"PR", b"", b"U3DX and more"] {
+ let err = read_model(&file(dir.path(), "junk.bin", junk), 64).unwrap_err().to_string();
+ assert!(err.contains("not a 3D model"), "{junk:?}: {err}");
+ }
+ }
+}
diff --git a/crates/automation/src/model_render.rs b/crates/automation/src/model_render.rs
new file mode 100644
index 0000000000..dbfabc6708
--- /dev/null
+++ b/crates/automation/src/model_render.rs
@@ -0,0 +1,104 @@
+//! `model_render`: a page's 3D model (ISO 32000-2 §13.6) drawn to a PNG, without a viewer.
+
+use pdfcraft_engine::model3d::Model3dKind;
+use pdfcraft_prc::PrcFile;
+use pdfcraft_prc_render::{Camera, RenderOptions, assemble, render};
+
+use super::Automation;
+use super::dispatch::Content;
+use super::error::{Result, ToolError, failed};
+use super::json::Args;
+use super::pages::encode_png;
+
+/// Default and largest side of the image, in pixels.
+pub(crate) const DEFAULT_SIDE: u32 = 512;
+pub(crate) const MAX_SIDE: u32 = 4096;
+
+/// The default view: from the front, right and above, as an isometric view looks.
+const DEFAULT_DIRECTION: [f64; 3] = [-1.0, 1.0, -1.0];
+
+impl Automation {
+ pub(crate) fn model_render(&mut self, a: &Args) -> Result> {
+ let page = self.page(a)?;
+ let width = side(a, "width")?;
+ let height = side(a, "height")?;
+ let direction = direction(a)?;
+ let perspective = a.opt_bool("perspective")?.unwrap_or(true);
+ let doc = self.doc(a)?;
+ let found = doc.model_3d_annotations(page).map_err(failed)?;
+ let index = match a.opt_int("model")? {
+ None => *found.first().ok_or_else(|| failed(format!("page {} has no 3D model", page.saturating_add(1))))?,
+ Some(n) => {
+ let pos = usize::try_from(n).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| Args::wrong("model", "a positive integer"))?;
+ *found
+ .get(pos)
+ .ok_or_else(|| ToolError::InvalidArgs(format!("page {} has {} 3D models, not {n}", page.saturating_add(1), found.len())))?
+ }
+ };
+ let model = doc.model_3d(page, index).map_err(failed)?;
+ if model.kind == Model3dKind::U3d {
+ return Err(failed("this 3D model is in U3D, which can't be drawn yet; only PRC models can"));
+ }
+ let prc = PrcFile::parse(&model.data).map_err(|e| failed(format!("the 3D model could not be read: {e}")))?;
+ let meshes = assemble(&prc).map_err(failed)?;
+ let aspect = f64::from(width) / f64::from(height);
+ let camera = Camera::fit_meshes(&meshes, direction, up_for(direction), perspective, aspect).map_err(failed)?;
+ let image = render(&meshes, &camera, &RenderOptions { width, height, ..RenderOptions::default() }).map_err(failed)?;
+ let data = encode_png(image.width, image.height, &premultiplied(image.rgba))?;
+ Ok(vec![Content::Png { data, width: image.width, height: image.height }])
+ }
+}
+
+fn side(a: &Args, key: &str) -> Result {
+ match a.opt_int(key)? {
+ None => Ok(DEFAULT_SIDE),
+ Some(n) => u32::try_from(n).ok().filter(|n| (1..=MAX_SIDE).contains(n)).ok_or_else(|| Args::wrong(key, &format!("between 1 and {MAX_SIDE}"))),
+ }
+}
+
+/// `direction`: three finite numbers, not all zero, from the eye towards the model.
+fn direction(a: &Args) -> Result<[f64; 3]> {
+ let Some(v) = a.get("direction") else { return Ok(DEFAULT_DIRECTION) };
+ let wrong = || Args::wrong("direction", "three numbers [x, y, z], not all zero");
+ let items = v.as_array().filter(|x| x.len() == 3).ok_or_else(wrong)?;
+ let mut out = [0.0; 3];
+ for (o, x) in out.iter_mut().zip(items) {
+ *o = x.as_f64().filter(|f| f.is_finite()).ok_or_else(wrong)?;
+ }
+ if out.iter().all(|c| *c == 0.0) {
+ return Err(wrong());
+ }
+ Ok(out)
+}
+
+/// Z is up, as CAD models are drawn, unless the view looks along it.
+fn up_for([x, y, z]: [f64; 3]) -> [f64; 3] {
+ if x.hypot(y) <= z.abs() * 1e-6 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] }
+}
+
+/// Straight RGBA to the premultiplied RGBA `encode_png` takes.
+fn premultiplied(mut rgba: Vec) -> Vec {
+ for [r, g, b, alpha] in rgba.as_chunks_mut::<4>().0 {
+ let a = u16::from(*alpha);
+ for c in [r, g, b] {
+ *c = u8::try_from((u16::from(*c) * a + 127) / 255).unwrap_or(u8::MAX);
+ }
+ }
+ rgba
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn premultiplying_scales_colour_by_alpha() {
+ assert_eq!(premultiplied(vec![200, 100, 0, 255, 200, 100, 0, 128, 9, 9, 9, 0]), vec![200, 100, 0, 255, 100, 50, 0, 128, 0, 0, 0, 0]);
+ }
+
+ #[test]
+ fn a_view_along_z_keeps_y_up() {
+ assert_eq!(up_for([0.0, 0.0, -1.0]), [0.0, 1.0, 0.0]);
+ assert_eq!(up_for(DEFAULT_DIRECTION), [0.0, 0.0, 1.0]);
+ }
+}
diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs
index 647a72e1d4..8c793be153 100644
--- a/crates/automation/src/pages.rs
+++ b/crates/automation/src/pages.rs
@@ -65,7 +65,9 @@ impl Automation {
Ok(&mut entry.1)
}
- pub(crate) fn page_render(&mut self, a: &Args) -> Result {
+ /// 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> {
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> {
+ 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>, password: Option>, pages: &[usize]) -> PageTexts {
let extract = |r: &mut PageRenderer, p: usize| {
diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs
index e662bcac19..a146f3f8ff 100644
--- a/crates/automation/src/redact.rs
+++ b/crates/automation/src/redact.rs
@@ -201,8 +201,8 @@ impl Automation {
}
pub(crate) fn doc_remove_hidden(&mut self, a: &Args) -> Result {
- let edit = match a.get("categories") {
- None => Edit::Sanitize,
+ let which = match a.get("categories") {
+ None => None,
Some(_) => {
let which = a
.strs("categories")?
@@ -212,10 +212,24 @@ impl Automation {
if which.is_empty() {
return Err(ToolError::InvalidArgs("categories is empty".into()));
}
+ Some(which)
+ }
+ };
+ // One scan, for this check and for what was removed. The removal refuses when it counts
+ // every listed category as 0 (an AcroForm with no fields counts 1), with an error about
+ // redaction marks; this reads the same scan, so it refuses exactly then. A document that
+ // can't be edited has no scan: `apply` says why.
+ let info = self.doc(a)?.hidden_info();
+ let edit = match which {
+ None => Edit::Sanitize,
+ Some(which) => {
+ if !info.is_empty() && which.iter().all(|h| info.iter().find(|c| c.0 == *h).is_none_or(|c| c.1 == 0)) {
+ return Err(failed("nothing to remove: the listed categories hold nothing (see doc_hidden_info)"));
+ }
Edit::RemoveHidden { which }
}
};
- let before: usize = self.doc(a)?.hidden_info().iter().map(|c| c.1).sum();
+ let before: usize = info.iter().map(|c| c.1).sum();
let mut out = self.apply(a, edit)?;
let after: usize = self.doc(a)?.hidden_info().iter().map(|c| c.1).sum();
out["removed"] = json!(before.saturating_sub(after));
diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs
index c718e4a47a..14b55361c5 100644
--- a/crates/automation/src/tools.rs
+++ b/crates/automation/src/tools.rs
@@ -121,7 +121,15 @@ pub fn tools() -> Vec {
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_bates_multi", "Add Bates numbering across files", "Apply one consecutive Bates sequence to an ordered list of PDFs. Each file entry has input and output paths and an optional open or permissions password. The operation first validates every input/output and computes inclusive numeric intervals; dry_run returns that allocation without writing. Existing output files are never replaced. On application, files that cannot be edited or saved are reported individually and later files continue.")
+ .destructive()
+ .with(schema(json!({
+ "files": {"type":"array","minItems":1,"maxItems":1000,"description":"PDFs in Bates sequence order.","items":{"type":"object","properties":{"input":{"type":"string","minLength":1,"description":"Input PDF path."},"output":{"type":"string","minLength":1,"description":"New output PDF path; must not exist."},"password":{"type":"string","description":"Open or permissions password, if needed."}},"required":["input","output"],"additionalProperties":false}},
+ "start_number": {"type":"integer","minimum":1,"description":"First number in the sequence (default 1)."}, "digits": {"type":"integer","minimum":1,"maximum":12,"description":"Zero-padded number width (default 6)."},
+ "prefix": {"type":"string","maxLength":128,"description":"Text before the number: at most 128 UTF-8 bytes, without # or >>."}, "suffix": {"type":"string","maxLength":128,"description":"Text after the number: at most 128 UTF-8 bytes, without # or >>."},
+ "dry_run": {"type":"boolean","description":"Return each file's inclusive number interval without writing (default false)."}
+ }), &["files"])),
+ 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,15 +175,22 @@ pub fn tools() -> Vec {
)),
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." },
"hide_added_text": { "type": "integer", "minimum": 1, "description": "Render a retyping background without this added text item (added index from object_list). Mutually exclusive with hide_text_block. Does not modify the document." }
}), &["doc", "page"])),
+ t("model_render", "Render a 3D model", "Draw a page's 3D model (a 3D annotation's PRC stream) to a PNG, framed to fill the image, with headlight shading on a white background. model is its 1-based position among the page's 3D models (default 1). direction is the view direction [x, y, z] from the eye towards the model (default [-1, 1, -1]); Z is up unless the view looks along Z. perspective defaults to true. U3D models can't be drawn yet and give an error. Does not modify the document.")
+ .ro()
+ .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "model": { "type": "integer", "minimum": 1 },
+ "width": { "type": "integer", "minimum": 1, "maximum": 4096 }, "height": { "type": "integer", "minimum": 1, "maximum": 4096 },
+ "direction": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 },
+ "perspective": { "type": "boolean" }
+ }), &["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}},
@@ -327,6 +342,50 @@ pub fn tools() -> Vec {
}),
&["doc", "title", "page"],
)),
+ t(
+ "bookmark_export",
+ "Export bookmarks",
+ "The bookmark tree, or the subtree at path, as a list of nodes bookmark_import takes back unchanged. A node has title, page (1-based, null when it goes nowhere), open, and when set view ({type, values}: a destination type from ISO 32000-2 §12.3.2.2 Table 149 and its values in PDF user space, as the file stores them), color ([r, g, b], 0-1), bold, italic and children. truncated is true when bookmarks nested deeper than 33 levels were left out. With out, the tree is also written there as a JSON file {\"bookmarks\": [...]}, with \"truncated\": true when that is so; out is overwritten.",
+ )
+ .destructive()
+ .with(schema(
+ json!({ "doc": doc(), "path": path("The bookmark whose subtree to export (omit for the whole outline)"), "out": path_arg() }),
+ &["doc"],
+ )),
+ t(
+ "bookmark_import",
+ "Import bookmarks",
+ "Add a tree of bookmarks, as bookmark_export gives it (bookmarks) or as a JSON file it wrote (file), under a parent bookmark (or at the top level) at a position, or with replace in place of the whole outline. A bookmark whose page this document lacks is kept without a destination and listed in unmapped (1-based paths into the given tree). A view takes exactly its type's values (XYZ 3, FitR 4, FitH/FitV/FitBH/FitBV 1, Fit/FitB none). Give exactly one of bookmarks and file. A title may be empty but not missing; a key a node does not have is refused. Every node is checked before anything is written: at most 100000 bookmarks, nested at most 33 levels counting the parent's. truncated is true when the file says its export left bookmarks out. Undoable.",
+ )
+ .destructive()
+ .with(schema(
+ json!({
+ "doc": doc(),
+ "bookmarks": { "type": "array", "items": { "type": "object" }, "minItems": 1, "description": "Nodes as bookmark_export returns them." },
+ "file": path_arg(),
+ "parent": path("Parent bookmark (omit for the top level)"),
+ "position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." },
+ "replace": { "type": "boolean", "description": "Replace the document's bookmarks instead of adding to them (default false; leave parent out)." }
+ }),
+ &["doc"],
+ )),
+ t(
+ "bookmark_copy",
+ "Copy bookmarks from another document",
+ "Copy the bookmarks of document from (or the subtree at path) into doc, by page number: under a parent bookmark (or at the top level) at a position, or with replace in place of doc's outline. A bookmark whose page doc lacks is kept without a destination and listed in unmapped (1-based paths into the copied tree). truncated is true when bookmarks nested deeper than 33 levels were left out of the copy. Undoable.",
+ )
+ .destructive()
+ .with(schema(
+ json!({
+ "doc": doc(),
+ "from": { "type": "integer", "minimum": 1, "description": "Id of the document to copy from." },
+ "path": path("The bookmark whose subtree to copy (omit for the whole outline)"),
+ "parent": path("Parent bookmark in doc (omit for the top level)"),
+ "position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." },
+ "replace": { "type": "boolean", "description": "Replace doc's bookmarks instead of adding to them (default false; leave parent out)." }
+ }),
+ &["doc", "from"],
+ )),
t("bookmark_rename", "Rename a bookmark", "Change a bookmark's title. Undoable.")
.with(schema(json!({ "doc": doc(), "path": path("The bookmark"), "title": { "type": "string", "minLength": 1 } }), &["doc", "path", "title"])),
t("bookmark_delete", "Delete a bookmark", "Delete a bookmark and the bookmarks under it. Undoable.")
@@ -359,6 +418,39 @@ pub fn tools() -> Vec {
"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(),
@@ -370,7 +462,7 @@ pub fn tools() -> Vec {
}),
&["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(
@@ -440,6 +532,20 @@ pub fn tools() -> Vec {
}),
&["doc", "page", "path"],
)),
+ t("model_add", "Add a 3D model", "Add a 3D model (Acrobat: Rich Media ▸ Add 3D): the U3D or PRC file at path, recognised by its first bytes, as a 3D annotation in rect [x0, y0, x1, y1] (points from the top-left of the displayed page; each side 1–14400 pt; at most 256 MiB). Its poster, what prints and what readers show until the model is activated, is the picture at poster (a PDF page, poster_page default 1, or an image) fitted inside rect keeping its shape and centred; without poster, a frame labelled \"3D model\". activation: click (default), page_open or page_visible. name is its description (default: the file name). No views are added: readers open the model on its own default view. Undoable.")
+ .with(schema(
+ json!({
+ "doc": doc(),
+ "page": { "type": "integer", "minimum": 1 },
+ "path": { "type": "string" },
+ "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
+ "poster": { "type": "string" },
+ "poster_page": { "type": "integer", "minimum": 1 },
+ "activation": { "type": "string", "enum": ["click", "page_open", "page_visible"] },
+ "name": { "type": "string" },
+ }),
+ &["doc", "page", "path", "rect"],
+ )),
t("form_set_image", "Set an image field's picture", "Show an image file (PNG, JPEG, TIFF, GIF or BMP) in an image field or button, scaled to fit and centred (what clicking an image field does). On an XFA form the picture is kept in its data too (base64, as Designer saves it) and the form shows it from there. Undoable.")
.with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "path": { "type": "string" } }), &["doc", "field", "path"])),
t(
@@ -454,8 +560,9 @@ pub fn tools() -> Vec {
"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" },
@@ -471,17 +578,17 @@ pub fn tools() -> Vec {
"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" } }),
@@ -490,11 +597,11 @@ pub fn tools() -> Vec {
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",
@@ -540,7 +647,7 @@ pub fn tools() -> Vec {
t(
"doc_remove_hidden",
"Remove hidden information / Sanitize",
- "Remove the listed categories (see doc_hidden_info), or every category when none are listed (Sanitize Document). Form fields are flattened so their values stay visible. The next save rewrites the whole file. Undoable until saved.",
+ "Remove the listed categories (see doc_hidden_info), or every category when none are listed (Sanitize Document). Fails when the listed categories hold nothing. Form fields are flattened so their values stay visible. The next save rewrites the whole file. Undoable until saved.",
)
.destructive()
.with(schema(
@@ -615,6 +722,7 @@ pub fn tools() -> Vec {
"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"],
@@ -637,6 +745,7 @@ pub fn tools() -> Vec {
"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." },
@@ -730,7 +839,7 @@ pub fn tools() -> Vec {
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(),
@@ -898,7 +1007,7 @@ pub fn tools() -> Vec {
)),
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",
@@ -1008,12 +1117,16 @@ pub fn tools() -> Vec {
}),
&["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"])),
@@ -1147,7 +1260,7 @@ pub fn tools() -> Vec {
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." } }),
@@ -1166,9 +1279,9 @@ pub fn tools() -> Vec {
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. HTML uses rich styling by default; set html_mode to simple for CSS-free headings, paragraphs, emphasis and tables. Simple HTML keeps text and inline images but omits colour and layout styling. 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"])),
+ .with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "html_mode": { "type": "string", "enum": ["rich", "simple"], "default": "rich" } }), &["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.")
.ro()
.cmd("standards.pdfa")
@@ -1253,7 +1366,7 @@ pub fn tools() -> Vec {
.ro()
.cmd("ocr.recognize")
.with(schema(json!({}), &[])),
- t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.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 (`_Page__Image_.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!({
@@ -1270,7 +1383,7 @@ pub fn tools() -> Vec {
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(
@@ -1281,6 +1394,7 @@ pub fn tools() -> Vec {
"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)." },
@@ -1311,7 +1425,7 @@ pub fn tools() -> Vec {
}),
&["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(
@@ -1349,17 +1463,18 @@ pub fn tools() -> Vec {
.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"],
@@ -1399,6 +1514,7 @@ pub fn tools() -> Vec {
"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)." },
@@ -1408,11 +1524,19 @@ pub fn tools() -> Vec {
"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, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), 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!({
@@ -1423,6 +1547,7 @@ pub fn tools() -> Vec {
"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" },
@@ -1457,7 +1582,7 @@ pub fn tools() -> Vec {
.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}}), &[])),
]
}
diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs
index c6616f11f3..35c4db79c2 100644
--- a/crates/automation/tests/it/automation.rs
+++ b/crates/automation/tests/it/automation.rs
@@ -516,6 +516,78 @@ fn autotag_form_fields_writes_reviewable_widget_tags() {
assert_eq!(result["status"], "passed");
}
+/// A two-page PDF with headings set apart only by their style: chapters in Helvetica-Bold 18,
+/// a section in Helvetica-Bold 14, text in Helvetica 11 (#1693).
+fn styled_headings_pdf() -> Vec {
+ let page1 = "BT /FB 18 Tf 72 700 Td (Introduction) Tj ET BT /FR 11 Tf 72 670 Td (Some text) Tj ET \
+ BT /FB 14 Tf 72 640 Td (Background) Tj ET";
+ let page2 = "BT /FB 18 Tf 72 700 Td (Results) Tj ET BT /FR 11 Tf 72 670 Td (More text) Tj ET";
+ let objs: Vec = vec![
+ "<< /Type /Catalog /Pages 2 0 R >>".into(),
+ "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] /Resources << /Font << /FB 7 0 R /FR 8 0 R >> >> >>".into(),
+ "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(),
+ "<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>".into(),
+ format!("<< /Length {} >>\nstream\n{page1}\nendstream", page1.len()),
+ format!("<< /Length {} >>\nstream\n{page2}\nendstream", page2.len()),
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>".into(),
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
+ ];
+ let mut bytes = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offsets.push(bytes.len());
+ bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
+ }
+ let xref = bytes.len();
+ bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offsets {
+ bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+ bytes
+}
+
+/// #1693: text_styles lists the styles, largest first; bookmark_from_text_styles makes the
+/// bookmarks from the ones given per level, returns the tree, undoes in one step and says why
+/// when it can't.
+#[test]
+fn bookmarks_from_text_styles_through_tools() {
+ let dir = workdir("text-style-bookmarks");
+ std::fs::write(dir.join("styled.pdf"), styled_headings_pdf()).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "styled.pdf" }))["doc"].as_u64().unwrap();
+ let styles = ok(&mut a, "text_styles", json!({ "doc": doc }))["styles"].clone();
+ assert_eq!(
+ styles,
+ json!([
+ { "font": "Helvetica-Bold", "size": 18.0, "bold": true, "italic": false, "lines": 2, "pages": 2, "sample": "Introduction" },
+ { "font": "Helvetica-Bold", "size": 14.0, "bold": true, "italic": false, "lines": 1, "pages": 1, "sample": "Background" },
+ { "font": "Helvetica", "size": 11.0, "bold": false, "italic": false, "lines": 2, "pages": 2, "sample": "Some text" },
+ ])
+ );
+ // The styles as text_styles gave them, the chapters first.
+ let levels = json!([styles[0].clone(), { "font": "Helvetica-Bold", "size": 14, "bold": true }]);
+ let r = ok(&mut a, "bookmark_from_text_styles", json!({ "doc": doc, "levels": levels }));
+ let top = &r["bookmarks"][0];
+ assert_eq!(top["title"], "Untitled");
+ let titles: Vec<(&str, u64)> =
+ top["children"].as_array().unwrap().iter().map(|c| (c["title"].as_str().unwrap(), c["page"].as_u64().unwrap())).collect();
+ assert_eq!(titles, [("Introduction", 1), ("Results", 2)]);
+ assert_eq!(top["children"][0]["children"][0]["title"], "Background");
+ assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "New bookmarks from text styles");
+ // What can't be done says why.
+ let call = |a: &mut pdfcraft_automation::Automation, levels: Value| a.call("bookmark_from_text_styles", &json!({ "doc": doc, "levels": levels }));
+ assert!(matches!(call(&mut a, json!([])), Err(ToolError::InvalidArgs(_))));
+ assert!(matches!(call(&mut a, json!([{ "font": "Helvetica-Bold" }])), Err(ToolError::InvalidArgs(m)) if m.contains("size")));
+ assert!(
+ matches!(call(&mut a, json!([{ "font": "Helvetica-Bold", "size": 18, "bold": "yes" }])), Err(ToolError::InvalidArgs(m)) if m.contains("bold"))
+ );
+ let unused = call(&mut a, json!([{ "font": "Times-Roman", "size": 30 }]));
+ assert!(matches!(&unused, Err(ToolError::Failed(m)) if m.contains("no text is set in the chosen styles")), "{unused:?}");
+ let too_many: Vec = (0..10).map(|_| styles[0].clone()).collect();
+ assert!(matches!(call(&mut a, json!(too_many)), Err(ToolError::InvalidArgs(_))));
+}
+
/// A one-page PDF whose form holds one text field named `name`.
fn form_pdf(name: &str) -> Vec {
let objs: Vec = vec![
@@ -592,6 +664,44 @@ fn tool_table_is_well_formed() {
}
}
+fn assert_homogeneous_enums(schema: &Value, path: &str) {
+ fn json_kind(value: &Value) -> &'static str {
+ match value {
+ Value::Null => "null",
+ Value::Bool(_) => "boolean",
+ Value::Number(_) => "number",
+ Value::String(_) => "string",
+ Value::Array(_) => "array",
+ Value::Object(_) => "object",
+ }
+ }
+
+ if let Some(values) = schema.get("enum").and_then(Value::as_array) {
+ let first = values.first().map(json_kind);
+ assert!(values.iter().map(json_kind).all(|kind| Some(kind) == first), "mixed enum types at {path}: {values:?}");
+ }
+ match schema {
+ Value::Object(object) => {
+ for (key, child) in object {
+ assert_homogeneous_enums(child, &format!("{path}.{key}"));
+ }
+ }
+ Value::Array(array) => {
+ for (index, child) in array.iter().enumerate() {
+ assert_homogeneous_enums(child, &format!("{path}[{index}]"));
+ }
+ }
+ _ => {}
+ }
+}
+
+#[test]
+fn tool_schema_enums_have_one_json_type() {
+ for tool in tools() {
+ assert_homogeneous_enums(&tool.input_schema, tool.name);
+ }
+}
+
#[test]
fn explicit_font_and_disjoint_ranges_save_reopen_through_tools() {
let dir = workdir("font-ranges");
@@ -2039,6 +2149,27 @@ fn comments_through_tools() {
save_and_reopen_comments(&dir, &mut a, doc);
}
+#[test]
+fn freehand_highlight_through_tools() {
+ let dir = workdir("freehand-highlight");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ ok(
+ &mut a,
+ "comment_add",
+ json!({ "doc": doc, "page": 1, "type": "freehand_highlight", "width": 100,
+ "strokes": [[[20, 100], [120, 100]]] }),
+ );
+ let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
+ assert_eq!(list["count"], 1);
+ assert_eq!(list["comments"][0]["type"], "Ink");
+ assert_eq!(list["comments"][0]["intent"], "FreehandHighlight");
+ assert_eq!(list["comments"][0]["color"], "#FFF000");
+ let undone = ok(&mut a, "edit_undo", json!({ "doc": doc }));
+ assert_eq!(undone["undone"], "Add freehand highlight");
+ assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 0);
+}
+
fn add_comment_examples(a: &mut Automation, doc: u64) -> String {
// "Page 2" sits at y = 150 (user space) = 150 from the top of the 300 pt page.
let hl = ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" }));
@@ -2154,6 +2285,35 @@ fn nested_replies_stay_in_the_listing() {
assert!(replies.contains(&"DIRECT_REPLY") && replies.contains(&"NESTED_REPLY"), "the nested reply is listed: {replies:?}");
}
+/// comment_delete on a reply deletes that reply and the replies to it, as the Comments panel's
+/// reply menu does: the comment and its other replies stay, and one undo brings it back.
+#[test]
+fn deleting_a_reply_keeps_its_comment_and_the_other_replies() {
+ let dir = workdir("delete-reply");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ let root = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [40, 40], "contents": "ROOT_NOTE", "author": "A" }));
+ let root_id = root["comment"]["id"].as_str().unwrap().to_string();
+ ok(&mut a, "comment_reply", json!({ "doc": doc, "id": root_id, "text": "WRONG_REPLY", "author": "B" }));
+ ok(&mut a, "comment_reply", json!({ "doc": doc, "id": root_id, "text": "RIGHT_REPLY", "author": "C" }));
+ let replies = |a: &mut Automation| -> Vec {
+ let list = ok(a, "comment_list", json!({ "doc": doc }));
+ assert_eq!(list["count"], 1, "{list}");
+ list["comments"][0]["replies"].as_array().unwrap().clone()
+ };
+ let wrong = replies(&mut a).into_iter().find(|r| r["contents"] == "WRONG_REPLY").unwrap();
+ ok(&mut a, "comment_reply", json!({ "doc": doc, "id": wrong["id"], "text": "ANSWER_TO_WRONG", "author": "D" }));
+ let texts = |a: &mut Automation| -> Vec { replies(a).iter().filter_map(|r| r["contents"].as_str().map(str::to_owned)).collect() };
+ assert_eq!(texts(&mut a), ["WRONG_REPLY", "RIGHT_REPLY", "ANSWER_TO_WRONG"]);
+
+ ok(&mut a, "comment_delete", json!({ "doc": doc, "page": wrong["page"], "index": wrong["index"] }));
+ let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
+ assert_eq!(list["comments"][0]["contents"], "ROOT_NOTE", "{list}");
+ assert_eq!(texts(&mut a), ["RIGHT_REPLY"], "the reply took the answer to it along");
+ assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Delete comment");
+ assert_eq!(texts(&mut a), ["WRONG_REPLY", "RIGHT_REPLY", "ANSWER_TO_WRONG"]);
+}
+
/// Replies thread by their `/IRT` reference, not `/NM`: PyMuPDF names the first comment on every
/// page `fitz-A0`. A shared id is refused rather than taken to mean page 1's comment.
#[test]
@@ -2568,6 +2728,29 @@ fn orphan_form_fields_through_tools() {
assert_ne!(v, "Filled by an agent");
}
+/// An auto-sized (`0 Tf`) single-line field fills to the size its height gives, as Acrobat
+/// draws it, not a 12 pt cap: 30 pt tall, less border and padding, is about 21 pt text.
+#[test]
+fn auto_sized_text_follows_a_tall_field() {
+ let objs: Vec<&str> = vec![
+ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", // 1
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 300] >>", // 2
+ "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", // 3
+ "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >>", // 4
+ "<< /Type /Annot /Subtype /Widget /FT /Tx /T (tall) /Rect [10 200 290 230] /P 3 0 R >>", // 5
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", // 6
+ ];
+ let dir = workdir("auto-size-tall");
+ std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
+ ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "tall": "Hi" } }));
+ let found = ok(&mut a, "text_find", json!({ "doc": doc, "query": "Hi" }));
+ let r = &found["matches"][0]["rects"][0];
+ let height = r[3].as_f64().unwrap() - r[1].as_f64().unwrap();
+ assert!(height > 16.0, "12 pt text would be about 12 tall, ~21 pt about 21: {height} ({found})");
+}
+
#[test]
fn reversing_pages_through_tools() {
let dir = workdir("reverse");
@@ -2649,6 +2832,74 @@ fn headers_watermarks_and_backgrounds_through_tools() {
assert!(matches!(refused, Err(ToolError::InvalidArgs(e)) if e.contains("Courier")));
}
+#[test]
+fn bates_numbers_continue_across_files_and_report_preflight_and_permission_failures() {
+ let dir = workdir("bates-multi");
+ let files = json!([
+ {"input":"b.pdf","output":"b-numbered.pdf"},
+ {"input":"a.pdf","output":"a-numbered.pdf"}
+ ]);
+ let mut a = auto(&dir);
+ let allocation = ok(&mut a, "doc_bates_multi", json!({"files":files,"start_number":50,"dry_run":true}));
+ assert_eq!(allocation["files"][0]["first"], 50);
+ assert_eq!(allocation["files"][0]["last"], 51);
+ assert_eq!(allocation["files"][1]["first"], 52);
+ assert_eq!(allocation["files"][1]["last"], 54);
+ assert!(!dir.join("a-numbered.pdf").exists());
+
+ let applied = ok(&mut a, "doc_bates_multi", json!({"files":files,"start_number":17,"prefix":"CASE-"}));
+ assert_eq!(applied["completed"], 2);
+ let mut out = auto(&dir);
+ let first = ok(&mut out, "doc_open", json!({"path":"a-numbered.pdf"}))["doc"].as_u64().unwrap();
+ let second = ok(&mut out, "doc_open", json!({"path":"b-numbered.pdf"}))["doc"].as_u64().unwrap();
+ assert!(page_text(&mut out, second)[0].contains("CASE-000017"));
+ assert!(page_text(&mut out, first)[0].contains("CASE-000019"));
+
+ let overflow = err(&mut a, "doc_bates_multi", json!({"files":[{"input":"b.pdf","output":"overflow.pdf"}],"start_number":u64::MAX}));
+ assert!(overflow.to_string().contains("overflows"));
+ assert!(!dir.join("overflow.pdf").exists());
+ // A range may end at u64::MAX itself.
+ let at_max = ok(&mut a, "doc_bates_multi", json!({"files":[{"input":"b.pdf","output":"max.pdf"}],"start_number":u64::MAX - 1,"dry_run":true}));
+ assert_eq!(at_max["files"][0]["last"], u64::MAX);
+ for bad in
+ [json!({"start_number":-3}), json!({"start_number":2.5}), json!({"start_number":"7"}), json!({"prefix":"CASE>>"}), json!({"suffix":"A>>B"})]
+ {
+ let mut args = json!({"files":[{"input":"b.pdf","output":"bad.pdf"}],"dry_run":true});
+ args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone());
+ assert!(a.call("doc_bates_multi", &args).is_err(), "{bad}");
+ }
+
+ use pdfcraft_engine::{Changes, Edit, Protection, Session};
+ let mut session = Session::new();
+ let id = session.open("locked.pdf", None, std::sync::Arc::new(fixture(1)), None).unwrap();
+ session
+ .apply(
+ id,
+ Edit::Protect(Protection {
+ open_password: Some("open".into()),
+ permissions_password: Some("owner".into()),
+ changes: Changes::None,
+ ..Default::default()
+ }),
+ )
+ .unwrap();
+ std::fs::write(dir.join("locked.pdf"), session.save_bytes(id).unwrap().as_ref()).unwrap();
+ let result = ok(
+ &mut a,
+ "doc_bates_multi",
+ json!({"files":[
+ {"input":"b.pdf","output":"partial-numbered.pdf"},
+ {"input":"locked.pdf","output":"locked-numbered.pdf","password":"open"}
+ ]}),
+ );
+ assert_eq!(result["completed"], 1);
+ assert_eq!(result["failed"], 1);
+ assert_eq!(result["files"][1]["first"], 3);
+ assert!(result["files"][1]["error"].as_str().unwrap().contains("owner password"), "{result}");
+ assert!(dir.join("partial-numbered.pdf").exists());
+ assert!(!dir.join("locked-numbered.pdf").exists());
+}
+
#[test]
fn a_shrinking_header_and_footer_moves_the_page_content_out_of_their_way_through_tools() {
let dir = workdir("hf-shrink");
@@ -3162,6 +3413,39 @@ fn editing_existing_text_through_tools() {
assert_eq!(p["lines"].as_array().map(Vec::len), Some(2), "{p}");
}
+/// Format text ▸ Superscript and Subscript without the GUI: a range, then back again over a space
+/// (which has no superscript form), each one undo step; refusals change nothing.
+#[test]
+fn superscript_and_subscript_through_tools() {
+ let dir = workdir("text-script");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ let paragraph =
+ |a: &mut Automation| ok(a, "text_paragraphs", json!({ "doc": doc, "page": 2 }))["paragraphs"][0]["text"].as_str().unwrap().to_string();
+ let script = |script: &str, range: Value| json!({ "doc": doc, "page": 2, "paragraph": 1, "script": script, "range": range });
+ // "Page 2": the 2 is byte 5.
+ let r = ok(&mut a, "text_script", script("superscript", json!([5, 6])));
+ assert_eq!((r["script"].as_str(), r["paragraph"]["text"].as_str()), (Some("superscript"), Some("Page ²")), "{r}");
+ assert_eq!(page_text(&mut a, doc)[1], "Page ²");
+ // " ²": the space has no superscript form, and the range still toggles back.
+ ok(&mut a, "text_script", script("superscript", json!([4, 7])));
+ assert_eq!(paragraph(&mut a), "Page 2");
+ assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Edit text");
+ assert_eq!(paragraph(&mut a), "Page ²");
+ // Refused, changing nothing: a range that splits ², one past the end, an empty one, a range
+ // with nothing that has a subscript form, an unknown script.
+ for (name, range) in [
+ ("superscript", json!([5, 6])),
+ ("superscript", json!([0, 99])),
+ ("superscript", json!([3, 3])),
+ ("subscript", json!([4, 5])),
+ ("italic", json!([0, 1])),
+ ] {
+ assert!(matches!(a.call("text_script", &script(name, range.clone())), Err(ToolError::InvalidArgs(_))), "{name} {range}");
+ }
+ assert_eq!(paragraph(&mut a), "Page ²");
+}
+
#[test]
fn styled_cyrillic_fallback_is_batched_through_tools_and_reopen() {
if pdfcraft_fonts::document_japanese_font().is_none() {
@@ -3329,6 +3613,13 @@ fn editing_page_images_through_tools() {
assert_eq!(cropped["visible_rect"], json!([10.0, 10.0, 25.0, 30.0]));
ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "rotate" }));
ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "flip_horizontal" }));
+ // A single image is already in front and at the back; no-op arrange is refused without dirtying undo.
+ assert!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "bring_to_front" })).is_err());
+ assert!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "send_to_back" })).is_err());
+ assert!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "bring_forward" })).is_err());
+ assert!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "send_backward" })).is_err());
+ let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "remove_crop" }));
+ assert_eq!(r["undo"], "Remove crop");
// Any angle: the box grows to the turned picture's bounds, and it undoes like a quarter turn.
let before = ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"].clone();
let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "rotate_by", "degrees": 30.5 }));
@@ -3348,6 +3639,80 @@ fn editing_page_images_through_tools() {
assert!(matches!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "delete" })), Err(ToolError::InvalidArgs(_))));
}
+#[test]
+fn arranging_page_images_through_tools() {
+ let dir = workdir("arrange-page-images");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "src", "dpi": 18, "pages": [1, 2] }));
+ let made = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["src/a_page_1.png"] }))["doc"].as_u64().unwrap();
+ ok(&mut a, "page_add_image", json!({ "doc": made, "page": 1, "path": "src/a_page_2.png", "rect": [50, 50, 100, 100] }));
+ assert_eq!(ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["count"], 2);
+ let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "bring_to_front" }));
+ assert_eq!(r["undo"], "Bring to front");
+ let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 2, "action": "send_to_back" }));
+ assert_eq!(r["undo"], "Send to back");
+ ok(&mut a, "doc_save", json!({ "doc": made, "path": "arranged.pdf" }));
+ let reopened = ok(&mut a, "doc_open", json!({ "path": "arranged.pdf" }))["doc"].as_u64().unwrap();
+ let after_reopen = ok(&mut a, "page_images", json!({ "doc": reopened, "page": 1 }));
+ assert_eq!(after_reopen["count"], 2);
+ assert_eq!(after_reopen["images"][0]["rect"], json!([0.0, 0.0, 50.0, 75.0]));
+ assert_eq!(after_reopen["images"][1]["rect"], json!([50.0, 50.0, 100.0, 100.0]));
+}
+
+/// The transform a rotated-image resize sends (`ImageEdit::Transform`) is reachable headlessly,
+/// in the same top-left-origin points as the other image tools, also on a turned page.
+#[test]
+fn transforming_page_images_through_tools() {
+ let dir = workdir("transform-page-images");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "src", "dpi": 18, "pages": [1] }));
+ let made = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["src/a_page_1.png"] }))["doc"].as_u64().unwrap();
+ let rect = |a: &mut Automation| ok(a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"].clone();
+ let transform =
+ |a: &mut Automation, m: Value| ok(a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "transform", "matrix": m }));
+ ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "move", "rect": [5, 5, 25, 35] }));
+
+ // Right 10, down 20.
+ assert_eq!(transform(&mut a, json!([1, 0, 0, 1, 10, 20]))["undo"], "Transform image");
+ assert_eq!(rect(&mut a), json!([15.0, 25.0, 35.0, 55.0]));
+ // Twice the size about the top-left corner of the page.
+ transform(&mut a, json!([2, 0, 0, 2, 0, 0]));
+ assert_eq!(rect(&mut a), json!([30.0, 50.0, 70.0, 110.0]));
+ // A quarter turn, (x, y) → (200 − y, x): the box turns with it, and it undoes in one step.
+ transform(&mut a, json!([0, 1, -1, 0, 200, 0]));
+ assert_eq!(rect(&mut a), json!([90.0, 30.0, 150.0, 70.0]));
+ ok(&mut a, "edit_undo", json!({ "doc": made }));
+ assert_eq!(rect(&mut a), json!([30.0, 50.0, 70.0, 110.0]));
+
+ // On a turned page "right" is still right on screen.
+ ok(&mut a, "page_rotate", json!({ "doc": made, "pages": [1], "degrees": 90 }));
+ let numbers = |v: Value| [0, 1, 2, 3].map(|i| v[i].as_f64().unwrap());
+ let [x0, y0, x1, y1] = numbers(rect(&mut a));
+ transform(&mut a, json!([1, 0, 0, 1, 10, 0]));
+ let after = numbers(rect(&mut a));
+ for (got, want) in after.into_iter().zip([x0 + 10.0, y0, x1 + 10.0, y1]) {
+ assert!((got - want).abs() < 0.015, "{after:?} is not ({x0}, {y0}, {x1}, {y1}) moved right by 10");
+ }
+
+ // Flat, non-finite, short and missing matrices are refused before anything changes.
+ let before = rect(&mut a);
+ for bad in [
+ json!([1, 2, 2, 4, 0, 0]),
+ json!([0, 0, 0, 0, 0, 0]),
+ json!([1e300, 0, 0, 1e300, 0, 0]),
+ json!([1, 0, 0, 1]),
+ json!([1, 0, "invalid", 0, 1, 10, 0]),
+ json!([1, 0, null, 0, 1, 10, 0]),
+ ] {
+ let r = a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "transform", "matrix": bad }));
+ assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "{bad}: {r:?}");
+ }
+ assert!(matches!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "transform" })), Err(ToolError::InvalidArgs(_))));
+ assert_eq!(rect(&mut a), before);
+}
+
#[test]
fn enhance_camera_image_through_tools() {
let dir = workdir("enhance-camera");
@@ -3628,6 +3993,38 @@ fn fill_sign_text_font_size_is_saved_and_validated() {
));
}
+#[test]
+fn fill_sign_text_and_date_colours_are_saved_and_validated() {
+ let dir = workdir("fill-sign-text-colour");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ for (kind, y) in [("text", 40), ("date", 80)] {
+ ok(
+ &mut a,
+ "fill_sign_add",
+ json!({ "doc": doc, "page": 1, "type": kind, "at": [20, y], "text": "Blue", "color": "#3366CC", "font_size": 6.5 }),
+ );
+ }
+ for (kind, color) in [("text", "not-a-colour"), ("signature", "#3366CC"), ("check", "#3366CC")] {
+ assert!(matches!(
+ a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": kind, "at": [20, 100], "text": "No", "color": color })),
+ Err(ToolError::InvalidArgs(_))
+ ));
+ }
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "coloured.pdf" }));
+ let pdf = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("coloured.pdf")).unwrap())).unwrap();
+ let page = pdfcraft_model::pages(&pdf).remove(0);
+ let annotations = pdf.resolve(page.dict.get(b"Annots").unwrap());
+ let annotations = annotations.as_array().unwrap();
+ assert_eq!(annotations.len(), 2, "invalid requests add nothing");
+ for reference in annotations {
+ let annotation = pdf.resolve(reference);
+ // FreeText stores its text colour in DA; C is the annotation background.
+ let da = annotation.as_dict().unwrap().get(b"DA").unwrap().as_string().unwrap();
+ assert_eq!(String::from_utf8(da.bytes.to_vec()).unwrap(), "0.2 0.4 0.8 rg /Helv 6.5 Tf");
+ }
+}
+
#[test]
fn image_signatures_through_tools_preserve_transparency_and_survive_save() {
let dir = workdir("image-signatures");
@@ -4086,6 +4483,29 @@ fn preparing_a_form_through_tools() {
assert!(matches!(a.call("form_set_image", &json!({ "doc": doc, "field": "size", "path": "pics/a_page_1.png" })), Err(ToolError::Failed(_))));
}
+#[test]
+fn renaming_a_radio_button_to_a_groups_name_joins_the_group_through_tools() {
+ // #4949: two buttons placed separately are two groups; naming one after the other makes
+ // them one group, and a clashing export value is renamed instead of shared.
+ let dir = workdir("radio-join");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "radio", "rect": [20, 100, 34, 114], "group": "size", "export": "S" }));
+ ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "radio", "rect": [40, 100, 54, 114], "group": "other", "export": "S" }));
+ ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "S" } }));
+ let r = ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "other", "name": "size" }));
+ assert_eq!(r["field"], "size");
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "joined.pdf" }));
+ let doc = ok(&mut a, "doc_open", json!({ "path": "joined.pdf" }))["doc"].as_u64().unwrap();
+ let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
+ let f = fields["fields"].as_array().unwrap();
+ assert_eq!(f.iter().map(|f| f["name"].as_str().unwrap()).collect::>(), ["size"], "one group");
+ assert_eq!(f[0]["options"], json!(["S", "Choice1"]), "the joining button takes a free export value");
+ assert_eq!(f[0]["value"], "S", "the group keeps its selection");
+ ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "Choice1" } }));
+ assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "Choice1");
+}
+
#[test]
fn rotating_a_field_through_tools() {
let dir = workdir("rotate-field");
@@ -4374,12 +4794,23 @@ fn removing_hidden_information_through_tools() {
assert_eq!(r["undo"], "Remove hidden information");
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 0);
assert!(count(&ok(&mut a, "doc_hidden_info", json!({ "doc": doc })), "metadata") >= 1, "only comments went");
+ // With none left, removing them again says so, and Optimize discarding them still runs.
+ let again = a.call("doc_remove_hidden", &json!({ "doc": doc, "categories": ["comments"] }));
+ assert!(matches!(&again, Err(ToolError::Failed(m)) if m.starts_with("nothing to remove")), "{again:?}");
+ let optimized = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "optimized.pdf", "discard": ["comments", "bookmarks"] }));
+ assert_eq!(optimized["discarded"], json!([]), "{optimized}");
let r = ok(&mut a, "doc_remove_hidden", json!({ "doc": doc }));
assert_eq!(r["undo"], "Sanitize document");
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "clean.pdf" }));
let bytes = std::fs::read(dir.join("clean.pdf")).unwrap();
assert!(!bytes.windows(11).any(|w| w == b"Secret plan"), "the old revision is gone");
assert!(matches!(a.call("doc_remove_hidden", &json!({ "doc": doc, "categories": ["nonsense"] })), Err(ToolError::InvalidArgs(_))));
+ // A document that can't be edited has no scan: the refusal says why, not that nothing is there.
+ let junk = [vec![b'x'; 5000], std::fs::read(dir.join("a.pdf")).unwrap()].concat();
+ std::fs::write(dir.join("junk.pdf"), junk).unwrap();
+ let read_only = ok(&mut a, "doc_open", json!({ "path": "junk.pdf" }))["doc"].as_u64().unwrap();
+ let refused = a.call("doc_remove_hidden", &json!({ "doc": read_only, "categories": ["comments"] }));
+ assert!(matches!(&refused, Err(ToolError::Failed(m)) if m.contains("can't be edited")), "{refused:?}");
}
/// A one-page PDF whose text "TOP SECRET" sits under an opaque black rectangle.
@@ -4996,6 +5427,168 @@ fn the_calculation_order_is_read_fixed_and_set() {
assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("0"), json!("0")), "undone with the values it recalculated");
}
+/// A malformed form with two calculated fields named `dup`, one summing `src`, the other `x`.
+fn two_calculations_with_one_name() -> Vec {
+ let widget = |name: &str, y: u32, reads: &str| {
+ format!(
+ "<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", \"{reads}\"\\);) >> >> >>",
+ y + 20
+ )
+ };
+ pdf_of(&[
+ "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>",
+ "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>",
+ "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /CO [6 0 R 7 0 R 5 0 R] >>",
+ &widget("dup", 700, "x"),
+ &widget("dup", 660, "src"),
+ &widget("src", 620, "x"),
+ "<< /Type /Annot /Subtype /Widget /FT /Tx /T (x) /Rect [50 580 250 600] /P 3 0 R /V (3) >>",
+ ])
+}
+
+/// Each row of the calculation order gets its own field's formula, even where two fields share a
+/// name (a malformed form): the first dup sums src, the second x.
+#[test]
+fn the_calculation_order_gives_each_field_its_own_formula() {
+ let dir = workdir("calcdup");
+ std::fs::write(dir.join("dup.pdf"), two_calculations_with_one_name()).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "dup.pdf" }))["doc"].as_u64().unwrap();
+ let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc }));
+ assert_eq!(r["calculation_order"], json!(["dup", "src", "dup"]));
+ let formulas: Vec = r["fields"].as_array().unwrap().iter().map(|f| f["formula"].clone()).collect();
+ assert_eq!(
+ formulas,
+ [json!({ "op": "sum", "fields": ["src"] }), json!({ "op": "sum", "fields": ["x"] }), json!({ "op": "sum", "fields": ["x"] })]
+ );
+}
+
+/// The calculation order through the tool shows each formula, in form_set_props's shape or as a
+/// script, and flags a read of a field the form doesn't have and a script that builds the names
+/// it reads; form_fields shows a custom script too.
+#[test]
+fn the_calculation_order_shows_formulas_and_what_they_cannot_read() {
+ let dir = workdir("calcformulas");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ for (i, name) in ["qty", "price", "sub", "tax", "total"].into_iter().enumerate() {
+ let y = 20 + 30 * i;
+ ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name }));
+ }
+ let script = "event.value = getField(\"sub\").value / 10 + getField(\"row\" + 1).value;";
+ ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "sub", "calculate": { "notation": "qty * pric" } }));
+ ok(&mut a, "form_set_script", json!({ "doc": doc, "field": "tax", "event": "calculate", "script": script }));
+ ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": { "op": "sum", "fields": ["sub", "tax"] } }));
+ let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc }));
+ assert_eq!(r["calculation_order"], json!(["sub", "tax", "total"]));
+ let formulas: Vec = r["fields"].as_array().unwrap().iter().map(|f| f["formula"].clone()).collect();
+ assert_eq!(formulas, [json!({ "notation": "qty * pric" }), json!({ "script": script }), json!({ "op": "sum", "fields": ["sub", "tax"] })]);
+ assert_eq!(r["fields"][0]["issues"], json!([{ "issue": "missing_field", "field": "pric" }]));
+ assert_eq!(r["fields"][1]["reads"], json!(["sub"]));
+ assert_eq!(r["fields"][1]["issues"], json!([{ "issue": "built_names" }]));
+ assert_eq!(r["fields"][2]["issues"], json!([]));
+ assert_eq!(r["fixable"], false, "neither is an order to fix");
+ let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
+ let tax = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "tax").unwrap();
+ assert_eq!(tax["calculate"], json!({ "script": script }));
+ // A script is set with form_set_script, and form_set_props says so, also when a notation
+ // comes with it.
+ for calculate in [json!({ "script": script }), json!({ "notation": "qty * 2", "script": script })] {
+ let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "tax", "calculate": calculate }));
+ assert!(e.to_string().contains("form_set_script"), "{calculate}: {e}");
+ }
+}
+
+/// Every operation comes back from form_fields and form_calculation_order as form_set_props takes
+/// it, so a formula can be read and given back: product and average came back as prd and avg,
+/// which it refused.
+#[test]
+fn every_calculation_op_can_be_read_and_given_back() {
+ let dir = workdir("calcops");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ for (name, y) in [("a", 20), ("b", 60), ("total", 100)] {
+ ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name }));
+ }
+ for op in ["sum", "product", "average", "min", "max"] {
+ let calculate = json!({ "op": op, "fields": ["a", "b"] });
+ ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": calculate }));
+ let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
+ let shown = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "total").unwrap()["calculate"].clone();
+ let order = ok(&mut a, "form_calculation_order", json!({ "doc": doc }));
+ assert_eq!((&shown, &order["fields"][0]["formula"]), (&calculate, &calculate), "{op}");
+ ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": shown }));
+ }
+}
+
+/// form_set_props refuses a calculate or validate it would only partly apply (a key it doesn't
+/// know, a value of the wrong type, a notation beside op and fields) as a bad argument, before
+/// the edit runs, and changes nothing. A validate read back from form_fields, with null for the
+/// bound it doesn't have, is taken back.
+#[test]
+fn calculate_and_validate_refuse_what_they_would_drop() {
+ let dir = workdir("calcstrict");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ for (name, y) in [("a", 20), ("b", 60), ("total", 100)] {
+ ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name }));
+ }
+ let set = json!({ "op": "sum", "fields": ["a", "b"] });
+ ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": set, "validate": { "min": 0, "max": 10 } }));
+ let total = |a: &mut Automation| {
+ let fields = ok(a, "form_fields", json!({ "doc": doc }));
+ fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "total").unwrap().clone()
+ };
+ for (calculate, says) in [
+ (json!({ "notation": "a", "op": "sum", "fields": ["b"] }), "not both"),
+ (json!({ "notation": "a", "op": "sum" }), "not both"),
+ (json!({ "notation": "a", "fields": ["b"] }), "not both"),
+ // A notation that wouldn't read back as itself from the script's comment.
+ (json!({ "notation": "a EVCALC b" }), "can't be stored"),
+ (json!({ "notation": "a */ x /*" }), "can't be stored"),
+ (json!({ "notation": " " }), "can't be stored"),
+ (json!({ "op": "sum", "fields": ["a", 5] }), "field names"),
+ (json!({ "op": "sum", "fields": "a" }), "list of field names"),
+ (json!({ "notation": 5 }), "notation must be a string"),
+ (json!({ "op": 5, "fields": ["a"] }), "op must be"),
+ (json!({ "fields": ["a"] }), "needs op"),
+ (json!({ "op": "sum", "feilds": ["a"] }), "unknown key \"feilds\""),
+ ] {
+ // InvalidArgs, not Failed: an unstorable notation is refused by the tool itself, not
+ // only by the edit underneath it.
+ let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": calculate }));
+ assert!(matches!(e, ToolError::InvalidArgs(_)) && e.to_string().contains(says), "{calculate}: {e:?}");
+ }
+ for (validate, says) in [(json!({ "min": 0, "max": "100" }), "max must be a number"), (json!({ "min": 0, "mxa": 5 }), "unknown key \"mxa\"")] {
+ let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "validate": validate }));
+ assert!(matches!(e, ToolError::InvalidArgs(_)) && e.to_string().contains(says), "{validate}: {e:?}");
+ }
+ let t = total(&mut a);
+ assert_eq!((&t["calculate"], &t["validate"]), (&set, &json!({ "min": 0.0, "max": 10.0 })), "nothing changed");
+ ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "validate": { "min": null, "max": 5 } }));
+ let shown = total(&mut a)["validate"].clone();
+ assert_eq!(shown, json!({ "min": null, "max": 5.0 }));
+ ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "validate": shown }));
+}
+
+#[test]
+fn page_tab_orders_are_read_and_set_per_page_through_tools() {
+ let dir = workdir("pagetabs");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ let r = ok(&mut a, "form_tab_order", json!({ "doc": doc }));
+ assert_eq!(r["page_tabs"], json!(["annotations", "annotations", "annotations"]));
+ assert!(r["undo"].is_null(), "reading changes nothing: {r}");
+ let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "column", "pages": [1, 3] }));
+ assert_eq!(r["page_tabs"], json!(["column", "annotations", "column"]));
+ assert_eq!((r["undo"].as_str(), r["dirty"].as_bool()), (Some("Set tab order"), Some(true)));
+ let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "structure", "pages": [2] }));
+ assert_eq!(r["page_tabs"], json!(["column", "structure", "column"]));
+ let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "unspecified", "pages": [1] }));
+ assert_eq!(r["page_tabs"], json!(["annotations", "structure", "column"]));
+}
+
#[test]
fn tab_order_and_field_appearance_through_tools() {
let dir = workdir("taborder");
@@ -6193,10 +6786,12 @@ fn optimizing_through_tools() {
let reduced = ok(&mut a, "doc_reduce", json!({ "doc": doc, "path": "reduced.pdf" }));
assert!(reduced["bytes_after"].as_u64().unwrap() < reduced["bytes_before"].as_u64().unwrap());
assert_eq!(reduced["written"], true);
+ assert_eq!(reduced["images_refused"], 0);
assert_eq!(std::fs::metadata(dir.join("reduced.pdf")).unwrap().len(), reduced["bytes_after"].as_u64().unwrap());
// Target PDF 1.4 (disables object streams) and PDF 2.0 (with object_streams toggled off).
let v14 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "v14.pdf", "compatibility": "1.4" }));
+ assert_eq!(v14["images_refused"], 0);
assert_eq!((v14["pdf_version"].as_str(), v14["object_streams"].as_bool()), (Some("1.4"), Some(false)), "{v14}");
let v14_bytes = std::fs::read(dir.join("v14.pdf")).unwrap();
assert!(v14_bytes.starts_with(b"%PDF-1.4\n") && !v14_bytes.windows(7).any(|w| w == b"/ObjStm"));
@@ -6214,6 +6809,15 @@ fn optimizing_through_tools() {
let cat_doc = ok(&mut a, "doc_open", json!({ "path": "cat16.pdf" }))["doc"].as_u64().unwrap();
let retained = ok(&mut a, "doc_optimize", json!({ "doc": cat_doc, "path": "retained.pdf", "compatibility": "retain", "object_streams": false }));
assert_eq!(retained["pdf_version"].as_str(), Some("1.6"), "{retained}");
+ // The same with the catalog's /Version given by reference: read through it.
+ let v16 = cos14.add(pdfcraft_cos::Object::name("1.6"));
+ cos14.update_dict(root, |c| c.set(b"Version".to_vec(), pdfcraft_cos::Object::Ref(v16))).unwrap();
+ let ref16 = pdfcraft_cos::write_full(&cos14, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap();
+ std::fs::write(dir.join("ref16.pdf"), &ref16).unwrap();
+ let ref_doc = ok(&mut a, "doc_open", json!({ "path": "ref16.pdf" }))["doc"].as_u64().unwrap();
+ let by_ref =
+ ok(&mut a, "doc_optimize", json!({ "doc": ref_doc, "path": "retained-ref.pdf", "compatibility": "retain", "object_streams": false }));
+ assert_eq!(by_ref["pdf_version"].as_str(), Some("1.6"), "{by_ref}");
assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "compatibility": "1.3" })), Err(ToolError::InvalidArgs(_))));
assert!(matches!(
@@ -6293,6 +6897,240 @@ fn lzw_streams_are_stored_with_flate_through_tools() {
assert_eq!(filters("kept.pdf"), (Some(Object::name("LZWDecode")), ops));
}
+/// A one-page PDF drawing a 1-bit scan of text (glyph strokes with ragged edges, as a scanner
+/// leaves them) of 1000 × 400 samples, stored with Flate.
+fn scanned_page() -> Vec {
+ scan_drawn(b"q 500 0 0 200 50 500 cm /Scan Do Q")
+}
+
+/// [`scanned_page`]'s scan, drawn by `content` (which names it `/Scan`).
+fn scan_drawn(content: &[u8]) -> Vec {
+ use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full};
+ let (w, h) = (1000usize, 400usize);
+ let stride = w.div_ceil(8);
+ let mut data = vec![0xFFu8; stride * h];
+ let mut seed = 0x2545_F491_4F6C_DD1Du64;
+ let mut next = move || {
+ seed ^= seed << 13;
+ seed ^= seed >> 7;
+ seed ^= seed << 17;
+ seed
+ };
+ for line in (4..h - 20).step_by(24) {
+ let mut x = 8;
+ while x + 12 < w {
+ let wide = 3 + (next() % 5) as usize;
+ let (top, bottom) = (line + (next() % 4) as usize, line + 14);
+ for y in top..bottom {
+ let left = x + usize::from(next() % 8 == 0);
+ let right = x + wide - usize::from(next() % 8 == 0);
+ for xx in left..right {
+ data[y * stride + xx / 8] &= !(0x80 >> (xx % 8));
+ }
+ }
+ x += wide + 2 + (next() % 4) as usize;
+ }
+ }
+ let mut doc = Document::new_empty();
+ let mut d = Dict::new();
+ d.set(b"Type".to_vec(), Object::name("XObject"));
+ d.set(b"Subtype".to_vec(), Object::name("Image"));
+ d.set(b"Width".to_vec(), Object::Int(w as i64));
+ d.set(b"Height".to_vec(), Object::Int(h as i64));
+ d.set(b"BitsPerComponent".to_vec(), Object::Int(1));
+ d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
+ let image = doc.add(Object::Stream(Stream::flate(d, &data)));
+ let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap();
+ let mut x = Dict::new();
+ x.set(b"Scan".to_vec(), Object::Ref(image));
+ let mut res = Dict::new();
+ res.set(b"XObject".to_vec(), Object::Dict(x));
+ let content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), content.to_vec())));
+ let mut p = Dict::new();
+ p.set(b"Type".to_vec(), Object::name("Page"));
+ p.set(b"Parent".to_vec(), Object::Ref(pages));
+ p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]));
+ p.set(b"Resources".to_vec(), Object::Dict(res));
+ p.set(b"Contents".to_vec(), Object::Ref(content));
+ let page = doc.add(Object::Dict(p));
+ doc.update_dict(pages, |d| {
+ d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
+ d.set(b"Count".to_vec(), Object::Int(1));
+ })
+ .unwrap();
+ write_full(&doc, &SaveOptions::default()).unwrap()
+}
+
+/// A scan saved as a 1-bit PNG becomes a page whose picture stays one bit per pixel (not the eight
+/// bits the PNG decodes to), and Export all images gives the picture back.
+#[test]
+fn a_one_bit_scan_stays_one_bit_and_exports() {
+ let dir = workdir("bilevel");
+ let (w, h) = (800u32, 200u32);
+ let stride = (w as usize).div_ceil(8);
+ let mut bits = vec![0xFFu8; stride * h as usize];
+ for y in 0..h as usize {
+ for x in 0..w as usize {
+ if y % 25 >= 6 && y % 25 < 18 && (x * 7 + y / 25 * 13) % 11 < 4 {
+ bits[y * stride + x / 8] &= !(0x80 >> (x % 8));
+ }
+ }
+ }
+ let mut png_bytes = Vec::new();
+ {
+ let mut enc = png::Encoder::new(&mut png_bytes, w, h);
+ enc.set_color(png::ColorType::Grayscale);
+ enc.set_depth(png::BitDepth::One);
+ enc.write_header().unwrap().write_image_data(&bits).unwrap();
+ }
+ std::fs::write(dir.join("scan.png"), &png_bytes).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["scan.png"] }))["doc"].as_u64().unwrap();
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "scan.pdf" }));
+ let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("scan.pdf")).unwrap())).unwrap();
+ let images: Vec> = saved
+ .object_numbers()
+ .into_iter()
+ .filter_map(|num| match &*saved.get(pdfcraft_cos::ObjRef { num, generation: saved.generation(num) }) {
+ pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => Some(s.dict.int(b"BitsPerComponent")),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(images, [Some(1)]);
+ let r = ok(&mut a, "doc_export_all_images", json!({ "doc": doc, "folder": "out" }));
+ let files = r["files"].as_array().unwrap();
+ assert_eq!(files.len(), 1, "{r}");
+ let exported = std::fs::read(files[0]["path"].as_str().unwrap()).unwrap();
+ let mut reader = png::Decoder::new(std::io::Cursor::new(exported)).read_info().unwrap();
+ let mut buf = vec![0; reader.output_buffer_size().unwrap()];
+ let frame = reader.next_frame(&mut buf).unwrap();
+ let white: Vec = buf[..frame.buffer_size()].as_chunks::<3>().0.iter().map(|p| *p == [255, 255, 255]).collect();
+ let expected: Vec =
+ (0..h as usize).flat_map(|y| (0..w as usize).map(move |x| (y, x))).map(|(y, x)| bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0).collect();
+ assert_eq!(white, expected);
+}
+
+/// With `mono: { compression: jbig2 }` a scan's black-and-white image is stored as lossless JBIG2,
+/// smaller than as Group 4, and exports as the same picture.
+#[test]
+fn doc_optimize_stores_black_and_white_images_as_jbig2() {
+ let dir = workdir("optimize-jbig2");
+ std::fs::write(dir.join("scan.pdf"), scanned_page()).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "scan.pdf" }))["doc"].as_u64().unwrap();
+ let g4 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "g4.pdf" }));
+ let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "jbig2.pdf", "mono": { "compression": "jbig2" } }));
+ assert_eq!((r["images"].as_u64(), r["images_recompressed"].as_u64()), (Some(1), Some(1)), "{r}");
+ assert!(r["image_bytes_after"].as_u64() < g4["image_bytes_after"].as_u64(), "JBIG2 {r}, Group 4 {g4}");
+ let written = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("jbig2.pdf")).unwrap())).unwrap();
+ let filters: Vec> = written
+ .object_numbers()
+ .into_iter()
+ .filter_map(|num| match &*written.get(pdfcraft_cos::ObjRef { num, generation: written.generation(num) }) {
+ pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => s.dict.name(b"Filter").map(<[u8]>::to_vec),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(filters, [b"JBIG2Decode".to_vec()]);
+ // Export ▸ Images gives the copy's picture back as it gives the scan's.
+ let copy = ok(&mut a, "doc_open", json!({ "path": "jbig2.pdf" }))["doc"].as_u64().unwrap();
+ let mut exported = |doc: u64, folder: &str| {
+ let r = ok(&mut a, "doc_export_all_images", json!({ "doc": doc, "folder": folder }));
+ let files = r["files"].as_array().unwrap();
+ assert_eq!(files.len(), 1, "{r}");
+ std::fs::read(files[0]["path"].as_str().unwrap()).unwrap()
+ };
+ assert_eq!(exported(copy, "from-jbig2"), exported(doc, "from-scan"));
+}
+
+/// A scan's black-and-white image is stored as CCITT Group 4 by default, and kept as it is with
+/// `mono: { compression: retain }`.
+#[test]
+fn doc_optimize_stores_black_and_white_images_as_ccitt_group_4() {
+ let dir = workdir("optimize-mono");
+ std::fs::write(dir.join("scan.pdf"), scanned_page()).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "scan.pdf" }))["doc"].as_u64().unwrap();
+ let kept = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf", "mono": { "compression": "retain" } }));
+ assert_eq!((kept["images"].as_u64(), kept["images_recompressed"].as_u64()), (Some(1), Some(0)), "{kept}");
+ let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf" }));
+ assert_eq!((r["images"].as_u64(), r["images_recompressed"].as_u64()), (Some(1), Some(1)), "{r}");
+ assert!(r["image_bytes_after"].as_u64() < r["image_bytes_before"].as_u64(), "{r}");
+ // The copy written stores the image as Group 4.
+ let small = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("small.pdf")).unwrap())).unwrap();
+ let filters: Vec> = small
+ .object_numbers()
+ .into_iter()
+ .filter_map(|num| match &*small.get(pdfcraft_cos::ObjRef { num, generation: small.generation(num) }) {
+ pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => s.dict.name(b"Filter").map(<[u8]>::to_vec),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(filters, [b"CCITTFaxDecode".to_vec()]);
+ // An unknown compression, one that isn't a name, a misspelled key, or a mono that isn't an
+ // object is refused, not read as the default.
+ for mono in [json!({ "compression": "jpeg" }), json!({ "compression": false }), json!({ "compresson": "retain" }), json!("retain")] {
+ assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "mono": mono })), Err(ToolError::InvalidArgs(_))), "{mono}");
+ }
+}
+
+/// A scan drawn at 600 ppi is averaged down to 300 ppi by default. `mono: { downsample: false }`
+/// keeps its size, `ppi` and `above_ppi` choose others, and values of the wrong type are refused.
+#[test]
+fn doc_optimize_averages_black_and_white_scans_down() {
+ let dir = workdir("optimize-mono-downsample");
+ // 1000 × 400 samples on 120 × 48 pt: 600 ppi.
+ std::fs::write(dir.join("scan.pdf"), simple_rule_scan_drawn(b"q 120 0 0 48 50 500 cm /Scan Do Q")).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "scan.pdf" }))["doc"].as_u64().unwrap();
+ let sizes = |name: &str| image_sizes(&dir.join(name));
+ let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf" }));
+ assert_eq!(r["images_resampled"].as_u64(), Some(1), "{r}");
+ assert_eq!(sizes("small.pdf"), [(Some(500), Some(200))]);
+ let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf", "mono": { "downsample": false } }));
+ assert_eq!(r["images_resampled"].as_u64(), Some(0), "{r}");
+ assert_eq!(sizes("kept.pdf"), [(Some(1000), Some(400))]);
+ ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "200.pdf", "mono": { "ppi": 200, "above_ppi": 250 } }));
+ assert_eq!(sizes("200.pdf"), [(Some(333), Some(133))]);
+ for mono in [json!({ "downsample": "yes" }), json!({ "ppi": "300" }), json!({ "above": 450 })] {
+ assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "mono": mono })), Err(ToolError::InvalidArgs(_))), "{mono}");
+ }
+}
+
+/// The width and height of every image in the PDF at `path`.
+fn image_sizes(path: &std::path::Path) -> Vec<(Option, Option)> {
+ let written = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap();
+ written
+ .object_numbers()
+ .into_iter()
+ .filter_map(|num| match &*written.get(pdfcraft_cos::ObjRef { num, generation: written.generation(num) }) {
+ pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => Some((s.dict.int(b"Width"), s.dict.int(b"Height"))),
+ _ => None,
+ })
+ .collect()
+}
+
+/// The batch optimize step's `ppi` reaches black-and-white images too: they go to the larger of
+/// it and Reduce File Size's 300 ppi. Before, a batch run took every scan to 300 ppi whatever
+/// `ppi` said, so `ppi: 600` halved 600 ppi scans.
+#[test]
+fn batch_ppi_bounds_how_far_black_and_white_scans_are_resampled() {
+ let dir = workdir("batch-mono-ppi");
+ // 1000 × 400 samples on 120 × 48 pt: 600 ppi.
+ std::fs::write(dir.join("scan.pdf"), simple_rule_scan_drawn(b"q 120 0 0 48 50 500 cm /Scan Do Q")).unwrap();
+ let mut a = auto(&dir);
+ for (ppi, size) in [(None, (500, 200)), (Some(600), (1000, 400)), (Some(96), (500, 200))] {
+ let out = format!("out-{ppi:?}");
+ let mut args = json!({ "paths": ["scan.pdf"], "steps": ["optimize"], "out_dir": out });
+ if let Some(ppi) = ppi {
+ args["ppi"] = json!(ppi);
+ }
+ let report = ok(&mut a, "doc_batch", args);
+ assert_eq!(report["completed"], 1, "{report}");
+ assert_eq!(image_sizes(&dir.join(&out).join("scan.pdf")), [(Some(size.0), Some(size.1))], "ppi {ppi:?}");
+ }
+}
+
/// A one-page PDF whose page picture is drawn as `rows` grey images one pixel row high, 600 px
/// wide on a 72 pt square (600 ppi), as some PDF printers write it.
fn printed_in_strips(rows: usize) -> Vec {
@@ -6569,6 +7407,8 @@ fn ocr_recognize_files_with_distinct_names_runs_as_before() {
}
}
+/// Field scripts set through the tools validate, calculate and format, and `js_run` reports console
+/// lines, every message with its details, and requests.
#[test]
fn javascript_through_tools() {
let dir = workdir("js");
@@ -6602,10 +7442,17 @@ fn javascript_through_tools() {
let r = ok(
&mut a,
"js_run",
- json!({ "doc": doc, "script": "console.println(getField('Total').value); getField('Qty').value = 3; this.pageNum = 1; app.alert('ok');" }),
+ json!({ "doc": doc, "script": "console.println(getField('Total').value); getField('Qty').value = 3; this.pageNum = 1; app.alert('ok'); app.alert({ cMsg: 'Clear?', cTitle: 'Order', nIcon: 2, nType: 3 });" }),
);
assert_eq!(r["console"][0], "2500");
- assert_eq!(r["alerts"][0], "ok");
+ // Every message, in order, with its title, icon and buttons.
+ assert_eq!(
+ r["alerts"],
+ json!([
+ { "message": "ok", "title": null, "icon": "error", "buttons": "ok" },
+ { "message": "Clear?", "title": "Order", "icon": "question", "buttons": "yes_no_cancel" },
+ ])
+ );
assert_eq!(r["requests"][0]["page"], 2);
let f = ok(&mut a, "form_fields", json!({ "doc": doc }));
let total = f["fields"].as_array().unwrap().iter().find(|x| x["name"] == "Total").unwrap().clone();
@@ -6886,15 +7733,30 @@ fn exporting_to_word_html_and_rtf() {
let dir = workdir("office");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
- for ext in ["docx", "pptx", "html", "rtf"] {
+ for ext in ["docx", "pptx", "html", "rtf", "md"] {
let r = ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": format!("a.{ext}") }));
assert_eq!(r["format"], ext);
}
let html = std::fs::read_to_string(dir.join("a.html")).unwrap();
assert!(html.contains("Page 1") && html.contains("Page 3") && html.matches(" ").count() == 2, "{html}");
+ ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "simple.html", "html_mode": "simple" }));
+ let simple_html = std::fs::read_to_string(dir.join("simple.html")).unwrap();
+ assert!(
+ simple_html.contains("Page 1")
+ && !simple_html.contains("\n\n\n",
- esc(title)
+ "\n\n\n \n{} \n{}\n\n\n",
+ esc(title),
+ if simple {
+ ""
+ } else {
+ ""
+ }
);
for it in items(pages) {
match it {
@@ -59,13 +71,13 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi
}
// The paragraph has its first run's colour; a run in another colour says so.
let colour = rgb8(r.block.color);
- if colour != rgb8(first.color) {
+ if !simple && colour != rgb8(first.color) {
let [red, green, blue] = colour;
run = format!("{run} ");
}
t.push_str(&run);
}
- let style = hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\""));
+ let style = if simple { String::new() } else { hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) };
match lvl {
0 => s.push_str(&format!("{t}
\n")),
l => s.push_str(&format!("{t} \n")),
@@ -84,7 +96,7 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi
body = format!("{body} ");
}
let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() };
- let style = hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\""));
+ let style = if simple { String::new() } else { hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) };
s.push_str(&format!("{body} "));
}
s.push_str("\n");
diff --git a/crates/export/src/as_markdown.rs b/crates/export/src/as_markdown.rs
new file mode 100644
index 0000000000..a5c986c6b3
--- /dev/null
+++ b/crates/export/src/as_markdown.rs
@@ -0,0 +1,151 @@
+use super::as_html::allowed;
+use super::layout::{Cell, Item, MAX_COLS, Page, items};
+
+/// Markdown's deepest heading.
+const MAX_HEADING: u8 = 6;
+
+/// `s` as Markdown text on one line: line breaks become spaces, control characters are dropped,
+/// and every character Markdown would read as markup is escaped with a backslash.
+fn md_text(s: &str) -> String {
+ let mut o = String::with_capacity(s.len());
+ for c in s.chars().filter(|c| allowed(*c)) {
+ match c {
+ '\n' | '\r' | '\t' => o.push(' '),
+ '\\' | '`' | '*' | '_' | '[' | ']' | '<' | '>' | '#' | '|' | '~' | '!' | '&' => {
+ o.push('\\');
+ o.push(c);
+ }
+ c => o.push(c),
+ }
+ }
+ o
+}
+
+/// `line` with its start escaped where Markdown would read a list item there (`- `, `+ `,
+/// `1. `, `1) `); the other block markers (`#`, `>`) are escaped by [`md_text`] already.
+fn block_start(line: String) -> String {
+ let digits = line.bytes().take_while(u8::is_ascii_digit).count();
+ match line.as_bytes().get(digits) {
+ Some(b'.' | b')') if digits > 0 => format!("{}\\{}", line.get(..digits).unwrap_or_default(), line.get(digits..).unwrap_or_default()),
+ Some(b'-' | b'+') if digits == 0 => format!("\\{line}"),
+ _ => line,
+ }
+}
+
+/// `text` (already escaped) in bold and/or italic, its outer spaces kept outside. `open` and
+/// `close` say whether `*` markers would be read as emphasis on that side (see [`marks`]);
+/// where they would not, as in `foo` + bold `.` + `bar`, the run is wrapped in HTML tags, which
+/// CommonMark takes inline. The text's own outer spaces count as a side that marks.
+pub(crate) fn emphasis(text: &str, bold: bool, italic: bool, open: bool, close: bool) -> String {
+ let core = text.trim();
+ if core.is_empty() || !(bold || italic) {
+ return text.to_string();
+ }
+ let lead = text.get(..text.len().saturating_sub(text.trim_start().len())).unwrap_or_default();
+ let trail = text.get(text.trim_end().len()..).unwrap_or_default();
+ let markers = (open || !lead.is_empty()) && (close || !trail.is_empty());
+ let (before, after) = match (markers, bold, italic) {
+ (true, true, true) => ("***", "***"),
+ (true, true, false) => ("**", "**"),
+ (true, false, _) => ("*", "*"),
+ (false, true, true) => ("", " "),
+ (false, true, false) => ("", " "),
+ (false, false, _) => ("", " "),
+ };
+ format!("{lead}{before}{core}{after}{trail}")
+}
+
+/// A paragraph's runs (text, bold, italic) as one line of Markdown. Neighbouring runs in the
+/// same style are joined first, so two bold runs never meet as `****`.
+pub(crate) fn paragraph(runs: &[(&str, bool, bool)]) -> String {
+ let mut merged: Vec<(String, bool, bool)> = Vec::new();
+ for &(text, bold, italic) in runs {
+ match merged.last_mut() {
+ Some(last) if last.1 == bold && last.2 == italic => last.0.push_str(text),
+ _ => merged.push((text.to_string(), bold, italic)),
+ }
+ }
+ let texts: Vec = merged.iter().map(|(text, ..)| md_text(text)).collect();
+ let mut out = String::new();
+ for (k, ((_, bold, italic), text)) in merged.iter().zip(&texts).enumerate() {
+ let core = text.trim();
+ let open = marks(core.chars().next(), out.chars().last());
+ // Markers of a styled neighbour right next to these would join them into one run.
+ let close = match (merged.get(k.saturating_add(1)), texts.get(k.saturating_add(1))) {
+ (Some((_, b, i)), Some(next)) if (*b || *i) && !next.starts_with(char::is_whitespace) => false,
+ (_, next) => marks(core.chars().last(), next.and_then(|n| n.chars().next())),
+ };
+ out.push_str(&emphasis(text, *bold, *italic, open, close));
+ }
+ out
+}
+
+/// Whether a `*` marker between the text's `edge` character and the `outside` one is read as
+/// emphasis (CommonMark's flanking rules for `*`): always next to a space or the line's edge,
+/// never next to another `*`, and otherwise unless the text's edge is punctuation while the
+/// outside is a letter or digit.
+fn marks(edge: Option, outside: Option) -> bool {
+ match (edge, outside) {
+ (_, None) => true,
+ (_, Some(o)) if o.is_whitespace() => true,
+ (_, Some('*')) => false,
+ (Some(e), Some(o)) => e.is_alphanumeric() || o.is_ascii_punctuation(),
+ (None, Some(_)) => false,
+ }
+}
+
+fn cell(c: &Cell) -> String {
+ // A cell sits between `| ` and ` |`, so its markers always have a space outside.
+ emphasis(&md_text(&c.text), c.bold, c.italic, true, true)
+}
+
+/// Markdown (CommonMark, with GitHub's pipe tables): headings, paragraphs with bold and italic,
+/// tables with their first row as the header, and a rule between pages. Images are left out.
+pub fn markdown(pages: &[Page]) -> String {
+ let mut blocks: Vec = Vec::new();
+ for it in items(pages) {
+ match it {
+ Item::Para(runs, lvl) => {
+ // A heading's weight is the heading's own, as in the HTML export.
+ let runs: Vec<(&str, bool, bool)> = runs.iter().map(|r| (r.text.as_str(), r.block.bold && lvl == 0, r.block.italic)).collect();
+ let text = paragraph(&runs);
+ let text = text.trim();
+ if text.is_empty() {
+ continue;
+ }
+ blocks.push(match lvl {
+ 0 => block_start(text.to_string()),
+ l => format!("{} {text}", "#".repeat(usize::from(l.min(MAX_HEADING)))),
+ });
+ }
+ Item::Table(t) => {
+ // Each row as wide as the widest; a cell spanning columns is followed by empty ones.
+ // Detected tables have at most MAX_COLS columns, and a span never counts for more.
+ let span = |c: &Cell| c.span.clamp(1, MAX_COLS);
+ let width = t.rows.iter().map(|r| r.iter().map(span).fold(0usize, usize::saturating_add)).max().unwrap_or(0);
+ if width == 0 {
+ continue;
+ }
+ let mut lines = Vec::with_capacity(t.rows.len().saturating_add(1));
+ for (i, row) in t.rows.iter().enumerate() {
+ let mut cells: Vec = Vec::with_capacity(width);
+ for c in row {
+ cells.push(cell(c));
+ cells.extend(std::iter::repeat_n(String::new(), span(c).saturating_sub(1)));
+ }
+ cells.resize(width, String::new());
+ lines.push(format!("| {} |", cells.join(" | ")));
+ if i == 0 {
+ lines.push(format!("|{}", " --- |".repeat(width)));
+ }
+ }
+ blocks.push(lines.join("\n"));
+ }
+ Item::Img(_) => {}
+ Item::PageBreak => blocks.push("---".into()),
+ }
+ }
+ let mut s = blocks.join("\n\n");
+ s.push('\n');
+ s
+}
diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs
index 5275b02439..2b82919bbf 100644
--- a/crates/export/src/lib.rs
+++ b/crates/export/src/lib.rs
@@ -1,4 +1,4 @@
-//! pdfcraft-export — Export a PDF ▸ Word, HTML, RTF (L4).
+//! pdfcraft-export — Export a PDF ▸ Word, HTML, RTF, Markdown (L4).
//!
//! The engine reduces each page to [`Page`]: paragraphs (text, box, size, bold/italic) and
//! images (encoded bytes and box), in reading order. The writers turn that into a flowing
@@ -18,6 +18,7 @@ pub use zip::Zip;
mod as_docx;
mod as_html;
+mod as_markdown;
mod as_rtf;
mod layout;
@@ -26,9 +27,12 @@ pub(crate) use as_docx::hex;
#[cfg(test)]
use as_docx::{MAX_MEDIA, docx_table};
pub(crate) use as_html::esc;
-pub use as_html::html;
#[cfg(test)]
use as_html::{allowed, base64};
+pub use as_html::{html, html_simple};
+pub use as_markdown::markdown;
+#[cfg(test)]
+use as_markdown::{emphasis, paragraph};
pub use as_rtf::rtf;
pub use layout::{Block, Cell, Image, Page, Table};
#[cfg(test)]
diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs
index d735eb4c20..f630d98d5d 100644
--- a/crates/export/src/tests.rs
+++ b/crates/export/src/tests.rs
@@ -85,6 +85,24 @@ fn html_has_headings_paragraphs_and_images_in_order() {
assert_eq!(base64(b"Ma"), "TWE=");
}
+#[test]
+fn simple_html_has_no_css_or_layout_elements() {
+ let mut p = page();
+ let mut unicode = block("naïve 日本語.", 72.0, 300.0, 300.0, 311.0);
+ unicode.bold = true;
+ unicode.italic = true;
+ p.blocks.push(unicode);
+ let source = html_simple(&[p, table_page()], "Simple export");
+ for banned in ["