Merge remote-tracking branch 'upstream/main' into feat/sign-tab-empty-signature-fields

This commit is contained in:
Eric Kaden committed 2026-11-01 15:24:28 -05:00
commit eb0ec5a1f6
212 files changed
+20693 -2140

No files matched your search

+7
View File
@@ -1,3 +1,10 @@
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
nextest-version = "0.9.150"
# These measurements require a quiet runner. Their in-process SERIAL mutex only
# serializes cargo test; nextest gives each test its own process. Reserve all slots
# so other tests cannot charge their CPU/render-worker time to a frame budget.
[[profile.default.overrides]]
filter = 'package(pdfcraft-ui-egui) & binary(perf)'
threads-required = "num-test-threads"
+1
View File
@@ -12,6 +12,7 @@ on:
permissions:
contents: write
packages: write
concurrency:
group: release-${{ github.ref }}
+6 -10
View File
@@ -2,8 +2,6 @@
These rules bind every AI agent and every human contributor working in this repository. They come before any task, instruction or deadline. `CLAUDE.md` holds the day-to-day working instructions. If the two ever disagree, **this file wins.**
Some sections link to **craftrules**, the standards shared by the Crafting Apps. That repository is internal to the storytold team, so outside contributors can't open it. Every rule a contributor here must follow is stated in this file and `CLAUDE.md`; the craftrules links give team members the background.
---
## 1. Asset policy (absolute — no exceptions)
@@ -69,9 +67,10 @@ Anything else is forbidden, including assets whose licence you cannot prove from
### 1.4 Fonts live in craft-fonts
- Font assets shared by the Crafting Apps, including every Japanese font, live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), not here. **Never commit a font file to this repository**; adding a font means adding it to craft-fonts. (The Latin UI fonts already in `assets/fonts/` — Inter, JetBrains Mono, Dancing Script — stay.) Team members: the shared rules are in [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md) (internal).
- Font assets shared by the Crafting Apps, including every Japanese font, live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), not here. **Never commit a font file to this repository**; adding a font means adding it to craft-fonts. (The Latin UI fonts already in `assets/fonts/` — Inter, JetBrains Mono, Dancing Script — stay.)
- craft-fonts is an **optional build input**, never a Cargo dependency: `CRAFT_FONTS_DIR=<checkout> cargo build` makes `crates/fonts/build.rs` embed its fonts as `pdfcraft_fonts::CRAFT_FONTS`. Without it PdfCraft builds, tests and runs, but has no Japanese face (Japanese UI text shows replacement boxes, and writing Japanese into a PDF returns a clear error). Release builds always set it (`.github/workflows/release.yml`), and `ATTRIBUTION.toml` lists it as a `[[build_input]]`.
- The desktop app may read one font **already installed on the user's machine** at runtime, as the last interface fallback for characters no embedded face has (`crates/ui-egui/src/system_fonts.rs`). That font is never embedded, shipped, committed or copied, and never appears in published screenshots (`PDFCRAFT_SYSTEM_FONTS=0`, which `cargo xtask screenshots` sets). It is not a way around §1.2: nothing from it may enter a PDF, a package or the repository.
- The page viewer may also draw a CID font of an Adobe CJK collection (Japan1, GB1, CNS1, Korea1) that a PDF does **not embed** with one face already installed on the machine (`crates/render/src/system_font.rs`), **on screen only**. `RenderConfig::system_fonts` is off by default and is enabled only for on-screen viewer and preview rasters (`RenderConfig::on_screen`, used by the engine viewer, Combine thumbnails and accessibility figure previews), so exports, printing, OCR and the automation tools never use it. Like the interface font it is read at runtime and never embedded, shipped, committed or copied; Noto CJK and Source Han are never candidates (§1.1), nor is any Adobe family; and `PDFCRAFT_SYSTEM_FONTS=0` turns it off. It is not a way around §1.2 either: nothing drawn with it may be saved or printed.
### 1.5 Procedure for adding or changing an asset
@@ -92,7 +91,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi
- Behaviour comes only from public specifications (ISO 32000-2, the Arlington model), public documentation and black-box observation.
- Never copy GPL/AGPL code. Copyleft tools run only as external oracle processes.
- Observe Acrobat only with synthetic fixtures. Never capture personal data, account information or recent files.
- Shared real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md) (internal). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- Shared real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
## 3. Agent control (automation and MCP)
@@ -105,7 +104,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi
## 4. Never crash
PdfCraft opens files from strangers, and people trust it with their work. A malformed PDF, a bad tool argument, a corrupt settings file or a full disk must produce an error the user (or agent) can act on, never a crash and never lost work. **This outranks feature work:** don't ship a feature by adding a panic path, and fix a crash before building on top of it. The rules below are the ones that bind this repository; the shared standard for every Crafting App, [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md), is internal to the storytold team.
PdfCraft opens files from strangers, and people trust it with their work. A malformed PDF, a bad tool argument, a corrupt settings file or a full disk must produce an error the user (or agent) can act on, never a crash and never lost work. **This outranks feature work:** don't ship a feature by adding a panic path, and fix a crash before building on top of it. The rules below are the ones that bind this repository.
- **Fail with `Result`.** Non-test code returns errors through the crate's error type and `?`. Where readers are lenient by design, fall back gracefully and record the repair (see Fidelity in `CLAUDE.md`).
- **No panicking shortcuts.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!` outside tests, unless the call is provably infallible (e.g. writing to a `Vec`), with a comment saying why. `assert!` only for internal invariants that input cannot reach.
@@ -124,20 +123,17 @@ Follow `CLAUDE.md`. It covers quality gates, layering, commit rules and how to r
## Contributor credits (About window)
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
(commit stats; never hand-edit) and `contributors/people.toml` (names people chose for
themselves). See `docs/contributors.md`.
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
(copy the template at the top of the file)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
+204 -204
View File
@@ -36,181 +36,181 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/square-terminal.svg` | Lucide icon "square-terminal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-terminal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-code.svg` | Lucide icon "file-code" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-code.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-spreadsheet.svg` | Lucide icon "file-spreadsheet" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-spreadsheet.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-justify.svg` | Lucide icon "align-justify" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-justify.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-right.svg` | Lucide icon "align-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/columns-3.svg` | Lucide icon "columns-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/rows-3.svg` | Lucide icon "rows-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rows-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/flip-horizontal-2.svg` | Lucide icon "flip-horizontal-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-horizontal-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/flip-vertical-2.svg` | Lucide icon "flip-vertical-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-vertical-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/app-window.svg` | Lucide icon "app-window" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/app-window.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/arrow-left-right.svg` | Lucide icon "arrow-left-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-left-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/arrow-up-down.svg` | Lucide icon "arrow-up-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/arrow-up-right.svg` | Lucide icon "arrow-up-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/badge-check.svg` | Lucide icon "badge-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/badge-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/bandage.svg` | Lucide icon "bandage" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bandage.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/bell.svg` | Lucide icon "bell" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bell.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/blend.svg` | Lucide icon "blend" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/blend.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/bold.svg` | Lucide icon "bold" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bold.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/book-open.svg` | Lucide icon "book-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/book-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/bookmark.svg` | Lucide icon "bookmark" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/bookmark-plus.svg` | Lucide icon "bookmark-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/braces.svg` | Lucide icon "braces" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/braces.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/brush-cleaning.svg` | Lucide icon "brush-cleaning" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush-cleaning.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/brush.svg` | Lucide icon "brush" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/camera.svg` | Lucide icon "camera" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/camera.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/check-circle-2.svg` | Lucide icon "check-circle-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check-circle-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/check.svg` | Lucide icon "check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevron-down.svg` | Lucide icon "chevron-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevron-left.svg` | Lucide icon "chevron-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevron-right.svg` | Lucide icon "chevron-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevron-up.svg` | Lucide icon "chevron-up" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-up.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevrons-left.svg` | Lucide icon "chevrons-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevrons-right.svg` | Lucide icon "chevrons-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-check.svg` | Lucide icon "circle-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-dashed.svg` | Lucide icon "circle-dashed" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dashed.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-dot.svg` | Lucide icon "circle-dot" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dot.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-help.svg` | Lucide icon "circle-help" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-help.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle.svg` | Lucide icon "circle" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/clock-3.svg` | Lucide icon "clock-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/clock.svg` | Lucide icon "clock" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/cloud-upload.svg` | Lucide icon "cloud-upload" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud-upload.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/cloud.svg` | Lucide icon "cloud" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/code-xml.svg` | Lucide icon "code-xml" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/code-xml.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/columns-2.svg` | Lucide icon "columns-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/compass.svg` | Lucide icon "compass" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/compass.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/contrast.svg` | Lucide icon "contrast" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/contrast.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/copy-plus.svg` | Lucide icon "copy-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/copy.svg` | Lucide icon "copy" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/crop.svg` | Lucide icon "crop" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/crop.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/droplet.svg` | Lucide icon "droplet" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/droplet.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/ellipsis-vertical.svg` | Lucide icon "ellipsis-vertical" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis-vertical.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/ellipsis.svg` | Lucide icon "ellipsis" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/eraser.svg` | Lucide icon "eraser" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eraser.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/external-link.svg` | Lucide icon "external-link" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/external-link.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/eye-off.svg` | Lucide icon "eye-off" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye-off.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/eye.svg` | Lucide icon "eye" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-check.svg` | Lucide icon "file-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-down.svg` | Lucide icon "file-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-input.svg` | Lucide icon "file-input" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-input.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-lock-2.svg` | Lucide icon "file-lock-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-lock-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-minus.svg` | Lucide icon "file-minus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-minus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-output.svg` | Lucide icon "file-output" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-output.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-pen-line.svg` | Lucide icon "file-pen-line" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-pen-line.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-plus-2.svg` | Lucide icon "file-plus-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-plus.svg` | Lucide icon "file-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-scan.svg` | Lucide icon "file-scan" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-scan.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-search.svg` | Lucide icon "file-search" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-search.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-symlink.svg` | Lucide icon "file-symlink" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-symlink.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-text.svg` | Lucide icon "file-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-x.svg` | Lucide icon "file-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/files.svg` | Lucide icon "files" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/files.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/film.svg` | Lucide icon "film" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/film.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/filter.svg` | Lucide icon "filter" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/filter.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/folder-open.svg` | Lucide icon "folder-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/folder-plus.svg` | Lucide icon "folder-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/folder.svg` | Lucide icon "folder" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/gauge.svg` | Lucide icon "gauge" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/gauge.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/git-compare.svg` | Lucide icon "git-compare" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/git-compare.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/globe.svg` | Lucide icon "globe" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/globe.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/grid-3x3.svg` | Lucide icon "grid-3x3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/grid-3x3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/hand.svg` | Lucide icon "hand" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hand.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/hash.svg` | Lucide icon "hash" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hash.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/heading.svg` | Lucide icon "heading" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/heading.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/highlighter.svg` | Lucide icon "highlighter" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/highlighter.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/house.svg` | Lucide icon "house" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/house.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/image-plus.svg` | Lucide icon "image-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/image.svg` | Lucide icon "image" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/info.svg` | Lucide icon "info" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/info.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/italic.svg` | Lucide icon "italic" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/italic.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/languages.svg` | Lucide icon "languages" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/languages.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/lasso.svg` | Lucide icon "lasso" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lasso.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/layers.svg` | Lucide icon "layers" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layers.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/layout-grid.svg` | Lucide icon "layout-grid" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layout-grid.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/link-2.svg` | Lucide icon "link-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/link.svg` | Lucide icon "link" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/list-checks.svg` | Lucide icon "list-checks" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-checks.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/list-ordered.svg` | Lucide icon "list-ordered" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-ordered.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/list.svg` | Lucide icon "list" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/lock-open.svg` | Lucide icon "lock-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/lock.svg` | Lucide icon "lock" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/maximize-2.svg` | Lucide icon "maximize-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/maximize.svg` | Lucide icon "maximize" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-circle-reply.svg` | Lucide icon "message-circle-reply" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-circle-reply.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square-text.svg` | Lucide icon "message-square-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square.svg` | Lucide icon "message-square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/messages-square.svg` | Lucide icon "messages-square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/messages-square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/mic.svg` | Lucide icon "mic" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mic.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/minimize.svg` | Lucide icon "minimize" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/minimize-2.svg` | Lucide icon "minimize-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/minus.svg` | Lucide icon "minus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/moon.svg` | Lucide icon "moon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/moon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/mouse-pointer-2.svg` | Lucide icon "mouse-pointer-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mouse-pointer-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/move.svg` | Lucide icon "move" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/navigation.svg` | Lucide icon "navigation" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/navigation.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/paint-bucket.svg` | Lucide icon "paint-bucket" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paint-bucket.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/palette.svg` | Lucide icon "palette" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/palette.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/panel-left.svg` | Lucide icon "panel-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/panel-right.svg` | Lucide icon "panel-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/panels-top-left.svg` | Lucide icon "panels-top-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panels-top-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/paperclip.svg` | Lucide icon "paperclip" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paperclip.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pen-line.svg` | Lucide icon "pen-line" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-line.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pen-tool.svg` | Lucide icon "pen-tool" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-tool.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pencil.svg` | Lucide icon "pencil" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pencil.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pipette.svg` | Lucide icon "pipette" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pipette.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/plus.svg` | Lucide icon "plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/presentation.svg` | Lucide icon "presentation" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/presentation.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/printer.svg` | Lucide icon "printer" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/printer.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/rectangle-horizontal.svg` | Lucide icon "rectangle-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rectangle-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/redo-2.svg` | Lucide icon "redo-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/redo-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/replace.svg` | Lucide icon "replace" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/replace.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/rotate-ccw.svg` | Lucide icon "rotate-ccw" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-ccw.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/rotate-cw.svg` | Lucide icon "rotate-cw" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-cw.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/ruler.svg` | Lucide icon "ruler" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ruler.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/save.svg` | Lucide icon "save" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/save.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/scan-text.svg` | Lucide icon "scan-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/scan.svg` | Lucide icon "scan" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/scissors.svg` | Lucide icon "scissors" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scissors.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/search.svg` | Lucide icon "search" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/search.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/send.svg` | Lucide icon "send" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/send.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/settings-2.svg` | Lucide icon "settings-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/settings.svg` | Lucide icon "settings" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/share-2.svg` | Lucide icon "share-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/share-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/shield-check.svg` | Lucide icon "shield-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/shield-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/signature.svg` | Lucide icon "signature" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/signature.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/sliders-horizontal.svg` | Lucide icon "sliders-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sliders-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/sparkles.svg` | Lucide icon "sparkles" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sparkles.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/spline.svg` | Lucide icon "spline" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/spline.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/square-dashed-mouse-pointer.svg` | Lucide icon "square-dashed-mouse-pointer" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed-mouse-pointer.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/square-dashed.svg` | Lucide icon "square-dashed" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/square.svg` | Lucide icon "square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/squares-subtract.svg` | Lucide icon "squares-subtract" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/squares-subtract.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/stamp.svg` | Lucide icon "stamp" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/stamp.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/star.svg` | Lucide icon "star" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/star.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/sticky-note.svg` | Lucide icon "sticky-note" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sticky-note.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/strikethrough.svg` | Lucide icon "strikethrough" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/strikethrough.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/sun.svg` | Lucide icon "sun" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sun.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/swatch-book.svg` | Lucide icon "swatch-book" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/swatch-book.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/tag.svg` | Lucide icon "tag" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/tag.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/text-cursor-input.svg` | Lucide icon "text-cursor-input" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-cursor-input.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/text-select.svg` | Lucide icon "text-select" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-select.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/trash-2.svg` | Lucide icon "trash-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/trash.svg` | Lucide icon "trash" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/triangle-alert.svg` | Lucide icon "triangle-alert" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/triangle-alert.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/type.svg` | Lucide icon "type" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/type.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/underline.svg` | Lucide icon "underline" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/underline.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/undo-2.svg` | Lucide icon "undo-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/undo-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/user-round.svg` | Lucide icon "user-round" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/user-round.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/wand-sparkles.svg` | Lucide icon "wand-sparkles" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wand-sparkles.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/wrench.svg` | Lucide icon "wrench" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wrench.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/x.svg` | Lucide icon "x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/zoom-in.svg` | Lucide icon "zoom-in" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-in.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/zoom-out.svg` | Lucide icon "zoom-out" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-out.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/square-terminal.svg` | Lucide icon "square-terminal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-terminal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-code.svg` | Lucide icon "file-code" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-code.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-spreadsheet.svg` | Lucide icon "file-spreadsheet" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-spreadsheet.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-justify.svg` | Lucide icon "align-justify" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-justify.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-right.svg` | Lucide icon "align-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/columns-3.svg` | Lucide icon "columns-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/rows-3.svg` | Lucide icon "rows-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rows-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/flip-horizontal-2.svg` | Lucide icon "flip-horizontal-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-horizontal-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/flip-vertical-2.svg` | Lucide icon "flip-vertical-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-vertical-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/app-window.svg` | Lucide icon "app-window" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/app-window.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/arrow-left-right.svg` | Lucide icon "arrow-left-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-left-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/arrow-up-down.svg` | Lucide icon "arrow-up-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/arrow-up-right.svg` | Lucide icon "arrow-up-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/badge-check.svg` | Lucide icon "badge-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/badge-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/bandage.svg` | Lucide icon "bandage" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bandage.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/bell.svg` | Lucide icon "bell" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bell.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/blend.svg` | Lucide icon "blend" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/blend.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/bold.svg` | Lucide icon "bold" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bold.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/book-open.svg` | Lucide icon "book-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/book-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/bookmark.svg` | Lucide icon "bookmark" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/bookmark-plus.svg` | Lucide icon "bookmark-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/braces.svg` | Lucide icon "braces" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/braces.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/brush-cleaning.svg` | Lucide icon "brush-cleaning" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush-cleaning.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/brush.svg` | Lucide icon "brush" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/camera.svg` | Lucide icon "camera" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/camera.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/check-circle-2.svg` | Lucide icon "check-circle-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check-circle-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/check.svg` | Lucide icon "check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevron-down.svg` | Lucide icon "chevron-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevron-left.svg` | Lucide icon "chevron-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevron-right.svg` | Lucide icon "chevron-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevron-up.svg` | Lucide icon "chevron-up" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-up.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevrons-left.svg` | Lucide icon "chevrons-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/chevrons-right.svg` | Lucide icon "chevrons-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-check.svg` | Lucide icon "circle-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-dashed.svg` | Lucide icon "circle-dashed" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dashed.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-dot.svg` | Lucide icon "circle-dot" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dot.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-help.svg` | Lucide icon "circle-help" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-help.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle.svg` | Lucide icon "circle" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/clock-3.svg` | Lucide icon "clock-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/clock.svg` | Lucide icon "clock" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/cloud-upload.svg` | Lucide icon "cloud-upload" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud-upload.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/cloud.svg` | Lucide icon "cloud" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/code-xml.svg` | Lucide icon "code-xml" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/code-xml.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/columns-2.svg` | Lucide icon "columns-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/compass.svg` | Lucide icon "compass" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/compass.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/contrast.svg` | Lucide icon "contrast" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/contrast.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/copy-plus.svg` | Lucide icon "copy-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/copy.svg` | Lucide icon "copy" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/crop.svg` | Lucide icon "crop" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/crop.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/droplet.svg` | Lucide icon "droplet" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/droplet.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/ellipsis-vertical.svg` | Lucide icon "ellipsis-vertical" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis-vertical.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/ellipsis.svg` | Lucide icon "ellipsis" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/eraser.svg` | Lucide icon "eraser" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eraser.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/external-link.svg` | Lucide icon "external-link" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/external-link.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/eye-off.svg` | Lucide icon "eye-off" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye-off.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/eye.svg` | Lucide icon "eye" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-check.svg` | Lucide icon "file-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-down.svg` | Lucide icon "file-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-input.svg` | Lucide icon "file-input" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-input.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-lock-2.svg` | Lucide icon "file-lock-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-lock-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-minus.svg` | Lucide icon "file-minus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-minus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-output.svg` | Lucide icon "file-output" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-output.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-pen-line.svg` | Lucide icon "file-pen-line" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-pen-line.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-plus-2.svg` | Lucide icon "file-plus-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-plus.svg` | Lucide icon "file-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-scan.svg` | Lucide icon "file-scan" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-scan.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-search.svg` | Lucide icon "file-search" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-search.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-symlink.svg` | Lucide icon "file-symlink" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-symlink.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-text.svg` | Lucide icon "file-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/file-x.svg` | Lucide icon "file-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/files.svg` | Lucide icon "files" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/files.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/film.svg` | Lucide icon "film" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/film.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/filter.svg` | Lucide icon "filter" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/filter.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/folder-open.svg` | Lucide icon "folder-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/folder-plus.svg` | Lucide icon "folder-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/folder.svg` | Lucide icon "folder" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/gauge.svg` | Lucide icon "gauge" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/gauge.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/git-compare.svg` | Lucide icon "git-compare" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/git-compare.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/globe.svg` | Lucide icon "globe" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/globe.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/grid-3x3.svg` | Lucide icon "grid-3x3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/grid-3x3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/hand.svg` | Lucide icon "hand" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hand.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/hash.svg` | Lucide icon "hash" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hash.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/heading.svg` | Lucide icon "heading" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/heading.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/highlighter.svg` | Lucide icon "highlighter" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/highlighter.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/house.svg` | Lucide icon "house" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/house.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/image-plus.svg` | Lucide icon "image-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/image.svg` | Lucide icon "image" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/info.svg` | Lucide icon "info" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/info.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/italic.svg` | Lucide icon "italic" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/italic.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/languages.svg` | Lucide icon "languages" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/languages.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/lasso.svg` | Lucide icon "lasso" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lasso.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/layers.svg` | Lucide icon "layers" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layers.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/layout-grid.svg` | Lucide icon "layout-grid" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layout-grid.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/link-2.svg` | Lucide icon "link-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/link.svg` | Lucide icon "link" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/list-checks.svg` | Lucide icon "list-checks" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-checks.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/list-ordered.svg` | Lucide icon "list-ordered" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-ordered.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/list.svg` | Lucide icon "list" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/lock-open.svg` | Lucide icon "lock-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/lock.svg` | Lucide icon "lock" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/maximize-2.svg` | Lucide icon "maximize-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/maximize.svg` | Lucide icon "maximize" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-circle-reply.svg` | Lucide icon "message-circle-reply" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-circle-reply.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square-text.svg` | Lucide icon "message-square-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square.svg` | Lucide icon "message-square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/messages-square.svg` | Lucide icon "messages-square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/messages-square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/mic.svg` | Lucide icon "mic" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mic.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/minimize.svg` | Lucide icon "minimize" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/minimize-2.svg` | Lucide icon "minimize-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/minus.svg` | Lucide icon "minus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/moon.svg` | Lucide icon "moon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/moon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/mouse-pointer-2.svg` | Lucide icon "mouse-pointer-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mouse-pointer-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/move.svg` | Lucide icon "move" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/navigation.svg` | Lucide icon "navigation" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/navigation.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/paint-bucket.svg` | Lucide icon "paint-bucket" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paint-bucket.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/palette.svg` | Lucide icon "palette" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/palette.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/panel-left.svg` | Lucide icon "panel-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/panel-right.svg` | Lucide icon "panel-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/panels-top-left.svg` | Lucide icon "panels-top-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panels-top-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/paperclip.svg` | Lucide icon "paperclip" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paperclip.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pen-line.svg` | Lucide icon "pen-line" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-line.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pen-tool.svg` | Lucide icon "pen-tool" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-tool.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pencil.svg` | Lucide icon "pencil" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pencil.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pipette.svg` | Lucide icon "pipette" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pipette.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/plus.svg` | Lucide icon "plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/presentation.svg` | Lucide icon "presentation" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/presentation.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/printer.svg` | Lucide icon "printer" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/printer.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/rectangle-horizontal.svg` | Lucide icon "rectangle-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rectangle-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/redo-2.svg` | Lucide icon "redo-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/redo-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/replace.svg` | Lucide icon "replace" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/replace.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/rotate-ccw.svg` | Lucide icon "rotate-ccw" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-ccw.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/rotate-cw.svg` | Lucide icon "rotate-cw" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-cw.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/ruler.svg` | Lucide icon "ruler" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ruler.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/save.svg` | Lucide icon "save" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/save.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/scan-text.svg` | Lucide icon "scan-text" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan-text.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/scan.svg` | Lucide icon "scan" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/scissors.svg` | Lucide icon "scissors" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scissors.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/search.svg` | Lucide icon "search" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/search.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/send.svg` | Lucide icon "send" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/send.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/settings-2.svg` | Lucide icon "settings-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/settings.svg` | Lucide icon "settings" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/share-2.svg` | Lucide icon "share-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/share-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/shield-check.svg` | Lucide icon "shield-check" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/shield-check.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/signature.svg` | Lucide icon "signature" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/signature.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/sliders-horizontal.svg` | Lucide icon "sliders-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sliders-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/sparkles.svg` | Lucide icon "sparkles" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sparkles.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/spline.svg` | Lucide icon "spline" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/spline.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/square-dashed-mouse-pointer.svg` | Lucide icon "square-dashed-mouse-pointer" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed-mouse-pointer.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/square-dashed.svg` | Lucide icon "square-dashed" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/square.svg` | Lucide icon "square" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/squares-subtract.svg` | Lucide icon "squares-subtract" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/squares-subtract.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/stamp.svg` | Lucide icon "stamp" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/stamp.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/star.svg` | Lucide icon "star" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/star.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/sticky-note.svg` | Lucide icon "sticky-note" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sticky-note.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/strikethrough.svg` | Lucide icon "strikethrough" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/strikethrough.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/sun.svg` | Lucide icon "sun" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sun.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/swatch-book.svg` | Lucide icon "swatch-book" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/swatch-book.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/tag.svg` | Lucide icon "tag" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/tag.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/text-cursor-input.svg` | Lucide icon "text-cursor-input" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-cursor-input.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/text-select.svg` | Lucide icon "text-select" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-select.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/trash-2.svg` | Lucide icon "trash-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/trash.svg` | Lucide icon "trash" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/triangle-alert.svg` | Lucide icon "triangle-alert" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/triangle-alert.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/type.svg` | Lucide icon "type" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/type.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/underline.svg` | Lucide icon "underline" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/underline.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/undo-2.svg` | Lucide icon "undo-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/undo-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/user-round.svg` | Lucide icon "user-round" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/user-round.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/wand-sparkles.svg` | Lucide icon "wand-sparkles" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wand-sparkles.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/wrench.svg` | Lucide icon "wrench" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wrench.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/x.svg` | Lucide icon "x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/zoom-in.svg` | Lucide icon "zoom-in" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-in.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/zoom-out.svg` | Lucide icon "zoom-out" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-out.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/fonts/Inter-Regular.ttf` | Inter Regular | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels; slanted fallback glyphs in Fill & Sign typed signatures for characters Dancing Script lacks (Cyrillic, Greek), drawn as filled paths |
| `assets/fonts/Inter-Medium.ttf` | Inter Medium | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels |
| `assets/fonts/Inter-SemiBold.ttf` | Inter SemiBold | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/rsms/inter | UI font, embedded in the app; demo PDF labels |
@@ -240,22 +240,22 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `docs/brand/artcraft-mark-black.svg` | ArtCraft brand: artcraft-mark-black | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-mark.png` | ArtCraft brand: artcraft-mark | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-mark.svg` | ArtCraft brand: artcraft-mark | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `assets/app-icon/pdfcraft.svg` | PdfCraft app icon (master vector) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Master artwork; every other app-icon file is rendered from it (packaging/icons.sh) |
| `assets/app-icon/pdfcraft-small.svg` | PdfCraft app icon (light vector) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | README header and assets/app-icon/README.md |
| `assets/app-icon/pdfcraft-1024.png` | PdfCraft app icon, 1024 px on Apple's icon grid | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Runtime window/Dock icon on macOS (apps/pdfcraft/src/main.rs) |
| `assets/app-icon/pdfcraft.icns` | PdfCraft app icon (macOS .icns) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | macOS app bundle icon |
| `assets/app-icon/pdfcraft.ico` | PdfCraft app icon (Windows .ico, 16-256 px) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Embedded in pdfcraft.exe by apps/pdfcraft/build.rs (Explorer, taskbar, Start menu, Alt-Tab) |
| `assets/app-icon/pdfcraft.svg` | PdfCraft app icon (master vector) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Master artwork; every other app-icon file is rendered from it (packaging/icons.sh) |
| `assets/app-icon/pdfcraft-small.svg` | PdfCraft app icon (light vector) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | README header and assets/app-icon/README.md |
| `assets/app-icon/pdfcraft-1024.png` | PdfCraft app icon, 1024 px on Apple's icon grid | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Runtime window/Dock icon on macOS (apps/pdfcraft/src/main.rs) |
| `assets/app-icon/pdfcraft.icns` | PdfCraft app icon (macOS .icns) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | macOS app bundle icon |
| `assets/app-icon/pdfcraft.ico` | PdfCraft app icon (Windows .ico, 16-256 px) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Embedded in pdfcraft.exe by apps/pdfcraft/build.rs (Explorer, taskbar, Start menu, Alt-Tab) |
| `assets/app-icon/pdfcraft-document.svg` | PdfCraft PDF document icon (master vector) | Lucide Icons and Contributors (document outline); PdfCraft contributors (PDF badge, lettering and composition) | ISC AND (MIT OR Apache-2.0) | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg); contributor-original composition (assets/app-icon/README.md) | Master artwork for the Windows PDF file type icon; rendered by packaging/icons.sh, without fonts or external images |
| `assets/app-icon/pdfcraft-document.ico` | PdfCraft PDF document icon (Windows .ico, 16-256 px) | Lucide Icons and Contributors (document outline); PdfCraft contributors (PDF badge, lettering and composition) | ISC AND (MIT OR Apache-2.0) | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg); contributor-original composition (assets/app-icon/README.md) | Embedded in pdfcraft.exe as resource 2; the Windows installer uses it for PdfCraft.Document and Applications/pdfcraft.exe PDF file associations |
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 16 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 24 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 32 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 48 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 64 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 128 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 256 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop); runtime window icon on Windows and Linux (apps/pdfcraft/src/main.rs) |
| `assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 512 px | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg` | PdfCraft app icon (Linux scalable) | PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 16 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 24 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 32 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 48 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 64 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 128 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 256 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop); runtime window icon on Windows and Linux (apps/pdfcraft/src/main.rs) |
| `assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png` | PdfCraft app icon, 512 px | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg` | PdfCraft app icon (Linux scalable) | PdfCraft contributors (drawn by the project owner in ArtCraft) | MIT OR Apache-2.0 | Contributor-original (see assets/app-icon/README.md) | Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop) |
| `docs/images/pdfcraft-bookmarks.png` | PdfCraft screenshot: Bookmarks | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: navigation. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
| `docs/images/pdfcraft-dark.png` | PdfCraft screenshot: Dark theme | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: themes. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
| `docs/images/pdfcraft-find.png` | PdfCraft screenshot: Find text | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: search. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
@@ -269,31 +269,31 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `docs/images/pdfcraft-tools.png` | PdfCraft screenshot: All tools | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: tool catalogue. Shows PdfCraft UI, Lucide icons, the showcase PDF built from the [[fetched]] OFL fonts, and the ArtCraft mark (Storyteller trademark from docs/brand/, not covered by this licence) |
| `docs/images/pdfcraft-twoup.png` | PdfCraft screenshot: Two-up reading | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: layouts. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
| `docs/images/pdfcraft-viewer.png` | PdfCraft screenshot: Viewer with comments | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README hero. Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts |
| `assets/icons/pin.svg` | Lucide icon "pin" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pin.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/move-right.svg` | Lucide icon "move-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/reply.svg` | Lucide icon "reply" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/reply.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/thumbs-up.svg` | Lucide icon "thumbs-up" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-up.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/thumbs-down.svg` | Lucide icon "thumbs-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square-plus.svg` | Lucide icon "message-square-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-x.svg` | Lucide icon "circle-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pin.svg` | Lucide icon "pin" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pin.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/move-right.svg` | Lucide icon "move-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/reply.svg` | Lucide icon "reply" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/reply.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/thumbs-up.svg` | Lucide icon "thumbs-up" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-up.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/thumbs-down.svg` | Lucide icon "thumbs-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square-plus.svg` | Lucide icon "message-square-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-x.svg` | Lucide icon "circle-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `crates/ui-egui/tests/data/form.pdf` | One-page AcroForm test fixture (text, check box, radio group, combo box) | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects, no third-party content | Form-filling UI tests (crates/ui-egui/tests/it/forms.rs, commands.rs) |
| `assets/icons/pentagon.svg` | Lucide icon "pentagon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square-quote.svg` | Lucide icon "message-square-quote" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pentagon.svg` | Lucide icon "pentagon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square-quote.svg` | Lucide icon "message-square-quote" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): a signature PdfCraft did not make |
| `assets/icons/clipboard-paste.svg` | Lucide icon "clipboard-paste" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/clipboard-paste.svg` | Lucide icon "clipboard-paste" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/fonts/DancingScript.ttf` | Dancing Script (variable) | The Dancing Script Project Authors (Pablo Impallari) | OFL-1.1 | https://github.com/google/fonts/blob/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/dancingscript/DancingScript%5Bwght%5D.ttf | Fill & Sign typed signatures and initials: the typed name's glyph outlines, drawn as filled paths (the font file itself is not embedded in PDFs) |
| `packaging/macos/dmg/background.svg` | PdfCraft macOS DMG window background | @XusBadia | MIT OR Apache-2.0 | Original work for this repository, derived from the PdfCraft app icon (assets/app-icon/pdfcraft.svg, referenced via an SVG image element, not copied) | Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py |
| `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile |
| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format |
| `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file |
| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs |
| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs |
+204 -204
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -4,7 +4,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
## Start every session here
1. Read `plan/STATUS.md`: the current milestone, the next unchecked task and any blockers. `ROADMAP.md` is the one-page summary (stage, numbers, progress log) and `docs/roadmap.md` the milestone table; at the end of every session update the milestone row and add a line to the ROADMAP.md progress log.
- Read `docs/gaps.md` (the ranked gaps: Acrobat interop, the open-issue backlog, the renderer and a fidelity harness, editing existing content, Pro workflows, 1.0 polish) and `docs/target-app-parity.md` (the numbers and how they are measured) before choosing work. Prefer the gaps over new P2/P3 features, and keep both documents true when things change (`docs/` progress docs follow craftrules `standards/progress-docs.md`: status line, revision history).
- Read `docs/gaps.md` (the ranked gaps: Acrobat interop, the open-issue backlog, the renderer and a fidelity harness, editing existing content, Pro workflows, 1.0 polish) and `docs/target-app-parity.md` (the numbers and how they are measured) before choosing work. Prefer the gaps over new P2/P3 features, and keep both documents true when things change (`docs/` progress docs carry a status line and a revision history).
- "Shipped" in `parity/` means "exists and tested", not "as good as Acrobat". Don't mark a feature shipped on a generic test, and say in its notes what is still missing.
2. Read that task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README of the crate you're touching.
3. Follow the **autonomous operation protocol** in `plan/execution-plan.md` §7 (orient → plan → implement + test → verify → record → commit). Don't stop to ask unless §7 lists the decision as the user's.
@@ -13,7 +13,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
## Non-negotiables
- **Assets: read `AGENTS.md` §1 before adding or showing any icon, image, font or document.** No assets from Adobe products, ever. Only openly licensed or contributor-original assets are allowed, each with an entry in `ATTRIBUTION.toml`. `cargo xtask assets` enforces this. `AGENTS.md` overrides this file.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts).** Never commit font files here (`AGENTS.md` §1.4; team members: [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md), internal). It is the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts && CRAFT_FONTS_DIR=../craft-fonts cargo test --workspace` embeds the Japanese fonts (UI fallback, Japanese text in edited PDFs) and runs their tests, which otherwise skip. Code using `pdfcraft_fonts::CRAFT_FONTS` must work when it is empty.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts).** Never commit font files here (`AGENTS.md` §1.4). It is the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts && CRAFT_FONTS_DIR=../craft-fonts cargo test --workspace` embeds the Japanese fonts (UI fallback, Japanese text in edited PDFs) and runs their tests, which otherwise skip. Code using `pdfcraft_fonts::CRAFT_FONTS` must work when it is empty.
- **Clean-room.**
- Never read, disassemble or copy anything inside the Acrobat bundle (names and listings only). **Never open `Contents/Resources/JavaScripts/`.**
- Behaviour comes from public docs, specs (ISO 32000-2, the Arlington model) and black-box observation (`plan/acrobat/`).
@@ -32,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.
Generated
+52 -46
View File
@@ -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=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
[[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=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
[[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=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
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=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
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=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
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=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
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=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
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=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -1279,7 +1280,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"chrono",
"dunce",
@@ -4207,7 +4208,7 @@ dependencies = [
[[package]]
name = "pdfcraft"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"chrono",
"craft-update",
@@ -4231,7 +4232,7 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -4243,7 +4244,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -4254,7 +4255,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -4265,7 +4266,7 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"base64 0.23.1",
"flate2",
@@ -4287,7 +4288,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cli"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"base64 0.23.1",
"pdfcraft-audit",
@@ -4305,11 +4306,11 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
version = "0.15.0"
version = "0.17.0"
[[package]]
name = "pdfcraft-content"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -4317,7 +4318,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cos"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"chrono",
"hayro-syntax",
@@ -4332,7 +4333,7 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"encoding_rs",
"hayro-ccitt",
@@ -4352,7 +4353,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"aes",
"md-5",
@@ -4364,7 +4365,7 @@ dependencies = [
[[package]]
name = "pdfcraft-edit"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"hayro-syntax",
"kurbo 0.13.1",
@@ -4373,6 +4374,8 @@ dependencies = [
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-model",
"pdfcraft-render",
"proptest",
"sha2 0.11.1",
"thiserror 2.0.21",
"unicode-bidi",
@@ -4380,7 +4383,7 @@ dependencies = [
[[package]]
name = "pdfcraft-engine"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"chrono",
"craft-docpath",
@@ -4392,6 +4395,7 @@ dependencies = [
"pdfcraft-annot",
"pdfcraft-audit",
"pdfcraft-compare",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-create",
"pdfcraft-edit",
@@ -4422,14 +4426,14 @@ dependencies = [
[[package]]
name = "pdfcraft-export"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"fax",
"flate2",
@@ -4442,7 +4446,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -4452,7 +4456,7 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4465,11 +4469,11 @@ dependencies = [
[[package]]
name = "pdfcraft-geom"
version = "0.15.0"
version = "0.17.0"
[[package]]
name = "pdfcraft-js"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"boa_engine",
"chrono",
@@ -4477,7 +4481,7 @@ dependencies = [
[[package]]
name = "pdfcraft-measure"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4490,14 +4494,14 @@ dependencies = [
[[package]]
name = "pdfcraft-model"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"ocrs",
"pdfcraft-fonts",
@@ -4508,7 +4512,7 @@ dependencies = [
[[package]]
name = "pdfcraft-optimize"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"image",
"pdfcraft-annot",
@@ -4524,11 +4528,11 @@ dependencies = [
[[package]]
name = "pdfcraft-oracle"
version = "0.15.0"
version = "0.17.0"
[[package]]
name = "pdfcraft-organize"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4539,7 +4543,7 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"async-channel",
"futures-lite",
@@ -4549,7 +4553,7 @@ dependencies = [
[[package]]
name = "pdfcraft-preflight"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"flate2",
"pdfcraft-content",
@@ -4560,7 +4564,7 @@ dependencies = [
[[package]]
name = "pdfcraft-print"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"craft-print",
"pdfcraft-content",
@@ -4572,7 +4576,7 @@ dependencies = [
[[package]]
name = "pdfcraft-redact"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4589,7 +4593,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"bytemuck",
"flate2",
@@ -4608,7 +4612,7 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"aes",
"aws-lc-rs",
@@ -4644,11 +4648,11 @@ dependencies = [
[[package]]
name = "pdfcraft-testkit"
version = "0.15.0"
version = "0.17.0"
[[package]]
name = "pdfcraft-ui-egui"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"arboard",
"base64 0.23.1",
@@ -4672,6 +4676,7 @@ dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-engine",
"pdfcraft-fonts",
@@ -4682,6 +4687,7 @@ dependencies = [
"plist",
"png 0.18.1",
"pollster 1.0.1",
"proptest",
"rfd",
"rustix 1.1.5",
"serde",
@@ -4700,7 +4706,7 @@ dependencies = [
[[package]]
name = "pdfcraft-web"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"eframe",
"egui",
@@ -4712,7 +4718,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"base64 0.23.1",
"image",
@@ -4726,7 +4732,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfdf"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
@@ -6765,7 +6771,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.61.2",
+41 -41
View File
@@ -5,7 +5,7 @@ exclude = ["vendor"]
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
[workspace.package]
version = "0.15.0"
version = "0.17.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -13,52 +13,52 @@ repository = "https://github.com/storytold/pdfcraft"
[workspace.dependencies]
# Shared frontend components; engines remain independent of egui.
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", features = ["serde"] }
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", 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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
# 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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", default-features = false }
# Shared system spooler; document composition remains in pdfcraft-print.
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
# 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-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
# 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 = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
# internal
pdfcraft-geom = { path = "crates/geom", version = "0.15.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.15.0" }
pdfcraft-cos = { path = "crates/cos", version = "0.15.0" }
pdfcraft-crypt = { path = "crates/crypt", version = "0.15.0" }
pdfcraft-organize = { path = "crates/organize", version = "0.15.0" }
pdfcraft-measure = { path = "crates/measure", version = "0.15.0" }
pdfcraft-annot = { path = "crates/annot", version = "0.15.0" }
pdfcraft-audit = { path = "crates/audit", version = "0.15.0" }
pdfcraft-fonts = { path = "crates/fonts", version = "0.15.0" }
pdfcraft-model = { path = "crates/model", version = "0.15.0" }
pdfcraft-content = { path = "crates/content", version = "0.15.0" }
pdfcraft-redact = { path = "crates/redact", version = "0.15.0" }
pdfcraft-print = { path = "crates/print", version = "0.15.0" }
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.15.0" }
pdfcraft-sign = { path = "crates/sign", version = "0.15.0" }
pdfcraft-optimize = { path = "crates/optimize", version = "0.15.0" }
pdfcraft-a11y = { path = "crates/a11y", version = "0.15.0" }
pdfcraft-oracle = { path = "crates/oracle", version = "0.15.0" }
pdfcraft-testkit = { path = "crates/testkit", version = "0.15.0" }
pdfcraft-ocr = { path = "crates/ocr", version = "0.15.0" }
pdfcraft-js = { path = "crates/js", version = "0.15.0" }
pdfcraft-compare = { path = "crates/compare", version = "0.15.0" }
pdfcraft-preflight = { path = "crates/preflight", version = "0.15.0" }
pdfcraft-export = { path = "crates/export", version = "0.15.0" }
pdfcraft-edit = { path = "crates/edit", version = "0.15.0" }
pdfcraft-create = { path = "crates/create", version = "0.15.0" }
pdfcraft-forms = { path = "crates/forms", version = "0.15.0" }
pdfcraft-xfa = { path = "crates/xfa", version = "0.15.0" }
pdfcraft-render = { path = "crates/render", version = "0.15.0" }
pdfcraft-engine = { path = "crates/engine", version = "0.15.0" }
pdfcraft-platform = { path = "crates/platform", version = "0.15.0" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.15.0" }
pdfcraft-automation = { path = "crates/automation", version = "0.15.0", default-features = false }
pdfcraft-geom = { path = "crates/geom", version = "0.17.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.17.0" }
pdfcraft-cos = { path = "crates/cos", version = "0.17.0" }
pdfcraft-crypt = { path = "crates/crypt", version = "0.17.0" }
pdfcraft-organize = { path = "crates/organize", version = "0.17.0" }
pdfcraft-measure = { path = "crates/measure", version = "0.17.0" }
pdfcraft-annot = { path = "crates/annot", version = "0.17.0" }
pdfcraft-audit = { path = "crates/audit", version = "0.17.0" }
pdfcraft-fonts = { path = "crates/fonts", version = "0.17.0" }
pdfcraft-model = { path = "crates/model", version = "0.17.0" }
pdfcraft-content = { path = "crates/content", version = "0.17.0" }
pdfcraft-redact = { path = "crates/redact", version = "0.17.0" }
pdfcraft-print = { path = "crates/print", version = "0.17.0" }
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.17.0" }
pdfcraft-sign = { path = "crates/sign", version = "0.17.0" }
pdfcraft-optimize = { path = "crates/optimize", version = "0.17.0" }
pdfcraft-a11y = { path = "crates/a11y", version = "0.17.0" }
pdfcraft-oracle = { path = "crates/oracle", version = "0.17.0" }
pdfcraft-testkit = { path = "crates/testkit", version = "0.17.0" }
pdfcraft-ocr = { path = "crates/ocr", version = "0.17.0" }
pdfcraft-js = { path = "crates/js", version = "0.17.0" }
pdfcraft-compare = { path = "crates/compare", version = "0.17.0" }
pdfcraft-preflight = { path = "crates/preflight", version = "0.17.0" }
pdfcraft-export = { path = "crates/export", version = "0.17.0" }
pdfcraft-edit = { path = "crates/edit", version = "0.17.0" }
pdfcraft-create = { path = "crates/create", version = "0.17.0" }
pdfcraft-forms = { path = "crates/forms", version = "0.17.0" }
pdfcraft-xfa = { path = "crates/xfa", version = "0.17.0" }
pdfcraft-render = { path = "crates/render", version = "0.17.0" }
pdfcraft-engine = { path = "crates/engine", version = "0.17.0" }
pdfcraft-platform = { path = "crates/platform", version = "0.17.0" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.17.0" }
pdfcraft-automation = { path = "crates/automation", version = "0.17.0", default-features = false }
# pdf
hayro = "0.7.1"
+159
View File
@@ -0,0 +1,159 @@
# Stage 1 source documents only. Destinations are logical names, not packaging instructions.
# licences lists audited full-text identifiers; omission grants no bundle applicability.
[[document]]
source = "LICENSE-APACHE"
licences = ["Apache-2.0"]
destination = "LICENSE-APACHE"
sha256 = "0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594"
[[document]]
source = "LICENSE-MIT"
licences = ["MIT"]
destination = "LICENSE-MIT"
sha256 = "cd7032ea33b71427f188e34ffb4aee9d6868a57fd196037c5c8b2be03a2a0352"
[[document]]
source = "NOTICE"
destination = "NOTICE"
sha256 = "9f854f730719af962cd840529324d089e05e68a30bd245c50e33f96e009b4314"
[[document]]
source = "assets/app-icon/LICENSE-document.txt"
destination = "assets/app-icon/LICENSE-document.txt"
sha256 = "64a3137e3d5cc41e5b78d2ad8dd9ddddd6588283f9f7b3128aa6a8a50852fb9a"
[[document]]
source = "assets/app-icon/LICENSE.txt"
destination = "assets/app-icon/LICENSE.txt"
sha256 = "b768acafe6cbad8247eedf86b5b5381feefcd4ee0f8c7808b86811bc687cf37b"
[[document]]
source = "assets/fonts/OFL-DancingScript.txt"
destination = "assets/fonts/OFL-DancingScript.txt"
sha256 = "6f090277c00af96651ce6dbcc38ff1591047a3bffef486e80b6a32e8276a8201"
[[document]]
source = "assets/fonts/OFL-Inter.txt"
destination = "assets/fonts/OFL-Inter.txt"
sha256 = "262481e844521b326f5ecd053e59b98c8b2da78c8ee1bdbb6e8174305e54935a"
[[document]]
source = "assets/fonts/OFL-JetBrainsMono.txt"
destination = "assets/fonts/OFL-JetBrainsMono.txt"
sha256 = "a76abf002c49097d146e86740a3105a5d00450b1592e820a1109a8c5680cd697"
[[document]]
source = "assets/icons/LICENSE-lucide.txt"
destination = "assets/icons/LICENSE-lucide.txt"
sha256 = "b495047bd93a9b06913511076f504daba17d5bbeb3e0650f3bb53a4220329c57"
[[document]]
source = "docs/brand/LICENSE-brand.txt"
destination = "docs/brand/LICENSE-brand.txt"
sha256 = "0f33f48e7a96cd359d4e4e5b11424b13cebeb59cc70c77928186695e4173585c"
[[document]]
source = "licenses/CMap-LICENSE.txt"
licences = ["BSD-3-Clause"]
destination = "licenses/CMap-LICENSE.txt"
sha256 = "742665db9c8e1bc72603c6d319ca3e90b83bd6d95202f0cd4ef11068a07a9c29"
origin = "https://static.crates.io/crates/hayro-cmap/hayro-cmap-0.1.0.crate#assets/LICENSE.txt"
[[document]]
source = "licenses/egui-Hack-Regular.txt"
licences = ["MIT", "Bitstream-Vera"]
destination = "licenses/egui-Hack-Regular.txt"
sha256 = "47c0cccbeec7e8614548cc485588b28149e7874188df5f41b36efebcee285c87"
origin = "https://raw.githubusercontent.com/emilk/egui/0.36.2/crates/epaint_default_fonts/fonts/Hack-Regular.txt"
[[document]]
source = "licenses/egui-OFL.txt"
licences = ["OFL-1.1"]
destination = "licenses/egui-OFL.txt"
sha256 = "6a73f9541c2de74158c0e7cf6b0a58ef774f5a780bf191f2d7ec9cc53efe2bf2"
origin = "https://raw.githubusercontent.com/emilk/egui/0.36.2/crates/epaint_default_fonts/fonts/OFL.txt"
[[document]]
source = "licenses/egui-UFL.txt"
licences = ["Ubuntu-font-1.0"]
destination = "licenses/egui-UFL.txt"
sha256 = "2f0015108d68627bd788d313f529c21ff4da2c2c42a5e1f3883acc83480f9002"
origin = "https://raw.githubusercontent.com/emilk/egui/0.36.2/crates/epaint_default_fonts/fonts/UFL.txt"
[[document]]
source = "licenses/egui-emoji-icon-font-mit-license.txt"
licences = ["MIT"]
destination = "licenses/egui-emoji-icon-font-mit-license.txt"
sha256 = "b9d2c1d909aa149996fd4c91dcb92b2362a04431640c1d200959da94caf8cde1"
origin = "https://raw.githubusercontent.com/emilk/egui/0.36.2/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt"
[[document]]
source = "vendor/hayro-interpret/LICENSE-APACHE"
licences = ["Apache-2.0"]
destination = "vendor/hayro-interpret/LICENSE-APACHE"
sha256 = "0cec06e0e55fbc3dc5cee4fca9b607f66cb8f4e4dbcf3b3c013594dd156732e9"
[[document]]
source = "vendor/hayro-interpret/LICENSE-MIT"
licences = ["MIT"]
destination = "vendor/hayro-interpret/LICENSE-MIT"
sha256 = "51b50e6120c344dcc146bbd6375ca1c78e079d97c8bea76e67a53d6ff0efebac"
[[document]]
source = "vendor/hayro-interpret/assets/CGATS_LICENSE.txt"
destination = "vendor/hayro-interpret/assets/CGATS_LICENSE.txt"
sha256 = "469d87b5fa48f8aaf69e25f4fcac3a309c97dbb7d561b917638abdb640e004d9"
[[document]]
source = "vendor/hayro-interpret/assets/LICENSE_FOXIT"
destination = "vendor/hayro-interpret/assets/LICENSE_FOXIT"
sha256 = "b578cdd2345840ada550bd12519533812320d5f1d21cf4c1c7e1b1b0a31c98b7"
[[document]]
source = "vendor/hayro-cmap/assets/LICENSE.txt"
licences = ["BSD-3-Clause"]
destination = "vendor/hayro-cmap/assets/LICENSE.txt"
sha256 = "742665db9c8e1bc72603c6d319ca3e90b83bd6d95202f0cd4ef11068a07a9c29"
[[document]]
source = "vendor/vello_common/LICENSE-MIT"
licences = ["MIT"]
destination = "vendor/vello_common/LICENSE-MIT"
sha256 = "adf157330f722fe7cfd5964a2a6974eff851b718228c6735724a11706e6f4dec"
[[bundled]]
crate = "epaint_default_fonts"
path = "fonts/Ubuntu-Light.ttf"
licence = "Ubuntu-font-1.0"
documents = ["licenses/egui-UFL.txt"]
[[bundled]]
crate = "epaint_default_fonts"
path = "fonts/Hack-Regular.ttf"
licence = "MIT AND Bitstream-Vera"
documents = ["licenses/egui-Hack-Regular.txt"]
[[bundled]]
crate = "epaint_default_fonts"
path = "fonts/NotoEmoji-Regular.ttf"
licence = "OFL-1.1"
documents = ["licenses/egui-OFL.txt"]
[[bundled]]
crate = "epaint_default_fonts"
path = "fonts/emoji-icon-font.ttf"
licence = "MIT"
documents = ["licenses/egui-emoji-icon-font-mit-license.txt"]
[[bundled]]
crate = "epaint_default_fonts"
path = "fonts/egui-icons.ttf"
licence = "MIT"
documents = ["licenses/egui-emoji-icon-font-mit-license.txt"]
[[bundled]]
crate = "hayro-interpret"
path = "src/font/generated/metrics.rs"
licence = "MIT OR Apache-2.0"
documents = ["vendor/hayro-interpret/LICENSE-MIT", "vendor/hayro-interpret/LICENSE-APACHE"]
+1 -1
View File
@@ -12,7 +12,7 @@ Third-party material
This product bundles third-party material under its own licenses, listed below. The per-file
list (author, source, license) is in ATTRIBUTION.md.
- Lucide icons (assets/icons/*.svg) — ISC License. See assets/icons/LICENSE-lucide.txt.
- Lucide icons (assets/icons/*.svg) — ISC and MIT licences (Feather-derived icons). See assets/icons/LICENSE-lucide.txt.
The Windows PDF file icon (assets/app-icon/pdfcraft-document.svg and .ico) adapts Lucide's
file-text outline and combines it with contributor-original artwork. See
assets/app-icon/LICENSE-document.txt (ISC AND (MIT OR Apache-2.0)), packaged as
+12 -1
View File
@@ -140,6 +140,8 @@ Search the whole document as you type, step through matches with <kbd>⌘G</kbd>
## Navigate long documents
Launch with `pdfcraft --two-paged file.pdf` (or `pdfcraft file.pdf --two-paged`) to open each command-line PDF in noncontinuous two-page view. This optional launch override does not change saved default-layout preferences or PDFs restored from the previous session. An explicit `--layout` option takes precedence on the active document regardless of argument order; `--default-layout` retains its existing behavior for subsequently opened documents.
Right-click a PDF tab to close it, reopen the last closed file-backed tab, or reveal its file. Closing still asks about unsaved changes. Reopening reads the saved file from disk; it does not restore discarded edits. Reveal selects the file in Explorer or Finder. On Linux and FreeBSD it requests `org.freedesktop.FileManager1.ShowItems`, falling back to opening the containing directory with `xdg-open` when that service is unavailable. Reopen and Reveal are unavailable on the web; Reveal is also unavailable for pathless documents.
Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state.
@@ -545,9 +547,18 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | **Reading, organizing and protecting PDFs · you are here** | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
+56 -6
View File
@@ -1,5 +1,20 @@
# PdfCraft roadmap
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged)
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 latest comments: explicit image-paste events, mixed-size field refresh, and documented mixed-script normalization with Undo; estimates unchanged)
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 review follow-up: preserve image crop/tag/stream ownership, resize in page orientation, harden clipboard and text formatting, and reload installed fonts; estimates unchanged)
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: GBK rasters compared with explicit Unicode and independently selected installed faces; wrong-glyph and wrong-family negative controls fail; estimates unchanged)
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: integrate main fa5f9f8c; retain CJK viewer and upstream raster regressions, restore explicit display_config imports; estimates unchanged)
> **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** patch (M2, PR #4172: integrate main 5af10276; retain both roadmap histories and viewer-only CJK font policy; estimates unchanged)
> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M2, PR #4172: integrate main 1dd2737b; preserve viewer-only CJK fallback and regression coverage across engine, renderer and Combine module splits; estimates unchanged)
> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M2, PR #4172: integrate main 9a9813bf and preserve viewer-only CJK fallback through the engine module split; estimates unchanged)
> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: document installed-font resolution helpers and regression contracts; behavior and estimates unchanged)
> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: print preview excludes installed CJK fonts and isolates its font cache from the viewer; estimates unchanged)
> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: reconcile current main while retaining display-only CJK fallback and upstream Japanese font selection; estimates unchanged)
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M2, PR #4172: extend on-screen CJK fallback to Combine thumbnails and figure previews; recognise FangSong and subset-prefixed Founder names; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (typed small Edit font sizes and Fill & Sign text/date colour, #2444/#2496; estimates unchanged)
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
@@ -204,9 +219,30 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **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: PR #2155 further review preserves inherited Form text state and soft-mask chronology during Arrange, composes crop-group moves in the proper scope, and uses affine/press-relative image resize plus a shared rotation pivot. No-op script buttons preserve hidden source text; paste coordinates, visible selection ownership, and Snapshot/Export permissions have dedicated regressions. Candidate only; estimates unchanged.
- 2026-11-01: PR #2155 latest follow-up handles explicit image-paste events without release-time guesses, refreshes the font-size field when a selection becomes mixed, and specifies mixed-script normalization with exact Undo restoration. Candidate only; estimates unchanged.
- 2026-11-01: PR #2155 review follow-up preserves editor crops through group moves and keeps arranged artwork outside added-item streams; unsafe nested-tag extraction is refused. Image handles and flips follow page orientation, added images accept general transforms, and clipboard/text/Home/font-discovery edge cases have focused regressions. Candidate only; estimates unchanged.
- 2026-11-01: PR #2155 maintainer edits remove redundant image/text helpers and add regressions for crop ownership, arrange scope/state fidelity, clipboard precedence, crop-drag bounds, and font scanning. Transform and Unicode-script tools complete the headless paths.
- **2026-10-18 (M3, #1354, local candidate):** Integrated --two-paged with current main, retaining upstream modal/picker ownership and single-tab reuse. Native launch/opening/password regressions, negative checks against the old wrapper, visual inspection, formatting, strict scoped Clippy, layers, assets, parity and scoped WASM compilation pass. Three existing socket tests are unavailable in this workspace; one existing network test remains ignored. No push or GitHub merge; estimates unchanged.
- **2026-10-31 (M2, PR #4172 review):** Added same-machine pixel oracles for GBK Song, Hei, Kai and FangSong, including all four subset-prefixed Founder aliases. Reference PDFs use explicit Unicode mappings and independently selected installed faces; font data and rasters remain in memory. All eight cases ran; wrong-glyph and wrong-family production mutations each failed the new assertion and were restored. Validation: 422 renderer unit tests, renderer all-target Clippy with -D warnings, formatting, layers/assets/parity and renderer WASM checks passed; existing optional-patch/WASM warnings and parity notes remain. Estimates unchanged.
- **2026-10-31 (M2, PR #4172):** Integrated main fa5f9f8c, retaining both roadmap histories and raster regression groups. Restored display_config and thumbnail_config imports after the upstream explicit-import migration. Validation: 422 renderer unit tests, engine/UI all-target checks, scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Existing optional-patch/WASM warnings and parity notes remain; no full engine/UI runtime test rerun. Display-only font policy and estimates unchanged.
- **2026-10-28 (M2, PR #4172):** Integrated main 5af10276, preserving both roadmap histories and reviewing auto-merged export tests and accessibility UI changes. Validation: 488 scoped tests passed (18 engine, 412 renderer unit, 43 renderer integration, 15 Combine; one existing renderer integration test ignored); scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Viewer-only CJK fallback and print/output font policy remain unchanged; estimates unchanged.
- **2026-10-27 (M2, PR #4172 module-split conflicts):** Integrated main 1dd2737b, retaining viewer-only CJK fallback in split session modules and Combine thumbnail workers, and moving renderer regressions to the new test module. Validation: 798 scoped tests passed (371 engine, 412 renderer, 15 Combine; two existing engine tests ignored); scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Estimates unchanged.
- **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence.
- **2026-10-26 (M2, PR #4172):** Reconciled current main, preserving its Japanese serif/weight selection and both branches' progress records. Installed CJK viewer and preview fallback remains display-only; estimates unchanged.
- **2026-10-29 (M7, #2517):** Widening a paragraph rejoins a wrap with the number of spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split. The count survives an incremental save and a full rewrite, and a width-only `text_edit` uses it. Estimates unchanged.
- **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged.
- **2026-10-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-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.
@@ -238,6 +274,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **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.
@@ -357,6 +395,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.
@@ -529,10 +569,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%).
@@ -702,6 +742,7 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
| 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. |
@@ -740,11 +781,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. |
@@ -767,9 +808,18 @@ Newest first: the date, what moved, and the new overall estimate.
- **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.
+21 -29
View File
@@ -1,6 +1,8 @@
//! PdfCraft desktop app.
//!
//! Usage: `pdfcraft [options] [files…]` (`-h` / `--help` prints a short version of this, [`USAGE`]).
//! `--two-paged` opens each command-line PDF in noncontinuous two-page view, without changing defaults.
//! An explicit `--layout` takes precedence for the active document, regardless of argument order.
//! `--create-images [images…]` stages the images in one PDF and asks for the page DPI.
//!
//! View options (applied after the files open; also the seed of the UI control channel):
@@ -8,7 +10,7 @@
//! --theme light|dark|system --language auto|<code> --mode all|read|edit|convert|sign --tool <catalogue id> --left open|closed
//! --organize on --fields on --dialog properties|shortcuts|about --palette <query> --home on
//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page|<percent> --always-default-view on|off
//! --page-gaps on|off --default-page-gaps on|off
//! --page-gaps on|off --default-page-gaps on|off --recent-files 0..50
//! --combine-view grid|list --combine-zoom 60..200`
//!
//! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files
@@ -40,6 +42,7 @@ mod motion;
mod reopen;
mod settings;
mod single_instance;
mod startup;
mod text_scale;
#[cfg(test)]
#[path = "windows_manifest.rs"]
@@ -63,6 +66,7 @@ Usage: pdfcraft [options] [files…]
Opens the files in the PdfCraft desktop app.
Options:
--two-paged open command-line PDFs in noncontinuous two-page view
--new-window open a window of its own instead of passing the files to a running PdfCraft
--create-images [images…] put the images in one PDF and ask for the page DPI
--control <file> enable the UI control channel (pdfcraft-cli ui --control <file> …)
@@ -218,28 +222,16 @@ fn main() -> eframe::Result {
let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("pdfcraft: {report}\n"));
}
}));
let mut files = Vec::new();
let mut options: Vec<(String, String)> = Vec::new();
let mut control_file: Option<String> = None;
let mut create_images = false;
let mut new_window = false;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--version" => return print_and_exit(&format!("pdfcraft {}", pdfcraft_engine::build_info::version())),
// Before any window or log file, so a scripted call returns (#4076).
"-h" | "--help" => return print_and_exit(USAGE),
"--control" => control_file = args.next(),
"--create-images" => create_images = true,
"--new-window" => new_window = true,
flag if flag.starts_with("--") => {
let value = args.next().unwrap_or_default();
options.push((flag.trim_start_matches("--").to_string(), value));
}
_ => files.push(path_from_arg(a)),
}
let mut startup = startup::Arguments::parse(std::env::args().skip(1));
if startup.version {
return print_and_exit(&format!("pdfcraft {}", pdfcraft_engine::build_info::version()));
}
let files = rejoin_split_paths(files, |p| std::path::Path::new(p).is_file());
if startup.help {
return print_and_exit(USAGE);
}
startup.files = rejoin_split_paths(startup.files, |p| std::path::Path::new(p).is_file());
let may_hand_off = startup.may_hand_off();
let startup::Arguments { files, options, control_file, create_images, two_paged, .. } = startup;
let integrated = cfg!(target_os = "macos");
// GNOME on Wayland leaves the window frame to the app (src/decorations.rs).
let window_controls = if cfg!(any(target_os = "macos", target_os = "windows")) { None } else { decorations::app_drawn() };
@@ -260,7 +252,6 @@ fn main() -> eframe::Result {
// Explorer double-click, a file manager or `xdg-open`) hands them to the PdfCraft already
// running and exits (#282, #317, #1726). Any option means the caller wants this launch's own
// window.
let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images;
// Absolute for this instance too: links relative to a document resolve against its folder,
// never the working directory.
let files = single_instance::absolute(&files);
@@ -270,7 +261,7 @@ fn main() -> eframe::Result {
single_instance::Claim::Alone => None,
};
let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref());
let launch = Launch { files, options, control_file, create_images, integrated, window_controls };
let launch = Launch { files, options, control_file, create_images, integrated, window_controls, two_paged };
// Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one
// that launched us as well as later ones. Lives until the event loop returns.
#[cfg(target_os = "macos")]
@@ -380,6 +371,7 @@ struct Launch {
integrated: bool,
/// The window buttons the app draws, when the desktop leaves the frame to it.
window_controls: Option<pdfcraft_ui_egui::window_controls::Layout>,
two_paged: bool,
}
/// Whether the window is a native Wayland one (not X11 or XWayland).
@@ -400,7 +392,7 @@ fn app_creator<'a>(
instance: Option<&'a single_instance::Server>,
#[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents,
) -> eframe::AppCreator<'a> {
let Launch { files, options, control_file, create_images, integrated, window_controls } = launch;
let Launch { files, options, control_file, create_images, integrated, window_controls, two_paged } = launch;
Box::new(move |cc| {
started.set(true);
let mut app = PdfCraftApp::new();
@@ -467,6 +459,7 @@ fn app_creator<'a>(
&[("folder", &w.folder.display().to_string()), ("error", &w.error)],
);
}
let defer_layout = two_paged && !files.is_empty() && !create_images;
if create_images {
if let Err(e) = app.begin_image_import_paths(&files) {
app.notify(e);
@@ -475,12 +468,11 @@ fn app_creator<'a>(
// With the preference on, last session's files come back first; files named on
// the command line open after them, in front (#442).
app.reopen_last_files(&files);
for f in files {
app.open_path(&f);
}
startup::open_files(&mut app, &files, two_paged);
}
for (k, v) in options {
if let Err(e) = app.set_option(&k, &v) {
let result = if defer_layout && k == "layout" { app.set_launch_layout(&v) } else { app.set_option(&k, &v) };
if let Err(e) = result {
log::warn!("--{k} {v}: {e}");
}
}
+244
View File
@@ -0,0 +1,244 @@
//! Native launch arguments and per-file view overrides (#1354).
use pdfcraft_ui_egui::PdfCraftApp;
#[derive(Default)]
pub(super) struct Arguments {
pub files: Vec<String>,
pub options: Vec<(String, String)>,
pub control_file: Option<String>,
pub create_images: bool,
pub new_window: bool,
pub two_paged: bool,
pub version: bool,
pub help: bool,
}
impl Arguments {
pub fn may_hand_off(&self) -> bool {
!self.new_window && !self.files.is_empty() && self.options.is_empty() && self.control_file.is_none() && !self.create_images && !self.two_paged
}
pub fn parse(args: impl IntoIterator<Item = String>) -> Self {
let mut parsed = Self::default();
let mut args = args.into_iter();
while let Some(a) = args.next() {
match a.as_str() {
"--version" => {
parsed.version = true;
break;
}
"-h" | "--help" => {
parsed.help = true;
break;
}
"--control" => parsed.control_file = args.next(),
"--create-images" => parsed.create_images = true,
"--new-window" => parsed.new_window = true,
"--two-paged" => parsed.two_paged = true,
flag if flag.starts_with("--two-paged=") => {
// It has no value: do not consume the following filename on malformed use.
log::warn!("{flag}: use --two-paged without a value");
}
flag if flag.starts_with("--") => {
let value = args.next().unwrap_or_default();
parsed.options.push((flag.trim_start_matches("--").to_string(), value));
}
_ => parsed.files.push(super::path_from_arg(a)),
}
}
parsed
}
}
pub(super) fn open_files(app: &mut PdfCraftApp, files: &[String], two_paged: bool) {
for path in files {
if two_paged {
// The per-file override wins over initial view without changing saved defaults.
app.open_path_with_display(path, pdfcraft_ui_egui::canvas::PageLayout::TwoUp, false);
} else {
app.open_path(path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Arguments {
Arguments::parse(args.iter().map(|s| s.to_string()))
}
#[test]
fn standalone_flag_keeps_files_in_either_position() {
for args in
[["--two-paged", "first.pdf", "second.pdf"], ["first.pdf", "--two-paged", "second.pdf"], ["first.pdf", "second.pdf", "--two-paged"]]
{
let parsed = parse(&args);
assert!(parsed.two_paged);
assert_eq!(parsed.files, ["first.pdf", "second.pdf"]);
assert!(parsed.options.is_empty());
assert!(!parsed.may_hand_off());
}
let empty = parse(&["--two-paged"]);
assert!(empty.two_paged && empty.files.is_empty());
let normal = parse(&["first.pdf", "second.pdf"]);
assert!(!normal.two_paged);
assert!(normal.may_hand_off());
for args in [
&["--new-window", "first.pdf"][..],
&["--layout", "two-page", "first.pdf"],
&["--control", "control.json", "first.pdf"],
&["--create-images", "first.png"],
] {
assert!(!parse(args).may_hand_off());
}
}
#[test]
fn existing_layout_options_are_preserved_in_either_order() {
for args in [["--two-paged", "--layout", "single", "file.pdf"], ["--layout", "single", "--two-paged", "file.pdf"]] {
let parsed = parse(&args);
assert!(parsed.two_paged);
assert_eq!(parsed.files, ["file.pdf"]);
assert_eq!(parsed.options, [("layout".into(), "single".into())]);
}
let parsed = parse(&["--two-paged", "--default-layout", "two-up", "file.pdf"]);
assert_eq!(parsed.options, [("default-layout".into(), "two-up".into())]);
}
#[test]
fn newer_options_and_version_are_preserved() {
let parsed = parse(&["--two-paged", "--new-window", "--grid-zoom", "150", "--default-mode", "read", "file:///tmp/a%20b.pdf"]);
assert!(parsed.two_paged && parsed.new_window);
assert_eq!(parsed.files, ["/tmp/a b.pdf"]);
assert_eq!(parsed.options, [("grid-zoom".into(), "150".into()), ("default-mode".into(), "read".into())]);
assert!(!parsed.may_hand_off());
let version = parse(&["--two-paged", "--version", "ignored.pdf"]);
assert!(version.version && version.files.is_empty());
}
#[test]
fn help_keeps_the_headless_early_exit() {
for flag in ["-h", "--help"] {
let args = parse(&["--two-paged", flag, "ignored.pdf"]);
assert!(args.help && args.files.is_empty());
}
}
#[test]
fn malformed_boolean_does_not_eat_a_file_or_option() {
let args = parse(&["--two-paged=false", "first.pdf", "--layout", "single", "second.pdf"]);
assert!(!args.two_paged);
assert_eq!(args.files, ["first.pdf", "second.pdf"]);
assert_eq!(args.options, [("layout".into(), "single".into())]);
}
#[test]
fn startup_override_is_per_file_and_does_not_persist() {
use std::io::Write as _;
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let path = std::env::temp_dir().join(format!("pdfcraft-two-paged-{}-{nonce}.pdf", std::process::id()));
let mut file = std::fs::OpenOptions::new().create_new(true).write(true).open(&path).unwrap();
file.write_all(include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap();
drop(file);
let second_path = path.with_extension("second.pdf");
std::fs::copy(&path, &second_path).unwrap();
let text = path.to_str().unwrap();
let second = second_path.to_str().unwrap();
for enabled in [false, true] {
let mut app = PdfCraftApp::new();
app.set_option("default-layout", "single").unwrap();
app.open_bytes("restored.pdf", None, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf").to_vec()).unwrap();
let restored = app.views[0].id;
open_files(&mut app, &[text.into(), second.into()], enabled);
assert_eq!(app.views.len(), 3);
assert_eq!(app.views[0].id, restored);
assert_eq!(app.views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::Single);
for view in app.views.iter().skip(1) {
assert_eq!(
view.layout,
if enabled { pdfcraft_ui_egui::canvas::PageLayout::TwoUp } else { pdfcraft_ui_egui::canvas::PageLayout::Single }
);
assert!(!view.continuous());
}
let persisted: serde_json::Value = serde_json::from_str(&app.persist()).unwrap();
assert_eq!(persisted["default_layout"], "single");
assert!(persisted.get("two_paged").is_none());
// Existing --layout applies after the flag to the active document only.
if enabled {
app.set_launch_layout("continuous").unwrap();
} else {
app.set_option("layout", "continuous").unwrap();
}
assert_eq!(app.views[2].layout, pdfcraft_ui_egui::canvas::PageLayout::Single);
assert!(app.views[2].continuous());
app.set_option("default-layout", "two-up").unwrap();
assert_eq!(app.view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::TwoUp);
assert!(app.view_defaults.continuous);
open_files(&mut app, &[], enabled);
assert_eq!(app.views.len(), 3);
}
std::fs::remove_file(path).unwrap();
std::fs::remove_file(second_path).unwrap();
}
#[test]
fn explicit_launch_layout_preserves_argument_order_and_active_file() {
use pdfcraft_ui_egui::canvas::PageLayout;
let path = std::env::temp_dir().join(format!("pdfcraft-explicit-layout-{}.pdf", std::process::id()));
std::fs::write(&path, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap();
let second_path = path.with_extension("second.pdf");
std::fs::copy(&path, &second_path).unwrap();
let names = [path.to_str().unwrap(), second_path.to_str().unwrap()];
for prefix in [["--two-paged", "--layout", "single"], ["--layout", "single", "--two-paged"]] {
for count in [1, 2] {
let args = Arguments::parse(prefix.into_iter().chain(names.iter().take(count).copied()).map(str::to_owned));
let mut app = PdfCraftApp::new();
app.set_option("default-layout", "continuous").unwrap();
app.open_bytes("restored.pdf", None, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf").to_vec()).unwrap();
open_files(&mut app, &args.files, args.two_paged);
for (key, value) in args.options {
assert_eq!(key, "layout");
app.set_launch_layout(&value).unwrap();
}
assert_eq!(app.active, Some(count));
assert!(app.views[0].continuous());
for view in app.views.iter().skip(1).take(count - 1) {
assert_eq!(view.layout, PageLayout::TwoUp);
}
assert_eq!(app.views[count].layout, PageLayout::Single);
assert!(!app.views[count].continuous());
assert!(app.view_defaults.continuous);
}
}
std::fs::remove_file(path).unwrap();
std::fs::remove_file(second_path).unwrap();
}
}
#[cfg(test)]
mod repeated_file_tests {
use super::*;
#[test]
fn a_repeated_file_reuses_its_tab_and_receives_the_launch_override() {
use pdfcraft_ui_egui::canvas::PageLayout;
let path = std::env::temp_dir().join(format!("pdfcraft-repeated-startup-{}.pdf", std::process::id()));
std::fs::write(&path, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap();
let name = path.to_str().unwrap().to_string();
let mut app = PdfCraftApp::new();
app.set_option("default-layout", "continuous").unwrap();
app.open_path(&name);
let original = app.views[0].id;
open_files(&mut app, &[name.clone(), name], true);
assert_eq!(app.views.len(), 1);
assert_eq!(app.views[0].id, original);
assert_eq!(app.views[0].layout, PageLayout::TwoUp);
assert!(!app.views[0].continuous());
app.set_launch_layout("single").unwrap();
assert_eq!(app.views[0].layout, PageLayout::Single);
assert!(app.view_defaults.continuous);
std::fs::remove_file(path).unwrap();
}
}
+1 -2
View File
@@ -20,8 +20,7 @@ icons use the full-bleed tile. macOS icons put it on Apple's grid (an 824 px bod
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
+2 -5
View File
@@ -1,13 +1,10 @@
# Contributor names for the About window (craftrules standards/contributors.md).
# Contributor names for the About window.
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
# Copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
+16
View File
@@ -21,6 +21,9 @@ them with the image's current orientation. Full turns retain its appearance, rec
and flips. Angles that are not multiples of 90 are refused without changing the document or
its undo/redo history.
`page_add_text`, `content_update` and `text_edit` take per-line `aligns`. An `"inherit"` entry
follows the box-level `align`; legacy `null` entries remain accepted with the same meaning.
`Content` is `Json(Value)` or `Png { data, width, height }`. Errors are `UnknownTool`, `InvalidArgs` (the call didn't match the schema) or `Failed` (a readable message for the agent).
## Conventions
@@ -199,6 +202,19 @@ undoable and survive saving/reopening. `image_hit` shares the UI's curve and hol
The bounds, limits and font requirements are in [existing-text editing](../../docs/editing-existing-text.md)
and [image editing](../../docs/editing-images.md).
### Superscript and subscript normalization
`text_script` changes characters to their Unicode superscript or subscript forms; it does not
change the font size or baseline. For characters that have such forms, uniformly plain text
toggles to the requested script, and uniformly scripted text toggles back to plain text.
Characters without a matching form stay unchanged and do not determine the selection's state.
A selection mixing plain and target-script characters normalizes to the requested script.
For example, subscript changes `H₂2` to `H₂₂`, then to `H22` on the next call; superscript
changes `Q²2` to `Q²²`, then to `Q22`. A second toggle does not reconstruct the original
mixture. Each call is one undoable text edit: `edit_undo` restores the exact prior text,
including its original mixture. Save/reopen preserves the resulting normalized characters.
## Opening local GoToR links
`link_open {doc, page, index, password?}` follows a local PDF link using `link_list`
+4 -3
View File
@@ -83,10 +83,10 @@ pub(crate) fn parse_align(al: &str) -> Result<TextAlign> {
/// 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 null where it follows `align`.
/// `aligns`: each paragraph's (line's) own alignment, or inherit/null where it follows `align`.
pub(crate) fn opt_aligns(a: &Args) -> Result<Option<Vec<Option<TextAlign>>>> {
let Some(v) = a.get("aligns") else { return Ok(None) };
let items = v.as_array().ok_or_else(|| bad("aligns must be an array of left, center, right, justify or null"))?;
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")));
}
@@ -94,8 +94,9 @@ pub(crate) fn opt_aligns(a: &Args) -> Result<Option<Vec<Option<TextAlign>>>> {
.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 or null")),
_ => Err(bad("each entry of aligns is left, center, right, justify, inherit or null")),
})
.collect::<Result<Vec<_>>>()
.map(Some)
+100 -3
View File
@@ -12,6 +12,35 @@ pub enum Content {
Png { data: Vec<u8>, width: u32, height: u32 },
}
/// `image_edit` `transform`: a matrix `[a b c d e f]` acting on top-left-origin points (a point
/// moves to `(a·x + c·y + e, b·x + d·y + f)`) → the same motion in PDF user space, which is
/// what `ImageEdit::Transform` applies after the image's placement. Both spaces are related by
/// an affine map, so three corners of the crop box pin the result down.
fn view_transform_to_user(info: &pdfcraft_render::PageInfo, m: [f64; 6]) -> Result<[f64; 6]> {
let [a, b, c, d, e, f] = m;
let det = a * d - b * c;
if !m.iter().all(|v| v.is_finite()) || !det.is_finite() || det.abs() < 1e-9 {
return Err(ToolError::InvalidArgs("transform needs a finite matrix that keeps an area (a·d − b·c must not be 0)".into()));
}
let moved = |x: f32, y: f32| {
let [vx, vy] = info.user_to_view(x, y).map(f64::from);
info.view_to_user((a * vx + c * vy + e) as f32, (b * vx + d * vy + f) as f32).map(f64::from)
};
let [x0, y0, x1, y1] = info.crop;
let (w, h) = (f64::from(x1 - x0), f64::from(y1 - y0));
if w.abs() < 1e-3 || h.abs() < 1e-3 {
return Err(ToolError::InvalidArgs("the page has no area to transform in".into()));
}
let (o, px, py) = (moved(x0, y0), moved(x1, y0), moved(x0, y1));
let (ua, ub, uc, ud) = ((px[0] - o[0]) / w, (px[1] - o[1]) / w, (py[0] - o[0]) / h, (py[1] - o[1]) / h);
let (x0, y0) = (f64::from(x0), f64::from(y0));
let user = [ua, ub, uc, ud, o[0] - ua * x0 - uc * y0, o[1] - ub * x0 - ud * y0];
if !user.iter().all(|v| v.is_finite()) {
return Err(ToolError::InvalidArgs("the transform moves the image too far".into()));
}
Ok(user)
}
impl Automation {
/// Run the tool `name` with JSON `args` (an object; `null` means no arguments).
pub fn call(&mut self, name: &str, args: &Value) -> Result<Vec<Content>> {
@@ -357,7 +386,7 @@ impl Automation {
let (u, v) = (info.user_to_view(rect[0] as f32, rect[1] as f32), info.user_to_view(rect[2] as f32, rect[3] as f32));
[r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))]
});
json!({ "visible_rect": visible, "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } })
json!({ "visible_rect": visible, "has_editor_crop": im.has_editor_crop(), "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } })
})
.collect();
json!({ "page": page + 1, "count": list.len(), "images": list })
@@ -395,6 +424,16 @@ impl Automation {
let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32));
ImageEdit::Move([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64])
}
"transform" => {
let m: Vec<f64> = a
.get("matrix")
.and_then(Value::as_array)
.and_then(|x| x.iter().map(Value::as_f64).collect())
.ok_or_else(|| ToolError::InvalidArgs("transform needs matrix: 6 numbers [a, b, c, d, e, f]".into()))?;
let m = <[f64; 6]>::try_from(m)
.map_err(|_| ToolError::InvalidArgs("transform needs matrix: 6 numbers [a, b, c, d, e, f]".into()))?;
ImageEdit::Transform(view_transform_to_user(&self.doc(&a)?.info.pages[page], m)?)
}
"rotate_by" => ImageEdit::RotateBy(
a.get("degrees")
.and_then(Value::as_f64)
@@ -410,9 +449,18 @@ impl Automation {
let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32));
ImageEdit::Crop([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64])
}
"flip_horizontal" => ImageEdit::Flip { horizontal: true },
"flip_vertical" => ImageEdit::Flip { horizontal: false },
action @ ("flip_horizontal" | "flip_vertical") => {
let horizontal = action == "flip_horizontal";
let info = self.doc(&a)?.info.pages.get(page).ok_or_else(|| ToolError::InvalidArgs("the page does not exist".into()))?;
// The tool names displayed axes; the engine names PDF user-space axes.
ImageEdit::Flip { horizontal: horizontal ^ (info.rotation % 180 == 90) }
}
"delete" => ImageEdit::Delete,
"bring_to_front" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringToFront),
"send_to_back" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendToBack),
"bring_forward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringForward),
"send_backward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendBackward),
"remove_crop" => ImageEdit::RemoveCrop,
"replace" => {
let path = self.resolve(a.str("path")?, false)?;
let bytes = read_input(&path)?;
@@ -624,6 +672,7 @@ impl Automation {
}
out
}
"text_script" => self.text_script(&a)?,
"doc_audit_space" => {
let rows: Vec<Value> = self
.doc(&a)?
@@ -815,4 +864,52 @@ impl Automation {
};
Ok(vec![Content::Json(out)])
}
/// `text_script`: Format text ▸ Superscript or Subscript on a paragraph, or on a byte range
/// of its editor text, written as `text_edit` writes the paragraph.
fn text_script(&mut self, a: &Args) -> Result<Value> {
use pdfcraft_engine::text_script::{Script, toggle};
let name = a.str("script")?;
let script = match name {
"superscript" => Script::Super,
"subscript" => Script::Sub,
other => return Err(ToolError::InvalidArgs(format!("unknown script {other:?} (superscript, subscript)"))),
};
let page = self.page(a)?;
let k = a.int("paragraph")?;
let text = {
let doc = self.doc(a)?;
let blocks = doc.text_blocks(page);
let index = self.paragraph_index(doc.id, page, k, &blocks)?;
blocks.get(index).map(|b| b.editor_text.clone()).ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?
};
let range = match a.opt_ints("range")? {
None => None,
Some(r) => {
let bounds = match r.as_slice() {
[start, end] => usize::try_from(*start).ok().zip(usize::try_from(*end).ok()),
_ => None,
};
let range = bounds
.map(|(start, end)| start..end)
.filter(|r| r.start < r.end && r.end <= text.len() && text.is_char_boundary(r.start) && text.is_char_boundary(r.end));
let Some(range) = range else {
return Err(ToolError::InvalidArgs(format!(
"range must be [start, end]: UTF-8 byte offsets of the paragraph's editor_text (from text_paragraphs) with start < end <= {}, on character boundaries",
text.len()
)));
};
Some(range)
}
};
let (toggled, _) = toggle(&text, range.as_ref(), script)
.ok_or_else(|| ToolError::InvalidArgs(format!("nothing to change: no character of the text has a {name} form")))?;
let args = json!({ "doc": a.get("doc"), "page": a.get("page"), "paragraph": k, "text": toggled });
let mut out = match self.call("text_edit", &args)?.into_iter().next() {
Some(Content::Json(out)) => out,
_ => json!({}),
};
out["script"] = json!(name);
Ok(out)
}
}
+15 -6
View File
@@ -175,7 +175,7 @@ pub fn tools() -> Vec<ToolDef> {
}), &["doc", "page"])),
t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.")
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])),
t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Clipping and vertical text are refused. A bad/stale reference refuses the entire move.")
t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Text used as a clipping path and vertical text are refused. A bad/stale reference refuses the entire move.")
.with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},
"objects":{"type":"array","minItems":1,"maxItems":1000,"uniqueItems":true,"items":{
"type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}},
@@ -637,7 +637,7 @@ pub fn tools() -> Vec<ToolDef> {
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
"aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
"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"],
@@ -660,7 +660,7 @@ pub fn tools() -> Vec<ToolDef> {
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
"aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
"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." },
@@ -1378,17 +1378,18 @@ pub fn tools() -> Vec<ToolDef> {
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), remove_crop (remove a crop created by PdfCraft while preserving clipping paths from the original document), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), transform (matrix: 6 numbers [a, b, c, d, e, f] in top-left-origin points, applied after the current placement so a point (x, y) of the image moves to (a·x + c·y + e, b·x + d·y + f); finite, with a·d − b·c not 0; the box in page_images becomes the transformed image's bounding box), flip_horizontal (reflect left/right on the displayed page), flip_vertical (reflect top/bottom on the displayed page; both flip actions follow the page rotation), replace (path: an image file, drawn in the same place), delete, or arrange: bring_to_front, send_to_back, bring_forward, send_backward (reorder stacking order among content on the page; errors when the image is already at the requested limit; arranging an added image converts it into an ordinary page image). Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
.cmd("edit.edit_text")
.with(schema(
json!({
"doc": doc(),
"page": { "type": "integer", "minimum": 1 },
"image": { "type": "integer", "minimum": 1 },
"action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "flip_horizontal", "flip_vertical", "replace", "delete"] },
"action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "transform", "flip_horizontal", "flip_vertical", "replace", "delete", "bring_to_front", "send_to_back", "bring_forward", "send_backward", "remove_crop"] },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"quarters": { "type": "integer" },
"degrees": { "type": "number" },
"matrix": { "type": "array", "items": { "type": "number" }, "minItems": 6, "maxItems": 6 },
"path": { "type": "string" },
}),
&["doc", "page", "image", "action"],
@@ -1428,7 +1429,7 @@ pub fn tools() -> Vec<ToolDef> {
"size": { "type": "number", "exclusiveMinimum": 0 },
"color": { "type": "string", "description": "#rrggbb" },
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
"aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
"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)." },
@@ -1438,6 +1439,14 @@ pub fn tools() -> Vec<ToolDef> {
"scale": { "type": "number", "description": "Horizontal scale in percent." } }),
&["doc", "page"],
)),
t("text_script", "Superscript or subscript", "Format text ▸ Superscript or Subscript on one paragraph (paragraph, from text_paragraphs), or on range [start, end): UTF-8 byte offsets of its editor_text. The characters take the Unicode superscript or subscript forms (2 → ², 2 → ₂; the font must have those glyphs, and extracted text contains them); text already in that script goes back to plain characters. Characters Unicode has no such form for (q, a space) stay unchanged and do not determine the selection's script state. Mixed plain and target-script characters normalize to that script, then to plain text on the next call (H₂2 → H₂₂ → H22); a second toggle does not reconstruct the original mixture. Use Undo to restore the exact prior text. The paragraph is then rewritten as text_edit rewrites it, with the same result fields. Refused when no character would change. Undoable. Mathematical minus U+2212 has its own script mapping; ASCII hyphen-minus stays unchanged.")
.cmd("edit.edit_text")
.with(schema(
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 },
"script": { "type": "string", "enum": ["superscript", "subscript"] },
"range": { "type": "array", "items": { "type": "integer", "minimum": 0 }, "minItems": 2, "maxItems": 2, "description": "UTF-8 byte offsets [start, end) of the paragraph's editor_text; the whole paragraph when omitted." } }),
&["doc", "page", "paragraph", "script"],
)),
t("doc_revisions", "List revisions", "List the document's saved revisions (oldest first): each incremental update is one. Returns revision number, where it ends in the file and its size, and which signatures sign exactly that revision.")
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.")
+311 -1
View File
@@ -664,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");
@@ -3286,6 +3324,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() {
@@ -3453,6 +3524,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 }));
@@ -3472,6 +3550,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");
@@ -9764,7 +9916,11 @@ fn added_text_lines_take_their_own_alignments() {
let dir = workdir("paragraph-aligns");
let mut a = auto(&dir);
let id = ok(&mut a, "doc_create", json!({"from":"blank","width":600,"height":400}))["doc"].as_u64().unwrap();
ok(&mut a, "page_add_text", json!({"doc":id,"page":1,"text":"Left line\nRight line","at":[40,50],"width":500,"size":12,"aligns":[null,"right"]}));
ok(
&mut a,
"page_add_text",
json!({"doc":id,"page":1,"text":"Left line\nRight line\nInherited line","at":[40,50],"width":500,"size":12,"aligns":[null,"right","inherit"]}),
);
let rect = |a: &mut Automation, text: &str| -> Vec<f64> {
let lines = ok(a, "text_lines", json!({"doc":id,"page":1}));
let line = lines["lines"].as_array().unwrap().iter().find(|l| l["text"] == text).cloned().unwrap();
@@ -9772,10 +9928,13 @@ fn added_text_lines_take_their_own_alignments() {
};
assert!((rect(&mut a, "Left line")[0] - 40.0).abs() < 1.0);
assert!((rect(&mut a, "Right line")[2] - 540.0).abs() < 1.0, "{:?}", rect(&mut a, "Right line"));
assert!((rect(&mut a, "Inherited line")[0] - 40.0).abs() < 1.0);
// The whole box centred: the right-aligned line goes with it.
ok(&mut a, "content_update", json!({"doc":id,"page":1,"index":1,"align":"center"}));
let right = rect(&mut a, "Right line");
assert!(((right[0] + right[2]) / 2.0 - 290.0).abs() < 1.0, "{right:?}");
let inherited = rect(&mut a, "Inherited line");
assert!(((inherited[0] + inherited[2]) / 2.0 - 290.0).abs() < 1.0, "{inherited:?}");
assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":["sideways"]})).is_err());
assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":"right"})).is_err());
}
@@ -10190,3 +10349,154 @@ fn simple_rule_scan_drawn(content: &[u8]) -> Vec<u8> {
}
write_full(&doc, &SaveOptions::default()).unwrap()
}
#[test]
fn editor_crop_group_move_and_arrange_survive_added_item_deletion_through_tools() {
let dir = workdir("editor-crop-group-arrange");
let mut a = auto(&dir);
let source = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap();
ok(&mut a, "doc_export_images", json!({"doc":source,"folder":"src","dpi":18,"pages":[1]}));
let doc = ok(&mut a, "doc_create", json!({"from":"images","paths":["src/a_page_1.png"]}))["doc"].as_u64().unwrap();
ok(&mut a, "image_edit", json!({"doc":doc,"page":1,"image":1,"action":"move","rect":[5,5,25,35]}));
ok(&mut a, "image_edit", json!({"doc":doc,"page":1,"image":1,"action":"crop","rect":[10,10,20,30]}));
ok(&mut a, "page_add_text", json!({"doc":doc,"page":1,"text":"Movable label","rect":[2,40,45,55],"size":6}));
let inventory = ok(&mut a, "object_list", json!({"doc":doc,"page":1}));
let objects: Vec<_> = inventory["objects"].as_array().unwrap().iter().map(|o| json!({"kind":o["kind"],"index":o["index"]})).collect();
assert_eq!(objects.len(), 2);
let moved = ok(&mut a, "object_move", json!({"doc":doc,"page":1,"objects":objects,"offset":[3,4]}));
assert_eq!(moved["moved"], 2);
let picture = ok(&mut a, "page_images", json!({"doc":doc,"page":1}))["images"][0].clone();
assert_eq!(picture["rect"], json!([8.0, 9.0, 28.0, 39.0]));
assert_eq!(picture["visible_rect"], json!([13.0, 14.0, 23.0, 34.0]));
assert!(page_text(&mut a, doc)[0].contains("Movable label"));
ok(&mut a, "edit_undo", json!({"doc":doc}));
assert_eq!(ok(&mut a, "page_images", json!({"doc":doc,"page":1}))["images"][0]["visible_rect"], json!([10.0, 10.0, 20.0, 30.0]));
ok(&mut a, "edit_redo", json!({"doc":doc}));
ok(&mut a, "doc_save", json!({"doc":doc,"path":"grouped.pdf"}));
let reopened = ok(&mut a, "doc_open", json!({"path":"grouped.pdf"}))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut a, "page_images", json!({"doc":reopened,"page":1}))["images"][0]["visible_rect"], picture["visible_rect"]);
ok(&mut a, "image_edit", json!({"doc":reopened,"page":1,"image":1,"action":"bring_to_front"}));
assert_eq!(ok(&mut a, "content_list", json!({"doc":reopened}))["count"], 1);
ok(&mut a, "content_delete", json!({"doc":reopened,"page":1,"index":1}));
ok(&mut a, "doc_save", json!({"doc":reopened,"path":"independent.pdf"}));
let saved = ok(&mut a, "doc_open", json!({"path":"independent.pdf"}))["doc"].as_u64().unwrap();
let after = ok(&mut a, "page_images", json!({"doc":saved,"page":1}));
assert_eq!(after["count"], 1);
assert_eq!(after["images"][0]["rect"], picture["rect"]);
assert_eq!(after["images"][0]["visible_rect"], picture["visible_rect"]);
let rendered = a.call("page_render", &json!({"doc":saved,"page":1,"dpi":72})).unwrap();
assert!(rendered.iter().any(|c| matches!(c, Content::Png {data,width,height} if !data.is_empty() && *width > 0 && *height > 0)));
}
#[test]
fn image_flip_tools_follow_displayed_axes_on_every_page_rotation() {
fn corners(a: &Automation, id: u64) -> [[f64; 2]; 4] {
let doc = a.session().get(pdfcraft_engine::DocId(id)).unwrap();
let [aa, b, c, d, e, f] = doc.page_images(0)[0].matrix;
[(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)]
.map(|(x, y)| doc.info.pages[0].user_to_view((aa * x + c * y + e) as f32, (b * x + d * y + f) as f32).map(f64::from))
}
fn raster(a: &mut Automation, id: u64) -> (usize, usize, Vec<u8>) {
let rendered = a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap();
let Content::Png { data, .. } = &rendered[0] else { panic!("expected PNG") };
let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap();
let mut bytes = vec![0; reader.output_buffer_size().unwrap()];
let frame = reader.next_frame(&mut bytes).unwrap();
bytes.truncate(frame.buffer_size());
if frame.color_type == png::ColorType::Rgb {
bytes = bytes.as_chunks::<3>().0.iter().flat_map(|&[r, g, b]| [r, g, b, 255]).collect();
} else {
assert_eq!(frame.color_type, png::ColorType::Rgba);
}
(frame.width as usize, frame.height as usize, bytes)
}
let dir = workdir("displayed-image-flip");
let mut png_bytes = Vec::new();
{
let mut encoder = png::Encoder::new(&mut png_bytes, 8, 4);
encoder.set_color(png::ColorType::Rgb);
let colors = [[240, 20, 30], [20, 230, 40], [30, 40, 220], [220, 210, 20]];
let pixels: Vec<u8> = (0..4).flat_map(|y| (0..8).flat_map(move |x| colors[usize::from(x >= 4) + 2 * usize::from(y >= 2)])).collect();
encoder.write_header().unwrap().write_image_data(&pixels).unwrap();
}
std::fs::write(dir.join("quadrants.png"), png_bytes).unwrap();
let mut a = auto(&dir);
let mut failures = Vec::new();
for rotation in [0, 90, 180, 270] {
let doc = ok(&mut a, "doc_create", json!({"from":"images","paths":["quadrants.png"]}))["doc"].as_u64().unwrap();
if rotation != 0 {
ok(&mut a, "page_rotate", json!({"doc":doc,"pages":[1],"degrees":rotation}));
}
let before = corners(&a, doc);
let (width, height, pixels) = raster(&mut a, doc);
let xmin = before.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min);
let xmax = before.iter().map(|p| p[0]).fold(f64::NEG_INFINITY, f64::max);
let ymin = before.iter().map(|p| p[1]).fold(f64::INFINITY, f64::min);
let ymax = before.iter().map(|p| p[1]).fold(f64::NEG_INFINITY, f64::max);
for (action, horizontal) in [("flip_horizontal", true), ("flip_vertical", false)] {
ok(&mut a, "image_edit", json!({"doc":doc,"page":1,"image":1,"action":action}));
let after = corners(&a, doc);
for (old, new) in before.into_iter().zip(after) {
let expected = if horizontal { [xmin + xmax - old[0], old[1]] } else { [old[0], ymin + ymax - old[1]] };
if new.into_iter().zip(expected).any(|(actual, want)| (actual - want).abs() > 0.001) {
failures.push(format!("rotation {rotation} {action}: corner {old:?} became {new:?}, expected {expected:?}"));
break;
}
}
let (new_width, new_height, actual_pixels) = raster(&mut a, doc);
assert_eq!((new_width, new_height), (width, height));
let mut expected_pixels = Vec::new();
for y in 0..height {
for x in 0..width {
let source = if horizontal { (y * width + width - 1 - x) * 4 } else { ((height - 1 - y) * width + x) * 4 };
expected_pixels.extend_from_slice(&pixels[source..source + 4]);
}
}
if actual_pixels.iter().zip(&expected_pixels).any(|(actual, want)| actual.abs_diff(*want) > 1) {
failures.push(format!("rotation {rotation} {action}: raster flipped the wrong displayed axis"));
}
assert_ne!(actual_pixels, pixels, "the asymmetric image makes both flip axes observable");
ok(&mut a, "edit_undo", json!({"doc":doc}));
assert_eq!(corners(&a, doc), before, "one undo restores the original placement");
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn mixed_script_tools_normalize_but_undo_restores_the_exact_mixture() {
let dir = workdir("mixed-script-policy");
let mut a = auto(&dir);
for (script, mixed, uniform, plain) in [("superscript", "Q²2", "Q²²", "Q22"), ("subscript", "H₂2", "H₂₂", "H22")] {
let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap();
ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":mixed,"size":14,"width":160}));
let paragraph =
|a: &mut Automation| ok(a, "text_paragraphs", json!({"doc":doc,"page":1}))["paragraphs"][0]["editor_text"].as_str().unwrap().to_owned();
assert_eq!(paragraph(&mut a), mixed);
let before = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().bytes.clone();
let args = json!({"doc":doc,"page":1,"paragraph":1,"script":script});
let once = ok(&mut a, "text_script", args.clone());
assert_eq!(once["paragraph"]["text"], uniform);
assert_eq!(paragraph(&mut a), uniform);
ok(&mut a, "text_script", args);
assert_eq!(paragraph(&mut a), plain);
assert_ne!(plain, mixed, "normalization does not promise to recreate the original mixture");
ok(&mut a, "edit_undo", json!({"doc":doc}));
assert_eq!(paragraph(&mut a), uniform, "one Undo restores the preceding scripted text");
ok(&mut a, "edit_undo", json!({"doc":doc}));
assert_eq!(paragraph(&mut a), mixed, "Undo restores the exact initial Unicode mixture");
assert_eq!(
a.session().get(pdfcraft_engine::DocId(doc)).unwrap().bytes.as_ref(),
before.as_ref(),
"Undo also restores the exact prior document bytes"
);
ok(&mut a, "edit_redo", json!({"doc":doc}));
assert_eq!(paragraph(&mut a), uniform);
ok(&mut a, "edit_redo", json!({"doc":doc}));
assert_eq!(paragraph(&mut a), plain);
let path = format!("mixed-{script}.pdf");
ok(&mut a, "doc_save", json!({"doc":doc,"path":path}));
let reopened = ok(&mut a, "doc_open", json!({"path":path}))["doc"].as_u64().unwrap();
assert_eq!(page_text(&mut a, reopened)[0], plain);
}
}
+1 -1
View File
@@ -245,7 +245,7 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
if let Some(p) = prev {
trailer.set(b"Prev".to_vec(), Object::Int(p as i64));
}
let size = doc.next_num().max(trailer.int(b"Size").unwrap_or(0) as u32);
let size = doc.next_num().max(trailer.int(b"Size").map_or(0, |n| n.clamp(0, i64::from(u32::MAX)) as u32));
if as_stream {
let rows = offsets.iter().map(|(n, (o, g, used))| (*n, if *used { Row::InFile(*o, *g) } else { Row::Free(*g) })).collect();
write_xref_stream(&mut out, base, &mut trailer, rows, size);
+10
View File
@@ -228,6 +228,16 @@ fn incremental_save_keeps_a_trailer_only_edit() {
assert_qpdf_clean(&saved);
}
#[test]
fn incremental_save_ignores_a_negative_trailer_size() {
let mut doc = Document::open(Arc::new(classic_table())).unwrap();
doc.trailer_mut().set(b"Size".to_vec(), Object::Int(-1));
let next = doc.add(Object::Int(5)).num + 1;
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
let back = Document::open(Arc::new(saved)).unwrap();
assert_eq!(back.trailer().int(b"Size"), Some(i64::from(next)));
}
/// What one edit added and a later one unlinked (a deleted comment, a cleared redaction mark) is
/// not written: the file never held it, and writing it would keep what was removed. Additions the
/// update still refers to, from the trailer or a changed object and through other additions, are.
+23 -2
View File
@@ -236,11 +236,16 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) };
let key = if mask { None } else { colour_key(doc, s) };
// Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images.
let endpoint = |value: Option<&Object>| value.map(|v| doc.resolve(v)).and_then(|v| v.as_f64()).filter(|v| v.is_finite());
let inverted = (jpeg || mask)
&& s.dict
.get(b"Decode")
.map(|d| doc.resolve(d))
.and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64)))
// Both ends have to be finite numbers, as for lossless images, or the default mapping stands.
.and_then(|d| {
let a = d.as_array()?;
Some(endpoint(a.first())? > endpoint(a.get(1))?)
})
.unwrap_or(false);
// In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image
// viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as
@@ -612,7 +617,23 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>>
return Some(px);
}
let img = image::GrayImage::from_raw(mw as u32, mh as u32, px)?;
Some(image::imageops::resize(&img, w as u32, h as u32, image::imageops::FilterType::Triangle).into_raw())
Some(scaled(&img, w as u32, h as u32).into_raw())
}
/// `img` scaled to `w` × `h`. Scaling goes down first, through a buffer of `img`'s width by `h` at
/// 16 bytes a pixel, which a mask of other proportions than its image can make larger than any
/// machine: 100,000 × 1 on an image of 1 × 100,000 asks for 160 GB, and that aborts the process.
/// Such a mask is scaled across first, which holds no more pixels than one of the two has. Past a
/// quarter over the larger of them, so that rounding leaves a mask of its image's shape as it was.
pub(crate) fn scaled(img: &image::GrayImage, w: u32, h: u32) -> image::GrayImage {
use image::imageops::{FilterType, resize};
let (mw, mh) = (u64::from(img.width()), u64::from(img.height()));
let largest = (mw * mh).max(u64::from(w) * u64::from(h));
if (mw * u64::from(h)).saturating_mul(4) > largest.saturating_mul(5) {
resize(&resize(img, w, img.height(), FilterType::Triangle), w, h, FilterType::Triangle)
} else {
resize(img, w, h, FilterType::Triangle)
}
}
fn png(w: u32, h: u32, rgb: &[u8], alpha: Option<&[u8]>) -> Result<Vec<u8>, String> {
+112
View File
@@ -2209,3 +2209,115 @@ fn jbig2_globals_are_decoded_within_a_limit_of_their_own() {
assert!(why.starts_with("the JBIG2 globals can't be read") && why.contains(&format!("limit of {LIMIT} bytes")), "{why}");
}
}
/// `/Decode` says which way round a stencil mask is, and its numbers can be given by reference. Read
/// as absent, they gave the wrong answer, so the file came out a negative of what the page draws.
#[test]
fn a_stencil_masks_decode_array_can_hold_references() {
use pdfcraft_cos::Document;
let export = |decode: &str| {
let pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /XObject << /Im 4 0 R >> >> >> endobj\n\
4 0 obj << /Subtype /Image /ImageMask true /Width 8 /Height 1 /BitsPerComponent 1 /Decode {decode} /Filter /ASCIIHexDecode /Length 3 >> stream\nF0>\nendstream endobj\n\
5 0 obj 1 endobj\n6 0 obj 0 endobj\n7 0 obj 7 0 R endobj\n8 0 obj 9 0 R endobj\n9 0 obj 8 0 R endobj\n\
trailer << /Root 1 0 R >>\n%%EOF"
);
let doc = Document::open(std::sync::Arc::new(pdf.into_bytes())).unwrap();
let mut export = crate::extract::extract_images(&doc, &[0], 1);
assert_eq!(export.images.len(), 1, "{decode}: {:?}", export.skipped);
export.images.remove(0).data
};
let (inverted, identity) = (export("[1 0]"), export("[0 1]"));
assert_ne!(inverted, identity, "the two ways round differ");
for decode in ["[5 0 R 0]", "[1 6 0 R]", "[5 0 R 6 0 R]"] {
assert_eq!(export(decode), inverted, "{decode}");
}
for decode in ["[0 5 0 R]", "[6 0 R 1]", "[6 0 R 5 0 R]"] {
assert_eq!(export(decode), identity, "{decode}");
}
// A reference that only leads back to itself holds no number, and an end that is missing or isn't
// a number leaves the default mapping, whichever end it is.
for decode in ["[7 0 R 0]", "[1 7 0 R]", "[8 0 R 0]", "[1 8 0 R]", "[7 0 R 8 0 R]", "[1]", "[1 /Name]", "[/Name 0]"] {
assert_eq!(export(decode), identity, "{decode}");
}
// Equal ends aren't the two ways round either, and a number too long to hold reads as infinite.
assert_eq!(export("[1 1]"), identity);
assert_eq!(export(&format!("[{} 0]", "9".repeat(400))), identity);
}
/// A mask of other proportions than its image is scaled to it, however far apart they are: the
/// first two shapes asked for 160 GB on the way, in a file of a kilobyte, and that aborted the
/// process. The third would ask for 10 GB if it went across first, which is why the way round
/// depends on the shapes. The value comes through, and masks of ordinary proportions, of the
/// image's size and larger than it, are scaled as they always were.
#[test]
fn a_mask_of_other_proportions_than_its_image_is_scaled_without_the_memory_of_both() {
use pdfcraft_cos::{Dict, Document, Object, Stream};
let gray = |w: i64, h: i64, data: &[u8]| {
let mut d = Dict::new();
d.set(b"Subtype".to_vec(), Object::name("Image"));
d.set(b"Width".to_vec(), Object::Int(w));
d.set(b"Height".to_vec(), Object::Int(h));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
Stream::from_raw(d, pdfcraft_filters::encode_flate(data))
};
let shapes = [
(100_000usize, 1usize, 1usize, 100_000usize),
(100_000, 64, 64, 100_000),
(1, 100_000, 100_000, 1),
(64, 1, 1, 64),
(3, 2, 6, 4),
(3, 2, 3, 2),
(6, 4, 3, 2),
];
for (mask_w, mask_h, w, h) in shapes {
let mut doc = Document::new_empty();
let mask = doc.add(Object::Stream(gray(mask_w as i64, mask_h as i64, &vec![0x80; mask_w * mask_h])));
let mut image = gray(w as i64, h as i64, &vec![0x40; w * h]);
image.dict.set(b"SMask".to_vec(), Object::Ref(mask));
let image = doc.add(Object::Stream(image));
let (name, png) = crate::image_file(&doc, image).unwrap_or_else(|e| panic!("{mask_w} × {mask_h} on {w} × {h}: {e}"));
assert_eq!(name, "png");
let mut reader = ::png::Decoder::new(std::io::Cursor::new(&png)).read_info().unwrap();
let mut rgba = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut rgba).unwrap();
assert_eq!((info.width as usize, info.height as usize, info.color_type), (w, h, ::png::ColorType::Rgba));
assert!(rgba.as_chunks::<4>().0.iter().all(|p| p[3].abs_diff(0x80) <= 1), "{mask_w} × {mask_h} on {w} × {h}");
}
}
/// The long way round is for proportions far apart: up to a quarter over the larger image the
/// buffer is no danger, and such a mask scales as it always did, to the byte. Noise, as a flat mask comes
/// out the same either way.
#[test]
fn proportions_up_to_the_limit_scale_as_they_did() {
let noise = |w: u32, h: u32| {
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let px = (0..w * h)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed as u8
})
.collect();
image::GrayImage::from_raw(w, h, px).unwrap()
};
// Each exactly a quarter over the larger: the source's size, then the target's.
for (mw, mh, w, h) in [(25, 16, 10, 20), (20, 5, 16, 25)] {
let mask = noise(mw, mh);
let direct = image::imageops::resize(&mask, w, h, image::imageops::FilterType::Triangle);
assert_eq!(crate::extract::scaled(&mask, w, h), direct, "{mw} × {mh} to {w} × {h}");
}
// A step past it goes the long way, which is how to tell.
let mask = noise(25, 15);
let direct = image::imageops::resize(&mask, 10, 20, image::imageops::FilterType::Triangle);
assert_ne!(crate::extract::scaled(&mask, 10, 20), direct);
// A mask of its image's size comes through as it is.
let mask = noise(20, 10);
assert_eq!(crate::extract::scaled(&mask, 20, 10), mask);
}
+2
View File
@@ -24,3 +24,5 @@ unicode-bidi = "0.3"
[dev-dependencies]
hayro-syntax = { workspace = true }
pdfcraft-render = { workspace = true }
proptest = { workspace = true }
+1313 -142
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -35,6 +35,7 @@ pub type Rgb = [f64; 3];
pub mod backdrop;
pub use backdrop::{Backdrop, backdrops_left_off, page_backdrop, page_backdrops};
pub use text::strip_subset_tag;
mod background;
pub use background::page_background;
pub mod objects;
@@ -43,7 +44,7 @@ pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, ed
mod flatten;
pub use flatten::{flatten, flatten_fill_sign};
pub mod images;
pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees};
pub use images::{ArrangeOrder, ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees};
pub mod bidi;
pub mod indic;
mod linebreak;
+1728 -1
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -24,6 +24,7 @@ use crate::EditError;
mod regions;
#[path = "text_session.rs"]
mod session;
pub use fallback::strip_subset_tag;
use regions::{alignment_frame, group_blocks, is_centred, rewrite_styled_block};
pub use session::TextSession;
@@ -47,7 +48,7 @@ pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_l
pub(crate) use fallback::SOURCE_SPACE_CALLS;
use fallback::{
MAX_TYPE3_GLYPHS, Type3Glyph, fallback_face, needs_type3, no_fallback_font, no_glyph_for, same_base_font, same_font, source_encodes,
source_family, source_program, source_show, source_space, source_width, strip_subset_tag, type3_faces, type3_glyph, write_type3_glyphs,
source_family, source_program, source_show, source_space, source_width, type3_faces, type3_glyph, write_type3_glyphs,
};
#[cfg(test)]
use fallback::{add_type3_font, is_type3_fallback};
+1 -1
View File
@@ -163,7 +163,7 @@ pub(crate) fn same_base_font(a: &str, b: &str) -> bool {
}
/// `ABCDEF+Arial` → `Arial`. A name without a subset tag is returned unchanged.
pub(crate) fn strip_subset_tag(name: &str) -> &str {
pub fn strip_subset_tag(name: &str) -> &str {
let bytes = name.as_bytes();
let is_tag = bytes.get(6) == Some(&b'+') && bytes.get(..6).is_some_and(|tag| tag.iter().all(u8::is_ascii_uppercase));
if is_tag { name.get(7..).unwrap_or(name) } else { name }
+1
View File
@@ -19,6 +19,7 @@ pdfcraft-annot = { workspace = true }
pdfcraft-forms = { workspace = true }
pdfcraft-xfa = { workspace = true }
pdfcraft-edit = { workspace = true }
pdfcraft-content = { workspace = true }
pdfcraft-audit = { workspace = true }
pdfcraft-redact = { workspace = true }
pdfcraft-print = { workspace = true }
+1 -2
View File
@@ -18,9 +18,8 @@ impl Document {
/// [`Document::page_images`]: a raster image, or Form artwork that holds one.
pub fn page_image_at(&self, page: usize, at: [f64; 2]) -> Option<usize> {
let editor = self.editor.as_ref()?;
let inside = |r: &[f64; 4]| (r[0]..=r[2]).contains(&at[0]) && (r[1]..=r[3]).contains(&at[1]);
self.page_images(page).iter().rposition(|i| {
inside(&i.rect)
i.contains(at)
&& match (i.is_form, i.object) {
(false, Some(_)) => true,
(true, Some(form)) => picture_in_form(&editor.cos, form).is_some(),
+7 -1
View File
@@ -1,4 +1,4 @@
use super::{Arc, EditError, Hidden, SignatureInfo, TrustStore, optimize};
use super::{Arc, ArrangeOrder, EditError, Hidden, SignatureInfo, TrustStore, optimize};
/// A change to an existing page image.
#[derive(Clone, Debug, PartialEq)]
@@ -20,6 +20,12 @@ pub enum ImageEdit {
bytes: Arc<Vec<u8>>,
},
Delete,
/// Affine transform [a, b, c, d, e, f] applied to the image's matrix.
Transform([f64; 6]),
/// Change the stacking order.
Arrange(ArrangeOrder),
/// Remove any private crop on the image.
RemoveCrop,
}
/// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a
+15
View File
@@ -111,10 +111,17 @@ pub const EDIT_LABELS: &[&str] = &[
"Move object",
"Move objects",
"Move image",
"Transform image",
"Rotate image",
"Crop image",
"Flip image",
"Replace image",
"Delete image",
"Bring to front",
"Bring forward",
"Send backward",
"Send to back",
"Remove crop",
"Add header & footer",
"Update header & footer",
"Add watermark",
@@ -254,11 +261,19 @@ impl Edit {
Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()),
Edit::EditPageImage { change, .. } => Label::of(match change {
ImageEdit::Move(_) => "Move image",
ImageEdit::Transform(_) => "Transform image",
ImageEdit::Rotate(_) | ImageEdit::RotateBy(_) => "Rotate image",
ImageEdit::Crop(_) => "Crop image",
ImageEdit::Flip { .. } => "Flip image",
ImageEdit::Replace { .. } => "Replace image",
ImageEdit::Delete => "Delete image",
ImageEdit::Arrange(ord) => match ord {
pdfcraft_edit::ArrangeOrder::BringToFront => "Bring to front",
pdfcraft_edit::ArrangeOrder::BringForward => "Bring forward",
pdfcraft_edit::ArrangeOrder::SendBackward => "Send backward",
pdfcraft_edit::ArrangeOrder::SendToBack => "Send to back",
},
ImageEdit::RemoveCrop => "Remove crop",
}),
Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"),
Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"),
+165 -34
View File
@@ -4,6 +4,7 @@ use super::{
};
use crate::SourceProblem;
use crate::sources::{comment_source, import_pdf_comments};
use pdfcraft_content::Matrix;
/// Whether the opening password allows an edit (§7.6.4.2, Table 22).
pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> {
@@ -250,6 +251,169 @@ impl EditCtx {
}
}
fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usize, change: &ImageEdit) -> Result<(), EditError> {
match change {
ImageEdit::Move(to) if !to.iter().all(|v| v.is_finite()) => {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid position".into())));
}
ImageEdit::Transform(t) => {
let [a, b, c, d, _, _] = *t;
let determinant = a * d - b * c;
if !t.iter().all(|v| v.is_finite()) || !determinant.is_finite() || determinant.abs() < 1e-9 {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("the image transform must be finite and nonsingular".into())));
}
}
ImageEdit::Crop(keep) if !keep.iter().all(|v| v.is_finite()) => {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid crop".into())));
}
_ => {}
}
let img = pdfcraft_edit::page_images(doc, page)?
.into_iter()
.nth(index)
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?;
let c = match change {
ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)),
ImageEdit::Transform(t) => pdfcraft_edit::ImageChange::Transform(*t),
ImageEdit::Rotate(q) => {
pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false))
}
ImageEdit::RotateBy(degrees) => {
let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees)
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?;
pdfcraft_edit::ImageChange::Transform(t)
}
ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(
img.visible_rect.unwrap_or(img.rect),
0,
*horizontal,
!*horizontal,
)),
ImageEdit::Replace { name, bytes } => {
if img.is_form {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("grouped Form artwork cannot be replaced with a bitmap".into())));
}
pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0)
}
ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep),
ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete,
ImageEdit::Arrange(order) => pdfcraft_edit::ImageChange::Arrange(*order),
ImageEdit::RemoveCrop => pdfcraft_edit::ImageChange::RemoveCrop,
};
let page_model = pdfcraft_model::pages(doc)
.into_iter()
.nth(page)
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} not found", page + 1))))?;
let target_stream_obj = match page_model.dict.get(b"Contents") {
None => None,
Some(c) => match &*doc.resolve(c) {
pdfcraft_cos::Object::Array(a) => a.get(img.stream).and_then(|o| o.as_ref()),
_ if img.stream == 0 => c.as_ref(),
_ => None,
},
};
let added_items: Vec<(usize, pdfcraft_edit::Added)> = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == page).enumerate().collect();
// Ownership belongs to the drawing stream, not the XObject: original and added images may
// show the same resource without sharing an editable item.
let shows_image = |a: &pdfcraft_edit::Added| img.object.is_some_and(|t| matches!(&a.content, AddedContent::Image(ai) if ai.image == t));
let matched_added = added_items.iter().find(|(_, a)| target_stream_obj == Some(a.obj) && (img.object.is_none() || shows_image(a)));
let mut ai_to_update = None;
if let Some((added_idx, added_item)) = matched_added
&& let AddedContent::Image(ai) = added_item.content.clone()
{
let has_active_crop = ai.crop.iter().any(|v| v.abs() > 1e-6)
|| img.visible_rect.is_some_and(|v| {
(v[0] - img.rect[0]).abs() > 1e-4
|| (v[1] - img.rect[1]).abs() > 1e-4
|| (v[2] - img.rect[2]).abs() > 1e-4
|| (v[3] - img.rect[3]).abs() > 1e-4
});
let non_axis_transform = matches!(change, ImageEdit::Transform([a, b, c, d, _, _])
if !((b.abs() < 1e-5 && c.abs() < 1e-5) || (a.abs() < 1e-5 && d.abs() < 1e-5)));
let should_promote = has_active_crop
|| non_axis_transform
|| matches!(change, ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete);
if should_promote {
doc.update_dict(added_item.obj, |d| {
d.remove(b"PCAdded");
d.remove(b"PCMark");
})
.map_err(|e| EditError::Edit(pdfcraft_edit::EditError::Invalid(e.to_string())))?;
} else {
ai_to_update = Some((*added_idx, ai));
}
}
if let Some((added_idx, mut ai)) = ai_to_update {
let view = Matrix(page_model.view_matrix(doc));
let to_display = view.invert().ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid page transform".into())))?;
match change {
ImageEdit::Move(to) => {
ai.rect = to_display.bbox(*to);
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Transform(_) | ImageEdit::Flip { .. } => {
let pdfcraft_edit::ImageChange::Transform(t) = c else {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("expected transform".into())));
};
let t_display = view.then(&Matrix(t)).then(&to_display);
let [ta, tb, tc, td, _, _] = t_display.0;
let is_parallel = tb.abs() < 1e-5 && tc.abs() < 1e-5;
let is_perpendicular = ta.abs() < 1e-5 && td.abs() < 1e-5;
let (quarters_ccw, disp_flip_h, disp_flip_v) = if is_parallel {
(0, ta < 0.0, td < 0.0)
} else if is_perpendicular {
match (tb > 0.0, tc > 0.0) {
(true, false) => (1, false, false),
(false, true) => (3, false, false),
(true, true) => (1, false, true),
(false, false) => (1, true, false),
}
} else {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid(
"non-axis-aligned transforms are not supported for added images".into(),
)));
};
let (toggle_h, toggle_v) = if ai.rotation % 2 == 1 { (disp_flip_v, disp_flip_h) } else { (disp_flip_h, disp_flip_v) };
ai.flip_h ^= toggle_h;
ai.flip_v ^= toggle_v;
ai.rotation = (ai.rotation + quarters_ccw) % 4;
ai.rect = t_display.bbox(ai.rect);
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Rotate(q) => {
let k = ((4 - q.rem_euclid(4)) % 4) as u8;
ai.rotation = (ai.rotation + k) % 4;
let r = ai.rect;
let (cx, cy) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
if k % 2 == 1 {
let (w, h) = (r[2] - r[0], r[3] - r[1]);
ai.rect = [cx - h / 2.0, cy - w / 2.0, cx + h / 2.0, cy + w / 2.0];
}
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Replace { .. } => {
let pdfcraft_edit::ImageChange::Replace(new_image) = c else {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("expected replace".into())));
};
ai.image = new_image;
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete => {
pdfcraft_edit::change_image(doc, page, index, &c)?;
}
}
} else {
pdfcraft_edit::change_image(doc, page, index, &c)?;
}
Ok(())
}
/// Perform an edit on a working copy (the caller discards it on error).
pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> {
match edit {
@@ -525,40 +689,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
Edit::AddOcrText { page, words } => {
pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?;
}
Edit::EditPageImage { page, index, change } => {
let img = pdfcraft_edit::page_images(doc, *page)?
.into_iter()
.nth(*index)
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?;
let c = match change {
ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)),
ImageEdit::Rotate(q) => {
pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false))
}
ImageEdit::RotateBy(degrees) => {
let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees)
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?;
pdfcraft_edit::ImageChange::Transform(t)
}
ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(
img.visible_rect.unwrap_or(img.rect),
0,
*horizontal,
!*horizontal,
)),
ImageEdit::Replace { name, bytes } => {
if img.is_form {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid(
"grouped Form artwork cannot be replaced with a bitmap".into(),
)));
}
pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0)
}
ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep),
ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete,
};
pdfcraft_edit::change_image(doc, *page, *index, &c)?;
}
Edit::EditPageImage { page, index, change } => run_edit_page_image(doc, *page, *index, change)?,
Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?,
Edit::EditTextBlock { page, block, text, style } => {
pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?;
+2 -2
View File
@@ -4,7 +4,7 @@ use std::sync::Arc;
use pdfcraft_render::{Layer, LayerOp, RenderPool};
use crate::{Document, render_threads};
use crate::{Document, display_config, render_threads};
/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy
/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its
@@ -31,7 +31,7 @@ pub(crate) fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers:
pub(crate) fn use_layer_choices(doc: &mut Document) {
let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect();
doc.config.layers = Arc::new(overrides);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
}
/// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the
+13 -1
View File
@@ -54,6 +54,7 @@ mod stamp_preview;
mod test_hooks;
pub mod text_fonts;
mod text_preview;
pub mod text_script;
pub mod text_style_bookmarks;
pub mod word_count;
pub mod xfa;
@@ -64,6 +65,7 @@ use std::sync::Arc;
pub use links::{
MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path,
};
pub use pdfcraft_edit::strip_subset_tag;
pub use signature_image::{ImageSignaturePreview, SignatureImage};
pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
pub use stamp_preview::{CommentLayer, StampPreview};
@@ -137,7 +139,7 @@ pub use history::Label;
use layers::{apply_layer_state, set_default_layer_state, use_layer_choices};
pub use open::{OpenContext, PreparedDocument};
pub use page_filter::{PageOrientation, PageParity, filter_pages};
pub use pictures::MarkFile;
pub use pictures::{MarkFile, image_dimensions};
use pictures::{embed_image, fit_picture, mark_source};
use protection::unrestricted;
pub use protection::{Changes, Printing, Protection, SecuritySummary};
@@ -154,9 +156,19 @@ use xfa_form::{
mod clock;
mod doc_state;
pub use pdfcraft_edit::ArrangeOrder;
use clock::{local_utc_offset, parse_ymd};
pub use doc_state::{DocId, ImageEdit, OptimizeReport, RecoverySnapshot, SplitPart, guard, merge_data_files};
use doc_state::{LOW_RESOLUTION_PRINT_DPI, render_threads, renderer_password, signatures_of, writable};
/// The configuration of the pool that draws the viewer: the document's own, plus installed CJK
/// fonts for fonts a PDF does not embed (`pdfcraft_render::RenderConfig::system_fonts`). Exports,
/// printing, OCR and automation use the document configuration itself, so nothing drawn with an
/// installed font is saved or printed (AGENTS.md §1.2, §1.4).
fn display_config(config: &pdfcraft_render::RenderConfig) -> pdfcraft_render::RenderConfig {
config.clone().on_screen()
}
#[cfg(test)]
mod tests;
+3 -3
View File
@@ -7,8 +7,8 @@ use pdfcraft_render::{DocInfo, OpenError, RenderConfig, RenderPool, inspect};
use crate::history::{Editor, Keys, TextBlocksCache};
use crate::{
AppearanceCache, DocId, Document, Session, TrustStore, XFA_SCRIPTS_OFF, XfaLayout, display_bytes, guard, info_fields, js, note_warnings,
recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout, xfa_values_from_datasets,
AppearanceCache, DocId, Document, Session, TrustStore, XFA_SCRIPTS_OFF, XfaLayout, display_bytes, display_config, guard, info_fields, js,
note_warnings, recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout, xfa_values_from_datasets,
};
/// Immutable session state needed while a document is prepared away from its owning thread.
@@ -304,7 +304,7 @@ impl Session {
}
}
let display = display_bytes(editor.as_mut(), &bytes);
let renderer = RenderPool::new(display.clone(), render_threads(), config.clone());
let renderer = RenderPool::new(display.clone(), render_threads(), display_config(&config));
let (form, adopted) = editor
.as_ref()
.map(|e| {
+68
View File
@@ -2,6 +2,8 @@
use std::sync::Arc;
use image::ImageDecoder;
use crate::EditError;
/// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page`
@@ -57,3 +59,69 @@ pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Res
Ok(pdfcraft_edit::MarkSource { xobject, size, image: true })
}
}
/// The dimensions of an image from its bytes (JPEG, PNG, TIFF), without decoding the full raster.
pub fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
let format = image::guess_format(bytes).ok()?;
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), format);
let mut limits = image::Limits::default();
limits.max_alloc = Some(64_000_000 * 4);
reader.limits(limits);
let mut decoder = reader.into_decoder().ok()?;
let (mut w, mut h) = image::ImageDecoder::dimensions(&decoder);
if w == 0 || h == 0 {
return None;
}
if format == image::ImageFormat::Jpeg
&& u64::from(w) * u64::from(h) <= (64 << 20)
&& let Ok(orientation) = decoder.orientation()
&& matches!(
orientation,
image::metadata::Orientation::Rotate90
| image::metadata::Orientation::Rotate270
| image::metadata::Orientation::Rotate90FlipH
| image::metadata::Orientation::Rotate270FlipH
)
{
std::mem::swap(&mut w, &mut h);
}
Some((w, h))
}
#[cfg(test)]
mod tests {
use super::*;
fn phone_jpeg(w: u32, h: u32, orientation: u8) -> Vec<u8> {
use image::codecs::jpeg::JpegEncoder;
let mut jpeg = Vec::new();
let mut encoder = JpegEncoder::new(&mut jpeg);
encoder.encode_image(&image::RgbImage::from_pixel(w, h, image::Rgb([20, 20, 20]))).expect("valid jpeg");
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]);
let mut out = jpeg[..2].to_vec();
out.extend_from_slice(&[0xff, 0xe1]);
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
out.extend_from_slice(&exif);
out.extend_from_slice(&jpeg[2..]);
out
}
#[test]
fn jpeg_exif_orientation_swaps_dimensions_for_quarter_turns() {
let turned_6 = phone_jpeg(60, 20, 6);
assert_eq!(image_dimensions(&turned_6), Some((20, 60)));
let turned_8 = phone_jpeg(60, 20, 8);
assert_eq!(image_dimensions(&turned_8), Some((20, 60)));
let upright_1 = phone_jpeg(60, 20, 1);
assert_eq!(image_dimensions(&upright_1), Some((60, 20)));
}
#[test]
fn invalid_inputs_return_none() {
assert_eq!(image_dimensions(&[]), None);
assert_eq!(image_dimensions(b"not an image"), None);
}
}
+4 -4
View File
@@ -12,10 +12,10 @@ use crate::history::{Keys, MAX_UNDO, Scope, keys_after, scope_of, uses_scripts};
use crate::{
BookmarkPage, BookmarkRow, CombineSource, Combined, DataFormat, DocId, Document, Edit, EditError, Hidden, ImageResolution,
LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, SummarySort, TrustStore,
XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, comment_list, comment_summary, decode_text, display_bytes, guard, info_fields, js,
local_utc_offset, note_warnings, open_source, open_source_with, optimize, outline_rows, parse_ymd, print, print_raster, render_threads,
scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, source_kind, sync_xfa_state, undrawn_notices, use_layer_choices,
writable, xfa, xfa_sync_datasets,
XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, comment_list, comment_summary, decode_text, display_bytes, display_config, guard,
info_fields, js, local_utc_offset, note_warnings, open_source, open_source_with, optimize, outline_rows, parse_ymd, print, print_raster,
render_threads, scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, source_kind, sync_xfa_state, undrawn_notices,
use_layer_choices, writable, xfa, xfa_sync_datasets,
};
mod editing;
+22 -7
View File
@@ -2,12 +2,21 @@
use super::check_refresh_panic;
use super::{
Arc, DocId, Document, Edit, EditCtx, EditError, Label, MAX_UNDO, ObjectKind, OnceLock, RenderPool, SaveOptions, Scope, Session,
XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, check_permission, comment_list, display_bytes, guard, info_fields, inspect, js, keys_after, note_warnings,
render_threads, run_edit, scan_colours, scope_of, shown_under_redactions, signatures_of, sync_xfa_state, undrawn_notices, uses_scripts,
write_incremental, xfa, xfa_sync_datasets,
XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, check_permission, comment_list, display_bytes, display_config, guard, info_fields, inspect, js, keys_after,
note_warnings, render_threads, run_edit, scan_colours, scope_of, shown_under_redactions, signatures_of, sync_xfa_state, undrawn_notices,
uses_scripts, write_incremental, xfa, xfa_sync_datasets,
};
use crate::{EditNotice, MarkKind};
/// Whether the edit, or any edit inside a batch, deletes pages.
fn deletes_pages(edit: &Edit) -> bool {
match edit {
Edit::DeletePages { .. } => true,
Edit::Batch { edits, .. } => edits.iter().any(deletes_pages),
_ => false,
}
}
impl Session {
/// Apply an edit. On success the previous state is undoable and the view data is refreshed.
pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> {
@@ -163,6 +172,11 @@ impl Session {
// Signed documents are only ever saved incrementally: an edit that needs a full rewrite
// (applying redactions, changing security, sanitizing) would invalidate the signatures.
// So does any edit to a file rebuilt from object headers: it has no chain to append to.
// Deleted pages remain in earlier incremental revisions (#3407), so rewrite
// unsigned files in full. Signed files must retain their signed bytes.
if !signed && deletes_pages(&edit) {
next.require_full_save();
}
let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed();
if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) {
return Err(EditError::SignedRewrite(edit.label().to_string()));
@@ -375,7 +389,7 @@ impl Session {
let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) };
doc.display = display_bytes(Some(editor), &bytes);
doc.signatures = Arc::new(signatures);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.bytes = bytes;
Ok(())
}
@@ -399,6 +413,7 @@ impl Session {
| Edit::DeleteContent { .. }
| Edit::ReplaceImage { .. }
| Edit::PasteContent { .. }
| Edit::EditPageImage { .. }
) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added))
});
let touches_marks = edit.is_none_or(|e| {
@@ -432,7 +447,7 @@ impl Session {
let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) };
doc.display = display_bytes(Some(editor), &bytes);
doc.signatures = Arc::new(signatures);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.bytes = bytes;
doc.fonts_stale = true;
Ok(())
@@ -459,7 +474,7 @@ impl Session {
/// After a refresh failed: draw the display bytes it left (the original, once released),
/// so the page still shows a document.
pub(super) fn show_released(doc: &mut Document) {
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
}
/// Rebuild working bytes, inspection and renderer from the current edit state.
@@ -516,7 +531,7 @@ impl Session {
let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache);
doc.display = display_bytes(Some(editor), &bytes);
doc.signatures = Arc::new(signatures);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.bytes = bytes;
Ok(())
}
+4 -4
View File
@@ -1,6 +1,6 @@
use super::{
Arc, DocId, EditError, Keys, OpenError, RecoverySnapshot, RenderPool, SaveOptions, Session, SignOptions, TrustStore, display_bytes, guard,
render_threads, signatures_of, writable, write_full, write_incremental, xfa_sync_datasets,
Arc, DocId, EditError, Keys, OpenError, RecoverySnapshot, RenderPool, SaveOptions, Session, SignOptions, TrustStore, display_bytes,
display_config, guard, render_threads, signatures_of, writable, write_full, write_incremental, xfa_sync_datasets,
};
impl Session {
@@ -170,7 +170,7 @@ impl Session {
{
doc.config.reading_colors = old.config.reading_colors;
doc.config.hide_comments = old.config.hide_comments;
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
}
// Caches keyed by the edit generation (edit lines, images, measurements) must see a new
// one; there is nothing new to autosave.
@@ -249,7 +249,7 @@ impl Session {
let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache) };
doc.display = display_bytes(Some(editor), &doc.bytes);
doc.signatures = Arc::new(signatures);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
use super::{
Arc, BookmarkPage, BookmarkRow, DocId, Document, EditError, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, Session, SummarySort,
apply_layer_state, comment_summary, local_utc_offset, outline_rows, parse_ymd, render_threads, use_layer_choices,
apply_layer_state, comment_summary, display_config, local_utc_offset, outline_rows, parse_ymd, render_threads, use_layer_choices,
};
impl Session {
@@ -246,7 +246,7 @@ impl Session {
return false;
}
doc.config.hide_comments = hide;
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.generation += 1;
true
}
@@ -259,7 +259,7 @@ impl Session {
return false;
}
doc.config.reading_colors = colors;
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.generation += 1;
true
}
+693 -16
View File
@@ -33,6 +33,39 @@ pub(crate) fn fixture(n: usize) -> Vec<u8> {
out
}
fn fixture_with_rotation_and_user_unit(rotate: i64, user_unit: f64) -> Vec<u8> {
let mut objs: Vec<Vec<u8>> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()];
objs.push(b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec());
objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec());
let mut page_dict = String::from("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >>");
if rotate != 0 {
page_dict.push_str(&format!(" /Rotate {rotate}"));
}
if (user_unit - 1.0).abs() > 1e-6 {
page_dict.push_str(&format!(" /UserUnit {user_unit}"));
}
page_dict.push_str(" >>");
objs.push(page_dict.into_bytes());
let body = "BT /F1 24 Tf 20 150 Td (Page 1) Tj ET";
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes());
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());
out
}
fn session_with(n: usize) -> (Session, DocId) {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("fixture.pdf", None, Arc::new(fixture(n)), None).expect("opens");
@@ -581,6 +614,80 @@ fn edits_update_view_data_and_mark_dirty() {
assert_eq!(page_texts(&s, id), ["Page 1", "Page 3"], "renderer refreshed");
}
/// A font of an Adobe CJK collection that a PDF does not embed is drawn with an installed face on
/// the viewer's pool only. An export of the same page, which can be saved, has nothing drawn with
/// an installed font (AGENTS.md §1.2, §1.4). Skipped where no such face is installed.
#[test]
fn installed_cjk_fonts_draw_in_the_viewer_but_not_in_an_export() {
let content = "BT /F1 40 Tf 5 15 Td <7E419AD45B57> Tj ET";
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 60] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /Font /Subtype /Type0 /BaseFont /MSung-Light /Encoding /UniCNS-UCS2-H /DescendantFonts [6 0 R] >> endobj
6 0 obj << /Type /Font /Subtype /CIDFontType0 /BaseFont /MSung-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (CNS1) /Supplement 2 >> /FontDescriptor 7 0 R /DW 1000 >> endobj
7 0 obj << /Type /FontDescriptor /FontName /MSung-Light /Flags 6 /FontBBox [0 -141 1000 859] /ItalicAngle 0 /Ascent 859 /Descent -141 /CapHeight 700 /StemV 80 >> endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
);
let mut s = Session::new();
let id = s.open("cns1.pdf", None, Arc::new(pdf.into_bytes()), None).unwrap();
let doc = s.get(id).unwrap();
doc.renderer.set_queue(vec![pdfcraft_render::RenderRequest {
page: 0,
kind: pdfcraft_render::RequestKind::Pixels,
scale: 1.0,
..Default::default()
}]);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let viewer = loop {
if let Some(page) = doc.renderer.try_recv() {
assert!(page.error.is_none(), "{:?}", page.error);
break page;
}
assert!(std::time::Instant::now() < deadline, "page render did not finish");
std::thread::yield_now();
};
let inked = |rgba: &[u8]| rgba.as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count();
// Whether this machine has a face that can draw the page at all, asked of a renderer with the
// fallback switched on by hand, so a viewer pool that lost it fails below instead of skipping.
let probe = pdfcraft_render::PageRenderer::new(doc.display.clone(), pdfcraft_render::RenderConfig { system_fonts: true, ..Default::default() })
.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
assert!(probe.error.is_none(), "{:?}", probe.error);
if inked(&probe.rgba) < 300 {
eprintln!("no installed Traditional Chinese face (or PDFCRAFT_SYSTEM_FONTS=0): skipped");
return;
}
assert!(inked(&viewer.rgba) > 300, "the viewer's pool draws it with the installed face ({} dark pixels)", inked(&viewer.rgba));
let png = export::Exporter::new(doc).png(0, 72.0).unwrap();
let exported = image::load_from_memory(&png).unwrap().into_rgba8();
assert!(inked(exported.as_raw()) < 50, "an export draws nothing with an installed font ({} dark pixels)", inked(exported.as_raw()));
// Reload rebuilds the pool when retaining a view preference. That path must keep
// the display-only fallback too; otherwise Chinese disappears after a file refresh.
let bytes = doc.bytes.clone();
assert!(s.set_hide_comments(id, true));
s.reload(id, bytes).unwrap();
let doc = s.get(id).unwrap();
assert!(doc.comments_hidden());
doc.renderer.set_queue(vec![pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }]);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let reloaded = loop {
if let Some(page) = doc.renderer.try_recv() {
assert!(page.error.is_none(), "{:?}", page.error);
break page;
}
assert!(std::time::Instant::now() < deadline, "reloaded page render did not finish");
std::thread::yield_now();
};
assert!(inked(&reloaded.rgba) > 300, "reload must retain visible Chinese text");
}
#[test]
fn metadata_edit_keeps_the_renderer_without_changing_page_content() {
let (mut s, id) = session_with(1);
@@ -860,9 +967,10 @@ fn revert_preserves_the_saved_pages_after_undo() {
#[test]
fn full_save_is_readable_and_smaller_after_deletes() {
let (mut s, id) = session_with(5);
let original = s.save_bytes(id).unwrap();
s.apply(id, Edit::DeletePages { pages: vec![0, 1, 2] }).unwrap();
let full = s.save_full_bytes(id).unwrap();
assert!(full.len() < s.save_bytes(id).unwrap().len());
assert!(full.len() < original.len());
let mut s2 = Session::new();
let id2 = s2.open("full.pdf", None, full, None).unwrap();
assert_eq!(page_texts(&s2, id2), ["Page 4", "Page 5"]);
@@ -6084,24 +6192,27 @@ fn a_static_xfa_form_takes_many_data_values_as_one_by_one() {
/// Opening a static XFA form whose datasets fill thousands of fields took minutes: each value
/// read the whole form again (twice), so the time grew with the square of the fields. Eight
/// times the fields now takes at most about eight times as long (it took about fifty).
///
/// Each round opens the small form and then the large one, so a busy spell on a shared machine
/// slows both, and the best round's ratio is checked. Timing every small opening before the
/// large ones let such a spell fall on the large ones alone.
#[test]
fn opening_a_static_xfa_form_grows_linearly_with_its_data_values() {
let time = |n: usize| {
let bytes = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(n));
(0..3)
.map(|_| {
let mut s = Session::new();
let start = std::time::Instant::now();
s.open("many.pdf", None, bytes.clone(), None).expect("opens");
start.elapsed()
})
.min()
.unwrap_or_default()
let small = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(100));
let large = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(800));
let time = |bytes: &Arc<Vec<u8>>| {
let mut s = Session::new();
let start = std::time::Instant::now();
s.open("many.pdf", None, bytes.clone(), None).expect("opens");
start.elapsed()
};
let small = time(100);
let large = time(800);
let ratio = large.as_secs_f64() / small.as_secs_f64().max(1e-6);
assert!(ratio < 16.0, "100 fields: {small:?}, 800 fields: {large:?} ({ratio:.1}x)");
// The first openings also pay for what the process sets up once.
time(&small);
time(&large);
let rounds: Vec<_> = (0..5).map(|_| (time(&small), time(&large))).collect();
let ratio = |(s, l): &(std::time::Duration, std::time::Duration)| l.as_secs_f64() / s.as_secs_f64().max(1e-6);
let best = rounds.iter().map(ratio).fold(f64::INFINITY, f64::min);
assert!(best < 16.0, "100 and 800 fields, by round: {rounds:?} (best {best:.1}x)");
}
/// A date goes into the XFA data in the field's data picture (`<bind><picture>`), as other
@@ -8027,6 +8138,13 @@ fn copy_image_finds_the_image_under_a_point_and_decodes_it_at_full_size() {
assert_eq!((w, h), (3, 2));
assert_eq!(rgba, px.into_raw());
assert!(d.page_image_rgba(0, 1).is_err());
// When the image is cropped, page_image_at only hit-tests the visible cropped region:
let crop_keep = [r[0] + (r[2] - r[0]) * 0.25, r[1] + (r[3] - r[1]) * 0.25, r[0] + (r[2] - r[0]) * 0.75, r[1] + (r[3] - r[1]) * 0.75];
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop(crop_keep) }).unwrap();
let d = s.get(id).unwrap();
assert_eq!(d.page_image_at(0, [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]), Some(0));
assert_eq!(d.page_image_at(0, [r[0] + 0.1, r[1] + 0.1]), None, "cropped-away corner must not hit-test positive");
}
/// Copy Image: a JPEG drawn through grouped Form artwork (as Word, InDesign and scanners write
@@ -9044,6 +9162,474 @@ fn a_page_truncated_for_forms_and_pictures_is_listed_once() {
assert_eq!(truncated, [0]);
}
/// Moving an added image, then rotating it, must keep the moved position.
#[test]
fn added_image_move_then_rotate_keeps_position() {
let mut png = Vec::new();
{
let mut enc = png::Encoder::new(&mut png, 2, 2);
enc.set_color(png::ColorType::Rgb);
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
}
let (mut s, id) = session_with(1);
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap();
let rect = |s: &Session| s.get(id).unwrap().page_images(0)[0].rect;
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([120.0, 120.0, 160.0, 160.0]) }).unwrap();
let moved = rect(&s);
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap();
let rotated = rect(&s);
let c = |r: [f64; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
let (a, b) = (c(moved), c(rotated));
assert!((a.0 - b.0).abs() < 1.0 && (a.1 - b.1).abs() < 1.0, "rotate moved the centre from {a:?} to {b:?}");
}
/// Transforming an added image, then rotating it, must keep the moved position.
#[test]
fn added_image_transform_then_rotate_keeps_position() {
let mut png = Vec::new();
{
let mut enc = png::Encoder::new(&mut png, 2, 2);
enc.set_color(png::ColorType::Rgb);
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
}
let (mut s, id) = session_with(1);
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap();
let rect = |s: &Session| s.get(id).unwrap().page_images(0)[0].rect;
// Translate by (100, 100): matrix [1, 0, 0, 1, 100, 100]
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, 1.0, 100.0, 100.0]) }).unwrap();
let transformed = rect(&s);
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap();
let rotated = rect(&s);
let c = |r: [f64; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
let (a, b) = (c(transformed), c(rotated));
assert!((a.0 - b.0).abs() < 1.0 && (a.1 - b.1).abs() < 1.0, "rotate moved the centre from {a:?} to {b:?}");
}
/// Moving an added image, then rotating by degrees, must keep the moved position.
#[test]
fn added_image_move_then_rotate_by_keeps_position() {
let mut png = Vec::new();
{
let mut enc = png::Encoder::new(&mut png, 2, 2);
enc.set_color(png::ColorType::Rgb);
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
}
let (mut s, id) = session_with(1);
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap();
let rect = |s: &Session| s.get(id).unwrap().page_images(0)[0].rect;
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([120.0, 120.0, 160.0, 160.0]) }).unwrap();
let moved = rect(&s);
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::RotateBy(90.0) }).unwrap();
let rotated = rect(&s);
let c = |r: [f64; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
let (a, b) = (c(moved), c(rotated));
assert!((a.0 - b.0).abs() < 1.0 && (a.1 - b.1).abs() < 1.0, "rotate_by moved the centre from {a:?} to {b:?}");
}
/// Arranging an added image moves it into the document stream and converts it to an ordinary
/// page image (dropping the PCAdded marker), so no empty shell remains in list_added.
#[test]
fn added_image_arrange_converts_to_ordinary_image() {
let mut png = Vec::new();
{
let mut enc = png::Encoder::new(&mut png, 2, 2);
enc.set_color(png::ColorType::Rgb);
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
}
let (mut s, id) = session_with(1);
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p1.png".into(), bytes: Arc::new(png.clone()) }).unwrap();
s.apply(id, Edit::AddImage { page: 0, rect: Some([100.0, 100.0, 200.0, 200.0]), name: "p2.png".into(), bytes: Arc::new(png) }).unwrap();
assert_eq!(s.get(id).unwrap().page_images(0).len(), 2);
assert_eq!(s.get(id).unwrap().added().len(), 2);
s.apply(id, Edit::EditPageImage { page: 0, index: 1, change: ImageEdit::Arrange(ArrangeOrder::SendToBack) }).unwrap();
assert_eq!(s.get(id).unwrap().page_images(0).len(), 2);
assert_eq!(s.get(id).unwrap().added().len(), 1, "arranged image is converted and no longer an added-item shell");
}
#[test]
fn added_image_crop_then_move_keeps_crop() {
let mut png = Vec::new();
{
let mut enc = png::Encoder::new(&mut png, 2, 2);
enc.set_color(png::ColorType::Rgb);
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
}
let (mut s, id) = session_with(1);
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 100.0, 100.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap();
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([30.0, 30.0, 70.0, 70.0]) }).unwrap();
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([50.0, 50.0, 130.0, 130.0]) }).unwrap();
let img = s.get(id).unwrap().page_images(0)[0].clone();
assert_eq!(img.visible_rect, Some([60.0, 60.0, 100.0, 100.0]));
}
#[test]
fn added_image_move_and_transform_on_rotated_and_user_unit_pages() {
let mut png = Vec::new();
{
let mut enc = png::Encoder::new(&mut png, 2, 2);
enc.set_color(png::ColorType::Rgb);
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
}
let png_bytes = Arc::new(png);
let cases = [(90, 1.0), (180, 1.0), (270, 1.0), (0, 2.0), (90, 2.0)];
for (rotate, user_unit) in cases {
let pdf = fixture_with_rotation_and_user_unit(rotate, user_unit);
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("test.pdf", None, Arc::new(pdf), None).expect("opens");
s.apply(id, Edit::AddImage { page: 0, rect: Some([10.0, 10.0, 50.0, 50.0]), name: "img.png".into(), bytes: png_bytes.clone() }).unwrap();
// Target user-space rectangle R
let r = [30.0, 40.0, 80.0, 90.0];
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move(r) }).unwrap();
let painted = s.get(id).unwrap().page_images(0)[0].rect;
for i in 0..4 {
assert!(
(painted[i] - r[i]).abs() < 1e-3,
"Move: rotate={rotate}, user_unit={user_unit}: painted[{i}] = {}, expected {}",
painted[i],
r[i]
);
}
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
let added = pdfcraft_edit::list_added(&cos);
assert_eq!(added.len(), 1);
let stream_obj = match &*cos.get(added[0].obj) {
pdfcraft_cos::Object::Stream(s) => s.clone(),
other => panic!("expected stream, got {other:?}"),
};
let pc_rect = stream_obj
.dict
.get(b"PCAdded")
.and_then(|o| cos.resolve(o).as_dict().cloned())
.and_then(|d| d.get(b"Rect").cloned())
.and_then(|o| cos.resolve(&o).as_array().cloned())
.unwrap();
let pc_box: [f64; 4] =
[pc_rect[0].as_f64().unwrap(), pc_rect[1].as_f64().unwrap(), pc_rect[2].as_f64().unwrap(), pc_rect[3].as_f64().unwrap()];
let content = String::from_utf8(stream_obj.decoded().unwrap()).unwrap();
let re_idx = content.find(" re W n").expect("re W n in content stream");
let prefix = &content[..re_idx];
let nums: Vec<f64> =
prefix.split_whitespace().rev().take(4).collect::<Vec<_>>().into_iter().rev().map(|val| val.parse::<f64>().unwrap()).collect();
assert_eq!(nums.len(), 4);
let (rx, ry, rw, rh) = (nums[0], nums[1], nums[2], nums[3]);
assert!((rx - pc_box[0]).abs() < 1e-3, "re x {rx} != pc_box[0] {}", pc_box[0]);
assert!((ry - pc_box[1]).abs() < 1e-3, "re y {ry} != pc_box[1] {}", pc_box[1]);
assert!((rw - (pc_box[2] - pc_box[0])).abs() < 1e-3, "re w {rw} != {}", pc_box[2] - pc_box[0]);
assert!((rh - (pc_box[3] - pc_box[1])).abs() < 1e-3, "re h {rh} != {}", pc_box[3] - pc_box[1]);
// Transform test: user-space translation by (10, 10)
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, 1.0, 10.0, 10.0]) }).unwrap();
let expected_transformed = [r[0] + 10.0, r[1] + 10.0, r[2] + 10.0, r[3] + 10.0];
let painted_transformed = s.get(id).unwrap().page_images(0)[0].rect;
for i in 0..4 {
assert!(
(painted_transformed[i] - expected_transformed[i]).abs() < 1e-3,
"Transform: rotate={rotate}, user_unit={user_unit}: painted[{i}] = {}, expected {}",
painted_transformed[i],
expected_transformed[i]
);
}
}
}
#[test]
fn replace_on_added_image_replaces_xobject_and_undo_restores() {
let make_png = |rgb: [u8; 3]| {
let mut png = Vec::new();
{
let mut enc = png::Encoder::new(&mut png, 2, 2);
enc.set_color(png::ColorType::Rgb);
let data = [rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]];
enc.write_header().unwrap().write_image_data(&data).unwrap();
}
Arc::new(png)
};
let png1 = make_png([255, 0, 0]);
let png2 = make_png([0, 0, 255]);
let (mut s, id) = session_with(1);
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "img1.png".into(), bytes: png1 }).unwrap();
let old_obj = s.get(id).unwrap().page_images(0)[0].object.expect("initial image object ref");
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Replace { name: "img2.png".into(), bytes: png2 } }).unwrap();
let new_obj = s.get(id).unwrap().page_images(0)[0].object.expect("new image object ref");
assert_ne!(new_obj, old_obj, "after Replace the page references the new XObject");
s.undo(id).unwrap();
let restored_obj = s.get(id).unwrap().page_images(0)[0].object.expect("restored image object ref");
assert_eq!(restored_obj, old_obj, "undo restores the old XObject");
// Also test replacement on an image with active crop (promoted path)
let png3 = make_png([0, 255, 0]);
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([25.0, 25.0, 55.0, 55.0]) }).unwrap();
let crop_obj = s.get(id).unwrap().page_images(0)[0].object.expect("crop image object ref");
assert_eq!(crop_obj, old_obj);
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Replace { name: "img3.png".into(), bytes: png3 } }).unwrap();
let promoted_new_obj = s.get(id).unwrap().page_images(0)[0].object.expect("promoted replace object ref");
assert_ne!(promoted_new_obj, crop_obj);
s.undo(id).unwrap();
assert_eq!(s.get(id).unwrap().page_images(0)[0].object.expect("undo promoted replace"), crop_obj);
}
#[test]
fn mirroring_transform_on_added_image_flips_and_non_finite_is_refused() {
let make_png = |rgb: [u8; 3]| {
let mut png = Vec::new();
{
let mut enc = png::Encoder::new(&mut png, 2, 2);
enc.set_color(png::ColorType::Rgb);
let data = [rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]];
enc.write_header().unwrap().write_image_data(&data).unwrap();
}
Arc::new(png)
};
let png1 = make_png([255, 0, 0]);
let (mut s, id) = session_with(1);
s.apply(id, Edit::AddImage { page: 0, rect: Some([0.0, 0.0, 80.0, 40.0]), name: "img1.png".into(), bytes: png1 }).unwrap();
let initial_img = s.get(id).unwrap().page_images(0)[0].clone();
assert_eq!(initial_img.rect, [0.0, 0.0, 80.0, 40.0]);
assert!(initial_img.matrix[0] > 0.0, "initially unflipped");
// Mirroring transform: [-1.0, 0.0, 0.0, 1.0, 80.0, 0.0]
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([-1.0, 0.0, 0.0, 1.0, 80.0, 0.0]) }).unwrap();
let flipped_img = s.get(id).unwrap().page_images(0)[0].clone();
assert!(flipped_img.matrix[0] < 0.0, "horizontal mirror flips matrix x-axis, got matrix: {:?}", flipped_img.matrix);
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
let added = pdfcraft_edit::list_added(&cos);
assert_eq!(added.len(), 1);
let AddedContent::Image(ai) = &added[0].content else { panic!("expected added image") };
assert!(ai.flip_h, "PCAdded record must have flip_h = true");
// Mirroring horizontally again flips it back
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([-1.0, 0.0, 0.0, 1.0, 80.0, 0.0]) }).unwrap();
let unflipped_img = s.get(id).unwrap().page_images(0)[0].clone();
assert!(unflipped_img.matrix[0] > 0.0, "double horizontal mirror restores positive x-axis");
let cos2 = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
let AddedContent::Image(ai2) = &pdfcraft_edit::list_added(&cos2)[0].content else { panic!("expected added image") };
assert!(!ai2.flip_h, "PCAdded record must have flip_h = false");
// Mirroring vertically: [1.0, 0.0, 0.0, -1.0, 0.0, 40.0]
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, -1.0, 0.0, 40.0]) }).unwrap();
let v_flipped_img = s.get(id).unwrap().page_images(0)[0].clone();
assert!(v_flipped_img.matrix[3] < 0.0, "vertical mirror flips matrix y-axis, got matrix: {:?}", v_flipped_img.matrix);
let cos3 = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
let AddedContent::Image(ai3) = &pdfcraft_edit::list_added(&cos3)[0].content else { panic!("expected added image") };
assert!(ai3.flip_v, "PCAdded record must have flip_v = true");
// Non-finite transform must be refused with error
let res = s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0]) });
assert!(res.is_err(), "NaN transform must be refused");
let res_inf = s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, f64::INFINITY, 0.0, 0.0]) });
assert!(res_inf.is_err(), "infinite transform must be refused");
let res_move = s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([f64::NAN, 20.0, 100.0, 60.0]) });
assert!(res_move.is_err(), "NaN position must be refused");
}
#[test]
fn transform_on_added_image_folds_quarter_turns_and_promotes_non_axis_aligned() {
let make_png = |rgb: [u8; 3]| {
let mut png = Vec::new();
{
let mut enc = png::Encoder::new(&mut png, 2, 2);
enc.set_color(png::ColorType::Rgb);
let data = [rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]];
enc.write_header().unwrap().write_image_data(&data).unwrap();
}
Arc::new(png)
};
let png1 = make_png([0, 128, 255]);
let (mut s, id) = session_with(1);
s.apply(id, Edit::AddImage { page: 0, rect: Some([100.0, 100.0, 200.0, 150.0]), name: "img.png".into(), bytes: png1 }).unwrap();
// 1. A pure 90° clockwise rotation matrix about the image centre:
let rot_90_cw = pdfcraft_edit::turn_about_centre([100.0, 100.0, 200.0, 150.0], 1, false, false);
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(rot_90_cw) }).unwrap();
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
let added = pdfcraft_edit::list_added(&cos);
assert_eq!(added.len(), 1, "stays as PCAdded image");
let AddedContent::Image(ai) = &added[0].content else { panic!("expected added image") };
assert_eq!(ai.rotation, 3, "quarter turn clockwise folds into ai.rotation = 3 (270° CCW)");
// Centre is (150, 125); rotated width=50, height=100 -> [125, 75, 175, 175]
assert!((ai.rect[0] - 125.0).abs() < 1e-4);
assert!((ai.rect[1] - 75.0).abs() < 1e-4);
assert!((ai.rect[2] - 175.0).abs() < 1e-4);
assert!((ai.rect[3] - 175.0).abs() < 1e-4);
// 2. A pure 90° counter-clockwise rotation matrix:
let rot_90_ccw = pdfcraft_edit::turn_about_centre(ai.rect, 3, false, false);
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(rot_90_ccw) }).unwrap();
let cos2 = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
let AddedContent::Image(ai2) = &pdfcraft_edit::list_added(&cos2)[0].content else { panic!("expected added image") };
assert_eq!(ai2.rotation, 0, "quarter turn counter-clockwise restores ai.rotation = 0");
assert!((ai2.rect[0] - 100.0).abs() < 1e-4);
assert!((ai2.rect[1] - 100.0).abs() < 1e-4);
assert!((ai2.rect[2] - 200.0).abs() < 1e-4);
assert!((ai2.rect[3] - 150.0).abs() < 1e-4);
// Arbitrary transforms promote the added image, just like RotateBy.
let initial_matrix = s.get(id).unwrap().page_images(0)[0].matrix;
let rot_45 = pdfcraft_edit::turn_by_degrees([100.0, 100.0, 200.0, 150.0], 45.0).unwrap();
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(rot_45) }).unwrap();
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
assert!(pdfcraft_edit::list_added(&cos).is_empty(), "arbitrary transform promotes the image");
assert_eq!(s.get(id).unwrap().page_images(0).len(), 1);
let expected = pdfcraft_content::Matrix(initial_matrix).then(&pdfcraft_content::Matrix(rot_45)).0;
let actual = s.get(id).unwrap().page_images(0)[0].matrix;
assert!(actual.into_iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-3), "{actual:?} != {expected:?}");
s.undo(id).unwrap();
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
assert_eq!(pdfcraft_edit::list_added(&cos).len(), 1, "undo restores added-image editability");
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.5, 0.0, 1.0, 0.0, 0.0]) }).unwrap();
assert_eq!(s.get(id).unwrap().page_images(0).len(), 1);
}
#[test]
fn test_added_image_transform_reflections_with_rotation() {
let make_asymmetric_png = || {
let mut px = Vec::with_capacity(20 * 20 * 3);
for y in 0..20 {
for x in 0..20 {
let color = match (x < 10, y < 10) {
(true, true) => [255, 0, 0], // TL: Red
(false, true) => [0, 255, 0], // TR: Green
(true, false) => [0, 0, 255], // BL: Blue
(false, false) => [255, 255, 0], // BR: Yellow
};
px.extend_from_slice(&color);
}
}
let mut png = Vec::new();
{
let mut enc = png::Encoder::new(&mut png, 20, 20);
enc.set_color(png::ColorType::Rgb);
enc.write_header().unwrap().write_image_data(&px).unwrap();
}
Arc::new(png)
};
let colour_at = |s: &Session, id: DocId, x: u32, y_from_top: u32| -> [u8; 3] {
let doc = s.get(id).unwrap();
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default());
let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
let i = ((y_from_top * out.width + x) * 4) as usize;
[out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]]
};
let is_red = |c: [u8; 3]| c[0] > 200 && c[1] < 50 && c[2] < 50;
let is_green = |c: [u8; 3]| c[0] < 50 && c[1] > 200 && c[2] < 50;
let is_blue = |c: [u8; 3]| c[0] < 50 && c[1] < 50 && c[2] > 200;
let is_yellow = |c: [u8; 3]| c[0] > 200 && c[1] > 200 && c[2] < 50;
let check_quadrants =
|s: &Session, id: DocId, tl: fn([u8; 3]) -> bool, tr: fn([u8; 3]) -> bool, bl: fn([u8; 3]) -> bool, br: fn([u8; 3]) -> bool, label: &str| {
let c_tl = colour_at(s, id, 75, 125);
let c_tr = colour_at(s, id, 125, 125);
let c_bl = colour_at(s, id, 75, 175);
let c_br = colour_at(s, id, 125, 175);
assert!(tl(c_tl), "{label} TL mismatch: got {:?}", c_tl);
assert!(tr(c_tr), "{label} TR mismatch: got {:?}", c_tr);
assert!(bl(c_bl), "{label} BL mismatch: got {:?}", c_bl);
assert!(br(c_br), "{label} BR mismatch: got {:?}", c_br);
};
// --- CASE 1: 90° clockwise rotation (ImageEdit::Rotate(1), ai.rotation = 3) ---
// Unrotated: TL=Red, TR=Green, BL=Blue, BR=Yellow.
// After 90° CW: TL=Blue, TR=Red, BL=Yellow, BR=Green.
{
// 1a: Flip Horizontal after 90° rotation
let (mut s, id) = session_with(1);
s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() })
.unwrap();
check_quadrants(&s, id, is_red, is_green, is_blue, is_yellow, "initial");
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap();
check_quadrants(&s, id, is_blue, is_red, is_yellow, is_green, "90° CW");
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: true } }).unwrap();
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() };
assert_eq!(ai.rotation, 3);
assert!(!ai.flip_h, "at 90° rotation, horizontal flip toggles flip_v, not flip_h");
assert!(ai.flip_v, "at 90° rotation, horizontal flip toggles flip_v");
// Flipping horizontally on screen swaps left & right columns: TL=Red, TR=Blue, BL=Green, BR=Yellow
check_quadrants(&s, id, is_red, is_blue, is_green, is_yellow, "90° CW + flip horizontal");
}
{
// 1b: Flip Vertical after 90° rotation
let (mut s, id) = session_with(1);
s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() })
.unwrap();
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap();
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: false } }).unwrap();
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() };
assert_eq!(ai.rotation, 3);
assert!(ai.flip_h, "at 90° rotation, vertical flip toggles flip_h");
assert!(!ai.flip_v, "at 90° rotation, vertical flip toggles flip_h, not flip_v");
// Flipping vertically on screen swaps top & bottom rows: TL=Yellow, TR=Green, BL=Blue, BR=Red
check_quadrants(&s, id, is_yellow, is_green, is_blue, is_red, "90° CW + flip vertical");
}
// --- CASE 2: 270° clockwise rotation (ImageEdit::Rotate(3), ai.rotation = 1) ---
// Unrotated: TL=Red, TR=Green, BL=Blue, BR=Yellow.
// After 270° CW: TL=Green, TR=Yellow, BL=Red, BR=Blue.
{
// 2a: Flip Horizontal after 270° rotation
let (mut s, id) = session_with(1);
s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() })
.unwrap();
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(3) }).unwrap();
check_quadrants(&s, id, is_green, is_yellow, is_red, is_blue, "270° CW");
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: true } }).unwrap();
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() };
assert_eq!(ai.rotation, 1);
assert!(!ai.flip_h, "at 270° rotation, horizontal flip toggles flip_v, not flip_h");
assert!(ai.flip_v, "at 270° rotation, horizontal flip toggles flip_v");
// Flipping horizontally on screen swaps left & right columns: TL=Yellow, TR=Green, BL=Blue, BR=Red
check_quadrants(&s, id, is_yellow, is_green, is_blue, is_red, "270° CW + flip horizontal");
}
{
// 2b: Flip Vertical after 270° rotation
let (mut s, id) = session_with(1);
s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() })
.unwrap();
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(3) }).unwrap();
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: false } }).unwrap();
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() };
assert_eq!(ai.rotation, 1);
assert!(ai.flip_h, "at 270° rotation, vertical flip toggles flip_h");
assert!(!ai.flip_v, "at 270° rotation, vertical flip toggles flip_h, not flip_v");
// Flipping vertically on screen swaps top & bottom rows: TL=Red, TR=Blue, BL=Green, BR=Yellow
check_quadrants(&s, id, is_red, is_blue, is_green, is_yellow, "270° CW + flip vertical");
}
}
/// ISO 32000-2 Table 192, 12.7.5.2.3 and Table 228 (Comb): a check box, a push button, a comb
/// field the file leaves to the viewer. The page as displayed and printed as an image shows the
/// check box's caption symbol in its text colour, the button's grey face and the comb's cells in
@@ -9335,3 +9921,94 @@ fn repeated_backdrop_artwork_survives_background_undo_redo_and_both_save_modes()
s.apply(rewritten, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap();
black(&s, rewritten);
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|w| w == needle)
}
#[test]
fn saving_after_deleting_pages_rewrites_the_file_without_them() {
let (mut s, id) = session_with(6);
let original = s.save_bytes(id).unwrap();
s.apply(id, Edit::DeletePages { pages: vec![1, 2, 3, 4] }).unwrap();
let saved = s.save_bytes(id).unwrap();
assert!(saved.len() < original.len(), "the file shrank: {} -> {}", original.len(), saved.len());
assert!(!saved.starts_with(original.as_slice()), "a full rewrite, not an update appended to the old file");
assert!(!contains(&saved, b"Page 3"), "a deleted page's text is not left in an earlier revision");
let mut again = Session::new();
let id2 = again.open("saved.pdf", None, saved, None).unwrap();
assert_eq!(page_texts(&again, id2), ["Page 1", "Page 6"]);
}
#[test]
fn saving_after_deleting_pages_from_a_signed_file_stays_incremental() {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
let mut s = Session::new().with_clock(|| 1_800_000_000);
let id = s.open("fixture.pdf", None, Arc::new(fixture(3)), None).unwrap();
let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 160.0, 50.0]), ..SignOptions::default() };
let signed = s.sign(id, &digital_id, opts).unwrap();
s.mark_signed(id, signed.clone(), None).unwrap();
s.apply(id, Edit::DeletePages { pages: vec![2] }).unwrap();
let saved = s.save_bytes(id).unwrap();
assert!(saved.starts_with(signed.as_ref()), "the signed bytes stay an incremental update");
assert!(s.get(id).unwrap().is_signed());
}
#[test]
fn saving_after_a_batch_that_deletes_pages_rewrites_the_file() {
let (mut s, id) = session_with(6);
let original = s.save_bytes(id).unwrap();
let batch = Edit::Batch {
label: "Delete and rotate".into(),
edits: vec![
Edit::Batch { label: "Delete pages".into(), edits: vec![Edit::DeletePages { pages: vec![1, 2, 3, 4] }] },
Edit::RotatePages { pages: vec![0], degrees: 90 },
],
};
s.apply(id, batch).unwrap();
let saved = s.save_bytes(id).unwrap();
assert!(!saved.starts_with(original.as_slice()), "a full rewrite, not an update appended to the old file");
assert!(!contains(&saved, b"Page 3"), "a deleted page's text is not left in an earlier revision");
}
#[test]
fn saving_without_deleting_pages_stays_incremental() {
let (mut s, id) = session_with(3);
let original = s.save_bytes(id).unwrap();
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
let saved = s.save_bytes(id).unwrap();
assert!(saved.starts_with(original.as_slice()), "an edit that deletes nothing is still appended");
}
#[test]
fn original_image_edit_does_not_match_an_added_sibling_of_the_same_xobject() {
let mut document = pdfcraft_cos::Document::open(Arc::new(jpeg_cover_pdf(650))).unwrap();
let original = pdfcraft_edit::page_images(&document, 0).unwrap()[0].clone();
let added = AddedImage::new([20.0, 30.0, 80.0, 90.0], original.object.unwrap());
pdfcraft_edit::add_content(&mut document, 0, &AddedContent::Image(added.clone())).unwrap();
let bytes = pdfcraft_cos::write_full(&document, &pdfcraft_cos::SaveOptions::default()).unwrap();
let mut session = Session::new();
let id = session.open("shared-image.pdf", None, Arc::new(bytes), None).unwrap();
let before_added = session.get(id).unwrap().added()[0].content.clone();
session.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([40.0, 50.0, 140.0, 150.0]) }).unwrap();
let doc = session.get(id).unwrap();
assert_eq!(doc.added()[0].content, before_added, "matching an original Do must not select the added sibling's metadata");
assert_eq!(doc.page_images(0)[0].rect, [40.0, 50.0, 140.0, 150.0]);
}
#[test]
fn image_transform_engine_refuses_singular_matrices_without_mutation() {
let mut png = Vec::new();
let mut enc = png::Encoder::new(&mut png, 1, 1);
enc.set_color(png::ColorType::Rgb);
enc.write_header().unwrap().write_image_data(&[100, 150, 200]).unwrap();
let mut s = Session::new();
let bytes = s.create_from_images(&[("pixel.png".into(), png)]).unwrap();
let id = s.open("image.pdf", None, bytes, None).unwrap();
for matrix in [[0.0, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 1.0, 1.0, 1.0 + 1e-12, 0.0, 0.0], [1e308, 0.0, 0.0, 1e308, 0.0, 0.0]] {
let before = s.get(id).unwrap().bytes.clone();
assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(matrix) }).is_err());
assert_eq!(s.get(id).unwrap().bytes, before);
}
}
+190 -10
View File
@@ -32,17 +32,29 @@ pub fn installed_directories() -> Vec<PathBuf> {
}
}
/// Bounded, symlink-free scan. Only standalone, openly licensed, embeddable faces are listed.
/// Bounded scan with resolved roots and no descendant symlinks.
/// Only standalone, openly licensed, embeddable faces are listed.
/// The caller controls roots (automation resolves confinement before calling this).
pub fn discover(roots: &[PathBuf]) -> Vec<FontFile> {
discover_budgeted(roots, 64u64 << 20)
}
/// Bounded scan with resolved roots and no descendant symlinks. `budget` caps the total
/// size of the fonts listed: a rejected file (unreadable, not openly licensed, a duplicate)
/// does not spend it. Every file read
/// counts against four times the budget, so a folder of unusable fonts cannot stall the scan.
pub fn discover_budgeted(roots: &[PathBuf], mut budget: u64) -> Vec<FontFile> {
use std::io::Read;
let mut pending: Vec<_> = roots.iter().take(16).map(|r| (r.clone(), 0usize)).collect();
// User font directories themselves are often links. Resolve only the explicitly
// supplied roots; links found below them must not escape an automation read root.
let mut pending: Vec<_> = roots.iter().take(16).filter_map(|r| r.canonicalize().ok()).map(|r| (r, 0usize)).collect();
let mut files = Vec::new();
let mut entries = 0usize;
let mut budget = 64u64 << 20;
let mut read_allowance = budget.saturating_mul(4);
let mut seen = std::collections::HashSet::new();
let mut seen_face_content = std::collections::HashSet::new();
while let Some((path, depth)) = pending.pop() {
if entries >= 4096 || budget == 0 {
if entries >= 4096 || budget == 0 || read_allowance == 0 {
break;
}
entries += 1;
@@ -53,26 +65,194 @@ pub fn discover(roots: &[PathBuf]) -> Vec<FontFile> {
if meta.is_dir() && depth < 4 {
if let Ok(children) = std::fs::read_dir(&path) {
let remaining = 4096usize.saturating_sub(entries.saturating_add(pending.len()));
pending.extend(children.take(remaining).filter_map(Result::ok).map(|p| (p.path(), depth + 1)));
// Best effort: unreadable entries are skipped and do not count against the entry allowance.
let mut sorted_children: Vec<_> = children.filter_map(Result::ok).take(remaining).map(|p| p.path()).collect();
sorted_children.sort();
pending.extend(sorted_children.into_iter().rev().map(|p| (p, depth + 1)));
}
} else if meta.is_file() && meta.len() <= (16 << 20) && font_extension(&path) {
if meta.len() > budget {
if meta.len() > budget || meta.len() > read_allowance {
continue;
}
budget = budget.saturating_sub(meta.len());
read_allowance = read_allowance.saturating_sub(meta.len());
let mut bytes = Vec::new();
let read = std::fs::File::open(&path).and_then(|f| f.take((16 << 20) + 1).read_to_end(&mut bytes));
if read.is_err() || pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() {
let read = std::fs::File::open(&path).and_then(|f| f.take(meta.len().saturating_add(1)).read_to_end(&mut bytes));
if read.is_err() || bytes.len() != meta.len() as usize {
continue;
}
if pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() {
continue;
}
if let Ok((family, face)) = pdfcraft_fonts::embedding::names(&bytes) {
files.push(FontFile { path, family, face });
let key = font_content_key(&bytes);
if seen_face_content.insert((face.clone(), key)) {
budget = budget.saturating_sub(meta.len());
files.push(FontFile { path, family, face });
}
}
}
}
files.sort_by(|a, b| a.face.cmp(&b.face).then(a.path.cmp(&b.path)));
files
}
fn font_content_key(bytes: &[u8]) -> (usize, u64) {
use std::hash::{DefaultHasher, Hasher};
let mut h = DefaultHasher::new();
h.write(bytes);
(bytes.len(), h.finish())
}
fn font_extension(path: &Path) -> bool {
path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf"))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn a_linked_font_root_is_scanned_but_descendant_links_are_not() {
let root = std::env::temp_dir().join(format!("pdfcraft-font-links-{}", std::process::id()));
let real = root.join("real");
std::fs::create_dir_all(&real).unwrap();
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Regular.ttf");
std::fs::copy(&source, real.join("regular.ttf")).unwrap();
let link = root.join("linked");
std::os::unix::fs::symlink(&real, &link).unwrap();
std::os::unix::fs::symlink(&real, real.join("cycle")).unwrap();
std::os::unix::fs::symlink(source.with_file_name("Inter-Medium.ttf"), real.join("escape.ttf")).unwrap();
let found = discover(&[link]);
let expected = real.canonicalize().unwrap().join("regular.ttf");
std::fs::remove_dir_all(&root).unwrap();
assert_eq!(found.len(), 1);
assert_eq!(found.first().unwrap().face, "Inter-Regular");
assert_eq!(found.first().unwrap().path, expected);
}
#[test]
fn discovery_keeps_all_valid_faces_within_its_scan_budget() {
let root = std::env::temp_dir().join(format!("pdfcraft-font-count-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Regular.ttf");
let bytes = std::fs::read(source).unwrap();
for id in 0..501u32 {
let mut font = bytes.clone();
// Valid standalone fonts with distinct bytes: an unreferenced trailer
// does not change their tables or embedding licence.
font.extend_from_slice(&id.to_le_bytes());
std::fs::write(root.join(format!("font-{id:03}.ttf")), font).unwrap();
}
let found = discover_budgeted(std::slice::from_ref(&root), (bytes.len() as u64 + 4) * 501);
std::fs::remove_dir_all(&root).unwrap();
assert_eq!(found.len(), 501, "do not silently discard accepted fonts after sorting");
}
#[test]
fn test_installed_directories() {
let dirs = installed_directories();
assert!(!dirs.is_empty());
}
#[test]
fn test_font_extension() {
assert!(font_extension(Path::new("Arial.ttf")));
assert!(font_extension(Path::new("Noto.otf")));
assert!(font_extension(Path::new("CUSTOM.TTF")));
assert!(font_extension(Path::new("ROBOTO.OTF")));
assert!(!font_extension(Path::new("document.pdf")));
assert!(!font_extension(Path::new("font.woff")));
assert!(!font_extension(Path::new("font.woff2")));
assert!(!font_extension(Path::new("no_extension")));
}
#[test]
fn test_font_content_key() {
let empty = font_content_key(&[]);
assert_eq!(empty.0, 0);
let a = font_content_key(b"TrueType font sample data bytes");
let b = font_content_key(b"TrueType font sample data bytes");
let c = font_content_key(b"Different font sample data bytes");
assert_eq!(a, b, "identical bytes must produce identical keys");
assert_ne!(a, c, "differing bytes must produce distinct keys");
assert_eq!(a.0, b"TrueType font sample data bytes".len());
}
#[test]
fn test_discover_empty_and_nonexistent_roots() {
let empty = discover(&[]);
assert!(empty.is_empty());
let nonexistent = discover(&[PathBuf::from("Z:\\nonexistent_font_path_xyz_12345")]);
assert!(nonexistent.is_empty());
}
#[test]
fn test_discover_deduplicates_non_adjacent_same_content() {
let root = std::env::temp_dir().join(format!("pdfcraft-font-dedup-test-{}", std::process::id()));
let dir_a = root.join("a");
let dir_b = root.join("b");
let dir_c = root.join("c");
let _ = std::fs::create_dir_all(&dir_a);
let _ = std::fs::create_dir_all(&dir_b);
let _ = std::fs::create_dir_all(&dir_c);
let inter_reg = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Regular.ttf");
let inter_med = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Medium.ttf");
if inter_reg.exists() && inter_med.exists() {
let _ = std::fs::copy(&inter_reg, dir_a.join("FontA.ttf"));
let _ = std::fs::copy(&inter_med, dir_b.join("FontB.ttf"));
let _ = std::fs::copy(&inter_reg, dir_c.join("FontC.ttf"));
let discovered = discover(std::slice::from_ref(&root));
let _ = std::fs::remove_dir_all(&root);
assert_eq!(discovered.len(), 2, "identical content in non-adjacent paths is deduplicated");
let faces: Vec<&str> = discovered.iter().map(|f| f.face.as_str()).collect();
assert!(faces.contains(&"Inter-Regular"));
assert!(faces.contains(&"Inter-Medium"));
} else {
let _ = std::fs::remove_dir_all(&root);
}
}
#[test]
fn rejected_fonts_do_not_spend_the_budget_and_the_order_is_deterministic() {
let root = std::env::temp_dir().join(format!("pdfcraft-font-budget-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let fonts = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts");
let reg_len = std::fs::metadata(fonts.join("Inter-Regular.ttf")).unwrap().len();
let med_len = std::fs::metadata(fonts.join("Inter-Medium.ttf")).unwrap().len();
// An unusable candidate, scanned first (sorted before the real fonts).
std::fs::write(root.join("00_candidate_invalid.ttf"), vec![0u8; 400_000]).unwrap();
std::fs::copy(fonts.join("Inter-Regular.ttf"), root.join("01_Inter-Regular.ttf")).unwrap();
// The budget fits Inter-Regular alone; the rejected file must not use it up first.
let run1 = discover_budgeted(std::slice::from_ref(&root), reg_len + 10);
let run2 = discover_budgeted(std::slice::from_ref(&root), reg_len + 10);
let faces = |run: &[FontFile]| run.iter().map(|f| (f.face.clone(), f.path.clone())).collect::<Vec<_>>();
assert_eq!(run1.iter().map(|f| f.face.as_str()).collect::<Vec<_>>(), ["Inter-Regular"], "a rejected file spends no budget");
assert_eq!(faces(&run1), faces(&run2));
// Accepted fonts do spend it: Inter-Medium no longer fits after Inter-Regular.
std::fs::copy(fonts.join("Inter-Medium.ttf"), root.join("02_Inter-Medium.ttf")).unwrap();
let one = discover_budgeted(std::slice::from_ref(&root), reg_len + 10);
assert_eq!(one.iter().map(|f| f.face.as_str()).collect::<Vec<_>>(), ["Inter-Regular"]);
let both = discover_budgeted(std::slice::from_ref(&root), reg_len + med_len);
assert_eq!(both.len(), 2);
// Reading stops at four times the budget, whatever was rejected.
for i in 0..4 {
std::fs::write(root.join(format!("00_unusable_{i}.ttf")), vec![0u8; reg_len as usize]).unwrap();
}
let capped = discover_budgeted(std::slice::from_ref(&root), reg_len + 10);
let _ = std::fs::remove_dir_all(&root);
assert!(capped.is_empty(), "the read allowance ends the scan before the real fonts");
}
}
+272
View File
@@ -0,0 +1,272 @@
//! Superscript and subscript as Unicode characters (Edit a PDF ▸ Format text): `2` ↔ `²` and
//! `2` ↔ `₂`.
//!
//! The text keeps its font, size and baseline; its characters change to the forms Unicode encodes
//! for raised and lowered text, so the font must have those glyphs, and extracted text contains
//! them. Characters Unicode has no such form for are left as they are.
//!
//! A selection mixing plain and target-script characters is normalized: `H₂2` becomes `H₂₂`,
//! then `H22` on the next subscript toggle; superscript follows the same policy. Two toggles
//! restore uniformly plain or uniformly scripted input, but do not reconstruct an original
//! mixture. Restoring that exact prior text requires the caller's edit history (Undo).
use std::ops::Range;
/// Raised or lowered text.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Script {
Super,
Sub,
}
/// Each character Unicode raises, and its superscript form.
const SUPERSCRIPT: &[(char, char)] = &[
('0', '⁰'),
('1', '¹'),
('2', '²'),
('3', '³'),
('4', '⁴'),
('5', '⁵'),
('6', '⁶'),
('7', '⁷'),
('8', '⁸'),
('9', '⁹'),
('+', '⁺'),
('−', '⁻'),
('=', '⁼'),
('(', '⁽'),
(')', '⁾'),
('a', 'ᵃ'),
('b', 'ᵇ'),
('c', 'ᶜ'),
('d', 'ᵈ'),
('e', 'ᵉ'),
('f', 'ᶠ'),
('g', 'ᵍ'),
('h', 'ʰ'),
('i', 'ⁱ'),
('j', 'ʲ'),
('k', 'ᵏ'),
('l', 'ˡ'),
('m', 'ᵐ'),
('n', 'ⁿ'),
('o', 'ᵒ'),
('p', 'ᵖ'),
('r', 'ʳ'),
('s', 'ˢ'),
('t', 'ᵗ'),
('u', 'ᵘ'),
('v', 'ᵛ'),
('w', 'ʷ'),
('x', 'ˣ'),
('y', 'ʸ'),
('z', 'ᶻ'),
('A', 'ᴬ'),
('B', 'ᴮ'),
('D', 'ᴰ'),
('E', 'ᴱ'),
('G', 'ᴳ'),
('H', 'ᴴ'),
('I', 'ᴵ'),
('J', 'ᴶ'),
('K', 'ᴷ'),
('L', 'ᴸ'),
('M', 'ᴹ'),
('N', 'ᴺ'),
('O', 'ᴼ'),
('P', 'ᴾ'),
('R', 'ᴿ'),
('T', 'ᵀ'),
('U', 'ᵁ'),
('V', 'ⱽ'),
('W', 'ᵂ'),
];
/// Each character Unicode lowers, and its subscript form.
const SUBSCRIPT: &[(char, char)] = &[
('0', '₀'),
('1', '₁'),
('2', '₂'),
('3', '₃'),
('4', '₄'),
('5', '₅'),
('6', '₆'),
('7', '₇'),
('8', '₈'),
('9', '₉'),
('+', '₊'),
('−', '₋'),
('=', '₌'),
('(', '₍'),
(')', '₎'),
('a', 'ₐ'),
('e', 'ₑ'),
('h', 'ₕ'),
('i', 'ᵢ'),
('j', 'ⱼ'),
('k', 'ₖ'),
('l', 'ₗ'),
('m', 'ₘ'),
('n', 'ₙ'),
('o', 'ₒ'),
('p', 'ₚ'),
('r', 'ᵣ'),
('s', 'ₛ'),
('t', 'ₜ'),
('u', 'ᵤ'),
('v', 'ᵥ'),
('x', 'ₓ'),
];
impl Script {
fn pairs(self) -> &'static [(char, char)] {
match self {
Script::Super => SUPERSCRIPT,
Script::Sub => SUBSCRIPT,
}
}
/// `c` raised or lowered, or `None` when Unicode has no distinct reversible form for it.
/// Mathematical minus U+2212 has a form; ASCII hyphen-minus stays unchanged to avoid a lossy collision.
pub fn form(self, c: char) -> Option<char> {
self.pairs().iter().find(|(plain, _)| *plain == c).map(|&(_, form)| form)
}
/// The character `c` is the raised or lowered form of, or `None` when it is no such form.
pub fn plain(self, c: char) -> Option<char> {
self.pairs().iter().find(|(_, form)| *form == c).map(|&(plain, _)| plain)
}
}
/// `range` (UTF-8 bytes of `text`) clamped to it and widened to whole characters, or all of `text`
/// when there is none.
fn span(text: &str, range: Option<&Range<usize>>) -> Range<usize> {
let Some(range) = range else { return 0..text.len() };
let mut start = range.start.min(text.len());
while !text.is_char_boundary(start) {
start = start.saturating_sub(1);
}
let mut end = range.end.min(text.len());
while !text.is_char_boundary(end) {
end = end.saturating_add(1);
}
start..end
}
/// Whether the characters of `text` in `range` (UTF-8 bytes; all of it when `None`) are already in
/// `script`: at least one of them is, and none of the others could be. Characters Unicode has no
/// form for (a space, a `q`) don't count toward that state.
pub fn is_in(text: &str, range: Option<&Range<usize>>, script: Script) -> bool {
let target = text.get(span(text, range)).unwrap_or_default();
target.chars().any(|c| script.plain(c).is_some()) && target.chars().all(|c| script.plain(c).is_some() || script.form(c).is_none())
}
/// Superscript or Subscript on the characters of `text` in `range` (UTF-8 bytes; all of it when
/// `None`): back to plain characters when they are already in `script` ([`is_in`]), and otherwise
/// each character that has a form takes it. Returns the new text and the range the converted
/// characters now take, or `None` when nothing changes.
///
/// Mixed plain/scripted input normalizes to the target script and then to plain text; for
/// example, `H₂2` → `H₂₂` → `H22`. This stateless conversion does not remember the original
/// mixture. Use edit history to restore it, rather than treating a second toggle as Undo.
pub fn toggle(text: &str, range: Option<&Range<usize>>, script: Script) -> Option<(String, Range<usize>)> {
let span = span(text, range);
let target = text.get(span.clone()).filter(|t| !t.is_empty())?;
let converted: String = if is_in(text, Some(&span), script) {
target.chars().map(|c| script.plain(c).unwrap_or(c)).collect()
} else {
target.chars().map(|c| script.form(c).unwrap_or(c)).collect()
};
if converted == target {
return None;
}
let mut out = String::with_capacity(text.len().saturating_add(converted.len()));
out.push_str(text.get(..span.start).unwrap_or_default());
out.push_str(&converted);
out.push_str(text.get(span.end..).unwrap_or_default());
let end = span.start.saturating_add(converted.len());
Some((out, span.start..end))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_form_toggles_back_to_its_character() {
for script in [Script::Super, Script::Sub] {
for &(plain, form) in script.pairs() {
let s = plain.to_string();
let (raised, range) = toggle(&s, None, script).unwrap();
assert_eq!(raised, form.to_string());
assert_eq!(range, 0..raised.len());
assert_eq!(toggle(&raised, None, script).unwrap().0, s, "{script:?} {plain}");
}
}
assert_eq!(toggle("-", None, Script::Super), None);
assert_eq!(toggle("-", None, Script::Sub), None);
}
#[test]
fn review_script_round_trips_preserve_both_minus_characters() {
for script in [Script::Super, Script::Sub] {
let text = "−2 -3";
let once = toggle(text, None, script).unwrap().0;
assert_eq!(toggle(&once, None, script).unwrap().0, text);
}
}
#[test]
fn text_with_characters_unicode_cannot_raise_toggles_back() {
// q has no superscript form and Q no subscript one; the space neither.
for (script, text, toggled) in [(Script::Super, "x2 q", "ˣ² q"), (Script::Sub, "CO2 Q", "CO₂ Q")] {
let (once, _) = toggle(text, None, script).unwrap();
assert_eq!(once, toggled);
assert!(is_in(&once, None, script));
assert_eq!(toggle(&once, None, script).unwrap().0, text, "{script:?}");
}
// Nothing to raise: nothing changes.
assert_eq!(toggle("q Q", None, Script::Super), None);
assert!(!is_in("q Q", None, Script::Super));
}
#[test]
fn a_range_converts_only_its_characters_and_follows_them() {
let (text, range) = toggle("E = mc2", Some(&(6..7)), Script::Super).unwrap();
assert_eq!((text.as_str(), range.clone()), ("E = mc²", 6..8));
assert_eq!(toggle(&text, Some(&range), Script::Super).unwrap(), ("E = mc2".to_string(), 6..7));
// A mixed range takes the form throughout.
assert_eq!(toggle("H₂2", None, Script::Sub).unwrap().0, "H₂₂");
assert!(!is_in("H₂2", None, Script::Sub));
}
#[test]
fn mixed_plain_and_scripted_text_normalizes_instead_of_round_tripping() {
for (script, mixed, uniform, plain) in [(Script::Super, "Q²2", "Q²²", "Q22"), (Script::Sub, "H₂2", "H₂₂", "H22")] {
assert!(!is_in(mixed, None, script));
let (once, range) = toggle(mixed, None, script).unwrap();
assert_eq!(once, uniform);
assert_eq!(range, 0..uniform.len());
assert!(is_in(&once, None, script));
let (twice, range) = toggle(&once, None, script).unwrap();
assert_eq!(twice, plain);
assert_eq!(range, 0..plain.len());
assert_ne!(twice, mixed, "a second toggle normalizes the original mixture");
assert_eq!(toggle(&twice, None, script).unwrap().0, uniform);
}
}
#[test]
fn hostile_ranges_are_clamped_to_whole_characters() {
// Byte 1 is inside ²: widened to the whole character on each side.
assert_eq!(toggle("²x", Some(&(1..1)), Script::Super).unwrap(), ("2x".to_string(), 0..1));
assert_eq!(toggle("²x", Some(&(2..2)), Script::Super), None);
assert_eq!(toggle("x2", Some(&(1..usize::MAX)), Script::Super).unwrap(), ("x²".to_string(), 1..3));
assert_eq!(toggle("x2", Some(&(5..9)), Script::Super), None);
#[allow(clippy::reversed_empty_ranges)]
let backwards = 2..1;
assert_eq!(toggle("x2", Some(&backwards), Script::Super), None);
assert_eq!(toggle("", None, Script::Sub), None);
assert!(!is_in("", None, Script::Sub));
}
}
+1 -1
View File
@@ -72,7 +72,7 @@ needed.
wasm32 builds embed only BIZ UDPGothic Regular and any `Arab`, `Telu`, `Hebr` or `Thai` face, to keep the web build small: the web build
currently has no Chinese face, so Chinese there still shows the replacement glyph.
Font files are never
committed here (`AGENTS.md` §1.4; team members: [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md), internal).
committed here (`AGENTS.md` §1.4).
The full font subsystem (parsing, shaping, subsetting and embedding) arrives with M2.2/M7.
+79 -3
View File
@@ -618,13 +618,29 @@ fn jpeg_samples(data: &[u8], w: usize, h: usize, n: usize, ycc: bool) -> Option<
}
pub(crate) fn resample(px: &[u8], n: usize, w: u32, h: u32, nw: u32, nh: u32) -> Option<Vec<u8>> {
use image::imageops::{FilterType, resize};
Some(match n {
1 => resize(&image::GrayImage::from_raw(w, h, px.to_vec())?, nw, nh, FilterType::CatmullRom).into_raw(),
_ => resize(&image::RgbImage::from_raw(w, h, px.to_vec())?, nw, nh, FilterType::CatmullRom).into_raw(),
1 => scaled(&image::GrayImage::from_raw(w, h, px.to_vec())?, nw, nh).into_raw(),
_ => scaled(&image::RgbImage::from_raw(w, h, px.to_vec())?, nw, nh).into_raw(),
})
}
/// `img` scaled to `w` × `h`. Scaling goes down first, through a buffer of `img`'s width by `h` at
/// 16 bytes a pixel, which a soft mask of other proportions than its image can make larger than
/// any machine: 1,000,000 × 1 resampled to 4 × 100,000 asks for 1.6 TB, and that aborts the
/// process. Such a one is scaled across first, which holds no more pixels than one of the two has.
/// Past a quarter over the larger of them, so that rounding leaves a picture of the same shape as
/// it was.
fn scaled<P: image::Pixel<Subpixel = u8> + 'static>(img: &image::ImageBuffer<P, Vec<u8>>, w: u32, h: u32) -> image::ImageBuffer<P, Vec<u8>> {
use image::imageops::{FilterType, resize};
let (iw, ih) = (u64::from(img.width()), u64::from(img.height()));
let largest = (iw * ih).max(u64::from(w) * u64::from(h));
if (iw * u64::from(h)).saturating_mul(4) > largest.saturating_mul(5) {
resize(&resize(img, w, img.height(), FilterType::CatmullRom), w, h, FilterType::CatmullRom)
} else {
resize(img, w, h, FilterType::CatmullRom)
}
}
fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option<Vec<u8>> {
let mut out = Vec::new();
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100));
@@ -1244,6 +1260,66 @@ mod dimension_tests {
assert_eq!(DECODED_PIXELS.with(|c| c.get()), 0, "pixels decoded");
assert_eq!(pixels(&Document::new_empty(), &stream(1000, 1000), 1).map(|p| p.len()), Some(1000 * 1000));
}
/// Resampling goes through no more than one of the two sizes holds, whatever their proportions:
/// the first three shapes asked for 160 GB and more on the way, and that aborted the process.
/// The last two would ask for 10 GB and 30 GB if they went across first, which is why the way
/// round depends on the shapes. A flat picture stays flat, and pictures of ordinary proportions
/// resample as they always did.
#[test]
fn resampling_across_proportions_takes_the_memory_of_neither_product() {
let shapes = [
(100_000u32, 1u32, 1u32, 100_000u32, 1usize),
(1_000_000, 1, 4, 100_000, 1),
(100_000, 1, 1, 100_000, 3),
(1, 100_000, 100_000, 1, 1),
(1, 100_000, 100_000, 1, 3),
];
for (w, h, nw, nh, n) in shapes {
let px = vec![0x60; (w * h) as usize * n];
let out = resample(&px, n, w, h, nw, nh).unwrap();
assert_eq!(out.len(), (nw * nh) as usize * n);
assert!(out.iter().all(|v| v.abs_diff(0x60) <= 1), "{w} × {h} to {nw} × {nh}");
}
let (w, h, nw, nh) = (40u32, 30u32, 20u32, 15u32);
let px: Vec<u8> = (0..w * h).map(|i| (i * 7 % 251) as u8).collect();
let image = image::GrayImage::from_raw(w, h, px.clone()).unwrap();
let direct = image::imageops::resize(&image, nw, nh, image::imageops::FilterType::CatmullRom).into_raw();
assert_eq!(resample(&px, 1, w, h, nw, nh).unwrap(), direct, "as it was");
}
/// The long way round is for proportions far apart: up to a quarter over the larger picture the
/// buffer is no danger, and that scales as it always did, to the byte. Noise, as a flat picture comes
/// out the same either way.
#[test]
fn proportions_up_to_the_limit_resample_as_they_did() {
let noise = |w: u32, h: u32| -> Vec<u8> {
let mut seed = 0x2545_F491_4F6C_DD1Du64;
(0..w * h)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed as u8
})
.collect()
};
let direct = |px: &[u8], w, h, nw, nh| {
let image = image::GrayImage::from_raw(w, h, px.to_vec()).unwrap();
image::imageops::resize(&image, nw, nh, image::imageops::FilterType::CatmullRom).into_raw()
};
// Each exactly a quarter over the larger: the source's size, then the target's.
for (w, h, nw, nh) in [(25, 16, 10, 20), (20, 5, 16, 25)] {
let px = noise(w, h);
assert_eq!(resample(&px, 1, w, h, nw, nh).unwrap(), direct(&px, w, h, nw, nh), "{w} × {h} to {nw} × {nh}");
}
// A step past it goes the long way, which is how to tell.
let px = noise(25, 15);
assert_ne!(resample(&px, 1, 25, 15, 10, 20).unwrap(), direct(&px, 25, 15, 10, 20));
// A picture of the size asked for comes through as it is.
let px = noise(20, 10);
assert_eq!(resample(&px, 1, 20, 10, 20, 10).unwrap(), px);
}
}
#[cfg(test)]
+2 -1
View File
@@ -1775,7 +1775,8 @@ fn monochrome_images_keep_what_they_should() {
optimize(&mut doc, &Settings::default()).unwrap();
assert_eq!([alone, deep].map(|r| raw(&doc, r)), before, "a Group 4 halftone alone, and sixteen-bit grey");
assert_eq!(filters(&doc, wrapped), 1, "a wrapped Group 4 halftone is stored again alone by default");
assert_eq!(stream(&doc, scan).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "the scan: Group 4 is smaller");
assert!(matches!(stream(&doc, scan).filters().as_slice(), [(Filter::CcittFax | Filter::Flate, _)]), "the scan: Group 4 or Flate");
assert_eq!(doc.decoded_image(&stream(&doc, scan)).unwrap(), ragged, "the scan preserves every sample");
assert_eq!((stream(&doc, fax).dict.name(b"Filter"), filters(&doc, fax)), (Some(&b"CCITTFaxDecode"[..]), 1), "the busy fax image");
let (mut doc, [_, wrapped, scan, _, _]) = build();
+2 -2
View File
@@ -13,6 +13,8 @@ pdfcraft-cos = { workspace = true }
pdfcraft-fonts = { workspace = true }
hayro = { workspace = true }
hayro-syntax = { workspace = true }
# Picks the face of an installed font collection (the version pdfcraft-fonts already uses).
skrifa = "0.44"
lopdf = { workspace = true }
kurbo = { workspace = true }
thiserror = { workspace = true }
@@ -27,7 +29,5 @@ bytemuck = { version = "1" }
getrandom = { version = "0.4", features = ["wasm_js"] }
[dev-dependencies]
# Checks the Japanese substitute's character coverage; the version pdfcraft-fonts already uses.
skrifa = "0.44"
# Builds the decompression-bomb streams in the raster tests.
flate2 = { workspace = true }
+1
View File
@@ -13,6 +13,7 @@ mod pixels;
mod raster;
mod reading;
mod structure;
mod system_font;
pub mod text;
pub mod transition;
+48 -1
View File
@@ -78,15 +78,36 @@ pub struct RenderConfig {
/// The page clears to transparent instead of white (or the reading background): for a page
/// holding only what is laid over another raster, such as a comment while it is dragged.
pub transparent: bool,
/// Draw a CID font of an Adobe CJK collection that the PDF does not embed with one face already
/// installed on this machine (see `system_font`). Off by default and on only for on-screen
/// viewer and preview rasters: exports, printing and OCR render without it, so nothing drawn with an
/// installed font is saved or printed (AGENTS.md §1.2, §1.4).
pub system_fonts: bool,
}
impl RenderConfig {
/// This configuration for a raster that is only shown on screen (the viewer, Combine
/// thumbnails, figure previews): installed CJK faces may draw fonts the PDF doesn't embed.
/// Never use it for anything that can be saved, printed, recognised or returned to an agent
/// (AGENTS.md §1.2, §1.4).
#[must_use]
pub fn on_screen(self) -> Self {
Self { system_fonts: true, ..self }
}
/// Build interpreter settings with layer and comment preferences and a font resolver that
/// tries bundled Japanese faces, then opt-in installed CJK faces, then standard substitutes.
/// Passwords belong to parser construction; request-specific preview overrides are applied
/// by `render_page`, which also preserves the caller's warning and cancellation hooks.
fn settings(&self) -> InterpreterSettings {
let standard = InterpreterSettings::default().font_resolver;
let system_fonts = self.system_fonts;
InterpreterSettings {
ocg_overrides: self.layers.clone(),
hide_comments: self.hide_comments,
font_resolver: Arc::new(move |query| japanese_fallback(query).or_else(|| standard(query))),
font_resolver: Arc::new(move |query| {
japanese_fallback(query).or_else(|| system_fonts.then(|| system_fallback(query)).flatten()).or_else(|| standard(query))
}),
..InterpreterSettings::default()
}
}
@@ -119,6 +140,14 @@ fn japanese_fallback(query: &FontQuery) -> Option<(FontData, u32)> {
Some((Arc::new(face.bytes), 0))
}
/// An installed face for a CID font of an Adobe CJK collection that the PDF doesn't embed and
/// craft-fonts has no face for (Traditional and Simplified Chinese, Korean, and Japanese in a
/// build without craft-fonts). `None` for every other font.
fn system_fallback(query: &FontQuery) -> Option<(FontData, u32)> {
let FontQuery::Fallback(f) = query else { return None };
crate::system_font::fallback(f)
}
#[derive(Debug, PartialEq)]
enum JapaneseFace {
Mincho,
@@ -182,6 +211,7 @@ pub struct RenderRequest {
/// The tag bit of a print-preview raster. Such requests always render the document's true
/// colours, ignoring `RenderConfig::reading_colors`: a preview's job is fidelity to the
/// printout, and the print itself — imposed from the document — is never recoloured.
/// Installed-font fallback is also disabled, matching the output's font policy.
pub const PRINT_TAG: u64 = 1 << 60;
/// The reading colours a request renders with: print previews ([`PRINT_TAG`]) ignore the
@@ -319,6 +349,23 @@ fn render_page<'a>(
config: &RenderConfig,
req: RenderRequest,
) -> Result<Output, (String, bool)> {
// Print previews share the viewer pool, but must use the output font policy. Keep the
// caller's cancellation/warning hooks, layers and annotation settings. A fresh cache is
// essential: hayro keys cached fonts by PDF dictionary, not by the font resolver, so the
// viewer cache could supply an installed face even after changing the resolver (and a
// cached missing font from print preview could blank the viewer on switching back).
let print_settings;
let print_cache;
let (settings, cache) = if config.system_fonts && req.tag & PRINT_TAG != 0 {
print_settings = InterpreterSettings {
font_resolver: RenderConfig { system_fonts: false, ..config.clone() }.settings().font_resolver,
..settings.clone()
};
print_cache = RenderCache::new();
(&print_settings, &print_cache)
} else {
(settings, cache)
};
let reading = request_reading(config, &req);
if req.kind == RequestKind::Text {
return match catch_unwind(AssertUnwindSafe(|| crate::text::extract_page(pdf, req.page, settings))) {
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,60 @@
//! A same-machine pixel oracle, independent of GBK decoding and installed-face selection.
//! All font reads and reference rasters stay in memory; nothing is embedded or published.
use super::*;
use hayro::hayro_interpret::font::FontData;
use skrifa::MetadataProvider as _;
use skrifa::string::StringId;
/// Compare the real GBK PDF path with explicit Unicode and a known installed face. Unlike an
/// ink count or extracted text, this fails if drawing chooses another glyph or font family.
pub(super) fn assert_expected_glyphs(pdf: &Arc<Vec<u8>>, base_font: &str) {
let (expected, file) = match base_font {
"SimSun" | "ABCDEF+FZSSK--GBK1-0" => ("SimSun", "simsun.ttc"),
"SimHei" | "ABCDEF+FZHTK--GBK1-0" => ("SimHei", "simhei.ttf"),
"KaiTi" | "ABCDEF+FZKTK--GBK1-0" => ("KaiTi", "simkai.ttf"),
"FangSong" | "ABCDEF+FZFSK--GBK1-0" => ("FangSong", "simfang.ttf"),
other => panic!("no independent GBK oracle for {other}"),
};
// Do not use the production candidate list, resolver or face-selection helpers here.
let directory = std::env::var_os("WINDIR").or_else(|| std::env::var_os("SystemRoot")).unwrap_or_else(|| r"C:\Windows".into());
let path = std::path::PathBuf::from(directory).join("Fonts").join(file);
if !path.is_file() {
eprintln!("{base_font}: exact {expected} face unavailable; pixel oracle skipped");
return;
}
let bytes = Arc::new(std::fs::read(path).expect("read installed reference font"));
let index = (0..32)
.find(|&index| {
skrifa::FontRef::from_index(&bytes, index)
.is_ok_and(|font| font.localized_strings(StringId::POSTSCRIPT_NAME).any(|name| name.chars().collect::<String>() == expected))
})
.expect("reference file contains the expected PostScript face");
let face = skrifa::FontRef::from_index(&bytes, index).unwrap();
for character in ['\u{4e2d}', '\u{6587}'] {
assert!(face.charmap().map(character).is_some_and(|id| id.to_u32() != 0), "{expected} must contain {character}");
}
// Explicit ToUnicode maps arbitrary reference codes to U+4E2D and U+6587, bypassing GBK
// and Adobe-GB1 CID lookup. The fixed resolver below bypasses family classification too.
let cmap = "begincmap /CMapType 2 def 1 begincodespacerange <0000> <FFFF> endcodespacerange \
2 beginbfchar <0001> <4E2D> <0002> <6587> endbfchar endcmap";
let source = std::str::from_utf8(pdf).unwrap();
assert!(source.contains("/Encoding /GBK-EUC-H") && source.contains("<D6D0CEC4>"));
let reference = source
.replace("/Encoding /GBK-EUC-H", "/Encoding /Identity-H /ToUnicode 8 0 R")
.replace("<D6D0CEC4>", "<00010002>")
.replace("trailer <<", &format!("8 0 obj << /Length {} >> stream\n{cmap}\nendstream endobj\ntrailer <<", cmap.len()));
let parsed = Pdf::new(Arc::new(reference.into_bytes())).unwrap();
let data: FontData = bytes;
let settings = InterpreterSettings { font_resolver: Arc::new(move |_| Some((data.clone(), index))), ..Default::default() };
let reference = hayro::render(&parsed.pages()[0], &RenderCache::new(), &settings, &RenderSettings { bg_color: WHITE, ..Default::default() });
let mut renderer = PageRenderer::new(pdf.clone(), RenderConfig::default().on_screen());
let actual = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
assert!(actual.error.is_none(), "{base_font}: {:?}", actual.error);
let expected_pixels = reference.data_as_u8_slice();
assert_eq!(actual.rgba.len(), expected_pixels.len());
assert!(expected_pixels.as_chunks::<4>().0.iter().filter(|pixel| pixel[0] < 128).count() > 300, "reference must draw two visible glyphs");
assert!(actual.rgba.as_ref() == expected_pixels, "{base_font}: GBK raster must draw the exact {expected} glyphs for U+4E2D U+6587");
eprintln!("{base_font}: exact {expected} Unicode-reference pixels matched");
}
+519
View File
@@ -0,0 +1,519 @@
//! Installed CJK faces for CID fonts a PDF doesn't embed.
//!
//! Acrobat and other tools often write Chinese, Japanese or Korean text with one of the Adobe
//! collections (Adobe-Japan1, GB1, CNS1, Korea1) and a font name such as `KozGoPr6N-Medium` or
//! `MSung-Light` without embedding the font. hayro maps those CIDs to Unicode itself, but it needs
//! a face to draw them with; without one the text is blank. The craft-fonts Japanese faces come
//! first (see `japanese_fallback` in `raster.rs`); this module is the last resort: one face
//! already installed on this machine, used for the viewer only (`RenderConfig::system_fonts`, off
//! for exports, printing and OCR). It is read at runtime and never embedded or shipped
//! (AGENTS.md §1.4), Noto CJK and Source Han are left out on purpose (§1.1), and
//! `PDFCRAFT_SYSTEM_FONTS=0` turns it off, as for the interface font.
use std::collections::HashMap;
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use hayro::hayro_interpret::font::{FallbackFontQuery, FontData};
use hayro::hayro_interpret::hayro_cmap::CidFamily;
use skrifa::MetadataProvider as _;
use skrifa::string::StringId;
/// Larger files are not read: a font path is still untrusted input. CJK system faces are big
/// collections (PingFang is tens of megabytes).
const MAX_BYTES: u64 = 96 << 20;
/// Faces tried in a collection (`.ttc`).
const MAX_FACES: u32 = 32;
/// Downloaded font assets looked at per kind on macOS.
const MAX_ASSETS: usize = 512;
/// The writing system a character collection is for.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum Script {
Japanese,
SimplifiedChinese,
TraditionalChinese,
Korean,
}
impl Script {
/// Map the four supported Adobe CJK collections to their writing systems. Identity and
/// custom collections provide no regional hint and cannot select an installed face here.
fn of(family: &CidFamily) -> Option<Self> {
match family {
CidFamily::AdobeJapan1 => Some(Self::Japanese),
CidFamily::AdobeGB1 => Some(Self::SimplifiedChinese),
CidFamily::AdobeCNS1 => Some(Self::TraditionalChinese),
CidFamily::AdobeKorea1 => Some(Self::Korean),
CidFamily::AdobeIdentity | CidFamily::Custom { .. } => None,
}
}
/// A character the face must have to be worth using. Each is common and not shared by the
/// other scripts' faces: 物 (Japanese; also Chinese, so it only rules out Latin-only faces),
/// 欢 (Simplified only), 說 (Traditional only), 한 (Hangul).
fn probe(self) -> char {
match self {
Self::Japanese => '\u{7269}',
Self::SimplifiedChinese => '\u{6B22}',
Self::TraditionalChinese => '\u{8AAA}',
Self::Korean => '\u{D55C}',
}
}
}
/// One installed file and the words (in a face's family name) that pick the face for the right
/// region out of a collection that holds several (PingFang SC, TC and HK share one file).
struct Candidate {
path: PathBuf,
region: &'static [&'static str],
}
/// Keep named Windows body, heading, calligraphic and FangSong faces distinct. A cache keyed only
/// by script and weight would make the first Chinese font on a page stand in for all of them.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum ChineseFamily {
Default,
Song,
Hei,
Kai,
FangSong,
}
impl ChineseFamily {
/// The family a font name asks for: the Windows and Founder (方正) names that Chinese PDFs,
/// CNKI papers in particular, use without embedding the font. FangSong is tested first, as
/// its names contain "song" too. Founder PostScript names are matched by prefix (`FZSSK--GBK1-0`
/// is 方正书宋, `FZXBSK--GBK1-0` 方正小标宋, `FZHTK--GBK1-0` 方正黑体, `FZKTK--GBK1-0` 方正楷体,
/// `FZFSK--GBK1-0` 方正仿宋).
fn of(name: &str) -> Self {
// PDF subset names prepend six uppercase ASCII letters and '+'. Match the actual
// PostScript name so Founder prefixes work for subsets too.
let name = match name.split_once('+') {
Some((tag, family)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) => family,
_ => name,
};
let name = name.to_ascii_lowercase();
let has = |hints: &[&str]| hints.iter().any(|hint| name.contains(hint));
let starts = |prefixes: &[&str]| prefixes.iter().any(|p| name.starts_with(p));
if has(&["fangsong", "simfang", "仿宋"]) || starts(&["fzfs"]) {
Self::FangSong
} else if has(&["simsun", "stsong", "songti", "song", "宋"]) || starts(&["fzss", "fzbs", "fzxbs", "fzcs"]) {
Self::Song
} else if has(&["simhei", "stheiti", "stxihei", "heiti", "黑"]) || starts(&["fzht", "fzhei", "fzlth", "fzdh"]) {
Self::Hei
} else if has(&["kaiti", "stkaiti", "kai", "楷"]) || starts(&["fzkt", "fzkai"]) {
Self::Kai
} else {
Self::Default
}
}
}
/// The installed face to draw a CID font of `query`'s collection with, as `(data, face index)`.
/// `None` when it is turned off, the collection isn't one of the four, or nothing fits.
pub(crate) fn fallback(query: &FallbackFontQuery) -> Option<(FontData, u32)> {
if cfg!(target_arch = "wasm32") || std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") {
return None;
}
let script = Script::of(&query.character_collection.as_ref()?.family)?;
let bold = query.is_bold || query.font_weight >= 600;
let family = ChineseFamily::of(query.post_script_name.as_deref().unwrap_or_default());
/// A face file and the index of the chosen face in it, or `None` when nothing fits.
type Face = Option<(Arc<Vec<u8>>, u32)>;
type Faces = HashMap<(Script, bool, ChineseFamily), Face>;
static CACHE: Mutex<Option<Faces>> = Mutex::new(None);
let mut cache = CACHE.lock().unwrap_or_else(PoisonError::into_inner);
let found = cache.get_or_insert_with(HashMap::new).entry((script, bold, family)).or_insert_with(|| {
candidates(script, family, bold).iter().find_map(|c| {
read(c, script.probe(), bold).inspect(|(_, index)| {
log::info!("CJK font fallback for {script:?}/{family:?}: {} (face {index})", c.path.display());
})
})
});
found.as_ref().map(|(bytes, index)| (Arc::clone(bytes) as FontData, *index))
}
/// Read a candidate through the bounded file cache and select a face that maps `probe`,
/// preferring the requested region and weight. Unreadable files and unsuitable faces return
/// `None`, allowing the caller to try the next candidate without changing the PDF.
fn read(candidate: &Candidate, probe: char, bold: bool) -> Option<(Arc<Vec<u8>>, u32)> {
let bytes = file_bytes(&candidate.path)?;
let index = pick_face(&bytes, probe, candidate.region, bold)?;
Some((bytes, index))
}
/// The bytes of an installed font file, read once for the whole process and shared: a collection
/// such as `PingFang.ttc` is tens of megabytes and is the candidate of several scripts and both
/// weights, so the per-script cache keeps only a handle and a face index. A file that cannot be
/// used is remembered too, so it is not tried again for every script or family.
fn file_bytes(path: &Path) -> Option<Arc<Vec<u8>>> {
/// The bytes of each file tried, or `None` for one that cannot be used.
type Files = HashMap<PathBuf, Option<Arc<Vec<u8>>>>;
static FILES: Mutex<Option<Files>> = Mutex::new(None);
let mut files = FILES.lock().unwrap_or_else(PoisonError::into_inner);
files.get_or_insert_with(HashMap::new).entry(path.to_path_buf()).or_insert_with(|| read_file(path, MAX_BYTES).map(Arc::new)).clone()
}
/// A regular file of at most `max` bytes, or `None`: a font path is untrusted input.
fn read_file(path: &Path, max: u64) -> Option<Vec<u8>> {
// Reject special files before opening them (in particular a FIFO can block on open).
let meta = std::fs::metadata(path).ok()?;
if !meta.is_file() || meta.len() > max {
return None;
}
let file = std::fs::File::open(path).ok()?;
let meta = file.metadata().ok()?;
if !meta.is_file() || meta.len() > max {
return None;
}
// Bound the actual read too: the file can grow after the metadata check.
let mut bytes = Vec::new();
file.take(max.saturating_add(1)).read_to_end(&mut bytes).ok()?;
(bytes.len() as u64 <= max).then_some(bytes)
}
/// The best face of the file that parses and maps `probe`: the region's face when the file has
/// one, then the regular (or, for bold text, the semibold or bold) weight, then the first.
fn pick_face(bytes: &[u8], probe: char, region: &[&str], bold: bool) -> Option<u32> {
let mut best: Option<(u32, u32)> = None;
for index in 0..MAX_FACES {
let Ok(font) = skrifa::FontRef::from_index(bytes, index) else { continue };
if font.charmap().map(probe).is_none() {
continue;
}
let names = |id: StringId| font.localized_strings(id).map(|s| s.chars().collect::<String>()).collect::<Vec<_>>();
let family = names(StringId::FAMILY_NAME).join(" ");
let style = names(StringId::SUBFAMILY_NAME).join(" ").to_ascii_lowercase();
let mut score = 1;
if region.iter().any(|r| family.split_whitespace().any(|w| w == *r)) {
score += 4;
}
let weighty = style.contains("bold") || style.contains("semibold") || style.contains("heavy");
let plain = style.is_empty() || style.contains("regular") || style.contains("normal") || style.contains("medium") && !bold;
if (bold && weighty) || (!bold && plain && !weighty) {
score += 2;
}
if best.is_none_or(|(_, s)| score > s) {
best = Some((index, score));
}
}
best.map(|(index, _)| index)
}
/// List installed-font paths in preference order, with regional face-name hints for collections.
/// Windows Simplified Chinese first tries the named document family; its generic list also
/// prefers a bold face for bold requests. macOS includes bounded downloaded-font locations.
/// Only fixed platform filenames are used: a PDF font name never becomes a filesystem path.
fn candidates(script: Script, family: ChineseFamily, bold: bool) -> Vec<Candidate> {
let region: &'static [&'static str] = match script {
Script::Japanese => &["JP", "Japanese"],
Script::SimplifiedChinese => &["SC", "GB"],
Script::TraditionalChinese => &["TC", "HK", "CNS"],
Script::Korean => &["KR", "Korean"],
};
let files: Vec<PathBuf> = if cfg!(windows) {
let dir = windows_fonts();
let names: &[&str] = match script {
Script::Japanese => &["YuGothR.ttc", "meiryo.ttc", "msgothic.ttc"],
Script::SimplifiedChinese => match family {
ChineseFamily::Song => &["simsun.ttc", "msyh.ttc", "msjh.ttc"],
ChineseFamily::Hei => &["simhei.ttf", "msyh.ttc", "simsun.ttc", "msjh.ttc"],
ChineseFamily::Kai => &["simkai.ttf", "simsun.ttc", "msyh.ttc", "msjh.ttc"],
ChineseFamily::FangSong => &["simfang.ttf", "simsun.ttc", "msyh.ttc", "msjh.ttc"],
ChineseFamily::Default if bold => &["msyhbd.ttc", "msyh.ttc", "simsun.ttc", "msjh.ttc"],
ChineseFamily::Default => &["msyh.ttc", "simsun.ttc", "msjh.ttc"],
},
Script::TraditionalChinese => &["msjh.ttc", "mingliu.ttc", "msyh.ttc"],
Script::Korean => &["malgun.ttf", "gulim.ttc"],
};
names.iter().map(|f| dir.join(f)).collect()
} else if cfg!(target_os = "macos") {
let names: &[&str] = match script {
Script::Japanese => &["ヒラギノ角ゴシック W3.ttc", "Hiragino Sans GB.ttc", "PingFang.ttc"],
Script::SimplifiedChinese => &["PingFang.ttc", "Hiragino Sans GB.ttc", "STHeiti Light.ttc"],
Script::TraditionalChinese => &["PingFang.ttc", "STHeiti Light.ttc", "Hiragino Sans GB.ttc"],
Script::Korean => &["AppleSDGothicNeo.ttc", "AppleGothic.ttf", "PingFang.ttc"],
};
// The system folder first, then the downloaded font assets: on recent macOS a face such as
// PingFang lives in `/System/Library/AssetsV2/com_apple_MobileAsset_Font*/<id>.asset/AssetData`.
let mut dirs = vec![PathBuf::from("/System/Library/Fonts")];
dirs.extend(macos_font_asset_dirs().iter().cloned());
names.iter().flat_map(|f| dirs.iter().map(move |d| d.join(f))).collect()
} else {
let names: &[&str] = match script {
Script::Korean => {
&["truetype/nanum/NanumGothic.ttf", "nanum/NanumGothic.ttf", "truetype/baekmuk/gulim.ttf", "truetype/unfonts-core/UnDotum.ttf"]
}
Script::Japanese => &[
"truetype/fonts-japanese-gothic.ttf",
"ipa-gothic/ipag.ttf",
"truetype/takao-gothic/TakaoGothic.ttf",
"truetype/vlgothic/VL-Gothic-Regular.ttf",
],
Script::SimplifiedChinese | Script::TraditionalChinese => &[
"truetype/wqy/wqy-microhei.ttc",
"wenquanyi/wqy-microhei/wqy-microhei.ttc",
"wqy-microhei/wqy-microhei.ttc",
"truetype/wqy/wqy-zenhei.ttc",
"wenquanyi/wqy-zenhei/wqy-zenhei.ttc",
"wqy-zenhei/wqy-zenhei.ttc",
"truetype/droid/DroidSansFallbackFull.ttf",
"google-droid-sans-fonts/DroidSansFallbackFull.ttf",
"truetype/arphic/uming.ttc",
"arphic-uming/uming.ttc",
],
};
["/usr/share/fonts", "/usr/local/share/fonts"].iter().flat_map(|dir| names.iter().map(move |f| Path::new(dir).join(f))).collect()
};
files.into_iter().map(|path| Candidate { path, region }).collect()
}
/// The `AssetData` folders of the downloaded font assets of macOS, found once. The ids in their
/// names change with each asset version, so they are listed rather than named; at most
/// [`MAX_ASSETS`] are looked at.
fn macos_font_asset_dirs() -> &'static [PathBuf] {
static DIRS: OnceLock<Vec<PathBuf>> = OnceLock::new();
DIRS.get_or_init(|| {
let root = Path::new("/System/Library/AssetsV2");
let Ok(kinds) = std::fs::read_dir(root) else { return Vec::new() };
let mut dirs = Vec::new();
for kind in kinds.flatten().filter(|k| k.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font")) {
let Ok(assets) = std::fs::read_dir(kind.path()) else { continue };
for asset in assets.flatten().take(MAX_ASSETS) {
let data = asset.path().join("AssetData");
if data.is_dir() {
dirs.push(data);
}
}
}
dirs
})
}
/// Locate the Windows Fonts directory using `WINDIR`, then `SystemRoot`, then `C:\Windows`.
/// This only constructs the path; candidate reads still enforce regular-file and size limits.
fn windows_fonts() -> PathBuf {
let dir = std::env::var_os("WINDIR").or_else(|| std::env::var_os("SystemRoot")).map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from);
dir.join("Fonts")
}
#[cfg(test)]
mod tests {
use super::*;
const SCRIPTS: [Script; 4] = [Script::Japanese, Script::SimplifiedChinese, Script::TraditionalChinese, Script::Korean];
/// Standard, localized and subset-prefixed names retain their Chinese family hint;
/// unrelated Latin names and generic YaHei requests use the default candidate list.
#[test]
fn chinese_family_hints_include_subset_and_localized_names() {
for name in ["SimSun", "ABCDEF+SimSun", "STSong-Light", "宋体"] {
assert_eq!(ChineseFamily::of(name), ChineseFamily::Song, "{name}");
}
for name in ["SimHei", "STHeiti", "黑体"] {
assert_eq!(ChineseFamily::of(name), ChineseFamily::Hei, "{name}");
}
for name in ["KaiTi", "STKaiti", "楷体"] {
assert_eq!(ChineseFamily::of(name), ChineseFamily::Kai, "{name}");
}
assert_eq!(ChineseFamily::of("Helvetica"), ChineseFamily::Default);
assert_eq!(ChineseFamily::of("MicrosoftYaHei"), ChineseFamily::Default);
}
/// FangSong names contain "song" and must not become Song; Founder (方正) names in CNKI papers
/// pick the matching installed family instead of YaHei for everything (#3587).
#[test]
fn fangsong_and_founder_names_pick_their_family() {
for name in ["FangSong", "FangSong_GB2312", "STFangsong", "SimFang", "仿宋", "FZFSK--GBK1-0", "ABCDEF+FZFSK--GBK1-0"] {
assert_eq!(ChineseFamily::of(name), ChineseFamily::FangSong, "{name}");
}
for name in ["FZSSK--GBK1-0", "FZBSK--GBK1-0", "FZXBSK--GBK1-0", "FZShuSong-Z01", "STZhongsong", "NSimSun", "ABCDEF+FZSSK--GBK1-0"] {
assert_eq!(ChineseFamily::of(name), ChineseFamily::Song, "{name}");
}
for name in ["FZHTK--GBK1-0", "FZLTHJW--GB1-0", "STXihei", "ABCDEF+FZHTK--GBK1-0"] {
assert_eq!(ChineseFamily::of(name), ChineseFamily::Hei, "{name}");
}
for name in ["FZKTK--GBK1-0", "KaiTi_GB2312", "ABCDEF+FZKTK--GBK1-0"] {
assert_eq!(ChineseFamily::of(name), ChineseFamily::Kai, "{name}");
}
}
/// Every family has a Windows list that ends in faces any Chinese Windows has, so a missing
/// named face still draws the text.
#[cfg(windows)]
#[test]
fn every_windows_chinese_family_falls_back_to_common_faces() {
for family in [ChineseFamily::Default, ChineseFamily::Song, ChineseFamily::Hei, ChineseFamily::Kai, ChineseFamily::FangSong] {
let list = candidates(Script::SimplifiedChinese, family, false);
assert!(list.iter().any(|c| c.path.ends_with("msyh.ttc")), "{family:?}");
}
let fangsong = candidates(Script::SimplifiedChinese, ChineseFamily::FangSong, false);
assert!(fangsong.first().is_some_and(|c| c.path.ends_with("simfang.ttf")));
}
/// Invalid font bytes, missing paths and directories produce no candidate face rather
/// than preventing the resolver from trying a later installed font.
#[test]
fn broken_and_missing_files_are_skipped() {
assert_eq!(pick_face(b"", '\u{7269}', &[], false), None);
assert_eq!(pick_face(b"not a font at all", '\u{7269}', &[], false), None);
assert_eq!(pick_face(&[0u8; 4096], '\u{7269}', &[], false), None);
let missing = Candidate { path: PathBuf::from("definitely/not/here.ttc"), region: &[] };
assert!(read(&missing, '\u{7269}', false).is_none());
// A directory is not a font.
let dir = Candidate { path: std::env::temp_dir(), region: &[] };
assert!(read(&dir, '\u{7269}', false).is_none());
}
/// The files are bounded by `max`: a larger one is not read.
#[test]
fn a_file_over_the_cap_is_not_read() {
let inter = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/Inter-Regular.ttf"));
assert!(read_file(inter, MAX_BYTES).is_some());
assert!(read_file(inter, 1024).is_none());
}
/// Two faces drawn from one installed file (two scripts, or regular and bold) hold the same
/// bytes, not a copy each; a file that can't be used is remembered as unusable.
#[test]
fn one_installed_file_is_read_once_and_shared() {
let inter = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/Inter-Regular.ttf"));
let first = file_bytes(inter).expect("readable");
let second = file_bytes(inter).expect("readable");
assert!(Arc::ptr_eq(&first, &second), "one allocation for the file");
let missing = Path::new("definitely/not/here.ttc");
assert!(file_bytes(missing).is_none() && file_bytes(missing).is_none());
}
/// A Latin-only face fails every CJK probe even though its Latin glyph remains readable,
/// distinguishing unsupported script coverage from an unparseable font file.
#[test]
fn a_latin_face_is_rejected_for_every_script() {
let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
for script in SCRIPTS {
assert_eq!(pick_face(inter, script.probe(), &[], false), None, "{script:?}");
}
assert_eq!(pick_face(inter, 'A', &[], false), Some(0));
}
/// Platform candidates are absolute TrueType paths and exclude the font families barred
/// by the asset policy, independently of whether those files are installed locally.
#[test]
fn candidates_are_absolute_font_files_without_adobe_designs() {
for script in SCRIPTS {
let list = candidates(script, ChineseFamily::Default, false);
assert!(!list.is_empty(), "{script:?}");
for c in list {
assert!(c.path.is_absolute(), "{}", c.path.display());
assert!(c.path.extension().is_some_and(|e| e == "ttf" || e == "ttc"), "{}", c.path.display());
let name = c.path.to_string_lossy().to_lowercase();
assert!(
!["notosanscjk", "notoserifcjk", "sourcehan", "source-han", "adobe", "kozuka", "minion"].iter().any(|b| name.contains(b)),
"{name}"
);
}
}
}
/// The four regional Adobe collections select their corresponding scripts; Identity
/// carries no regional information and must not acquire an installed fallback by guess.
#[test]
fn only_the_four_adobe_collections_have_a_script() {
assert_eq!(Script::of(&CidFamily::AdobeCNS1), Some(Script::TraditionalChinese));
assert_eq!(Script::of(&CidFamily::AdobeGB1), Some(Script::SimplifiedChinese));
assert_eq!(Script::of(&CidFamily::AdobeKorea1), Some(Script::Korean));
assert_eq!(Script::of(&CidFamily::AdobeJapan1), Some(Script::Japanese));
assert_eq!(Script::of(&CidFamily::AdobeIdentity), None);
}
/// A real Windows GBK book names these faces. Replacing both with YaHei hides the
/// distinction between serif body text and sans headings even when the exact face exists.
#[cfg(windows)]
#[test]
fn windows_preserves_installed_chinese_document_families() {
use hayro::hayro_interpret::hayro_cmap::CharacterCollection;
for (name, expected, file) in [
("SimSun", "SimSun", "simsun.ttc"),
("SimHei", "SimHei", "simhei.ttf"),
("KaiTi", "KaiTi", "simkai.ttf"),
("FangSong", "FangSong", "simfang.ttf"),
("ABCDEF+FZSSK--GBK1-0", "SimSun", "simsun.ttc"),
("ABCDEF+FZHTK--GBK1-0", "SimHei", "simhei.ttf"),
("ABCDEF+FZKTK--GBK1-0", "KaiTi", "simkai.ttf"),
("ABCDEF+FZFSK--GBK1-0", "FangSong", "simfang.ttf"),
] {
if !windows_fonts().join(file).is_file() {
continue;
}
let query = FallbackFontQuery {
post_script_name: Some(name.into()),
character_collection: Some(CharacterCollection { family: CidFamily::AdobeGB1, supplement: 4 }),
..FallbackFontQuery::default()
};
let (bytes, index) = fallback(&query).expect("installed Chinese font");
let font = skrifa::FontRef::from_index(bytes.as_ref().as_ref(), index).unwrap();
let names: Vec<String> = font.localized_strings(StringId::POSTSCRIPT_NAME).map(|n| n.chars().collect()).collect();
assert!(names.iter().any(|n| n.eq_ignore_ascii_case(expected)), "{name} resolved to {names:?}, expected {expected}");
}
}
/// On a Mac the face for Traditional Chinese is the TC one of PingFang, not the first of the
/// collection (which is another region's), and it maps a character only Traditional has.
#[cfg(target_os = "macos")]
#[test]
fn macos_picks_the_regional_pingfang_face() {
// Found where this macOS keeps it: the system folder, or the downloaded font assets.
let path = candidates(Script::TraditionalChinese, ChineseFamily::Default, false)
.into_iter()
.map(|c| c.path)
.find(|p| p.file_name().is_some_and(|n| n == "PingFang.ttc") && p.is_file());
let Some(bytes) = path.and_then(|p| std::fs::read(p).ok()) else {
eprintln!("PingFang is not installed here: skipped");
return;
};
for (script, region) in [(Script::TraditionalChinese, "TC"), (Script::SimplifiedChinese, "SC")] {
let index = pick_face(&bytes, script.probe(), &[region], false).expect("PingFang covers it");
let font = skrifa::FontRef::from_index(&bytes, index).unwrap();
let family: String = font.localized_strings(StringId::FAMILY_NAME).next().unwrap().chars().collect();
assert!(family.contains(region), "{script:?} got {family:?}");
}
}
/// A face that this macOS keeps only in its downloaded font assets (PingFang, on recent
/// versions) is among the candidates. The assets are searched here on their own, not by the
/// code under test; where there are none, nothing is checked.
#[cfg(target_os = "macos")]
#[test]
fn macos_candidates_include_a_pingfang_that_lives_in_the_font_assets() {
/// Independently locate an installed asset by filename within the test's depth limit,
/// providing an oracle for the production candidate list without using its discovery.
fn find(dir: &Path, name: &str, depth: u32) -> Option<PathBuf> {
for entry in std::fs::read_dir(dir).ok()?.flatten() {
let path = entry.path();
if path.file_name().is_some_and(|n| n == name) && path.is_file() {
return Some(path);
}
if depth > 0
&& path.is_dir()
&& !path.is_symlink()
&& let Some(found) = find(&path, name, depth - 1)
{
return Some(found);
}
}
None
}
let Some(asset) = find(Path::new("/System/Library/AssetsV2"), "PingFang.ttc", 3) else {
eprintln!("no PingFang in the font assets here: skipped");
return;
};
for script in [Script::SimplifiedChinese, Script::TraditionalChinese] {
assert!(
candidates(script, ChineseFamily::Default, false).iter().any(|c| c.path == asset),
"{script:?}: {} is not a candidate",
asset.display()
);
}
}
}
+1
View File
@@ -8,6 +8,7 @@ mod luminosity_group_space;
mod named_form_properties;
mod nonseparable_gray;
mod open_type_cff_simple_font;
mod rotated_image_snap;
mod search;
mod separation_all;
mod stray_q_in_a_form;
@@ -0,0 +1,163 @@
//! Contributor-original synthetic pages (#5530): a shrunk image on a page turned by `/Rotate` is
//! drawn exactly as on the upright page, turned. The crop box and scale put every page edge on a
//! whole device pixel, so a turn is a pure permutation of pixels and the turned render, turned
//! back, must match the upright one. The image itself sits at fractional coordinates: an upright
//! image there is snapped to whole pixels and shrunk once, and before the fix a turned one was
//! not, so it was shrunk to a rounded-up size and resampled a second time at a fractional scale
//! and offset (most of its pixels off, by up to 217 levels).
//!
//! Every kind of image that reaches the snap is drawn: an 8-bit and a 1-bit grey image, a stencil
//! mask (a CCITT scan is often one), and images with a soft mask of their own size and of another
//! size. The last needs no `/Interpolate`: a shrunk soft mask of another size is filtered, and so
//! snapped, whatever the image asks (hayro patch 15). The other kinds without `/Interpolate` are
//! not snapped upright or turned, and were already the same both ways; they stay in the test as a
//! guard.
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RenderedPage};
use std::sync::Arc;
/// A PDF of the given objects (object 1 the catalog), with a cross-reference table.
fn pdf(objects: &[Vec<u8>]) -> Vec<u8> {
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
bytes.extend_from_slice(object);
bytes.extend_from_slice(b"\nendobj\n");
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
bytes
}
fn stream(dict: &str, data: &[u8]) -> Vec<u8> {
let mut bytes = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes();
bytes.extend_from_slice(data);
bytes.extend_from_slice(b"\nendstream");
bytes
}
/// Image size in samples.
const W: usize = 400;
const H: usize = 500;
/// A busy grey pattern with edges both ways, as a scan has.
fn grey_samples() -> Vec<u8> {
(0..H).flat_map(|y| (0..W).map(move |x| ((x * 7 + y * 13) % 256) as u8 ^ if (x / 3 + y / 5) % 2 == 0 { 0 } else { 0x80 })).collect()
}
/// One bit a sample, rows padded to whole bytes: a text-like pattern of thin strokes.
fn bit_samples() -> Vec<u8> {
let row = W.div_ceil(8);
let mut out = vec![0u8; row * H];
for y in 0..H {
for x in 0..W {
if (x / 2 + y / 3) % 3 == 0 || (x * 5 + y * 3) % 11 < 3 {
out[y * row + x / 8] |= 0x80 >> (x % 8);
}
}
}
out
}
fn alpha_samples(w: usize, h: usize) -> Vec<u8> {
(0..h).flat_map(|y| (0..w).map(move |x| ((x * 3 + y * 5) % 256) as u8)).collect()
}
/// The kinds of image drawn.
const KINDS: [&str; 5] = ["8-bit grey", "1-bit grey", "stencil mask", "soft mask", "smaller soft mask"];
/// The image of `kind` as objects 5 (and 6, its soft mask).
fn image(kind: &str, interpolate: bool) -> Vec<Vec<u8>> {
let image = |depth: u8, extra: &str, data: &[u8]| {
let dict = format!(
"/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent {depth} /Interpolate {interpolate}{extra}"
);
stream(&dict, data)
};
let mask = |w: usize, h: usize| {
let dict =
format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate {interpolate}");
stream(&dict, &alpha_samples(w, h))
};
match kind {
"8-bit grey" => vec![image(8, "", &grey_samples())],
"1-bit grey" => vec![image(1, "", &bit_samples())],
"stencil mask" => vec![stream(
&format!("/Type /XObject /Subtype /Image /Width {W} /Height {H} /ImageMask true /BitsPerComponent 1 /Interpolate {interpolate}"),
&bit_samples(),
)],
"soft mask" => vec![image(8, " /SMask 6 0 R", &grey_samples()), mask(W, H)],
_ => vec![image(8, " /SMask 6 0 R", &grey_samples()), mask(300, 350)],
}
}
/// A page whose crop box sits inside a larger media box, as in the report, with the image drawn
/// at fractional coordinates inside it, in a colour (a stencil mask paints it).
fn page(rotate: u32, kind: &str, interpolate: bool) -> Vec<u8> {
let mut objects = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 794 1224] /CropBox [114 510 680 1210] /Rotate {rotate} /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> >> >>"
)
.into_bytes(),
stream("", b"0.1 0.2 0.6 rg q 500.3 0 0 600.7 150.15 560.45 cm /Im Do Q"),
];
objects.extend(image(kind, interpolate));
pdf(&objects)
}
/// At half a pixel per point every crop-box edge, upright or turned, is a whole device pixel,
/// and the 400 × 500 image is shrunk to about 250 × 300 pixels.
fn render(rotate: u32, kind: &str, interpolate: bool) -> RenderedPage {
let mut renderer = PageRenderer::new(Arc::new(page(rotate, kind, interpolate)), RenderConfig::default());
let out = renderer.render(RenderRequest { page: 0, scale: 0.5, ..Default::default() });
assert!(out.error.is_none(), "{kind}: {:?}", out.error);
out
}
fn rgb(out: &RenderedPage, x: u32, y: u32) -> [u8; 3] {
let i = ((y * out.width + x) * 4) as usize;
[out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]]
}
/// The largest channel difference between the turned render and the upright one turned the same
/// way.
fn turned_difference(rotate: u32, kind: &str, interpolate: bool) -> u8 {
let upright = render(0, kind, interpolate);
let turned = render(rotate, kind, interpolate);
let (w, h) = (upright.width, upright.height);
// `/Rotate` turns the page clockwise.
let expected = |x: u32, y: u32| match rotate {
90 => rgb(&upright, y, h - 1 - x),
180 => rgb(&upright, w - 1 - x, h - 1 - y),
_ => rgb(&upright, w - 1 - y, x),
};
let (tw, th) = if rotate == 180 { (w, h) } else { (h, w) };
assert_eq!((turned.width, turned.height), (tw, th), "{kind}, /Rotate {rotate}");
let mut worst = 0;
for y in 0..th {
for x in 0..tw {
let (got, want) = (rgb(&turned, x, y), expected(x, y));
worst = (0..3).map(|c| got[c].abs_diff(want[c])).fold(worst, u8::max);
}
}
worst
}
#[test]
fn a_shrunk_image_on_a_turned_page_is_the_upright_render_turned() {
for kind in KINDS {
for interpolate in [true, false] {
for rotate in [90, 180, 270] {
let worst = turned_difference(rotate, kind, interpolate);
assert!(worst <= 1, "{kind}, /Rotate {rotate}, /Interpolate {interpolate}: off by up to {worst} levels");
}
}
}
}
+14 -1
View File
@@ -465,7 +465,12 @@ pub fn oid(dotted: &str) -> Vec<u8> {
/// Encode a dotted OID supplied by a caller. Unlike [`oid`], this validates every arc and
/// never panics on malformed input.
pub fn try_oid(dotted: &str) -> Result<Vec<u8>, SignError> {
let mut arcs = dotted.split('.').map(|a| a.parse::<u64>().map_err(|_| bad("invalid OID arc")));
let mut arcs = dotted.split('.').map(|a| {
if a.is_empty() || !a.bytes().all(|c| c.is_ascii_digit()) {
return Err(bad("invalid OID arc"));
}
a.parse::<u64>().map_err(|_| bad("invalid OID arc"))
});
let first = arcs.next().ok_or_else(|| bad("empty OID"))??;
let second = arcs.next().ok_or_else(|| bad("OID needs two arcs"))??;
if first > 2 || (first < 2 && second >= 40) {
@@ -549,6 +554,14 @@ pub fn algorithm(oid_s: &str, params: Option<&[u8]>) -> Vec<u8> {
mod tests {
use super::*;
#[test]
fn try_oid_rejects_signs_and_empty_arcs() {
for bad in ["+1.2.840", "1.+2", "1.2.-3", "1..2"] {
assert!(try_oid(bad).is_err(), "accepted {bad}");
}
assert_eq!(try_oid("1.2.840.113549").unwrap(), oid("1.2.840.113549"));
}
#[test]
fn round_trips() {
let o = oid("1.2.840.113549.1.7.2");
+2
View File
@@ -20,6 +20,7 @@ craft-quit = { workspace = true, features = ["egui"] }
pdfcraft-fonts = { workspace = true }
pdfcraft-engine = { workspace = true }
pdfcraft-cos = { workspace = true }
pdfcraft-content = { workspace = true }
pdfcraft-render = { workspace = true }
pdfcraft-model = { workspace = true }
pdfcraft-platform = { workspace = true }
@@ -96,6 +97,7 @@ egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"
image = { version = "0.25", default-features = false, features = ["png"] }
# Glyph-coverage test of the interface fonts; the same skrifa pdfcraft-fonts already uses.
skrifa = "0.44"
proptest = { workspace = true }
[build-dependencies]
serde_json = { workspace = true }
+3 -4
View File
@@ -1,8 +1,7 @@
//! Bake the contributor credits into the binary as static Rust tables (craftrules
//! standards/contributors.md). Nothing is read at run time.
//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time.
//!
//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules
//! scripts/contributors.py, which also marks whose people.toml entry it verified.
//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose people.toml
//! entry is verified.
//! - contributors/people.toml: the names people chose for themselves. A name is used only when the
//! JSON says the entry was verified (`people_entry`).
//!
+53 -1
View File
@@ -73,6 +73,7 @@ impl PdfCraftApp {
default_mode: Mode::AllTools,
view_defaults: Default::default(),
units: Default::default(),
blank_paper: crate::paper::Paper::system(),
date_format_draft: None,
mode_override: None,
left: LeftPanel::AllTools,
@@ -91,6 +92,7 @@ impl PdfCraftApp {
// A fresh install follows the desktop's appearance (#1248); saved settings override.
theme_preference: ThemePreference::System,
reading_colors: Default::default(),
show_page_edge_shadows: true,
#[cfg(target_os = "linux")]
desktop_theme: None,
#[cfg(target_os = "linux")]
@@ -117,6 +119,7 @@ impl PdfCraftApp {
all_tools_query: String::new(),
all_tools_tab: (None, false),
recent: Vec::new(),
recent_limit: crate::RECENT_FILES_DEFAULT,
reopen_last_session: false,
restore_last_view: true,
last_views: Vec::new(),
@@ -133,9 +136,15 @@ impl PdfCraftApp {
pending_opens: Vec::new(),
#[cfg(not(target_arch = "wasm32"))]
next_open_token: 0,
#[cfg(not(target_arch = "wasm32"))]
launch_layout: None,
#[cfg(not(target_arch = "wasm32"))]
launch_display_target: None,
#[cfg(all(test, not(target_arch = "wasm32")))]
open_prepare_gate: None,
pinned: Default::default(),
pinned_tools: Vec::new(),
pin_tool_search: String::new(),
toast: None,
toast_script: None,
script_messages: std::collections::VecDeque::new(),
@@ -223,7 +232,6 @@ impl PdfCraftApp {
last_copied_file: None,
system_clipboard: true,
clipboard_override: None,
v_pressed: false,
attach_override: None,
duplicate_draft: None,
page_props_draft: None,
@@ -353,6 +361,46 @@ impl PdfCraftApp {
pub fn render_pending(&self) -> bool {
self.views.iter().any(|v| v.render_pending())
}
/// Check whether a tool group is pinned to Home.
pub fn is_tool_pinned(&self, id: &str) -> bool {
self.pinned_tools.iter().any(|t| t == id)
}
/// Pin a tool group to Home. Returns true if newly pinned.
pub fn pin_tool(&mut self, id: &str) -> bool {
if pdfcraft_engine::catalog::group(id).is_none() {
return false;
}
if !self.is_tool_pinned(id) {
self.pinned_tools.push(id.to_string());
if let Some(g) = pdfcraft_engine::catalog::group(id) {
self.notify_fmt("Pinned {tool} to Home", &[("tool", tl!(g.label))]);
}
return true;
}
false
}
/// Unpin a tool group from Home.
pub fn unpin_tool(&mut self, id: &str) {
if let Some(pos) = self.pinned_tools.iter().position(|t| t == id) {
self.pinned_tools.remove(pos);
if let Some(g) = pdfcraft_engine::catalog::group(id) {
self.notify_fmt("Unpinned {tool} from Home", &[("tool", tl!(g.label))]);
}
}
}
/// Toggle a tool group's pinned state on Home. Returns whether it is now pinned.
pub fn toggle_pin_tool(&mut self, id: &str) -> bool {
if self.is_tool_pinned(id) {
self.unpin_tool(id);
false
} else {
self.pin_tool(id)
}
}
}
impl eframe::App for PdfCraftApp {
@@ -362,6 +410,10 @@ impl eframe::App for PdfCraftApp {
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
// The button's release a window move or resize kept from egui.
window_controls::release_after_desktop_grab(ctx, raw);
// The wheel after Alt+Tab back, before the mouse has moved (#1518).
let known = ctx.input(|i| i.pointer.latest_pos().is_some());
let size = ctx.input(|i| i.viewport().inner_rect).map_or(egui::Vec2::ZERO, |r| r.size());
drag_pointer::locate_wheel(raw, known, size, || drag_pointer::in_window(ctx));
self.prepare_raw_input(ctx, raw);
if raw.hovered_files.is_empty() && raw.dropped_files.is_empty() {
return;
+299 -6
View File
@@ -10,6 +10,11 @@ pub(crate) fn print_refused() -> &'static str {
tl!("The document's security settings don't allow printing")
}
enum ClipboardPaste {
Text,
Image(std::sync::Arc<egui::ColorImage>),
}
impl PdfCraftApp {
/// Whether text and images may be copied out of document `id`; says why not when they may
/// not. Reading aloud and accessibility tools don't ask (assistive technology may read
@@ -22,14 +27,83 @@ impl PdfCraftApp {
false
}
/// ⌘C copies the selected text, unless a text field has the keyboard (it copies its own).
pub(super) fn copy_key(&mut self, i: usize, ctx: &egui::Context) {
// ⌘C arrives as a Copy event on most platforms.
let copy = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Copy)));
if !copy || ctx.egui_wants_keyboard_input() {
/// ⌘C / ⌘X / ⌘V copy, cut and paste text or images on canvas (unless a text field has focus).
pub(super) fn clipboard_keys(&mut self, i: usize, ctx: &egui::Context) {
use egui::{Key, KeyboardShortcut, Modifiers};
let pastes: Vec<_> = ctx.input(|input| {
input
.events
.iter()
.filter_map(|event| match event {
egui::Event::Paste(_) => Some(ClipboardPaste::Text),
egui::Event::PasteImage(image) => Some(ClipboardPaste::Image(image.clone())),
_ => None,
})
.collect()
});
let free = self.dialog.is_none() && !self.palette_open && !ctx.egui_wants_keyboard_input() && !self.is_preparing();
if !free || self.views.get(i).is_some_and(|v| v.forms.focus.is_some() || v.organize) {
self.receive_pastes(i, pastes, false);
return;
}
self.copy_selected_text(i, ctx);
let copy = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Copy)))
|| ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::C)));
let cut = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Cut)))
|| ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::X)));
let paste = ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::V)));
if copy
&& !self.copy_selected_text(i, ctx)
&& let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone())
&& let Some(id) = self.views.get(i).map(|v| v.id)
&& self.may_copy(id)
{
self.copy_page_image(sel.page, sel.index);
}
if cut
&& let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone())
&& let Some(id) = self.views.get(i).map(|v| v.id)
&& self.may_copy(id)
{
self.cut_page_image(sel.page, sel.index);
}
if !pastes.is_empty() {
self.receive_pastes(i, pastes, true);
return;
}
if paste {
let has_override_image = matches!(self.clipboard_override, Some(crate::create_ui::Clip::Image { .. }));
#[cfg(not(target_arch = "wasm32"))]
let system_has_text = if !has_override_image && self.system_clipboard { crate::os_clipboard::has_text() } else { false };
#[cfg(target_arch = "wasm32")]
let system_has_text = false;
if !has_override_image && system_has_text {
self.last_copied_image = None;
self.last_copied_file = None;
return;
}
let page = self.views.get(i).map_or(0, |v| v.current);
self.paste_image(page, None);
}
}
fn receive_pastes(&mut self, view: usize, pastes: Vec<ClipboardPaste>, images_allowed: bool) {
let page = self.views.get(view).map_or(0, |view| view.current);
for paste in pastes {
match paste {
ClipboardPaste::Text => {
// Text fields may own the paste; the canvas cache still observes its contents.
self.last_copied_image = None;
self.last_copied_file = None;
}
ClipboardPaste::Image(image) if images_allowed => {
self.paste_received_image(page, &image);
}
ClipboardPaste::Image(_) => {}
}
}
}
/// Shared permission gate for copying selected page text from menus and keyboard events.
@@ -79,3 +153,222 @@ impl PdfCraftApp {
}
}
}
#[cfg(test)]
mod review_tests {
use super::*;
fn delivered() -> egui::Event {
egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([1, 1], egui::Color32::GREEN)))
}
fn key(pressed: bool, modifiers: egui::Modifiers) -> egui::Event {
egui::Event::Key { key: egui::Key::V, physical_key: None, pressed, repeat: false, modifiers }
}
fn image_app() -> PdfCraftApp {
let mut app = PdfCraftApp::new();
app.system_clipboard = false;
app.create_blank();
app.clipboard_override = Some(crate::create_ui::Clip::Image { width: 1, height: 1, rgba: vec![255, 0, 0, 255] });
app
}
fn frame(app: &mut PdfCraftApp, ctx: &egui::Context, time: f64, events: Vec<egui::Event>) -> usize {
ctx.run_ui(egui::RawInput { time: Some(time), events, ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx()))
.drop_without_applying_deltas();
app.session.get(app.views[0].id).unwrap().page_images(0).len()
}
#[test]
fn delivered_image_pastes_for_either_modifier_release_order() {
for command_first in [true, false] {
let mut app = image_app();
let ctx = egui::Context::default();
frame(&mut app, &ctx, 1.0, vec![egui::Event::ModifiersChanged(egui::Modifiers::COMMAND)]);
let releases = if command_first {
vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)]
} else {
vec![key(false, egui::Modifiers::COMMAND), egui::Event::ModifiersChanged(egui::Modifiers::NONE)]
};
assert_eq!(frame(&mut app, &ctx, 1.1, releases), 0, "key release is not a delivered paste");
assert_eq!(frame(&mut app, &ctx, 1.2, vec![delivered()]), 1, "asynchronous pixels identify the actual paste");
assert_eq!(frame(&mut app, &ctx, 1.25, vec![key(false, egui::Modifiers::NONE)]), 1);
}
}
#[test]
fn clipboard_history_after_a_completed_paste_does_not_paste_on_open() {
for explicit_press in [false, true] {
let mut app = image_app();
let ctx = egui::Context::default();
let mut first = vec![egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), delivered()];
if explicit_press {
first.push(key(true, egui::Modifiers::COMMAND));
}
first.push(key(false, egui::Modifiers::COMMAND));
assert_eq!(frame(&mut app, &ctx, 1.0, first), 1);
// The integration drops Windows-key identity; Win+V can leave only this release.
assert_eq!(
frame(&mut app, &ctx, 1.1, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)]),
1,
"opening history must not paste"
);
assert_eq!(
frame(&mut app, &ctx, 1.2, vec![delivered(), key(true, egui::Modifiers::COMMAND), key(false, egui::Modifiers::COMMAND)]),
2,
"choosing an item produces exactly one intentional paste"
);
}
}
#[test]
fn clipboard_history_and_plain_v_after_an_unrelated_shortcut_do_nothing() {
let mut app = image_app();
let ctx = egui::Context::default();
let copy = egui::Event::Key { key: egui::Key::C, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND };
frame(&mut app, &ctx, 1.0, vec![egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), copy]);
assert_eq!(frame(&mut app, &ctx, 1.1, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)]), 0);
assert_eq!(frame(&mut app, &ctx, 1.2, vec![key(true, egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)]), 0);
assert_eq!(frame(&mut app, &ctx, 10.0, vec![key(false, egui::Modifiers::NONE)]), 0);
}
#[test]
fn delivered_image_is_authoritative_and_not_duplicated_by_keys() {
let mut app = image_app();
app.system_clipboard = true;
app.clipboard_override = Some(crate::create_ui::Clip::Text("newer OS contents".into()));
let ctx = egui::Context::default();
assert_eq!(frame(&mut app, &ctx, 1.0, vec![delivered(), key(true, egui::Modifiers::COMMAND), key(false, egui::Modifiers::COMMAND)]), 1);
assert_eq!(app.last_copied_image.as_ref().unwrap().2, vec![0, 255, 0, 255]);
}
#[test]
fn text_then_image_batch_pastes_image_and_keeps_its_cache() {
let mut app = image_app();
let ctx = egui::Context::default();
assert_eq!(frame(&mut app, &ctx, 1.0, vec![egui::Event::Paste("text".into()), delivered()]), 1);
assert_eq!(app.last_copied_image.unwrap().2, vec![0, 255, 0, 255]);
}
#[test]
fn image_then_text_batch_pastes_image_and_leaves_text_authoritative() {
let mut app = image_app();
let ctx = egui::Context::default();
assert_eq!(frame(&mut app, &ctx, 1.0, vec![delivered(), egui::Event::Paste("text".into())]), 1);
assert!(app.last_copied_image.is_none());
assert!(app.last_copied_file.is_none());
}
#[test]
fn separate_delivered_images_in_one_batch_each_paste_once() {
let mut app = image_app();
let ctx = egui::Context::default();
assert_eq!(
frame(&mut app, &ctx, 1.0, vec![delivered(), delivered(), key(true, egui::Modifiers::COMMAND), key(false, egui::Modifiers::COMMAND)]),
2
);
}
#[test]
fn explicit_control_shortcut_still_pastes_once_without_an_image_event() {
let mut app = image_app();
let ctx = egui::Context::default();
assert_eq!(frame(&mut app, &ctx, 1.0, vec![key(true, egui::Modifiers::COMMAND)]), 1);
assert_eq!(frame(&mut app, &ctx, 1.1, vec![key(false, egui::Modifiers::COMMAND)]), 1);
}
#[test]
fn delivered_image_obeys_focus_modal_and_permission_guards() {
let ctx = egui::Context::default();
let mut app = image_app();
app.palette_open = true;
assert_eq!(frame(&mut app, &ctx, 1.0, vec![delivered()]), 0);
app.palette_open = false;
app.views[0].organize = true;
assert_eq!(frame(&mut app, &ctx, 2.0, vec![delivered()]), 0);
app.views[0].organize = false;
app.apply_edit(pdfcraft_engine::Edit::Protect(pdfcraft_engine::Protection {
permissions_password: Some("owner".into()),
..Default::default()
}));
let bytes = app.session.save_bytes(app.views[0].id).unwrap();
let mut restricted = PdfCraftApp::new();
restricted.open_bytes("restricted.pdf", None, bytes.as_ref().clone()).unwrap();
assert!(!restricted.session.get(restricted.views[0].id).unwrap().allows_modification());
assert_eq!(frame(&mut restricted, &ctx, 3.0, vec![delivered()]), 0);
}
#[test]
fn review_an_image_less_release_is_silent_but_explicit_paste_explains() {
let mut app = PdfCraftApp::new();
app.system_clipboard = false;
app.create_blank();
app.toast = None;
let ctx = egui::Context::default();
ctx.run_ui(
egui::RawInput {
events: vec![egui::Event::Key {
key: egui::Key::V,
physical_key: None,
pressed: false,
repeat: false,
modifiers: egui::Modifiers::NONE,
}],
..Default::default()
},
|ui| app.clipboard_keys(0, ui.ctx()),
)
.drop_without_applying_deltas();
assert!(app.toast.is_none());
assert!(!app.paste_image(0, None));
assert!(app.toast.is_some(), "an explicit menu action still explains why there is no image");
}
#[test]
fn review_text_paste_release_is_silent_and_later_image_paste_still_works() {
let mut app = PdfCraftApp::new();
app.system_clipboard = false;
app.create_blank();
app.last_copied_image = Some((1, 1, vec![255, 0, 0, 255]));
app.palette_open = true; // A text field owns the first event.
let ctx = egui::Context::default();
ctx.run_ui(egui::RawInput { events: vec![egui::Event::Paste("text".into())], ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx()))
.drop_without_applying_deltas();
assert!(app.last_copied_image.is_none());
app.palette_open = false;
app.toast = None;
ctx.run_ui(
egui::RawInput {
events: vec![egui::Event::Key {
key: egui::Key::V,
physical_key: None,
pressed: false,
repeat: false,
modifiers: egui::Modifiers::NONE,
}],
..Default::default()
},
|ui| app.clipboard_keys(0, ui.ctx()),
)
.drop_without_applying_deltas();
assert!(app.toast.is_none(), "the text gesture's key release must be silent");
app.clipboard_override = None;
let delivered = egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([1, 1], egui::Color32::GREEN)));
ctx.run_ui(egui::RawInput { events: vec![delivered], ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx()))
.drop_without_applying_deltas();
let id = app.active_ids().unwrap().1;
assert_eq!(app.session.get(id).unwrap().page_images(0).len(), 1, "a later real image shortcut still works");
}
#[test]
fn review_failed_keyboard_cut_keeps_the_image_selection() {
let mut app = PdfCraftApp::new();
app.system_clipboard = false;
app.create_blank();
app.views[0].image_selection = Some(crate::edit_text_ui::ImageSelection::at(0, 0));
let ctx = egui::Context::default();
ctx.run_ui(egui::RawInput { events: vec![egui::Event::Cut], ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx()))
.drop_without_applying_deltas();
assert!(app.views[0].image_selection.is_some());
}
}
+9
View File
@@ -32,6 +32,9 @@ impl PdfCraftApp {
};
self.set_theme_preference(preference);
}
("show_page_edge_shadows", _) => {
self.show_page_edge_shadows = value.parse::<bool>().map_err(|_| "show_page_edge_shadows must be true or false")?;
}
("scroll_speed", _) => {
self.scroll_speed = scroll_speed::ScrollSpeed::parse(value).ok_or("scroll_speed must be slow, normal, or fast")?;
}
@@ -167,6 +170,12 @@ impl PdfCraftApp {
_ => return Err("default-page-gaps must be on or off".into()),
};
}
("recent-files", _) => {
let max = crate::RECENT_FILES_MAX;
let limit = value.parse().ok().filter(|n| *n <= max).ok_or_else(|| format!("recent-files must be between 0 and {max}"))?;
self.recent_limit = limit;
self.recent.truncate(limit);
}
("cover", Some(v)) => {
let on = match value {
"on" => true,
+102 -5
View File
@@ -117,6 +117,7 @@ impl PdfCraftApp {
after_open: request.after_open,
recovered: request.recovered,
then: request.then,
display_override: None,
});
Ok(token)
}
@@ -147,6 +148,7 @@ impl PdfCraftApp {
// earlier waiting replies, without consuming a blocked receiver's result.
waiting.append(&mut self.pending_opens);
self.pending_opens = waiting;
self.settle_launch_layout();
}
#[cfg(not(target_arch = "wasm32"))]
@@ -182,6 +184,12 @@ impl PdfCraftApp {
if let Some(destination) = pending.after_open {
self.apply_remote_destination(destination);
}
if let Some((layout, continuous)) = pending.display_override
&& let Some(view) = self.views.iter_mut().find(|view| view.id == id)
{
view.set_display(layout, continuous);
self.launch_display_target = Some(id);
}
if let Some(meta) = &pending.recovered {
self.finish_recovery(meta);
}
@@ -205,6 +213,7 @@ impl PdfCraftApp {
recovered: pending.recovered,
then: pending.then,
after_open: pending.after_open,
display_override: pending.display_override,
});
}
Err(error) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error.to_string())]),
@@ -239,6 +248,7 @@ impl PdfCraftApp {
recovered: None,
then: None,
after_open: None,
display_override: None,
});
return Ok(());
}
@@ -323,7 +333,7 @@ impl PdfCraftApp {
macos_association::repair(std::path::Path::new(&p));
self.recent.retain(|r| r.path != p);
self.recent.insert(0, RecentFile { name: name.to_string(), path: p, pages, size });
self.recent.truncate(12);
self.recent.truncate(self.recent_limit);
}
// What the form's scripts said while it opened (messages, errors) shows now, not with
// the next edit.
@@ -416,6 +426,8 @@ impl PdfCraftApp {
if let Some(meta) = p.recovered {
self.recoverable.push(meta);
}
#[cfg(not(target_arch = "wasm32"))]
self.settle_launch_layout();
return;
};
#[cfg(not(target_arch = "wasm32"))]
@@ -431,6 +443,11 @@ impl PdfCraftApp {
recovered: p.recovered.clone(),
then: p.then,
});
if let Ok(token) = &result
&& let Some(pending) = self.pending_opens.iter_mut().find(|pending| pending.token == *token)
{
pending.display_override = p.display_override;
}
if let Err(error) = result {
let mut prompt = p;
prompt.error = Some(error);
@@ -458,6 +475,15 @@ impl PdfCraftApp {
if let Some(destination) = after_open {
self.apply_remote_destination(destination);
}
if let Some((layout, continuous)) = p.display_override
&& let Some(view) = self.views.last_mut()
{
view.set_display(layout, continuous);
#[cfg(not(target_arch = "wasm32"))]
{
self.launch_display_target = Some(view.id);
}
}
if let Some(meta) = &p.recovered {
self.finish_recovery(meta);
}
@@ -472,9 +498,12 @@ impl PdfCraftApp {
prompt.recovered = p.recovered;
prompt.then = p.then;
prompt.after_open = after_open;
prompt.display_override = p.display_override;
}
}
}
#[cfg(not(target_arch = "wasm32"))]
self.settle_launch_layout();
}
/// Ask for the active document's permissions password, then run `then` (if any).
@@ -533,17 +562,37 @@ impl PdfCraftApp {
/// the file opened, focused its tab, was handed to the background worker, or failed.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn open_path_then(&mut self, path: &str, then: Option<&'static str>) -> OpenOutcome {
self.open_path_request(path, then, None)
}
#[cfg(not(target_arch = "wasm32"))]
fn open_path_request(&mut self, path: &str, then: Option<&'static str>, display_override: Option<(canvas::PageLayout, bool)>) -> OpenOutcome {
// One file, one tab (#2166): a file that is open already comes to the front instead,
// and the tool it was opened for runs on it. One still being opened is left to that.
if let Some(i) = self.tab_showing(path) {
self.startup_superseded = true;
self.active = Some(i);
if let Some((layout, continuous)) = display_override
&& let Some(view) = self.views.get_mut(i)
{
view.set_display(layout, continuous);
self.launch_display_target = Some(view.id);
}
if let Some(command) = then {
self.execute(command);
}
return OpenOutcome::Focused;
}
if self.pending_opens.iter().any(|p| p.path.as_deref().is_some_and(|p| same_file(p, path))) {
if let Some(pending) = self.pending_opens.iter_mut().find(|p| p.path.as_deref().is_some_and(|p| same_file(p, path))) {
if display_override.is_some() {
pending.display_override = display_override;
}
return OpenOutcome::Pending;
}
if display_override.is_some()
&& let Some(prompt) = self.password_prompt.as_mut().filter(|p| p.path.as_deref().is_some_and(|p| same_file(p, path)))
{
prompt.display_override = display_override;
return OpenOutcome::Pending;
}
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string());
@@ -552,18 +601,29 @@ impl PdfCraftApp {
// Before the first frame there is no interaction to keep responsive; this also preserves
// deterministic inline startup and test flows. Once live, reading and preparation use
// the bounded worker and commit on the UI thread.
if self.ctx.is_none() || self.run_inline {
// Keep flagged launch files behind an existing password prompt or picker.
if (self.ctx.is_none() || self.run_inline) && !(display_override.is_some() && (self.modal_open() || self.pickers.pending())) {
match std::fs::read(path) {
Ok(bytes) => {
let before = self.views.len();
let previous_prompt = self.password_prompt.as_ref().map(|p| p.bytes.clone());
if let Err(error) = self.open_bytes(&name, Some(path.to_string()), bytes) {
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]);
return OpenOutcome::Failed;
}
if let Some(prompt) = self.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path)) {
if let Some(prompt) = self.password_prompt.as_mut().filter(|p| {
p.path.as_deref() == Some(path) && previous_prompt.as_ref().is_none_or(|bytes| !std::sync::Arc::ptr_eq(bytes, &p.bytes))
}) {
// Encrypted: the tool runs once the password is in.
prompt.then = then;
prompt.display_override = display_override;
} else if self.views.len() > before {
if let Some((layout, continuous)) = display_override
&& let Some(view) = self.views.last_mut()
{
view.set_display(layout, continuous);
self.launch_display_target = Some(view.id);
}
if let Some(doc) = self.views.last().map(|view| view.id) {
self.watch.known_as(doc, stamp);
}
@@ -595,7 +655,12 @@ impl PdfCraftApp {
recovered: None,
then,
}) {
Ok(_) => OpenOutcome::Pending,
Ok(token) => {
if let Some(pending) = self.pending_opens.iter_mut().find(|pending| pending.token == token) {
pending.display_override = display_override;
}
OpenOutcome::Pending
}
Err(error) => {
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]);
OpenOutcome::Failed
@@ -604,6 +669,38 @@ impl PdfCraftApp {
}
}
/// Apply an explicit launch layout after the command-line display overrides settle.
#[cfg(not(target_arch = "wasm32"))]
pub fn set_launch_layout(&mut self, value: &str) -> Result<(), String> {
let (layout, continuous) = canvas::parse_display_token(value).ok_or("layout must be single, continuous, two-up or two-page")?;
self.launch_layout = Some((layout, continuous));
self.settle_launch_layout();
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn settle_launch_layout(&mut self) {
if self.pending_opens.iter().any(|open| open.display_override.is_some())
|| self.password_prompt.as_ref().is_some_and(|prompt| prompt.display_override.is_some())
{
return;
}
// Follow the existing completion/activation order, not a guessed tab index or path.
// Failed or cancelled opens cannot cause the layout to land on a restored document.
if let Some((layout, continuous)) = self.launch_layout.take()
&& let Some(id) = self.launch_display_target
&& let Some(view) = self.views.iter_mut().find(|view| view.id == id)
{
view.set_display(layout, continuous);
}
}
/// Open one native file with a transient display override, including after a password prompt.
#[cfg(not(target_arch = "wasm32"))]
pub fn open_path_with_display(&mut self, path: &str, layout: canvas::PageLayout, continuous: bool) {
self.open_path_request(path, None, Some((layout, continuous)));
}
/// The File ▸ Open picker: PDFs, and images and text that become PDFs.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn open_picker() -> rfd::AsyncFileDialog {
+1 -3
View File
@@ -3,7 +3,6 @@ use super::*;
impl PdfCraftApp {
pub(super) fn shortcuts(&mut self, ctx: &egui::Context) {
use egui::Key;
let pasted = self.paste_key_released(ctx);
// "Save changes?" is modal: its keys are its own (⌘D is Don't save there, not Document
// properties; Escape cancels it rather than clearing a selection), and nothing may run
// underneath it. They are read here, before the canvas can consume them.
@@ -78,8 +77,7 @@ impl PdfCraftApp {
prepare::nudge_keys(ctx, &mut self.views[i], doc);
}
canvas::shortcuts(&mut self.views[i], ctx, self.scroll_speed);
self.copy_key(i, ctx);
self.paste_image_key(i, ctx, pasted);
self.clipboard_keys(i, ctx);
self.image_pick_keys(i, ctx);
}
}
+24 -1
View File
@@ -78,7 +78,7 @@ impl PdfCraftApp {
let mut v = serde_json::json!({
"side_panels": self.side_panels.to_json(self.left_open),
"docking_right": self.right,
"recent": self.recent,
"recent": self.recent.iter().take(self.recent_limit).collect::<Vec<_>>(),
"reopen_last_session": self.reopen_last_session,
"restore_last_view": self.restore_last_view,
"last_views": self.last_views_to_save(),
@@ -139,10 +139,13 @@ impl PdfCraftApp {
});
// Set apart: one more key inside the macro above passes serde_json's recursion limit.
v["default_page_gaps"] = serde_json::Value::Bool(self.view_defaults.show_page_gaps);
v["recent_limit"] = serde_json::json!(self.recent_limit);
v["fill_text_color"] = serde_json::json!(self.fill_text_color);
v["redact_prefs"] = self.redact_defaults.to_json();
v["pinned_tools"] = serde_json::json!(self.pinned_tools);
// Set outside json! above, which is at serde_json's macro recursion limit.
v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover);
v["show_page_edge_shadows"] = self.show_page_edge_shadows.into();
v["comment_tool_pinned"] = serde_json::Value::Bool(self.comment_prefs.pinned);
v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice);
v["organize_zoom"] = serde_json::json!(self.organize_zoom);
@@ -179,6 +182,11 @@ impl PdfCraftApp {
let r: Vec<RecentFile> = r.into_iter().filter(|f| std::path::Path::new(&f.path).exists()).collect();
self.recent = r;
}
// Nonsense keeps the limit it had; settings from before the limit have none.
if let Some(limit) = v["recent_limit"].as_u64() {
self.recent_limit = usize::try_from(limit).map_or(crate::RECENT_FILES_MAX, |n| n.min(crate::RECENT_FILES_MAX));
}
self.recent.truncate(self.recent_limit);
self.restore_layout_prefs(&v);
self.restore_behavior_prefs(&v);
self.restore_view_prefs(&v);
@@ -206,6 +214,18 @@ impl PdfCraftApp {
self.show_secured_notice = on;
}
self.pinned.restore(&v["pinned_folders"]);
if let Some(items) = v.get("pinned_tools").and_then(serde_json::Value::as_array) {
let mut tools = Vec::new();
for item in items {
if let Some(id) = item.as_str()
&& pdfcraft_engine::catalog::group(id).is_some()
&& !tools.iter().any(|t| t == id)
{
tools.push(id.to_string());
}
}
self.pinned_tools = tools;
}
if let Ok(preference) = serde_json::from_value::<ThemePreference>(v["theme"].clone()) {
self.set_theme_preference(preference);
}
@@ -267,6 +287,9 @@ impl PdfCraftApp {
{
self.fill_text_color = color;
}
if let Some(show) = v["show_page_edge_shadows"].as_bool() {
self.show_page_edge_shadows = show;
}
if let Some(prefs) = RedactPrefs::from_json(&v["redact_prefs"]) {
self.redact_prefs = prefs.clone();
self.redact_defaults = prefs;
+611 -35
View File
@@ -86,6 +86,175 @@ pub enum Fit {
None,
}
use crate::edit_text_ui::ImageHandle;
/// Extension trait on `egui::Painter` to draw polygons.
pub trait PainterExt {
fn polygon(&self, points: Vec<Pos2>, fill: Color32, stroke: Stroke) -> egui::layers::ShapeIdx;
}
impl PainterExt for egui::Painter {
fn polygon(&self, points: Vec<Pos2>, fill: Color32, stroke: Stroke) -> egui::layers::ShapeIdx {
self.add(egui::Shape::convex_polygon(points, fill, stroke))
}
}
/// User-space selectable bounding box for an image object.
/// If the image object has an active clipping path applied to it (the crop box),
/// this is the mathematical intersection of the full CTM-derived rect and the clipping path's rect.
pub fn image_selectable_bounds(im: &pdfcraft_engine::PageImage) -> [f64; 4] {
if let Some(clip) = im.clip_rect {
let r = [im.rect[0].max(clip[0]), im.rect[1].max(clip[1]), im.rect[2].min(clip[2]), im.rect[3].min(clip[3])];
if r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1] {
return r;
}
}
im.visible_rect.unwrap_or(im.rect)
}
/// Screen-space selectable bounding box for an image object.
/// If the image object has an active clipping path applied to it (the crop box),
/// the final selectable `egui::Rect` is the mathematical intersection of the image's
/// full CTM-derived rect and the clipping path's rect.
pub fn image_screen_rect(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect {
let full = xf.user_rect_f64(info, page, im.rect);
if let Some(clip) = im.clip_rect {
let clip_r = xf.user_rect_f64(info, page, clip);
let inter = full.intersect(clip_r);
if inter.is_positive() {
return inter;
}
}
if let Some(vr) = im.visible_rect {
let r = xf.user_rect_f64(info, page, vr);
if r.is_positive() {
return r;
}
}
full
}
/// An Oriented Bounding Box (OBB) representing a rotated rectangle in screen space,
/// with 8 resize handles and rotated local axes.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ImageObb {
pub s00: Pos2,
pub s10: Pos2,
pub s11: Pos2,
pub s01: Pos2,
pub s_bc: Pos2,
pub s_rc: Pos2,
pub s_tc: Pos2,
pub s_lc: Pos2,
pub s_rot: Pos2,
pub screen_center: Pos2,
pub u_axis: (f64, f64),
pub v_axis: (f64, f64),
pub is_rotated: bool,
}
impl ImageObb {
pub(crate) fn handle_cursor(&self, handle: ImageHandle) -> egui::CursorIcon {
let direction = self.handle_pos(handle) - self.screen_center;
match handle {
ImageHandle::TopLeft | ImageHandle::TopRight | ImageHandle::BottomLeft | ImageHandle::BottomRight => {
if direction.x * direction.y > 0.0 {
egui::CursorIcon::ResizeNwSe
} else {
egui::CursorIcon::ResizeNeSw
}
}
_ if direction.x.abs() > direction.y.abs() => egui::CursorIcon::ResizeHorizontal,
_ => egui::CursorIcon::ResizeVertical,
}
}
pub(crate) fn contains(&self, visible: Rect, p: Pos2) -> bool {
(visible.is_positive() && visible.contains(p)) || crate::edit_text_ui::quad_contains(self.corners(), p)
}
pub fn from_matrix(matrix: [f64; 6], xf: &PageXform, info: &DocInfo, page: usize) -> Self {
let m = crate::edit_text_ui::Matrix(matrix);
let p00 = m.apply(0.0, 0.0);
let p10 = m.apply(1.0, 0.0);
let p11 = m.apply(1.0, 1.0);
let p01 = m.apply(0.0, 1.0);
let s00 = crate::edit_text_ui::user_to_screen(xf, info, page, p00.0, p00.1);
let s10 = crate::edit_text_ui::user_to_screen(xf, info, page, p10.0, p10.1);
let s11 = crate::edit_text_ui::user_to_screen(xf, info, page, p11.0, p11.1);
let s01 = crate::edit_text_ui::user_to_screen(xf, info, page, p01.0, p01.1);
let s_bc = Pos2::new((s00.x + s10.x) * 0.5, (s00.y + s10.y) * 0.5);
let s_rc = Pos2::new((s10.x + s11.x) * 0.5, (s10.y + s11.y) * 0.5);
let s_tc = Pos2::new((s01.x + s11.x) * 0.5, (s01.y + s11.y) * 0.5);
let s_lc = Pos2::new((s00.x + s01.x) * 0.5, (s00.y + s01.y) * 0.5);
let screen_center = Pos2::new((s00.x + s10.x + s11.x + s01.x) * 0.25, (s00.y + s10.y + s11.y + s01.y) * 0.25);
let out_dir = s_tc - screen_center;
let out_len = out_dir.length().max(1e-4);
let out_norm = out_dir / out_len;
let s_rot = s_tc + out_norm * 24.0;
let [a, b, c, d, ..] = matrix;
let w_u = (a * a + b * b).sqrt().max(1e-6);
let h_v = (c * c + d * d).sqrt().max(1e-6);
let u_axis = (a / w_u, b / w_u);
let v_axis = (c / h_v, d / h_v);
// The fast rectangle path interprets labels in screen space. Use matrix
// resizing whenever the image's user-space axes are turned or reflected
// on screen, including page /Rotate and View > Rotate View.
let u = s10 - s00;
let v = s01 - s00;
let is_rotated = u.y.abs() > 1e-4 || v.x.abs() > 1e-4 || u.x < 0.0 || v.y > 0.0;
Self { s00, s10, s11, s01, s_bc, s_rc, s_tc, s_lc, s_rot, screen_center, u_axis, v_axis, is_rotated }
}
pub fn from_image(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Self {
let [a, b, c, d, ..] = im.matrix;
let is_rot = b.abs() > 1e-4 || c.abs() > 1e-4 || a < 0.0 || d < 0.0;
let bounds = image_selectable_bounds(im);
let matrix = if !is_rot && bounds[2] > bounds[0] && bounds[3] > bounds[1] {
[bounds[2] - bounds[0], 0.0, 0.0, bounds[3] - bounds[1], bounds[0], bounds[1]]
} else {
im.matrix
};
Self::from_matrix(matrix, xf, info, page)
}
pub fn handles(&self) -> [(ImageHandle, Pos2); 8] {
[
ImageHandle::TopLeft,
ImageHandle::TopCenter,
ImageHandle::TopRight,
ImageHandle::RightCenter,
ImageHandle::BottomRight,
ImageHandle::BottomCenter,
ImageHandle::BottomLeft,
ImageHandle::LeftCenter,
]
.map(|h| (h, self.handle_pos(h)))
}
pub fn handle_pos(&self, handle: ImageHandle) -> Pos2 {
match handle {
ImageHandle::TopLeft => self.s01,
ImageHandle::TopCenter => self.s_tc,
ImageHandle::TopRight => self.s11,
ImageHandle::RightCenter => self.s_rc,
ImageHandle::BottomRight => self.s10,
ImageHandle::BottomCenter => self.s_bc,
ImageHandle::BottomLeft => self.s00,
ImageHandle::LeftCenter => self.s_lc,
}
}
pub fn corners(&self) -> [Pos2; 4] {
[self.s00, self.s10, self.s11, self.s01]
}
}
/// The page display as it was before full screen, put back on leaving it
/// ([`DocView::enter_presentation`]).
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -571,6 +740,104 @@ pub enum ViewAction {
CameraNext(&'static str),
}
/// Default page right-click menu actions.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum PageMenuAction {
RotateCCW,
RotateCW,
Print,
SelectTool,
HandTool,
MarqueeZoom,
Copy,
Paste(usize, [f64; 2]),
AddComment(usize, [f64; 2]),
AddBookmark,
TakeSnapshot,
EditPdf,
AddText,
AddImage,
DeletePage(usize),
ExportPdf,
Find,
DocProperties,
FlattenComments,
}
pub(crate) struct PageMenuPerms {
pub can_modify: bool,
pub can_assemble: bool,
pub can_annotate: bool,
pub can_print: bool,
pub can_copy: bool,
pub can_copy_items: bool,
pub can_paste: bool,
pub has_annotations: bool,
}
pub(crate) fn default_page_context_menu(ui: &mut egui::Ui, page: usize, at: [f64; 2], perms: PageMenuPerms, action: &mut Option<PageMenuAction>) {
use PageMenuAction as A;
// Takes `choice` when `entry` was clicked, and closes the menu.
let mut pick = |entry: egui::Response, ui: &mut egui::Ui, choice: PageMenuAction| {
if entry.clicked() {
*action = Some(choice);
ui.close();
}
};
let icon = |ui: &mut egui::Ui, name: &str, tip: &str| crate::icons::button(ui, name, 24.0, false, tip);
// Top horizontal icon bar: Rotate CCW, Print, Select tool, Hand tool, Marquee Zoom
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
pick(icon(ui, "rotate-ccw", tl!("Rotate view counterclockwise")), ui, A::RotateCCW);
ui.add_enabled_ui(perms.can_print, |ui| pick(icon(ui, "printer", tl!("Print")), ui, A::Print));
pick(icon(ui, "mouse-pointer-2", tl!("Select Tool")), ui, A::SelectTool);
pick(icon(ui, "hand", tl!("Hand Tool")), ui, A::HandTool);
pick(icon(ui, "zoom-in", tl!("Marquee Zoom")), ui, A::MarqueeZoom);
});
ui.separator();
pick(
ui.add_enabled(
perms.can_copy_items,
egui::Button::new(tl!("Copy")).shortcut_text(crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY)),
),
ui,
A::Copy,
);
pick(
ui.add_enabled(
perms.can_paste,
egui::Button::new(tl!("Paste")).shortcut_text(crate::commands::shortcut_label(ui.ctx(), pdfcraft_engine::commands::Shortcut::cmd("V"))),
),
ui,
A::Paste(page, at),
);
ui.separator();
pick(ui.add_enabled(perms.can_annotate, egui::Button::new(tl!("Add comment"))), ui, A::AddComment(page, at));
if perms.has_annotations {
pick(ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Flatten comments"))), ui, A::FlattenComments);
}
pick(ui.add_enabled(perms.can_assemble, egui::Button::new(tl!("Add bookmark"))), ui, A::AddBookmark);
pick(ui.add_enabled(perms.can_copy, egui::Button::new(tl!("Take a snapshot"))), ui, A::TakeSnapshot);
ui.separator();
pick(ui.add_enabled(perms.can_modify, egui::Button::new(egui::RichText::new(tl!("Edit this PDF")).strong())), ui, A::EditPdf);
pick(ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Add text"))), ui, A::AddText);
pick(ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Add image"))), ui, A::AddImage);
pick(ui.add_enabled(perms.can_assemble, egui::Button::new(tl!("Delete page"))), ui, A::DeletePage(page));
pick(ui.add_enabled(perms.can_copy, egui::Button::new(tl!("Export this PDF"))), ui, A::ExportPdf);
ui.separator();
pick(ui.button(tl!("Find text or tools")), ui, A::Find);
pick(ui.button(tl!("Rotate view clockwise")), ui, A::RotateCW);
pick(ui.button(tl!("Document properties…")), ui, A::DocProperties);
}
impl DocView {
/// Whether a middle-button scrolling gesture is active (tests and automation).
pub fn auto_scrolling(&self) -> bool {
@@ -864,6 +1131,7 @@ struct PageEnv<'a> {
modal: bool,
navigation_enabled: bool,
paper: Color32,
show_page_edge_shadows: bool,
ppp: f32,
precise_scroll: Vec2,
prefs: &'a comments::CommentPrefs,
@@ -887,6 +1155,7 @@ struct PageEnv<'a> {
fill_text_size: f64,
fill_text_color: [f64; 3],
today: (i64, u32, u32),
can_paste_image: bool,
}
/// What the page-scroll closure produces for the rest of the frame.
@@ -904,6 +1173,7 @@ struct PageOut {
open_initials: bool,
open_signature: bool,
open_table: Option<(usize, [f64; 4])>,
page_action: Option<PageMenuAction>,
primary_down: bool,
refused: Option<String>,
stamp_placed: bool,
@@ -947,6 +1217,70 @@ struct PageSlot {
/// Paints the page's paper, shadow and raster (or why it can't be shown), and asks for the renders
/// it wants.
#[allow(clippy::too_many_arguments)]
fn paint_image_suppressed(
xf: &PageXform,
painter: &egui::Painter,
tex: egui::TextureId,
u0: f32,
v0: f32,
u1: f32,
v1: f32,
suppress: Option<[f32; 4]>,
is_full_page_tex: bool,
) {
let Some([su0, sv0, su1, sv1]) = suppress else {
if is_full_page_tex {
xf.paint_page_texture_rect(painter, tex, u0, v0, u1, v1);
} else {
xf.paint_image(painter, tex, u0, v0, u1, v1);
}
return;
};
let iu0 = u0.max(su0);
let iu1 = u1.min(su1);
let iv0 = v0.max(sv0);
let iv1 = v1.min(sv1);
if iu0 >= iu1 || iv0 >= iv1 {
if is_full_page_tex {
xf.paint_page_texture_rect(painter, tex, u0, v0, u1, v1);
} else {
xf.paint_image(painter, tex, u0, v0, u1, v1);
}
return;
}
let map_uv = |pu: f32, pv: f32| -> (f32, f32) {
if is_full_page_tex {
(pu, pv)
} else {
let du = (u1 - u0).max(1e-6);
let dv = (v1 - v0).max(1e-6);
((pu - u0) / du, (pv - v0) / dv)
}
};
let draw_strip = |pu0: f32, pv0: f32, pu1: f32, pv1: f32| {
if pu1 <= pu0 || pv1 <= pv0 {
return;
}
let (tu0, tv0) = map_uv(pu0, pv0);
let (tu1, tv1) = map_uv(pu1, pv1);
xf.paint_image_region(painter, tex, pu0, pv0, pu1, pv1, tu0, tv0, tu1, tv1);
};
if iv0 > v0 {
draw_strip(u0, v0, u1, iv0);
}
if v1 > iv1 {
draw_strip(u0, iv1, u1, v1);
}
if iu0 > u0 {
draw_strip(u0, iv0, iu0, iv1);
}
if u1 > iu1 {
draw_strip(iu1, iv0, u1, iv1);
}
}
fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) {
let PageEnv { paper, ppp, scale, t, tag, .. } = frame.env;
let PageSlot { i, r, xf } = *slot;
@@ -954,7 +1288,7 @@ fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFra
let info = frame.env.info;
let painter = ui.painter();
// Shadows would darken the preceding page when pages touch.
if view.show_page_gaps {
if view.show_page_gaps && frame.env.show_page_edge_shadows {
painter.add(egui::epaint::Shadow { offset: [0, 3], blur: 14, spread: 0, color: t.page_shadow }.as_shape(r, CornerRadius::ZERO));
}
painter.rect_filled(r, CornerRadius::ZERO, paper);
@@ -989,13 +1323,33 @@ fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFra
Some(p) => frame.wanted.push((i, tag_scale(p.tag), p.tag, None)),
None => frame.wanted.push((i, want_scale, want_tag, None)),
}
let doc = frame.env.doc;
let active_drag_suppress = if let Some(sel) = view.image_selection.clone()
&& sel.page == i
&& sel.drag.is_some()
{
let images = cached_edit_images(view, doc, i);
if let Some(img) = images.get(sel.index) {
let obb = ImageObb::from_image(img, &xf, info, i);
let uvs = obb.corners().map(|c| xf.screen_to_norm(c));
let u0 = uvs.iter().map(|(u, _)| *u).fold(1.0f32, f32::min).clamp(0.0, 1.0);
let u1 = uvs.iter().map(|(u, _)| *u).fold(0.0f32, f32::max).clamp(0.0, 1.0);
let v0 = uvs.iter().map(|(_, v)| *v).fold(1.0f32, f32::min).clamp(0.0, 1.0);
let v1 = uvs.iter().map(|(_, v)| *v).fold(0.0f32, f32::max).clamp(0.0, 1.0);
if u1 > u0 && v1 > v0 { Some([u0, v0, u1, v1]) } else { None }
} else {
None
}
} else {
None
};
match view.pages.get(&i) {
Some(p) => {
if !tiled && p.tag == want_tag {
xf.texel_aligned(p.tex.size(), ppp).paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0);
paint_image_suppressed(&xf.texel_aligned(p.tex.size(), ppp), painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0, active_drag_suppress, true);
} else {
// A backdrop, or a raster at an older scale until the new one arrives.
xf.paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0);
paint_image_suppressed(&xf, painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0, active_drag_suppress, true);
}
}
None => {
@@ -1006,14 +1360,21 @@ fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFra
}
}
if tiled && r.intersects(visible) {
paint_page_tiles(ui, view, frame, slot, (pw_pt, ph_pt, max_side));
paint_page_tiles(ui, view, frame, slot, (pw_pt, ph_pt, max_side), active_drag_suppress);
}
}
}
/// Paints the tiles of a page too big to draw whole: the tiles of earlier zooms first, stretched,
/// then the visible tiles of this one, nearest the viewport centre first, asking for those missing.
fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, (pw_pt, ph_pt, max_side): (f32, f32, f32)) {
fn paint_page_tiles(
ui: &egui::Ui,
view: &mut DocView,
frame: &mut PageFrame<'_>,
slot: &PageSlot,
(pw_pt, ph_pt, max_side): (f32, f32, f32),
active_drag_suppress: Option<[f32; 4]>,
) {
let PageEnv { ppp, scale, tag, .. } = frame.env;
let PageSlot { i, r, xf } = *slot;
let visible = frame.visible;
@@ -1042,7 +1403,7 @@ fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>
let (ow, oh) = (device_pixels(pw_pt, s) as f32, device_pixels(ph_pt, s) as f32);
let [w, h] = tex.size();
let (x, y) = (ox as f32, oy as f32);
xf.paint_image(painter, tex.id(), x / ow, y / oh, (x + w as f32) / ow, (y + h as f32) / oh);
paint_image_suppressed(&xf, painter, tex.id(), x / ow, y / oh, (x + w as f32) / ow, (y + h as f32) / oh, active_drag_suppress, false);
}
// Visible tiles, nearest the viewport centre first: each tile costs a full
// page interpret, so the workers sharpen what is looked at before the edges.
@@ -1067,13 +1428,16 @@ fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>
frame.wanted.push((i, scale, tag, Some(Tile { x, y, w, h })));
if let Some(tex) = view.tiles.get(&(i, tag, x, y)) {
let (fw, fh) = (dw as f32, dh as f32);
xf.texel_aligned([dw as usize, dh as usize], ppp).paint_image(
paint_image_suppressed(
&xf.texel_aligned([dw as usize, dh as usize], ppp),
painter,
tex.id(),
x as f32 / fw,
y as f32 / fh,
(x + w) as f32 / fw,
(y + h) as f32 / fh,
active_drag_suppress,
false,
);
}
}
@@ -1344,9 +1708,18 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
info,
allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used),
};
let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view, &mut frame.out.image_action);
let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view);
!others_own_pointer && (text_used || image_used)
};
if tool == QuickTool::EditText {
if others_own_pointer {
if ptr.pressed().is_some() || resp.clicked() || resp.secondary_clicked() {
view.image_selection = None;
}
} else {
crate::content_ui::sync_image_selection(view, i, &images, added);
}
}
let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, doc_links, view);
if tool == QuickTool::Select
&& resp.secondary_clicked()
@@ -1384,17 +1757,20 @@ fn edit_inventory(
l
}
};
let images = match view.edit_images.get(&i) {
Some((g, l)) if *g == generation => l.clone(),
_ => {
let l = doc.page_images(i);
view.edit_images.insert(i, (generation, l.clone()));
l
}
};
let images = cached_edit_images(view, doc, i).to_vec();
(lines, images)
}
/// Images parsed once per page and document edit generation, shared by painting and menus.
fn cached_edit_images<'a>(view: &'a mut DocView, doc: &pdfcraft_engine::Document, page: usize) -> &'a [pdfcraft_engine::PageImage] {
let generation = doc.edit_generation();
let entry = view.edit_images.entry(page).or_insert_with(|| (generation, doc.page_images(page)));
if entry.0 != generation {
*entry = (generation, doc.page_images(page));
}
&entry.1
}
/// What the page modules have under a point on one page, for the page router.
struct PageHits<'a> {
page: usize,
@@ -1447,6 +1823,19 @@ impl PageHits<'_> {
clip,
tabs,
} = *self;
// Crop mode owns presses on its page, including the controls beside its
// frame. Underlying paragraphs and the empty-page marquee must not take
// a crop button's press before the image module receives it.
if tool == QuickTool::EditText
&& can_modify
&& let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_some())
{
return vec![crate::page_router::Hit::new(
crate::page_router::Owner::Picture { index: selection.index, part: crate::page_router::PicturePart::Body },
crate::page_cursor::Layer::Handle,
egui::CursorIcon::Default,
)];
}
let fields = if preparing {
crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p)
} else if tool == QuickTool::Select {
@@ -1966,6 +2355,7 @@ fn cancel_on_escape(ctx: &egui::Context, view: &mut DocView, ptr: &PagePointer)
}
}
#[allow(clippy::cognitive_complexity)]
fn draw_pages(
ui: &mut egui::Ui,
viewport: Rect,
@@ -1999,6 +2389,8 @@ fn draw_pages(
prefs,
preparing,
selection_wheel,
ppp,
rects,
single_paging,
tool,
unobstructed,
@@ -2065,6 +2457,38 @@ fn draw_pages(
if resp.secondary_clicked() {
// Set again below when the right-click lands on a link.
view.links.context = None;
let mut over_image = false;
if tool == QuickTool::EditText {
let pointer_pos = ptr.pos;
if let Some(p) = pointer_pos {
for &page_idx in visible_pages {
let r = snap_to_pixels(rects[page_idx].translate(origin.to_vec2()), ppp);
if !r.intersects(visible) {
continue;
}
let xf =
PageXform { rect: r, rot: view.rotation, pw: info.pages[page_idx].width.max(1.0), ph: info.pages[page_idx].height.max(1.0) };
let images = cached_edit_images(view, doc, page_idx).to_vec();
for (idx, img) in images.iter().enumerate().rev() {
let obb = ImageObb::from_image(img, &xf, info, page_idx);
let b = image_screen_rect(img, &xf, info, page_idx);
if obb.contains(b, p) {
over_image = true;
if view.image_selection.as_ref().is_none_or(|s| s.page != page_idx || s.index != idx) {
view.image_selection = Some(crate::edit_text_ui::ImageSelection::at(page_idx, idx));
}
break;
}
}
if over_image {
break;
}
}
}
}
if !over_image && tool == QuickTool::EditText {
view.image_selection = None;
}
}
let mut frame = PageFrame {
env: *env,
@@ -2094,20 +2518,22 @@ fn draw_pages(
let PageOut {
mut canvas_action,
clicked_link,
mut copy_selected,
copy_selected,
mut field_menu,
field_placed,
field_props,
hover_text: _,
image_action,
mut image_action,
open_boxes,
open_initials,
open_signature,
open_table,
page_action: _,
primary_down: _,
refused,
stamp_placed,
} = page_out;
let mut page_action: Option<PageMenuAction> = None;
let painter = ui.painter();
if calc_order {
view.calc_order.set_hover_page(calc_hover);
@@ -2227,20 +2653,33 @@ fn draw_pages(
}
return;
}
if view.comments.selected.is_none() {
let selected = view.selected_text().is_some_and(|t| !t.is_empty());
let key = crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY);
if ui.add_enabled(can_copy && selected, egui::Button::new(tl!("Copy")).shortcut_text(key)).clicked() {
copy_selected = true;
ui.close();
}
ui.separator();
let can_paste_image = env.can_paste_image;
let is_image_menu = tool == QuickTool::EditText
&& (ui.ctx().data_mut(|d| d.get_temp::<bool>(crate::edit_text_ui::image_menu_id())).unwrap_or(false)
|| view.image_selection.clone().is_some_and(|sel| {
let pointer_pos = ptr.pos;
let Some(p) = pointer_pos else { return false };
let Some((_, xf)) = view.screen_xforms.iter().find(|(page_idx, _)| *page_idx == sel.page).cloned() else { return false };
let images = cached_edit_images(view, doc, sel.page).to_vec();
let Some(img) = images.get(sel.index) else { return false };
let obb = ImageObb::from_image(img, &xf, info, sel.page);
let b = image_screen_rect(img, &xf, info, sel.page);
obb.contains(b, p)
}));
if is_image_menu && let Some(sel) = view.image_selection.clone() {
let images = cached_edit_images(view, doc, sel.page).to_vec();
let at = view.comments.context_at.map(|(_, at)| at);
let rotation = (view.rotation % 360 + info.pages.get(sel.page).map_or(0, |p| p.rotation % 360)) % 360;
crate::edit_text_ui::image_context_menu(ui, view, &images, sel.page, sel.index, can_paste_image, &mut image_action, at, rotation);
return;
}
if view.comments.selected.is_some() {
canvas_action = comments::context_menu(ui, view, info, &|page, index| doc.annotation_deletable(page, index), prefs, allowed, can_modify);
return;
}
// Over a picture (and no comment): Copy Image, as in Acrobat's page menu.
let mut copy_image = None;
if view.comments.selected.is_none()
&& let Some((page, at)) = view.comments.context_at
{
if let Some((page, at)) = view.comments.context_at {
// The page's content is parsed once per right-click, not on every frame the menu is open.
let key = egui::Id::unique("copy-image-under-menu");
let cached = ui.data(|d| d.get_temp::<(usize, [f64; 2], Option<usize>)>(key)).filter(|c| c.0 == page && c.1 == at);
@@ -2264,9 +2703,28 @@ fn draw_pages(
ui.separator();
}
}
canvas_action =
comments::context_menu(ui, view, info, &|page, index| doc.annotation_deletable(page, index), prefs, allowed, can_modify).or(copy_image);
let has_copy_image = copy_image.is_some();
if copy_image.is_some() {
canvas_action = copy_image;
}
let (page, at) = view.comments.context_at.unwrap_or((view.current, [0.0, 0.0]));
let can_copy_items = can_copy && (view.image_selection.is_some() || view.selected_text().is_some() || has_copy_image);
let can_paste = can_paste_image && can_modify;
let perms = PageMenuPerms {
can_modify,
can_assemble: doc.allows_assembly(),
can_annotate: allowed,
can_print: doc.allows_printing(),
can_copy,
can_copy_items,
can_paste,
has_annotations: !info.annotations.is_empty(),
};
default_page_context_menu(ui, page, at, perms, &mut page_action);
});
if !resp.context_menu_opened() {
ui.ctx().data_mut(|d| d.insert_temp(crate::edit_text_ui::image_menu_id(), false));
}
*out = PageOut {
canvas_action,
clicked_link,
@@ -2280,6 +2738,7 @@ fn draw_pages(
open_initials,
open_signature,
open_table,
page_action,
primary_down: ptr.primary_down,
refused,
stamp_placed,
@@ -2390,6 +2849,7 @@ fn read_wheel(ui: &mut egui::Ui, view: &mut DocView, avail: Rect, single_paging:
// The turn leaves egui's eased copy of the notch to decay for a few frames; it must not
// scroll the page just shown away from its top (or bottom).
view.precise_scroll_until = now + 0.25;
view.fling.stop();
}
// Paging owns the vertical axis in single-page view; leave it to the pager.
// The rest of a gesture that turned the page is swallowed too, or it would pan the new page
@@ -3024,6 +3484,14 @@ fn refine_goto_point(mut scroll: egui::ScrollArea, view: &mut DocView, geometry:
/// signature badges and sign tabs) and render requests.
pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let can_paste_key = egui::Id::unique("can-paste-image");
let can_paste_image = if ui.input(|i| i.pointer.secondary_pressed()) {
let can = app.can_paste_image();
ui.data_mut(|d| d.insert_temp(can_paste_key, can));
can
} else {
ui.data(|d| d.get_temp(can_paste_key)).unwrap_or(false)
};
// Keyboard scrolling applies to this frame's page view only, never later.
let key_scroll = std::mem::take(&mut app.views[index].key_scroll);
if key_scroll != 0.0 {
@@ -3082,7 +3550,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if view.organize {
// One remembered grid size for every document, kept in the settings.
view.set_grid_zoom(organize_zoom);
organize_grid(view, info, doc.allows_assembly(), doc.dirty, unobstructed, ui, &t);
organize_grid(view, doc, unobstructed, app.show_page_edge_shadows, ui, &t);
app.organize_zoom = view.grid_zoom();
return;
}
@@ -3237,6 +3705,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
modal,
navigation_enabled,
paper,
show_page_edge_shadows: app.show_page_edge_shadows,
ppp,
precise_scroll,
prefs,
@@ -3260,9 +3729,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
fill_text_size: app.fill_text_size,
fill_text_color: app.fill_text_color,
today,
can_paste_image,
};
let mut page_out = PageOut::default();
let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), |ui, viewport| {
let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), app.show_page_edge_shadows, |ui, viewport| {
draw_pages(ui, viewport, view, &env, &mut page_out, &mut app.signature_preview)
});
let PageOut {
@@ -3278,6 +3748,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
open_initials,
open_signature,
open_table,
page_action,
primary_down,
mut refused,
stamp_placed,
@@ -3317,8 +3788,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if preparing {
crate::prepare::keys(ui.ctx(), view);
}
if tool != QuickTool::EditText {
view.image_selection = None;
crate::content_ui::release_image_selection(view, &added);
}
if editing_content {
crate::content_ui::keys(ui.ctx(), view);
crate::content_ui::keys(ui.ctx(), view, tool == QuickTool::EditText);
}
if tool == QuickTool::Link {
crate::link_ui::keys(ui.ctx(), view);
@@ -3364,6 +3839,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
field_props,
field_menu,
image_action,
page_action,
refused,
open_signature,
open_initials,
@@ -3385,6 +3861,7 @@ struct FrameRequests {
field_props: Option<(String, usize)>,
field_menu: Option<FieldMenu>,
image_action: Option<crate::edit_text_ui::ImageAction>,
page_action: Option<PageMenuAction>,
refused: Option<String>,
open_signature: bool,
open_initials: bool,
@@ -3399,12 +3876,14 @@ struct FrameRequests {
}
/// Opens the dialogs and runs the app commands the frame asked for.
#[allow(clippy::cognitive_complexity)]
fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, requests: FrameRequests) {
let FrameRequests {
open_props,
field_props,
field_menu,
image_action,
page_action,
refused,
open_signature,
open_initials,
@@ -3468,12 +3947,109 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui,
}
Some(crate::edit_text_ui::ImageAction::CopyFile(page, index)) => app.copy_page_image_file(page, index),
Some(crate::edit_text_ui::ImageAction::Cut(page, image)) => {
app.views[index].image_selection = None;
app.cut_page_image(page, image);
}
Some(crate::edit_text_ui::ImageAction::Paste(page, at)) => {
if let Some(pt) = at {
app.paste_image_at(page, pt);
} else {
app.paste_image(page, None);
}
}
Some(crate::edit_text_ui::ImageAction::DrawFreehand) => {
app.quick_tool = crate::QuickTool::Comment(crate::comments::CommentTool::Ink);
}
Some(crate::edit_text_ui::ImageAction::Redact(page, index)) => app.redact_page_image(page, index),
Some(crate::edit_text_ui::ImageAction::Link(page, index)) => app.link_page_image(page, index),
Some(crate::edit_text_ui::ImageAction::Recognize(page)) => app.recognize_text_on_page(page),
Some(crate::edit_text_ui::ImageAction::AddComment(page, i)) => {
if let Some(d) = app.session.get(app.views[index].id) {
let images = d.page_images(page);
if let Some(img) = images.get(i) {
let r = img.visible_rect.unwrap_or(img.rect);
let center = [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0];
app.views[index].comments.context_at = Some((page, center));
app.views[index].comments.composer = Some(crate::comments::Composer {
page,
at: center,
kind: crate::comments::ComposerKind::Note,
text: String::new(),
focus: true,
});
}
}
}
None => {}
}
match page_action {
Some(PageMenuAction::Copy) => {
if let Some(sel) = app.views[index].image_selection.clone() {
app.copy_page_image(sel.page, sel.index);
} else if app.views[index].selected_text().is_some() {
app.execute("edit.copy");
} else if let Some((page, at)) = app.views[index].comments.context_at
&& let Some(doc) = app.session.get(app.views[index].id)
&& let Some(image) = doc.page_image_at(page, at)
{
app.copy_page_image(page, image);
}
}
Some(PageMenuAction::Paste(page, at)) => {
app.paste_image_at(page, at);
}
Some(PageMenuAction::RotateCCW) => {
app.views[index].rotate_view(false);
}
Some(PageMenuAction::RotateCW) => {
app.views[index].rotate_view(true);
}
Some(PageMenuAction::Print) => {
app.open_print();
}
Some(PageMenuAction::SelectTool) => {
app.quick_tool = QuickTool::Select;
}
Some(PageMenuAction::HandTool) => {
app.quick_tool = QuickTool::Hand;
}
Some(PageMenuAction::MarqueeZoom) => {
app.quick_tool = QuickTool::MarqueeZoom;
}
Some(PageMenuAction::AddComment(page, at)) => {
app.views[index].comments.composer =
Some(crate::comments::Composer { page, at, kind: crate::comments::ComposerKind::Note, text: String::new(), focus: true });
}
Some(PageMenuAction::FlattenComments) => {
app.views[index].comments.selected = None;
app.views[index].pending_edit = Some(pdfcraft_engine::Edit::Flatten { comments: true, fields: false });
}
Some(PageMenuAction::AddBookmark) => {
app.bookmark_action(crate::panels::BmAction::New);
}
Some(PageMenuAction::TakeSnapshot) => {
app.quick_tool = QuickTool::Snapshot;
}
Some(PageMenuAction::EditPdf) => {
app.quick_tool = QuickTool::EditText;
}
Some(PageMenuAction::AddText) => {
app.quick_tool = QuickTool::AddText;
}
Some(PageMenuAction::AddImage) => {
app.add_image_dialog();
}
Some(PageMenuAction::DeletePage(page)) => {
app.views[index].pending_edit = Some(pdfcraft_engine::Edit::DeletePages { pages: vec![page] });
}
Some(PageMenuAction::ExportPdf) => {
app.select_tool("export");
}
Some(PageMenuAction::Find) => {
app.views[index].open_find();
}
Some(PageMenuAction::DocProperties) => {
app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Description));
}
None => {}
}
if let Some(why) = refused {
+10 -2
View File
@@ -147,14 +147,22 @@ pub(super) const SUPPRESS_SECS: f64 = 0.25;
/// Short rolling extension while egui still has an unprocessed wheel tail.
pub(super) const SUPPRESS_TAIL_SECS: f64 = 0.05;
// Keep the viewer's scroll identity tied to the document instead of its parent UI.
// Keep the viewer's scroll identity tied to the document instead of its parent UI. Without
// `fades` (Show Page Edge Shadows off) the viewport-edge gradients are off for this area only.
pub(super) fn page_scroll<R>(
ui: &mut egui::Ui,
scroll: egui::ScrollArea,
id: egui::Id,
fades: bool,
draw: impl FnOnce(&mut egui::Ui, egui::Rect) -> R,
) -> egui::scroll_area::ScrollAreaOutput<R> {
ui.scope_builder(egui::UiBuilder::new().scope_id(id), |ui| scroll.show_viewport(ui, draw)).inner
let strength = ui.spacing().scroll.fade.strength;
if !fades {
ui.spacing_mut().scroll.fade.strength = 0.0;
}
let out = ui.scope_builder(egui::UiBuilder::new().scope_id(id), |ui| scroll.show_viewport(ui, draw)).inner;
ui.spacing_mut().scroll.fade.strength = strength;
out
}
/// Run a push button's action (the ones that need no JavaScript engine).
+6
View File
@@ -239,6 +239,12 @@ impl DocView {
self.texts.get(&page).cloned()
}
/// Whether `page`'s text layer has arrived from the renderer. Selecting text, the I-beam and
/// find matches on a page all wait for it (tests and automation).
pub fn has_page_text(&self, page: usize) -> bool {
self.texts.contains_key(&page)
}
pub fn thumb(&self, page: usize) -> Option<&TextureHandle> {
self.thumbs.get(&page).map(|t| &t.tex)
}
+15 -5
View File
@@ -46,8 +46,16 @@ pub(crate) fn page_input(
let u = info_page.view_to_user(vx, vy);
let at = [f64::from(u[0]), f64::from(u[1])];
let generation = doc.edit_generation();
view.picked_image =
doc.page_image_at(page, at).and_then(|index| doc.page_images(page).get(index).map(|i| PickedImage { page, index, rect: i.rect, generation }));
view.picked_image = doc.page_image_at(page, at).and_then(|index| {
doc.page_images(page).get(index).and_then(|i| {
let screen_rect = crate::canvas::image_screen_rect(i, xf, info, page);
if screen_rect.is_positive() && screen_rect.contains(p) {
Some(PickedImage { page, index, rect: crate::canvas::image_selectable_bounds(i), generation })
} else {
None
}
})
});
}
/// Forget the selected picture once the document has been edited since it was picked: Edit text &
@@ -71,7 +79,7 @@ pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info:
}
/// A file name without characters file systems refuse.
fn safe_name(s: &str) -> String {
pub(crate) fn safe_name(s: &str) -> String {
let cleaned: String = s.chars().map(|c| if c.is_control() || r#"/\:*?"<>|"#.contains(c) { '_' } else { c }).take(80).collect();
let cleaned = cleaned.trim().trim_matches('.').to_string();
if cleaned.is_empty() { "PdfCraft".into() } else { cleaned }
@@ -171,7 +179,8 @@ impl PdfCraftApp {
return;
}
// The document may have changed since the click: the same picture must still be there.
let current = self.session.get(self.views[i].id).and_then(|d| d.page_images(picked.page).get(picked.index).map(|im| im.rect));
let current =
self.session.get(self.views[i].id).and_then(|d| d.page_images(picked.page).get(picked.index).map(crate::canvas::image_selectable_bounds));
if current == Some(picked.rect) {
self.copy_page_image_file(picked.page, picked.index);
} else {
@@ -195,6 +204,7 @@ impl PdfCraftApp {
let copied = file.and_then(|(ext, bytes)| {
let name = format!("{stem} p{} img{}.{ext}", page.saturating_add(1), index.saturating_add(1));
self.put_file_on_clipboard(&name, &bytes)?;
self.last_copied_image = None;
self.last_copied_file = Some((name, bytes));
Ok(ext)
});
@@ -213,7 +223,7 @@ impl PdfCraftApp {
}
let dir = clipboard_dir().ok_or("there is no folder for the copied file")?;
let path = write_clipboard_file(&dir, name, bytes)?;
arboard::Clipboard::new().and_then(|mut c| c.set().file_list(&[path])).map_err(|e| e.to_string())
crate::os_clipboard::with(|c| c.set().file_list(&[path])).map_err(|e| e.to_string())
}
#[cfg(target_arch = "wasm32")]
+19 -2
View File
@@ -267,7 +267,17 @@ fn request_sharper(
}
}
pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, unobstructed: bool, ui: &mut egui::Ui, t: &Tokens) {
pub(super) fn organize_grid(
view: &mut DocView,
doc: &pdfcraft_engine::Document,
unobstructed: bool,
show_page_edge_shadows: bool,
ui: &mut egui::Ui,
t: &Tokens,
) {
let info = &doc.info;
let editable = doc.allows_assembly();
let dirty = doc.dirty;
let ppp = ui.ctx().pixels_per_point();
// The page image scales with the zoom; the padding and the page number don't.
let cell = vec2(146.0 * view.grid_zoom + 44.0, 194.0 * view.grid_zoom + 88.0);
@@ -290,6 +300,10 @@ pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool,
let (mut in_view, mut sharper): (Vec<(usize, f32)>, Vec<RenderRequest>) = (Vec::new(), Vec::new());
// The source cell can scroll out of the virtualized rows during a drag.
let mut drop = view.org_drag.is_some() && ui.input(|i| i.pointer.primary_released());
let fade_strength = ui.spacing().scroll.fade.strength;
if !show_page_edge_shadows {
ui.spacing_mut().scroll.fade.strength = 0.0;
}
egui::ScrollArea::vertical().auto_shrink([false, false]).show_viewport(ui, |ui, clip| {
apply_auto_scroll(ui, middle_gesture, auto_delta);
ui.add_space(20.0);
@@ -329,7 +343,9 @@ pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool,
if view.selected.contains(&i) {
ui.painter().rect_stroke(c.shrink(6.0), CornerRadius::same(8), Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
}
ui.painter().rect_filled(pr.translate(vec2(0.0, 1.5)), CornerRadius::same(1), t.page_shadow);
if show_page_edge_shadows {
ui.painter().rect_filled(pr.translate(vec2(0.0, 1.5)), CornerRadius::same(1), t.page_shadow);
}
ui.painter().rect_filled(pr, CornerRadius::ZERO, Color32::WHITE);
// A page drawn larger than its thumbnail gets a render at the size drawn, while
// it is in view; until that arrives the thumbnail stands in.
@@ -507,6 +523,7 @@ pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool,
view.org_drag = None;
}
});
ui.spacing_mut().scroll.fade.strength = fade_strength;
// Files dragged in from outside go to the gap under the pointer. Some platforms don't say
// where the pointer is until the files are dropped; then the whole grid is the target.
let pointer = ui.input(|i| i.pointer.hover_pos()).filter(|p| viewport.contains(*p));
+4 -5
View File
@@ -56,18 +56,17 @@ impl PagePointer {
// pointer is outside the window. With a dialog open only a drag counts, up to the frame
// it stops: on the frame one opens egui still gives the pages a press that lands under
// it, and its click.
let held = resp.dragged() || resp.drag_stopped() || (!modal && resp.interact_pointer_pos().is_some());
// `contains_pointer` alone also holds a few points under the edge of an Area over the
// pages (egui's interact radius), so the top layer there must be the pages' own. A
// dialog covers them from the frame it opens, before egui knows its layer.
let layer = ui.layer_id();
let on_top =
|p: &Pos2| !modal && !dragged_elsewhere(ui, resp) && resp.contains_pointer() && ui.ctx().layer_id_at(*p).is_none_or(|l| l == layer);
let press_origin = origin.or_else(|| first_press(&edges));
let held =
resp.dragged() || resp.drag_stopped() || (!modal && (resp.interact_pointer_pos().is_some() || press_origin.is_some_and(|p| on_top(&p))));
let pos = at.filter(|p| held || on_top(p));
if !held {
return Self { pos, origin: None, edges: Vec::new(), primary_down, any_down };
}
Self { pos, origin: origin.or_else(|| first_press(&edges)), edges, primary_down, any_down }
Self { pos, origin: press_origin, edges, primary_down, any_down }
}
/// Where the pointer is, when it is inside `r`.
+1 -2
View File
@@ -42,8 +42,7 @@ pub(crate) enum Owner {
pub(crate) enum PicturePart {
/// The picture: a click selects it, a drag moves the selected one.
Body,
/// A corner of the selected picture (left top, right top, left bottom, right bottom): a drag
/// resizes it.
/// A resize grip in `ImageObb::handles` order (four corners and four edge midpoints).
Corner(usize),
/// The selected picture's rotate knob: a drag turns it.
Knob,
+35
View File
@@ -2002,3 +2002,38 @@ fn a_moved_picture_is_drawn_from_the_fresh_raster_while_a_tile_is_still_stale()
h.state_mut().views[0].stale_tiles.clear();
h.state_mut().views[0].tiles.clear();
}
#[test]
fn image_handles_account_for_document_and_view_rotation() {
for page_rotation in [0, 90, 180, 270] {
for view_rotation in [0, 90, 180, 270] {
let (width, height) = if page_rotation % 180 == 0 { (300.0, 400.0) } else { (400.0, 300.0) };
let info = DocInfo {
pages: vec![pdfcraft_render::PageInfo {
width,
height,
label: String::new(),
crop: [0.0, 0.0, 300.0, 400.0],
rotation: page_rotation,
}],
..Default::default()
};
let size = if view_rotation % 180 == 0 { vec2(width, height) } else { vec2(height, width) };
let xf = PageXform { rect: Rect::from_min_size(pos2(10.0, 20.0), size), rot: view_rotation, pw: width, ph: height };
let obb = ImageObb::from_matrix([100.0, 0.0, 0.0, 50.0, 50.0, 70.0], &xf, &info, 0);
let total = (page_rotation + view_rotation) % 360;
assert_eq!(obb.is_rotated, total != 0, "page={page_rotation} view={view_rotation}: user-labelled grips need matrix resize when turned");
for (handle, point) in obb.handles() {
let direction = point - obb.screen_center;
let cursor = match handle {
ImageHandle::TopLeft | ImageHandle::TopRight | ImageHandle::BottomLeft | ImageHandle::BottomRight => {
if direction.x * direction.y > 0.0 { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }
}
_ if direction.x.abs() > direction.y.abs() => egui::CursorIcon::ResizeHorizontal,
_ => egui::CursorIcon::ResizeVertical,
};
assert_eq!(obb.handle_cursor(handle), cursor, "page={page_rotation} view={view_rotation} handle={handle:?}");
}
}
}
}
+35 -1
View File
@@ -17,7 +17,7 @@ impl DocView {
}
/// Where `page` is drawn this frame, with its coordinate mapping.
pub(crate) fn page_xform(&self, page: usize) -> Option<PageXform> {
pub fn page_xform(&self, page: usize) -> Option<PageXform> {
self.screen_xforms.iter().find(|(p, _)| *p == page).map(|(_, xf)| *xf)
}
}
@@ -128,6 +128,10 @@ impl PageXform {
Rect::from_two_pos(self.norm_to_screen(a.0, a.1), self.norm_to_screen(b.0, b.1))
}
pub fn user_rect_f64(&self, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect {
self.user_rect(info, page, r.map(|v| v as f32))
}
/// This transform resized to a raster of `px` device pixels (width, height before the view
/// rotation), so each of its texels covers one screen pixel. The rectangle's corner must
/// already be on a whole pixel ([`snap_to_pixels`]).
@@ -148,4 +152,34 @@ impl PageXform {
mesh.add_triangle(0, 2, 3);
painter.add(egui::Shape::mesh(mesh));
}
/// Draw a sub-rectangle of a whole-page raster texture with specific texture UVs covering the normalised page region.
#[allow(clippy::too_many_arguments)]
pub fn paint_image_region(
&self,
painter: &egui::Painter,
tex: egui::TextureId,
pu0: f32,
pv0: f32,
pu1: f32,
pv1: f32,
tu0: f32,
tv0: f32,
tu1: f32,
tv1: f32,
) {
let mut mesh = egui::Mesh::with_texture(tex);
let corners = [(pu0, pv0, tu0, tv0), (pu1, pv0, tu1, tv0), (pu1, pv1, tu1, tv1), (pu0, pv1, tu0, tv1)];
for (u, v, tu, tv) in corners {
mesh.vertices.push(egui::epaint::Vertex { pos: self.norm_to_screen(u, v), uv: pos2(tu, tv), color: Color32::WHITE });
}
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
painter.add(egui::Shape::mesh(mesh));
}
/// Draw a sub-rectangle of a whole-page raster texture, where UV space matches page normalized space.
pub fn paint_page_texture_rect(&self, painter: &egui::Painter, tex: egui::TextureId, u0: f32, v0: f32, u1: f32, v1: f32) {
self.paint_image_region(painter, tex, u0, v0, u1, v1, u0, v0, u1, v1);
}
}
+1 -1
View File
@@ -145,7 +145,7 @@ impl PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
if self.system_clipboard {
let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&out.rgba) };
arboard::Clipboard::new().and_then(|mut c| c.set_image(img)).map_err(|e| e.to_string())?;
crate::os_clipboard::with(|c| c.set_image(img)).map_err(|e| e.to_string())?;
}
self.last_snapshot = Some((w, h, out.rgba));
Ok((w, h))
+2
View File
@@ -28,6 +28,8 @@
//! comments, selecting text, moving comments). `ui.state` reports `pages_on_screen` to aim at.
//! - `ui.type {text}`, `ui.key {key, modifiers?}`: keyboard input to the focused widget / app.
//! - `ui.command {id}`: run a registry command (as the menu would). `ui.commands` lists them.
//! - `ui.set {key: "show_page_edge_shadows", value: "true"|"false"}` controls page
//! decoration app-wide; `ui.state.show_page_edge_shadows` reports the saved preference.
//! - `ui.set {key, value}`: view options (`--page`, `--zoom`, …), plus persistent preferences.
//! `select` accepts physical page numbers and ranges such as `1,12,16-18`; blank clears the
//! explicit selection. This is the same bounded parser as the Organize Pages field; invalid
+1
View File
@@ -141,6 +141,7 @@ impl Host for crate::PdfCraftApp {
"palette_open": self.palette_open,
"theme": format!("{:?}", self.theme),
"theme_preference": self.theme_preference,
"show_page_edge_shadows": self.show_page_edge_shadows,
"language": self.language,
"notice": self.toast.as_ref().map(|t| t.0.clone()),
"progress": self.progress_notice.as_ref().map(|p| json!({ "label": p.label, "fraction": p.fraction })),
+128 -20
View File
@@ -128,6 +128,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
Dialog::Print => 820.0,
Dialog::FieldProps | Dialog::BulkFieldProps => 600.0,
Dialog::About => 780.0,
Dialog::PinTool => 540.0,
_ => 520.0,
});
if dialog_body(ui, app, dialog, &mut req) {
@@ -446,6 +447,11 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m
return false;
}
}
Dialog::PinTool => {
if !dialog_pin_tool(ui, app, req) {
return false;
}
}
}
true
}
@@ -1080,6 +1086,7 @@ fn dialog_rotate_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi
use pdfcraft_engine::{PageOrientation as O, PageParity as P};
let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len());
let d = &mut app.rotate_draft;
let mut typing_to = false;
ui.label(egui::RichText::new(tl!("Rotate Pages")).font(theme::semibold(18.0)));
ui.add_space(8.0);
egui::Grid::new("rotate-pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
@@ -1101,10 +1108,11 @@ fn dialog_rotate_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi
widgets::radio_value(ui, &mut d.which, 0, tl!("All"));
widgets::radio_value(ui, &mut d.which, 1, tl!("Selection"));
ui.horizontal(|ui| {
widgets::radio_value(ui, &mut d.which, 2, tl!("From"));
ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.from).range(1..=n));
ui.label(tl!("to"));
ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.to).range(1..=n));
let from_label = widgets::radio_value(ui, &mut d.which, 2, tl!("From"));
ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.from).range(1..=n)).labelled_by(from_label.id);
let to_label = ui.label(tl!("to"));
typing_to =
widgets::typing_into(&ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.to).range(1..=n)).labelled_by(to_label.id));
ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())]));
});
});
@@ -1136,10 +1144,12 @@ fn dialog_rotate_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi
});
ui.end_row();
});
d.to = d.to.max(d.from);
widgets::keep_range_ordered(d.from, &mut d.to, typing_to);
ui.add_space(12.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
// OK while the end is still being typed (a screen reader can press it).
widgets::keep_range_ordered(d.from, &mut d.to, false);
req.rotate_now = true;
req.close = true;
}
@@ -1161,17 +1171,21 @@ fn dialog_duplicate_field(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pe
ui.add_space(8.0);
ui.label(crate::i18n::fmt(tl!("Duplicate \"{name}\" onto:"), &[("name", &d.name)]));
widgets::radio_value(ui, &mut d.all, true, tl!("All pages"));
ui.horizontal(|ui| {
widgets::radio_value(ui, &mut d.all, false, tl!("From"));
ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n));
ui.label(tl!("to"));
ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n));
ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())]));
});
d.to = d.to.max(d.from);
let typing_to = ui
.horizontal(|ui| {
widgets::radio_value(ui, &mut d.all, false, tl!("From"));
ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n));
ui.label(tl!("to"));
let typing_to = widgets::typing_into(&ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n)));
ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())]));
typing_to
})
.inner;
widgets::keep_range_ordered(d.from, &mut d.to, typing_to);
ui.add_space(12.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
widgets::keep_range_ordered(d.from, &mut d.to, false);
let pages: Vec<usize> = if d.all { (0..n).collect() } else { (d.from - 1..d.to.min(n)).collect() };
req.duplicate_now = Some(Edit::DuplicateField { name: d.name.clone(), pages });
req.close = true;
@@ -1370,13 +1384,14 @@ fn dialog_number_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi
let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(1).max(1);
let d = &mut app.number_draft;
d.to = d.to.clamp(1, n);
d.from = d.from.clamp(1, d.to);
d.from = d.from.clamp(1, n);
let mut typing_to = false;
egui::Grid::new("number_pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
ui.label(tl!("Pages"));
ui.horizontal(|ui| {
ui.add(egui::DragValue::new(&mut d.from).range(1..=n));
ui.label(tl!("to"));
ui.add(egui::DragValue::new(&mut d.to).range(1..=n));
typing_to = widgets::typing_into(&ui.add(egui::DragValue::new(&mut d.to).range(1..=n)));
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])).color(t.text_muted));
});
ui.end_row();
@@ -1403,18 +1418,20 @@ fn dialog_number_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi
ui.add(egui::DragValue::new(&mut d.start).range(1..=99_999));
ui.end_row();
});
d.to = d.to.max(d.from);
widgets::keep_range_ordered(d.from, &mut d.to, typing_to);
let label = |k: u32| format!("{}{}", d.prefix, d.style.format(k));
ui.add_space(8.0);
let preview = if d.from == d.to {
// `to` can be below `from` while it is being typed.
let preview = if d.to <= d.from {
label(d.start)
} else {
format!("{}, {} … {}", label(d.start), label(d.start + 1), label(d.start + (d.to - d.from) as u32))
format!("{}, {} … {}", label(d.start), label(d.start + 1), label(d.start.saturating_add(u32::try_from(d.to - d.from).unwrap_or(u32::MAX))))
};
ui.label(
egui::RichText::new(crate::i18n::fmt(tl!("Labels: {preview}. Later pages keep their labels."), &[("preview", &preview)])).color(t.text_muted),
);
req.number_now = Some(Edit::NumberPages { from: d.from - 1, to: d.to - 1, style: d.style, prefix: d.prefix.clone(), first: d.start });
// Ordered here too: the footer's OK can be pressed while the end is still being typed.
req.number_now = Some(Edit::NumberPages { from: d.from - 1, to: d.to.max(d.from) - 1, style: d.style, prefix: d.prefix.clone(), first: d.start });
true
}
@@ -1506,7 +1523,7 @@ fn dialog_shortcuts(ui: &mut egui::Ui) -> bool {
/// `Dialog::About` content. Returns `false` when it has its own buttons and no footer is wanted.
fn dialog_about(ui: &mut egui::Ui, req: &mut Pending) -> bool {
let t = Tokens::get(ui.ctx());
// Tabs About · Contributors · Models (craftrules standards/contributors.md).
// Tabs About · Contributors · Models.
let tab_id = egui::Id::unique("about_tab");
let mut tab = ui.data_mut(|d| d.get_temp::<u8>(tab_id)).unwrap_or(0);
ui.horizontal_wrapped(|ui| {
@@ -1553,6 +1570,91 @@ fn dialog_about(ui: &mut egui::Ui, req: &mut Pending) -> bool {
true
}
fn dialog_pin_tool(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Pin tools to Home")).font(theme::semibold(18.0)));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::ghost_button(ui, "x", tl!("Close")).clicked() {
req.close = true;
}
});
});
ui.add_space(4.0);
ui.label(egui::RichText::new(tl!("Select tools to display on your Home page for quick access.")).color(t.text_muted));
ui.add_space(10.0);
ui.horizontal(|ui| {
let (icon_rect, _) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover());
crate::icons::paint(ui, icon_rect, "search", 16.0, t.text_muted);
let search_resp =
ui.add(egui::TextEdit::singleline(&mut app.pin_tool_search).hint_text(tl!("Search tools…")).desired_width(ui.available_width() - 32.0));
search_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), tl!("Search tools…")));
if !app.pin_tool_search.is_empty() && widgets::ghost_button(ui, "x", tl!("Clear search")).clicked() {
app.pin_tool_search.clear();
}
});
ui.add_space(8.0);
ui.separator();
ui.add_space(6.0);
let query = app.pin_tool_search.trim().to_lowercase();
egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]).show(ui, |ui| {
let mut toggle = None;
for g in pdfcraft_engine::catalog::TOOL_GROUPS {
let label = tl!(g.label);
let blurb = g.sections.first().map(|s| s.items.iter().take(4).map(|i| tl!(i.label)).collect::<Vec<_>>().join(" • ")).unwrap_or_default();
if !query.is_empty()
&& !label.to_lowercase().contains(&query)
&& !blurb.to_lowercase().contains(&query)
&& !g.id.to_lowercase().contains(&query)
{
continue;
}
let pinned = app.is_tool_pinned(g.id);
let (rect, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 44.0), egui::Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
let press = theme::Press::track(ui, &resp);
press.wash(ui, rect, 6, false);
let body = rect.translate(press.offset());
let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]);
crate::icons::paint(ui, egui::Rect::from_min_size(body.min + egui::vec2(8.0, 11.0), egui::vec2(22.0, 22.0)), g.icon, 20.0, color);
let btn_w = 78.0;
let btn_rect = egui::Rect::from_center_size(body.right_center() - egui::vec2(btn_w / 2.0 + 8.0, 0.0), egui::vec2(btn_w, 26.0));
let btn_resp = ui.interact(btn_rect, ui.scope_id().with(("pin_modal_btn", g.id)), egui::Sense::click());
let btn_hovered = btn_resp.hovered();
let (btn_fill, btn_stroke, text_color, btn_label) = if pinned {
(t.accent, egui::Stroke::NONE, egui::Color32::WHITE, tl!("Pinned"))
} else if btn_hovered {
(t.hover, egui::Stroke::new(1.0, t.border), t.text, tl!("Pin"))
} else {
(egui::Color32::TRANSPARENT, egui::Stroke::new(1.0, t.border), t.text_muted, tl!("Pin"))
};
let tip = if pinned { tl!("Unpin from Home") } else { tl!("Pin to Home") };
btn_resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, pinned, format!("{tip}: {label}")));
ui.painter().rect(btn_rect, egui::CornerRadius::same(6), btn_fill, btn_stroke, egui::StrokeKind::Inside);
ui.painter().text(btn_rect.center(), egui::Align2::CENTER_CENTER, btn_label, theme::medium(12.0), text_color);
let max_w = (btn_rect.left() - body.left() - 44.0).max(10.0);
widgets::row_text(ui, body.min + egui::vec2(38.0, 6.0), label, theme::semibold(13.5), t.text, max_w);
widgets::row_text(ui, body.min + egui::vec2(38.0, 24.0), &blurb, theme::regular(11.0), t.text_muted, max_w);
if btn_resp.clicked() || resp.clicked() {
toggle = Some(g.id);
}
}
if let Some(id) = toggle {
app.toggle_pin_tool(id);
}
});
true
}
fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) {
ui.add_space(16.0);
let changed = draft_changes(app).is_some_and(|c| !c.is_empty());
@@ -1569,6 +1671,10 @@ fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req:
req.recover = Some(false);
req.close = true;
}
} else if dialog == Dialog::PinTool {
if widgets::pill_button(ui, tl!("Done"), true).clicked() {
req.close = true;
}
} else if dialog == Dialog::NumberPages {
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
req.apply_number = true;
@@ -1836,6 +1942,8 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req:
// Protect / Remove security replace the Properties dialog.
let replaces = req.link_command.is_some_and(|c| c.starts_with("protect.")) || req.open_revision.is_some();
if modal_closed || req.close || replaces {
// Only now: the number in Preferences can pass through 0 on its way to the one wanted.
app.recent.truncate(app.recent_limit);
app.dialog = None;
app.props_draft = None;
app.view_draft = None;
+1 -1
View File
@@ -1,6 +1,6 @@
//! The About window's credits: every contributor (GitHub login always; display and real names only
//! with recorded consent) and the AI models that helped. The tables are baked into the binary by
//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is
//! build.rs from contributors/contributors.json; nothing is
//! read at run time.
use std::cmp::Ordering;
+98 -9
View File
@@ -31,7 +31,7 @@ fn new_text_typed(v: &crate::DocView) -> bool {
/// is a deleted annotation's page's annotations before the delete (empty for other edits).
fn select_after_edit(
view: &mut crate::DocView,
edit: Edit,
edit: &Edit,
info: &pdfcraft_render::DocInfo,
annots_before: &[pdfcraft_render::Annotation],
quick_tool: crate::QuickTool,
@@ -39,15 +39,15 @@ fn select_after_edit(
match edit {
Edit::MovePages { pages, to } => {
let n = pages.iter().collect::<std::collections::BTreeSet<_>>().len();
let to = to.min(info.pages.len() - n);
let to = (*to).min(info.pages.len() - n);
view.select_pages(&(to..to + n).collect::<Vec<_>>());
}
Edit::DuplicatePages { pages, at: Some(at) } => {
let n = pages.iter().collect::<std::collections::BTreeSet<_>>().len();
let at = at.min(info.pages.len().saturating_sub(n));
let at = (*at).min(info.pages.len().saturating_sub(n));
view.select_pages(&(at..at + n).collect::<Vec<_>>());
}
Edit::InsertBlankPage { at, .. } => view.select_pages(&[at.min(info.pages.len() - 1)]),
Edit::InsertBlankPage { at, .. } => view.select_pages(&[(*at).min(info.pages.len() - 1)]),
Edit::DeletePages { .. } => view.select_pages(&[]),
// A stroke drawn with the pen stays unselected, so the selection box and
// author popup don't sit over the next stroke (#429).
@@ -71,7 +71,7 @@ fn select_after_edit(
view.comments.open_editor(a.page, n, String::new());
}
}
Edit::DeleteAnnotation { page, index } => view.comments.annotation_deleted(annots_before, &info.annotations, (page, index)),
Edit::DeleteAnnotation { page, index } => view.comments.annotation_deleted(annots_before, &info.annotations, (*page, *index)),
_ => {}
}
}
@@ -112,6 +112,25 @@ impl PdfCraftApp {
pub fn apply_edit(&mut self, edit: Edit) -> bool {
let Some((i, id)) = self.active_ids() else { return false };
let label = edit.label();
let target_arrange_image = match &edit {
Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Arrange(_) } => {
let doc = self.session.get(id);
let target = doc.and_then(|d| d.page_images(*page).get(*index).cloned());
let ambiguous = doc.is_some_and(|d| {
if let Some(t) = &target {
d.page_images(*page)
.iter()
.filter(|img| img.name == t.name && img.object == t.object && rect_matches(img.rect, t.rect))
.count()
> 1
} else {
false
}
});
target.map(|t| (t, ambiguous))
}
_ => None,
};
let (form_before, page_count_before, had_xfa) = if let Some(d) = self.session.get(id) {
(Some(std::sync::Arc::clone(&d.form)), d.info.pages.len(), d.xfa.is_some())
} else {
@@ -166,7 +185,11 @@ impl PdfCraftApp {
view.comments.comment_added();
}
// Keep the pages the user acted on selected, where they now are.
select_after_edit(view, edit, info, &annots_before, self.quick_tool);
select_after_edit(view, &edit, info, &annots_before, self.quick_tool);
if let (Some((target, ambiguous_before)), Edit::EditPageImage { page, .. }) = (target_arrange_image, &edit) {
let page_images = doc.page_images(*page);
re_resolve_image_selection(view, *page, &target, ambiguous_before, &page_images);
}
if std::mem::take(&mut view.comments.tool_done) && !self.comment_prefs.pinned {
self.quick_tool = crate::QuickTool::Select;
}
@@ -187,6 +210,9 @@ impl PdfCraftApp {
if let Some(view) = self.views.get_mut(i) {
// A drawing whose annotation wasn't added must not stay painted.
view.comments.drawings_refused();
if matches!(&edit, Edit::EditPageImage { change, .. } if !matches!(change, pdfcraft_engine::ImageEdit::Arrange(_))) {
view.image_selection = None;
}
// A paragraph that couldn't be applied opens again with its typing, instead of
// the box the click moved to.
if let Edit::EditTextBlock { page, block, .. } = &edit
@@ -287,13 +313,34 @@ impl PdfCraftApp {
if !self.close_text_editors(i, id) {
return;
}
let target_image = self.views.get(i).and_then(|v| v.image_selection.as_ref()).and_then(|s| {
let doc = self.session.get(id)?;
let images = doc.page_images(s.page);
let target = images.get(s.index).cloned()?;
let ambiguous =
images.iter().filter(|img| img.name == target.name && img.object == target.object && rect_matches(img.rect, target.rect)).count() > 1;
Some((s.page, target, ambiguous))
});
let had_selection = self.views.get(i).and_then(|v| v.image_selection.as_ref()).is_some();
let result = if undo { self.session.undo(id) } else { self.session.redo(id) };
match result {
Ok(label) => {
if let Some(doc) = self.session.get(id) {
self.views[i].document_changed(&doc.info);
if let Some(doc) = self.session.get(id)
&& let Some(view) = self.views.get_mut(i)
{
view.document_changed(&doc.info);
view.picked_image = None;
if let Some((page, target, ambiguous_before)) = target_image {
let page_images = doc.page_images(page);
re_resolve_image_selection(view, page, &target, ambiguous_before, &page_images);
} else if had_selection {
view.image_selection = None;
}
}
if let Some(view) = self.views.get_mut(i) {
view.comments.selected = None;
view.comments.history_stepped();
}
self.views[i].comments.history_stepped();
let label = crate::i18n::edit_label(&label);
if undo {
self.notify_fmt("Undid {label}", &[("label", &label)]);
@@ -783,6 +830,48 @@ impl PdfCraftApp {
}
}
fn rect_matches(a: [f64; 4], b: [f64; 4]) -> bool {
a.iter().zip(b.iter()).all(|(x, y)| (x - y).abs() < 1e-3)
}
fn re_resolve_image_selection(
view: &mut crate::DocView,
page: usize,
target: &pdfcraft_engine::PageImage,
ambiguous_before: bool,
page_images: &[pdfcraft_engine::PageImage],
) {
let exact_matches: Vec<usize> = page_images
.iter()
.enumerate()
.filter(|(_, img)| img.name == target.name && img.object == target.object && rect_matches(img.rect, target.rect))
.map(|(idx, _)| idx)
.collect();
let new_idx = if !ambiguous_before && exact_matches.len() == 1 {
exact_matches.first().copied()
} else if !ambiguous_before && exact_matches.is_empty() {
let name_obj_matches: Vec<usize> =
page_images.iter().enumerate().filter(|(_, img)| img.name == target.name && img.object == target.object).map(|(idx, _)| idx).collect();
if name_obj_matches.len() == 1 {
name_obj_matches.first().copied()
} else if name_obj_matches.is_empty() {
let name_matches: Vec<usize> = page_images.iter().enumerate().filter(|(_, img)| img.name == target.name).map(|(idx, _)| idx).collect();
if name_matches.len() == 1 { name_matches.first().copied() } else { None }
} else {
None
}
} else {
None
};
if let Some(s) = view.image_selection.as_mut().filter(|s| s.page == page) {
if let Some(idx) = new_idx {
s.index = idx;
} else {
view.image_selection = None;
}
}
}
/// The single page a content edit changes (`None` for other edits). Like comment edits,
/// these leave every other page's bytes, geometry and text alone, so their rasters stay valid.
fn content_page(edit: &Edit) -> Option<usize> {
+137 -51
View File
@@ -20,6 +20,57 @@ pub(crate) fn card_lift(ui: &egui::Ui, t: &Tokens, rect: Rect, press: theme::Pre
}
}
/// Render a single tool card with the standard lift, styling, icon, title, description, and action.
/// If `allow_unpin` is true, an unpin button is always accessible in the top right corner.
fn tool_card(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static catalog::ToolGroup, allow_unpin: bool) -> Option<&'static str> {
let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 104.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(g.label)));
let press = theme::Press::track(ui, &resp);
card_lift(ui, t, rect, press);
let fill = press.fill(t.card, t.hover, t.pressed);
ui.painter().rect(rect, CornerRadius::same(10), fill, Stroke::new(1.0, t.divider), egui::StrokeKind::Inside);
let body = rect.translate(press.offset());
let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]);
icons::paint(ui, Rect::from_min_size(body.min + vec2(14.0, 14.0), vec2(22.0, 22.0)), g.icon, 21.0, color);
let mut unpin_clicked = false;
if allow_unpin {
let unpin_rect = Rect::from_center_size(body.right_top() + vec2(-18.0, 18.0), vec2(22.0, 22.0));
let unpin_resp = ui.interact(unpin_rect, ui.scope_id().with(("unpin_card_btn", g.id)), Sense::click());
unpin_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Unpin tool")));
if unpin_resp.hovered() {
ui.painter().rect_filled(unpin_rect, CornerRadius::same(4), t.pressed);
}
icons::paint(ui, unpin_rect.shrink(4.0), "x", 14.0, t.icon);
if unpin_resp.on_hover_text(tl!("Unpin from Home")).clicked() {
unpin_clicked = true;
}
}
let mut title_job = egui::text::LayoutJob::simple_singleline(tl!(g.label).to_owned(), theme::semibold(13.5), t.text);
title_job.wrap = egui::text::TextWrapping { max_width: body.width() - 58.0, max_rows: 1, break_anywhere: true, ..Default::default() };
let title = ui.fonts_mut(|f| f.layout_job(title_job));
let title_elided = title.elided;
ui.painter().galley(body.min + vec2(44.0, 25.0 - title.size().y * 0.5), title, t.text);
let blurb = g.sections.first().map(|s| s.items.iter().take(3).map(|i| tl!(i.label)).collect::<Vec<_>>().join(" · ")).unwrap_or_default();
// Fixed-height cards must leave room for their action in every language.
let mut job = egui::text::LayoutJob::simple(blurb.clone(), theme::regular(11.5), t.text_muted, body.width() - 28.0);
job.wrap.max_rows = 2;
let galley = ui.fonts_mut(|f| f.layout_job(job));
let blurb_elided = galley.elided;
ui.painter().galley(body.min + vec2(14.0, 46.0), galley, t.text_muted);
ui.painter().text(body.left_bottom() + vec2(14.0, -14.0), Align2::LEFT_CENTER, tl!("Use now"), theme::medium(12.0), t.accent_text);
let resp = if title_elided || blurb_elided { resp.on_hover_text(format!("{}\n{blurb}", tl!(g.label))) } else { resp };
if unpin_clicked {
Some(g.id)
} else {
if theme::hand(resp).clicked() {
app.select_tool(g.id);
}
None
}
}
/// Draw the Home page with the welcome text, community card and recent documents.
pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
@@ -73,44 +124,11 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(14.0, 14.0);
for id in RECOMMENDED {
let Some(g) = catalog::group(id) else { continue };
let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 104.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(g.label)));
let press = theme::Press::track(ui, &resp);
card_lift(ui, &t, rect, press);
let fill = press.fill(t.card, t.hover, t.pressed);
ui.painter().rect(rect, CornerRadius::same(10), fill, Stroke::new(1.0, t.divider), egui::StrokeKind::Inside);
let body = rect.translate(press.offset());
let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]);
icons::paint(ui, Rect::from_min_size(body.min + vec2(14.0, 14.0), vec2(22.0, 22.0)), g.icon, 21.0, color);
let mut title_job = egui::text::LayoutJob::simple_singleline(tl!(g.label).to_owned(), theme::semibold(13.5), t.text);
title_job.wrap =
egui::text::TextWrapping { max_width: body.width() - 58.0, max_rows: 1, break_anywhere: true, ..Default::default() };
let title = ui.fonts_mut(|f| f.layout_job(title_job));
let title_elided = title.elided;
ui.painter().galley(body.min + vec2(44.0, 25.0 - title.size().y * 0.5), title, t.text);
let blurb = g
.sections
.first()
.map(|s| s.items.iter().take(3).map(|i| tl!(i.label)).collect::<Vec<_>>().join(" · "))
.unwrap_or_default();
// Fixed-height cards must leave room for their action in every language.
let mut job = egui::text::LayoutJob::simple(blurb.clone(), theme::regular(11.5), t.text_muted, body.width() - 28.0);
job.wrap.max_rows = 2;
let galley = ui.fonts_mut(|f| f.layout_job(job));
let blurb_elided = galley.elided;
ui.painter().galley(body.min + vec2(14.0, 46.0), galley, t.text_muted);
ui.painter().text(
body.left_bottom() + vec2(14.0, -14.0),
Align2::LEFT_CENTER,
tl!("Use now"),
theme::medium(12.0),
t.accent_text,
);
let resp = if title_elided || blurb_elided { resp.on_hover_text(format!("{}\n{blurb}", tl!(g.label))) } else { resp };
if theme::hand(resp).clicked() {
app.select_tool(g.id);
if app.is_tool_pinned(id) {
continue;
}
let Some(g) = catalog::group(id) else { continue };
tool_card(app, ui, &t, g, false);
}
let (rect, resp) = ui.allocate_exact_size(vec2(170.0, 104.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Open file")));
@@ -132,31 +150,64 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
});
});
ui.add_space(26.0);
let mut unpin_tool = None;
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Pinned Tools")).font(theme::semibold(17.0)));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::ghost_button(ui, "pin", tl!("Pin a tool…")).clicked() {
app.dialog = Some(crate::Dialog::PinTool);
}
});
});
ui.add_space(4.0);
if app.pinned_tools.is_empty() {
ui.label(egui::RichText::new(tl!("Pin your favorite tools here for quick access.")).color(t.text_muted));
} else {
ui.add_space(6.0);
let pinned = app.pinned_tools.clone();
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(14.0, 14.0);
for id in &pinned {
let Some(g) = catalog::group(id) else { continue };
if let Some(to_unpin) = tool_card(app, ui, &t, g, true) {
unpin_tool = Some(to_unpin);
}
}
});
}
if let Some(id) = unpin_tool {
app.unpin_tool(id);
}
#[cfg(not(target_arch = "wasm32"))]
{
ui.add_space(26.0);
crate::folders_ui::section(app, ui);
}
ui.add_space(26.0);
let mut clear = false;
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Recent")).font(theme::semibold(17.0)));
if !app.recent.is_empty() {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let resp = widgets::ghost_button(ui, "trash-2", tl!("Clear"));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear Recent Files")));
clear = resp.on_hover_text(tl!("Clear Recent Files")).clicked();
});
// Preferences can turn the list off (#5572).
if app.recent_limit > 0 {
ui.add_space(26.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Recent")).font(theme::semibold(17.0)));
if !app.recent.is_empty() {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let resp = widgets::ghost_button(ui, "trash-2", tl!("Clear"));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear Recent Files")));
clear = resp.on_hover_text(tl!("Clear Recent Files")).clicked();
});
}
});
ui.add_space(8.0);
if app.recent.is_empty() {
ui.label(egui::RichText::new(tl!("Files you open in PdfCraft appear here. Drop a PDF anywhere to open it.")).color(t.text_muted));
}
});
ui.add_space(8.0);
if app.recent.is_empty() {
ui.label(egui::RichText::new(tl!("Files you open in PdfCraft appear here. Drop a PDF anywhere to open it.")).color(t.text_muted));
}
let mut open = None;
let mut remove = None;
for r in &app.recent {
for r in app.recent.iter().take(app.recent_limit) {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &r.name));
let press = theme::Press::track(ui, &resp);
@@ -229,6 +280,41 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
mod tests {
use super::*;
fn review_home_harness() -> egui_kittest::Harness<'static, PdfCraftApp> {
crate::i18n::set_current(crate::i18n::Lang::EN);
let mut app = PdfCraftApp::new();
app.pinned_tools = vec!["edit".into()];
let mut fonts = false;
egui_kittest::Harness::builder().with_size(vec2(1500.0, 1200.0)).build_ui_state(
move |ui, app| {
if !fonts {
ui.ctx().set_fonts(theme::font_definitions());
fonts = true;
}
show(app, ui);
},
app,
)
}
#[test]
fn review_pinned_recommended_tools_appear_once() {
use egui_kittest::kittest::Queryable;
let mut h = review_home_harness();
h.run_steps(3);
assert_eq!(h.get_all_by_label("Edit a PDF").count(), 1);
}
#[test]
fn review_a_pinned_tool_can_be_unpinned_without_hover() {
use egui_kittest::kittest::Queryable;
let mut h = review_home_harness();
h.run_steps(3);
h.get_by_label("Unpin tool").click();
h.run_steps(2);
assert!(h.state().pinned_tools.is_empty());
}
#[test]
fn translated_card_description_leaves_room_for_the_action() {
let de = crate::i18n::Lang::from_code("de").unwrap();
+1 -1
View File
@@ -16,7 +16,7 @@
//! placeholders, plural forms) pick it up from the registry.
//!
//! # Looking strings up
//! - [`tl!`](crate::tl) / [`t`]: a plain string in the current language. [`tr`]: in a given one.
//! - `tl!` / [`t`]: a plain string in the current language. [`tr`]: in a given one.
//! - [`tr_ctx`] (`tl_ctx!` in UI code): when one English word needs different translations.
//! - [`tr_id`]: a command-id keyed string with the English label as fallback (menu items), so a
//! translation survives rewording of the English text and can differ per command.
+38 -5
View File
@@ -20,6 +20,10 @@
# reorders each line with UAX #9, so preordering here would reverse right-to-left text twice.
# Spaces and a colon at either end of an entry stay at that end, next to what the code appends.
# Untranslated: English text until a translator fills these in.
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
Recent files to keep Recent files to keep
Menu القائمة
File ملف
Edit تحرير
@@ -823,14 +827,9 @@
Copy Image in Original Format نسخ الصورة بتنسيقها الأصلي
The image has been copied as a {ext} file نُسخت الصورة كملف {ext}
The page has changed; select the image again تغيّرت الصفحة؛ حدّد الصورة مرة أخرى
Check for updates التحقق من التحديثات
No check has run yet. لم يُجرَ أي تحقق بعد.
Checking for a newer version… جارٍ التحقق من وجود إصدار أحدث…
PdfCraft {v} is available. PdfCraft {v} متاح.
You have version {c}. Download the new version from its release page. لديك الإصدار {c}. نزّل الإصدار الجديد من صفحة إصداره.
PdfCraft {c} is up to date. PdfCraft {c} محدَّث.
Couldn't check for updates: {e} تعذّر التحقق من التحديثات: {e}
You have version {c}. All releases are listed at {page}. لديك الإصدار {c}. كل الإصدارات مدرجة في {page}.
Asks GitHub for the latest release. Nothing is downloaded or installed automatically. يسأل GitHub عن أحدث إصدار. لا يُنزَّل أو يُثبَّت شيء تلقائيًا.
Download تنزيل
Later لاحقًا
@@ -2797,6 +2796,25 @@ certificate Version الإصدار
Make properties default جعل الخصائص افتراضية
Pages selected الصفحات المحددة
Page properties خصائص الصفحة
Clear search مسح البحث
Pin تثبيت
Pin a tool… تثبيت أداة…
Pin to Home تثبيت في الصفحة الرئيسية
Pin tools to Home تثبيت الأدوات في الصفحة الرئيسية
Pin your favorite tools here for quick access. ثبّت أدواتك المفضلة هنا للوصول السريع.
Pinned مثبت
Pinned Tools الأدوات المثبتة
Search tools… البحث عن الأدوات…
Select tools to display on your Home page for quick access. حدد الأدوات المراد عرضها على صفحتك الرئيسية للوصول السريع.
Unpin from Home إلغاء التثبيت من الصفحة الرئيسية
Unpin tool إلغاء تثبيت الأداة
Transform image تحويل الصورة
Select Tool أداة التحديد
Hand Tool أداة اليد
Marquee Zoom تكبير بالتحديد
Edit this PDF تحرير هذا الملف PDF
Export this PDF تصدير هذا الملف PDF
Find text or tools البحث عن نص أو أدوات
Create & Edit الإنشاء والتحرير
Review & Compare المراجعة والمقارنة
Forms & Signatures النماذج والتوقيعات
@@ -2843,6 +2861,13 @@ certificate Version الإصدار
field names can't contain a period: {name} لا يمكن أن تحتوي أسماء الحقول على نقطة: {name}
two cells would both be named {name} ستحمل خليتان الاسم نفسه {name}
a field named {name} already exists يوجد حقل باسم {name} بالفعل
Arrange image ترتيب الصورة
Bring forward إحضار للأمام
Bring to front إحضار إلى المقدمة
Crop قص
Remove crop إزالة القص
Send backward إرسال للخلف
Send to back إرسال إلى المؤخرة
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today اليوم
@@ -2859,3 +2884,11 @@ certificate Version الإصدار
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply خيارات هذا الرد
Export value قيمة التصدير
Show Page Edge Shadows إظهار ظلال حواف الصفحات
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
Search… بحث…
Image pasted تم لصق الصورة
No image on clipboard to paste لا توجد صورة في الحافظة للصقها
Pinned {tool} to Home تم تثبيت {tool} في الرئيسية
Unpinned {tool} from Home تم إلغاء تثبيت {tool} من الرئيسية
Can't render this file because it contains an unexpected character in line 6 and column 47.
+40 -6
View File
@@ -7,6 +7,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters;
# the trailing "…" of dialog commands is kept.
# Untranslated: English text until a translator fills these in.
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
Recent files to keep Recent files to keep
About За програмата
Contributors Сътрудници
Models Модели
@@ -486,14 +490,9 @@
Copy Image in Original Format Копирай изображението в оригиналния формат
The image has been copied as a {ext} file Изображението е копирано като файл {ext}
The page has changed; select the image again Страницата е променена; изберете изображението отново
Check for updates Провери за актуализации
No check has run yet. Още не е извършвана проверка.
Checking for a newer version… Проверка за по-нова версия…
PdfCraft {v} is available. Налична е PdfCraft {v}.
You have version {c}. Download the new version from its release page. Имате версия {c}. Изтеглете новата версия от страницата на изданието.
PdfCraft {c} is up to date. PdfCraft {c} е актуална.
Couldn't check for updates: {e} Неуспешна проверка за актуализации: {e}
You have version {c}. All releases are listed at {page}. Имате версия {c}. Всички издания са изброени на {page}.
Asks GitHub for the latest release. Nothing is downloaded or installed automatically. Запитва GitHub за най-новото издание. Нищо не се изтегля и не се инсталира автоматично.
Download Изтегли
Later По-късно
@@ -2437,7 +2436,7 @@ animation effects Off Изключени
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Разглеждате редакция {rev} на „{from}“. Това е моментна снимка: промените тук няма да засегнат оригинала.
Turn upside down (180°) Обърни наопаки (180°)
4 x 6 in label (101.6 x 152.4 mm) Етикет 4 × 6 in (101,6 × 152,4 mm)
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Изрязва всяка страница до отпечатаната ѝ област, завърта я изправено и я побира в листа без разтягане. Един етикет на лист.
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Изрязва всяка страница до отпечатаната и област, завърта я изправено и я побира в листа без разтягане. Един етикет на лист.
Find untagged content Намиране на немаркирано съдържание
No untagged content was found. Не е намерено немаркирано съдържание.
Found {n} untagged content problems. Открити са {n} проблема с немаркирано съдържание.
@@ -2483,6 +2482,26 @@ animation effects Off Изключени
Make properties default Задаване като свойства по подразбиране
Pages selected Избрани страници
Page properties Свойства на страницата
Clear search Изчистване на търсенето
Pin Закачане
Pin a tool… Закачане на инструмент…
Pin to Home Закачане към Начало
Pin tools to Home Закачане на инструменти към Начало
Pin your favorite tools here for quick access. Закачете любимите си инструменти тук за бърз достъп.
Pinned Закачено
Pinned Tools Закачени инструменти
Search tools… Търсене на инструменти…
Select tools to display on your Home page for quick access. Изберете инструменти за показване на началната страница за бърз достъп.
Unpin from Home Откачане от Начало
Unpin tool Откачане на инструмент
Transform image Трансформиране на изображение
Select Tool Инструмент за избор
Hand Tool Инструмент „Ръка“
Marquee Zoom Мащабиране с рамка
Paste Постави
Edit this PDF Редактирай този PDF
Export this PDF Експортирай този PDF
Find text or tools Търсене на текст или инструменти
Create & Edit Създаване и редактиране
Review & Compare Преглед и сравнение
Forms & Signatures Формуляри и подписи
@@ -2530,6 +2549,13 @@ animation effects Off Изключени
field names can't contain a period: {name} имената на полетата не може да съдържат точка: {name}
two cells would both be named {name} две клетки биха имали едно и също име {name}
a field named {name} already exists вече съществува поле с име {name}
Arrange image Подреждане на изображението
Bring forward Преместване напред
Bring to front Преместване най-отпред
Crop Изрязване
Remove crop Премахване на изрязването
Send backward Преместване назад
Send to back Преместване най-отзад
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Днес
@@ -2546,3 +2572,11 @@ animation effects Off Изключени
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Опции за този отговор
Export value Стойност за експортиране
Show Page Edge Shadows Показване на сенки по краищата на страниците
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
Search… Търсене…
Image pasted Изображението е поставено
No image on clipboard to paste Няма изображение в клипборда за поставяне
Pinned {tool} to Home {tool} е закачен в Начало
Unpinned {tool} from Home {tool} е откачен от Начало
Can't render this file because it contains an unexpected character in line 8 and column 16.
+38 -5
View File
@@ -9,6 +9,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
# the trailing "…" of dialog commands is kept.
# Untranslated: English text until a translator fills these in.
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
Recent files to keep Recent files to keep
Menu Menú
File Fitxer
Edit Edita
@@ -777,14 +781,9 @@
Copy Image in Original Format Copia la imatge en el format original
The image has been copied as a {ext} file S'ha copiat la imatge com a fitxer {ext}
The page has changed; select the image again La pàgina ha canviat; torna a seleccionar la imatge
Check for updates Cerca actualitzacions
No check has run yet. Encara no s'ha fet cap comprovació.
Checking for a newer version… S'està cercant una versió més recent…
PdfCraft {v} is available. PdfCraft {v} està disponible.
You have version {c}. Download the new version from its release page. Tens la versió {c}. Baixa la versió nova des de la seva pàgina de publicació.
PdfCraft {c} is up to date. PdfCraft {c} està actualitzat.
Couldn't check for updates: {e} No s'han pogut cercar actualitzacions: {e}
You have version {c}. All releases are listed at {page}. Tens la versió {c}. Totes les versions es llisten a {page}.
Asks GitHub for the latest release. Nothing is downloaded or installed automatically. Consulta a GitHub la darrera versió. No es baixa ni s'instal·la res automàticament.
Download Baixa
Later Més tard
@@ -2820,6 +2819,33 @@ theme Appearance Aspecte
field names can't contain a period: {name} els noms dels camps no poden contenir cap punt: {name}
two cells would both be named {name} dues cel·les es dirien {name}
a field named {name} already exists ja existeix un camp anomenat {name}
Bring forward Porta endavant
Bring to front Porta al capdavant
Crop image Retalla la imatge
Remove crop Elimina el retall
Send backward Envia enrere
Send to back Envia al fons
Clear search Neteja la cerca
Edit this PDF Edita aquest PDF
Export this PDF Exporta aquest PDF
Find text or tools Cerca text o eines
Hand Tool Eina Mà
Marquee Zoom Zoom d'enquadrament
Pin Fixa
Pin a tool… Fixa una eina…
Pin to Home Fixa a l'Inici
Pin tools to Home Fixa eines a l'Inici
Pin your favorite tools here for quick access. Fixeu aquí les vostres eines preferides per a un accés ràpid.
Pinned Fixat
Pinned Tools Eines fixades
Search tools… Cerca eines…
Select Tool Eina Selecció
Select tools to display on your Home page for quick access. Seleccioneu les eines que es mostraran a la pàgina d'Inici per a un accés ràpid.
Transform image Transforma la imatge
Unpin from Home Desenganxa de l'Inici
Unpin tool Desenganxa l'eina
Arrange image Organitza la imatge
Crop Escapça
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Avui
@@ -2843,3 +2869,10 @@ theme Appearance Aspecte
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Opcions d'aquesta resposta
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
Search… Cerca…
Image pasted Imatge enganxada
No image on clipboard to paste No hi ha cap imatge al porta-retalls per enganxar
Pinned {tool} to Home {tool} fixat a Inici
Unpinned {tool} from Home {tool} desfixat d’Inici
Can't render this file because it contains an unexpected character in line 7 and column 3.
+40 -5
View File
@@ -20,6 +20,10 @@
# reorders each line with UAX #9, so preordering here would reverse right-to-left text twice.
# Spaces and a colon at either end of an entry stay at that end, next to what the code appends.
# Untranslated: English text until a translator fills these in.
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
Recent files to keep Recent files to keep
Menu پێڕست
File فایل
Edit دەستکاریکردن
@@ -787,14 +791,9 @@
Copy Image in Original Format کۆپیکردنی وێنە بە فۆرماتی ڕەسەن
The image has been copied as a {ext} file وێنەکە وەک فایلی {ext} کۆپی کرا
The page has changed; select the image again پەڕەکە گۆڕاوە؛ دووبارە وێنەکە هەڵبژێرە
Check for updates پشکنین بۆ نوێکردنەوەکان
No check has run yet. هێشتا هیچ پشکنینێک نەکراوە.
Checking for a newer version… پشکنین بۆ وەشانێکی نوێتر…
PdfCraft {v} is available. PdfCraft {v} بەردەستە.
You have version {c}. Download the new version from its release page. وەشانی {c} هەیە. وەشانە نوێیەکە لە پەڕەی وەشانەکەیەوە دابگرە.
PdfCraft {c} is up to date. PdfCraft {c} نوێکراوەتەوە.
Couldn't check for updates: {e} پشکنین بۆ نوێکردنەوە نەکرا: {e}
You have version {c}. All releases are listed at {page}. وەشانی {c} هەیە. هەموو وەشانەکان لە {page} پێڕست کراون.
Asks GitHub for the latest release. Nothing is downloaded or installed automatically. لە GitHub دەپرسێت بۆ نوێترین وەشان. هیچ شتێک بە شێوەی خۆکار داگیر ناکرێت یان دادەمەزرێنرێت.
Download داگرتن
Later دواتر
@@ -2740,6 +2739,13 @@ certificate Version وەشان
Make properties default کردنی تایبەتمەندییەکان بە بنەڕەتی
Pages selected پەڕە هەڵبژێردراوەکان
Page properties تایبەتمەندییەکانی پەڕە
Arrange image ڕێکخستنی وێنە
Bring forward هێنانە پێشەوە
Bring to front هێنانە پێشەوەی سەرەکی
Crop بڕینەوە
Remove crop لابردنی بڕینەوە
Send backward ناردنە دواوە
Send to back ناردنە دواوەی سەرەکی
Create & Edit دروستکردن و دەستکاریکردن
Review & Compare پێداچوونەوە و بەراوردکردن
Forms & Signatures فۆڕمەکان و واژووەکان
@@ -2775,3 +2781,32 @@ certificate Version وەشان
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} more formula reads a missing field.|{n} more formulas read missing fields.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked.
@plural {fields} is missing|{fields} are missing {fields} is missing|{fields} are missing
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
Search… گەڕان…
Image pasted وێنەکە لکێنرا
No image on clipboard to paste هیچ وێنەیەک لە کلیپبۆرد نییە بۆ لکاندن
Pinned {tool} to Home {tool} لە ماڵەوە جێگیرکرا
Unpinned {tool} from Home {tool} لە ماڵەوە لابرا
# Home tools (AI-assisted; not separately native-reviewed)
Clear search پاککردنەوەی گەڕان
Pin جێگیرکردن
Pin a tool… جێگیرکردنی ئامراز…
Pin to Home جێگیرکردن لە ماڵەوە
Pin tools to Home جێگیرکردنی ئامرازەکان لە ماڵەوە
Pin your favorite tools here for quick access. ئامرازە دڵخوازەکانت لێرە جێگیر بکە بۆ دەستگەیشتنی خێرا.
Pinned جێگیرکراو
Pinned Tools ئامرازە جێگیرکراوەکان
Search tools… گەڕان بەدوای ئامرازەکان…
Select tools to display on your Home page for quick access. ئامرازەکان هەڵبژێرە بۆ پیشاندان لە ماڵەوە و دەستگەیشتنی خێرا.
Unpin from Home لابردنی جێگیرکردن لە ماڵەوە
Unpin tool لابردنی جێگیرکردنی ئامراز
Transform image گۆڕینی شێوەی وێنە
Select Tool ئامرازی هەڵبژاردن
Hand Tool ئامرازی دەست
Marquee Zoom گەورەکردنی ناوچەی هەڵبژێردراو
Edit this PDF دەستکاریکردنی ئەم PDF ـە
Export this PDF هەناردەکردنی ئەم PDF ـە
Find text or tools دۆزینەوەی دەق یان ئامراز
Crop image بڕینەوەی وێنە
Can't render this file because it contains an unexpected character in line 6 and column 47.
+40 -6
View File
@@ -19,6 +19,10 @@
# commands is kept. Messages with a count but no `@plural` entry use count-neutral wording
# ("Vybráno stránek: {n}"), because Czech has three plural forms.
# Untranslated: English text until a translator fills these in.
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
Recent files to keep Recent files to keep
Menu Nabídka
File Soubor
Rotation: Otočení:
@@ -902,14 +906,9 @@ key Space Mezerník
Copy Image in Original Format Kopírovat obrázek v původním formátu
The image has been copied as a {ext} file Obrázek byl zkopírován jako soubor {ext}
The page has changed; select the image again Stránka se změnila; vyberte obrázek znovu
Check for updates Zkontrolovat aktualizace
No check has run yet. Kontrola zatím neproběhla.
Checking for a newer version… Hledání novější verze…
PdfCraft {v} is available. Je k dispozici PdfCraft {v}.
You have version {c}. Download the new version from its release page. Máte verzi {c}. Novou verzi si stáhněte z její stránky s vydáním.
PdfCraft {c} is up to date. PdfCraft {c} je aktuální.
Couldn't check for updates: {e} Aktualizace nelze zkontrolovat: {e}
You have version {c}. All releases are listed at {page}. Máte verzi {c}. Všechna vydání najdete na {page}.
Asks GitHub for the latest release. Nothing is downloaded or installed automatically. Zjistí na GitHubu nejnovější vydání. Nic se automaticky nestahuje ani neinstaluje.
Download Stáhnout
Later Později
@@ -2594,6 +2593,26 @@ animation effects Off Vypnuto
Make properties default Nastavit vlastnosti jako výchozí
Pages selected Vybrané stránky
Page properties Vlastnosti stránky
Clear search Vymazat hledání
Pin Připnout
Pin a tool… Připnout nástroj…
Pin to Home Připnout na domovskou stránku
Pin tools to Home Připnout nástroje na domovskou stránku
Pin your favorite tools here for quick access. Připněte si sem oblíbené nástroje pro rychlý přístup.
Pinned Připnuto
Pinned Tools Připnuté nástroje
Search tools… Hledat nástroje…
Select tools to display on your Home page for quick access. Vyberte nástroje, které se mají zobrazovat na domovské stránce pro rychlý přístup.
Unpin from Home Odepnout z domovské stránky
Unpin tool Odepnout nástroj
Transform image Transformovat obrázek
Select Tool Nástroj pro výběr
Hand Tool Nástroj ručička
Marquee Zoom Zvětšit vybranou oblast
Paste Vložit
Edit this PDF Upravit toto PDF
Export this PDF Exportovat toto PDF
Find text or tools Hledat text nebo nástroje
Create & Edit Vytváření a úpravy
Review & Compare Revize a porovnání
Forms & Signatures Formuláře a podpisy
@@ -2798,7 +2817,6 @@ animation effects Off Vypnuto
Organize Uspořádat
Page {n} Stránka {n}
Page {n} of {total} Stránka {n} z {total}
Paste Vložit
Previous Předchozí
Previous (⇧⌘G) Předchozí (⇧⌘G)
Print (⌘P) Tisk (⌘P)
@@ -2874,6 +2892,14 @@ certificate Version Verze
field names can't contain a period: {name} názvy polí nesmějí obsahovat tečku: {name}
two cells would both be named {name} dvě buňky by se jmenovaly {name}
a field named {name} already exists pole s názvem {name} už existuje
Bring forward Přenést blíž
Bring to front Přenést do popředí
Crop image Oříznout obrázek
Remove crop Odebrat oříznutí
Send backward Přenést dál
Send to back Přenést do pozadí
Arrange image Uspořádat obrázek
Crop Oříznout
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Dnes
@@ -2890,3 +2916,11 @@ certificate Version Verze
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Možnosti této odpovědi
Export value Exportní hodnota
Show Page Edge Shadows Zobrazovat stíny okrajů stránek
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
Search… Hledat…
Image pasted Obrázek vložen
No image on clipboard to paste Ve schránce není obrázek k vložení
Pinned {tool} to Home {tool} připnuto na úvodní stránku
Unpinned {tool} from Home {tool} odepnuto z úvodní stránky
Can't render this file because it contains an unexpected character in line 6 and column 47.
+39 -5
View File
@@ -9,6 +9,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
# the trailing "…" of dialog commands is kept.
# Untranslated: English text until a translator fills these in.
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
Recent files to keep Recent files to keep
Menu Menü
File Datei
Edit Bearbeiten
@@ -801,14 +805,9 @@
Copy Image in Original Format Bild im Originalformat kopieren
The image has been copied as a {ext} file Das Bild wurde als {ext}-Datei kopiert
The page has changed; select the image again Die Seite hat sich geändert; wählen Sie das Bild erneut aus
Check for updates Nach Updates suchen
No check has run yet. Es wurde noch nicht gesucht.
Checking for a newer version… Suche nach einer neueren Version…
PdfCraft {v} is available. PdfCraft {v} ist verfügbar.
You have version {c}. Download the new version from its release page. Du hast Version {c}. Lade die neue Version von ihrer Release-Seite herunter.
PdfCraft {c} is up to date. PdfCraft {c} ist auf dem neuesten Stand.
Couldn't check for updates: {e} Updates konnten nicht gesucht werden: {e}
You have version {c}. All releases are listed at {page}. Du hast Version {c}. Alle Releases findest du unter {page}.
Asks GitHub for the latest release. Nothing is downloaded or installed automatically. Fragt GitHub nach dem neuesten Release. Es wird nichts automatisch heruntergeladen oder installiert.
Download Herunterladen
Later Später
@@ -2521,6 +2520,26 @@ animation effects Off Aus
Make properties default Eigenschaften als Standard festlegen
Pages selected Ausgewählte Seiten
Page properties Seiteneigenschaften
Clear search Suche löschen
Pin Anheften
Pin a tool… Werkzeug anheften…
Pin to Home An Start anheften
Pin tools to Home Werkzeuge an Start anheften
Pin your favorite tools here for quick access. Heften Sie Ihre bevorzugten Werkzeuge hier an, um schnell darauf zuzugreifen.
Pinned Angeheftet
Pinned Tools Angeheftete Werkzeuge
Search tools… Werkzeuge suchen…
Select tools to display on your Home page for quick access. Wählen Sie Werkzeuge für den Schnellzugriff auf Ihrer Startseite aus.
Unpin from Home Von Start lösen
Unpin tool Werkzeug lösen
Transform image Bild transformieren
Select Tool Auswahl-Werkzeug
Hand Tool Hand-Werkzeug
Marquee Zoom Auswahlrahmen-Zoom
Paste Einfügen
Edit this PDF Dieses PDF bearbeiten
Export this PDF Dieses PDF exportieren
Find text or tools Text oder Werkzeuge suchen
Create & Edit Erstellen & Bearbeiten
Review & Compare Prüfen & Vergleichen
Forms & Signatures Formulare & Signaturen
@@ -2568,6 +2587,13 @@ animation effects Off Aus
field names can't contain a period: {name} Feldnamen dürfen keinen Punkt enthalten: {name}
two cells would both be named {name} zwei Zellen hätten beide den Namen {name}
a field named {name} already exists ein Feld namens {name} ist bereits vorhanden
Arrange image Bild anordnen
Bring forward Nach vorne bringen
Bring to front In den Vordergrund
Crop Zuschneiden
Remove crop Zuschnitt entfernen
Send backward Nach hinten senden
Send to back In den Hintergrund
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Heute
@@ -2584,3 +2610,11 @@ animation effects Off Aus
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Optionen für diese Antwort
Export value Exportwert
Show Page Edge Shadows Schatten an Seitenrändern anzeigen
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
Search… Suchen…
Image pasted Bild eingefügt
No image on clipboard to paste Kein Bild in der Zwischenablage zum Einfügen
Pinned {tool} to Home {tool} an Start angeheftet
Unpinned {tool} from Home {tool} von Start gelöst
Can't render this file because it contains an unexpected character in line 6 and column 51.
+40 -5
View File
@@ -2,6 +2,11 @@
# Partial, clean-room initial contribution. Entries not yet translated fall back to English.
# Translate from English meanings; retain placeholders and trailing ellipses.
# Greek plural forms: one (1) | other (0, 2+).
# Untranslated: English text until a translator fills these in.
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
Recent files to keep Recent files to keep
Menu Μενού
File Αρχείο
Edit Επεξεργασία
@@ -272,7 +277,6 @@
General Γενικά
About Πληροφορίες
About PdfCraft Σχετικά με το PdfCraft
Check for updates Έλεγχος για ενημερώσεις
Keyboard shortcuts Συντομεύσεις πληκτρολογίου
Default Προεπιλογή
Custom Προσαρμοσμένο
@@ -394,12 +398,8 @@
Replace Image… Αντικατάσταση εικόνας…
Save Image As… Αποθήκευση εικόνας ως…
No check has run yet. Δεν έχει πραγματοποιηθεί ακόμη έλεγχος.
Checking for a newer version… Έλεγχος για νεότερη έκδοση…
PdfCraft {v} is available. Η έκδοση {v} του PdfCraft είναι διαθέσιμη.
You have version {c}. Download the new version from its release page. Έχετε την έκδοση {c}. Κάντε λήψη της νέας έκδοσης από τη σελίδα εκδόσεών της.
PdfCraft {c} is up to date. Το PdfCraft {c} είναι ενημερωμένο.
Couldn't check for updates: {e} Δεν ήταν δυνατός ο έλεγχος για ενημερώσεις: {e}
You have version {c}. All releases are listed at {page}. Έχετε την έκδοση {c}. Όλες οι εκδόσεις βρίσκονται στη διεύθυνση {page}.
Asks GitHub for the latest release. Nothing is downloaded or installed automatically. Αναζητά την τελευταία έκδοση στο GitHub. Δεν γίνεται αυτόματα λήψη ή εγκατάσταση.
Later Αργότερα
none κανένα
@@ -2526,6 +2526,26 @@ animation effects Off Ανενεργά
Make properties default Ορισμός ιδιοτήτων ως προεπιλογή
Pages selected Επιλεγμένες σελίδες
Page properties Ιδιότητες σελίδας
Clear search Εκκαθάριση αναζήτησης
Pin Καρφίτσωμα
Pin a tool… Καρφίτσωμα εργαλείου…
Pin to Home Καρφίτσωμα στην Αρχική
Pin tools to Home Καρφίτσωμα εργαλείων στην Αρχική
Pin your favorite tools here for quick access. Καρφιτσώστε τα αγαπημένα σας εργαλεία εδώ για γρήγορη πρόσβαση.
Pinned Καρφιτσωμένο
Pinned Tools Καρφιτσωμένα εργαλεία
Search tools… Αναζήτηση εργαλείων…
Select tools to display on your Home page for quick access. Επιλέξτε εργαλεία για εμφάνιση στην Αρχική σας για γρήγορη πρόσβαση.
Unpin from Home Ξεκαρφίτσωμα από την Αρχική
Unpin tool Ξεκαρφίτσωμα εργαλείου
Transform image Μετασχηματισμός εικόνας
Select Tool Εργαλείο επιλογής
Hand Tool Εργαλείο χεριού
Marquee Zoom Μεγέθυνση επιλεγμένης περιοχής
Paste Επικόλληση
Edit this PDF Επεξεργασία αυτού του PDF
Export this PDF Εξαγωγή αυτού του PDF
Find text or tools Εύρεση κειμένου ή εργαλείων
Create & Edit Δημιουργία και επεξεργασία
Review & Compare Αναθεώρηση και σύγκριση
Forms & Signatures Φόρμες και υπογραφές
@@ -2573,6 +2593,13 @@ animation effects Off Ανενεργά
field names can't contain a period: {name} τα ονόματα πεδίων δεν μπορούν να περιέχουν τελεία: {name}
two cells would both be named {name} δύο κελιά θα είχαν το ίδιο όνομα {name}
a field named {name} already exists υπάρχει ήδη πεδίο με όνομα {name}
Arrange image Διάταξη εικόνας
Bring forward Μεταφορά εμπρός
Bring to front Μεταφορά σε πρώτο πλάνο
Crop Περικοπή
Remove crop Κατάργηση περικοπής
Send backward Μεταφορά πίσω
Send to back Μεταφορά σε δεύτερο πλάνο
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Σήμερα
@@ -2589,3 +2616,11 @@ animation effects Off Ανενεργά
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Επιλογές για αυτήν την απάντηση
Export value Τιμή εξαγωγής
Show Page Edge Shadows Εμφάνιση σκιών στα άκρα των σελίδων
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
Search… Αναζήτηση…
Image pasted Η εικόνα επικολλήθηκε
No image on clipboard to paste Δεν υπάρχει εικόνα στο πρόχειρο για επικόλληση
Pinned {tool} to Home Το {tool} καρφιτσώθηκε στην Αρχική
Unpinned {tool} from Home Το {tool} ξεκαρφιτσώθηκε από την Αρχική
Can't render this file because it contains an unexpected character in line 490 and column 9.
+40 -6
View File
@@ -9,6 +9,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
# the trailing "…" of dialog commands is kept.
# Untranslated: English text until a translator fills these in.
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
Recent files to keep Recent files to keep
Menu Menú
File Archivo
Edit Edición
@@ -817,14 +821,9 @@
Copy Image in Original Format Copiar imagen en formato original
The image has been copied as a {ext} file Se ha copiado la imagen como archivo {ext}
The page has changed; select the image again La página ha cambiado; vuelva a seleccionar la imagen
Check for updates Buscar actualizaciones
No check has run yet. Aún no se ha hecho ninguna comprobación.
Checking for a newer version… Buscando una versión más reciente…
PdfCraft {v} is available. PdfCraft {v} está disponible.
You have version {c}. Download the new version from its release page. Tienes la versión {c}. Descarga la nueva versión desde su página de publicación.
PdfCraft {c} is up to date. PdfCraft {c} está actualizado.
Couldn't check for updates: {e} No se pudieron buscar actualizaciones: {e}
You have version {c}. All releases are listed at {page}. Tienes la versión {c}. Todas las versiones aparecen en {page}.
Asks GitHub for the latest release. Nothing is downloaded or installed automatically. Consulta a GitHub cuál es la última versión. No se descarga ni se instala nada automáticamente.
Download Descargar
Later Más tarde
@@ -2017,9 +2016,16 @@ action wizard Start Iniciar
Edit text Editar texto
Load document font Cargar fuente del documento
Move image Mover imagen
Transform image Transformar imagen
Rotate image Girar imagen
Flip image Voltear imagen
Delete image Eliminar imagen
Bring to front Traer al frente
Send to back Enviar al fondo
Bring forward Traer adelante
Send backward Enviar atrás
Remove crop Quitar recorte
Crop image Recortar imagen
Add header & footer Añadir encabezado y pie de página
Update header & footer Actualizar encabezado y pie de página
Update watermark Actualizar marca de agua
@@ -2684,6 +2690,25 @@ animation effects Off Desactivados
# Historical snapshot notice bar (View signed version / View revision).
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Estás viendo la versión firmada de «{from}» (revisión {rev}). Es una instantánea: los cambios aquí no afectarán al original.
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Estás viendo la revisión {rev} de «{from}». Es una instantánea: los cambios aquí no afectarán al original.
Clear search Borrar búsqueda
Pin Anclar
Pin a tool… Anclar una herramienta…
Pin to Home Anclar en Inicio
Pin tools to Home Anclar herramientas en Inicio
Pin your favorite tools here for quick access. Ancla tus herramientas favoritas aquí para un acceso rápido.
Pinned Anclado
Pinned Tools Herramientas ancladas
Search tools… Buscar herramientas…
Select tools to display on your Home page for quick access. Selecciona las herramientas que se mostrarán en la página de Inicio para un acceso rápido.
Unpin from Home Desanclar de Inicio
Unpin tool Desanclar herramienta
Select Tool Herramienta Selección
Hand Tool Herramienta Mano
Marquee Zoom Zoom por selección
Paste Pegar
Edit this PDF Editar este PDF
Export this PDF Exportar este PDF
Find text or tools Buscar texto o herramientas
Attach a file… Adjuntar un archivo…
Delete attachment Eliminar adjunto
Attaching files is not available on the web yet Adjuntar archivos aún no está disponible en la web
@@ -2839,7 +2864,6 @@ theme Appearance Aspecto
Import Importar
Move Mover
Duplicate Duplicar
Paste Pegar
Select all Seleccionar todo
Refresh Actualizar
Next Siguiente
@@ -2890,6 +2914,8 @@ theme Appearance Aspecto
field names can't contain a period: {name} los nombres de campo no pueden contener un punto: {name}
two cells would both be named {name} dos celdas se llamarían {name}
a field named {name} already exists ya existe un campo llamado {name}
Arrange image Organizar imagen
Crop Recortar
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Hoy
@@ -2906,3 +2932,11 @@ theme Appearance Aspecto
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Opciones de esta respuesta
Export value Valor de exportación
Show Page Edge Shadows Mostrar sombras en los bordes de las páginas
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
Search… Buscar…
Image pasted Imagen pegada
No image on clipboard to paste No hay ninguna imagen en el portapapeles para pegar
Pinned {tool} to Home {tool} fijado en Inicio
Unpinned {tool} from Home {tool} desfijado de Inicio
Can't render this file because it contains an unexpected character in line 6 and column 60.
+11 -5
View File
@@ -19,6 +19,10 @@
# each rendered line. The Persian comma \u060c and question mark \u061f are used, and \u200c (ZWNJ) joins
# compound words.
# Untranslated: English text until a translator fills these in.
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
Recent files to keep Recent files to keep
Menu منو
File فایل
Edit ویرایش
@@ -818,14 +822,9 @@
Copy Image in Original Format کپی تصویر در قالب اصلی
The image has been copied as a {ext} file تصویر به‌صورت یک فایل {ext} کپی شد
The page has changed; select the image again صفحه تغییر کرده است؛ تصویر را دوباره انتخاب کنید
Check for updates بررسی به‌روز‌رسانی‌ها
No check has run yet. هنوز هیچ بررسیای انجام نشده است.
Checking for a newer version… بررسی برای نگارش تازه‌تر…
PdfCraft {v} is available. PdfCraft {v} در دسترس است.
You have version {c}. Download the new version from its release page. شما نگارش {c} را دارید. نگارش جدید را از صفحه انتشار آن دانلود کنید.
PdfCraft {c} is up to date. PdfCraft {c} به‌روز است.
Couldn't check for updates: {e} بررسی به‌روز‌رسانی‌ها انجام نشد: {e}
You have version {c}. All releases are listed at {page}. شما نگارش {c} را دارید. همه انتشار‌ها در {page} فهرست شدهاند.
Asks GitHub for the latest release. Nothing is downloaded or installed automatically. آخرین انتشار را از GitHub می‌پرسد. هیچ چیزی به‌طور خودکار دانلود یا نصب نمی‌شود.
Download دانلود
Later بعداً
@@ -2821,3 +2820,10 @@ theme Appearance ظاهر
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply گزینه‌های این پاسخ
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
Search… جستجو…
Image pasted تصویر چسبانده شد
No image on clipboard to paste تصویری در حافظهٔ موقت برای چسباندن نیست
Pinned {tool} to Home {tool} به خانه سنجاق شد
Unpinned {tool} from Home {tool} از خانه جدا شد
Can't render this file because it contains an unexpected character in line 6 and column 47.
+39 -5
View File
@@ -16,6 +16,11 @@
# Labels use sentence case. Product and technology names (PdfCraft, PDF, OCR, JavaScript ...)
# stay in Latin letters; the trailing "…" of dialog commands is kept. Calendar headings use the
# nominative month (`Lokakuu 2026`); dates use the partitive (`14. lokakuuta 2026`).
# Untranslated: English text until a translator fills these in.
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
Recent files to keep Recent files to keep
Menu Valikko
File Tiedosto
Edit Muokkaa
@@ -776,14 +781,9 @@
Copy Image in Original Format Kopioi kuva alkuperäisessä muodossa
The image has been copied as a {ext} file Kuva on kopioitu {ext}-tiedostona
The page has changed; select the image again Sivu on muuttunut; valitse kuva uudelleen
Check for updates Tarkista päivitykset
No check has run yet. Tarkistusta ei ole vielä tehty.
Checking for a newer version… Tarkistetaan uudempaa versiota…
PdfCraft {v} is available. PdfCraft {v} on saatavilla.
You have version {c}. Download the new version from its release page. Käytössä on versio {c}. Lataa uusi versio sen julkaisusivulta.
PdfCraft {c} is up to date. PdfCraft {c} on ajan tasalla.
Couldn't check for updates: {e} Päivitysten tarkistus epäonnistui: {e}
You have version {c}. All releases are listed at {page}. Käytössä on versio {c}. Kaikki julkaisut on lueteltu sivulla {page}.
Asks GitHub for the latest release. Nothing is downloaded or installed automatically. Kysyy uusinta julkaisua GitHubista. Mitään ei ladata tai asenneta automaattisesti.
Download Lataa
Later Myöhemmin
@@ -2829,6 +2829,33 @@ theme Appearance Teema
field names can't contain a period: {name} kenttien nimissä ei voi olla pistettä: {name}
two cells would both be named {name} kahdella solulla olisi sama nimi {name}
a field named {name} already exists kenttä nimeltä {name} on jo olemassa
Bring forward Tuo eteenpäin
Bring to front Tuo etualalle
Crop image Rajaa kuva
Remove crop Poista rajaus
Send backward Vie taaksepäin
Send to back Vie taustalle
Clear search Tyhjennä haku
Edit this PDF Muokkaa tätä PDF-tiedostoa
Export this PDF Vie tämä PDF-tiedosto
Find text or tools Etsi tekstiä tai työkaluja
Hand Tool Käsityökalu
Marquee Zoom Alueen zoomaus
Pin Kiinnitä
Pin a tool… Kiinnitä työkalu…
Pin to Home Kiinnitä Aloitus-näkymään
Pin tools to Home Kiinnitä työkaluja Aloitus-näkymään
Pin your favorite tools here for quick access. Kiinnitä suosikkityökalusi tähän nopeaa käyttöä varten.
Pinned Kiinnitetty
Pinned Tools Kiinnitetyt työkalut
Search tools… Etsi työkaluja…
Select Tool Valintatyökalu
Select tools to display on your Home page for quick access. Valitse Aloitus-sivulla näytettävät työkalut nopeaa käyttöä varten.
Transform image Muunna kuvaa
Unpin from Home Irrota Aloitus-näkymästä
Unpin tool Irrota työkalu
Arrange image Järjestä kuva
Crop Rajaa
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Tänään
@@ -2852,3 +2879,10 @@ theme Appearance Teema
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Tämän vastauksen asetukset
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
Search… Hae…
Image pasted Kuva liitetty
No image on clipboard to paste Leikepöydällä ei ole liitettävää kuvaa
Pinned {tool} to Home {tool} kiinnitetty aloitussivulle
Unpinned {tool} from Home {tool} irrotettu aloitussivulta
Can't render this file because it contains an unexpected character in line 5 and column 47.
+39 -6
View File
@@ -9,6 +9,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
# the trailing "…" of dialog commands is kept.
# Untranslated: English text until a translator fills these in.
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
Recent files to keep Recent files to keep
Menu Menu
File Fichier
Edit Édition
@@ -797,14 +801,9 @@
Copy Image in Original Format Copier l'image au format d'origine
The image has been copied as a {ext} file L'image a été copiée en tant que fichier {ext}
The page has changed; select the image again La page a changé ; sélectionnez de nouveau l'image
Check for updates Rechercher des mises à jour
No check has run yet. Aucune vérification n'a encore été faite.
Checking for a newer version… Recherche d'une version plus récente…
PdfCraft {v} is available. PdfCraft {v} est disponible.
You have version {c}. Download the new version from its release page. Vous avez la version {c}. Téléchargez la nouvelle version depuis sa page de publication.
PdfCraft {c} is up to date. PdfCraft {c} est à jour.
Couldn't check for updates: {e} Impossible de rechercher les mises à jour : {e}
You have version {c}. All releases are listed at {page}. Vous avez la version {c}. Toutes les versions sont listées sur {page}.
Asks GitHub for the latest release. Nothing is downloaded or installed automatically. Demande à GitHub la dernière version. Rien n'est téléchargé ni installé automatiquement.
Download Télécharger
Later Plus tard
@@ -2520,6 +2519,26 @@ animation effects Off Désactivés
Make properties default Définir comme propriétés par défaut
Pages selected Pages sélectionnées
Page properties Propriétés de la page
Clear search Effacer la recherche
Pin Épingler
Pin a tool… Épingler un outil…
Pin to Home Épingler à l'Accueil
Pin tools to Home Épingler des outils à l'Accueil
Pin your favorite tools here for quick access. Épinglez vos outils préférés ici pour un accès rapide.
Pinned Épinglé
Pinned Tools Outils épinglés
Search tools… Rechercher des outils…
Select tools to display on your Home page for quick access. Sélectionnez les outils à afficher sur votre page d'Accueil pour un accès rapide.
Unpin from Home Désépingler de l'Accueil
Unpin tool Désépingler l'outil
Transform image Transformer l'image
Select Tool Outil Sélection
Hand Tool Outil Main
Marquee Zoom Zoom par sélection
Paste Coller
Edit this PDF Modifier ce PDF
Export this PDF Exporter ce PDF
Find text or tools Rechercher du texte ou des outils
Create & Edit Créer et modifier
Review & Compare Réviser et comparer
Forms & Signatures Formulaires et signatures
@@ -2637,7 +2656,6 @@ animation effects Off Désactivés
Page {} has additional actions La page {} contient des actions supplémentaires
Panel layout could not be restored: {error} Impossible de restaurer la disposition des panneaux : {error}
Panel move was not applied: {error} Le déplacement du panneau n'a pas été appliqué : {error}
Paste Coller
PdfCraft can't check this signature yet: {}. PdfCraft ne peut pas encore contrôler cette signature : {}.
Previous Précédent
Previous (⇧⌘G) Précédent (⇧⌘G)
@@ -2833,6 +2851,13 @@ certificate Version Version
field names can't contain a period: {name} les noms de champ ne peuvent pas contenir de point : {name}
two cells would both be named {name} deux cellules porteraient le nom {name}
a field named {name} already exists un champ nommé {name} existe déjà
Arrange image Organiser l'image
Bring forward Avancer
Bring to front Mettre au premier plan
Crop Recadrer
Remove crop Supprimer le recadrage
Send backward Reculer
Send to back Mettre à l'arrière-plan
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Aujourd'hui
@@ -2849,3 +2874,11 @@ certificate Version Version
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Options de cette réponse
Export value Valeur d’exportation
Show Page Edge Shadows Afficher les ombres des bords des pages
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
Search… Rechercher…
Image pasted Image collée
No image on clipboard to paste Aucune image à coller dans le presse-papiers
Pinned {tool} to Home {tool} épinglé à l’Accueil
Unpinned {tool} from Home {tool} désépinglé de l’Accueil
Can't render this file because it contains an unexpected character in line 6 and column 39.
+37 -5
View File
@@ -2,6 +2,11 @@
# context <TAB> English source <TAB> translation
# Clean-room translation written from the English source's meaning.
# License: MIT OR Apache-2.0.
# Untranslated: English text until a translator fills these in.
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
Recent files to keep Recent files to keep
Menu मेनू
File फ़ाइल
Edit संपादित करें
@@ -760,14 +765,9 @@
Copy Image in Original Format छवि मूल प्रारूप में कॉपी करें
The image has been copied as a {ext} file छवि {ext} फ़ाइल के रूप में कॉपी हो गई है
The page has changed; select the image again पृष्ठ बदल गया है; छवि फिर से चुनें
Check for updates अपडेट के लिए जाँचें
No check has run yet. अभी तक कोई जाँच नहीं चली है।
Checking for a newer version… नए संस्करण की जाँच हो रही है…
PdfCraft {v} is available. PdfCraft {v} उपलब्ध है।
You have version {c}. Download the new version from its release page. आपके पास संस्करण {c} है। नया संस्करण इसके रिलीज़ पृष्ठ से डाउनलोड करें।
PdfCraft {c} is up to date. PdfCraft {c} अद्यतन है।
Couldn't check for updates: {e} अपडेट की जाँच नहीं हो सकी: {e}
You have version {c}. All releases are listed at {page}. आपके पास संस्करण {c} है। सभी रिलीज़ {page} पर सूचीबद्ध हैं।
Asks GitHub for the latest release. Nothing is downloaded or installed automatically. GitHub से नवीनतम रिलीज़ की क्वेरी करता है। कुछ भी स्वतः डाउनलोड या इंस्टॉल नहीं होता।
Download डाउनलोड
Later बाद में
@@ -2791,6 +2791,31 @@ theme Appearance स्वरूप
field names can't contain a period: {name} फ़ील्ड के नामों में पूर्ण विराम (.) नहीं हो सकता: {name}
two cells would both be named {name} दो सेल का नाम {name} हो जाएगा
a field named {name} already exists {name} नाम का फ़ील्ड पहले से मौजूद है
Clear search खोज साफ़ करें
Edit this PDF इस PDF को संपादित करें
Export this PDF इस PDF को निर्यात करें
Find text or tools पाठ या उपकरण ढूंढें
Hand Tool हैंड टूल
Marquee Zoom मार्की ज़ूम
Pin पिन करें
Pin a tool… उपकरण पिन करें…
Pin to Home होम पर पिन करें
Pin tools to Home होम पर उपकरण पिन करें
Pin your favorite tools here for quick access. त्वरित पहुँच के लिए अपने पसंदीदा उपकरण यहाँ पिन करें।
Pinned पिन किया गया
Pinned Tools पिन किए गए उपकरण
Search tools… उपकरण खोजें…
Select Tool चयन उपकरण
Select tools to display on your Home page for quick access. त्वरित पहुँच के लिए अपने होम पेज पर प्रदर्शित करने हेतु उपकरण चुनें।
Unpin from Home होम से अनपिन करें
Unpin tool उपकरण अनपिन करें
Arrange image छवि व्यवस्थित करें
Bring forward आगे लाएँ
Bring to front सबसे आगे लाएँ
Crop क्रॉप करें
Remove crop क्रॉप हटाएँ
Send backward पीछे भेजें
Send to back सबसे पीछे भेजें
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today आज
@@ -2814,3 +2839,10 @@ theme Appearance स्वरूप
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply इस जवाब के विकल्प
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
Search… खोजें…
Image pasted छवि चिपकाई गई
No image on clipboard to paste चिपकाने के लिए क्लिपबोर्ड में कोई छवि नहीं है
Pinned {tool} to Home {tool} को होम पर पिन किया गया
Unpinned {tool} from Home {tool} को होम से अनपिन किया गया
Can't render this file because it contains an unexpected character in line 875 and column 12.
+40 -5
View File
@@ -9,6 +9,11 @@
# consulted or copied; add entries the same way.
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
# the trailing "…" of dialog commands is kept.
# Untranslated: English text until a translator fills these in.
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
Recent files to keep Recent files to keep
Menu Menü
File Fájl
Edit Szerkesztés
@@ -794,14 +799,9 @@
Copy Image in Original Format Kép másolása eredeti formátumban
The image has been copied as a {ext} file A kép {ext}-fájlként a vágólapra került
The page has changed; select the image again Az oldal megváltozott; jelölje ki újra a képet
Check for updates Frissítések keresése
No check has run yet. Még nem futott ellenőrzés.
Checking for a newer version… Újabb verzió keresése…
PdfCraft {v} is available. Elérhető a PdfCraft {v}.
You have version {c}. Download the new version from its release page. A jelenlegi verzió: {c}. Töltsd le az újat a kiadási oldaláról.
PdfCraft {c} is up to date. A PdfCraft {c} naprakész.
Couldn't check for updates: {e} A frissítések keresése nem sikerült: {e}
You have version {c}. All releases are listed at {page}. A jelenlegi verzió: {c}. Minden kiadás itt található: {page}.
Asks GitHub for the latest release. Nothing is downloaded or installed automatically. A GitHubtól kéri le a legújabb kiadást. Semmi sem töltődik le vagy települ automatikusan.
Download Letöltés
Later Később
@@ -2519,6 +2519,26 @@ animation effects Off Ki
Make properties default Tulajdonságok beállítása alapértelmezettként
Pages selected Kijelölt oldalak
Page properties Oldaltulajdonságok
Clear search Keresés törlése
Pin Rögzítés
Pin a tool… Eszköz rögzítése…
Pin to Home Rögzítés a Kezdőlapra
Pin tools to Home Eszközök rögzítése a Kezdőlapra
Pin your favorite tools here for quick access. Rögzítse ide kedvenc eszközeit a gyors eléréshez.
Pinned Rögzítve
Pinned Tools Rögzített eszközök
Search tools… Eszközök keresése…
Select tools to display on your Home page for quick access. Válassza ki a Kezdőlapon megjelenítendő eszközöket a gyors eléréshez.
Unpin from Home Rögzítés feloldása a Kezdőlapról
Unpin tool Eszköz rögzítésének feloldása
Transform image Kép transzformálása
Select Tool Kijelölő eszköz
Hand Tool Kéz eszköz
Marquee Zoom Kijelölő nagyítás
Paste Beillesztés
Edit this PDF Ezen PDF szerkesztése
Export this PDF Ezen PDF exportálása
Find text or tools Szöveg vagy eszközök keresése
Create & Edit Létrehozás és szerkesztés
Review & Compare Áttekintés és összehasonlítás
Forms & Signatures Űrlapok és aláírások
@@ -2566,6 +2586,13 @@ animation effects Off Ki
field names can't contain a period: {name} a mezőnevek nem tartalmazhatnak pontot: {name}
two cells would both be named {name} két cella neve is {name} lenne
a field named {name} already exists már van {name} nevű mező
Arrange image Kép elrendezése
Bring forward Előrébb hozás
Bring to front Előrehozás
Crop Körülvágás
Remove crop Körülvágás eltávolítása
Send backward Hátrébb küldés
Send to back Hátraküldés
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Ma
@@ -2582,3 +2609,11 @@ animation effects Off Ki
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply A válasz beállításai
Export value Exportérték
Show Page Edge Shadows Oldalszéli árnyékok megjelenítése
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
Search… Keresés…
Image pasted Kép beillesztve
No image on clipboard to paste Nincs beilleszthető kép a vágólapon
Pinned {tool} to Home {tool} rögzítve a kezdőlapra
Unpinned {tool} from Home {tool} leválasztva a kezdőlapról
Can't render this file because it contains an unexpected character in line 7 and column 44.
+37 -5
View File
@@ -3,6 +3,11 @@
# Clean-room translations of PdfCraft's English UI labels. Use standard Indonesian computing
# terms; keep product and format names (PdfCraft, PDF, PDF/A, OCR, XFA, Word...) unchanged.
# Indonesian does not inflect nouns for number, so @plural entries list one form.
# Untranslated: English text until a translator fills these in.
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
Recent files to keep Recent files to keep
Menu Menu
Edit Edit
Pages Halaman
@@ -334,12 +339,10 @@
Check Again Periksa Lagi
Check again Periksa lagi
Check box is checked by default Kotak centang tercentang secara bawaan
Check for updates Periksa pembaruan
Check spelling Periksa ejaan
Check the pages Periksa halaman
Check the pages of {name}: {why} Periksa halaman {name}: {why}
Checking Options ({on} of 32 in all categories) Memeriksa Opsi ({on} dari 32 di semua kategori)
Checking for a newer version… Mencari versi yang lebih baru…
Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Pilih gambar PNG atau JPEG (hingga 4 MiB). Transparansi dipertahankan.
Choose a folder for the exported files Pilih folder untuk file yang diekspor
Choose a folder for the extracted pages Pilih folder untuk halaman yang diekstrak
@@ -398,7 +401,6 @@
Copies: Salinan:
Copy selected text Salin teks yang dipilih
Copy text Salin teks
Couldn't check for updates: {e} Tidak dapat memeriksa pembaruan: {e}
Couldn't open the printer's preferences: {e} Tidak dapat membuka preferensi printer: {e}
Counterclockwise 90 degrees 90 derajat berlawanan arah jarum jam
Country/Region Negara/Wilayah
@@ -859,7 +861,6 @@
Patterns (graphics objects) Pola (objek grafis)
PdfCraft didn't shut down normally. These documents had changes that were autosaved: PdfCraft tidak berhenti secara normal. Dokumen-dokumen ini memiliki perubahan yang disimpan otomatis:
PdfCraft works offline. No telemetry, no account, and no cloud processing unless you add a provider. PdfCraft bekerja secara luring. Tidak ada telemetri, tidak ada akun, dan tidak ada pemrosesan awan kecuali Anda menambahkan penyedia.
PdfCraft {c} is up to date. PdfCraft {c} sudah terbaru.
PdfCraft {v} is available. PdfCraft {v} tersedia.
Percentage Persentase
Permissions password Kata sandi izin
@@ -1278,7 +1279,6 @@
XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. Formulir XFA: kolom dan data XFA-nya dijaga tetap sinkron, sehingga penampil lain menampilkan yang Anda isi.
You are about to apply 1 redaction mark. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently. Anda akan menerapkan 1 tanda sensor. Teks, gambar, dan gambar-gambar di bawah tanda tersebut, serta komentar dan kolom formulir yang bertumpang tindih dengannya, akan dihapus permanen.
You are about to apply {n} redaction marks. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently. Anda akan menerapkan {n} tanda sensor. Teks, gambar, dan gambar-gambar di bawah tanda tersebut, serta komentar dan kolom formulir yang bertumpang tindih dengannya, akan dihapus permanen.
You have version {c}. All releases are listed at {page}. Anda memiliki versi {c}. Semua rilis tercantum di {page}.
You have version {c}. Download the new version from its release page. Anda memiliki versi {c}. Unduh versi baru dari halaman rilisnya.
Your changes will be lost if you don't save them. Perubahan Anda akan hilang jika tidak Anda simpan.
ZIP ZIP
@@ -2792,6 +2792,31 @@ theme Appearance Tampilan
field names can't contain a period: {name} nama kolom tidak boleh berisi titik: {name}
two cells would both be named {name} dua sel akan sama-sama bernama {name}
a field named {name} already exists kolom bernama {name} sudah ada
Clear search Bersihkan pencarian
Edit this PDF Edit PDF ini
Export this PDF Ekspor PDF ini
Find text or tools Temukan teks atau alat
Hand Tool Alat Tangan
Marquee Zoom Zoom Bidang
Pin Sematkan
Pin a tool… Sematkan alat…
Pin to Home Sematkan ke Beranda
Pin tools to Home Sematkan alat ke Beranda
Pin your favorite tools here for quick access. Sematkan alat favorit Anda di sini untuk akses cepat.
Pinned Disematkan
Pinned Tools Alat yang Disematkan
Search tools… Cari alat…
Select Tool Alat Pemilih
Select tools to display on your Home page for quick access. Pilih alat untuk ditampilkan di halaman Beranda Anda untuk akses cepat.
Unpin from Home Lepas sematan dari Beranda
Unpin tool Lepas sematan alat
Arrange image Atur gambar
Bring forward Maju Selangkah
Bring to front Bawa ke Depan
Crop Pangkas
Remove crop Hapus pangkasan
Send backward Mundur Selangkah
Send to back Kirim ke Belakang
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Hari ini
@@ -2815,3 +2840,10 @@ theme Appearance Tampilan
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Opsi untuk balasan ini
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
Search… Cari…
Image pasted Gambar ditempel
No image on clipboard to paste Tidak ada gambar di papan klip untuk ditempel
Pinned {tool} to Home {tool} disematkan ke Beranda
Unpinned {tool} from Home {tool} dilepas dari Beranda
Can't render this file because it contains an unexpected character in line 485 and column 12.
+39 -5
View File
@@ -15,6 +15,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters;
# the trailing "…" of dialog commands is kept.
# Untranslated: English text until a translator fills these in.
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
Recent files to keep Recent files to keep
Menu Menu
File File
Edit Modifica
@@ -820,14 +824,9 @@
Copy Image in Original Format Copia immagine nel formato originale
The image has been copied as a {ext} file L'immagine è stata copiata come file {ext}
The page has changed; select the image again La pagina è cambiata; seleziona di nuovo l'immagine
Check for updates Verifica aggiornamenti
No check has run yet. Nessuna verifica eseguita finora.
Checking for a newer version… Ricerca di una versione più recente…
PdfCraft {v} is available. PdfCraft {v} è disponibile.
You have version {c}. Download the new version from its release page. Hai la versione {c}. Scarica la nuova versione dalla sua pagina di rilascio.
PdfCraft {c} is up to date. PdfCraft {c} è aggiornato.
Couldn't check for updates: {e} Impossibile verificare gli aggiornamenti: {e}
You have version {c}. All releases are listed at {page}. Hai la versione {c}. Tutte le versioni sono elencate su {page}.
Asks GitHub for the latest release. Nothing is downloaded or installed automatically. Chiede a GitHub qual è l'ultima versione. Nulla viene scaricato o installato automaticamente.
Download Scarica
Later Più tardi
@@ -2732,6 +2731,26 @@ certificate Key usage Utilizzo chiave
certificate Self-signed Autofirmato
certificate SHA-1 digest Impronta SHA-1
certificate SHA-256 digest Impronta SHA-256
Clear search Cancella ricerca
Pin Fissa
Pin a tool… Fissa uno strumento…
Pin to Home Fissa sulla Home
Pin tools to Home Fissa strumenti sulla Home
Pin your favorite tools here for quick access. Fissa qui i tuoi strumenti preferiti per un accesso rapido.
Pinned Fissato
Pinned Tools Strumenti fissati
Search tools… Cerca strumenti…
Select tools to display on your Home page for quick access. Seleziona gli strumenti da visualizzare nella pagina Home per un acceso rapido.
Unpin from Home Rimuovi da Home
Unpin tool Sblocca strumento dalla Home
Transform image Trasforma immagine
Select Tool Strumento Selezione
Hand Tool Strumento Mano
Marquee Zoom Ingrandimento selettivo
Paste Incolla
Edit this PDF Modifica questo PDF
Export this PDF Esporta questo PDF
Find text or tools Trova testo o strumenti
Create & Edit Crea e modifica
Review & Compare Revisione e confronto
Forms & Signatures Moduli e firme
@@ -2799,6 +2818,13 @@ certificate SHA-256 digest Impronta SHA-256
field names can't contain a period: {name} i nomi dei campi non possono contenere un punto: {name}
two cells would both be named {name} due celle si chiamerebbero entrambe {name}
a field named {name} already exists esiste già un campo chiamato {name}
Arrange image Disponi immagine
Bring forward Porta avanti
Bring to front Porta in primo piano
Crop Ritaglia
Remove crop Rimuovi ritaglio
Send backward Porta indietro
Send to back Porta in secondo piano
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Oggi
@@ -2815,3 +2841,11 @@ certificate SHA-256 digest Impronta SHA-256
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Opzioni per questa risposta
Export value Valore di esportazione
Show Page Edge Shadows Mostra le ombre ai bordi delle pagine
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
Search… Cerca…
Image pasted Immagine incollata
No image on clipboard to paste Nessuna immagine negli appunti da incollare
Pinned {tool} to Home {tool} fissato alla Home
Unpinned {tool} from Home {tool} rimosso dai preferiti della Home
Can't render this file because it contains an unexpected character in line 6 and column 47.
+39 -5
View File
@@ -15,6 +15,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters;
# the trailing "…" of dialog commands is kept.
# Untranslated: English text until a translator fills these in.
Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
Recent files to keep Recent files to keep
Menu メニュー
File ファイル
Edit 編集
@@ -824,14 +828,9 @@
Copy Image in Original Format 画像を元の形式でコピー
The image has been copied as a {ext} file 画像を {ext} ファイルとしてコピーしました
The page has changed; select the image again ページが変更されました。画像をもう一度選択してください
Check for updates アップデートを確認
No check has run yet. まだ確認していません。
Checking for a newer version… 新しいバージョンを確認中…
PdfCraft {v} is available. PdfCraft {v} が利用できます。
You have version {c}. Download the new version from its release page. 現在のバージョンは {c} です。リリースページから新しいバージョンをダウンロードしてください。
PdfCraft {c} is up to date. PdfCraft {c} は最新です。
Couldn't check for updates: {e} アップデートを確認できませんでした: {e}
You have version {c}. All releases are listed at {page}. 現在のバージョンは {c} です。すべてのリリースは {page} に掲載されています。
Asks GitHub for the latest release. Nothing is downloaded or installed automatically. GitHub で最新リリースを確認します。自動でダウンロードやインストールは行いません。
Download ダウンロード
Later 後で
@@ -2520,6 +2519,26 @@ animation effects Off オフ
Make properties default プロパティをデフォルトに設定
Pages selected 選択したページ
Page properties ページのプロパティ
Clear search 検索をクリア
Pin ピン留め
Pin a tool… ツールをピン留め…
Pin to Home ホームにピン留め
Pin tools to Home ツールをホームにピン留め
Pin your favorite tools here for quick access. よく使うツールをここにピン留めして、すばやくアクセスできるようにします。
Pinned ピン留め済み
Pinned Tools ピン留めしたツール
Search tools… ツールを検索…
Select tools to display on your Home page for quick access. すばやくアクセスできるように、ホームに表示するツールを選択します。
Unpin from Home ホームからピン留めを解除
Unpin tool ツールのピン留めを解除
Transform image 画像を変換
Select Tool 選択ツール
Hand Tool 手のひらツール
Marquee Zoom 範囲指定ズーム
Paste 貼り付け
Edit this PDF この PDF を編集
Export this PDF この PDF を書き出す
Find text or tools テキストまたはツールを検索
Create & Edit 作成と編集
Review & Compare レビューと比較
Forms & Signatures フォームと署名
@@ -2567,6 +2586,13 @@ animation effects Off オフ
field names can't contain a period: {name} フィールド名にピリオドは使えません: {name}
two cells would both be named {name} 2 つのセルが同じ名前 {name} になります
a field named {name} already exists {name} という名前のフィールドは既にあります
Arrange image 画像を配置
Bring forward 前面へ移動
Bring to front 最前面へ移動
Crop 切り抜き
Remove crop 切り抜きを解除
Send backward 背面へ移動
Send to back 最背面へ移動
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today 今日
@@ -2583,3 +2609,11 @@ animation effects Off オフ
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply この返信のオプション
Export value 書き出し値
Show Page Edge Shadows ページ端の影を表示
# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
Search… 検索…
Image pasted 画像を貼り付けました
No image on clipboard to paste 貼り付ける画像がクリップボードにありません
Pinned {tool} to Home {tool} をホームにピン留めしました
Unpinned {tool} from Home {tool} のホームへのピン留めを解除しました
Can't render this file because it contains an unexpected character in line 6 and column 47.
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff. Show more