diff --git a/.config/nextest.toml b/.config/nextest.toml index 2a4cd2cb93..11ec94cc1a 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,3 +1,10 @@ # Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI # installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET). nextest-version = "0.9.150" + +# These measurements require a quiet runner. Their in-process SERIAL mutex only +# serializes cargo test; nextest gives each test its own process. Reserve all slots +# so other tests cannot charge their CPU/render-worker time to a frame budget. +[[profile.default.overrides]] +filter = 'package(pdfcraft-ui-egui) & binary(perf)' +threads-required = "num-test-threads" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5c2ba7f7f..d8549142f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,6 +12,7 @@ on: permissions: contents: write + packages: write concurrency: group: release-${{ github.ref }} diff --git a/AGENTS.md b/AGENTS.md index 1f90ec3d8f..cd6c0e2ca5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,6 @@ These rules bind every AI agent and every human contributor working in this repository. They come before any task, instruction or deadline. `CLAUDE.md` holds the day-to-day working instructions. If the two ever disagree, **this file wins.** -Some sections link to **craftrules**, the standards shared by the Crafting Apps. That repository is internal to the storytold team, so outside contributors can't open it. Every rule a contributor here must follow is stated in this file and `CLAUDE.md`; the craftrules links give team members the background. - --- ## 1. Asset policy (absolute — no exceptions) @@ -69,9 +67,10 @@ Anything else is forbidden, including assets whose licence you cannot prove from ### 1.4 Fonts live in craft-fonts -- Font assets shared by the Crafting Apps, including every Japanese font, live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), not here. **Never commit a font file to this repository**; adding a font means adding it to craft-fonts. (The Latin UI fonts already in `assets/fonts/` — Inter, JetBrains Mono, Dancing Script — stay.) Team members: the shared rules are in [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md) (internal). +- Font assets shared by the Crafting Apps, including every Japanese font, live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), not here. **Never commit a font file to this repository**; adding a font means adding it to craft-fonts. (The Latin UI fonts already in `assets/fonts/` — Inter, JetBrains Mono, Dancing Script — stay.) - craft-fonts is an **optional build input**, never a Cargo dependency: `CRAFT_FONTS_DIR= cargo build` makes `crates/fonts/build.rs` embed its fonts as `pdfcraft_fonts::CRAFT_FONTS`. Without it PdfCraft builds, tests and runs, but has no Japanese face (Japanese UI text shows replacement boxes, and writing Japanese into a PDF returns a clear error). Release builds always set it (`.github/workflows/release.yml`), and `ATTRIBUTION.toml` lists it as a `[[build_input]]`. - The desktop app may read one font **already installed on the user's machine** at runtime, as the last interface fallback for characters no embedded face has (`crates/ui-egui/src/system_fonts.rs`). That font is never embedded, shipped, committed or copied, and never appears in published screenshots (`PDFCRAFT_SYSTEM_FONTS=0`, which `cargo xtask screenshots` sets). It is not a way around §1.2: nothing from it may enter a PDF, a package or the repository. +- The page viewer may also draw a CID font of an Adobe CJK collection (Japan1, GB1, CNS1, Korea1) that a PDF does **not embed** with one face already installed on the machine (`crates/render/src/system_font.rs`), **on screen only**. `RenderConfig::system_fonts` is off by default and is enabled only for on-screen viewer and preview rasters (`RenderConfig::on_screen`, used by the engine viewer, Combine thumbnails and accessibility figure previews), so exports, printing, OCR and the automation tools never use it. Like the interface font it is read at runtime and never embedded, shipped, committed or copied; Noto CJK and Source Han are never candidates (§1.1), nor is any Adobe family; and `PDFCRAFT_SYSTEM_FONTS=0` turns it off. It is not a way around §1.2 either: nothing drawn with it may be saved or printed. ### 1.5 Procedure for adding or changing an asset @@ -92,7 +91,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi - Behaviour comes only from public specifications (ISO 32000-2, the Arlington model), public documentation and black-box observation. - Never copy GPL/AGPL code. Copyleft tools run only as external oracle processes. - Observe Acrobat only with synthetic fixtures. Never capture personal data, account information or recent files. -- Shared real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md) (internal). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. +- Shared real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. ## 3. Agent control (automation and MCP) @@ -105,7 +104,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi ## 4. Never crash -PdfCraft opens files from strangers, and people trust it with their work. A malformed PDF, a bad tool argument, a corrupt settings file or a full disk must produce an error the user (or agent) can act on, never a crash and never lost work. **This outranks feature work:** don't ship a feature by adding a panic path, and fix a crash before building on top of it. The rules below are the ones that bind this repository; the shared standard for every Crafting App, [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md), is internal to the storytold team. +PdfCraft opens files from strangers, and people trust it with their work. A malformed PDF, a bad tool argument, a corrupt settings file or a full disk must produce an error the user (or agent) can act on, never a crash and never lost work. **This outranks feature work:** don't ship a feature by adding a panic path, and fix a crash before building on top of it. The rules below are the ones that bind this repository. - **Fail with `Result`.** Non-test code returns errors through the crate's error type and `?`. Where readers are lenient by design, fall back gracefully and record the repair (see Fidelity in `CLAUDE.md`). - **No panicking shortcuts.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!` outside tests, unless the call is provably infallible (e.g. writing to a `Vec`), with a comment saying why. `assert!` only for internal invariants that input cannot reach. @@ -124,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.]` 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. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 23ebcbbe82..fc88e8b664 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 7dcd882f84..76f4cf40a8 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -296,7 +296,7 @@ path = "assets/icons/accessibility.svg" title = "Lucide icon \"accessibility\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -307,7 +307,7 @@ path = "assets/icons/align-left.svg" title = "Lucide icon \"align-left\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -318,7 +318,7 @@ path = "assets/icons/align-center.svg" title = "Lucide icon \"align-center\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -329,7 +329,7 @@ path = "assets/icons/square-terminal.svg" title = "Lucide icon \"square-terminal\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-terminal.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -340,7 +340,7 @@ path = "assets/icons/file-code.svg" title = "Lucide icon \"file-code\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-code.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -351,7 +351,7 @@ path = "assets/icons/file-spreadsheet.svg" title = "Lucide icon \"file-spreadsheet\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-spreadsheet.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -362,7 +362,7 @@ path = "assets/icons/align-justify.svg" title = "Lucide icon \"align-justify\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-justify.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -373,7 +373,7 @@ path = "assets/icons/align-right.svg" title = "Lucide icon \"align-right\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-right.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -384,7 +384,7 @@ path = "assets/icons/columns-3.svg" title = "Lucide icon \"columns-3\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-3.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -395,7 +395,7 @@ path = "assets/icons/rows-3.svg" title = "Lucide icon \"rows-3\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rows-3.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -406,7 +406,7 @@ path = "assets/icons/flip-horizontal-2.svg" title = "Lucide icon \"flip-horizontal-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-horizontal-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -417,7 +417,7 @@ path = "assets/icons/flip-vertical-2.svg" title = "Lucide icon \"flip-vertical-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-vertical-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -428,7 +428,7 @@ path = "assets/icons/app-window.svg" title = "Lucide icon \"app-window\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/app-window.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -439,7 +439,7 @@ path = "assets/icons/arrow-left-right.svg" title = "Lucide icon \"arrow-left-right\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-left-right.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -450,7 +450,7 @@ path = "assets/icons/arrow-up-down.svg" title = "Lucide icon \"arrow-up-down\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-down.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -461,7 +461,7 @@ path = "assets/icons/arrow-up-right.svg" title = "Lucide icon \"arrow-up-right\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-right.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -472,7 +472,7 @@ path = "assets/icons/badge-check.svg" title = "Lucide icon \"badge-check\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/badge-check.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -483,7 +483,7 @@ path = "assets/icons/bandage.svg" title = "Lucide icon \"bandage\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bandage.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -494,7 +494,7 @@ path = "assets/icons/bell.svg" title = "Lucide icon \"bell\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bell.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -505,7 +505,7 @@ path = "assets/icons/blend.svg" title = "Lucide icon \"blend\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/blend.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -516,7 +516,7 @@ path = "assets/icons/bold.svg" title = "Lucide icon \"bold\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bold.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -527,7 +527,7 @@ path = "assets/icons/book-open.svg" title = "Lucide icon \"book-open\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/book-open.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -538,7 +538,7 @@ path = "assets/icons/bookmark.svg" title = "Lucide icon \"bookmark\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -549,7 +549,7 @@ path = "assets/icons/bookmark-plus.svg" title = "Lucide icon \"bookmark-plus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark-plus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -560,7 +560,7 @@ path = "assets/icons/braces.svg" title = "Lucide icon \"braces\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/braces.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -571,7 +571,7 @@ path = "assets/icons/brush-cleaning.svg" title = "Lucide icon \"brush-cleaning\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush-cleaning.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -582,7 +582,7 @@ path = "assets/icons/brush.svg" title = "Lucide icon \"brush\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -593,7 +593,7 @@ path = "assets/icons/camera.svg" title = "Lucide icon \"camera\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/camera.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -604,7 +604,7 @@ path = "assets/icons/check-circle-2.svg" title = "Lucide icon \"check-circle-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check-circle-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -615,7 +615,7 @@ path = "assets/icons/check.svg" title = "Lucide icon \"check\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/check.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -626,7 +626,7 @@ path = "assets/icons/chevron-down.svg" title = "Lucide icon \"chevron-down\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-down.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -637,7 +637,7 @@ path = "assets/icons/chevron-left.svg" title = "Lucide icon \"chevron-left\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-left.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -648,7 +648,7 @@ path = "assets/icons/chevron-right.svg" title = "Lucide icon \"chevron-right\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-right.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -659,7 +659,7 @@ path = "assets/icons/chevron-up.svg" title = "Lucide icon \"chevron-up\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevron-up.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -670,7 +670,7 @@ path = "assets/icons/chevrons-left.svg" title = "Lucide icon \"chevrons-left\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-left.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -681,7 +681,7 @@ path = "assets/icons/chevrons-right.svg" title = "Lucide icon \"chevrons-right\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/chevrons-right.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -692,7 +692,7 @@ path = "assets/icons/circle-check.svg" title = "Lucide icon \"circle-check\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-check.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -703,7 +703,7 @@ path = "assets/icons/circle-dashed.svg" title = "Lucide icon \"circle-dashed\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dashed.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -714,7 +714,7 @@ path = "assets/icons/circle-dot.svg" title = "Lucide icon \"circle-dot\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-dot.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -725,7 +725,7 @@ path = "assets/icons/circle-help.svg" title = "Lucide icon \"circle-help\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-help.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -736,7 +736,7 @@ path = "assets/icons/circle.svg" title = "Lucide icon \"circle\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -747,7 +747,7 @@ path = "assets/icons/clock-3.svg" title = "Lucide icon \"clock-3\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock-3.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -758,7 +758,7 @@ path = "assets/icons/clock.svg" title = "Lucide icon \"clock\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clock.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -769,7 +769,7 @@ path = "assets/icons/cloud-upload.svg" title = "Lucide icon \"cloud-upload\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud-upload.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -780,7 +780,7 @@ path = "assets/icons/cloud.svg" title = "Lucide icon \"cloud\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/cloud.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -791,7 +791,7 @@ path = "assets/icons/code-xml.svg" title = "Lucide icon \"code-xml\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/code-xml.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -802,7 +802,7 @@ path = "assets/icons/columns-2.svg" title = "Lucide icon \"columns-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -813,7 +813,7 @@ path = "assets/icons/compass.svg" title = "Lucide icon \"compass\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/compass.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -824,7 +824,7 @@ path = "assets/icons/contrast.svg" title = "Lucide icon \"contrast\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/contrast.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -835,7 +835,7 @@ path = "assets/icons/copy-plus.svg" title = "Lucide icon \"copy-plus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy-plus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -846,7 +846,7 @@ path = "assets/icons/copy.svg" title = "Lucide icon \"copy\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/copy.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -857,7 +857,7 @@ path = "assets/icons/crop.svg" title = "Lucide icon \"crop\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/crop.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -868,7 +868,7 @@ path = "assets/icons/droplet.svg" title = "Lucide icon \"droplet\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/droplet.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -879,7 +879,7 @@ path = "assets/icons/ellipsis-vertical.svg" title = "Lucide icon \"ellipsis-vertical\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis-vertical.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -890,7 +890,7 @@ path = "assets/icons/ellipsis.svg" title = "Lucide icon \"ellipsis\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ellipsis.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -901,7 +901,7 @@ path = "assets/icons/eraser.svg" title = "Lucide icon \"eraser\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eraser.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -912,7 +912,7 @@ path = "assets/icons/external-link.svg" title = "Lucide icon \"external-link\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/external-link.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -923,7 +923,7 @@ path = "assets/icons/eye-off.svg" title = "Lucide icon \"eye-off\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye-off.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -934,7 +934,7 @@ path = "assets/icons/eye.svg" title = "Lucide icon \"eye\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/eye.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -945,7 +945,7 @@ path = "assets/icons/file-check.svg" title = "Lucide icon \"file-check\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-check.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -956,7 +956,7 @@ path = "assets/icons/file-down.svg" title = "Lucide icon \"file-down\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-down.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -967,7 +967,7 @@ path = "assets/icons/file-input.svg" title = "Lucide icon \"file-input\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-input.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -978,7 +978,7 @@ path = "assets/icons/file-lock-2.svg" title = "Lucide icon \"file-lock-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-lock-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -989,7 +989,7 @@ path = "assets/icons/file-minus.svg" title = "Lucide icon \"file-minus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-minus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1000,7 +1000,7 @@ path = "assets/icons/file-output.svg" title = "Lucide icon \"file-output\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-output.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1011,7 +1011,7 @@ path = "assets/icons/file-pen-line.svg" title = "Lucide icon \"file-pen-line\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-pen-line.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1022,7 +1022,7 @@ path = "assets/icons/file-plus-2.svg" title = "Lucide icon \"file-plus-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1033,7 +1033,7 @@ path = "assets/icons/file-plus.svg" title = "Lucide icon \"file-plus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-plus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1044,7 +1044,7 @@ path = "assets/icons/file-scan.svg" title = "Lucide icon \"file-scan\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-scan.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1055,7 +1055,7 @@ path = "assets/icons/file-search.svg" title = "Lucide icon \"file-search\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-search.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1066,7 +1066,7 @@ path = "assets/icons/file-symlink.svg" title = "Lucide icon \"file-symlink\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-symlink.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1077,7 +1077,7 @@ path = "assets/icons/file-text.svg" title = "Lucide icon \"file-text\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-text.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1088,7 +1088,7 @@ path = "assets/icons/file-x.svg" title = "Lucide icon \"file-x\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/file-x.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1099,7 +1099,7 @@ path = "assets/icons/files.svg" title = "Lucide icon \"files\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/files.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1110,7 +1110,7 @@ path = "assets/icons/film.svg" title = "Lucide icon \"film\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/film.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1121,7 +1121,7 @@ path = "assets/icons/filter.svg" title = "Lucide icon \"filter\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/filter.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1132,7 +1132,7 @@ path = "assets/icons/folder-open.svg" title = "Lucide icon \"folder-open\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-open.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1143,7 +1143,7 @@ path = "assets/icons/folder-plus.svg" title = "Lucide icon \"folder-plus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder-plus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1154,7 +1154,7 @@ path = "assets/icons/folder.svg" title = "Lucide icon \"folder\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/folder.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1165,7 +1165,7 @@ path = "assets/icons/gauge.svg" title = "Lucide icon \"gauge\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/gauge.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1176,7 +1176,7 @@ path = "assets/icons/git-compare.svg" title = "Lucide icon \"git-compare\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/git-compare.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1187,7 +1187,7 @@ path = "assets/icons/globe.svg" title = "Lucide icon \"globe\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/globe.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1198,7 +1198,7 @@ path = "assets/icons/grid-3x3.svg" title = "Lucide icon \"grid-3x3\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/grid-3x3.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1209,7 +1209,7 @@ path = "assets/icons/hand.svg" title = "Lucide icon \"hand\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hand.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1220,7 +1220,7 @@ path = "assets/icons/hash.svg" title = "Lucide icon \"hash\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/hash.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1231,7 +1231,7 @@ path = "assets/icons/heading.svg" title = "Lucide icon \"heading\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/heading.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1242,7 +1242,7 @@ path = "assets/icons/highlighter.svg" title = "Lucide icon \"highlighter\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/highlighter.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1253,7 +1253,7 @@ path = "assets/icons/house.svg" title = "Lucide icon \"house\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/house.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1264,7 +1264,7 @@ path = "assets/icons/image-plus.svg" title = "Lucide icon \"image-plus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image-plus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1275,7 +1275,7 @@ path = "assets/icons/image.svg" title = "Lucide icon \"image\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/image.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1286,7 +1286,7 @@ path = "assets/icons/info.svg" title = "Lucide icon \"info\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/info.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1297,7 +1297,7 @@ path = "assets/icons/italic.svg" title = "Lucide icon \"italic\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/italic.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1308,7 +1308,7 @@ path = "assets/icons/languages.svg" title = "Lucide icon \"languages\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/languages.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1319,7 +1319,7 @@ path = "assets/icons/lasso.svg" title = "Lucide icon \"lasso\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lasso.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1330,7 +1330,7 @@ path = "assets/icons/layers.svg" title = "Lucide icon \"layers\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layers.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1341,7 +1341,7 @@ path = "assets/icons/layout-grid.svg" title = "Lucide icon \"layout-grid\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/layout-grid.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1352,7 +1352,7 @@ path = "assets/icons/link-2.svg" title = "Lucide icon \"link-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1363,7 +1363,7 @@ path = "assets/icons/link.svg" title = "Lucide icon \"link\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/link.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1374,7 +1374,7 @@ path = "assets/icons/list-checks.svg" title = "Lucide icon \"list-checks\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-checks.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1385,7 +1385,7 @@ path = "assets/icons/list-ordered.svg" title = "Lucide icon \"list-ordered\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list-ordered.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1396,7 +1396,7 @@ path = "assets/icons/list.svg" title = "Lucide icon \"list\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/list.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1407,7 +1407,7 @@ path = "assets/icons/lock-open.svg" title = "Lucide icon \"lock-open\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock-open.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1418,7 +1418,7 @@ path = "assets/icons/lock.svg" title = "Lucide icon \"lock\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/lock.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1429,7 +1429,7 @@ path = "assets/icons/maximize-2.svg" title = "Lucide icon \"maximize-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1440,7 +1440,7 @@ path = "assets/icons/maximize.svg" title = "Lucide icon \"maximize\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/maximize.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1451,7 +1451,7 @@ path = "assets/icons/message-circle-reply.svg" title = "Lucide icon \"message-circle-reply\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-circle-reply.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1462,7 +1462,7 @@ path = "assets/icons/message-square-text.svg" title = "Lucide icon \"message-square-text\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-text.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1473,7 +1473,7 @@ path = "assets/icons/message-square.svg" title = "Lucide icon \"message-square\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1484,7 +1484,7 @@ path = "assets/icons/messages-square.svg" title = "Lucide icon \"messages-square\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/messages-square.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1495,7 +1495,7 @@ path = "assets/icons/mic.svg" title = "Lucide icon \"mic\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mic.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1506,7 +1506,7 @@ path = "assets/icons/minimize.svg" title = "Lucide icon \"minimize\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1517,7 +1517,7 @@ path = "assets/icons/minimize-2.svg" title = "Lucide icon \"minimize-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minimize-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1528,7 +1528,7 @@ path = "assets/icons/minus.svg" title = "Lucide icon \"minus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/minus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1539,7 +1539,7 @@ path = "assets/icons/moon.svg" title = "Lucide icon \"moon\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/moon.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1550,7 +1550,7 @@ path = "assets/icons/mouse-pointer-2.svg" title = "Lucide icon \"mouse-pointer-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/mouse-pointer-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1561,7 +1561,7 @@ path = "assets/icons/move.svg" title = "Lucide icon \"move\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1572,7 +1572,7 @@ path = "assets/icons/navigation.svg" title = "Lucide icon \"navigation\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/navigation.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1583,7 +1583,7 @@ path = "assets/icons/paint-bucket.svg" title = "Lucide icon \"paint-bucket\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paint-bucket.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1594,7 +1594,7 @@ path = "assets/icons/palette.svg" title = "Lucide icon \"palette\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/palette.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1605,7 +1605,7 @@ path = "assets/icons/panel-left.svg" title = "Lucide icon \"panel-left\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-left.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1616,7 +1616,7 @@ path = "assets/icons/panel-right.svg" title = "Lucide icon \"panel-right\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panel-right.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1627,7 +1627,7 @@ path = "assets/icons/panels-top-left.svg" title = "Lucide icon \"panels-top-left\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/panels-top-left.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1638,7 +1638,7 @@ path = "assets/icons/paperclip.svg" title = "Lucide icon \"paperclip\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/paperclip.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1649,7 +1649,7 @@ path = "assets/icons/pen-line.svg" title = "Lucide icon \"pen-line\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-line.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1660,7 +1660,7 @@ path = "assets/icons/pen-tool.svg" title = "Lucide icon \"pen-tool\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pen-tool.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1671,7 +1671,7 @@ path = "assets/icons/pencil.svg" title = "Lucide icon \"pencil\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pencil.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1682,7 +1682,7 @@ path = "assets/icons/pipette.svg" title = "Lucide icon \"pipette\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pipette.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1693,7 +1693,7 @@ path = "assets/icons/plus.svg" title = "Lucide icon \"plus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/plus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1704,7 +1704,7 @@ path = "assets/icons/presentation.svg" title = "Lucide icon \"presentation\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/presentation.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1715,7 +1715,7 @@ path = "assets/icons/printer.svg" title = "Lucide icon \"printer\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/printer.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1726,7 +1726,7 @@ path = "assets/icons/rectangle-horizontal.svg" title = "Lucide icon \"rectangle-horizontal\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rectangle-horizontal.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1737,7 +1737,7 @@ path = "assets/icons/redo-2.svg" title = "Lucide icon \"redo-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/redo-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1748,7 +1748,7 @@ path = "assets/icons/replace.svg" title = "Lucide icon \"replace\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/replace.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1759,7 +1759,7 @@ path = "assets/icons/rotate-ccw.svg" title = "Lucide icon \"rotate-ccw\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-ccw.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1770,7 +1770,7 @@ path = "assets/icons/rotate-cw.svg" title = "Lucide icon \"rotate-cw\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rotate-cw.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1781,7 +1781,7 @@ path = "assets/icons/ruler.svg" title = "Lucide icon \"ruler\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/ruler.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1792,7 +1792,7 @@ path = "assets/icons/save.svg" title = "Lucide icon \"save\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/save.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1803,7 +1803,7 @@ path = "assets/icons/scan-text.svg" title = "Lucide icon \"scan-text\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan-text.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1814,7 +1814,7 @@ path = "assets/icons/scan.svg" title = "Lucide icon \"scan\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scan.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1825,7 +1825,7 @@ path = "assets/icons/scissors.svg" title = "Lucide icon \"scissors\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/scissors.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1836,7 +1836,7 @@ path = "assets/icons/search.svg" title = "Lucide icon \"search\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/search.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1847,7 +1847,7 @@ path = "assets/icons/send.svg" title = "Lucide icon \"send\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/send.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1858,7 +1858,7 @@ path = "assets/icons/settings-2.svg" title = "Lucide icon \"settings-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1869,7 +1869,7 @@ path = "assets/icons/settings.svg" title = "Lucide icon \"settings\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/settings.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1880,7 +1880,7 @@ path = "assets/icons/share-2.svg" title = "Lucide icon \"share-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/share-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1891,7 +1891,7 @@ path = "assets/icons/shield-check.svg" title = "Lucide icon \"shield-check\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/shield-check.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1902,7 +1902,7 @@ path = "assets/icons/signature.svg" title = "Lucide icon \"signature\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/signature.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1913,7 +1913,7 @@ path = "assets/icons/sliders-horizontal.svg" title = "Lucide icon \"sliders-horizontal\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sliders-horizontal.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1924,7 +1924,7 @@ path = "assets/icons/sparkles.svg" title = "Lucide icon \"sparkles\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sparkles.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1935,7 +1935,7 @@ path = "assets/icons/spline.svg" title = "Lucide icon \"spline\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/spline.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1946,7 +1946,7 @@ path = "assets/icons/square-dashed-mouse-pointer.svg" title = "Lucide icon \"square-dashed-mouse-pointer\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed-mouse-pointer.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1957,7 +1957,7 @@ path = "assets/icons/square-dashed.svg" title = "Lucide icon \"square-dashed\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square-dashed.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1968,7 +1968,7 @@ path = "assets/icons/square.svg" title = "Lucide icon \"square\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/square.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1979,7 +1979,7 @@ path = "assets/icons/squares-subtract.svg" title = "Lucide icon \"squares-subtract\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/squares-subtract.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -1990,7 +1990,7 @@ path = "assets/icons/stamp.svg" title = "Lucide icon \"stamp\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/stamp.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2001,7 +2001,7 @@ path = "assets/icons/star.svg" title = "Lucide icon \"star\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/star.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2012,7 +2012,7 @@ path = "assets/icons/sticky-note.svg" title = "Lucide icon \"sticky-note\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sticky-note.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2023,7 +2023,7 @@ path = "assets/icons/strikethrough.svg" title = "Lucide icon \"strikethrough\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/strikethrough.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2034,7 +2034,7 @@ path = "assets/icons/sun.svg" title = "Lucide icon \"sun\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/sun.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2045,7 +2045,7 @@ path = "assets/icons/swatch-book.svg" title = "Lucide icon \"swatch-book\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/swatch-book.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2056,7 +2056,7 @@ path = "assets/icons/tag.svg" title = "Lucide icon \"tag\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/tag.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2067,7 +2067,7 @@ path = "assets/icons/text-cursor-input.svg" title = "Lucide icon \"text-cursor-input\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-cursor-input.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2078,7 +2078,7 @@ path = "assets/icons/text-select.svg" title = "Lucide icon \"text-select\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/text-select.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2089,7 +2089,7 @@ path = "assets/icons/trash-2.svg" title = "Lucide icon \"trash-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2100,7 +2100,7 @@ path = "assets/icons/trash.svg" title = "Lucide icon \"trash\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/trash.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2111,7 +2111,7 @@ path = "assets/icons/triangle-alert.svg" title = "Lucide icon \"triangle-alert\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/triangle-alert.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2122,7 +2122,7 @@ path = "assets/icons/type.svg" title = "Lucide icon \"type\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/type.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2133,7 +2133,7 @@ path = "assets/icons/underline.svg" title = "Lucide icon \"underline\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/underline.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2144,7 +2144,7 @@ path = "assets/icons/undo-2.svg" title = "Lucide icon \"undo-2\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/undo-2.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2155,7 +2155,7 @@ path = "assets/icons/user-round.svg" title = "Lucide icon \"user-round\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/user-round.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2166,7 +2166,7 @@ path = "assets/icons/wand-sparkles.svg" title = "Lucide icon \"wand-sparkles\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wand-sparkles.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2177,7 +2177,7 @@ path = "assets/icons/wrench.svg" title = "Lucide icon \"wrench\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/wrench.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2188,7 +2188,7 @@ path = "assets/icons/x.svg" title = "Lucide icon \"x\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/x.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2199,7 +2199,7 @@ path = "assets/icons/zoom-in.svg" title = "Lucide icon \"zoom-in\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-in.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2210,7 +2210,7 @@ path = "assets/icons/zoom-out.svg" title = "Lucide icon \"zoom-out\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/zoom-out.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -2873,7 +2873,7 @@ sha256 = "f87af81b6fa411da8820eed2bfdcc54c0ee9cd453b4e868c64f56c69db142d0e" [[asset]] path = "assets/app-icon/pdfcraft.svg" title = "PdfCraft app icon (master vector)" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" @@ -2884,7 +2884,7 @@ sha256 = "27745c021830f82c033b0f15f2d8d9f8e099138743297887ff7e2c9e7ed069da" [[asset]] path = "assets/app-icon/pdfcraft-small.svg" title = "PdfCraft app icon (light vector)" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" @@ -2895,7 +2895,7 @@ sha256 = "b524df1a583f8034a5a23836955e4fb423e0149429d695e80d95e5056f408678" [[asset]] path = "assets/app-icon/pdfcraft-1024.png" title = "PdfCraft app icon, 1024 px on Apple's icon grid" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" @@ -2906,7 +2906,7 @@ sha256 = "68ac6730b8990d07503351d32eacea81e9ec171a7a6f09daf52d6555d8245c8a" [[asset]] path = "assets/app-icon/pdfcraft.icns" title = "PdfCraft app icon (macOS .icns)" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" @@ -2917,7 +2917,7 @@ sha256 = "fca7e7a0e34cc5c5ecae5bca97ee896aac68c4a34b088bc3a0b3271484f23974" [[asset]] path = "assets/app-icon/pdfcraft.ico" title = "PdfCraft app icon (Windows .ico, 16-256 px)" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" @@ -2950,7 +2950,7 @@ sha256 = "910be5e0ecdc94ce00368077ca1bef449bd5d62a75516feeb37884296c6fac4d" [[asset]] path = "assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 16 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" @@ -2961,7 +2961,7 @@ sha256 = "660d2aace45ec1a28bc7e3708eab71ffee5cb1203f57a6256f7bd7c727193567" [[asset]] path = "assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 24 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" @@ -2972,7 +2972,7 @@ sha256 = "1989441bdcc8d1df0d73cfe7eb836e9211abaa0fd7fd6ec7e4634bfcd472995d" [[asset]] path = "assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 32 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" @@ -2983,7 +2983,7 @@ sha256 = "7fd1ac555f7f6b13ef3fba2696dfb86900cfcb7f7b669aece38590bf2306cf2e" [[asset]] path = "assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 48 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" @@ -2994,7 +2994,7 @@ sha256 = "784ef3bd9f9e479cb2df22a0aee259a441a9c358390e646639c3a0ed79d41288" [[asset]] path = "assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 64 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" @@ -3005,7 +3005,7 @@ sha256 = "deb1a9f849549d3f0652fb6d3c30b6eb6ea54498eaea8c1007d901bbafe7067c" [[asset]] path = "assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 128 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" @@ -3016,7 +3016,7 @@ sha256 = "1933bbeaea290497facd328e917dafcc433d0c0caa9f1e207aa45ac1e430898c" [[asset]] path = "assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 256 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" @@ -3027,7 +3027,7 @@ sha256 = "47094c09fb77eb35d465eee46445626a4d78c7ef50629cc7d04a602de3e144e9" [[asset]] path = "assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 512 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" @@ -3038,7 +3038,7 @@ sha256 = "dea8010a8b41011aba054d3bfb8b790da57bc958c60869f508e1bbd288d3f280" [[asset]] path = "assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg" title = "PdfCraft app icon (Linux scalable)" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" @@ -3194,7 +3194,7 @@ path = "assets/icons/pin.svg" title = "Lucide icon \"pin\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pin.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3205,7 +3205,7 @@ path = "assets/icons/move-right.svg" title = "Lucide icon \"move-right\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-right.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3216,7 +3216,7 @@ path = "assets/icons/reply.svg" title = "Lucide icon \"reply\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/reply.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3227,7 +3227,7 @@ path = "assets/icons/thumbs-up.svg" title = "Lucide icon \"thumbs-up\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-up.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3238,7 +3238,7 @@ path = "assets/icons/thumbs-down.svg" title = "Lucide icon \"thumbs-down\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3249,7 +3249,7 @@ path = "assets/icons/message-square-plus.svg" title = "Lucide icon \"message-square-plus\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3260,7 +3260,7 @@ path = "assets/icons/circle-x.svg" title = "Lucide icon \"circle-x\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3282,7 +3282,7 @@ path = "assets/icons/pentagon.svg" title = "Lucide icon \"pentagon\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3293,7 +3293,7 @@ path = "assets/icons/message-square-quote.svg" title = "Lucide icon \"message-square-quote\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3315,7 +3315,7 @@ path = "assets/icons/clipboard-paste.svg" title = "Lucide icon \"clipboard-paste\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3408,7 +3408,7 @@ path = "assets/icons/monitor.svg" title = "Lucide icon \"monitor\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3461,7 +3461,7 @@ path = "assets/icons/superscript.svg" title = "Lucide icon \"superscript\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3472,7 +3472,7 @@ path = "assets/icons/subscript.svg" title = "Lucide icon \"subscript\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3483,7 +3483,7 @@ path = "assets/icons/pilcrow.svg" title = "Lucide icon \"pilcrow\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" @@ -3494,7 +3494,7 @@ path = "assets/icons/move-horizontal.svg" title = "Lucide icon \"move-horizontal\"" author = "Lucide Contributors (portions Cole Bemis, Feather)" source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg)" -licence = "ISC" +licence = "ISC AND MIT" licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" diff --git a/CLAUDE.md b/CLAUDE.md index b6c91b1fe6..5e9328f8d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado ## Start every session here 1. Read `plan/STATUS.md`: the current milestone, the next unchecked task and any blockers. `ROADMAP.md` is the one-page summary (stage, numbers, progress log) and `docs/roadmap.md` the milestone table; at the end of every session update the milestone row and add a line to the ROADMAP.md progress log. - - Read `docs/gaps.md` (the ranked gaps: Acrobat interop, the open-issue backlog, the renderer and a fidelity harness, editing existing content, Pro workflows, 1.0 polish) and `docs/target-app-parity.md` (the numbers and how they are measured) before choosing work. Prefer the gaps over new P2/P3 features, and keep both documents true when things change (`docs/` progress docs follow craftrules `standards/progress-docs.md`: status line, revision history). + - Read `docs/gaps.md` (the ranked gaps: Acrobat interop, the open-issue backlog, the renderer and a fidelity harness, editing existing content, Pro workflows, 1.0 polish) and `docs/target-app-parity.md` (the numbers and how they are measured) before choosing work. Prefer the gaps over new P2/P3 features, and keep both documents true when things change (`docs/` progress docs carry a status line and a revision history). - "Shipped" in `parity/` means "exists and tested", not "as good as Acrobat". Don't mark a feature shipped on a generic test, and say in its notes what is still missing. 2. Read that task in `plan/execution-plan.md` §3, the relevant section of `plan/architecture.md`, and the README of the crate you're touching. 3. Follow the **autonomous operation protocol** in `plan/execution-plan.md` §7 (orient → plan → implement + test → verify → record → commit). Don't stop to ask unless §7 lists the decision as the user's. @@ -13,7 +13,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado ## Non-negotiables - **Assets: read `AGENTS.md` §1 before adding or showing any icon, image, font or document.** No assets from Adobe products, ever. Only openly licensed or contributor-original assets are allowed, each with an entry in `ATTRIBUTION.toml`. `cargo xtask assets` enforces this. `AGENTS.md` overrides this file. -- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts).** Never commit font files here (`AGENTS.md` §1.4; team members: [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md), internal). It is the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts && CRAFT_FONTS_DIR=../craft-fonts cargo test --workspace` embeds the Japanese fonts (UI fallback, Japanese text in edited PDFs) and runs their tests, which otherwise skip. Code using `pdfcraft_fonts::CRAFT_FONTS` must work when it is empty. +- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts).** Never commit font files here (`AGENTS.md` §1.4). It is the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts && CRAFT_FONTS_DIR=../craft-fonts cargo test --workspace` embeds the Japanese fonts (UI fallback, Japanese text in edited PDFs) and runs their tests, which otherwise skip. Code using `pdfcraft_fonts::CRAFT_FONTS` must work when it is empty. - **Clean-room.** - Never read, disassemble or copy anything inside the Acrobat bundle (names and listings only). **Never open `Contents/Resources/JavaScripts/`.** - Behaviour comes from public docs, specs (ISO 32000-2, the Arlington model) and black-box observation (`plan/acrobat/`). @@ -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. diff --git a/Cargo.lock b/Cargo.lock index cfcb9dc362..9c29337d75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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.16.1" dependencies = [ "chrono", "craft-update", @@ -4231,7 +4232,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.15.0" +version = "0.16.1" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4243,7 +4244,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.15.0" +version = "0.16.1" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4254,7 +4255,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.15.0" +version = "0.16.1" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4265,7 +4266,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.15.0" +version = "0.16.1" dependencies = [ "base64 0.23.1", "flate2", @@ -4287,7 +4288,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.15.0" +version = "0.16.1" dependencies = [ "base64 0.23.1", "pdfcraft-audit", @@ -4305,11 +4306,11 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.15.0" +version = "0.16.1" [[package]] name = "pdfcraft-content" -version = "0.15.0" +version = "0.16.1" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4317,7 +4318,7 @@ dependencies = [ [[package]] name = "pdfcraft-cos" -version = "0.15.0" +version = "0.16.1" dependencies = [ "chrono", "hayro-syntax", @@ -4332,7 +4333,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.15.0" +version = "0.16.1" dependencies = [ "encoding_rs", "hayro-ccitt", @@ -4352,7 +4353,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.15.0" +version = "0.16.1" dependencies = [ "aes", "md-5", @@ -4364,7 +4365,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.15.0" +version = "0.16.1" 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.16.1" 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.16.1" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.15.0" +version = "0.16.1" dependencies = [ "fax", "flate2", @@ -4442,7 +4446,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.15.0" +version = "0.16.1" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4452,7 +4456,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.15.0" +version = "0.16.1" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4465,11 +4469,11 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.15.0" +version = "0.16.1" [[package]] name = "pdfcraft-js" -version = "0.15.0" +version = "0.16.1" dependencies = [ "boa_engine", "chrono", @@ -4477,7 +4481,7 @@ dependencies = [ [[package]] name = "pdfcraft-measure" -version = "0.15.0" +version = "0.16.1" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4490,14 +4494,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.15.0" +version = "0.16.1" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.15.0" +version = "0.16.1" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4508,7 +4512,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.15.0" +version = "0.16.1" dependencies = [ "image", "pdfcraft-annot", @@ -4524,11 +4528,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.15.0" +version = "0.16.1" [[package]] name = "pdfcraft-organize" -version = "0.15.0" +version = "0.16.1" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4539,7 +4543,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.15.0" +version = "0.16.1" dependencies = [ "async-channel", "futures-lite", @@ -4549,7 +4553,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.15.0" +version = "0.16.1" dependencies = [ "flate2", "pdfcraft-content", @@ -4560,7 +4564,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.15.0" +version = "0.16.1" dependencies = [ "craft-print", "pdfcraft-content", @@ -4572,7 +4576,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.15.0" +version = "0.16.1" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4589,7 +4593,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.15.0" +version = "0.16.1" dependencies = [ "bytemuck", "flate2", @@ -4608,7 +4612,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.15.0" +version = "0.16.1" dependencies = [ "aes", "aws-lc-rs", @@ -4644,11 +4648,11 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.15.0" +version = "0.16.1" [[package]] name = "pdfcraft-ui-egui" -version = "0.15.0" +version = "0.16.1" 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.16.1" dependencies = [ "eframe", "egui", @@ -4712,7 +4718,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.15.0" +version = "0.16.1" dependencies = [ "base64 0.23.1", "image", @@ -4726,7 +4732,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.15.0" +version = "0.16.1" 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", diff --git a/Cargo.toml b/Cargo.toml index 1b04ab5263..85d02570ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.15.0" +version = "0.16.1" 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.16.1" } +pdfcraft-filters = { path = "crates/filters", version = "0.16.1" } +pdfcraft-cos = { path = "crates/cos", version = "0.16.1" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.16.1" } +pdfcraft-organize = { path = "crates/organize", version = "0.16.1" } +pdfcraft-measure = { path = "crates/measure", version = "0.16.1" } +pdfcraft-annot = { path = "crates/annot", version = "0.16.1" } +pdfcraft-audit = { path = "crates/audit", version = "0.16.1" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.16.1" } +pdfcraft-model = { path = "crates/model", version = "0.16.1" } +pdfcraft-content = { path = "crates/content", version = "0.16.1" } +pdfcraft-redact = { path = "crates/redact", version = "0.16.1" } +pdfcraft-print = { path = "crates/print", version = "0.16.1" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.16.1" } +pdfcraft-sign = { path = "crates/sign", version = "0.16.1" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.16.1" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.16.1" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.16.1" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.16.1" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.16.1" } +pdfcraft-js = { path = "crates/js", version = "0.16.1" } +pdfcraft-compare = { path = "crates/compare", version = "0.16.1" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.16.1" } +pdfcraft-export = { path = "crates/export", version = "0.16.1" } +pdfcraft-edit = { path = "crates/edit", version = "0.16.1" } +pdfcraft-create = { path = "crates/create", version = "0.16.1" } +pdfcraft-forms = { path = "crates/forms", version = "0.16.1" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.16.1" } +pdfcraft-render = { path = "crates/render", version = "0.16.1" } +pdfcraft-engine = { path = "crates/engine", version = "0.16.1" } +pdfcraft-platform = { path = "crates/platform", version = "0.16.1" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.16.1" } +pdfcraft-automation = { path = "crates/automation", version = "0.16.1", default-features = false } # pdf hayro = "0.7.1" diff --git a/LICENSES.toml b/LICENSES.toml new file mode 100644 index 0000000000..d9ceb3180e --- /dev/null +++ b/LICENSES.toml @@ -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"] diff --git a/NOTICE b/NOTICE index 63a8fe672c..e8f31ade2f 100644 --- a/NOTICE +++ b/NOTICE @@ -12,7 +12,7 @@ Third-party material This product bundles third-party material under its own licenses, listed below. The per-file list (author, source, license) is in ATTRIBUTION.md. -- Lucide icons (assets/icons/*.svg) — ISC License. See assets/icons/LICENSE-lucide.txt. +- Lucide icons (assets/icons/*.svg) — ISC and MIT licences (Feather-derived icons). See assets/icons/LICENSE-lucide.txt. The Windows PDF file icon (assets/app-icon/pdfcraft-document.svg and .ico) adapts Lucide's file-text outline and combines it with contributor-original artwork. See assets/app-icon/LICENSE-document.txt (ISC AND (MIT OR Apache-2.0)), packaged as diff --git a/README.md b/README.md index 9599a54844..3c1b67936a 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,8 @@ Search the whole document as you type, step through matches with ⌘G ## Navigate long documents +Launch with `pdfcraft --two-paged file.pdf` (or `pdfcraft file.pdf --two-paged`) to open each command-line PDF in noncontinuous two-page view. This optional launch override does not change saved default-layout preferences or PDFs restored from the previous session. An explicit `--layout` option takes precedence on the active document regardless of argument order; `--default-layout` retains its existing behavior for subsequently opened documents. + Right-click a PDF tab to close it, reopen the last closed file-backed tab, or reveal its file. Closing still asks about unsaved changes. Reopening reads the saved file from disk; it does not restore discarded edits. Reveal selects the file in Explorer or Finder. On Linux and FreeBSD it requests `org.freedesktop.FileManager1.ShowItems`, falling back to opening the containing directory with `xdg-open` when that service is unavailable. Reopen and Reveal are unavailable on the web; Reveal is also unavailable for pathless documents. Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state. @@ -545,9 +547,18 @@ stand on its own. | | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) | | | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) | | | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) | -| | **PdfCraft** | **Reading, organizing and protecting PDFs · you are here** | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | +| | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | | | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | | | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | +| | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) | +| | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) | +| | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) | +| | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) | +| | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) | +| | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) | +| | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) | +| | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) | +| | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | | And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control. diff --git a/ROADMAP.md b/ROADMAP.md index 069612ba31..01b9dbe814 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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,28 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- 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 +272,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 +393,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 +567,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 +740,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 +779,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 +806,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. diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 814730f344..d2a75c5941 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -1,6 +1,8 @@ //! PdfCraft desktop app. //! //! Usage: `pdfcraft [options] [files…]` (`-h` / `--help` prints a short version of this, [`USAGE`]). +//! `--two-paged` opens each command-line PDF in noncontinuous two-page view, without changing defaults. +//! An explicit `--layout` takes precedence for the active document, regardless of argument order. //! `--create-images [images…]` stages the images in one PDF and asks for the page DPI. //! //! View options (applied after the files open; also the seed of the UI control channel): @@ -40,6 +42,7 @@ mod motion; mod reopen; mod settings; mod single_instance; +mod startup; mod text_scale; #[cfg(test)] #[path = "windows_manifest.rs"] @@ -63,6 +66,7 @@ Usage: pdfcraft [options] [files…] Opens the files in the PdfCraft desktop app. Options: + --two-paged open command-line PDFs in noncontinuous two-page view --new-window open a window of its own instead of passing the files to a running PdfCraft --create-images [images…] put the images in one PDF and ask for the page DPI --control enable the UI control channel (pdfcraft-cli ui --control …) @@ -218,28 +222,16 @@ fn main() -> eframe::Result { let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("pdfcraft: {report}\n")); } })); - let mut files = Vec::new(); - let mut options: Vec<(String, String)> = Vec::new(); - let mut control_file: Option = None; - let mut create_images = false; - let mut new_window = false; - let mut args = std::env::args().skip(1); - while let Some(a) = args.next() { - match a.as_str() { - "--version" => return print_and_exit(&format!("pdfcraft {}", pdfcraft_engine::build_info::version())), - // Before any window or log file, so a scripted call returns (#4076). - "-h" | "--help" => return print_and_exit(USAGE), - "--control" => control_file = args.next(), - "--create-images" => create_images = true, - "--new-window" => new_window = true, - flag if flag.starts_with("--") => { - let value = args.next().unwrap_or_default(); - options.push((flag.trim_start_matches("--").to_string(), value)); - } - _ => files.push(path_from_arg(a)), - } + let mut startup = startup::Arguments::parse(std::env::args().skip(1)); + if startup.version { + return print_and_exit(&format!("pdfcraft {}", pdfcraft_engine::build_info::version())); } - let files = rejoin_split_paths(files, |p| std::path::Path::new(p).is_file()); + if startup.help { + return print_and_exit(USAGE); + } + startup.files = rejoin_split_paths(startup.files, |p| std::path::Path::new(p).is_file()); + let may_hand_off = startup.may_hand_off(); + let startup::Arguments { files, options, control_file, create_images, two_paged, .. } = startup; let integrated = cfg!(target_os = "macos"); // GNOME on Wayland leaves the window frame to the app (src/decorations.rs). let window_controls = if cfg!(any(target_os = "macos", target_os = "windows")) { None } else { decorations::app_drawn() }; @@ -260,7 +252,6 @@ fn main() -> eframe::Result { // Explorer double-click, a file manager or `xdg-open`) hands them to the PdfCraft already // running and exits (#282, #317, #1726). Any option means the caller wants this launch's own // window. - let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images; // Absolute for this instance too: links relative to a document resolve against its folder, // never the working directory. let files = single_instance::absolute(&files); @@ -270,7 +261,7 @@ fn main() -> eframe::Result { single_instance::Claim::Alone => None, }; let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref()); - let launch = Launch { files, options, control_file, create_images, integrated, window_controls }; + let launch = Launch { files, options, control_file, create_images, integrated, window_controls, two_paged }; // Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one // that launched us as well as later ones. Lives until the event loop returns. #[cfg(target_os = "macos")] @@ -380,6 +371,7 @@ struct Launch { integrated: bool, /// The window buttons the app draws, when the desktop leaves the frame to it. window_controls: Option, + two_paged: bool, } /// Whether the window is a native Wayland one (not X11 or XWayland). @@ -400,7 +392,7 @@ fn app_creator<'a>( instance: Option<&'a single_instance::Server>, #[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents, ) -> eframe::AppCreator<'a> { - let Launch { files, options, control_file, create_images, integrated, window_controls } = launch; + let Launch { files, options, control_file, create_images, integrated, window_controls, two_paged } = launch; Box::new(move |cc| { started.set(true); let mut app = PdfCraftApp::new(); @@ -467,6 +459,7 @@ fn app_creator<'a>( &[("folder", &w.folder.display().to_string()), ("error", &w.error)], ); } + let defer_layout = two_paged && !files.is_empty() && !create_images; if create_images { if let Err(e) = app.begin_image_import_paths(&files) { app.notify(e); @@ -475,12 +468,11 @@ fn app_creator<'a>( // With the preference on, last session's files come back first; files named on // the command line open after them, in front (#442). app.reopen_last_files(&files); - for f in files { - app.open_path(&f); - } + startup::open_files(&mut app, &files, two_paged); } for (k, v) in options { - if let Err(e) = app.set_option(&k, &v) { + let result = if defer_layout && k == "layout" { app.set_launch_layout(&v) } else { app.set_option(&k, &v) }; + if let Err(e) = result { log::warn!("--{k} {v}: {e}"); } } diff --git a/apps/pdfcraft/src/startup.rs b/apps/pdfcraft/src/startup.rs new file mode 100644 index 0000000000..be25da45b6 --- /dev/null +++ b/apps/pdfcraft/src/startup.rs @@ -0,0 +1,244 @@ +//! Native launch arguments and per-file view overrides (#1354). +use pdfcraft_ui_egui::PdfCraftApp; + +#[derive(Default)] +pub(super) struct Arguments { + pub files: Vec, + pub options: Vec<(String, String)>, + pub control_file: Option, + pub create_images: bool, + pub new_window: bool, + pub two_paged: bool, + pub version: bool, + pub help: bool, +} + +impl Arguments { + pub fn may_hand_off(&self) -> bool { + !self.new_window && !self.files.is_empty() && self.options.is_empty() && self.control_file.is_none() && !self.create_images && !self.two_paged + } + + pub fn parse(args: impl IntoIterator) -> Self { + let mut parsed = Self::default(); + let mut args = args.into_iter(); + while let Some(a) = args.next() { + match a.as_str() { + "--version" => { + parsed.version = true; + break; + } + "-h" | "--help" => { + parsed.help = true; + break; + } + "--control" => parsed.control_file = args.next(), + "--create-images" => parsed.create_images = true, + "--new-window" => parsed.new_window = true, + "--two-paged" => parsed.two_paged = true, + flag if flag.starts_with("--two-paged=") => { + // It has no value: do not consume the following filename on malformed use. + log::warn!("{flag}: use --two-paged without a value"); + } + flag if flag.starts_with("--") => { + let value = args.next().unwrap_or_default(); + parsed.options.push((flag.trim_start_matches("--").to_string(), value)); + } + _ => parsed.files.push(super::path_from_arg(a)), + } + } + parsed + } +} + +pub(super) fn open_files(app: &mut PdfCraftApp, files: &[String], two_paged: bool) { + for path in files { + if two_paged { + // The per-file override wins over initial view without changing saved defaults. + app.open_path_with_display(path, pdfcraft_ui_egui::canvas::PageLayout::TwoUp, false); + } else { + app.open_path(path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Arguments { + Arguments::parse(args.iter().map(|s| s.to_string())) + } + + #[test] + fn standalone_flag_keeps_files_in_either_position() { + for args in + [["--two-paged", "first.pdf", "second.pdf"], ["first.pdf", "--two-paged", "second.pdf"], ["first.pdf", "second.pdf", "--two-paged"]] + { + let parsed = parse(&args); + assert!(parsed.two_paged); + assert_eq!(parsed.files, ["first.pdf", "second.pdf"]); + assert!(parsed.options.is_empty()); + assert!(!parsed.may_hand_off()); + } + let empty = parse(&["--two-paged"]); + assert!(empty.two_paged && empty.files.is_empty()); + let normal = parse(&["first.pdf", "second.pdf"]); + assert!(!normal.two_paged); + assert!(normal.may_hand_off()); + for args in [ + &["--new-window", "first.pdf"][..], + &["--layout", "two-page", "first.pdf"], + &["--control", "control.json", "first.pdf"], + &["--create-images", "first.png"], + ] { + assert!(!parse(args).may_hand_off()); + } + } + + #[test] + fn existing_layout_options_are_preserved_in_either_order() { + for args in [["--two-paged", "--layout", "single", "file.pdf"], ["--layout", "single", "--two-paged", "file.pdf"]] { + let parsed = parse(&args); + assert!(parsed.two_paged); + assert_eq!(parsed.files, ["file.pdf"]); + assert_eq!(parsed.options, [("layout".into(), "single".into())]); + } + let parsed = parse(&["--two-paged", "--default-layout", "two-up", "file.pdf"]); + assert_eq!(parsed.options, [("default-layout".into(), "two-up".into())]); + } + + #[test] + fn newer_options_and_version_are_preserved() { + let parsed = parse(&["--two-paged", "--new-window", "--grid-zoom", "150", "--default-mode", "read", "file:///tmp/a%20b.pdf"]); + assert!(parsed.two_paged && parsed.new_window); + assert_eq!(parsed.files, ["/tmp/a b.pdf"]); + assert_eq!(parsed.options, [("grid-zoom".into(), "150".into()), ("default-mode".into(), "read".into())]); + assert!(!parsed.may_hand_off()); + let version = parse(&["--two-paged", "--version", "ignored.pdf"]); + assert!(version.version && version.files.is_empty()); + } + + #[test] + fn help_keeps_the_headless_early_exit() { + for flag in ["-h", "--help"] { + let args = parse(&["--two-paged", flag, "ignored.pdf"]); + assert!(args.help && args.files.is_empty()); + } + } + + #[test] + fn malformed_boolean_does_not_eat_a_file_or_option() { + let args = parse(&["--two-paged=false", "first.pdf", "--layout", "single", "second.pdf"]); + assert!(!args.two_paged); + assert_eq!(args.files, ["first.pdf", "second.pdf"]); + assert_eq!(args.options, [("layout".into(), "single".into())]); + } + + #[test] + fn startup_override_is_per_file_and_does_not_persist() { + use std::io::Write as _; + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("pdfcraft-two-paged-{}-{nonce}.pdf", std::process::id())); + let mut file = std::fs::OpenOptions::new().create_new(true).write(true).open(&path).unwrap(); + file.write_all(include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap(); + drop(file); + let second_path = path.with_extension("second.pdf"); + std::fs::copy(&path, &second_path).unwrap(); + let text = path.to_str().unwrap(); + let second = second_path.to_str().unwrap(); + for enabled in [false, true] { + let mut app = PdfCraftApp::new(); + app.set_option("default-layout", "single").unwrap(); + app.open_bytes("restored.pdf", None, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf").to_vec()).unwrap(); + let restored = app.views[0].id; + open_files(&mut app, &[text.into(), second.into()], enabled); + assert_eq!(app.views.len(), 3); + assert_eq!(app.views[0].id, restored); + assert_eq!(app.views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::Single); + for view in app.views.iter().skip(1) { + assert_eq!( + view.layout, + if enabled { pdfcraft_ui_egui::canvas::PageLayout::TwoUp } else { pdfcraft_ui_egui::canvas::PageLayout::Single } + ); + assert!(!view.continuous()); + } + let persisted: serde_json::Value = serde_json::from_str(&app.persist()).unwrap(); + assert_eq!(persisted["default_layout"], "single"); + assert!(persisted.get("two_paged").is_none()); + // Existing --layout applies after the flag to the active document only. + if enabled { + app.set_launch_layout("continuous").unwrap(); + } else { + app.set_option("layout", "continuous").unwrap(); + } + assert_eq!(app.views[2].layout, pdfcraft_ui_egui::canvas::PageLayout::Single); + assert!(app.views[2].continuous()); + app.set_option("default-layout", "two-up").unwrap(); + assert_eq!(app.view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::TwoUp); + assert!(app.view_defaults.continuous); + open_files(&mut app, &[], enabled); + assert_eq!(app.views.len(), 3); + } + std::fs::remove_file(path).unwrap(); + std::fs::remove_file(second_path).unwrap(); + } + + #[test] + fn explicit_launch_layout_preserves_argument_order_and_active_file() { + use pdfcraft_ui_egui::canvas::PageLayout; + let path = std::env::temp_dir().join(format!("pdfcraft-explicit-layout-{}.pdf", std::process::id())); + std::fs::write(&path, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap(); + let second_path = path.with_extension("second.pdf"); + std::fs::copy(&path, &second_path).unwrap(); + let names = [path.to_str().unwrap(), second_path.to_str().unwrap()]; + for prefix in [["--two-paged", "--layout", "single"], ["--layout", "single", "--two-paged"]] { + for count in [1, 2] { + let args = Arguments::parse(prefix.into_iter().chain(names.iter().take(count).copied()).map(str::to_owned)); + let mut app = PdfCraftApp::new(); + app.set_option("default-layout", "continuous").unwrap(); + app.open_bytes("restored.pdf", None, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf").to_vec()).unwrap(); + open_files(&mut app, &args.files, args.two_paged); + for (key, value) in args.options { + assert_eq!(key, "layout"); + app.set_launch_layout(&value).unwrap(); + } + assert_eq!(app.active, Some(count)); + assert!(app.views[0].continuous()); + for view in app.views.iter().skip(1).take(count - 1) { + assert_eq!(view.layout, PageLayout::TwoUp); + } + assert_eq!(app.views[count].layout, PageLayout::Single); + assert!(!app.views[count].continuous()); + assert!(app.view_defaults.continuous); + } + } + std::fs::remove_file(path).unwrap(); + std::fs::remove_file(second_path).unwrap(); + } +} + +#[cfg(test)] +mod repeated_file_tests { + use super::*; + + #[test] + fn a_repeated_file_reuses_its_tab_and_receives_the_launch_override() { + use pdfcraft_ui_egui::canvas::PageLayout; + let path = std::env::temp_dir().join(format!("pdfcraft-repeated-startup-{}.pdf", std::process::id())); + std::fs::write(&path, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap(); + let name = path.to_str().unwrap().to_string(); + let mut app = PdfCraftApp::new(); + app.set_option("default-layout", "continuous").unwrap(); + app.open_path(&name); + let original = app.views[0].id; + open_files(&mut app, &[name.clone(), name], true); + assert_eq!(app.views.len(), 1); + assert_eq!(app.views[0].id, original); + assert_eq!(app.views[0].layout, PageLayout::TwoUp); + assert!(!app.views[0].continuous()); + app.set_launch_layout("single").unwrap(); + assert_eq!(app.views[0].layout, PageLayout::Single); + assert!(app.view_defaults.continuous); + std::fs::remove_file(path).unwrap(); + } +} diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md index 58ef07d37c..7c17ad0045 100644 --- a/assets/app-icon/README.md +++ b/assets/app-icon/README.md @@ -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 diff --git a/contributors/people.toml b/contributors/people.toml index 53cc2391ed..a5a47f5c52 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -1,13 +1,10 @@ -# Contributor names for the About window (craftrules standards/contributors.md). +# Contributor names for the About window. # # Everyone who contributes is credited by GitHub username automatically. Anything more is up to # you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a # maintainer may add a person only when that person asked, recording where in consent_source. # -# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \ -# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name] -# -# Or copy this block, replacing your-github-username (every field is optional): +# Copy this block, replacing your-github-username (every field is optional): # # [people.your-github-username] # real_name = "Your Name" # shown when the viewer picks "Real name" diff --git a/crates/automation/README.md b/crates/automation/README.md index 176946c175..7a9de516c0 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -199,6 +199,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` diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index ca6e3fb936..3d322e2a5c 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -12,6 +12,35 @@ pub enum Content { Png { data: Vec, width: u32, height: u32 }, } +/// `image_edit` `transform`: a matrix `[a b c d e f]` acting on top-left-origin points (a point +/// moves to `(a·x + c·y + e, b·x + d·y + f)`) → the same motion in PDF user space, which is +/// what `ImageEdit::Transform` applies after the image's placement. Both spaces are related by +/// an affine map, so three corners of the crop box pin the result down. +fn view_transform_to_user(info: &pdfcraft_render::PageInfo, m: [f64; 6]) -> Result<[f64; 6]> { + let [a, b, c, d, e, f] = m; + let det = a * d - b * c; + if !m.iter().all(|v| v.is_finite()) || !det.is_finite() || det.abs() < 1e-9 { + return Err(ToolError::InvalidArgs("transform needs a finite matrix that keeps an area (a·d − b·c must not be 0)".into())); + } + let moved = |x: f32, y: f32| { + let [vx, vy] = info.user_to_view(x, y).map(f64::from); + info.view_to_user((a * vx + c * vy + e) as f32, (b * vx + d * vy + f) as f32).map(f64::from) + }; + let [x0, y0, x1, y1] = info.crop; + let (w, h) = (f64::from(x1 - x0), f64::from(y1 - y0)); + if w.abs() < 1e-3 || h.abs() < 1e-3 { + return Err(ToolError::InvalidArgs("the page has no area to transform in".into())); + } + let (o, px, py) = (moved(x0, y0), moved(x1, y0), moved(x0, y1)); + let (ua, ub, uc, ud) = ((px[0] - o[0]) / w, (px[1] - o[1]) / w, (py[0] - o[0]) / h, (py[1] - o[1]) / h); + let (x0, y0) = (f64::from(x0), f64::from(y0)); + let user = [ua, ub, uc, ud, o[0] - ua * x0 - uc * y0, o[1] - ub * x0 - ud * y0]; + if !user.iter().all(|v| v.is_finite()) { + return Err(ToolError::InvalidArgs("the transform moves the image too far".into())); + } + Ok(user) +} + impl Automation { /// Run the tool `name` with JSON `args` (an object; `null` means no arguments). pub fn call(&mut self, name: &str, args: &Value) -> Result> { @@ -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 = 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 = self .doc(&a)? @@ -816,4 +865,52 @@ impl Automation { }; Ok(vec![Content::Json(out)]) } + + /// `text_script`: Format text ▸ Superscript or Subscript on a paragraph, or on a byte range + /// of its editor text, written as `text_edit` writes the paragraph. + fn text_script(&mut self, a: &Args) -> Result { + use pdfcraft_engine::text_script::{Script, toggle}; + let name = a.str("script")?; + let script = match name { + "superscript" => Script::Super, + "subscript" => Script::Sub, + other => return Err(ToolError::InvalidArgs(format!("unknown script {other:?} (superscript, subscript)"))), + }; + let page = self.page(a)?; + let k = a.int("paragraph")?; + let text = { + let doc = self.doc(a)?; + let blocks = doc.text_blocks(page); + let index = self.paragraph_index(doc.id, page, k, &blocks)?; + blocks.get(index).map(|b| b.editor_text.clone()).ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))? + }; + let range = match a.opt_ints("range")? { + None => None, + Some(r) => { + let bounds = match r.as_slice() { + [start, end] => usize::try_from(*start).ok().zip(usize::try_from(*end).ok()), + _ => None, + }; + let range = bounds + .map(|(start, end)| start..end) + .filter(|r| r.start < r.end && r.end <= text.len() && text.is_char_boundary(r.start) && text.is_char_boundary(r.end)); + let Some(range) = range else { + return Err(ToolError::InvalidArgs(format!( + "range must be [start, end]: UTF-8 byte offsets of the paragraph's editor_text (from text_paragraphs) with start < end <= {}, on character boundaries", + text.len() + ))); + }; + Some(range) + } + }; + let (toggled, _) = toggle(&text, range.as_ref(), script) + .ok_or_else(|| ToolError::InvalidArgs(format!("nothing to change: no character of the text has a {name} form")))?; + let args = json!({ "doc": a.get("doc"), "page": a.get("page"), "paragraph": k, "text": toggled }); + let mut out = match self.call("text_edit", &args)?.into_iter().next() { + Some(Content::Json(out)) => out, + _ => json!({}), + }; + out["script"] = json!(name); + Ok(out) + } } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 3b7a218c3b..c303d6c6e5 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -175,7 +175,7 @@ pub fn tools() -> Vec { }), &["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}}, @@ -1392,17 +1392,18 @@ pub fn tools() -> Vec { .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])), - t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.") + t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), remove_crop (remove a crop created by PdfCraft while preserving clipping paths from the original document), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), transform (matrix: 6 numbers [a, b, c, d, e, f] in top-left-origin points, applied after the current placement so a point (x, y) of the image moves to (a·x + c·y + e, b·x + d·y + f); finite, with a·d − b·c not 0; the box in page_images becomes the transformed image's bounding box), flip_horizontal (reflect left/right on the displayed page), flip_vertical (reflect top/bottom on the displayed page; both flip actions follow the page rotation), replace (path: an image file, drawn in the same place), delete, or arrange: bring_to_front, send_to_back, bring_forward, send_backward (reorder stacking order among content on the page; errors when the image is already at the requested limit; arranging an added image converts it into an ordinary page image). Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.") .cmd("edit.edit_text") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "image": { "type": "integer", "minimum": 1 }, - "action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "flip_horizontal", "flip_vertical", "replace", "delete"] }, + "action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "transform", "flip_horizontal", "flip_vertical", "replace", "delete", "bring_to_front", "send_to_back", "bring_forward", "send_backward", "remove_crop"] }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "quarters": { "type": "integer" }, "degrees": { "type": "number" }, + "matrix": { "type": "array", "items": { "type": "number" }, "minItems": 6, "maxItems": 6 }, "path": { "type": "string" }, }), &["doc", "page", "image", "action"], @@ -1452,6 +1453,14 @@ pub fn tools() -> Vec { "scale": { "type": "number", "description": "Horizontal scale in percent." } }), &["doc", "page"], )), + t("text_script", "Superscript or subscript", "Format text ▸ Superscript or Subscript on one paragraph (paragraph, from text_paragraphs), or on range [start, end): UTF-8 byte offsets of its editor_text. The characters take the Unicode superscript or subscript forms (2 → ², 2 → ₂; the font must have those glyphs, and extracted text contains them); text already in that script goes back to plain characters. Characters Unicode has no such form for (q, a space) stay unchanged and do not determine the selection's script state. Mixed plain and target-script characters normalize to that script, then to plain text on the next call (H₂2 → H₂₂ → H22); a second toggle does not reconstruct the original mixture. Use Undo to restore the exact prior text. The paragraph is then rewritten as text_edit rewrites it, with the same result fields. Refused when no character would change. Undoable. Mathematical minus U+2212 has its own script mapping; ASCII hyphen-minus stays unchanged.") + .cmd("edit.edit_text") + .with(schema( + json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, + "script": { "type": "string", "enum": ["superscript", "subscript"] }, + "range": { "type": "array", "items": { "type": "integer", "minimum": 0 }, "minItems": 2, "maxItems": 2, "description": "UTF-8 byte offsets [start, end) of the paragraph's editor_text; the whole paragraph when omitted." } }), + &["doc", "page", "paragraph", "script"], + )), t("doc_revisions", "List revisions", "List the document's saved revisions (oldest first): each incremental update is one. Returns revision number, where it ends in the file and its size, and which signatures sign exactly that revision.") .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index e55ff7eaf7..404622a703 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -3286,6 +3286,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 +3486,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 +3512,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"); @@ -10279,3 +10393,154 @@ fn simple_rule_scan_drawn(content: &[u8]) -> Vec { } 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) { + 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 = (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); + } +} diff --git a/crates/edit/Cargo.toml b/crates/edit/Cargo.toml index 89d9387989..2cd33a275d 100644 --- a/crates/edit/Cargo.toml +++ b/crates/edit/Cargo.toml @@ -24,3 +24,5 @@ unicode-bidi = "0.3" [dev-dependencies] hayro-syntax = { workspace = true } +pdfcraft-render = { workspace = true } +proptest = { workspace = true } diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index f66e7f24ee..2920a2a9ad 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -4,13 +4,28 @@ //! or delete. Only the stream that draws it is rewritten, as a new object. use kurbo::{BezPath, Point, Shape}; -use pdfcraft_content::{Matrix, Op, Pieces, parse}; +use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops}; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use std::sync::Arc; use crate::EditError; use crate::text::content_streams; +/// The identity of a soft mask (`/SMask`) in the PDF graphics state. +#[derive(Clone, Debug)] +pub(crate) struct MaskIdentity { + // Share the owning ExtGState instead of copying an arbitrarily large inline mask on q/Q. + // Its complete SMask value retains S, TR, BC and every other mask parameter, not just G. + definition: Arc, + ctm: Matrix, +} + +impl PartialEq for MaskIdentity { + fn eq(&self, other: &Self) -> bool { + self.ctm == other.ctm && self.definition.as_dict().and_then(|d| d.get(b"SMask")) == other.definition.as_dict().and_then(|d| d.get(b"SMask")) + } +} + /// An image drawn on a page. #[derive(Clone, Debug, PartialEq)] pub struct PageImage { @@ -33,7 +48,7 @@ pub struct PageImage { pub height: u32, /// The content stream its `Do` (its first token) is in, and the `Do`'s index among the /// operators of the page's streams joined ([`Pieces`]). - stream: usize, + pub stream: usize, op: usize, /// CTM before `Do`, excluding a Form's own Matrix. draw_matrix: [f64; 6], @@ -41,6 +56,11 @@ pub struct PageImage { } impl PageImage { + /// Whether an active clip was written by PdfCraft's image crop tool. + pub fn has_editor_crop(&self) -> bool { + self.clips.iter().any(|clip| clip.app_crop) + } + /// Whether a user-space point belongs to the placed artwork and every active clip. pub fn contains(&self, point: [f64; 2]) -> bool { if !point.iter().all(|v| v.is_finite()) { @@ -72,6 +92,7 @@ impl PageImage { struct ClipPath { path: Arc, even_odd: bool, + app_crop: bool, } /// Image draws one page's inventory lists at most: a form drawn many times from other forms @@ -115,17 +136,39 @@ struct ImageState { placement: Placement, stack: Vec, path: BezPath, + subpath_start: Option, pending_clip: Option, + app_crop: bool, } impl ImageState { fn new(matrix: Matrix, clip: [f64; 4], clips: Vec) -> Self { - Self { placement: Placement { matrix, clip, clips }, stack: Vec::new(), path: BezPath::new(), pending_clip: None } + Self { + placement: Placement { matrix, clip, clips }, + stack: Vec::new(), + path: BezPath::new(), + subpath_start: None, + pending_clip: None, + app_crop: false, + } } - fn point(&self, x: f64, y: f64) -> Point { + fn current_point(&self) -> Option { + match self.path.elements().last() { + Some(kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p)) => Some(*p), + Some(kurbo::PathEl::QuadTo(_, p2) | kurbo::PathEl::CurveTo(_, _, p2)) => Some(*p2), + Some(kurbo::PathEl::ClosePath) => self.subpath_start, + None => None, + } + } + + fn has_current_point(&self) -> bool { + self.current_point().is_some() + } + + fn point(&self, x: f64, y: f64) -> Option { let (x, y) = self.placement.matrix.apply(x, y); - Point::new(x, y) + if x.is_finite() && y.is_finite() { Some(Point::new(x, y)) } else { None } } fn apply(&mut self, op: &Op) -> Result<(), EditError> { @@ -146,12 +189,18 @@ impl ImageState { } b"cm" => { if let Some(m) = op.nums::<6>() { - self.placement.matrix = Matrix(m).then(&self.placement.matrix); + let next_mat = Matrix(m).then(&self.placement.matrix); + if next_mat.0.iter().all(|v| v.is_finite()) { + self.placement.matrix = next_mat; + } } } b"m" => { - if let Some([x, y]) = op.nums::<2>() { - self.path.move_to(self.point(x, y)); + if let Some([x, y]) = op.nums::<2>() + && let Some(p) = self.point(x, y) + { + self.subpath_start = Some(p); + self.path.move_to(p); } } b"l" => { @@ -159,46 +208,57 @@ impl ImageState { // friends panic on an empty path by contract, so the segment is skipped, as the // renderer, MuPDF and pdftotext do (#3581). if let Some([x, y]) = op.nums::<2>() - && self.path.elements().last().is_some() + && self.has_current_point() + && let Some(p) = self.point(x, y) { - self.path.line_to(self.point(x, y)); + self.path.line_to(p); } } b"c" => { if let Some([x1, y1, x2, y2, x3, y3]) = op.nums::<6>() - && self.path.elements().last().is_some() + && self.has_current_point() + && let (Some(p1), Some(p2), Some(p3)) = (self.point(x1, y1), self.point(x2, y2), self.point(x3, y3)) { - self.path.curve_to(self.point(x1, y1), self.point(x2, y2), self.point(x3, y3)); + self.path.curve_to(p1, p2, p3); } } b"v" => { if let Some([x1, y1, x2, y2]) = op.nums::<4>() - && let Some(current) = self.path.elements().last().and_then(|e| e.end_point()) + && self.has_current_point() + && let (Some(p1), Some(p2)) = (self.point(x1, y1), self.point(x2, y2)) + && let Some(current) = self.current_point() { - self.path.curve_to(current, self.point(x1, y1), self.point(x2, y2)); + self.path.curve_to(current, p1, p2); } } b"y" => { if let Some([x1, y1, x2, y2]) = op.nums::<4>() - && self.path.elements().last().is_some() + && self.has_current_point() + && let (Some(p1), Some(end)) = (self.point(x1, y1), self.point(x2, y2)) { - let end = self.point(x2, y2); - self.path.curve_to(self.point(x1, y1), end, end); + self.path.curve_to(p1, end, end); } } - // ClosePath with no subpath panics by the same contract (#3581). - b"h" if self.path.elements().last().is_some() => { - self.path.close_path(); - } - b"re" => { - if let Some([x, y, w, h]) = op.nums::<4>() { - self.path.move_to(self.point(x, y)); - self.path.line_to(self.point(x + w, y)); - self.path.line_to(self.point(x + w, y + h)); - self.path.line_to(self.point(x, y + h)); + b"h" => { + // ClosePath with no subpath panics by the same contract (#3581). + if self.has_current_point() { self.path.close_path(); } } + b"re" => { + if let Some([x, y, w, h]) = op.nums::<4>() + && let (Some(p0), Some(p1), Some(p2), Some(p3)) = + (self.point(x, y), self.point(x + w, y), self.point(x + w, y + h), self.point(x, y + h)) + { + self.subpath_start = Some(p0); + self.path.move_to(p0); + self.path.line_to(p1); + self.path.line_to(p2); + self.path.line_to(p3); + self.path.close_path(); + } + } + b"MP" if op.name(0) == Some(b"PdfCraftImageCrop") => self.app_crop = true, b"W" => self.pending_clip = Some(false), b"W*" => self.pending_clip = Some(true), b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => { @@ -207,9 +267,11 @@ impl ImageState { // follows the same rule, and its bounds use cubic extrema, not controls. let bounds = self.path.bounding_box(); self.placement.clip = intersection(self.placement.clip, [bounds.x0, bounds.y0, bounds.x1, bounds.y1]).unwrap_or([0.0; 4]); - self.placement.clips.push(ClipPath { path: Arc::new(std::mem::take(&mut self.path)), even_odd }); + self.placement.clips.push(ClipPath { path: Arc::new(std::mem::take(&mut self.path)), even_odd, app_crop: self.app_crop }); } self.path = BezPath::new(); + self.subpath_start = None; + self.app_crop = false; } _ => {} } @@ -264,6 +326,7 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr } else { continue; }; + let rect = matrix.bbox(bounds); if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite()) || bounds[2] <= bounds[0] @@ -382,6 +445,15 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi Ok(out) } +/// The four stacking order arrangements. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ArrangeOrder { + BringToFront, + SendToBack, + BringForward, + SendBackward, +} + /// What to do with an image. #[derive(Clone, Debug, PartialEq)] pub enum ImageChange { @@ -394,6 +466,10 @@ pub enum ImageChange { /// Draw this image XObject instead, in the same place. Replace(ObjRef), Delete, + /// Change the stacking order. + Arrange(ArrangeOrder), + /// Remove any private crop on the image. + RemoveCrop, } /// The user-space transform that maps box `from` onto box `to` (move and resize). @@ -473,6 +549,1077 @@ fn clip_matrix(draw: Matrix, cm: Matrix, t: Matrix) -> Option { Some(outer.then(&t).then(&outer.invert()?)) } +pub(crate) fn is_entire_page_wrapped(ops: &[Op]) -> bool { + if ops.len() < 2 || !ops.first().is_some_and(|o| o.is("q")) || !ops.last().is_some_and(|o| o.is("Q")) { + return false; + } + let mut depth: usize = 0; + for (i, op) in ops.iter().enumerate() { + if op.is("q") { + depth = depth.saturating_add(1); + } else if op.is("Q") { + if depth == 1 && i < ops.len().saturating_sub(1) { + return false; + } + depth = depth.saturating_sub(1); + } + } + depth == 0 +} + +/// The end (exclusive) of the sequence opened at `i`: just past the `close` that matches it, +/// counting the nested `opens`, or `end` when it never closes. +fn sequence_end(ops: &[Op], i: usize, end: usize, opens: &[&str], close: &str) -> usize { + let mut depth: usize = 1; + for (j, op) in ops.iter().enumerate().take(end).skip(i.saturating_add(1)) { + if opens.iter().any(|o| op.is(o)) { + depth = depth.saturating_add(1); + } else if op.is(close) { + depth = depth.saturating_sub(1); + if depth == 0 { + return j.saturating_add(1); + } + } + } + end +} + +pub(crate) fn content_units(ops: &[Op], scan_start: usize, scan_end: usize) -> Vec> { + let mut units = Vec::new(); + let mut i = scan_start; + while i < scan_end { + let Some(op) = ops.get(i) else { break }; + let j = if op.is("BDC") || op.is("BMC") { + // A marked-content sequence is one unit up to its matching EMC (#3145), so Arrange moves + // it whole and never inserts into another one: its content belongs to its tag (ISO 32000-2 + // § 14.6). An unbalanced sequence runs to the end of the scan. + sequence_end(ops, i, scan_end, &["BDC", "BMC"], "EMC") + } else if op.is("q") { + sequence_end(ops, i, scan_end, &["q"], "Q") + } else if op.is("BT") { + sequence_end(ops, i, scan_end, &[], "ET") + } else if op.is("BI") { + sequence_end(ops, i, scan_end, &[], "EI") + } else if op.is("Do") || op.is("sh") { + i.saturating_add(1) + } else { + let mut j = i; + let mut found_end = false; + while j < scan_end { + let Some(next) = ops.get(j) else { break }; + if matches!(next.op.as_slice(), b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { + j = j.saturating_add(1); + found_end = true; + break; + } else if j > i + && matches!( + next.op.as_slice(), + b"q" | b"Q" | b"BT" | b"BI" | b"Do" | b"sh" | b"BDC" | b"BMC" | b"EMC" | b"Tj" | b"TJ" | b"'" | b"\"" + ) + { + found_end = true; + break; + } + j = j.saturating_add(1); + } + if found_end { j } else { scan_end.max(i.saturating_add(1)) } + }; + units.push(i..j); + i = j; + } + units.retain(|u| { + ops.get(u.clone()).is_some_and(|slice| { + slice.iter().any(|op| { + matches!( + op.op.as_slice(), + b"Do" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"Tj" | b"TJ" | b"'" | b"\"" + ) + }) + }) + }); + units +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ExtractedScopeState { + pub clip_ops: Vec, + pub state_ops: Vec, + pub do_op: Op, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct EffectiveGState { + pub(crate) ca: Option, + pub(crate) ca_stroke: Option, + pub(crate) bm: Option>, + pub(crate) smask: Option, + // Printing parameters cannot generally be reset by a content-stream operator. Retain their + // exact values so Arrange refuses a destination whose state it cannot reconstruct. + print_params: std::collections::BTreeMap, std::rc::Rc>, + rendering_intent: Option>, +} + +impl EffectiveGState { + pub(crate) fn visually_differs(&self, other: &EffectiveGState) -> bool { + (self.ca.unwrap_or(1.0) - other.ca.unwrap_or(1.0)).abs() > 1e-4 + || (self.ca_stroke.unwrap_or(1.0) - other.ca_stroke.unwrap_or(1.0)).abs() > 1e-4 + || self.bm.as_deref().unwrap_or(b"Normal") != other.bm.as_deref().unwrap_or(b"Normal") + || self.smask != other.smask + || self.print_params != other.print_params + } + + #[cfg(test)] + pub(crate) fn merge_dict(&mut self, dict: &Dict, doc: &Document, ctm: Matrix) { + self.merge_shared(&Arc::new(Object::Dict(dict.clone())), doc, ctm); + } + + fn merge_shared(&mut self, definition: &Arc, doc: &Document, ctm: Matrix) { + let Some(dict) = definition.as_dict() else { return }; + for key in [b"TR".as_slice(), b"TR2", b"OP", b"op", b"OPM", b"BG", b"BG2", b"UCR", b"UCR2", b"HT"] { + if let Some(value) = dict.get(key) { + self.print_params.insert(key.to_vec(), std::rc::Rc::new(value.clone())); + } + } + if let Some(intent) = dict.get(b"RI").map(|v| doc.resolve(v)).and_then(|v| v.as_name().map(<[u8]>::to_vec)) { + self.rendering_intent = Some(intent); + } + if let Some(val) = dict.get(b"ca").map(|v| doc.resolve(v)).and_then(|v| v.as_f64()) { + self.ca = Some(val); + } + if let Some(val) = dict.get(b"CA").map(|v| doc.resolve(v)).and_then(|v| v.as_f64()) { + self.ca_stroke = Some(val); + } + if let Some(bm_obj) = dict.get(b"BM").map(|v| doc.resolve(v)) { + if let Some(name) = bm_obj.as_name() { + self.bm = Some(name.to_vec()); + } else if let Some(arr) = bm_obj.as_array() + && let Some(first_name) = arr.first().map(|v| doc.resolve(v)).and_then(|v| v.as_name().map(|n| n.to_vec())) + { + self.bm = Some(first_name); + } + } + if let Some(raw_smask) = dict.get(b"SMask") { + let disabled = raw_smask.as_name() == Some(b"None") || raw_smask.as_ref().is_some_and(|r| doc.get(r).as_name() == Some(b"None")); + self.smask = (!disabled).then(|| MaskIdentity { definition: Arc::clone(definition), ctm }); + } + } +} + +const MAX_ACTIVE_GS_OPS: usize = 128; + +type ActiveGsOp = (Vec, Op, Matrix); +type ActiveGsOps = std::rc::Rc>; + +#[derive(Clone, Default)] +struct ExtendedGStateTracker { + stack: Vec<(EffectiveGState, ActiveGsOps)>, + current_state: EffectiveGState, + active_gs_ops: ActiveGsOps, + definitions: std::collections::HashMap, Arc>, +} + +impl ExtendedGStateTracker { + fn step(&mut self, op: &Op, ext_gstates: &Dict, doc: &Document, ctm: Matrix) -> Result<(), EditError> { + match op.op.as_slice() { + b"ri" => self.current_state.rendering_intent = op.name(0).map(<[u8]>::to_vec), + b"q" => { + if self.stack.len() >= 4096 { + return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); + } + self.stack.push((self.current_state.clone(), std::rc::Rc::clone(&self.active_gs_ops))); + } + b"Q" => { + if let Some((prev_state, prev_ops)) = self.stack.pop() { + self.current_state = prev_state; + self.active_gs_ops = prev_ops; + } + } + b"gs" => { + let valid_gs = op.name(0).and_then(|name| { + let definition = self.definitions.get(name).cloned().or_else(|| { + let definition = doc.resolve(ext_gstates.get(name)?); + definition.as_dict()?; + self.definitions.insert(name.to_vec(), Arc::clone(&definition)); + Some(definition) + })?; + Some((name, definition)) + }); + if let Some((name, definition)) = valid_gs { + self.current_state.merge_shared(&definition, doc, ctm); + let mut next = (*self.active_gs_ops).clone(); + if let Some(pos) = next.iter().position(|(k, _, _)| k.as_slice() == name) { + next.remove(pos); + } + if next.len() < MAX_ACTIVE_GS_OPS { + next.push((name.to_vec(), op.clone(), ctm)); + } + self.active_gs_ops = std::rc::Rc::new(next); + } + } + _ => {} + } + Ok(()) + } +} + +/// The drawing-state operators besides colour that a moved unit restores, in the order it writes them. +const DRAWING_PARAMS: [&str; 14] = ["w", "J", "j", "M", "d", "ri", "i", "Tc", "Tw", "Tz", "TL", "Tr", "Ts", "Tf"]; + +#[derive(Clone, Default, Debug, PartialEq)] +struct DrawingState { + non_stroking_color: Option, + stroking_color: Option, + /// The last operator of each of [`DRAWING_PARAMS`]. + params: [Option>; DRAWING_PARAMS.len()], +} + +// Save/restore shares operand storage, including when only the current colour changes under q. +type SharedColorOps = std::rc::Rc>>; + +/// Keep the explicit or implicit colour-space selector before `sc`/`scn` (or `SC`/`SCN`). +fn set_color(slot: &mut Option, op: &Op, space: &str) { + match slot { + Some(ops) if ops.first().is_some_and(|o| o.is(space) || matches!(o.op.as_slice(), b"g" | b"rg" | b"k" | b"G" | b"RG" | b"K")) => { + let ops = std::rc::Rc::make_mut(ops); + ops.truncate(1); + ops.push(std::rc::Rc::new(op.clone())); + } + _ => *slot = Some(std::rc::Rc::new(vec![std::rc::Rc::new(op.clone())])), + } +} + +#[derive(Clone, Default, Debug)] +struct DrawingStateTracker { + stack: Vec, + current: DrawingState, +} + +impl DrawingStateTracker { + fn step(&mut self, op: &Op) -> Result<(), EditError> { + match op.op.as_slice() { + b"q" => { + if self.stack.len() >= 4096 { + return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); + } + self.stack.push(self.current.clone()); + } + b"Q" => { + if let Some(prev) = self.stack.pop() { + self.current = prev; + } + } + b"g" | b"rg" | b"k" | b"cs" => self.current.non_stroking_color = Some(std::rc::Rc::new(vec![std::rc::Rc::new(op.clone())])), + b"sc" | b"scn" => set_color(&mut self.current.non_stroking_color, op, "cs"), + b"G" | b"RG" | b"K" | b"CS" => self.current.stroking_color = Some(std::rc::Rc::new(vec![std::rc::Rc::new(op.clone())])), + b"SC" | b"SCN" => set_color(&mut self.current.stroking_color, op, "CS"), + _ => { + if let Some(slot) = DRAWING_PARAMS.iter().position(|p| op.is(p)).and_then(|k| self.current.params.get_mut(k)) { + *slot = Some(std::rc::Rc::new(op.clone())); + } + } + } + Ok(()) + } +} + +/// Initial line style, rendering intent and text-state parameters. +fn initial_param(name: &str) -> Option { + use pdfcraft_content::num; + let operands = match name { + "w" => vec![num(1.0)], + "J" | "j" => vec![num(0.0)], + "M" => vec![num(10.0)], + "d" => vec![Object::Array(vec![]), num(0.0)], + "ri" => vec![Object::name("RelativeColorimetric")], + "Tc" | "Tw" | "TL" | "Tr" | "Ts" => vec![num(0.0)], + "Tz" => vec![num(100.0)], + _ => return None, + }; + Some(Op::new(name, operands)) +} + +/// The drawing state the image had at `source` that differs at `dest`, unless the moved unit's own +/// state operators (`extracted_state_ops`) set it. +fn compute_inherited_drawing_ops(source: &DrawingState, dest: &DrawingState, extracted_state_ops: &[Op]) -> Vec { + let sets = |names: &[&str]| extracted_state_ops.iter().any(|o| names.iter().any(|n| o.is(n))); + let mut ops = Vec::new(); + for (own, source, dest, black) in [ + (sets(&["g", "rg", "k", "cs"]), &source.non_stroking_color, &dest.non_stroking_color, "g"), + (sets(&["G", "RG", "K", "CS"]), &source.stroking_color, &dest.stroking_color, "G"), + ] { + match (own, source, dest) { + (true, _, _) | (false, None, None) => {} + (false, Some(color), _) => ops.extend(color.iter().map(|op| (**op).clone())), + (false, None, Some(_)) => ops.push(Op::new(black, vec![pdfcraft_content::num(0.0)])), + } + } + let mut text_ops = Vec::new(); + for ((name, source), dest) in DRAWING_PARAMS.iter().zip(&source.params).zip(&dest.params) { + let output = if matches!(*name, "Tc" | "Tw" | "Tz" | "TL" | "Tr" | "Ts" | "Tf") { &mut text_ops } else { &mut ops }; + match (sets(&[name]), source, dest) { + (true, _, _) | (false, None, None) => {} + (false, Some(op), _) => output.push((**op).clone()), + (false, None, Some(_)) => output.extend(initial_param(name)), + } + } + if !text_ops.is_empty() { + // Forms inherit text state, even though each BT resets its text matrices. + ops.push(Op::new("BT", vec![])); + ops.extend(text_ops); + ops.push(Op::new("ET", vec![])); + } + ops +} + +/// The operators that set `clip` again, in user space. [`ImageState`] builds clips only from +/// finite points with `m`, `l`, `c` and `h`, so `None` (a quadratic segment) does not happen. +fn clip_path_to_ops(clip: &ClipPath) -> Option> { + let mut ops = Vec::new(); + if clip.app_crop { + ops.push(crop_marker()); + } + for el in clip.path.elements() { + let (name, points) = match el { + kurbo::PathEl::MoveTo(p) => ("m", vec![*p]), + kurbo::PathEl::LineTo(p) => ("l", vec![*p]), + kurbo::PathEl::CurveTo(p1, p2, p3) => ("c", vec![*p1, *p2, *p3]), + kurbo::PathEl::ClosePath => ("h", Vec::new()), + kurbo::PathEl::QuadTo(..) => return None, + }; + ops.push(Op::new(name, points.iter().flat_map(|p| [pdfcraft_content::num(p.x), pdfcraft_content::num(p.y)]).collect())); + } + if !ops.is_empty() { + ops.push(Op::new(if clip.even_odd { "W*" } else { "W" }, vec![])); + ops.push(Op::new("n", vec![])); + } + Some(ops) +} + +pub(crate) fn extract_scope_state(ops: &[Op], start: usize, target: usize, initial_ctm: Matrix) -> Result, EditError> { + if target >= ops.len() || start > target { + return Ok(None); + } + let target_op = ops.get(target).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + if start == target { + return Ok(Some(ExtractedScopeState { clip_ops: Vec::new(), state_ops: Vec::new(), do_op: target_op })); + } + let mut clip_ops = Vec::new(); + let mut state_ops = Vec::new(); + let mut pending_path: Vec = Vec::new(); + let mut pending_clip = false; + let mut active_path = false; + let mut depth: usize = 0; + let mut matrix = initial_ctm; + let mut matrix_at_clip = None; + + let scan_start = if ops.get(start).is_some_and(|o| o.is("q")) { start.saturating_add(1) } else { start }; + + for i in scan_start..target { + let Some(op) = ops.get(i) else { + return Ok(None); + }; + match op.op.as_slice() { + b"q" => { + if depth >= 4096 { + return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); + } + depth = depth.saturating_add(1); + } + b"Q" => { + if depth == 0 { + return Ok(None); + } + depth = depth.saturating_sub(1); + } + _ if depth > 0 => {} + b"cm" => { + if active_path { + return Ok(None); + } + if let Some(m) = op.nums::<6>() { + matrix = Matrix(m).then(&matrix); + } + } + b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" => { + active_path = true; + pending_path.push(op.clone()); + } + b"W" | b"W*" => { + if !active_path { + return Ok(None); + } + pending_clip = true; + pending_path.push(op.clone()); + } + b"n" => { + if pending_clip { + if matrix_at_clip.is_none() { + matrix_at_clip = Some(matrix); + } else if matrix_at_clip != Some(matrix) { + return Ok(None); + } + clip_ops.extend(std::mem::take(&mut pending_path)); + clip_ops.push(op.clone()); + pending_clip = false; + active_path = false; + } else { + pending_path.clear(); + active_path = false; + } + } + b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => { + if pending_clip { + return Ok(None); + } + pending_path.clear(); + active_path = false; + } + b"gs" | b"CS" | b"cs" | b"SC" | b"sc" | b"SCN" | b"scn" | b"G" | b"g" | b"RG" | b"rg" | b"K" | b"k" | b"ri" | b"i" => { + state_ops.push(op.clone()); + } + b"BT" => { + return Ok(None); + } + _ => {} + } + } + + if depth != 0 || active_path || pending_clip { + return Ok(None); + } + + if !clip_ops.is_empty() && matrix_at_clip != Some(Matrix::IDENTITY) { + return Ok(None); + } + + Ok(Some(ExtractedScopeState { clip_ops, state_ops, do_op: target_op })) +} + +/// The unit `order` puts the image's unit `target_idx` next to, and whether after it. An image that +/// shares its unit with other paint at the limit moves within that unit, past or behind the paint. +fn target_destination_unit<'a>( + units: &'a [std::ops::Range], + target_idx: usize, + target_unit: &'a std::ops::Range, + order: ArrangeOrder, + has_other_paint: bool, +) -> Result<(&'a std::ops::Range, bool), EditError> { + let forward = matches!(order, ArrangeOrder::BringToFront | ArrangeOrder::BringForward); + let refused = || EditError::Invalid(if forward { "the image is already in front" } else { "the image is already at the back" }.into()); + let at_limit = if forward { target_idx.saturating_add(1) >= units.len() } else { target_idx == 0 }; + if at_limit { + return if has_other_paint { Ok((target_unit, forward)) } else { Err(refused()) }; + } + let dest = match order { + ArrangeOrder::BringToFront => units.last(), + ArrangeOrder::SendToBack => units.first(), + ArrangeOrder::BringForward => units.get(target_idx.saturating_add(1)), + ArrangeOrder::SendBackward => target_idx.checked_sub(1).and_then(|i| units.get(i)), + }; + dest.map(|u| (u, forward)).ok_or_else(refused) +} + +/// A moved unit must not borrow a graphics or marked-content boundary from its neighbours. +fn balanced_arrange_span(ops: &[Op], range: std::ops::Range) -> bool { + let Some(slice) = ops.get(range) else { return false }; + let (mut graphics, mut marked) = (0usize, 0usize); + for op in slice { + let depth = match op.op.as_slice() { + b"q" => { + graphics = graphics.saturating_add(1); + continue; + } + b"BDC" | b"BMC" => { + marked = marked.saturating_add(1); + continue; + } + b"Q" => &mut graphics, + b"EMC" => &mut marked, + _ => continue, + }; + let Some(next) = depth.checked_sub(1) else { return false }; + *depth = next; + } + graphics == 0 && marked == 0 +} + +/// What Arrange takes out: the byte span, the op where the image's own scope state starts, and the +/// ops `start..end` removed. That is the image's own `q … Q` scope inside its unit, else the `Do` +/// alone when the unit paints something else too, else the whole unit. +fn determine_arrange_spans( + ops: &[Op], + target: usize, + target_unit: &std::ops::Range, + has_other_paint: bool, + marked_unit: bool, +) -> Result<(std::ops::Range, usize, usize, usize), EditError> { + let scope = image_scopes(ops, target, "arranged") + .ok() + .and_then(|s| s.isolated) + .filter(|&(start, end, _)| start >= target_unit.start && end < target_unit.end); + let unit_last = target_unit.end.saturating_sub(1); + let (first, scope_start, last) = match scope { + Some((start, end, _)) if has_other_paint || !marked_unit => (start, start, end), + _ if has_other_paint => (target, target, target), + // A marked-content unit holding only this image moves whole, with its BDC…EMC. + // The whole sequence is removed; the image's state is rebuilt from the ops inside it. + _ if marked_unit => (target_unit.start, target_unit.start.saturating_add(1).min(target), unit_last), + _ => (target_unit.start, target_unit.start, unit_last), + }; + if !balanced_arrange_span(ops, first..last.saturating_add(1)) { + return Err(EditError::Invalid("cannot arrange an image across graphics or marked-content boundaries".into())); + } + let missing = || EditError::Invalid("the image content unit could not be located".into()); + let start = ops.get(first).ok_or_else(missing)?.span.start; + let end = ops.get(last).ok_or_else(missing)?.span.end; + Ok((start..end, scope_start, first, last.saturating_add(1))) +} + +fn scan_leaking_ops(ops: &[Op], rem_start: usize, rem_end: usize) -> (Vec, bool) { + let mut q_depth = 0usize; + let mut leaking_ops = Vec::new(); + let mut pending_clip_path: Vec = Vec::new(); + let mut pending_clip = false; + let mut has_leaking_clip = false; + for op in ops.get(rem_start..rem_end).unwrap_or_default() { + match op.op.as_slice() { + b"q" => q_depth = q_depth.saturating_add(1), + b"Q" => q_depth = q_depth.saturating_sub(1), + b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" if q_depth == 0 => { + pending_clip_path.push(op.clone()); + } + b"W" | b"W*" if q_depth == 0 => { + if !pending_clip_path.is_empty() { + pending_clip = true; + pending_clip_path.push(op.clone()); + } + } + b"n" if q_depth == 0 => { + if pending_clip { + pending_clip_path.push(op.clone()); + leaking_ops.extend(std::mem::take(&mut pending_clip_path)); + has_leaking_clip = true; + pending_clip = false; + } else { + pending_clip_path.clear(); + } + } + b"rg" | b"RG" | b"g" | b"G" | b"k" | b"K" | b"cs" | b"CS" | b"sc" | b"scn" | b"SC" | b"SCN" | b"w" | b"J" | b"j" | b"M" | b"d" + | b"ri" | b"i" | b"gs" | b"Tf" | b"Tc" | b"Tw" | b"Tz" | b"TL" | b"Tr" | b"Ts" + if q_depth == 0 => + { + leaking_ops.push(op.clone()); + } + b"cm" if q_depth == 0 => { + leaking_ops.push(op.clone()); + } + _ => { + if q_depth == 0 && !op.is("BDC") && !op.is("BMC") && !op.is("EMC") && !pending_clip { + pending_clip_path.clear(); + } + } + } + } + (leaking_ops, has_leaking_clip) +} + +fn build_relocated_unit_ops( + marked_open: Option<&Op>, + source_gs_ops: &[(Op, Matrix)], + inherited_drawing_ops: &[Op], + extracted: ExtractedScopeState, + dest_ctm: Matrix, + draw_matrix: [f64; 6], + has_closing_emc: bool, +) -> Result, EditError> { + let mut unit_ops = Vec::new(); + if let Some(open) = marked_open { + unit_ops.push(open.clone()); + } + unit_ops.push(Op::new("q", vec![])); + let mut current_ctm = dest_ctm; + + for (gs_op, target_ctm) in source_gs_ops { + if *target_ctm != current_ctm { + let current_inv = current_ctm.invert().ok_or_else(|| EditError::Invalid("unsupported graphics state: non-invertible CTM".into()))?; + let delta = target_ctm.then(¤t_inv); + if !delta.0.iter().all(|v| v.is_finite()) { + return Err(EditError::Invalid("unsupported graphics state: invalid transform coordinates".into())); + } + if delta != Matrix::IDENTITY { + unit_ops.push(Op::new("cm", delta.0.map(pdfcraft_content::num).to_vec())); + current_ctm = *target_ctm; + } + } + unit_ops.push(gs_op.clone()); + } + + unit_ops.extend(inherited_drawing_ops.iter().cloned()); + + let non_gs_ops = extracted.state_ops.into_iter().filter(|o| !o.is("gs")); + unit_ops.extend(non_gs_ops); + + if !extracted.clip_ops.is_empty() { + if current_ctm != Matrix::IDENTITY { + let current_inv = current_ctm.invert().ok_or_else(|| EditError::Invalid("unsupported graphics state: non-invertible CTM".into()))?; + unit_ops.push(Op::new("cm", current_inv.0.map(pdfcraft_content::num).to_vec())); + } + unit_ops.extend(extracted.clip_ops); + if Matrix(draw_matrix) != Matrix::IDENTITY { + unit_ops.push(Op::new("cm", draw_matrix.map(pdfcraft_content::num).to_vec())); + } + } else if Matrix(draw_matrix) != current_ctm { + let current_inv = current_ctm.invert().ok_or_else(|| EditError::Invalid("unsupported graphics state: non-invertible CTM".into()))?; + let delta = Matrix(draw_matrix).then(¤t_inv); + if !delta.0.iter().all(|v| v.is_finite()) { + return Err(EditError::Invalid("unsupported graphics state: invalid transform coordinates".into())); + } + if delta != Matrix::IDENTITY { + unit_ops.push(Op::new("cm", delta.0.map(pdfcraft_content::num).to_vec())); + } + } + unit_ops.push(extracted.do_op); + unit_ops.push(Op::new("Q", vec![])); + if has_closing_emc { + unit_ops.push(Op::new("EMC", vec![])); + } + Ok(unit_ops) +} + +/// Change the stacking order of `img`, drawn on page `p` by its content streams `all`. +fn arrange_image( + doc: &mut Document, + p: &pdfcraft_model::Page, + all: &[(Object, Vec)], + img: &PageImage, + order: ArrangeOrder, +) -> Result<(), EditError> { + let pieces = joined(all); + let ops = pieces.parse().ops; + let target = img.op; + let target_op = ops.get(target).filter(|op| op.is("Do")).ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?; + + let is_wrapped = is_entire_page_wrapped(&ops); + let (scan_start, scan_end) = if is_wrapped { (1, ops.len().saturating_sub(1)) } else { (0, ops.len()) }; + let units = content_units(&ops, scan_start, scan_end); + let Some((target_idx, target_unit)) = units.iter().enumerate().find(|(_, u)| u.contains(&target)) else { + return Err(EditError::Invalid("the image content unit could not be located".into())); + }; + let has_other_paint = ops.iter().enumerate().any(|(i, op)| { + i >= target_unit.start + && i < target_unit.end + && i != target + && matches!( + op.op.as_slice(), + b"Do" | b"Tj" | b"TJ" | b"'" | b"\"" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" + ) + }); + if units.len() <= 1 && !has_other_paint { + return Err(EditError::Invalid("the page has only one content unit to arrange".into())); + } + + let (dest_unit, insert_after) = target_destination_unit(&units, target_idx, target_unit, order, has_other_paint)?; + if !balanced_arrange_span(&ops, target_unit.clone()) || !balanced_arrange_span(&ops, dest_unit.clone()) { + return Err(EditError::Invalid("cannot arrange an image across graphics or marked-content boundaries".into())); + } + + // A marked-content sequence that holds other content cannot be split without generating + // a new structure element and MCID, so we refuse to rearrange it to avoid leaving the image untagged. + // If the sequence holds only this image, the whole marked unit is moved intact. + let marked_unit = ops.get(target_unit.start).is_some_and(|o| o.is("BDC") || o.is("BMC")); + let marked_depth = ops.iter().take(target).fold(0usize, |depth, op| { + if op.is("BDC") || op.is("BMC") { + depth.saturating_add(1) + } else if op.is("EMC") { + depth.saturating_sub(1) + } else { + depth + } + }); + if marked_depth > usize::from(marked_unit) { + return Err(EditError::Invalid("cannot rearrange an image out of a nested marked-content sequence".into())); + } + if marked_unit && has_other_paint { + return Err(EditError::Invalid("cannot rearrange an image that shares a marked-content sequence with other content".into())); + } + + let (mut target_span, scope_start, rem_start, rem_end) = determine_arrange_spans(&ops, target, target_unit, has_other_paint, marked_unit)?; + + let missing = || EditError::Invalid("the destination content unit could not be located".into()); + let (dest_pos, scan_target) = if insert_after { + (dest_unit.end.checked_sub(1).and_then(|i| ops.get(i)).ok_or_else(missing)?.span.end, dest_unit.end) + } else { + (ops.get(dest_unit.start).ok_or_else(missing)?.span.start, dest_unit.start) + }; + + // An added item owns its entire stream: editing or deleting it replaces that stream. + // Insert ordinary artwork next to the complete owned unit in a separate stream instead. + let destination_piece = if insert_after { + dest_unit.end.checked_sub(1).and_then(|i| ops.get(i)).map(|op| pieces.piece_of(op.span.end.saturating_sub(1))) + } else { + ops.get(dest_unit.start).map(|op| pieces.piece_of(op.span.start)) + }; + let owned_destination = destination_piece + .filter(|piece| all.get(*piece).is_some_and(|(obj, _)| doc.resolve(obj).as_dict().is_some_and(|d| d.get(b"PCAdded").is_some()))); + if let Some(piece) = owned_destination { + let whole_unit = ops.iter().enumerate().all(|(index, op)| { + let (first, last) = pieces.pieces_of(op); + if dest_unit.contains(&index) { first == piece && last == piece } else { piece < first || piece > last } + }); + if !whole_unit || img.stream == piece { + return Err(EditError::Invalid("cannot arrange an image inside an added item's content stream".into())); + } + } + + let (leaking_ops, has_leaking_clip) = scan_leaking_ops(&ops, rem_start, rem_end); + + let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let ext_gstates = res.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); + + let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); + let mut tracker = ExtendedGStateTracker::default(); + let mut drawing_tracker = DrawingStateTracker::default(); + let mut source_effective_state = EffectiveGState::default(); + let mut source_gs_ops = Vec::new(); + let mut source_drawing_state = DrawingState::default(); + let mut dest_effective_state = EffectiveGState::default(); + let mut dest_drawing_state = DrawingState::default(); + let mut dest_ctm = Matrix::IDENTITY; + let mut scope_start_ctm = Matrix::IDENTITY; + let mut outer_clips = Vec::new(); + let mut dest_clips = Vec::new(); + let mut ctm_before_removal = Matrix::IDENTITY; + let mut ctm_after_removal = Matrix::IDENTITY; + + let max_scan = scan_target.max(target.saturating_add(1)).max(scope_start.saturating_add(1)).max(rem_end); + for (i, op) in ops.iter().enumerate() { + if i == rem_start { + ctm_before_removal = state.placement.matrix; + } + if i == scope_start { + scope_start_ctm = state.placement.matrix; + outer_clips = state.placement.clips.clone(); + } + if i == target { + source_effective_state = tracker.current_state.clone(); + source_gs_ops = tracker.active_gs_ops.iter().map(|(_, op, ctm)| (op.clone(), *ctm)).collect(); + source_drawing_state = drawing_tracker.current.clone(); + } + if i == scan_target { + dest_effective_state = tracker.current_state.clone(); + dest_drawing_state = drawing_tracker.current.clone(); + dest_ctm = state.placement.matrix; + dest_clips = state.placement.clips.clone(); + } + if i >= max_scan { + break; + } + state.apply(op)?; + tracker.step(op, &ext_gstates, doc, state.placement.matrix)?; + drawing_tracker.step(op)?; + if op.is("gs") + && op.name(0).and_then(|name| tracker.definitions.get(name)).and_then(|gs| gs.as_dict()).is_some_and(|gs| gs.get(b"Font").is_some()) + && let Some(font) = DRAWING_PARAMS.iter().position(|name| *name == "Tf").and_then(|index| drawing_tracker.current.params.get_mut(index)) + { + // The replayed gs selects its font; do not overwrite it with an older direct Tf. + *font = None; + } + if op.is("gs") + && let Some(intent) = &tracker.current_state.rendering_intent + { + drawing_tracker.step(&Op::new("ri", vec![Object::Name(intent.clone())]))?; + } + if i == rem_end.saturating_sub(1) { + ctm_after_removal = state.placement.matrix; + } + } + if scan_target >= ops.len() { + dest_effective_state = tracker.current_state.clone(); + dest_drawing_state = drawing_tracker.current.clone(); + dest_ctm = state.placement.matrix; + dest_clips = state.placement.clips.clone(); + } + if dest_ctm.invert().is_none() { + return Err(EditError::Invalid("unsupported graphics state: non-invertible CTM".into())); + } + + let owned_crop = ops.get(scope_start..target).unwrap_or_default().iter().any(|op| op.is("MP") && op.name(0) == Some(b"PdfCraftImageCrop")); + let extracted_scope = if owned_crop { None } else { extract_scope_state(&ops, scope_start, target, scope_start_ctm)? }; + let mut extracted = match extracted_scope { + Some(ext) => ext, + None if scope_start != target => { + if !marked_unit || has_other_paint { + target_span = target_op.span.clone(); + } + extract_scope_state(&ops, target, target, Matrix(img.draw_matrix))? + .ok_or_else(|| EditError::Invalid("unsupported graphics state: unable to cleanly extract image scope state".into()))? + } + None => { + return Err(EditError::Invalid("unsupported graphics state: unable to cleanly extract image scope state".into())); + } + }; + + // The clips the moved unit draws itself: all of the image's when they are rebuilt from + // `img.clips`, else those its own scope set after the clips around it. + let all_clips = extracted.clip_ops.is_empty(); + let reemitted = if all_clips { + img.clips.as_slice() + } else if img.clips.starts_with(&outer_clips) { + img.clips.get(outer_clips.len()..).unwrap_or_default() + } else { + return Err(EditError::Invalid("unsupported graphics state: the image clipping path cannot be cleanly preserved".into())); + }; + // At the destination it is clipped by the clips there and the ones it draws. Moving past a + // clip the image isn't under (or out from under one it is) would change what part shows. + if !dest_clips.iter().all(|c| img.clips.contains(c)) || !img.clips.iter().all(|c| dest_clips.contains(c) || reemitted.contains(c)) { + return Err(EditError::Invalid( + "unsupported graphics state: moving the image past a clipping path would change what part of it shows".into(), + )); + } + + if all_clips { + for clip in &img.clips { + let ops = clip_path_to_ops(clip) + .ok_or_else(|| EditError::Invalid("unsupported graphics state: the image clipping path cannot be cleanly preserved".into()))?; + extracted.clip_ops.extend(ops); + } + } + + let has_inherited_clip = !img.clips.is_empty() && extracted.clip_ops.is_empty(); + if has_inherited_clip { + return Err(EditError::Invalid("unsupported graphics state: inherited clipping path cannot be cleanly preserved".into())); + } + + let mut reconstructed_state = dest_effective_state.clone(); + for (op, ctm) in &source_gs_ops { + if let Some(name) = op.name(0) + && let Some(definition) = tracker.definitions.get(name) + { + reconstructed_state.merge_shared(definition, doc, *ctm); + } + } + + if reconstructed_state.visually_differs(&source_effective_state) { + return Err(EditError::Invalid( + "unsupported graphics state: moving the image would alter opacity, blend mode, soft mask, or printing parameters".into(), + )); + } + + let marked_open = if marked_unit && !has_other_paint { ops.get(target_unit.start) } else { None }; + let has_closing_emc = marked_open.is_some() && target_unit.end.checked_sub(1).and_then(|i| ops.get(i)).is_some_and(|o| o.is("EMC")); + let inherited_drawing_ops = compute_inherited_drawing_ops(&source_drawing_state, &dest_drawing_state, &extracted.state_ops); + let unit_ops = + build_relocated_unit_ops(marked_open, &source_gs_ops, &inherited_drawing_ops, extracted, dest_ctm, img.draw_matrix, has_closing_emc)?; + + let mut serialized = Vec::new(); + serialized.push(b'\n'); + serialized.extend_from_slice(&serialize_ops(&unit_ops)); + serialized.push(b'\n'); + + let leaking = if let Some(inv) = ctm_before_removal.invert() { ctm_after_removal.then(&inv) } else { Matrix::IDENTITY }; + if leaking != Matrix::IDENTITY && has_leaking_clip { + return Err(EditError::Invalid("unsupported graphics state: moving the image would alter clipping path transform".into())); + } + let mut leak_ops = leaking_ops; + if leaking != Matrix::IDENTITY && leak_ops.iter().all(|o| !o.is("cm")) { + leak_ops.insert(0, Op::new("cm", leaking.0.map(pdfcraft_content::num).to_vec())); + } + let removal_replacement = if !leak_ops.is_empty() { + let mut s = Vec::new(); + s.push(b'\n'); + s.extend_from_slice(&serialize_ops(&leak_ops)); + s.push(b'\n'); + s + } else { + Vec::new() + }; + + if let Some(piece) = owned_destination { + let changed = pieces.splice([(target_span, removal_replacement)]); + let contents = crate::text::rewritten_contents(doc, all, changed); + let mut list = match contents { + Object::Array(list) => list, + one => vec![one], + }; + let insertion = piece.saturating_add(usize::from(insert_after)); + if insertion > list.len() { + return Err(EditError::Invalid("the destination content stream is missing".into())); + } + let relocated = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &serialized))); + list.insert(insertion, Object::Ref(relocated)); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?; + return Ok(()); + } + + let mut edits = Vec::new(); + if dest_pos >= target_span.end { + edits.push((target_span, removal_replacement)); + edits.push((dest_pos..dest_pos, serialized)); + } else if dest_pos <= target_span.start { + edits.push((dest_pos..dest_pos, serialized)); + edits.push((target_span, removal_replacement)); + } else { + return Err(EditError::Invalid("cannot arrange image into overlapping content range".into())); + } + + let changed = pieces.splice(edits); + let contents = crate::text::rewritten_contents(doc, all, changed); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(()) +} + +/// The saved graphics-state scopes (`q … Q`) around the `Do` at `target` that paint nothing else, +/// each as `(q, Q, CTM before the q)`. +struct ImageScopes { + /// The innermost one inside which every clip on the image is set. + isolated: Option<(usize, usize, Matrix)>, + /// The outermost one. It holds all of the image's private clips, but not a clip set outside it. + outermost: Option<(usize, usize, Matrix)>, + /// The CTM at the `Do`, and whether a clip applies there. + placement: Matrix, + clipped: bool, +} + +impl ImageScopes { + /// The saved graphics-state scope containing just this image and its crop, or the `Do` alone + /// when nothing clips it. Applying a transform before that scope moves the actual clipping path + /// as well as the pixels. A shared clip must never be moved along with unrelated artwork or text. + /// + /// Under a clip set outside every scope that holds only this image (a report or table drawing + /// each picture as `q re W n cm Do Q` inside a section's clip), no scope contains every active + /// clip. The outermost scope holding only the image is used then: it contains all of the + /// image's private clips, which move with it, while the outer clip is shared and stays. + fn transform_scope(&self, target: usize) -> Option<(usize, usize, Matrix)> { + if !self.clipped { + return Some((target, target, self.placement)); + } + self.isolated.or(self.outermost) + } +} + +/// [`ImageScopes`] of the `Do` at `target`. An image inside a text object cannot be `done`. +fn image_scopes(ops: &[Op], target: usize, done: &str) -> Result { + struct Scope { + start: usize, + matrix: Matrix, + inherited_clip: bool, + inherited_path: bool, + selected: bool, + other_paint: bool, + } + let mut stack: Vec = Vec::new(); + let mut matrix = Matrix::IDENTITY; + let mut clipped = false; + let mut path = false; + let mut pending_clip = false; + let mut text = false; + let mut at_image = None; + let mut isolated = None; + let mut outermost = None; + for (index, op) in ops.iter().enumerate() { + match op.op.as_slice() { + b"q" => { + if stack.len() >= 256 { + return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); + } + stack.push(Scope { start: index, matrix, inherited_clip: clipped, inherited_path: path, selected: false, other_paint: false }); + } + b"Q" => { + if let Some(scope) = stack.pop() { + if scope.selected && !scope.other_paint && !scope.inherited_path { + let found = (scope.start, index, scope.matrix); + if !scope.inherited_clip { + isolated.get_or_insert(found); + } + // Scopes close inner first, so the last one recorded is the outermost. + outermost = Some(found); + } + matrix = scope.matrix; + clipped = scope.inherited_clip; + path = scope.inherited_path; + pending_clip = false; + } + } + b"cm" => { + if let Some(m) = op.nums::<6>() { + matrix = Matrix(m).then(&matrix); + } + } + b"BT" => text = true, + b"ET" => text = false, + b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" => path = true, + b"W" | b"W*" => pending_clip = true, + _ => {} + } + if index == target { + if text { + return Err(EditError::Invalid(format!("an image inside a text object cannot be {done}"))); + } + at_image = Some((matrix, clipped)); + for scope in &mut stack { + scope.selected = true; + } + } else if matches!( + op.op.as_slice(), + b"Do" | b"Tj" | b"TJ" | b"'" | b"\"" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" + ) { + for scope in &mut stack { + scope.other_paint = true; + } + } + if matches!(op.op.as_slice(), b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*") { + clipped |= pending_clip; + pending_clip = false; + path = false; + } + } + let (placement, clipped) = at_image.ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + Ok(ImageScopes { isolated, outermost, placement, clipped }) +} + +/// A marked-content point identifies only the clipping path written by this editor. +/// It does not open a marked-content sequence or change the document's structure tags. +fn crop_marker() -> Op { + Op::new("MP", vec![Object::Name(b"PdfCraftImageCrop".to_vec())]) +} + +fn app_crop_ranges(ops: &[Op], start: usize, target: usize) -> Vec> { + let mut ranges = Vec::new(); + for (index, op) in ops.iter().enumerate().take(target).skip(start) { + if !op.is("MP") || op.name(0) != Some(b"PdfCraftImageCrop") { + continue; + } + let path_start = index.saturating_add(1); + let mut end = path_start; + while end < target && ops.get(end).is_some_and(|o| matches!(o.op.as_slice(), b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re")) { + end = end.saturating_add(1); + } + if end > path_start && ops.get(end).is_some_and(|o| o.is("W") || o.is("W*")) && ops.get(end.saturating_add(1)).is_some_and(|o| o.is("n")) { + ranges.push(index..end.saturating_add(2)); + } + } + ranges +} + +/// Remove only crops written by PdfCraft, preserving the document author's clipping paths. +fn remove_image_crop(doc: &mut Document, p: &pdfcraft_model::Page, all: &[(Object, Vec)], img: &PageImage) -> Result<(), EditError> { + let pieces = joined(all); + let ops = pieces.parse().ops; + let target = img.op; + let scope = image_scopes(&ops, target, "cropped")?; + let Some((start, _, _)) = scope.transform_scope(target) else { + return Err(EditError::Invalid("the image has no PdfCraft crop to remove".into())); + }; + let crops = app_crop_ranges(&ops, start, target); + if crops.is_empty() { + return Err(EditError::Invalid("the image has no PdfCraft crop to remove".into())); + } + let changed = crate::text::splice(&pieces, &ops, |index| (Vec::new(), !crops.iter().any(|r| r.contains(&index)))); + let contents = crate::text::rewritten_contents(doc, all, changed); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(()) +} + /// Change image `index` (from [`page_images`]) on `page`. pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &ImageChange) -> Result<(), EditError> { let images = page_images(doc, page)?; @@ -490,8 +1637,14 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag ImageChange::Transform(t) => { return transform_image(doc, &p, &all, &img, *t); } + ImageChange::Arrange(order) => { + return arrange_image(doc, &p, &all, &img, *order); + } + ImageChange::RemoveCrop => { + return remove_image_crop(doc, &p, &all, &img); + } ImageChange::Crop(keep) => { - return crop_image(doc, &p, &all, &img, *keep); + return crop_image_impl(doc, &p, &all, &img, *keep); } ImageChange::Replace(r) => { if img.is_form { @@ -534,94 +1687,6 @@ pub(crate) fn selectable_images( .collect()) } -/// Find the saved graphics-state scope containing just this image and its crop. Applying -/// the transform before that scope moves the actual clipping path as well as the pixels. -/// A shared clip must never be moved along with unrelated artwork or text. -/// -/// Under a clip set outside every scope that holds only this image (a report or table drawing -/// each picture as `q re W n cm Do Q` inside a section's clip), no scope contains every active -/// clip. The outermost scope holding only the image is used then: it contains all of the -/// image's private clips, which move with it, while the outer clip is shared and stays. -fn image_transform_scope(ops: &[Op], target: usize) -> Result, EditError> { - struct Scope { - start: usize, - matrix: Matrix, - inherited_clip: bool, - inherited_path: bool, - selected: bool, - other_paint: bool, - } - let mut stack: Vec = Vec::new(); - let mut matrix = Matrix::IDENTITY; - let mut clipped = false; - let mut path = false; - let mut pending_clip = false; - let mut text = false; - let mut at_image = None; - let mut isolated = None; - let mut outermost = None; - for (index, op) in ops.iter().enumerate() { - match op.op.as_slice() { - b"q" => { - if stack.len() >= 256 { - return Err(EditError::Invalid("the image graphics state is too deeply nested to transform".into())); - } - stack.push(Scope { start: index, matrix, inherited_clip: clipped, inherited_path: path, selected: false, other_paint: false }); - } - b"Q" => { - if let Some(scope) = stack.pop() { - if scope.selected && !scope.other_paint && !scope.inherited_path { - if !scope.inherited_clip { - // Prefer the innermost scope that contains every active clipping path. - isolated.get_or_insert((scope.start, index, scope.matrix)); - } - // Scopes close inner first, so the last one recorded is the outermost. - outermost = Some((scope.start, index, scope.matrix)); - } - matrix = scope.matrix; - clipped = scope.inherited_clip; - } - } - b"cm" => { - if let Some(m) = op.nums::<6>() { - matrix = Matrix(m).then(&matrix); - } - } - b"BT" => text = true, - b"ET" => text = false, - b"m" | b"l" | b"c" | b"v" | b"y" | b"re" => path = true, - b"W" | b"W*" => pending_clip = true, - _ => {} - } - if index == target { - if text { - return Err(EditError::Invalid("an image inside a text object cannot be transformed".into())); - } - at_image = Some((matrix, clipped)); - for scope in &mut stack { - scope.selected = true; - } - } else if matches!( - op.op.as_slice(), - b"Do" | b"Tj" | b"TJ" | b"'" | b"\"" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" - ) { - for scope in &mut stack { - scope.other_paint = true; - } - } - if matches!(op.op.as_slice(), b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*") { - clipped |= pending_clip; - pending_clip = false; - path = false; - } - } - let (placement, clipped) = at_image.ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; - if !clipped { - return Ok(Some((target, target, placement))); - } - Ok(isolated.or(outermost)) -} - fn transform_image( doc: &mut Document, page: &pdfcraft_model::Page, @@ -640,12 +1705,14 @@ fn transform_image( } // A private path crop moves with its image regardless of intervening state operators. // Shared crops retain the existing fixed-clip behavior, preserving neighbouring paint. - let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix))); + let (start, end, placement) = + image_scopes(&ops, target, "transformed")?.transform_scope(target).unwrap_or((target, target, Matrix(image.draw_matrix))); let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?; let local = placement.then(&Matrix(transform)).then(&inverse); if !local.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { return Err(EditError::Invalid("the image transform is too large".into())); } + // Already alone in a `q cm … Q` (its own placement, or the wrapper an earlier edit added): // change that `cm` in place. The CTM inside is `local · cm · outer` either way. if let Some((at, cm)) = enclosing_transform(&ops, start, end) { @@ -686,13 +1753,16 @@ fn transform_image( /// that holds the image and its private crop (or a new `q … Q` around the `Do`), written in /// that scope's coordinates as the box's four corners, so a turned image crops to an upright /// box too and a later transform of the scope moves the crop with the pixels. -fn crop_image( +fn crop_image_impl( doc: &mut Document, page: &pdfcraft_model::Page, streams: &[(Object, Vec)], image: &PageImage, keep: [f64; 4], ) -> Result<(), EditError> { + if image.is_form { + return Err(EditError::Invalid("cropping grouped Form artwork is not supported".into())); + } if !keep.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { return Err(EditError::Invalid("the crop box must be finite and no larger than 1e12".into())); } @@ -700,36 +1770,53 @@ fn crop_image( if x1 - x0 < 1e-6 || y1 - y0 < 1e-6 { return Err(EditError::Invalid("the crop box has no area".into())); } + if intersection(image.rect, [x0, y0, x1, y1]).is_none() { + return Err(EditError::Invalid("the crop box does not intersect the image".into())); + } + let pieces = joined(streams); let ops = pieces.parse().ops; let target = image.op; if !ops.get(target).is_some_and(|op| op.is("Do")) { return Err(EditError::Invalid("the image operation could not be located".into())); } - let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix))); + let (start, end, placement) = + image_scopes(&ops, target, "cropped")?.transform_scope(target).unwrap_or((target, target, Matrix(image.draw_matrix))); let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?; let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| inverse.apply(x, y)); if !corners.iter().all(|(x, y)| x.is_finite() && y.is_finite() && x.abs() <= 1e12 && y.abs() <= 1e12) { return Err(EditError::Invalid("the crop box is too large for the image's placement".into())); } - let clip: Vec = corners - .iter() - .enumerate() - .map(|(i, (x, y))| Op::new(if i == 0 { "m" } else { "l" }, vec![Object::Real(*x), Object::Real(*y)])) - .chain([Op::new("h", vec![]), Op::new("W", vec![]), Op::new("n", vec![])]) - .collect(); - // The image's own earlier crop (a clipping path right after the scope's `q`) gives way to - // the new one, so a crop can grow again as well as shrink. - let old_clip: Option> = (start != end) - .then(|| { - let mut k = start + 1; - while ops.get(k).is_some_and(|o| matches!(o.op.as_slice(), b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re")) { - k += 1; - } - (k > start + 1 && ops.get(k).is_some_and(|o| matches!(o.op.as_slice(), b"W" | b"W*")) && ops.get(k + 1).is_some_and(|o| o.is("n"))) - .then_some(start + 1..k + 2) - }) - .flatten(); + let is_axis_aligned = ((corners[0].1 - corners[1].1).abs() < 1e-4 + && (corners[1].0 - corners[2].0).abs() < 1e-4 + && (corners[2].1 - corners[3].1).abs() < 1e-4 + && (corners[3].0 - corners[0].0).abs() < 1e-4) + || ((corners[0].0 - corners[1].0).abs() < 1e-4 + && (corners[1].1 - corners[2].1).abs() < 1e-4 + && (corners[2].0 - corners[3].0).abs() < 1e-4 + && (corners[3].1 - corners[0].1).abs() < 1e-4); + let mut clip: Vec = if is_axis_aligned { + let min_x = corners.iter().map(|(x, _)| *x).fold(f64::INFINITY, f64::min); + let max_x = corners.iter().map(|(x, _)| *x).fold(f64::NEG_INFINITY, f64::max); + let min_y = corners.iter().map(|(_, y)| *y).fold(f64::INFINITY, f64::min); + let max_y = corners.iter().map(|(_, y)| *y).fold(f64::NEG_INFINITY, f64::max); + vec![ + Op::new("re", vec![Object::Real(min_x), Object::Real(min_y), Object::Real(max_x - min_x), Object::Real(max_y - min_y)]), + Op::new("W", vec![]), + Op::new("n", vec![]), + ] + } else { + corners + .iter() + .enumerate() + .map(|(i, (x, y))| Op::new(if i == 0 { "m" } else { "l" }, vec![Object::Real(*x), Object::Real(*y)])) + .chain([Op::new("h", vec![]), Op::new("W", vec![]), Op::new("n", vec![])]) + .collect() + }; + clip.insert(0, crop_marker()); + // Replace only our identified crop; an author's path must remain intact. + let old_crops = app_crop_ranges(&ops, start, target); + let insert_at = start.saturating_add(1); let changed = crate::text::splice(&pieces, &ops, |index| { if start == end && index == target { // No scope of its own yet: one around the drawing, with the crop inside. @@ -738,10 +1825,10 @@ fn crop_image( out.extend(ops.get(index).cloned()); out.push(Op::new("Q", vec![])); (out, false) - } else if start != end && index == start + 1 { + } else if start != end && index == insert_at { // Inside the scope's `q`, before whatever it draws (in place of its old crop). - (clip.clone(), old_clip.is_none()) - } else if old_clip.as_ref().is_some_and(|r| r.contains(&index)) { + (clip.clone(), !old_crops.iter().any(|r| r.contains(&index))) + } else if old_crops.iter().any(|r| r.contains(&index)) { (Vec::new(), false) } else { (Vec::new(), true) @@ -787,6 +1874,42 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize } // Images alone in their own `q re W n cm Do Q` take the clip with them. let mut clips = std::collections::BTreeMap::new(); + let mut crop_starts = std::collections::BTreeMap::new(); + let mut crop_ends = std::collections::BTreeSet::new(); + let mut folds = std::collections::BTreeMap::new(); + for image in images.iter().filter(|image| image.has_editor_crop()) { + let Some(&(_, _, offset)) = plans.get(&image.op) else { continue }; + let Some((start, end, placement)) = image_scopes(&ops, image.op, "moved")?.transform_scope(image.op) else { + return Err(EditError::Invalid("the image crop cannot be moved independently".into())); + }; + let inverse = placement.invert().ok_or_else(|| EditError::Invalid("a selected image has no area".into()))?; + let local = placement.then(&Matrix::translate(offset[0], offset[1])).then(&inverse); + if !local.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { + return Err(EditError::Invalid("the image move is too large".into())); + } + let leading_transform = || { + let at = start.checked_add(1)?; + if start >= end || !ops.get(start)?.is("q") || !ops.get(at)?.is("cm") { + return None; + } + ops.get(at)?.nums::<6>().map(|m| (at, Matrix(m))) + }; + // An enclosing cm is before the captured placement; an inside-scope cm is after it. + // Their multiplication orders differ when that cm scales or shears a translation. + let folded = enclosing_transform(&ops, start, end) + .map(|(at, cm)| (at, local.then(&cm))) + .or_else(|| leading_transform().map(|(at, cm)| (at, cm.then(&local)))); + if let Some((at, folded)) = folded { + if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { + return Err(EditError::Invalid("the image move is too large".into())); + } + folds.insert(at, folded); + } else { + crop_starts.insert(start, local); + crop_ends.insert(end); + } + plans.remove(&image.op); + } for index in 0..ops.len() { let Some(&(_, placement, offset)) = plans.get(&index) else { continue }; let Some((at, cm)) = exclusive_clip(&ops, index) else { continue }; @@ -807,7 +1930,6 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize }; } // An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another. - let mut folds = std::collections::BTreeMap::new(); for index in 0..ops.len() { let Some(&(movement, _, _)) = plans.get(&index) else { continue }; let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue }; @@ -822,6 +1944,14 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize return Err(EditError::Invalid("a selected image operation is missing".into())); } let data = crate::text::splice(&pieces, &ops, |index| { + if let Some(matrix) = crop_starts.remove(&index) { + return (vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())], true); + } + if crop_ends.remove(&index) { + let mut out: Vec<_> = ops.get(index).cloned().into_iter().collect(); + out.push(Op::new("Q", vec![])); + return (out, false); + } if let Some((c, keep)) = clips.remove(&index) { return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], keep); } @@ -841,3 +1971,44 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; Ok(()) } + +#[cfg(test)] +mod allocation_tests { + use super::*; + + #[test] + fn saved_drawing_states_share_large_operand_storage() { + let mut tracker = DrawingStateTracker::default(); + let parsed = parse(b"[1 2 3 4 5 6 7 8] 0 d /Pattern cs 1 2 3 4 5 6 /P scn q q q"); + for op in &parsed.ops { + tracker.step(op).unwrap(); + } + for state in &tracker.stack { + let dash = state.params[4].as_ref().unwrap(); + let current_dash = tracker.current.params[4].as_ref().unwrap(); + assert!(std::ptr::eq(dash.operands.as_ptr(), current_dash.operands.as_ptr()), "q must share the dash operands instead of cloning them"); + let color = state.non_stroking_color.as_ref().unwrap().last().unwrap(); + let current_color = tracker.current.non_stroking_color.as_ref().unwrap().last().unwrap(); + assert!(std::ptr::eq(color.operands.as_ptr(), current_color.operands.as_ptr()), "q must share scn operands instead of cloning them"); + } + let saved = tracker.current.clone(); + for op in &parse(b"[9 10] 0 d 4 5 6 /Other scn Q").ops { + tracker.step(op).unwrap(); + } + assert_eq!(tracker.current, saved, "changing shared operands must preserve the saved state"); + } + + #[test] + fn review_soft_mask_identity_includes_establishment_matrix() { + let doc = Document::new_empty(); + let mask = Dict::from_iter([(b"S".to_vec(), Object::name("Alpha")), (b"G".to_vec(), Object::Ref(ObjRef::new(8, 0)))]); + let definition = Dict::from_iter([(b"SMask".to_vec(), Object::Dict(mask))]); + let states = Dict::from_iter([(b"A".to_vec(), Object::Dict(definition))]); + let mut tracker = ExtendedGStateTracker::default(); + let op = Op::new("gs", vec![Object::name("A")]); + tracker.step(&op, &states, &doc, Matrix::IDENTITY).unwrap(); + let before = tracker.current_state.clone(); + tracker.step(&op, &states, &doc, Matrix::translate(50.0, 0.0)).unwrap(); + assert!(before.visually_differs(&tracker.current_state), "the same mask established under a different CTM is a different effective mask"); + } +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 193f7d8424..aa11617c14 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -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; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index d45e983a2e..f7740d60cd 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3516,11 +3516,24 @@ fn image_page() -> Document { image_page_with("q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET") } +/// Arrange, Crop and Remove crop as the engine asks for them, through [`images::change_image`]. +fn arrange(doc: &mut Document, page: usize, index: usize, order: images::ArrangeOrder) -> Result<(), EditError> { + images::change_image(doc, page, index, &images::ImageChange::Arrange(order)) +} + +fn crop(doc: &mut Document, page: usize, index: usize, keep: [f64; 4]) -> Result<(), EditError> { + images::change_image(doc, page, index, &images::ImageChange::Crop(keep)) +} + +fn remove_crop(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> { + images::change_image(doc, page, index, &images::ImageChange::RemoveCrop) +} + fn image_page_with(content: &str) -> Document { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> /ExtGState << /GS0 << /Type /ExtGState /ca 0.5 /CA 0.5 >> /GSalpha << /Type /ExtGState /ca 0.6 >> /GSblend << /Type /ExtGState /BM /Multiply >> /GS_translucent << /Type /ExtGState /ca 0.5 >> >> >> >>".into(), format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), "<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 8 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\nendstream".into(), @@ -3540,6 +3553,57 @@ fn image_page_with(content: &str) -> Document { Document::open(Arc::new(out)).unwrap() } +fn stencil_page_with(content: &str) -> Document { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> >>" + .into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ImageMask true /Filter /ASCIIHexDecode /Length 5 >>\nstream\n0000>\nendstream".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn form_page_with(content: &str) -> Document { + let form_content = "0 0 100 100 re f"; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 6 0 R >> >> >>" + .into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >>\nstream\n{form_content}\nendstream", form_content.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + /// A path segment operator before any `m` (or `re`) has no subpath to extend: the image scan used /// to call `BezPath`'s `line_to`/`curve_to`/`close_path` on an empty path, which panics by contract, /// taking `object_list`, `page_images` and the Office export's image walk down with it (#3581). @@ -3728,6 +3792,880 @@ fn page_images_move_turn_replace_and_delete() { assert!(images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).is_err()); } +#[test] +fn image_arrange_stacking_order_and_remove_crop() { + let content = "q 100 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Middle Text) Tj ET q 150 0 0 150 200 200 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + let initial_images = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial_images.len(), 2); + assert!(close(initial_images[0].rect, [100.0, 100.0, 200.0, 200.0])); + assert!(close(initial_images[1].rect, [200.0, 200.0, 350.0, 350.0])); + + // Boundary case: image 0 is already at the back; SendToBack and SendBackward report an error. + assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::SendToBack).is_err()); + assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::SendBackward).is_err()); + let imgs = images::page_images(&doc, 0).unwrap(); + assert_eq!(imgs.len(), 2); + assert!(close(imgs[0].rect, initial_images[0].rect)); + + // Bring forward image 0: moves it past the middle text, but it is still behind image 1. + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringForward).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + // Middle text should now precede image 0's Do in the content stream! + let text_pos = str_content.find("Middle Text").unwrap(); + let first_do = str_content.find("/Im0 Do").unwrap(); + assert!(text_pos < first_do, "text should now precede image 0"); + + // Bring to front on image 0: moves it past image 1 to the front! + let mut doc = doc; + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let imgs_after_front = images::page_images(&doc, 0).unwrap(); + assert_eq!(imgs_after_front.len(), 2); + // In drawing order, the image that was at [100, 100, 200, 200] is now drawn LAST (index 1)! + assert!(close(imgs_after_front[0].rect, [200.0, 200.0, 350.0, 350.0])); + assert!(close(imgs_after_front[1].rect, [100.0, 100.0, 200.0, 200.0])); + + // Send to back on the front image (index 1) restores it to the back. + let mut doc = doc; + arrange(&mut doc, 0, 1, images::ArrangeOrder::SendToBack).unwrap(); + let imgs_after_back = images::page_images(&doc, 0).unwrap(); + assert!(close(imgs_after_back[0].rect, [100.0, 100.0, 200.0, 200.0])); + assert!(close(imgs_after_back[1].rect, [200.0, 200.0, 350.0, 350.0])); + + // Test remove crop + let cropped_content = "q 200 0 0 100 100 100 cm /Im0 Do Q"; + let mut doc = image_page_with(cropped_content); + crop(&mut doc, 0, 0, [100.0, 100.0, 150.0, 150.0]).unwrap(); + let img_before = &images::page_images(&doc, 0).unwrap()[0]; + assert_ne!(img_before.visible_rect, Some(img_before.rect)); + remove_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let img_after = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_after.visible_rect, Some(img_after.rect)); + // Removing crop again when there is no crop is refused. + let mut doc = doc; + assert!(remove_crop(&mut doc, 0, 0).is_err()); + + // Test crop + let uncropped_content = "q 200 0 0 100 100 100 cm /Im0 Do Q"; + let mut doc = image_page_with(uncropped_content); + let img_before = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_before.visible_rect, Some(img_before.rect)); + crop(&mut doc, 0, 0, [120.0, 110.0, 250.0, 180.0]).unwrap(); + let doc = reopen(&doc); + let img_cropped = &images::page_images(&doc, 0).unwrap()[0]; + assert!(close(img_cropped.visible_rect.unwrap(), [120.0, 110.0, 250.0, 180.0])); + // And remove_crop works on it: + let mut doc = doc; + remove_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let img_restored = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_restored.visible_rect, Some(img_restored.rect)); + + // Test cropping an image that was moved (has an outer transform) + let mut doc = image_page_with("q 200 0 0 100 100 100 cm /Im0 Do Q"); + let move_transform = [1.0, 0.0, 0.0, 1.0, 50.0, 40.0]; + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(move_transform)).unwrap(); + let doc = reopen(&doc); + let img_moved = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_moved.rect, [150.0, 140.0, 350.0, 240.0]); + let mut doc = doc; + crop(&mut doc, 0, 0, [170.0, 150.0, 300.0, 220.0]).unwrap(); + let doc = reopen(&doc); + let img_crop_moved = &images::page_images(&doc, 0).unwrap()[0]; + assert!(img_crop_moved.visible_rect.is_some(), "cropped image must not be deleted"); + assert!(close(img_crop_moved.visible_rect.unwrap(), [170.0, 150.0, 300.0, 220.0])); + // Adjust crop (re-crop) + let mut doc = doc; + crop(&mut doc, 0, 0, [180.0, 160.0, 280.0, 210.0]).unwrap(); + let doc = reopen(&doc); + let img_recropped = &images::page_images(&doc, 0).unwrap()[0]; + assert!(close(img_recropped.visible_rect.unwrap(), [180.0, 160.0, 280.0, 210.0])); + // Remove crop from moved image + let mut doc = doc; + remove_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let img_restored_moved = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_restored_moved.visible_rect, Some(img_restored_moved.rect)); +} + +#[test] +fn crop_rotated_image_preserves_angle_and_in_place_geometry() { + let content = "q 173.20508 100 -50 86.60254 100 100 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + let img_before = &images::page_images(&doc, 0).unwrap()[0]; + let m_before = img_before.matrix; + assert!((m_before[1] - 100.0).abs() < 1e-3); + assert!((m_before[2] - (-50.0)).abs() < 1e-3); + + crop(&mut doc, 0, 0, [140.0, 170.0, 180.0, 210.0]).unwrap(); + let doc = reopen(&doc); + let img_cropped = &images::page_images(&doc, 0).unwrap()[0]; + + let m_after = img_cropped.matrix; + assert!((m_after[0] - m_before[0]).abs() < 1e-3); + assert!((m_after[1] - m_before[1]).abs() < 1e-3); + assert!((m_after[2] - m_before[2]).abs() < 1e-3); + assert!((m_after[3] - m_before[3]).abs() < 1e-3); + assert!((m_after[4] - m_before[4]).abs() < 1e-3); + assert!((m_after[5] - m_before[5]).abs() < 1e-3); + + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + assert!( + (str_content.contains("re") || (str_content.contains("m\n") && str_content.contains("l\n"))) && str_content.contains("W\nn"), + "{str_content}" + ); + assert!(str_content.contains("/Im0 Do"), "{str_content}"); + + let mut doc = doc; + remove_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let img_restored = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_restored.matrix, m_before); +} + +#[test] +fn image_arrange_preserves_shared_group_drawings() { + let content = "q 100 0 0 100 100 100 cm /Im0 Do /Im0 Do Q BT /F1 12 Tf 72 700 Td (End Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2, "both images initially present in shared group"); + + // Bring image 0 to front: it must not delete image 1 that shares the same q...Q group! + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images must be preserved after BringToFront"); + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + assert!(str_content.contains("End Text"), "text in page must be preserved"); +} + +/// The marked-content sequence that `mcid` opens (`/MCID n >> BDC` … its `EMC`), as content text. +fn marked_sequence(content: &str, mcid: u32) -> &str { + let tag = format!("/MCID {mcid}"); + let start = content.find(&tag).unwrap_or_else(|| panic!("no {tag} in {content}")); + let end = content[start..].find("EMC").map(|e| start + e).unwrap_or_else(|| panic!("{tag} has no EMC in {content}")); + &content[start..end] +} + +/// #3145: Arrange treated marked-content boundaries as if they weren't there, so Bring to Front put +/// the image inside the text's `BDC`…`EMC` (tagged as the text) and left its own sequence empty. +#[test] +fn image_arrange_keeps_marked_content_sequences_whole() { + let content = "/P <> BDC q 100 0 0 100 50 50 cm /Im0 Do Q EMC /P <> BDC BT /F1 12 Tf 72 700 Td (Text) Tj ET EMC"; + let mut doc = image_page_with(content); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let s = String::from_utf8_lossy(&bytes); + assert!(marked_sequence(&s, 0).contains("/Im0 Do"), "the image stays in its own sequence: {s}"); + assert!(!marked_sequence(&s, 1).contains("/Im0 Do"), "the text's sequence holds no image: {s}"); + assert!(marked_sequence(&s, 1).contains("(Text)"), "{s}"); + assert!(s.find("/MCID 1").unwrap() < s.find("/MCID 0").unwrap(), "the image's sequence now comes after the text's: {s}"); + assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1); +} + +/// A `BMC` sequence (here an artifact) moves as a whole too. +#[test] +fn image_arrange_moves_a_bmc_sequence_as_a_whole() { + let content = "/Artifact BMC q 100 0 0 100 50 50 cm /Im0 Do Q EMC BT /F1 12 Tf 72 700 Td (Body) Tj ET"; + let mut doc = image_page_with(content); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let s = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + let (art, emc, body, img) = (s.find("/Artifact BMC").unwrap(), s.rfind("EMC").unwrap(), s.find("(Body)").unwrap(), s.find("/Im0 Do").unwrap()); + assert!(body < art && art < img && img < emc, "text first, then the whole artifact sequence: {s}"); +} + +/// An image sharing a marked-content sequence with other content must not end up untagged. +/// Either the arrange call is refused with Err, or every image paint remains inside a marked-content sequence. +#[test] +fn arrange_image_in_shared_tagged_block_refuses_or_keeps_tagged() { + let content = + "/Figure <> BDC q 100 0 0 100 50 50 cm /Im0 Do Q BT /F1 9 Tf 50 40 Td (Caption) Tj ET EMC BT /F1 12 Tf 72 700 Td (Body) Tj ET"; + let mut doc = image_page_with(content); + let res = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront); + match res { + Err(_) => { + // Refusing is the expected safe behavior when moving out of a shared tagged unit. + } + Ok(()) => { + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let ops = pdfcraft_content::parse(&bytes).ops; + let mut mc_depth = 0usize; + for op in ops { + if op.is("BDC") || op.is("BMC") { + mc_depth += 1; + } else if op.is("EMC") { + mc_depth = mc_depth.saturating_sub(1); + } else if op.is("Do") { + assert!(mc_depth > 0, "every image paint must be inside a marked-content sequence: {}", String::from_utf8_lossy(&bytes)); + } + } + } + } +} + +/// An image that shares its marked-content sequence with other content cannot be rearranged +/// without leaving the image untagged or duplicating MCIDs, so it is refused. +#[test] +fn image_arrange_splits_a_shared_marked_sequence_into_own_tag() { + let content = + "/Figure <> BDC q 100 0 0 100 50 50 cm /Im0 Do Q BT /F1 9 Tf 50 40 Td (Caption) Tj ET EMC BT /F1 12 Tf 72 700 Td (Body) Tj ET"; + let mut doc = image_page_with(content); + assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).is_err()); +} + +/// Send backward also refuses to rearrange an image when it shares its sequence with other content. +#[test] +fn image_arrange_send_backward_splits_shared_marked_sequence() { + let content = + "BT /F1 12 Tf 72 700 Td (Header) Tj ET /Table <> BDC q 100 0 0 100 50 50 cm /Im0 Do Q BT /F1 9 Tf 50 40 Td (Cell) Tj ET EMC"; + let mut doc = image_page_with(content); + assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::SendBackward).is_err()); +} + +#[test] +fn arrange_preserves_color_leaking_out_of_marked_content() { + let content = "/Figure <> BDC 1 0 0 rg q 100 0 0 100 0 0 cm /Im0 Do Q EMC BT /F1 12 Tf 72 700 Td (Text) Tj ET"; + let mut doc = image_page_with(content); + let res = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront); + if res.is_ok() { + let bytes = page_content_bytes(&doc, 0); + let s = String::from_utf8_lossy(&bytes); + let text_pos = s.find("(Text) Tj").unwrap(); + assert!(s[..text_pos].contains("1 0 0 rg") || s[..text_pos].contains("1 0 0 RG")); + } +} + +#[test] +fn arrange_shared_tagged_does_not_duplicate_mcid() { + let content = "/Figure <> BDC q 100 0 0 100 0 0 cm /Im0 Do Q 0 0 m 10 10 l S EMC"; + let mut doc = image_page_with(content); + assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::SendToBack).is_err()); +} + +#[test] +fn image_crop_preserves_outer_cm_for_subsequent_drawings() { + let content = "100 0 0 100 50 50 cm /Im0 Do /Im0 Do"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + assert_eq!(initial[1].rect, [50.0, 50.0, 150.0, 150.0]); + + // Crop image 0 + crop(&mut doc, 0, 0, [60.0, 60.0, 120.0, 120.0]).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images must remain"); + // Image 0 is cropped: + assert!(close(after[0].visible_rect.unwrap(), [60.0, 60.0, 120.0, 120.0])); + // Crucial: Image 1 must still have its rect at [50, 50, 150, 150], proving outer cm was not destroyed! + assert_eq!(after[1].rect, [50.0, 50.0, 150.0, 150.0], "subsequent image must retain global cm"); +} + +#[test] +fn image_arrange_preserves_position_under_active_ctm() { + let content = "100 0 0 100 50 50 cm /Im0 Do /Im0 Do"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + assert_eq!(initial[1].rect, [50.0, 50.0, 150.0, 150.0]); + + // Move first image to front: must not apply destination CTM twice! + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: check order of operators in the content stream + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let first_do = str_content.find("/Im0 Do").unwrap(); + let last_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(first_do < last_do, "Position: multiple image Do calls exist and order was updated"); + + // 2. Appearance: both images' positions and sizes are preserved + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: first image position preserved"); + assert_eq!(after[1].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: second image position preserved"); + + // 3. Preservation: outer CTM is preserved + assert!(str_content.contains("100 0 0 100 50 50 cm"), "Preservation: initial CTM preserved"); +} + +#[test] +fn image_arrange_preserves_shared_group_clipping_bounds() { + let content = "q 120 120 40 40 re W n 100 0 0 100 100 100 cm /Im0 Do /Im0 Do Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + assert_eq!(initial[0].rect, [100.0, 100.0, 200.0, 200.0]); + assert_eq!(initial[0].visible_rect, Some([120.0, 120.0, 160.0, 160.0])); + assert_eq!(initial[1].rect, [100.0, 100.0, 200.0, 200.0]); + assert_eq!(initial[1].visible_rect, Some([120.0, 120.0, 160.0, 160.0])); + + // Bring image 0 to front: moves it past the external text while preserving clipping bounds + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: moved image Do now follows External Text in AST + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let text_pos = str_content.find("External Text").unwrap(); + let moved_do_pos = str_content.rfind("/Im0 Do").unwrap(); + assert!(text_pos < moved_do_pos, "Position: moved image Do follows External Text"); + + // 2. Appearance: both images retain exact clipping bounds and geometry + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images preserved"); + assert_eq!(after[0].visible_rect, Some([120.0, 120.0, 160.0, 160.0]), "Appearance: image 0 retains clip"); + assert_eq!(after[1].visible_rect, Some([120.0, 120.0, 160.0, 160.0]), "Appearance: image 1 retains clip"); + + // 3. Preservation: External Text and original group drawing preserved + assert!(str_content.contains("External Text"), "Preservation: external text preserved"); + assert!(str_content.contains("120 120 40 40 re W n"), "Preservation: clipping path operators preserved"); +} + +#[test] +fn image_arrange_preserves_extended_graphics_state_opacity() { + let content = "q /GS0 gs 100 0 0 100 50 50 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: /Im0 Do moved past External Text + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let text_pos = str_content.find("External Text").unwrap(); + let do_pos = str_content.rfind("/Im0 Do").unwrap(); + assert!(text_pos < do_pos, "Position: /Im0 Do moved past External Text"); + + // 2. Appearance: /GS0 gs is retained and directly precedes /Im0 Do + let gs_pos = str_content.rfind("/GS0 gs").unwrap(); + assert!(text_pos < gs_pos, "Appearance: /GS0 gs is in the new moved unit"); + assert!(gs_pos < do_pos, "Appearance: /GS0 gs directly precedes /Im0 Do"); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1, "image preserved"); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: rect preserved"); + + // 3. Preservation: External Text is preserved + assert!(str_content.contains("BT /F1 12 Tf 72 700 Td (External Text) Tj ET"), "Preservation: text preserved"); +} + +#[test] +fn image_arrange_preserves_exact_non_rectangular_clipping_path() { + let content = "q 120 120 m 160 120 l 140 160 l h W* n 100 0 0 100 100 100 cm /Im0 Do /Im0 Do Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: moved image follows External Text in AST + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let text_pos = str_content.find("External Text").unwrap(); + let last_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(text_pos < last_do, "Position: moved image follows External Text"); + + // 2. Appearance: polygon geometry and even-odd rule are preserved + assert!(str_content.contains("W*"), "Appearance: even-odd W* fill rule preserved"); + // The original group still clips the image left behind, so the moved copy must carry its own. + let flat = str_content.split_whitespace().collect::>().join(" "); + assert_eq!(flat.matches("120 120 m 160 120 l 140 160 l h W* n").count(), 2, "Appearance: exact polygon clip preserved in both units"); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images preserved"); + for image in &after { + assert!(image.contains([140.0, 130.0]), "Appearance: inside the triangle shows"); + assert!(!image.contains([125.0, 155.0]), "Appearance: inside its box but outside the triangle stays clipped"); + } + + // 3. Preservation: External Text and remaining original group content preserved + assert!(str_content.contains("External Text"), "Preservation: external text preserved"); +} + +#[test] +fn image_remove_crop_after_arrange_keeps_position_under_outer_transform() { + // Arrange writes `cm inverse`, the clip, then the absolute placement; Remove Crop used to drop + // only the first, which drew the image under the outer translation twice. + let content = "1 0 0 1 50 50 cm q 100 0 0 100 0 0 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + crop(&mut doc, 0, 0, [50.0, 50.0, 100.0, 100.0]).unwrap(); + assert_eq!(images::page_images(&doc, 0).unwrap()[0].rect, [50.0, 50.0, 150.0, 150.0]); + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let mut doc = reopen(&doc); + let moved = images::page_images(&doc, 0).unwrap(); + assert_eq!(moved[0].rect, [50.0, 50.0, 150.0, 150.0]); + assert_eq!(moved[0].visible_rect, Some([50.0, 50.0, 100.0, 100.0])); + + remove_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "the image stays where it was"); + assert_eq!(after[0].visible_rect, Some([50.0, 50.0, 150.0, 150.0]), "the crop is gone"); +} + +#[test] +fn image_arrange_refuses_to_move_past_a_clip_it_is_not_under() { + // The text after the image is drawn under a top-level clip; moving the image after it would + // crop the image to that clip. + let content = "q 100 0 0 100 50 50 cm /Im0 Do Q 0 0 60 60 re W n BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let before = page_content_bytes(&doc, 0); + assert_eq!(images::page_images(&doc, 0).unwrap()[0].visible_rect, Some([50.0, 50.0, 150.0, 150.0])); + + for order in [images::ArrangeOrder::BringToFront, images::ArrangeOrder::BringForward] { + let err = arrange(&mut doc, 0, 0, order).unwrap_err(); + assert!(err.to_string().contains("clipping path"), "{err}"); + } + assert_eq!(page_content_bytes(&doc, 0), before, "nothing changes"); +} + +#[test] +fn image_arrange_send_to_back_keeps_a_shared_clip() { + // The image after the text is clipped by a top-level clip set before both; behind the text it + // is no longer under that clip, so the moved unit has to draw it itself. + let content = "BT /F1 12 Tf 72 700 Td (External Text) Tj ET 0 0 60 60 re W n q 100 0 0 100 50 50 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + arrange(&mut doc, 0, 0, images::ArrangeOrder::SendToBack).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + assert!(str_content.rfind("/Im0 Do").unwrap() < str_content.find("External Text").unwrap(), "the image moved behind the text"); + assert_eq!(images::page_images(&doc, 0).unwrap()[0].visible_rect, Some([50.0, 50.0, 60.0, 60.0]), "still cropped to the clip"); +} + +#[test] +fn unwrapped_image_moves_successfully() { + let content = "100 0 0 100 50 50 cm /Im0 Do BT /F1 12 Tf 72 700 Td (Middle Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + + // Initial order: /Im0 Do precedes Middle Text + let initial_bytes = page_content_bytes(&doc, 0); + let initial_str = String::from_utf8_lossy(&initial_bytes); + let init_do = initial_str.find("/Im0 Do").unwrap(); + let init_text = initial_str.find("Middle Text").unwrap(); + assert!(init_do < init_text, "/Im0 Do initially precedes Middle Text"); + + // Move unwrapped image to front (past Middle Text) + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: /Im0 Do now follows Middle Text in the content stream AST + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let after_text = str_content.find("Middle Text").unwrap(); + let after_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(after_text < after_do, "Position: /Im0 Do actually moved after Middle Text"); + + // 2. Appearance: image rect and visibility are unchanged + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: image coordinates preserved"); + assert_eq!(after[0].visible_rect, Some([50.0, 50.0, 150.0, 150.0])); + + // 3. Preservation: Middle Text and CTM remain intact + assert!(str_content.contains("BT /F1 12 Tf 72 700 Td (Middle Text) Tj ET"), "Preservation: text operation preserved"); + assert!(str_content.contains("100 0 0 100 50 50 cm"), "Preservation: original cm preserved"); +} + +#[test] +fn test_arrange_unwrapped_image_extraction() { + let content = b"100 0 0 100 100 100 cm /Im0 Do /Im1 Do"; + let ops = pdfcraft_content::parse(content).ops; + let target = ops.iter().position(|op| op.is("Do") && op.name(0) == Some(b"Im0")).expect("target /Im0 not found"); + + // Scope start for unwrapped image matches target because content_units isolates Do + let scope_start = target; + + // Accumulate effective CTM prior to the image operation + let mut initial_ctm = pdfcraft_content::Matrix::IDENTITY; + for op in &ops[..target] { + if op.is("cm") + && let Some(m) = op.nums::<6>() + { + initial_ctm = pdfcraft_content::Matrix(m).then(&initial_ctm); + } + } + assert_eq!(initial_ctm, pdfcraft_content::Matrix([100.0, 0.0, 0.0, 100.0, 100.0, 100.0])); + + let extracted = images::extract_scope_state(&ops, scope_start, target, initial_ctm) + .expect("extraction must not return Err") + .expect("extraction must return Some rather than None"); + + assert_eq!(extracted.do_op.op.as_slice(), b"Do"); + assert_eq!(extracted.do_op.name(0), Some(b"Im0".as_slice())); +} + +#[test] +fn arrange_unwrapped_image_in_mixed_scope_succeeds() { + // Content unit contains text and an unwrapped image; scope extraction of unit fails on BT, + // so it safely falls back to extracting target Do directly. + let content = "q BT /F1 12 Tf 72 700 Td (Preceding Text) Tj ET 100 0 0 100 50 50 cm /Im0 Do Q BT /F1 12 Tf 72 500 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let text_pos = str_content.find("External Text").unwrap(); + let do_pos = str_content.rfind("/Im0 Do").unwrap(); + assert!(text_pos < do_pos, "moved image follows External Text"); + + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0]); +} + +#[test] +fn image_arrange_with_transformed_clipping_path_succeeds() { + // Nested graphics state: outer scope establishes a scaled clipping path (cm + path + W n), + // inner scope draws the image. + let content = "q 0.5 0 0 0.5 0 0 cm 100 100 m 300 100 l 300 300 l 100 300 l h W n q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let before = images::page_images(&doc, 0).unwrap(); + assert_eq!(before.len(), 1); + assert!(before[0].visible_rect.is_some()); + let rect_before = before[0].rect; + let clip_before = before[0].visible_rect; + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, rect_before); + assert_eq!(after[0].visible_rect, clip_before); + + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let text_pos = str_content.find("External Text").unwrap(); + let do_pos = str_content.rfind("/Im0 Do").unwrap(); + assert!(text_pos < do_pos, "image moved after text: {str_content}"); +} + +#[test] +fn inherited_source_opacity_is_preserved() { + // Outer q sets /GS0 gs; inner q draws /Im0 Do; outer Q restores state before External Text + let content = "q /GS0 gs q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + + // Initial order: /Im0 Do precedes External Text + let initial_bytes = page_content_bytes(&doc, 0); + let initial_str = String::from_utf8_lossy(&initial_bytes); + let init_do = initial_str.find("/Im0 Do").unwrap(); + let init_text = initial_str.find("External Text").unwrap(); + assert!(init_do < init_text, "/Im0 Do initially precedes External Text"); + + // Move image to front (past External Text, outside its original outer q...Q scope) + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: /Im0 Do now follows External Text in the AST + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let after_text = str_content.find("External Text").unwrap(); + let after_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(after_text < after_do, "Position: /Im0 Do moved past External Text"); + + // 2. Appearance: effective appearance state (/GS0 gs) is injected and precedes /Im0 Do in the new unit + let after_gs = str_content.rfind("/GS0 gs").unwrap(); + assert!(after_text < after_gs, "Appearance: /GS0 gs injected into the new unit at destination"); + assert!(after_gs < after_do, "Appearance: /GS0 gs directly precedes /Im0 Do in the moved unit"); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: rect preserved"); + + // 3. Preservation: External Text and original outer scope structure are preserved + assert!(str_content.contains("BT /F1 12 Tf 72 700 Td (External Text) Tj ET"), "Preservation: external text preserved"); +} + +#[test] +fn destination_opacity_pollution_is_prevented() { + // /Im0 Do is fully opaque at the start; destination has active top-level /GS_translucent gs + let content = "100 0 0 100 50 50 cm /Im0 Do /GS_translucent gs BT /F1 12 Tf 72 700 Td (Translucent Text) Tj ET"; + let mut doc = image_page_with(content); + let initial_bytes = page_content_bytes(&doc, 0); + + // Attempting to move opaque image 0 to front into active transparency scope must safely bail out with Err + let res = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront); + assert!(res.is_err(), "must return an explicit Err rather than silently corrupting opacity"); + let err_msg = res.unwrap_err().to_string(); + assert!(err_msg.contains("unsupported graphics state"), "error describes unsupported graphics state: {err_msg}"); + + // Preservation: the document contents are completely unmodified + let doc = reopen(&doc); + let after_bytes = page_content_bytes(&doc, 0); + assert_eq!(initial_bytes, after_bytes, "Preservation: content stream remained completely unmodified"); +} + +#[test] +fn sequential_gs_operators_merging_properties_are_preserved() { + // Sequential gs calls: /GSalpha sets ca=0.6, /GSblend sets BM=/Multiply (without touching ca). + let content = "q /GSalpha gs /GSblend gs q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: /Im0 Do now follows External Text in the AST + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let after_text = str_content.find("External Text").unwrap(); + let after_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(after_text < after_do, "Position: /Im0 Do moved past External Text"); + + // 2. Appearance: Both merged state operators (/GSalpha and /GSblend) are preserved and precede /Im0 Do + let after_alpha = str_content.rfind("/GSalpha gs").unwrap(); + let after_blend = str_content.rfind("/GSblend gs").unwrap(); + assert!(after_text < after_alpha, "Appearance: /GSalpha gs injected into moved unit"); + assert!(after_text < after_blend, "Appearance: /GSblend gs injected into moved unit"); + assert!(after_alpha < after_do, "Appearance: /GSalpha gs precedes /Im0 Do"); + assert!(after_blend < after_do, "Appearance: /GSblend gs precedes /Im0 Do"); + + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: rect preserved"); + + // 3. Preservation: External Text is intact + assert!(str_content.contains("BT /F1 12 Tf 72 700 Td (External Text) Tj ET"), "Preservation: external text preserved"); +} + +#[test] +fn destination_differing_blend_mode_pollution_is_prevented() { + // /Im0 Do has default Normal blend mode; destination has active top-level /GSblend gs (BM = /Multiply) + let content = "100 0 0 100 50 50 cm /Im0 Do /GSblend gs BT /F1 12 Tf 72 700 Td (Blended Section) Tj ET"; + let mut doc = image_page_with(content); + let initial_bytes = page_content_bytes(&doc, 0); + + // Attempting to move Normal-blend image to front into active Multiply-blend scope must safely bail out with Err + let res = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront); + assert!(res.is_err(), "must return an explicit Err rather than silently altering blend mode"); + let err_msg = res.unwrap_err().to_string(); + assert!(err_msg.contains("unsupported graphics state"), "error describes unsupported graphics state: {err_msg}"); + + // Preservation: document contents are completely unmodified + let doc = reopen(&doc); + let after_bytes = page_content_bytes(&doc, 0); + assert_eq!(initial_bytes, after_bytes, "Preservation: content stream remained completely unmodified"); +} + +fn nested_soft_mask_page(content: &str) -> Document { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R /GroupA 9 0 R /GroupB 10 0 R >> /ExtGState << /GSA 7 0 R /GSB 8 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\nendstream".into(), + "<< /Type /ExtGState /SMask << /Type /Mask /S /Alpha /G 9 0 R >> >>".into(), + "<< /Type /ExtGState /SMask << /Type /Mask /S /Alpha /G 10 0 R >> >>".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true >> /Length 20 >>\nstream\n0 g 0 0 100 50 re f\nendstream".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true >> /Length 20 >>\nstream\n0 g 0 0 50 100 re f\nendstream".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +#[test] +fn nested_soft_masks_replay_in_correct_order_and_effective_mask_is_rendered() { + let content = "q /GSA gs q /GSB gs 100 0 0 100 0 0 cm /Im0 Do Q Q BT /F1 12 Tf 10 90 Td (External Text) Tj ET"; + let mut doc = nested_soft_mask_page(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + + let content_bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&content_bytes); + + // 1. Assert emitted stream order: outer /GSA gs must precede inner /GSB gs, which precedes /Im0 Do + let after_text = str_content.find("External Text").unwrap(); + let after_a = str_content.rfind("/GSA gs").unwrap(); + let after_b = str_content.rfind("/GSB gs").unwrap(); + let after_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(after_text < after_a, "Position: /GSA gs relocated past external text"); + assert!(after_a < after_b, "Order: outer /GSA gs must be replayed before inner /GSB gs"); + assert!(after_b < after_do, "Order: inner /GSB gs must immediately precede /Im0 Do"); + + // 2. The image survives saving and reopening. + assert_eq!(images::page_images(&reopen(&doc), 0).unwrap().len(), 1); + + // 3. Rendered-pixel assertion: render output and verify Mask B (left 50x100) is effective, not Mask A (bottom 100x50) + let pdf_bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(pdf_bytes), pdfcraft_render::RenderConfig::default()); + let page = + renderer.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() }); + let px = |x: u32, y: u32| { + let idx = ((y * page.width + x) * 4) as usize; + page.rgba[idx..idx + 4].to_vec() + }; + // Left half (inside Mask B): image is rendered black + assert_eq!(px(25, 25), vec![0, 0, 0, 255], "point (25, 25) is inside Mask B: image rendered"); + assert_eq!(px(25, 75), vec![0, 0, 0, 255], "point (25, 75) is inside Mask B: image rendered"); + // Right half (outside Mask B): masked out to white page background + assert_eq!(px(75, 25), vec![255, 255, 255, 255], "point (75, 25) is outside Mask B: masked out to white"); + assert_eq!(px(75, 75), vec![255, 255, 255, 255], "point (75, 75) is outside Mask B: masked out to white"); +} + +#[test] +fn destination_differing_mask_pollution_is_prevented() { + // /Im0 Do has no mask; destination has active top-level /GSA gs (Mask A) + let content = "100 0 0 100 0 0 cm /Im0 Do /GSA gs BT /F1 12 Tf 10 90 Td (Masked Section) Tj ET"; + let mut doc = nested_soft_mask_page(content); + let initial_bytes = page_content_bytes(&doc, 0); + + let res = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront); + assert!(res.is_err(), "must return an explicit Err rather than corrupting mask state"); + let err_msg = res.unwrap_err().to_string(); + assert!(err_msg.contains("unsupported graphics state"), "error describes unsupported graphics state: {err_msg}"); + + // Preservation: document contents are completely unmodified + let doc = reopen(&doc); + let after_bytes = page_content_bytes(&doc, 0); + assert_eq!(initial_bytes, after_bytes, "Preservation: content stream remained completely unmodified"); +} + +fn soft_mask_with_alpha_page(content: &str) -> Document { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R /GroupA 9 0 R >> /ExtGState << /GSA 7 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\nendstream".into(), + "<< /Type /ExtGState /ca 0.25 /SMask << /Type /Mask /S /Alpha /G 9 0 R >> >>".into(), + "<< /Type /ExtGState /SMask /None >>".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true >> /Length 20 >>\nstream\n0 g 0 0 100 50 re f\nendstream".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +#[test] +fn non_identity_cm_before_gs_preserves_mask_coordinates_and_rendering() { + let content = "q 2 0 0 2 10 10 cm /GSA gs 50 0 0 50 0 0 cm /Im0 Do Q BT /F1 12 Tf 10 180 Td (External Text) Tj ET"; + let mut doc = soft_mask_with_alpha_page(content); + + // Initial state: image bounds are [10, 10, 110, 110] + let initial_images = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial_images.len(), 1); + assert!(close(initial_images[0].rect, [10.0, 10.0, 110.0, 110.0])); + + // Render baseline before reordering + let pdf_bytes_before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let mut renderer_before = pdfcraft_render::PageRenderer::new(Arc::new(pdf_bytes_before), pdfcraft_render::RenderConfig::default()); + let page_before = renderer_before.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + let px_before = |x: u32, y: u32| { + let idx = ((y * page_before.width + x) * 4) as usize; + page_before.rgba[idx..idx + 4].to_vec() + }; + + // Arrange image: BringToFront + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + + let content_bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&content_bytes); + + // 1. Assert AST reordering succeeds and interleaves cm before gs + let text_pos = str_content.find("External Text").unwrap(); + let cm_mask_pos = str_content.rfind("2 0 0 2 10 10 cm").unwrap(); + let gs_pos = str_content.rfind("/GSA gs").unwrap(); + let cm_img_pos = str_content.rfind("50 0 0 50 0 0 cm").unwrap(); + let do_pos = str_content.rfind("/Im0 Do").unwrap(); + + assert!(text_pos < cm_mask_pos, "Image relocated after external text"); + assert!(cm_mask_pos < gs_pos, "CTM for soft mask established before /GSA gs"); + assert!(gs_pos < cm_img_pos, "/GSA gs executed before image cm"); + assert!(cm_img_pos < do_pos, "image cm executed before /Im0 Do"); + + // 2. Assert image bounds remain unchanged after saving and reopening + let reopened_doc = reopen(&doc); + let reopened_images = images::page_images(&reopened_doc, 0).unwrap(); + assert_eq!(reopened_images.len(), 1); + assert!(close(reopened_images[0].rect, [10.0, 10.0, 110.0, 110.0]), "Image bounds preserved: {:?}", reopened_images[0].rect); + + // 3. Render reopened document and assert non-overlapping pixels remain unchanged + let pdf_bytes_after = write_incremental(&reopened_doc, &SaveOptions::default()).unwrap(); + let mut renderer_after = pdfcraft_render::PageRenderer::new(Arc::new(pdf_bytes_after), pdfcraft_render::RenderConfig::default()); + let page_after = renderer_after.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + let px_after = |x: u32, y: u32| { + let idx = ((y * page_after.width + x) * 4) as usize; + page_after.rgba[idx..idx + 4].to_vec() + }; + + // A point inside the masked image (e.g. at x=50, inside the image and bottom half of mask) + // Non-overlapping with text (text is at y=180). + // The pixel renders at [191, 191, 191, 255] (25% black over 100% white). + // In raster coordinates, raster y for PDF y=50 in a 200-high page is y=150. + let rendered_pixel_before = px_before(50, 150); + let rendered_pixel_after = px_after(50, 150); + assert_eq!(rendered_pixel_before, vec![191, 191, 191, 255], "baseline pixel rendered with 25% opacity mask"); + assert_eq!(rendered_pixel_after, rendered_pixel_before, "rendered non-overlapping pixels remain unchanged"); + + // Outside the image: pure white background + assert_eq!(px_after(5, 5), vec![255, 255, 255, 255]); + assert_eq!(px_after(5, 5), px_before(5, 5)); +} + #[test] fn form_artwork_edits_only_the_selected_placement() { let mut doc = fixture(); // three pages share resources; pages 0 and 1 also share contents @@ -4179,6 +5117,56 @@ fn the_graphics_state_carries_over_between_content_streams() { assert!(close(l.rect[0], 100.0) && close(l.size, 10.0), "{l:?}"); } +fn two_stream_image_page(first: &str, second: &str) -> Document { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents [4 0 R 5 0 R] /Resources << /Font << /F1 6 0 R >> /XObject << /Im0 7 0 R /Im1 7 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{first}\nendstream", first.len()), + format!("<< /Length {} >>\nstream\n{second}\nendstream", second.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 8 >>\nstream\n\0\0\0\0\0\0\0\0\nendstream".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +#[test] +fn arrange_and_crop_image_in_second_content_stream_succeeds() { + let first = "q 100 0 0 100 50 50 cm /Im0 Do Q"; + let second = "q 100 0 0 100 200 200 cm /Im1 Do Q"; + let mut doc = two_stream_image_page(first, second); + let before = images::page_images(&doc, 0).unwrap(); + assert_eq!(before.len(), 2); + assert_eq!(before[0].name, "Im0"); + assert_eq!(before[1].name, "Im1"); + + // Arrange second image (located in the second content stream) + arrange(&mut doc, 0, 1, images::ArrangeOrder::SendToBack).unwrap(); + let after_arrange = images::page_images(&reopen(&doc), 0).unwrap(); + assert_eq!(after_arrange.len(), 2); + + // Crop image in second stream + crop(&mut doc, 0, 0, [210.0, 210.0, 290.0, 290.0]).unwrap(); + let after_crop = images::page_images(&reopen(&doc), 0).unwrap(); + assert_eq!(after_crop.len(), 2); + assert_eq!(after_crop[0].name, "Im1"); + assert!(close(after_crop[0].visible_rect.unwrap(), [210.0, 210.0, 290.0, 290.0])); + assert_eq!(after_crop[1].name, "Im0"); + assert_eq!(after_crop[1].visible_rect, Some(after_crop[1].rect)); +} + /// One page whose font draws a few Hebrew letters (ToUnicode): A ב, B ד, C י, D ו, E ק, F ש, /// G ל, H ם. Content streams draw Hebrew in visual order, so "בדיוק" is `(EDCBA)`. fn hebrew_page(content: &str) -> Document { @@ -7495,6 +8483,155 @@ fn reading_keeps_dotted_leader_entries_whole_and_in_order() { ); } +#[cfg(test)] +mod proptests { + use super::*; + use proptest::prelude::*; + + proptest! { + + #[test] + fn prop_is_entire_page_wrapped_arbitrary_bytes(bytes in proptest::collection::vec(any::(), 0..2048)) { + let pieces = pdfcraft_content::Pieces::join(&[&bytes]); + let ops = pieces.parse().ops; + let _ = images::is_entire_page_wrapped(&ops); + } + + #[test] + fn prop_content_units_arbitrary_bytes( + bytes in proptest::collection::vec(any::(), 0..2048), + start in 0usize..50, + end in 0usize..50, + ) { + let pieces = pdfcraft_content::Pieces::join(&[&bytes]); + let ops = pieces.parse().ops; + let scan_start = start.min(ops.len()); + let scan_end = end.min(ops.len()).max(scan_start); + let units = images::content_units(&ops, scan_start, scan_end); + for u in units { + prop_assert!(u.start <= u.end); + prop_assert!(u.end <= ops.len()); + } + } + + #[test] + fn prop_arrange_image_arbitrary_malformed( + index in 0usize..10, + order_idx in 0u8..4, + bytes in proptest::collection::vec(any::(), 0..1024), + ) { + let order = match order_idx { + 0 => images::ArrangeOrder::BringToFront, + 1 => images::ArrangeOrder::SendToBack, + 2 => images::ArrangeOrder::BringForward, + _ => images::ArrangeOrder::SendBackward, + }; + let mut doc = image_page_with(&String::from_utf8_lossy(&bytes)); + let _ = arrange(&mut doc, 0, index, order); + } + + #[test] + fn prop_crop_image_malformed_inputs( + x1 in -500.0f64..500.0, + y1 in -500.0f64..500.0, + x2 in -500.0f64..500.0, + y2 in -500.0f64..500.0, + bytes in proptest::collection::vec(any::(), 0..1024), + ) { + let mut doc = image_page_with(&String::from_utf8_lossy(&bytes)); + let _ = crop(&mut doc, 0, 0, [x1, y1, x2, y2]); + let _ = remove_crop(&mut doc, 0, 0); + } + } +} + +/// One 600×800 page: Im0 (4×2 gray image), Fm0 (200×100 Form), ExtGState GSone (ca 1). +fn regression_page(content: &str) -> Document { + let form = "0 g 0 0 200 100 re f"; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R /Fm0 7 0 R >> /ExtGState << /GSone << /Type /ExtGState /ca 1 >> >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 8 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\nendstream".into(), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 200 100] /Length {} >>\nstream\n{form}\nendstream", form.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn regression_rects(doc: &Document) -> Vec<[i64; 4]> { + let mut v: Vec<[i64; 4]> = images::page_images(doc, 0).unwrap().iter().map(|i| i.rect.map(|x| x.round() as i64)).collect(); + v.sort(); + v +} + +/// A `cm` inside a BDC…EMC unit applies to later content; arranging the unit must not shift anything. +#[test] +fn arrange_keeps_cm_leaking_out_of_marked_content() { + let mut doc = regression_page("/Figure <> BDC 1 0 0 1 50 50 cm q 100 0 0 100 0 0 cm /Im0 Do Q EMC q 10 0 0 10 200 200 cm /Im0 Do Q"); + let before = regression_rects(&doc); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + assert_eq!(before, regression_rects(&reopen(&doc)), "other content moved"); +} + +/// Cropping a Form XObject to 100×100 keeps a 100×100 visible area (or is refused) — never 1×1. +#[test] +fn crop_form_xobject_keeps_requested_area() { + let mut doc = regression_page("q 1 0 0 1 100 100 cm /Fm0 Do Q"); + if crop(&mut doc, 0, 0, [100.0, 100.0, 200.0, 200.0]).is_err() { + return; // refusing to crop vector art is also acceptable + } + let after = images::page_images(&reopen(&doc), 0).unwrap()[0].clone(); + let v = after.visible_rect.unwrap_or(after.rect); + assert!((v[2] - v[0] - 100.0).abs() < 1.0 && (v[3] - v[1] - 100.0).abs() < 1.0, "expected 100x100 visible, got {v:?}"); +} + +/// A gs naming a missing ExtGState must not be read as 50% opacity and block the reorder. +#[test] +fn arrange_tolerates_missing_extgstate() { + let mut doc = regression_page("q /GSmissing gs 100 0 0 100 50 50 cm /Im0 Do Q /GSone gs q 10 0 0 10 200 200 cm /Im0 Do Q"); + let r = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront); + assert!(r.is_ok(), "{r:?}"); +} + +/// Bring to front on the image already in front must not report a change it didn't make. +#[test] +fn noop_arrange_is_not_ok_without_change() { + let mut doc = regression_page("q 10 0 0 10 200 200 cm /Im0 Do Q q 100 0 0 100 50 50 cm /Im0 Do Q"); + let before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let r = arrange(&mut doc, 0, 1, images::ArrangeOrder::BringToFront); + let after = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(r.is_err() || before != after, "Ok with no change"); +} + +/// Cropping a turned image keeps the requested upright box in user space, rather than expanding +/// to the bounding box of the turned image coordinates. +#[test] +fn crop_turned_image_preserves_requested_area() { + let mut doc = regression_page("q 70.71 70.71 -70.71 70.71 300 100 cm /Im0 Do Q"); + let req = [270.0, 150.0, 330.0, 200.0]; + crop(&mut doc, 0, 0, req).expect("crop succeeds"); + let after = &images::page_images(&reopen(&doc), 0).unwrap()[0]; + let v = after.visible_rect.expect("visible rect exists"); + assert!((v[0] - req[0]).abs() < 1.0, "x0: expected {}, got {}", req[0], v[0]); + assert!((v[1] - req[1]).abs() < 1.0, "y0: expected {}, got {}", req[1], v[1]); + assert!((v[2] - req[2]).abs() < 1.0, "x1: expected {}, got {}", req[2], v[2]); + assert!((v[3] - req[3]).abs() < 1.0, "y1: expected {}, got {}", req[3], v[3]); +} + /// Text set larger than the widest page PDF allows (14 400 pt) panicked when its paragraph was given /// a width: the width is clamped between the font size and 14 400 pt, and `f64::clamp` panics when /// its minimum is the larger. The width is now capped at 14 400 pt whatever the font size: from the @@ -7758,6 +8895,122 @@ fn a_flatten_refused_on_a_later_page_changes_nothing() { assert_eq!((*doc.get(first)).clone(), before, "the first page keeps its comment and content"); } +#[test] +fn arrange_preserves_clip_leaked_out_of_marked_unit() { + let content = "/Figure <> BDC 0 0 300 300 re W n q 10 0 0 10 50 50 cm /Im0 Do Q EMC q 10 0 0 10 400 400 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + assert!(initial[0].visible_rect.is_some()); + assert!(initial[1].visible_rect.is_none(), "second image is fully clipped initially"); + + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2); + let img1 = after.iter().find(|img| (img.rect[0] - 400.0).abs() < 1e-3).expect("second image exists"); + assert!(img1.visible_rect.is_none(), "second image must stay clipped after BringToFront"); + + // Conflicting leaking transform with leaking clip must be refused + let content_cm_clip = + "/Figure <> BDC 2 0 0 2 0 0 cm 0 0 300 300 re W n q 10 0 0 10 50 50 cm /Im0 Do Q EMC q 10 0 0 10 400 400 cm /Im0 Do Q"; + let mut doc3 = image_page_with(content_cm_clip); + let res3 = arrange(&mut doc3, 0, 0, images::ArrangeOrder::BringToFront); + assert!(res3.is_err(), "moving marked unit with both leaking cm and leaking clip must be refused"); +} + +#[test] +fn arrange_preserves_leaked_color_and_text_state() { + let content = "/Figure <> BDC /DeviceRGB cs 1 0 0 sc /F1 14 Tf 2 Tc q 10 0 0 10 50 50 cm /Im0 Do Q EMC 0 0 10 10 re f q 10 0 0 10 200 200 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let s = String::from_utf8_lossy(&bytes); + assert!(s.contains("1 0 0 sc"), "sc must be preserved in content stream: {s}"); + assert!(s.contains("/F1 14 Tf"), "Tf must be preserved in content stream: {s}"); + assert!(s.contains("2 Tc"), "Tc must be preserved in content stream: {s}"); + + // Pixel test: 0 0 10 10 re f still fills red [255, 0, 0] after arrange + let pdf_bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(pdf_bytes), pdfcraft_render::RenderConfig::default()); + let page = + renderer.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() }); + let idx = (((page.height - 5) * page.width + 5) * 4) as usize; + assert_eq!(page.rgba[idx..idx + 3], [255, 0, 0], "point (5, 5 from bottom) inside 10x10 rect fills red"); +} + +#[test] +fn arrange_preserves_inherited_drawing_state_for_stencil_and_form() { + // 1. Stencil mask test: + // Page has: q 1 0 0 rg q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET + // Non-overlapping stencil and text. + let content = "q 1 0 0 rg q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET"; + let mut doc = stencil_page_with(content); + + // Render before move: + let pdf_before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let mut renderer_before = pdfcraft_render::PageRenderer::new(Arc::new(pdf_before), pdfcraft_render::RenderConfig::default()); + let page_before = renderer_before.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + let stencil_pixel_idx = (((page_before.height - 75) * page_before.width + 75) * 4) as usize; + assert_eq!(page_before.rgba[stencil_pixel_idx..stencil_pixel_idx + 3], [255, 0, 0], "stencil starts red"); + + // Bring to front + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + + // Save and reopen + let doc = reopen(&doc); + + // Verify content stream has inherited color + let bytes = page_content_bytes(&doc, 0); + let s = String::from_utf8_lossy(&bytes); + assert!(s.contains("1 0 0 rg"), "inherited 1 0 0 rg must be replayed in moved unit: {s}"); + + // Render after reopen and verify stencil remains red: + let pdf_after = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let mut renderer_after = pdfcraft_render::PageRenderer::new(Arc::new(pdf_after), pdfcraft_render::RenderConfig::default()); + let page_after = renderer_after.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + assert_eq!(page_after.rgba[stencil_pixel_idx..stencil_pixel_idx + 3], [255, 0, 0], "stencil stays red after BringToFront and reopen"); + + // 2. Form XObject test: + // Page has: q 0 1 0 rg q 1 0 0 1 50 50 cm /Fm0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET + let form_content = "q 0 1 0 rg q 1 0 0 1 50 50 cm /Fm0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET"; + let mut form_doc = form_page_with(form_content); + + let form_pdf_before = write_incremental(&form_doc, &SaveOptions::default()).unwrap(); + let mut form_renderer_before = pdfcraft_render::PageRenderer::new(Arc::new(form_pdf_before), pdfcraft_render::RenderConfig::default()); + let form_page_before = form_renderer_before.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + let form_pixel_idx = (((form_page_before.height - 75) * form_page_before.width + 75) * 4) as usize; + assert_eq!(form_page_before.rgba[form_pixel_idx..form_pixel_idx + 3], [0, 255, 0], "form starts green"); + + arrange(&mut form_doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let form_doc = reopen(&form_doc); + + let form_pdf_after = write_incremental(&form_doc, &SaveOptions::default()).unwrap(); + let mut form_renderer_after = pdfcraft_render::PageRenderer::new(Arc::new(form_pdf_after), pdfcraft_render::RenderConfig::default()); + let form_page_after = form_renderer_after.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + assert_eq!(form_page_after.rgba[form_pixel_idx..form_pixel_idx + 3], [0, 255, 0], "form stays green after BringToFront and reopen"); +} /// Existing-row alignment is explicitly refused without mutating the paragraph. #[test] fn one_row_of_a_paragraph_takes_its_own_alignment_is_refused_without_changing_the_document() { @@ -8500,6 +9753,120 @@ fn a_run_of_spaces_in_a_subset_without_a_space_glyph_keeps_its_width() { assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}"); } +#[test] +fn image_exclusive_scope_resets_path_on_scope_exit() { + // An earlier scope has a path operation closed by Q (with 'h' closepath). + // The subsequent image in its own scope must cleanly extract and allow crop removal. + let content = "q 0 0 m 10 10 l h Q q 200 0 0 100 100 100 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + crop(&mut doc, 0, 0, [100.0, 100.0, 250.0, 180.0]).unwrap(); + let before = images::page_images(&doc, 0).unwrap(); + assert_eq!(before.len(), 1); + remove_crop(&mut doc, 0, 0).unwrap(); +} + +#[test] +fn content_units_does_not_swallow_subsequent_units_on_standalone_gs() { + // /GS0 gs followed by Q and subsequent images must isolate each Do unit properly + let content = "q /GS0 gs Q q 200 0 0 100 100 100 cm /Im0 Do Q q 10 0 0 10 300 300 cm /Im0 Do Q"; + let pieces = pdfcraft_content::Pieces::join(&[content.as_bytes()]); + let ops = pieces.parse().ops; + let units = images::content_units(&ops, 0, ops.len()); + assert_eq!(units.len(), 2); +} + +#[test] +fn different_inline_soft_masks_have_distinct_identities() { + let doc = Document::new_empty(); + let mut dict1 = Dict::default(); + let mut mask1 = Dict::default(); + mask1.set(b"Type".to_vec(), Object::Name(b"Mask1".to_vec())); + dict1.set(b"SMask".to_vec(), Object::Dict(mask1)); + + let mut dict2 = Dict::default(); + let mut mask2 = Dict::default(); + mask2.set(b"Type".to_vec(), Object::Name(b"Mask2".to_vec())); + dict2.set(b"SMask".to_vec(), Object::Dict(mask2)); + + let mut state1 = images::EffectiveGState::default(); + state1.merge_dict(&dict1, &doc, pdfcraft_content::Matrix::IDENTITY); + + let mut state2 = images::EffectiveGState::default(); + state2.merge_dict(&dict2, &doc, pdfcraft_content::Matrix::IDENTITY); + + assert!(state1.visually_differs(&state2), "two different inline soft masks must visually differ"); +} + +#[test] +fn is_entire_page_wrapped_with_nested_scopes() { + let wrapped = "q 1 0 0 1 0 0 cm q 0 0 10 10 re f Q 200 0 0 100 100 100 cm /Im0 Do Q"; + let pieces = pdfcraft_content::Pieces::join(&[wrapped.as_bytes()]); + let ops = pieces.parse().ops; + assert!(images::is_entire_page_wrapped(&ops), "page wrapped in outer q...Q with inner nested q...Q is wrapped"); + + let unwrapped = "q 1 0 0 1 0 0 cm Q q 200 0 0 100 100 100 cm /Im0 Do Q"; + let pieces2 = pdfcraft_content::Pieces::join(&[unwrapped.as_bytes()]); + let ops2 = pieces2.parse().ops; + assert!(!images::is_entire_page_wrapped(&ops2), "page with two separate top-level q...Q scopes is not wrapped"); +} + +#[test] +fn hostile_page_with_many_gs_and_nested_q_does_not_hang_or_oom() { + use std::fmt::Write as _; + let mut stream = String::new(); + for i in 0..10_000 { + let _ = write!(stream, "/G{i} gs "); + } + for _ in 0..500 { + stream.push_str("q "); + } + stream.push_str("100 0 0 100 0 0 cm /Im0 Do "); + for _ in 0..500 { + stream.push_str("Q "); + } + let doc = image_page_with(&stream); + let start = std::time::Instant::now(); + let imgs = images::page_images(&doc, 0).unwrap(); + assert_eq!(imgs.len(), 1); + assert!(start.elapsed().as_secs() < 5, "page_images on hostile 10k gs must finish in under 5s, took {:?}", start.elapsed()); +} + +#[test] +fn crop_image_with_leading_cm_in_scope_places_clip_before_internal_cm() { + let content = "q 10 0 0 10 50 50 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + let before = images::page_images(&doc, 0).unwrap(); + assert_eq!(before.len(), 1); + crop(&mut doc, 0, 0, [50.0, 50.0, 55.0, 55.0]).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert!(after[0].visible_rect.is_some()); + let (v0, v1, v2, v3) = + (after[0].visible_rect.unwrap()[0], after[0].visible_rect.unwrap()[1], after[0].visible_rect.unwrap()[2], after[0].visible_rect.unwrap()[3]); + assert!((v0 - 50.0).abs() < 1e-3 && (v2 - 55.0).abs() < 1e-3, "visible rect must match crop coordinates: {:?}", after[0].visible_rect); + assert!((v1 - 50.0).abs() < 1e-3 && (v3 - 55.0).abs() < 1e-3, "visible rect must match crop coordinates: {:?}", after[0].visible_rect); +} + +#[test] +fn remove_crop_preserves_cm_before_extgstate() { + let content = "q 2 0 0 2 0 0 cm /GS0 gs 0 0 100 100 re W n 10 0 0 10 50 50 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + let before = images::page_images(&doc, 0).unwrap(); + assert_eq!(before.len(), 1); + crop(&mut doc, 0, 0, [100.0, 100.0, 110.0, 110.0]).unwrap(); + remove_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let s = String::from_utf8_lossy(&bytes); + assert!(s.contains("W n"), "the author clipping path must remain: {s}"); + assert!(!s.contains("PdfCraftImageCrop"), "the editor crop must be removed: {s}"); + // Verify that the initial cm appears before /GS0 gs + let cm_pos = s.find("2 0 0 2 0 0 cm").expect("cm must be preserved"); + let gs_pos = s.find("/GS0 gs").expect("gs must be preserved"); + assert!(cm_pos < gs_pos, "cm must precede /GS0 gs so soft mask space is preserved: {s}"); +} + /// Inside a text object `q`/`Q` save and restore the text and text line matrices (ISO 32000-2 /// §9.4.1, as corrected by the PDF Association's errata, issue 368), as the renderer does: the /// text after `Q` is found, and stays, where it is drawn. @@ -8576,3 +9943,363 @@ fn styled_existing_justification_is_refused_instead_of_silently_left_aligning() assert_eq!(page_content_bytes(&doc, 0), before); assert_eq!(page_content_bytes(&reopen(&doc), 0), before); } + +#[test] +fn image_arrange_refuses_crossed_scope_boundaries_without_changes() { + for content in ["/P <> BDC q 10 0 0 10 0 0 cm /Im0 Do EMC Q /Im0 Do", "q /P <> BDC /Im0 Do Q EMC /Im0 Do"] { + let mut doc = image_page_with(content); + let before = page_content_bytes(&doc, 0); + assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).is_err(), "{content}"); + assert_eq!(page_content_bytes(&doc, 0), before); + } +} + +#[test] +fn removing_a_crop_preserves_author_clips_and_works_under_outer_clip() { + for content in ["0 0 300 300 re W n q 10 0 0 10 50 50 cm /Im0 Do Q", "q 50 50 8 8 re W n 10 0 0 10 50 50 cm /Im0 Do Q"] { + let mut doc = image_page_with(content); + let original = images::page_images(&doc, 0).unwrap()[0].clone(); + crop(&mut doc, 0, 0, [52.0, 52.0, 55.0, 55.0]).unwrap(); + let mut doc = reopen(&doc); + remove_crop(&mut doc, 0, 0).unwrap(); + let mut doc = reopen(&doc); + let restored = images::page_images(&doc, 0).unwrap()[0].clone(); + assert_eq!(restored.rect, original.rect); + assert_eq!(restored.visible_rect, original.visible_rect); + assert_eq!(restored.contains([59.0, 59.0]), original.contains([59.0, 59.0])); + assert!(remove_crop(&mut doc, 0, 0).is_err()); + } +} + +#[test] +fn remove_crop_refuses_an_unmarked_author_clip_without_changes() { + let mut doc = image_page_with("q 50 50 8 8 re W n 10 0 0 10 50 50 cm /Im0 Do Q"); + let before = page_content_bytes(&doc, 0); + assert!(remove_crop(&mut doc, 0, 0).is_err()); + assert_eq!(page_content_bytes(&doc, 0), before); +} + +#[test] +fn arrange_refuses_unrestorable_extended_print_state() { + let mut unexpectedly_moved = Vec::new(); + for (key, value) in [ + ("TR", Object::name("Identity")), + ("TR2", Object::name("Default")), + ("OP", Object::Bool(true)), + ("op", Object::Bool(true)), + ("OPM", Object::Int(1)), + ("BG", Object::name("Default")), + ("BG2", Object::name("Default")), + ("UCR", Object::name("Default")), + ("UCR2", Object::name("Default")), + ("HT", Object::name("Default")), + ] { + let mut doc = image_page_with("q 100 0 0 100 50 50 cm /Im0 Do Q /Print gs BT /F1 12 Tf 20 700 Td (Text) Tj ET"); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + resources.set( + b"ExtGState".to_vec(), + Object::Dict(Dict::from_iter([(b"Print".to_vec(), Object::Dict(Dict::from_iter([(key.as_bytes().to_vec(), value)])))])), + ); + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + let before = page_content_bytes(&doc, 0); + match arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront) { + Ok(()) => unexpectedly_moved.push(key), + Err(error) => { + assert!(error.to_string().contains("graphics state"), "{key}: {error}"); + assert_eq!(page_content_bytes(&doc, 0), before, "{key} refusal changes nothing"); + } + } + } + assert!(unexpectedly_moved.is_empty(), "unrestorable destination state was accepted: {unexpectedly_moved:?}"); +} + +#[test] +fn arrange_keeps_implicit_device_spaces_before_sc_and_scn() { + for (select, color) in [("0 g", "0.5 sc"), ("0 0 0 rg", "1 0 0 sc"), ("0 0 0 1 k", "0 1 1 0 scn")] { + for stroke in [false, true] { + let (select, color) = if stroke { (select.to_uppercase(), color.to_uppercase()) } else { (select.into(), color.into()) }; + for content in [ + format!("q {select} {color} q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET"), + format!("q {select} q {color} 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET"), + ] { + let mut doc = stencil_page_with(&content); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let ops = pdfcraft_content::parse(&bytes).ops; + let do_index = ops.iter().rposition(|op| op.is("Do")).unwrap(); + let start = ops[..do_index].iter().rposition(|op| op.is("q")).unwrap(); + let relocated = &ops[start..do_index]; + let selector = select.split_whitespace().last().unwrap(); + let color_op = color.split_whitespace().last().unwrap(); + let space_index = relocated.iter().position(|op| op.is(selector)); + let color_index = relocated.iter().position(|op| op.is(color_op)); + assert!( + space_index.is_some() && space_index < color_index, + "{select} must select its device space before {color}: {}", + String::from_utf8_lossy(&bytes) + ); + } + } + } +} + +#[test] +fn arrange_restores_initial_rendering_intent_from_ri_and_gs() { + for selection in ["/Saturation ri", "/Print gs"] { + let mut doc = image_page_with(&format!("q 100 0 0 100 50 50 cm /Im0 Do Q {selection} BT /F1 12 Tf 20 700 Td (Text) Tj ET")); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + resources.set( + b"ExtGState".to_vec(), + Object::Dict(Dict::from_iter([(b"Print".to_vec(), Object::Dict(Dict::from_iter([(b"RI".to_vec(), Object::name("Saturation"))])))])), + ); + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + assert!( + String::from_utf8_lossy(&bytes).contains("/RelativeColorimetric ri"), + "default rendering intent must be restored after {selection}: {}", + String::from_utf8_lossy(&bytes) + ); + } +} + +#[test] +fn editor_crop_moves_with_repeated_group_translation() { + let mut doc = image_page(); + let caption_rect = text::text_lines(&doc, 0).unwrap()[0].rect; + crop(&mut doc, 0, 0, [150.0, 120.0, 250.0, 170.0]).unwrap(); + let start = images::page_images(&doc, 0).unwrap()[0].clone(); + let mut count = 0; + for iteration in 0..20 { + images::translate_images(&mut doc, 0, &[0], [1.0, -2.0]).unwrap(); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + if iteration == 0 { + count = ops.len(); + } + assert_eq!(ops.len(), count, "repeated moves fold the same transform"); + } + let shifted = |r: [f64; 4]| [r[0] + 20.0, r[1] - 40.0, r[2] + 20.0, r[3] - 40.0]; + let doc = reopen(&doc); + let after = &images::page_images(&doc, 0).unwrap()[0]; + assert!(close(after.rect, shifted(start.rect))); + assert!( + close(after.visible_rect.unwrap(), shifted(start.visible_rect.unwrap())), + "the editor crop moves with its pixels: {:?}", + after.visible_rect + ); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Caption"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].rect, caption_rect); +} + +#[test] +fn arranging_original_image_keeps_it_independent_of_added_items() { + for update in [false, true] { + let mut doc = image_page(); + let caption_rect = text::text_lines(&doc, 0).unwrap()[0].rect; + add_content( + &mut doc, + 0, + &Content::Text(AddedText { rect: [20.0, 250.0, 250.0, 290.0], text: "Added caption".into(), ..AddedText::default() }), + ) + .unwrap(); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let mut doc = reopen(&doc); + assert_eq!(list_added(&doc).len(), 1, "the added item stays editable"); + if update { + update_content( + &mut doc, + 0, + 0, + &Content::Text(AddedText { rect: [20.0, 250.0, 250.0, 290.0], text: "Updated caption".into(), ..AddedText::default() }), + ) + .unwrap(); + } else { + delete_content(&mut doc, 0, 0).unwrap(); + } + let doc = reopen(&doc); + assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1, "editing/deleting an added item must preserve arranged original artwork"); + assert_eq!(images::page_images(&doc, 0).unwrap()[0].rect, [100.0, 100.0, 300.0, 200.0]); + let text = text::text_lines(&doc, 0).unwrap().into_iter().map(|line| line.text).collect::>(); + assert!(text.iter().any(|line| line == "Caption"), "the original caption survives"); + assert_eq!(text.iter().any(|line| line == "Updated caption"), update); + assert_eq!(text::text_lines(&doc, 0).unwrap().iter().find(|line| line.text == "Caption").unwrap().rect, caption_rect); + } +} + +#[test] +fn arranging_nested_marked_image_refuses_without_losing_tags() { + for marked in ["/Figure <> BDC", "/Figure BMC"] { + let mut doc = image_page_with(&format!( + "q {marked} 100 0 0 100 50 50 cm /Im0 Do EMC BT /F1 12 Tf 20 700 Td (Text) Tj ET Q BT /F1 12 Tf 20 720 Td (Outside) Tj ET" + )); + let before = page_content_bytes(&doc, 0); + let error = arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap_err(); + assert!(error.to_string().contains("marked-content"), "{error}"); + assert_eq!(page_content_bytes(&doc, 0), before); + assert_eq!(page_content_bytes(&reopen(&doc), 0), before); + } +} + +#[test] +fn editor_crop_group_move_keeps_an_outer_author_clip_fixed() { + let mut doc = image_page_with("0 0 220 300 re W n q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 10 250 Td (Caption) Tj ET"); + crop(&mut doc, 0, 0, [150.0, 120.0, 210.0, 170.0]).unwrap(); + let mut operators = 0; + for i in 0..20 { + images::translate_images(&mut doc, 0, &[0], [1.0, -2.0]).unwrap(); + let count = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops.len(); + if i == 0 { + operators = count; + } + assert_eq!(count, operators, "moves under an inherited clip must fold their transform"); + } + let doc = reopen(&doc); + let image = &images::page_images(&doc, 0).unwrap()[0]; + assert!(close(image.rect, [120.0, 60.0, 320.0, 160.0])); + assert!( + close(image.visible_rect.unwrap(), [170.0, 80.0, 220.0, 130.0]), + "the editor crop moves but its author's outer clip stays fixed: {:?}", + image.visible_rect + ); + let bytes = page_content_bytes(&doc, 0); + assert!(String::from_utf8_lossy(&bytes).starts_with("0 0 220 300 re W n")); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Caption"); +} + +fn review_state_raster(doc: &Document) -> Vec { + let bytes = write_full(doc, &SaveOptions::default()).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), Default::default()); + renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }).rgba.to_vec() +} + +#[test] +fn review_arrange_form_keeps_inherited_text_spacing() { + for (spacing, later_spacing) in [(0, false), (12, false), (0, true)] { + let initial = if later_spacing { String::new() } else { format!("BT {spacing} Tc ET") }; + let later = if later_spacing { "BT 12 Tc ET" } else { "" }; + let content = format!("q {initial} q 1 0 0 1 20 20 cm /Fm0 Do Q Q {later} q 0 0 1 rg 200 200 5 5 re f Q"); + let mut doc = form_page_with(&content); + let form = pdfcraft_cos::ObjRef::new(6, 0); + let dict = doc.get(form).as_dict().unwrap().clone(); + doc.set(form, Object::Stream(pdfcraft_cos::Stream::from_raw(dict, b"BT /F1 20 Tf 0 15 Td (ABC) Tj ET".to_vec()))); + let doc_before = reopen(&doc); + let before = review_state_raster(&doc_before); + assert!(before.as_chunks::<4>().0.iter().any(|p| p[0] == 0 && p[1] == 0 && p[2] == 0), "the Form actually paints text"); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let saved = reopen(&doc); + assert_eq!(images::page_images(&saved, 0).unwrap()[0].rect, images::page_images(&doc_before, 0).unwrap()[0].rect); + assert_eq!(review_state_raster(&saved), before, "inherited {spacing} Tc must survive arranging the Form and reopening"); + } +} + +fn review_shared_mask_page(states: &str, luminosity_b: bool, mask_paint: &str) -> Document { + let content = format!("q {states} q 100 0 0 100 20 20 cm /Im0 Do Q Q q 0 0 1 rg 200 200 5 5 re f Q"); + let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()); + let subtype = if luminosity_b { "Luminosity" } else { "Alpha" }; + let mask_b = format!("<< /Type /ExtGState /SMask << /S /{subtype} /G 8 0 R >> >>"); + let group = format!( + "<< /Type /XObject /Subtype /Form /BBox [0 0 300 300] /Group << /S /Transparency /I true /CS /DeviceGray >> /Length {} >>\nstream\n{mask_paint}\nendstream", + mask_paint.len() + ); + build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> /ExtGState << /A 6 0 R /B 7 0 R >> >> >>", + &stream, + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\0\0\0\0\nendstream", + "<< /Type /ExtGState /SMask << /S /Alpha /G 8 0 R >> >>", + &mask_b, + &group, + ]) +} + +#[test] +fn review_repeated_gs_keeps_alpha_vs_luminosity_mask_chronology() { + for states in ["/A gs", "/A gs /B gs", "/A gs /B gs /A gs"] { + let mut doc = review_shared_mask_page(states, true, "0.25 g 0 0 300 300 re f"); + let before = review_state_raster(&reopen(&doc)); + assert!(before.as_chunks::<4>().0.iter().any(|p| p[0] == p[1] && p[1] == p[2] && p[0] < 230), "the masked image is visible"); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + assert_eq!(review_state_raster(&reopen(&doc)), before, "mask application order must survive: {states}"); + } +} + +#[test] +fn review_repeated_gs_keeps_last_mask_establishment_ctm() { + for states in [ + "1 0 0 1 50 0 cm /A gs 1 0 0 1 -50 0 cm", + "/A gs 1 0 0 1 50 0 cm /B gs 1 0 0 1 -50 0 cm", + "/A gs 1 0 0 1 50 0 cm /B gs 1 0 0 1 -50 0 cm /A gs", + ] { + let mut doc = review_shared_mask_page(states, false, "0.25 g 0 0 60 300 re f"); + let before = review_state_raster(&reopen(&doc)); + arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + assert_eq!(review_state_raster(&reopen(&doc)), before, "mask CTM must survive: {states}"); + } +} + +#[test] +fn review_soft_masks_sharing_a_group_keep_distinct_configurations() { + let doc = Document::new_empty(); + let mask = |subtype: &str| { + Dict::from_iter([( + b"SMask".to_vec(), + Object::Dict(Dict::from_iter([(b"S".to_vec(), Object::name(subtype)), (b"G".to_vec(), Object::Ref(pdfcraft_cos::ObjRef::new(8, 0)))])), + )]) + }; + let mut alpha = images::EffectiveGState::default(); + let mut luminosity = images::EffectiveGState::default(); + alpha.merge_dict(&mask("Alpha"), &doc, pdfcraft_content::Matrix::IDENTITY); + luminosity.merge_dict(&mask("Luminosity"), &doc, pdfcraft_content::Matrix::IDENTITY); + assert!(alpha.visually_differs(&luminosity), "a shared group does not make Alpha and Luminosity the same mask"); + for (key, value) in [ + (b"BC".to_vec(), Object::Array(vec![Object::Real(0.5)])), + ( + b"TR".to_vec(), + Object::Dict(Dict::from_iter([ + (b"FunctionType".to_vec(), Object::Int(2)), + (b"Domain".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(1)])), + (b"N".to_vec(), Object::Int(2)), + ])), + ), + ] { + let mut changed = mask("Luminosity"); + let Object::Dict(definition) = changed.get_mut(b"SMask").unwrap() else { panic!("mask dictionary") }; + definition.set(key, value); + let mut state = images::EffectiveGState::default(); + state.merge_dict(&changed, &doc, pdfcraft_content::Matrix::IDENTITY); + assert!(state.visually_differs(&luminosity), "mask backdrop and transfer function belong to its definition"); + } +} + +#[test] +fn review_inside_scope_group_move_matches_direct_transform_under_outer_scale() { + for scale in [1, 2] { + let content = format!("{scale} 0 0 {scale} 0 0 cm q 40 0 0 40 10 10 cm /Im0 Do Q q 0 0 1 rg 75 75 5 5 re f Q"); + let mut cropped = image_page_with(&content); + let keep = if scale == 2 { [30.0, 30.0, 70.0, 70.0] } else { [15.0, 15.0, 35.0, 35.0] }; + crop(&mut cropped, 0, 0, keep).unwrap(); + arrange(&mut cropped, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + for offset in [[10.0, 0.0], [0.0, 10.0], [-10.0, 3.0]] { + let mut grouped = reopen(&cropped); + let mut direct = reopen(&cropped); + let before = images::page_images(&grouped, 0).unwrap()[0].clone(); + images::translate_images(&mut grouped, 0, &[0], offset).unwrap(); + images::change_image(&mut direct, 0, 0, &images::ImageChange::Transform([1.0, 0.0, 0.0, 1.0, offset[0], offset[1]])).unwrap(); + let moved = |r: [f64; 4]| [r[0] + offset[0], r[1] + offset[1], r[2] + offset[0], r[3] + offset[1]]; + let saved = reopen(&grouped); + let image = &images::page_images(&saved, 0).unwrap()[0]; + assert!(close(image.rect, moved(before.rect)), "scale {scale} offset {offset:?}: {:?}", image.rect); + assert!(close(image.visible_rect.unwrap(), moved(before.visible_rect.unwrap())), "the crop follows the complete displacement"); + assert_eq!( + review_state_raster(&saved), + review_state_raster(&reopen(&direct)), + "group and direct transforms must agree, preserving other artwork" + ); + } + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 98824c7694..0599b8ed38 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -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}; diff --git a/crates/edit/src/text/fallback.rs b/crates/edit/src/text/fallback.rs index b8f45a221f..78f9014a20 100644 --- a/crates/edit/src/text/fallback.rs +++ b/crates/edit/src/text/fallback.rs @@ -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 } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 6dae6adf0a..b8b9d8868c 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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 } diff --git a/crates/engine/src/copy_image.rs b/crates/engine/src/copy_image.rs index b377903ee4..53b890f27c 100644 --- a/crates/engine/src/copy_image.rs +++ b/crates/engine/src/copy_image.rs @@ -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 { 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(), diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index d28429de6f..91b33ad017 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -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>, }, 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 diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index c3339453af..afc6056e85 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -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"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index e7addf7b96..d7110b991e 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -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> { @@ -255,6 +256,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 { @@ -534,40 +698,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)?; diff --git a/crates/engine/src/layers.rs b/crates/engine/src/layers.rs index c2f4f63714..f75967097c 100644 --- a/crates/engine/src/layers.rs +++ b/crates/engine/src/layers.rs @@ -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 diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7cd2f96a96..fe139164be 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs index 5296e4c550..128accfc1f 100644 --- a/crates/engine/src/open.rs +++ b/crates/engine/src/open.rs @@ -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| { diff --git a/crates/engine/src/pictures.rs b/crates/engine/src/pictures.rs index b29941720a..49d76f07c0 100644 --- a/crates/engine/src/pictures.rs +++ b/crates/engine/src/pictures.rs @@ -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 { + 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); + } +} diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 1051903bae..9a4cb04dac 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -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; diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index e49e0d0029..49ca225b0c 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -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> { @@ -173,6 +182,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())); @@ -390,7 +404,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(()) } @@ -414,6 +428,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| { @@ -447,7 +462,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(()) @@ -474,7 +489,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. @@ -531,7 +546,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(()) } diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs index ca3870418a..1cf60eb887 100644 --- a/crates/engine/src/session/save.rs +++ b/crates/engine/src/session/save.rs @@ -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 { @@ -172,7 +172,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. @@ -251,7 +251,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)); } } diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs index 055437f562..c0e7417c8e 100644 --- a/crates/engine/src/session/view.rs +++ b/crates/engine/src/session/view.rs @@ -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 } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d6cc75711f..3bd62de781 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -33,6 +33,39 @@ pub(crate) fn fixture(n: usize) -> Vec { out } +fn fixture_with_rotation_and_user_unit(rotate: i64, user_unit: f64) -> Vec { + let mut objs: Vec> = 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"]); @@ -8197,6 +8305,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 @@ -9214,6 +9329,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 = + prefix.split_whitespace().rev().take(4).collect::>().into_iter().rev().map(|val| val.parse::().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 @@ -9505,3 +10088,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); + } +} diff --git a/crates/engine/src/text_fonts.rs b/crates/engine/src/text_fonts.rs index 859bc1a6f8..e2b413d88d 100644 --- a/crates/engine/src/text_fonts.rs +++ b/crates/engine/src/text_fonts.rs @@ -32,17 +32,29 @@ pub fn installed_directories() -> Vec { } } -/// 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 { + 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 { 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 { 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::>(); + assert_eq!(run1.iter().map(|f| f.face.as_str()).collect::>(), ["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::>(), ["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"); + } +} diff --git a/crates/engine/src/text_script.rs b/crates/engine/src/text_script.rs new file mode 100644 index 0000000000..04866eda60 --- /dev/null +++ b/crates/engine/src/text_script.rs @@ -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 { + 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 { + 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>) -> Range { + 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>, 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>, script: Script) -> Option<(String, Range)> { + 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)); + } +} diff --git a/crates/fonts/README.md b/crates/fonts/README.md index 36b305bd9d..b20b930a91 100644 --- a/crates/fonts/README.md +++ b/crates/fonts/README.md @@ -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. diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 2e3ea2d42f..683cbd0684 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -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(); diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 28202a38b4..71467dfeb2 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -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 } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 954823ef09..6f9a3ac319 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -13,6 +13,7 @@ mod pixels; mod raster; mod reading; mod structure; +mod system_font; pub mod text; pub mod transition; diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index c002c6ec92..f973851d26 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -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 { + // 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))) { diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 11266f057e..df6896b980 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -7040,6 +7040,146 @@ fn test_clip_only_text_raster_preserves_clipping_without_painting() { assert!(pixels.contains(&white), "the clip is text-shaped, not the whole page"); } +#[cfg(target_os = "windows")] +mod gbk_glyphs; + +/// Chinese and Korean CID fonts that aren't embedded (Adobe-CNS1 `MSung-Light`, Adobe-GB1 +/// `STSong-Light`, Adobe-Korea1 `HYGoThic-Medium`; Acrobat also writes Japanese `KozGoPr6N` +/// text boxes without the font) drew nothing, because craft-fonts has only Japanese faces. +/// They now draw in a face already installed on the machine, read at runtime. A machine +/// without such a face draws nothing, as before, so each check is skipped there. +#[test] +fn non_embedded_chinese_and_korean_cid_fonts_draw_with_an_installed_face() { + use hayro::hayro_interpret::font::FallbackFontQuery; + use hayro::hayro_interpret::hayro_cmap::CharacterCollection; + // (base font, collection, ordering, encoding, encoded text, descendant subtype) + let cases = [ + ("MSung-Light", CidFamily::AdobeCNS1, "CNS1", "UniCNS-UCS2-H", "7E419AD45B57", "CIDFontType0"), // 繁體字 + ("STSong-Light", CidFamily::AdobeGB1, "GB1", "UniGB-UCS2-H", "72698D448DF7", "CIDFontType0"), // 物资跷 + ("HYGoThic-Medium", CidFamily::AdobeKorea1, "Korea1", "UniKS-UCS2-H", "D55CAE00", "CIDFontType0"), // 한글 + // The real Windows book uses non-embedded TrueType CID fonts and a legacy GBK CMap, + // without ToUnicode. These bytes encode 中文; they are not Unicode code points. + ("SimSun", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"), + ("SimHei", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"), + ("KaiTi", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"), + ("FangSong", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"), + ("ABCDEF+FZSSK--GBK1-0", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"), + ("ABCDEF+FZHTK--GBK1-0", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"), + ("ABCDEF+FZKTK--GBK1-0", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"), + ("ABCDEF+FZFSK--GBK1-0", CidFamily::AdobeGB1, "GB1", "GBK-EUC-H", "D6D0CEC4", "CIDFontType2"), + ]; + for (base_font, family, ordering, encoding, code, subtype) in cases { + let query = FontQuery::Fallback(FallbackFontQuery { + post_script_name: Some(base_font.into()), + character_collection: Some(CharacterCollection { family, supplement: 2 }), + ..FallbackFontQuery::default() + }); + if super::system_fallback(&query).is_none() { + eprintln!("no installed face for {base_font} (or PDFCRAFT_SYSTEM_FONTS=0): skipped"); + continue; + } + let content = format!("BT /F1 40 Tf 5 15 Td <{code}> 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 /{base_font} /Encoding /{encoding} /DescendantFonts [6 0 R] >> endobj +6 0 obj << /Type /Font /Subtype /{subtype} /BaseFont /{base_font} /CIDSystemInfo << /Registry (Adobe) /Ordering ({ordering}) /Supplement 2 >> + /FontDescriptor 7 0 R /DW 1000 >> endobj +7 0 obj << /Type /FontDescriptor /FontName /{base_font} /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 pdf = Arc::new(pdf.into_bytes()); + let ink = |config: RenderConfig| { + let mut r = PageRenderer::new(pdf.clone(), config); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(p.error.is_none(), "{base_font}: {:?}", p.error); + p.rgba.as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count() + }; + // Two to four glyphs at 40 pt cover several hundred dark pixels; a blank run has none. + let shown = ink(RenderConfig { system_fonts: true, ..RenderConfig::default() }); + assert!(shown > 300, "{base_font}: the text is drawn ({shown} dark pixels)"); + // Off by default: exports, printing and OCR render without an installed font, so + // nothing drawn with one can be saved (AGENTS.md §1.2, §1.4). + let saved = ink(RenderConfig::default()); + assert!(saved < 50, "{base_font}: not drawn without system_fonts ({saved} dark pixels)"); + if encoding == "GBK-EUC-H" { + #[cfg(target_os = "windows")] + gbk_glyphs::assert_expected_glyphs(&pdf, base_font); + let mut renderer = PageRenderer::new(pdf, RenderConfig { system_fonts: true, ..Default::default() }); + let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() }); + assert!(text.error.is_none(), "{base_font}: {:?}", text.error); + assert_eq!(text.text.unwrap().plain_text().trim(), "中文", "the fallback preserves GBK text mapping"); + } + } +} + +/// Print preview must match output even after the same worker cached an installed face. +/// Switching back to the viewer must not retain the print resolver's missing-font result. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn print_preview_excludes_installed_fonts_without_poisoning_viewer_cache() { + use hayro::hayro_interpret::font::FallbackFontQuery; + use hayro::hayro_interpret::hayro_cmap::CharacterCollection; + let query = FontQuery::Fallback(FallbackFontQuery { + post_script_name: Some("SimSun".into()), + character_collection: Some(CharacterCollection { family: CidFamily::AdobeGB1, supplement: 2 }), + ..Default::default() + }); + if super::system_fallback(&query).is_none() { + eprintln!("no installed GB1 face (or PDFCRAFT_SYSTEM_FONTS=0): skipped"); + return; + } + let content = "BT /F1 40 Tf 5 15 Td Tj ET"; + let bytes = Arc::new(format!( + "%PDF-1.7\n\ + 1 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 120 60] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type0 /BaseFont /SimSun /Encoding /GBK-EUC-H /DescendantFonts [6 0 R] >> endobj\n\ + 6 0 obj << /Type /Font /Subtype /CIDFontType2 /BaseFont /SimSun /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> /FontDescriptor 7 0 R /DW 1000 >> endobj\n\ + 7 0 obj << /Type /FontDescriptor /FontName /SimSun /Flags 6 /FontBBox [0 -141 1000 859] /ItalicAngle 0 /Ascent 859 /Descent -141 /CapHeight 700 /StemV 80 >> endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", content.len() + ).into_bytes()); + let config = RenderConfig::default().on_screen(); + let mut inline = PageRenderer::new(bytes.clone(), config.clone()); + let mut output = PageRenderer::new(bytes.clone(), RenderConfig::default()); + let pool = RenderPool::new(bytes, 1, config); + for tile in [None, Some(Tile { x: 0, y: 0, w: 120, h: 60 })] { + // Cover both directions with a warm worker cache, including other caller tag bits. + for tag in [PRINT_TAG | 1, 2, PRINT_TAG | 3, 4] { + let req = RenderRequest { page: 0, scale: 1.0, tile, tag, ..Default::default() }; + let truth = output.render(req); + assert!(truth.error.is_none(), "{:?}", truth.error); + pool.set_queue(vec![req]); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + let worker = loop { + if let Some(page) = pool.try_recv() { + break page; + } + assert!(std::time::Instant::now() < deadline, "print-preview worker timed out"); + std::thread::sleep(std::time::Duration::from_millis(5)); + }; + for page in [inline.render(req), worker] { + assert!(page.error.is_none(), "{:?}", page.error); + if tag & PRINT_TAG != 0 { + assert_eq!(page.rgba, truth.rgba, "print preview must match output, tile={tile:?}"); + } else { + let ink = page.rgba.as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count(); + assert!(ink > 300, "viewer still draws installed Chinese glyphs: {ink}"); + } + } + } + } +} + /// A malformed token in a content stream used to end the stream there, so the renderer, text /// extraction, Find and search-based redaction lost the rest of the page while the edit /// reader, MuPDF and pdftotext read it (#3565, #3568). Each token below is now stepped over. diff --git a/crates/render/src/raster/tests/gbk_glyphs.rs b/crates/render/src/raster/tests/gbk_glyphs.rs new file mode 100644 index 0000000000..8443fed624 --- /dev/null +++ b/crates/render/src/raster/tests/gbk_glyphs.rs @@ -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>, 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::() == 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> 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("")); + let reference = source + .replace("/Encoding /GBK-EUC-H", "/Encoding /Identity-H /ToUnicode 8 0 R") + .replace("", "<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"); +} diff --git a/crates/render/src/system_font.rs b/crates/render/src/system_font.rs new file mode 100644 index 0000000000..8fcd167415 --- /dev/null +++ b/crates/render/src/system_font.rs @@ -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 { + 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>, u32)>; + type Faces = HashMap<(Script, bool, ChineseFamily), Face>; + static CACHE: Mutex> = 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>, 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>> { + /// The bytes of each file tried, or `None` for one that cannot be used. + type Files = HashMap>>>; + static FILES: Mutex> = 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> { + // 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 { + 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::()).collect::>(); + 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 { + 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 = 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*/.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> = 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 = 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 { + 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() + ); + } + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 804b5ea22a..7f164991d5 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -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 } diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index f40007fb2f..3116b01db3 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -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`). //! diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 425c2e26ce..abfecba94f 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -91,6 +91,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")] @@ -133,9 +134,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 +230,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 +359,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 { diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index 6641d93a86..9319368d29 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -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), +} + 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, 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) -> 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()); + } +} diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 79bd2fa258..85fdae38af 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -32,6 +32,9 @@ impl PdfCraftApp { }; self.set_theme_preference(preference); } + ("show_page_edge_shadows", _) => { + self.show_page_edge_shadows = value.parse::().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")?; } diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 3c68dc5619..5ef110d6aa 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -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(()); } @@ -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 { diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs index 6021af64d8..c014453806 100644 --- a/crates/ui-egui/src/app/keys.rs +++ b/crates/ui-egui/src/app/keys.rs @@ -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); } } diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index e5eaae5863..7d3fd6b948 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -141,8 +141,10 @@ impl PdfCraftApp { v["default_page_gaps"] = serde_json::Value::Bool(self.view_defaults.show_page_gaps); 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); @@ -206,6 +208,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::(v["theme"].clone()) { self.set_theme_preference(preference); } @@ -267,6 +281,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; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8b280ba75d..7a0976644b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -84,6 +84,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, fill: Color32, stroke: Stroke) -> egui::layers::ShapeIdx; +} + +impl PainterExt for egui::Painter { + fn polygon(&self, points: Vec, 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)] @@ -569,6 +738,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) { + 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 { @@ -862,6 +1129,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, @@ -885,6 +1153,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. @@ -902,6 +1171,7 @@ struct PageOut { open_initials: bool, open_signature: bool, open_table: Option<(usize, [f64; 4])>, + page_action: Option, primary_down: bool, refused: Option, stamp_placed: bool, @@ -945,6 +1215,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; @@ -952,7 +1286,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); @@ -987,13 +1321,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 => { @@ -1004,14 +1358,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; @@ -1040,7 +1401,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. @@ -1065,13 +1426,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, ); } } @@ -1326,9 +1690,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() @@ -1366,17 +1739,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, @@ -1425,6 +1801,19 @@ impl PageHits<'_> { images, clip, } = *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 { @@ -1934,6 +2323,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, @@ -1967,6 +2357,8 @@ fn draw_pages( prefs, preparing, selection_wheel, + ppp, + rects, single_paging, tool, unobstructed, @@ -2033,6 +2425,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, @@ -2062,20 +2486,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 = None; let painter = ui.painter(); if calc_order { view.calc_order.set_hover_page(calc_hover); @@ -2195,20 +2621,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::(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)>(key)).filter(|c| c.0 == page && c.1 == at); @@ -2232,9 +2671,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, @@ -2248,6 +2706,7 @@ fn draw_pages( open_initials, open_signature, open_table, + page_action, primary_down: ptr.primary_down, refused, stamp_placed, @@ -2358,6 +2817,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 @@ -2991,6 +3451,14 @@ fn refine_goto_point(mut scroll: egui::ScrollArea, view: &mut DocView, geometry: /// scroll area with this frame's zoom, scrolling, tool input, overlays 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 { @@ -3049,7 +3517,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; } @@ -3204,6 +3672,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, @@ -3227,9 +3696,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 { @@ -3245,6 +3715,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, @@ -3284,8 +3755,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); @@ -3331,6 +3806,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, @@ -3352,6 +3828,7 @@ struct FrameRequests { field_props: Option<(String, usize)>, field_menu: Option, image_action: Option, + page_action: Option, refused: Option, open_signature: bool, open_initials: bool, @@ -3366,12 +3843,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, @@ -3435,12 +3914,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 { diff --git a/crates/ui-egui/src/canvas/area.rs b/crates/ui-egui/src/canvas/area.rs index 068661ea5f..0d934dc8c9 100644 --- a/crates/ui-egui/src/canvas/area.rs +++ b/crates/ui-egui/src/canvas/area.rs @@ -32,14 +32,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( 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 { - 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). diff --git a/crates/ui-egui/src/canvas/cache.rs b/crates/ui-egui/src/canvas/cache.rs index a0ad8fb335..4b6f6c2e42 100644 --- a/crates/ui-egui/src/canvas/cache.rs +++ b/crates/ui-egui/src/canvas/cache.rs @@ -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) } diff --git a/crates/ui-egui/src/canvas/image_pick.rs b/crates/ui-egui/src/canvas/image_pick.rs index 403e9836c0..cc938170da 100644 --- a/crates/ui-egui/src/canvas/image_pick.rs +++ b/crates/ui-egui/src/canvas/image_pick.rs @@ -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")] diff --git a/crates/ui-egui/src/canvas/organize.rs b/crates/ui-egui/src/canvas/organize.rs index 7f333b3e46..48c7e82d8f 100644 --- a/crates/ui-egui/src/canvas/organize.rs +++ b/crates/ui-egui/src/canvas/organize.rs @@ -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) = (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)); diff --git a/crates/ui-egui/src/canvas/page_pointer.rs b/crates/ui-egui/src/canvas/page_pointer.rs index f0df1d92f1..02cb2208ed 100644 --- a/crates/ui-egui/src/canvas/page_pointer.rs +++ b/crates/ui-egui/src/canvas/page_pointer.rs @@ -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`. diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 06d3c48568..8b91cc865c 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -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, diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 634a885111..4761584735 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -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:?}"); + } + } + } +} diff --git a/crates/ui-egui/src/canvas/xform.rs b/crates/ui-egui/src/canvas/xform.rs index 91a3739d62..0f6249c8a8 100644 --- a/crates/ui-egui/src/canvas/xform.rs +++ b/crates/ui-egui/src/canvas/xform.rs @@ -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 { + pub fn page_xform(&self, page: usize) -> Option { 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); + } } diff --git a/crates/ui-egui/src/canvas/zoom_snap.rs b/crates/ui-egui/src/canvas/zoom_snap.rs index 5a0dcf6733..159adf2b7e 100644 --- a/crates/ui-egui/src/canvas/zoom_snap.rs +++ b/crates/ui-egui/src/canvas/zoom_snap.rs @@ -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)) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 43c41889e5..c3c730ed68 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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 diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs index 1166c0e7ec..64b10b69fb 100644 --- a/crates/ui-egui/src/control/host.rs +++ b/crates/ui-egui/src/control/host.rs @@ -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 })), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d24e62cb7d..6786ac6b0c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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 } @@ -1506,7 +1512,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::(tab_id)).unwrap_or(0); ui.horizontal_wrapped(|ui| { @@ -1553,6 +1559,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::>().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 +1660,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; diff --git a/crates/ui-egui/src/dialogs/credits.rs b/crates/ui-egui/src/dialogs/credits.rs index d509a3b0eb..ad45ec77b6 100644 --- a/crates/ui-egui/src/dialogs/credits.rs +++ b/crates/ui-egui/src/dialogs/credits.rs @@ -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; diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index bdf8729e0c..acd29d121d 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -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::>().len(); - let to = to.min(info.pages.len() - n); + let to = (*to).min(info.pages.len() - n); view.select_pages(&(to..to + n).collect::>()); } Edit::DuplicatePages { pages, at: Some(at) } => { let n = pages.iter().collect::>().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::>()); } - 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)]); @@ -840,6 +887,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 = 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 = + 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 = 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 { diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 390b059228..957fe661fd 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -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::>().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::>().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,6 +150,36 @@ 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); @@ -229,6 +277,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(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 3f478b637f..3ac16d622c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -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. diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index ce5bf387be..babaa33002 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -823,14 +823,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 +2792,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 +2857,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 +2880,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} من الرئيسية diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index eb37bdd775..48af0db09a 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -486,14 +486,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 +2432,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 +2478,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 +2545,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 +2568,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} е откачен от Начало diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 513016faf4..54901bb5a4 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -777,14 +777,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 +2815,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 +2865,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 diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index 645f174416..7138dc2c1f 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -787,14 +787,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 +2735,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 +2777,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 بڕینەوەی وێنە diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4ab47a4c3c..51a569f4fc 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -902,14 +902,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 +2589,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 +2813,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 +2888,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 +2912,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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index fab8b0bff5..e8e1f8b555 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -801,14 +801,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 +2516,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 +2583,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 +2606,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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 707f15f99a..4d9bd63fb0 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -272,7 +272,6 @@ General Γενικά About Πληροφορίες About PdfCraft Σχετικά με το PdfCraft - Check for updates Έλεγχος για ενημερώσεις Keyboard shortcuts Συντομεύσεις πληκτρολογίου Default Προεπιλογή Custom Προσαρμοσμένο @@ -394,12 +393,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 +2521,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 +2588,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 +2611,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} ξεκαρφιτσώθηκε από την Αρχική diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a465e5a1f8..1a695aae4d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -817,14 +817,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 +2012,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 +2686,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 +2860,6 @@ theme Appearance Aspecto Import Importar Move Mover Duplicate Duplicar - Paste Pegar Select all Seleccionar todo Refresh Actualizar Next Siguiente @@ -2890,6 +2910,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 +2928,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 diff --git a/crates/ui-egui/src/i18n/fa.tsv b/crates/ui-egui/src/i18n/fa.tsv index 99a156bc58..1dcb047198 100644 --- a/crates/ui-egui/src/i18n/fa.tsv +++ b/crates/ui-egui/src/i18n/fa.tsv @@ -818,14 +818,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 +2816,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} از خانه جدا شد diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b1dbaffc1e..0ba2f47d06 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -776,14 +776,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 +2824,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 +2874,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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 513ba27b3f..613e0d7107 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -797,14 +797,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 +2515,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 +2652,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 +2847,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 +2870,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 diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index fa59df3a15..3b38f82719 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -760,14 +760,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 +2786,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 +2834,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} को होम से अनपिन किया गया diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index bc4614e104..7d2f8fbf4c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -794,14 +794,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 +2514,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 +2581,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 +2604,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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 52b2dfbe4f..bb3a50830a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -334,12 +334,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 +396,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 +856,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 +1274,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 +2787,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 +2835,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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3228312cca..a43bbbff7e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -820,14 +820,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 +2727,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 +2814,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 +2837,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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 37ddfd4c58..879a68aff1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -824,14 +824,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 +2515,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 +2582,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 +2605,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} のホームへのピン留めを解除しました diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index a28d532498..673ae57b31 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -780,14 +780,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 Кейінірек @@ -2518,6 +2513,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 Пішіндер және қолтаңбалар @@ -2565,6 +2580,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 Бүгін @@ -2581,3 +2603,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} басты беттен ажыратылды diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ecf2b20faa..c57b7e4cd2 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -760,14 +760,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 +2786,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 +2834,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}의 홈 고정을 해제했습니다 diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index ca2d8bc7ea..3be898ead2 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -786,14 +786,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}. ດາວໂຫຼດເວີຊັນໃໝ່ໄດ້ຈາກໜ້າ release. - 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}. ລາຍການ release ທັງໝົດແມ່ນສະແດງຢູ່ທີ່ {page}. Asks GitHub for the latest release. Nothing is downloaded or installed automatically. ກວດສອບລຸ້ນລ່າສຸດຈາກ GitHub. ຈະບໍ່ມີການດາວໂຫຼດ ຫຼື ຕິດຕັ້ງໂດຍອັດຕະໂນມັດ. Download ດາວໂຫຼດ Later ພາຍຫຼັງ @@ -2456,6 +2451,13 @@ animation effects Off ປິດ 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 ຟອມ & ລາຍເຊັນ @@ -2486,3 +2488,33 @@ animation effects Off ປິດ Unchecked reads Unchecked reads {field} reads fields named as it runs {field} reads fields named as it runs Some names are built as it runs Some names are built as it runs + +# 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 ຊູມພື້ນທີ່ເລືອກ + Paste ວາງ + Edit this PDF ແກ້ໄຂ PDF ນີ້ + Export this PDF ສົ່ງອອກ PDF ນີ້ + Find text or tools ຊອກຫາຂໍ້ຄວາມ ຫຼື ເຄື່ອງມື + Crop image ຕັດຂອບຮູບພາບ diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index b2e588c48a..e6747a80aa 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -344,12 +344,10 @@ Check Again Tikrinti dar kartą Check again Tikrinti dar kartą Check box is checked by default Žymimasis langelis pažymėtas pagal numatytuosius parametrus - Check for updates Tikrinti, ar yra naujinimų Check spelling Tikrinti rašybą Check the pages Tikrinti puslapius Check the pages of {name}: {why} Tikrinti dokumento {name} puslapius: {why} Checking Options ({on} of 32 in all categories) Tikrinimo parinktys (visose kategorijose: {on} iš 32) - Checking for a newer version… Tikrinama, ar yra naujesnė versija… Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Pasirinkite PNG arba JPEG paveikslėlį (iki 4 MiB). Skaidrumas išsaugomas. Choose a folder for the exported files Pasirinkite aplanką eksportuojamiems failams Choose a folder for the extracted pages Pasirinkite aplanką išskirtiems puslapiams @@ -411,7 +409,6 @@ Copies: Kopijų skaičius: Copy selected text Kopijuoti pažymėtą tekstą Copy text Kopijuoti tekstą - Couldn't check for updates: {e} Nepavyko patikrinti, ar yra naujinimų: {e} Couldn't open the printer's preferences: {e} Nepavyko atverti spausdintuvo nuostatų: {e} Counterclockwise 90 degrees 90 laipsnių prieš laikrodžio rodyklę Country/Region Šalis / regionas @@ -916,7 +913,6 @@ Patterns (graphics objects) Šablonai (grafiniai objektai) PdfCraft didn't shut down normally. These documents had changes that were autosaved: „PdfCraft“ buvo uždaryta netinkamai. Šių dokumentų pakeitimai buvo automatiškai įrašyti: PdfCraft works offline. No telemetry, no account, and no cloud processing unless you add a provider. „PdfCraft“ veikia neprisijungus. Nerenkami telemetrijos duomenys, nereikia paskyros ir nenaudojamos debesijos paslaugos, nebent pridėtumėte jų teikėją. - PdfCraft {c} is up to date. Naudojate naujausią „PdfCraft“ versiją {c}. PdfCraft {v} is available. Išleista „PdfCraft“ versija {v}. Percentage Procentai Permissions password Teisių slaptažodis @@ -1344,7 +1340,6 @@ XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. XFA forma: jos laukai ir XFA duomenys sinchronizuojami, todėl kitose žiūryklėse rodoma tai, ką įvedate. 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. Ketinate pritaikyti 1 šalinimo žymą. Po žymomis esantis tekstas, paveikslėliai ir brėžiniai, taip pat su jomis persidengiantys komentarai ir formos laukai bus pašalinti visam laikui. 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. Ketinate pritaikyti šalinimo žymų: {n}. Po žymomis esantis tekstas, paveikslėliai ir brėžiniai, taip pat su jomis persidengiantys komentarai ir formos laukai bus pašalinti visam laikui. - You have version {c}. All releases are listed at {page}. Turite versiją {c}. Visos laidos išvardytos čia: {page}. You have version {c}. Download the new version from its release page. Turite versiją {c}. Atsisiųskite naują versiją iš jos laidos puslapio. Your changes will be lost if you don't save them. Jei pakeitimų neįrašysite, jie bus prarasti. ZIP ZIP @@ -2770,6 +2765,12 @@ key Space Tarpas Make properties default Nustatyti ypatybes kaip numatytąsias Pages selected Pasirinkti puslapiai Page properties Puslapio ypatybės + Bring forward Perkelti į priekį + Bring to front Iškelti į priekį + Crop image Apkarpyti paveikslėlį + Remove crop Pašalinti apkarpymą + Send backward Perkelti atgal + Send to back Nukelti į galą Create & Edit Kūrimas ir redagavimas Review & Compare Peržiūra ir palyginimas Forms & Signatures Formos ir parašai @@ -2778,7 +2779,6 @@ key Space Tarpas No tools match your search. Paieškos neatitinka joks įrankis. Expand all tools Išskleisti visus įrankius Collapse all tools Suskleisti visus įrankius - # AI-assisted additions for concurrently merged UI features (2026-10-15). # These additions have not had separate native-speaker review. Mouse wheel scroll speed Pelės ratuko slinkimo greitis @@ -2786,6 +2786,25 @@ key Space Tarpas The password must have at least 6 characters. Slaptažodį turi sudaryti bent 6 simboliai. This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Šis failas diske pasikeitė. Įkelkite jį iš naujo, kad pamatytumėte naują versiją; neįrašyti pakeitimai bus prarasti. theme Appearance Išvaizda + Clear search Išvalyti paiešką + Pin Prisegti + Pin a tool… Prisegti įrankį… + Pin to Home Prisegti pradžioje + Pin tools to Home Prisegti įrankius pradžioje + Pin your favorite tools here for quick access. Prisekite mėgstamus įrankius čia, kad greitai pasiektumėte. + Pinned Prisegta + Pinned Tools Prisegti įrankiai + Search tools… Ieškoti įrankių… + Select tools to display on your Home page for quick access. Pasirinkite įrankius, kuriuos norite rodyti pradžios puslapyje. + Unpin from Home Atsegti nuo pradžios + Unpin tool Atsegti įrankį + Transform image Transformuoti paveikslėlį + Select Tool Pasirinkimo įrankis + Hand Tool Rankos įrankis + Marquee Zoom Didinti pažymėtą sritį + Edit this PDF Redaguoti šį PDF + Export this PDF Eksportuoti šį PDF + Find text or tools Rasti tekstą arba įrankius Attach a file… Pridėti failą… Delete attachment Ištrinti priedą Attaching files is not available on the web yet Failų pridėjimas žiniatinklyje dar negalimas @@ -2828,6 +2847,8 @@ theme Appearance Išvaizda a table has 1 to {max} columns lentelėje gali būti nuo 1 iki {max} stulpelių the first row number can be at most {max} pirmosios eilutės numeris gali būti ne didesnis nei {max} the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 sritis, tarpai ir stulpelių pločiai turi būti skaičiai, tarpai – 0 arba daugiau, o kiekvienas plotis – daugiau nei 0 + Arrange image Tvarkyti vaizdą + Crop Apkarpyti # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Šiandien @@ -2844,3 +2865,11 @@ theme Appearance Išvaizda # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Šio atsakymo parinktys Export value Eksporto reikšmė + Show Page Edge Shadows Rodyti puslapių kraštų šešėlius + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Ieškoti… + Image pasted Paveikslėlis įklijuotas + No image on clipboard to paste Iškarpinėje nėra paveikslėlio įklijuoti + Pinned {tool} to Home {tool} prisegtas pradžios puslapyje + Unpinned {tool} from Home {tool} atsegtas nuo pradžios puslapio diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index c196fd2394..447897dd34 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -341,12 +341,10 @@ Check Again Sjekk igjen Check again Sjekk igjen Check box is checked by default Avkrysningsboksen er avkrysset som standard - Check for updates Se etter oppdateringer Check spelling Stavekontroll Check the pages Kontroller sidene Check the pages of {name}: {why} Kontroller sidene i {name}: {why} Checking Options ({on} of 32 in all categories) Kontrollalternativer ({on} av 32 i alle kategorier) - Checking for a newer version… Ser etter en nyere versjon… Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Velg et PNG- eller JPEG-bilde (opptil 4 MiB). Gjennomsiktighet bevares. Choose a folder for the exported files Velg en mappe for de eksporterte filene Choose a folder for the extracted pages Velg en mappe for de uttrukne sidene @@ -406,7 +404,6 @@ Copies: Antall kopier: Copy selected text Kopier merket tekst Copy text Kopier tekst - Couldn't check for updates: {e} Kunne ikke se etter oppdateringer: {e} Couldn't open the printer's preferences: {e} Kunne ikke åpne skriverinnstillingene: {e} Counterclockwise 90 degrees 90 grader mot klokka Country/Region Land/region @@ -868,7 +865,6 @@ Patterns (graphics objects) Mønstre (grafikkobjekter) PdfCraft didn't shut down normally. These documents had changes that were autosaved: PdfCraft ble ikke avsluttet normalt. Disse dokumentene hadde endringer som ble autolagret: PdfCraft works offline. No telemetry, no account, and no cloud processing unless you add a provider. PdfCraft fungerer uten nett. Ingen telemetri, ingen konto og ingen skybehandling med mindre du legger til en leverandør. - PdfCraft {c} is up to date. PdfCraft {c} er oppdatert. PdfCraft {v} is available. PdfCraft {v} er tilgjengelig. Percentage Prosent Permissions password Tillatelsespassord @@ -1289,7 +1285,6 @@ XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. XFA-skjema: feltene og XFA-dataene holdes i takt, så andre visere viser det du fyller inn. 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. Du er i ferd med å bruke 1 sladdemarkering. Tekst, bilder og tegninger under markeringene, og kommentarer og skjemafelt som overlapper dem, fjernes permanent. 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. Du er i ferd med å bruke {n} sladdemarkeringer. Tekst, bilder og tegninger under markeringene, og kommentarer og skjemafelt som overlapper dem, fjernes permanent. - You have version {c}. All releases are listed at {page}. Du har versjon {c}. Alle utgivelser er listet på {page}. You have version {c}. Download the new version from its release page. Du har versjon {c}. Last ned den nye versjonen fra utgivelsessiden. Your changes will be lost if you don't save them. Endringene dine går tapt hvis du ikke lagrer dem. ZIP ZIP @@ -2729,6 +2724,13 @@ key Space Mellomrom # Page gaps (AI-assisted, not separately native-reviewed) Show gaps between pages Vis mellomrom mellom sidene Hide gaps to join page edges. Margins inside the PDF remain unchanged. Skjul mellomrommene slik at sidekantene møtes. Margene inne i PDF-en endres ikke. + Arrange image Ranger bilde + Bring forward Flytt fremover + Bring to front Plasser lengst frem + Crop Beskjær + Remove crop Fjern beskjæring + Send backward Flytt bakover + Send to back Plasser lengst bak # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today I dag @@ -2759,3 +2761,32 @@ key Space Mellomrom @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… Søk… + Image pasted Bilde limt inn + No image on clipboard to paste Ingen bilder på utklippstavlen å lime inn + Pinned {tool} to Home Festet {tool} til Hjem + Unpinned {tool} from Home Løsnet {tool} fra Hjem + +# Home tools (AI-assisted; not separately native-reviewed) + Clear search Tøm søk + Pin Fest + Pin a tool… Fest et verktøy… + Pin to Home Fest til Hjem + Pin tools to Home Fest verktøy til Hjem + Pin your favorite tools here for quick access. Fest favorittverktøyene dine her for rask tilgang. + Pinned Festet + Pinned Tools Festede verktøy + Search tools… Søk etter verktøy… + Select tools to display on your Home page for quick access. Velg verktøy som skal vises på hjemmesiden for rask tilgang. + Unpin from Home Løsne fra Hjem + Unpin tool Løsne verktøy + Transform image Transformer bilde + Select Tool Markeringsverktøy + Hand Tool Håndverktøy + Marquee Zoom Områdezoom + Edit this PDF Rediger denne PDF-en + Export this PDF Eksporter denne PDF-en + Find text or tools Finn tekst eller verktøy + Crop image Beskjær bilde diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ae6463a156..e7e6b9d357 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -816,14 +816,9 @@ Copy Image in Original Format Kopiuj obraz w oryginalnym formacie The image has been copied as a {ext} file Obraz został skopiowany jako plik {ext} The page has changed; select the image again Strona się zmieniła; zaznacz obraz ponownie - Check for updates Sprawdź aktualizacje No check has run yet. Nie sprawdzono jeszcze aktualizacji. - Checking for a newer version… Sprawdzanie dostępności nowszej wersji… PdfCraft {v} is available. PdfCraft {v} jest dostępny. You have version {c}. Download the new version from its release page. Masz wersję {c}. Pobierz nową wersję ze strony wydania. - PdfCraft {c} is up to date. PdfCraft {c} jest aktualny. - Couldn't check for updates: {e} Nie można sprawdzić aktualizacji: {e} - You have version {c}. All releases are listed at {page}. Masz wersję {c}. Wszystkie wydania są dostępne na {page}. Asks GitHub for the latest release. Nothing is downloaded or installed automatically. Sprawdza najnowsze wydanie na GitHubie. Nic nie jest pobierane ani instalowane automatycznie. Download Pobierz Later Później @@ -2496,6 +2491,26 @@ animation effects Off Wyłączone Make properties default Ustaw właściwości jako domyślne Pages selected Zaznaczone strony Page properties Właściwości strony + Clear search Wyczyść wyszukiwanie + Pin Przypnij + Pin a tool… Przypnij narzędzie… + Pin to Home Przypnij do strony głównej + Pin tools to Home Przypnij narzędzia do strony głównej + Pin your favorite tools here for quick access. Przypnij tutaj ulubione narzędzia, aby mieć do nich szybki dostęp. + Pinned Przypięte + Pinned Tools Przypięte narzędzia + Search tools… Szukaj narzędzi… + Select tools to display on your Home page for quick access. Wybierz narzędzia do wyświetlenia na stronie głównej, aby mieć do nich szybki dostęp. + Unpin from Home Odepnij od strony głównej + Unpin tool Odepnij narzędzie + Transform image Przekształć obraz + Select Tool Narzędzie Zaznaczanie + Hand Tool Narzędzie Rączka + Marquee Zoom Powiększ zaznaczony obszar + Paste Wklej + Edit this PDF Edytuj ten plik PDF + Export this PDF Eksportuj ten plik PDF + Find text or tools Znajdź tekst lub narzędzia Create & Edit Tworzenie i edycja Review & Compare Recenzja i porównanie Forms & Signatures Formularze i podpisy @@ -2663,6 +2678,13 @@ animation effects Off Wyłączone field names can't contain a period: {name} nazwy pól nie mogą zawierać kropki: {name} two cells would both be named {name} dwie komórki miałyby nazwę {name} a field named {name} already exists pole o nazwie {name} już istnieje + Arrange image Rozmieść obraz + Bring forward Przesuń do przodu + Bring to front Przesuń na wierzch + Crop Przytnij + Remove crop Usuń przycięcie + Send backward Przesuń do tyłu + Send to back Przesuń pod spód # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Dzisiaj @@ -2679,3 +2701,11 @@ animation effects Off Wyłączone # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Opcje tej odpowiedzi Export value Wartość eksportowa + Show Page Edge Shadows Pokaż cienie krawędzi stron + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Szukaj… + Image pasted Wklejono obraz + No image on clipboard to paste Brak obrazu do wklejenia w schowku + Pinned {tool} to Home Przypięto {tool} do strony głównej + Unpinned {tool} from Home Odpięto {tool} od strony głównej diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 70309ffc15..3c4d0537cd 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -441,14 +441,9 @@ Copy Image in Original Format Copiar imagem no formato original The image has been copied as a {ext} file A imagem foi copiada como arquivo {ext} The page has changed; select the image again A página mudou; selecione a imagem novamente - Check for updates Verificar atualizações No check has run yet. Nenhuma verificação foi executada ainda. - Checking for a newer version… Verificando se há uma versão mais recente… PdfCraft {v} is available. O PdfCraft {v} está disponível. You have version {c}. Download the new version from its release page. Você tem a versão {c}. Baixe a nova versão na página de lançamentos. - PdfCraft {c} is up to date. O PdfCraft {c} está atualizado. - Couldn't check for updates: {e} Não foi possível verificar atualizações: {e} - You have version {c}. All releases are listed at {page}. Você tem a versão {c}. Todos os lançamentos estão listados em {page}. Asks GitHub for the latest release. Nothing is downloaded or installed automatically. Consulta o GitHub para obter o lançamento mais recente. Nada é baixado ou instalado automaticamente. Download Baixar Later Mais tarde @@ -2534,6 +2529,25 @@ animation effects Off Desativados Make properties default Tornar propriedades padrão Pages selected Páginas selecionadas Page properties Propriedades da página + Clear search Limpar pesquisa + Pin Fixar + Pin a tool… Fixar uma ferramenta… + Pin to Home Fixar no Início + Pin tools to Home Fixar ferramentas no Início + Pin your favorite tools here for quick access. Fixe suas ferramentas favoritas aqui para acesso rápido. + Pinned Fixado + Pinned Tools Ferramentas fixadas + Search tools… Pesquisar ferramentas… + Select tools to display on your Home page for quick access. Selecione ferramentas para exibir na página Início para acesso rápido. + Unpin from Home Desafixar do Início + Unpin tool Desafixar ferramenta + Transform image Transformar imagem + Select Tool Ferramenta Seleção + Hand Tool Ferramenta Mão + Marquee Zoom Zoom por seleção + Edit this PDF Editar este PDF + Export this PDF Exportar este PDF + Find text or tools Localizar texto ou ferramentas Create & Edit Criar e editar Review & Compare Revisar e comparar Forms & Signatures Formulários e assinaturas @@ -2852,6 +2866,14 @@ theme Appearance Aparência a table has 1 to {max} columns uma tabela tem de 1 a {max} colunas the first row number can be at most {max} o número da primeira linha pode ser no máximo {max} the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 a área, o espaçamento e as larguras das colunas devem ser números, o espaçamento 0 ou mais e cada largura maior que 0 + Bring forward Avançar + Bring to front Trazer para a frente + Crop image Cortar imagem + Remove crop Remover corte + Send backward Recuar + Send to back Enviar para trás + Arrange image Organizar imagem + Crop Cortar # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Hoje @@ -2868,3 +2890,11 @@ theme Appearance Aparência # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Opções desta resposta Export value Valor de exportação + Show Page Edge Shadows Mostrar sombras nas bordas das páginas + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Pesquisar… + Image pasted Imagem colada + No image on clipboard to paste Nenhuma imagem na área de transferência para colar + Pinned {tool} to Home {tool} fixado no Início + Unpinned {tool} from Home {tool} desafixado do Início diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index f8a2deb83e..cf6abda4b1 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -342,12 +342,10 @@ Check Again Verificare din nou Check again Verificare din nou Check box is checked by default Caseta de selectare este bifată implicit - Check for updates Căutare actualizări Check spelling Verificare ortografică Check the pages Verificați paginile Check the pages of {name}: {why} Verificați paginile din {name}: {why} Checking Options ({on} of 32 in all categories) Opțiuni de verificare ({on} din 32 în toate categoriile) - Checking for a newer version… Se caută o versiune mai nouă… Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Alegeți o imagine PNG sau JPEG (până la 4 MiB). Transparența se păstrează. Choose a folder for the exported files Alegeți un folder pentru fișierele exportate Choose a folder for the extracted pages Alegeți un folder pentru paginile extrase @@ -407,7 +405,6 @@ Copies: Copii: Copy selected text Copiere text selectat Copy text Copiere text - Couldn't check for updates: {e} Nu s-au putut căuta actualizări: {e} Couldn't open the printer's preferences: {e} Nu s-au putut deschide preferințele imprimantei: {e} Counterclockwise 90 degrees 90 de grade în sens invers acelor de ceasornic Country/Region Țară/regiune @@ -868,7 +865,6 @@ Patterns (graphics objects) Modele (obiecte grafice) PdfCraft didn't shut down normally. These documents had changes that were autosaved: PdfCraft nu s-a închis normal. Aceste documente aveau modificări salvate automat: PdfCraft works offline. No telemetry, no account, and no cloud processing unless you add a provider. PdfCraft funcționează offline. Fără telemetrie, fără cont și fără procesare în cloud, cu excepția cazului în care adăugați un furnizor. - PdfCraft {c} is up to date. PdfCraft {c} este la zi. PdfCraft {v} is available. Este disponibil PdfCraft {v}. Percentage Procent Permissions password Parolă de permisiuni @@ -1287,7 +1283,6 @@ XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. Formular XFA: câmpurile și datele XFA sunt sincronizate, ca alte vizualizatoare să arate ce completați. 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. Urmează să aplicați 1 marcaj de anonimizare. Textul, imaginile și desenele de sub marcaje, precum și comentariile și câmpurile de formular care se suprapun cu ele, se elimină definitiv. 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. Urmează să aplicați {n} marcaje de anonimizare. Textul, imaginile și desenele de sub marcaje, precum și comentariile și câmpurile de formular care se suprapun cu ele, se elimină definitiv. - You have version {c}. All releases are listed at {page}. Aveți versiunea {c}. Toate versiunile sunt listate la {page}. You have version {c}. Download the new version from its release page. Aveți versiunea {c}. Descărcați versiunea nouă de pe pagina ei de lansare. Your changes will be lost if you don't save them. Modificările se vor pierde dacă nu le salvați. ZIP ZIP @@ -2820,6 +2815,33 @@ theme Appearance Aspect field names can't contain a period: {name} numele câmpurilor nu pot conține punct: {name} two cells would both be named {name} două celule s-ar numi amândouă {name} a field named {name} already exists există deja un câmp numit {name} + Bring forward Aduce mai în față + Bring to front Aduce în prim-plan + Crop image Decupare imagine + Remove crop Elimină decuparea + Send backward Trimite mai în spate + Send to back Trimite în fundal + Clear search Golește căutarea + Edit this PDF Editează acest PDF + Export this PDF Exportă acest PDF + Find text or tools Găsește text sau instrumente + Hand Tool Instrumentul Mână + Marquee Zoom Zoom cadru + Pin Fixează + Pin a tool… Fixează un instrument… + Pin to Home Fixează la Pagina de pornire + Pin tools to Home Fixează instrumente la Pagina de pornire + Pin your favorite tools here for quick access. Fixează instrumentele preferate aici pentru acces rapid. + Pinned Fixat + Pinned Tools Instrumente fixate + Search tools… Caută instrumente… + Select Tool Instrumentul Selecție + Select tools to display on your Home page for quick access. Selectează instrumentele de afișat pe Pagina de pornire pentru acces rapid. + Transform image Transformare imagine + Unpin from Home Anulează fixarea la Pagina de pornire + Unpin tool Anulează fixarea instrumentului + Arrange image Aranjează imaginea + Crop Decupare # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Astăzi @@ -2843,3 +2865,10 @@ theme Appearance Aspect # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Opțiuni pentru acest răspuns + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Caută… + Image pasted Imagine lipită + No image on clipboard to paste Nicio imagine în clipboard de lipit + Pinned {tool} to Home {tool} fixat pe pagina principală + Unpinned {tool} from Home {tool} desprins de pe pagina principală diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 87042f7bd9..46ff335c81 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -817,14 +817,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 Позже @@ -2433,6 +2428,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 Формы и подписи @@ -2480,6 +2495,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 Сегодня @@ -2496,3 +2518,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} откреплён от главной diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 57d31bd66e..928aec3a7b 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -343,7 +343,6 @@ Check box is checked by default Potrditveno polje je privzeto označeno Check for accessibility Preveri dostopnost Check for accessibility… Preveri dostopnost… - Check for updates Preveri posodobitve Check for updates… Preveri posodobitve… Check spelling Preveri črkovanje Check that the tags read in the order a sighted reader would. Use the Reading Order tool or the Tags panel to correct it. Preverite, ali se oznake berejo v vrstnem redu, v katerem bi jih bral bralec. Za popravek uporabite orodje Vrstni red branja ali razdelek Oznake. @@ -352,7 +351,6 @@ Checkbox Potrditveno polje Checked Označeno Checking Options ({on} of 32 in all categories) Možnosti preverjanja ({on} od 32 v vseh kategorijah) - Checking for a newer version… Preverjanje za novejšo različico… Checkmark Kljukica Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Izberite sliko PNG ali JPEG (do 4 MiB). Prosojnost je ohranjena. Choose a folder for the exported files Izberite mapo za izvožene datoteke @@ -457,7 +455,6 @@ Correct recognized text Popravi prepoznano besedilo Could not save: {e} Shranjevanje ni uspelo: {e} Couldn't add {name}: {e} Datoteke {name} ni bilo mogoče dodati: {e} - Couldn't check for updates: {e} Posodobitev ni bilo mogoče preveriti: {e} Couldn't combine files: {e} Datotek ni bilo mogoče združiti: {e} Couldn't create a PDF: {e} PDF-ja ni bilo mogoče ustvariti: {e} Couldn't download {name}: {e} Datoteke {name} ni bilo mogoče prenesti: {e} @@ -1368,7 +1365,6 @@ PdfCraft on GitHub PdfCraft na GitHubu PdfCraft web page Spletna stran PdfCraft PdfCraft works offline. No telemetry, no account, and no cloud processing unless you add a provider. PdfCraft deluje brez povezave. Brez telemetrije, brez računa in brez obdelave v oblaku, razen če dodate ponudnika. - PdfCraft {c} is up to date. PdfCraft {c} je posodobljen. PdfCraft {v} is available. Na voljo je PdfCraft {v}. People may need more time to respond. Check that nothing has a time limit. Ljudje bodo morda potrebovali več časa za odziv. Preverite, da nič nima časovne omejitve. Percentage Odstotek @@ -2063,7 +2059,6 @@ 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. Uveljavili boste 1 oznako za počrnitev. Besedilo, slike in risbe pod oznakami ter komentarji in polja obrazca, ki se z njimi prekrivajo, bodo trajno odstranjeni. 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. Uveljavili boste oznake za počrnitev: {n}. Besedilo, slike in risbe pod oznakami ter komentarji in polja obrazca, ki se z njimi prekrivajo, bodo trajno odstranjeni. You can pin up to {n} folders. Unpin one first. Pripnete lahko do {n} map. Najprej eno odpnite. - You have version {c}. All releases are listed at {page}. Imate različico {c}. Vse izdaje so navedene na {page}. You have version {c}. Download the new version from its release page. Imate različico {c}. Novo različico prenesite z njene strani izdaje. Your changes will be lost if you don't save them. Vaše spremembe bodo izgubljene, če jih ne shranite. Your name Vaše ime @@ -2525,6 +2520,26 @@ animation effects Off Izklopljeni Make properties default Nastavi lastnosti kot privzete Pages selected Izbrane strani Page properties Lastnosti strani + Clear search Počisti iskanje + Pin Pripni + Pin a tool… Pripni orodje… + Pin to Home Pripni na domov + Pin tools to Home Pripni orodja na domov + Pin your favorite tools here for quick access. Tukaj pripnite svoja priljubljena orodja za hiter dostop. + Pinned Pripeto + Pinned Tools Pripeta orodja + Search tools… Išči orodja… + Select tools to display on your Home page for quick access. Izberite orodja za prikaz na domači strani za hiter dostop. + Unpin from Home Odpni z domače strani + Unpin tool Odpni orodje + Transform image Preoblikuj sliko + Select Tool Orodje za izbiro + Hand Tool Orodje z roko + Marquee Zoom Povečava z okvirjem + Paste Prilepi + Edit this PDF Uredi ta PDF + Export this PDF Izvozi ta PDF + Find text or tools Poišči besedilo ali orodja Create & Edit Ustvarjanje in urejanje Review & Compare Pregled in primerjava Forms & Signatures Obrazci in podpisi @@ -2572,6 +2587,13 @@ animation effects Off Izklopljeni field names can't contain a period: {name} imena polj ne smejo vsebovati pike: {name} two cells would both be named {name} dve celici bi imeli enako ime {name} a field named {name} already exists polje z imenom {name} že obstaja + Arrange image Uredi sliko + Bring forward Premakni naprej + Bring to front Postavi v ospredje + Crop Obreži + Remove crop Odstrani obrezovanje + Send backward Premakni nazaj + Send to back Postavi v ozadje # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Danes @@ -2588,3 +2610,11 @@ animation effects Off Izklopljeni # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Možnosti tega odgovora Export value Izvozna vrednost + Show Page Edge Shadows Prikaži sence robov strani + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Išči… + Image pasted Slika prilepljena + No image on clipboard to paste V odložišču ni slike za lepljenje + Pinned {tool} to Home {tool} pripeto na domačo stran + Unpinned {tool} from Home {tool} odpeto z domače strani diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index 4b1769c0f3..de28ddf506 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -337,7 +337,6 @@ Check box is checked by default Kutia e zgjedhjes është e zgjedhur si parazgjedhje Check for accessibility Kontrollo qasshmërinë Check for accessibility… Kontrollo qasshmërinë… - Check for updates Kontrollo për përditësime Check for updates… Kontrollo për përditësime… Check spelling Kontrollo drejtshkrimin Check that the tags read in the order a sighted reader would. Use the Reading Order tool or the Tags panel to correct it. Kontrolloni që etiketat lexohen në radhën që do t'i lexonte një lexues që sheh. Përdorni mjetin e radhës së leximit ose panelin e etiketave për ta korrigjuar. @@ -346,7 +345,6 @@ Checkbox Kuti zgjedhjeje Checked E zgjedhur Checking Options ({on} of 32 in all categories) Opsionet e kontrollit ({on} nga 32 në të gjitha kategoritë) - Checking for a newer version… Duke kontrolluar për një version më të ri… Checkmark Shenjë vlefshmërie Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Zgjidhni një imazh PNG ose JPEG (deri në 4 MiB). Transparenca ruhet. Choose a folder for the exported files Zgjidhni një dosje për skedarët e eksportuar @@ -450,7 +448,6 @@ Correct recognized text Korrigjo tekstin e njohur Could not save: {e} Nuk u ruajt: {e} Couldn't add {name}: {e} Nuk u shtua {name}: {e} - Couldn't check for updates: {e} Nuk u kontrollua për përditësime: {e} Couldn't combine files: {e} Nuk u bashkuan skedarët: {e} Couldn't create a PDF: {e} Nuk u krijua dot një PDF: {e} Couldn't download {name}: {e} Nuk u shkarkua {name}: {e} @@ -1317,7 +1314,6 @@ PdfCraft on GitHub PdfCraft në GitHub PdfCraft web page Faqja ueb e PdfCraft PdfCraft works offline. No telemetry, no account, and no cloud processing unless you add a provider. PdfCraft funksionon pa internet. Pa telemetri, pa llogari dhe pa përpunim në re, përveçse nëse shton një ofrues. - PdfCraft {c} is up to date. PdfCraft {c} është i përditësuar. PdfCraft {v} is available. PdfCraft {v} është i disponueshëm. People may need more time to respond. Check that nothing has a time limit. Njerëzit mund të kenë nevojë për më shumë kohë për të përgjigjur. Kontrollo që asgjë të mos ketë kufi kohe. Percentage Përqindja @@ -2002,7 +1998,6 @@ 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. Jeni gati të zbatoni 1 shenjë redaktimi. Teksti, imazhet dhe vizatimet nën shenjat, si dhe komentet dhe fushat e formularit që mbivendosen me to, hiqen përgjithmonë. 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. Jeni gati të zbatoni {n} shenja redaktimi. Teksti, imazhet dhe vizatimet nën shenjat, si dhe komentet dhe fushat e formularit që mbivendosen me to, hiqen përgjithmonë. You can pin up to {n} folders. Unpin one first. Mund të fiksoni deri në {n} dosje. Hiqni fiksimin e njërës më parë. - You have version {c}. All releases are listed at {page}. Keni versionin {c}. Të gjitha botimet janë të listuara te {page}. You have version {c}. Download the new version from its release page. Keni versionin {c}. Shkarkoni versionin e ri nga faqja e botimit. Your changes will be lost if you don't save them. Ndryshimet tuaja do të humbasin nëse nuk i ruani. Your name Emri juaj @@ -2390,7 +2385,7 @@ animation effects Off Joaktiv Collapse all pages Collapse all pages Counting words… {n} pages read Counting words… {n} pages read Create Link… Create Link… - Crop Image Crop Image + Crop Image Prit imazhin Current page only Current page only Cut Image Cut Image 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. @@ -2468,7 +2463,7 @@ animation effects Off Joaktiv Recognize Text… Recognize Text… Redact Image Redact Image Redaction mark appearance Redaction mark appearance - Remove Crop Remove Crop + Remove Crop Hiq prerjen Remove this leaf tag from reading order and mark its direct content as an artifact Remove this leaf tag from reading order and mark its direct content as an artifact Resolution: Resolution: Save anyway Save anyway @@ -2546,6 +2541,32 @@ animation effects Off Joaktiv field names can't contain a period: {name} emrat e fushave nuk mund të përmbajnë pikë: {name} two cells would both be named {name} dy qeliza do të kishin të dyja emrin {name} a field named {name} already exists ekziston tashmë një fushë me emrin {name} + Clear search Pastro kërkimin + Edit this PDF Redakto këtë PDF + Export this PDF Eksporto këtë PDF + Find text or tools Gjej tekst ose mjete + Hand Tool Mjeti Dorë + Marquee Zoom Zmadhim me kornizë + Paste Ngjit + Pin Fikso + Pin a tool… Fikso një mjet… + Pin to Home Fikso në Ballinë + Pin tools to Home Fikso mjetet në Ballinë + Pin your favorite tools here for quick access. Fiksoni mjetet tuaja të preferuara këtu për qasje të shpejtë. + Pinned I fiksuar + Pinned Tools Mjete të fiksuara + Search tools… Kërko mjete… + Select Tool Mjeti Përzgjedhje + Select tools to display on your Home page for quick access. Zgjidhni mjetet për t'u shfaqur në faqen tuaj të Ballinës për qasje të shpejtë. + Unpin from Home Hiq fiksimin nga Ballina + Unpin tool Hiq fiksimin e mjetit + Arrange image Rregullo figurën + Bring forward Kalo përpara + Bring to front Sille përpara + Crop Prit + Remove crop Hiq prerjen + Send backward Kalo prapa + Send to back Dërgo mbrapa # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Sot @@ -2569,3 +2590,10 @@ animation effects Off Joaktiv # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Opsionet për këtë përgjigje + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Kërko… + Image pasted Imazhi u ngjit + No image on clipboard to paste Nuk ka imazh në kujtesën e përkohshme për të ngjitur + Pinned {tool} to Home {tool} u fiksua te Kreu + Unpinned {tool} from Home {tool} u hoq nga fiksimi te Kreu diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 5b4ba64345..cac3c73204 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -798,14 +798,9 @@ comment menu Edit సవరించు 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 తర్వాత @@ -2429,6 +2424,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 ఫారమ్‌లు & సంతకాలు @@ -2476,6 +2491,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 ఈరోజు @@ -2492,3 +2514,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} హోమ్ నుండి అన్‌పిన్ చేయబడింది diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 41a4578cf0..22110de938 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1217,3 +1217,12 @@ fn date_languages_are_the_interface_languages() { let dates: Vec<(&str, &str)> = pdfcraft_engine::dates::DATE_LANGUAGES.iter().map(|l| (l.code, l.name)).collect(); assert_eq!(dates, interface); } + +#[test] +fn image_paste_and_home_notifications_are_translated() { + for language in Lang::all().filter(|l| *l != Lang::EN) { + for source in ["Search…", "Image pasted", "No image on clipboard to paste", "Pinned {tool} to Home", "Unpinned {tool} from Home"] { + assert!(language.catalog().plain(source).is_some(), "{}: {source}", language.code()); + } + } +} diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index 71c7d38fe8..72fecec870 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -771,14 +771,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 ภายหลัง @@ -2716,6 +2711,13 @@ key Space Space Collapse all tools ยุบเครื่องมือทั้งหมด The password must have at least 8 characters. รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร theme Appearance ลักษณะที่ปรากฏ + 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 วันนี้ @@ -2741,3 +2743,32 @@ theme Appearance ลักษณะที่ปรากฏ Unchecked reads Unchecked reads {field} reads fields named as it runs {field} reads fields named as it runs Some names are built as it runs Some names are built as it runs + +# 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 ครอบตัดรูปภาพ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f76f7f5cd0..1dba482650 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -779,14 +779,9 @@ Copy Image in Original Format Resmi Özgün Biçimde Kopyala The image has been copied as a {ext} file Resim {ext} dosyası olarak kopyalandı The page has changed; select the image again Sayfa değişti; resmi yeniden seçin - Check for updates Güncellemeleri denetle No check has run yet. Henüz denetim yapılmadı. - Checking for a newer version… Daha yeni bir sürüm denetleniyor… PdfCraft {v} is available. PdfCraft {v} kullanılabilir. You have version {c}. Download the new version from its release page. {c} sürümüne sahipsiniz. Yeni sürümü sürüm sayfasından indirin. - PdfCraft {c} is up to date. PdfCraft {c} güncel. - Couldn't check for updates: {e} Güncellemeler denetlenemedi: {e} - You have version {c}. All releases are listed at {page}. {c} sürümüne sahipsiniz. Tüm sürümler {page} adresinde listelenmiştir. Asks GitHub for the latest release. Nothing is downloaded or installed automatically. En son sürüm için GitHub'ı sorgular. Hiçbir şey otomatik olarak indirilmez veya kurulmaz. Download İndir Later Daha sonra @@ -2516,6 +2511,26 @@ animation effects Off Kapalı Make properties default Özellikleri varsayılan yap Pages selected Seçilen sayfalar Page properties Sayfa özellikleri + Clear search Aramayı temizle + Pin Sabitle + Pin a tool… Bir aracı sabitle… + Pin to Home Giriş'e sabitle + Pin tools to Home Araçları Giriş'e sabitle + Pin your favorite tools here for quick access. Hızlı erişim için favori araçlarınızı buraya sabitleyin. + Pinned Sabitlendi + Pinned Tools Sabitlenen Araçlar + Search tools… Araçları ara… + Select tools to display on your Home page for quick access. Hızlı erişim için Giriş sayfanızda görüntülenecek araçları seçin. + Unpin from Home Giriş'ten sabitlemeyi kaldır + Unpin tool Aracın sabitlemesini kaldır + Transform image Görüntüyü dönüştür + Select Tool Seçim Aracı + Hand Tool El Aracı + Marquee Zoom Seçim yakınlaştırma + Paste Yapıştır + Edit this PDF Bu PDF'i düzenle + Export this PDF Bu PDF'i dışa aktar + Find text or tools Metin veya araç bul Create & Edit Oluştur ve Düzenle Review & Compare İncele ve Karşılaştır Forms & Signatures Formlar ve İmzalar @@ -2563,6 +2578,13 @@ animation effects Off Kapalı field names can't contain a period: {name} alan adları nokta içeremez: {name} two cells would both be named {name} iki hücrenin de adı {name} olurdu a field named {name} already exists {name} adlı bir alan zaten var + Arrange image Resmi düzenle + Bring forward Öne getir + Bring to front En öne getir + Crop Kırp + Remove crop Kırpmayı kaldır + Send backward Arkaya gönder + Send to back En arkaya gönder # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Bugün @@ -2579,3 +2601,11 @@ animation effects Off Kapalı # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Bu yanıtın seçenekleri Export value Dışa aktarma değeri + Show Page Edge Shadows Sayfa kenarı gölgelerini göster + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Ara… + Image pasted Görüntü yapıştırıldı + No image on clipboard to paste Panoda yapıştırılacak görüntü yok + Pinned {tool} to Home {tool} Ana Sayfaya sabitlendi + Unpinned {tool} from Home {tool} Ana Sayfadan kaldırıldı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index eb7bb2ec4b..297e1e7fa3 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -821,14 +821,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 Пізніше @@ -2542,6 +2537,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 Форми й підписи @@ -2589,6 +2604,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 Сьогодні @@ -2605,3 +2627,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} відкріплено від головної diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 860884ae48..2a51854c07 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -342,12 +342,10 @@ theme Appearance Giao diện Check Again Kiểm tra lại Check again Kiểm tra lại Check box is checked by default Hộp kiểm được chọn theo mặc định - Check for updates Kiểm tra bản cập nhật Check spelling Kiểm tra chính tả Check the pages Kiểm tra các trang Check the pages of {name}: {why} Kiểm tra các trang của {name}: {why} Checking Options ({on} of 32 in all categories) Tùy chọn kiểm tra ({on} trên 32 ở tất cả danh mục) - Checking for a newer version… Đang kiểm tra phiên bản mới hơn… Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Chọn ảnh PNG hoặc JPEG (tối đa 4 MiB). Độ trong suốt được giữ nguyên. Choose a folder for the exported files Chọn thư mục cho các tệp đã xuất Choose a folder for the extracted pages Chọn thư mục cho các trang đã trích xuất @@ -408,7 +406,6 @@ theme Appearance Giao diện Copies: Số bản sao: Copy selected text Sao chép văn bản đã chọn Copy text Sao chép văn bản - Couldn't check for updates: {e} Không thể kiểm tra bản cập nhật: {e} Couldn't open the printer's preferences: {e} Không thể mở tùy chọn của máy in: {e} Counterclockwise 90 degrees Ngược chiều kim đồng hồ 90 độ Country/Region Quốc gia/Khu vực @@ -910,7 +907,6 @@ theme Appearance Giao diện Patterns (graphics objects) Họa tiết (đối tượng đồ họa) PdfCraft didn't shut down normally. These documents had changes that were autosaved: PdfCraft không đóng bình thường. Các tài liệu này có thay đổi đã được tự động lưu: PdfCraft works offline. No telemetry, no account, and no cloud processing unless you add a provider. PdfCraft hoạt động ngoại tuyến. Không thu thập dữ liệu sử dụng, không cần tài khoản, và không xử lý đám mây trừ khi bạn thêm nhà cung cấp. - PdfCraft {c} is up to date. PdfCraft {c} đã là bản mới nhất. PdfCraft {v} is available. PdfCraft {v} đã có sẵn. Percentage Phần trăm Permissions password Mật khẩu quyền hạn @@ -1333,7 +1329,6 @@ theme Appearance Giao diện XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. Biểu mẫu XFA: các trường và dữ liệu XFA của nó được giữ đồng bộ, nên các trình xem khác hiển thị những gì bạn điền. 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. Bạn sắp áp dụng 1 dấu xóa. Văn bản, hình ảnh và bản vẽ nằm dưới các dấu, cũng như bình luận và trường biểu mẫu chồng lên chúng, sẽ bị xóa vĩnh viễn. 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. Bạn sắp áp dụng {n} dấu xóa. Văn bản, hình ảnh và bản vẽ nằm dưới các dấu, cũng như bình luận và trường biểu mẫu chồng lên chúng, sẽ bị xóa vĩnh viễn. - You have version {c}. All releases are listed at {page}. Bạn đang dùng phiên bản {c}. Tất cả các bản phát hành được liệt kê tại {page}. You have version {c}. Download the new version from its release page. Bạn đang dùng phiên bản {c}. Hãy tải phiên bản mới từ trang phát hành của nó. Your changes will be lost if you don't save them. Các thay đổi của bạn sẽ bị mất nếu bạn không lưu. ZIP ZIP @@ -2707,6 +2702,12 @@ key Space Space Redact Image Xóa nội dung nhạy cảm của hình ảnh Create Link… Tạo liên kết… Recognize Text… Nhận dạng văn bản… + Bring forward Chuyển lên trước + Bring to front Đưa lên trước cùng + Crop image Cắt hình ảnh + Remove crop Xóa cắt + Send backward Chuyển về sau + Send to back Đưa về sau cùng Create & Edit Tạo và chỉnh sửa Review & Compare Xem xét và so sánh Forms & Signatures Biểu mẫu và chữ ký @@ -2794,6 +2795,25 @@ key Space Space # 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. Bạn đang xem phiên bản đã ký của “{from}” (bản sửa đổi {rev}). Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc. You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Bạn đang xem bản sửa đổi {rev} của “{from}”. Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc. + Clear search Xóa tìm kiếm + Pin Ghim + Pin a tool… Ghim công cụ… + Pin to Home Ghim vào Trang chủ + Pin tools to Home Ghim công cụ vào Trang chủ + Pin your favorite tools here for quick access. Ghim các công cụ yêu thích của bạn vào đây để truy cập nhanh. + Pinned Đã ghim + Pinned Tools Công cụ đã ghim + Search tools… Tìm kiếm công cụ… + Select tools to display on your Home page for quick access. Chọn các công cụ hiển thị trên Trang chủ để truy cập nhanh. + Unpin from Home Bỏ ghim khỏi Trang chủ + Unpin tool Bỏ ghim công cụ + Transform image Chuyển đổi hình ảnh + Select Tool Công cụ chọn + Hand Tool Công cụ bàn tay + Marquee Zoom Thu phóng theo vùng chọn + Edit this PDF Chỉnh sửa PDF này + Export this PDF Xuất PDF này + Find text or tools Tìm văn bản hoặc công cụ Attach a file… Đính kèm tệp… Delete attachment Xóa tệp đính kèm Attaching files is not available on the web yet Chưa thể đính kèm tệp trên web @@ -2831,6 +2851,8 @@ key Space Space a table has 1 to {max} columns một bảng có từ 1 đến {max} cột the first row number can be at most {max} số hàng đầu tiên tối đa là {max} the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 vùng, khoảng cách và độ rộng cột phải là số, khoảng cách từ 0 trở lên và mỗi độ rộng lớn hơn 0 + Arrange image Sắp xếp hình ảnh + Crop Xén # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Hôm nay @@ -2850,3 +2872,11 @@ key Space Space # Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) Options for this reply Tùy chọn cho câu trả lời này Export value Giá trị xuất + Show Page Edge Shadows Hiển thị bóng viền trang + +# Image paste and Home notifications (AI-assisted; not separately native-reviewed) + Search… Tìm kiếm… + Image pasted Đã dán hình ảnh + No image on clipboard to paste Không có hình ảnh trong bảng nhớ tạm để dán + Pinned {tool} to Home Đã ghim {tool} vào Trang chủ + Unpinned {tool} from Home Đã bỏ ghim {tool} khỏi Trang chủ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 995acc78e6..1425da8d0d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -492,14 +492,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 稍后 @@ -2484,6 +2479,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 表单和签名 @@ -2531,6 +2546,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 今天 @@ -2547,3 +2569,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} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 79b67e6e70..644ac99d54 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -445,14 +445,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 稍後 @@ -2470,6 +2465,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 表單與簽名 @@ -2517,6 +2532,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 今天 @@ -2533,3 +2555,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} diff --git a/crates/ui-egui/src/image_engine.rs b/crates/ui-egui/src/image_engine.rs new file mode 100644 index 0000000000..518bea0223 --- /dev/null +++ b/crates/ui-egui/src/image_engine.rs @@ -0,0 +1,578 @@ +//! Image manipulation engine for non-destructive crop, anchor-based resize, and movement. +//! +//! Contributor-original clean-room design with zero-panic safety and lockstep pixel-scale invariance. + +use egui::{Color32, Pos2, Rect, Stroke, Vec2, pos2}; + +use crate::edit_text_ui::ImageHandle; + +// ============================================================================ +// 1. MATHEMATICAL CORE: ANCHOR-BASED RESIZE & CROP +// ============================================================================ + +/// Minimum allowed dimensions in pixels to prevent panics, zero divisions, or inverted rects. +pub const MIN_RECT_SIZE: f32 = 10.0; + +/// **Anchor-Based Resize System** +/// +/// Recalculates the screen bounding box by locking the diagonally opposite point/edge as +/// a fixed anchor. +/// +/// Math breakdown: +/// - Corner handles: The diagonally opposite corner is fixed in both X and Y. +/// Width and height are calculated relative to that anchor, clamped to >= `MIN_RECT_SIZE`, +/// and the new bounding box is constructed from `anchor` and the clamped dragged coordinate. +/// - Mid-edge handles: The opposite edge coordinate is fixed in 1D. The perpendicular axis is +/// completely unaffected. +pub fn apply_resize(image_rect: Rect, handle: ImageHandle, delta: Vec2) -> Rect { + if !image_rect.is_finite() || !delta.x.is_finite() || !delta.y.is_finite() { + return image_rect; + } + let mut min = image_rect.min; + let mut max = image_rect.max; + + match handle { + // Dragging TopLeft: Fixed Anchor = BottomRight (max.x, max.y) + ImageHandle::TopLeft => { + let anchor = max; + let target = min + delta; + let new_w = (anchor.x - target.x).max(MIN_RECT_SIZE); + let new_h = (anchor.y - target.y).max(MIN_RECT_SIZE); + min.x = anchor.x - new_w; + min.y = anchor.y - new_h; + } + + // Dragging TopCenter: Fixed Anchor = Bottom edge (max.y) + ImageHandle::TopCenter => { + let anchor_y = max.y; + let target_y = min.y + delta.y; + let new_h = (anchor_y - target_y).max(MIN_RECT_SIZE); + min.y = anchor_y - new_h; + } + + // Dragging TopRight: Fixed Anchor = BottomLeft (min.x, max.y) + ImageHandle::TopRight => { + let anchor_x = min.x; + let anchor_y = max.y; + let target_x = max.x + delta.x; + let target_y = min.y + delta.y; + let new_w = (target_x - anchor_x).max(MIN_RECT_SIZE); + let new_h = (anchor_y - target_y).max(MIN_RECT_SIZE); + max.x = anchor_x + new_w; + min.y = anchor_y - new_h; + } + + // Dragging RightCenter: Fixed Anchor = Left edge (min.x) + ImageHandle::RightCenter => { + let anchor_x = min.x; + let target_x = max.x + delta.x; + let new_w = (target_x - anchor_x).max(MIN_RECT_SIZE); + max.x = anchor_x + new_w; + } + + // Dragging BottomRight: Fixed Anchor = TopLeft (min.x, min.y) + ImageHandle::BottomRight => { + let anchor = min; + let target = max + delta; + let new_w = (target.x - anchor.x).max(MIN_RECT_SIZE); + let new_h = (target.y - anchor.y).max(MIN_RECT_SIZE); + max.x = anchor.x + new_w; + max.y = anchor.y + new_h; + } + + // Dragging BottomCenter: Fixed Anchor = Top edge (min.y) + ImageHandle::BottomCenter => { + let anchor_y = min.y; + let target_y = max.y + delta.y; + let new_h = (target_y - anchor_y).max(MIN_RECT_SIZE); + max.y = anchor_y + new_h; + } + + // Dragging BottomLeft: Fixed Anchor = TopRight (max.x, min.y) + ImageHandle::BottomLeft => { + let anchor_x = max.x; + let anchor_y = min.y; + let target_x = min.x + delta.x; + let target_y = max.y + delta.y; + let new_w = (anchor_x - target_x).max(MIN_RECT_SIZE); + let new_h = (target_y - anchor_y).max(MIN_RECT_SIZE); + min.x = anchor_x - new_w; + max.y = anchor_y + new_h; + } + + // Dragging LeftCenter: Fixed Anchor = Right edge (max.x) + ImageHandle::LeftCenter => { + let anchor_x = max.x; + let target_x = min.x + delta.x; + let new_w = (anchor_x - target_x).max(MIN_RECT_SIZE); + min.x = anchor_x - new_w; + } + } + + Rect::from_min_max(min, max) +} + +/// **Anchor-Based Proportional Resize System** +/// +/// Recalculates the screen bounding box by locking the diagonally opposite point as +/// a fixed anchor while preserving the aspect ratio. +/// +/// For corner handles, uses the axis with the larger relative change to scale both +/// width and height uniformly from the locked anchor point, clamped to `>= MIN_RECT_SIZE`. +/// For mid-edge handles, delegates to `apply_resize`. +pub fn apply_resize_proportional(image_rect: Rect, handle: ImageHandle, delta: Vec2) -> Rect { + if !image_rect.is_finite() || !delta.x.is_finite() || !delta.y.is_finite() { + return image_rect; + } + let w0 = image_rect.width().max(1.0); + let h0 = image_rect.height().max(1.0); + + match handle { + ImageHandle::TopLeft => { + let horizontal = -delta.x / w0; + let vertical = -delta.y / h0; + let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; + let k = (1.0 + d).max(MIN_RECT_SIZE / w0).max(MIN_RECT_SIZE / h0); + let (w, h) = (w0 * k, h0 * k); + Rect::from_min_max(Pos2::new(image_rect.max.x - w, image_rect.max.y - h), image_rect.max) + } + ImageHandle::TopRight => { + let horizontal = delta.x / w0; + let vertical = -delta.y / h0; + let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; + let k = (1.0 + d).max(MIN_RECT_SIZE / w0).max(MIN_RECT_SIZE / h0); + let (w, h) = (w0 * k, h0 * k); + Rect::from_min_max(Pos2::new(image_rect.min.x, image_rect.max.y - h), Pos2::new(image_rect.min.x + w, image_rect.max.y)) + } + ImageHandle::BottomLeft => { + let horizontal = -delta.x / w0; + let vertical = delta.y / h0; + let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; + let k = (1.0 + d).max(MIN_RECT_SIZE / w0).max(MIN_RECT_SIZE / h0); + let (w, h) = (w0 * k, h0 * k); + Rect::from_min_max(Pos2::new(image_rect.max.x - w, image_rect.min.y), Pos2::new(image_rect.max.x, image_rect.min.y + h)) + } + ImageHandle::BottomRight => { + let horizontal = delta.x / w0; + let vertical = delta.y / h0; + let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; + let k = (1.0 + d).max(MIN_RECT_SIZE / w0).max(MIN_RECT_SIZE / h0); + let (w, h) = (w0 * k, h0 * k); + Rect::from_min_max(image_rect.min, Pos2::new(image_rect.min.x + w, image_rect.min.y + h)) + } + mid => apply_resize(image_rect, mid, delta), + } +} + +/// Trim a screen rectangle and its UVs together, keeping the opposite edge fixed. +/// The pixel scale stays unchanged, and each edge stops at the texture boundary. +pub fn apply_crop(image_rect: Rect, uv_rect: Rect, handle: ImageHandle, delta: Vec2) -> (Rect, Rect) { + if !delta.is_finite() || !image_rect.is_finite() || !uv_rect.is_finite() || !image_rect.is_positive() || !uv_rect.is_positive() { + return (image_rect, uv_rect); + } + let scale = image_rect.size() / uv_rect.size(); + if !scale.is_finite() || scale.x <= 0.0 || scale.y <= 0.0 { + return (image_rect, uv_rect); + } + let minimum = (egui::vec2(MIN_RECT_SIZE, MIN_RECT_SIZE) / scale).min(uv_rect.size() * 0.95); + let mut uv = uv_rect; + if matches!(handle, ImageHandle::TopLeft | ImageHandle::LeftCenter | ImageHandle::BottomLeft) { + uv.min.x = (uv.min.x + delta.x / scale.x).clamp(0.0, (uv.max.x - minimum.x).max(0.0)); + } else if matches!(handle, ImageHandle::TopRight | ImageHandle::RightCenter | ImageHandle::BottomRight) { + uv.max.x = (uv.max.x + delta.x / scale.x).clamp((uv.min.x + minimum.x).min(1.0), 1.0); + } + if matches!(handle, ImageHandle::TopLeft | ImageHandle::TopCenter | ImageHandle::TopRight) { + uv.min.y = (uv.min.y + delta.y / scale.y).clamp(0.0, (uv.max.y - minimum.y).max(0.0)); + } else if matches!(handle, ImageHandle::BottomLeft | ImageHandle::BottomCenter | ImageHandle::BottomRight) { + uv.max.y = (uv.max.y + delta.y / scale.y).clamp((uv.min.y + minimum.y).min(1.0), 1.0); + } + // Reconstruct from the fixed opposite edge, rather than adding a nearly full-width + // shift to the moved edge. Calculate in f64 so each screen coordinate rounds only + // once to f32; small crops far from the origin otherwise lose multiple ulps. + let span = |min: f32, max: f32| f64::from(max) - f64::from(min); + let width = span(image_rect.min.x, image_rect.max.x) * span(uv.min.x, uv.max.x) / span(uv_rect.min.x, uv_rect.max.x); + let height = span(image_rect.min.y, image_rect.max.y) * span(uv.min.y, uv.max.y) / span(uv_rect.min.y, uv_rect.max.y); + let mut screen = image_rect; + if uv.min.x != uv_rect.min.x { + screen.min.x = (f64::from(image_rect.max.x) - width) as f32; + } else if uv.max.x != uv_rect.max.x { + screen.max.x = (f64::from(image_rect.min.x) + width) as f32; + } + if uv.min.y != uv_rect.min.y { + screen.min.y = (f64::from(image_rect.max.y) - height) as f32; + } else if uv.max.y != uv_rect.max.y { + screen.max.y = (f64::from(image_rect.min.y) + height) as f32; + } + if !screen.is_finite() || !screen.is_positive() { + return (image_rect, uv_rect); + } + (screen, uv) +} + +// ============================================================================ +// 2. VISUAL RENDERING HELPERS +// ============================================================================ + +/// Vibrant amber/orange color for Crop Mode. +pub const CROP_ACCENT: Color32 = Color32::from_rgb(230, 126, 34); + +/// Renders the Rule-of-Thirds composition grid inside the given crop rectangle. +pub fn draw_rule_of_thirds(painter: &egui::Painter, rect: Rect, stroke: Stroke) { + let w = rect.width(); + let h = rect.height(); + if w <= 0.0 || h <= 0.0 { + return; + } + + let x1 = rect.min.x + w / 3.0; + let x2 = rect.min.x + (2.0 * w) / 3.0; + painter.line_segment([pos2(x1, rect.min.y), pos2(x1, rect.max.y)], stroke); + painter.line_segment([pos2(x2, rect.min.y), pos2(x2, rect.max.y)], stroke); + + let y1 = rect.min.y + h / 3.0; + let y2 = rect.min.y + (2.0 * h) / 3.0; + painter.line_segment([pos2(rect.min.x, y1), pos2(rect.max.x, y1)], stroke); + painter.line_segment([pos2(rect.min.x, y2), pos2(rect.max.x, y2)], stroke); +} + +/// Renders a circular handle with shadow, fill, and accent stroke using the image tool accent. +pub fn draw_handle(painter: &egui::Painter, center: Pos2, is_active: bool, is_hovered: bool, crop_mode: bool, accent_color: Color32) { + let base_radius = 6.5; + let circle_radius = if is_active { + base_radius + 2.0 + } else if is_hovered { + base_radius + 1.2 + } else { + base_radius + }; + + let fill_color = if crop_mode { + if is_active || is_hovered { Color32::from_rgb(255, 230, 150) } else { Color32::from_rgb(255, 255, 255) } + } else if is_active || is_hovered { + Color32::from_rgb(210, 235, 255) + } else { + Color32::from_rgb(255, 255, 255) + }; + + // Draw handle shadow/glow + painter.circle_filled(center, circle_radius + 1.5, Color32::from_rgba_unmultiplied(0, 0, 0, 80)); + + // Draw handle circle + painter.circle_filled(center, circle_radius, fill_color); + painter.circle_stroke(center, circle_radius, Stroke::new(1.8, accent_color)); +} + +// ============================================================================ +// 3. UNIT TESTS: MATHEMATICAL VERIFICATION +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use egui::vec2; + + // Verify scale in screen space: dividing an endpoint's rounding error by a tiny + // retained UV span would magnify it and make a fixed scale tolerance invalid. + fn crop_scale_preserved(before: Rect, uv_before: Rect, after: Rect, uv_after: Rect) -> bool { + let axis = |lo: f32, hi: f32, u0: f32, u1: f32, new_lo: f32, new_hi: f32, v0: f32, v1: f32| { + let span = f64::from(hi) - f64::from(lo); + let expected = span * (f64::from(v1) - f64::from(v0)) / (f64::from(u1) - f64::from(u0)); + let actual = f64::from(new_hi) - f64::from(new_lo); + // One moved endpoint rounds once to f32. EPSILON * coordinate magnitude + // bounds one ulp (conservatively twice the nearest-rounding error). + // The f64 term also covers the two arithmetic operations above. + let magnitude = f64::from(new_lo).abs().max(f64::from(new_hi).abs()).max(expected.abs()); + let bound = f64::from(f32::EPSILON) * magnitude + 4.0 * f64::EPSILON * span.abs(); + (actual - expected).abs() <= bound + }; + axis(before.min.x, before.max.x, uv_before.min.x, uv_before.max.x, after.min.x, after.max.x, uv_after.min.x, uv_after.max.x) + && axis(before.min.y, before.max.y, uv_before.min.y, uv_before.max.y, after.min.y, after.max.y, uv_after.min.y, uv_after.max.y) + } + + #[test] + fn crop_scale_at_large_screen_offset() { + let screen = Rect::from_min_size(pos2(813.7157, 0.0), vec2(495.0725, 50.0)); + let uv = Rect::from_min_size(pos2(0.114936605, 0.0), vec2(0.32074833, 0.3)); + let (cropped, kept) = apply_crop(screen, uv, ImageHandle::BottomLeft, vec2(1255.676, 0.0)); + let width = (f64::from(screen.max.x) - f64::from(screen.min.x)) * (f64::from(kept.max.x) - f64::from(kept.min.x)) + / (f64::from(uv.max.x) - f64::from(uv.min.x)); + assert_eq!(cropped.min.x, (f64::from(screen.max.x) - width) as f32, "moved edge rounds once from the fixed anchor"); + assert_eq!(cropped.max.x, screen.max.x, "opposite horizontal edge stays fixed"); + assert_eq!(cropped.min.y, screen.min.y, "opposite vertical edge stays fixed"); + assert!(crop_scale_preserved(screen, uv, cropped, kept)); + let stretched = Rect::from_min_max(cropped.min - vec2(0.1, 0.0), cropped.max); + assert!(!crop_scale_preserved(screen, uv, stretched, kept), "a visible stretch must exceed the rounding bound"); + } + + #[test] + fn test_resize_bottom_right_anchor() { + let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0)); + let res = apply_resize(r, ImageHandle::BottomRight, vec2(50.0, 25.0)); + assert_eq!(res.min, pos2(100.0, 100.0), "TopLeft anchor must remain fixed"); + assert_eq!(res.max, pos2(350.0, 225.0), "BottomRight must expand by delta"); + } + + #[test] + fn test_resize_top_left_anchor() { + let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0)); + let res = apply_resize(r, ImageHandle::TopLeft, vec2(20.0, 15.0)); + assert_eq!(res.max, pos2(300.0, 200.0), "BottomRight anchor must remain fixed"); + assert_eq!(res.min, pos2(120.0, 115.0), "TopLeft must move by delta"); + } + + #[test] + fn test_resize_clamping_prevents_inversion() { + let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(200.0, 200.0)); + let res = apply_resize(r, ImageHandle::TopLeft, vec2(500.0, 500.0)); + assert_eq!(res.max, pos2(200.0, 200.0)); + assert_eq!(res.min, pos2(200.0 - MIN_RECT_SIZE, 200.0 - MIN_RECT_SIZE)); + assert!(res.width() >= MIN_RECT_SIZE); + assert!(res.height() >= MIN_RECT_SIZE); + } + + #[test] + fn test_resize_top_right_anchor() { + let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0)); + let res = apply_resize(r, ImageHandle::TopRight, vec2(10.0, -10.0)); + assert_eq!(res.min.x, 100.0, "min.x (BL) anchor fixed"); + assert_eq!(res.max.y, 200.0, "max.y (BL) anchor fixed"); + assert_eq!(res.max.x, 310.0); + assert_eq!(res.min.y, 90.0); + } + + #[test] + fn test_resize_bottom_left_anchor() { + let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0)); + let res = apply_resize(r, ImageHandle::BottomLeft, vec2(-15.0, 20.0)); + assert_eq!(res.max.x, 300.0, "max.x (TR) anchor fixed"); + assert_eq!(res.min.y, 100.0, "min.y (TR) anchor fixed"); + assert_eq!(res.min.x, 85.0); + assert_eq!(res.max.y, 220.0); + } + + #[test] + fn test_resize_mid_edges() { + let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0)); + + let tm = apply_resize(r, ImageHandle::TopCenter, vec2(50.0, -30.0)); + assert_eq!(tm.min.x, 100.0, "X min unaffected"); + assert_eq!(tm.max.x, 300.0, "X max unaffected"); + assert_eq!(tm.max.y, 200.0, "Y max anchor locked"); + assert_eq!(tm.min.y, 70.0); + + let rm = apply_resize(r, ImageHandle::RightCenter, vec2(40.0, 50.0)); + assert_eq!(rm.min.x, 100.0, "X min anchor locked"); + assert_eq!(rm.max.x, 340.0); + assert_eq!(rm.min.y, 100.0, "Y unaffected"); + assert_eq!(rm.max.y, 200.0, "Y unaffected"); + } + + #[test] + fn test_crop_lockstep_ratio_preservation() { + let screen = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 400.0)); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + + let initial_px_per_uv_x = screen.width() / uv.width(); + let initial_px_per_uv_y = screen.height() / uv.height(); + + let (new_screen, new_uv) = apply_crop(screen, uv, ImageHandle::RightCenter, vec2(-80.0, 0.0)); + + assert_eq!(new_screen.min.x, 100.0, "Left edge anchored"); + assert_eq!(new_screen.max.x, 420.0, "Right edge moved in by 80px"); + assert_eq!(new_screen.width(), 320.0); + + assert_eq!(new_uv.min.x, 0.0); + assert!((new_uv.max.x - 0.8).abs() < 1e-4); + + let new_px_per_uv_x = new_screen.width() / new_uv.width(); + assert!((new_px_per_uv_x - initial_px_per_uv_x).abs() < 1e-4, "Pixel scale must remain invariant in crop mode"); + + let new_px_per_uv_y = new_screen.height() / new_uv.height(); + assert!((new_px_per_uv_y - initial_px_per_uv_y).abs() < 1e-4, "Pixel scale Y must remain invariant"); + } + + #[test] + fn test_crop_left_edge_anchoring() { + let screen = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 400.0)); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + + let (new_screen, new_uv) = apply_crop(screen, uv, ImageHandle::LeftCenter, vec2(50.0, 0.0)); + + assert_eq!(new_screen.max.x, 500.0, "Right edge must remain strictly anchored"); + assert_eq!(new_screen.min.x, 150.0, "Left edge must move by 50px"); + assert_eq!(new_screen.width(), 350.0); + + assert!((new_uv.min.x - 0.125).abs() < 1e-4); + assert_eq!(new_uv.max.x, 1.0); + } + + #[test] + fn test_crop_uncrop_clamped_at_zero_and_one() { + let screen = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 400.0)); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + + let (new_screen, new_uv) = apply_crop(screen, uv, ImageHandle::LeftCenter, vec2(-100.0, 0.0)); + assert_eq!(new_uv.min.x, 0.0, "UV min cannot go below 0.0"); + assert_eq!(new_screen.min.x, 100.0, "Screen min clamped at initial position"); + + let (new_screen2, new_uv2) = apply_crop(screen, uv, ImageHandle::RightCenter, vec2(100.0, 0.0)); + assert_eq!(new_uv2.max.x, 1.0, "UV max cannot exceed 1.0"); + assert_eq!(new_screen2.max.x, 500.0, "Screen max clamped at initial position"); + } + + #[test] + fn test_apply_resize_proportional_horizontal_only_scales_both_dimensions() { + let rect = Rect::from_min_max(pos2(100.0, 100.0), pos2(200.0, 200.0)); // 100 x 100 + // TopRight corner moved horizontally by -25px (from x=200 to x=175) + let resized = apply_resize_proportional(rect, ImageHandle::TopRight, vec2(-25.0, 0.0)); + assert_eq!(resized.min.x, 100.0, "Left anchor preserved"); + assert_eq!(resized.max.y, 200.0, "Bottom anchor preserved"); + assert_eq!(resized.width(), 75.0, "Width scaled to 75%"); + assert_eq!(resized.height(), 75.0, "Height scaled to 75% proportionally"); + assert_eq!(resized.max.x, 175.0); + assert_eq!(resized.min.y, 125.0); + } + + #[test] + fn test_apply_resize_proportional_bottom_right_anchor_locked() { + let rect = Rect::from_min_max(pos2(50.0, 50.0), pos2(150.0, 250.0)); // 100 x 200 + // BottomRight moved down/right by (20px, 40px) + let resized = apply_resize_proportional(rect, ImageHandle::BottomRight, vec2(20.0, 40.0)); + assert_eq!(resized.min, pos2(50.0, 50.0), "TopLeft anchor strictly locked"); + assert_eq!(resized.width(), 120.0); + assert_eq!(resized.height(), 240.0); + } + + #[cfg(test)] + mod proptests { + use super::*; + use proptest::prelude::*; + + fn arb_handle() -> impl Strategy { + prop_oneof![ + Just(ImageHandle::TopLeft), + Just(ImageHandle::TopCenter), + Just(ImageHandle::TopRight), + Just(ImageHandle::RightCenter), + Just(ImageHandle::BottomRight), + Just(ImageHandle::BottomCenter), + Just(ImageHandle::BottomLeft), + Just(ImageHandle::LeftCenter), + ] + } + + proptest! { + #[test] + fn prop_apply_resize_never_inverts_and_respects_min_size( + min_x in -5000.0f32..5000.0, + min_y in -5000.0f32..5000.0, + w in 10.0f32..2000.0, + h in 10.0f32..2000.0, + handle in arb_handle(), + dx in -10000.0f32..10000.0, + dy in -10000.0f32..10000.0, + ) { + let rect = Rect::from_min_size(pos2(min_x, min_y), vec2(w, h)); + let res = apply_resize(rect, handle, vec2(dx, dy)); + prop_assert!(res.is_finite()); + prop_assert!(res.width() >= MIN_RECT_SIZE - 1e-3); + prop_assert!(res.height() >= MIN_RECT_SIZE - 1e-3); + prop_assert!(res.min.x <= res.max.x); + prop_assert!(res.min.y <= res.max.y); + } + + #[test] + fn prop_apply_resize_proportional_preserves_aspect_ratio( + min_x in -2000.0f32..2000.0, + min_y in -2000.0f32..2000.0, + w in 20.0f32..1000.0, + h in 20.0f32..1000.0, + handle in arb_handle(), + dx in -5000.0f32..5000.0, + dy in -5000.0f32..5000.0, + ) { + let rect = Rect::from_min_size(pos2(min_x, min_y), vec2(w, h)); + let res = apply_resize_proportional(rect, handle, vec2(dx, dy)); + prop_assert!(res.is_finite()); + prop_assert!(res.width() >= MIN_RECT_SIZE - 1e-3); + prop_assert!(res.height() >= MIN_RECT_SIZE - 1e-3); + + let is_corner = matches!(handle, ImageHandle::TopLeft | ImageHandle::TopRight | ImageHandle::BottomLeft | ImageHandle::BottomRight); + if is_corner { + let orig_ratio = w / h; + let new_ratio = res.width() / res.height(); + prop_assert!((orig_ratio - new_ratio).abs() <= 1e-3 * orig_ratio.max(1.0), "orig: {orig_ratio}, new: {new_ratio}"); + } + } + + #[test] + fn prop_apply_crop_uv_clamped( + min_x in 0.0f32..1000.0, + min_y in 0.0f32..1000.0, + w in 50.0f32..500.0, + h in 50.0f32..500.0, + uv_u0 in 0.0f32..0.4, + uv_v0 in 0.0f32..0.4, + uv_w in 0.3f32..0.5, + uv_h in 0.3f32..0.5, + handle in arb_handle(), + dx in -2000.0f32..2000.0, + dy in -2000.0f32..2000.0, + ) { + let screen = Rect::from_min_size(pos2(min_x, min_y), vec2(w, h)); + let uv = Rect::from_min_size(pos2(uv_u0, uv_v0), vec2(uv_w, uv_h)); + let (new_screen, new_uv) = apply_crop(screen, uv, handle, vec2(dx, dy)); + + prop_assert!(new_screen.is_finite()); + prop_assert!(new_uv.is_finite()); + prop_assert!(new_uv.min.x >= 0.0 - 1e-5); + prop_assert!(new_uv.min.y >= 0.0 - 1e-5); + prop_assert!(new_uv.max.x <= 1.0 + 1e-5); + prop_assert!(new_uv.max.y <= 1.0 + 1e-5); + prop_assert!(new_uv.width() > 0.0); + prop_assert!(new_uv.height() > 0.0); + + if !matches!(handle, ImageHandle::TopLeft | ImageHandle::LeftCenter | ImageHandle::BottomLeft) { + prop_assert_eq!(new_screen.min.x, screen.min.x, "stationary left edge moved"); + } + if !matches!(handle, ImageHandle::TopRight | ImageHandle::RightCenter | ImageHandle::BottomRight) { + prop_assert_eq!(new_screen.max.x, screen.max.x, "stationary right edge moved"); + } + if !matches!(handle, ImageHandle::TopLeft | ImageHandle::TopCenter | ImageHandle::TopRight) { + prop_assert_eq!(new_screen.min.y, screen.min.y, "stationary top edge moved"); + } + if !matches!(handle, ImageHandle::BottomLeft | ImageHandle::BottomCenter | ImageHandle::BottomRight) { + prop_assert_eq!(new_screen.max.y, screen.max.y, "stationary bottom edge moved"); + } + + prop_assert!(crop_scale_preserved(screen, uv, new_screen, new_uv), + "screen/UV scale changed beyond endpoint rounding: {screen:?} {uv:?} => {new_screen:?} {new_uv:?}"); + } + + #[test] + fn prop_quad_contains_never_panics( + q0_x in -1000.0f32..1000.0, + q0_y in -1000.0f32..1000.0, + q1_x in -1000.0f32..1000.0, + q1_y in -1000.0f32..1000.0, + q2_x in -1000.0f32..1000.0, + q2_y in -1000.0f32..1000.0, + q3_x in -1000.0f32..1000.0, + q3_y in -1000.0f32..1000.0, + px in -2000.0f32..2000.0, + py in -2000.0f32..2000.0, + ) { + let quad = [pos2(q0_x, q0_y), pos2(q1_x, q1_y), pos2(q2_x, q2_y), pos2(q3_x, q3_y)]; + let p = pos2(px, py); + let _ = crate::edit_text_ui::quad_contains(quad, p); + } + } + + #[test] + fn degenerate_quad_does_not_contain_points() { + let zero_quad = [pos2(0.0, 0.0), pos2(0.0, 0.0), pos2(0.0, 0.0), pos2(0.0, 0.0)]; + assert!(!crate::edit_text_ui::quad_contains(zero_quad, pos2(10.0, 10.0))); + assert!(!crate::edit_text_ui::quad_contains(zero_quad, pos2(0.0, 0.0))); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 222bb988e4..37e8e53165 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -36,6 +36,7 @@ macro_rules! tl_ctx { } pub mod canvas; mod chrome; +mod image_engine; pub use combine_grid::ThumbState as CombineThumb; pub use combine_ui::{Columns as CombineColumns, CombineView, Lock as CombineLock, SortKey}; mod commands; @@ -45,13 +46,14 @@ mod menus; pub mod marks { pub use crate::marks_ui::{MarksDraft, PageRange, Subset}; } +pub use content_ui::TextDraft; pub use create_ui::Clip; pub use link_ui::LinkDraft; pub use optimize_ui::{OptimizeDraft, OptimizeTab}; pub use sign_ui::{DigitalIdEntry, SignDraft, SignGraphic, SignStep}; mod bidi; mod dialogs; -pub use edit_text_ui::CropMode; +pub use edit_text_ui::{CropMode, DragTarget, ImageDrag, ImageHandle, ImageSelection}; mod editing; mod home; mod panels; @@ -77,6 +79,9 @@ mod state; mod tools; mod workflows; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) mod os_clipboard; + #[cfg(not(target_arch = "wasm32"))] pub(crate) use app::same_file; pub(crate) use app::{MAX_AUTHOR_CHARS, copy_refused, date_text_for_pdf, print_refused}; @@ -239,6 +244,7 @@ struct PendingOpen { after_open: Option, recovered: Option, then: Option<&'static str>, + display_override: Option<(canvas::PageLayout, bool)>, } pub struct PdfCraftApp { @@ -291,6 +297,8 @@ pub struct PdfCraftApp { /// The reading-colours preference (Preferences ▸ Reading colours): applied to every open /// document, and documents open with it (Acrobat's Replace Document Colors). pub reading_colors: pdfcraft_render::ReadingColors, + /// App-wide page decoration only; never part of a PDF edit or render request. + pub show_page_edge_shadows: bool, /// The Linux portal is enabled only by the native host, and starts on the first System choice. #[cfg(target_os = "linux")] desktop_theme: Option, @@ -368,10 +376,19 @@ pub struct PdfCraftApp { pending_opens: Vec, #[cfg(not(target_arch = "wasm32"))] next_open_token: u64, + /// Explicit CLI layout waits for flagged launch opens, never targeting restored tabs. + #[cfg(not(target_arch = "wasm32"))] + launch_layout: Option<(canvas::PageLayout, bool)>, + #[cfg(not(target_arch = "wasm32"))] + launch_display_target: Option, #[cfg(all(test, not(target_arch = "wasm32")))] open_prepare_gate: Option>, /// Folders pinned to Home, and what they held when last listed. pub pinned: folders_ui::PinnedFolders, + /// Tools pinned to Home, in user-defined order. + pub pinned_tools: Vec, + /// Search query for the Pin a tool selection dialog. + pub pin_tool_search: String, pub toast: Option<(String, f64)>, /// The form script message the toast shows now, if it shows one: drawn with its title and /// icon (#4293). @@ -537,8 +554,6 @@ pub struct PdfCraftApp { pub system_clipboard: bool, /// What ⌘V reads instead of the system clipboard (tests). pub clipboard_override: Option, - /// V is down as a plain key press (see [`Self::paste_key_released`]). - v_pressed: bool, /// Attach file: the file to attach instead of asking (tests, automation). pub attach_override: Option<(String, Vec)>, /// Duplicate Field: which field and onto which pages. diff --git a/crates/ui-egui/src/os_clipboard.rs b/crates/ui-egui/src/os_clipboard.rs new file mode 100644 index 0000000000..9f68bd957b --- /dev/null +++ b/crates/ui-egui/src/os_clipboard.rs @@ -0,0 +1,28 @@ +//! The one way to the operating system's clipboard. +//! +//! The system clipboard is a single process-wide resource: on Windows, two threads that +//! open it at once can corrupt the heap. Every access goes through [`with`], which holds a +//! lock across the whole open, use and close, so parallel callers (test threads, workers) +//! take turns instead of racing. + +use std::sync::{Mutex, PoisonError}; + +static LOCK: Mutex<()> = Mutex::new(()); + +/// Opens the system clipboard, runs `f` on it and closes it, with no other thread inside. +pub(crate) fn with(f: impl FnOnce(&mut arboard::Clipboard) -> Result) -> Result { + let _turn = LOCK.lock().unwrap_or_else(PoisonError::into_inner); + // Declared after the guard, so it's closed before the lock is released. + let mut clipboard = arboard::Clipboard::new()?; + f(&mut clipboard) +} + +/// Nonempty text supersedes the app's cached image, even if another clipboard format also exists. +pub(crate) fn has_text() -> bool { + with(|cb| cb.get_text()).is_ok_and(|text| !text.is_empty()) +} + +/// The system clipboard as image paste sees it; text takes precedence over a cached image. +pub(crate) fn for_image_paste() -> Option { + if has_text() { Some(crate::create_ui::Clip::Text(String::new())) } else { crate::create_ui::read_clipboard() } +} diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index e95db85a72..2ba86ee5f5 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -266,11 +266,27 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { .into_iter() .map(|f| (f.resource, f.face)) .collect(); - let shown = ed.font_shown(&sources); + let fmt = ed.selection_formatting(&sources); + ed.sync_selection_look(&fmt); + let shown = fmt.font_shown; + let mixed_size = fmt.size.is_none(); // A selection aligns the rows it touches; the caret alone, the whole paragraph. let paragraphs = crate::format_text_ui::Paragraphs { text: &ed.text, selection: ed.selection.clone(), tidy: false }; - let out = crate::format_text_ui::panel(ui, t, &mut ed.look, Some(&mut ed.extras), Fonts { sources: Some(&sources), shown }, Some(paragraphs)); + let out = crate::format_text_ui::panel( + ui, + t, + &mut ed.look, + Some(&mut ed.extras), + Fonts { sources: Some(&sources), shown, mixed_size }, + Some(paragraphs), + ); + if out.size_committed { + ed.choose_size(ed.look.size); + } let mut changed = out.changed; + if let Some(script) = out.script { + changed |= crate::edit_text_ui::toggle_script(&mut ed.text, &mut ed.selection, script); + } match out.font { Some(FontChoice::KeepSource) => { ed.keep_source_font(); @@ -305,7 +321,13 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { } let mut style = draft.style.clone(); let paragraphs = crate::format_text_ui::Paragraphs { text: &draft.text, selection: draft.selection.clone(), tidy: true }; - added_text_format(ui, t, &mut style, Some(paragraphs)); + let out = added_text_format(ui, t, &mut style, Some(paragraphs)); + if let Some(script) = out.script { + let mut toggled = draft.clone(); + if crate::edit_text_ui::toggle_script(&mut toggled.text, &mut toggled.selection, script) { + app.views[i].content.draft = Some(toggled); + } + } if style != draft.style { app.restyle_draft(i, style); } @@ -363,12 +385,18 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { /// Format text for added text: the standard families, size, colour, style and alignment /// (of the lines `paragraphs` selects, while the text is being typed). -fn added_text_format(ui: &mut egui::Ui, t: &Tokens, style: &mut pdfcraft_engine::AddedText, paragraphs: Option) { +fn added_text_format( + ui: &mut egui::Ui, + t: &Tokens, + style: &mut pdfcraft_engine::AddedText, + paragraphs: Option, +) -> crate::format_text_ui::Outcome { let shown = style.family.label().to_string(); - let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: None, shown }, paragraphs); + let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: None, shown, mixed_size: false }, paragraphs); if let Some(crate::format_text_ui::FontChoice::Family(family)) = out.font { style.family = family; } + out } impl PdfCraftApp { diff --git a/crates/ui-egui/src/panels/format_text_ui.rs b/crates/ui-egui/src/panels/format_text_ui.rs index eccef2aa79..dbb50f73ec 100644 --- a/crates/ui-egui/src/panels/format_text_ui.rs +++ b/crates/ui-egui/src/panels/format_text_ui.rs @@ -3,12 +3,13 @@ //! horizontal scale and character spacing. One panel serves a paragraph being edited (with the //! fonts already on its page and the spacing controls) and added text (the standard families, //! size, colour, style, alignment and character spacing). What PdfCraft can't apply yet (lists, -//! superscript and subscript, paragraph spacing, or line spacing and scale for added text) is shown +//! paragraph spacing, or line spacing and scale for added text) is shown //! disabled and says so. //! //! The icons are Lucide's (AGENTS.md §1); only the arrangement follows Acrobat. use egui::{Align, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2}; +use pdfcraft_engine::text_script::Script; use pdfcraft_engine::{AddedText, FontFamily, TextAlign}; use crate::edit_text_ui::Extras; @@ -38,6 +39,7 @@ pub(crate) struct Fonts<'a> { pub sources: Option<&'a [(String, String)]>, /// The name the list shows for the current choice. pub shown: String, + pub mixed_size: bool, } /// Text being typed in, for the alignment buttons: with some of it selected, they show and set the @@ -85,7 +87,10 @@ impl Paragraphs<'_> { #[derive(Default)] pub(crate) struct Outcome { pub changed: bool, + pub size_committed: bool, pub font: Option, + /// Superscript or Subscript was clicked, for the selected paragraphs' text. + pub script: Option"##; + let form = layout_xml(&form_of_draws(master, &line_draw("1in", "1in", ""))).unwrap(); + let held = turned(&form.pages[0]); + assert_eq!(held.len(), 1, "{:?}", form.pages[0].items); + let text: Vec<&str> = held[0].items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s.text.as_str()) } else { None }).collect(); + assert_eq!(text.concat().trim(), "of 1", "{text:?}"); +} + +/// A link in turned text is clicked where the text is drawn, whichever way it turned: the box it +/// has unturned, turned about the draw's anchor. +#[test] +fn a_link_turns_with_its_text() { + let rect_at = |rotate: &str| { + let draw = format!( + r#"

a link in turned text

"# + ); + let (doc, page) = written(&form_of_draws("", &draw)); + let annots = page.get(b"Annots").map(|a| doc.resolve(a)).expect("the link"); + let annot = doc.get(annots.as_array().unwrap()[0].as_ref().unwrap()); + let rect: Vec = annot.as_dict().unwrap().get(b"Rect").unwrap().as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + rect + }; + let upright = rect_at(""); + let (ax, ay) = (18.0 + 72.0, 792.0 - 18.0 - 216.0); + let (left, bottom, right, top) = (upright[0] - ax, upright[1] - ay, upright[2] - ax, upright[3] - ay); + assert!(left >= -0.01 && top <= 0.01 && right - left > 3.0 * (top - bottom), "it stands right and down of its anchor: {upright:?}"); + for (rotate, want) in [ + ("90", [ax - top, ay + left, ax - bottom, ay + right]), + ("180", [ax - right, ay - top, ax - left, ay - bottom]), + ("270", [ax + bottom, ay - right, ax + top, ay - left]), + ] { + let got = rect_at(&format!(r#"rotate="{rotate}""#)); + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 0.01), "{rotate}: {got:?} for {want:?}"); + } +} + +/// Only a draw placed by position turns: a field, and a draw in a flow, stand as they did. +#[test] +fn only_positioned_draws_turn() { + let field = r#""#; + let form = layout_xml(&form_of_draws("", field)).unwrap(); + assert!(turned(&form.pages[0]).is_empty()); + assert_eq!(widgets(&form.pages[0]).len(), 1); + let flowed = form_of_draws("", &line_draw("1in", "1in", r#"rotate="90""#)) + .replace(r#""#, r#""#); + let form = layout_xml(&flowed).unwrap(); + assert!(turned(&form.pages[0]).is_empty(), "{:?}", form.pages[0].items); + assert!(form.pages[0].items.iter().any(|i| matches!(i, Item::Line { .. })), "it is still drawn"); + // A draw that shows nothing has nothing to turn. + let form = layout_xml(&form_of_draws("", &line_draw("1in", "1in", r#"rotate="90" presence="invisible""#))).unwrap(); + assert!(form.pages[0].items.is_empty(), "{:?}", form.pages[0].items); +} + +/// A block carried over to a content area further across takes a turned draw with it, point and +/// items both. +#[test] +fn a_turned_draw_moves_across_with_its_content_area() { + let xdp = form_of_draws("", &line_draw("1in", "6in", r#"rotate="90""#)) + .replace( + r#""#, + r#""#, + ) + .replace(r#"h="2.5in""#, r#"h="7in""#); + let form = layout_xml(&xdp).unwrap(); + let moved = form.pages.iter().flat_map(turned).next().expect("turned"); + let Item::Line { from, .. } = &moved.items[0] else { panic!("not a line") }; + assert_eq!(*from, moved.about); + assert!((moved.about.0 - (4.0 + 1.0) * 72.0).abs() < 0.01, "{:?}", moved.about); +} + +/// A positioned subform with no height grows to hold what it holds as it stands, so what comes next +/// neither runs through a turned draw nor leaves the room its upright box would have had. +#[test] +fn a_container_grows_to_a_turned_draw_as_it_stands() { + let next = r#"next"#; + let baseline = |rotate: &str| { + let xdp = form_of_draws("", &line_draw("1in", "1in", rotate)).replace(r#" h="2.5in""#, "").replace("", next); + let form = layout_xml(&xdp).unwrap(); + assert_eq!(form.pages.len(), 1); + form.pages[0].items.iter().find_map(|i| if let Item::Text(s) = i { Some(s.baseline) } else { None }).expect("the next block") + }; + // Upright the box ends 0.4in below the anchor, and the next block starts there. + let upright = baseline(""); + for (rotate, bottom) in [("90", 0.0), ("180", 0.0), ("270", 50.4)] { + let got = baseline(&format!(r#"rotate="{rotate}""#)) - (upright - 28.8); + assert!((got - bottom).abs() < 0.01, "{rotate}: the next block starts {got} below the anchor, not {bottom}"); + } +} + +/// A subform with no width is as wide as what it holds as it stands, whichever way that turned, +/// so a neighbour in a row doesn't stand off from it or over it. +#[test] +fn a_container_is_as_wide_as_a_turned_draw_stands() { + let row = |rotate: &str| { + let xdp = form_of_draws( + "", + &format!( + r#"{}beside"#, + line_draw("1in", "1in", rotate) + ), + ) + .replace(r#""#, r#""#); + let form = layout_xml(&xdp).unwrap(); + form.pages[0].items.iter().find_map(|i| if let Item::Text(s) = i { Some(s.x) } else { None }).expect("the neighbour") + }; + let upright = row(""); + // Upright it ends 0.7in right of the anchor; turned, 0.4in right, at the anchor, and at it again. + for (rotate, right) in [("90", 28.8), ("180", 0.0), ("270", 0.0)] { + let got = row(&format!(r#"rotate="{rotate}""#)) - (upright - 50.4); + assert!((got - right).abs() < 0.01, "{rotate}: the neighbour stands {got} right of the anchor, not {right}"); + } + // From a bottom-right anchor the box starts behind it, and the same turns take it the other way. + for (rotate, right) in [("90", 0.0), ("180", 50.4), ("270", 28.8)] { + let got = row(&format!(r#"anchorType="bottomRight" rotate="{rotate}""#)) - (upright - 50.4); + assert!((got - right).abs() < 0.01, "{rotate}: the neighbour stands {got} right of the anchor, not {right}"); + } +} diff --git a/docs/contributors.md b/docs/contributors.md index a1b0bcba13..fa7e988611 100644 --- a/docs/contributors.md +++ b/docs/contributors.md @@ -1,16 +1,14 @@ # Contributors in the About window **About ▸ Contributors** credits everyone who contributed to PdfCraft, and **About ▸ Models** credits -the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard -[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md); -this page is the local copy of the decision. +the AI models named in `Co-Authored-By` trailers. ## Decision - Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from disk or the network at run time; the web build has them too): - [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub - username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit. + username. - [`contributors/people.toml`](../contributors/people.toml): the names people chose for themselves. **Each contributor adds only their own entry**, in their own PR. - Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced @@ -36,20 +34,5 @@ display_name = "Nickname" sync_github_name = true # use the name on your public GitHub profile as display name ``` -or, with craftrules checked out next to `craft-apps/`: - -```sh -python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name -``` - -Names show after the next refresh: the script checks that each entry was committed by its own -user (or by a maintainer with a `consent_source`). Delete your entry to remove your names. - -## Refreshing (maintainers) - -```sh -python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh -python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only -``` - -Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded. +Names show for entries committed by their own user (or by a maintainer with a `consent_source`). +Delete your entry to remove your names. Git author names and emails are never recorded. diff --git a/docs/development.md b/docs/development.md index e50907ba56..8cb132d9f8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -154,7 +154,7 @@ copies retain their current preferences locations. | `PDFCRAFT_TITLEBAR` | Linux: `app` draws PdfCraft's own title bar (window buttons in the tab strip, in the order of GNOME's `button-layout`, and resize edges); `system` keeps the frame winit or the desktop draws; unset, PdfCraft draws its own when the Wayland compositor draws no frames (GNOME), and leaves it to the desktop otherwise (KDE, Sway, Hyprland, X11). Where PdfCraft draws it, Preferences ▸ Use system title bar switches back to winit's frame without a restart; `system` also hides that option | | `PDFCRAFT_BUILD_LABEL` | Build time: a label for a local or patched build (letters, digits, inner dots), shown after the version in `--version`, About and the log (`0.7.1 (titlebar)`). `packaging/linux/package.sh` also puts it in the file names and the .rpm/.deb release (`1.titlebar`), which upgrades over the release of the same version | | `CRAFT_FONTS_DIR` | Build time: a [craft-fonts](https://github.com/storytold/craft-fonts) checkout to embed (Japanese fonts) | -| `PDFCRAFT_SYSTEM_FONTS` | `0` stops the desktop app from using an installed font for characters its embedded fonts lack (`cargo xtask screenshots` sets it) | +| `PDFCRAFT_SYSTEM_FONTS` | `0` disables installed-font fallback for both interface text and non-embedded CJK page text in the desktop viewer, Combine thumbnails and accessibility figure previews (`cargo xtask screenshots` sets it). Document fallback is display-only: exports, printing, OCR and automation retain the bundled-font path. | | `FONTCONFIG_FILE` | On Linux/Unix, the Fontconfig configuration file used to discover installed UI fallback fonts (default: `/etc/fonts/fonts.conf`); relative filenames are resolved under `FONTCONFIG_PATH` | | `FONTCONFIG_PATH` | Directory for a relative `FONTCONFIG_FILE` (default: `/etc/fonts`; the first entry of a search path is used) | diff --git a/docs/licensing-self-check.md b/docs/licensing-self-check.md new file mode 100644 index 0000000000..305d756ff7 --- /dev/null +++ b/docs/licensing-self-check.md @@ -0,0 +1,33 @@ +# Source licensing self-check + +Run `cargo xtask license-check` to validate the checked-out source, offline. The command +does not download assets, fetch models, inspect installers, or generate dependency notices. +It reuses `cargo xtask assets` for attribution, asset policy and locked dependency versions, +then checks the documents recorded in `LICENSES.toml`. + +Each document has a repository-relative source, a unique portable logical destination and +the SHA-256 of its audited text. Destinations are validation metadata, not instructions to +copy files into packages. Required project documents and every asset's `licence_file` must +be mapped. Every dependency-bundled asset must have a document mapping matching its existing +`ATTRIBUTION.toml` licence expression. Foxit has an explicit required document. Licence +texts for egui's default fonts and CMaps are retained in `licenses/`, with their pinned +upstream origins recorded in the manifest. Existing font and asset texts stay in place. + +Missing files, empty or invalid text, changed hashes, duplicate mappings, case-insensitive +destination collisions, parent-file conflicts and unsafe paths fail the check. Source +symlinks must resolve inside the checkout. OCR models need complete attribution and valid +hashes and destination names; the model binaries and downloaded licence texts need not be +present. The optional craft-fonts checkout remains governed by its own attribution and +the existing asset validator; this command does not audit that external checkout. + +When changing attribution, edit `ATTRIBUTION.toml`, run `cargo xtask assets --write`. Generated attribution consistency remains checked only +by the existing asset validator. When changing licence or notice text, re-audit +its provenance before updating the corresponding hash. Do not replace a licence with +guessed terms or update a hash merely to silence a failure. `LICENSES.toml` is validated +with unknown fields rejected. + +The existing Linux source-validation step in `.github/workflows/ci.yml` and `cargo xtask ci` +run `license-check` in place of `assets`; the asset validator runs once within that command. +This Stage 1 check establishes source-document consistency, not completed-package +compliance or legal sufficiency. Installer inspection, dependency-notice generation and +font-delivery packaging changes remain separate work. diff --git a/docs/roadmap.md b/docs/roadmap.md index ab55c052c2..c5bc1ab7d0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,19 @@ # PdfCraft roadmap: milestones and what's next +> **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 migrate viewer-only CJK configuration to the split engine modules; estimates unchanged) +> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: document installed-font 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 font policy matches output, with isolated caches and GBK switching regressions; estimates unchanged) +> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: reconcile current main and retain Japanese serif/weight selection alongside display-only CJK fallback; estimates unchanged) > **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged) > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M5: one reply in the Comments panel is edited, copied or deleted from its own "…" menu; estimates unchanged) @@ -13,24 +27,29 @@ > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M6: the calculation order flags a formula that reads a field the form doesn't have and a script that builds the names it reads; the tools show each formula; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) - +> > **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: Word table cells use the widths of their own grid columns; estimates unchanged) > **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: table spans retain text from covered slots in Office exports; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: image export preserves soft-mask Decode opacity ranges; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: vector printing preserves existing page XObjects when adding annotation appearances; estimates unchanged) +> +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M2, PR #4172: CJK fallback in Combine thumbnails and figure previews; FangSong and subset-prefixed Founder family matching; estimates unchanged) +> +> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M14, #3558: rebase guarded macOS per-file association recovery onto current main; estimates unchanged) +> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections; estimates unchanged) > **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M7 #2517: a partial rewrite inside one text object closes its marker before the next paragraph; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7 #2517: a blank line stays a paragraph break; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) - +> > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged) - +> > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M4: initial-view options resolve indirect boolean preferences; estimates unchanged) > **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.) - +> > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged) - +> > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M3, #3541/#3620: comment hover follows individual marked lines and original annotation paint order; estimates unchanged) > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** patch (PR #3348 review: retain short tracked punctuation in gap estimates; estimates unchanged) - +> > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M2, #3601: missing spacing glyphs in non-embedded Japanese CID substitutes draw as blanks while PDF widths and Unicode stay unchanged; estimates unchanged) > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged) > **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M8, gap 6 #2662: the redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -38,7 +57,7 @@ > **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M6: calculation scripts read their notation only from a comment, and a regular expression after a condition as one; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** patch (M2 #3086: dotted TOC leaders no longer hide word spaces; estimates unchanged) - +> > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, PR #3101: optional gapless page display with translations in all 22 catalogs; organizer and comparison toggles corrected; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-20 - **Last updated:** 2026-10-20 - **Change:** minor (M6, #3502: form detection uses the existing content-stream boundary policy; estimates unchanged) @@ -53,7 +72,7 @@ > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-15 · **Change:** minor (M2: objects in one large object stream are read in linear time; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged) - +> > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-16 | **Last updated:** 2026-10-16 | **Change:** minor (M10 #1201 partial: lossless image Decode; estimates unchanged) | **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -116,7 +135,7 @@ encryption and storage-write retry behavior are unchanged; this does not resolve ## Alpha gate -The stage standard's gate: Acrobat Pro's everyday core workflows must each work end to end on the +The alpha gate: Acrobat Pro's everyday core workflows must each work end to end on the main platform (macOS), with the work saved and reopened. Any `no`, or a `partial` that blocks the workflow, keeps PdfCraft pre-alpha; those rows are the alpha checklist. Assessed 2026-10-10 with the rule applied across the Crafting Apps: a row blocks only when a typical user cannot complete the @@ -161,6 +180,14 @@ Help ▸ Check for updates is manual only. ## Milestones +M2 update (2026-10-23, #234 / continuation of #1567): the desktop viewer uses installed CJK +faces for non-embedded CID fonts, including Windows GBK-EUC-H books. Windows Song/Hei/Kai +families are retained when installed, and reload retains the fallback. This is display-only; +export, printing, OCR, automation and machines without a candidate retain their limitations. +Print preview excludes installed faces and isolates its font cache from the viewer, matching +output font availability even after repeated view/print switching (2026-10-26, PR #4172). +The M2 readiness and remaining-work estimates below are unchanged. + "Plan size" is the original single-agent sizing. "Done" is the estimated share of that milestone's *acceptance criteria* that are met, not lines of code. "Remaining" is re-measured on 2026-10-10 at the measured rate (see [target-app-parity.md](target-app-parity.md#effort-and-calibration)); the @@ -170,10 +197,10 @@ total matches the parity estimate. |---|---|---|---|---|---| | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | | M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Done since: #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Missing: ≥ 250 tests, own image codecs | -| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. Oversized missing inline-image samples, including header and terminator overhead, no longer pin the content window (#3107 / PR #3438); normal large streamed images retain deterministic sample boundaries. Radial luminosity fades have synthetic coverage for the existing #2132 mask guard, including per-pixel references, antialiased clips, previews, scales and tiles; no additional production fix was needed. Dotted TOC leaders no longer inflate letter tracking or hide word spaces (#3086); phrase search and selection are covered. Short tracked ellipses retain their gap samples and remain searchable. Missing spacing glyphs in non-embedded Japanese CID substitutes render blank without changing PDF advances or Unicode (#3601). | +| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. Oversized missing inline-image samples, including header and terminator overhead, no longer pin the content window (#3107 / PR #3438); normal large streamed images retain deterministic sample boundaries. Radial luminosity fades have synthetic coverage for the existing #2132 mask guard, including per-pixel references, antialiased clips, previews, scales and tiles; no additional production fix was needed. Dotted TOC leaders no longer inflate letter tracking or hide word spaces (#3086); phrase search and selection are covered. Short tracked ellipses retain their gap samples and remain searchable. Missing spacing glyphs in non-embedded Japanese CID substitutes render blank without changing PDF advances or Unicode (#3601). The desktop viewer can use installed CJK fonts, including Windows GBK Song/Hei/Kai/FangSong and subset-prefixed Founder names; Combine thumbnails and accessibility figure previews use the same display-only fallback, without changing export or print font policy (#234, continuation of #1567, review #4172). Print previews exclude installed faces and isolate their font cache; GBK regressions cover view/print switching for inline and pooled whole-page/tile rendering. Installed-font helpers and their regression contracts have function documentation. GBK regression rasters match explicit-Unicode references with independently selected installed faces; wrong-glyph and wrong-family negative controls fail. Integration with main fa5f9f8c retains viewer fallback across the split engine session modules, renderer tests and Combine thumbnail workers. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. Repeated window resizes by an edge and moves by the tab strip on GNOME/Wayland work without a click in between. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. Repeated window resizes by an edge and moves by the tab strip on GNOME/Wayland work without a click in between. Optional --two-paged applies noncontinuous two-page layout to explicitly opened CLI files without changing defaults; explicit --layout wins. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. | @@ -196,7 +223,7 @@ total matches the parity estimate. | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | -| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | +| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | @@ -253,18 +280,27 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st ## Revision history +**2026-10-26 · patch:** M2/PR #4172: reconcile current main and preserve upstream Japanese serif/weight selection alongside the installed CJK viewer and preview fallback. Estimates unchanged. + **2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged. **2026-10-23 · minor:** M11: image downsampling uses physical resolution after page-local `/UserUnit` scaling, including shared nested forms. Synthetic measurement and optimization regressions cover the change. Estimates unchanged. +**2026-10-23 · minor:** M2/PR #4172: display-only CJK fallback covers Combine thumbnails and accessibility figure previews. Windows family matching includes FangSong and subset-prefixed Founder names. Export, print, OCR and automation policy and readiness estimates are unchanged. + **2026-10-20 · minor:** M2/#3601: substitute-font spacing aliases preserve blank ink, original `/W` and `/DW` advances, and Unicode extraction. Synthetic renderer regressions cover the reported Japanese CID fixture. Estimates unchanged. **2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-31 | patch | #1577: reconcile optional page shadows with current viewer/organizer and settings modules; CI validation pending. | +| 2026-10-31 | patch | M3 #1354: reconcile the launch override with split app/file modules and the structured open result; preserve headless help/version exits. CI validation pending. | +| 2026-10-31 | patch | M2, PR #4172: eight GBK font/alias cases compare exact pixels with independent installed-face and explicit-Unicode references; wrong-glyph and wrong-family negative controls fail. Estimates unchanged. | +| 2026-10-31 | patch | M2, PR #4172: integrate main fa5f9f8c, preserve both raster test groups and restore explicit display_config imports; display-only CJK policy and estimates unchanged. | +| 2026-10-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and estimates unchanged. | | 2026-10-24 | minor | M11: the PDF Optimizer and Reduce File Size store Group 3 fax images as Group 4 when smaller (`Document::fax_params`), and pack eight-bit `DeviceRGB` of only black and white (as grey) and indexed images of 2, 4 or 8 bits that use only palette entries 0 and 1 (keeping the palette) to one bit, so Monochrome Images compresses and downsamples them. Estimates unchanged. | | 2026-10-24 | minor | M11: monochrome downsampling: the PDF Optimizer and Reduce File Size average black-and-white images (1-bit, CCITT fax, eight-bit black-and-white grey) above 450 ppi down to 300 ppi, by area with ties black, as Monochrome Images ▸ Downsample to and `doc_optimize` `mono: { downsample, ppi, above_ppi }`; CCITT decoding moves into `pdfcraft-filters` (`decode_ccitt`) behind `Document::decoded_image`. Estimates unchanged. | | 2026-10-24 | minor | M11: Create PDF stores pictures of only black and white at one bit (Group 4 or Flate, whichever is smaller) instead of eight-bit grey; the optimizer packs eight-bit black-and-white grey images to one bit before Monochrome Images compresses them, unless the grey settings make them smaller, as they can a dithered scan, soft mask counted; Export ▸ Images decodes CCITT fax images. Estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 46767848cc..d03c27c95c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -98,8 +98,8 @@ platforms 5%, hardware 4%, ecosystem 1%, AI 1%: History: the first version of this file subtracted 9 points from the area figure for cross-cutting shortfalls (40%); the second look found that a double count (Windows printing, encrypted-output interop and Chinese glyphs were already scored in their areas) and used a 2-point deduction (47%); -this version replaces the deduction with the standard's weighted sum over dimensions (49%). Method -aligned with the standard, no new evidence. Multiplicative discounts are used only for the +this version replaces the deduction with the weighted sum over dimensions (49%), with no new +evidence. Multiplicative discounts are used only for the mainstream and essentials numbers. ## Mainstream practitioner vs full Acrobat Pro @@ -306,7 +306,7 @@ licensed OCR or AI model. | 2026-10-11 | minor | Web printing shipped (gap 4): Hardware, Platforms and L Print text updated. Text only: scores, readiness and hours not re-judged | | 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-11 | minor | Synced with closed issues: encrypted output opens in Acrobat (#774, #836), Windows prints (#756, #847), column select (#740), 1:1 touchpad scrolling (#759), same-page multi-object move (#844), #825 and the #799–#821 fix batch. Text only: scores, readiness and hours not re-judged | -| 2026-10-10 | minor | Readiness table: hours to ≈ 95% per audience (full 780–1,400, mainstream 500–900, essentials 180–330). Full number restated as the standard's additive weighted sum over dimensions with written weights: 47% → 49%. Method aligned with the standard, no new evidence | +| 2026-10-10 | minor | Readiness table: hours to ≈ 95% per audience (full 780–1,400, mainstream 500–900, essentials 180–330). Full number restated as the additive weighted sum over dimensions with written weights: 47% → 49%. No new evidence | | 2026-10-10 | minor | Added the essentials-user score (≈ 66%): weighted depth of 13 everyday features, discounted for launch, discoverability and opening others' files | | 2026-10-10 | minor | Consistency check across apps: re-judged the convert-and-share gate row with the shared rule (blocking only when the workflow can't be completed at all; lossy exchange and missing sub-cases are beta items). Encrypted output (#774) and Office→PDF are sub-cases, Word export is lossy not absent → partial, not blocking; stage pre-alpha → **alpha**. Numbers unchanged | | 2026-10-10 | minor | Second look requested by the owner after practitioners praised PdfCraft: user sentiment gathered (stars, downloads, 23 praising issues, switching reports, issue themes); checklist split into mainstream (497) and specialist (311) features; the 9-point cross-cutting deduction removed as a double count (Windows printing, interop and glyphs were already scored in their areas). Ready for real work 40% → 47%; mainstream practitioner ≈ 55% added. Core-workflow alpha gate applied: convert and share fails → stage alpha → **pre-alpha**, ≈ 40–70 h from alpha | diff --git a/licenses/CMap-LICENSE.txt b/licenses/CMap-LICENSE.txt new file mode 100644 index 0000000000..2552cc990c --- /dev/null +++ b/licenses/CMap-LICENSE.txt @@ -0,0 +1,28 @@ +Copyright 1990-2023 Adobe. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +Neither the name of Adobe nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/licenses/egui-Hack-Regular.txt b/licenses/egui-Hack-Regular.txt new file mode 100644 index 0000000000..b0ec74488a --- /dev/null +++ b/licenses/egui-Hack-Regular.txt @@ -0,0 +1,31 @@ +The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License + +The work in the DejaVu project was committed to the public domain. + +Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera" +MIT License + +Copyright (c) 2018 Source Foundry Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +BITSTREAM VERA LICENSE + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions: + +The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera". + +This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names. + +The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org. diff --git a/licenses/egui-OFL.txt b/licenses/egui-OFL.txt new file mode 100644 index 0000000000..d952d62c06 --- /dev/null +++ b/licenses/egui-OFL.txt @@ -0,0 +1,92 @@ +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/licenses/egui-UFL.txt b/licenses/egui-UFL.txt new file mode 100644 index 0000000000..ae78a8f94e --- /dev/null +++ b/licenses/egui-UFL.txt @@ -0,0 +1,96 @@ +------------------------------- +UBUNTU FONT LICENCE Version 1.0 +------------------------------- + +PREAMBLE +This licence allows the licensed fonts to be used, studied, modified and +redistributed freely. The fonts, including any derivative works, can be +bundled, embedded, and redistributed provided the terms of this licence +are met. The fonts and derivatives, however, cannot be released under +any other licence. The requirement for fonts to remain under this +licence does not require any document created using the fonts or their +derivatives to be published under this licence, as long as the primary +purpose of the document is not to be a vehicle for the distribution of +the fonts. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this licence and clearly marked as such. This may +include source files, build scripts and documentation. + +"Original Version" refers to the collection of Font Software components +as received under this licence. + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to +a new environment. + +"Copyright Holder(s)" refers to all individuals and companies who have a +copyright ownership of the Font Software. + +"Substantially Changed" refers to Modified Versions which can be easily +identified as dissimilar to the Font Software by users of the Font +Software comparing the Original Version with the Modified Version. + +To "Propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification and with or without charging +a redistribution fee), making available to the public, and in some +countries other activities as well. + +PERMISSION & CONDITIONS +This licence does not grant any rights under trademark law and all such +rights are reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to propagate the Font Software, subject to +the below conditions: + +1) Each copy of the Font Software must contain the above copyright +notice and this licence. These can be included either as stand-alone +text files, human-readable headers or in the appropriate machine- +readable metadata fields within text or binary files as long as those +fields can be easily viewed by the user. + +2) The font name complies with the following: +(a) The Original Version must retain its name, unmodified. +(b) Modified Versions which are Substantially Changed must be renamed to +avoid use of the name of the Original Version or similar names entirely. +(c) Modified Versions which are not Substantially Changed must be +renamed to both (i) retain the name of the Original Version and (ii) add +additional naming elements to distinguish the Modified Version from the +Original Version. The name of such Modified Versions must be the name of +the Original Version, with "derivative X" where X represents the name of +the new work, appended to that name. + +3) The name(s) of the Copyright Holder(s) and any contributor to the +Font Software shall not be used to promote, endorse or advertise any +Modified Version, except (i) as required by this licence, (ii) to +acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with +their explicit written permission. + +4) The Font Software, modified or unmodified, in part or in whole, must +be distributed entirely under this licence, and must not be distributed +under any other licence. The requirement for fonts to remain under this +licence does not affect any document created using the Font Software, +except any version of the Font Software extracted from a document +created using the Font Software may only be distributed under this +licence. + +TERMINATION +This licence becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. diff --git a/licenses/egui-emoji-icon-font-mit-license.txt b/licenses/egui-emoji-icon-font-mit-license.txt new file mode 100644 index 0000000000..99016e120b --- /dev/null +++ b/licenses/egui-emoji-icon-font-mit-license.txt @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh index 26e09cd1c8..0534b3194e 100755 --- a/packaging/linux/flatpak-bundle.sh +++ b/packaging/linux/flatpak-bundle.sh @@ -20,10 +20,12 @@ APP_ID=ai.storyteller.pdfcraft FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo TEST=1 +OCI=0 TARBALL="" while [[ $# -gt 0 ]]; do case "$1" in --no-test) TEST=0; shift ;; + --oci) OCI=1; shift ;; -h | --help) sed -n '2,14p' "$0"; exit 0 ;; -*) echo "unknown argument: $1" >&2; exit 2 ;; *) TARBALL="$1"; shift ;; @@ -62,6 +64,13 @@ OUT="$DIST/$BASENAME.flatpak" flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable echo "wrote $OUT" +if [[ "$OCI" = 1 ]]; then + OUT_OCI="$DIST/$BASENAME-oci" + rm -rf "$OUT_OCI" + flatpak build-bundle --oci "$WORK/repo" "$OUT_OCI" "$APP_ID" stable + echo "wrote OCI layout to $OUT_OCI" +fi + if [[ "$TEST" = 1 ]]; then flatpak install --user -y --noninteractive --reinstall "$OUT" flatpak info --user "$APP_ID" diff --git a/packaging/linux/flatpak-oci.sh b/packaging/linux/flatpak-oci.sh new file mode 100755 index 0000000000..0572ef1a3a --- /dev/null +++ b/packaging/linux/flatpak-oci.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Build PdfCraft as an OCI container layout and push it to GitHub Container Registry (GHCR) +# for consumption by Flatpak OCI clients (flatpak-oci-specs). +# +# Usage: +# packaging/linux/flatpak-oci.sh [--push] [TARBALL] +# +# Environment variables: +# REGISTRY Target registry image (default: ghcr.io/storytold/pdfcraft) +# TAG Target image tag (default: $VERSION-$ARCH, also tags stable and latest) +# GITHUB_TOKEN Token for GHCR authentication when --push is used +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" +HERE="$ROOT/packaging/linux" + +PUSH=0 +TARBALL="" +while [[ $# -gt 0 ]]; do + case "$1" in + --push) PUSH=1; shift ;; + -h | --help) sed -n '2,12p' "$0"; exit 0 ;; + -*) echo "unknown argument: $1" >&2; exit 2 ;; + *) TARBALL="$1"; shift ;; + esac +done + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ;; + aarch64 | arm64) ARCH=aarch64 ;; + *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; +esac + +BASENAME="pdfcraft-$VERSION-linux-$ARCH" +OUT_OCI="$DIST/$BASENAME-oci" + +echo "==> Building Flatpak OCI layout for $BASENAME" +"$HERE/flatpak-bundle.sh" --oci --no-test "${TARBALL:-}" + +[[ -d "$OUT_OCI" ]] || { echo "error: $OUT_OCI layout not generated" >&2; exit 1; } + +REGISTRY="${REGISTRY:-ghcr.io/storytold/pdfcraft}" +TAG="${TAG:-$VERSION-$ARCH}" + +echo "==> OCI layout ready at $OUT_OCI (Target: $REGISTRY:$TAG)" + +if [[ "$PUSH" = 1 ]]; then + command -v skopeo >/dev/null || { echo "error: skopeo is required to push OCI images (apt install skopeo)" >&2; exit 1; } + + AUTH_ARGS=() + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + AUTH_ARGS=(--dest-creds "${GITHUB_ACTOR:-storytold}:$GITHUB_TOKEN") + fi + + echo "==> Pushing to $REGISTRY:$TAG" + skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:$TAG" + + # Also update architecture-specific stable tag + echo "==> Pushing to $REGISTRY:stable-$ARCH" + skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:stable-$ARCH" + + echo "==> Push complete" +fi diff --git a/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml index fcfb8183cc..065945c6ea 100644 --- a/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml @@ -17,6 +17,7 @@ runtime-version: '25.08' sdk: org.freedesktop.Sdk command: pdfcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 @@ -44,6 +45,11 @@ finish-args: # Deliberately not granted: home/host filesystem access. No CUPS socket either: printing # runs the `lp` command (crates/print), which the freedesktop runtime doesn't ship, so Print # reports that the spooler is unavailable until it goes through the print portal. +# The release runner's flatpak-builder has no eu-strip (elfutils), which its debuginfo pass runs +# on cups-client's unstripped tools, in whichever module comes first after they're installed. The +# app's own binaries come from the release tarball, already stripped. +build-options: + no-debuginfo: true modules: # CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions. # Linked statically against this release's libcups, so a runtime update can't break them; diff --git a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml index 0d0c25949c..5d30c5a4e3 100644 --- a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml @@ -18,6 +18,7 @@ sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable command: pdfcraft finish-args: + - --filesystem=xdg-data/ArtCraft/versions:ro # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland - --socket=fallback-x11 diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 26e9a52886..aa4a859e22 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -794,8 +794,8 @@ milestone = "M2" status = "partial" commands = [] tools = [] -evidence = ["crates/render/src/raster/tests.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster/tests.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face", "crates/render/src/raster/tests.rs::mincho_substitute_covers_half_width_katakana", "crates/render/src/text/tests.rs::identity_to_unicode_names_give_text", "crates/fonts/src/pdf/tests.rs::identity_to_unicode_names_read_codes_as_unicode"] -notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name, else by the descendant font descriptor's Serif flag; bold Gothic for a Bold name or a `/FontWeight` of 600 or more; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; non-embedded GB1, CNS1 and Korea1 fonts still draw nothing (no allowed face); a `/ToUnicode` given as the name `/Identity-H` or `/Identity-V` (WSCAD) reads codes as Unicode for selection, search and editing, without control characters" +evidence = ["crates/render/src/raster/tests.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster/tests.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face", "crates/render/src/raster/tests.rs::mincho_substitute_covers_half_width_katakana", "crates/render/src/raster/tests.rs::non_embedded_chinese_and_korean_cid_fonts_draw_with_an_installed_face", "crates/engine/src/tests.rs::installed_cjk_fonts_draw_in_the_viewer_but_not_in_an_export", "crates/render/src/text/tests.rs::identity_to_unicode_names_give_text", "crates/fonts/src/pdf/tests.rs::identity_to_unicode_names_read_codes_as_unicode"] +notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name, else by the descendant font descriptor's Serif flag; bold Gothic for a Bold name or a `/FontWeight` of 600 or more; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; the desktop viewer can fall back to an installed CJK face for GB1, CNS1, Korea1 and Japan1, including GBK-EUC-H TrueType CID fonts without ToUnicode. Windows preserves installed SimSun, SimHei, KaiTi and FangSong families, including subset-prefixed Founder aliases. Combine thumbnails and accessibility figure previews use the same on-screen fallback. System faces are display-only, disabled by PDFCRAFT_SYSTEM_FONTS=0, and not used by export, printing, OCR or automation; machines without a candidate and web builds remain unsupported. A `/ToUnicode` given as the name `/Identity-H` or `/Identity-V` (WSCAD) reads codes as Unicode for selection, search and editing, without control characters" [[feature]] id = "view.render-image-codecs" @@ -890,8 +890,8 @@ milestone = "M3" status = "shipped" commands = ["view.layout.two_up"] tools = [] -evidence = ["crates/ui-egui/tests/it/view.rs::two_up_layout_places_pages_side_by_side", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time"] -notes = "next/previous page step a spread at a time" +evidence = ["crates/ui-egui/tests/it/view.rs::two_up_layout_places_pages_side_by_side", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "apps/pdfcraft/src/startup.rs::startup_override_is_per_file_and_does_not_persist", "crates/ui-egui/src/platform/open_worker/tests.rs::flagged_startup_opens_wait_for_password_ownership_before_the_first_frame"] +notes = "Next/previous page step a spread at a time. Optional desktop --two-paged launches named PDFs in noncontinuous two-page view without changing saved defaults or unrelated restored tabs. Explicit --layout wins after flagged opens settle, including password retries; repeated files reuse their tab. This is a launch override, not a declarative configuration module." [[feature]] id = "view.layout-two-up-scrolling" @@ -1287,7 +1287,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/ui-egui/tests/it/view.rs::pages_sidebar_select_all_keeps_current_page_and_keyboard_scope", "crates/ui-egui/tests/it/editing.rs::dragging_pages_panel_thumbnails_reorders_pages", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnails_have_a_context_menu", "crates/ui-egui/tests/it/editing.rs::split_from_pages_panel_context_menu", "crates/ui-egui/tests/it/editing.rs::deleting_pages_from_the_pages_panel_menu", "crates/ui-egui/tests/it/editing.rs::the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page"] -notes = "Ctrl/Cmd+A selects every page while the Pages sidebar owns the keyboard, without navigation; Escape clears that selection. Viewer and text-field shortcuts keep their own scope. Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Split document…, Extract pages, Cut, Copy, Paste after and Delete pages (also the Delete key over the panel). Not yet: Rotate, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel." +notes = "Ctrl/Cmd+A selects every page while the Pages sidebar owns the keyboard, without navigation; Escape clears that selection. Viewer and text-field shortcuts keep their own scope. Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Split document…, Extract pages, Cut, Copy, Paste after and Delete pages (also the Delete key over the panel). Deleting pages makes the next Save a full rewrite (unsigned files), so the deleted pages leave the file; signed files stay incremental. Not yet: Rotate, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel." [[feature]] id = "view.bookmarks-panel" @@ -1799,11 +1799,17 @@ title = "Customise the quick-action toolbar and pinned tools" area = "B" tier = "P2" milestone = "M14" -status = "planned" +status = "partial" commands = [] tools = [] -evidence = [] -notes = "" +evidence = [ + "crates/ui-egui/tests/it/pinned_tools.rs::state_operations_and_validation", + "crates/ui-egui/tests/it/pinned_tools.rs::persistence_round_trip_and_sanitization", + "crates/ui-egui/tests/it/pinned_tools.rs::home_shows_pinned_tools_section_and_placeholder", + "crates/ui-egui/tests/it/pinned_tools.rs::pin_tool_dialog_workflow", + "crates/ui-egui/tests/it/pinned_tools.rs::sidebar_pin_toggle_unpins_and_pins", +] +notes = "Pin and unpin tools on the Home page via tool card hover button, All Tools row toggle, or 'Pin a tool' search dialog. Pinned tool order persists across sessions. Floating quick-action toolbar customisation remains planned." [[feature]] id = "view.restriction-notice" @@ -1959,11 +1965,19 @@ title = "Superscript and subscript" area = "C" tier = "P2" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["edit.edit_text"] +tools = ["text_script"] +evidence = [ + "crates/engine/src/text_script.rs::every_form_toggles_back_to_its_character", + "crates/engine/src/text_script.rs::text_with_characters_unicode_cannot_raise_toggles_back", + "crates/ui-egui/src/tools/edit_text_ui.rs::superscript_and_subscript_conversion", + "crates/ui-egui/tests/it/editing.rs::the_format_panel_toggles_superscript_and_subscript_on_selection", + "crates/automation/tests/it/automation.rs::superscript_and_subscript_through_tools", + "crates/engine/src/text_script.rs::mixed_plain_and_scripted_text_normalizes_instead_of_round_tripping", + "crates/automation/tests/it/automation.rs::mixed_script_tools_normalize_but_undo_restores_the_exact_mixture", +] +notes = "Toggled in the Format text panel for the selection, and headlessly with text_script on a paragraph or a byte range of it; each toggle of a paragraph is one undo step. Uses Unicode superscript and subscript forms (2 ↔ ²) rather than text rise (Ts) and font scaling, so the font must provide the corresponding glyphs and extracted text contains the Unicode characters. Characters Unicode has no form for (q, most capitals in subscript) stay unchanged and do not determine script state. Two toggles restore uniformly plain or uniformly scripted inputs. Mixed input normalizes (H₂2 → H₂₂ → H22), so a second toggle does not recover the original mixture; Undo restores the exact prior text. The mathematical minus sign U+2212 has its own script mapping, while ASCII hyphen-minus stays unchanged." [[feature]] id = "edit.paragraph-alignment" @@ -2129,8 +2143,8 @@ milestone = "M7" status = "shipped" commands = ["edit.image"] tools = ["content_update", "image_edit"] -evidence = ["crates/ui-egui/tests/it/editing.rs::cropping_a_picture_with_its_handles", "crates/engine/src/tests.rs::cropping_an_existing_image_clips_it_and_moves_with_it", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/automation/tests/it/automation.rs::adding_content_through_tools"] -notes = "Existing page images: right-click ▸ Crop Image puts the picture in crop mode: its eight handles trim (the inside pans), the part that will go is dimmed, Enter or Done applies, Esc or Cancel leaves it; Remove Crop shows the whole picture again. The pixels stay where they are (a clipping path inside the picture's own scope, so a later move or resize carries it along; cropping again replaces the earlier crop, so it can grow back); image_edit crop. Added images: trim each side by percent in Edit image, clipped. Not yet: cropping grouped Form artwork by handles is allowed the same way (it is a clip), but a shared clip of the original file is not changed." +evidence = ["crates/edit/src/tests.rs::removing_a_crop_preserves_author_clips_and_works_under_outer_clip", "crates/ui-egui/tests/it/editing.rs::cropping_a_picture_with_its_handles", "crates/engine/src/tests.rs::cropping_an_existing_image_clips_it_and_moves_with_it", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/automation/tests/it/automation.rs::adding_content_through_tools"] +notes = "Existing page images: right-click ▸ Crop Image puts the picture in crop mode: its eight handles trim (the inside pans), the part that will go is dimmed, Enter or Done applies, Esc or Cancel leaves it; Remove Crop removes only a crop created by PdfCraft, preserving original document clips. The pixels stay where they are (a clipping path inside the picture's own scope, so a later move or resize carries it along; cropping again replaces the earlier crop, so it can grow back); image_edit crop. Added images: trim each side by percent in Edit image, clipped. Grouped Form artwork cannot be cropped. Shared clips and other clipping paths from the original file remain unchanged." [[feature]] id = "edit.image-rotate-flip" @@ -2153,8 +2167,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["image_edit", "image_hit"] -evidence = ["crates/edit/src/tests.rs::private_image_crop_with_intervening_state_resizes_across_streams", "crates/automation/tests/it/automation.rs::private_crops_with_nested_state_resize_save_reopen_through_tools", "crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/it/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/it/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/it/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::resizing_an_image_under_a_shared_clip_moves_its_own_clip"] -notes = "existing raster images and grouped Form artwork: select, drag to move, drag a corner to resize keeping proportions; Delete removes one placement. Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too Since #1116: raster images and grouped Forms move/resize/turn as placements; selected grips own the gesture and opposite anchor, with no PDF writes during movement. Curved path frames use analytic Bezier extrema and hit testing evaluates actual nonzero/even-odd fill rules, excluding holes; intersections can retain a loose frame. Selection and resize use the visible (clipped) frame; exclusive image clips move with their pixels across nested states, intervening operators and content streams, also inside a clip the picture shares with other content (its own clip scales, the shared one stays); shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md" +evidence = ["crates/edit/src/tests.rs::private_image_crop_with_intervening_state_resizes_across_streams", "crates/automation/tests/it/automation.rs::private_crops_with_nested_state_resize_save_reopen_through_tools", "crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/it/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/it/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/it/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::resizing_an_image_under_a_shared_clip_moves_its_own_clip", "crates/automation/tests/it/automation.rs::transforming_page_images_through_tools"] +notes = "existing raster images and grouped Form artwork: select, drag to move, 8-point handles to resize (corners keep proportions, edges scale along axis, Shift inverts aspect lock); Delete removes one placement. Headless: image_edit action move (a new box) or transform (any finite, non-flat 6-number matrix in top-left-origin points, applied after the placement: the edit a resize of a turned image makes). Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too Since #1116: raster images and grouped Forms move/resize/turn as placements; selected grips own the gesture and opposite anchor, with no PDF writes during movement. Curved path frames use analytic Bezier extrema and hit testing evaluates actual nonzero/even-odd fill rules, excluding holes; intersections can retain a loose frame. Selection and resize use the visible (clipped) frame; exclusive image clips move with their pixels across nested states, intervening operators and content streams, also inside a clip the picture shares with other content (its own clip scales, the shared one stays); shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md" [[feature]] id = "edit.image-extract" @@ -2186,11 +2200,17 @@ title = "Arrange objects (bring forward, send backward)" area = "C" tier = "P1" milestone = "M7" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["image_edit"] +evidence = [ + "crates/edit/src/tests.rs::image_arrange_stacking_order_and_remove_crop", + "crates/edit/src/tests.rs::image_arrange_refuses_crossed_scope_boundaries_without_changes", + "crates/edit/src/tests.rs::arrange_refuses_unrestorable_extended_print_state", + "crates/automation/tests/it/automation.rs::arranging_page_images_through_tools", + "crates/ui-egui/tests/it/editing.rs::duplicate_xobject_at_different_rects_arrange_and_delete_keeps_other_instance_through_menu", +] +notes = "Image stacking order in content streams: Bring to Front, Send to Back, Bring Forward, Send Backward with undo/redo and persistence. Refuses arranging across shared marked content, unbalanced graphics/marked-content boundaries, or when clipping path transforms cannot be cleanly preserved. Stacking for arbitrary vector paths and inline images remains planned." [[feature]] id = "edit.align-distribute-objects" @@ -5320,8 +5340,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run", "form_focus"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/engine/src/tests.rs::xfa_enter_and_exit_scripts_run_on_every_focus_change", "crates/engine/src/tests.rs::an_exit_the_fields_change_ran_does_not_run_twice", "crates/engine/src/tests.rs::a_radio_buttons_own_focus_scripts_run", "crates/automation/tests/it/automation.rs::xfa_focus_scripts_run_through_form_focus", "crates/ui-egui/tests/it/xfa.rs::xfa_enter_and_exit_scripts_run_whenever_the_focus_moves", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout", "crates/js/src/tests.rs::message_box_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_in_an_xfa_script_reads_app_alerts_arguments", "crates/engine/src/tests.rs::xfa_message_boxes_keep_their_title_icon_and_buttons"] -notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), enter and exit each time a field gains or loses focus, changed or not (through form_focus and the app; a radio button runs its own; an exit its change already ran is not run again, and scripts that change nothing leave no edit; exit as measured on a synthetic form in another viewer, enter from the spec and not measured), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; a radio group's own enter and exit scripts; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60). xfa.host.messageBox keeps its title, icon and buttons, and app.alert in an XFA script reads its own argument order; every message box still answers OK (1) (#4291)." +evidence = ["crates/xfa/src/tests.rs::a_draw_turns_counter_clockwise_about_its_anchor", "crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/engine/src/tests.rs::xfa_enter_and_exit_scripts_run_on_every_focus_change", "crates/engine/src/tests.rs::an_exit_the_fields_change_ran_does_not_run_twice", "crates/engine/src/tests.rs::a_radio_buttons_own_focus_scripts_run", "crates/automation/tests/it/automation.rs::xfa_focus_scripts_run_through_form_focus", "crates/ui-egui/tests/it/xfa.rs::xfa_enter_and_exit_scripts_run_whenever_the_focus_moves", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout", "crates/js/src/tests.rs::message_box_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_in_an_xfa_script_reads_app_alerts_arguments", "crates/engine/src/tests.rs::xfa_message_boxes_keep_their_title_icon_and_buttons"] +notes = "A draw placed by position turns about its anchor (rotate); fields, subforms and draws in a flow don't. Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), enter and exit each time a field gains or loses focus, changed or not (through form_focus and the app; a radio button runs its own; an exit its change already ran is not run again, and scripts that change nothing leave no edit; exit as measured on a synthetic form in another viewer, enter from the spec and not measured), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; a radio group's own enter and exit scripts; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60). xfa.host.messageBox keeps its title, icon and buttons, and app.alert in an XFA script reads its own argument order; every message box still answers OK (1) (#4291)." [[feature]] id = "form.xfa-formcalc" @@ -9928,8 +9948,8 @@ milestone = "M14" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/tests/it/view.rs::default_zoom_is_used_for_new_documents", "crates/ui-egui/tests/it/view.rs::preferences_set_the_default_zoom", "crates/ui-egui/tests/it/view.rs::always_use_my_default_zoom_and_page_layout_ignores_what_the_pdf_asks_for"] -notes = "Preferences ▸ Documents and view: Default page display and Default zoom (Fit to width, Zoom to page level or a percentage) for every new tab; a PDF that asks for a layout or zoom gets it, unless Always use my default zoom and page layout is on. Missing: Acrobat's resolution, rendering and page-content options" +evidence = ["crates/ui-egui/tests/page_shadows.rs::viewport_fades_follow_the_preference_and_keep_other_scroll_areas_styled", "crates/ui-egui/tests/page_shadows.rs::rendered_page_content_is_identical_with_and_without_decoration", "crates/ui-egui/tests/page_shadows.rs::page_shadow_preference_round_trips_and_ignores_invalid_settings", "crates/ui-egui/tests/page_shadows.rs::viewer_shadows_follow_the_preference_without_moving_pages", "crates/ui-egui/tests/page_shadows.rs::organize_shadows_follow_the_preference", "crates/ui-egui/tests/it/control.rs::page_shadows_are_an_observable_app_wide_preference", "crates/ui-egui/tests/it/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/tests/it/view.rs::default_zoom_is_used_for_new_documents", "crates/ui-egui/tests/it/view.rs::preferences_set_the_default_zoom", "crates/ui-egui/tests/it/view.rs::always_use_my_default_zoom_and_page_layout_ignores_what_the_pdf_asks_for"] +notes = "Preferences ▸ Documents and view: Default page display and Default zoom (Fit to width, Zoom to page level or a percentage) for every new tab; a PDF that asks for a layout or zoom gets it, unless Always use my default zoom and page layout is on. Preferences ▸ Appearance also has an app-wide, persistent Show Page Edge Shadows checkbox (on by default), exposed through ui.set and ui.state; it affects viewer and Organize decorations, including the viewport-edge gradients painted over pages, only. Missing: Acrobat's resolution, rendering and page-content options" [[feature]] id = "misc.prefs-documents" diff --git a/xtask/src/gates.rs b/xtask/src/gates.rs index 0212e165a5..58efe66777 100644 --- a/xtask/src/gates.rs +++ b/xtask/src/gates.rs @@ -116,7 +116,7 @@ pub fn ci(_: &[String]) -> anyhow::Result<()> { ("i18n-report", Box::new(|| i18n_report(&["--workspace".to_string()]))), ("layers", Box::new(|| layers(&[]))), ("wasm", Box::new(|| wasm(&[]))), - ("assets", Box::new(|| crate::assets::run(&[]))), + ("license-check", Box::new(|| crate::license_check::run(&[]))), ("deny", Box::new(|| deny(&[]))), ("parity", Box::new(|| crate::parity::run(&[]))), ]; diff --git a/xtask/src/license_check.rs b/xtask/src/license_check.rs new file mode 100644 index 0000000000..c845101db6 --- /dev/null +++ b/xtask/src/license_check.rs @@ -0,0 +1,471 @@ +//! Offline source-document checks. This does not inspect completed packages or dependencies. +use std::collections::BTreeSet; +use std::path::Path; + +use anyhow::{Context, Result, bail}; +use serde::Deserialize; + +use crate::assets; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Documents { + document: Vec, + bundled: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Document { + source: String, + destination: String, + sha256: String, + /// Audited identifiers of the full licence texts in this document, not inferred from its contents. + #[serde(default)] + licences: Vec, + #[serde(default)] + origin: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Bundled { + #[serde(rename = "crate")] + krate: String, + path: String, + licence: String, + documents: Vec, +} + +pub fn run(args: &[String]) -> Result<()> { + if !args.is_empty() { + bail!("usage: cargo xtask license-check (no downloads or write mode)"); + } + let root = assets::root(); + // Reuse the existing policy and generated-attribution validator, once. + assets::run(&[])?; + validate(&root)?; + println!("license-check: source documents and licensing metadata are consistent"); + Ok(()) +} + +fn validate(root: &Path) -> Result<()> { + let text = std::fs::read_to_string(root.join("LICENSES.toml")).context("reading LICENSES.toml")?; + let docs: Documents = toml::from_str(&text).context("parsing LICENSES.toml")?; + let attribution = assets::load(root)?; + let mut problems = check(root, &docs, &attribution); + let ocr_source = std::fs::read_to_string(root.join("crates/ocr/src/lib.rs")).context("reading OCR model filename constants")?; + problems.extend(check_required_ocr_metadata(&ocr_source, &attribution)?); + if !problems.is_empty() { + bail!("source licensing errors:\n{}", problems.join("\n")); + } + Ok(()) +} + +/// Read only the two filename constants consumed by the OCR application. Fail closed if their +/// declaration format changes; do not guess filenames or add a dependency on the OCR engine. +fn check_required_ocr_metadata(source: &str, attribution: &assets::Manifest) -> Result> { + let mut problems = Vec::new(); + for name in ["DETECTION_MODEL", "RECOGNITION_MODEL"] { + let prefix = format!("pub const {name}: &str = \""); + let declarations: Vec<_> = source.lines().map(str::trim).filter_map(|line| line.strip_prefix(&prefix)).collect(); + let file = declarations.first().and_then(|value| value.strip_suffix("\";")); + let Some(file) = file.filter(|file| declarations.len() == 1 && safe_path(file) && !file.contains('/')) else { + bail!( + "OCR constant {name}: expected one literal filename declaration in crates/ocr/src/lib.rs; update the source validator if its syntax changed" + ); + }; + let entries: Vec<_> = attribution.fetched.iter().filter(|f| f.file == file).collect(); + if entries.is_empty() { + problems.push(format!("{file}: required OCR licensing metadata missing from ATTRIBUTION.toml")); + } else { + if entries.len() != 1 { + problems.push(format!("{file}: required OCR licensing metadata must have exactly one entry")); + } + if entries.iter().any(|f| f.kind != "model") { + problems.push(format!("{file}: required OCR licensing metadata must have kind = \"model\"")); + } + } + } + Ok(problems) +} + +/// The audited bundle inventory currently uses identifiers and flat AND/OR expressions only. +/// Keep their semantics distinct, and fail closed on mixed operators, grouping or exceptions +/// rather than introducing a general SPDX parser or flattening an unsupported expression. +fn licence_coverage(expression: &str, covered: &BTreeSet<&str>) -> Result { + let mut tokens = expression.split_whitespace(); + let Some(first) = tokens.next().filter(|id| assets::ALLOWED_LICENCES.contains(id)) else { + bail!("unsupported bundle licence expression `{expression}`"); + }; + let mut ids = vec![first]; + let mut operator = None; + while let Some(op) = tokens.next() { + if !matches!(op, "AND" | "OR") || operator.is_some_and(|previous| previous != op) { + bail!("unsupported bundle licence expression `{expression}`; audit its applicability before extending the validator"); + } + operator = Some(op); + let Some(id) = tokens.next().filter(|id| assets::ALLOWED_LICENCES.contains(id)) else { + bail!("unsupported bundle licence expression `{expression}`"); + }; + ids.push(id); + } + Ok(if operator == Some("OR") { ids.iter().any(|id| covered.contains(id)) } else { ids.iter().all(|id| covered.contains(id)) }) +} + +// A portable relative path, including on Windows; reject aliases before comparing mappings. +fn safe_path(s: &str) -> bool { + !s.is_empty() + && !s.contains(['\\', ':']) + && s.split('/').all(|p| { + !p.is_empty() + && p != "." + && p != ".." + && !p.ends_with(['.', ' ']) + && p.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) + && !matches!( + p.split('.').next().unwrap_or_default().to_ascii_uppercase().as_str(), + "CON" + | "PRN" + | "AUX" + | "NUL" + | "COM1" + | "COM2" + | "COM3" + | "COM4" + | "COM5" + | "COM6" + | "COM7" + | "COM8" + | "COM9" + | "LPT1" + | "LPT2" + | "LPT3" + | "LPT4" + | "LPT5" + | "LPT6" + | "LPT7" + | "LPT8" + | "LPT9" + ) + }) +} + +fn hash(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) +} + +fn check(root: &Path, docs: &Documents, attribution: &assets::Manifest) -> Vec { + let mut problems = Vec::new(); + let mut sources = BTreeSet::new(); + let mut destinations = BTreeSet::new(); + for d in &docs.document { + if !safe_path(&d.source) || !safe_path(&d.destination) { + problems.push(format!("{}: unsafe document source or destination", d.source)); + continue; + } + let new_source = sources.insert(d.source.as_str()); + let new_destination = destinations.insert(d.destination.to_ascii_lowercase()); + if !new_source || !new_destination { + problems.push(format!("{}: duplicate document source or destination", d.source)); + } + if d.origin.as_ref().is_some_and(|u| !u.starts_with("https://") || u.trim().len() <= 8) { + problems.push(format!("{}: invalid document origin", d.source)); + } + let mut identifiers = BTreeSet::new(); + for id in &d.licences { + if !assets::ALLOWED_LICENCES.contains(&id.as_str()) || !identifiers.insert(id.as_str()) { + problems.push(format!("{}: invalid or duplicate audited licence identifier `{id}`", d.source)); + } + } + let path = root.join(&d.source); + // Canonical confinement also catches symlinks escaping the checkout. + let confined = root.canonicalize().and_then(|r| path.canonicalize().map(|p| p.starts_with(r))).unwrap_or(false); + if !confined || !path.is_file() { + problems.push(format!("{}: required document missing or outside checkout", d.source)); + continue; + } + match std::fs::read_to_string(&path) { + Ok(text) if !text.trim().is_empty() && !text.contains('\0') => { + if !hash(&d.sha256) || assets::sha256_hex(text.as_bytes()) != d.sha256 { + problems.push(format!("{}: document hash mismatch; re-audit the source before updating LICENSES.toml", d.source)); + } + } + _ => problems.push(format!("{}: required document empty or invalid UTF-8 text", d.source)), + } + } + for destination in &destinations { + for (i, _) in destination.match_indices('/') { + if destination.get(..i).is_some_and(|prefix| destinations.contains(prefix)) { + problems.push(format!("{destination}: document destination conflicts with a parent file")); + } + } + } + for required in ["LICENSE-MIT", "LICENSE-APACHE", "NOTICE", "vendor/hayro-interpret/assets/LICENSE_FOXIT"] + .into_iter() + .chain(attribution.asset.iter().map(|a| a.licence_file.as_str())) + { + if !sources.contains(required) { + problems.push(format!("{required}: required document has no LICENSES.toml mapping")); + } + } + // One mapping per bundled asset, using the existing attribution's licence expression. + let mut seen = BTreeSet::new(); + for b in &docs.bundled { + if !seen.insert((b.krate.as_str(), b.path.as_str())) { + problems.push(format!("{} {}: duplicate bundled mapping", b.krate, b.path)); + } + if !attribution.bundled.iter().any(|a| a.krate == b.krate && a.path == b.path && a.licence == b.licence) { + problems.push(format!("{} {}: bundled mapping disagrees with ATTRIBUTION.toml", b.krate, b.path)); + } + if b.documents.is_empty() || b.documents.iter().any(|d| !sources.contains(d.as_str())) { + problems.push(format!("{} {}: bundled licence documents missing", b.krate, b.path)); + } + for document in docs.document.iter().filter(|d| b.documents.contains(&d.source)) { + if !document.licences.iter().any(|id| b.licence.split_whitespace().any(|token| token == id)) { + problems.push(format!("{} {}: inapplicable mapped document {}", b.krate, b.path, document.source)); + } + } + let covered: BTreeSet<_> = + docs.document.iter().filter(|d| b.documents.contains(&d.source)).flat_map(|d| d.licences.iter().map(String::as_str)).collect(); + match licence_coverage(&b.licence, &covered) { + Ok(true) => {} + Ok(false) => problems + .push(format!("{} {}: inapplicable licence documents; mapped audited identifiers do not cover `{}`", b.krate, b.path, b.licence)), + Err(error) => problems.push(format!("{} {}: {error}", b.krate, b.path)), + } + } + for b in &attribution.bundled { + if !seen.contains(&(b.krate.as_str(), b.path.as_str())) { + problems.push(format!("{} {}: bundled asset has no licence document mapping", b.krate, b.path)); + } + } + for a in attribution.asset.iter().filter(|a| a.licence_file == "assets/icons/LICENSE-lucide.txt") { + if a.licence != "ISC AND MIT" { + problems.push(format!("{}: Lucide collection must credit ISC and Feather MIT", a.path)); + } + } + check_model_metadata(attribution, &mut problems); + problems +} + +fn check_model_metadata(attribution: &assets::Manifest, problems: &mut Vec) { + let mut model_paths = BTreeSet::new(); + for f in attribution.fetched.iter().filter(|f| f.kind == "model") { + if [f.title.as_str(), &f.author, &f.source, &f.usage, &f.licence].iter().any(|v| v.trim().is_empty()) + || !hash(&f.sha256) + || !hash(&f.licence_sha256) + || !f.url.starts_with("https://") + || !f.licence_url.starts_with("https://") + { + problems.push(format!("{}: incomplete OCR model licensing metadata", f.file)); + } + for path in [f.file.clone(), format!("{}.LICENCE.txt", f.file)] { + if !safe_path(&path) + || path.contains('/') + || path.eq_ignore_ascii_case("ATTRIBUTION.txt") + || !model_paths.insert(path.to_ascii_lowercase()) + { + problems.push(format!("{}: duplicate or unsafe OCR model destination", f.file)); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + fn fixture() -> (std::path::PathBuf, Documents) { + static NEXT: AtomicU64 = AtomicU64::new(0); + let root = std::env::temp_dir().join(format!("pdfcraft-license-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(&root).unwrap(); + let document = ["LICENSE-MIT", "LICENSE-APACHE", "NOTICE", "vendor/hayro-interpret/assets/LICENSE_FOXIT"] + .into_iter() + .map(|p| { + let path = root.join(p); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "synthetic licence text\n").unwrap(); + Document { + source: p.into(), + destination: p.into(), + sha256: assets::sha256_hex(b"synthetic licence text\n"), + licences: match p { + "LICENSE-MIT" => vec!["MIT".into()], + "LICENSE-APACHE" => vec!["Apache-2.0".into()], + _ => vec![], + }, + origin: None, + } + }) + .collect(); + (root, Documents { document, bundled: vec![] }) + } + + #[test] + fn missing_license_fails_and_restoring_recovers() { + let (root, docs) = fixture(); + let m = assets::Manifest::default(); + assert!(check(&root, &docs, &m).is_empty()); + std::fs::remove_file(root.join("LICENSE-MIT")).unwrap(); + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("LICENSE-MIT: required document missing"))); + std::fs::write(root.join("LICENSE-MIT"), "synthetic licence text\n").unwrap(); + assert!(check(&root, &docs, &m).is_empty()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn empty_invalid_and_changed_documents_fail() { + let (root, docs) = fixture(); + for text in [b" ".as_slice(), b"\xff", b"changed licence", b"text\0"] { + std::fs::write(root.join("NOTICE"), text).unwrap(); + assert!(!check(&root, &docs, &assets::Manifest::default()).is_empty()); + } + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn duplicate_and_unsafe_destinations_fail() { + let (root, mut docs) = fixture(); + for dest in ["license-mit", "../NOTICE", "/NOTICE", "C:/NOTICE", "a\\NOTICE", "a//NOTICE", "CON.txt", "NOTICE."] { + docs.document.get_mut(2).unwrap().destination = dest.into(); + assert!(!check(&root, &docs, &assets::Manifest::default()).is_empty(), "{dest}"); + } + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn removing_required_mapping_fails() { + let (root, mut docs) = fixture(); + docs.document.retain(|d| d.source != "LICENSE-MIT"); + assert!(check(&root, &docs, &assets::Manifest::default()).iter().any(|p| p.contains("LICENSE-MIT: required document has no"))); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn bundled_documents_must_match_attribution() { + let (root, mut docs) = fixture(); + let mut m = assets::Manifest::default(); + m.bundled.push(assets::Bundled { + krate: "synthetic".into(), + version: "1.0.0".into(), + path: "font.ttf".into(), + title: "test".into(), + author: "test".into(), + source: "https://example.org".into(), + licence: "MIT".into(), + kind: "font".into(), + usage: "test".into(), + sha256: "a".repeat(64), + adobe_data: false, + }); + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("no licence document mapping"))); + docs.bundled.push(Bundled { + krate: "synthetic".into(), + path: "font.ttf".into(), + licence: "MIT".into(), + documents: vec!["LICENSE-MIT".into()], + }); + assert!(check(&root, &docs, &m).is_empty()); + docs.bundled.get_mut(0).unwrap().documents = vec!["LICENSE-APACHE".into()]; + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("inapplicable"))); + docs.bundled.get_mut(0).unwrap().documents = vec!["LICENSE-MIT".into(), "LICENSE-APACHE".into()]; + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("inapplicable mapped document"))); + docs.bundled.get_mut(0).unwrap().documents = vec!["LICENSE-MIT".into()]; + docs.bundled.get_mut(0).unwrap().licence = "ISC".into(); + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("disagrees"))); + docs.bundled.get_mut(0).unwrap().documents.clear(); + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("documents missing"))); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn ocr_metadata_and_destinations_are_checked_without_models() { + let (root, docs) = fixture(); + let mut m = assets::Manifest::default(); + m.fetched.push(assets::Fetched { + file: "synthetic.rten".into(), + url: "https://example.org/model".into(), + title: "test model".into(), + author: "test".into(), + source: "https://example.org/card".into(), + licence: "CC-BY-SA-4.0".into(), + licence_url: "https://example.org/licence".into(), + licence_sha256: "a".repeat(64), + kind: "model".into(), + usage: "test".into(), + sha256: "b".repeat(64), + }); + assert!(check(&root, &docs, &m).is_empty()); + m.fetched.get_mut(0).unwrap().licence_sha256 = "z".repeat(64); + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("incomplete OCR"))); + m.fetched.get_mut(0).unwrap().file = "../outside.rten".into(); + assert!(check(&root, &docs, &m).iter().any(|p| p.contains("unsafe OCR"))); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn required_ocr_models_cannot_be_missing() { + let source = include_str!("../../crates/ocr/src/lib.rs"); + let original = assets::load(&assets::root()).unwrap(); + assert_eq!(original.fetched.iter().filter(|f| f.kind == "model").count(), 2); + assert!(check_required_ocr_metadata(source, &original).unwrap().is_empty()); + for model in original.fetched.iter().filter(|f| f.kind == "model") { + let mut m = assets::load(&assets::root()).unwrap(); + m.fetched.retain(|f| f.file != model.file); + assert!(check_required_ocr_metadata(source, &m).unwrap().iter().any(|p| p.contains(&model.file) && p.contains("missing"))); + } + } + + #[test] + fn required_ocr_models_cannot_be_misclassified() { + let source = include_str!("../../crates/ocr/src/lib.rs"); + let original = assets::load(&assets::root()).unwrap(); + assert_eq!(original.fetched.iter().filter(|f| f.kind == "model").count(), 2); + for model in original.fetched.iter().filter(|f| f.kind == "model") { + let mut m = assets::load(&assets::root()).unwrap(); + m.fetched.iter_mut().find(|f| f.file == model.file).unwrap().kind = "font".into(); + assert!(check_required_ocr_metadata(source, &m).unwrap().iter().any(|p| p.contains(&model.file) && p.contains("kind"))); + } + } + + #[test] + fn licence_alternatives_and_combined_obligations_are_distinct() { + let mit = BTreeSet::from(["MIT"]); + assert!(licence_coverage("MIT OR Apache-2.0", &mit).unwrap()); + assert!(!licence_coverage("MIT AND Apache-2.0", &mit).unwrap()); + let both = BTreeSet::from(["MIT", "Bitstream-Vera"]); + assert!(licence_coverage("MIT AND Bitstream-Vera", &both).unwrap()); + assert!(!licence_coverage("MIT OR Apache-2.0", &BTreeSet::from(["OFL-1.1"])).unwrap()); + for expression in ["", "MIT AND", "MIT OR Apache-2.0 AND ISC", "(MIT OR Apache-2.0)", "MIT WITH exception"] { + assert!(licence_coverage(expression, &both).is_err(), "{expression}"); + } + } + + #[test] + fn changed_ocr_constant_syntax_fails_closed() { + assert!(check_required_ocr_metadata("", &assets::Manifest::default()).is_err()); + } + + #[test] + fn parent_file_destination_conflicts_fail() { + let (root, mut docs) = fixture(); + docs.document.get_mut(2).unwrap().destination = "LICENSE-MIT/NOTICE".into(); + assert!(check(&root, &docs, &assets::Manifest::default()).iter().any(|p| p.contains("parent file"))); + std::fs::remove_dir_all(root).unwrap(); + } + + #[cfg(unix)] + #[test] + fn symlink_escape_fails() { + let (root, docs) = fixture(); + let outside = root.with_extension("outside"); + std::fs::write(&outside, "synthetic licence text\n").unwrap(); + std::fs::remove_file(root.join("LICENSE-MIT")).unwrap(); + std::os::unix::fs::symlink(&outside, root.join("LICENSE-MIT")).unwrap(); + assert!(check(&root, &docs, &assets::Manifest::default()).iter().any(|p| p.contains("outside checkout"))); + std::fs::remove_dir_all(root).unwrap(); + std::fs::remove_file(outside).unwrap(); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 24ddd16d16..8e3255d5e6 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -8,6 +8,7 @@ mod demo_pdf; mod fuzz; mod gates; mod layers; +mod license_check; mod macos_integration; mod parity; mod screenshots; @@ -23,11 +24,12 @@ const COMMANDS: &[(&str, &str, Command)] = &[ ("deny", "Dependency licences, bans, sources and advisories (deny.toml; needs cargo-deny)", gates::deny), ( "ci", - "fmt --check, clippy -D warnings, the CLI without default features, test, i18n-report, layers, wasm, assets, deny, parity (stops at first failure)", + "fmt --check, clippy -D warnings, the CLI without default features, test, i18n-report, layers, wasm, license-check, deny, parity (stops at first failure)", gates::ci, ), ("test", "Workspace tests: cargo nextest (cargo test when it is missing), then doctests", gates::test), ("i18n-report", "Print the strings each interface language lacks (counts and a few keys); never fails", gates::i18n_report), + ("license-check", "Offline source licence documents, attribution and metadata", license_check::run), ("assets", "Enforce the asset policy (AGENTS.md §1) against ATTRIBUTION.toml; --write regenerates ATTRIBUTION.md", assets::run), ("corpus", "Fetch the pinned pdf.js test corpus into corpus/ (git-ignored) and verify it; --update-pin re-records the pin", gates::corpus), ("check", "Robustness sweep over corpus/ with pdfcraft-cli; fails on crashes or regressions vs xtask/baselines", gates::check),