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/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 838af4524c..f3e2b20c7e 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -27,7 +27,8 @@ permissions: jobs: windows-arm64: - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main with: app-name: pdfcraft app-dir: PdfCraft diff --git a/.gitignore b/.gitignore index 7834b668cb..f7922460c7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ plan/ /assets/models # A craft-fonts checkout used as the optional CRAFT_FONTS_DIR build input (CI and releases put it here). /craft-fonts +.claude/ diff --git a/AGENTS.md b/AGENTS.md index 2ecfe99a02..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,17 +123,19 @@ Follow `CLAUDE.md`. It covers quality gates, layering, commit rules and how to r ## Contributor credits (About window) - About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json` - (commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for + (commit stats; never hand-edit) and `contributors/people.toml` (names people chose for themselves). See `docs/contributors.md`. - **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once whether they want to be credited by more than their username: a real name, a display name, and/or their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry - (copy the template at the top of the file, or run - `python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`) + (copy the template at the top of the file) and include it in their PR, committed as them. If no, change nothing: they are credited as `@username` anyway. - Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or GitHub profiles). Never hand-edit `contributors.json`. -- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also - re-verifies who wrote each `people.toml` entry; `--check` only verifies). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 23ebcbbe82..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 489247559d..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,57 +2873,57 @@ sha256 = "f87af81b6fa411da8820eed2bfdcc54c0ee9cd453b4e868c64f56c69db142d0e" [[asset]] path = "assets/app-icon/pdfcraft.svg" title = "PdfCraft app icon (master vector)" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Master artwork; every other app-icon file is rendered from it (packaging/icons.sh)" -sha256 = "52b384a10a2a7fdd3145d9822972cfd568ef0cb7312549bd4399addd89d45a6d" +sha256 = "27745c021830f82c033b0f15f2d8d9f8e099138743297887ff7e2c9e7ed069da" [[asset]] path = "assets/app-icon/pdfcraft-small.svg" title = "PdfCraft app icon (light vector)" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "README header and assets/app-icon/README.md" -sha256 = "b036304c11ef5dcad36636d06ca0bf331b51e47650d25d3c916a0e1eabf544c7" +sha256 = "b524df1a583f8034a5a23836955e4fb423e0149429d695e80d95e5056f408678" [[asset]] path = "assets/app-icon/pdfcraft-1024.png" title = "PdfCraft app icon, 1024 px on Apple's icon grid" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Runtime window/Dock icon on macOS (apps/pdfcraft/src/main.rs)" -sha256 = "da577d3f21fbcf5c739067787a9e683be2b064d76813b5d5fec9074a7a106538" +sha256 = "68ac6730b8990d07503351d32eacea81e9ec171a7a6f09daf52d6555d8245c8a" [[asset]] path = "assets/app-icon/pdfcraft.icns" title = "PdfCraft app icon (macOS .icns)" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "macOS app bundle icon" -sha256 = "2425148222873de241d267aba667a8b234ecdd0149f2ca8bb6e79443b743b1e8" +sha256 = "fca7e7a0e34cc5c5ecae5bca97ee896aac68c4a34b088bc3a0b3271484f23974" [[asset]] path = "assets/app-icon/pdfcraft.ico" title = "PdfCraft app icon (Windows .ico, 16-256 px)" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Embedded in pdfcraft.exe by apps/pdfcraft/build.rs (Explorer, taskbar, Start menu, Alt-Tab)" -sha256 = "56daaaae9ae5389794909570885809adf5e1d9183bef2d9669ed2f9bba3ed6f5" +sha256 = "709271d66e07710a3f785ea21b0c85c5228eaf8bebe5058bc0f71f9ef20749eb" [[asset]] path = "assets/app-icon/pdfcraft-document.svg" @@ -2934,7 +2934,7 @@ licence = "ISC AND (MIT OR Apache-2.0)" licence_file = "assets/app-icon/LICENSE-document.txt" kind = "icon" usage = "Master artwork for the Windows PDF file type icon; rendered by packaging/icons.sh, without fonts or external images" -sha256 = "8da8278c2b89362be0a960879d453ff879d7544d6d58e47d5389574e8dfff77b" +sha256 = "c129f1b538ce16e3c3fa0d89d3301038c51249ba5d61bfd98c46e69cfdf5c316" [[asset]] path = "assets/app-icon/pdfcraft-document.ico" @@ -2945,106 +2945,106 @@ licence = "ISC AND (MIT OR Apache-2.0)" licence_file = "assets/app-icon/LICENSE-document.txt" kind = "icon" usage = "Embedded in pdfcraft.exe as resource 2; the Windows installer uses it for PdfCraft.Document and Applications/pdfcraft.exe PDF file associations" -sha256 = "ba3f9128b368fc6d17d376cf62dbdbc22455451c5c8c4779210d7a2a7409b9e6" +sha256 = "910be5e0ecdc94ce00368077ca1bef449bd5d62a75516feeb37884296c6fac4d" [[asset]] path = "assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 16 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "9b77095cbe75dcba6b74a1fdcd4064812850efaf7a12edebefc9ded5eabb49da" +sha256 = "660d2aace45ec1a28bc7e3708eab71ffee5cb1203f57a6256f7bd7c727193567" [[asset]] path = "assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 24 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "a4cd466687f6c5cb59eef3fc564bdba1c8f0d9e324754eeebab82f18823efae7" +sha256 = "1989441bdcc8d1df0d73cfe7eb836e9211abaa0fd7fd6ec7e4634bfcd472995d" [[asset]] path = "assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 32 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "e22279013a5ed5c02571e1e4e9bc4cef9aa0847fcb733a285b2916fabd16bf78" +sha256 = "7fd1ac555f7f6b13ef3fba2696dfb86900cfcb7f7b669aece38590bf2306cf2e" [[asset]] path = "assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 48 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "55daf2d5575f6c4e4c4975c9f1c74231178f1801963a8b19f68dfa9bc50cb22f" +sha256 = "784ef3bd9f9e479cb2df22a0aee259a441a9c358390e646639c3a0ed79d41288" [[asset]] path = "assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 64 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "2975c200f341e247b5fa18f568fb27d390e3fa01e9b2ad37acb48d0e1cc8a124" +sha256 = "deb1a9f849549d3f0652fb6d3c30b6eb6ea54498eaea8c1007d901bbafe7067c" [[asset]] path = "assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 128 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "6ef927ca252b40bc9b5d8843d84137334362f8aa1acbb1bf8c15a02affd25b2b" +sha256 = "1933bbeaea290497facd328e917dafcc433d0c0caa9f1e207aa45ac1e430898c" [[asset]] path = "assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 256 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop); runtime window icon on Windows and Linux (apps/pdfcraft/src/main.rs)" -sha256 = "cde7ee8c5ee38f9fc40a429fd9e6d99a035bcd321701fce331bb13bac126614d" +sha256 = "47094c09fb77eb35d465eee46445626a4d78c7ef50629cc7d04a602de3e144e9" [[asset]] path = "assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png" title = "PdfCraft app icon, 512 px" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "5ede733e93b96f4eb8d94299232cf2f9605fa19b0d913f95fe9828c316a2d066" +sha256 = "dea8010a8b41011aba054d3bfb8b790da57bc958c60869f508e1bbd288d3f280" [[asset]] path = "assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg" title = "PdfCraft app icon (Linux scalable)" -author = "PdfCraft contributors (drawn by the project owner in ArtCraft; vectorised with craftrules vectorize_tile.py)" +author = "PdfCraft contributors (drawn by the project owner in ArtCraft)" source = "Contributor-original (see assets/app-icon/README.md)" licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "icon" usage = "Linux hicolor theme icon (packaging/linux/ai.storyteller.pdfcraft.desktop)" -sha256 = "52b384a10a2a7fdd3145d9822972cfd568ef0cb7312549bd4399addd89d45a6d" +sha256 = "27745c021830f82c033b0f15f2d8d9f8e099138743297887ff7e2c9e7ed069da" [[asset]] path = "docs/images/pdfcraft-bookmarks.png" @@ -3194,7 +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" @@ -3341,7 +3341,7 @@ licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "image" usage = "Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py" -sha256 = "e8b9ea9d5e64b7b741c977543c1693825e77d5c4c579eeb674bf5dcd3764cd8a" +sha256 = "182663e4d1c4fabbb7ab2e1f0d4e991a8e0859b4a8dee6934d3e25d77ea020af" [[asset]] @@ -3353,7 +3353,7 @@ licence = "MIT OR Apache-2.0" licence_file = "assets/app-icon/LICENSE.txt" kind = "image" usage = "1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile" -sha256 = "f46881724a6621f3686a612a79dbdce4f7dbe9e2025610e44c9a094341334c2c" +sha256 = "bedf1b23707b9e34157440e83f65464ff314fa9f6b1d39c7a8930d1645c2f8be" [[fetched]] file = "text-detection.onnx" @@ -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 cd1b5b284a..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/`). @@ -23,6 +23,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone. - **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it. - **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim(Layer::…, icon)`, `crates/ui-egui/src/canvas/page_cursor.rs`), never set it (`set_cursor_icon`, `cursor_icon =`, `on_hover_cursor`); `document_area` sets it once, from the claim on the highest layer the active tool reaches. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it. +- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the router says the press is its own (`view.router.owner(page)`), never by testing the press point again or by asking another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_modules_take_presses_from_the_router_without_repeating_hit_tests` guards the centralized hit calls and captured-owner handlers; runtime routing tests cover gesture behavior. - **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it. - **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code: - No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why. @@ -31,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. @@ -57,3 +58,8 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado ## Current bootstrap debt (tracked in STATUS.md) - `pdfcraft-render` renders through the `hayro` crate directly and inspects documents through `lopdf`. Both get replaced by `cos` / `model` / the DisplayList device (M1–M2, ADR-0004). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/Cargo.lock b/Cargo.lock index 8df9cf6236..9c29337d75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,7 +229,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -1178,17 +1178,17 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" +source = "git+https://github.com/storytold/craft-libs?rev=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,13 +1280,14 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "chrono", "dunce", "egui", "flate2", "getrandom 0.3.4", + "image", "log", "plist", "semver", @@ -2015,7 +2017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2731,6 +2733,7 @@ version = "0.3.0" dependencies = [ "fearless_simd", "hayro-ccitt", + "image", ] [[package]] @@ -4114,7 +4117,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4205,7 +4208,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.15.0" +version = "0.16.1" dependencies = [ "chrono", "craft-update", @@ -4229,7 +4232,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.15.0" +version = "0.16.1" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4241,7 +4244,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.15.0" +version = "0.16.1" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4252,7 +4255,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.15.0" +version = "0.16.1" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -4263,7 +4266,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.15.0" +version = "0.16.1" dependencies = [ "base64 0.23.1", "flate2", @@ -4285,7 +4288,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.15.0" +version = "0.16.1" dependencies = [ "base64 0.23.1", "pdfcraft-audit", @@ -4303,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", @@ -4315,7 +4318,7 @@ dependencies = [ [[package]] name = "pdfcraft-cos" -version = "0.15.0" +version = "0.16.1" dependencies = [ "chrono", "hayro-syntax", @@ -4330,14 +4333,18 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.15.0" +version = "0.16.1" dependencies = [ "encoding_rs", + "hayro-ccitt", + "hayro-jbig2", "hayro-syntax", "image", "pdfcraft-cos", + "pdfcraft-filters", "pdfcraft-fonts", "pdfcraft-model", + "pdfcraft-xfa", "png 0.18.1", "thiserror 2.0.21", "tiff", @@ -4346,7 +4353,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.15.0" +version = "0.16.1" dependencies = [ "aes", "md-5", @@ -4358,7 +4365,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.15.0" +version = "0.16.1" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4367,16 +4374,20 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-fonts", "pdfcraft-model", + "pdfcraft-render", + "proptest", + "sha2 0.11.1", "thiserror 2.0.21", "unicode-bidi", ] [[package]] name = "pdfcraft-engine" -version = "0.15.0" +version = "0.16.1" dependencies = [ "chrono", "craft-docpath", + "getrandom 0.4.3", "icu_properties", "idna", "image", @@ -4384,6 +4395,7 @@ dependencies = [ "pdfcraft-annot", "pdfcraft-audit", "pdfcraft-compare", + "pdfcraft-content", "pdfcraft-cos", "pdfcraft-create", "pdfcraft-edit", @@ -4414,16 +4426,19 @@ 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", + "hayro-ccitt", + "hayro-jbig2", "proptest", "thiserror 2.0.21", "weezl 0.2.1", @@ -4431,7 +4446,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.15.0" +version = "0.16.1" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4441,7 +4456,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.15.0" +version = "0.16.1" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4454,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", @@ -4466,7 +4481,7 @@ dependencies = [ [[package]] name = "pdfcraft-measure" -version = "0.15.0" +version = "0.16.1" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4479,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", @@ -4497,7 +4512,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.15.0" +version = "0.16.1" dependencies = [ "image", "pdfcraft-annot", @@ -4506,17 +4521,18 @@ dependencies = [ "pdfcraft-filters", "pdfcraft-model", "pdfcraft-render", + "proptest", "thiserror 2.0.21", "zune-jpeg", ] [[package]] name = "pdfcraft-oracle" -version = "0.15.0" +version = "0.16.1" [[package]] name = "pdfcraft-organize" -version = "0.15.0" +version = "0.16.1" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4527,7 +4543,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.15.0" +version = "0.16.1" dependencies = [ "async-channel", "futures-lite", @@ -4537,18 +4553,18 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.15.0" +version = "0.16.1" dependencies = [ "flate2", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", - "roxmltree 0.20.0", + "roxmltree 0.21.2", ] [[package]] name = "pdfcraft-print" -version = "0.15.0" +version = "0.16.1" dependencies = [ "craft-print", "pdfcraft-content", @@ -4560,12 +4576,13 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.15.0" +version = "0.16.1" dependencies = [ "pdfcraft-annot", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-edit", + "pdfcraft-filters", "pdfcraft-fonts", "pdfcraft-forms", "pdfcraft-model", @@ -4576,7 +4593,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.15.0" +version = "0.16.1" dependencies = [ "bytemuck", "flate2", @@ -4595,7 +4612,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.15.0" +version = "0.16.1" dependencies = [ "aes", "aws-lc-rs", @@ -4631,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", @@ -4659,6 +4676,7 @@ dependencies = [ "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "pdfcraft-content", "pdfcraft-cos", "pdfcraft-engine", "pdfcraft-fonts", @@ -4669,6 +4687,7 @@ dependencies = [ "plist", "png 0.18.1", "pollster 1.0.1", + "proptest", "rfd", "rustix 1.1.5", "serde", @@ -4687,7 +4706,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.15.0" +version = "0.16.1" dependencies = [ "eframe", "egui", @@ -4699,27 +4718,27 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.15.0" +version = "0.16.1" dependencies = [ "base64 0.23.1", "image", "pdfcraft-cos", "pdfcraft-fonts", "png 0.18.1", - "roxmltree 0.20.0", + "roxmltree 0.21.2", "sha2 0.11.1", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-xfdf" -version = "0.15.0" +version = "0.16.1" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", "pdfcraft-forms", "pdfcraft-model", - "roxmltree 0.20.0", + "roxmltree 0.21.2", "thiserror 2.0.21", ] @@ -5258,7 +5277,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5795,7 +5814,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5865,7 +5884,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6755,7 +6774,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7350,8 +7369,6 @@ dependencies = [ [[package]] name = "vello_cpu" version = "0.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0" dependencies = [ "bytemuck", "glifo 0.1.1", @@ -7868,7 +7885,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8595,9 +8612,9 @@ dependencies = [ [[package]] name = "zip" -version = "9.1.0" +version = "9.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb" dependencies = [ "crc32fast", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 436731c137..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,51 +13,52 @@ repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] # Shared frontend components; engines remain independent of egui. -craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "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-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", features = ["egui"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "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 = "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" @@ -65,6 +66,11 @@ hayro-syntax = "0.7.2" lopdf = "0.45" flate2 = { version = "1.1", default-features = false, features = ["rust_backend"] } weezl = "0.2" +# CCITT Group 4 encoding (pdfcraft-filters); already in the tree through tiff. +fax = "0.2.7" +# Independent CCITT and JBIG2 codecs, already in the tree through hayro. +hayro-ccitt = "0.3" +hayro-jbig2 = "0.3" # foundation thiserror = "2" @@ -128,6 +134,7 @@ hayro-postscript = { path = "vendor/hayro-postscript" } hayro-jbig2 = { path = "vendor/hayro-jbig2" } lopdf = { path = "vendor/lopdf" } vello_common = { path = "vendor/vello_common" } +vello_cpu = { path = "vendor/vello_cpu" } # The egui and winit crates below are pinned to immutable storytold git revisions (egui # 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc). # Only the retained `vendor/winit` and `vendor/egui-wgpu` copies are provenance for the app's 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 1766e26ebb..3c1b67936a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- PdfCraft icon: an engraved lion on a teal tile + PdfCraft icon: an engraved lion on a red tile

PdfCraft

@@ -21,10 +21,10 @@

- License: MIT OR Apache-2.0 - Written in Rust - Platforms: macOS, Windows, Linux, FreeBSD, web - No account, no telemetry + License: MIT OR Apache-2.0 + Written in Rust + Platforms: macOS, Windows, Linux, FreeBSD, web + No account, no telemetry
Scanned by SonarCloud Reviewed by CodeRabbit @@ -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 f5de8044e1..01b9dbe814 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,25 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged) + +> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 latest comments: explicit image-paste events, mixed-size field refresh, and documented mixed-script normalization with Undo; estimates unchanged) + +> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 review follow-up: preserve image crop/tag/stream ownership, resize in page orientation, harden clipboard and text formatting, and reload installed fonts; estimates unchanged) + +> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: GBK rasters compared with explicit Unicode and independently selected installed faces; wrong-glyph and wrong-family negative controls fail; estimates unchanged) +> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: integrate main fa5f9f8c; retain CJK viewer and upstream raster regressions, restore explicit display_config imports; estimates unchanged) +> **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** patch (M2, PR #4172: integrate main 5af10276; retain both roadmap histories and viewer-only CJK font policy; estimates unchanged) +> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M2, PR #4172: integrate main 1dd2737b; preserve viewer-only CJK fallback and regression coverage across engine, renderer and Combine module splits; estimates unchanged) +> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** patch (M2, PR #4172: integrate main 9a9813bf and preserve viewer-only CJK fallback through the engine module split; estimates unchanged) +> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: document installed-font resolution helpers and regression contracts; behavior and estimates unchanged) +> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: print preview excludes installed CJK fonts and isolates its font cache from the viewer; estimates unchanged) +> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: reconcile current main while retaining display-only CJK fallback and upstream Japanese font selection; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M2, PR #4172: extend on-screen CJK fallback to Combine thumbnails and figure previews; recognise FangSong and subset-prefixed Founder names; estimates unchanged) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (typed small Edit font sizes and Fill & Sign text/date colour, #2444/#2496; estimates unchanged) + **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged) > **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) @@ -21,6 +39,7 @@ > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged) > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M4: initial-view options resolve indirect boolean preferences; estimates unchanged) > **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M7 #2517: a partial rewrite keeps its marker around a kept row, and off the next marked region; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) @@ -95,8 +114,8 @@ and add a line to the [progress log](#progress-log); when a gap closes, remove i | | Value | Kind | |---|---|---| -| **Feature breadth** | **≈ 67%** tier-weighted (56.9% unweighted: 423 shipped + 74 partial of 808; P0 94.2%) | measured, `parity/acrobat-features.toml` | -| Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.2% unweighted; P0 95.3%) | measured ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) | +| **Feature breadth** | **≈ 67%** tier-weighted (57.0% unweighted: 423 shipped + 75 partial of 808; P0 94.2%) | measured, `parity/acrobat-features.toml` | +| Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.3% unweighted; P0 95.3%) | measured ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) | | **Ready for real work**, full Acrobat Pro | **≈ 50%** (45–53%) | estimated, weighted sum over [dimensions](#by-dimension) | | **Ready for real work**, mainstream practitioner | **≈ 55%** (50–60%) | estimated ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) | | **Ready for real work**, essentials user | **≈ 66%** (60–70%) | estimated ([method](docs/target-app-parity.md#essentials-user)) | @@ -151,7 +170,7 @@ Hours are calibrated as in [Effort and calibration](docs/target-app-parity.md#ef | H Digital signatures | 62.2% | 45% | 40–70 | | I Scan and OCR | 23.1% | 15% | 40–75 | | J Create and export | 39.7% | 25% | 55–100 | -| K Optimize and standards | 34.5% | 20% | 65–115 | +| K Optimize and standards | 35.3% | 20% | 65–115 | | L Print | 39.7% | 35% | 30–55 | | M Accessibility | 63.1% | 30% | 40–70 | | N Automation, preferences, misc | 30.0% | 35% | 25–45 | @@ -200,11 +219,41 @@ 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-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged. - **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. - **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. +- **2026-10-22 (M7, #2517):** A partial rewrite of the last paragraph closes its marker after that paragraph's last `ET`, so a row left in the source stays in the paragraph. When the next paragraph shares the text object, the marker closes before that neighbour instead of after the shared `ET`. The extra marker is not added across two marked-content regions. Estimates unchanged. +- **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. An explicit `TL` is the measured baseline gap. Without one, three or more rows at a repeated step stay one paragraph, and a single wider gap stays a break. Adding a row, even when an earlier row is left in the source, does not absorb the next paragraph on a later edit. Estimates unchanged. +- **2026-10-24 (M11, more black-and-white images):** The optimizer re-stores Group 3 fax images as Group 4 when that is smaller, instead of leaving any CCITT image it doesn't resample as it is. Black-and-white images stored as eight-bit RGB (as some phone and scanner apps write them) become one-bit grey, and indexed images that use only their first two palette entries become one bit with their palette, so Monochrome Images compresses and downsamples them too. +- **2026-10-24 (M11, monochrome downsampling):** The PDF Optimizer and Reduce File Size average black-and-white images above 450 ppi down to 300 ppi (Monochrome Images ▸ Downsample to), Group 4 scans included, so a 600 ppi text page goes from 79 KB to 40 KB. CCITT decoding moved into `pdfcraft-filters` (`decode_ccitt`), shared by Export ▸ Images and the optimizer through `Document::decoded_image`. +- **2026-10-24 (M11, black-and-white pictures):** A scan saved as a 1-bit PNG became an eight-bit grey image when made into a PDF, larger than the PNG; Create PDF now stores pictures of only black and white at one bit (Group 4 when smaller), the optimizer packs eight-bit black-and-white grey images to one bit unless the grey settings make them smaller, as they can a dithered scan, soft mask counted, and Export ▸ Images reads CCITT fax images. +- **2026-10-23 (M11, JBIG2):** The PDF Optimizer can store black-and-white images as lossless JBIG2 (a generic region, written from the JBIG2 draft standard and checked against its arithmetic-coder test sequence), about a third smaller than Group 4 on 300 dpi text, and Export ▸ Images reads JBIG2 images. JBIG2 symbol coding, where its largest gains on text are, is next. +- **2026-10-23 (M11, monochrome compression):** The PDF Optimizer and Reduce File Size store black-and-white images (scans, image masks) losslessly as CCITT Group 4 when that is smaller, with a Monochrome Images setting in the Optimizer and `doc_optimize` `mono`. JBIG2 and monochrome downsampling are next. +- **2026-10-23 (M6, calculation checks):** The calculation order now flags a formula that reads a field the form doesn't have (a field deleted from under a sum, a typo in notation, a script's `getField`) and a script that builds the names it reads at run time. Prepare a form ▸ Calculation order marks both on the row and in a card that names the broken formulas, in place of its all-clear. `form_calculation_order` gives each field's formula, and `form_fields` shows a custom Calculate script. - **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged. - **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported. - **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged. @@ -220,8 +269,11 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md The regression checks two distinct appearances, inherited resources, print/reopen pixels and unchanged source data. Estimates unchanged. - **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged. +- **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged. - **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen. - **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged. + +- **2026-10-23 (M2, #234, continuation of #1567):** Reconciled installed CJK viewer fallback with current rendering changes; Windows preserves installed Song/Hei/Kai families and bounds font reads. GBK-EUC-H CIDFontType2 regression coverage includes text extraction; viewer fallback also survives reload with retained preferences. System fonts remain display-only. No overall readiness estimate changed. - **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged. - **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged. @@ -300,6 +352,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-19 (M3, #3360):** Switching between Read and the docking workspace retains the current document’s scroll position at fixed zoom, including within-page position; separate tabs retain their own offsets. Estimates unchanged. - **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged. - **2026-10-17 (M4, #2997):** Dragging page thumbnails in the Organize grid with Ctrl held (Option on macOS) drops copies at the gap instead of moving the pages: a "+" badge sits by the pointer, the copies land at the gap selected, and one undo step ("Duplicate pages") takes them out again. `page_duplicate` takes `at` (1-based, where the first copy goes; left out, after the last of them) so agents can drive the same thing, and the UI control channel's `ui.drag` modifiers now reach gestures that read the input state. Estimates unchanged. +- **2026-10-16 (M7, Add Background):** A background added behind a page that paints its own opaque white backdrop (browsers' Save as PDF, print drivers) was hidden completely, so the tool looked broken. The dialog now detects such pages and offers to replace the backdrop (on by default): the one fill becomes `n` in a per-page copy of its stream, and Remove background turns the fill back on, after edits and redactions too, unless the operators before it changed or it would no longer be painted first; then it says which pages keep it off. The copy's tag holds no reference to the original stream, which had kept text a redaction or Remove Hidden Information removed in the saved file. Scanned pages are warned about, not touched. New `doc_backdrops` tool; `doc_background` takes `replace_backdrop`. The default colour is a visible light yellow and the preview shows text over it. Acrobat's own behaviour on such pages is unobserved. Estimates unchanged. - **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged. - **2026-10-15 (M6, #1917 refresh):** Integrated upstream `99941daa` while retaining the XML/XFDF attribute whitespace correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. - **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. @@ -340,6 +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. @@ -449,6 +504,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Newest first: the date, what moved, and the new overall estimate. +- **2026-10-28 (M10, #1595):** Export to Markdown: `doc_export_office` with a `.md` or `.markdown` path writes CommonMark with GitHub pipe tables (headings, paragraphs with bold and italic, tables with their first row as the header, a rule between pages); text that Markdown would read as markup is escaped. Images, lists and links are not in the export's layout model and are left out. No menu item yet. Estimates unchanged. - **2026-10-12 (M3, GNOME title bar, #553):** On GNOME's Wayland session, whose compositor draws no window frames (it offers no `zxdg_decoration_manager_v1`), PdfCraft no longer shows winit's fallback title bar: it turns the frame off and the tab strip becomes the header bar, as in GNOME's own apps. Minimize, maximize/restore and close are drawn in Adwaita's style, in the order and on the side GNOME's `button-layout` names (Fedora's default shows only close); the window's edges and corners resize it, and a hairline outlines it while it isn't maximized. Preferences ▸ Use system title bar (shown only there) gives the window back winit's frame at once, without a restart (part of #1001), and `PDFCRAFT_TITLEBAR=app|system` overrides the choice. KDE, Sway, Hyprland, COSMIC and X11 keep the frame their desktop draws. Not done: right-clicking the header bar doesn't open GNOME's window menu (egui can't ask for it), the window has no shadow or rounded corners, the resize edges lie just inside the window rather than outside it, and Windows still uses its system frame (the rest of #1001). Estimates unchanged. - **2026-10-12 (M0, build label):** A local or patched build can carry a label, set at compile time with `PDFCRAFT_BUILD_LABEL`: `pdfcraft --version`, `pdfcraft-cli --version`, About and the log show `0.7.1 (titlebar)`, and `packaging/linux/package.sh` names the files `pdfcraft-0.7.1-titlebar-…` and makes the .rpm/.deb release `1.titlebar`, which upgrades over the release of the same version (and the next release over it). The version itself is unchanged, so the update check still compares it with published releases. Unlabelled builds are as before. Estimates unchanged. - **2026-10-11 (M6, #1208):** Dropdown and list-box Field Properties expose the existing Left/Center/Right alignment picker. Existing alignment stays intact until changed; cancel, undo/redo and save/reopen are covered through the real UI without changing options, values or flags. Estimates unchanged. @@ -511,10 +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%). @@ -684,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. | @@ -722,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. | @@ -748,3 +805,19 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged. - **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged. + +- **2026-10-23 (M2, PR #4172):** Combine thumbnails and accessibility figure previews use the viewer's display-only CJK fallback. Windows family matching includes FangSong and subset-prefixed Founder names. Export, print, OCR and automation remain off by default; estimates unchanged. + +- **2026-10-26 (M2, PR #4172 print-preview review):** Print-preview requests disable installed CJK fallback and use an isolated cache so preview font availability matches output. Viewer fallback survives switching in either direction. Synthetic GBK regressions cover inline and single-worker rendering, full pages and tiles; estimates unchanged. + +- **2026-10-26 (M2, PR #4172 documentation review):** Added function documentation for installed-font script selection, bounded candidate reads, platform paths, resolver precedence and regression contracts. No runtime behavior or estimate change. + +- **2026-10-27 (M2, PR #4172 conflict resolution):** Integrated main `9a9813bf` and its engine module split. Opening, layer changes and session renderer rebuilds retain display-only CJK fallback; output configuration and print-preview cache isolation are preserved. Validation passed: 16 scoped engine regressions, 412 renderer unit tests, 43 renderer integration tests (one existing corpus case ignored), 15 Combine tests, scoped Clippy, formatting, layers/assets/parity and renderer/engine WASM checks. Estimates unchanged. +## Pages sidebar keyboard refresh (2026-10-30) + +PR #2133 retains document-local Pages sidebar focus for Ctrl/Cmd+A and Escape after the UI module split. The selection does not navigate. The focused regression is in `crates/ui-egui/tests/it/view.rs`; current-head hosted validation is pending. Feature counts and estimates are unchanged. +## Button export-value refresh (2026-10-30) + +PR #1952 retains per-widget checkbox/radio export-value editing in the split form-authoring and Prepare modules. Caption editing remains supported. Tests cover preserved appearances and selection, invalid/locked refusals, data-format round trips, and Apply/Cancel/Undo. Current-head hosted validation is pending; feature estimates are unchanged. + +- **2026-10-31 (#1577):** Reconciled the page-shadow preference with split viewer, organizer, settings and control modules, preserving upstream page-gap shadow suppression. CI validation pending. diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index f8e4e5b0d2..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 …) @@ -196,7 +200,7 @@ fn rejoin_split_paths(args: Vec, exists: impl Fn(&str) -> bool) -> Vec eframe::Result { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] - if let Some(result) = craft_update::self_update::finish(&craft_update::suite::PDFCRAFT, &std::env::args_os().skip(1).collect::>()) { + if let Some(result) = craft_update::self_update::finish(&pdfcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { std::process::exit(i32::from(result.is_err())); } // First, so the panic hook and every start-up warning are recorded (`logging`). @@ -218,28 +222,16 @@ fn main() -> eframe::Result { let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("pdfcraft: {report}\n")); } })); - let mut files = Vec::new(); - let mut options: Vec<(String, String)> = Vec::new(); - let mut control_file: Option = None; - let mut create_images = false; - let mut new_window = false; - let mut args = std::env::args().skip(1); - while let Some(a) = args.next() { - match a.as_str() { - "--version" => return print_and_exit(&format!("pdfcraft {}", pdfcraft_engine::build_info::version())), - // Before any window or log file, so a scripted call returns (#4076). - "-h" | "--help" => return print_and_exit(USAGE), - "--control" => control_file = args.next(), - "--create-images" => create_images = true, - "--new-window" => new_window = true, - flag if flag.starts_with("--") => { - let value = args.next().unwrap_or_default(); - options.push((flag.trim_start_matches("--").to_string(), value)); - } - _ => files.push(path_from_arg(a)), - } + let mut startup = startup::Arguments::parse(std::env::args().skip(1)); + if startup.version { + return print_and_exit(&format!("pdfcraft {}", pdfcraft_engine::build_info::version())); } - let files = rejoin_split_paths(files, |p| std::path::Path::new(p).is_file()); + if startup.help { + return print_and_exit(USAGE); + } + startup.files = rejoin_split_paths(startup.files, |p| std::path::Path::new(p).is_file()); + let may_hand_off = startup.may_hand_off(); + let startup::Arguments { files, options, control_file, create_images, two_paged, .. } = startup; let integrated = cfg!(target_os = "macos"); // GNOME on Wayland leaves the window frame to the app (src/decorations.rs). let window_controls = if cfg!(any(target_os = "macos", target_os = "windows")) { None } else { decorations::app_drawn() }; @@ -260,7 +252,6 @@ fn main() -> eframe::Result { // Explorer double-click, a file manager or `xdg-open`) hands them to the PdfCraft already // running and exits (#282, #317, #1726). Any option means the caller wants this launch's own // window. - let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images; // Absolute for this instance too: links relative to a document resolve against its folder, // never the working directory. let files = single_instance::absolute(&files); @@ -270,7 +261,7 @@ fn main() -> eframe::Result { single_instance::Claim::Alone => None, }; let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref()); - let launch = Launch { files, options, control_file, create_images, integrated, window_controls }; + let launch = Launch { files, options, control_file, create_images, integrated, window_controls, two_paged }; // Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one // that launched us as well as later ones. Lives until the event loop returns. #[cfg(target_os = "macos")] @@ -380,6 +371,7 @@ struct Launch { integrated: bool, /// The window buttons the app draws, when the desktop leaves the frame to it. window_controls: Option, + two_paged: bool, } /// Whether the window is a native Wayland one (not X11 or XWayland). @@ -400,7 +392,7 @@ fn app_creator<'a>( instance: Option<&'a single_instance::Server>, #[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents, ) -> eframe::AppCreator<'a> { - let Launch { files, options, control_file, create_images, integrated, window_controls } = launch; + let Launch { files, options, control_file, create_images, integrated, window_controls, two_paged } = launch; Box::new(move |cc| { started.set(true); let mut app = PdfCraftApp::new(); @@ -467,6 +459,7 @@ fn app_creator<'a>( &[("folder", &w.folder.display().to_string()), ("error", &w.error)], ); } + let defer_layout = two_paged && !files.is_empty() && !create_images; if create_images { if let Err(e) = app.begin_image_import_paths(&files) { app.notify(e); @@ -475,12 +468,11 @@ fn app_creator<'a>( // With the preference on, last session's files come back first; files named on // the command line open after them, in front (#442). app.reopen_last_files(&files); - for f in files { - app.open_path(&f); - } + startup::open_files(&mut app, &files, two_paged); } for (k, v) in options { - if let Err(e) = app.set_option(&k, &v) { + let result = if defer_layout && k == "layout" { app.set_launch_layout(&v) } else { app.set_option(&k, &v) }; + if let Err(e) = result { log::warn!("--{k} {v}: {e}"); } } diff --git a/apps/pdfcraft/src/startup.rs b/apps/pdfcraft/src/startup.rs new file mode 100644 index 0000000000..be25da45b6 --- /dev/null +++ b/apps/pdfcraft/src/startup.rs @@ -0,0 +1,244 @@ +//! Native launch arguments and per-file view overrides (#1354). +use pdfcraft_ui_egui::PdfCraftApp; + +#[derive(Default)] +pub(super) struct Arguments { + pub files: Vec, + pub options: Vec<(String, String)>, + pub control_file: Option, + pub create_images: bool, + pub new_window: bool, + pub two_paged: bool, + pub version: bool, + pub help: bool, +} + +impl Arguments { + pub fn may_hand_off(&self) -> bool { + !self.new_window && !self.files.is_empty() && self.options.is_empty() && self.control_file.is_none() && !self.create_images && !self.two_paged + } + + pub fn parse(args: impl IntoIterator) -> Self { + let mut parsed = Self::default(); + let mut args = args.into_iter(); + while let Some(a) = args.next() { + match a.as_str() { + "--version" => { + parsed.version = true; + break; + } + "-h" | "--help" => { + parsed.help = true; + break; + } + "--control" => parsed.control_file = args.next(), + "--create-images" => parsed.create_images = true, + "--new-window" => parsed.new_window = true, + "--two-paged" => parsed.two_paged = true, + flag if flag.starts_with("--two-paged=") => { + // It has no value: do not consume the following filename on malformed use. + log::warn!("{flag}: use --two-paged without a value"); + } + flag if flag.starts_with("--") => { + let value = args.next().unwrap_or_default(); + parsed.options.push((flag.trim_start_matches("--").to_string(), value)); + } + _ => parsed.files.push(super::path_from_arg(a)), + } + } + parsed + } +} + +pub(super) fn open_files(app: &mut PdfCraftApp, files: &[String], two_paged: bool) { + for path in files { + if two_paged { + // The per-file override wins over initial view without changing saved defaults. + app.open_path_with_display(path, pdfcraft_ui_egui::canvas::PageLayout::TwoUp, false); + } else { + app.open_path(path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Arguments { + Arguments::parse(args.iter().map(|s| s.to_string())) + } + + #[test] + fn standalone_flag_keeps_files_in_either_position() { + for args in + [["--two-paged", "first.pdf", "second.pdf"], ["first.pdf", "--two-paged", "second.pdf"], ["first.pdf", "second.pdf", "--two-paged"]] + { + let parsed = parse(&args); + assert!(parsed.two_paged); + assert_eq!(parsed.files, ["first.pdf", "second.pdf"]); + assert!(parsed.options.is_empty()); + assert!(!parsed.may_hand_off()); + } + let empty = parse(&["--two-paged"]); + assert!(empty.two_paged && empty.files.is_empty()); + let normal = parse(&["first.pdf", "second.pdf"]); + assert!(!normal.two_paged); + assert!(normal.may_hand_off()); + for args in [ + &["--new-window", "first.pdf"][..], + &["--layout", "two-page", "first.pdf"], + &["--control", "control.json", "first.pdf"], + &["--create-images", "first.png"], + ] { + assert!(!parse(args).may_hand_off()); + } + } + + #[test] + fn existing_layout_options_are_preserved_in_either_order() { + for args in [["--two-paged", "--layout", "single", "file.pdf"], ["--layout", "single", "--two-paged", "file.pdf"]] { + let parsed = parse(&args); + assert!(parsed.two_paged); + assert_eq!(parsed.files, ["file.pdf"]); + assert_eq!(parsed.options, [("layout".into(), "single".into())]); + } + let parsed = parse(&["--two-paged", "--default-layout", "two-up", "file.pdf"]); + assert_eq!(parsed.options, [("default-layout".into(), "two-up".into())]); + } + + #[test] + fn newer_options_and_version_are_preserved() { + let parsed = parse(&["--two-paged", "--new-window", "--grid-zoom", "150", "--default-mode", "read", "file:///tmp/a%20b.pdf"]); + assert!(parsed.two_paged && parsed.new_window); + assert_eq!(parsed.files, ["/tmp/a b.pdf"]); + assert_eq!(parsed.options, [("grid-zoom".into(), "150".into()), ("default-mode".into(), "read".into())]); + assert!(!parsed.may_hand_off()); + let version = parse(&["--two-paged", "--version", "ignored.pdf"]); + assert!(version.version && version.files.is_empty()); + } + + #[test] + fn help_keeps_the_headless_early_exit() { + for flag in ["-h", "--help"] { + let args = parse(&["--two-paged", flag, "ignored.pdf"]); + assert!(args.help && args.files.is_empty()); + } + } + + #[test] + fn malformed_boolean_does_not_eat_a_file_or_option() { + let args = parse(&["--two-paged=false", "first.pdf", "--layout", "single", "second.pdf"]); + assert!(!args.two_paged); + assert_eq!(args.files, ["first.pdf", "second.pdf"]); + assert_eq!(args.options, [("layout".into(), "single".into())]); + } + + #[test] + fn startup_override_is_per_file_and_does_not_persist() { + use std::io::Write as _; + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("pdfcraft-two-paged-{}-{nonce}.pdf", std::process::id())); + let mut file = std::fs::OpenOptions::new().create_new(true).write(true).open(&path).unwrap(); + file.write_all(include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap(); + drop(file); + let second_path = path.with_extension("second.pdf"); + std::fs::copy(&path, &second_path).unwrap(); + let text = path.to_str().unwrap(); + let second = second_path.to_str().unwrap(); + for enabled in [false, true] { + let mut app = PdfCraftApp::new(); + app.set_option("default-layout", "single").unwrap(); + app.open_bytes("restored.pdf", None, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf").to_vec()).unwrap(); + let restored = app.views[0].id; + open_files(&mut app, &[text.into(), second.into()], enabled); + assert_eq!(app.views.len(), 3); + assert_eq!(app.views[0].id, restored); + assert_eq!(app.views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::Single); + for view in app.views.iter().skip(1) { + assert_eq!( + view.layout, + if enabled { pdfcraft_ui_egui::canvas::PageLayout::TwoUp } else { pdfcraft_ui_egui::canvas::PageLayout::Single } + ); + assert!(!view.continuous()); + } + let persisted: serde_json::Value = serde_json::from_str(&app.persist()).unwrap(); + assert_eq!(persisted["default_layout"], "single"); + assert!(persisted.get("two_paged").is_none()); + // Existing --layout applies after the flag to the active document only. + if enabled { + app.set_launch_layout("continuous").unwrap(); + } else { + app.set_option("layout", "continuous").unwrap(); + } + assert_eq!(app.views[2].layout, pdfcraft_ui_egui::canvas::PageLayout::Single); + assert!(app.views[2].continuous()); + app.set_option("default-layout", "two-up").unwrap(); + assert_eq!(app.view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::TwoUp); + assert!(app.view_defaults.continuous); + open_files(&mut app, &[], enabled); + assert_eq!(app.views.len(), 3); + } + std::fs::remove_file(path).unwrap(); + std::fs::remove_file(second_path).unwrap(); + } + + #[test] + fn explicit_launch_layout_preserves_argument_order_and_active_file() { + use pdfcraft_ui_egui::canvas::PageLayout; + let path = std::env::temp_dir().join(format!("pdfcraft-explicit-layout-{}.pdf", std::process::id())); + std::fs::write(&path, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap(); + let second_path = path.with_extension("second.pdf"); + std::fs::copy(&path, &second_path).unwrap(); + let names = [path.to_str().unwrap(), second_path.to_str().unwrap()]; + for prefix in [["--two-paged", "--layout", "single"], ["--layout", "single", "--two-paged"]] { + for count in [1, 2] { + let args = Arguments::parse(prefix.into_iter().chain(names.iter().take(count).copied()).map(str::to_owned)); + let mut app = PdfCraftApp::new(); + app.set_option("default-layout", "continuous").unwrap(); + app.open_bytes("restored.pdf", None, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf").to_vec()).unwrap(); + open_files(&mut app, &args.files, args.two_paged); + for (key, value) in args.options { + assert_eq!(key, "layout"); + app.set_launch_layout(&value).unwrap(); + } + assert_eq!(app.active, Some(count)); + assert!(app.views[0].continuous()); + for view in app.views.iter().skip(1).take(count - 1) { + assert_eq!(view.layout, PageLayout::TwoUp); + } + assert_eq!(app.views[count].layout, PageLayout::Single); + assert!(!app.views[count].continuous()); + assert!(app.view_defaults.continuous); + } + } + std::fs::remove_file(path).unwrap(); + std::fs::remove_file(second_path).unwrap(); + } +} + +#[cfg(test)] +mod repeated_file_tests { + use super::*; + + #[test] + fn a_repeated_file_reuses_its_tab_and_receives_the_launch_override() { + use pdfcraft_ui_egui::canvas::PageLayout; + let path = std::env::temp_dir().join(format!("pdfcraft-repeated-startup-{}.pdf", std::process::id())); + std::fs::write(&path, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap(); + let name = path.to_str().unwrap().to_string(); + let mut app = PdfCraftApp::new(); + app.set_option("default-layout", "continuous").unwrap(); + app.open_path(&name); + let original = app.views[0].id; + open_files(&mut app, &[name.clone(), name], true); + assert_eq!(app.views.len(), 1); + assert_eq!(app.views[0].id, original); + assert_eq!(app.views[0].layout, PageLayout::TwoUp); + assert!(!app.views[0].continuous()); + app.set_launch_layout("single").unwrap(); + assert_eq!(app.views[0].layout, PageLayout::Single); + assert!(app.view_defaults.continuous); + std::fs::remove_file(path).unwrap(); + } +} diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md index 10fb2d4cf3..7c17ad0045 100644 --- a/assets/app-icon/README.md +++ b/assets/app-icon/README.md @@ -1,6 +1,6 @@ # PdfCraft app icon -PdfCraft app icon: an engraved lion's head on green +PdfCraft app icon: an engraved lion's head on red **Creature:** a lion, in a frontal head-and-shoulders portrait, mane running off the bottom of the tile. @@ -13,15 +13,14 @@ a full-bleed colour field, no frame or roundel, the animal looking at the viewer |---|---|---| | Ink | `#0b0b0c` | line work and the figure's contour | | Paper | `#efe9dc` | the figure (the lion's silhouette) | -| PdfCraft green (app colour) | `#12a58a` | the full-bleed field | +| PdfCraft red (app colour) | `#b70228` | the full-bleed field | **Tile:** `viewBox="0 0 512 512"`, a rounded square with `rx=112` that clips everything. Windows and Linux icons use the full-bleed tile. macOS icons put it on Apple's grid (an 824 px body centred on a transparent 1024 px canvas). **Provenance:** the project owner's original drawing, made in ArtCraft (2880 px, keyed to the palette), then -vectorised with craftrules `assets/logo-options/_tools/vectorize_tile.py` (potrace; no filtering or -warping). The source drawing is kept in craftrules at `assets/app-icons/pdfcraft/source.png`, not here. +vectorised (potrace; no filtering or warping). Licence: [LICENSE.txt](LICENSE.txt) (`MIT OR Apache-2.0`, like the repo). ## Files @@ -33,7 +32,7 @@ Licence: [LICENSE.txt](LICENSE.txt) (`MIT OR Apache-2.0`, like the repo). | `pdfcraft-1024.png` | 1024 px on Apple's grid; also the runtime Dock icon on macOS | | `pdfcraft.icns` | macOS icon (16–1024 px) | | `pdfcraft.ico` | Windows icon (16–256 px), embedded in `pdfcraft.exe` by `apps/pdfcraft/build.rs` | -| `pdfcraft-document.svg` | Windows PDF file icon master: a Lucide document outline, green badge and outlined PDF lettering; no font dependency | +| `pdfcraft-document.svg` | Windows PDF file icon master: a Lucide document outline, red badge and outlined PDF lettering; no font dependency | | `pdfcraft-document.ico` | Windows PDF file icon (16–256 px), embedded as resource 2 in `pdfcraft.exe` | | `hicolor/x/apps/ai.storyteller.pdfcraft.png` | Linux hicolor theme, 16–512 px; the 256 px one is the runtime icon on Windows and Linux | | `hicolor/scalable/apps/ai.storyteller.pdfcraft.svg` | Linux scalable icon (copy of the master) | diff --git a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png index 20f4ca4393..b4ac96bc47 100644 Binary files a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png index be76e69b86..c3f4315212 100644 Binary files a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png index a00f8f9501..5ae21c7ded 100644 Binary files a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png index be413a715a..766624ec8c 100644 Binary files a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png index f9e2ef2b6e..ab6e8e9312 100644 Binary files a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png index 73971ee5f9..55256b293d 100644 Binary files a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png index 1d9ff4d11c..eb3699f9b8 100644 Binary files a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png index 14444f01bc..9bba411e39 100644 Binary files a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png and b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png differ diff --git a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg index 3a1de9f076..5a8017ec4f 100644 --- a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg +++ b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.pdfcraft.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/app-icon/pdfcraft-1024.png b/assets/app-icon/pdfcraft-1024.png index 3c79b60279..d6cf213765 100644 Binary files a/assets/app-icon/pdfcraft-1024.png and b/assets/app-icon/pdfcraft-1024.png differ diff --git a/assets/app-icon/pdfcraft-document.ico b/assets/app-icon/pdfcraft-document.ico index 79967a800a..ec1ea4568e 100644 Binary files a/assets/app-icon/pdfcraft-document.ico and b/assets/app-icon/pdfcraft-document.ico differ diff --git a/assets/app-icon/pdfcraft-document.svg b/assets/app-icon/pdfcraft-document.svg index 603a740157..b9d2fbf3c9 100644 --- a/assets/app-icon/pdfcraft-document.svg +++ b/assets/app-icon/pdfcraft-document.svg @@ -4,10 +4,10 @@ - - + + - + diff --git a/assets/app-icon/pdfcraft-small.svg b/assets/app-icon/pdfcraft-small.svg index fb35ea2912..a8d6517b54 100644 --- a/assets/app-icon/pdfcraft-small.svg +++ b/assets/app-icon/pdfcraft-small.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/app-icon/pdfcraft.icns b/assets/app-icon/pdfcraft.icns index 08369d5dd0..c41d1d10f0 100644 Binary files a/assets/app-icon/pdfcraft.icns and b/assets/app-icon/pdfcraft.icns differ diff --git a/assets/app-icon/pdfcraft.ico b/assets/app-icon/pdfcraft.ico index 6e1971d4a5..eedf34883a 100644 Binary files a/assets/app-icon/pdfcraft.ico and b/assets/app-icon/pdfcraft.ico differ diff --git a/assets/app-icon/pdfcraft.svg b/assets/app-icon/pdfcraft.svg index 3a1de9f076..5a8017ec4f 100644 --- a/assets/app-icon/pdfcraft.svg +++ b/assets/app-icon/pdfcraft.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/contributors/people.toml b/contributors/people.toml index dd75a07314..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" @@ -180,3 +177,6 @@ sync_github_name = true [people.dotCooCoo] real_name = "Robert Lee" sync_github_name = true + +[people.dylanmtaylor] +sync_github_name = true diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs index 41c749ed3c..0fa31ccbb1 100644 --- a/crates/a11y/src/headings.rs +++ b/crates/a11y/src/headings.rs @@ -188,7 +188,10 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { // Where each id's last glyph ended, in user space. let mut ends: HashMap = HashMap::new(); let mut gs = TextState::default(); - let mut saved: Vec = Vec::new(); + // With each saved state, the text and text line matrices: inside a text object `q`/`Q` + // save and restore them too (ISO 32000-2 §9.4.1, as corrected by the PDF Association's + // errata, issue 368), as the renderer does. + let mut saved: Vec<(TextState, Matrix, Matrix)> = Vec::new(); let mut unsaved = 0usize; let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); for op in pdfcraft_content::parse(&page_content(doc, page)).ops { @@ -209,12 +212,12 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { b"EMC" => { stack.pop(); } - b"q" if saved.len() < MAX_SAVED => saved.push(gs.clone()), + b"q" if saved.len() < MAX_SAVED => saved.push((gs.clone(), tm, tlm)), b"q" => unsaved += 1, b"Q" if unsaved > 0 => unsaved -= 1, b"Q" => { if let Some(saved) = saved.pop() { - gs = saved; + (gs, tm, tlm) = saved; } } b"cm" => { diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index a6dcdba199..a70ffbf385 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1235,6 +1235,19 @@ fn headings_drawn_glyph_by_glyph_keep_their_words() { ); } +/// Inside a text object `q`/`Q` save and restore the text matrices (ISO 32000-2 §9.4.1, as +/// corrected by the PDF Association's errata, issue 368), as the renderer does: a word whose +/// glyphs continue after a `Q` stays one word. +#[test] +fn q_inside_a_text_object_restores_the_text_matrices() { + let doc = heading_page(&[ + "BT /F1 20 Tf 72 700 Td (Chap) Tj q 0 -100 Td Q (ter One) Tj ET".into(), + "BT /F1 20 Tf 72 650 Td (Chap) Tj q 1 0 0 1 300 300 Tm Q (ter Two) Tj ET".into(), + ]); + let titles: Vec = headings(&doc).into_iter().map(|h| h.title).collect(); + assert_eq!(titles, ["Chapter One", "Chapter Two"]); +} + #[test] fn an_untagged_document_has_no_headings() { let mut objs = headings_doc(); diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index e9e0359ce0..7bd0894c66 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -33,11 +33,13 @@ pub use info::{Props, Summary, is_comment_subtype, props, set_info, summaries}; #[cfg(test)] use lookup::{ANNOTS_ENTRIES_COPIED, PageWalk, annot_dict, annot_ref}; use lookup::{annots, page_ref}; -pub use lookup::{has_visible_fill_sign, is_fill_sign, isolate_annotation, page_refs, picture_rotation, set_annots, signature_image}; +pub use lookup::{ + has_visible_fill_sign, is_fill_sign, isolate_annotation, isolate_annotations, page_refs, picture_rotation, set_annots, signature_image, +}; pub(crate) use modify::n; pub use modify::{ - add_reply, add_text_replacement, delete_annotation, erase_ink, fillable, goes_with, move_annotation, set_contents, set_fill, set_font_size, - set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups, + add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents, + set_fill, set_font_size, set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups, }; pub use types::{ AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape, diff --git a/crates/annot/src/lookup.rs b/crates/annot/src/lookup.rs index c81ec78ef4..c7a0fd9831 100644 --- a/crates/annot/src/lookup.rs +++ b/crates/annot/src/lookup.rs @@ -226,16 +226,33 @@ pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result Result { + isolate_annotations(doc, page, &[index]) +} + +/// [`isolate_annotation`] for several annotations of one page: those of them with a normal +/// appearance stay, in their order on the page. `false`, with `doc` unchanged, when none has one. +/// An index that isn't one of the page's annotations is an error even when the others are, and +/// leaves `doc` unchanged too. +pub fn isolate_annotations(doc: &mut Document, page: usize, indices: &[usize]) -> Result { let p = page_ref(doc, page)?; - let entry = annots(doc, p).get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?; - let has_normal = { - let obj = doc.resolve(&entry); - obj.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) - }; - if !has_normal { + let list = annots(doc, p); + let mut kept = Vec::new(); + for (index, entry) in list.iter().enumerate() { + if !indices.contains(&index) { + continue; + } + let obj = doc.resolve(entry); + if obj.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) { + kept.push(entry.clone()); + } + } + if let Some(&index) = indices.iter().find(|&&i| i >= list.len()) { + return Err(AnnotError::NoSuchAnnotation { page, index }); + } + if kept.is_empty() { return Ok(false); } - set_annots(doc, p, vec![entry])?; + set_annots(doc, p, kept)?; doc.update_dict(p, |d| { d.remove(b"Contents"); })?; diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index fc81348075..2e3ae0b7d0 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -70,20 +70,53 @@ pub(crate) fn touch(d: &mut Dict, meta: &Meta) { } /// Delete a comment together with its pop-up and its replies (and theirs), as Acrobat does. +/// Refused while it, or a reply that would go with it, is locked. pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> { - let p = page_ref(doc, page)?; - let list = annots(doc, p); - let target = list.get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?; + deletable(doc, page, index)?; + lift_annotation(doc, page, index) +} + +/// Whether [`delete_annotation`] deletes the annotation at `(page, index)`, or why not, without +/// changing anything: what a Delete command asks before it is offered. It is refused while the +/// annotation is locked, or a reply that would go with it is. +pub fn deletable(doc: &Document, page: usize, index: usize) -> Result<(), AnnotError> { + let list = annots(doc, page_ref(doc, page)?); + let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; if let Some(r) = target.as_ref() { unlocked(doc, r)?; } - // An inline comment has no reference to collect; the pop-up it names still goes. - let seed = target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup"))); - let doomed = with_replies_and_popups(doc, &list, seed); - let kept: Vec = list.into_iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e).collect(); + let doomed = with_replies_and_popups(doc, &list, seed(target)); + // A locked reply can't be deleted on its own, so it doesn't go with the comment or reply it + // answers either. Pop-ups follow their comment. + for (_, e) in list.iter().enumerate().filter(|(i, e)| *i != index && goes_with(e, &doomed)) { + let obj = doc.resolve(e); + if let Some(d) = obj.as_dict().filter(|d| d.name(b"Subtype") != Some(b"Popup".as_slice())) + && annotation_flags(doc, d) & FLAG_LOCKED != 0 + { + return Err(AnnotError::Invalid("a reply to it is locked".into())); + } + } + Ok(()) +} + +/// Take the annotation at `(page, index)` out of its page with its pop-up and replies, as +/// [`delete_annotation`] does but whatever their locks: for drawing the page beneath it on a +/// copy of the document (a stamp or signature being dragged), which changes nothing. +pub fn lift_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> { + let p = page_ref(doc, page)?; + let list = annots(doc, p); + let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + let doomed = with_replies_and_popups(doc, &list, seed(target)); + let kept: Vec = list.iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e.clone()).collect(); Ok(set_annots(doc, p, kept)?) } +/// What deleting the `/Annots` entry `target` starts from: its reference, or for an inline +/// comment (which has none to collect) the pop-up it names, which still goes. +fn seed(target: &Object) -> Option { + target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup"))) +} + /// Whether the `/Annots` entry `entry` goes with `going` (from [`with_replies_and_popups`]): it is /// one of them, or it is written inline (a file can, though the standard asks for references) and /// replies to (`/IRT`) or pops up for (`/Parent`) one of them. An inline entry has no reference diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index eb8d1a0f15..a3dc14e262 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -229,6 +229,34 @@ fn delete_takes_popup_and_reply_chain() { assert_eq!(delete_annotation(&mut doc, 0, 9), Err(AnnotError::NoSuchAnnotation { page: 0, index: 9 })); } +/// A locked reply can't be deleted, so it doesn't go with what it answers either: deleting the +/// reply above it, or the comment, is refused and changes nothing; once it is unlocked they go. +#[test] +fn a_locked_reply_keeps_what_it_answers() { + let mut doc = fixture(); + let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [100.0, 100.0], icon: NoteIcon::Comment }), &meta("n")).unwrap(); + let reply = add_reply(&mut doc, 0, note, "first", "Bob", &meta("r1")).unwrap(); + let answer = add_reply(&mut doc, 0, reply, "locked answer", "Ada", &meta("r2")).unwrap(); + set_locked(&mut doc, 0, answer, true).unwrap(); + let before = list(&doc, 0).len(); + assert_eq!(delete_annotation(&mut doc, 0, reply), Err(AnnotError::Invalid("a reply to it is locked".into()))); + assert_eq!(delete_annotation(&mut doc, 0, note), Err(AnnotError::Invalid("a reply to it is locked".into()))); + assert_eq!(list(&doc, 0).len(), before, "nothing went"); + // Asked first, read-only, as a Delete command does: the same answer, and the answer's own + // lock is its own. + assert_eq!(deletable(&doc, 0, note), Err(AnnotError::Invalid("a reply to it is locked".into()))); + assert_eq!(deletable(&doc, 0, answer), Err(AnnotError::Invalid("the comment is locked".into()))); + // Lifted on a copy, to draw the page beneath, the locks don't count. + let mut copy = doc.clone(); + lift_annotation(&mut copy, 0, note).unwrap(); + assert!(list(©, 0).len() < before, "the thread is lifted off the copy"); + set_locked(&mut doc, 0, answer, false).unwrap(); + assert_eq!(deletable(&doc, 0, reply), Ok(())); + delete_annotation(&mut doc, 0, reply).unwrap(); + let left: Vec = list(&doc, 0).iter().map(|d| text(d, b"Contents")).collect(); + assert!(!left.iter().any(|t| t == "first" || t == "locked answer"), "the reply and its answer went: {left:?}"); +} + /// A reply or pop-up written inline in `/Annots` (a file can, though the standard asks for /// references) goes with the comment it points at, and so does a pop-up an inline reply or an /// inline comment names. An unrelated inline comment stays until it is deleted itself. diff --git a/crates/automation/README.md b/crates/automation/README.md index d17084aa30..7a9de516c0 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -181,6 +181,12 @@ working document unchanged. `content_list` or `object_list`. It removes only that item in a read-only preview and is mutually exclusive with `hide_text_block`. +When the renderer reports a warning for the page, `page_render` and `render_preview` return a +JSON part after the PNG, `{ "page": 1, "warnings": [...] }`, with the sentences +`pdfcraft-cli render` prints: "content past the page's safety budget was skipped" and "a colour +that could not be computed was painted in a fallback colour". A page without warnings returns +the PNG alone. + ## Existing text and document fonts @@ -193,6 +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` @@ -214,6 +233,18 @@ unapplied with a warning. folders. The CLI also exposes it as `pdfcraft-cli batch`. See the [batch guide](../../docs/cli-batch.md) for examples, output protection, errors and limits. +## Editing button export values + +Use `form_set_props` with `field`, `export_value` and optional `widget` +(1-based, default 1) to change a checkbox or radio button export value. +`form_fields` returns `export_values` in widget order. The selected/default +appearance states are preserved; the operation is undoable and survives saving. +Widgets sharing an appearance state keep one export value. Bulk edits are not +supported for this widget-specific property. Empty values, `Off`, conflicting +choices and values over 4096 UTF-8 bytes return an error. + +Example: `form_set_props doc=1 field=agree export_value=Accepted widget=1`. + ## Viewer navigation history A running PdfCraft instance launched with `--control FILE` exposes its per-tab history through @@ -229,3 +260,10 @@ behavior. Back/Forward returns to page tops, does not restore zoom, does not switch documents, and is not saved across restarts. Standalone engine/MCP sessions have no viewport, so they do not have a separate navigation-history tool; use the opt-in UI control channel to navigate the displayed document. + +## Fill & Sign text appearance + +`fill_sign_add` accepts `font_size` (4–300 pt, default 10) and `color` +(`#RRGGBB`, default black) for `text` and `date`. The colour is saved in the +FreeText appearance and survives reopening. Other mark/signature types reject +`color`; their appearance controls are separate. diff --git a/crates/automation/src/a11y/documents.rs b/crates/automation/src/a11y/documents.rs index f2c9f6b77d..e52768d535 100644 --- a/crates/automation/src/a11y/documents.rs +++ b/crates/automation/src/a11y/documents.rs @@ -5,7 +5,7 @@ impl Automation { let path = self.resolve(a.str("path")?, true)?; let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default(); let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext) - .ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html or rtf)")))?; + .ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html, rtf or md)")))?; let export = self.doc_to_copy(a)?.export_office_checked(format); write_atomic(&path, &export.bytes)?; // Text left out to keep a hostile page bounded is said, not silent (#3120). diff --git a/crates/automation/src/batch.rs b/crates/automation/src/batch.rs index 47cf714ec9..decb18d2b7 100644 --- a/crates/automation/src/batch.rs +++ b/crates/automation/src/batch.rs @@ -17,6 +17,7 @@ const MAX_INPUT: u64 = 512 * 1024 * 1024; struct Recipe<'a> { steps: Vec<&'a str>, images: optimize::ImageSettings, + mono: optimize::MonoSettings, ocr: ocr::OcrSettings, } @@ -38,12 +39,17 @@ impl<'a> Recipe<'a> { } } let mut images = optimize::ImageSettings::REDUCE; + let mut mono = optimize::MonoSettings::REDUCE; if let Some(ppi) = args.opt_num("ppi")? { if !(9.0..=1600.0).contains(&ppi) { return Err(Args::wrong("ppi", "9 to 1600")); } images.target_ppi = ppi; images.above_ppi = ppi * 1.5; + // Black-and-white images keep Reduce File Size's higher resolution, at which text + // stays legible, and are never taken below a higher `ppi` asked for. + mono.target_ppi = mono.target_ppi.max(ppi); + mono.above_ppi = mono.target_ppi * 1.5; } if let Some(q) = args.opt_int("quality")? { if !(1..=100).contains(&q) { @@ -54,7 +60,7 @@ impl<'a> Recipe<'a> { if args.opt_num("dpi")?.is_some_and(|dpi| !(72.0..=600.0).contains(&dpi)) { return Err(Args::wrong("dpi", "72 to 600")); } - Ok(Self { steps, images, ocr: auto.ocr_settings(args)? }) + Ok(Self { steps, images, mono, ocr: auto.ocr_settings(args)? }) } fn accepts(&self, path: &Path) -> bool { @@ -88,7 +94,7 @@ impl<'a> Recipe<'a> { } "optimize" => { let id = session.open(name, None, bytes.clone(), None).map_err(failed)?; - let settings = optimize::Settings { color: self.images, gray: self.images, ..Default::default() }; + let settings = optimize::Settings { color: self.images, gray: self.images, mono: self.mono, ..Default::default() }; let (candidate, _) = session.optimized_bytes(id, &settings, &[])?; session.close(id); if candidate.len() < bytes.len() { diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 0150001096..f87fd0b58e 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -502,6 +502,10 @@ impl Automation { if sensitivity.is_some() && !scan { return Err(ToolError::InvalidArgs("sensitivity is only for scan: true".into())); } + let requested_color = a.color("color")?; + if requested_color.is_some() && !matches!(kind, "text" | "date") { + return Err(ToolError::InvalidArgs("color is only for type text or date".into())); + } let requested_size = a.opt_num("font_size")?; if requested_size.is_some() && !matches!(kind, "text" | "date") { return Err(ToolError::InvalidArgs("font_size is only for type text or date".into())); @@ -579,7 +583,10 @@ impl Automation { (Shape::Mark { rect: mark.rect_at(at, i64::from(info.rotation)), mark }, String::new()) } }; - let style = Style::default_for(&shape); + let mut style = Style::default_for(&shape); + if let Some(color) = requested_color { + style.color = color; + } self.apply(a, Edit::AddAnnotation(NewAnnotation { page, shape, style, contents, author })) } diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index c860298d98..7518072d1c 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -53,13 +53,13 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> { t.color = parse_color(c)?; } if let Some(al) = a.opt_str("align")? { - t.align = match al { - "left" => TextAlign::Left, - "center" | "centre" => TextAlign::Center, - "right" => TextAlign::Right, - "justify" => TextAlign::Justify, - other => return Err(bad(format!("unknown align {other:?}"))), - }; + t.align = parse_align(al)?; + // The whole box: no paragraph keeps an alignment of its own unless `aligns` gives it one. + t.aligns.clear(); + } + if let Some(aligns) = opt_aligns(a)? { + let count = pdfcraft_engine::paragraph_align::count(&t.text); + t.aligns = pdfcraft_engine::paragraph_align::tidy(aligns.into_iter().take(count).collect(), t.align); } if let Some(c) = a.opt_num("char_spacing")? { if !(-ADDED_CHAR_SPACING_LIMIT..=ADDED_CHAR_SPACING_LIMIT).contains(&c) { @@ -70,6 +70,37 @@ fn style(a: &Args, t: &mut AddedText) -> Result<()> { Ok(()) } +pub(crate) fn parse_align(al: &str) -> Result { + Ok(match al { + "left" => TextAlign::Left, + "center" | "centre" => TextAlign::Center, + "right" => TextAlign::Right, + "justify" => TextAlign::Justify, + other => return Err(bad(format!("unknown align {other:?}"))), + }) +} + +/// Most entries `aligns` takes: one per paragraph, and no text has this many. +const MAX_ALIGNS: usize = 4096; + +/// `aligns`: each paragraph's (line's) own alignment, or null where it follows `align`. +pub(crate) fn opt_aligns(a: &Args) -> Result>>> { + let Some(v) = a.get("aligns") else { return Ok(None) }; + let items = v.as_array().ok_or_else(|| bad("aligns must be an array of left, center, right, justify or null"))?; + if items.len() > MAX_ALIGNS { + return Err(bad(format!("aligns takes at most {MAX_ALIGNS} entries"))); + } + items + .iter() + .map(|v| match v { + Value::Null => Ok(None), + Value::String(s) => parse_align(s).map(Some), + _ => Err(bad("each entry of aligns is left, center, right, justify or null")), + }) + .collect::>>() + .map(Some) +} + impl Automation { pub(crate) fn content_list(&self, a: &Args) -> Result { let doc = self.doc(a)?; @@ -160,6 +191,8 @@ impl Automation { match &mut content { AddedContent::Text(t) => { if let Some(s) = a.opt_str("text")? { + // Each paragraph keeps its own alignment through the retyping. + t.aligns = pdfcraft_engine::paragraph_align::remap(&t.text, s, &t.aligns); t.text = s.to_string(); } style(a, t)?; @@ -171,8 +204,8 @@ impl Automation { if a.get("text").is_some() { return Err(bad("images have no text")); } - if ["font", "size", "bold", "italic", "color", "align", "char_spacing"].iter().any(|k| a.get(k).is_some()) { - return Err(bad("font, size, bold, italic, color, align and char_spacing apply to text")); + if ["font", "size", "bold", "italic", "color", "align", "aligns", "char_spacing"].iter().any(|k| a.get(k).is_some()) { + return Err(bad("font, size, bold, italic, color, align, aligns and char_spacing apply to text")); } if let Some(deg) = a.opt_int("rotate")? { if deg.rem_euclid(90) != 0 { diff --git a/crates/automation/src/conventions.rs b/crates/automation/src/conventions.rs index ac670109fb..a84c6488a4 100644 --- a/crates/automation/src/conventions.rs +++ b/crates/automation/src/conventions.rs @@ -1,7 +1,7 @@ //! Shared command/inspection/preview entry points, using the existing confined tool path. use serde_json::{Value, json}; -use crate::{Args, Automation, Content, Result, ToolError, encode_png, failed, info, tools}; +use crate::{Args, Automation, Content, Result, ToolError, failed, info, rendered_content, tools}; use pdfcraft_engine::commands; use pdfcraft_render::{RenderRequest, RequestKind}; @@ -108,7 +108,8 @@ impl Automation { Ok(json!({"documents":self.session.docs().iter().map(info).collect::>()})) } - pub(crate) fn render_preview(&mut self, args: &Args) -> Result { + /// A bounded PNG of the page, with its render warnings after it as `page_render` gives them. + pub(crate) fn render_preview(&mut self, args: &Args) -> Result> { let id = if args.opt_int("doc")?.is_some() { self.doc(args)?.id } else { @@ -134,7 +135,6 @@ impl Automation { if let Some(error) = out.error { return Err(failed(error)); } - let data = encode_png(out.width, out.height, &out.rgba)?; - Ok(Content::Png { data, width: out.width, height: out.height }) + rendered_content(&out, page) } } diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index b136076bc8..8bc23ff280 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -1,7 +1,7 @@ use super::error::{Result, ToolError, failed}; use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary, text_style_levels}; use super::paths::{read_input, write_atomic}; -use super::{Arc, Automation, Edit, Value, block_overlapping, comments, json, tools}; +use super::{Arc, Automation, Edit, Value, block_overlapping, comments, content, json, tools}; /// One piece of a tool's result. #[derive(Clone, Debug, PartialEq)] @@ -12,6 +12,35 @@ pub enum Content { Png { data: Vec, width: u32, height: u32 }, } +/// `image_edit` `transform`: a matrix `[a b c d e f]` acting on top-left-origin points (a point +/// moves to `(a·x + c·y + e, b·x + d·y + f)`) → the same motion in PDF user space, which is +/// what `ImageEdit::Transform` applies after the image's placement. Both spaces are related by +/// an affine map, so three corners of the crop box pin the result down. +fn view_transform_to_user(info: &pdfcraft_render::PageInfo, m: [f64; 6]) -> Result<[f64; 6]> { + let [a, b, c, d, e, f] = m; + let det = a * d - b * c; + if !m.iter().all(|v| v.is_finite()) || !det.is_finite() || det.abs() < 1e-9 { + return Err(ToolError::InvalidArgs("transform needs a finite matrix that keeps an area (a·d − b·c must not be 0)".into())); + } + let moved = |x: f32, y: f32| { + let [vx, vy] = info.user_to_view(x, y).map(f64::from); + info.view_to_user((a * vx + c * vy + e) as f32, (b * vx + d * vy + f) as f32).map(f64::from) + }; + let [x0, y0, x1, y1] = info.crop; + let (w, h) = (f64::from(x1 - x0), f64::from(y1 - y0)); + if w.abs() < 1e-3 || h.abs() < 1e-3 { + return Err(ToolError::InvalidArgs("the page has no area to transform in".into())); + } + let (o, px, py) = (moved(x0, y0), moved(x1, y0), moved(x0, y1)); + let (ua, ub, uc, ud) = ((px[0] - o[0]) / w, (px[1] - o[1]) / w, (py[0] - o[0]) / h, (py[1] - o[1]) / h); + let (x0, y0) = (f64::from(x0), f64::from(y0)); + let user = [ua, ub, uc, ud, o[0] - ua * x0 - uc * y0, o[1] - ub * x0 - ud * y0]; + if !user.iter().all(|v| v.is_finite()) { + return Err(ToolError::InvalidArgs("the transform moves the image too far".into())); + } + Ok(user) +} + impl Automation { /// Run the tool `name` with JSON `args` (an object; `null` means no arguments). pub fn call(&mut self, name: &str, args: &Value) -> Result> { @@ -38,7 +67,7 @@ impl Automation { let edit = Edit::SetInfo { key: a.str("key")?.into(), value: a.str("value")?.into() }; self.apply(&a, edit)? } - "page_render" => return self.page_render(&a).map(|c| vec![c]), + "page_render" => return self.page_render(&a), "comment_image_preview" => return self.comment_image_preview(&a), "text_extract" => self.text_extract(&a)?, "text_word_count" => self.text_word_count(&a)?, @@ -142,7 +171,7 @@ impl Automation { "command_run" => return self.command_run(&a), "command_batch" => self.command_batch(&a)?, "doc_inspect" => self.doc_inspect(&a)?, - "render_preview" => return self.render_preview(&a).map(|c| vec![c]), + "render_preview" => return self.render_preview(&a), "page_number" => { use pdfcraft_organize::LabelStyle as L; let n = self.doc(&a)?.info.pages.len(); @@ -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)?; @@ -538,7 +586,15 @@ impl Automation { }; let index = self.paragraph_index(id, page, k, &blocks)?; let block = blocks.get(index).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?; - let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text); + let width = a.opt_num("width")?; + // Explicit row alignments keep the editor's row boundaries; a width-only + // reflow rejoins soft wraps while retaining typed newlines and spaces. + let text = match a.opt_str("text")? { + Some(text) => text.to_owned(), + None if a.get("aligns").is_some() => block.editor_text, + None if width.is_some() => block.reflow_text.clone(), + None => block.text, + }; let mut style = pdfcraft_engine::BlockStyle { ranges: parse_style_ranges(a.get("ranges"))?, resource: a.opt_str("font_resource")?.map(str::to_owned), @@ -548,7 +604,7 @@ impl Automation { line_spacing: a.opt_num("line_spacing")?, char_spacing: a.opt_num("char_spacing")?, scale: a.opt_num("scale")?, - width: a.opt_num("width")?, + width, ..Default::default() }; let (dx, dy) = (a.opt_num("dx")?, a.opt_num("dy")?); @@ -568,13 +624,10 @@ impl Automation { style.color = Some(comments::parse_color(c)?); } if let Some(al) = a.opt_str("align")? { - style.align = Some(match al { - "left" => pdfcraft_engine::TextAlign::Left, - "center" => pdfcraft_engine::TextAlign::Center, - "right" => pdfcraft_engine::TextAlign::Right, - "justify" => pdfcraft_engine::TextAlign::Justify, - other => return Err(ToolError::InvalidArgs(format!("unknown align {other:?}"))), - }); + style.align = Some(content::parse_align(al)?); + } + if let Some(aligns) = content::opt_aligns(&a)? { + style.aligns = aligns; } // Where the paragraph is drawn after the edit: moved by dx, dy (up is +dy). let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); @@ -619,6 +672,7 @@ impl Automation { } out } + "text_script" => self.text_script(&a)?, "doc_audit_space" => { let rows: Vec = self .doc(&a)? @@ -636,6 +690,7 @@ impl Automation { } "doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?, "doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?, + "doc_backdrops" => self.backdrops(&a)?, "doc_unprotect" => { let mut out = self.apply(&a, Edit::RemoveProtection)?; out["security"] = security(self.doc(&a)?); @@ -809,4 +864,52 @@ impl Automation { }; Ok(vec![Content::Json(out)]) } + + /// `text_script`: Format text ▸ Superscript or Subscript on a paragraph, or on a byte range + /// of its editor text, written as `text_edit` writes the paragraph. + fn text_script(&mut self, a: &Args) -> Result { + use pdfcraft_engine::text_script::{Script, toggle}; + let name = a.str("script")?; + let script = match name { + "superscript" => Script::Super, + "subscript" => Script::Sub, + other => return Err(ToolError::InvalidArgs(format!("unknown script {other:?} (superscript, subscript)"))), + }; + let page = self.page(a)?; + let k = a.int("paragraph")?; + let text = { + let doc = self.doc(a)?; + let blocks = doc.text_blocks(page); + let index = self.paragraph_index(doc.id, page, k, &blocks)?; + blocks.get(index).map(|b| b.editor_text.clone()).ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))? + }; + let range = match a.opt_ints("range")? { + None => None, + Some(r) => { + let bounds = match r.as_slice() { + [start, end] => usize::try_from(*start).ok().zip(usize::try_from(*end).ok()), + _ => None, + }; + let range = bounds + .map(|(start, end)| start..end) + .filter(|r| r.start < r.end && r.end <= text.len() && text.is_char_boundary(r.start) && text.is_char_boundary(r.end)); + let Some(range) = range else { + return Err(ToolError::InvalidArgs(format!( + "range must be [start, end]: UTF-8 byte offsets of the paragraph's editor_text (from text_paragraphs) with start < end <= {}, on character boundaries", + text.len() + ))); + }; + Some(range) + } + }; + let (toggled, _) = toggle(&text, range.as_ref(), script) + .ok_or_else(|| ToolError::InvalidArgs(format!("nothing to change: no character of the text has a {name} form")))?; + let args = json!({ "doc": a.get("doc"), "page": a.get("page"), "paragraph": k, "text": toggled }); + let mut out = match self.call("text_edit", &args)?.into_iter().next() { + Some(Content::Json(out)) => out, + _ => json!({}), + }; + out["script"] = json!(name); + Ok(out) + } } diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index 476c451028..859c92da48 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -279,7 +279,8 @@ impl Automation { } pub(crate) fn doc_optimize(&mut self, a: &Args) -> Result { - use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset}; + use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, MonoCompression, Preset}; + const MONO_KEYS: [&str; 4] = ["downsample", "ppi", "above_ppi", "compression"]; let id = self.doc(a)?.id; let before = self.doc(a)?.bytes.len(); let path = self.resolve(a.str("path")?, true)?; @@ -317,6 +318,38 @@ impl Automation { }; settings.color = images("color", settings.color)?; settings.gray = images("gray", settings.gray)?; + if let Some(mono) = a.get("mono") { + let unknown = |c: &Value| ToolError::InvalidArgs(format!("unknown mono compression {c} (ccitt_g4, jbig2, retain)")); + let mono = mono.as_object().ok_or_else(|| { + ToolError::InvalidArgs("mono must be an object: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain }".into()) + })?; + // A misspelled key would otherwise leave the preset's setting in place unnoticed. + if let Some(key) = mono.keys().find(|k| !MONO_KEYS.contains(&k.as_str())) { + return Err(ToolError::InvalidArgs(format!("unknown mono key {key:?} ({})", MONO_KEYS.join(", ")))); + } + let m = &mut settings.mono; + if let Some(d) = mono.get("downsample") { + m.downsample = d.as_bool().ok_or_else(|| ToolError::InvalidArgs(format!("mono downsample must be true or false, not {d}")))?; + } + for (key, ppi) in [("ppi", &mut m.target_ppi), ("above_ppi", &mut m.above_ppi)] { + if let Some(p) = mono.get(key) { + *ppi = p + .as_f64() + .filter(|p| p.is_finite()) + .ok_or_else(|| ToolError::InvalidArgs(format!("mono {key} must be a number, not {p}")))? + .clamp(9.0, 2400.0); + } + } + m.above_ppi = m.above_ppi.max(m.target_ppi); + if let Some(c) = mono.get("compression") { + m.compression = match c.as_str() { + Some("ccitt_g4" | "ccitt" | "g4") => MonoCompression::CcittG4, + Some("jbig2") => MonoCompression::Jbig2, + Some("retain") => MonoCompression::Retain, + _ => return Err(unknown(c)), + }; + } + } for (key, flag) in [ ("merge_fragments", &mut settings.merge_fragments), ("discard_thumbnails", &mut settings.discard_thumbnails), @@ -356,7 +389,9 @@ impl Automation { let pw = self.doc(a).ok().and_then(|d| d.password.as_deref()); let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), pw).ok()?; let root = doc.root()?; - doc.get(root).as_dict()?.name(b"Version").and_then(valid_version).map(str::to_owned) + // Through a reference too, as the optimizer reads it. + let version = doc.get(root).as_dict()?.get(b"Version")?.clone(); + doc.resolve(&version).as_name().and_then(valid_version).map(str::to_owned) }) .flatten(); let header_version = bytes.strip_prefix(b"%PDF-").and_then(|b| b.get(..3)).and_then(valid_version); @@ -416,6 +451,28 @@ impl Automation { Ok(out) } + /// Pages whose own backdrop hides a background (see `pdfcraft_edit::backdrop`). + pub(crate) fn backdrops(&mut self, a: &Args) -> Result { + let n = self.doc(a)?.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let doc = self.doc(a)?; + let found: Vec = pages + .iter() + .zip(doc.page_backdrops(&pages)) + .filter_map(|(p, found)| { + let kind = match found? { + pdfcraft_engine::Backdrop::Fill => "fill", + pdfcraft_engine::Backdrop::Image => "image", + }; + Some(json!({ "page": p + 1, "backdrop": kind })) + }) + .collect(); + Ok(json!({ "pages": found })) + } + pub(crate) fn marks(&mut self, tool: &str, a: &Args) -> Result { use pdfcraft_engine::{Background, HeaderFooter, MarkKind, Watermark}; let n = self.doc(a)?.info.pages.len(); @@ -483,6 +540,7 @@ impl Automation { color: color("color", [1.0; 3])?, opacity: a.opt_num("opacity")?.unwrap_or(1.0), scale: a.opt_num("scale")?.unwrap_or(1.0).clamp(0.01, 1.0), + replace_backdrop: a.opt_bool("replace_backdrop")?.unwrap_or(true), ..Background::default() }, replace, diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 1c1e3c3371..adf0bd0884 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -1,7 +1,7 @@ //! Form tools: list fields, fill them (several at once, one undo step), clear the form, and //! prepare a form (add fields one at a time or as a table, change and delete them). -use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, Validate}; +use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, NOTATION_NOT_STORABLE, Validate, notation_storable}; use pdfcraft_engine::{CalculationIssue, Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags}; use serde_json::{Value, json}; @@ -94,13 +94,20 @@ fn look_arg(v: &Value) -> Result { }) } -/// `{"min": 0, "max": 100}` or `"none"`. +/// `{"min": 0, "max": 100}` or `"none"`. A null bound is no bound, as `form_fields` shows it. fn validate_arg(v: &Value) -> Result { if v.as_str() == Some("none") { return Ok(Validate::None); } let o = v.as_object().ok_or_else(|| bad("validate must be {\"min\": …, \"max\": …} or \"none\""))?; - let (min, max) = (o.get("min").and_then(Value::as_f64), o.get("max").and_then(Value::as_f64)); + if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "min" | "max")) { + return Err(bad(format!("unknown key {k:?} in validate (min, max)"))); + } + let bound = |k: &str| match o.get(k) { + None | Some(Value::Null) => Ok(None), + Some(x) => x.as_f64().map(Some).ok_or_else(|| bad(format!("validate {k} must be a number"))), + }; + let (min, max) = (bound("min")?, bound("max")?); if min.is_none() && max.is_none() { return Err(bad("validate needs min, max or both")); } @@ -113,25 +120,67 @@ fn calculate_arg(v: &Value) -> Result { return Ok(Calculate::None); } let o = v.as_object().ok_or_else(|| bad("calculate must be {\"op\": …, \"fields\": […]}, {\"notation\": …} or \"none\""))?; - if let Some(n) = o.get("notation").and_then(Value::as_str) { + // Before anything else is read, so a script given beside a notation isn't dropped unnoticed. + if o.contains_key("script") { + return Err(bad("a custom calculate script is set with form_set_script (event: calculate)")); + } + if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "op" | "fields" | "notation")) { + return Err(bad(format!("unknown key {k:?} in calculate (op and fields, or notation)"))); + } + // A null is no value, as for the tool's own arguments. + let get = |k: &str| o.get(k).filter(|v| !v.is_null()); + if let Some(n) = get("notation") { + if get("op").is_some() || get("fields").is_some() { + return Err(bad("calculate takes a notation, or op and fields, not both")); + } + let n = n.as_str().ok_or_else(|| bad("notation must be a string such as \"Price * Qty\""))?; + // Refused rather than stored as a different formula. + if !notation_storable(n) { + return Err(bad(NOTATION_NOT_STORABLE)); + } return Ok(Calculate::Notation(n.to_string())); } - let op = match o.get("op").and_then(Value::as_str).unwrap_or("") { - "sum" => CalcOp::Sum, - "product" => CalcOp::Product, - "average" => CalcOp::Average, - "min" | "minimum" => CalcOp::Minimum, - "max" | "maximum" => CalcOp::Maximum, - x => return Err(bad(format!("unknown op {x:?} (sum, product, average, min, max)"))), + let op = get("op").ok_or_else(|| bad("calculate needs op and fields, or a notation"))?; + let op = match op.as_str().ok_or_else(|| bad("op must be one of sum, product, average, min, max"))? { + "minimum" => CalcOp::Minimum, + "maximum" => CalcOp::Maximum, + x => { + CalcOp::ALL.into_iter().find(|&op| op_name(op) == x).ok_or_else(|| bad(format!("unknown op {x:?} (sum, product, average, min, max)")))? + } }; - let fields: Vec = - o.get("fields").and_then(Value::as_array).map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_owned)).collect()).unwrap_or_default(); + let fields = get("fields").and_then(Value::as_array).ok_or_else(|| bad("calculate fields must be a list of field names"))?; + let fields: Vec = fields + .iter() + .map(|x| x.as_str().map(str::to_owned).ok_or_else(|| bad("calculate fields must be field names (strings)"))) + .collect::>()?; if fields.is_empty() { return Err(bad("calculate needs fields")); } Ok(Calculate::Simple { op, fields }) } +/// An operation's name in `calculate`, as `form_set_props` takes it and the listings show it. +fn op_name(op: CalcOp) -> &'static str { + match op { + CalcOp::Sum => "sum", + CalcOp::Product => "product", + CalcOp::Average => "average", + CalcOp::Minimum => "min", + CalcOp::Maximum => "max", + } +} + +/// A field's calculation as `form_fields` and `form_calculation_order` show it, in +/// `form_set_props`'s shape, or `{"script": …}` for a custom Calculate script; `None` without one. +fn calculate_json(f: &FormField) -> Option { + match (&f.actions.calculate, &f.actions.scripts.calculate) { + (Calculate::Simple { op, fields }, _) => Some(json!({ "op": op_name(*op), "fields": fields })), + (Calculate::Notation(n), _) => Some(json!({ "notation": n })), + (Calculate::None, Some(js)) => Some(json!({ "script": js })), + (Calculate::None, None) => None, + } +} + fn format_json(f: &Format) -> Value { match f { Format::None => Value::Null, @@ -220,18 +269,15 @@ impl Automation { if let Validate::Range { min, max } = &f.actions.validate { o.insert("validate".into(), json!({ "min": min, "max": max })); } - match &f.actions.calculate { - Calculate::Simple { op, fields } => { - o.insert("calculate".into(), json!({ "op": op.code().to_lowercase(), "fields": fields })); - } - Calculate::Notation(n) => { - o.insert("calculate".into(), json!({ "notation": n })); - } - Calculate::None => {} + if let Some(c) = calculate_json(f) { + o.insert("calculate".into(), c); } if !f.actions.unsupported.is_empty() { o.insert("unsupported_scripts".into(), json!(f.actions.unsupported)); } + if matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio) { + o.insert("export_values".into(), json!((0..f.widgets.len()).map(|i| f.export_of(i)).collect::>())); + } match f.kind { FormFieldKind::PushButton => { o.insert("caption".into(), json!(w.and_then(|w| w.caption.as_deref()).unwrap_or_default())); @@ -418,8 +464,8 @@ impl Automation { } pub(crate) fn form_set_props(&mut self, a: &Args) -> Result { - if a.get("widget").is_some() && a.get("caption").is_none() { - return Err(bad("widget requires caption")); + if a.get("widget").is_some() && a.get("caption").is_none() && a.get("export_value").is_none() { + return Err(bad("widget requires caption or export_value")); } let single = a.opt_str("field")?; let bulk = a.get("fields").is_some(); @@ -438,7 +484,7 @@ impl Automation { return Err(bad(format!("field {name:?} is listed more than once"))); } } - for key in ["name", "rect", "rotation", "caption", "widget"] { + for key in ["name", "rect", "rotation", "caption", "export_value", "widget"] { if a.get(key).is_some() { return Err(bad(format!("{key} applies to one field; use field instead of fields"))); } @@ -502,6 +548,15 @@ impl Automation { }; Ok(FieldProps { rect, + export_value: a + .opt_str("export_value")? + .map(|value| -> Result<_> { + let index = a.opt_int("widget")?.unwrap_or(1); + let index = + usize::try_from(index).ok().and_then(|n| n.checked_sub(1)).ok_or_else(|| bad("widget must be a positive 1-based index"))?; + Ok((index, value.to_owned())) + }) + .transpose()?, name: a.opt_str("name")?.map(str::to_owned), tooltip: a.opt_str("tooltip")?.map(str::to_owned), caption: a @@ -653,15 +708,28 @@ impl Automation { }; let order = self.doc(a)?.calculation_order(); let issue = |i: &CalculationIssue| match i { + CalculationIssue::MissingField { field } => json!({ "issue": "missing_field", "field": field }), CalculationIssue::RunsTooEarly { field } => json!({ "issue": "runs_too_early", "field": field }), CalculationIssue::Cycle { fields } => json!({ "issue": "cycle", "fields": fields }), CalculationIssue::NotInOrder => json!({ "issue": "not_in_order" }), + CalculationIssue::BuiltNames => json!({ "issue": "built_names" }), }; + // By field object, not name: two calculated fields can share a name in a malformed form. + let doc = self.doc(a)?; + let by_obj: std::collections::HashMap<_, &FormField> = doc.form.iter().map(|f| (f.obj, f)).collect(); out["calculation_order"] = json!(order.fields.iter().map(|f| f.name.as_str()).collect::>()); out["fields"] = order .fields .iter() - .map(|f| json!({ "name": f.name, "reads": f.reads, "listed": f.listed, "issues": f.issues.iter().map(issue).collect::>() })) + .map(|f| { + json!({ + "name": f.name, + "formula": by_obj.get(&f.obj).and_then(|x| calculate_json(x)), + "reads": f.reads, + "listed": f.listed, + "issues": f.issues.iter().map(issue).collect::>(), + }) + }) .collect(); out["extra"] = json!(order.extra); out["fixable"] = json!(order.fixable()); diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index e1ae0141c4..f1910867c9 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -62,7 +62,7 @@ use error::{Result, failed}; pub(crate) use json::destination; pub use json::view_rect; use json::{Args, info, summary}; -use pages::{DEFAULT_DPI, MAX_DPI, encode_png}; +use pages::{DEFAULT_DPI, MAX_DPI, encode_png, rendered_content}; #[cfg(test)] use pages::{FIND_FIRST_BATCH, FIND_MAX_WARNING_BYTES, FIND_MAX_WARNINGS, extract_parallel}; #[cfg(test)] diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs index 647a72e1d4..8c793be153 100644 --- a/crates/automation/src/pages.rs +++ b/crates/automation/src/pages.rs @@ -65,7 +65,9 @@ impl Automation { Ok(&mut entry.1) } - pub(crate) fn page_render(&mut self, a: &Args) -> Result { + /// The page as a PNG, followed by a JSON part listing the render warnings when there are any, + /// so a caller can tell that the picture is incomplete, as `pdfcraft-cli render` does (#4340). + pub(crate) fn page_render(&mut self, a: &Args) -> Result> { let id = self.doc(a)?.id; let page = self.page(a)?; let dpi = a.opt_num("dpi")?.unwrap_or(DEFAULT_DPI); @@ -98,8 +100,7 @@ impl Automation { if let Some(e) = out.error { return Err(failed(format!("page {} could not be rendered: {e}", page + 1))); } - let data = encode_png(out.width, out.height, &out.rgba)?; - Ok(Content::Png { data, width: out.width, height: out.height }) + rendered_content(&out, page) } /// A page as RGBA pixels, without encoding a PNG. `page` is 1-based and `scale` is pixels @@ -324,6 +325,29 @@ pub(crate) fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Resul pdfcraft_engine::export::encode_png(width, height, premultiplied).map_err(failed) } +/// The output of a page-rendering tool: the PNG of the 0-based `page`, followed by a JSON part +/// listing its render warnings when there are any. Without warnings it is the PNG alone, as it +/// always was. +pub(crate) fn rendered_content(out: &pdfcraft_render::RenderedPage, page: usize) -> Result> { + let data = encode_png(out.width, out.height, &out.rgba)?; + let mut content = vec![Content::Png { data, width: out.width, height: out.height }]; + if !out.warnings.is_empty() { + // Sentences, like the other tools' `warnings`, so command_run can add its own to the list. + let warnings: Vec<&str> = out.warnings.iter().map(|&w| render_warning_text(w)).collect(); + content.push(Content::Json(json!({ "page": page.saturating_add(1), "warnings": warnings }))); + } + Ok(content) +} + +/// What a render warning means, in the words `pdfcraft-cli render` prints for it. +fn render_warning_text(warning: pdfcraft_render::RenderWarning) -> &'static str { + use pdfcraft_render::RenderWarning; + match warning { + RenderWarning::ContentTruncated => "content past the page's safety budget was skipped", + RenderWarning::ColourFallback => "a colour that could not be computed was painted in a fallback colour", + } +} + /// Extract the text of `pages` (0-based) with one renderer per worker thread. pub(crate) fn extract_parallel(bytes: &Arc>, password: Option>, pages: &[usize]) -> PageTexts { let extract = |r: &mut PageRenderer, p: usize| { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 33dfd3a018..ef80287bf3 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -121,7 +121,7 @@ pub fn tools() -> Vec { let save_out = json!({ "type": "string", "description": "File to write. Omit to open the result as a new unsaved document instead." }); let open = json!({ "type": "boolean", "description": "Also open the result as a new document (default: only when out is omitted)." }); vec![ - t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.") + t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi; black-and-white images go to the larger of ppi and 300) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.") .with(schema(json!({ "paths": {"type":"array","items":{"type":"string"},"minItems":1,"maxItems":1000}, "out_dir": {"type":"string"}, @@ -167,7 +167,7 @@ pub fn tools() -> Vec { )), t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.") .with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])), - t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).") + t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600). When the picture is incomplete or approximate (content cut at the page's safety budget, a colour painted in a fallback colour), a JSON part follows the image: { page, warnings: [sentence, ...] }.") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 }, "hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." }, @@ -175,7 +175,7 @@ pub fn tools() -> Vec { }), &["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}}, @@ -391,7 +391,7 @@ pub fn tools() -> Vec { }), &["doc", "from", "to"], )), - t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).") + t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. A calculated field includes calculate: {op, fields} or {notation} as form_set_props takes them, or {script} for a custom Calculate script (set with form_set_script). Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).") .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), t( @@ -475,8 +475,9 @@ pub fn tools() -> Vec { "fields": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 1000, "uniqueItems": true, "description": "Apply the same changes to these fields in one atomic edit. Use either field or fields." }, "name": { "type": "string" }, "tooltip": { "type": "string" }, + "export_value": { "type": "string", "description": "Checkbox/radio export value; preserves appearance states and selection. Requires a single field." }, "caption": { "type": "string", "description": "Normal push-button label; an empty string clears it. Requires a single field." }, - "widget": { "type": "integer", "minimum": 1, "description": "Widget whose caption changes (1-based, default 1). Requires caption. See form_fields.captions." }, + "widget": { "type": "integer", "minimum": 1, "description": "Widget whose caption or export value changes (1-based, default 1). Requires caption or export_value. See form_fields.captions and export_values." }, "read_only": { "type": "boolean" }, "required": { "type": "boolean" }, "multiline": { "type": "boolean" }, @@ -492,7 +493,7 @@ pub fn tools() -> Vec { "rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. Changing between horizontal and vertical swaps the rectangle around its center." }, "appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (0–12 points; 1 thin, 2 medium, 3 thick), style (solid, dashed, beveled, inset, underline), font (helvetica, times, courier)." }, "format": { "description": "Format tab: {\"type\": \"number\", \"decimals\": 2, \"currency\": \"$\", \"separator\": 0-4, \"negative\": 0-3}, {\"type\": \"percent\"}, {\"type\": \"date\"|\"time\", \"pattern\": \"mm/dd/yyyy\"}, {\"type\": \"zip\"|\"zip4\"|\"phone\"|\"ssn\"}, {\"type\": \"mask\", \"mask\": \"AA-9999\"}, or \"none\"." }, - "validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out) or \"none\"." }, + "validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out, or null) or \"none\"." }, "calculate": { "description": "Calculate tab: {\"op\": \"sum\"|\"product\"|\"average\"|\"min\"|\"max\", \"fields\": [\"a\", \"b\"]}, {\"notation\": \"Price * Qty\"} (simplified field notation), or \"none\"." }, }), &["doc"], @@ -502,7 +503,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }), &["doc"], )), - t("form_calculation_order", "Read or set the calculation order", "The calculated fields in the order they calculate (Acrobat: Set Field Calculation Order), each with the fields its formula reads (`reads`; a script's names built at run time aren't known) and its `issues`: runs_too_early (it reads `field`, which calculates after it, so it shows a stale value), cycle (it reads `fields`, which read it back: no order fixes that, a formula has to change) and not_in_order (the PDF's order leaves it out, so other viewers may not calculate it). `extra` lists entries of the PDF's order that calculate nothing. Without a change it only reads. Changes, one at a time: `order` (these fields first, in this order, then the other calculated fields in their order), `field` with `move` earlier|later (one place), or `fix` true (only what must move moves, so every field calculates after the fields it reads, and the extra entries go). A change recalculates the form. Undoable.") + t("form_calculation_order", "Read or set the calculation order", "The calculated fields in the order they calculate (Acrobat: Set Field Calculation Order), each with its `formula` (as form_fields shows calculate), the fields it reads (`reads`; a script's names built at run time aren't known) and its `issues`: missing_field (it reads `field`, which the form doesn't have, so it gets no value there), runs_too_early (it reads `field`, which calculates after it, so it shows a stale value), cycle (it reads `fields`, which read it back: no order fixes that, a formula has to change) and not_in_order (the PDF's order leaves it out, so other viewers may not calculate it) and built_names (its script also reads fields by names it builds at run time, which aren't checked). `extra` lists entries of the PDF's order that calculate nothing. Without a change it only reads. Changes, one at a time: `order` (these fields first, in this order, then the other calculated fields in their order), `field` with `move` earlier|later (one place), or `fix` true (only what must move moves, so every field calculates after the fields it reads, and the extra entries go). A change recalculates the form. Undoable.") .cmd("form.calculation_order") .with(schema( json!({ "doc": doc(), "order": { "type": "array", "items": { "type": "string" } }, "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] }, "fix": { "type": "boolean" } }), @@ -636,6 +637,7 @@ pub fn tools() -> Vec { "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 }, "bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" }, "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, + "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." }, "char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." }, }), &["doc", "page", "text"], @@ -658,6 +660,7 @@ pub fn tools() -> Vec { "font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 }, "bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" }, "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, + "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." }, "char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." }, "rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" }, "crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.95 }, "minItems": 4, "maxItems": 4, "description": "Fractions trimmed: left, bottom, right, top. Left and right together, and bottom and top together, trim at most 0.95." }, @@ -920,7 +923,7 @@ pub fn tools() -> Vec { )), t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.") .ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])), - t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])), + t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies, or one reply (its id, or page and index, from comment_list) with its pop-up and the replies to it; the comment and its other replies stay. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])), t( "doc_protect", "Protect with passwords", @@ -1030,12 +1033,16 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("doc_background", "Add background", "Fill page backgrounds with a colour, or a picture from `file` (an image, or page `file_page` of a PDF) fitted at `scale` (default 1), behind the content. Undoable.") + t("doc_background", "Add background", "Fill page backgrounds with a colour, or a picture from `file` (an image, or page `file_page` of a PDF) fitted at `scale` (default 1), behind the content. A page that paints its own opaque white backdrop would hide it, so that backdrop is replaced unless replace_backdrop is false (removing the background turns it back on where that is still safe, and doc_remove_marks warns about the pages where it isn't); a scanned page's picture still hides it (doc_backdrops lists both). Undoable.") .cmd("edit.background") .with(schema( - json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "file": { "type": "string" }, "file_page": { "type": "integer", "minimum": 1 }, "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" } }), + json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "file": { "type": "string" }, "file_page": { "type": "integer", "minimum": 1 }, "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" }, "replace_backdrop": { "type": "boolean", "description": "Replace a white backdrop the page paints itself, which would hide the background. Default true." } }), &["doc"], )), + t("doc_backdrops", "List page backdrops", "Pages that paint their own backdrop, which hides a background added behind the content: `fill` (an opaque white fill doc_background can replace) or `image` (a page-sized picture, e.g. a scan, that still hides it). Pages are 1-based; pages without one are left out.") + .ro() + .cmd("edit.background") + .with(schema(json!({ "doc": doc(), "pages": pages("to check (default: all)") }), &["doc"])), t("doc_remove_marks", "Remove header & footer, watermark or background", "Remove every header and footer, watermark or background PdfCraft (or a compatible tool) added. Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background"] } }), &["doc", "kind"])), @@ -1188,7 +1195,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }), &["doc", "field", "event"], )), - t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit are listed in truncated_pages, with a warning; the text and images of the forms left out are missing.") + t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit are listed in truncated_pages, with a warning; the text and images of the forms left out are missing.") .cmd("export.docx") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.") @@ -1275,7 +1282,7 @@ pub fn tools() -> Vec { .ro() .cmd("ocr.recognize") .with(schema(json!({}), &[])), - t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2, CCITT, separations) are listed under skipped. Includes unsaved edits.") + t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`_Page__Image_.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2 inside other filters, separations) are listed under skipped. Includes unsaved edits.") .cmd("export.all_images") .with(schema( json!({ @@ -1292,7 +1299,7 @@ pub fn tools() -> Vec { t( "fill_sign_add", "Fill & Sign: type text or place a mark", - "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).", + "Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).", ) .cmd("sign.fill.text") .with(schema( @@ -1303,6 +1310,7 @@ pub fn tools() -> Vec { "at": point(), "text": { "type": "string", "minLength": 1 }, "font_size": { "type": "number", "minimum": 4, "maximum": 300, "description": "For type text or date (default 10 pt)." }, + "color": color(), "path": { "type": "string", "description": "PNG or JPEG for signature/initials; pass either path or text." }, "scan": { "type": "boolean", "description": "With path: scan a photo of a signature on paper (default false)." }, "sensitivity": { "type": "integer", "minimum": 0, "maximum": 100, "description": "With scan: how faint ink may be, 0–100 (default 50)." }, @@ -1333,7 +1341,7 @@ pub fn tools() -> Vec { }), &["from"], )), - t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. If the copy wouldn't be smaller, nothing is written and `written` is false. The open document is unchanged.") + t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: colour and grey images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), black-and-white images averaged down to 300 ppi above 450 ppi and each stored as CCITT Group 4 (or 1-bit Flate, once resampled or packed from more bits) whenever that is smaller, thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. If the copy wouldn't be smaller, nothing is written and `written` is false. The open document is unchanged.") .cmd("optimize.reduce") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), t("doc_initial_view", "Initial view", "Read or change how the document opens (Document Properties ▸ Initial View) and its reading options: navigation (page, bookmarks, pages, attachments, layers), layout (default, single, continuous, two_up, two_up_continuous, two_up_cover, two_up_continuous_cover), magnification (default, actual, fit_page, fit_width, fit_height, fit_visible, or a percentage), page, window options (fit_window, center_window, full_screen, display_title), interface options (hide_menubar, hide_toolbar, hide_window_ui), language and binding (left, right). Only the given ones change; returns the result. Undoable.").with(schema( @@ -1371,17 +1379,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"], @@ -1421,6 +1430,7 @@ pub fn tools() -> Vec { "size": { "type": "number", "exclusiveMinimum": 0 }, "color": { "type": "string", "description": "#rrggbb" }, "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, + "aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." }, "underline": { "type": "boolean" }, "line_spacing": { "type": "number", "description": "Multiple of the font size (1.2 is ordinary)." }, "dx": { "type": "number", "description": "Paragraph only: move right by this many points (negative: left)." }, @@ -1430,11 +1440,19 @@ pub fn tools() -> Vec { "scale": { "type": "number", "description": "Horizontal scale in percent." } }), &["doc", "page"], )), + t("text_script", "Superscript or subscript", "Format text ▸ Superscript or Subscript on one paragraph (paragraph, from text_paragraphs), or on range [start, end): UTF-8 byte offsets of its editor_text. The characters take the Unicode superscript or subscript forms (2 → ², 2 → ₂; the font must have those glyphs, and extracted text contains them); text already in that script goes back to plain characters. Characters Unicode has no such form for (q, a space) stay unchanged and do not determine the selection's script state. Mixed plain and target-script characters normalize to that script, then to plain text on the next call (H₂2 → H₂₂ → H22); a second toggle does not reconstruct the original mixture. Use Undo to restore the exact prior text. The paragraph is then rewritten as text_edit rewrites it, with the same result fields. Refused when no character would change. Undoable. Mathematical minus U+2212 has its own script mapping; ASCII hyphen-minus stays unchanged.") + .cmd("edit.edit_text") + .with(schema( + json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, + "script": { "type": "string", "enum": ["superscript", "subscript"] }, + "range": { "type": "array", "items": { "type": "integer", "minimum": 0 }, "minItems": 2, "maxItems": 2, "description": "UTF-8 byte offsets [start, end) of the paragraph's editor_text; the whole paragraph when omitted." } }), + &["doc", "page", "paragraph", "script"], + )), t("doc_revisions", "List revisions", "List the document's saved revisions (oldest first): each incremental update is one. Returns revision number, where it ends in the file and its size, and which signatures sign exactly that revision.") .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") + t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT and any other becoming Flate). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ @@ -1445,6 +1463,7 @@ pub fn tools() -> Vec { "merge_fragments": { "type": "boolean" }, "color": { "type": "object" }, "gray": { "type": "object" }, + "mono": { "type": "object" }, "discard_thumbnails": { "type": "boolean" }, "discard_alternate_images": { "type": "boolean" }, "discard_tags": { "type": "boolean" }, @@ -1479,7 +1498,7 @@ pub fn tools() -> Vec { .destructive().with(schema(json!({"steps":{"type":"array","items":schema(json!({"id":{"type":"string"},"params":{"type":"object"}}), &["id"])},"stop_on_error":{"type":"boolean"}}), &["steps"])), t("doc_inspect", "Inspect open documents", "With doc, return doc_info; otherwise return information for every open document (empty documents array when none are open).") .ro().with(schema(json!({"doc":doc()}), &[])), - t("render_preview", "Render a preview", "Render a PNG bounded by max_side (default 1024), without changing or saving the document. Page defaults to 1; doc may be omitted when exactly one document is open.") + t("render_preview", "Render a preview", "Render a PNG bounded by max_side (default 1024), without changing or saving the document. Page defaults to 1; doc may be omitted when exactly one document is open. Render warnings follow the image as page_render gives them.") .ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"max_side":{"type":"integer","minimum":1,"maximum":4096}}), &[])), ] } diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 7e55485a2f..0544312cc6 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2226,6 +2226,35 @@ fn nested_replies_stay_in_the_listing() { assert!(replies.contains(&"DIRECT_REPLY") && replies.contains(&"NESTED_REPLY"), "the nested reply is listed: {replies:?}"); } +/// comment_delete on a reply deletes that reply and the replies to it, as the Comments panel's +/// reply menu does: the comment and its other replies stay, and one undo brings it back. +#[test] +fn deleting_a_reply_keeps_its_comment_and_the_other_replies() { + let dir = workdir("delete-reply"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let root = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [40, 40], "contents": "ROOT_NOTE", "author": "A" })); + let root_id = root["comment"]["id"].as_str().unwrap().to_string(); + ok(&mut a, "comment_reply", json!({ "doc": doc, "id": root_id, "text": "WRONG_REPLY", "author": "B" })); + ok(&mut a, "comment_reply", json!({ "doc": doc, "id": root_id, "text": "RIGHT_REPLY", "author": "C" })); + let replies = |a: &mut Automation| -> Vec { + let list = ok(a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["count"], 1, "{list}"); + list["comments"][0]["replies"].as_array().unwrap().clone() + }; + let wrong = replies(&mut a).into_iter().find(|r| r["contents"] == "WRONG_REPLY").unwrap(); + ok(&mut a, "comment_reply", json!({ "doc": doc, "id": wrong["id"], "text": "ANSWER_TO_WRONG", "author": "D" })); + let texts = |a: &mut Automation| -> Vec { replies(a).iter().filter_map(|r| r["contents"].as_str().map(str::to_owned)).collect() }; + assert_eq!(texts(&mut a), ["WRONG_REPLY", "RIGHT_REPLY", "ANSWER_TO_WRONG"]); + + ok(&mut a, "comment_delete", json!({ "doc": doc, "page": wrong["page"], "index": wrong["index"] })); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["comments"][0]["contents"], "ROOT_NOTE", "{list}"); + assert_eq!(texts(&mut a), ["RIGHT_REPLY"], "the reply took the answer to it along"); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Delete comment"); + assert_eq!(texts(&mut a), ["WRONG_REPLY", "RIGHT_REPLY", "ANSWER_TO_WRONG"]); +} + /// Replies thread by their `/IRT` reference, not `/NM`: PyMuPDF names the first comment on every /// page `fitz-A0`. A shared id is refused rather than taken to mean page 1's comment. #[test] @@ -2640,6 +2669,29 @@ fn orphan_form_fields_through_tools() { assert_ne!(v, "Filled by an agent"); } +/// An auto-sized (`0 Tf`) single-line field fills to the size its height gives, as Acrobat +/// draws it, not a 12 pt cap: 30 pt tall, less border and padding, is about 21 pt text. +#[test] +fn auto_sized_text_follows_a_tall_field() { + let objs: Vec<&str> = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 300] >>", // 2 + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", // 3 + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >>", // 4 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (tall) /Rect [10 200 290 230] /P 3 0 R >>", // 5 + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", // 6 + ]; + let dir = workdir("auto-size-tall"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "tall": "Hi" } })); + let found = ok(&mut a, "text_find", json!({ "doc": doc, "query": "Hi" })); + let r = &found["matches"][0]["rects"][0]; + let height = r[3].as_f64().unwrap() - r[1].as_f64().unwrap(); + assert!(height > 16.0, "12 pt text would be about 12 tall, ~21 pt about 21: {height} ({found})"); +} + #[test] fn reversing_pages_through_tools() { let dir = workdir("reverse"); @@ -3234,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() { @@ -3401,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 })); @@ -3420,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"); @@ -3644,6 +3810,38 @@ fn fill_sign_text_font_size_is_saved_and_validated() { )); } +#[test] +fn fill_sign_text_and_date_colours_are_saved_and_validated() { + let dir = workdir("fill-sign-text-colour"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (kind, y) in [("text", 40), ("date", 80)] { + ok( + &mut a, + "fill_sign_add", + json!({ "doc": doc, "page": 1, "type": kind, "at": [20, y], "text": "Blue", "color": "#3366CC", "font_size": 6.5 }), + ); + } + for (kind, color) in [("text", "not-a-colour"), ("signature", "#3366CC"), ("check", "#3366CC")] { + assert!(matches!( + a.call("fill_sign_add", &json!({ "doc": doc, "page": 1, "type": kind, "at": [20, 100], "text": "No", "color": color })), + Err(ToolError::InvalidArgs(_)) + )); + } + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "coloured.pdf" })); + let pdf = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("coloured.pdf")).unwrap())).unwrap(); + let page = pdfcraft_model::pages(&pdf).remove(0); + let annotations = pdf.resolve(page.dict.get(b"Annots").unwrap()); + let annotations = annotations.as_array().unwrap(); + assert_eq!(annotations.len(), 2, "invalid requests add nothing"); + for reference in annotations { + let annotation = pdf.resolve(reference); + // FreeText stores its text colour in DA; C is the annotation background. + let da = annotation.as_dict().unwrap().get(b"DA").unwrap().as_string().unwrap(); + assert_eq!(String::from_utf8(da.bytes.to_vec()).unwrap(), "0.2 0.4 0.8 rg /Helv 6.5 Tf"); + } +} + #[test] fn image_signatures_through_tools_preserve_transparency_and_survive_save() { let dir = workdir("image-signatures"); @@ -4102,6 +4300,29 @@ fn preparing_a_form_through_tools() { assert!(matches!(a.call("form_set_image", &json!({ "doc": doc, "field": "size", "path": "pics/a_page_1.png" })), Err(ToolError::Failed(_)))); } +#[test] +fn renaming_a_radio_button_to_a_groups_name_joins_the_group_through_tools() { + // #4949: two buttons placed separately are two groups; naming one after the other makes + // them one group, and a clashing export value is renamed instead of shared. + let dir = workdir("radio-join"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "radio", "rect": [20, 100, 34, 114], "group": "size", "export": "S" })); + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "radio", "rect": [40, 100, 54, 114], "group": "other", "export": "S" })); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "S" } })); + let r = ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "other", "name": "size" })); + assert_eq!(r["field"], "size"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "joined.pdf" })); + let doc = ok(&mut a, "doc_open", json!({ "path": "joined.pdf" }))["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let f = fields["fields"].as_array().unwrap(); + assert_eq!(f.iter().map(|f| f["name"].as_str().unwrap()).collect::>(), ["size"], "one group"); + assert_eq!(f[0]["options"], json!(["S", "Choice1"]), "the joining button takes a free export value"); + assert_eq!(f[0]["value"], "S", "the group keeps its selection"); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "Choice1" } })); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "Choice1"); +} + #[test] fn rotating_a_field_through_tools() { let dir = workdir("rotate-field"); @@ -5012,6 +5233,151 @@ fn the_calculation_order_is_read_fixed_and_set() { assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("0"), json!("0")), "undone with the values it recalculated"); } +/// A malformed form with two calculated fields named `dup`, one summing `src`, the other `x`. +fn two_calculations_with_one_name() -> Vec { + let widget = |name: &str, y: u32, reads: &str| { + format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", \"{reads}\"\\);) >> >> >>", + y + 20 + ) + }; + pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>", + "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /CO [6 0 R 7 0 R 5 0 R] >>", + &widget("dup", 700, "x"), + &widget("dup", 660, "src"), + &widget("src", 620, "x"), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (x) /Rect [50 580 250 600] /P 3 0 R /V (3) >>", + ]) +} + +/// Each row of the calculation order gets its own field's formula, even where two fields share a +/// name (a malformed form): the first dup sums src, the second x. +#[test] +fn the_calculation_order_gives_each_field_its_own_formula() { + let dir = workdir("calcdup"); + std::fs::write(dir.join("dup.pdf"), two_calculations_with_one_name()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "dup.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc })); + assert_eq!(r["calculation_order"], json!(["dup", "src", "dup"])); + let formulas: Vec = r["fields"].as_array().unwrap().iter().map(|f| f["formula"].clone()).collect(); + assert_eq!( + formulas, + [json!({ "op": "sum", "fields": ["src"] }), json!({ "op": "sum", "fields": ["x"] }), json!({ "op": "sum", "fields": ["x"] })] + ); +} + +/// The calculation order through the tool shows each formula, in form_set_props's shape or as a +/// script, and flags a read of a field the form doesn't have and a script that builds the names +/// it reads; form_fields shows a custom script too. +#[test] +fn the_calculation_order_shows_formulas_and_what_they_cannot_read() { + let dir = workdir("calcformulas"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (i, name) in ["qty", "price", "sub", "tax", "total"].into_iter().enumerate() { + let y = 20 + 30 * i; + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name })); + } + let script = "event.value = getField(\"sub\").value / 10 + getField(\"row\" + 1).value;"; + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "sub", "calculate": { "notation": "qty * pric" } })); + ok(&mut a, "form_set_script", json!({ "doc": doc, "field": "tax", "event": "calculate", "script": script })); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": { "op": "sum", "fields": ["sub", "tax"] } })); + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc })); + assert_eq!(r["calculation_order"], json!(["sub", "tax", "total"])); + let formulas: Vec = r["fields"].as_array().unwrap().iter().map(|f| f["formula"].clone()).collect(); + assert_eq!(formulas, [json!({ "notation": "qty * pric" }), json!({ "script": script }), json!({ "op": "sum", "fields": ["sub", "tax"] })]); + assert_eq!(r["fields"][0]["issues"], json!([{ "issue": "missing_field", "field": "pric" }])); + assert_eq!(r["fields"][1]["reads"], json!(["sub"])); + assert_eq!(r["fields"][1]["issues"], json!([{ "issue": "built_names" }])); + assert_eq!(r["fields"][2]["issues"], json!([])); + assert_eq!(r["fixable"], false, "neither is an order to fix"); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let tax = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "tax").unwrap(); + assert_eq!(tax["calculate"], json!({ "script": script })); + // A script is set with form_set_script, and form_set_props says so, also when a notation + // comes with it. + for calculate in [json!({ "script": script }), json!({ "notation": "qty * 2", "script": script })] { + let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "tax", "calculate": calculate })); + assert!(e.to_string().contains("form_set_script"), "{calculate}: {e}"); + } +} + +/// Every operation comes back from form_fields and form_calculation_order as form_set_props takes +/// it, so a formula can be read and given back: product and average came back as prd and avg, +/// which it refused. +#[test] +fn every_calculation_op_can_be_read_and_given_back() { + let dir = workdir("calcops"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (name, y) in [("a", 20), ("b", 60), ("total", 100)] { + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name })); + } + for op in ["sum", "product", "average", "min", "max"] { + let calculate = json!({ "op": op, "fields": ["a", "b"] }); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": calculate })); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let shown = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "total").unwrap()["calculate"].clone(); + let order = ok(&mut a, "form_calculation_order", json!({ "doc": doc })); + assert_eq!((&shown, &order["fields"][0]["formula"]), (&calculate, &calculate), "{op}"); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": shown })); + } +} + +/// form_set_props refuses a calculate or validate it would only partly apply (a key it doesn't +/// know, a value of the wrong type, a notation beside op and fields) as a bad argument, before +/// the edit runs, and changes nothing. A validate read back from form_fields, with null for the +/// bound it doesn't have, is taken back. +#[test] +fn calculate_and_validate_refuse_what_they_would_drop() { + let dir = workdir("calcstrict"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (name, y) in [("a", 20), ("b", 60), ("total", 100)] { + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name })); + } + let set = json!({ "op": "sum", "fields": ["a", "b"] }); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": set, "validate": { "min": 0, "max": 10 } })); + let total = |a: &mut Automation| { + let fields = ok(a, "form_fields", json!({ "doc": doc })); + fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "total").unwrap().clone() + }; + for (calculate, says) in [ + (json!({ "notation": "a", "op": "sum", "fields": ["b"] }), "not both"), + (json!({ "notation": "a", "op": "sum" }), "not both"), + (json!({ "notation": "a", "fields": ["b"] }), "not both"), + // A notation that wouldn't read back as itself from the script's comment. + (json!({ "notation": "a EVCALC b" }), "can't be stored"), + (json!({ "notation": "a */ x /*" }), "can't be stored"), + (json!({ "notation": " " }), "can't be stored"), + (json!({ "op": "sum", "fields": ["a", 5] }), "field names"), + (json!({ "op": "sum", "fields": "a" }), "list of field names"), + (json!({ "notation": 5 }), "notation must be a string"), + (json!({ "op": 5, "fields": ["a"] }), "op must be"), + (json!({ "fields": ["a"] }), "needs op"), + (json!({ "op": "sum", "feilds": ["a"] }), "unknown key \"feilds\""), + ] { + // InvalidArgs, not Failed: an unstorable notation is refused by the tool itself, not + // only by the edit underneath it. + let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": calculate })); + assert!(matches!(e, ToolError::InvalidArgs(_)) && e.to_string().contains(says), "{calculate}: {e:?}"); + } + for (validate, says) in [(json!({ "min": 0, "max": "100" }), "max must be a number"), (json!({ "min": 0, "mxa": 5 }), "unknown key \"mxa\"")] { + let e = err(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "validate": validate })); + assert!(matches!(e, ToolError::InvalidArgs(_)) && e.to_string().contains(says), "{validate}: {e:?}"); + } + let t = total(&mut a); + assert_eq!((&t["calculate"], &t["validate"]), (&set, &json!({ "min": 0.0, "max": 10.0 })), "nothing changed"); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "validate": { "min": null, "max": 5 } })); + let shown = total(&mut a)["validate"].clone(); + assert_eq!(shown, json!({ "min": null, "max": 5.0 })); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "validate": shown })); +} + #[test] fn page_tab_orders_are_read_and_set_per_page_through_tools() { let dir = workdir("pagetabs"); @@ -6357,6 +6723,15 @@ fn optimizing_through_tools() { let cat_doc = ok(&mut a, "doc_open", json!({ "path": "cat16.pdf" }))["doc"].as_u64().unwrap(); let retained = ok(&mut a, "doc_optimize", json!({ "doc": cat_doc, "path": "retained.pdf", "compatibility": "retain", "object_streams": false })); assert_eq!(retained["pdf_version"].as_str(), Some("1.6"), "{retained}"); + // The same with the catalog's /Version given by reference: read through it. + let v16 = cos14.add(pdfcraft_cos::Object::name("1.6")); + cos14.update_dict(root, |c| c.set(b"Version".to_vec(), pdfcraft_cos::Object::Ref(v16))).unwrap(); + let ref16 = pdfcraft_cos::write_full(&cos14, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap(); + std::fs::write(dir.join("ref16.pdf"), &ref16).unwrap(); + let ref_doc = ok(&mut a, "doc_open", json!({ "path": "ref16.pdf" }))["doc"].as_u64().unwrap(); + let by_ref = + ok(&mut a, "doc_optimize", json!({ "doc": ref_doc, "path": "retained-ref.pdf", "compatibility": "retain", "object_streams": false })); + assert_eq!(by_ref["pdf_version"].as_str(), Some("1.6"), "{by_ref}"); assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "compatibility": "1.3" })), Err(ToolError::InvalidArgs(_)))); assert!(matches!( @@ -6436,6 +6811,240 @@ fn lzw_streams_are_stored_with_flate_through_tools() { assert_eq!(filters("kept.pdf"), (Some(Object::name("LZWDecode")), ops)); } +/// A one-page PDF drawing a 1-bit scan of text (glyph strokes with ragged edges, as a scanner +/// leaves them) of 1000 × 400 samples, stored with Flate. +fn scanned_page() -> Vec { + scan_drawn(b"q 500 0 0 200 50 500 cm /Scan Do Q") +} + +/// [`scanned_page`]'s scan, drawn by `content` (which names it `/Scan`). +fn scan_drawn(content: &[u8]) -> Vec { + use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full}; + let (w, h) = (1000usize, 400usize); + let stride = w.div_ceil(8); + let mut data = vec![0xFFu8; stride * h]; + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + for line in (4..h - 20).step_by(24) { + let mut x = 8; + while x + 12 < w { + let wide = 3 + (next() % 5) as usize; + let (top, bottom) = (line + (next() % 4) as usize, line + 14); + for y in top..bottom { + let left = x + usize::from(next() % 8 == 0); + let right = x + wide - usize::from(next() % 8 == 0); + for xx in left..right { + data[y * stride + xx / 8] &= !(0x80 >> (xx % 8)); + } + } + x += wide + 2 + (next() % 4) as usize; + } + } + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let image = doc.add(Object::Stream(Stream::flate(d, &data))); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + let mut x = Dict::new(); + x.set(b"Scan".to_vec(), Object::Ref(image)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(x)); + let content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), content.to_vec()))); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"Contents".to_vec(), Object::Ref(content)); + let page = doc.add(Object::Dict(p)); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + write_full(&doc, &SaveOptions::default()).unwrap() +} + +/// A scan saved as a 1-bit PNG becomes a page whose picture stays one bit per pixel (not the eight +/// bits the PNG decodes to), and Export all images gives the picture back. +#[test] +fn a_one_bit_scan_stays_one_bit_and_exports() { + let dir = workdir("bilevel"); + let (w, h) = (800u32, 200u32); + let stride = (w as usize).div_ceil(8); + let mut bits = vec![0xFFu8; stride * h as usize]; + for y in 0..h as usize { + for x in 0..w as usize { + if y % 25 >= 6 && y % 25 < 18 && (x * 7 + y / 25 * 13) % 11 < 4 { + bits[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + let mut png_bytes = Vec::new(); + { + let mut enc = png::Encoder::new(&mut png_bytes, w, h); + enc.set_color(png::ColorType::Grayscale); + enc.set_depth(png::BitDepth::One); + enc.write_header().unwrap().write_image_data(&bits).unwrap(); + } + std::fs::write(dir.join("scan.png"), &png_bytes).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["scan.png"] }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "scan.pdf" })); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("scan.pdf")).unwrap())).unwrap(); + let images: Vec> = saved + .object_numbers() + .into_iter() + .filter_map(|num| match &*saved.get(pdfcraft_cos::ObjRef { num, generation: saved.generation(num) }) { + pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => Some(s.dict.int(b"BitsPerComponent")), + _ => None, + }) + .collect(); + assert_eq!(images, [Some(1)]); + let r = ok(&mut a, "doc_export_all_images", json!({ "doc": doc, "folder": "out" })); + let files = r["files"].as_array().unwrap(); + assert_eq!(files.len(), 1, "{r}"); + let exported = std::fs::read(files[0]["path"].as_str().unwrap()).unwrap(); + let mut reader = png::Decoder::new(std::io::Cursor::new(exported)).read_info().unwrap(); + let mut buf = vec![0; reader.output_buffer_size().unwrap()]; + let frame = reader.next_frame(&mut buf).unwrap(); + let white: Vec = buf[..frame.buffer_size()].as_chunks::<3>().0.iter().map(|p| *p == [255, 255, 255]).collect(); + let expected: Vec = + (0..h as usize).flat_map(|y| (0..w as usize).map(move |x| (y, x))).map(|(y, x)| bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0).collect(); + assert_eq!(white, expected); +} + +/// With `mono: { compression: jbig2 }` a scan's black-and-white image is stored as lossless JBIG2, +/// smaller than as Group 4, and exports as the same picture. +#[test] +fn doc_optimize_stores_black_and_white_images_as_jbig2() { + let dir = workdir("optimize-jbig2"); + std::fs::write(dir.join("scan.pdf"), scanned_page()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "scan.pdf" }))["doc"].as_u64().unwrap(); + let g4 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "g4.pdf" })); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "jbig2.pdf", "mono": { "compression": "jbig2" } })); + assert_eq!((r["images"].as_u64(), r["images_recompressed"].as_u64()), (Some(1), Some(1)), "{r}"); + assert!(r["image_bytes_after"].as_u64() < g4["image_bytes_after"].as_u64(), "JBIG2 {r}, Group 4 {g4}"); + let written = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("jbig2.pdf")).unwrap())).unwrap(); + let filters: Vec> = written + .object_numbers() + .into_iter() + .filter_map(|num| match &*written.get(pdfcraft_cos::ObjRef { num, generation: written.generation(num) }) { + pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => s.dict.name(b"Filter").map(<[u8]>::to_vec), + _ => None, + }) + .collect(); + assert_eq!(filters, [b"JBIG2Decode".to_vec()]); + // Export ▸ Images gives the copy's picture back as it gives the scan's. + let copy = ok(&mut a, "doc_open", json!({ "path": "jbig2.pdf" }))["doc"].as_u64().unwrap(); + let mut exported = |doc: u64, folder: &str| { + let r = ok(&mut a, "doc_export_all_images", json!({ "doc": doc, "folder": folder })); + let files = r["files"].as_array().unwrap(); + assert_eq!(files.len(), 1, "{r}"); + std::fs::read(files[0]["path"].as_str().unwrap()).unwrap() + }; + assert_eq!(exported(copy, "from-jbig2"), exported(doc, "from-scan")); +} + +/// A scan's black-and-white image is stored as CCITT Group 4 by default, and kept as it is with +/// `mono: { compression: retain }`. +#[test] +fn doc_optimize_stores_black_and_white_images_as_ccitt_group_4() { + let dir = workdir("optimize-mono"); + std::fs::write(dir.join("scan.pdf"), scanned_page()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "scan.pdf" }))["doc"].as_u64().unwrap(); + let kept = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf", "mono": { "compression": "retain" } })); + assert_eq!((kept["images"].as_u64(), kept["images_recompressed"].as_u64()), (Some(1), Some(0)), "{kept}"); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf" })); + assert_eq!((r["images"].as_u64(), r["images_recompressed"].as_u64()), (Some(1), Some(1)), "{r}"); + assert!(r["image_bytes_after"].as_u64() < r["image_bytes_before"].as_u64(), "{r}"); + // The copy written stores the image as Group 4. + let small = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("small.pdf")).unwrap())).unwrap(); + let filters: Vec> = small + .object_numbers() + .into_iter() + .filter_map(|num| match &*small.get(pdfcraft_cos::ObjRef { num, generation: small.generation(num) }) { + pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => s.dict.name(b"Filter").map(<[u8]>::to_vec), + _ => None, + }) + .collect(); + assert_eq!(filters, [b"CCITTFaxDecode".to_vec()]); + // An unknown compression, one that isn't a name, a misspelled key, or a mono that isn't an + // object is refused, not read as the default. + for mono in [json!({ "compression": "jpeg" }), json!({ "compression": false }), json!({ "compresson": "retain" }), json!("retain")] { + assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "mono": mono })), Err(ToolError::InvalidArgs(_))), "{mono}"); + } +} + +/// A scan drawn at 600 ppi is averaged down to 300 ppi by default. `mono: { downsample: false }` +/// keeps its size, `ppi` and `above_ppi` choose others, and values of the wrong type are refused. +#[test] +fn doc_optimize_averages_black_and_white_scans_down() { + let dir = workdir("optimize-mono-downsample"); + // 1000 × 400 samples on 120 × 48 pt: 600 ppi. + std::fs::write(dir.join("scan.pdf"), simple_rule_scan_drawn(b"q 120 0 0 48 50 500 cm /Scan Do Q")).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "scan.pdf" }))["doc"].as_u64().unwrap(); + let sizes = |name: &str| image_sizes(&dir.join(name)); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf" })); + assert_eq!(r["images_resampled"].as_u64(), Some(1), "{r}"); + assert_eq!(sizes("small.pdf"), [(Some(500), Some(200))]); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf", "mono": { "downsample": false } })); + assert_eq!(r["images_resampled"].as_u64(), Some(0), "{r}"); + assert_eq!(sizes("kept.pdf"), [(Some(1000), Some(400))]); + ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "200.pdf", "mono": { "ppi": 200, "above_ppi": 250 } })); + assert_eq!(sizes("200.pdf"), [(Some(333), Some(133))]); + for mono in [json!({ "downsample": "yes" }), json!({ "ppi": "300" }), json!({ "above": 450 })] { + assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "mono": mono })), Err(ToolError::InvalidArgs(_))), "{mono}"); + } +} + +/// The width and height of every image in the PDF at `path`. +fn image_sizes(path: &std::path::Path) -> Vec<(Option, Option)> { + let written = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap(); + written + .object_numbers() + .into_iter() + .filter_map(|num| match &*written.get(pdfcraft_cos::ObjRef { num, generation: written.generation(num) }) { + pdfcraft_cos::Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => Some((s.dict.int(b"Width"), s.dict.int(b"Height"))), + _ => None, + }) + .collect() +} + +/// The batch optimize step's `ppi` reaches black-and-white images too: they go to the larger of +/// it and Reduce File Size's 300 ppi. Before, a batch run took every scan to 300 ppi whatever +/// `ppi` said, so `ppi: 600` halved 600 ppi scans. +#[test] +fn batch_ppi_bounds_how_far_black_and_white_scans_are_resampled() { + let dir = workdir("batch-mono-ppi"); + // 1000 × 400 samples on 120 × 48 pt: 600 ppi. + std::fs::write(dir.join("scan.pdf"), simple_rule_scan_drawn(b"q 120 0 0 48 50 500 cm /Scan Do Q")).unwrap(); + let mut a = auto(&dir); + for (ppi, size) in [(None, (500, 200)), (Some(600), (1000, 400)), (Some(96), (500, 200))] { + let out = format!("out-{ppi:?}"); + let mut args = json!({ "paths": ["scan.pdf"], "steps": ["optimize"], "out_dir": out }); + if let Some(ppi) = ppi { + args["ppi"] = json!(ppi); + } + let report = ok(&mut a, "doc_batch", args); + assert_eq!(report["completed"], 1, "{report}"); + assert_eq!(image_sizes(&dir.join(&out).join("scan.pdf")), [(Some(size.0), Some(size.1))], "ppi {ppi:?}"); + } +} + /// A one-page PDF whose page picture is drawn as `rows` grey images one pixel row high, 600 px /// wide on a 72 pt square (600 ppi), as some PDF printers write it. fn printed_in_strips(rows: usize) -> Vec { @@ -7038,7 +7647,7 @@ fn exporting_to_word_html_and_rtf() { let dir = workdir("office"); let mut a = auto(&dir); let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); - for ext in ["docx", "pptx", "html", "rtf"] { + for ext in ["docx", "pptx", "html", "rtf", "md"] { let r = ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": format!("a.{ext}") })); assert_eq!(r["format"], ext); } @@ -7047,6 +7656,10 @@ fn exporting_to_word_html_and_rtf() { assert!(std::fs::read(dir.join("a.docx")).unwrap().starts_with(b"PK")); assert!(std::fs::read(dir.join("a.pptx")).unwrap().starts_with(b"PK")); assert!(std::fs::read_to_string(dir.join("a.rtf")).unwrap().contains("Page 2")); + // #1595: Markdown, with a rule between pages. + let md = std::fs::read_to_string(dir.join("a.md")).unwrap(); + assert!(md.contains("Page 1") && md.contains("Page 3") && md.matches("\n---\n").count() == 2, "{md}"); + assert_eq!(ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "b.markdown" }))["format"], "md"); assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "a.xyz" })).is_err()); } @@ -9026,8 +9639,8 @@ fn text_box_and_callout_fill_and_border_through_tools() { } /// Three paragraphs: Alpha (two rows), Bravo (one row) and Charlie (two rows), at the leading -/// of #2516. A third Alpha row brings its last baseline within 2.5 × size of Bravo's, so the next -/// read groups the two as one paragraph. +/// of #2516. Alpha's last baseline is 35.6 pt above Bravo's; moving Alpha down 21.2 pt leaves +/// 14.4 pt, Alpha's own leading, so the next read groups the two as one paragraph. fn three_paragraph_pdf() -> Vec { use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; use std::sync::Arc; @@ -9076,6 +9689,10 @@ fn an_emptied_paragraph_does_not_pass_its_number_to_an_overlapping_neighbour() { /// #2516: a paragraph number names the paragraph `text_paragraphs` listed. When an edit regroups /// the page so that paragraph no longer exists on its own, the next edit by that number is /// refused. It used to rewrite whichever paragraph had taken the number, and that text was gone. +/// +/// Moving Alpha down (no text change) is the regrouping edit: #2517 wraps a rewrite that adds or +/// keeps rows in a marked region precisely so it can't be mistaken for a neighbour by geometry +/// alone, so a text rewrite can no longer manufacture this scenario; a pure move still can. #[test] fn text_edit_refuses_a_paragraph_number_an_earlier_edit_regrouped() { let dir = workdir("text-edit-stale-number"); @@ -9087,12 +9704,9 @@ fn text_edit_refuses_a_paragraph_number_an_earlier_edit_regrouped() { assert!(listed["paragraphs"][1]["text"].as_str().unwrap().starts_with("Bravo")); // Listing again here would renumber the page, so the first edit's result says what moved. - let first = ok( - &mut a, - "text_edit", - json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha paragraph, first row.\nAlpha paragraph, second row.\nAlpha paragraph, a third row."}), - ); - assert_eq!(first["stale_paragraphs"], json!([1, 2]), "the third row regroups Alpha with Bravo: {first}"); + // Moving Alpha down by 21.2 pt brings its last row within 14.4 pt of Bravo, its own leading. + let first = ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"dy":-21.2})); + assert_eq!(first["stale_paragraphs"], json!([1, 2]), "moving Alpha down regroups it with Bravo: {first}"); // Paragraph 2 of the earlier listing was Bravo, which is now part of Alpha's paragraph. let stale = a.call("text_edit", &json!({"doc":doc,"page":1,"paragraph":2,"text":"Bravo paragraph, edited."})); @@ -9107,13 +9721,14 @@ fn text_edit_refuses_a_paragraph_number_an_earlier_edit_regrouped() { assert!(relisted["paragraphs"][1]["text"].as_str().unwrap().starts_with("Charlie")); ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":2,"text":"Charlie paragraph, edited."})); let text = page_text(&mut a, doc).join(" "); - assert!(text.contains("Charlie paragraph, edited.") && text.contains("Alpha paragraph, a third row."), "{text}"); + assert!(text.contains("Charlie paragraph, edited.") && text.contains("Alpha paragraph, second row."), "{text}"); std::fs::remove_dir_all(dir).unwrap(); } /// An edit that merges its paragraph into a neighbour leaves no paragraph of its own to follow: /// the merged one holds the edited text too, but rewriting it by the edited paragraph's number -/// would drop the neighbour's text (#2516). +/// would drop the neighbour's text (#2516). Moving Alpha down (see the previous test) is the +/// regrouping edit, since a text rewrite is marked precisely so it cannot merge this way (#2517). #[test] fn text_edit_does_not_follow_a_paragraph_into_the_one_it_merged_with() { let dir = workdir("text-edit-merged-number"); @@ -9121,11 +9736,7 @@ fn text_edit_does_not_follow_a_paragraph_into_the_one_it_merged_with() { let mut a = auto(&dir); let doc = ok(&mut a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap(); ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); - let first = ok( - &mut a, - "text_edit", - json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha paragraph, first row.\nAlpha paragraph, second row.\nAlpha paragraph, a third row."}), - ); + let first = ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"dy":-21.2})); assert!(first.get("paragraph").is_none(), "the result must not present the merged paragraph as the edited one: {first}"); // Paragraph 1 was Alpha. Alpha now shares a paragraph with Bravo, so the number is refused. @@ -9139,6 +9750,8 @@ fn text_edit_does_not_follow_a_paragraph_into_the_one_it_merged_with() { /// The editor background preview takes the same paragraph number as `text_edit`, so it hides the /// paragraph the number named in the listing and refuses a number an edit made stale (#2516). +/// Moving Alpha down (see the two tests above) is the regrouping edit; a text rewrite is marked +/// precisely so it cannot merge this way (#2517). #[test] fn page_render_hide_text_block_refuses_a_paragraph_number_an_earlier_edit_regrouped() { let dir = workdir("hide-text-block-stale-number"); @@ -9146,11 +9759,7 @@ fn page_render_hide_text_block_refuses_a_paragraph_number_an_earlier_edit_regrou let mut a = auto(&dir); let doc = ok(&mut a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap(); ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); - ok( - &mut a, - "text_edit", - json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha paragraph, first row.\nAlpha paragraph, second row.\nAlpha paragraph, a third row."}), - ); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"dy":-21.2})); // Paragraph 2 of the listing was Bravo, now inside Alpha's paragraph; position 2 is Charlie. let stale = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72,"hide_text_block":2})); @@ -9372,6 +9981,46 @@ fn bookmark_list_reports_colour_and_style() { assert_eq!(style(&ok(&mut b, "bookmark_list", json!({ "doc": re }))["bookmarks"]), style(&set), "saved"); } +/// `aligns` gives each line of added text an alignment of its own; `align` alone is the whole box +/// again. Text edits take `aligns` for a paragraph's rows the same way. +#[test] +fn added_text_lines_take_their_own_alignments() { + let dir = workdir("paragraph-aligns"); + let mut a = auto(&dir); + let id = ok(&mut a, "doc_create", json!({"from":"blank","width":600,"height":400}))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_text", json!({"doc":id,"page":1,"text":"Left line\nRight line","at":[40,50],"width":500,"size":12,"aligns":[null,"right"]})); + let rect = |a: &mut Automation, text: &str| -> Vec { + let lines = ok(a, "text_lines", json!({"doc":id,"page":1})); + let line = lines["lines"].as_array().unwrap().iter().find(|l| l["text"] == text).cloned().unwrap(); + line["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect() + }; + assert!((rect(&mut a, "Left line")[0] - 40.0).abs() < 1.0); + assert!((rect(&mut a, "Right line")[2] - 540.0).abs() < 1.0, "{:?}", rect(&mut a, "Right line")); + // The whole box centred: the right-aligned line goes with it. + ok(&mut a, "content_update", json!({"doc":id,"page":1,"index":1,"align":"center"})); + let right = rect(&mut a, "Right line"); + assert!(((right[0] + right[2]) / 2.0 - 290.0).abs() < 1.0, "{right:?}"); + assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":["sideways"]})).is_err()); + assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":"right"})).is_err()); +} + +/// Existing-row alignment is withheld until its intent survives later editing sessions. +#[test] +fn text_edit_row_alignment_is_refused_without_changing_existing_text() { + let dir = workdir("paragraph-row-aligns"); + let body = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running.) Tj ET"; + std::fs::write(dir.join("rows.pdf"), win_ansi_paragraph_pdf(body)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "rows.pdf" }))["doc"].as_u64().unwrap(); + let before = ok(&mut a, "text_lines", json!({ "doc": doc, "page": 1 })); + let refused = err(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "aligns": [null, "right"] })); + assert!(matches!(&refused, ToolError::Failed(m) | ToolError::InvalidArgs(m) if m.contains("selected-row alignment")), "{refused:?}"); + assert_eq!(ok(&mut a, "text_lines", json!({ "doc": doc, "page": 1 })), before); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "unchanged.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "unchanged.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "text_lines", json!({ "doc": reopened, "page": 1 })), before); +} + /// An editable combo box (ISO 32000-2 Table 233, Edit) takes a value that isn't one of its /// options, and its page appearance shows it, before and after a save; one without Edit takes /// only its options. @@ -9470,3 +10119,449 @@ fn deleting_one_radio_button_through_tools_keeps_the_group() { ok(&mut a, "edit_undo", json!({ "doc": doc })); assert_eq!(size(&mut a)["options"], json!(["S", "M", "L"]), "one undo step brings it back"); } +/// `/I` of the top-level field `name` in the saved file `path`. +fn saved_selection_indices(path: &Path, name: &str) -> Option> { + let d = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap(); + let root = d.get(d.root().unwrap()).as_dict().cloned().unwrap(); + let form = d.resolve(root.get(b"AcroForm").unwrap()).as_dict().cloned().unwrap(); + let all = d.resolve(form.get(b"Fields").unwrap()).as_array().cloned().unwrap(); + let f = all + .iter() + .filter_map(|o| d.resolve(o).as_dict().cloned()) + .find(|f| f.get(b"T").and_then(|t| d.resolve(t).as_string().map(|s| s.to_text())).as_deref() == Some(name)) + .unwrap(); + f.get(b"I").map(|i| d.resolve(i).as_array().unwrap().iter().map(|o| o.as_int().unwrap()).collect()) +} + +#[test] +fn changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_items() { + let dir = workdir("choice-index"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let args = json!({ "doc": doc, "page": 1, "type": "list", "rect": [20, 60, 180, 140], "options": ["A", "B", "C"], "multi_select": true, "name": "letters" }); + ok(&mut a, "form_add_field", args); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "letters": ["B", "C"] } })); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "letters", "options": ["B", "C", "D"] })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "letters.pdf" })); + assert_eq!(saved_selection_indices(&dir.join("letters.pdf"), "letters"), Some(vec![0, 1]), "B and C are now items 0 and 1"); +} + +/// Clearing a field's default through `form_set_props` (`"default": ""`) leaves it with none, +/// even when its parent field has one it would otherwise inherit. +#[test] +fn clearing_a_kids_default_through_tools_does_not_bring_back_its_parents() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>", + "<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>", + "<< /T (address) /FT /Tx /DV (Main St) /Kids [6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /T (city) /Parent 5 0 R /DV (Elm) /V (Elm) /Rect [10 250 150 268] /P 3 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]; + let dir = workdir("kid-default"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "address.city", "default": "" })); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "address.city": "Oak" } })); + ok(&mut a, "form_reset", json!({ "doc": doc })); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let city = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "address.city").unwrap().clone(); + assert!(city["value"].is_null() || city["value"] == "", "reset clears it rather than restoring the parent's Main St: {}", city["value"]); +} + +/// #2517, through the same tools as the report. One blank line is two paragraphs, and editing +/// the first leaves the second. The neighbour at y=656.8 is one blank line below (two 14.4 pt +/// leadings under the second row). Growing the first paragraph, saving, reopening and editing +/// again leaves that neighbour in place. An explicit TL of 27.6 pt stays one paragraph. +#[test] +fn a_blank_line_stays_a_separate_paragraph_through_tools() { + let dir = workdir("blank-line"); + let body = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (First paragraph, first row.) Tj T* (First paragraph, second row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Second paragraph, first row.) Tj T* (Second paragraph, second row.) Tj ET"; + std::fs::write(dir.join("a.pdf"), helvetica_page(body)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let before = ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 })); + assert_eq!(before["count"], 2, "{before}"); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "First paragraph, edited row.\nFirst paragraph, second row." })); + let after = ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 })); + assert_eq!(after["count"], 2, "{after}"); + let second = after["paragraphs"][1]["text"].as_str().unwrap(); + assert!(second.contains("Second paragraph"), "{second}"); + + let grown = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbour) Tj ET"; + std::fs::write(dir.join("grown.pdf"), helvetica_page(grown)).unwrap(); + let grown_doc = ok(&mut a, "doc_open", json!({ "path": "grown.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": grown_doc, "page": 1 }))["count"], 2); + ok(&mut a, "text_edit", json!({ "doc": grown_doc, "page": 1, "paragraph": 1, "text": "One\nTwo\nThree" })); + ok(&mut a, "doc_save", json!({ "doc": grown_doc, "path": "grown-out.pdf" })); + let saved = ok(&mut a, "doc_open", json!({ "path": "grown-out.pdf" }))["doc"].as_u64().unwrap(); + let after = ok(&mut a, "text_paragraphs", json!({ "doc": saved, "page": 1 })); + assert_eq!(after["count"], 2, "{after}"); + assert!(after["paragraphs"][1]["text"].as_str().unwrap().contains("Neighbour"), "{after}"); + ok(&mut a, "text_edit", json!({ "doc": saved, "page": 1, "paragraph": 1, "text": "One\nTwo\nFour" })); + let after = ok(&mut a, "text_paragraphs", json!({ "doc": saved, "page": 1 })); + assert_eq!(after["count"], 2, "{after}"); + assert!(after["paragraphs"][1]["text"].as_str().unwrap().contains("Neighbour"), "{after}"); + + let spaced = "BT /F1 12 Tf 27.6 TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj T* (Three) Tj ET"; + std::fs::write(dir.join("spaced.pdf"), helvetica_page(spaced)).unwrap(); + let spaced_doc = ok(&mut a, "doc_open", json!({ "path": "spaced.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": spaced_doc, "page": 1 }))["count"], 1); +} + +/// #2529, through the same tools as a width drag. The typed double space falls on the wrap. +/// Omitting `text` reflows with the saved gap count. An incremental save and a full rewrite +/// both still have both spaces after the box is widened again. +#[test] +fn narrowing_then_widening_keeps_a_typed_double_space_through_tools() { + let dir = workdir("double-space-width"); + std::fs::write(dir.join("a.pdf"), helvetica_page("BT /F1 24 Tf 20 150 Td (One Two) Tj ET")).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"][0]["editor_text"], "One Two"); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "width": 45.0 })); + let narrow = &ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"][0]; + assert_eq!(narrow["editor_text"], "One\nTwo", "{narrow}"); + for (name, full) in [("narrow-inc.pdf", false), ("narrow-full.pdf", true)] { + ok(&mut a, "doc_save", json!({ "doc": doc, "path": name, "full": full })); + let saved = ok(&mut a, "doc_open", json!({ "path": name }))["doc"].as_u64().unwrap(); + let block = &ok(&mut a, "text_paragraphs", json!({ "doc": saved, "page": 1 }))["paragraphs"][0]; + assert_eq!(block["editor_text"], "One\nTwo", "{name}: {block}"); + ok(&mut a, "text_edit", json!({ "doc": saved, "page": 1, "paragraph": 1, "width": 200.0 })); + let wide_name = format!("wide-{name}"); + ok(&mut a, "doc_save", json!({ "doc": saved, "path": wide_name, "full": full })); + let wide = ok(&mut a, "doc_open", json!({ "path": wide_name }))["doc"].as_u64().unwrap(); + let block = &ok(&mut a, "text_paragraphs", json!({ "doc": wide, "page": 1 }))["paragraphs"][0]; + assert_eq!(block["editor_text"], "One Two", "{name}: the second typed space was lost: {block}"); + } +} + +fn paragraph_texts(a: &mut Automation, doc: u64) -> Vec { + ok(a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"] + .as_array() + .unwrap() + .iter() + .map(|p| p["text"].as_str().unwrap().to_string()) + .collect() +} + +/// The reported edits, through open / text_paragraphs / text_edit / save / reopen / text_extract. +/// Covers an explicit TL, the three ways a file moves rows without TL, a blank line written with +/// Td, a two-row file that must stay split, and a partial rewrite that adds a row. +#[test] +fn paragraph_grouping_survives_save_and_reopen_through_tools() { + let dir = workdir("paragraph-grouping"); + let mut a = auto(&dir); + for step in [27.6_f64, 28.8] { + let y2 = 700.0 - step; + let y3 = 700.0 - 2.0 * step; + let bodies = [ + format!("BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj 0 -{step} Td (Two) Tj 0 -{step} Td (Three) Tj ET"), + format!( + "BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj ET BT /F1 12 Tf 1 0 0 1 72 {y2} Tm (Two) Tj ET BT /F1 12 Tf 1 0 0 1 72 {y3} Tm (Three) Tj ET" + ), + format!("BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {y2} Td (Two) Tj ET BT /F1 12 Tf 72 {y3} Td (Three) Tj ET"), + ]; + for (i, body) in bodies.iter().enumerate() { + let path = format!("step-{step}-{i}.pdf"); + std::fs::write(dir.join(&path), helvetica_page(body)).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap(); + let paras = paragraph_texts(&mut a, doc); + assert_eq!(paras.len(), 1, "{path}: {paras:?}"); + assert!(paras[0].contains("One") && paras[0].contains("Two") && paras[0].contains("Three"), "{path}: {paras:?}"); + let extracted = page_text(&mut a, doc); + assert!(extracted[0].contains("One") && extracted[0].contains("Three"), "{path}: {extracted:?}"); + } + } + + // One gap and no third row: still two paragraphs. + let pair = format!("BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET", 700.0 - 27.6); + std::fs::write(dir.join("pair.pdf"), helvetica_page(&pair)).unwrap(); + let pair_doc = ok(&mut a, "doc_open", json!({ "path": "pair.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, pair_doc).len(), 2); + + // Blank line written with Td, not TL. The inner step repeats; the break does not. + let inner = 14.4_f64; + let blank = format!( + "BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET \ + BT /F1 12 Tf 72 {} Td (Neighbour) Tj ET BT /F1 12 Tf 72 {} Td (Line) Tj ET", + 700.0 - inner, + 700.0 - 3.0 * inner, + 700.0 - 4.0 * inner + ); + std::fs::write(dir.join("blank.pdf"), helvetica_page(&blank)).unwrap(); + let blank_doc = ok(&mut a, "doc_open", json!({ "path": "blank.pdf" }))["doc"].as_u64().unwrap(); + let blank_paras = paragraph_texts(&mut a, blank_doc); + assert_eq!(blank_paras.len(), 2, "{blank_paras:?}"); + assert!(blank_paras[0].contains("One") && blank_paras[0].contains("Two"), "{blank_paras:?}"); + assert!(blank_paras[1].contains("Neighbour") && blank_paras[1].contains("Line"), "{blank_paras:?}"); + + // The first row is unchanged. Save incremental, reopen, edit again, save in full, reopen. + let partial = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET"; + std::fs::write(dir.join("partial.pdf"), helvetica_page(partial)).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "partial.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, doc).len(), 2); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nThree" })); + let incremental = ok(&mut a, "doc_save", json!({ "doc": doc, "path": "partial.pdf" })); + assert_eq!(incremental["incremental"], true, "{incremental}"); + let reopened = ok(&mut a, "doc_open", json!({ "path": "partial.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, reopened), ["Alpha first row. Two Three", "Neighbor stays."]); + ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nFour" })); + let full = ok(&mut a, "doc_save", json!({ "doc": reopened, "path": "partial-full.pdf", "full": true })); + assert_eq!(full["incremental"], false, "{full}"); + let again = ok(&mut a, "doc_open", json!({ "path": "partial-full.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, again), ["Alpha first row. Two Four", "Neighbor stays."]); + let extracted = page_text(&mut a, again); + assert!(extracted[0].contains("Neighbor stays.") && extracted[0].contains("Four"), "{extracted:?}"); +} + +/// Editing the only paragraph on the page, while leaving its second row in the source, stays +/// one paragraph through save and reopen. A neighbour in a different marked region stays in +/// its own paragraph. +#[test] +fn a_partial_rewrite_of_the_last_paragraph_stays_one_through_tools() { + let dir = workdir("paragraph-tail"); + let mut a = auto(&dir); + let only = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET"; + std::fs::write(dir.join("only.pdf"), helvetica_page(only)).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "only.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, doc), ["Alpha first row. Alpha next row."]); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "One.\nAlpha next row." })); + assert_eq!(paragraph_texts(&mut a, doc), ["One. Alpha next row."]); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "only.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "only.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, reopened), ["One. Alpha next row."]); + ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "One.\nTwo." })); + assert_eq!(paragraph_texts(&mut a, reopened), ["One. Two."]); + + let marked = "/H1 << /MCID 0 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET EMC \ + /P << /MCID 1 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET EMC"; + std::fs::write(dir.join("marked.pdf"), helvetica_page(marked)).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "marked.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, doc).len(), 2); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "One.\nAlpha next row." })); + assert_eq!(paragraph_texts(&mut a, doc), ["One. Alpha next row.", "Neighbor stays."]); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "marked.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "marked.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, reopened), ["One. Alpha next row.", "Neighbor stays."]); + ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "One.\nTwo." })); + assert_eq!(paragraph_texts(&mut a, reopened), ["One. Two.", "Neighbor stays."]); +} + +/// Both ends of one text object, through open / text_edit / save / reopen / text_extract. +/// Editing the last paragraph must not take the one before it, and editing the first must +/// not take the one after it. Incremental and full saves. +#[test] +fn a_shared_text_object_keeps_both_paragraphs_through_tools() { + let dir = workdir("shared-text-object"); + let mut a = auto(&dir); + let body = + "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj T* T* (Neighbor stays.) Tj T* (Neighbor next.) Tj ET"; + std::fs::write(dir.join("last.pdf"), helvetica_page(body)).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "last.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, doc), ["Alpha first row. Alpha next row.", "Neighbor stays. Neighbor next."]); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 2, "text": "Neighbor stays.\nChanged." })); + assert_eq!(paragraph_texts(&mut a, doc), ["Alpha first row. Alpha next row.", "Neighbor stays. Changed."]); + let incremental = ok(&mut a, "doc_save", json!({ "doc": doc, "path": "last.pdf" })); + assert_eq!(incremental["incremental"], true, "{incremental}"); + let reopened = ok(&mut a, "doc_open", json!({ "path": "last.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, reopened), ["Alpha first row. Alpha next row.", "Neighbor stays. Changed."]); + ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 2, "text": "Neighbor stays.\nEdited." })); + let full = ok(&mut a, "doc_save", json!({ "doc": reopened, "path": "last-full.pdf", "full": true })); + assert_eq!(full["incremental"], false, "{full}"); + let again = ok(&mut a, "doc_open", json!({ "path": "last-full.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, again), ["Alpha first row. Alpha next row.", "Neighbor stays. Edited."]); + let extracted = page_text(&mut a, again); + assert!(extracted[0].contains("Alpha first row.") && extracted[0].contains("Edited."), "{extracted:?}"); + + std::fs::write(dir.join("first.pdf"), helvetica_page(body)).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "first.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nThree" })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "first.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "first.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, reopened), ["Alpha first row. Two Three", "Neighbor stays. Neighbor next."]); + ok(&mut a, "text_edit", json!({ "doc": reopened, "page": 1, "paragraph": 1, "text": "Alpha first row.\nTwo\nFour" })); + ok(&mut a, "doc_save", json!({ "doc": reopened, "path": "first-full.pdf", "full": true })); + let again = ok(&mut a, "doc_open", json!({ "path": "first-full.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(paragraph_texts(&mut a, again), ["Alpha first row. Two Four", "Neighbor stays. Neighbor next."]); + let extracted = page_text(&mut a, again); + assert!(extracted[0].contains("Four") && extracted[0].contains("Neighbor stays."), "{extracted:?}"); +} + +/// Unambiguous separated one-pixel rules test the requested scale independently of the +/// conservative local-tone guard. Ragged text can legitimately be retained by that guard. +fn simple_rule_scan_drawn(content: &[u8]) -> Vec { + use pdfcraft_cos::{Document, Object, SaveOptions, Stream, write_full}; + let mut doc = Document::open(std::sync::Arc::new(scan_drawn(content))).unwrap(); + for number in doc.object_numbers() { + let reference = pdfcraft_cos::ObjRef::new(number, doc.generation(number)); + let object = doc.get(reference); + let Object::Stream(image) = &*object else { continue }; + if image.dict.name(b"Subtype") != Some(b"Image") { + continue; + } + let mut data = vec![0xFF; 125 * 400]; + for y in 0..400 { + data[y * 125] &= 0x7F; + data[y * 125 + 124] &= 0xFE; + } + doc.set(reference, Object::Stream(Stream::flate(image.dict.clone(), &data))); + } + 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/automation/tests/it/backdrop.rs b/crates/automation/tests/it/backdrop.rs new file mode 100644 index 0000000000..00570b260c --- /dev/null +++ b/crates/automation/tests/it/backdrop.rs @@ -0,0 +1,269 @@ +//! Add Background on pages that paint their own backdrop, end to end through the tools: what +//! `page_render` draws, `doc_backdrops`, save and reopen, and Remove putting the page back. + +use std::path::PathBuf; + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +/// Two 200 × 100 pt pages saying "Hi": page 1 on a white fill of its own (as browsers write), +/// page 2 on bare paper; a third page is a "scan" (a page-sized grey picture). +fn fixture() -> Vec { + let text = "0 g BT /F1 24 Tf 20 40 Td (Hi) Tj ET"; + let contents = [format!("q 1 1 1 rg 0 0 200 100 re f Q {text}"), text.to_string(), format!("q 200 0 0 100 0 0 cm /Im0 Do Q {text}")]; + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [4 0 R 6 0 R 8 0 R] /Count 3 /MediaBox [0 0 200 100] >>".into(), + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{80}\nendstream" + .into(), + ]; + for (i, c) in contents.iter().enumerate() { + objs.push(format!( + "<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /Im0 3 0 R >> >> >>", + 5 + 2 * i + )); + objs.push(format!("<< /Length {} >>\nstream\n{c}\nendstream", c.len())); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn workdir() -> PathBuf { + named_workdir("main") +} + +/// A folder of its own per test: the tests of this binary run at the same time. +fn named_workdir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("pdfcraft-automation-backdrop-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("a.pdf"), fixture()).unwrap(); + dir +} + +fn ok(a: &mut Automation, tool: &str, args: Value) -> Value { + match a.call(tool, &args) { + Ok(mut c) => match c.remove(0) { + Content::Json(v) => v, + other => panic!("{tool}: expected JSON, got {other:?}"), + }, + Err(e) => panic!("{tool} {args}: {e}"), + } +} + +/// The RGB of a blank spot (top-right corner area) of a page rendered at 72 dpi. +fn blank_spot(a: &mut Automation, doc: u64, page: u64) -> [u8; 3] { + let rendered = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 72 })).unwrap(); + let Content::Png { data, width, .. } = &rendered[0] else { panic!("expected a PNG") }; + let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut pixels).unwrap(); + let channels = info.color_type.samples(); + let at = (10 * *width as usize + 180) * channels; + [pixels[at], pixels[at + 1], pixels[at + 2]] +} + +const RED: [u8; 3] = [255, 0, 0]; +const WHITE: [u8; 3] = [255, 255, 255]; + +#[test] +fn a_background_shows_on_a_page_that_paints_its_own_white_backdrop() { + let dir = workdir(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!( + ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], + json!([{ "page": 1, "backdrop": "fill" }, { "page": 3, "backdrop": "image" }]) + ); + + // Not replacing: the page's own white hides the background, as it always did. + ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000", "replace_backdrop": false })); + assert_eq!(blank_spot(&mut a, doc, 1), WHITE); + assert_eq!(blank_spot(&mut a, doc, 2), RED); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + + // The default replaces it: red on both pages; the scan still hides it. + ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000" })); + assert_eq!(blank_spot(&mut a, doc, 1), RED); + assert_eq!(blank_spot(&mut a, doc, 2), RED); + assert_ne!(blank_spot(&mut a, doc, 3), RED); + assert_eq!(ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], json!([{ "page": 3, "backdrop": "image" }])); + + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" })); + let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(blank_spot(&mut a, saved, 1), RED, "after save and reopen"); + ok(&mut a, "doc_remove_marks", json!({ "doc": saved, "kind": "background" })); + assert_eq!(blank_spot(&mut a, saved, 1), WHITE); + assert_eq!( + ok(&mut a, "doc_backdrops", json!({ "doc": saved, "pages": [1] }))["pages"], + json!([{ "page": 1, "backdrop": "fill" }]), + "the backdrop is back" + ); + let _ = std::fs::remove_dir_all(&dir); +} + +/// One 200 × 100 pt page on a white fill of its own: a line to redact, invisible (`3 Tr`) text +/// that Remove Hidden Information removes, and a line that stays (`0 Tr` again: the render mode +/// outlives `ET`). With `tag`, the content is a +/// backdrop copy carrying that tag, under a background. +fn secret_pdf(tag: Option<&str>) -> Vec { + let fill = if tag.is_some() { "n" } else { "f" }; + let content = format!( + "1 1 1 rg 0 0 200 100 re {fill} 0 g BT /F1 12 Tf 10 70 Td (SECRET-LINE) Tj ET BT 3 Tr /F1 12 Tf 10 45 Td (HIDDEN-LINE) Tj ET BT 0 Tr /F1 12 Tf 10 20 Td (KEEP) Tj ET" + ); + let bg = "q 1 0 0 rg 0 0 200 100 re f Q"; + let (contents, extra) = match tag { + Some(t) => ( + "[5 0 R 6 0 R]", + vec![ + format!("<< /PCMark /Background /Length {} >>\nstream\n{bg}\nendstream", bg.len()), + format!("<< /PCBackdrop {t} /Length {} >>\nstream\n{content}\nendstream", content.len()), + ], + ), + None => ("[5 0 R]", vec![format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())]), + }; + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 100] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Contents {contents} /Resources << /Font << /F1 4 0 R >> >> >>"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + ]; + objs.extend(extra); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// Every stream of a saved file, decoded, joined: what anyone reading the file can recover. +fn every_stream(path: &std::path::Path) -> String { + let doc = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap(); + doc.scan_objects() + .filter_map(|(_, o)| match &*o { + pdfcraft_cos::Object::Stream(s) => Some(String::from_utf8_lossy(&s.decoded().unwrap_or_default()).into_owned()), + _ => None, + }) + .collect::>() + .join("\n") +} + +/// Text a redaction or Remove Hidden Information took off a page whose backdrop a background +/// replaced is gone from the saved file. The backdrop copy's tag used to keep a reference to the +/// page's original content stream, and with it the removed text. Removing the background +/// afterwards still turns the page's white fill back on. +#[test] +fn removed_text_leaves_the_file_when_a_background_replaced_the_pages_backdrop() { + for (remove, gone) in [ + (vec![("redact_mark", json!({ "find": "SECRET-LINE" })), ("redact_apply", json!({}))], "SECRET-LINE"), + (vec![("doc_remove_hidden", json!({ "categories": ["hidden-text"] }))], "HIDDEN-LINE"), + ] { + let dir = named_workdir(gone); + std::fs::write(dir.join("s.pdf"), secret_pdf(None)).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "s.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000" })); + for (tool, args) in &remove { + let mut args = args.clone(); + args["doc"] = json!(doc); + ok(&mut a, tool, args); + } + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true })); + let file = every_stream(&dir.join("out.pdf")); + assert!(file.contains("(KEEP)"), "{gone}: the page's other text is still there: {file}"); + assert!(!file.contains(gone), "{gone} is still in the file: {file}"); + + let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].as_u64().unwrap(); + let removed = ok(&mut a, "doc_remove_marks", json!({ "doc": saved, "kind": "background" })); + assert!(removed.get("warnings").is_none(), "{gone}: {removed}"); + assert_eq!( + ok(&mut a, "doc_backdrops", json!({ "doc": saved }))["pages"], + json!([{ "page": 1, "backdrop": "fill" }]), + "{gone}: the fill is back" + ); + assert_eq!(blank_spot(&mut a, saved, 1), WHITE); + let _ = std::fs::remove_dir_all(&dir); + } +} + +/// When the backdrop can't be turned back on safely, Remove background says which pages keep it +/// off. +#[test] +fn remove_background_warns_about_pages_whose_own_backdrop_stays_off() { + let dir = named_workdir("left-off"); + // A tag whose hash doesn't match the bytes before the fill: they changed since. + let tag = format!("<< /Index 2 /Op (f) /Before <{}> >>", "00".repeat(32)); + std::fs::write(dir.join("t.pdf"), secret_pdf(Some(&tag))).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "t.pdf" }))["doc"].as_u64().unwrap(); + let removed = ok(&mut a, "doc_remove_marks", json!({ "doc": doc, "kind": "background" })); + let warnings = removed["warnings"].as_array().unwrap_or_else(|| panic!("{removed}")); + assert_eq!(warnings.len(), 1, "{removed}"); + assert!(warnings[0].as_str().unwrap().starts_with("page 1 changed after the background was added"), "{removed}"); + assert_eq!(ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], json!([]), "the fill stays off"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Two 200 × 100 pt pages whose full-page `1 g` fill is not a plain white backdrop (#2815 +/// review): page 1 paints it half transparent, through a `gs` that sets `ca` and a later one +/// that only sets the line width; page 2 sends it through a transfer function that maps 1 to 0. +fn graphics_state_pdf() -> Vec { + let invert = "<< /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>"; + let resources = format!("<< /ExtGState << /Transparent << /ca 0.5 >> /LineOnly << /LW 2 >> /Invert << /TR {invert} >> >> >>"); + let contents = ["/Transparent gs /LineOnly gs 1 g 0 0 200 100 re f", "/Invert gs 1 g 0 0 200 100 re f"]; + let mut objs: Vec = + vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 /MediaBox [0 0 200 100] >>".into()]; + for (i, c) in contents.iter().enumerate() { + objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources {resources} >>", 4 + 2 * i)); + objs.push(format!("<< /Length {} >>\nstream\n{c}\nendstream", c.len())); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// #2815 review: a translucent fill (its `ca` left in force by a later `gs`) and a fill through +/// a transfer function are not backdrops, so a red background goes under them and the +/// translucent white still lightens it. +#[test] +fn fills_the_graphics_state_changes_are_not_replaced() { + let dir = named_workdir("graphics-state"); + std::fs::write(dir.join("gs.pdf"), graphics_state_pdf()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "gs.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "doc_backdrops", json!({ "doc": doc }))["pages"], json!([]), "neither fill is a backdrop"); + ok(&mut a, "doc_background", json!({ "doc": doc, "color": "#FF0000" })); + let [r, g, b] = blank_spot(&mut a, doc, 1); + assert_eq!(r, 255, "{:?}", [r, g, b]); + assert!((100..=160).contains(&g) && (100..=160).contains(&b), "half-transparent white over red, not red: {:?}", [r, g, b]); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/automation/tests/it/form_bulk.rs b/crates/automation/tests/it/form_bulk.rs index 8b8904c974..4296cbc891 100644 --- a/crates/automation/tests/it/form_bulk.rs +++ b/crates/automation/tests/it/form_bulk.rs @@ -151,3 +151,26 @@ fn bulk_options_apply_per_field_and_refuse_an_invalid_comb_atomically() { assert_eq!(bytes(&a, id), before); std::fs::remove_dir_all(dir).unwrap(); } + +#[test] +fn button_export_edit_is_one_undo_step_and_survives_save() { + let (mut a, id, dir) = setup("button-export"); + tool(&mut a, "form_add_field", json!({"doc": id, "page": 1, "type": "checkbox", "name": "agree", "rect": [20, 20, 40, 40]})); + tool(&mut a, "form_fill", json!({"doc": id, "values": {"agree": true}})); + let before = bytes(&a, id); + call(&mut a, json!({"doc": id, "field": "agree", "export_value": "Accepted", "widget": 1})); + let f = tool(&mut a, "form_fields", json!({"doc": id})); + let check = f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "agree").unwrap(); + assert_eq!(check["export_values"], json!(["Accepted"])); + assert_eq!(check["value"], true); + tool(&mut a, "edit_undo", json!({"doc": id})); + assert_eq!(bytes(&a, id), before); + tool(&mut a, "edit_redo", json!({"doc": id})); + tool(&mut a, "doc_save", json!({"doc": id, "path": "saved.pdf"})); + let reopened = tool(&mut a, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap(); + let f = tool(&mut a, "form_fields", json!({"doc": reopened})); + let check = f["fields"].as_array().unwrap().iter().find(|f| f["name"] == "agree").unwrap(); + assert_eq!(check["export_values"], json!(["Accepted"])); + assert_eq!(check["value"], true); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index d94aa9f7c4..d36dbe93a9 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -3,8 +3,12 @@ mod attachment_key; mod automation; mod automation_roots; +mod backdrop; mod batch; mod button_caption; mod form_bulk; mod hostile_args; mod optimize_content_boundaries; +mod redact_after_stray_q_form; +mod redact_text_after_q; +mod render_warnings; diff --git a/crates/automation/tests/it/redact_after_stray_q_form.rs b/crates/automation/tests/it/redact_after_stray_q_form.rs new file mode 100644 index 0000000000..a91f872750 --- /dev/null +++ b/crates/automation/tests/it/redact_after_stray_q_form.rs @@ -0,0 +1,67 @@ +//! Search-and-redact of page text drawn after a form XObject with more `Q` than `q`. Find's boxes +//! come from the renderer's text and redaction measures the content itself, so the two must agree +//! that the form's stray `Q`s do not undo the page's `cm` (vendored hayro-interpret patch 68). + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +const CONTENT: &str = "q 1 0 0 1 20 -100 cm BT /F1 12 Tf 72 700 Td (AAAA) Tj ET /X Do \ + BT /F1 12 Tf 72 680 Td (SECRET) Tj ET Q"; +const FORM: &str = "BT /F1 12 Tf 200 0 Td ET Q Q Q"; + +fn pdf() -> Vec { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_owned(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << \ + /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /X 5 0 R >> >> >>" + .to_owned(), + format!("<< /Length {} >>\nstream\n{CONTENT}\nendstream", CONTENT.len()), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 612 792] /Length {} >>\nstream\n{FORM}\nendstream", FORM.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +fn ok(a: &mut Automation, tool: &str, args: Value) -> Value { + match a.call(tool, &args) { + Ok(mut c) => match c.remove(0) { + Content::Json(v) => v, + other => panic!("{tool}: expected JSON, got {other:?}"), + }, + Err(e) => panic!("{tool} {args}: {e}"), + } +} + +fn text(a: &mut Automation, doc: &Value) -> String { + ok(a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap_or_default().to_owned() +} + +#[test] +fn find_and_redact_removes_text_drawn_after_a_form_with_stray_q() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redact-stray-q-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("in.pdf"), pdf()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "in.pdf" }))["doc"].clone(); + assert_eq!(ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "SECRET" }))["marked"], 1); + ok(&mut a, "redact_apply", json!({ "doc": doc })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true })); + let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].clone(); + let after = text(&mut a, &saved); + assert!(!after.contains("SECRET"), "{after:?}"); + assert!(after.contains("AAAA"), "the text before the mark stays: {after:?}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/automation/tests/it/redact_text_after_q.rs b/crates/automation/tests/it/redact_text_after_q.rs new file mode 100644 index 0000000000..fceaffb872 --- /dev/null +++ b/crates/automation/tests/it/redact_text_after_q.rs @@ -0,0 +1,77 @@ +//! Search-and-redact of text that follows a `q`/`Q` inside a text object. `Q` restores the text +//! matrices there (ISO 32000-2 §9.4.1 as corrected by the PDF Association's errata, issue 368), so +//! "SECRET" is drawn from the pen position before `q`, over the "B". Find's boxes come from the +//! renderer's text, which places it there; redaction measures the content itself, and while it +//! carried the pen across `Q` the two disagreed and `redact_apply` refused to change anything. +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +/// The content, the text that stays after SECRET is redacted, and the text that goes with it. +const CASES: [(&str, &str, &str); 3] = [ + // A larger font inside `q`/`Q`: the font and the pen are both restored, so SECRET lies over + // the B and the B goes with it. + ("BT /F1 12 Tf 72 700 Td (AAAA) Tj q /F2 24 Tf (B) Tj Q (SECRET) Tj ET", "AAAA", "B"), + // A move inside `q`/`Q` is undone. + ("BT /F1 12 Tf 72 700 Td (AA) Tj q 100 0 Td Q (SECRET) Tj ET", "AA", "SECRET"), + // The text line matrix is restored too: `T*` starts the next line from the line before `q`. + ("BT /F1 12 Tf 14 TL 72 700 Td (AA) Tj q 100 0 Td Q T* (SECRET) Tj ET", "AA", "SECRET"), +]; + +fn pdf(content: &str) -> Vec { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_owned(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << \ + /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> \ + /F2 << /Type /Font /Subtype /Type1 /BaseFont /Courier >> >> >> >>" + .to_owned(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut out = b"%PDF-2.0\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +fn ok(a: &mut Automation, tool: &str, args: Value) -> Value { + match a.call(tool, &args) { + Ok(mut c) => match c.remove(0) { + Content::Json(v) => v, + other => panic!("{tool}: expected JSON, got {other:?}"), + }, + Err(e) => panic!("{tool} {args}: {e}"), + } +} + +fn text(a: &mut Automation, doc: &Value) -> String { + ok(a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap_or_default().to_owned() +} + +#[test] +fn find_and_redact_removes_text_after_a_q_q_inside_its_text_object() { + for (case, (content, kept, gone)) in CASES.iter().enumerate() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redact-after-q-{}-{case}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("in.pdf"), pdf(content)).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "in.pdf" }))["doc"].clone(); + assert_eq!(ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "SECRET" }))["marked"], 1, "{content}"); + ok(&mut a, "redact_apply", json!({ "doc": doc })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true })); + let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].clone(); + let after = text(&mut a, &saved); + assert!(!after.contains("SECRET") && !after.contains(gone), "{content}: {after:?}"); + assert!(after.contains(kept), "{content}: the text before the mark stays: {after:?}"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/automation/tests/it/render_warnings.rs b/crates/automation/tests/it/render_warnings.rs new file mode 100644 index 0000000000..ff50c11e16 --- /dev/null +++ b/crates/automation/tests/it/render_warnings.rs @@ -0,0 +1,119 @@ +//! `page_render` reports the renderer's warnings next to the PNG, so a headless caller can tell +//! that a picture is incomplete or approximate, as `pdfcraft-cli render` can (#4340). + +use std::path::PathBuf; + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +/// The warning a colour that falls back gives, worded as `pdfcraft-cli render` prints it. +const FALLBACK: &str = "a colour that could not be computed was painted in a fallback colour"; + +/// A one-page 200 × 200 pt PDF that draws `content`, with the colour space `CS1` defined as +/// `[/DeviceN [/Spot1 /Spot2] /DeviceRGB 5 0 R]` and object 5 a calculator function `program`. +fn devicen_pdf(content: &str, program: &str) -> Vec { + format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /ColorSpace << /CS1 [/DeviceN [/Spot1 /Spot2] /DeviceRGB 5 0 R] >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1] /Length {} >> stream\n{program}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len(), + program.len() + ) + .into_bytes() +} + +/// A page whose colour falls back: the tint transform pops more than it is given, so it can't be +/// evaluated (the renderer's own test of the warning uses the same function). +fn fallback_pdf() -> Vec { + devicen_pdf("/CS1 cs 0.5 0.5 scn 0 0 100 100 re f", "{ pop pop pop }") +} + +/// The same page with a tint transform that works: nothing to warn about. +fn clean_pdf() -> Vec { + devicen_pdf("/CS1 cs 0.5 0.5 scn 0 0 100 100 re f", "{ pop pop 0 0 0 }") +} + +/// A fresh folder holding `fallback.pdf` and `clean.pdf`, and an automation rooted there. +fn setup(test: &str) -> (PathBuf, Automation) { + let dir = std::env::temp_dir().join(format!("pdfcraft-render-warnings-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("fallback.pdf"), fallback_pdf()).unwrap(); + std::fs::write(dir.join("clean.pdf"), clean_pdf()).unwrap(); + let a = Automation::new().with_root(&dir).unwrap(); + (dir, a) +} + +fn open(a: &mut Automation, path: &str) -> u64 { + match a.call("doc_open", &json!({ "path": path })).unwrap().remove(0) { + Content::Json(v) => v["doc"].as_u64().unwrap(), + other => panic!("expected JSON: {other:?}"), + } +} + +#[test] +fn page_render_lists_the_warnings_after_the_png() { + let (dir, mut a) = setup("fallback"); + let doc = open(&mut a, "fallback.pdf"); + let out = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(); + assert_eq!(out.len(), 2, "the PNG and the warnings: {out:?}"); + assert!(matches!(out[0], Content::Png { width: 200, height: 200, .. }), "the image comes first, as before"); + let Content::Json(report) = &out[1] else { panic!("expected the warnings as JSON: {:?}", out[1]) }; + assert_eq!(report["page"], 1, "the page is 1-based, like the argument"); + assert_eq!(report["warnings"], json!([FALLBACK])); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn render_preview_lists_the_warnings_after_the_png_too() { + let (dir, mut a) = setup("preview"); + let doc = open(&mut a, "fallback.pdf"); + let out = a.call("render_preview", &json!({ "doc": doc, "max_side": 64 })).unwrap(); + assert!(matches!(out.first(), Some(Content::Png { width: 64, height: 64, .. })), "{out:?}"); + let Some(Content::Json(report)) = out.get(1) else { panic!("expected the warnings as JSON: {out:?}") }; + assert_eq!(report["page"], 1, "the page defaults to the first"); + assert_eq!(report["warnings"], json!([FALLBACK])); + let clean = open(&mut a, "clean.pdf"); + assert_eq!(a.call("render_preview", &json!({ "doc": clean })).unwrap().len(), 1, "a clean page gives the PNG alone"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_clean_page_renders_to_the_png_alone() { + let (dir, mut a) = setup("clean"); + let fallback = open(&mut a, "fallback.pdf"); + let clean = open(&mut a, "clean.pdf"); + // The warning of one render doesn't carry over to the next, nor to another document. + a.call("page_render", &json!({ "doc": fallback, "page": 1, "dpi": 72 })).unwrap(); + let out = a.call("page_render", &json!({ "doc": clean, "page": 1, "dpi": 72 })).unwrap(); + assert_eq!(out.len(), 1, "no warnings, no extra part: {out:?}"); + assert!(matches!(out[0], Content::Png { .. })); + let again = a.call("page_render", &json!({ "doc": fallback, "page": 1, "dpi": 72 })).unwrap(); + assert_eq!(again.len(), 2, "a cached renderer still reports the page's warning: {again:?}"); + let _ = std::fs::remove_dir_all(dir); +} + +/// Over MCP the warnings are a text block after the image and the call's `structuredContent`. +#[cfg(feature = "mcp")] +#[test] +fn mcp_page_render_carries_the_warnings_as_structured_content() { + use pdfcraft_automation::mcp::McpServer; + let (dir, a) = setup("mcp"); + let mut s = McpServer::new(a); + let mut rpc = |id: u64, method: &str, params: Value| -> Value { + let line = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string(); + serde_json::from_str(&s.handle_line(&line).expect("a reply")).unwrap() + }; + rpc(1, "initialize", json!({})); + rpc(2, "tools/call", json!({ "name": "doc_open", "arguments": { "path": "fallback.pdf" } })); + let reply = rpc(3, "tools/call", json!({ "name": "page_render", "arguments": { "doc": 1, "page": 1 } })); + let result = &reply["result"]; + assert_eq!(result["isError"], false, "{reply}"); + assert_eq!(result["content"][0]["type"], "image"); + assert_eq!(result["content"][1]["type"], "text"); + assert_eq!(result["structuredContent"]["warnings"], json!([FALLBACK])); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/cos/README.md b/crates/cos/README.md index ab382219d5..2cf3342954 100644 --- a/crates/cos/README.md +++ b/crates/cos/README.md @@ -24,6 +24,9 @@ let obj = doc.get(ObjRef { num: 12, gen: 0 }); // load (and cache): Arc, resolving first let data = stream.decoded()?; // filters applied on demand +let data = doc.with_direct_parms(&stream).decoded()?; // /DecodeParms given by reference too +let px = doc.decoded_image(&stream)?; // an image's samples, a CCITT fax image's too +let fax = doc.fax_params(&stream); // a CCITT fax image's /DecodeParms, Table 11 defaults doc.set(r, Object::Dict(d)); // edit: lands in the overlay let r = doc.add(obj); // append a new object @@ -87,5 +90,6 @@ recognisable in a diff and byte-stable when re-serialized. - Linearized output, Fast Web View (`core.linearization`, M11). - Structural validation against the Arlington model (`core.arlington-validation`), which will live in the reserved `arlington` crate. -- Image codecs. `pdfcraft-filters` carries the non-image filters; DCT, JPX, JBIG2 and CCITT are - still decoded through the bootstrap renderer (`core.image-filters`, M2.6). +- Image codecs. `pdfcraft-filters` carries the non-image filters and CCITT fax decoding + (`Document::decoded_image`); DCT, JPX and JBIG2 are still decoded through the bootstrap renderer + (`core.image-filters`, M2.6). diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 0fac1591a9..0d9b84b749 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -5,11 +5,12 @@ //! Edits never touch the original bytes; the writer appends them (incremental save) or rewrites //! the file (full save). +use std::borrow::Cow; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, Mutex, Weak}; use crate::CosError; -use crate::object::{Dict, ObjRef, Object}; +use crate::object::{Dict, MAX_DECODED, ObjRef, Object, Stream}; use crate::parser::{EndstreamIndex, Lexer, is_whitespace, parse_indirect, parse_indirect_shared}; mod objects; diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 8744a3ea3e..4f05d03ae0 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -1,7 +1,7 @@ use super::reader::find; use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, objstm_member, owned_bytes}; use super::xref::{Revision, XrefEntry}; -use super::{Arc, CosError, Dict, Document, Lexer, ObjRef, Object, is_whitespace, parse_indirect_shared}; +use super::{Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared}; impl Document { pub fn bytes(&self) -> &Arc> { @@ -158,11 +158,148 @@ impl Document { } } + /// `s` with the `/DecodeParms` its filters use made direct, so [`Stream::decoded`] reads them: + /// an indirect dictionary resolved, and of an array (itself perhaps indirect), the entries at + /// the filters' places (`/Crypt` counts, ISO 32000-2 §7.4.1) resolved, the rest dropped. Of + /// each dictionary only the entries the decoders read are kept, numbers and flags given by + /// reference (`/Predictor 7 0 R`) resolved; `/JBIG2Globals` stays a reference. A `/Crypt` + /// entry, which no decoder reads ([`Stream::filters`] leaves it out), is null. `s` itself + /// when none of those is indirect, or when the other filters are more than any decoder takes + /// ([`pdfcraft_filters::MAX_CHAIN`]). Only small values are copied, so a file can't have a + /// large object copied once for each key and filter. + pub fn with_direct_parms<'s>(&self, s: &'s Stream) -> Cow<'s, Stream> { + let Some(p) = s.dict.get(b"DecodeParms") else { return Cow::Borrowed(s) }; + let crypt = |f: &Object| matches!(f.as_name(), Some(b"Crypt")); + let filters: Vec = match s.dict.get(b"Filter") { + Some(Object::Array(a)) => a.iter().map(crypt).collect(), + Some(f) => vec![crypt(f)], + None => Vec::new(), + }; + if filters.iter().filter(|c| !**c).count() > pdfcraft_filters::MAX_CHAIN { + return Cow::Borrowed(s); + } + let mut changed = matches!(p, Object::Ref(_)); + // Its numbers and flags kept, resolved; anything else is no filter's parameters (null). + let one = |e: &Object, changed: &mut bool| -> Object { + *changed |= matches!(e, Object::Ref(_)); + let Object::Dict(d) = &*self.held(e) else { return Object::Null }; + let mut made = Dict::new(); + for key in DECODE_PARMS_KEYS { + let Some(v) = d.get(key) else { continue }; + *changed |= matches!(v, Object::Ref(_)); + let v = self.held(v); + if matches!(*v, Object::Int(_) | Object::Real(_) | Object::Bool(_)) { + made.set(key.to_vec(), (*v).clone()); + } + } + if let Some(g @ Object::Ref(_)) = d.get(b"JBIG2Globals") { + made.set(b"JBIG2Globals".to_vec(), g.clone()); + } + Object::Dict(made) + }; + let made = match &*self.held(p) { + Object::Array(a) => { + Object::Array(a.iter().zip(&filters).map(|(e, crypt)| if *crypt { Object::Null } else { one(e, &mut changed) }).collect()) + } + _ => one(p, &mut changed), + }; + if !changed { + return Cow::Borrowed(s); + } + let mut s = s.clone(); + s.dict.set(b"DecodeParms".to_vec(), made); + Cow::Owned(s) + } + + /// `o`, or the object it refers to, without copying either. + fn held<'o>(&self, o: &'o Object) -> Held<'o> { + match o { + Object::Ref(_) => Held::Resolved(self.resolve(o)), + o => Held::Direct(o), + } + } + /// Resolve and return a dictionary (a stream's dictionary counts). pub fn dict(&self, o: &Object) -> Option { self.resolve(o).as_dict().cloned() } + /// The CCITT parameters of image `s` when its last filter is `CCITTFaxDecode`: its + /// `/DecodeParms` (resolved; the dictionary of a lone filter, or its entry in an array), with the + /// defaults of ISO 32000-2 Table 11 for what that leaves out. `None` for another image, and for + /// one whose `/Filter` isn't a name or an array of names. + pub fn fax_params(&self, s: &Stream) -> Option { + let chain = s.readable_filters().ok()?; + if !matches!(chain.last(), Some((pdfcraft_filters::Filter::CcittFax, _))) { + return None; + } + // Its place in /Filter, /Crypt included, pairs it with its /DecodeParms entry (§7.4.1): the + // last filter but a /Crypt, which `filters` leaves out wherever it stands. + let index = match s.dict.get(b"Filter") { + Some(Object::Array(a)) => a.iter().rposition(|f| f.as_name() != Some(b"Crypt".as_slice())).unwrap_or(0), + _ => 0, + }; + let parms = match s.dict.get(b"DecodeParms").map(|p| self.resolve(p)).as_deref() { + Some(Object::Array(a)) => a.get(index).and_then(|p| self.dict(p)), + Some(p) if index == 0 => p.as_dict().cloned(), + _ => None, + } + .unwrap_or_default(); + let int = |key: &[u8], default: i64| parms.get(key).and_then(|v| self.resolve(v).as_int()).unwrap_or(default); + let flag = |key: &[u8], default: bool| match parms.get(key).map(|v| self.resolve(v)).as_deref() { + Some(Object::Bool(b)) => *b, + _ => default, + }; + let d = pdfcraft_filters::CcittParams::default(); + Some(pdfcraft_filters::CcittParams { + k: int(b"K", d.k), + columns: int(b"Columns", d.columns), + rows: int(b"Rows", d.rows), + end_of_line: flag(b"EndOfLine", d.end_of_line), + encoded_byte_align: flag(b"EncodedByteAlign", d.encoded_byte_align), + end_of_block: flag(b"EndOfBlock", d.end_of_block), + black_is_1: flag(b"BlackIs1", d.black_is_1), + }) + } + + /// The samples of image `s`, decoded: through its filters ([`Stream::decoded`]), or, for a + /// CCITT fax image (its last filter), through the filters before it and then + /// [`pdfcraft_filters::decode_ccitt`] with its [`Document::fax_params`], as `CCITTFaxDecode` + /// gives them (ISO 32000-2 §7.4.6). Parameters given by reference count, for every filter + /// ([`Document::with_direct_parms`]). A fax image's `/Columns` has to be its `/Width`. Up to + /// its `/Height` rows are decoded, whatever `/Rows` says, within [`MAX_DECODED`] bytes; damaged + /// data gives fewer, so a caller checks it has the rows it needs. + pub fn decoded_image(&self, s: &Stream) -> Result, CosError> { + self.decoded_image_within(s, MAX_DECODED) + } + + /// [`Document::decoded_image`] for an image whose decoding has a tighter bound than + /// [`MAX_DECODED`]: no stage, the filters before a fax decoder included, may produce more than + /// `max` bytes. + pub fn decoded_image_within(&self, s: &Stream, max: usize) -> Result, CosError> { + let max = max.min(MAX_DECODED); + let s = &*self.with_direct_parms(s); + let Some(p) = self.fax_params(s) else { return s.decoded_within(max) }; + // As many rows as the image has: a /Rows below /Height would cut the picture short, and + // rows past /Height aren't drawn. + let p = pdfcraft_filters::CcittParams { rows: 0, ..p }; + if s.dict.int(b"Width") != Some(p.columns) { + return Err(CosError::Filter("a fax image's /Columns aren't its /Width".into())); + } + let height = s.dict.int(b"Height").and_then(|h| u32::try_from(h).ok()).unwrap_or(0); + let stride = usize::try_from(p.columns).unwrap_or(usize::MAX).div_ceil(8).max(1); + let rows = u32::try_from(max / stride).unwrap_or(u32::MAX).min(height); + // The filters before it, such as ASCII85Decode, are undone first. + let chain = s.readable_filters().map_err(|e| CosError::Filter(e.to_string()))?; + let data = match chain.split_last() { + Some((_, before)) if !before.is_empty() => { + Cow::Owned(pdfcraft_filters::decode_tolerant(before, &s.raw, max).map_err(|e| CosError::Filter(e.to_string()))?.0) + } + _ => Cow::Borrowed(&s.raw[..]), + }; + pdfcraft_filters::decode_ccitt(&data, &p, rows).map_err(|e| CosError::Filter(e.to_string())) + } + pub fn try_get(&self, num: u32) -> Result, CosError> { match self.overlay.get(&num) { Some(Slot::Set(_, o)) => return Ok(o.clone()), @@ -570,3 +707,40 @@ impl Document { matches!(self.try_get(r.num).as_deref(), Ok(Object::Dict(d)) if d.name(b"Type") == Some(b"Catalog") || d.contains(b"Pages")) } } + +/// The `/DecodeParms` entries the decoders read (ISO 32000-2 Tables 8, 11 and 13: the Flate and +/// LZW predictors, CCITT, DCT's colour transform): numbers and flags a file may give by +/// reference, which [`Dict::int`] doesn't follow. +const DECODE_PARMS_KEYS: [&[u8]; 13] = [ + b"Predictor", + b"Colors", + b"BitsPerComponent", + b"Columns", + b"EarlyChange", + b"K", + b"Rows", + b"EndOfLine", + b"EncodedByteAlign", + b"EndOfBlock", + b"BlackIs1", + b"DamagedRowsBeforeError", + b"ColorTransform", +]; + +/// An object read in place, or the object a reference names as the document holds it: either way +/// nothing is copied ([`Document::held`]). +enum Held<'o> { + Direct(&'o Object), + Resolved(Arc), +} + +impl std::ops::Deref for Held<'_> { + type Target = Object; + + fn deref(&self) -> &Object { + match self { + Held::Direct(o) => o, + Held::Resolved(o) => o, + } + } +} diff --git a/crates/cos/src/document/open.rs b/crates/cos/src/document/open.rs index d41d564e33..57d697ed66 100644 --- a/crates/cos/src/document/open.rs +++ b/crates/cos/src/document/open.rs @@ -1,4 +1,4 @@ -use super::reader::{find, generated_id}; +use super::reader::find; use super::state::Credentials; use super::xref::XrefIndex; use super::{Arc, BTreeMap, CosError, Dict, Document, ObjRef, Object}; @@ -271,8 +271,7 @@ impl Document { let id0 = match self.trailer.get(b"ID") { Some(Object::Array(a)) if a.len() == 2 => a[0].as_string().map(|s| s.bytes.clone()).unwrap_or_default(), _ => { - let mut id = generated_id(¶ms.seed); - id.truncate(16); + let id = pdfcraft_crypt::file_id(¶ms.seed); let s = Object::String(crate::PdfString { bytes: id.clone(), hex: true }); self.trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s])); id diff --git a/crates/cos/src/document/reader.rs b/crates/cos/src/document/reader.rs index 66129c1423..d21bf9403f 100644 --- a/crates/cos/src/document/reader.rs +++ b/crates/cos/src/document/reader.rs @@ -818,18 +818,6 @@ pub(super) fn rfind(hay: &[u8], needle: &[u8], from: usize) -> Option { (from..=hay.len() - needle.len()).rev().find(|&i| &hay[i..i + needle.len()] == needle) } -/// A file identifier derived from entropy supplied by the caller. -pub(super) fn generated_id(seed: &[u8; 32]) -> Vec { - use std::hash::{Hash, Hasher}; - let mut out = Vec::new(); - for i in 0..2u8 { - let mut h = std::collections::hash_map::DefaultHasher::new(); - (seed, i, b"pdfcraft id").hash(&mut h); - out.extend_from_slice(&h.finish().to_be_bytes()); - } - out -} - /// The object number a classic xref subsection really starts at. A common producer bug heads the /// table `1 n` and then writes object 0's free entry (`0000000000 65535 f`) as its first row, so /// every row would be read one number too high and object 1 as free. Such a subsection is read diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index bf22f23d76..f5e36f9c83 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -1,5 +1,210 @@ use super::*; +/// /DecodeParms given by reference, whole, as array entries or as the numbers inside, are read +/// once made direct: here a Flate predictor, which the stream alone doesn't see. Only the entries the filters use are +/// resolved, and nothing is for a chain longer than a decoder takes. +#[test] +fn decode_parms_given_by_reference_are_read_once_direct() { + let rows = [1u8, 2, 3, 4, 5, 6, 7, 8]; + let params = pdfcraft_filters::Params { predictor: 12, colors: 1, bits_per_component: 8, columns: 4, early_change: 1 }; + let coded = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &rows).unwrap(); + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(12)); + parms.set(b"Columns".to_vec(), Object::Int(4)); + let mut doc = Document::new_empty(); + let r = doc.add(Object::Dict(parms.clone())); + let array = doc.add(Object::Array(vec![Object::Ref(r)])); + let stream = |filters: usize, p: Object| { + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"); filters])); + d.set(b"DecodeParms".to_vec(), p); + Stream::from_raw(d, coded.clone()) + }; + for p in [Object::Ref(r), Object::Array(vec![Object::Ref(r)]), Object::Ref(array)] { + let s = stream(1, p.clone()); + assert_ne!(s.decoded().unwrap(), rows, "{p:?} read as it is"); + assert_eq!(doc.with_direct_parms(&s).decoded().unwrap(), rows, "{p:?}"); + } + // A number inside given by reference, which `Dict::int` doesn't follow: in the dictionary + // itself, and in an array's entry. + let twelve = doc.add(Object::Int(12)); + let mut inner = parms.clone(); + inner.set(b"Predictor".to_vec(), Object::Ref(twelve)); + for p in [Object::Dict(inner.clone()), Object::Array(vec![Object::Dict(inner)])] { + let s = stream(1, p.clone()); + assert_ne!(s.decoded().unwrap(), rows, "{p:?} read as it is"); + assert_eq!(doc.with_direct_parms(&s).decoded().unwrap(), rows, "{p:?}"); + } + // Only numbers and flags are copied: a key naming a big array, or an entry that is no + // dictionary, is left out (a file could name one big object from every key and filter). + let big = doc.add(Object::Array(vec![Object::Int(1); 100_000])); + let mut hostile = Dict::new(); + hostile.set(b"Predictor".to_vec(), Object::Ref(big)); + hostile.set(b"Columns".to_vec(), Object::Ref(big)); + hostile.set(b"Colors".to_vec(), Object::Ref(twelve)); + let entry = doc.add(Object::Dict(hostile)); + let two = stream(2, Object::Array(vec![Object::Ref(entry), Object::Ref(big)])); + let made = doc.with_direct_parms(&two); + let made = made.dict.get(b"DecodeParms").and_then(Object::as_array).unwrap(); + let first = made[0].as_dict().unwrap(); + assert_eq!((first.contains(b"Predictor"), first.contains(b"Columns"), first.int(b"Colors")), (false, false, Some(12))); + assert!(matches!(made[1], Object::Null), "{:?}", made[1]); + let direct = stream(1, Object::Dict(parms)); + assert!(matches!(doc.with_direct_parms(&direct), Cow::Borrowed(_)), "nothing to resolve"); + assert_eq!(direct.decoded().unwrap(), rows); + // Entries past the filters' are left out, unresolved; so is everything past the chain limit. + let many = stream(1, Object::Array(vec![Object::Ref(r); 1000])); + let made = doc.with_direct_parms(&many); + assert_eq!(made.dict.get(b"DecodeParms").and_then(Object::as_array).map(Vec::len), Some(1)); + let long = stream(pdfcraft_filters::MAX_CHAIN + 1, Object::Array(vec![Object::Ref(r); 9])); + assert!(matches!(doc.with_direct_parms(&long), Cow::Borrowed(_))); + // A chain as long as a decoder takes is resolved, and a `/Crypt` in it doesn't count, as + // the decoders leave it out: ASCIIHex as often as the limit allows, then the predictor's + // Flate, its parameters by reference. + let hex = |b: &[u8]| b.iter().flat_map(|x| format!("{x:02X}").into_bytes()).chain(*b">").collect::>(); + let wrapped = (1..pdfcraft_filters::MAX_CHAIN).fold(coded.clone(), |data, _| hex(&data)); + for crypt in [false, true] { + let mut names = vec![Object::name("ASCIIHexDecode"); pdfcraft_filters::MAX_CHAIN - 1]; + names.push(Object::name("FlateDecode")); + if crypt { + names.insert(0, Object::name("Crypt")); + } + let mut entries = vec![Object::Null; names.len() - 1]; + entries.push(Object::Ref(r)); + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::Array(names)); + d.set(b"DecodeParms".to_vec(), Object::Array(entries)); + let s = Stream::from_raw(d, wrapped.clone()); + assert_eq!(doc.with_direct_parms(&s).decoded().unwrap(), rows, "with /Crypt: {crypt}"); + } +} + +/// A CCITT fax image reads through its /DecodeParms, given directly, in an array or by +/// reference (its numbers too), with /BlackIs1, and behind other filters; one whose /Columns +/// aren't its /Width is refused, /Height rows are decoded whatever /Rows says, and other images +/// decode through their filters. +#[test] +fn fax_images_decode_with_their_parameters() { + // 10 × 3: the first five samples of each row white, the rest black. + let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0]; + let coded = pdfcraft_filters::encode_g4(&bits, 10, 3).unwrap(); + let mut doc = Document::new_empty(); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(10)); + let indirect = doc.add(Object::Dict(parms.clone())); + let image = |doc: &Document, parms: Option, width: i64, height: i64| { + let mut d = Dict::new(); + d.set(b"Width".to_vec(), Object::Int(width)); + d.set(b"Height".to_vec(), Object::Int(height)); + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + if let Some(p) = parms { + d.set(b"DecodeParms".to_vec(), p); + } + doc.decoded_image(&Stream::from_raw(d, coded.clone())) + }; + for p in [ + Object::Dict(parms.clone()), + Object::Array(vec![Object::Dict(parms.clone())]), + Object::Ref(indirect), + Object::Array(vec![Object::Ref(indirect)]), + ] { + assert_eq!(image(&doc, Some(p.clone()), 10, 3).unwrap(), bits, "{p:?}"); + } + let mut inverted = parms.clone(); + inverted.set(b"BlackIs1".to_vec(), Object::Bool(true)); + assert_eq!( + image(&doc, Some(Object::Dict(inverted)), 10, 3).unwrap(), + [0b0000_0111, 0b1100_0000, 0b0000_0111, 0b1100_0000, 0b0000_0111, 0b1100_0000] + ); + assert_eq!(image(&doc, Some(Object::Dict(parms.clone())), 10, 2).unwrap(), bits[..4], "at most /Height rows"); + // Within a bound: no more rows than it holds. + let mut bounded = Dict::new(); + bounded.set(b"Width".to_vec(), Object::Int(10)); + bounded.set(b"Height".to_vec(), Object::Int(3)); + bounded.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + bounded.set(b"DecodeParms".to_vec(), Object::Dict(parms.clone())); + assert_eq!(doc.decoded_image_within(&Stream::from_raw(bounded, coded.clone()), 5).unwrap(), bits[..4], "two rows of two bytes in five"); + let mut by_reference = Dict::new(); + by_reference.set(b"K".to_vec(), Object::Ref(doc.add(Object::Int(-1)))); + by_reference.set(b"Columns".to_vec(), Object::Ref(doc.add(Object::Int(10)))); + assert_eq!(image(&doc, Some(Object::Dict(by_reference)), 10, 3).unwrap(), bits, "/K and /Columns by reference"); + let mut understated = parms.clone(); + understated.set(b"Rows".to_vec(), Object::Int(1)); + assert_eq!(image(&doc, Some(Object::Dict(understated.clone())), 10, 3).unwrap(), bits, "/Height rows, whatever /Rows says"); + // The parameters as the file gives them, /Rows included, and Table 11's defaults. + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(understated)); + let p = doc.fax_params(&Stream::from_raw(d.clone(), Vec::new())).unwrap(); + assert_eq!((p.k, p.columns, p.rows, p.end_of_block, p.black_is_1), (-1, 10, 1, true, false)); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + assert!(doc.fax_params(&Stream::from_raw(d, Vec::new())).is_none(), "not a fax image"); + assert!(matches!(image(&doc, Some(Object::Dict(parms.clone())), 12, 3), Err(CosError::Filter(_))), "/Columns other than /Width"); + assert!(matches!(image(&doc, None, 10, 3), Err(CosError::Filter(_))), "the default 1728 /Columns"); + let mut plain = Dict::new(); + plain.set(b"Filter".to_vec(), Object::name("FlateDecode")); + assert_eq!(doc.decoded_image(&Stream::from_raw(plain, pdfcraft_filters::encode_flate(&bits))).unwrap(), bits); + + // Behind other filters, its parameters at its own place in the array (/Crypt counts). + let behind = |filters: &[Object], parms: &[Object], data: Vec| { + let mut d = Dict::new(); + d.set(b"Width".to_vec(), Object::Int(10)); + d.set(b"Height".to_vec(), Object::Int(3)); + d.set(b"Filter".to_vec(), Object::Array(filters.to_vec())); + d.set(b"DecodeParms".to_vec(), Object::Array(parms.to_vec())); + doc.decoded_image(&Stream::from_raw(d, data)) + }; + let (fax, null, own) = (Object::name("CCITTFaxDecode"), Object::Null, Object::Dict(parms.clone())); + let hex = coded.iter().map(|b| format!("{b:02x}")).chain([">".to_string()]).collect::().into_bytes(); + let flated = pdfcraft_filters::encode_flate(&coded); + let ahx = [Object::name("AHx"), fax.clone()]; + assert_eq!(behind(&ahx, &[null.clone(), Object::Ref(indirect)], hex.clone()).unwrap(), bits, "behind ASCIIHexDecode"); + assert_eq!( + behind(&[Object::name("FlateDecode"), Object::name("CCF")], &[null.clone(), own.clone()], flated.clone()).unwrap(), + bits, + "behind Flate" + ); + assert_eq!(behind(&[Object::name("Crypt"), fax.clone()], &[null.clone(), own.clone()], coded.clone()).unwrap(), bits, "after /Crypt"); + // A /Crypt belongs first (§7.4.10); one after the fax filter leaves it its own entry. + assert_eq!(behind(&[fax.clone(), Object::name("Crypt")], &[own.clone(), null.clone()], coded.clone()).unwrap(), bits, "before /Crypt"); + assert!(behind(&ahx, &[own.clone(), null.clone()], hex).is_err(), "the first entry is the other filter's"); + assert!(behind(&[fax.clone(), Object::name("FlateDecode")], &[own.clone(), null.clone()], flated).is_err(), "CCITT, then Flate"); + assert!(behind(&[Object::Int(5), fax], &[null, own], coded.clone()).is_err(), "a /Filter entry that isn't a name"); +} + +#[test] +fn a_missing_file_id_is_derived_from_the_encryption_seed() { + let mut doc = Document::new_empty(); + let seed = [9u8; 32]; + doc.set_encryption(&pdfcraft_crypt::NewEncryption { + algorithm: pdfcraft_crypt::Algorithm::Aes256, + user_password: "user", + owner_password: "owner", + permissions: -4, + encrypt_metadata: true, + seed, + }) + .unwrap(); + let id = doc.trailer().get(b"ID").and_then(|o| o.as_array()).and_then(|a| a.first()).and_then(|o| o.as_string()).map(|s| s.bytes.clone()); + assert_eq!(id.as_deref(), Some(pdfcraft_crypt::file_id(&seed).as_slice())); + + let mut kept = Document::new_empty(); + let fixed = Object::String(crate::PdfString { bytes: b"0123456789abcdef".to_vec(), hex: true }); + kept.trailer_mut().set(b"ID".to_vec(), Object::Array(vec![fixed.clone(), fixed])); + kept.set_encryption(&pdfcraft_crypt::NewEncryption { + algorithm: pdfcraft_crypt::Algorithm::Aes256, + user_password: "user", + owner_password: "owner", + permissions: -4, + encrypt_metadata: true, + seed, + }) + .unwrap(); + let still = kept.trailer().get(b"ID").and_then(|o| o.as_array()).and_then(|a| a.first()).and_then(|o| o.as_string()).map(|s| s.bytes.clone()); + assert_eq!(still.as_deref(), Some(b"0123456789abcdef".as_slice())); +} + #[test] fn a_read_state_matches_a_clone_and_no_changed_document() { let mut doc = Document::new_empty(); diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index c9b2ff9a36..17ee7844cf 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -261,7 +261,7 @@ impl Stream { /// [`Stream::filters`], or an error when `/Filter` is present but is not a name or an /// array of names (an indirect reference, say): the data is not unfiltered, so it must not /// read as decoded. - fn readable_filters(&self) -> Result, pdfcraft_filters::FilterError> { + pub(crate) fn readable_filters(&self) -> Result, pdfcraft_filters::FilterError> { let readable = match self.dict.get(b"Filter") { None | Some(Object::Null | Object::Name(_)) => true, Some(Object::Array(a)) => a.iter().all(|f| f.as_name().is_some()), diff --git a/crates/create/Cargo.toml b/crates/create/Cargo.toml index fa649de47d..b41caa2b88 100644 --- a/crates/create/Cargo.toml +++ b/crates/create/Cargo.toml @@ -10,8 +10,13 @@ workspace = true [dependencies] pdfcraft-cos = { workspace = true } +pdfcraft-filters = { workspace = true } pdfcraft-model = { workspace = true } pdfcraft-fonts = { workspace = true } +# For `image::bmp_with_pixel_offset`, shared with XFA pictures. +pdfcraft-xfa = { workspace = true } +# Already in the graph through hayro: decodes JBIG2 images for Export ▸ Images. +hayro-jbig2.workspace = true png = "0.18" tiff = "0.11" image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] } @@ -22,5 +27,8 @@ thiserror = { workspace = true } encoding_rs = "0.8" [dev-dependencies] +# An independent CCITT decoder, to check Create PDF's Group 4 images. +hayro-ccitt.workspace = true image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] } hayro-syntax = { workspace = true } +pdfcraft-filters = { workspace = true } diff --git a/crates/create/README.md b/crates/create/README.md index d1561a6333..d359eed9ae 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -6,7 +6,9 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: - `from_images(&[(name, bytes)])`: one page per image, sized from the image's resolution (PNG `pHYs`, JPEG JFIF density; 72 dpi when absent). JPEG data is embedded as is (`/DCTDecode`, grey, RGB or Adobe-inverted CMYK); PNG is decoded and stored with Flate, - with transparency as a soft mask. An ICC profile in the file (JPEG `APP2 ICC_PROFILE`, PNG + with transparency as a soft mask. A picture of only pure black and white (a 1-bit PNG or TIFF, + eight-bit grey, a two-colour palette, RGB without a profile) is stored at one bit per pixel, as + CCITT Group 4 or Flate, whichever is smaller as stored (`pdfcraft_filters::encode_g4`). An ICC profile in the file (JPEG `APP2 ICC_PROFILE`, PNG `iCCP`) that matches the image's colour space is kept as an `/ICCBased` colour space with the device space as `/Alternate`; images with the same profile share one profile object. JPEG metadata scanning skips marker fill bytes and reads application segments after the @@ -25,9 +27,20 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: Every function returns a `pdfcraft_cos::Document`; the caller writes it. Image export and Save Image As apply each lossless image's Decode pair before converting its color components. +JBIG2 images and masks (JBIG2 as their only filter) are decoded with `hayro-jbig2`, their `/JBIG2Globals` included, +as `JBIG2Decode` gives them: black as 0. An image whose JBIG2 page has another width than the image, or more +pixels than an exported image may have, or whose globals decode to more than 32 MiB (they are decoded before the +page's size is known, so they have a limit of their own), is skipped; such a mask is left out and the image +exported opaque, as JPX and CCITT masks are. Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals map samples to opacity before the mask is resized, including reversed ranges and indirect entries. The existing 16-bit path still uses each sample's high byte. JPEG image Decode handling remains limited. ICC transforms and calibrated color conversion remain outside this export path. +CCITT fax images (Group 3 and 4) are decoded for export through `pdfcraft_cos::Document::decoded_image` with their +`/DecodeParms` (`/Columns` has to be the image's width; up to `/Height` rows, whatever `/Rows` says), behind other filters such as +`ASCII85Decode` too. Byte-aligned Group 3 rows are read by their end-of-line codes, whether the fill starts each code on a byte or +ends it on one, when the data starts with one (T.4 sends one before the first row), and on bytes otherwise, whatever `/EndOfLine` +says; data that comes up short is reported. Data with codes but none before the first row is read on bytes, and is reported or, +at `/K 0`, may come out wrong. JPEG 2000 and JBIG2 images are still reported, not exported. diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 7828952edc..0fc6297e94 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -1,7 +1,7 @@ //! Export a PDF ▸ Image ▸ Export all images, and Edit ▸ Save image as: the images that pages //! use, as files. JPEG images are written unchanged; other images, and JPEG images with //! transparency or with CMYK samples a viewer would read inverted, are decoded and written as PNG with their soft mask, stencil mask or colour -//! key as alpha. Images PdfCraft can't decode yet (JPEG 2000, JBIG2, CCITT, separations) are +//! key as alpha. Images PdfCraft can't decode yet (JPEG 2000, separations) are //! reported, never silently left out. use std::collections::HashSet; @@ -100,6 +100,116 @@ fn filters(s: &Stream) -> Vec> { } } +/// The samples of image `s`, decoded: through its filters, with a `/DecodeParms` dictionary given +/// by reference, or as an array's entry, or with numbers inside by reference, read too (a predictor +/// given so would otherwise be lost), or, for a JBIG2 image +/// (its only filter), through a JBIG2 decoder with its `/JBIG2Globals`, as rows of one-bit samples +/// each starting on a byte, with black as 0, as `JBIG2Decode` gives them (ISO 32000-2 §7.4.7). +fn decoded(doc: &Document, s: &Stream) -> Result, String> { + match filters(s).as_slice() { + [f] if f == b"JBIG2Decode" => jbig2(doc, s), + _ => doc.decoded_image(s).map_err(|e| e.to_string()), + } +} + +/// The `/DecodeParms` of a stream with one filter: a dictionary, or the one in an array (7.4.2). +fn decode_parms(doc: &Document, s: &Stream) -> Dict { + let Some(p) = s.dict.get(b"DecodeParms").map(|p| doc.resolve(p)) else { return Dict::default() }; + let p = match &*p { + Object::Array(a) => match a.first() { + Some(first) => doc.resolve(first), + None => return Dict::default(), + }, + _ => p, + }; + p.as_dict().cloned().unwrap_or_default() +} + +/// The most an image's `/JBIG2Globals` decode to: the symbol and pattern dictionaries an encoder +/// shares between pages, themselves coded, so far less than any stream may decode to +/// ([`pdfcraft_cos::MAX_DECODED`], 1 GiB). Globals past it refuse the image. +const MAX_GLOBALS: usize = 32 << 20; + +/// [`decoded`] for a JBIG2 image. Its JBIG2 page has to be the image's width, which the caller +/// checked against the export's size limit, and within that limit itself: the decoder decodes the +/// whole page, rows past the image's height too, so a small `/Height` over a far taller page +/// would cost more than the limit allows. Rows past the image's height are dropped, and too few +/// rows are refused by the caller. Globals that can't be read refuse the image, rather than +/// decoding it without them. They are decoded before the page's size is known, so the page's +/// limit can't bound them; they have [`MAX_GLOBALS`] of their own. +fn jbig2(doc: &Document, s: &Stream) -> Result, String> { + let globals = match decode_parms(doc, s).get(b"JBIG2Globals").map(|g| doc.resolve(g)).as_deref() { + Some(Object::Stream(g)) => { + Some(doc.with_direct_parms(g).decoded_within(MAX_GLOBALS).map_err(|e| format!("the JBIG2 globals can't be read: {e}"))?) + } + _ => None, + }; + let image = hayro_jbig2::Image::new_embedded(&s.raw, globals.as_deref()).map_err(|e| format!("the JBIG2 image can't be read: {e}"))?; + if s.dict.int(b"Width") != Some(i64::from(image.width())) { + return Err("the JBIG2 image's width isn't the image's width".into()); + } + if u64::from(image.width()).saturating_mul(u64::from(image.height())) > MAX_PIXELS { + return Err(format!("the JBIG2 page, {} × {}, is too large to export", image.width(), image.height())); + } + let height = s.dict.int(b"Height").and_then(|h| usize::try_from(h).ok()).unwrap_or(0); + let mut rows = Rows::new(image.width() as usize, height); + image.decode(&mut rows).map_err(|e| format!("the JBIG2 image can't be decoded: {e}"))?; + Ok(rows.out) +} + +/// Rows of one-bit samples, each starting on a byte, filled from a decoder's pixels: `columns` +/// a row and at most `rows` rows. Pixels past a row's end and rows past the last are dropped, so +/// the output is bounded by the size the caller gave. +struct Rows { + out: Vec, + row: Vec, + x: usize, + columns: usize, + rows: usize, + done: usize, +} + +impl Rows { + fn new(columns: usize, rows: usize) -> Self { + Rows { out: Vec::new(), row: vec![0; columns.div_ceil(8)], x: 0, columns, rows, done: 0 } + } + + /// `count` pixels, as 1 samples when `one`. + fn push(&mut self, one: bool, count: usize) { + let end = self.x.saturating_add(count); + if one { + for x in self.x..end.min(self.columns) { + if let Some(b) = self.row.get_mut(x / 8) { + *b |= 0x80 >> (x % 8); + } + } + } + self.x = end; + } + + fn end_row(&mut self) { + if self.done < self.rows { + self.out.extend_from_slice(&self.row); + self.done += 1; + } + self.row.fill(0); + self.x = 0; + } +} + +/// JBIG2 codes black as 1, which `JBIG2Decode` gives as 0. +impl hayro_jbig2::Decoder for Rows { + fn push_pixel(&mut self, black: bool) { + self.push(!black, 1); + } + fn push_pixel_chunk(&mut self, black: bool, chunks: u32) { + self.push(!black, (chunks as usize).saturating_mul(8)); + } + fn next_line(&mut self) { + self.end_row(); + } +} + /// The file for one image: its JPEG data as is, or a PNG. An image with transparency (a soft /// mask, a stencil `/Mask` or a colour-key `/Mask`) is always a PNG with that transparency as /// alpha, JPEG images included, so cut-out images keep their transparent background. @@ -108,8 +218,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> let jpeg = matches!(f.last().map(Vec::as_slice), Some(b"DCTDecode" | b"DCT")) && f.len() == 1; match f.last().map(Vec::as_slice) { Some(b"JPXDecode") => return Err("JPEG 2000 images can't be exported yet".into()), - Some(b"JBIG2Decode") => return Err("JBIG2 images can't be exported yet".into()), - Some(b"CCITTFaxDecode" | b"CCF") => return Err("CCITT fax images can't be exported yet".into()), + Some(b"JBIG2Decode") if f.len() > 1 => return Err("JBIG2 images inside other filters can't be exported yet".into()), Some(b"DCTDecode" | b"DCT") if !jpeg => return Err("JPEG images inside other filters can't be exported yet".into()), _ => {} } @@ -148,7 +257,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> space = decoded; (samples, 8) } else { - let data = s.decoded().map_err(|e| e.to_string())?; + let data = decoded(doc, s)?; let bpc = if mask { 1 } else { s.dict.int(b"BitsPerComponent").unwrap_or(8) }; let bpc = match bpc { 1 => 1, @@ -403,9 +512,11 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result { }; let table = match a.get(3).map(|t| doc.resolve(t)).as_deref() { Some(Object::String(s)) => s.bytes.clone(), - Some(Object::Stream(t)) => { - t.decoded_within(MAX_LOOKUP).map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())? - } + // Through its parameters given by reference too, as the image's samples. + Some(Object::Stream(t)) => doc + .with_direct_parms(t) + .decoded_within(MAX_LOOKUP) + .map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())?, _ => return Err("an indexed colour space without a lookup table".into()), }; Ok(Space::Indexed(Box::new(base), hival, table)) @@ -466,7 +577,7 @@ fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option Option> { let (mw, mh) = size(m.dict.int(b"Width")?, m.dict.int(b"Height")?)?; let stencil = m.dict.get(b"ImageMask").is_some_and(|v| matches!(&*doc.resolve(v), Object::Bool(true))); @@ -476,7 +587,7 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option> let img = image::load_from_memory_with_format(&m.raw, image::ImageFormat::Jpeg).ok()?.into_luma8(); (img.width() as usize == mw && img.height() as usize == mh).then(|| img.into_raw())? } - Some(b"DCTDecode" | b"DCT" | b"JPXDecode" | b"JBIG2Decode" | b"CCITTFaxDecode" | b"CCF") => return None, + Some(b"DCTDecode" | b"DCT" | b"JPXDecode") => return None, _ => { let bpc = if stencil { 1 } else { m.dict.int(b"BitsPerComponent").unwrap_or(8) }; let bpc = match bpc { @@ -488,7 +599,7 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option> _ => return None, }; let max = (1u32 << bpc.min(8)) - 1; - unpack(&m.decoded().ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect() + unpack(&decoded(doc, m).ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect() } }; if let Some([lo, hi]) = decode(doc, m, 1).and_then(|pairs| pairs.first().copied()) { @@ -501,7 +612,23 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option> return Some(px); } let img = image::GrayImage::from_raw(mw as u32, mh as u32, px)?; - Some(image::imageops::resize(&img, w as u32, h as u32, image::imageops::FilterType::Triangle).into_raw()) + Some(scaled(&img, w as u32, h as u32).into_raw()) +} + +/// `img` scaled to `w` × `h`. Scaling goes down first, through a buffer of `img`'s width by `h` at +/// 16 bytes a pixel, which a mask of other proportions than its image can make larger than any +/// machine: 100,000 × 1 on an image of 1 × 100,000 asks for 160 GB, and that aborts the process. +/// Such a mask is scaled across first, which holds no more pixels than one of the two has. Past a +/// quarter over the larger of them, so that rounding leaves a mask of its image's shape as it was. +pub(crate) fn scaled(img: &image::GrayImage, w: u32, h: u32) -> image::GrayImage { + use image::imageops::{FilterType, resize}; + let (mw, mh) = (u64::from(img.width()), u64::from(img.height())); + let largest = (mw * mh).max(u64::from(w) * u64::from(h)); + if (mw * u64::from(h)).saturating_mul(4) > largest.saturating_mul(5) { + resize(&resize(img, w, img.height(), FilterType::Triangle), w, h, FilterType::Triangle) + } else { + resize(img, w, h, FilterType::Triangle) + } } fn png(w: u32, h: u32, rgb: &[u8], alpha: Option<&[u8]>) -> Result, String> { diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 02bccd5fa8..36b52d4211 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -2,7 +2,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream, serialize}; mod extract; mod upright_image; @@ -108,9 +108,42 @@ fn jpeg_icc(mut chunks: Vec<(u8, u8, &[u8])>) -> Option> { complete.then(|| chunks.iter().flat_map(|c| c.2.iter().copied()).collect()) } +/// The samples of a black-and-white image packed one bit per pixel (1 for white, each row +/// starting on a byte): eight-bit grey (`n` 1) or RGB (`n` 3) whose every pixel is pure black or +/// pure white, or one-bit grey, already packed (a bilevel TIFF). `None` for anything else, found +/// at its first other pixel. +fn bilevel(data: &[u8], (w, h): (u32, u32), n: usize, bits: Option) -> Option> { + let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); + let stride = w.div_ceil(8); + let size = stride.checked_mul(h)?; + match (bits, n) { + (Some(1), 1) => data.get(..size).map(<[u8]>::to_vec), + (Some(8), 1 | 3) if w > 0 => { + let samples = data.get(..w.checked_mul(h)?.checked_mul(n)?)?; + let mut packed = vec![0u8; size]; + for (i, px) in samples.chunks_exact(n).enumerate() { + let white = match px { + [0] | [0, 0, 0] => false, + [255] | [255, 255, 255] => true, + _ => return None, + }; + if white && let Some(b) = packed.get_mut((i / w) * stride + (i % w) / 8) { + *b |= 0x80 >> (i % w % 8); + } + } + Some(packed) + } + _ => None, + } +} + /// Add `img` to `doc` as an image XObject. An ICC profile becomes an `/ICCBased` colour space /// (with the device space as `/Alternate`); `profiles` shares one object between images that /// carry the same profile. +/// +/// A black-and-white image is stored at one bit per pixel, as CCITT Group 4 when that is smaller +/// than Flate: losslessly, and several times smaller than the eight bits a scan saved as a 1-bit +/// PNG decodes to. RGB keeps its colour space when it has a colour profile. fn add_image(doc: &mut Document, img: Embedded, profiles: &mut Vec<(Vec, ObjRef)>) -> ObjRef { let mut d = img.dict; d.set(b"Type".to_vec(), Object::name("XObject")); @@ -118,12 +151,48 @@ fn add_image(doc: &mut Document, img: Embedded, profiles: &mut Vec<(Vec, Obj d.set(b"Width".to_vec(), Object::Int(img.px.0 as i64)); d.set(b"Height".to_vec(), Object::Int(img.px.1 as i64)); let device = d.name(b"ColorSpace").map(<[u8]>::to_vec); - let n = match device.as_deref() { + let mut n = match device.as_deref() { Some(b"DeviceGray") => 1, Some(b"DeviceRGB") => 3, Some(b"DeviceCMYK") => 4, _ => 0, }; + let (mut data, mut filtered) = (img.data, img.filtered); + let profiled_rgb = n == 3 && img.icc.is_some(); + if !filtered + && !profiled_rgb + && let Some(packed) = bilevel(&data, img.px, n, d.int(b"BitsPerComponent")) + { + if n == 3 { + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + n = 1; + } + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + // The smaller as stored, dictionary included: Group 4's parameters outweigh its saving on + // a tiny image. + let stored = |d: &Dict, data: &[u8]| { + let mut out = Vec::new(); + serialize(&Object::Dict(d.clone()), &mut out); + out.len().saturating_add(data.len()) + }; + let mut flate_dict = d.clone(); + flate_dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let flate = pdfcraft_filters::encode_flate(&packed); + (d, data) = match pdfcraft_filters::encode_g4(&packed, img.px.0, img.px.1) { + Ok(g4) => { + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(i64::from(img.px.0))); + parms.set(b"Rows".to_vec(), Object::Int(i64::from(img.px.1))); + let mut g4_dict = d.clone(); + g4_dict.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + g4_dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + if stored(&g4_dict, &g4) < stored(&flate_dict, &flate) { (g4_dict, g4) } else { (flate_dict, flate) } + } + Err(_) => (flate_dict, flate), + }; + filtered = true; + } if let (Some(icc), Some(device)) = (img.icc.and_then(|p| icc_for(p, n)), device) { let r = match profiles.iter().find(|(p, _)| *p == icc) { Some((_, r)) => *r, @@ -142,7 +211,7 @@ fn add_image(doc: &mut Document, img: Embedded, profiles: &mut Vec<(Vec, Obj let mr = doc.add(Object::Stream(Stream::flate(m, &alpha))); d.set(b"SMask".to_vec(), Object::Ref(mr)); } - let stream = if img.filtered { Stream::from_raw(d, img.data) } else { Stream::flate(d, &img.data) }; + let stream = if filtered { Stream::from_raw(d, data) } else { Stream::flate(d, &data) }; doc.add(Object::Stream(stream)) } @@ -562,7 +631,7 @@ fn embed(name: &str, bytes: &[u8], wanted: usize) -> Result, Creat } else if is_tiff(bytes) { tiff_pages(name, bytes, wanted) } else if bytes.starts_with(b"BM") { - Ok(vec![decoded(name, bytes, image::ImageFormat::Bmp)?]) + Ok(vec![decoded(name, &pdfcraft_xfa::image::bmp_with_pixel_offset(bytes), image::ImageFormat::Bmp)?]) } else if is_gif(bytes) { Ok(vec![decoded(name, bytes, image::ImageFormat::Gif)?]) } else if bytes.starts_with(JP2_SIGNATURE) || bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51]) { diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 3c9aa42a0e..e7e5560748 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -186,7 +186,7 @@ fn bmp_gif_and_multi_page_tiff_images() { let mut enc = image::codecs::gif::GifEncoder::new(&mut gif); enc.encode(img.as_raw(), 2, 2, image::ExtendedColorType::Rgba8).unwrap(); } - // TIFF: two pages, an 8-bit gray one at 144 dpi and a 1-bit one. + // TIFF: two 8-bit gray pages, one at 144 dpi and one of only black and white. let mut tif = std::io::Cursor::new(Vec::new()); { let mut enc = tiff::encoder::TiffEncoder::new(&mut tif).unwrap(); @@ -206,6 +206,26 @@ fn bmp_gif_and_multi_page_tiff_images() { assert!(matches!(from_images(&[("x.webp".into(), b"RIFF0000WEBP".to_vec())]), Err(CreateError::Image(..)))); } +/// #4937: a BMP whose header gives the pixel data's offset as 0 is placed as the same image as +/// the intact file, not shifted by the header bytes. +#[test] +fn a_bmp_with_a_zero_pixel_offset_is_placed_like_the_intact_file() { + use image::ImageEncoder; + let mut bmp = Vec::new(); + let pixels: Vec = (0..18).map(|i| i * 13).collect(); + image::codecs::bmp::BmpEncoder::new(&mut bmp).write_image(&pixels, 3, 2, image::ExtendedColorType::Rgb8).unwrap(); + let mut zero = bmp.clone(); + zero[10..14].copy_from_slice(&0u32.to_le_bytes()); + let samples = |data: Vec| { + let doc = reopen(&from_images(&[("a.bmp".into(), data)]).unwrap()); + let res = doc.resolve(pages(&doc)[0].get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let Object::Stream(s) = &*doc.resolve(xo.get(b"Im0").unwrap()) else { panic!("not an image") }; + s.decoded().unwrap() + }; + assert_eq!(samples(zero), samples(bmp)); +} + /// #665 lifted the tiff crate's 256 MiB decode limit; a few bytes claiming a 60000x60000 page /// (3.6 GB of gray pixels) must still be refused, not allocated. #[test] @@ -1275,6 +1295,48 @@ fn a_cyclic_indexed_colour_space_is_refused() { assert_eq!(out.skipped.len(), 1, "{:?}", out.skipped); } +/// A baseline TIFF of one uncompressed strip of 1-bit samples, `packed` rows each starting on a +/// byte, 0 for black (BlackIsZero; TIFF 6.0, section 3): the `tiff` crate writes no 1-bit images. +fn one_bit_tiff(w: u32, h: u32, packed: &[u8]) -> Vec { + // Tag, type (3 SHORT, 4 LONG), value; the strip follows the IFD. + let entries: [(u16, u16, u32); 8] = + [(256, 4, w), (257, 4, h), (258, 3, 1), (259, 3, 1), (262, 3, 1), (273, 4, 0), (278, 4, h), (279, 4, packed.len() as u32)]; + let strip = 8 + 2 + entries.len() as u32 * 12 + 4; + let mut out = b"II*\0".to_vec(); + out.extend(8u32.to_le_bytes()); + out.extend((entries.len() as u16).to_le_bytes()); + for (tag, kind, value) in entries { + let value = if tag == 273 { strip } else { value }; + out.extend(tag.to_le_bytes()); + out.extend(kind.to_le_bytes()); + out.extend(1u32.to_le_bytes()); + match kind { + 3 => out.extend((value as u16).to_le_bytes().into_iter().chain([0, 0])), + _ => out.extend(value.to_le_bytes()), + } + } + out.extend(0u32.to_le_bytes()); + out.extend_from_slice(packed); + out +} + +/// A bilevel TIFF (one bit a sample) stays one bit, stored as Group 4 when that is smaller, and +/// exports as its picture. +#[test] +fn a_one_bit_tiff_is_stored_as_group_4() { + let (w, h) = (1200u32, 240u32); + let bits = strokes(w, h); + let stride = (w as usize).div_ceil(8); + let picture: Vec = + (0..(w * h) as usize).map(|i| bits[i / w as usize * stride + i % w as usize / 8] & (0x80 >> (i % w as usize % 8)) != 0).collect(); + let doc = from_images(&[("scan.tif".into(), one_bit_tiff(w, h, &bits))]).unwrap(); + let im = image_of(&doc, 0); + assert_eq!((im.int(b"BitsPerComponent"), im.name(b"Filter")), (Some(1), Some(&b"CCITTFaxDecode"[..]))); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), picture); +} + /// A TIFF of `pages` 64×64 gray pages, then (with `unreadable`) one whose samples Create can't read. fn gray_tiff(pages: usize, unreadable: bool) -> Vec { let mut tif = std::io::Cursor::new(Vec::new()); @@ -1318,3 +1380,907 @@ fn a_tiny_gif_on_a_huge_screen_is_refused() { let doc = reopen(&from_images(&[("small.gif".into(), gif_on_screen(40, 30))]).unwrap()); assert_eq!(media(&pages(&doc)[0]), [0.0, 0.0, 40.0, 30.0]); } + +/// A PNG of `w` × `h` samples `data` in `color` at `depth`, with a palette or an ICC profile. +fn png_of(w: u32, h: u32, color: png::ColorType, depth: png::BitDepth, data: &[u8], palette: Option>, icc: Option>) -> Vec { + let mut out = Vec::new(); + { + let mut info = png::Info::with_size(w, h); + info.color_type = color; + info.bit_depth = depth; + info.palette = palette.map(Into::into); + info.icc_profile = icc.map(Into::into); + let mut writer = png::Encoder::with_info(&mut out, info).unwrap().write_header().unwrap(); + writer.write_image_data(data).unwrap(); + } + out +} + +/// Rows of `w` one-bit samples (1 white), each on a new byte: lines of glyphs drawn from a few +/// shapes at uneven spacing, whose edges move a pixel now and then, as a scan of text has them. +/// (Clean, evenly spaced strokes favour Flate over Group 4.) +fn strokes(w: u32, h: u32) -> Vec { + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let (w, h) = (w as usize, h as usize); + for line in (4..h.saturating_sub(18)).step_by(24) { + let mut x = 8; + while x + 16 < w { + let wide = 5 + (next() % 4) as usize; + let shape = next() % 4; + let mut fill = |x0: usize, x1: usize, y0: usize, y1: usize| { + for y in line + y0..line + y1 { + let ragged = |r: u64| match r % 8 { + 0 => 0, + 1 => 2, + _ => 1, + }; + let (left, right) = ((x + x0 + 1).saturating_sub(ragged(next())), x + x1 + ragged(next())); + for xx in left..right.saturating_sub(1).min(w) { + data[y * stride + xx / 8] &= !(0x80 >> (xx % 8)); + } + } + }; + match shape { + 0 => fill(0, 2, 0, 14), + 1 => { + fill(0, wide, 4, 6); + fill(0, wide, 12, 14); + fill(0, 2, 4, 14); + fill(wide - 2, wide, 4, 14); + } + 2 => { + fill(0, 2, 4, 14); + fill(wide - 2, wide, 6, 14); + fill(0, wide - 1, 4, 6); + } + _ => { + fill(0, 2, 7, 14); + fill(0, 2, 2, 4); + } + } + x += wide + 2 + (next() % 2) as usize + if next() % 6 == 0 { 7 } else { 0 }; + } + } + data +} + +/// The one-bit samples (true for white) a Group 4 image stream decodes to, through an +/// independent decoder. +fn g4_rows(s: &Stream) -> Vec> { + struct Rows(Vec>, Vec, usize); + impl hayro_ccitt::Decoder for Rows { + fn push_pixel(&mut self, white: bool) { + self.1.push(white); + } + fn push_pixel_chunk(&mut self, white: bool, chunks: u32) { + self.1.extend(std::iter::repeat_n(white, chunks as usize * 8)); + } + fn next_line(&mut self) { + let mut row = std::mem::take(&mut self.1); + row.truncate(self.2); + self.0.push(row); + } + } + let (w, h) = (s.dict.int(b"Width").unwrap() as u32, s.dict.int(b"Height").unwrap() as u32); + let settings = hayro_ccitt::DecodeSettings { + columns: w, + rows: h, + end_of_block: true, + end_of_line: false, + rows_are_byte_aligned: false, + encoding: hayro_ccitt::EncodingMode::Group4, + invert_black: false, + }; + let mut out = Rows(Vec::new(), Vec::new(), w as usize); + hayro_ccitt::decode(&s.raw, &mut out, &mut hayro_ccitt::DecoderContext::new(settings)).unwrap(); + out.0 +} + +fn image_stream(doc: &Document, page: usize) -> Stream { + let p = &pages(doc)[page]; + let res = doc.resolve(p.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + match &*doc.resolve(xo.get(b"Im0").unwrap()) { + Object::Stream(s) => s.clone(), + _ => panic!("not an image"), + } +} + +/// Black-and-white pictures are stored at one bit per pixel, as CCITT Group 4 when that is +/// smaller, whichever way the file holds them: a 1-bit PNG (which decodes to eight bits), eight-bit +/// grey of only black and white, a two-colour palette, black-and-white RGB. The bits are the +/// picture's. A grey profile stays; a picture with any other shade, colour, or an RGB profile is +/// stored as before. +#[test] +fn black_and_white_images_are_stored_at_one_bit() { + use png::{BitDepth, ColorType}; + let (w, h) = (1200u32, 240u32); + let bits = strokes(w, h); + let stride = (w as usize).div_ceil(8); + let white = |x: usize, y: usize| bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0; + let expected: Vec> = (0..h as usize).map(|y| (0..w as usize).map(|x| white(x, y)).collect()).collect(); + let grey: Vec = expected.iter().flatten().map(|&wh| if wh { 255 } else { 0 }).collect(); + let rgb: Vec = grey.iter().flat_map(|&v| [v, v, v]).collect(); + let pictures = [ + png_of(w, h, ColorType::Grayscale, BitDepth::One, &bits, None, None), + png_of(w, h, ColorType::Grayscale, BitDepth::Eight, &grey, None, None), + png_of(w, h, ColorType::Indexed, BitDepth::One, &bits.iter().map(|b| !b).collect::>(), Some(vec![255, 255, 255, 0, 0, 0]), None), + png_of(w, h, ColorType::Rgb, BitDepth::Eight, &rgb, None, None), + png_of(w, h, ColorType::Grayscale, BitDepth::One, &bits, None, Some(icc_profile(b"GRAY"))), + ]; + let files: Vec<(String, Vec)> = pictures.into_iter().enumerate().map(|(i, p)| (format!("{i}.png"), p)).collect(); + let doc = reopen(&from_images(&files).unwrap()); + for page in 0..5 { + let s = image_stream(&doc, page); + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), (Some(1), Some(&b"CCITTFaxDecode"[..])), "page {page}"); + assert_eq!(g4_rows(&s), expected, "page {page}"); + assert!(s.raw.len() < bits.len(), "page {page}: {} bytes, smaller than its one-bit samples", s.raw.len()); + } + assert_eq!(image_of(&doc, 3).name(b"ColorSpace"), Some(&b"DeviceGray"[..]), "black-and-white RGB becomes grey"); + assert_eq!(icc_of(&doc, &image_of(&doc, 4)).map(|(n, ..)| n), Some(1), "the grey profile stays"); + + // Another shade, colour, an RGB profile: as before. A tiny picture: one bit, but Flate. + let mut shaded = grey.clone(); + shaded[5] = 128; + let mut coloured = rgb.clone(); + coloured[0] = 200; + let others = [ + png_of(w, h, ColorType::Grayscale, BitDepth::Eight, &shaded, None, None), + png_of(w, h, ColorType::Rgb, BitDepth::Eight, &coloured, None, None), + png_of(w, h, ColorType::Rgb, BitDepth::Eight, &rgb, None, Some(icc_profile(b"RGB "))), + png_of(2, 2, ColorType::Grayscale, BitDepth::Eight, &[0, 255, 255, 0], None, None), + ]; + let files: Vec<(String, Vec)> = others.into_iter().enumerate().map(|(i, p)| (format!("o{i}.png"), p)).collect(); + let doc = reopen(&from_images(&files).unwrap()); + let kept: Vec<(Option, Option>)> = + (0..4).map(|p| (image_of(&doc, p).int(b"BitsPerComponent"), image_of(&doc, p).name(b"Filter").map(<[u8]>::to_vec))).collect(); + assert_eq!( + kept[..3], + [(Some(8), Some(b"FlateDecode".to_vec())), (Some(8), Some(b"FlateDecode".to_vec())), (Some(8), Some(b"FlateDecode".to_vec()))] + ); + assert_eq!(kept[3], (Some(1), Some(b"FlateDecode".to_vec())), "Group 4 isn't smaller for 2 × 2"); +} + +/// The pixels of an exported PNG (RGB), as true for white. +fn exported_white(png_data: &[u8]) -> Vec { + let mut reader = png::Decoder::new(std::io::Cursor::new(png_data.to_vec())).read_info().unwrap(); + let mut buf = vec![0; reader.output_buffer_size().unwrap()]; + let frame = reader.next_frame(&mut buf).unwrap(); + assert_eq!(frame.color_type, png::ColorType::Rgb); + buf[..frame.buffer_size()].as_chunks::<3>().0.iter().map(|p| *p == [255, 255, 255]).collect() +} + +/// Export ▸ Images reads CCITT fax images: a page created from a 1-bit PNG (Group 4 now) exports as +/// the picture, as does the picture coded the other way round with /BlackIs1, the picture with its +/// filter and parameters in arrays, with /K and /Columns given by reference, with a /Rows below its +/// height, and behind another filter (its parameters by reference too). One whose /Columns aren't +/// its width is skipped, not misread. +#[test] +fn ccitt_fax_images_export_as_their_picture() { + use png::{BitDepth, ColorType}; + let (w, h) = (1200u32, 240u32); + let bits = strokes(w, h); + let stride = (w as usize).div_ceil(8); + let picture: Vec = + (0..h as usize).flat_map(|y| (0..w as usize).map(move |x| (y, x))).map(|(y, x)| bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0).collect(); + let mut doc = from_images(&[("scan.png".into(), png_of(w, h, ColorType::Grayscale, BitDepth::One, &bits, None, None))]).unwrap(); + assert_eq!(image_of(&doc, 0).name(b"Filter"), Some(&b"CCITTFaxDecode"[..])); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), picture); + + // The same picture coded with 1 for black, which /BlackIs1 says. + let r = { + let page = pdfcraft_model::pages(&doc)[0].clone(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + xo.get(b"Im0").and_then(Object::as_ref).unwrap() + }; + let base = image_of(&doc, 0); + let with = |black_is_1: bool, columns: i64, data: Vec| { + let mut d = base.clone(); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(columns)); + parms.set(b"Rows".to_vec(), Object::Int(i64::from(h))); + parms.set(b"BlackIs1".to_vec(), Object::Bool(black_is_1)); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + d.remove(b"Length"); + Stream::from_raw(d, data) + }; + let inverted: Vec = bits.iter().map(|b| !b).collect(); + let stream = with(true, i64::from(w), pdfcraft_filters::encode_g4(&inverted, w, h).unwrap()); + doc.set(r, Object::Stream(stream)); + let out = extract_images(&doc, &[0], 0); + assert_eq!(exported_white(&out.images[0].data), picture, "BlackIs1"); + + let mut stream = with(false, i64::from(w), pdfcraft_filters::encode_g4(&bits, w, h).unwrap()); + let parms = stream.dict.get(b"DecodeParms").cloned().unwrap(); + stream.dict.set(b"DecodeParms".to_vec(), Object::Array(vec![parms])); + stream.dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("CCITTFaxDecode")])); + doc.set(r, Object::Stream(stream)); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), picture, "in arrays"); + + let mut stream = with(false, i64::from(w), pdfcraft_filters::encode_g4(&bits, w, h).unwrap()); + let mut parms = stream.dict.get(b"DecodeParms").and_then(Object::as_dict).cloned().unwrap(); + parms.set(b"K".to_vec(), Object::Ref(doc.add(Object::Int(-1)))); + parms.set(b"Columns".to_vec(), Object::Ref(doc.add(Object::Int(i64::from(w))))); + parms.set(b"Rows".to_vec(), Object::Int(1)); + stream.dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + doc.set(r, Object::Stream(stream)); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), picture, "/K and /Columns by reference, /Rows 1"); + + // Behind another filter, with its parameters second in the array, as 7.4.1 pairs them. + let g4 = pdfcraft_filters::encode_g4(&bits, w, h).unwrap(); + let hex = g4.iter().map(|b| format!("{b:02x}")).chain([">".to_string()]).collect::().into_bytes(); + for (outer, data) in [("ASCIIHexDecode", hex), ("FlateDecode", pdfcraft_filters::encode_flate(&g4))] { + let mut stream = with(false, i64::from(w), data); + let parms = stream.dict.get(b"DecodeParms").cloned().unwrap(); + stream.dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, parms])); + stream.dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name(outer), Object::name("CCITTFaxDecode")])); + doc.set(r, Object::Stream(stream)); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{outer}: {:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), picture, "behind {outer}"); + } + // Behind Flate with a PNG predictor, whose parameters are given by reference. + let padded: Vec = g4.iter().copied().chain(std::iter::repeat_n(0, 16 - g4.len() % 16)).collect(); + let params = pdfcraft_filters::Params { predictor: 12, colors: 1, bits_per_component: 8, columns: 16, early_change: 1 }; + let predicted = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &padded).unwrap(); + let mut flate_parms = Dict::new(); + flate_parms.set(b"Predictor".to_vec(), Object::Int(12)); + flate_parms.set(b"Columns".to_vec(), Object::Int(16)); + let flate_parms = doc.add(Object::Dict(flate_parms)); + let mut stream = with(false, i64::from(w), predicted); + let parms = stream.dict.get(b"DecodeParms").cloned().unwrap(); + stream.dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Ref(flate_parms), parms])); + stream.dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("CCITTFaxDecode")])); + doc.set(r, Object::Stream(stream)); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), picture, "behind Flate with a predictor given by reference"); + // CCITT before another filter isn't a picture yet. + let mut stream = with(false, i64::from(w), pdfcraft_filters::encode_flate(&g4)); + stream.dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("CCITTFaxDecode"), Object::name("FlateDecode")])); + doc.set(r, Object::Stream(stream)); + let out = extract_images(&doc, &[0], 0); + assert!(out.images.is_empty() && out.skipped.len() == 1, "CCITT, then Flate: skipped"); + + let stream = with(false, i64::from(w) + 8, pdfcraft_filters::encode_g4(&bits, w, h).unwrap()); + doc.set(r, Object::Stream(stream)); + let out = extract_images(&doc, &[0], 0); + assert!(out.images.is_empty() && out.skipped.len() == 1, "columns other than the width: skipped"); +} + +/// Group 3 data: rows of run lengths below 64, white first, coded one-dimensionally with T.4's +/// terminating codes (ITU-T T.4 Tables 2 and 3). Each row starts with an end-of-line code with +/// `eol`, then with `tag` a 1 bit (the next row one-dimensional, as `/K` above 0 has it). Fill bits +/// (0s) go where `fill` says. +fn group_3(rows: &[impl AsRef<[u32]>], eol: bool, tag: bool, fill: Fill) -> Vec { + // The codes for runs 0 to 63, in order. + const WHITE: &str = concat!( + "00110101 000111 0111 1000 1011 1100 1110 1111 10011 10100 00111 01000 001000 000011 110100 110101 101010 101011 ", + "0100111 0001100 0001000 0010111 0000011 0000100 0101000 0101011 0010011 0100100 0011000 00000010 00000011 00011010 ", + "00011011 00010010 00010011 00010100 00010101 00010110 00010111 00101000 00101001 00101010 00101011 00101100 00101101 ", + "00000100 00000101 00001010 00001011 01010010 01010011 01010100 01010101 00100100 00100101 01011000 01011001 01011010 ", + "01011011 01001010 01001011 00110010 00110011 00110100", + ); + const BLACK: &str = concat!( + "0000110111 010 11 10 011 0011 0010 00011 000101 000100 0000100 0000101 0000111 00000100 00000111 000011000 0000010111 ", + "0000011000 0000001000 00001100111 00001101000 00001101100 00000110111 00000101000 00000010111 00000011000 ", + "000011001010 000011001011 000011001100 000011001101 000001101000 000001101001 000001101010 000001101011 000011010010 ", + "000011010011 000011010100 000011010101 000011010110 000011010111 000001101100 000001101101 000011011010 000011011011 ", + "000001010100 000001010101 000001010110 000001010111 000001100100 000001100101 000001010010 000001010011 000000100100 ", + "000000110111 000000111000 000000100111 000000101000 000001011000 000001011001 000000101011 000000101100 000001011010 ", + "000001100110 000001100111", + ); + let code = |white: bool, run: u32| (if white { WHITE } else { BLACK }).split(' ').nth(run as usize).expect("a run below 64"); + let mut bits = String::new(); + for row in rows { + // The end-of-line code is 12 bits. + let ends_code = if fill == Fill::CodeEnd && eol { 12 } else { 0 }; + while fill != Fill::None && !(bits.len() + ends_code).is_multiple_of(8) { + bits.push('0'); + } + if eol { + bits.push_str("000000000001"); + } + if tag { + bits.push('1'); + } + row.as_ref().iter().enumerate().for_each(|(i, run)| bits.push_str(code(i % 2 == 0, *run))); + } + bits.as_bytes().chunks(8).map(|c| c.iter().enumerate().fold(0u8, |b, (i, d)| b | (u8::from(*d == b'1') << (7 - i)))).collect() +} + +/// `count` text-like pictures `w` by `h` for [`group_3`], from a fixed seed: rows of random runs, +/// long white ones and short black ones, and a row may start black. +fn random_pictures(w: u32, h: u32, count: usize) -> Vec>> { + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut random = |n: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % n) as u32 + }; + let mut row = || { + let (mut runs, mut left) = (Vec::new(), w); + while left > 0 { + let run = match runs.len() % 2 { + 0 if runs.is_empty() => random(31), + 0 => 1 + random(30), + _ => 1 + random(5), + }; + runs.push(run.min(left)); + left -= run.min(left); + } + runs + }; + (0..count).map(|_| (0..h).map(|_| row()).collect()).collect() +} + +/// The picture `rows` of runs (white first) make, `w` by `h`, true where white. +fn white_of(rows: &[Vec], w: u32, h: u32) -> Vec { + let mut white = vec![false; (w * h) as usize]; + for (y, row) in rows.iter().enumerate() { + let mut x = y * w as usize; + for (i, run) in row.iter().enumerate() { + white[x..x + *run as usize].fill(i % 2 == 0); + x += *run as usize; + } + } + white +} + +/// Where [`group_3`] puts fill bits: none; so each row, its end-of-line code first, starts on a +/// byte; or so each end-of-line code ends on one (T.4's fill, as TIFF writes it). +#[derive(Clone, Copy, Debug, PartialEq)] +enum Fill { + None, + RowStart, + CodeEnd, +} + +/// What Export ▸ Images claims for CCITT and for parameters given by reference, each checked +/// against the same picture stored another way: Group 3 one-dimensional data (`/K 0`) exports as +/// its picture; a CCITT soft mask and a CCITT stencil `/Mask` export as the same masks in Flate; +/// and a Flate image's PNG predictor, and an indexed image's lookup table stream with one, read +/// the same with their `/DecodeParms` given by reference as direct. +#[test] +fn group_3_ccitt_masks_and_parameters_by_reference_export_as_their_picture() { + use png::{BitDepth, ColorType}; + let (w, h) = (64u32, 16u32); + let rows: Vec<&[u32]> = (0..h).map(|y| if y % 2 == 0 { &[32u32, 32][..] } else { &[16, 16, 32][..] }).collect(); + let mut bits = vec![0u8; 8 * h as usize]; + for (y, runs) in rows.iter().enumerate() { + let mut x = 0; + for (i, run) in runs.iter().enumerate() { + if i % 2 == 0 { + (x..x + *run as usize).for_each(|xx| bits[y * 8 + xx / 8] |= 0x80 >> (xx % 8)); + } + x += *run as usize; + } + } + let picture: Vec = (0..(w * h) as usize).map(|i| bits[i / 8] & (0x80 >> (i % 8)) != 0).collect(); + let mut doc = from_images(&[("scan.png".into(), png_of(w, h, ColorType::Grayscale, BitDepth::One, &bits, None, None))]).unwrap(); + let r = { + let page = pdfcraft_model::pages(&doc)[0].clone(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + xo.get(b"Im0").and_then(Object::as_ref).unwrap() + }; + let image = |entries: &[(&str, Object)]| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + d + }; + let fax = |k: i64| { + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(k)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + parms.set(b"Rows".to_vec(), Object::Int(h.into())); + [("Filter", Object::name("CCITTFaxDecode")), ("DecodeParms", Object::Dict(parms))] + }; + let grey = |bits: i64| [("BitsPerComponent", Object::Int(bits)), ("ColorSpace", Object::name("DeviceGray"))]; + + // One-dimensional (/K 0) plain, with end-of-line codes, with each row on a byte, and with + // both; mixed (/K 1, each row tagged one-dimensional) with end-of-line codes and without. + // Byte-aligned with end-of-line codes either way: each code starting on a byte, or ending on + // one (T.4's fill, as TIFF writes it). /EncodedByteAlign is given by reference once, and so is + // a /BlackIs1 that inverts the picture. + let inverted: Vec = picture.iter().map(|white| !white).collect(); + for (k, eol, fill, align_by_reference, black_is_1) in [ + (0, false, Fill::None, false, false), + (0, true, Fill::None, false, false), + (0, false, Fill::RowStart, true, false), + (0, true, Fill::RowStart, false, false), + (0, true, Fill::CodeEnd, false, false), + (1, true, Fill::None, false, false), + (1, false, Fill::None, false, false), + (1, true, Fill::CodeEnd, false, false), + (0, false, Fill::None, false, true), + ] { + let mut d = image(&[&grey(1)[..], &fax(k)].concat()); + let align = Object::Bool(fill != Fill::None); + let align = if align_by_reference { Object::Ref(doc.add(align)) } else { align }; + let inverts = Object::Ref(doc.add(Object::Bool(black_is_1))); + if let Some(Object::Dict(parms)) = d.get_mut(b"DecodeParms") { + parms.set(b"EndOfLine".to_vec(), Object::Bool(eol)); + parms.set(b"EncodedByteAlign".to_vec(), align); + parms.set(b"BlackIs1".to_vec(), inverts); + } + doc.set(r, Object::Stream(Stream::from_raw(d, group_3(&rows, eol, k > 0, fill)))); + let case = format!("/K {k}, EndOfLine {eol}, fill {fill:?}, BlackIs1 {black_is_1}"); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{case}: {:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), if black_is_1 { inverted.clone() } else { picture.clone() }, "{case}"); + } + // Byte-aligned rows against /EndOfLine, over several pictures, as how a misreading comes out + // depends on the picture: no codes (as TIFF's Modified Huffman has it), and /EndOfLine without + // them; codes, each starting on a byte or ending on one, /EndOfLine or not. Each exports as its + // picture, and a byte short is reported. The first picture is stripes, every row 32 white then + // 32 black; the others are text-like rows of random runs. + let image_with = |doc: &mut Document, k: i64, eol: bool, data: Vec| { + let mut d = image(&[&grey(1)[..], &fax(k)].concat()); + if let Some(Object::Dict(parms)) = d.get_mut(b"DecodeParms") { + parms.set(b"EndOfLine".to_vec(), Object::Bool(eol)); + parms.set(b"EncodedByteAlign".to_vec(), Object::Bool(true)); + } + doc.set(r, Object::Stream(Stream::from_raw(d, data))); + extract_images(doc, &[0], 0) + }; + let mut pictures = vec![vec![vec![32u32, 32]; h as usize]]; + pictures.extend(random_pictures(w, h, 8)); + for (n, runs) in pictures.iter().enumerate() { + let white = white_of(runs, w, h); + for k in [0, 1] { + for (codes, fill, eol) in [ + (false, Fill::RowStart, false), + (false, Fill::RowStart, true), + (true, Fill::RowStart, false), + (true, Fill::RowStart, true), + (true, Fill::CodeEnd, false), + (true, Fill::CodeEnd, true), + ] { + let out = image_with(&mut doc, k, eol, group_3(runs, codes, k > 0, fill)); + let case = format!("picture {n}, /K {k}, codes {codes}, fill {fill:?}, /EndOfLine {eol}"); + assert!(out.skipped.is_empty(), "{case}: {:?}", out.skipped); + assert_eq!(exported_white(&out.images[0].data), white, "{case}"); + // A byte short, it is reported, not read the other way into other rows. + let mut short = group_3(runs, codes, k > 0, fill); + short.pop(); + let out = image_with(&mut doc, k, eol, short); + assert!(out.images.is_empty() && out.skipped.len() == 1, "{case}, a byte short: {:?}", out.skipped); + // At /K 1, codes with none before the first row (the first code and its fill are 2 + // bytes here) are read on bytes: the picture or reported, never another picture. + if k == 1 && fill == Fill::CodeEnd { + let out = image_with(&mut doc, k, eol, group_3(runs, codes, true, fill).split_off(2)); + assert!(out.images.iter().all(|i| exported_white(&i.data) == white), "{case}, no code first"); + } + } + } + } + // Group 4 rows on bytes keep their aligned read, /EndOfLine or not: here white rows, each the + // one code 1 (vertical, no change) and its fill. + for eol in [false, true] { + let out = image_with(&mut doc, -1, eol, vec![0x80; h as usize]); + assert!(out.skipped.is_empty(), "byte-aligned Group 4, /EndOfLine {eol}: {:?}", out.skipped); + assert!(exported_white(&out.images[0].data).iter().all(|white| *white), "byte-aligned Group 4, /EndOfLine {eol}"); + } + + // A grey picture under the same bits as a soft mask, then as a stencil mask: CCITT against Flate. + let g4 = pdfcraft_filters::encode_g4(&bits, w, h).unwrap(); + for (key, mask) in [("SMask", grey(1).to_vec()), ("Mask", vec![("ImageMask", Object::Bool(true))])] { + let mut exported = Vec::new(); + for coded in [true, false] { + let m = if coded { Stream::from_raw(image(&[&mask[..], &fax(-1)].concat()), g4.clone()) } else { Stream::flate(image(&mask), &bits) }; + let m = doc.add(Object::Stream(m)); + let d = image(&[&grey(8)[..], &[(key, Object::Ref(m))]].concat()); + doc.set(r, Object::Stream(Stream::flate(d, &vec![100u8; (w * h) as usize]))); + exported.push(exported_rgba(&doc)); + } + assert!(exported[0].1.chunks(4).any(|p| p[3] == 0), "{key}: masked somewhere"); + assert_eq!(exported[0], exported[1], "{key}: CCITT as Flate"); + } + + // A PNG predictor given by reference, for the samples and for a lookup table. + let predicted = |rows: &[u8], columns: i64| { + let params = pdfcraft_filters::Params { predictor: 12, colors: 1, bits_per_component: 8, columns, early_change: 1 }; + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(12)); + parms.set(b"Columns".to_vec(), Object::Int(columns)); + (pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, rows).unwrap(), parms) + }; + let shades: Vec = (0..w * h).map(|i| (i * 7 % 256) as u8).collect(); + let (table, table_parms) = predicted(&[255, 0, 0, 0, 0, 255], 3); + let indices: Vec = (0..w * h).map(|i| (i % 3 % 2) as u8).collect(); + let mut exported = Vec::new(); + for by_reference in [false, true] { + let parms = |doc: &mut Document, p: &Dict| if by_reference { Object::Ref(doc.add(Object::Dict(p.clone()))) } else { Object::Dict(p.clone()) }; + let (samples, sample_parms) = predicted(&shades, w.into()); + let sample_parms = parms(&mut doc, &sample_parms); + let d = image(&[&grey(8)[..], &[("Filter", Object::name("FlateDecode")), ("DecodeParms", sample_parms)]].concat()); + doc.set(r, Object::Stream(Stream::from_raw(d, samples))); + let plain = exported_rgba(&doc); + + let mut lookup = Dict::new(); + lookup.set(b"Filter".to_vec(), Object::name("FlateDecode")); + lookup.set(b"DecodeParms".to_vec(), parms(&mut doc, &table_parms)); + let lookup = doc.add(Object::Stream(Stream::from_raw(lookup, table.clone()))); + let cs = Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), 1.into(), Object::Ref(lookup)]); + let d = image(&[("BitsPerComponent", Object::Int(8)), ("ColorSpace", cs)]); + doc.set(r, Object::Stream(Stream::flate(d, &indices))); + exported.push((plain, exported_rgba(&doc))); + } + assert_eq!(exported[0].0.1.chunks(4).map(|p| p[0]).collect::>(), shades, "the samples, direct"); + assert_eq!(exported[0].1.1.get(..8), Some(&[255, 0, 0, 255, 0, 0, 255, 255][..]), "red, then blue"); + assert_eq!(exported[1], exported[0], "by reference as direct"); +} + +/// An image whose PNG predictor is given by reference exports as its picture: its `/DecodeParms` +/// dictionary given by reference, as an array's entry, or direct with its numbers by reference. So +/// do an indexed image whose lookup table is a stream with its `/DecodeParms` given by reference, +/// and a JBIG2 image whose `/JBIG2Globals` stream has them so. `Stream::decoded` reads only a +/// direct `/DecodeParms` with direct numbers: the row tags stayed in the samples, and the picture +/// came out shifted. +#[test] +fn images_whose_predictor_is_given_by_reference_export_as_their_picture() { + // PNG predictor parameters for `columns` one-byte samples a row. + let parms = |columns: i64| { + let mut p = Dict::new(); + p.set(b"Predictor".to_vec(), Object::Int(15)); + p.set(b"Colors".to_vec(), Object::Int(1)); + p.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + p.set(b"Columns".to_vec(), Object::Int(columns)); + p + }; + // Two rows of four grey samples, each behind a PNG "None" row tag. + let predicted = pdfcraft_filters::encode_flate(&[0, 10, 20, 30, 40, 0, 50, 60, 70, 80]); + let grey: Vec = [10u8, 20, 30, 40, 50, 60, 70, 80].iter().flat_map(|v| [*v, *v, *v, 255]).collect(); + for form in ["a reference", "an array of a reference", "numbers by reference"] { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut d = image_dict(4, 2, "DeviceGray", 8); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let decode_parms = match form { + "numbers by reference" => { + let mut p = parms(4); + for key in [&b"Predictor"[..], b"Columns"] { + let v = p.get(key).cloned().unwrap(); + p.set(key.to_vec(), Object::Ref(doc.add(v))); + } + Object::Dict(p) + } + "a reference" => Object::Ref(doc.add(Object::Dict(parms(4)))), + _ => Object::Array(vec![Object::Ref(doc.add(Object::Dict(parms(4))))]), + }; + d.set(b"DecodeParms".to_vec(), decode_parms); + Stream::from_raw(d, predicted.clone()) + }); + assert_eq!(exported_rgba(&doc), ("png", grey.clone()), "/DecodeParms as {form}"); + } + // An indexed image's lookup table, red then blue, as a stream behind a "None" row tag. + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut t = Dict::new(); + t.set(b"Filter".to_vec(), Object::name("FlateDecode")); + t.set(b"DecodeParms".to_vec(), Object::Ref(doc.add(Object::Dict(parms(6))))); + let table = doc.add(Object::Stream(Stream::from_raw(t, pdfcraft_filters::encode_flate(&[0, 255, 0, 0, 0, 0, 255])))); + let mut d = image_dict(4, 1, "DeviceGray", 8); + d.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), Object::Int(1), Object::Ref(table)])); + Stream::from_raw(d, vec![0, 1, 0, 1]) + }); + let (red, blue) = ([255, 0, 0, 255], [0, 0, 255, 255]); + assert_eq!(exported_rgba(&doc), ("png", [red, blue, red, blue].concat()), "the lookup table's /DecodeParms by reference"); + // A JBIG2 image whose globals hold one extension segment (ISO/IEC 14492 7.4.14) a decoder + // ignores, behind a "None" row tag. With the tag left in, the globals can't be read and the + // image is skipped. + let segment = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]; + let bits = [0xF0u8, 0x0F, 0xAA, 0x55]; + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut g = Dict::new(); + g.set(b"Filter".to_vec(), Object::name("FlateDecode")); + g.set(b"DecodeParms".to_vec(), Object::Ref(doc.add(Object::Dict(parms(segment.len() as i64))))); + let tagged: Vec = [0].iter().chain(&segment).copied().collect(); + let globals = doc.add(Object::Stream(Stream::from_raw(g, pdfcraft_filters::encode_flate(&tagged)))); + let mut decode_parms = Dict::new(); + decode_parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals)); + let mut d = image_dict(16, 2, "DeviceGray", 1); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(decode_parms)); + Stream::from_raw(d, pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap()) + }); + let picture: Vec = (0..32).flat_map(|i| if bits[i / 8] & (0x80 >> (i % 8)) != 0 { [255, 255, 255, 255] } else { [0, 0, 0, 255] }).collect(); + assert_eq!(exported_rgba(&doc), ("png", picture), "the JBIG2 globals' /DecodeParms by reference"); +} + +/// An image the optimizer stored as JBIG2 symbols (a symbol dictionary and text regions, rather +/// than one generic region) exports as its picture. +#[test] +fn symbol_coded_jbig2_images_export_as_their_picture() { + const LETTERS: [[&str; 7]; 3] = [ + ["#####", "#....", "####.", "#....", "#....", "#....", "#####"], + [".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."], + ["#...#", "#...#", "#####", "#...#", "#...#", "#...#", "#...#"], + ]; + let (w, h) = (400usize, 40usize); + let stride = w.div_ceil(8); + // 1 samples white, as a grey image's are; the letters' pixels 0. + let mut bits = vec![0xFFu8; stride * h]; + for (line, top) in [2usize, 12, 22, 32].into_iter().enumerate() { + for (k, x) in (3..w - 6).step_by(7).enumerate() { + for (dy, row) in LETTERS[(k + line) % 3].iter().enumerate() { + for (dx, c) in row.bytes().enumerate() { + if c == b'#' { + bits[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8)); + } + } + } + } + } + let jbig2 = pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap(); + let generic = pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap(); + assert!(jbig2.len() < generic.len(), "symbol coded: {} bytes, generic {}", jbig2.len(), generic.len()); + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |_| { + let mut d = image_dict(w as i64, h as i64, "DeviceGray", 1); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + Stream::from_raw(d, jbig2) + }); + let picture: Vec = (0..w * h) + .flat_map(|i| if bits[i / w * stride + i % w / 8] & (0x80 >> (i % w % 8)) != 0 { [255, 255, 255, 255] } else { [0, 0, 0, 255] }) + .collect(); + assert_eq!(exported_rgba(&doc), ("png", picture)); +} + +/// Export ▸ Images reads JBIG2 images (ISO 32000-2 §7.4.7), JBIG2 black as 0: as the image, and as +/// a stencil `/Mask` of another. Their `/JBIG2Globals` are read, from a dictionary or the one in +/// an array, so globals that can't be read skip the image rather than being ignored. Rows past the +/// image's height are left out; a JBIG2 page of another width, or with fewer rows than the image, +/// or larger than an exported image may be (however small the image's `/Height`), or data that +/// isn't JBIG2, skips the image. +#[test] +fn jbig2_images_export_as_their_picture() { + let (w, h) = (203i64, 41i64); + let stride = 26; + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let bits: Vec = (0..stride * h as usize) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed as u8 + }) + .collect(); + // The first `rows` rows as RGBA: a 1 sample white, a 0 black. + let picture = |rows: i64| -> Vec { + (0..rows as usize) + .flat_map(|y| (0..w as usize).map(move |x| (x, y))) + .flat_map(|(x, y)| { + let v = if bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0 { 255 } else { 0 }; + [v, v, v, 255] + }) + .collect() + }; + let jbig2 = pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap(); + // A page whose image is `data` as JBIG2, `width` × `height`. With `globals`, its /DecodeParms + // (an array when `.1`) name a globals stream; with `mask`, it is the stencil /Mask of a grey + // image. + let page = |width: i64, height: i64, data: &[u8], globals: Option<(&[u8], bool)>, mask: bool| { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut d = image_dict(width, height, "DeviceGray", 1); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + if let Some((g, in_array)) = globals { + let g = doc.add(Object::Stream(Stream::from_raw(Dict::new(), g.to_vec()))); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(g)); + d.set(b"DecodeParms".to_vec(), if in_array { Object::Array(vec![Object::Dict(parms)]) } else { Object::Dict(parms) }); + } + if !mask { + return Stream::from_raw(d, data.to_vec()); + } + d.remove(b"ColorSpace"); + d.set(b"ImageMask".to_vec(), Object::Bool(true)); + let m = doc.add(Object::Stream(Stream::from_raw(d, data.to_vec()))); + let mut image = image_dict(width, height, "DeviceGray", 8); + image.set(b"Mask".to_vec(), Object::Ref(m)); + Stream::from_raw(image, vec![128; (width * height) as usize]) + }); + doc + }; + let skipped = |doc: &Document| { + let out = extract_images(doc, &[0], 0); + out.images.is_empty() && out.skipped.len() == 1 + }; + + assert_eq!(exported_rgba(&page(w, h, &jbig2, None, false)), ("png", picture(h))); + assert_eq!(exported_rgba(&page(w, h, &jbig2, Some((&[][..], false)), false)).1, picture(h), "empty globals"); + for in_array in [false, true] { + assert!(skipped(&page(w, h, &jbig2, Some((&b"not JBIG2"[..], in_array)), false)), "unreadable globals, in an array: {in_array}"); + } + assert_eq!(exported_rgba(&page(w, h - 5, &jbig2, None, false)).1, picture(h - 5), "rows past the height"); + for (width, height, data) in [(w + 8, h, &jbig2[..]), (w - 1, h, &jbig2[..]), (w, h + 1, &jbig2[..]), (w, h, &b"not JBIG2"[..])] { + assert!(skipped(&page(width, height, data, None, false)), "{width} × {height}, {} bytes", data.len()); + } + // The same stream with its page information (the first segment's data, after its 11-byte + // header: width, then height) claiming a 20,000 × 10,000 page: 200 million pixels, over the + // export's 100 million, behind an image of 20,000 × 4. The decoder would decode the whole page + // (it takes pages of up to about 2^28 pixels), so it is refused first. + let mut large = jbig2.clone(); + large[11..15].copy_from_slice(&20_000u32.to_be_bytes()); + large[15..19].copy_from_slice(&10_000u32.to_be_bytes()); + assert!(skipped(&page(20_000, 4, &large, None, false)), "a page over the export's size"); + // Refused the same ways as a stencil /Mask, it is left out: the image exports opaque. + for (width, height, mask) in [(w + 8, h, &jbig2), (20_000, 4, &large)] { + let rgba = exported_rgba(&page(width, height, mask, None, true)).1; + assert_eq!(rgba.len() as i64, width * height * 4, "a {width}-wide mask"); + assert!(alphas(&rgba).iter().all(|a| *a == 255), "a refused {width}-wide mask is left out"); + } + // A page of exactly the budget, 20,000 × 5,000, is decoded; one row more is refused. + for (rows, refused) in [(5_000u32, false), (5_001, true)] { + large[15..19].copy_from_slice(&rows.to_be_bytes()); + let doc = page(20_000, 4, &large, None, false); + assert_eq!(skipped(&doc), refused, "a page of 20,000 × {rows}"); + if !refused { + assert_eq!(exported_rgba(&doc).1.len(), 20_000 * 4 * 4); + } + } + // Damaged data never panics. + for end in [0, 1, 10, 20, 30, jbig2.len() / 2] { + let _ = extract_images(&page(w, h, &jbig2[..end], None, false), &[0], 0); + } + + // As a stencil /Mask, its 1 samples (JBIG2 white) hide the image. + let alpha: Vec = picture(h).as_chunks::<4>().0.iter().map(|p| 255 - p[0]).collect(); + assert_eq!(alphas(&exported_rgba(&page(w, h, &jbig2, None, true)).1), alpha); +} + +/// A JBIG2 image's `/JBIG2Globals` are decoded before its JBIG2 page's size is known, so they have +/// a limit of their own, 32 MiB, far below the 1 GiB of any stream: globals that Flate-decode to +/// one byte more skip the image, with the limit in the reason, though they take a few kilobytes +/// in the file. Globals of exactly the limit are read, and the limit is the same through an +/// indirect `/DecodeParms`, in an array or not. +#[test] +fn jbig2_globals_are_decoded_within_a_limit_of_their_own() { + const LIMIT: usize = 32 << 20; + let bits = [0xF0u8, 0x0F, 0xAA, 0x55]; + let jbig2 = pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap(); + let picture: Vec = (0..32).flat_map(|i| if bits[i / 8] & (0x80 >> (i % 8)) != 0 { [255; 4] } else { [0, 0, 0, 255] }).collect(); + // Globals of `len` bytes, Flate-encoded: one extension segment (ISO/IEC 14492 7.4.14), which a + // decoder skips; its 11-byte header gives the length of the data after it. + let globals = |len: usize| { + let mut g = vec![0, 0, 0, 0, 0x3E, 0, 0]; + g.extend_from_slice(&u32::try_from(len - 11).unwrap().to_be_bytes()); + g.extend_from_slice(&[0, 0, 0, 1]); + g.resize(len, 0); + pdfcraft_filters::encode_flate(&g) + }; + // The image, its /DecodeParms naming `flated` as its globals: direct (0), by reference (1), or + // by reference in an array (2). + let page = |flated: &[u8], form: usize| { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let mut g = Dict::new(); + g.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let g = doc.add(Object::Stream(Stream::from_raw(g, flated.to_vec()))); + let mut parms = Dict::new(); + parms.set(b"JBIG2Globals".to_vec(), Object::Ref(g)); + let parms = match form { + 0 => Object::Dict(parms), + 1 => Object::Ref(doc.add(Object::Dict(parms))), + _ => Object::Array(vec![Object::Ref(doc.add(Object::Dict(parms)))]), + }; + let mut d = image_dict(16, 2, "DeviceGray", 1); + d.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + d.set(b"DecodeParms".to_vec(), parms); + Stream::from_raw(d, jbig2.clone()) + }); + doc + }; + + let (small, full, over) = (globals(15), globals(LIMIT), globals(LIMIT + 1)); + assert!(over.len() < 64 << 10, "the oversized globals take {} bytes in the file", over.len()); + assert_eq!(exported_rgba(&page(&full, 0)).1, picture, "globals of exactly the limit"); + for form in 0..3 { + assert_eq!(exported_rgba(&page(&small, form)).1, picture, "small globals, form {form}"); + let out = extract_images(&page(&over, form), &[0], 0); + assert!(out.images.is_empty(), "globals over the limit, form {form}"); + let why = &out.skipped.first().expect("the image is skipped").2; + assert!(why.starts_with("the JBIG2 globals can't be read") && why.contains(&format!("limit of {LIMIT} bytes")), "{why}"); + } +} + +/// A mask of other proportions than its image is scaled to it, however far apart they are: the +/// first two shapes asked for 160 GB on the way, in a file of a kilobyte, and that aborted the +/// process. The third would ask for 10 GB if it went across first, which is why the way round +/// depends on the shapes. The value comes through, and masks of ordinary proportions, of the +/// image's size and larger than it, are scaled as they always were. +#[test] +fn a_mask_of_other_proportions_than_its_image_is_scaled_without_the_memory_of_both() { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + let gray = |w: i64, h: i64, data: &[u8]| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w)); + d.set(b"Height".to_vec(), Object::Int(h)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + Stream::from_raw(d, pdfcraft_filters::encode_flate(data)) + }; + let shapes = [ + (100_000usize, 1usize, 1usize, 100_000usize), + (100_000, 64, 64, 100_000), + (1, 100_000, 100_000, 1), + (64, 1, 1, 64), + (3, 2, 6, 4), + (3, 2, 3, 2), + (6, 4, 3, 2), + ]; + for (mask_w, mask_h, w, h) in shapes { + let mut doc = Document::new_empty(); + let mask = doc.add(Object::Stream(gray(mask_w as i64, mask_h as i64, &vec![0x80; mask_w * mask_h]))); + let mut image = gray(w as i64, h as i64, &vec![0x40; w * h]); + image.dict.set(b"SMask".to_vec(), Object::Ref(mask)); + let image = doc.add(Object::Stream(image)); + let (name, png) = crate::image_file(&doc, image).unwrap_or_else(|e| panic!("{mask_w} × {mask_h} on {w} × {h}: {e}")); + assert_eq!(name, "png"); + let mut reader = ::png::Decoder::new(std::io::Cursor::new(&png)).read_info().unwrap(); + let mut rgba = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut rgba).unwrap(); + assert_eq!((info.width as usize, info.height as usize, info.color_type), (w, h, ::png::ColorType::Rgba)); + assert!(rgba.as_chunks::<4>().0.iter().all(|p| p[3].abs_diff(0x80) <= 1), "{mask_w} × {mask_h} on {w} × {h}"); + } +} + +/// The long way round is for proportions far apart: up to a quarter over the larger image the +/// buffer is no danger, and such a mask scales as it always did, to the byte. Noise, as a flat mask comes +/// out the same either way. +#[test] +fn proportions_up_to_the_limit_scale_as_they_did() { + let noise = |w: u32, h: u32| { + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let px = (0..w * h) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed as u8 + }) + .collect(); + image::GrayImage::from_raw(w, h, px).unwrap() + }; + // Each exactly a quarter over the larger: the source's size, then the target's. + for (mw, mh, w, h) in [(25, 16, 10, 20), (20, 5, 16, 25)] { + let mask = noise(mw, mh); + let direct = image::imageops::resize(&mask, w, h, image::imageops::FilterType::Triangle); + assert_eq!(crate::extract::scaled(&mask, w, h), direct, "{mw} × {mh} to {w} × {h}"); + } + // A step past it goes the long way, which is how to tell. + let mask = noise(25, 15); + let direct = image::imageops::resize(&mask, 10, 20, image::imageops::FilterType::Triangle); + assert_ne!(crate::extract::scaled(&mask, 10, 20), direct); + // A mask of its image's size comes through as it is. + let mask = noise(20, 10); + assert_eq!(crate::extract::scaled(&mask, 20, 10), mask); +} diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index 58e96e7d4f..ff92d9af66 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -634,12 +634,20 @@ fn expand(seed: &[u8; 32], label: &[u8], n: usize) -> Vec { h.update(label); h.update(counter.to_le_bytes()); out.extend_from_slice(&h.finalize()); - counter += 1; + counter = counter.saturating_add(1); } out.truncate(n); out } +/// Sixteen bytes of file identifier from `seed` (SHA-256 in counter mode, label `id`). +/// +/// `/ID` is written in the clear. Deriving it the same way as the file key keeps that public +/// value from being an unkeyed hash of the seed. +pub fn file_id(seed: &[u8; 32]) -> Vec { + expand(seed, b"id", 16) +} + /// Build the `/Encrypt` values and an authenticated (owner) handler for a new encryption. pub fn create(params: &NewEncryption, id0: &[u8]) -> Result { // Bits 7–8 and 13–32 must be 1; bits 1–2 must be 0 (§7.6.4.2). diff --git a/crates/crypt/src/tests.rs b/crates/crypt/src/tests.rs index c812f9f7bd..6064176d78 100644 --- a/crates/crypt/src/tests.rs +++ b/crates/crypt/src/tests.rs @@ -4,6 +4,16 @@ fn hex(s: &str) -> Vec { (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() } +#[test] +fn file_id_is_a_one_way_16_byte_identifier() { + let seed = [7u8; 32]; + let id = file_id(&seed); + assert_eq!(id.len(), 16); + assert_eq!(id, file_id(&seed)); + assert_ne!(id, seed[..16]); + assert_ne!(id, file_id(&[8u8; 32])); +} + #[test] fn rc4_reference_vectors() { assert_eq!(rc4(b"Key", b"Plaintext"), hex("bbf316e8d940af0ad3")); diff --git a/crates/edit/Cargo.toml b/crates/edit/Cargo.toml index 4eb2d6132b..2cd33a275d 100644 --- a/crates/edit/Cargo.toml +++ b/crates/edit/Cargo.toml @@ -15,6 +15,8 @@ pdfcraft-content = { workspace = true } pdfcraft-fonts = { workspace = true } pdfcraft-model = { workspace = true } thiserror = { workspace = true } +# A backdrop copy's tag records a hash of the operators before the fill it turned off (backdrop.rs). +sha2 = "0.11" kurbo = { workspace = true } # Display order of right-to-left lines in added text, and reading and writing order in Edit text; # already in the tree via the interface. @@ -22,3 +24,5 @@ unicode-bidi = "0.3" [dev-dependencies] hayro-syntax = { workspace = true } +pdfcraft-render = { workspace = true } +proptest = { workspace = true } diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index 40e540fb0c..a603a37060 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -67,5 +67,23 @@ impl Content { } } +fn align_code(a: Align) -> i64 { + match a { + Align::Left => 0, + Align::Center => 1, + Align::Right => 2, + Align::Justify => 3, + } +} + +fn align_from(code: Option) -> Align { + match code { + Some(1) => Align::Center, + Some(2) => Align::Right, + Some(3) => Align::Justify, + _ => Align::Left, + } +} + #[cfg(test)] mod arabic_tests; diff --git a/crates/edit/src/added/arabic_tests.rs b/crates/edit/src/added/arabic_tests.rs index d4ebb73e42..0671e3ce32 100644 --- a/crates/edit/src/added/arabic_tests.rs +++ b/crates/edit/src/added/arabic_tests.rs @@ -24,7 +24,7 @@ fn cluster(id: u32, advance: f64) -> ShapedCluster { fn line(clusters: Vec, rtl: bool) -> LaidLine { let text = clusters.iter().map(|c| c.text.as_str()).collect(); let width = clusters.iter().map(|c| c.advance).sum::() * item().size; - (text, rtl, vec![Piece::Shaped(clusters)], width) + (text, rtl, 0, vec![Piece::Shaped(clusters)], width) } /// A glyph outline: a box on the baseline, `advance` em wide. @@ -147,7 +147,7 @@ fn an_item_below_the_old_limit_is_drawn_exactly_as_before() { let space = ShapedCluster { script: ShapedScript::Arabic, glyphs: vec![(6, [0.0, 0.0])], advance: 0.25, text: " ".into() }; let ligature = ShapedCluster { script: ShapedScript::Arabic, glyphs: vec![(4, [0.0, 0.0]), (5, [-0.1, 0.2])], advance: 0.6, text: "لا".into() }; let mixed: LaidLine = - ("Hi ب".into(), false, vec![Piece::Latin("Hi ".into()), Piece::Shaped(vec![cluster(1, 0.5), cluster(2, 0.5), space, ligature])], 4.0); + ("Hi ب".into(), false, 0, vec![Piece::Latin("Hi ".into()), Piece::Shaped(vec![cluster(1, 0.5), cluster(2, 0.5), space, ligature])], 4.0); let rtl = line(vec![cluster(2, 0.5), cluster(9, 0.75)], true); let laid = vec![mixed, rtl]; let mut doc = Document::new_empty(); diff --git a/crates/edit/src/added/draw.rs b/crates/edit/src/added/draw.rs index 61297bcf24..9404f939b1 100644 --- a/crates/edit/src/added/draw.rs +++ b/crates/edit/src/added/draw.rs @@ -1,10 +1,10 @@ use super::layout::{ - Piece, arabic_error, hebrew_error, is_arabic, is_hebrew, is_thai, latin_width, lines, norm, shaped_layout, text_rect, thai_error, wrapped, + Piece, arabic_error, hebrew_error, is_arabic, is_hebrew, is_thai, latin_width, norm, shaped_layout, text_rect, thai_error, wrapped, }; use super::types::{AddedText, Align, Family}; use super::{ Content, Dict, Document, EditError, GlyphError, GlyphOutline, HashMap, HashSet, ObjRef, Object, PdfString, ShapedCluster, ShapedScript, Stream, - literal, matrix_number, n, shaped_glyph, win_ansi, + align_code, literal, matrix_number, n, shaped_glyph, win_ansi, }; fn font_name(base: &str) -> String { @@ -16,8 +16,26 @@ fn font_name(base: &str) -> String { pub(super) const GLYPHS_PER_FONT: usize = 240; /// A line of a shaped-text item after layout: its text, whether its paragraph runs right to left, -/// its pieces in drawing order and its width at the item's size. -pub(super) type LaidLine = (String, bool, Vec, f64); +/// its paragraph, its pieces in drawing order and its width at the item's size. +pub(super) type LaidLine = (String, bool, usize, Vec, f64); + +/// Where line `i` (`w` wide unstretched, with `spaces` spaces) starts in the box `r`, and its word +/// spacing. `paragraphs` holds each line's paragraph: a line takes its paragraph's alignment, and +/// every line of a justified paragraph but its last is stretched to the box's width. +fn placed(t: &AddedText, r: [f64; 4], paragraphs: &[usize], i: usize, w: f64, spaces: usize, rtl: bool) -> (f64, f64) { + let paragraph = paragraphs.get(i).copied().unwrap_or(0); + let align = t.paragraph_align(paragraph); + let ends_paragraph = paragraphs.get(i + 1) != Some(¶graph); + let tw = if align == Align::Justify && !ends_paragraph && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 }; + let x = match align { + // A right-to-left paragraph's unstretched last line ends at the right edge. + Align::Justify if rtl => r[2] - w - tw * spaces as f64, + Align::Left | Align::Justify => r[0], + Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0, + Align::Right => r[2] - w, + }; + (x, tw) +} /// Draw a text item that has Arabic, Hebrew or Thai in it. Each line is laid out in display order; their glyphs /// come from Type 3 fonts drawn from the craft-fonts face's outlines (no font file is embedded), @@ -25,7 +43,7 @@ pub(super) type LaidLine = (String, bool, Vec, f64); fn draw_shaped(doc: &mut Document, t: &AddedText, taken: &Dict, fonts: &mut Dict, out: &mut Vec) -> Result<(), EditError> { let laid = wrapped(t) .into_iter() - .map(|(line, rtl)| shaped_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, pieces, w))) + .map(|(line, rtl, paragraph)| shaped_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, paragraph, pieces, w))) .collect::, _>>()?; draw_laid_shaped(doc, t, &laid, taken, fonts, out, shaped_glyph) } @@ -47,7 +65,7 @@ pub(super) fn draw_laid_shaped( // one of the fonts, so copy and search get each character exactly once. let mut keys: Vec<&ShapedCluster> = Vec::new(); let mut index: HashMap = HashMap::new(); - for c in laid.iter().flat_map(|(_, _, pieces, _)| pieces).flat_map(|p| match p { + for c in laid.iter().flat_map(|(_, _, _, pieces, _)| pieces).flat_map(|p| match p { Piece::Shaped(clusters) => clusters.as_slice(), Piece::Latin(_) => &[], }) { @@ -92,21 +110,14 @@ pub(super) fn draw_laid_shaped( if t.char_spacing != 0.0 { out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes()); } - for (i, (line, rtl, pieces, w)) in laid.iter().enumerate() { + let paragraphs: Vec = laid.iter().map(|(_, _, p, ..)| *p).collect(); + for (i, (line, rtl, _, pieces, w)) in laid.iter().enumerate() { // Shaped glyphs are painted in visual order. Keep the original line's typing order // explicitly, including marks, ligatures, digits and mixed-script punctuation. let logical: String = line.encode_utf16().map(|u| format!("{u:04X}")).collect(); out.extend(format!("/Span <>> BDC\n").bytes()); let spaces = line.matches(' ').count(); - // Justified: the spaces of every line but the last stretch to fill the box. - let tw = if t.align == Align::Justify && i + 1 < laid.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 }; - let x = match t.align { - // A right-to-left paragraph's unstretched last line ends at the right edge. - Align::Justify if *rtl => r[2] - w - tw * spaces as f64, - Align::Left | Align::Justify => r[0], - Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0, - Align::Right => r[2] - w, - }; + let (x, tw) = placed(t, r, ¶graphs, i, *w, spaces, *rtl); let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size; let mut pen = x; let mut current: Option<&str> = None; @@ -287,20 +298,14 @@ pub(super) fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict if t.char_spacing != 0.0 { out.extend(format!("{} Tc\n", n(t.char_spacing)).bytes()); } - let all = lines(t); - for (i, line) in all.iter().enumerate() { + let all = wrapped(t); + let paragraphs: Vec = all.iter().map(|(_, _, p)| *p).collect(); + for (i, (line, ..)) in all.iter().enumerate() { let w = latin_width(t, line); - let x = match t.align { - Align::Left | Align::Justify => r[0], - Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0, - Align::Right => r[2] - w, - }; // Baseline: 0.8 em below the line top. let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size; - // Justified: word spacing makes every line but the last fill the box. - let spaces = line.matches(' ').count(); - let tw = - if t.align == Align::Justify && i + 1 < all.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 }; + // Justified: word spacing makes every line of a paragraph but its last fill the box. + let (x, tw) = placed(t, r, ¶graphs, i, w, line.matches(' ').count(), false); out.extend(format!("1 0 0 1 {} {} Tm {} Tw ", n(x), n(y), n(tw)).bytes()); out.extend(literal(&win_ansi(line))); out.extend_from_slice(b" Tj\n"); @@ -350,15 +355,12 @@ pub(super) fn params(c: &Content) -> Dict { d.set(b"Font".to_vec(), Object::name(t.family.base_font(t.bold, t.italic))); d.set(b"Size".to_vec(), Object::Real(t.size)); d.set(b"Color".to_vec(), arr(&t.color)); - d.set( - b"Align".to_vec(), - Object::Int(match t.align { - Align::Left => 0, - Align::Center => 1, - Align::Right => 2, - Align::Justify => 3, - }), - ); + d.set(b"Align".to_vec(), Object::Int(align_code(t.align))); + // Each paragraph's own alignment, or null where it follows /Align. + let aligns = crate::paragraph_align::tidy(t.aligns.clone(), t.align); + if !aligns.is_empty() { + d.set(b"Aligns".to_vec(), Object::Array(aligns.iter().map(|a| a.map_or(Object::Null, |a| Object::Int(align_code(a)))).collect())); + } d.set(b"Rect".to_vec(), arr(&text_rect(t))); if t.char_spacing != 0.0 { d.set(b"CharSpacing".to_vec(), Object::Real(t.char_spacing)); diff --git a/crates/edit/src/added/layout.rs b/crates/edit/src/added/layout.rs index 67d80be405..31a4dc409f 100644 --- a/crates/edit/src/added/layout.rs +++ b/crates/edit/src/added/layout.rs @@ -20,7 +20,7 @@ pub(super) fn latin_width(t: &AddedText, s: &str) -> f64 { /// The lines of a text item after wrapping to its box width. pub fn lines(t: &AddedText) -> Vec { - wrapped(t).into_iter().map(|(line, _)| line).collect() + wrapped(t).into_iter().map(|(line, ..)| line).collect() } #[cfg(test)] @@ -30,13 +30,13 @@ thread_local! { } /// [`lines`], each with whether its paragraph runs right to left (its first strong character -/// does). -pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool)> { +/// does) and which paragraph it is in. +pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool, usize)> { #[cfg(test)] WRAP_CALLS.with(|c| c.set(c.get().saturating_add(1))); let width = (t.rect[2] - t.rect[0]).max(t.size); let mut out = Vec::new(); - for para in t.text.split('\n') { + for (index, para) in t.text.split('\n').enumerate() { // Arabic or Hebrew: laid out with the bidirectional algorithm and shaped. let rtl_script = para.chars().any(|c| is_arabic(c) || is_hebrew(c)); let thai = para.chars().any(is_thai); @@ -44,7 +44,7 @@ pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool)> { && !rtl_script && let Ok(lines) = thai_lines(t, para, width) { - out.extend(lines.into_iter().map(|line| (line, false))); + out.extend(lines.into_iter().map(|line| (line, false, index))); continue; } let rtl = rtl_script && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl; @@ -60,7 +60,7 @@ pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool)> { let candidate_w = if rtl_script || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) }; if !line.is_empty() && candidate_w > width { - out.push((std::mem::take(&mut line), rtl)); + out.push((std::mem::take(&mut line), rtl, index)); line = word.to_string(); line_w = measure(word); } else { @@ -68,7 +68,7 @@ pub(super) fn wrapped(t: &AddedText) -> Vec<(String, bool)> { line_w = candidate_w; } } - out.push((line, rtl)); + out.push((line, rtl, index)); } out } @@ -142,7 +142,7 @@ pub fn first_undrawable(t: &AddedText) -> Option { return t.text.split('\n').find_map(pdfcraft_fonts::first_non_win_ansi); } // What `shaped_layout` leaves to the standard font, exactly as `draw_shaped` splits it. - wrapped(t).into_iter().find_map(|(line, rtl)| { + wrapped(t).into_iter().find_map(|(line, rtl, _)| { let (pieces, _) = shaped_layout(t, &line, rtl).ok()?; pieces.iter().find_map(|p| match p { Piece::Latin(s) => pdfcraft_fonts::first_non_win_ansi(s), diff --git a/crates/edit/src/added/store.rs b/crates/edit/src/added/store.rs index 5ce5e15d19..7dae55596f 100644 --- a/crates/edit/src/added/store.rs +++ b/crates/edit/src/added/store.rs @@ -1,7 +1,7 @@ use super::draw::{draw, params}; use super::layout::{first_undrawable, is_thai, nums, shaped_arabic, thai_error}; -use super::types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG}; -use super::{Content, Dict, Document, EditError, GlyphError, MarkKind, ObjRef, Object, Stream, check, contents, page_list, place_tagged}; +use super::types::{Added, AddedImage, AddedText, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG}; +use super::{Content, Dict, Document, EditError, GlyphError, MarkKind, ObjRef, Object, Stream, align_from, check, contents, page_list, place_tagged}; fn parse(doc: &Document, d: &Dict) -> Option { let r = nums(doc, d.get(b"Rect")); @@ -10,20 +10,27 @@ fn parse(doc: &Document, d: &Dict) -> Option { b"Text" => { let (family, bold, italic) = Family::from_base(d.name(b"Font").unwrap_or(b"Helvetica")); let c = nums(doc, d.get(b"Color")); + let text = d.get(b"Text").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(); + let align = align_from(d.int(b"Align")); + // At most one entry per paragraph, whatever the file says. + let aligns = d + .get(b"Aligns") + .and_then(|a| { + doc.resolve(a) + .as_array() + .map(|a| a.iter().take(crate::paragraph_align::count(&text)).map(|v| v.as_int().map(|c| align_from(Some(c)))).collect()) + }) + .unwrap_or_default(); Some(Content::Text(AddedText { rect, - text: d.get(b"Text").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(), + text, family, bold, italic, size: d.get(b"Size").and_then(Object::as_f64).unwrap_or(12.0), color: if c.len() == 3 { [c[0], c[1], c[2]] } else { [0.0; 3] }, - align: match d.int(b"Align") { - Some(1) => Align::Center, - Some(2) => Align::Right, - Some(3) => Align::Justify, - _ => Align::Left, - }, + align, + aligns: crate::paragraph_align::tidy(aligns, align), // Items written before character spacing have none. char_spacing: d.get(b"CharSpacing").and_then(Object::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0), })) diff --git a/crates/edit/src/added/types.rs b/crates/edit/src/added/types.rs index 28b61d5869..0caaaa863b 100644 --- a/crates/edit/src/added/types.rs +++ b/crates/edit/src/added/types.rs @@ -104,6 +104,16 @@ pub struct AddedText { pub align: Align, /// Extra space after every character, in points: the PDF's character spacing (`Tc`). pub char_spacing: f64, + /// Paragraphs (lines typed with Enter) aligned other than [`AddedText::align`], by index; + /// `None` and paragraphs past the end follow `align` ([`crate::paragraph_align`]). + pub aligns: Vec>, +} + +impl AddedText { + /// Paragraph `i`'s alignment. + pub fn paragraph_align(&self, i: usize) -> Align { + crate::paragraph_align::align_of(&self.aligns, i, self.align) + } } impl Default for AddedText { @@ -117,6 +127,7 @@ impl Default for AddedText { size: 12.0, color: [0.0; 3], align: Align::Left, + aligns: Vec::new(), char_spacing: 0.0, } } diff --git a/crates/edit/src/apply.rs b/crates/edit/src/apply.rs index 427f51f943..ff570d5483 100644 --- a/crates/edit/src/apply.rs +++ b/crates/edit/src/apply.rs @@ -5,7 +5,7 @@ use super::place::{ END, Isolation, add_page_resources, add_resources, begin, check, check_on_top, contents, free_font_name, isolation, page_fonts, page_list, place, place_inside_wrapper, stream_tag, text_op, undrawable, wrapper_role, }; -use super::{Document, EditError, Object, added, helvetica_width}; +use super::{Document, EditError, Object, added, backdrop, helvetica_width}; /// Add a header and footer to `pages` (0-based). With `replace`, existing headers and footers on /// those pages are removed first (Acrobat's Replace Existing). @@ -199,7 +199,9 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac Ok(()) } -/// Add a solid-colour background behind the content of `pages`. +/// Add a solid-colour background behind the content of `pages`. With `bg.replace_backdrop`, a +/// white backdrop a page paints itself is replaced first, so the background shows (see +/// [`backdrop`]); a picture backdrop (a scan) still hides it. pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, replace: bool) -> Result<(), EditError> { let all = page_list(doc); check(pages, all.len())?; @@ -208,7 +210,10 @@ pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, repl } let opacity = if bg.opacity.is_finite() { bg.opacity.clamp(0.0, 1.0) } else { 1.0 }; for &i in pages { - let page = page_list(doc)[i].clone(); + let mut page = page_list(doc)[i].clone(); + if bg.replace_backdrop && backdrop::replace(doc, &page)? { + page = page_list(doc)[i].clone(); + } let (w, h) = page.display_size(doc); let mut content = begin(MarkKind::Background, "Background", page.view_matrix(doc)); match &bg.source { @@ -231,13 +236,18 @@ pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, repl /// Remove marks of `kind` from `pages`; returns how many were removed. Wrappers left with /// nothing after them go too, restoring the original content list; a wrapper that content -/// after it still relies on (another tool's appended stream) stays. +/// after it still relies on (another tool's appended stream) stays. Removing backgrounds also +/// turns a backdrop [`add_background`] replaced back on where that is still safe; the pages where +/// it isn't are [`backdrops_left_off`]. pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Result { let all = page_list(doc); check(pages, all.len())?; let mut removed = 0; for &i in pages { let page = &all[i]; + if kind == MarkKind::Background { + backdrop::restore(doc, page)?; + } let list = contents(doc, page)?; let before = list.len(); let mut kept: Vec = list.into_iter().filter(|o| stream_tag(doc, o).as_deref() != Some(kind.tag())).collect(); diff --git a/crates/edit/src/backdrop.rs b/crates/edit/src/backdrop.rs new file mode 100644 index 0000000000..43e16b40ec --- /dev/null +++ b/crates/edit/src/backdrop.rs @@ -0,0 +1,584 @@ +//! A page's own backdrop: an opaque white fill (or a picture) covering the whole visible page +//! before anything else is drawn. Many writers paint one (browsers' Save as PDF, print drivers; +//! scans are a page-sized picture), and it hides anything placed behind the content, so a +//! background added behind such a page never shows. +//! +//! A white fill can be replaced: the one fill operator becomes `n` (end the path without +//! painting), which looks the same on white paper. The page then gets a copy of the content +//! stream that held it, tagged `/PCBackdrop << /Index n /Op (f) /Before /Was [num gen] >>`: +//! how many of the copy's operators come before the `n`, the operator it stands in for, a hash of +//! those operators (their values, not their spacing: redaction writes a stream out again with +//! other whitespace), and the number of the stream it was copied from. The stream itself is left +//! alone (other pages may share it). [`restore`] turns the fill back on when the background is +//! removed, even after the page was edited or redacted, as long as the operators before the fill +//! are unchanged and the fill would again be the first thing painted, covering the page. +//! Otherwise the fill stays off, and [`backdrops_left_off`] names those pages. A picture can't be +//! replaced without losing it, so it is only reported. +//! +//! The tag holds no page content and no reference that keeps another object alive (`/Was` is two +//! numbers, not a reference): a reference to the original stream would keep text in the file +//! that a redaction, Remove Hidden Information or Edit Text removed from the copy. +//! +//! Detection is deliberately strict: only one rectangle (`re`), in DeviceGray, DeviceRGB or +//! DeviceCMYK, opaque, unmasked, normally blended and with the identity transfer function as the +//! graphics state stands after every `gs` so far (each sets only its own keys), with an +//! axis-aligned transform, filled before any other painting, and covering the crop box. +//! Anything else is real content and stays. + +use pdfcraft_content::{Matrix, Op, Pieces}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString, Stream}; +use sha2::{Digest, Sha256}; + +use crate::{EditError, MarkKind}; + +/// The dictionary key of a backdrop copy's tag. +const TAG: &[u8] = b"PCBackdrop"; +/// The fill operators a backdrop can be painted with, and so the only ones a tag may restore. +const FILLS: [&[u8]; 3] = [b"f", b"F", b"f*"]; + +/// How much of a page's content is read looking for a backdrop: it has to come first. +const SCAN_BYTES: usize = 64 * 1024; +/// How many operators are walked before giving up. +const SCAN_OPS: usize = 4096; +/// How far (in points) a backdrop may fall short of the crop box and still count as covering it. +const COVER_EPS: f64 = 1.0; +/// Components at least this close to white (per channel) count as white: a near-white fill +/// (`0.99 g`) is a backdrop too, and replacing it changes the page by at most 2%. +const WHITE: f64 = 0.98; + +/// What paints a page's backdrop. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Backdrop { + /// An opaque white fill: replaceable. + Fill, + /// A picture covering the page (a scan): a background behind it stays hidden. + Image, +} + +/// Where a replaceable fill is: the content streams (references in `/Contents` order, untagged +/// ones only), their joined bytes, where each starts in them, and the fill operator's joined +/// byte range. +struct Found { + refs: Vec<(usize, ObjRef)>, + pieces: Pieces, + starts: Vec, + op: std::ops::Range, +} + +impl Found { + /// The piece holding the fill operator, and the operator's offset in it. + fn place(&self) -> Option<(usize, usize)> { + let piece = self.pieces.piece_of(self.op.start); + Some((piece, self.op.start.checked_sub(*self.starts.get(piece)?)?)) + } +} + +#[derive(Clone)] +struct State { + ctm: Matrix, + /// Clip in default user space, when it is a known rectangle; `None` = unknown shape. + clip: Option<[f64; 4]>, + /// The fill colour is white. + white: bool, + /// Non-stroking colour space components (1, 3, 4), or 0 for a space we don't read. + space: usize, + paint: Paint, +} + +/// The graphics-state parameters (ExtGState, ISO 32000-2 §8.4.5) that decide whether a fill +/// paints its colour as given. A `gs` sets only the keys its dictionary has, so each is kept on +/// its own: a later dictionary that leaves one out keeps the earlier value. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Paint { + /// Non-stroking alpha (`ca`) is 1. + full_alpha: bool, + /// No soft mask (`SMask` is `/None`). + no_soft_mask: bool, + /// The blend mode (`BM`) is Normal or Compatible. + normal_blend: bool, + /// The transfer function (`TR`, or `TR2`, which takes precedence) is the identity: a + /// function could turn white into any colour. + identity_transfer: bool, +} + +impl Paint { + const INITIAL: Paint = Paint { full_alpha: true, no_soft_mask: true, normal_blend: true, identity_transfer: true }; + + /// A fill paints its colour exactly, opaque. + fn plain(self) -> bool { + self.full_alpha && self.no_soft_mask && self.normal_blend && self.identity_transfer + } +} + +/// The backdrop `page` (0-based) paints, if any. Finding the page walks the page tree, so to +/// check many pages use [`page_backdrops`], which walks it once. +pub fn page_backdrop(doc: &Document, page: usize) -> Result, EditError> { + let all = pdfcraft_model::pages(doc); + let p = all.get(page).ok_or(EditError::NoSuchPage(page))?; + Ok(scan(doc, p, None).map(|(b, _)| b)) +} + +/// The backdrop each of `pages` (0-based) paints, in the same order, walking the page tree once. +/// A page past the end has none, and doesn't hide what the others paint: a page count taken +/// elsewhere (the viewer's) can disagree with the page tree's on a malformed document. +pub fn page_backdrops(doc: &Document, pages: &[usize]) -> Vec> { + let all = pdfcraft_model::pages(doc); + pages.iter().map(|&p| all.get(p).and_then(|page| scan(doc, page, None)).map(|(b, _)| b)).collect() +} + +/// Pages (0-based, ascending) whose own white backdrop [`replace`] turned off and [`restore`] +/// couldn't turn back on: a tagged copy is still in their content, with no background over it. +/// They look the same on white paper. +pub fn backdrops_left_off(doc: &Document) -> Vec { + let tag_of = |o: &Object| crate::place::stream_tag(doc, o); + let copy = |o: &Object| doc.resolve(o).as_dict().is_some_and(|d| d.get(TAG).is_some()); + pdfcraft_model::pages(doc) + .iter() + .enumerate() + .filter(|(_, p)| { + let list: Vec = match p.dict.get(b"Contents").map(|c| doc.resolve(c)).as_deref() { + Some(Object::Array(a)) => a.clone(), + Some(_) => p.dict.get(b"Contents").cloned().into_iter().collect(), + None => Vec::new(), + }; + list.iter().any(copy) && !list.iter().any(|o| tag_of(o).as_deref() == Some(MarkKind::Background.tag())) + }) + .map(|(i, _)| i) + .collect() +} + +/// Replace `page`'s white backdrop fill, if it has one (see the module doc). Returns whether it +/// did; when it did, `page` no longer matches the document. A picture backdrop is left alone. +pub(crate) fn replace(doc: &mut Document, page: &pdfcraft_model::Page) -> Result { + let Some((Backdrop::Fill, Some(found))) = scan(doc, page, None) else { return Ok(false) }; + let Some((piece, at)) = found.place() else { return Ok(false) }; + let op = found.pieces.data().get(found.op.clone()).unwrap_or_default().to_vec(); + let edited = found.pieces.splice([(found.op.clone(), b"n".to_vec())]); + let (Some(&(_, original)), Some(bytes)) = (found.refs.get(piece), edited.get(piece)) else { return Ok(false) }; + // The `n` among the copy's own operators. A copy whose fill can't be found that way again is + // not made: there would be no safe way back. + let parsed = pdfcraft_content::parse(head(bytes)); + let Some(index) = parsed.ops.iter().position(|o| o.span.start == at && o.op == b"n") else { return Ok(false) }; + let Some(before) = parsed.ops.get(..index) else { return Ok(false) }; + let mut note = Dict::new(); + note.set(b"Index".to_vec(), Object::Int(i64::try_from(index).unwrap_or(i64::MAX))); + note.set(b"Op".to_vec(), Object::String(PdfString::literal(op))); + note.set(b"Before".to_vec(), Object::String(PdfString { bytes: ops_hash(before), hex: true })); + note.set(b"Was".to_vec(), Object::Array(vec![Object::Int(original.num.into()), Object::Int(original.generation.into())])); + // The copy keeps the original's dictionary (unknown entries included) less what describes + // the old encoding; `scan` refused streams whose bytes live in an external file (`/F`). + let Object::Stream(source) = &*doc.get(original) else { return Ok(false) }; + let mut dict = source.dict.clone(); + for key in [&b"Length"[..], b"DL", TAG] { + dict.remove(key); + } + dict.set(TAG.to_vec(), Object::Dict(note)); + let copy = doc.add(Object::Stream(Stream::flate(dict, bytes))); + // scan accepts only one execution of this stream on the page. Other pages keep the + // original stream; ambiguous repeated executions are never rewritten. + let mut list = crate::place::contents(doc, page)?; + let mut replaced = false; + for entry in list.iter_mut().filter(|o| matches!(o, Object::Ref(r) if *r == original)) { + *entry = Object::Ref(copy); + replaced = true; + } + if replaced { + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?; + } + Ok(replaced) +} + +/// Turn the fills [`replace`] turned off on `page` back on, where [`restored_copy`] finds that +/// safe; a copy listed in several slots is restored in each. Returns how many slots it restored. +/// A copy it can't restore stays as it is, tag and all, so [`backdrops_left_off`] names its page. +pub(crate) fn restore(doc: &mut Document, page: &pdfcraft_model::Page) -> Result { + let mut list = crate::place::contents(doc, page)?; + // Per copy: the stream that takes its place, or `None` when it stays. + let mut done: Vec<(ObjRef, Option)> = Vec::new(); + let mut restored = 0; + for entry in list.iter_mut() { + let Object::Ref(copy) = *entry else { continue }; + let with = match done.iter().find(|(c, _)| *c == copy) { + Some(&(_, with)) => with, + None => { + let with = restored_copy(doc, page, copy).map(|(bytes, dict, was)| { + // The stream it was copied from, while that is still here with these bytes + // (other pages may share it): the page is then exactly as it was. `/Was` is + // only a number, and a full save renumbers objects, so the bytes decide. + let same = |w: &ObjRef| match &*doc.get(*w) { + Object::Stream(s) => { + [TAG, &b"PCMark"[..], b"Subtype"].iter().all(|k| s.dict.get(k).is_none()) + && DecodeBudget::new(SCAN_BYTES, 0).decode_strict(s).ok().as_deref() == Some(bytes.as_slice()) + } + _ => false, + }; + match was.filter(same) { + Some(w) => w, + None => doc.add(Object::Stream(Stream::flate(dict, &bytes))), + } + }); + done.push((copy, with)); + with + } + }; + if let Some(with) = with { + *entry = Object::Ref(with); + restored += 1; + } + } + if restored > 0 { + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?; + } + Ok(restored) +} + +/// `copy` (a stream [`replace`] wrote for `page`) with its fill turned back on: the bytes, its +/// dictionary without the tag, and the stream it was copied from. `None` when it isn't such a +/// copy, or when turning the fill on could paint over something: the operators before the fill +/// changed since (their hash no longer matches), or the page would no longer paint the fill +/// first, covering it. What comes after the fill (an edit, a redaction) is kept as it is. +fn restored_copy(doc: &Document, page: &pdfcraft_model::Page, copy: ObjRef) -> Option<(Vec, Dict, Option)> { + let obj = doc.get(copy); + let Object::Stream(s) = &*obj else { return None }; + let note = s.dict.get(TAG).map(|n| doc.resolve(n))?; + let note = note.as_dict()?; + let index = usize::try_from(note.get(b"Index")?.as_int()?).ok()?; + let op = note.get(b"Op")?.as_string()?.bytes.as_slice(); + if !FILLS.contains(&op) { + return None; + } + let hash = note.get(b"Before")?.as_string()?; + let bytes = DecodeBudget::new(SCAN_BYTES, 0).decode_strict(s).ok()?; + let parsed = pdfcraft_content::parse(head(&bytes)); + // The `n` standing in for the fill, after exactly the operators that came before it. + let fill = parsed.ops.get(index)?; + if fill.op != b"n" || !fill.operands.is_empty() || ops_hash(parsed.ops.get(..index)?) != hash.bytes { + return None; + } + let mut out = Vec::with_capacity(bytes.len().saturating_add(op.len())); + out.extend_from_slice(bytes.get(..fill.span.start)?); + out.extend_from_slice(op); + out.extend_from_slice(bytes.get(fill.span.end..)?); + let (Backdrop::Fill, Some(found)) = scan(doc, page, Some((copy, &out)))? else { return None }; + let (piece, offset) = found.place()?; + if found.refs.get(piece).map(|&(_, r)| r) != Some(copy) || offset != fill.span.start || found.op.len() != op.len() { + return None; + } + let mut dict = s.dict.clone(); + for key in [&b"Length"[..], b"DL", TAG] { + dict.remove(key); + } + let was = match note.get(b"Was").and_then(Object::as_array).map(Vec::as_slice) { + Some([num, generation]) => Some(ObjRef { num: u32::try_from(num.as_int()?).ok()?, generation: u16::try_from(generation.as_int()?).ok()? }), + _ => None, + }; + Some((out, dict, was)) +} + +/// As much of a content stream as a backdrop could be in: it has to come first. +fn head(bytes: &[u8]) -> &[u8] { + bytes.get(..bytes.len().min(SCAN_BYTES)).unwrap_or_default() +} + +/// A SHA-256 of what `ops` are, not how they are written: keywords and operand values, with +/// numbers in one form (`1`, `1.0` and `1.00000` are the same), so a stream written out again +/// with other spacing or number formatting still matches. +fn ops_hash(ops: &[Op]) -> Vec { + let mut h = Sha256::new(); + for op in ops { + for o in &op.operands { + canonical(o, &mut h, 0); + } + if let Some((dict, data)) = &op.inline { + canonical_dict(dict, &mut h, 0); + bytes(b'i', data, &mut h); + } + bytes(b'k', &op.op, &mut h); + } + h.finalize().to_vec() +} + +/// How deep [`canonical`] follows nested arrays and dictionaries; deeper ones count as equal. +const CANONICAL_DEPTH: usize = 32; + +fn canonical(o: &Object, h: &mut Sha256, depth: usize) { + if depth > CANONICAL_DEPTH { + h.update(b"~"); + return; + } + match o { + Object::Null => h.update(b"z"), + Object::Bool(b) => h.update(if *b { b"T" } else { b"F" }), + Object::Int(i) => number(&i.to_string(), h), + Object::Real(r) if *r == 0.0 => number("0", h), + Object::Real(r) => number(&format!("{r:.5}"), h), + Object::String(s) => bytes(b's', &s.bytes, h), + Object::Name(n) => bytes(b'/', n, h), + Object::Array(a) => { + h.update(b"["); + for x in a { + canonical(x, h, depth.saturating_add(1)); + } + h.update(b"]"); + } + Object::Dict(d) => canonical_dict(d, h, depth), + Object::Stream(_) | Object::Ref(_) => h.update(b"?"), + } +} + +fn canonical_dict(d: &Dict, h: &mut Sha256, depth: usize) { + h.update(b"<"); + for (k, v) in d.iter() { + bytes(b'/', k, h); + canonical(v, h, depth.saturating_add(1)); + } + h.update(b">"); +} + +/// A number written with up to five decimals, without trailing zeros. +fn number(text: &str, h: &mut Sha256) { + let t = if text.contains('.') { text.trim_end_matches('0').trim_end_matches('.') } else { text }; + bytes(b'#', t.as_bytes(), h); +} + +/// A typed, length-prefixed value, so no two different sequences hash the same way. +fn bytes(kind: u8, b: &[u8], h: &mut Sha256) { + h.update([kind]); + h.update(u64::try_from(b.len()).unwrap_or(u64::MAX).to_le_bytes()); + h.update(b); +} + +/// Find the backdrop, and for a fill, where it is. With `swap`, that stream reads as those bytes +/// instead of its own (to check a change before making it). +fn scan(doc: &Document, p: &pdfcraft_model::Page, swap: Option<(ObjRef, &[u8])>) -> Option<(Backdrop, Option)> { + let crop = p.crop(doc); + let c = p.dict.get(b"Contents")?; + let list: Vec = match &*doc.resolve(c) { + Object::Array(a) if a.len() <= SCAN_OPS => a.clone(), + Object::Array(_) => return None, + _ => vec![c.clone()], + }; + // The page's own content: untagged streams (marks and the q/Q wrapper carry /PCMark). Read + // only as far as a backdrop could be. + let mut refs = Vec::new(); + let mut data = Vec::new(); + let mut starts = Vec::new(); + let mut total = 0usize; + let mut budget = DecodeBudget::new(SCAN_BYTES, 0); + for (slot, o) in list.iter().enumerate() { + if total >= SCAN_BYTES { + break; + } + let Object::Ref(r) = o else { return None }; + let obj = doc.get(*r); + let Object::Stream(s) = &*obj else { continue }; + if s.dict.get(b"PCMark").is_some() { + continue; + } + // Bytes in an external file (`/F`) replace the stream's own: what it draws is unknown here. + if s.dict.get(b"F").is_some() { + return None; + } + let bytes = match swap { + Some((with, b)) if with == *r => { + if b.len() > SCAN_BYTES.saturating_sub(total) { + return None; + } + budget.decode_strict(&Stream::from_raw(Dict::new(), b.to_vec())).ok()? + } + // Tolerant decoding can return an empty or partial stream after corruption. + // That cannot prove which paint preceded the candidate backdrop. + _ => budget.decode_strict(s).ok()?, + }; + if bytes.len() > SCAN_BYTES.saturating_sub(total).saturating_sub(1) { + return None; + } + starts.push(total); + total += bytes.len() + 1; + refs.push((slot, *r)); + data.push(bytes); + } + let pieces = Pieces::join(&data); + let parsed = pdfcraft_content::parse(head(pieces.data())); + let res = resources(doc, &p.dict); + let found = walk(doc, &res, crop, &parsed.ops); + // A match proves the graphics state for one execution only. Repeated references may + // paint real artwork under a different color or transform: leave them untouched. + if let Some((Backdrop::Fill, Some(op))) = &found { + let piece = pieces.piece_of(op.start); + let (_, reference) = refs.get(piece)?; + if list.iter().filter(|o| matches!(o, Object::Ref(r) if r == reference)).count() != 1 { + return None; + } + } + found.map(|(b, op)| match (b, op) { + (Backdrop::Fill, Some(op)) => (b, Some(Found { refs, pieces, starts, op })), + _ => (b, None), + }) +} + +fn resources(doc: &Document, page: &Dict) -> Dict { + page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default() +} + +fn sub(doc: &Document, res: &Dict, kind: &[u8], name: &[u8]) -> Option> { + let d = res.get(kind).map(|d| doc.resolve(d))?; + let o = d.as_dict()?.get(name)?.clone(); + Some(doc.resolve(&o)) +} + +/// Walk the operators until the first one that paints; a backdrop is that one, or nothing. +fn walk(doc: &Document, res: &Dict, crop: [f64; 4], ops: &[Op]) -> Option<(Backdrop, Option>)> { + let mut st = State { ctm: Matrix::IDENTITY, clip: Some([f64::MIN, f64::MIN, f64::MAX, f64::MAX]), white: false, space: 1, paint: Paint::INITIAL }; + let mut saved: Vec = Vec::new(); + // Current path: its rectangles in user space, or `None` once it is anything else. + let mut path: Option> = Some(Vec::new()); + let mut clip_next = false; + for op in ops.iter().take(SCAN_OPS) { + match op.op.as_slice() { + b"q" => { + if saved.len() >= 64 { + return None; + } + saved.push(st.clone()); + } + b"Q" => st = saved.pop()?, + b"cm" => { + let m = Matrix::from_operands(op.operands.get(op.operands.len().checked_sub(6)?..)?)?; + if !m.0.iter().all(|v| v.is_finite()) { + return None; + } + st.ctm = m.then(&st.ctm); + } + b"gs" => apply_state(doc, res, op.name(0)?, &mut st.paint)?, + b"g" => set_colour(&mut st, 1, op), + b"rg" => set_colour(&mut st, 3, op), + b"k" => set_colour(&mut st, 4, op), + b"cs" => { + st.space = match op.name(0)? { + b"DeviceGray" => 1, + b"DeviceRGB" => 3, + b"DeviceCMYK" => 4, + _ => 0, + }; + // The initial colour of these spaces is black. + st.white = false; + } + b"sc" | b"scn" => { + let n = st.space; + set_colour(&mut st, n, op) + } + b"re" => { + let [x, y, w, h] = op.nums::<4>()?; + let finite = [x, y, w, h].iter().all(|v| v.is_finite()); + match (&mut path, finite && axis_aligned(&st.ctm)) { + (Some(rects), true) => rects.push(st.ctm.bbox([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)])), + _ => path = None, + } + } + b"m" | b"l" | b"c" | b"v" | b"y" | b"h" => path = None, + b"W" | b"W*" => clip_next = true, + b"n" => { + if clip_next { + st.clip = clipped(st.clip, path.as_deref()); + } + path = Some(Vec::new()); + clip_next = false; + } + b"f" | b"F" | b"f*" => { + // One rectangle: with two, a fill rule can cut a hole where content shows. + let covers = match path.as_deref() { + Some([r]) => st.clip.and_then(|c| intersect(*r, c)).is_some_and(|r| pdfcraft_content::contains(r, crop, COVER_EPS)), + _ => false, + }; + return (covers && st.white && st.paint.plain() && !clip_next).then(|| (Backdrop::Fill, Some(op.span.clone()))); + } + b"Do" => { + let xo = sub(doc, res, b"XObject", op.name(0)?)?; + let Object::Stream(s) = &*xo else { return None }; + // A stencil or masked picture lets what is behind it show. + let masked = [&b"SMask"[..], b"Mask"].iter().any(|k| s.dict.get(k).is_some()) + || s.dict.get(b"ImageMask").is_some_and(|m| matches!(*doc.resolve(m), Object::Bool(true))); + let image = s.dict.name(b"Subtype") == Some(b"Image") && !masked; + return covers_unit_square(&st, crop, image).then_some((Backdrop::Image, None)); + } + b"BI" => { + let masked = op + .inline + .as_ref() + .is_some_and(|(d, _)| [&b"IM"[..], b"ImageMask"].iter().any(|k| d.get(k).is_some_and(|m| matches!(m, Object::Bool(true))))); + return covers_unit_square(&st, crop, !masked).then_some((Backdrop::Image, None)); + } + // Anything else that paints comes first: no backdrop. + b"S" | b"s" | b"B" | b"B*" | b"b" | b"b*" | b"sh" | b"Tj" | b"TJ" | b"'" | b"\"" | b"EI" | b"d0" | b"d1" => return None, + _ => {} + } + } + None +} + +/// A picture placed by the CTM (onto the unit square) covering the crop box, opaque, unclipped. +fn covers_unit_square(st: &State, crop: [f64; 4], image: bool) -> bool { + image + && st.paint.plain() + && axis_aligned(&st.ctm) + && st.clip.and_then(|c| intersect(st.ctm.bbox([0.0, 0.0, 1.0, 1.0]), c)).is_some_and(|r| pdfcraft_content::contains(r, crop, COVER_EPS)) +} + +fn set_colour(st: &mut State, components: usize, op: &Op) { + st.space = components; + let vals: Vec = op.operands.iter().filter_map(Object::as_f64).collect(); + st.white = vals.len() == components + && match components { + 1 | 3 => vals.iter().all(|v| *v >= WHITE), + 4 => vals.iter().all(|v| *v <= 1.0 - WHITE), + _ => false, + }; +} + +/// Apply the named ExtGState's keys that [`Paint`] tracks; the keys it doesn't have leave +/// `paint` as it was. `None` when the dictionary or one of those values can't be read. +fn apply_state(doc: &Document, res: &Dict, name: &[u8], paint: &mut Paint) -> Option<()> { + let gs = sub(doc, res, b"ExtGState", name)?; + let gs = gs.as_dict()?; + if let Some(alpha) = gs.get(b"ca") { + paint.full_alpha = doc.resolve(alpha).as_f64()? >= 0.999; + } + if let Some(mask) = gs.get(b"SMask") { + paint.no_soft_mask = doc.resolve(mask).as_name() == Some(b"None"); + } + if let Some(blend) = gs.get(b"BM") { + paint.normal_blend = matches!(doc.resolve(blend).as_name(), Some(b"Normal" | b"Compatible")); + } + // TR2 takes precedence over TR in the same dictionary (§8.4.5, Table 57). Only the names + // are read: a function (or one per colourant) is taken to change white. + if let Some(transfer) = gs.get(b"TR2") { + paint.identity_transfer = matches!(doc.resolve(transfer).as_name(), Some(b"Identity" | b"Default")); + } else if let Some(transfer) = gs.get(b"TR") { + paint.identity_transfer = doc.resolve(transfer).as_name() == Some(b"Identity"); + } + Some(()) +} + +/// Rotations by multiples of 90° and scales only: rectangles stay rectangles. +fn axis_aligned(m: &Matrix) -> bool { + let [a, b, c, d, ..] = m.0; + (b.abs() < 1e-9 && c.abs() < 1e-9) || (a.abs() < 1e-9 && d.abs() < 1e-9) +} + +fn intersect(a: [f64; 4], b: [f64; 4]) -> Option<[f64; 4]> { + let r = [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]; + (r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1]).then_some(r) +} + +/// The clip after intersecting with a path: known only when the path is one rectangle. +fn clipped(clip: Option<[f64; 4]>, path: Option<&[[f64; 4]]>) -> Option<[f64; 4]> { + match path { + Some([r]) => intersect(clip?, *r).or(Some([0.0, 0.0, 0.0, 0.0])), + _ => None, + } +} + +#[cfg(test)] +#[path = "backdrop_tests.rs"] +mod tests; diff --git a/crates/edit/src/backdrop_tests.rs b/crates/edit/src/backdrop_tests.rs new file mode 100644 index 0000000000..30bc2ef8b1 --- /dev/null +++ b/crates/edit/src/backdrop_tests.rs @@ -0,0 +1,607 @@ +use std::sync::Arc; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full, write_incremental}; + +use super::*; +use crate::{Background, MarkKind, add_background, remove_marks}; + +const TEXT: &str = "BT /F1 24 Tf 40 90 Td (Hello) Tj ET"; + +/// A document from its objects (object 1 is the catalog, 2 the page tree). +fn build(objs: &[String]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn stream(dict: &str, data: &str) -> String { + format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()) +} + +/// One 300 × 200 page drawing `pieces` (objects 5…), with a font, a page-sized image `/Im0` +/// (object 4) and two graphics states, `/Half` (ca 0.5) and `/Full` (ca 1). +/// The graphics states [`page`] offers. `INVERT` is a transfer function that maps 1 to 0, so +/// `1 g` through it paints black (#2815 review). +const GRAPHICS_STATES: &str = concat!( + "/Half << /ca 0.5 >> /Full << /ca 1 /CA 1 >> /LineOnly << /LW 2 >> ", + "/Masked << /SMask << /S /Luminosity /G 4 0 R >> >> /Unmasked << /SMask /None >> ", + "/Multiply << /BM /Multiply >> /NormalBlend << /BM /Normal >> ", + "/Invert << /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> ", + "/Invert2 << /TR2 << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> ", + "/IdentityTR << /TR /Identity >> /DefaultTR2 << /TR2 /Default >> ", + "/InvertButTR2Default << /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> /TR2 /Default >> ", + "/IdentityButTR2Invert << /TR /Identity /TR2 << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >>", +); + +fn page(pieces: &[&str]) -> Document { + let refs: Vec = (0..pieces.len()).map(|i| format!("{} 0 R", i + 5)).collect(); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents [{}] /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /Im0 4 0 R >> /ExtGState << {GS} >> >> >>", + refs.join(" "), + GS = GRAPHICS_STATES + ), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "\u{80}"), + ]; + objs.extend(pieces.iter().map(|p| stream("", p))); + build(&objs) +} + +/// The decoded content of a page, stream by stream. +fn streams(doc: &Document) -> Vec { + let p = &pdfcraft_model::pages(doc)[0]; + let list = match p.dict.get(b"Contents").map(|c| doc.resolve(c)).as_deref() { + Some(Object::Array(a)) => a.clone(), + _ => vec![], + }; + list.iter() + .map(|o| match &*doc.resolve(o) { + Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(), + _ => String::new(), + }) + .collect() +} + +fn reopen(doc: &Document) -> Document { + let bytes = write_incremental(doc, &SaveOptions::default()).unwrap(); + Document::open(Arc::new(bytes)).unwrap() +} + +fn red() -> Background { + Background { color: [1.0, 0.0, 0.0], ..Background::default() } +} + +#[test] +fn a_white_fill_backdrop_is_replaced_and_put_back_when_the_background_goes() { + let content = format!("q 1 1 1 rg 0 0 300 200 re f Q {TEXT}"); + let mut doc = page(&[&content]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill)); + let original = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + let s = streams(&reopen(&doc)); + assert_eq!(s.len(), 2, "{s:?}"); + assert!(s[0].contains("/PCMark /Background")); + assert_eq!(s[1], format!("q 1 1 1 rg 0 0 300 200 re n Q {TEXT}"), "only the fill operator changed"); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "nothing hides the background now"); + // The original stream object is untouched; the page points at a copy. + let Object::Stream(orig) = &*doc.get(ObjRef { num: 5, generation: 0 }) else { panic!("object 5 is the content") }; + assert_eq!(String::from_utf8_lossy(&orig.decoded().unwrap()), content); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(), 1); + assert_eq!(streams(&reopen(&doc)), original, "exactly the original content"); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill)); +} + +#[test] +fn updating_the_background_replaces_the_backdrop_once_and_removing_still_restores() { + let mut doc = page(&[&format!("1 1 1 rg 0 0 300 200 re f 0 g {TEXT}")]); + let original = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + add_background(&mut doc, &[0], &Background { color: [0.0, 0.0, 1.0], ..Background::default() }, true).unwrap(); + let s = streams(&doc); + assert_eq!(s.len(), 2, "one background, one copy: {s:?}"); + assert!(s[1].contains("re n")); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), original); +} + +#[test] +fn a_backdrop_split_across_content_streams_is_replaced_in_the_piece_holding_the_operator() { + let mut doc = page(&["1 1 1 rg 0 0 300 200 re", &format!("f* {TEXT}")]); + let original = streams(&doc); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill)); + add_background(&mut doc, &[0], &red(), false).unwrap(); + let s = streams(&doc); + assert_eq!(s[1..], ["1 1 1 rg 0 0 300 200 re".to_string(), format!("n {TEXT}")], "{s:?}"); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), original); +} + +#[test] +fn backdrops_writers_paint_are_recognised() { + for content in [ + // Browsers: a flipped, scaled coordinate system and a page clip first. + "q 0.5 0 0 -0.5 0 200 cm 0 0 600 400 re W n 1 1 1 rg 0 0 600 400 re f Q", + // Larger than the page, in CMYK, through an opaque graphics state. + "/Full gs 0 0 0 0 k -10 -10 320 220 re f", + "/DeviceRGB cs 1 1 1 sc 0 0 300 200 re F", + "0.99 g 0 0 300 200 re f", + // Turned a quarter: still a rectangle. + "0 1 -1 0 300 0 cm 1 g 0 0 200 300 re f", + ] { + let doc = page(&[&format!("{content} 0 g {TEXT}")]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill), "{content}"); + } +} + +#[test] +fn content_that_is_not_a_page_backdrop_is_left_alone() { + for content in [ + // Not the whole page. + "1 g 0 0 300 150 re f", + "q 0 0 100 100 re W n 1 g 0 0 300 200 re f Q", + // Not white, or not opaque. + "0.5 g 0 0 300 200 re f", + "1 0 0 rg 0 0 300 200 re f", + "0 0 0 0.5 k 0 0 300 200 re f", + "/Half gs 1 g 0 0 300 200 re f", + "/Missing gs 1 g 0 0 300 200 re f", + "/Pattern cs /P0 scn 0 0 300 200 re f", + // The initial colour is black. + "0 0 300 200 re f", + // Something else is painted first. + "BT /F1 9 Tf (x) Tj ET 1 g 0 0 300 200 re f", + "0 g 0 0 1 1 re f 1 g 0 0 300 200 re f", + "1 g 0 0 300 200 re S 1 g 0 0 300 200 re f", + // Not a single rectangle: a hole, a turned square, a fill-and-stroke. + "1 g 0 0 300 200 re 50 50 10 10 re f*", + "1 g 0 0 m 300 0 l 300 200 l 0 200 l h f", + "0.7 0.7 -0.7 0.7 150 -100 cm 1 g 0 0 400 400 re f", + "1 g 0 0 300 200 re B", + // Malformed. + "Q 1 g 0 0 300 200 re f", + "1 g 0 0 300 re f", + "1 g 0 0 1e400 200 re f", + ] { + let mut doc = page(&[&format!("{content} {TEXT}")]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "{content}"); + let before = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(streams(&doc)[1..], before[..], "{content}: the page's content is unchanged"); + } +} + +#[test] +fn a_scanned_page_is_reported_and_its_picture_kept() { + let mut doc = page(&[&format!("q 300 0 0 200 0 0 cm /Im0 Do Q {TEXT}")]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Image)); + let before = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(streams(&doc)[1..], before[..]); + // A small picture is content, not a backdrop. + let doc = page(&["q 30 0 0 20 0 0 cm /Im0 Do Q"]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None); +} + +#[test] +fn replacing_can_be_turned_off() { + let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]); + let before = streams(&doc); + add_background(&mut doc, &[0], &Background { replace_backdrop: false, ..red() }, false).unwrap(); + assert_eq!(streams(&doc)[1..], before[..]); +} + +/// Rewrite the page's backdrop copy (the stream after the background) as another tool would: +/// Edit text and redaction keep its dictionary and change its bytes. +fn rewrite_copy(doc: &mut Document, bytes: &str) -> ObjRef { + let p = &pdfcraft_model::pages(doc)[0]; + let Some(Object::Array(list)) = p.dict.get(b"Contents").cloned() else { panic!("contents") }; + let Object::Ref(copy) = list[1] else { panic!("a reference") }; + let Object::Stream(s) = (*doc.get(copy)).clone() else { panic!("a stream") }; + assert!(s.dict.get(b"PCBackdrop").is_some(), "the copy"); + doc.set(copy, Object::Stream(Stream::flate(s.dict.clone(), bytes.as_bytes()))); + copy +} + +#[test] +fn an_edit_after_the_fill_is_kept_and_the_backdrop_still_comes_back() { + let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]); + add_background(&mut doc, &[0], &red(), false).unwrap(); + rewrite_copy(&mut doc, "1 g 0 0 300 200 re n BT /F1 24 Tf 40 90 Td (Edited) Tj ET"); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), ["1 g 0 0 300 200 re f BT /F1 24 Tf 40 90 Td (Edited) Tj ET"], "the edit survives, under the fill"); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill)); + assert_eq!(backdrops_left_off(&doc), Vec::::new()); +} + +#[test] +fn a_change_before_the_fill_leaves_it_off_and_names_the_page() { + for edited in [ + // A different colour before the fill: the hash of what comes before it no longer matches. + "0.99 g 0 0 300 200 re n BT /F1 24 Tf 40 90 Td (Edited) Tj ET", + // Something painted before it. + "BT /F1 9 Tf (x) Tj ET 1 g 0 0 300 200 re n", + // The fill is gone. + "BT /F1 24 Tf 40 90 Td (Edited) Tj ET", + ] { + let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(backdrops_left_off(&doc), Vec::::new(), "a background is over it"); + rewrite_copy(&mut doc, edited); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), [edited.to_string()], "{edited}: the copy stays as it is"); + assert_eq!(backdrops_left_off(&doc), [0], "{edited}"); + } +} + +#[test] +fn a_fill_that_would_cover_content_painted_before_it_stays_off() { + let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]); + add_background(&mut doc, &[0], &red(), false).unwrap(); + // A tool puts a stream of its own before the copy, untagged: turning the fill back on would + // now paint white over that text. + let over = doc.add(Object::Stream(Stream::flate(pdfcraft_cos::Dict::new(), b"BT /F1 9 Tf 10 10 Td (Over) Tj ET"))); + let p = pdfcraft_model::pages(&doc)[0].clone(); + let Some(Object::Array(mut list)) = p.dict.get(b"Contents").cloned() else { panic!("contents") }; + list.insert(1, Object::Ref(over)); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list))).unwrap(); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + let s = streams(&doc); + assert_eq!(s.len(), 2, "{s:?}"); + assert!(s[1].contains("re n") && !s[1].contains("re f"), "nothing white goes over the text: {s:?}"); + assert_eq!(backdrops_left_off(&doc), [0]); +} + +#[test] +fn the_tag_holds_no_reference_and_a_full_save_keeps_no_second_copy_of_the_page() { + let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]); + add_background(&mut doc, &[0], &red(), false).unwrap(); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + // Every stream in the file that holds the page's text: only the copy the page draws. A + // reference from the tag to the original stream kept a second, which a redaction or Remove + // Hidden Information on the copy left in the file. + let holding: Vec<(ObjRef, Dict)> = saved + .scan_objects() + .filter_map(|(r, o)| match &*o { + Object::Stream(s) if String::from_utf8_lossy(&s.decoded().unwrap_or_default()).contains("(Hello)") => Some((r, s.dict.clone())), + _ => None, + }) + .collect(); + assert_eq!(holding.len(), 1, "{holding:?}"); + let Some(Object::Dict(note)) = holding[0].1.get(b"PCBackdrop").map(|n| (*saved.resolve(n)).clone()) else { panic!("the tag") }; + fn refs(o: &Object) -> bool { + match o { + Object::Ref(_) => true, + Object::Array(a) => a.iter().any(refs), + Object::Dict(d) => d.iter().any(|(_, v)| refs(v)), + _ => false, + } + } + assert!(!note.iter().any(|(_, v)| refs(v)), "{note:?}"); + assert_eq!(note.get(b"Op").and_then(Object::as_string).map(|s| s.bytes.clone()), Some(b"f".to_vec())); + assert_eq!(note.get(b"Before").and_then(Object::as_string).map(|s| s.bytes.len()), Some(32), "a SHA-256"); + // The original is gone, so Remove writes the fill back into a stream of its own. + let mut saved = saved; + remove_marks(&mut saved, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&saved), [format!("1 g 0 0 300 200 re f {TEXT}")]); + assert_eq!(page_backdrop(&saved, 0).unwrap(), Some(Backdrop::Fill)); +} + +#[test] +fn a_copy_written_out_again_with_other_spacing_still_comes_back() { + // Redaction writes the whole stream out again: one operator a line, numbers its own way. + let mut doc = page(&[&format!("1 g 0 0 300 200 re f {TEXT}")]); + add_background(&mut doc, &[0], &red(), false).unwrap(); + rewrite_copy(&mut doc, "1.0 g\n0 0 300.000 200 re\nn\nBT\n/F1 24 Tf\n40 90 Td\n[-6505] TJ\nET"); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), ["1.0 g\n0 0 300.000 200 re\nf\nBT\n/F1 24 Tf\n40 90 Td\n[-6505] TJ\nET"], "the fill is back, the rest as written"); + assert_eq!(backdrops_left_off(&doc), Vec::::new()); +} + +#[test] +fn a_tag_that_does_not_add_up_is_left_alone() { + let content = format!("1 g 0 0 300 200 re f {TEXT}"); + let copied = format!("1 g 0 0 300 200 re n {TEXT}"); + let good = ops_hash(&pdfcraft_content::parse(b"1 g 0 0 300 200 re").ops); + let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::(); + for tag in [ + // Not a fill operator, a wrong hash, an index that isn't the `n` or runs past the end. + format!("<< /Index 2 /Op (B) /Before <{}> >>", hex(&good)), + format!("<< /Index 2 /Op (f) /Before <{}> >>", hex(&[0; 32])), + format!("<< /Index 1 /Op (f) /Before <{}> >>", hex(&good)), + format!("<< /Index 99999 /Op (f) /Before <{}> >>", hex(&good)), + format!("<< /Index -1 /Op (f) /Before <{}> >>", hex(&good)), + // Missing entries, wrong types, and the reference earlier test builds wrote. + "<< /Index 2 /Op (f) >>".to_string(), + "<< /Index (2) /Op /f /Before 3 >>".to_string(), + "<< /Original 5 0 R /At 19 /Len 1 >>".to_string(), + "(not a dictionary)".to_string(), + ] { + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R] >>".into(), + stream(&format!("/PCBackdrop {tag}"), &copied), + stream("", &content), + ]); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), std::slice::from_ref(&copied), "{tag}"); + assert_eq!(backdrops_left_off(&doc), [0], "{tag}"); + } + // The same page with an honest tag comes back. + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R] >>".into(), + stream(&format!("/PCBackdrop << /Index 2 /Op (f) /Before <{}> >>", hex(&good)), &copied), + ]); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), [content]); +} + +#[test] +fn operators_hash_by_value_not_by_spelling() { + let h = |s: &[u8]| ops_hash(&pdfcraft_content::parse(s).ops); + assert_eq!(h(b"1 g 0 0 300 200 re"), h(b"1.0 g\n0 0 300.00000 200 re")); + assert_eq!(h(b"0 g"), h(b"-0.0 g")); + assert_ne!(h(b"1 g 0 0 300 200 re"), h(b"0.99 g 0 0 300 200 re")); + assert_ne!(h(b"(ab) Tj"), h(b"(a) (b) Tj"), "operand boundaries count"); + assert_ne!(h(b"/a /b BDC"), h(b"/ab BDC")); + assert_ne!(h(b""), h(b"q")); + // Nesting is bounded: deep arrays hash without running out of stack. + let deep = format!("{}{} Tj", "[".repeat(1000), "]".repeat(1000)); + let _ = h(deep.as_bytes()); +} + +#[test] +fn a_content_stream_shared_by_two_pages_is_copied_for_the_one_page() { + let content = format!("1 g 0 0 300 200 re f {TEXT}"); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(), + stream("", &content), + ]); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None); + assert_eq!(page_backdrop(&doc, 1).unwrap(), Some(Backdrop::Fill), "the other page keeps its backdrop"); +} + +#[test] +fn pages_without_content_or_with_odd_contents_have_no_backdrop() { + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [5 0 R 99 0 R] >>".into(), + "<< /Not /AStream >>".into(), + ]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None); + assert_eq!(page_backdrop(&doc, 1).unwrap(), None); + assert!(matches!(page_backdrop(&doc, 7), Err(EditError::NoSuchPage(7)))); +} + +/// `pages` 300 × 200 pages in a flat tree; even pages paint a white backdrop, odd ones don't. +fn many_pages(pages: usize) -> Document { + let kids: Vec = (0..pages).map(|i| format!("{} 0 R", i + 5)).collect(); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 300 200] >>", kids.join(" ")), + stream("", &format!("1 g 0 0 300 200 re f 0 g {TEXT}")), + stream("", TEXT), + ]; + objs.extend((0..pages).map(|i| format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R >>", 3 + i % 2))); + build(&objs) +} + +#[test] +fn checking_many_pages_walks_the_page_tree_once() { + // The Add Background dialog checks every page of a long document. Looking each page up on + // its own walked the whole tree per page, so checking them all grew with the square of the + // page count (minutes at 20,000 pages); one walk keeps it in proportion. + const PAGES: usize = 4000; + let doc = many_pages(PAGES); + let all: Vec = (0..PAGES).collect(); + let start = std::time::Instant::now(); + let found = page_backdrops(&doc, &all); + let took = start.elapsed(); + assert_eq!(found.len(), PAGES); + for (i, f) in found.iter().enumerate() { + assert_eq!(*f, (i % 2 == 0).then_some(Backdrop::Fill), "page {i}"); + } + assert!(took < std::time::Duration::from_secs(5), "checking {PAGES} pages took {took:?}"); +} + +#[test] +fn a_batch_answers_in_the_order_asked_and_a_missing_page_hides_nothing() { + let doc = many_pages(5); + assert_eq!(page_backdrops(&doc, &[3, 0, 0]), [None, Some(Backdrop::Fill), Some(Backdrop::Fill)]); + assert_eq!(page_backdrops(&doc, &[]), []); + // The viewer can count more pages than the page tree holds: the others still answer. + assert_eq!(page_backdrops(&doc, &[2, 5, usize::MAX]), [Some(Backdrop::Fill), None, None]); + assert_eq!(page_backdrop(&doc, 4).unwrap(), Some(Backdrop::Fill)); + assert!(matches!(page_backdrop(&doc, 5), Err(EditError::NoSuchPage(5)))); +} + +#[test] +fn a_background_over_many_pages_replaces_each_backdrop_and_removing_restores_them() { + // Pages share their content streams, so each replaced page gets its own copy and the rest + // are checked against the document as it is after the earlier ones. + let mut doc = many_pages(4); + let all = [0, 1, 2, 3]; + let before: Vec = pdfcraft_model::pages(&doc).iter().filter_map(|p| p.dict.get(b"Contents").cloned()).collect(); + add_background(&mut doc, &all, &red(), true).unwrap(); + assert_eq!(page_backdrops(&doc, &all), [None; 4]); + // Update: the backdrops are gone already, so nothing is copied again. + add_background(&mut doc, &all, &red(), true).unwrap(); + assert_eq!(page_backdrops(&doc, &all), [None; 4]); + remove_marks(&mut doc, &all, MarkKind::Background).unwrap(); + let after: Vec = pdfcraft_model::pages(&doc).iter().filter_map(|p| p.dict.get(b"Contents").cloned()).collect(); + let refs = |list: &[Object]| -> Vec> { + list.iter() + .map(|c| match &*doc.resolve(c) { + Object::Array(a) => a.clone(), + _ => vec![c.clone()], + }) + .collect() + }; + assert_eq!(refs(&after), refs(&before), "every page points at its original stream again"); + assert_eq!(page_backdrops(&doc, &all), [Some(Backdrop::Fill), None, Some(Backdrop::Fill), None]); +} + +#[test] +fn a_content_stream_whose_bytes_live_in_an_external_file_is_left_alone() { + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R] >>".into(), + stream("/F (content.bin)", &format!("1 g 0 0 300 200 re f {TEXT}")), + ]; + let mut doc = build(&objs); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "the inline bytes are not what the page draws"); + let before = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(streams(&doc)[1..], before[..]); +} + +#[test] +fn the_backdrop_copy_keeps_the_original_dictionary_less_its_old_encoding() { + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R] >>".into(), + stream("/Vendor (keep me) /DL 9999", &format!("1 g 0 0 300 200 re f {TEXT}")), + ]; + let mut doc = build(&objs); + add_background(&mut doc, &[0], &red(), false).unwrap(); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + let Some(Object::Array(list)) = p.dict.get(b"Contents").map(|c| (*doc.resolve(c)).clone()) else { panic!("contents") }; + let Object::Stream(copy) = &*doc.resolve(&list[1]) else { panic!("a stream") }; + assert!(copy.dict.get(b"Vendor").is_some(), "unknown entries survive"); + assert!(copy.dict.get(b"DL").is_none(), "the old decoded length no longer holds"); + assert!(copy.dict.get(b"PCBackdrop").is_some()); + assert_eq!(String::from_utf8_lossy(©.decoded().unwrap()), format!("1 g 0 0 300 200 re n {TEXT}")); +} + +#[test] +fn a_stream_listed_twice_is_left_untouched_as_ambiguous() { + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R 4 0 R] >>".into(), + stream("", &format!("q 1 g 0 0 300 200 re f Q {TEXT}")), + ]); + let original = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + let s = streams(&doc); + assert_eq!(s.len(), 3, "{s:?}"); + assert_eq!(s[1..], original[..], "repeated executions are not qualified independently"); + remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&doc), original); +} + +/// #2815 review: a `gs` sets only the keys its dictionary has, so a translucent, masked, +/// blended or transferred fill stays that way through a later `gs` that leaves the key out, +/// and a transfer function that isn't the identity can turn `1 g` into any colour. +#[test] +fn graphics_state_a_later_gs_leaves_out_still_keeps_the_fill() { + for state in [ + "/Half gs /LineOnly gs", + "/Masked gs /LineOnly gs", + "/Multiply gs /LineOnly gs", + "/Invert gs /LineOnly gs", + "/Invert2 gs /LineOnly gs", + "/Invert gs", + "/Invert2 gs", + "/IdentityButTR2Invert gs", + "/Invert gs q /LineOnly gs Q", + ] { + let mut doc = page(&[&format!("{state} 1 g 0 0 300 200 re f {TEXT}")]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "{state}"); + let before = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(streams(&doc)[1..], before[..], "{state}: the page's content is unchanged"); + } +} + +/// The other side of the same rule: a later `gs` that does set the key back, `TR2` naming the +/// default over a `TR` function, and `Q` restoring what `q` saved still leave a plain white fill. +#[test] +fn graphics_state_set_back_before_the_fill_still_finds_the_backdrop() { + for state in [ + "/Half gs /Full gs", + "/Masked gs /Unmasked gs", + "/Multiply gs /NormalBlend gs", + "/Invert gs /IdentityTR gs", + "/Invert2 gs /DefaultTR2 gs", + "/InvertButTR2Default gs", + "/LineOnly gs", + "q /Half gs Q", + "q /Invert gs Q", + ] { + let doc = page(&[&format!("{state} 1 g 0 0 300 200 re f {TEXT}")]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), Some(Backdrop::Fill), "{state}"); + } +} + +#[test] +fn an_undecodable_stream_before_a_white_fill_keeps_the_backdrop_unknown() { + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R 5 0 R] >>".into(), + stream("/Filter /FlateDecode", "not a deflate stream"), + stream("", &format!("1 g 0 0 300 200 re f {TEXT}")), + ]; + let doc = build(&objs); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None, "unknown earlier paint must not be treated as an empty stream"); +} + +#[test] +fn shared_fill_used_as_later_artwork_is_not_rewritten() { + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R 5 0 R 6 0 R 5 0 R 7 0 R] >>".into(), + stream("", "1 g"), + stream("", "0 0 300 200 re f"), + stream("", "q 0 g 0.25 0 0 0.25 20 20 cm"), + stream("", "Q"), + ]); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None); + let original = streams(&doc); + add_background(&mut doc, &[0], &red(), false).unwrap(); + assert_eq!(streams(&doc)[1..], original[..], "the later black artwork keeps its fill"); + let mut saved = reopen(&doc); + assert_eq!(streams(&saved)[1..], original[..]); + remove_marks(&mut saved, &[0], MarkKind::Background).unwrap(); + assert_eq!(streams(&saved), original); + assert_eq!(streams(&reopen(&saved)), original); +} + +#[test] +fn compressed_backdrop_content_is_refused_before_expanding_past_the_scan_budget() { + let mut doc = page(&["1 g 0 0 300 200 re f"]); + let mut bytes = b"1 g 0 0 300 200 re f\n".to_vec(); + bytes.resize(2 * 1024 * 1024, b' '); + let source = doc.add(Object::Stream(Stream::flate(Dict::new(), &bytes))); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(source))).unwrap(); + assert_eq!(page_backdrop(&doc, 0).unwrap(), None); + add_background(&mut doc, &[0], &red(), false).unwrap(); + let Object::Stream(unchanged) = &*doc.get(source) else { panic!("stream") }; + assert_eq!(unchanged.decoded_strict().unwrap(), bytes, "refusal preserves the source"); +} diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 5180e76018..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,48 +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); } } b"h" => { // ClosePath with no subpath panics by the same contract (#3581). - if self.path.elements().last().is_some() { + if self.has_current_point() { 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)); + 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*" => { @@ -209,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; } _ => {} } @@ -266,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] @@ -384,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 { @@ -396,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). @@ -475,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)?; @@ -492,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 { @@ -536,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, @@ -642,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) { @@ -688,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())); } @@ -702,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. @@ -740,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) @@ -789,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 }; @@ -809,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 }; @@ -824,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); } @@ -843,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 7c8556332b..aa11617c14 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -33,6 +33,9 @@ pub enum EditError { pub type Rgb = [f64; 3]; +pub mod backdrop; +pub use backdrop::{Backdrop, backdrops_left_off, page_backdrop, page_backdrops}; +pub use text::strip_subset_tag; mod background; pub use background::page_background; pub mod objects; @@ -41,10 +44,11 @@ pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, ed mod flatten; pub use flatten::{flatten, flatten_fill_sign}; pub mod images; -pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees}; +pub use images::{ArrangeOrder, ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees}; pub mod bidi; pub mod indic; mod linebreak; +pub mod paragraph_align; pub mod text; pub use text::{ BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping, diff --git a/crates/edit/src/marks.rs b/crates/edit/src/marks.rs index 00c53ec27a..d936955184 100644 --- a/crates/edit/src/marks.rs +++ b/crates/edit/src/marks.rs @@ -173,11 +173,17 @@ pub struct Background { pub source: Option, /// Pictures: size relative to the page (1.0 fits the page). pub scale: f64, + /// Replace a white backdrop the page paints itself, which would hide the background + /// ([`backdrop`](crate::backdrop)). Removing the background turns it back on where that is + /// still safe ([`backdrops_left_off`](crate::backdrops_left_off) are the pages where it isn't). + pub replace_backdrop: bool, } impl Default for Background { + /// A light yellow that still reads as a colour on screen (a near-white default looked as if + /// nothing had happened). fn default() -> Self { - Self { color: [1.0, 1.0, 0.85], opacity: 1.0, source: None, scale: 1.0 } + Self { color: [1.0, 0.95, 0.7], opacity: 1.0, source: None, scale: 1.0, replace_backdrop: true } } } diff --git a/crates/edit/src/paragraph_align.rs b/crates/edit/src/paragraph_align.rs new file mode 100644 index 0000000000..8fb0a4c5e4 --- /dev/null +++ b/crates/edit/src/paragraph_align.rs @@ -0,0 +1,174 @@ +//! Alignment paragraph by paragraph. A paragraph is the text between two hard line breaks +//! (`\n`): a line typed with Enter in added text, or a source row of an edited paragraph. Each can +//! have an alignment of its own; `None` (or no entry) follows the box's or paragraph's alignment. + +use std::ops::RangeInclusive; + +use crate::added::Align; + +/// How many paragraphs `text` has (an empty text has one). +pub fn count(text: &str) -> usize { + text.bytes().filter(|b| *b == b'\n').count().saturating_add(1) +} + +/// The paragraph the byte position `at` of `text` is in. +fn paragraph_at(text: &str, at: usize) -> usize { + let bytes = text.as_bytes(); + bytes.get(..at.min(bytes.len())).unwrap_or_default().iter().filter(|b| **b == b'\n').count() +} + +/// The paragraphs a selection (byte range) of `text` touches. An empty selection is the caret's +/// paragraph; a selection ending just after a line break doesn't reach the next paragraph. +pub fn selected(text: &str, range: std::ops::Range) -> RangeInclusive { + let first = paragraph_at(text, range.start); + let last = if range.end > range.start { paragraph_at(text, range.end - 1) } else { first }; + first..=last.max(first) +} + +/// Paragraph `i`'s alignment: its own, or `base`. +pub fn align_of(aligns: &[Option], i: usize, base: Align) -> Align { + aligns.get(i).copied().flatten().unwrap_or(base) +} + +/// Give paragraphs `which` of a text with `count` paragraphs the alignment `align`; the others +/// keep theirs. With a `base`, entries equal to it become `None` ([`tidy`]), so equal alignments +/// compare equal however they were set; without one (the base is only guessed, as for an edited +/// paragraph's own rows) every entry set stays. Trailing `None`s are dropped either way. +pub fn set(aligns: &[Option], count: usize, which: RangeInclusive, align: Align, base: Option) -> Vec> { + let mut out: Vec> = (0..count).map(|i| aligns.get(i).copied().flatten()).collect(); + for i in which { + if let Some(a) = out.get_mut(i) { + *a = Some(align); + } + } + match base { + Some(base) => tidy(out, base), + None => trimmed(out), + } +} + +/// `aligns` without entries that only repeat `base`, and without trailing `None`s. +pub fn tidy(mut aligns: Vec>, base: Align) -> Vec> { + for a in &mut aligns { + if *a == Some(base) { + *a = None; + } + } + trimmed(aligns) +} + +fn trimmed(mut aligns: Vec>) -> Vec> { + while aligns.last() == Some(&None) { + aligns.pop(); + } + aligns +} + +/// Carry the alignments of `old`'s paragraphs over to `new`: the paragraphs before and after +/// the change keep theirs, and the paragraphs the change made take the alignment of the first +/// paragraph it changed, or for lines only inserted, of the paragraph before them (so Enter in or +/// after a centred paragraph makes centred ones). +pub fn remap(old: &str, new: &str, aligns: &[Option]) -> Vec> { + remap_at(old, new, aligns, None) +} + +/// [`remap`] for an edit that left the caret at byte `caret` of `new`. Repeated paragraphs can +/// make where the edit was ambiguous ("A\nA" to "A" deletes either one); the caret, at the edit's +/// end, says which. It only chooses between readings that fit the texts, so a caret away from the +/// edit never makes it an edit of other paragraphs. +pub fn remap_at(old: &str, new: &str, aligns: &[Option], caret: Option) -> Vec> { + if aligns.is_empty() || old == new { + return aligns.to_vec(); + } + let (o, n): (Vec<&str>, Vec<&str>) = (old.split('\n').collect(), new.split('\n').collect()); + let shortest = o.len().min(n.len()); + let same_start = o.iter().zip(&n).take_while(|(a, b)| a == b).count().min(shortest); + let same_end = o.iter().rev().zip(n.iter().rev()).take_while(|(a, b)| a == b).count().min(shortest); + let fewest_kept = shortest - same_start; + let (prefix, suffix) = match caret { + Some(at) if fewest_kept <= same_end => { + // The edit's paragraphs of `new` end before the caret's paragraph when the caret starts + // one, and with it otherwise. + let starts = at == 0 || at.checked_sub(1).and_then(|i| new.as_bytes().get(i)) == Some(&b'\n'); + let end = paragraph_at(new, at).saturating_add(usize::from(!starts)); + let suffix = n.len().saturating_sub(end).clamp(fewest_kept, same_end); + (shortest - suffix, suffix) + } + _ => (same_start, same_end.min(shortest - same_start)), + }; + let at = |i: usize| aligns.get(i).copied().flatten(); + let inserted_only = o.len() - prefix - suffix == 0; + let changed = if inserted_only && prefix > 0 { at(prefix - 1) } else { at(prefix) }; + let mut out: Vec> = (0..prefix).map(at).collect(); + out.extend(std::iter::repeat_n(changed, n.len() - prefix - suffix)); + out.extend((o.len() - suffix..o.len()).map(at)); + trimmed(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use Align::*; + + #[test] + fn a_selection_names_the_paragraphs_it_touches() { + let text = "One\nTwo\nThree"; + assert_eq!(count(text), 3); + assert_eq!(count(""), 1); + assert_eq!(selected(text, 0..0), 0..=0, "the caret's paragraph"); + assert_eq!(selected(text, 5..6), 1..=1); + assert_eq!(selected(text, 2..9), 0..=2); + assert_eq!(selected(text, 4..8), 1..=1, "a whole line with its break stays on that line"); + assert_eq!(selected(text, 0..999), 0..=2, "past the end is clamped"); + // Not a character boundary: still counted by bytes, never a panic. + assert_eq!(selected("é\nb", 1..1), 0..=0); + } + + #[test] + fn setting_one_paragraph_keeps_the_others_and_tidies() { + let one = set(&[], 3, 1..=1, Center, Some(Left)); + assert_eq!(one, [None, Some(Center)]); + assert_eq!(align_of(&one, 0, Left), Left); + assert_eq!(align_of(&one, 1, Left), Center); + assert_eq!(align_of(&one, 9, Right), Right); + assert_eq!(set(&one, 3, 1..=1, Left, Some(Left)), [], "back to the box's alignment is no entry at all"); + assert_eq!(set(&one, 3, 2..=7, Right, Some(Left)), [None, Some(Center), Some(Right)], "paragraphs past the end are ignored"); + assert_eq!(set(&[Some(Right); 9], 2, 0..=0, Center, Some(Left)), [Some(Center), Some(Right)], "entries past the text are dropped"); + assert_eq!(set(&[], 3, 0..=0, Left, None), [Some(Left)], "without a base, an explicit entry stays"); + } + + #[test] + fn alignments_follow_their_paragraphs_through_an_edit() { + let aligns = [Some(Center), None, Some(Right)]; + assert_eq!(remap("A\nB\nC", "A\nB\nC", &aligns), aligns); + // A line inserted before the last keeps the last right-aligned. + assert_eq!(remap("A\nB\nC", "A\nB\nnew\nC", &aligns), [Some(Center), None, None, Some(Right)]); + // Enter inside the centred first paragraph: both halves are centred. + assert_eq!(remap("Ab\nB\nC", "A\nb\nB\nC", &aligns), [Some(Center), Some(Center), None, Some(Right)]); + // The middle paragraph deleted. + assert_eq!(remap("A\nB\nC", "A\nC", &aligns), [Some(Center), Some(Right)]); + // Everything replaced takes the first changed paragraph's alignment. + assert_eq!(remap("A\nB", "x", &[None, Some(Right)]), []); + assert_eq!(remap("A\nA", "A", &[Some(Center), Some(Right)]), [Some(Center)]); + assert_eq!(remap("A", "A\nA", &[Some(Center)]), [Some(Center), Some(Center)]); + } + + /// Repeated rows make the edit's place ambiguous: "A\nA" to "A" deletes either row. The caret, + /// where the edit ended, settles which (CodeRabbit on 648dd428). + #[test] + fn the_caret_says_which_of_two_same_rows_an_edit_took() { + let aligns = [Some(Center), Some(Right)]; + assert_eq!(remap_at("A\nA", "A", &aligns, Some(0)), [Some(Right)], "the first row and its break deleted"); + assert_eq!(remap_at("A\nA", "A", &aligns, Some(1)), [Some(Center)], "the break and the second row deleted"); + assert_eq!(remap_at("A\nA", "A", &aligns, None), [Some(Center)], "no caret: as before"); + // Enter at the start of the first of two same rows: the new row is before it. + assert_eq!(remap_at("A\nA", "\nA\nA", &aligns, Some(1)), [Some(Center), Some(Center), Some(Right)]); + // Enter at the end of the second: the new row follows it. + assert_eq!(remap_at("A\nA", "A\nA\n", &aligns, Some(4)), [Some(Center), Some(Right), Some(Right)]); + // A caret away from the edit never makes it an edit of other rows. + assert_eq!(remap_at("A\nB\nC", "A\nB\nCx", &[Some(Center), Some(Right), None], Some(0)), [Some(Center), Some(Right)]); + // A caret past the end, or not on a character boundary, is no panic. + assert_eq!(remap_at("A\nA", "A", &aligns, Some(99)), [Some(Center)]); + assert_eq!(remap_at("é\né", "é", &aligns, Some(1)), [Some(Center)]); + } +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index aa624c355b..f7740d60cd 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -2790,6 +2790,643 @@ fn a_bold_stand_in_font_leaves_an_earlier_regular_one_alone() { assert_eq!(font_of("TENSÃO").as_deref(), Some("Times-Bold"), "{lines:?}"); } +/// #2517: one blank line at ordinary leading (2 × 1.2 × the font size) is a paragraph break. +/// Editing the first paragraph must leave the second one in place. +#[test] +fn a_blank_line_keeps_the_next_paragraph() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (First paragraph, first row.) Tj T* (First paragraph, second row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Second paragraph, first row.) Tj T* (Second paragraph, second row.) Tj ET"; + let mut doc = text_page(src); + let before = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(before.len(), 2, "{:?}", before.iter().map(|b| b.text.as_str()).collect::>()); + text::replace_block(&mut doc, 0, 0, "First paragraph, edited row.\nFirst paragraph, second row.").unwrap(); + let after = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "{:?}", after.iter().map(|b| b.text.as_str()).collect::>()); + assert!(after[0].text.contains("edited row") && after[0].text.contains("second row"), "{:?}", after[0].text); + assert!(after[1].text.contains("Second paragraph"), "{:?}", after[1].text); + // The second row is left in the source stream. A marker around only the rewritten row + // would split the paragraph; either there is no marker, or it covers both rows and not the next paragraph. + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + if content.contains("/P BMC") { + let marked = region_through_matching_close(&content, "/P BMC"); + assert!(marked.contains("edited row") && marked.contains("second row"), "marker split the paragraph: {content}"); + assert!(!marked.contains("Second paragraph"), "marker swallowed the next paragraph: {content}"); + } +} + +/// #2517: adding a row must not pull in a paragraph that was 3 × the font size below. +#[test] +fn a_grown_paragraph_does_not_swallow_the_next_one() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj ET \ + BT /F1 12 Tf 1 0 0 1 72 649.6 Tm (Neighbour) Tj ET"; + let mut doc = text_page(src); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + text::replace_block(&mut doc, 0, 0, "One\nTwo\nThree").unwrap(); + let after = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "{:?}", after.iter().map(|b| b.text.as_str()).collect::>()); + assert!(after[1].text.contains("Neighbour"), "{:?}", after[1].text); +} + +/// An explicit `TL` is the leading `T*` moves by. Rows at that gap are one paragraph, including +/// when the gap is larger than twice the font size. A following paragraph that starts two +/// leadings lower stays separate. The numbers are the gaps under test, not a claim about how a +/// word processor derives them: this repo's Helvetica box is 0.925 em (ascent 0.718, descent +/// 0.207), so twice that box at 12 pt is 22.2 pt, while 27.6 pt is 12 × 2.3. +#[test] +fn explicit_leading_is_the_measured_baseline_gap() { + for tl in [14.4_f64, 18.0, 22.2, 24.0, 27.6, 28.8] { + let src = format!("BT /F1 12 Tf {tl} TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj T* (Three) Tj ET"); + let doc = text_page(&src); + let lines = text::text_lines(&doc, 0).unwrap(); + let gaps: Vec = lines.windows(2).map(|w| w[0].origin_baseline() - w[1].origin_baseline()).collect(); + assert!(gaps.iter().all(|gap| (gap - tl).abs() < 0.05), "TL {tl} did not move the baselines: {gaps:?}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 1, "TL {tl}, measured gaps {gaps:?}: {:?}", blocks.iter().map(|b| b.text.as_str()).collect::>()); + } + // Two rows at the TL, then a paragraph whose first baseline is two TLs below the second row. + let tl = 14.4_f64; + let next = 700.0 - tl - 2.0 * tl; + let blank = format!( + "BT /F1 12 Tf {tl} TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj ET \ + BT /F1 12 Tf {tl} TL 1 0 0 1 72 {next} Tm (Neighbour) Tj ET" + ); + let doc = text_page(&blank); + let lines = text::text_lines(&doc, 0).unwrap(); + let between = lines[1].origin_baseline() - lines[2].origin_baseline(); + assert!((between - 2.0 * tl).abs() < 0.05, "the blank line measured {between}, not {}", 2.0 * tl); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 2, "{:?}", blocks.iter().map(|b| b.text.as_str()).collect::>()); +} + +/// The neighbour starts one blank line below (two leadings under the second row). Adding a row +/// puts the new baseline one leading above that neighbour, inside the join window. The neighbour +/// still has to survive the edit, both saves, and a second edit. +#[test] +fn growing_into_a_blank_line_keeps_the_neighbour_after_save() { + let tl = 14.4_f64; + let src = format!( + "BT /F1 12 Tf {tl} TL 1 0 0 1 72 700 Tm (One) Tj T* (Two) Tj ET \ + BT /F1 12 Tf {tl} TL 1 0 0 1 72 {} Tm (Neighbour) Tj ET", + 700.0 - 3.0 * tl + ); + let mut doc = text_page(&src); + let before = text::text_lines(&doc, 0).unwrap(); + let opening = before[1].origin_baseline() - before[2].origin_baseline(); + assert!((opening - 2.0 * tl).abs() < 0.05, "fixture gap {opening} is not a blank line"); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + text::replace_block(&mut doc, 0, 0, "One\nTwo\nThree").unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + let bases: Vec = lines.iter().map(|l| l.origin_baseline()).collect(); + assert_eq!(bases.len(), 4, "{bases:?}"); + let grown = bases[2] - bases[3]; + assert!((grown - tl).abs() < 0.05, "the new row is {grown} pt above the neighbour, not one leading"); + assert!(grown < tl * 1.5, "this gap is outside the join window, so the test would pass without the marker"); + let texts = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::>(); + assert_eq!(texts(&doc), ["One Two Three", "Neighbour"]); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + let marked = region_through_matching_close(&content, "/P BMC"); + for row in ["(One)", "(Two)", "(Three)"] { + assert!(marked.contains(row), "{row} is outside the marker: {content}"); + } + assert!(!marked.contains("Neighbour"), "the neighbour was pulled into the marker: {content}"); + for saved in [reopen(&doc), Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()] { + assert_eq!(texts(&saved), ["One Two Three", "Neighbour"]); + let mut again = saved; + text::replace_block(&mut again, 0, 0, "One\nTwo\nFour").unwrap(); + assert_eq!(texts(&reopen(&again)), ["One Two Four", "Neighbour"]); + } +} + +/// No `TL`: `Td` does not set the leading (`TD` does). Three rows at one repeated step are one +/// paragraph. A single large gap, with no third row at that step, stays a break. +#[test] +fn a_repeated_step_without_tl_is_the_paragraph_leading() { + let step = 27.6_f64; + let bodies = [ + format!("BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj 0 -{step} Td (Two) Tj 0 -{step} Td (Three) Tj ET"), + format!( + "BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj ET BT /F1 12 Tf 1 0 0 1 72 {} Tm (Two) Tj ET BT /F1 12 Tf 1 0 0 1 72 {} Tm (Three) Tj ET", + 700.0 - step, + 700.0 - 2.0 * step + ), + format!( + "BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET BT /F1 12 Tf 72 {} Td (Three) Tj ET", + 700.0 - step, + 700.0 - 2.0 * step + ), + ]; + for body in bodies { + let doc = text_page(&body); + let lines = text::text_lines(&doc, 0).unwrap(); + let gaps: Vec = lines.windows(2).map(|w| w[0].origin_baseline() - w[1].origin_baseline()).collect(); + assert!(gaps.iter().all(|gap| (gap - step).abs() < 0.05), "step {step} measured {gaps:?} in {body}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 1, "{body} -> {:?}", blocks.iter().map(|b| b.text.as_str()).collect::>()); + } + // Two rows and nothing after them: one gap cannot be told from a paragraph break. + let pair = format!("BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET", 700.0 - step); + assert_eq!(text::text_blocks(&text_page(&pair), 0).unwrap().len(), 2); + // The original blank line, written with Td instead of TL: the inner step repeats, the break does not. + let inner = 14.4_f64; + let blank = format!( + "BT /F1 12 Tf 72 700 Td (One) Tj ET BT /F1 12 Tf 72 {} Td (Two) Tj ET \ + BT /F1 12 Tf 72 {} Td (Neighbour) Tj ET BT /F1 12 Tf 72 {} Td (Line) Tj ET", + 700.0 - inner, + 700.0 - 3.0 * inner, + 700.0 - 4.0 * inner + ); + let doc = text_page(&blank); + let lines = text::text_lines(&doc, 0).unwrap(); + let break_gap = lines[1].origin_baseline() - lines[2].origin_baseline(); + assert!((break_gap - 2.0 * inner).abs() < 0.05, "blank line measured {break_gap}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 2, "{:?}", blocks.iter().map(|b| b.text.as_str()).collect::>()); +} + +/// The first row is unchanged, so the rewrite leaves it in the source stream and skips the +/// paragraph marker. The added row then sits one leading above the neighbour. +#[test] +fn a_partial_rewrite_that_adds_a_row_keeps_the_neighbour() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET"; + let mut doc = text_page(src); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + text::replace_block(&mut doc, 0, 0, "Alpha first row.\nTwo\nThree").unwrap(); + let texts = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::>(); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert_eq!(texts(&doc), ["Alpha first row. Two Three", "Neighbor stays."], "{content}"); + for saved in [reopen(&doc), Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()] { + assert_eq!(texts(&saved), ["Alpha first row. Two Three", "Neighbor stays."]); + let mut again = saved; + text::replace_block(&mut again, 0, 0, "Alpha first row.\nTwo\nFour").unwrap(); + assert_eq!(texts(&reopen(&again)), ["Alpha first row. Two Four", "Neighbor stays."]); + } +} + +/// Both paragraphs share one text object, with a blank line between them. The paragraph +/// marker has to close before the neighbour, not after the shared `ET`. +#[test] +fn a_partial_rewrite_inside_one_text_object_keeps_the_neighbour() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj T* T* (Neighbor stays.) Tj ET"; + let mut doc = text_page(src); + let texts = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::>(); + assert_eq!(texts(&doc), ["Alpha first row. Alpha next row.", "Neighbor stays."], "the blank line is a paragraph break"); + text::replace_block(&mut doc, 0, 0, "Alpha first row.\nTwo\nThree").unwrap(); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert_eq!(texts(&doc), ["Alpha first row. Two Three", "Neighbor stays."], "{content}"); + for saved in [reopen(&doc), Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()] { + assert_eq!(texts(&saved), ["Alpha first row. Two Three", "Neighbor stays."], "save joined the neighbour"); + let mut again = saved; + text::replace_block(&mut again, 0, 0, "Alpha first row.\nTwo\nFour").unwrap(); + assert_eq!(texts(&reopen(&again)), ["Alpha first row. Two Four", "Neighbor stays."]); + } +} + +/// The last paragraph of a shared text object has no neighbour after it. The marker still has +/// to open after the preceding paragraph, not at the shared `BT`, or that paragraph joins it. +#[test] +fn a_partial_rewrite_of_the_last_paragraph_in_one_text_object_keeps_the_one_before_it() { + let src = + "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj T* T* (Neighbor stays.) Tj T* (Neighbor next.) Tj ET"; + let mut doc = text_page(src); + let texts = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::>(); + assert_eq!(texts(&doc), ["Alpha first row. Alpha next row.", "Neighbor stays. Neighbor next."], "the blank line is a paragraph break"); + text::replace_block(&mut doc, 0, 1, "Neighbor stays.\nChanged.").unwrap(); + let content = page_content(&doc); + assert_eq!(texts(&doc), ["Alpha first row. Alpha next row.", "Neighbor stays. Changed."], "{content}"); + let alpha = content.find("(Alpha first row.)").expect("the preceding paragraph"); + let open = content.find("/P BMC").expect("marker"); + assert!(alpha < open, "the marker swallowed the preceding paragraph: {content}"); + let neighbour = content.find("(Neighbor stays.)").expect("the kept row"); + assert!(open < neighbour, "the kept row is outside the marker: {content}"); + for saved in [reopen(&doc), Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()] { + assert_eq!(texts(&saved), ["Alpha first row. Alpha next row.", "Neighbor stays. Changed."], "save joined the preceding paragraph"); + let mut again = saved; + text::replace_block(&mut again, 0, 1, "Neighbor stays.\nEdited.").unwrap(); + assert_eq!(texts(&reopen(&again)), ["Alpha first row. Alpha next row.", "Neighbor stays. Edited."]); + } +} + +/// Three paragraphs share one text object. Rewriting the middle one must leave the paragraph +/// before it and the paragraph after it outside the marker. +#[test] +fn a_partial_rewrite_of_the_middle_paragraph_in_one_text_object_keeps_both_neighbours() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj \ + T* T* (Mid stays.) Tj T* (Mid next.) Tj T* T* (End stays.) Tj T* (End next.) Tj ET"; + let mut doc = text_page(src); + let texts = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::>(); + assert_eq!( + texts(&doc), + ["Alpha first row. Alpha next row.", "Mid stays. Mid next.", "End stays. End next."], + "each blank line is a paragraph break" + ); + text::replace_block(&mut doc, 0, 1, "Mid stays.\nChanged.").unwrap(); + let content = page_content(&doc); + assert_eq!(texts(&doc), ["Alpha first row. Alpha next row.", "Mid stays. Changed.", "End stays. End next."], "{content}"); + let alpha = content.find("(Alpha first row.)").expect("the preceding paragraph"); + let open = content.find("/P BMC").expect("marker"); + let end = content.find("(End stays.)").expect("the following paragraph"); + assert!(alpha < open, "the marker swallowed the preceding paragraph: {content}"); + assert!(open < end, "the following paragraph is inside the marker: {content}"); + let close = content[open..].find("EMC").map(|at| open + at).expect("close"); + assert!(close < end, "the marker closed after the following paragraph: {content}"); + for saved in [reopen(&doc), Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()] { + assert_eq!(texts(&saved), ["Alpha first row. Alpha next row.", "Mid stays. Changed.", "End stays. End next."]); + let mut again = saved; + text::replace_block(&mut again, 0, 1, "Mid stays.\nEdited.").unwrap(); + assert_eq!(texts(&reopen(&again)), ["Alpha first row. Alpha next row.", "Mid stays. Edited.", "End stays. End next."]); + } +} + +/// #2516's published steps: adding a row must not make the next edit-by-number rewrite Charlie. +/// The issue also asks for a stable paragraph id. That is #2508, and this test does not add one. +#[test] +fn adding_a_row_does_not_make_the_next_paragraph_number_mean_charlie() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha paragraph, first row of text.) Tj T* (Alpha paragraph, second row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 650 Tm (Bravo paragraph, a single row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 600 Tm (Charlie paragraph, first row of text.) Tj T* (Charlie paragraph, second row.) Tj ET"; + let mut doc = text_page(src); + let names = |doc: &Document| text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect::>(); + assert_eq!(names(&doc).len(), 3, "{:?}", names(&doc)); + text::replace_block(&mut doc, 0, 0, "Alpha paragraph, first row of text.\nAlpha paragraph, second row.\nAlpha paragraph, a third row.").unwrap(); + let after = names(&doc); + assert!(after.iter().any(|t| t.contains("Bravo") && !t.contains("Alpha")), "Bravo was absorbed: {after:?}"); + assert!(after.iter().any(|t| t.contains("Charlie")), "Charlie is gone: {after:?}"); + let bravo = after.iter().position(|t| t.contains("Bravo")).unwrap(); + text::replace_block(&mut doc, 0, bravo, "Bravo paragraph, edited.").unwrap(); + let done = names(&doc); + assert!(done.iter().any(|t| t.contains("Charlie")), "editing Bravo removed Charlie: {done:?}"); + assert!(done.iter().any(|t| t.contains("Bravo paragraph, edited.")), "{done:?}"); +} + +/// The paragraph is the last thing on the page. Editing the first row and leaving the second +/// in the source must keep both rows inside one marker, including after a save. +#[test] +fn a_partial_rewrite_of_the_only_paragraph_stays_one_block() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET"; + let mut doc = text_page(src); + text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap(); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Alpha next row."], "{texts:?}"); + assert_marker_wraps(&page_content(&doc), "/P BMC", "(One.)", "(Alpha next row.)"); + let doc = reopen(&doc); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Alpha next row."], "{texts:?}"); + assert_marker_wraps(&page_content(&doc), "/P BMC", "(One.)", "(Alpha next row.)"); + let mut doc = doc; + text::replace_block(&mut doc, 0, 0, "One.\nTwo.").unwrap(); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Two."], "{texts:?}"); + assert_marker_wraps(&page_content(&doc), "/P BMC", "(One.)", "(Two.)"); +} + +/// The same rewrite when an operator follows the paragraph's `ET`. The close belongs before +/// that operator, and still after the kept row. +#[test] +fn a_partial_rewrite_closes_before_the_operator_that_follows_the_paragraph() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET 0 0 0 RG"; + let mut doc = text_page(src); + text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap(); + let doc = reopen(&doc); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Alpha next row."], "{texts:?}"); + let content = page_content(&doc); + assert_marker_wraps(&content, "/P BMC", "(One.)", "(Alpha next row.)"); + let start = content.find("/P BMC").expect("marker"); + let close = content[start..].find("EMC").map(|at| start + at).expect("close"); + let after = content.find("0 0 0 RG").expect("the following operator is gone"); + assert!(close < after, "the marker swallowed the operator after the paragraph: {content}"); +} + +/// The last paragraph on the page is already inside a marked region. A partial rewrite stays +/// one paragraph, and that region's close still comes after both rows. +#[test] +fn a_partial_rewrite_of_the_last_marked_paragraph_stays_inside_its_region() { + let src = "/H1 << /MCID 0 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET EMC"; + let mut doc = text_page(src); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 1); + text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap(); + let doc = reopen(&doc); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Alpha next row."], "{texts:?}"); + assert_marker_wraps(&page_content(&doc), "/MCID 0", "(One.)", "(Alpha next row.)"); +} + +/// A partial rewrite must not insert `/P BMC` across two existing marked-content regions. +/// The neighbour's own marker has to keep wrapping its text. +#[test] +fn a_partial_rewrite_keeps_the_next_marked_region_closed() { + let src = "/H1 << /MCID 0 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET EMC \ + /P << /MCID 1 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET EMC"; + let mut doc = text_page(src); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap(); + let doc = reopen(&doc); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Alpha next row.", "Neighbor stays."], "{texts:?}"); + let content = page_content(&doc); + assert_marker_wraps(&content, "/MCID 0", "(One.)", "(Alpha next row.)"); + assert_marker_wraps(&content, "/MCID 1", "(Neighbor stays.)", "(Neighbor stays.)"); + let neighbour = content.find("/MCID 1").expect("neighbour region"); + let close = content[neighbour..].find("EMC").map(|at| neighbour + at).expect("neighbour close"); + assert!(!content[neighbour..close].contains("(One.)"), "the rewrite was pulled into the neighbour's marker: {content}"); +} + +/// The paragraph being edited is not in a marked region. The next one is. Closing the new +/// marker at the neighbour's `BT` would land after that region's `BDC` and close it early. +#[test] +fn a_partial_rewrite_of_an_unmarked_paragraph_leaves_the_marked_neighbour_closed() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \ + /P << /MCID 1 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET EMC"; + let mut doc = text_page(src); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap(); + let doc = reopen(&doc); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Alpha next row.", "Neighbor stays."], "{texts:?}"); + let content = page_content(&doc); + assert_marker_wraps(&content, "/MCID 1", "(Neighbor stays.)", "(Neighbor stays.)"); + assert!(!region_through_matching_close(&content, "/MCID 1").contains("(One.)"), "the rewrite was pulled into the neighbour: {content}"); +} + +/// Both paragraphs already share one marked region, separated by a blank line. The extra marker +/// around the edit has to close itself, not the region, so the neighbour stays inside it. +#[test] +fn a_partial_rewrite_inside_one_marked_region_leaves_the_neighbour_inside_it() { + let src = "/P << /MCID 0 >> BDC BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 656.8 Tm (Neighbor stays.) Tj ET EMC"; + let mut doc = text_page(src); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + text::replace_block(&mut doc, 0, 0, "One.\nAlpha next row.").unwrap(); + let doc = reopen(&doc); + let texts: Vec<_> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["One. Alpha next row.", "Neighbor stays."], "{texts:?}"); + let content = page_content(&doc); + let region = region_through_matching_close(&content, "/MCID 0"); + assert!(region.contains("(One.)") && region.contains("(Neighbor stays.)"), "the region closed before the neighbour: {content}"); +} + +/// Bytes from `open` through the `EMC` that balances the marked region it begins. +fn region_through_matching_close<'a>(content: &'a str, open: &str) -> &'a str { + let start = content.find(open).unwrap_or_else(|| panic!("missing {open}: {content}")); + let bytes = content.as_bytes(); + let mut index = start; + let mut depth = 0i32; + while index < bytes.len() { + let rest = &bytes[index..]; + let boundary = index == 0 || !bytes[index - 1].is_ascii_alphanumeric(); + if boundary && (rest.starts_with(b"BMC") || rest.starts_with(b"BDC")) && rest.get(3).is_none_or(|b| !b.is_ascii_alphanumeric()) { + depth += 1; + index += 3; + continue; + } + if boundary && rest.starts_with(b"EMC") && rest.get(3).is_none_or(|b| !b.is_ascii_alphanumeric()) { + depth -= 1; + if depth == 0 { + return &content[start..index]; + } + index += 3; + continue; + } + index += 1; + } + panic!("unbalanced region {open}: {content}"); +} + +fn page_content(doc: &Document) -> String { + String::from_utf8_lossy(&page_content_bytes(doc, 0)).into_owned() +} + +/// `open` through the next `EMC` contains both strings, and no `EMC` sits inside a text object. +fn assert_marker_wraps(content: &str, open: &str, first: &str, second: &str) { + let marked = region_through_matching_close(content, open); + assert!(marked.contains(first) && marked.contains(second), "{open} closed too early: {content}"); + assert!(!marker_crosses_a_text_object(content), "a marker opened outside a text object and closed inside it: {content}"); +} + +/// A marker may sit inside one text object. It may not open outside one and close inside it. +fn marker_crosses_a_text_object(content: &str) -> bool { + let mut text = 0i32; + let mut opened_outside = 0i32; + let mut opened_inside = 0i32; + for token in content.split_whitespace() { + match token { + "BT" => text += 1, + "ET" => text -= 1, + "BMC" | "BDC" => { + if text == 0 { + opened_outside += 1; + } else { + opened_inside += 1; + } + } + "EMC" => { + if text > 0 { + if opened_inside > 0 { + opened_inside -= 1; + } else { + return true; + } + } else if opened_outside > 0 { + opened_outside -= 1; + } + } + _ => {} + } + } + text != 0 || opened_outside != 0 || opened_inside != 0 +} + +/// A blank line is one extra leading, so the fourth row of a grown paragraph sits on the +/// neighbour's baseline. Changing the box width must not treat that neighbour as a duplicate +/// drawing and delete it. +#[test] +fn resizing_a_grown_paragraph_keeps_the_neighbour() { + let src = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 410 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 366.8 Tm (Neighbor stays.) Tj ET"; + let mut doc = text_page(src); + let text = "Alpha first row.\nthis is another\nalpha test\nAlpha next row."; + text::replace_block(&mut doc, 0, 0, text).unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + let added = lines.iter().find(|l| l.text.contains("Alpha next row.")).unwrap(); + let neighbour = lines.iter().find(|l| l.text.contains("Neighbor")).unwrap(); + assert!((added.origin_baseline() - neighbour.origin_baseline()).abs() < 0.5, "the new row is not on the neighbour"); + let width = text::text_blocks(&doc, 0).unwrap()[0].rect[2] - text::text_blocks(&doc, 0).unwrap()[0].rect[0]; + for factor in [0.6_f64, 1.6] { + let mut copy = doc.clone(); + text::rewrite_block(&mut copy, 0, 0, Some(text), &text::BlockStyle { width: Some((width * factor).max(40.0)), ..Default::default() }) + .unwrap(); + let texts = text::text_blocks(©, 0).unwrap().into_iter().map(|b| b.text).collect::>(); + assert!(texts.iter().any(|t| t.contains("Neighbor stays.")), "width ×{factor} removed the neighbour: {texts:?}"); + } +} + +/// Narrowing wraps a line. Widening it again rejoins that wrap. A newline that was typed stays. +#[test] +fn a_wrapped_line_rejoins_when_widened_and_a_typed_break_stays() { + let mut doc = text_page("BT /F1 24 Tf 20 150 Td (Page 1) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { width: Some(45.0), ..Default::default() }).unwrap(); + let block = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); + assert!(block.editor_text.contains('\n'), "the narrow box did not wrap: {}", block.editor_text); + assert_eq!(block.reflow_text, "Page 1", "a wrap was kept as a hard break: {}", block.reflow_text); + text::rewrite_block(&mut doc, 0, 0, Some(&block.reflow_text), &text::BlockStyle { width: Some(200.0), ..Default::default() }).unwrap(); + let lines: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(lines, ["Page 1"]); + + let mut typed = text_page("BT /F1 12 Tf 72 700 Td (One) Tj ET"); + text::replace_block(&mut typed, 0, 0, "One\nTwo\nThree\nfour").unwrap(); + let block = text::text_blocks(&typed, 0).unwrap().into_iter().next().unwrap(); + assert_eq!(block.reflow_text, "One\nTwo\nThree\nfour", "a typed break was rejoined: {}", block.reflow_text); + let width = (block.rect[2] - block.rect[0]) * 6.0; + text::rewrite_block(&mut typed, 0, 0, Some(&block.reflow_text), &text::BlockStyle { width: Some(width.max(180.0)), ..Default::default() }) + .unwrap(); + assert_eq!(text::text_blocks(&typed, 0).unwrap()[0].editor_text, "One\nTwo\nThree\nfour"); +} + +/// #2529 (reviewer): a run of typed spaces that happens to fall at a wrap point is not the same +/// as the single space an ordinary word gap leaves. Narrowing drops the whole gap, as any wrap +/// does (#4199's own test: `wrapped("aa bb cc", 6.0) == ["aa bb", "cc"]`); widening it back +/// must not quietly turn two typed spaces into one. The count is stored on the wrap marker, so +/// it has to survive an incremental save and a full rewrite, not only the in-memory rewrite. +#[test] +fn narrowing_then_widening_keeps_a_typed_double_space() { + let fresh = text_page("BT /F1 24 Tf 20 150 Td (One Two) Tj ET"); + let wide_width = { + let wide = text::text_blocks(&fresh, 0).unwrap().into_iter().next().unwrap().rect; + wide[2] - wide[0] + }; + for (save, reopen_as) in + [("incremental", reopen(&fresh)), ("full", Document::open(Arc::new(write_full(&fresh, &SaveOptions::default()).unwrap())).unwrap())] + { + let mut doc = reopen_as; + text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { width: Some(45.0), ..Default::default() }).unwrap(); + let doc = + if save == "incremental" { reopen(&doc) } else { Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap() }; + let block = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); + assert_eq!(block.editor_text, "One\nTwo", "{save}: the narrow box did not wrap between the two typed spaces: {}", block.editor_text); + assert_eq!(block.reflow_text, "One Two", "{save}: the saved wrap did not remember both spaces: {:?}", block.reflow_text); + let mut doc = doc; + text::rewrite_block(&mut doc, 0, 0, Some(&block.reflow_text), &text::BlockStyle { width: Some(wide_width), ..Default::default() }).unwrap(); + let doc = + if save == "incremental" { reopen(&doc) } else { Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap() }; + let rewidened = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); + assert_eq!(rewidened.editor_text, "One Two", "{save}: the second typed space was lost: {:?}", rewidened.editor_text); + } +} + +/// #2529: a Chinese/Japanese word split has no space of its own (`wrap`'s own doc comment: "those +/// pieces join without a space"). Narrowing a paragraph until it wraps inside such a word, then +/// widening it back, must restore the two pieces exactly as they were, not insert a space between +/// them. Checked on both the single-style rewrite path and the row-editor (mixed-style) path. +#[test] +fn narrowing_then_widening_a_japanese_word_adds_no_space() { + if pdfcraft_fonts::document_japanese_font().is_none() { + eprintln!("skipping narrowing_then_widening_a_japanese_word_adds_no_space: built without craft-fonts (set CRAFT_FONTS_DIR)"); + return; + } + let text = "日本語の文章です"; + for draft in [text.to_string(), format!("見出し\n{text}")] { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Placeholder text) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(&draft), &text::BlockStyle { width: Some(400.0), ..Default::default() }).unwrap(); + let wide = text::text_blocks(&reopen(&doc), 0).unwrap().into_iter().next().unwrap(); + let wide_width = wide.rect[2] - wide.rect[0]; + + // Narrow enough to force a wrap inside the Japanese word, not just between the two rows. + // A width-only change must pass `reflow_text` explicitly, as the editor UI does: `None` + // falls back to the paragraph's space-joined display text and would not keep a typed break. + text::rewrite_block(&mut doc, 0, 0, Some(&wide.reflow_text), &text::BlockStyle { width: Some(60.0), ..Default::default() }).unwrap(); + let narrow = text::text_blocks(&reopen(&doc), 0).unwrap().into_iter().next().unwrap(); + assert!(narrow.rows.len() > draft.matches('\n').count() + 1, "the narrow box did not wrap: {:?}", narrow.editor_text); + assert_eq!( + narrow.editor_text.replace('\n', ""), + draft.replace('\n', ""), + "wrapping must not add or drop characters: {:?}", + narrow.editor_text + ); + + text::rewrite_block(&mut doc, 0, 0, Some(&narrow.reflow_text), &text::BlockStyle { width: Some(wide_width), ..Default::default() }).unwrap(); + let rewidened = text::text_blocks(&reopen(&doc), 0).unwrap().into_iter().next().unwrap(); + assert_eq!(rewidened.editor_text, draft, "a Japanese word split gained a space it never had: {:?}", rewidened.editor_text); + } +} + +/// A file that did not mark its breaks still rejoins a line that fills the measure, and keeps a +/// short line that had room. +#[test] +fn an_unmarked_full_line_rejoins_and_a_short_break_stays() { + let long = "BT /F1 12 Tf 1 0 0 1 72 700 Tm (This sentence is long enough to fill a measure.) Tj ET \ + BT /F1 12 Tf 1 0 0 1 72 685.6 Tm (Tail) Tj ET"; + let doc = text_page(long); + let block = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); + assert_eq!(block.rows.len(), 2, "{}", block.editor_text); + assert_eq!(block.reflow_text, "This sentence is long enough to fill a measure. Tail", "{}", block.reflow_text); + + let short = "BT /F1 12 Tf 1 0 0 1 72 700 Tm (One) Tj ET BT /F1 12 Tf 1 0 0 1 72 685.6 Tm (Two) Tj ET"; + let doc = text_page(short); + let block = text::text_blocks(&doc, 0).unwrap().into_iter().next().unwrap(); + assert_eq!(block.reflow_text, "One\nTwo", "{}", block.reflow_text); +} + +/// The letter the screenshots edit. Widening example 1, or adding a row there, must leave the +/// heading above it and both paragraphs under example 2 as they were. +fn letter_page() -> &'static str { + "BT /F1 14 Tf 72 740 Td (1. Repeated step, no TL. Click Two.) Tj ET \ + BT /F1 12 Tf 72 700 Td (One) Tj ET \ + BT /F1 12 Tf 72 672.4 Td (Two) Tj ET \ + BT /F1 12 Tf 72 644.8 Td (Three) Tj ET \ + BT /F1 14 Tf 72 590 Td (2. Blank line. Edit the first paragraph.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 550 Tm (First paragraph, first row.) Tj T* (First paragraph, second row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 506.8 Tm (Second paragraph, first row.) Tj T* (Second paragraph, second row.) Tj ET \ + BT /F1 14 Tf 72 450 Td (3. Add a row under Alpha. Neighbor stays.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 410 Tm (Alpha first row.) Tj T* (Alpha next row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 72 366.8 Tm (Neighbor stays.) Tj ET" +} + +fn block_texts(doc: &Document) -> Vec { + text::text_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect() +} + +#[test] +fn widening_example_one_does_not_swallow_the_heading_or_split_the_next_paragraph() { + let mut doc = text_page(letter_page()); + let before = block_texts(&doc); + assert!(before.iter().any(|t| t == "1. Repeated step, no TL. Click Two."), "{before:?}"); + assert!(before.iter().any(|t| t.contains("One") && t.contains("Three") && !t.contains("Repeated")), "{before:?}"); + assert!(before.iter().any(|t| t.contains("First paragraph, first row") && t.contains("second row")), "{before:?}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let idx = blocks.iter().position(|b| b.text.contains("One") && b.text.contains("Three")).unwrap(); + // What the editor committed, including the line breaks. A later width change must keep them. + text::replace_block(&mut doc, 0, idx, "One\nTwo\nThree\nfour").unwrap(); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let idx = blocks.iter().position(|b| b.text.contains("four") || b.editor_text.contains("four")).unwrap(); + assert_eq!(blocks[idx].reflow_text, "One\nTwo\nThree\nfour", "a typed break was treated as a wrap: {}", blocks[idx].reflow_text); + let shown = blocks[idx].reflow_text.clone(); + let width = blocks[idx].rect[2] - blocks[idx].rect[0]; + text::rewrite_block(&mut doc, 0, idx, Some(&shown), &text::BlockStyle { width: Some((width * 6.0).max(180.0)), ..Default::default() }).unwrap(); + let doc = reopen(&doc); + let after = block_texts(&doc); + let kept = text::text_blocks(&doc, 0).unwrap().into_iter().find(|b| b.editor_text.contains("four")).unwrap(); + assert_eq!(kept.editor_text, "One\nTwo\nThree\nfour", "a wider box flattened the line breaks: {:?}", kept.editor_text); + assert!(after.iter().any(|t| t == "1. Repeated step, no TL. Click Two."), "the heading joined the resized box: {after:?}"); + assert!( + after.iter().any(|t| t.contains("First paragraph, first row") && t.contains("First paragraph, second row")), + "widening example 1 split the next paragraph: {after:?}" + ); + assert!(after.iter().any(|t| t.contains("Second paragraph, first row") && t.contains("second row")), "{after:?}"); + assert!(after.iter().any(|t| t.contains("Neighbor stays.") && !t.contains("Alpha")), "{after:?}"); +} + +#[test] +fn a_row_added_one_leading_above_the_neighbour_does_not_absorb_it() { + let mut doc = text_page(letter_page()); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let idx = blocks.iter().position(|b| b.text.contains("Alpha first row")).unwrap(); + text::replace_block(&mut doc, 0, idx, "Alpha first row.\ntest\nAlpha next row.").unwrap(); + let doc = reopen(&doc); + let after = block_texts(&doc); + assert!(after.iter().any(|t| t.contains("Neighbor stays.") && !t.contains("Alpha")), "the added row absorbed the neighbour: {after:?}"); +} + #[test] fn paragraphs_are_found_and_rewrapped() { let mut doc = text_page( @@ -2879,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(), @@ -2903,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). @@ -3091,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 @@ -3542,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 { @@ -6858,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 @@ -7121,6 +8895,448 @@ 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() { + for align in [added::Align::Left, added::Align::Center, added::Align::Right, added::Align::Justify] { + let mut doc = text_page("BT /F1 10 Tf 12 TL 72 700 Td (First row) Tj T* (Second row) Tj ET"); + let before = page_content_bytes(&doc, 0); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let style = text::BlockStyle { aligns: vec![None, Some(align)], ..Default::default() }; + let error = text::rewrite_block(&mut doc, 0, 0, Some(&blocks[0].editor_text), &style).unwrap_err(); + assert!(error.to_string().contains("selected-row alignment")); + assert_eq!(page_content_bytes(&doc, 0), before); + assert_eq!(page_content_bytes(&reopen(&doc), 0), before); + } +} + +/// Refusal preserves all existing rows and their saved content stream. +#[test] +fn rows_without_their_own_alignment_keep_their_operators_is_refused_without_changing_the_document() { + for align in [added::Align::Left, added::Align::Center, added::Align::Right, added::Align::Justify] { + let mut doc = text_page("BT /F1 10 Tf 12 TL 72 700 Td (First row) Tj T* (Second row) Tj ET"); + let before = page_content_bytes(&doc, 0); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let style = text::BlockStyle { aligns: vec![None, Some(align)], ..Default::default() }; + let error = text::rewrite_block(&mut doc, 0, 0, Some(&blocks[0].editor_text), &style).unwrap_err(); + assert!(error.to_string().contains("selected-row alignment")); + assert_eq!(page_content_bytes(&doc, 0), before); + assert_eq!(page_content_bytes(&reopen(&doc), 0), before); + } +} + +/// Mixed-style rows also refuse unsupported per-row intent before writing. +#[test] +fn one_row_of_a_styled_region_takes_its_own_alignment_is_refused_without_changing_the_document() { + for align in [added::Align::Left, added::Align::Center, added::Align::Right, added::Align::Justify] { + let mut doc = mixed_region_page(); + let before = page_content_bytes(&doc, 0); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let style = text::BlockStyle { aligns: vec![None, Some(align)], ..Default::default() }; + let error = text::rewrite_block(&mut doc, 0, 1, Some(&blocks[1].editor_text), &style).unwrap_err(); + assert!(error.to_string().contains("selected-row alignment")); + assert_eq!(page_content_bytes(&doc, 0), before); + assert_eq!(page_content_bytes(&reopen(&doc), 0), before); + } +} + +/// Added text: each paragraph is laid out with its own alignment, a justified paragraph's last +/// line isn't stretched, and the alignments survive saving and reopening. +#[test] +fn added_text_paragraphs_keep_their_own_alignments() { + let mut doc = fixture(); + let text = AddedText { + rect: [72.0, 600.0, 372.0, 700.0], + text: "Left line\nRight line\nCentred line".into(), + size: 12.0, + aligns: vec![None, Some(Align::Right), Some(Align::Center)], + ..AddedText::default() + }; + assert_eq!(text.paragraph_align(1), Align::Right); + add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap(); + let doc = reopen(&doc); + let Content::Text(t) = &list_added(&doc)[0].content else { panic!() }; + assert_eq!(t.aligns, text.aligns, "saved with the item"); + let page = streams(&doc, 0).join("\n"); + let x_of = |s: &str| -> f64 { + let at = page.find(&format!("({s}) Tj")).unwrap(); + let tm = page[..at].rfind(" Tm").unwrap(); + let nums: Vec = page[..tm].rsplit("1 0 0 1 ").next().unwrap().split_whitespace().filter_map(|v| v.parse().ok()).collect(); + nums[0] + }; + let w = |s: &str| Family::Helvetica.width(s, 12.0, false, false); + assert!((x_of("Left line") - 72.0).abs() < 0.01); + assert!((x_of("Right line") - (372.0 - w("Right line"))).abs() < 0.01); + assert!((x_of("Centred line") - (72.0 + (300.0 - w("Centred line")) / 2.0)).abs() < 0.01); + // A paragraph's own alignment the same as the box's is no entry at all. + let tidy = paragraph_align::tidy(vec![Some(Align::Left), Some(Align::Right)], Align::Left); + assert_eq!(tidy, [None, Some(Align::Right)]); +} + +/// With character spacing, a paragraph aligned on its own is placed by its spaced width: a +/// right-aligned line still ends at the box's right edge, and a centred one sits in the middle. +#[test] +fn added_text_paragraphs_align_by_their_spaced_width() { + let mut doc = fixture(); + let text = AddedText { + rect: [72.0, 600.0, 372.0, 700.0], + text: "Left line\nRight line\nCentred line".into(), + size: 12.0, + aligns: vec![None, Some(Align::Right), Some(Align::Center)], + char_spacing: 2.0, + ..AddedText::default() + }; + add_content(&mut doc, 0, &Content::Text(text)).unwrap(); + let page = streams(&reopen(&doc), 0).join("\n"); + assert!(page.contains("2 Tc"), "{page}"); + let x_of = |s: &str| -> f64 { + let at = page.find(&format!("({s}) Tj")).unwrap(); + let tm = page[..at].rfind(" Tm").unwrap(); + let nums: Vec = page[..tm].rsplit("1 0 0 1 ").next().unwrap().split_whitespace().filter_map(|v| v.parse().ok()).collect(); + nums[0] + }; + let w = |s: &str| Family::Helvetica.width(s, 12.0, false, false) + 2.0 * s.chars().count() as f64; + assert!((x_of("Left line") - 72.0).abs() < 0.01); + assert!((x_of("Right line") - (372.0 - w("Right line"))).abs() < 0.01); + assert!((x_of("Centred line") - (72.0 + (300.0 - w("Centred line")) / 2.0)).abs() < 0.01); +} + +/// A one-row title over a body column: an alignment picked for it is measured across the column. +/// Left puts it on the column's left edge, and centring it again leaves it where it is, rather +/// than aligning it in its own box (a no-op) or toward the page's margin (moving it right each +/// time). It is aligned as the editor aligns a selected row, in whichever paragraph holds it. +#[test] +fn a_lone_row_aligns_across_the_column_it_sits_in() { + let mut doc = text_page( + "BT /F1 24 Tf 1 0 0 1 250 700 Tm (Title) Tj ET BT /F1 10 Tf 12 TL 72 650 Td (The body of the page runs across the whole column here.) Tj T* (It has a second row that is a little shorter.) Tj ET", + ); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks[0].text, "Title"); + let column = [blocks[1].rect[0], blocks[1].rect[2]]; + let title = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == "Title").unwrap().rect; + let align = |doc: &mut Document, a: added::Align| { + let lines = text::text_lines(doc, 0).unwrap(); + let line = lines.iter().position(|l| l.text == "Title").unwrap(); + let blocks = text::text_blocks(doc, 0).unwrap(); + let (index, block) = blocks.iter().enumerate().find(|(_, b)| b.lines.contains(&line)).unwrap(); + // The title is the paragraph's first row. + let style = text::BlockStyle { align: Some(a), ..Default::default() }; + text::rewrite_block(doc, 0, index, Some(&block.editor_text), &style).unwrap(); + }; + align(&mut doc, added::Align::Center); + let centred = title(&doc); + assert!(((centred[0] + centred[2]) / 2.0 - (column[0] + column[1]) / 2.0).abs() < 1.0, "centred in the column: {centred:?} vs {column:?}"); + align(&mut doc, added::Align::Center); + assert!((title(&doc)[0] - centred[0]).abs() < 0.5, "centring again leaves it where it is: {:?} vs {centred:?}", title(&doc)); + align(&mut doc, added::Align::Left); + assert!((title(&doc)[0] - column[0]).abs() < 0.5, "left: back on the column's edge, {:?} vs {column:?}", title(&doc)); + align(&mut doc, added::Align::Right); + assert!((title(&doc)[2] - column[1]).abs() < 1.0, "right: on the column's right edge, {:?} vs {column:?}", title(&doc)); +} + +/// An edited paragraph's text has a line break between its source rows (`editor_text`), as the +/// paragraph editor sends it. Those are the rows of one paragraph, not paragraph ends: justified +/// in a font of its own (the single-style rewrite), every row but the last still fills the width. +#[test] +fn a_justified_paragraph_from_the_editor_fills_its_rows() { + let mut doc = + text_page("BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj T* (end) Tj ET"); + let block = text::text_blocks(&doc, 0).unwrap()[0].clone(); + assert_eq!(block.editor_text.matches('\n').count(), 2); + let style = text::BlockStyle { align: Some(added::Align::Justify), family: Some((Family::Helvetica, true, false)), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(&block.editor_text), &style).unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + let row = |t: &str| lines.iter().find(|l| l.text.starts_with(t)).unwrap().rect; + let right = row("One")[2]; + assert!((row("eight")[2] - right).abs() < 1.0, "the second row is stretched: {:?} vs {right}", row("eight")); + assert!(row("end")[2] < right - 20.0, "the last row isn't"); +} + +/// An alignment for a row the draft doesn't have names no row: a one-row title given one for a +/// second row only stays where it is, not moved to its column's edge. Nor does a row of the draft +/// with no alignment of its own move because another row has one (CodeRabbit on 648dd428). +#[test] +fn a_title_moves_to_its_column_only_for_an_alignment_of_its_own() { + let page = "BT /F1 24 Tf 1 0 0 1 250 700 Tm (Title) Tj ET BT /F1 10 Tf 12 TL 72 650 Td (The body of the page runs across the whole column here.) Tj T* (It has a second row that is a little shorter.) Tj ET"; + let title = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().find(|l| l.text == "Title").unwrap().rect; + let before = title(&text_page(page)); + for draft in ["Title", "Title\nSub"] { + let mut doc = text_page(page); + let style = text::BlockStyle { aligns: vec![None, Some(added::Align::Right)], ..Default::default() }; + assert!(text::rewrite_block(&mut doc, 0, 0, Some(draft), &style).is_err()); + assert!((title(&doc)[0] - before[0]).abs() < 0.5, "{draft:?}: the title stays put, {:?} vs {before:?}", title(&doc)); + } +} + +/// A column whose rows are several runs (a second style on the same baseline) reaches the right +/// edge of its whole rows, not of the run at the left edge: a title wider than that first run is +/// still held by the column and aligns to it (CodeRabbit on b9940d7d). +#[test] +fn a_column_is_measured_by_its_whole_rows_not_its_first_runs() { + let page = "BT /F1 18 Tf 1 0 0 1 100 672 Tm (A longer title) Tj ET BT /F1 10 Tf 12 TL 50 610 Td (The first column runs to here.) Tj ET BT /F1 11 Tf 1 0 0 rg 1 0 0 1 185 610 Tm (and keeps going on.) Tj ET BT /F1 10 Tf 12 TL 50 598 Td (A second row of it.) Tj ET"; + let blocks = text::text_blocks(&text_page(page), 0).unwrap(); + let body = blocks.iter().find(|b| b.text.starts_with("The first")).expect("the body is a block"); + assert!(body.rows.first().is_some_and(|row| row.len() >= 2), "the body's first row is several runs: {:?}", body.rows); + let lines = text::text_lines(&text_page(page), 0).unwrap(); + let column_right = + body.rows.iter().filter_map(|row| row.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect[2]).reduce(f64::max)).fold(f64::MIN, f64::max); + let mut doc = text_page(page); + let index = blocks.iter().position(|b| b.text == "A longer title").expect("the title is a block of its own"); + let style = text::BlockStyle { align: Some(added::Align::Right), ..Default::default() }; + text::rewrite_block(&mut doc, 0, index, Some("A longer title"), &style).unwrap(); + let title = text::text_lines(&doc, 0).unwrap().into_iter().find(|l| l.text == "A longer title").unwrap().rect; + assert!((title[2] - column_right).abs() < 1.0, "right: on the whole row's right edge, {title:?} vs {column_right}"); +} + +/// A row with no column of its own is aligned in its own box, but that box is not the width its new +/// text wraps at: changing the text together with an alignment keeps a single row on one row, as +/// it does without the alignment (CodeRabbit on b9940d7d). +#[test] +fn an_isolated_row_edited_with_an_alignment_does_not_wrap_at_its_old_width() { + for align in [added::Align::Center, added::Align::Right] { + let mut doc = text_page("BT /F1 18 Tf 1 0 0 1 100 700 Tm (Title) Tj ET"); + let longer = "A much longer title for the page"; + let style = text::BlockStyle { align: Some(align), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(longer), &style).unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>(), [longer], "{align:?}: the title stays on one row"); + } +} + +/// A heading across the whole page sits closer to the title than the title's own column does: the +/// title is still aligned in its own column, not across the heading's page-wide frame (FredApps's +/// review of 43f848d5). Centre and right both go by the column. +#[test] +fn a_lone_row_aligns_in_its_own_column_not_a_closer_full_width_heading() { + let page = "BT /F1 12 Tf 1 0 0 1 50 700 Tm (A heading that runs across both columns of the page, well past the first column, to the far margin.) Tj ET BT /F1 18 Tf 1 0 0 1 120 672 Tm (Title) Tj ET BT /F1 10 Tf 12 TL 50 610 Td (The first column runs to here.) Tj T* (A second row of it.) Tj ET BT /F1 10 Tf 12 TL 320 610 Td (The second column has more rows.) Tj T* (One.) Tj T* (Two.) Tj T* (Three.) Tj ET"; + let blocks = text::text_blocks(&text_page(page), 0).unwrap(); + let column = blocks.iter().find(|b| b.text.starts_with("The first")).unwrap().rect; + let heading = blocks.iter().find(|b| b.text.starts_with("A heading")).unwrap().rect; + let title_block = blocks.iter().find(|b| b.text == "Title").expect("the title is a block of its own").rect; + let gap = |other: [f64; 4]| (other[1] - title_block[3]).max(title_block[1] - other[3]).max(0.0); + assert!(gap(heading) < gap(column), "the heading is the nearer block: {} vs {}", gap(heading), gap(column)); + assert!(heading[2] - heading[0] > 2.0 * (column[2] - column[0]), "the heading is page-wide: {heading:?} vs {column:?}"); + let title_after = |align: added::Align| { + let mut doc = text_page(page); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let index = blocks.iter().position(|b| b.text == "Title").unwrap(); + let style = text::BlockStyle { align: Some(align), ..Default::default() }; + text::rewrite_block(&mut doc, 0, index, Some("Title"), &style).unwrap(); + text::text_lines(&doc, 0).unwrap().into_iter().find(|l| l.text == "Title").unwrap().rect + }; + let centred = title_after(added::Align::Center); + assert!(((centred[0] + centred[2]) / 2.0 - (column[0] + column[2]) / 2.0).abs() < 1.0, "centred in its own column: {centred:?} vs {column:?}"); + let right = title_after(added::Align::Right); + assert!((right[2] - column[2]).abs() < 1.0, "right: on its own column's right edge, {right:?} vs {column:?}"); +} + +/// The same on a page of two columns, where the other column has more rows: a title over the +/// first column is aligned across the first column, not its own box. +#[test] +fn a_lone_row_aligns_across_its_own_column_of_two() { + let mut doc = text_page( + "BT /F1 18 Tf 1 0 0 1 120 700 Tm (Title) Tj ET \ + BT /F1 10 Tf 12 TL 50 660 Td (The first column runs to here.) Tj T* (A second row of it.) Tj ET \ + BT /F1 10 Tf 12 TL 320 660 Td (The second column has more rows.) Tj T* (One.) Tj T* (Two.) Tj T* (Three.) Tj T* (Four.) Tj ET", + ); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let column = blocks.iter().find(|b| b.text.starts_with("The first")).unwrap().rect; + let index = blocks.iter().position(|b| b.text == "Title").unwrap(); + let style = text::BlockStyle { align: Some(added::Align::Left), ..Default::default() }; + text::rewrite_block(&mut doc, 0, index, Some("Title"), &style).unwrap(); + let title = text::text_lines(&doc, 0).unwrap().into_iter().find(|l| l.text == "Title").unwrap().rect; + assert!((title[0] - column[0]).abs() < 0.5, "left: on the first column's edge, {title:?} vs {column:?}"); +} + +/// A one-row title in two colours is rewritten by the styled-region path, which is given the +/// same column frame: centred twice, it stays centred in the column instead of moving right. +#[test] +fn a_lone_row_of_two_styles_aligns_across_its_column() { + let mut doc = text_page( + "BT /F1 18 Tf 1 0 0 1 250 700 Tm (Big ) Tj 1 0 0 rg (Title) Tj ET \ + BT 0 g /F1 10 Tf 12 TL 72 650 Td (The body of the page runs across the whole column here.) Tj T* (It has a second row that is a little shorter.) Tj ET", + ); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks[0].text, "Big Title"); + let column = [blocks[1].rect[0], blocks[1].rect[2]]; + let title = |doc: &Document| { + let lines = text::text_lines(doc, 0).unwrap(); + let block = text::text_blocks(doc, 0).unwrap().into_iter().find(|b| b.text == "Big Title").unwrap(); + block.lines.iter().map(|i| lines[*i].rect).reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]).unwrap() + }; + let centre = |doc: &mut Document| { + let blocks = text::text_blocks(doc, 0).unwrap(); + let (index, block) = blocks.iter().enumerate().find(|(_, b)| b.text == "Big Title").unwrap(); + let style = text::BlockStyle { align: Some(added::Align::Center), ..Default::default() }; + text::rewrite_block(doc, 0, index, Some(&block.editor_text), &style).unwrap(); + }; + centre(&mut doc); + let centred = title(&doc); + assert!(((centred[0] + centred[2]) / 2.0 - (column[0] + column[1]) / 2.0).abs() < 1.0, "centred in the column: {centred:?} vs {column:?}"); + centre(&mut doc); + assert!((title(&doc)[0] - centred[0]).abs() < 0.5, "centring again leaves it where it is: {:?} vs {centred:?}", title(&doc)); +} + +#[test] +fn a_titles_column_ignores_distant_full_width_headers_and_footers() { + for (title_x, column_prefix) in [(95, "The first"), (365, "The second")] { + for mixed in [false, true] { + for title_y in [710, 580] { + let title = if mixed { "(Big ) Tj 1 0 0 rg (Title) Tj" } else { "(Title) Tj" }; + let name = if mixed { "Big Title" } else { "Title" }; + let mut doc = text_page(&format!( + "BT /F1 10 Tf 12 TL 50 780 Td (A page-wide header extending well beyond either body column on this page and continuing all the way to the right margin.) Tj T* (A repeated page-wide heading across both columns and continuing to the right margin of the page.) Tj ET \ + BT /F1 18 Tf 1 0 0 1 {title_x} {title_y} Tm {title} ET \ + BT 0 g /F1 10 Tf 12 TL 50 660 Td (The first column runs to here.) Tj T* (A second row of it.) Tj ET \ + BT /F1 10 Tf 12 TL 320 660 Td (The second column runs to here.) Tj T* (Another row.) Tj ET \ + BT /F1 10 Tf 50 40 Td (A page-wide footer extending well beyond either body column on this page and continuing all the way to the right margin.) Tj ET" + )); + let rect = |doc: &Document, prefix: &str| { + let blocks = text::text_blocks(doc, 0).unwrap(); + blocks.iter().find(|b| b.text.starts_with(prefix)).unwrap_or_else(|| panic!("missing {prefix:?} in {blocks:?}")).rect + }; + let column = rect(&doc, column_prefix); + let header = rect(&doc, "A page-wide header"); + let footer = rect(&doc, "A page-wide footer"); + assert!(header[2] > column[2] + 20.0 && footer[2] > column[2] + 20.0); + for align in [added::Align::Center, added::Align::Center, added::Align::Right, added::Align::Left] { + let blocks = text::text_blocks(&doc, 0).unwrap(); + let (index, block) = blocks.iter().enumerate().find(|(_, b)| b.text == name).unwrap(); + let style = text::BlockStyle { align: Some(align), ..Default::default() }; + text::rewrite_block(&mut doc, 0, index, Some(&block.editor_text), &style).unwrap(); + doc = reopen(&doc); + let title = rect(&doc, name); + let error = match align { + added::Align::Center => ((title[0] + title[2]) - (column[0] + column[2])).abs() / 2.0, + added::Align::Right => (title[2] - column[2]).abs(), + _ => (title[0] - column[0]).abs(), + }; + assert!(error < 1.0, "{align:?}, mixed={mixed}, title_y={title_y}: {title:?} must use {column:?}"); + assert_eq!(rect(&doc, "A page-wide header"), header); + assert_eq!(rect(&doc, "A page-wide footer"), footer); + assert_eq!(rect(&doc, column_prefix), column); + } + } + } + } +} + /// How each glyph-showing operator on page 0 is painted, read the way a renderer reads the page: /// its text, `Tr`, stroke colour operators, line width (from `w` or an ExtGState's `/LW`) and /// the ExtGStates selected before it, following `q`/`Q`. @@ -7536,3 +9752,554 @@ fn a_run_of_spaces_in_a_subset_without_a_space_glyph_keeps_its_width() { let content = String::from_utf8_lossy(&page_content_bytes(&reopen(&doc), 0)).into_owned(); 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. +#[test] +fn q_inside_a_text_object_restores_the_text_matrices() { + for (content, text, baseline) in [ + ("BT /F1 12 Tf 72 700 Td q 0 -100 Td Q (After) Tj ET", "After", [72.0, 700.0]), + ("BT /F1 12 Tf 72 700 Td q 1 0 0 1 300 300 Tm Q (After) Tj ET", "After", [72.0, 700.0]), + // `T*` moves from the restored line, not the one set inside q/Q. + ("BT /F1 12 Tf 14 TL 72 700 Td q 0 -100 Td Q T* (Next) Tj ET", "Next", [72.0, 686.0]), + ] { + let mut doc = text_page(content); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!((lines[0].text.as_str(), lines[0].baseline()), (text, baseline), "{content}"); + text::replace_line(&mut doc, 0, 0, "Edited").unwrap(); + let doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!((lines[0].text.as_str(), lines[0].baseline()), ("Edited", baseline), "{content} after editing"); + } +} + +/// Whole-paragraph alignment retains both soft wraps and typed hard breaks after save/reopen. +/// Selected-row alignment for existing PDF content is withheld until it can persist safely. +#[test] +fn wrapped_rows_keep_whole_paragraph_alignment_and_hard_break_markers() { + let mut doc = text_page("BT /F1 10 Tf 12 TL 72 700 Td (Old) Tj ET"); + let style = text::BlockStyle { width: Some(60.0), align: Some(added::Align::Left), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some("One two three four five six\nTail"), &style).unwrap(); + let doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(lines.len() > 2, "the first logical row must wrap"); + let tail = lines.iter().find(|line| line.text == "Tail").unwrap(); + assert!((tail.rect[0] - 72.0).abs() < 1.0, "the second logical row keeps the paragraph alignment: {:?}", tail.rect); + for line in lines.iter().filter(|line| line.text != "Tail") { + assert!((line.rect[0] - 72.0).abs() < 1.0, "soft-wrapped rows stay left-aligned: {:?}", line.rect); + } + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("/PCSoft") && content.contains("/PCHard"), "both break kinds survive: {content}"); +} + +#[test] +fn hostile_soft_wrap_counts_fail_before_reflow_allocates() { + for marker in [ + "/PCSoft << /N 9223372036854775807 >> BDC EMC", + "/PCSoft << /N 16777217 >> BDC EMC", + "/PCSoft << /N 8388608 >> BDC EMC /PCSoft << /N 8388609 >> BDC EMC", + ] { + let doc = text_page(&format!("BT /F1 12 Tf 20 700 Td (First) Tj {marker} 0 -14 Td (Second) Tj ET")); + let error = text::text_lines(&doc, 0).unwrap_err(); + assert!(error.to_string().contains("too large"), "{marker}: {error}"); + } +} + +#[test] +fn ordinary_soft_wrap_counts_keep_their_spaces() { + for gap in [0, 1, 2, 8] { + let doc = text_page(&format!("BT /F1 12 Tf 20 700 Td (First) Tj /PCSoft << /N {gap} >> BDC EMC 0 -14 Td (Second) Tj ET")); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 2); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].reflow_text, format!("First{}Second", " ".repeat(gap))); + } +} + +#[test] +fn styled_existing_justification_is_refused_instead_of_silently_left_aligning() { + let mut doc = mixed_region_page(); + let before = page_content_bytes(&doc, 0); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let style = text::BlockStyle { align: Some(added::Align::Justify), ..Default::default() }; + let error = text::rewrite_block(&mut doc, 0, 1, Some(&blocks[1].editor_text), &style).unwrap_err(); + assert!(error.to_string().contains("justification")); + 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 eb45209353..0599b8ed38 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -24,7 +24,8 @@ use crate::EditError; mod regions; #[path = "text_session.rs"] mod session; -use regions::{group_blocks, is_centred, rewrite_styled_block}; +pub use fallback::strip_subset_tag; +use regions::{alignment_frame, group_blocks, is_centred, rewrite_styled_block}; pub use session::TextSession; mod blocks; @@ -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}; @@ -118,6 +119,14 @@ pub struct TextLine { /// `ink`, and whether joining put a space before it. Empty when the line's text was put /// into another order (see [`TextLine::runs`]). runs: Vec<(String, usize, bool)>, + /// This line continues the previous one because a rewrite wrapped it (`/PCSoft`). + soft_break: bool, + /// This line starts after a break a rewrite kept on purpose (`/PCHard`). + hard_break: bool, + /// When `soft_break`: the exact number of separator characters the wrap consumed (0 for a + /// Chinese/Japanese word split), from `/PCSoft`'s `/N`; 1 when the marker carried none + /// (#2529). Meaningless otherwise. + soft_gap: usize, } impl TextLine { @@ -154,6 +163,7 @@ mod tests { lines: Vec::new(), rows: Vec::new(), editor_text: text.into(), + reflow_text: text.into(), spans: Vec::new(), invisible: false, } @@ -204,40 +214,62 @@ mod tests { assert!(!same_base_font("Arial", "")); } + fn wrapped(text: &str, width: f64) -> Vec { + wrap(text, width, |s| s.chars().count() as f64).into_iter().map(|(line, _, _)| line).collect() + } + #[test] fn paragraph_wrapping_keeps_ideographic_spaces() { + assert_eq!(wrapped("A  B C", 4.0), ["A  B", "C"]); + assert_eq!(wrapped("A\u{a0}B C", 3.0), ["A B", "C"]); + assert_eq!(wrapped("A\u{2028}B C", 3.0), ["A B", "C"]); + // `wrap` treats every whitespace character, including `\r`, as a separator to keep + // (#4199); the caller turns `\r\n` and `\r` into `\n` before calling it. + assert_eq!(wrapped(" \tA B\r\nC ", 3.0), [" A", "B ", "C "]); + } + + #[test] + fn a_wrap_is_marked_soft_and_a_newline_is_not() { let width = |s: &str| s.chars().count() as f64; - assert_eq!(wrap("A  B C", 4.0, width), ["A  B", "C"]); - assert_eq!(wrap("A\u{a0}B C", 3.0, width), ["A B", "C"]); - assert_eq!(wrap("A\u{2028}B C", 3.0, width), ["A B", "C"]); + assert_eq!(wrap("Page 1", 5.0, width), [("Page".into(), false, 0), ("1".into(), true, 1)]); + assert_eq!(wrap("One\nTwo", 10.0, width), [("One".into(), false, 0), ("Two".into(), false, 0)]); + } + + /// #2529: a run of spaces that happens to fall at a wrap point is recorded exactly, not + /// assumed to be one space; a Chinese/Japanese word split, which has no space of its own, + /// is recorded as none. + #[test] + fn a_wrap_records_how_many_spaces_it_consumed() { + let width = |s: &str| s.chars().count() as f64; + assert_eq!(wrap("One Two", 5.0, width), [("One".into(), false, 0), ("Two".into(), true, 2)]); + assert_eq!(wrap("One Two", 5.0, width), [("One".into(), false, 0), ("Two".into(), true, 3)]); + assert_eq!(wrap("日本語の文章です", 3.0, width), [("日本語".into(), false, 0), ("の文章".into(), true, 0), ("です".into(), true, 0)]); } /// #4199: a run of spaces stays a run, before, between and after words; the spaces where a /// row wraps go, and each other white-space character is one space. #[test] fn paragraph_wrapping_keeps_every_space_typed() { - let width = |s: &str| s.chars().count() as f64; - assert_eq!(wrap("a b c", 20.0, width), ["a b c"]); - assert_eq!(wrap(" a b ", 20.0, width), [" a b "]); - assert_eq!(wrap("a\t\tb", 20.0, width), ["a b"]); - assert_eq!(wrap("aa bb cc", 6.0, width), ["aa bb", "cc"]); + assert_eq!(wrapped("a b c", 20.0), ["a b c"]); + assert_eq!(wrapped(" a b ", 20.0), [" a b "]); + assert_eq!(wrapped("a\t\tb", 20.0), ["a b"]); + assert_eq!(wrapped("aa bb cc", 6.0), ["aa bb", "cc"]); // An indent alone on a row doesn't wrap before its first word. - assert_eq!(wrap(" abc", 3.0, width), [" abc"]); + assert_eq!(wrapped(" abc", 3.0), [" abc"]); // The rewrite turns `\r\n` and `\r` into `\n` before wrapping. - assert_eq!(wrap(" \tA B\nC ", 3.0, width), [" A", "B", "C "]); - assert_eq!(wrap(" ", 3.0, width), [" "]); - assert_eq!(wrap("", 3.0, width), [""]); + assert_eq!(wrapped(" \tA B\nC ", 3.0), [" A", "B", "C "]); + assert_eq!(wrapped(" ", 3.0), [" "]); + assert_eq!(wrapped("", 3.0), [""]); } #[test] fn japanese_text_wraps_between_characters_but_not_before_closing_marks() { - let width = |s: &str| s.chars().count() as f64; - assert_eq!(wrap("日本語の文章です。", 3.0, width), ["日本語", "の文章", "です。"]); + assert_eq!(wrapped("日本語の文章です。", 3.0), ["日本語", "の文章", "です。"]); // The comma can't begin a row, so the character before it moves down with it. - assert_eq!(wrap("あいう、えお", 3.0, width), ["あい", "う、え", "お"]); + assert_eq!(wrapped("あいう、えお", 3.0), ["あい", "う、え", "お"]); // Pieces of one word join without a space; separate words keep theirs. - assert_eq!(wrap("Edit PDFを編集", 6.0, width), ["Edit", "PDFを編集"]); - assert_eq!(wrap("一行目 二行目", 4.0, width), ["一行目 ", "二行目"]); + assert_eq!(wrapped("Edit PDFを編集", 6.0), ["Edit", "PDFを編集"]); + assert_eq!(wrapped("一行目 二行目", 4.0), ["一行目 ", "二行目"]); } #[test] diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index 615e7699a2..9ddf4db4b1 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -25,6 +25,9 @@ pub struct TextBlock { pub rows: Vec>, /// Text for the inline editor, with a newline only between visual rows. pub editor_text: String, + /// What a width change reflows. A wrap rejoins with a space; a break that was typed, or a + /// short line that still had room for the next word, stays a newline. + pub reflow_text: String, /// Byte ranges in `editor_text` and their source line indexes. pub spans: Vec<(std::ops::Range, usize)>, /// Every line is invisible text (rendering mode 3): an OCR layer over a scanned image, whose @@ -337,11 +340,18 @@ pub(crate) fn paragraph_separator(c: char) -> bool { /// new line. A row may also end inside a word of Chinese or Japanese text ([`crate::linebreak`]); /// those pieces join without a space. Every white-space character is written as one space, so /// a run of spaces typed between, before or after words stays (#4199); only the spaces where a -/// row wraps are dropped. -pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec { +/// row wraps are dropped (recorded as the third value, so a later rejoin can restore the exact +/// count instead of guessing one, #2529). The bool is true when the line continues the previous +/// one because the next word did not fit, rather than because of a newline; the number is how +/// many separator characters stood where that wrap fell (0 for a Chinese/Japanese word split, +/// which has no space of its own; meaningless when the bool is false). +pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<(String, bool, usize)> { let mut out = Vec::new(); for row in text.split('\n') { let mut line = String::new(); + let mut soft = false; + // The gap consumed right before `line` started: 0 until a wrap sets it. + let mut wrap_gap = 0usize; let start = out.len(); // The separators read since the last word: each one boundary between `split` pieces. let mut gap = 0usize; @@ -352,12 +362,17 @@ pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec if word.is_empty() { continue; } - let spaces = " ".repeat(std::mem::take(&mut gap)); + let gap_here = std::mem::take(&mut gap); + let spaces = " ".repeat(gap_here); for (i, piece) in crate::linebreak::pieces(word).into_iter().enumerate() { let candidate = if i == 0 { format!("{line}{spaces}{piece}") } else { format!("{line}{piece}") }; // A row holding only the spaces that indent it doesn't wrap before its first word. if line.chars().any(|c| c != ' ') && advance(&candidate) > width { - out.push(std::mem::take(&mut line)); + out.push((std::mem::take(&mut line), soft, wrap_gap)); + soft = true; + // A mid-word piece (i > 0) had no separator of its own; the first piece of a + // word had exactly the separators just taken. + wrap_gap = if i == 0 { gap_here } else { 0 }; line = piece.to_string(); } else { line = candidate; @@ -366,7 +381,7 @@ pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec } line.push_str(&" ".repeat(gap)); if !line.is_empty() || out.len() == start { - out.push(line); + out.push((line, soft, wrap_gap)); } } out @@ -395,6 +410,9 @@ pub struct BlockStyle { /// Fill colour (RGB 0–1). pub color: Option<[f64; 3]>, pub align: Option, + /// Rows (the text's lines between `\n`s) aligned other than the paragraph, by index; `None` + /// and rows past the end follow `align` ([`crate::paragraph_align`]). + pub aligns: Vec>, /// Draw a line under each line of text. pub underline: Option, /// Line spacing as a multiple of the font size (1.2 is ordinary). 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/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index e4248aedea..2b55e51ef8 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -301,6 +301,14 @@ pub(super) struct Shown { pub(super) paint: Paint, pub(super) logical_order: bool, pub(super) actual_text: bool, + /// Set when a `/PCSoft` or `/PCHard` marker was seen since the previous text line. + pub(super) soft_break: bool, + pub(super) hard_break: bool, + /// The `/N` a `/PCSoft` marker carried: how many separator characters the wrap consumed (0 + /// for a Chinese/Japanese word split). Meaningless unless `soft_break` is set; 1 for a + /// `/PCSoft` with no `/N` (a file saved before this was tracked, or by another writer), + /// matching the single space a rejoin always used before #2529. + pub(super) soft_gap: usize, } impl Shown { @@ -425,7 +433,10 @@ fn positioned_tracking(shown: &mut [Shown]) { /// stream), an unbalanced `q`, the font and the colour still apply in the streams after it. pub(super) struct Carry { pub(super) ts: Ts, - pub(super) stack: Vec, + /// Each saved state with the text and text line matrices: inside a text object `q`/`Q` + /// save and restore them too (ISO 32000-2 §9.4.1, as corrected by the PDF Association's + /// errata, issue 368), as the renderer does. + pub(super) stack: Vec<(Ts, Matrix, Matrix)>, pub(super) marked: Vec>, /// For each open marked-content sequence, its optional content group when it is `/OC`. pub(super) layers: Vec>>, @@ -546,6 +557,9 @@ struct TextRunState<'a> { marked: &'a MarkedContent, carry: &'a mut Carry, out: &'a mut Vec, + soft_break: bool, + hard_break: bool, + soft_gap: usize, } fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), EditError> { @@ -742,6 +756,9 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit paint: Paint { layers: marked.layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() }, logical_order: false, actual_text: false, + soft_break: state.soft_break, + hard_break: state.hard_break, + soft_gap: state.soft_gap, }); Ok(()) } @@ -761,25 +778,53 @@ pub(super) fn interpret( let mut out: Vec = Vec::new(); let mut ts = carry.ts.clone(); let mut at_bt = (0usize, ts.clone()); - let mut stack: Vec = std::mem::take(&mut carry.stack); + let mut stack: Vec<(Ts, Matrix, Matrix)> = std::mem::take(&mut carry.stack); let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); let mut bt = 0usize; let mut marked = MarkedContent { regions: std::mem::take(&mut carry.marked), layers: std::mem::take(&mut carry.layers), ..MarkedContent::default() }; + // A rewrite names a wrap (`/PCSoft`) or a break it kept (`/PCHard`) in an empty marker + // before the next line. Neither name is a paragraph region, so it does not change scope. + let mut soft_break = false; + let mut hard_break = false; + // `/PCSoft`'s `/N` property: how many separator characters the wrap consumed. A `/PCSoft` + // with no properties dict (a file saved before this was tracked, or by another writer) reads + // as 1, the single space a rejoin always used before #2529. + let mut soft_gap = 1usize; for (i, op) in ops.iter().enumerate() { match op.op.as_slice() { // No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing // sequence (#3573). Both stacks stay bounded by the number of operators. b"BMC" | b"BDC" => { + if op.name(0) == Some(b"PCSoft") { + soft_break = true; + hard_break = false; + soft_gap = op + .operands + .get(1) + .and_then(Object::as_dict) + .and_then(|d| d.get(b"N")) + .and_then(Object::as_int) + .and_then(|n| usize::try_from(n).ok()) + .unwrap_or(1); + // The marker is untrusted and later expands into spaces during reflow. + // Charge it to the same page-wide budget as glyph text, so many individually + // small markers cannot multiply the allocation past the limit either. + carry.text_left = + carry.text_left.checked_sub(soft_gap).ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?; + } else if op.name(0) == Some(b"PCHard") { + hard_break = true; + soft_break = false; + } marked.begin(doc, op, i, logical_text, out.len()); } b"EMC" => { marked.end(i, &mut out); } - b"q" => stack.push(ts.clone()), + b"q" => stack.push((ts.clone(), tm, tlm)), b"Q" => { - if let Some(s) = stack.pop() { - ts = s; + if let Some(saved) = stack.pop() { + (ts, tm, tlm) = saved; } } b"cm" => { @@ -875,8 +920,28 @@ pub(super) fn interpret( tm = tlm; } b"Tj" | b"TJ" | b"'" | b"\"" => { - let mut state = TextRunState { ts: &mut ts, tm: &mut tm, tlm: &mut tlm, at_bt: &at_bt, bt, marked: &marked, carry, out: &mut out }; + let before = out.len(); + let mut state = TextRunState { + ts: &mut ts, + tm: &mut tm, + tlm: &mut tlm, + at_bt: &at_bt, + bt, + marked: &marked, + carry, + out: &mut out, + soft_break, + hard_break, + soft_gap, + }; show_text(op, i, &mut state)?; + // A numeric-only TJ adjustment shows no glyphs and pushes nothing: the break + // belongs to whichever text-showing operator comes next. + if out.len() > before { + soft_break = false; + hard_break = false; + soft_gap = 1; + } } _ => {} } diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index c48522984d..f811f34ea6 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -280,6 +280,9 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> clips: s.state.render_mode >= 4, unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned), runs: vec![(s.text.clone(), s.ink.len(), false)], + soft_break: s.soft_break, + hard_break: s.hard_break, + soft_gap: s.soft_gap, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -394,6 +397,9 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream clips: s.state.render_mode >= 4, unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned), runs: vec![(s.text.clone(), s.ink.len(), false)], + soft_break: s.soft_break, + hard_break: s.hard_break, + soft_gap: s.soft_gap, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 513077a027..ec2fac984c 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -6,8 +6,8 @@ use super::fallback::{ use super::interp::{Carry, Origin, TextState, interpret}; use super::lines::{content_streams, page_dict, rewritten_contents, splice, text_lines}; use super::{ - Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, Pieces, TextLine, TextSession, group_blocks, is_centred, parse, - rewrite_styled_block, + Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, Pieces, TextLine, TextSession, alignment_frame, group_blocks, + is_centred, parse, rewrite_styled_block, }; /// What replacing a line did. @@ -501,7 +501,6 @@ fn underline_ops(fill: &[Op], tm: &[f64; 6], size: f64, underlines: &[(f64, f64, /// Alignment: each line's offset from the left edge. struct Alignment { - align: Option, width: f64, k: f64, anchored_right: bool, @@ -510,10 +509,11 @@ struct Alignment { } impl Alignment { - /// `visible` is the width the line is aligned on, in user space; the offset is in text space. - fn offset(&self, visible: f64) -> f64 { + /// `visible` is the width the line is aligned on, in user space, and `align` its row's + /// alignment; the offset is in text space. + fn offset(&self, visible: f64, align: Option) -> f64 { let free = (self.width - visible).max(0.0) / self.k; - match self.align { + match align { Some(crate::added::Align::Center) => free / 2.0, // A right-to-left line anchored at its right edge may start left of the old box. Some(crate::added::Align::Right) if self.anchored_right => (self.right_edge - visible) / self.k, @@ -531,7 +531,9 @@ struct LineWriter<'a> { alignment: Alignment, encode: &'a Encoder, source: Option<&'a Metrics>, - justify: bool, + /// The paragraph's alignment, and the rows aligned on their own ([`BlockStyle::aligns`]). + align: Option, + aligns: &'a [Option], /// Whether `Tw` reaches every shown space. word_spacing_reaches_spaces: bool, width: f64, @@ -551,13 +553,18 @@ struct LineCursor { } impl LineWriter<'_> { + /// Row `row`'s alignment: its own, or the paragraph's. + fn align_of(&self, row: usize) -> Option { + self.aligns.get(row).copied().flatten().or(self.align) + } + /// Justify: each line but the last fills the width. A stretched line shows only its words, as /// before #4199: word spacing would reach the spaces before its first word and after its /// last, so they are left out. Nothing to stretch over, or a paragraph drawn at `0 Tz`, isn't /// justified. - fn stretch(&self, body: &str, last: bool) -> f64 { + fn stretch(&self, body: &str, last: bool, justify: bool) -> f64 { let interior = body.matches(' ').count(); - if self.justify && !last && interior > 0 && self.scale.abs() > 1e-6 { + if justify && !last && interior > 0 && self.scale.abs() > 1e-6 { Some((self.width - self.advance.of(body)).max(0.0) / self.k / self.scale / interior as f64).filter(|tw| tw.is_finite()).unwrap_or(0.0) } else { 0.0 @@ -578,14 +585,41 @@ impl LineWriter<'_> { } } - /// Writes one wrapped line, returning its underline (x0, x1, y) in text space. - fn write_line(&self, index: usize, line: &str, last: bool, cursor: &mut LineCursor, ops: &mut Vec) -> Result<(f64, f64, f64), EditError> { + /// Writes one wrapped line of its logical draft row, returning its underline (x0, x1, y) in text space. `wrap`: whether + /// this line continues the previous one because a word did not fit, and, when it does, how + /// many separator characters the wrap consumed (0 for a Chinese/Japanese word split). + fn write_line( + &self, + index: usize, + line: &str, + wrap: (usize, bool, usize), + last: bool, + cursor: &mut LineCursor, + ops: &mut Vec, + ) -> Result<(f64, f64, f64), EditError> { let n = pdfcraft_content::num; + let (row, soft, gap) = wrap; + let line_align = self.align_of(row); let body = line.trim_matches(' '); let interior = body.matches(' ').count(); - let tw = self.stretch(body, last); + let tw = self.stretch(body, last, line_align == Some(crate::added::Align::Justify)); let (shown, visible) = self.shown(line, body, tw); - let dx = if tw > 0.0 { 0.0 } else { self.alignment.offset(visible) }; + let dx = if tw > 0.0 { 0.0 } else { self.alignment.offset(visible, line_align) }; + if index > 0 { + // Inside this text object, so the marker starts and ends here. `/PCHard` is a newline + // that was already in the text. `/PCSoft` rejoins when the box widens, with `/N` the + // exact number of separator characters the wrap consumed (0 for a Chinese/Japanese + // word split), so a rejoin restores what was actually there instead of guessing a + // single space and silently dropping the rest of a typed run (#2529). + if soft { + let mut properties = Dict::new(); + properties.set(b"N".to_vec(), Object::Int(i64::try_from(gap).unwrap_or(i64::MAX))); + ops.push(Op::new("BDC", vec![Object::name("PCSoft"), Object::Dict(properties)])); + } else { + ops.push(Op::new("BMC", vec![Object::name("PCHard")])); + } + ops.push(Op::new("EMC", vec![])); + } if index > 0 || dx != 0.0 { ops.push(Op::new("Td", vec![n(dx - cursor.x), n(if index > 0 { -self.lead } else { 0.0 })])); } @@ -633,8 +667,18 @@ impl LineWriter<'_> { } /// Writes every wrapped line, returning their underlines. - fn write_all(&self, wrapped: &[String], cursor: &mut LineCursor, ops: &mut Vec) -> Result, EditError> { - wrapped.iter().enumerate().map(|(i, line)| self.write_line(i, line, i + 1 == wrapped.len(), cursor, ops)).collect() + fn write_all(&self, wrapped: &[(String, bool, usize)], cursor: &mut LineCursor, ops: &mut Vec) -> Result, EditError> { + let mut row = 0usize; + wrapped + .iter() + .enumerate() + .map(|(i, (line, soft, gap))| { + if i > 0 && !soft { + row = row.saturating_add(1); + } + self.write_line(i, line, (row, *soft, *gap), i + 1 == wrapped.len(), cursor, ops) + }) + .collect() } } @@ -653,7 +697,7 @@ struct BlockPlan<'a> { underline: bool, } -fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[String], show_font: String) -> Result, EditError> { +fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[(String, bool, usize)], show_font: String) -> Result, EditError> { let n = pdfcraft_content::num; let mut ops = vec![Op::new("q", vec![])]; ops.extend(reveal_ops(plan.block, plan.lines, plan.style, plan.emit.ctm)?); @@ -747,6 +791,23 @@ pub(crate) fn rewrite_block_from( ) -> Result { let blocks = group_blocks(&lines); let b = blocks.get(block).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?; + // Existing PDF paragraphs do not yet persist selected-row alignment intent. Refuse + // that operation before writing anything; whole-paragraph `align` remains supported. + if style.aligns.iter().any(Option::is_some) { + return Err(EditError::Invalid( + "selected-row alignment of existing paragraphs is not supported until it can be preserved across editing sessions".into(), + )); + } + // An alignment for a one-row paragraph is laid out across the column it sits in, as a + // paragraph of that width moved to its left edge. + let framed; + let style = match alignment_frame(&lines, &blocks, &b, style, text.unwrap_or(&b.editor_text)) { + Some([left, right]) => { + framed = BlockStyle { width: Some(right - left), offset: Some([left - b.rect[0], 0.0]), ..style.clone() }; + &framed + } + None => style, + }; let members: Vec<&TextLine> = b.lines.iter().map(|i| &lines[*i]).collect(); // One clipping run is enough: rewriting the paragraph would drop its mask with the rest. let mut dropped = coincident_ops(&lines, &members); @@ -758,6 +819,9 @@ pub(crate) fn rewrite_block_from( let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); let dispatch = choose_rewrite_path(doc, &fonts_res, &b, &members, first, text, style)?; + if dispatch.use_styled_path && style.align == Some(crate::added::Align::Justify) { + return Err(EditError::Invalid("justification of mixed-style existing paragraphs is not supported".into())); + } if dispatch.use_styled_path { return rewrite_styled_block(doc, page, &b, &lines, text, style, max_glyphs); } @@ -852,10 +916,11 @@ pub(crate) fn rewrite_block_from( }; let writer = LineWriter { advance: &advance, - alignment: Alignment { align, width, k, anchored_right, right_edge, rtl }, + alignment: Alignment { width, k, anchored_right, right_edge, rtl }, encode: &font.encode, source: source_metrics, - justify: align == Some(crate::added::Align::Justify), + align, + aligns: &style.aligns, word_spacing_reaches_spaces, width, scale: o_state.scale, diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index be5b4dffa6..83046bfbf7 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -12,5 +12,5 @@ mod grouping; #[path = "text_regions/styled.rs"] mod styled; -pub(super) use grouping::{group_blocks, group_reading_blocks, is_centred}; +pub(super) use grouping::{alignment_frame, group_blocks, group_reading_blocks, is_centred}; pub(super) use styled::rewrite_styled_block; diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index b8a5abca1e..788499fafe 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -1,4 +1,4 @@ -use super::{HashMap, Matrix, TextBlock, TextLine, paragraph_separator, same_base_font, same_font, strip_subset_tag, union}; +use super::{BlockStyle, HashMap, Matrix, TextBlock, TextLine, paragraph_separator, same_base_font, same_font, strip_subset_tag, union}; fn same_plane(a: &TextLine, b: &TextLine) -> bool { let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm)); @@ -42,6 +42,54 @@ fn neighbour_left(lines: &[TextLine], block: &TextBlock) -> Option { Some(best as f64 / 4.0) } +/// Where an alignment picked for a one-row paragraph is measured, as `[left, right]`: the column of +/// text it sits in (an overlapping paragraph's left edge, to the furthest right its rows reach) +/// when that column holds it, otherwise its own box. Of the columns that hold it, the narrowest is +/// its own, the nearest on a tie: a heading across the whole page, even one closer than the body, +/// is wider than the column the title sits in, and gives it no frame while a narrower one does. A lone row's own box is no frame +/// to align in: left-aligning a title centred on the page left it where it was, and since a single +/// row grows to the page's margin, centring it again moved it right each time. `None` when no +/// alignment is asked for, when the paragraph is several rows, turned, or given a width or a move of +/// its own, or when its `draft` text changes and no column holds it. +pub(crate) fn alignment_frame(lines: &[TextLine], blocks: &[TextBlock], block: &TextBlock, style: &BlockStyle, draft: &str) -> Option<[f64; 2]> { + // Every row of the draft is aligned, by its own alignment or the paragraph's: moving the + // paragraph to its column's edge would move a row that has none. Entries past the draft's + // rows name no row. + let asked = (0..crate::paragraph_align::count(draft)).all(|row| style.aligns.get(row).copied().flatten().or(style.align).is_some()); + if !asked || style.width.is_some() || style.offset.is_some() || block.rows.len() != 1 || block.rotation.abs() > 0.01 { + return None; + } + let own = [block.rect[0], block.rect[2]]; + let tolerance = block.size.max(1.0) * 0.5; + let gap = |other: &TextBlock| (other.rect[1] - block.rect[3]).max(block.rect[1] - other.rect[3]).max(0.0); + // The column an overlapping paragraph makes, when it holds the row. + let column = |other: &TextBlock| -> Option<[f64; 2]> { + let left = other.rect[0]; + // Whole rows, not their runs: a row of several styles reaches its last run's right edge. + let right = other + .rows + .iter() + .filter_map(|row| row_rect(row, lines)) + .filter(|row| (row[0] - left).abs() < tolerance) + .map(|row| row[2]) + .fold(f64::NEG_INFINITY, f64::max); + (right.is_finite() && right - left > own[1] - own[0] && own[0] >= left - tolerance && own[1] <= right + tolerance).then_some([left, right]) + }; + let frame = blocks + .iter() + .filter(|other| other.lines.iter().all(|i| !block.lines.contains(i)) && other.rect[2] > own[0] && other.rect[0] < own[1]) + .filter_map(|other| column(other).map(|frame| (frame, gap(other)))) + .min_by(|(a, near_a), (b, near_b)| (a[1] - a[0]).total_cmp(&(b[1] - b[0])).then(near_a.total_cmp(near_b))) + .map(|(frame, _)| frame); + // The row's own box, with no column to align in, is no width for new text to wrap at: edited, + // the row keeps the width it would have had without the alignment. + match frame { + Some(frame) => Some(frame), + None if draft != block.editor_text => None, + None => Some(own), + } +} + /// Whether a block reads as centred, and so must keep its centre when it grows or wraps. /// /// Several ragged rows whose left edges disagree while their centres agree are centred. A one row @@ -227,14 +275,15 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { } } let mut blocks: Vec = Vec::new(); - for row in rows { + for row_at in 0..rows.len() { + let Some(row) = rows.get(row_at) else { continue }; let Some(first) = row.first().and_then(|i| lines.get(*i)) else { continue }; - let (Some(rect), Some(frame)) = (row_rect(&row, lines), frame_row_rect(&row, lines)) else { continue }; + let (Some(rect), Some(frame)) = (row_rect(row, lines), frame_row_rect(row, lines)) else { continue }; let joins = blocks.last().is_some_and(|block| { // A bullet of its own opens a list item (#2564): such a row is a new paragraph, and // letting it join is what made an edit of the row above delete this item and the ones // after it. - if starts_list_item(&row, lines) { + if starts_list_item(row, lines) { return false; } let Some(prev_row) = block.rows.last() else { return false }; @@ -253,6 +302,36 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { || ((pr[0] + pr[2] - frame[0] - frame[2]) / 2.0).abs() < tol || (pr[2] - frame[2]).abs() < tol || hanging; + // Once a paragraph has two rows, its own leading is the measured baseline gap. A + // blank line is about twice that, so a wider gap is the next paragraph (#2517). A + // single row uses the text object's TL when it has one: a gap equal to that TL + // joins, and a gap of twice the TL does not. `Td` does not set TL (`TD` does). A + // step that repeats on the following row is that leading; one large gap, with no + // second step to compare, stays a break. The old cap of 2.5 × the size joined the + // blank line too. + let own_leading = block.rows.len().checked_sub(2).and_then(|i| { + let above = block.rows.get(i)?.first().and_then(|n| lines.get(*n)); + let below = prev_row.first().and_then(|n| lines.get(*n)); + match (above, below) { + (Some(above), Some(below)) => Some((above.frame_origin().1 - below.frame_origin().1).abs()), + _ => None, + } + }); + let stated = (prev.origin.state.leading > 0.0).then(|| prev.wrap_leading()).filter(|lead| lead.is_finite() && *lead > tol * 0.4); + // The next row is not in this block yet. Its gap is only a leading when it matches + // this one: a blank line is a single wider gap and has no matching step after it. + let repeated = rows.get(row_at.saturating_add(1)).and_then(|next| { + let nfirst = next.first().and_then(|i| lines.get(*i))?; + let nframe = frame_row_rect(next, lines)?; + let ngap = first.frame_origin().1 - nfirst.frame_origin().1; + let same = same_plane(first, nfirst) + && (mixed || same_style(first, nfirst)) + && first.size.min(nfirst.size) >= first.size.max(nfirst.size) * 0.4 + && (nframe[0] - frame[0]).abs() < tol; + let scale = gap.abs().max(ngap.abs()); + if same && ngap > tol * 0.8 && scale < tol * 4.0 && (ngap - gap).abs() <= scale * 0.08 + 0.5 { Some(gap) } else { None } + }); + let max_gap = own_leading.filter(|lead| *lead > tol * 0.4).or(stated).or(repeated).map_or(tol * 2.05, |lead| lead * 1.5); // OCR lines stay apart: the scan fixes where each one is, so they never rewrap together. (!mixed || !(invisible(prev) || invisible(first))) && (mixed || same_style(prev, first)) @@ -262,12 +341,13 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { && aligned && first.size.min(prev.size) >= first.size.max(prev.size) * 0.4 && gap > first.size.min(prev.size) * 0.8 - && gap < first.size.max(prev.size) * 2.5 + && gap < max_gap }); if !joins { blocks.push(TextBlock { text: String::new(), editor_text: String::new(), + reflow_text: String::new(), spans: Vec::new(), rect, base_font: first.base_font.clone(), @@ -286,7 +366,7 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { if !block.rows.is_empty() { block.editor_text.push('\n'); } - for &index in &row { + for &index in row { let Some(line) = lines.get(index) else { continue }; // Some producers position separate words without encoding a space. if let Some(prev) = block.lines.last().filter(|i| row.contains(i)).and_then(|i| lines.get(*i)) @@ -313,7 +393,62 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { } block.text.push_str(row_text.trim_matches(paragraph_separator)); block.rect = union(block.rect, rect); - block.rows.push(row); + block.rows.push(row.clone()); + } + for block in &mut blocks { + block.reflow_text = reflow_text(block, lines); } blocks } + +/// The text a width change should reflow. A `/PCSoft` line is a wrap and rejoins. A `/PCHard` +/// line, or a short line that is not marked, keeps its newline. An unmarked line that reaches +/// the paragraph's right edge and is wide enough to be a measure rejoins too: that is how a +/// file written elsewhere wraps a body line. +fn reflow_text(block: &TextBlock, lines: &[TextLine]) -> String { + let parts: Vec<&str> = block.editor_text.split('\n').collect(); + if parts.len() != block.rows.len() { + return block.editor_text.clone(); + } + let mut out = String::new(); + for (index, part) in parts.iter().enumerate() { + if index > 0 { + match break_gap(block, lines, index) { + Some(_) if out.ends_with('-') => { + out.pop(); + } + // `gap` is the exact number of separator characters the wrap consumed (0 for a + // Chinese/Japanese word split, which has no space of its own) when the marker or + // the source recorded it; a line already ending in whitespace is left alone so a + // row whose own text kept a trailing space isn't doubled. + Some(gap) if !out.ends_with(char::is_whitespace) => out.push_str(&" ".repeat(gap)), + Some(_) => {} + None => out.push('\n'), + } + } + out.push_str(part); + } + out +} + +/// `None`: a hard break, kept as a newline. `Some(gap)`: a soft wrap, rejoined with exactly `gap` +/// spaces — the count a `/PCSoft` marker recorded ([`TextLine::soft_gap`]), or, for a line this +/// app did not mark (a file written elsewhere, or an older save), a single space when the +/// geometry looks like an ordinary wrapped body line (`#2529`). +fn break_gap(block: &TextBlock, lines: &[TextLine], row_index: usize) -> Option { + let line = block.rows.get(row_index).and_then(|row| row.first()).and_then(|i| lines.get(*i))?; + if line.hard_break { + return None; + } + if line.soft_break { + return Some(line.soft_gap); + } + let prev = block.rows.get(row_index.wrapping_sub(1)).and_then(|row| row_rect(row, lines))?; + let next = block.rows.get(row_index).and_then(|row| row_rect(row, lines))?; + let size = line.size.max(1.0); + let slack = block.rect[2] - prev[2]; + let width = prev[2] - prev[0]; + // Short rows define the right edge by their own letters. A body line that fills the + // measure leaves almost no room for the next word. + (slack <= size * 0.75 && width >= size * 8.0 && (next[2] - next[0]) + size * 0.25 > slack).then_some(1) +} diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index e8b3b683d0..885a0a7158 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -401,13 +401,26 @@ fn paragraph_geometry(doc: &Document, page: &pdfcraft_model::Page, block: &TextB Ok((dx, dy, style.width.unwrap_or(natural).clamp(1.0, 14_400.0))) } +type WrappedStyledRows = (StyledRows, RowRanges, Vec, Vec, Vec); + fn wrap_styled_rows( chars: &[StyledChar], width: f64, measure: impl Fn(&[StyledChar]) -> Result, -) -> Result<(StyledRows, RowRanges), EditError> { +) -> Result { let mut rows: Vec> = Vec::new(); let mut row_ranges = Vec::new(); + // True when this row starts because the previous word did not fit, not because of a newline. + let mut row_soft = Vec::new(); + let mut next_soft = false; + // How many separator characters a wrap trimmed off the row that just ended, for the row + // about to start (0 for a Chinese/Japanese word split, which trims none; meaningless unless + // the row it belongs to is soft). Mirrors `next_soft`. + let mut row_gap = Vec::new(); + let mut next_gap = 0usize; + // Logical draft rows drive per-paragraph alignment independently of soft wraps. + let mut row_lines: Vec = Vec::new(); + let mut draft_line = 0usize; let mut row_start = 0; let mut row = Vec::new(); let mut row_width = 0.0; @@ -419,7 +432,13 @@ fn wrap_styled_rows( continue; } if item.0 == '\n' { + row_soft.push(next_soft); + next_soft = false; + row_gap.push(next_gap); + next_gap = 0; row_ranges.push(row_start..row_start + row.len()); + row_lines.push(draft_line); + draft_line = draft_line.saturating_add(1); rows.push(std::mem::take(&mut row)); row_width = 0.0; at += 1; @@ -439,10 +458,17 @@ fn wrap_styled_rows( let word = chars.get(at..end).unwrap_or_default(); let word_width = measure(word)?; if !row.is_empty() && !item.0.is_whitespace() && row_width + word_width > width + 0.5 { + let mut trimmed = 0usize; while row.last().is_some_and(|c: &StyledChar| c.0.is_whitespace()) { row.pop(); + trimmed += 1; } + row_soft.push(next_soft); + next_soft = true; + row_gap.push(next_gap); + next_gap = trimmed; row_ranges.push(row_start..row_start + row.len()); + row_lines.push(draft_line); rows.push(std::mem::take(&mut row)); row_width = 0.0; row_start = at; @@ -452,10 +478,13 @@ fn wrap_styled_rows( at = end; } if !row.is_empty() { + row_soft.push(next_soft); + row_gap.push(next_gap); row_ranges.push(row_start..row_start + row.len()); + row_lines.push(draft_line); rows.push(row); } - Ok((rows, row_ranges)) + Ok((rows, row_ranges, row_lines, row_soft, row_gap)) } fn apply_range_styles<'a>( @@ -609,6 +638,92 @@ fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range .collect() } +/// Operator index of the paragraph's first text object, and of the next line's text object +/// when one follows. +fn paragraph_span(block: &TextBlock, lines: &[TextLine]) -> Option<(usize, Option)> { + let belongs: std::collections::HashSet = block.lines.iter().copied().collect(); + let start = block.lines.iter().filter_map(|i| lines.get(*i)).map(|line| line.origin.bt_op).min()?; + let end = + block.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|line| line.ops.iter().copied().chain(std::iter::once(line.origin.bt_op))).max()?; + let next = lines + .iter() + .enumerate() + .filter(|(index, _)| !belongs.contains(index)) + .filter_map(|(_, line)| (line.origin.bt_op > end).then_some(line.origin.bt_op)) + .min(); + Some((start, next)) +} + +/// The scope shared by every line of the paragraph, when each one is in the same marked region. +fn shared_scope(block: &TextBlock, lines: &[TextLine]) -> Option { + let mut scopes = block.lines.iter().filter_map(|i| lines.get(*i)).map(|line| line.scope); + let first = scopes.next().flatten()?; + scopes.all(|scope| scope == Some(first)).then_some(first) +} + +/// Where an extra `/P` marker belongs. +/// +/// The marker is omitted when the paragraph is already one marked region and the next line is +/// in a different one. Otherwise it closes after the paragraph's last `ET` (`ops.len()` means +/// append it after the stream). Closing at the next line's `BT` is too late when that line's +/// region opened with `BDC` before the `BT`: the `EMC` would end the neighbour's region. Closing +/// inside the rewritten operators leaves a kept source row outside the marker. +/// +/// A neighbour in the same text object has no `BT` of its own. Closing after the shared `ET` +/// would take that neighbour into the marker. The marker then opens after `BT` and closes +/// before the neighbour's text, so `BT` and `ET` stay matched. +fn paragraph_marker(block: &TextBlock, lines: &[TextLine], ops: &[Op]) -> Option<(usize, Option, usize)> { + let (start, next) = paragraph_span(block, lines)?; + if let Some(region) = shared_scope(block, lines) + && let Some(at) = next + { + let next_scope = lines.iter().find(|line| line.origin.bt_op == at).map(|line| line.scope); + if next_scope != Some(Some(region)) { + return None; + } + } + let belongs: std::collections::HashSet = block.lines.iter().copied().collect(); + let end = + block.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|line| line.ops.iter().copied().chain(std::iter::once(line.origin.bt_op))).max()?; + let after_text = ops + .iter() + .enumerate() + .skip(end.saturating_add(1)) + .find(|(_, op)| op.op.as_slice() == b"ET") + .map(|(index, _)| index.saturating_add(1)) + .unwrap_or(ops.len()); + let neighbour = lines + .iter() + .enumerate() + .filter(|(index, _)| !belongs.contains(index)) + .filter_map(|(_, line)| line.ops.iter().copied().filter(|op| *op > end).min()) + .min(); + let open = block.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|line| line.ops.iter().copied()).min()?; + if let Some(op) = neighbour.filter(|op| *op < after_text) { + // `BT` stays outside the marker. Both ends are inside the one text object. + if open > start { + return Some((open, None, op)); + } + } + // The last paragraph of a shared text object has no neighbour after it. Opening at the + // shared `BT` would take the preceding paragraph into the marker. Open at this paragraph's + // first operator instead, and close before the shared `ET`, so both ends stay inside it. + let preceding = + lines.iter().enumerate().filter(|(index, _)| !belongs.contains(index)).any(|(_, line)| line.ops.iter().any(|op| *op > start && *op < open)); + if preceding { + if let Some(et) = after_text.checked_sub(1).filter(|index| ops.get(*index).is_some_and(|op| op.op.as_slice() == b"ET")) + && open > start + && open < et + { + return Some((open, None, et)); + } + return None; + } + // `None` is the next-line index: the close is after this paragraph's `ET`, not at the + // following line's `BT`. + Some((start, None, after_text)) +} + struct RewriteAnchor<'a> { lead: &'a TextLine, drop: std::collections::HashSet, @@ -651,6 +766,78 @@ fn rewrite_anchor<'a>( RewriteAnchor { lead, drop, start, split } } +/// Open (`op` is `"BMC"`) or close (`"EMC"`) the `/P` marker that wraps the whole rewrite when +/// `marked` (every row is in this rewrite, see [`rewrite_styled_block`]), so the paragraph +/// cannot be mistaken for a neighbour by geometry alone (#2517). +fn push_whole_paragraph_marker(block_ops: &mut Vec, marked: bool, op: &str) { + if !marked { + return; + } + let operands = if op == "BMC" { vec![Object::name("P")] } else { Vec::new() }; + block_ops.push(Op::new(op, operands)); +} + +/// An empty marker between two rows of one rewrite. `/PCHard` is a newline that was already in +/// the text. `/PCSoft` is a wrap and rejoins when the box widens, with `/N` the exact number of +/// separator characters it consumed (0 for a Chinese/Japanese word split), so a rejoin restores +/// what was actually there instead of guessing a single space (#2529). Neither is a paragraph +/// region, so the lines stay in one scope. +fn push_row_break_marker(block_ops: &mut Vec, row_soft: &[bool], row_gap: &[usize], row_index: usize) { + if row_soft.get(row_index).copied().unwrap_or(false) { + let mut properties = Dict::new(); + let gap = row_gap.get(row_index).copied().unwrap_or(1); + properties.set(b"N".to_vec(), Object::Int(i64::try_from(gap).unwrap_or(i64::MAX))); + block_ops.push(Op::new("BDC", vec![Object::name("PCSoft"), Object::Dict(properties)])); + } else { + block_ops.push(Op::new("BMC", vec![Object::name("PCHard")])); + } + block_ops.push(Op::new("EMC", vec![])); +} + +/// [`splice`] with the rewritten rows spliced in at `at`, and (when `marker` names one) a `/P` +/// marker opened at its start and closed at its `next` line or, when nothing follows, after the +/// paragraph's own last `ET` (appended past the stream's last operator when that close is at or +/// beyond `ops.len()`, since `splice`'s callback never visits that index). +fn splice_with_marker( + pieces: &Pieces, + ops: &[Op], + marker: Option<(usize, Option, usize)>, + at: usize, + mut block_ops: Vec, + drop: &std::collections::HashSet, +) -> Vec> { + let data = splice(pieces, ops, |i| { + let mut insert = Vec::new(); + if let Some((start_at, next, close)) = marker { + if i == start_at { + insert.push(Op::new("BMC", vec![Object::name("P")])); + } + if next.is_none() && i == close { + insert.push(Op::new("EMC", vec![])); + } + if next == Some(i) { + insert.push(Op::new("EMC", vec![])); + } + } + if i == at { + insert.append(&mut block_ops); + } + (insert, !drop.contains(&i)) + }); + if !marker.is_some_and(|(_, next, close)| next.is_none() && close >= ops.len()) { + return data; + } + let mut data = data; + if let Some(last) = data.last_mut() { + if last.last().is_some_and(|byte| !byte.is_ascii_whitespace()) { + last.push(b'\n'); + } + // `splice` never visits `ops.len()`, so a close after the final operator is appended. + last.extend_from_slice(&pdfcraft_content::serialize_ops(&[Op::new("EMC", vec![])])); + } + data +} + /// Rewrite visual rows while keeping the source style on each surviving character. Resource /// names may differ between Unicode subsets of the same face; encoding searches those subsets. pub(crate) fn rewrite_styled_block( @@ -762,15 +949,21 @@ pub(crate) fn rewrite_styled_block( } Ok(width) }; - let (rows, row_ranges) = wrap_styled_rows(&chars, width, measure)?; + let (rows, row_ranges, row_lines, row_soft, row_gap) = wrap_styled_rows(&chars, width, measure)?; let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; // A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and - // glyph widths stay exactly as they were instead of being rebuilt from the text state. - let plain = BlockStyle { ranges: Vec::new(), ..style.clone() } == BlockStyle::default(); + // glyph widths stay exactly as they were instead of being rebuilt from the text state. A row + // aligned on its own is reached; the rows around it are not. + let plain = BlockStyle { ranges: Vec::new(), aligns: Vec::new(), ..style.clone() } == BlockStyle::default(); let mut kept = if plain { - untouched_rows(block, draft, &row_ranges).into_iter().zip(&rows).map(|(same, row)| same && row.iter().all(|c| c.1 < source_count)).collect() + untouched_rows(block, draft, &row_ranges) + .into_iter() + .zip(&rows) + .zip(&row_lines) + .map(|((same, row), line)| same && row.iter().all(|c| c.1 < source_count) && style.aligns.get(*line).copied().flatten().is_none()) + .collect() } else { vec![false; rows.len()] }; @@ -796,6 +989,12 @@ pub(crate) fn rewrite_styled_block( .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?; let (tx, ty) = back.apply(dx, dy); block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(tx), n(ty)])); + // A new row can land one leading above the next paragraph and look like the same + // paragraph. When every row is in this rewrite, a paragraph marker keeps them in one + // scope so the neighbour stays out (#2517). A row left in the source stream cannot share + // that marker, so it is omitted and geometry groups those rows as before. + let marked = kept.iter().all(|row| !*row); + push_whole_paragraph_marker(&mut block_ops, marked, "BMC"); let crop = p.crop(doc); let centered = is_centred(block, lines, (crop[0] + crop[2]) / 2.0, first.size); let source_leading = block @@ -823,11 +1022,16 @@ pub(crate) fn rewrite_styled_block( baseline -= source_leading; continue; } + if !row.is_empty() && row_index > 0 { + push_row_break_marker(&mut block_ops, &row_soft, &row_gap, row_index); + } let w = measured.get(row_index).copied().unwrap_or(0.0); - let align = style.align.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left }); + // The row's own alignment, or the paragraph's. + let asked = row_lines.get(row_index).and_then(|l| style.aligns.get(*l)).copied().flatten().or(style.align); + let align = asked.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left }); let original_rect = block.rows.get(row_index).and_then(|row| row_rect(row, lines)).unwrap_or(block.rect); - let left = if style.width.is_none() && style.align.is_none() { original_rect[0] } else { block.rect[0] }; - let available = if style.width.is_none() && style.align.is_none() { original_rect[2] - original_rect[0] } else { width }; + let left = if style.width.is_none() && asked.is_none() { original_rect[0] } else { block.rect[0] }; + let available = if style.width.is_none() && asked.is_none() { original_rect[2] - original_rect[0] } else { width }; let mut x = left + match align { crate::added::Align::Center => (available - w) / 2.0, @@ -966,6 +1170,7 @@ pub(crate) fn rewrite_styled_block( style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(anchor.size) }; } + push_whole_paragraph_marker(&mut block_ops, marked, "EMC"); block_ops.push(Op::new("Q", vec![])); block_ops.extend(after.ops()); if split { @@ -974,7 +1179,11 @@ pub(crate) fn rewrite_styled_block( block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect())); } let at = if split { start } else { o.bt_op }; - let data = splice(&pieces, &ops, |i| (if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }, !drop.contains(&i))); + // A kept source row sits outside `block_ops`. Bound the whole paragraph when a marker is + // safe. The close is not part of `block_ops`: that vector can end inside a text object + // when the rewrite splits one, and a kept row's operators come later in the stream. + let marker = if marked { None } else { paragraph_marker(block, lines, &ops) }; + let data = splice_with_marker(&pieces, &ops, marker, at, block_ops, &drop); let contents = rewritten_contents(doc, &streams, data); res.set(b"Font".to_vec(), Object::Dict(fonts)); doc.update_dict(p.obj, |d| { diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index f5e35088b5..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 } @@ -51,6 +52,10 @@ unicode-segmentation = "1.13" # Already in the graph through lopdf; used only for the local UTC offset ("today"). The workspace # entry is std-only; this target adds `clock` for `chrono::Local::now`. chrono = { workspace = true, features = ["clock"] } +getrandom = "0.4" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.4", features = ["wasm_js"] } [dev-dependencies] pdfcraft-testkit = { workspace = true } diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index 044e40e2e2..dca1e24af5 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -141,6 +141,7 @@ pub enum OfficeFormat { Pptx, Html, Rtf, + Markdown, } impl OfficeFormat { @@ -150,6 +151,7 @@ impl OfficeFormat { OfficeFormat::Pptx => "pptx", OfficeFormat::Html => "html", OfficeFormat::Rtf => "rtf", + OfficeFormat::Markdown => "md", } } @@ -159,12 +161,13 @@ impl OfficeFormat { "pptx" => Some(OfficeFormat::Pptx), "html" | "htm" => Some(OfficeFormat::Html), "rtf" => Some(OfficeFormat::Rtf), + "md" | "markdown" => Some(OfficeFormat::Markdown), _ => None, } } } -/// A Word, PowerPoint, HTML or RTF export ([`crate::Document::export_office_checked`]). +/// A Word, PowerPoint, HTML, RTF or Markdown export ([`crate::Document::export_office_checked`]). #[derive(Clone, Debug, Default, PartialEq)] pub struct OfficeExport { /// The exported file. @@ -275,14 +278,16 @@ impl crate::Document { /// [`Self::export_office`], and the pages whose text it had to leave out /// ([`OfficeExport::truncated_pages`]). pub fn export_office_checked(&self, format: OfficeFormat) -> OfficeExport { - // RTF leaves pictures out. - let (pages, truncated_pages) = self.export_pages_checked(format != OfficeFormat::Rtf, MAX_EXPORT_IMAGE_BYTES); + // RTF and Markdown leave pictures out. + let (pages, truncated_pages) = + self.export_pages_checked(!matches!(format, OfficeFormat::Rtf | OfficeFormat::Markdown), MAX_EXPORT_IMAGE_BYTES); let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string()); let bytes = match format { OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title), OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title), OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(), OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(), + OfficeFormat::Markdown => pdfcraft_export::markdown(&pages).into_bytes(), }; OfficeExport { bytes, truncated_pages } } 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/document.rs b/crates/engine/src/document.rs index 02c1bf269a..a1ade7458e 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -59,9 +59,52 @@ pub struct Document { /// XFA forms: what was approximated, rewritten or could not be written to the XFA data /// (also in `info.warnings`, kept there when the document is re-read). pub xfa_warnings: Vec, - /// What the last successful [`Session::apply`](crate::Session::apply) wrote that its fonts can't draw: kept as typed, - /// drawn with `?` in its place (see [`TextNotice`]). The interface shows it; tools return it. - pub edit_notices: Vec, + /// What the last successful [`Session::apply`](crate::Session::apply) wants said (see + /// [`EditNotice`]): text its fonts can't draw, pages whose own backdrop stays off. The + /// interface shows it; tools return it. + pub edit_notices: Vec, +} + +/// Something the last successful edit did that the user should hear about, never left silent +/// (AGENTS.md §4). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EditNotice { + /// Text stored as typed but drawn with `?`. + Undrawn(TextNotice), + /// Remove background couldn't turn back on the white backdrop these pages (0-based, + /// ascending) paint themselves: their content changed after the background replaced it, so + /// turning it on could paint over something (`pdfcraft_edit::backdrops_left_off`). They look + /// the same on white paper. + BackdropsLeftOff { pages: Vec }, +} + +impl std::fmt::Display for EditNotice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EditNotice::Undrawn(n) => n.fmt(f), + EditNotice::BackdropsLeftOff { pages } => { + let (which, own) = if pages.len() == 1 { ("page", "its") } else { ("pages", "their") }; + write!( + f, + "{which} {} changed after the background was added, so {own} own white backdrop was not turned back on (it looks the same on white paper)", + page_runs(pages) + ) + } + } + } +} + +/// 0-based pages as 1-based runs: `[0, 1, 2, 5]` is "1-3, 6". +pub(crate) fn page_runs(pages: &[usize]) -> String { + let mut runs: Vec<(usize, usize)> = Vec::new(); + for &p in pages { + match runs.last_mut() { + Some((_, end)) if end.checked_add(1) == Some(p) => *end = p, + _ => runs.push((p, p)), + } + } + let one = |p: usize| p.saturating_add(1); + runs.iter().map(|&(a, b)| if a == b { one(a).to_string() } else { format!("{}-{}", one(a), one(b)) }).collect::>().join(", ") } /// Text an edit stored as typed but drew with `?` in place of `ch`, because the font it is drawn @@ -156,6 +199,20 @@ impl Document { self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default() } + /// The backdrop `page` (0-based) paints itself, which hides a background (Add Background). + pub fn page_backdrop(&self, page: usize) -> Option { + self.editor.as_ref().and_then(|e| pdfcraft_edit::page_backdrop(&e.cos, page).ok().flatten()) + } + + /// [`Self::page_backdrop`] for each of `pages`, in order, walking the page tree once: the + /// way to check many pages. A page out of range has none. + pub fn page_backdrops(&self, pages: &[usize]) -> Vec> { + match &self.editor { + Some(e) => pdfcraft_edit::page_backdrops(&e.cos, pages), + None => vec![None; pages.len()], + } + } + /// Edit a PDF: the images `page` draws (0-based). pub fn page_images(&self, page: usize) -> Vec { self.editor.as_ref().and_then(|e| pdfcraft_edit::page_images(&e.cos, page).ok()).unwrap_or_default() @@ -320,6 +377,13 @@ impl Document { self.config.hide_comments } + /// Whether `Edit::DeleteAnnotation` would delete the annotation at `(page, index)`: the + /// engine's own check (it, and every reply that would go with it, unlocked), for a Delete + /// command to ask before it is offered. + pub fn annotation_deletable(&self, page: usize, index: usize) -> bool { + self.editor.as_ref().is_some_and(|e| pdfcraft_annot::deletable(&e.cos, page, index).is_ok()) + } + pub fn can_undo(&self) -> Option<&str> { self.undo_label().map(Label::as_str) } 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 32dc7942ab..5ed70aed7a 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> { @@ -177,6 +178,8 @@ pub(crate) struct EditCtx { pub(crate) today: (i64, u32, u32), pub(crate) seed: u64, pub(crate) count: u64, + /// Platform CSPRNG (`getrandom::fill`). Tests replace it so Protect can fail closed. + pub(crate) rng: fn(&mut [u8]) -> Result<(), getrandom::Error>, /// Fields the edit renamed to keep imported names separate (old → new), for the repair /// log and the undo label. pub(crate) renamed_fields: Vec<(String, String)>, @@ -209,24 +212,21 @@ impl EditCtx { today: (1970, 1, 1), seed, count: 0, + rng: getrandom::fill, renamed_fields: Vec::new(), redaction_overlays: Vec::new(), } } - /// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the - /// operating system, so no extra dependency is needed. - fn entropy(&mut self) -> [u8; 32] { - use std::hash::{BuildHasher, Hasher}; + /// 32 bytes of entropy for new encryption keys, salts and generated owner passwords from the + /// platform CSPRNG. Fails closed when secure randomness is unavailable. + fn entropy(&mut self) -> Result<[u8; 32], EditError> { let mut out = [0u8; 32]; - for (i, chunk) in out.chunks_mut(8).enumerate() { - let mut h = std::collections::hash_map::RandomState::new().build_hasher(); - h.write_u64(self.seed ^ i as u64); - h.write_u64(self.count); - self.count += 1; - chunk.copy_from_slice(&h.finish().to_le_bytes()); + (self.rng)(&mut out).map_err(|e| EditError::Protection(format!("no secure randomness available: {e}")))?; + if out == [0u8; 32] { + return Err(EditError::Protection("no secure randomness available".into())); } - out + Ok(out) } /// A fresh `/NM`: a random-looking UUID (version 4 layout) from a splitmix64 stream. @@ -251,6 +251,169 @@ impl EditCtx { } } +fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usize, change: &ImageEdit) -> Result<(), EditError> { + match change { + ImageEdit::Move(to) if !to.iter().all(|v| v.is_finite()) => { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid position".into()))); + } + ImageEdit::Transform(t) => { + let [a, b, c, d, _, _] = *t; + let determinant = a * d - b * c; + if !t.iter().all(|v| v.is_finite()) || !determinant.is_finite() || determinant.abs() < 1e-9 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("the image transform must be finite and nonsingular".into()))); + } + } + ImageEdit::Crop(keep) if !keep.iter().all(|v| v.is_finite()) => { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid crop".into()))); + } + _ => {} + } + let img = pdfcraft_edit::page_images(doc, page)? + .into_iter() + .nth(index) + .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?; + let c = match change { + ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)), + ImageEdit::Transform(t) => pdfcraft_edit::ImageChange::Transform(*t), + ImageEdit::Rotate(q) => { + pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false)) + } + ImageEdit::RotateBy(degrees) => { + let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees) + .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?; + pdfcraft_edit::ImageChange::Transform(t) + } + ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre( + img.visible_rect.unwrap_or(img.rect), + 0, + *horizontal, + !*horizontal, + )), + ImageEdit::Replace { name, bytes } => { + if img.is_form { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("grouped Form artwork cannot be replaced with a bitmap".into()))); + } + pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) + } + ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), + ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, + ImageEdit::Arrange(order) => pdfcraft_edit::ImageChange::Arrange(*order), + ImageEdit::RemoveCrop => pdfcraft_edit::ImageChange::RemoveCrop, + }; + let page_model = pdfcraft_model::pages(doc) + .into_iter() + .nth(page) + .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} not found", page + 1))))?; + let target_stream_obj = match page_model.dict.get(b"Contents") { + None => None, + Some(c) => match &*doc.resolve(c) { + pdfcraft_cos::Object::Array(a) => a.get(img.stream).and_then(|o| o.as_ref()), + _ if img.stream == 0 => c.as_ref(), + _ => None, + }, + }; + + let added_items: Vec<(usize, pdfcraft_edit::Added)> = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == page).enumerate().collect(); + // Ownership belongs to the drawing stream, not the XObject: original and added images may + // show the same resource without sharing an editable item. + let shows_image = |a: &pdfcraft_edit::Added| img.object.is_some_and(|t| matches!(&a.content, AddedContent::Image(ai) if ai.image == t)); + let matched_added = added_items.iter().find(|(_, a)| target_stream_obj == Some(a.obj) && (img.object.is_none() || shows_image(a))); + + let mut ai_to_update = None; + if let Some((added_idx, added_item)) = matched_added + && let AddedContent::Image(ai) = added_item.content.clone() + { + let has_active_crop = ai.crop.iter().any(|v| v.abs() > 1e-6) + || img.visible_rect.is_some_and(|v| { + (v[0] - img.rect[0]).abs() > 1e-4 + || (v[1] - img.rect[1]).abs() > 1e-4 + || (v[2] - img.rect[2]).abs() > 1e-4 + || (v[3] - img.rect[3]).abs() > 1e-4 + }); + let non_axis_transform = matches!(change, ImageEdit::Transform([a, b, c, d, _, _]) + if !((b.abs() < 1e-5 && c.abs() < 1e-5) || (a.abs() < 1e-5 && d.abs() < 1e-5))); + let should_promote = has_active_crop + || non_axis_transform + || matches!(change, ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete); + + if should_promote { + doc.update_dict(added_item.obj, |d| { + d.remove(b"PCAdded"); + d.remove(b"PCMark"); + }) + .map_err(|e| EditError::Edit(pdfcraft_edit::EditError::Invalid(e.to_string())))?; + } else { + ai_to_update = Some((*added_idx, ai)); + } + } + + if let Some((added_idx, mut ai)) = ai_to_update { + let view = Matrix(page_model.view_matrix(doc)); + let to_display = view.invert().ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid page transform".into())))?; + + match change { + ImageEdit::Move(to) => { + ai.rect = to_display.bbox(*to); + pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?; + } + ImageEdit::Transform(_) | ImageEdit::Flip { .. } => { + let pdfcraft_edit::ImageChange::Transform(t) = c else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("expected transform".into()))); + }; + let t_display = view.then(&Matrix(t)).then(&to_display); + let [ta, tb, tc, td, _, _] = t_display.0; + let is_parallel = tb.abs() < 1e-5 && tc.abs() < 1e-5; + let is_perpendicular = ta.abs() < 1e-5 && td.abs() < 1e-5; + + let (quarters_ccw, disp_flip_h, disp_flip_v) = if is_parallel { + (0, ta < 0.0, td < 0.0) + } else if is_perpendicular { + match (tb > 0.0, tc > 0.0) { + (true, false) => (1, false, false), + (false, true) => (3, false, false), + (true, true) => (1, false, true), + (false, false) => (1, true, false), + } + } else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( + "non-axis-aligned transforms are not supported for added images".into(), + ))); + }; + let (toggle_h, toggle_v) = if ai.rotation % 2 == 1 { (disp_flip_v, disp_flip_h) } else { (disp_flip_h, disp_flip_v) }; + ai.flip_h ^= toggle_h; + ai.flip_v ^= toggle_v; + ai.rotation = (ai.rotation + quarters_ccw) % 4; + ai.rect = t_display.bbox(ai.rect); + pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?; + } + ImageEdit::Rotate(q) => { + let k = ((4 - q.rem_euclid(4)) % 4) as u8; + ai.rotation = (ai.rotation + k) % 4; + let r = ai.rect; + let (cx, cy) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); + if k % 2 == 1 { + let (w, h) = (r[2] - r[0], r[3] - r[1]); + ai.rect = [cx - h / 2.0, cy - w / 2.0, cx + h / 2.0, cy + w / 2.0]; + } + pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?; + } + ImageEdit::Replace { .. } => { + let pdfcraft_edit::ImageChange::Replace(new_image) = c else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("expected replace".into()))); + }; + ai.image = new_image; + pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?; + } + ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete => { + pdfcraft_edit::change_image(doc, page, index, &c)?; + } + } + } else { + pdfcraft_edit::change_image(doc, page, index, &c)?; + } + Ok(()) +} + /// Perform an edit on a working copy (the caller discards it on error). pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> { match edit { @@ -526,40 +689,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::AddOcrText { page, words } => { pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?; } - Edit::EditPageImage { page, index, change } => { - let img = pdfcraft_edit::page_images(doc, *page)? - .into_iter() - .nth(*index) - .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?; - let c = match change { - ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)), - ImageEdit::Rotate(q) => { - pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false)) - } - ImageEdit::RotateBy(degrees) => { - let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees) - .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?; - pdfcraft_edit::ImageChange::Transform(t) - } - ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre( - img.visible_rect.unwrap_or(img.rect), - 0, - *horizontal, - !*horizontal, - )), - ImageEdit::Replace { name, bytes } => { - if img.is_form { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( - "grouped Form artwork cannot be replaced with a bitmap".into(), - ))); - } - pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) - } - ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), - ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, - }; - pdfcraft_edit::change_image(doc, *page, *index, &c)?; - } + Edit::EditPageImage { page, index, change } => run_edit_page_image(doc, *page, *index, change)?, Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?, Edit::EditTextBlock { page, block, text, style } => { pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; @@ -738,10 +868,13 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E } Edit::Protect(p) => { p.validate()?; - let seed = cx.entropy(); + let seed = cx.entropy()?; // Without a permissions password nothing is restricted, so the owner password is a // random one nobody needs. - let random_owner: String = cx.entropy().iter().map(|b| format!("{b:02x}")).collect(); + let random_owner = match p.permissions_password { + Some(_) => String::new(), + None => cx.entropy()?.iter().map(|b| format!("{b:02x}")).collect(), + }; let params = pdfcraft_cos::NewEncryption { algorithm: p.algorithm, user_password: p.open_password.as_deref().unwrap_or(""), 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 024058ecc2..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,10 +65,11 @@ 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::StampPreview; -pub use text_preview::TextEditPreview; +pub use stamp_preview::{CommentLayer, StampPreview}; +pub use text_preview::{TextEditPreview, UnmarkedPages}; pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges}; @@ -82,6 +84,7 @@ pub use pdfcraft_edit::added::{ CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect, text_width as added_text_width, }; +pub use pdfcraft_edit::paragraph_align; pub use pdfcraft_edit::{ Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark, }; @@ -89,11 +92,11 @@ pub use pdfcraft_forms::{ BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, - calculations_changed as form_calculations_changed, flags as field_flags, + appearance::comb_auto_text_size as field_comb_auto_text_size, calculations_changed as form_calculations_changed, flags as field_flags, }; pub use pdfcraft_a11y as a11y; -pub use pdfcraft_edit::{BlockKey, BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, block_overlapping, first_undrawable}; +pub use pdfcraft_edit::{Backdrop, BlockKey, BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, block_overlapping, first_undrawable}; pub use pdfcraft_measure as measure; pub use pdfcraft_xfa::Report as XfaLayout; @@ -125,7 +128,9 @@ use bookmarks::outline_rows; pub use bookmarks::{BookmarkPage, BookmarkRow}; use comments::comment_list; pub use comments::{SummarySort, comment_kind, comment_summary, comment_type_name}; -pub use document::{Document, TextNotice, display_bytes_for, render_password_for}; +#[cfg(test)] +use document::page_runs; +pub use document::{Document, EditNotice, TextNotice, display_bytes_for, render_password_for}; use document::{display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices}; pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; pub use error::{EditError, SourceProblem}; @@ -134,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}; @@ -151,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 58bc8327e2..32db07abee 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 f8d5648a2d..be7fd5aca5 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -2,10 +2,20 @@ 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. @@ -162,17 +172,16 @@ 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())); } - // What this edit drew with `?` (a value or comment text its font can't draw, stored as - // typed): reported, never silent (AGENTS.md §4). - let notices = if matches!(scope_of(&edit), Scope::Form | Scope::Comments | Scope::Full) { - guard(|| undrawn_notices(&editor.cos, &next)).unwrap_or_default() - } else { - Vec::new() - }; + let notices = edit_notices(&edit, &editor.cos, &next); let previous = std::mem::replace(&mut editor.cos, next); let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone()); let previous_keys = std::mem::replace(&mut editor.keys, keys); @@ -380,7 +389,7 @@ impl Session { let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; doc.display = display_bytes(Some(editor), &bytes); doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); doc.bytes = bytes; Ok(()) } @@ -404,6 +413,7 @@ impl Session { | Edit::DeleteContent { .. } | Edit::ReplaceImage { .. } | Edit::PasteContent { .. } + | Edit::EditPageImage { .. } ) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) }); let touches_marks = edit.is_none_or(|e| { @@ -437,7 +447,7 @@ impl Session { let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; doc.display = display_bytes(Some(editor), &bytes); doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); doc.bytes = bytes; doc.fonts_stale = true; Ok(()) @@ -464,7 +474,7 @@ impl Session { /// After a refresh failed: draw the display bytes it left (the original, once released), /// so the page still shows a document. pub(super) fn show_released(doc: &mut Document) { - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); } /// Rebuild working bytes, inspection and renderer from the current edit state. @@ -521,8 +531,26 @@ 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(()) } } + +/// What `edit`, taking `prev` to `next`, wants said: never silent (AGENTS.md §4). +fn edit_notices(edit: &Edit, prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec { + // What it drew with `?` (a value or comment text its font can't draw, stored as typed). + let mut notices: Vec = if matches!(scope_of(edit), Scope::Form | Scope::Comments | Scope::Full) { + guard(|| undrawn_notices(prev, next)).unwrap_or_default().into_iter().map(EditNotice::Undrawn).collect() + } else { + Vec::new() + }; + // Pages whose own white backdrop Remove background couldn't turn back on. + if matches!(edit, Edit::RemoveMarks { kind: MarkKind::Background }) { + let pages = guard(|| pdfcraft_edit::backdrops_left_off(next)).unwrap_or_default(); + if !pages.is_empty() { + notices.push(EditNotice::BackdropsLeftOff { pages }); + } + } + notices +} diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 1e8cd9b08a..34d75e5c82 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -68,10 +68,12 @@ impl Session { if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) } } - /// Reduce File Size: Acrobat's defaults (images above 225 ppi to 150 ppi, JPEG medium - /// quality; thumbnails dropped), identical resources merged, unused objects dropped, objects - /// packed into compressed object streams. Returns the bytes and how many objects were - /// merged. The open document is not changed (Acrobat saves the reduced copy as a new file). + /// Reduce File Size: Acrobat's defaults (colour and grey images above 225 ppi to 150 ppi, + /// JPEG medium quality; black-and-white averaged down to 300 ppi above 450 ppi, and stored as + /// CCITT Group 4 whenever smaller; thumbnails dropped), identical resources merged, unused + /// objects dropped, objects packed into compressed object streams. Returns the bytes and how + /// many objects were merged. The open document is not changed (Acrobat saves the reduced copy + /// as a new file). pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc>, usize), EditError> { let (bytes, report) = self.optimized_bytes(id, &optimize::Settings::default(), &[])?; Ok((bytes, report.merged)) diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs index d4156a2eb1..1e6918f6a6 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 { @@ -170,7 +170,7 @@ impl Session { { doc.config.reading_colors = old.config.reading_colors; doc.config.hide_comments = old.config.hide_comments; - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); } // Caches keyed by the edit generation (edit lines, images, measurements) must see a new // one; there is nothing new to autosave. @@ -249,7 +249,7 @@ impl Session { let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache) }; doc.display = display_bytes(Some(editor), &doc.bytes); doc.signatures = Arc::new(signatures); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config)); } } 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/signature_image.rs b/crates/engine/src/signature_image.rs index daff674c19..ae8842ddfb 100644 --- a/crates/engine/src/signature_image.rs +++ b/crates/engine/src/signature_image.rs @@ -61,7 +61,7 @@ impl Document { pdfcraft_annot::props(&editor.cos, page, index).map(|p| p.opacity).filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0) as f32; let turn = pdfcraft_annot::picture_rotation(&editor.cos, page, index).map_err(|e| e.to_string())?; let mut cos = editor.cos.clone(); - pdfcraft_annot::delete_annotation(&mut cos, page, index).map_err(|e| e.to_string())?; + pdfcraft_annot::lift_annotation(&mut cos, page, index).map_err(|e| e.to_string())?; let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); Ok(Some(ImageSignaturePreview { image, opacity, turn, page, bytes, config: self.config.clone() })) })? diff --git a/crates/engine/src/stamp_preview.rs b/crates/engine/src/stamp_preview.rs index 6e5758a7ce..630ec0c3f0 100644 --- a/crates/engine/src/stamp_preview.rs +++ b/crates/engine/src/stamp_preview.rs @@ -1,4 +1,5 @@ -//! A stamp while it is moved or resized: the page without it, and the stamp alone. +//! A stamp while it is moved or resized: the page without it, and the stamp alone. Any other +//! comment while it is moved: the comment alone. use std::sync::Arc; @@ -73,7 +74,7 @@ impl Document { return Ok(None); } let mut background = editor.cos.clone(); - pdfcraft_annot::delete_annotation(&mut background, page, index).map_err(|e| e.to_string())?; + pdfcraft_annot::lift_annotation(&mut background, page, index).map_err(|e| e.to_string())?; // The page beneath as it is displayed: comments without an appearance get theirs. let background = pdfcraft_annot::with_missing_appearances(&background).unwrap_or(background); let write = |cos: &pdfcraft_cos::Document| write_incremental(cos, &SaveOptions::default()).map(Arc::new).map_err(|e| e.to_string()); @@ -81,3 +82,59 @@ impl Document { })? } } + +/// Comments alone on a transparent page, for drawing them where they go while they are moved. +/// The page beneath keeps showing them where they were until the move is applied: rendering the page +/// without it would cost a whole page's render at every drag on a heavy plan. Read-only, as +/// [`StampPreview`] is. +pub struct CommentLayer { + page: usize, + bytes: Arc>, + config: RenderConfig, +} + +impl CommentLayer { + /// One worker for the comments alone: their page has no content and no other annotations. + pub fn renderer(&self) -> RenderPool { + RenderPool::new(self.bytes.clone(), 1, self.transparent()) + } + + /// The comments alone, rendered synchronously with the renderer's normal pixel limits. + pub fn render(&self, scale: f32) -> Result { + if !scale.is_finite() || scale <= 0.0 { + return Err("preview scale must be finite and positive".into()); + } + guard(|| { + let out = + PageRenderer::new(self.bytes.clone(), self.transparent()).render(RenderRequest { page: self.page, scale, ..Default::default() }); + if let Some(e) = &out.error { + return Err(e.clone()); + } + Ok(out) + })? + } + + fn transparent(&self) -> RenderConfig { + RenderConfig { transparent: true, ..self.config.clone() } + } +} + +impl Document { + /// The comments at `indices` on `page` alone (the ones moved together), drawn as on screen: + /// one without an appearance of its own gets the one the page view draws. `None` when one + /// of them doesn't exist or none has anything to draw. + pub fn comment_layer(&self, page: usize, indices: &[usize]) -> Result, String> { + guard(|| { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + if indices.is_empty() || !indices.iter().all(|&i| self.info.annotations.iter().any(|a| a.page == page && a.index == i)) { + return Ok(None); + } + let mut alone = pdfcraft_annot::with_missing_appearances(&editor.cos).unwrap_or_else(|| editor.cos.clone()); + if !pdfcraft_annot::isolate_annotations(&mut alone, page, indices).map_err(|e| e.to_string())? { + return Ok(None); + } + let bytes = write_incremental(&alone, &SaveOptions::default()).map(Arc::new).map_err(|e| e.to_string())?; + Ok(Some(CommentLayer { page, bytes, config: self.config.clone() })) + })? + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e66b6bc6f3..000a511414 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use super::document::is_placeholder_title; -use super::edit::check_permission; +use super::edit::{EditCtx, check_permission, run_edit}; use super::*; use pdfcraft_render::{Layer, LayerOp, OpenError, ReadingColors}; @@ -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"]); @@ -2317,6 +2425,84 @@ fn passwords_that_encryption_would_make_empty_or_equal_are_refused() { } } +#[test] +fn fresh_sessions_protecting_identical_documents_produce_different_salts() { + // Characterization only: two sessions' salts differ. A per-call RandomState already did + // that before Protect drew from the platform CSPRNG, so this does not prove the source. + // `protect_fails_closed_when_randomness_is_unavailable` is the guard. It does not compile + // against a Protect with no CSPRNG hook, and it fails closed when that hook errors or + // returns zeros. Comparing the stored function with `getrandom::fill` is not reliable: + // one function can have different addresses in different codegen units. + let raw = Arc::new(fixture(1)); + let encrypt_bytes = |saved: Arc>, key: &[u8]| -> Vec { + let doc = pdfcraft_cos::Document::open_with_password(saved, Some("secret")).unwrap(); + let enc = doc.trailer().get(b"Encrypt").map(|o| doc.resolve(o)).unwrap(); + enc.as_dict().unwrap().get(key).map(|o| doc.resolve(o)).unwrap().as_string().unwrap().bytes.clone() + }; + let protect_once = |p: Protection| -> Arc> { + let mut s = Session::new(); + let id = s.open("same.pdf", None, raw.clone(), None).unwrap(); + s.apply(id, Edit::Protect(p)).unwrap(); + s.save_bytes(id).unwrap() + }; + let a = protect_once(protection(Some("secret"), None)); + let b = protect_once(protection(Some("secret"), None)); + let (u_a, u_b) = (encrypt_bytes(a.clone(), b"U"), encrypt_bytes(b.clone(), b"U")); + let (o_a, o_b) = (encrypt_bytes(a.clone(), b"O"), encrypt_bytes(b.clone(), b"O")); + let (ue_a, ue_b) = (encrypt_bytes(a.clone(), b"UE"), encrypt_bytes(b.clone(), b"UE")); + let (oe_a, oe_b) = (encrypt_bytes(a, b"OE"), encrypt_bytes(b, b"OE")); + assert_eq!((u_a.len(), u_b.len(), o_a.len(), o_b.len()), (48, 48, 48, 48)); + assert_ne!(&u_a[32..48], &u_b[32..48], "user validation and key salts must differ across fresh sessions"); + assert_ne!(&o_a[32..48], &o_b[32..48], "owner validation and key salts must differ across fresh sessions"); + assert_ne!(ue_a, ue_b, "wrapped user file keys must differ across fresh sessions"); + assert_ne!(oe_a, oe_b, "wrapped owner file keys must differ across fresh sessions"); + + // Generated owner passwords in R4 (AES-128) must also come from CSPRNG entropy, even when the + // document already carries a fixed /ID and only an open password is set. + let mut with_id = pdfcraft_cos::Document::open(raw).unwrap(); + let fixed_id = pdfcraft_cos::Object::String(pdfcraft_cos::PdfString { bytes: b"0123456789abcdef".to_vec(), hex: true }); + with_id.trailer_mut().set(b"ID".to_vec(), pdfcraft_cos::Object::Array(vec![fixed_id.clone(), fixed_id])); + let raw_with_id = Arc::new(pdfcraft_cos::write_full(&with_id, &Default::default()).unwrap()); + let protect_r4 = || -> Vec { + let mut s = Session::new(); + let id = s.open("same.pdf", None, raw_with_id.clone(), None).unwrap(); + let p = Protection { algorithm: pdfcraft_cos::Algorithm::Aes128, ..protection(Some("secret"), None) }; + s.apply(id, Edit::Protect(p)).unwrap(); + encrypt_bytes(s.save_bytes(id).unwrap(), b"O") + }; + assert_ne!(protect_r4(), protect_r4(), "generated owner passwords must differ across fresh sessions"); +} + +#[test] +fn protect_fails_closed_when_randomness_is_unavailable() { + use std::sync::atomic::{AtomicUsize, Ordering}; + static CALLS: AtomicUsize = AtomicUsize::new(0); + fn fail_rng(_: &mut [u8]) -> Result<(), getrandom::Error> { + Err(getrandom::Error::UNSUPPORTED) + } + fn zero_rng(out: &mut [u8]) -> Result<(), getrandom::Error> { + out.fill(0); + Ok(()) + } + fn fail_second_rng(out: &mut [u8]) -> Result<(), getrandom::Error> { + if CALLS.fetch_add(1, Ordering::SeqCst) == 0 { + out.fill(0xA5); + Ok(()) + } else { + Err(getrandom::Error::UNSUPPORTED) + } + } + CALLS.store(0, Ordering::SeqCst); + for rng in [fail_rng, zero_rng, fail_second_rng] { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + let mut cx = EditCtx::new(None, 0); + cx.rng = rng; + let err = run_edit(&mut doc, &Edit::Protect(protection(Some("secret"), None)), &mut cx); + assert!(matches!(err, Err(EditError::Protection(_))), "Protect must fail closed when CSPRNG fails: {err:?}"); + assert!(doc.output_handler().is_none(), "document must stay unprotected when CSPRNG fails"); + } +} + /// A one-page form: a text field and a check box (with appearance states). fn form_fixture() -> Vec { let objs = [ @@ -2522,10 +2708,10 @@ fn a_value_its_font_cannot_draw_is_reported_by_the_edit_that_drew_it() { fill(&mut s, "name", FieldValue::Text("张三".into())).unwrap(); let d = s.get(id).unwrap(); assert_eq!(d.form[0].value, ["张三"]); - let n = &d.edit_notices; - assert_eq!(n.len(), 1, "{n:?}"); - assert_eq!((n[0].field.as_deref(), n[0].page, n[0].ch, n[0].redrawn_elsewhere), (Some("name"), 0, '张', true)); - assert!(n[0].to_string().contains("\"张\" (U+5F20)") && n[0].to_string().contains("other PDF viewers redraw"), "{}", n[0]); + let [EditNotice::Undrawn(n)] = d.edit_notices.as_slice() else { panic!("{:?}", d.edit_notices) }; + assert_eq!((n.field.as_deref(), n.page, n.ch, n.redrawn_elsewhere), (Some("name"), 0, '张', true)); + let said = d.edit_notices[0].to_string(); + assert!(said.contains("\"张\" (U+5F20)") && said.contains("other PDF viewers redraw"), "{said}"); fill(&mut s, "ok", FieldValue::Check(true)).unwrap(); assert!(s.get(id).unwrap().edit_notices.is_empty(), "an unrelated edit reports nothing"); fill(&mut s, "name", FieldValue::Text("李四".into())).unwrap(); @@ -3149,6 +3335,98 @@ fn headers_footers_watermarks_and_backgrounds_show_update_and_remove() { assert_eq!(s.get(id).unwrap().can_undo(), Some("Remove background")); } +/// One 100 × 100 page under a background (`/PCMark /Background`), its own content painting a +/// white backdrop first. With `copy`, that content is a backdrop copy whose tag is `copy`. +fn backdrop_pdf(copy: Option<&str>) -> Vec { + let bg = "q 1 0 0 rg 0 0 100 100 re f Q"; + let (tag, fill) = match copy { + Some(tag) => (format!("/PCBackdrop {tag} "), "n"), + None => (String::new(), "f"), + }; + let content = format!("1 g 0 0 100 100 re {fill} 0 g BT /F1 12 Tf 10 50 Td (Hi) Tj ET"); + 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 100 100] /Contents [4 0 R 5 0 R] /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> >> endobj\n\ + 4 0 obj << /PCMark /Background /Length {} >> stream\n{bg}\nendstream endobj\n\ + 5 0 obj << {tag}/Length {} >> stream\n{content}\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + bg.len(), + content.len() + ) + .into_bytes() +} + +/// Remove background says which pages keep their own white backdrop off (it couldn't be turned +/// back on safely), and says nothing when every backdrop comes back. +#[test] +fn remove_background_names_the_pages_whose_own_backdrop_stays_off() { + let mut s = Session::new(); + // A tag whose hash doesn't match the bytes before the fill: something changed them since. + let tag = format!("<< /Index 2 /Op (f) /Before <{}> >>", "00".repeat(32)); + let id = s.open("a.pdf", None, Arc::new(backdrop_pdf(Some(&tag))), None).unwrap(); + s.apply(id, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!(d.edit_notices, [EditNotice::BackdropsLeftOff { pages: vec![0] }]); + let said = d.edit_notices[0].to_string(); + assert!(said.starts_with("page 1 changed after the background was added"), "{said}"); + // An unrelated edit after it says nothing. + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + assert!(s.get(id).unwrap().edit_notices.is_empty()); + + // Added and removed here: the backdrop comes back, and nothing is said. + let id = s.open("b.pdf", None, Arc::new(backdrop_pdf(None)), None).unwrap(); + s.apply(id, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap(); + let background = Background { color: [1.0, 0.0, 0.0], ..Background::default() }; + s.apply(id, Edit::AddBackground { pages: vec![0], settings: background, replace: false, file: None }).unwrap(); + assert_eq!(s.get(id).unwrap().page_backdrops(&[0]), [None], "replaced"); + s.apply(id, Edit::RemoveMarks { kind: MarkKind::Background }).unwrap(); + assert!(s.get(id).unwrap().edit_notices.is_empty()); + assert_eq!(s.get(id).unwrap().page_backdrops(&[0]), [Some(Backdrop::Fill)], "turned back on"); + assert_eq!(page_runs(&[0, 1, 2, 5, 7, 8]), "1-3, 6, 8-9"); + assert_eq!(page_runs(&[]), ""); +} + +/// Update previews its marks on the page without the ones it replaces: a yellow background +/// updated to blue isn't previewed as blue over yellow, nor a new watermark over the old one. +/// The other kinds of mark and the page's own content stay, every page is prepared at once (the +/// dialog steps through them), a document without that kind has nothing to prepare, and +/// preparing changes nothing in the document. +#[test] +fn update_previews_on_the_page_without_the_marks_it_replaces() { + let (mut s, id) = session_with(2); + let yellow = Background { color: [1.0, 1.0, 0.0], ..Background::default() }; + s.apply(id, Edit::AddBackground { pages: vec![0, 1], settings: yellow, replace: false, file: None }).unwrap(); + let red = Watermark { text: "SECRET".into(), color: [1.0, 0.0, 0.0], opacity: 1.0, ..Watermark::default() }; + s.apply(id, Edit::AddWatermark { pages: vec![0], settings: red, replace: false, file: None }).unwrap(); + let doc = s.get(id).unwrap(); + let (generation, dirty) = (doc.edit_generation(), doc.dirty); + let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 3] { + p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..3].try_into().unwrap() + }; + let reds = |p: &pdfcraft_render::RenderedPage| p.rgba.as_chunks::<4>().0.iter().filter(|[r, g, b, _]| *r > 200 && *g < 80 && *b < 80).count(); + let dark = |p: &pdfcraft_render::RenderedPage| p.rgba.as_chunks::<4>().0.iter().filter(|[r, g, b, _]| *r < 100 && *g < 100 && *b < 100).count(); + assert_eq!(pixel(&s, id, 0, 5, 5)[..3], [255, 255, 0], "the page has the yellow background"); + + let mut unmarked = doc.without_marks(MarkKind::Background).unwrap().expect("the pages have a background"); + let page = unmarked.render_page(0, 1.0).unwrap(); + assert_eq!(px(&page, 5, 5), [255, 255, 255], "without the background the page is white"); + assert!(reds(&page) > 50, "the watermark stays"); + assert!(dark(&page) > 10, "the page text stays"); + + let page = doc.without_marks(MarkKind::Watermark).unwrap().expect("page 1 has a watermark").render_page(0, 1.0).unwrap(); + assert_eq!(reds(&page), 0, "without the watermark"); + assert_eq!(px(&page, 5, 5), [255, 255, 0], "the background stays"); + + assert_eq!(px(&unmarked.render_page(1, 1.0).unwrap(), 5, 5), [255, 255, 255], "made once, for every page: page 2 is white too"); + assert_eq!(unmarked.parser_builds(), 1, "parsed once for both pages"); + assert!(doc.without_marks(MarkKind::HeaderFooter).unwrap().is_none(), "the document has no header"); + assert!(unmarked.render_page(9, 1.0).is_err(), "no page 10"); + let doc = s.get(id).unwrap(); + assert_eq!((doc.edit_generation(), doc.dirty, doc.can_undo()), (generation, dirty, Some("Add watermark")), "the document is unchanged"); +} + #[test] fn marker_indexes_match_after_open_and_save_when_requested() { let (mut source, source_id) = session_with(1); @@ -3392,6 +3670,24 @@ fn comment_text(s: &Session, id: DocId) -> Vec { t } +/// A stamp whose reply is locked can still be dragged, so its layers are still prepared: the page +/// beneath is drawn on a copy, where locks don't count. Deleting the stamp is what they refuse. +#[test] +fn a_stamp_with_a_locked_reply_still_has_its_drag_layers() { + let (mut s, id) = session_with(1); + s.apply(id, fill_item(Shape::Mark { rect: [30.0, 60.0, 130.0, 66.0], mark: FillMark::Line }, "")).unwrap(); + s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 0, text: "Approved".into(), author: "Bob".into() }).unwrap(); + let reply = s.get(id).unwrap().info.annotations.iter().find(|a| a.parent == Some((0, 0))).expect("the reply").index; + s.apply(id, Edit::LockAnnotation { page: 0, index: reply, locked: true }).unwrap(); + let doc = s.get(id).unwrap(); + let background = doc.stamp_preview(0, 0).unwrap().expect("the line is a stamp").render_background(1.0).unwrap(); + let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 4] { + p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..4].try_into().unwrap() + }; + assert!((30..=130).all(|x| px(&background, x, 63) == [255, 255, 255, 255]), "the page beneath has no line"); + assert!(s.apply(id, Edit::DeleteAnnotation { page: 0, index: 0 }).is_err(), "the locked reply keeps the stamp"); +} + /// A dragged stamp's layers: the page without it (its text and other comments kept) and the /// stamp alone on a transparent page. Preparing them changes nothing in the document. #[test] @@ -3446,6 +3742,44 @@ trailer << /Root 1 0 R >> assert!(doc.stamp_preview(0, 1).unwrap().is_none(), "a stamp without an appearance"); } +/// A moved comment's layer: the comment alone on a transparent page (no page text, no other +/// comment), drawn as on screen, another app's comment without an appearance too. Preparing it +/// changes nothing in the document. +#[test] +fn comment_layer_draws_the_comment_alone() { + let (mut s, id) = session_with(1); + s.apply(id, rect_comment(0, [140.0, 40.0, 190.0, 90.0])).unwrap(); + s.apply(id, rect_comment(0, [20.0, 20.0, 60.0, 60.0])).unwrap(); + let doc = s.get(id).unwrap(); + let (generation, dirty) = (doc.edit_generation(), doc.dirty); + let layer = doc.comment_layer(0, &[0]).unwrap().expect("a rectangle").render(1.0).unwrap(); + let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 4] { + p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..4].try_into().unwrap() + }; + assert_eq!(px(&layer, 165, 65), [255, 0, 0, 255], "the rectangle is drawn"); + let near = |x: u32, y: u32| (137..=193).contains(&x) && (37..=93).contains(&y); + let stray: Vec<(u32, u32)> = + (1..300).flat_map(|y| (0..200).map(move |x| (x, y))).filter(|&(x, y)| !near(x, y) && px(&layer, x, y) != [0, 0, 0, 0]).collect(); + assert!(stray.is_empty(), "only the rectangle is on its layer: {:?}", &stray[..stray.len().min(8)]); + assert!(doc.comment_layer(0, &[9]).unwrap().is_none(), "no such comment"); + let both = doc.comment_layer(0, &[0, 1]).unwrap().expect("both").render(1.0).unwrap(); + assert_eq!((px(&both, 165, 65), px(&both, 40, 40)), ([255, 0, 0, 255], [255, 0, 0, 255]), "moved together, drawn together"); + let doc = s.get(id).unwrap(); + assert_eq!((doc.edit_generation(), doc.dirty, doc.can_undo()), (generation, dirty, Some("Add rectangle")), "the document is unchanged"); + + let bare = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Square /Rect [140 40 190 90] /C [1 0 0] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut s = Session::new(); + let id = s.open("bare.pdf", None, Arc::new(bare.to_vec()), None).unwrap(); + let layer = s.get(id).unwrap().comment_layer(0, &[0]).unwrap().expect("drawn as on screen").render(1.0).unwrap(); + assert!((138..=142).any(|x| px(&layer, x, 65)[..3] == [255, 0, 0]), "the square without an appearance is drawn"); +} + #[test] fn flattening_fill_and_sign_keeps_other_comments_fields_and_undoes() { let (mut s, id) = session_with(1); @@ -3710,6 +4044,7 @@ fn pasted_added_content_keeps_its_style_and_picture() { size: 12.0, color: [0.8, 0.1, 0.2], align: TextAlign::Center, + aligns: Vec::new(), char_spacing: 0.0, }; s.apply(id, Edit::AddText { page: 0, text: text.clone() }).unwrap(); @@ -7667,6 +8002,7 @@ fn content_edit_keeps_pages_annotations_fields_and_links() { size: 12.0, color: [0.0, 0.0, 0.0], align: TextAlign::Left, + aligns: Vec::new(), char_spacing: 0.0, }; session.apply(id, Edit::AddText { page: 1, text }).unwrap(); @@ -7707,6 +8043,7 @@ fn content_refresh_lists_match_the_storages_an_edit_touches() { size: 12.0, color: [0.0, 0.0, 0.0], align: TextAlign::Left, + aligns: Vec::new(), char_spacing: 0.0, }, }, @@ -7836,6 +8173,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 @@ -8346,11 +8690,11 @@ fn rolls_back_metadata_scope(s: &mut Session, id: DocId) { // 5. Metadata scope: a panic after `doc.info.title` was written rolls back `info.title` and keeps `edit_notices`. s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Draft".into() }).unwrap(); let notice = TextNotice { field: None, page: 0, ch: '☺', redrawn_elsewhere: false }; - s.doc_mut(id).unwrap().edit_notices.push(notice.clone()); + s.doc_mut(id).unwrap().edit_notices.push(EditNotice::Undrawn(notice.clone())); panic_on_refresh(1); assert_eq!(s.undo(id), Err(EditError::Reopen("injected refresh panic".into()))); assert_eq!(s.get(id).unwrap().info.title.as_deref(), Some("Draft")); - assert_eq!(s.get(id).unwrap().edit_notices, [notice]); + assert_eq!(s.get(id).unwrap().edit_notices, [EditNotice::Undrawn(notice)]); assert_eq!(s.undo(id).unwrap().as_str(), "Change Title"); assert_eq!(s.get(id).unwrap().info.title, None); assert!(s.get(id).unwrap().edit_notices.is_empty()); @@ -8853,6 +9197,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 @@ -9023,69 +9835,215 @@ fn pdf_with_annots(annots: &[&[u8]]) -> Vec { bytes } -/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its -/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the -/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked -/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without -/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. -#[test] -fn need_appearances_buttons_are_displayed_from_their_captions() { - let pdf = |need: bool| { - let flag = if need { "/NeedAppearances true " } else { "" }; - format!( - "%PDF-1.7\n\ - 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 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 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ - 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ - 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ - 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF" - ) - .into_bytes() - }; - let render = |bytes: Arc>| { - let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); - r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) - }; - // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. - let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { - let (px, _) = page.rgba.as_chunks::<4>(); - (200 - y1..200 - y0) - .flat_map(|y| (x0..x1).map(move |x| (x, y))) - .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) - .count() - }; - // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside - // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). - let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); - let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); - let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); - let mut s = Session::new(); - let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); - let shown = render(s.get(id).unwrap().display.clone()); - let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; - let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); - let printed = render(Arc::new(pictures)); - for (page, how) in [(&shown, "shown"), (&printed, "printed")] { - let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); - assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); - assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); - assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); - assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); - let dot = ink(page, (188, 108, 212, 132)); - assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); - } - let saved = s.save_bytes(id).unwrap(); - assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); - - // Without /NeedAppearances the file's filled squares are shown. - let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); - let shown = render(s.get(id).unwrap().display.clone()); - for x in [20, 100, 180] { - assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); - } -} +/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its +/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the +/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked +/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without +/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. +#[test] +fn need_appearances_buttons_are_displayed_from_their_captions() { + let pdf = |need: bool| { + let flag = if need { "/NeedAppearances true " } else { "" }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 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 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ + 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + ) + .into_bytes() + }; + let render = |bytes: Arc>| { + let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); + r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) + }; + // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. + let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { + let (px, _) = page.rgba.as_chunks::<4>(); + (200 - y1..200 - y0) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) + .count() + }; + // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside + // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). + let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); + let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); + let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; + let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); + let printed = render(Arc::new(pictures)); + for (page, how) in [(&shown, "shown"), (&printed, "printed")] { + let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); + assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); + assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); + assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); + assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); + let dot = ink(page, (188, 108, 212, 132)); + assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); + } + let saved = s.save_bytes(id).unwrap(); + assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); + + // Without /NeedAppearances the file's filled squares are shown. + let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + for x in [20, 100, 180] { + assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); + } +} + +#[test] +fn repeated_backdrop_artwork_survives_background_undo_redo_and_both_save_modes() { + let white = "1 g"; + let rectangle = "0 0 100 100 re f"; + let artwork = "q 0 g 0.25 0 0 0.25 20 20 cm"; + let raw = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents [4 0 R 5 0 R 6 0 R 5 0 R 7 0 R] >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{white}\nendstream endobj\n\ + 5 0 obj << /Length {} >> stream\n{rectangle}\nendstream endobj\n\ + 6 0 obj << /Length {} >> stream\n{artwork}\nendstream endobj\n\ + 7 0 obj << /Length 1 >> stream\nQ\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + white.len(), + rectangle.len(), + artwork.len() + ); + // Start with a conventional xref, so the first save can exercise incremental output. + let source = pdfcraft_cos::Document::open(Arc::new(raw.into_bytes())).unwrap(); + let bytes = pdfcraft_cos::write_full(&source, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let mut s = Session::new(); + let id = s.open("shared-backdrop.pdf", None, Arc::new(bytes), None).unwrap(); + let black = |s: &Session, id| { + let doc = s.get(id).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let out = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + assert_eq!((out.width, out.height), (100, 100)); + // PDF point (30, 30), inside the later black square, in top-down raster coordinates. + let i = ((70 * out.width + 30) * 4) as usize; + let rgba = &out.rgba[i..i + 4]; + assert!(rgba[0] < 20 && rgba[1] < 20 && rgba[2] < 20, "later artwork survives: {rgba:?}"); + }; + black(&s, id); + s.apply( + id, + Edit::AddBackground { pages: vec![0], settings: Background { color: [1.0, 0.0, 0.0], ..Background::default() }, replace: false, file: None }, + ) + .unwrap(); + black(&s, id); + s.undo(id).unwrap(); + black(&s, id); + s.redo(id).unwrap(); + black(&s, id); + let saved = s.save_bytes(id).unwrap(); + let reopened = s.open("incremental.pdf", None, saved.clone(), None).unwrap(); + black(&s, reopened); + let doc = pdfcraft_cos::Document::open(saved).unwrap(); + let full = pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let rewritten = s.open("full.pdf", None, Arc::new(full), None).unwrap(); + black(&s, rewritten); + 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_preview.rs b/crates/engine/src/text_preview.rs index 290231cf73..f2e60dac07 100644 --- a/crates/engine/src/text_preview.rs +++ b/crates/engine/src/text_preview.rs @@ -1,4 +1,5 @@ -//! The page beneath an inline text editor, with the source glyphs removed on a COS clone. +//! The page beneath an inline text editor, with the source glyphs removed on a COS clone; and, +//! the same way, the page beneath a dragged added item or the marks an Update replaces. use std::sync::Arc; @@ -18,18 +19,75 @@ impl TextEditPreview { } pub fn render_background(&self, scale: f32) -> Result { + render_one(&self.bytes, &self.config, self.page, scale) + } +} + +/// A document's pages without its marks of one kind ([`Document::without_marks`]). +pub struct UnmarkedPages { + bytes: Arc>, + config: RenderConfig, + /// Parsed on the first [`Self::render_page`], once (a copy that can't be parsed isn't tried + /// again), and kept: parsing costs about as much as reading the document. + parsed: Option, + /// How many times it was parsed. + #[cfg(test)] + builds: u64, +} + +impl UnmarkedPages { + pub fn renderer(&self) -> RenderPool { + RenderPool::new(self.bytes.clone(), 1, self.config.clone()) + } + + /// `page` at `scale`, on this thread. + pub fn render_page(&mut self, page: usize, scale: f32) -> Result { if !scale.is_finite() || scale <= 0.0 { return Err("preview scale must be finite and positive".into()); } - guard(|| { - let mut renderer = PageRenderer::new(self.bytes.clone(), self.config.clone()); - let out = renderer.render(RenderRequest { page: self.page, scale, ..Default::default() }); - if let Some(e) = &out.error { - return Err(e.clone()); + if self.parsed.is_none() { + self.parsed = Some(PageRenderer::new(self.bytes.clone(), self.config.clone())); + #[cfg(test)] + { + self.builds += 1; } - Ok(out) - })? + } + let Some(renderer) = self.parsed.as_mut() else { return Err("the document could not be parsed".into()) }; + let out = guard(|| renderer.render(RenderRequest { page, scale, ..Default::default() })); + let out = match out { + Ok(out) => out, + Err(e) => { + // A panic may have left it half-way: parse afresh next time. + self.parsed = None; + return Err(e); + } + }; + if let Some(e) = &out.error { + return Err(e.clone()); + } + Ok(out) } + + /// How many times [`Self::render_page`] has parsed the document. + #[cfg(test)] + pub(crate) fn parser_builds(&self) -> u64 { + self.builds + } +} + +/// `page` of `bytes` at `scale`, on this thread. +fn render_one(bytes: &Arc>, config: &RenderConfig, page: usize, scale: f32) -> Result { + if !scale.is_finite() || scale <= 0.0 { + return Err("preview scale must be finite and positive".into()); + } + guard(|| { + let mut renderer = PageRenderer::new(bytes.clone(), config.clone()); + let out = renderer.render(RenderRequest { page, scale, ..Default::default() }); + if let Some(e) = &out.error { + return Err(e.clone()); + } + Ok(out) + })? } impl Document { @@ -90,6 +148,33 @@ impl Document { })? } + /// The document without its marks of `kind`, on every page: what Update replaces them on, so + /// its preview doesn't draw the new marks over the old ones (or tint the old background). + /// Made once for all the pages a dialog steps through, as cloning and writing the document + /// costs about a save. `None` when the document has none, and the thumbnails already are + /// those pages. Read-only, like [`Self::text_edit_preview`]. + pub fn without_marks(&self, kind: crate::MarkKind) -> Result, String> { + if !self.marks().contains(&kind) { + return Ok(None); + } + guard(|| { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let mut cos = editor.cos.clone(); + let n = pdfcraft_model::pages(&cos).len(); + if pdfcraft_edit::remove_marks(&mut cos, &(0..n).collect::>(), kind).map_err(|e| e.to_string())? == 0 { + return Ok(None); + } + let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); + Ok(Some(UnmarkedPages { + bytes, + config: self.config.clone(), + parsed: None, + #[cfg(test)] + builds: 0, + })) + })? + } + /// An added item alone, on a transparent page: what follows the pointer while it is dragged, /// so the page under its first place doesn't come along. Read-only, like /// [`Self::added_content_preview`]. 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/export/src/as_markdown.rs b/crates/export/src/as_markdown.rs new file mode 100644 index 0000000000..a5c986c6b3 --- /dev/null +++ b/crates/export/src/as_markdown.rs @@ -0,0 +1,151 @@ +use super::as_html::allowed; +use super::layout::{Cell, Item, MAX_COLS, Page, items}; + +/// Markdown's deepest heading. +const MAX_HEADING: u8 = 6; + +/// `s` as Markdown text on one line: line breaks become spaces, control characters are dropped, +/// and every character Markdown would read as markup is escaped with a backslash. +fn md_text(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars().filter(|c| allowed(*c)) { + match c { + '\n' | '\r' | '\t' => o.push(' '), + '\\' | '`' | '*' | '_' | '[' | ']' | '<' | '>' | '#' | '|' | '~' | '!' | '&' => { + o.push('\\'); + o.push(c); + } + c => o.push(c), + } + } + o +} + +/// `line` with its start escaped where Markdown would read a list item there (`- `, `+ `, +/// `1. `, `1) `); the other block markers (`#`, `>`) are escaped by [`md_text`] already. +fn block_start(line: String) -> String { + let digits = line.bytes().take_while(u8::is_ascii_digit).count(); + match line.as_bytes().get(digits) { + Some(b'.' | b')') if digits > 0 => format!("{}\\{}", line.get(..digits).unwrap_or_default(), line.get(digits..).unwrap_or_default()), + Some(b'-' | b'+') if digits == 0 => format!("\\{line}"), + _ => line, + } +} + +/// `text` (already escaped) in bold and/or italic, its outer spaces kept outside. `open` and +/// `close` say whether `*` markers would be read as emphasis on that side (see [`marks`]); +/// where they would not, as in `foo` + bold `.` + `bar`, the run is wrapped in HTML tags, which +/// CommonMark takes inline. The text's own outer spaces count as a side that marks. +pub(crate) fn emphasis(text: &str, bold: bool, italic: bool, open: bool, close: bool) -> String { + let core = text.trim(); + if core.is_empty() || !(bold || italic) { + return text.to_string(); + } + let lead = text.get(..text.len().saturating_sub(text.trim_start().len())).unwrap_or_default(); + let trail = text.get(text.trim_end().len()..).unwrap_or_default(); + let markers = (open || !lead.is_empty()) && (close || !trail.is_empty()); + let (before, after) = match (markers, bold, italic) { + (true, true, true) => ("***", "***"), + (true, true, false) => ("**", "**"), + (true, false, _) => ("*", "*"), + (false, true, true) => ("", ""), + (false, true, false) => ("", ""), + (false, false, _) => ("", ""), + }; + format!("{lead}{before}{core}{after}{trail}") +} + +/// A paragraph's runs (text, bold, italic) as one line of Markdown. Neighbouring runs in the +/// same style are joined first, so two bold runs never meet as `****`. +pub(crate) fn paragraph(runs: &[(&str, bool, bool)]) -> String { + let mut merged: Vec<(String, bool, bool)> = Vec::new(); + for &(text, bold, italic) in runs { + match merged.last_mut() { + Some(last) if last.1 == bold && last.2 == italic => last.0.push_str(text), + _ => merged.push((text.to_string(), bold, italic)), + } + } + let texts: Vec = merged.iter().map(|(text, ..)| md_text(text)).collect(); + let mut out = String::new(); + for (k, ((_, bold, italic), text)) in merged.iter().zip(&texts).enumerate() { + let core = text.trim(); + let open = marks(core.chars().next(), out.chars().last()); + // Markers of a styled neighbour right next to these would join them into one run. + let close = match (merged.get(k.saturating_add(1)), texts.get(k.saturating_add(1))) { + (Some((_, b, i)), Some(next)) if (*b || *i) && !next.starts_with(char::is_whitespace) => false, + (_, next) => marks(core.chars().last(), next.and_then(|n| n.chars().next())), + }; + out.push_str(&emphasis(text, *bold, *italic, open, close)); + } + out +} + +/// Whether a `*` marker between the text's `edge` character and the `outside` one is read as +/// emphasis (CommonMark's flanking rules for `*`): always next to a space or the line's edge, +/// never next to another `*`, and otherwise unless the text's edge is punctuation while the +/// outside is a letter or digit. +fn marks(edge: Option, outside: Option) -> bool { + match (edge, outside) { + (_, None) => true, + (_, Some(o)) if o.is_whitespace() => true, + (_, Some('*')) => false, + (Some(e), Some(o)) => e.is_alphanumeric() || o.is_ascii_punctuation(), + (None, Some(_)) => false, + } +} + +fn cell(c: &Cell) -> String { + // A cell sits between `| ` and ` |`, so its markers always have a space outside. + emphasis(&md_text(&c.text), c.bold, c.italic, true, true) +} + +/// Markdown (CommonMark, with GitHub's pipe tables): headings, paragraphs with bold and italic, +/// tables with their first row as the header, and a rule between pages. Images are left out. +pub fn markdown(pages: &[Page]) -> String { + let mut blocks: Vec = Vec::new(); + for it in items(pages) { + match it { + Item::Para(runs, lvl) => { + // A heading's weight is the heading's own, as in the HTML export. + let runs: Vec<(&str, bool, bool)> = runs.iter().map(|r| (r.text.as_str(), r.block.bold && lvl == 0, r.block.italic)).collect(); + let text = paragraph(&runs); + let text = text.trim(); + if text.is_empty() { + continue; + } + blocks.push(match lvl { + 0 => block_start(text.to_string()), + l => format!("{} {text}", "#".repeat(usize::from(l.min(MAX_HEADING)))), + }); + } + Item::Table(t) => { + // Each row as wide as the widest; a cell spanning columns is followed by empty ones. + // Detected tables have at most MAX_COLS columns, and a span never counts for more. + let span = |c: &Cell| c.span.clamp(1, MAX_COLS); + let width = t.rows.iter().map(|r| r.iter().map(span).fold(0usize, usize::saturating_add)).max().unwrap_or(0); + if width == 0 { + continue; + } + let mut lines = Vec::with_capacity(t.rows.len().saturating_add(1)); + for (i, row) in t.rows.iter().enumerate() { + let mut cells: Vec = Vec::with_capacity(width); + for c in row { + cells.push(cell(c)); + cells.extend(std::iter::repeat_n(String::new(), span(c).saturating_sub(1))); + } + cells.resize(width, String::new()); + lines.push(format!("| {} |", cells.join(" | "))); + if i == 0 { + lines.push(format!("|{}", " --- |".repeat(width))); + } + } + blocks.push(lines.join("\n")); + } + Item::Img(_) => {} + Item::PageBreak => blocks.push("---".into()), + } + } + let mut s = blocks.join("\n\n"); + s.push('\n'); + s +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 5275b02439..db53ee59c8 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -1,4 +1,4 @@ -//! pdfcraft-export — Export a PDF ▸ Word, HTML, RTF (L4). +//! pdfcraft-export — Export a PDF ▸ Word, HTML, RTF, Markdown (L4). //! //! The engine reduces each page to [`Page`]: paragraphs (text, box, size, bold/italic) and //! images (encoded bytes and box), in reading order. The writers turn that into a flowing @@ -18,6 +18,7 @@ pub use zip::Zip; mod as_docx; mod as_html; +mod as_markdown; mod as_rtf; mod layout; @@ -29,6 +30,9 @@ pub(crate) use as_html::esc; pub use as_html::html; #[cfg(test)] use as_html::{allowed, base64}; +pub use as_markdown::markdown; +#[cfg(test)] +use as_markdown::{emphasis, paragraph}; pub use as_rtf::rtf; pub use layout::{Block, Cell, Image, Page, Table}; #[cfg(test)] diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index d735eb4c20..27cd8264e0 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -769,3 +769,75 @@ fn a_paragraph_is_a_heading_only_when_its_text_is() { assert!(!xml.contains("Heading2"), "the lead-in made its paragraph a heading: {xml}"); assert_eq!(word_paragraphs(&xml).len(), 2, "{xml}"); } + +/// #1595: Markdown gets the headings, paragraphs and a rule between pages, in reading order; +/// images are left out. +#[test] +fn markdown_has_headings_paragraphs_and_page_rules_in_order() { + let md = markdown(&[page(), page()]); + let order: Vec = ["# Annual Report\n\n", "## Overview\n\n", "Some \\ text \\& more", "Body text after the picture", "\n\n---\n\n"] + .iter() + .map(|x| md.find(x).unwrap_or_else(|| panic!("{x:?} missing in {md}"))) + .collect(); + assert!(order.windows(2).all(|w| w[0] < w[1]), "{md}"); + assert_eq!(md.matches("\n---\n").count(), 1, "one rule between two pages: {md}"); + assert_eq!(md.matches("# Annual Report").count(), 2, "{md}"); + assert!(!md.contains("PNG") && !md.contains("!["), "images are left out: {md}"); + assert!(md.ends_with(".\n") && !md.ends_with("\n\n"), "{md:?}"); +} + +/// #1595: text Markdown would read as markup stays text, emphasis wraps a run without its outer +/// spaces, and a detected table becomes a pipe table with its first row as the header. +#[test] +fn markdown_escapes_markup_and_keeps_emphasis_and_tables() { + let b = |t: &str, y: f64, bold: bool, italic: bool| Block { + text: t.into(), + rect: [72.0, y, 500.0, y + 11.0], + size: 11.0, + bold, + italic, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }; + let p = Page { + width: 612.0, + height: 792.0, + blocks: vec![ + b("- not a list item, and long enough to make eleven points the body size here", 700.0, false, false), + b("1. not a numbered item either", 650.0, false, false), + b("*stars* _under_ `tick` [link](x) a|b # #tag\u{1}\nnext", 600.0, false, false), + b("Strong words in a sentence of their own.", 550.0, true, false), + b("slanted", 500.0, false, true), + ], + images: Vec::new(), + background: None, + }; + let md = markdown(&[p]); + assert!(md.contains("\\- not a list item"), "{md}"); + assert!(md.contains("1\\. not a numbered item"), "{md}"); + assert!(md.contains("\\*stars\\* \\_under\\_ \\`tick\\` \\[link\\](x) a\\|b \\# \\#tag next"), "{md}"); + assert!(md.contains("**Strong words in a sentence of their own.**"), "{md}"); + // Markdown reads no emphasis where a marker sits next to a space: the outer spaces go outside. + assert_eq!(emphasis(" two words ", true, false, false, false), " **two words** "); + assert_eq!(emphasis("both", true, true, true, true), "***both***"); + assert_eq!(emphasis(" ", true, true, true, true), " ", "nothing to mark"); + // Runs in one style are one: two bold runs side by side don't meet as `****`. + assert_eq!(paragraph(&[("hello", true, false), ("world", true, false)]), "**helloworld**"); + // Markers inside a word would stay literal in CommonMark, so a run there gets HTML tags. + assert_eq!(paragraph(&[("foo", false, false), (".", true, false), ("bar", false, false)]), "foo.bar"); + assert_eq!(paragraph(&[("x", true, false), ("y", false, true)]), "x*y*", "two styles side by side"); + // `*` may sit inside a word, and before punctuation that follows a letter. + assert_eq!(paragraph(&[("foo", false, false), ("bar", true, false), ("baz", false, false)]), "foo**bar**baz"); + assert_eq!(paragraph(&[("a ", false, false), ("b", true, false), (" c", false, false)]), "a **b** c"); + assert_eq!(paragraph(&[("say ", false, false), ("this", false, true), (", then", false, false)]), "say *this*, then"); + assert!(md.contains("*slanted*"), "{md}"); + assert!(!md.chars().any(|c| !allowed(c)), "{md:?}"); + + let md = markdown(&[table_page()]); + let rows: Vec<&str> = md.lines().filter(|l| l.starts_with('|')).collect(); + assert_eq!(rows.first(), Some(&"| Meter | Unit | Reading |"), "{md}"); + assert_eq!(rows.get(1), Some(&"| --- | --- | --- |"), "{md}"); + assert!(rows.contains(&"| A1 | kWh | 37414.00 |"), "{md}"); + assert!(rows.iter().all(|r| r.matches(" | ").count() == 2), "every row has the header's three cells: {md}"); +} diff --git a/crates/filters/Cargo.toml b/crates/filters/Cargo.toml index 9d2ae34b94..aa8aaa0c4d 100644 --- a/crates/filters/Cargo.toml +++ b/crates/filters/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pdfcraft-filters" -description = "PDF stream filters (ISO 32000-2 §7.4): Flate, LZW, predictors, ASCII85, ASCIIHex, RunLength" +description = "PDF stream filters (ISO 32000-2 §7.4): Flate, LZW, predictors, ASCII85, ASCIIHex, RunLength, CCITT Group 4 encoding and Group 3 and 4 decoding" version.workspace = true edition.workspace = true license.workspace = true @@ -11,9 +11,14 @@ repository.workspace = true workspace = true [dependencies] +fax.workspace = true +# CCITT fax decoding (`decode_ccitt`); already in the tree through hayro. +hayro-ccitt.workspace = true flate2.workspace = true thiserror.workspace = true [dev-dependencies] +hayro-ccitt.workspace = true +hayro-jbig2.workspace = true proptest.workspace = true weezl.workspace = true diff --git a/crates/filters/README.md b/crates/filters/README.md index 959180d87a..0c34857c43 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -3,7 +3,8 @@ PDF stream filters: the decoders and encoders behind `/Filter` and `/DecodeParms`. - **Layer:** L0, standalone. No dependency on any other PdfCraft crate. `cos` depends on it (architecture §3 rule 3). -- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). +- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). CCITT Group 4 encoding (ITU-T T.6) is the `fax` crate's (MIT, already in the tree through `tiff`), checked here by round trips through an independent decoder; CCITT decoding (T.4 and T.6) is the `hayro-ccitt` crate's (MIT OR Apache-2.0, already in the tree through hayro), bounded here. JBIG2 generic region encoding is written from the public ISO/IEC 14492 Final Committee Draft (WG1 N1359, 1999); Annex H.2 checks the arithmetic coder byte for byte. + - `#![forbid(unsafe_code)]`. Builds for `wasm32-unknown-unknown`. ## API @@ -22,6 +23,11 @@ decode_tolerant(chain, data, max_output) -> Result<(Vec, bool /*partial*/), decode_tolerant_counted(chain, data, max_output) -> Result<(Vec, bool /*partial*/, usize /*decoded*/), (FilterError, usize /*decoded*/)> encode(filter, params, data) -> Result, FilterError> encode_flate(data) -> Vec // zlib, level 6 +encode_g4(data, columns, rows) -> Result, FilterError> // 1-bit rows as CCITT Group 4; columns <= MAX_G4_COLUMNS +pub struct CcittParams { k, columns, rows, end_of_line, encoded_byte_align, end_of_block, black_is_1 } // Table 11; Default = its defaults +decode_ccitt(data, ¶ms, max_rows) -> Result, FilterError> // CCITT fax as CCITTFaxDecode gives it; columns <= MAX_CCITT_COLUMNS +encode_jbig2_generic(data, columns, rows) -> Result, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode; columns <= MAX_JBIG2_COLUMNS +encode_jbig2(data, columns, rows) -> Result, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode, symbol coded when that is smaller; columns <= MAX_JBIG2_COLUMNS ``` - `chain` is applied first to last, as in a `/Filter` array. @@ -40,14 +46,23 @@ encode_flate(data) -> Vec // zlib, level 6 | ASCIIHexDecode | white space ignored, odd digit padded with 0, `>` EOD (optional), other characters → Corrupt | upper case, newline every 64 digits, `>` | | ASCII85Decode | `<~` prefix, `z`, white space, `~>` (or bare `~`, or none) EOD, final partial group; `z` inside a group, a 1-char final group, overflow or bad characters → Corrupt | `z` for zero groups, newline every 75 characters, `~>` | | RunLengthDecode | 0–127 literal, 129–255 repeat, 128 EOD (optional); truncated run → Corrupt | runs of ≥2 repeat, literals up to 128, 128 | +| CCITTFaxDecode | not in a chain (`Unsupported`); `decode_ccitt` decodes Group 3 and Group 4 on its own, as rows of 1-bit samples each on a new byte, black 0 unless `black_is_1`, at most `max_rows` rows (fewer with `/Rows`), the rows before any damage, up to 1,048,576 samples a row; Group 3 rows on bytes are read by their end-of-line codes when the data starts with one and on bytes otherwise, whatever `end_of_line` says, so data with codes but none before the first row (not as T.4 sends them) is read on bytes, as the renderer draws it, and at `k` 0 may come out as other rows (`pdfcraft_cos::Document::decoded_image` calls it for a fax image, after the filters before it) | `encode_g4`: Group 4 (`/K -1`) with an end-of-block mark, 0 bits coded black so `/BlackIs1 false` decodes the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 32,767 samples a row (the encoder counts positions in 16 bits); no rows or columns, or too wide → Unsupported, too few bytes → Corrupt | +| JBIG2Decode | not here (`Unsupported`; the renderer and image export decode it with `hayro-jbig2`) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt. `encode_jbig2`: the smaller of that and symbol coding (`jbig2/symbols.rs`): each distinct mark (8-connected black pixels, at most 256 a side) stored once in a symbol dictionary (7.4.2; arithmetic coded, each bitmap a template 0 generic region without typical prediction, the bitmaps sharing their contexts) and placed by immediate lossless text regions referring to it (7.4.3; bottom-left reference corner, strips of 1, 2, 4 or 8 rows, whichever is smallest), each of at most 10,000 instances over a band of rows, as the decoder PdfCraft renders with (`hayro-jbig2`) takes no more in one region, with larger marks, and in a second plan marks that occur once, in a generic region over their box. Only exact copies share a symbol (no refinement), so the stream decodes to the same bits; fewer than two symbols, more than 2^20 black runs, 65,536 distinct marks or 16 MiB of their pixels, or more than 2^24 rows, and the generic coding is used; a plan of more than 32,767 symbols (that decoder refuses more) isn't made | ## Tests - Unit tests per filter with hand-computed vectors (the ISO 32000-2 LZW example, RFC 1951 stored blocks, PNG/TIFF rows). - LZW is cross-checked against `weezl` (dev-dependency) in both directions for both early-change settings. - `tests/proptests.rs`: encode→decode round trips for every encodable filter × predictor × colors × bpc × columns, two-filter chains, output-limit checks, and fuzz-style no-panic tests (random bytes, random/nonsense params, bit-flipped valid streams). +- `ccitt.rs`: `decode_ccitt` gives back the rows `encode_g4` was given (with `/BlackIs1`, `/Rows` and a row limit), refuses bad widths, and bounds its output for any input; byte-aligned Group 3 in each layout, over 32 seeded pictures, decodes to its picture, and a byte short to fewer rows. Group 4 output decoded by `hayro-ccitt` (an independent decoder) matches the input bits: a text-like page, padding bits, the widest row, and property tests over bitmaps of noise and of runs; any input returns rather than panics. +- `mq.rs`: the arithmetic coder reproduces Annex H.2's 30 bytes from its 256 decisions. +- `jbig2.rs`: output decoded by `hayro-jbig2` (dev-dependency, an independent decoder) matches the input bits: a text-like page (smaller than Group 4), edges, padding, all-white and all-black regions (typical rows), and property tests over bitmaps of noise and of runs; any input returns rather than panics. +- `jbig2/symbols.rs`: the integer coders against Annex A's example and Table A.1; symbol-coded output decoded by `hayro-jbig2` matches the input bits for every plan and strip size: text whose letters repeat (under half the generic coding's size, the dictionary retained and referred to), large and lone marks left to a generic region, marks on the edges, touching at corners and inside each other's boxes, and property tests over glyphs scattered over each other and the edges with stray pixels (never larger than the generic coding); one shape, a blank page and noise are coded generically; marks joined at a corner, either way, are one mark, and 256 pixels a side is a symbol while 257 wide or tall is left out; black padding bits are ignored; coordinates past 4,435 use Annex A's 32-bit range and PREV stays below 512; past 2^20 runs, 2^24 rows, 65,536 distinct marks or 2^24 symbol pixels the image is coded generically, and at each cap it is still looked at; a plan codes at most 32,767 symbols, which decode, and past that the second plan still codes the repeated ones; more than 10,000 marks are placed by several text regions, numbered in turn, the dictionary retained for each but the last; any input returns rather than panics. - `fuzz/`: a detached cargo-fuzz crate (`cargo +nightly fuzz run decode`, run from `crates/filters/`). ## Status -M1.1/M1.2 for the general-purpose filters are done. Not yet here: the image codecs (DCT via zune-jpeg, JPX via hayro-jpeg2000, JBIG2, CCITT G3/G4 decode and G4 encode, DCT encode) and Flate encode levels other than 6. +M1.1/M1.2 for the general-purpose filters are done. Not yet here: the image decoders (DCT via zune-jpeg, JPX via hayro-jpeg2000, JBIG2), DCT encoding, CCITT Group 3 encoding and Flate encode levels other than 6. + + +Lossless symbol coding is also implemented: exact-copy dictionaries/text regions, bounded by the encoder; no approximate or lossy symbol substitution. diff --git a/crates/filters/src/ccitt.rs b/crates/filters/src/ccitt.rs new file mode 100644 index 0000000000..e87a82fe16 --- /dev/null +++ b/crates/filters/src/ccitt.rs @@ -0,0 +1,486 @@ +//! CCITT fax for `CCITTFaxDecode` (ISO 32000-2 §7.4.6): Group 4 (ITU-T T.6) encoding, with `/K -1`, +//! and decoding of Group 3 (T.4) and Group 4. The coding is the `fax` crate's and the decoding the +//! `hayro-ccitt` crate's; this module checks what they are given and bounds what they give. + +use fax::encoder::Encoder; +use fax::{Color, VecWriter}; + +use crate::FilterError; + +/// The widest row [`encode_g4`] encodes. The encoder counts the positions in a row in 16 bits +/// and adds two of them, so a row stays within half that range. +pub const MAX_G4_COLUMNS: u32 = 32_767; + +/// `rows` rows of `columns` 1-bit samples, most significant bit first, each row starting on a new +/// byte (as a PDF image's samples are packed), encoded as CCITT Group 4 with an end-of-block mark. +/// +/// A 0 bit is coded black and a 1 bit white, so the stream decodes to the same bits with +/// `/BlackIs1 false`, the default: an image keeps its colour space and decode array. Bytes after +/// the last row are ignored, as are the bits that pad a row to a whole byte. +/// +/// Errors: no rows or columns, or rows wider than [`MAX_G4_COLUMNS`] ([`FilterError::Unsupported`]); +/// fewer bytes than the rows need ([`FilterError::Corrupt`]). +pub fn encode_g4(data: &[u8], columns: u32, rows: u32) -> Result, FilterError> { + let width = match u16::try_from(columns) { + Ok(w) if w > 0 && columns <= MAX_G4_COLUMNS => w, + _ => return Err(FilterError::Unsupported(format!("CCITT Group 4 encoding of rows {columns} samples wide"))), + }; + if rows == 0 { + return Err(FilterError::Unsupported("CCITT Group 4 encoding of an image without rows".into())); + } + let stride = usize::from(width).div_ceil(8); + let samples = usize::try_from(rows).ok().and_then(|r| r.checked_mul(stride)).and_then(|n| data.get(..n)).ok_or_else(|| FilterError::Corrupt { + filter: "CCITTFaxDecode", + detail: format!("{} bytes for {rows} rows of {columns} samples", data.len()), + })?; + let mut encoder = Encoder::new(VecWriter::new()); + for row in samples.chunks_exact(stride) { + let pels = (0..usize::from(width)).map(|x| { + let white = row.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0); + if white { Color::White } else { Color::Black } + }); + // `VecWriter` writes to memory: its error type has no values. + if let Err(never) = encoder.encode_line(pels, width) { + match never {} + } + } + match encoder.finish() { + Ok(writer) => Ok(writer.finish()), + Err(never) => match never {}, + } +} + +/// The widest row [`decode_ccitt`] decodes: its row buffer stays within 128 KiB. +pub const MAX_CCITT_COLUMNS: u32 = 1 << 20; + +/// A `CCITTFaxDecode` stream's `/DecodeParms` (ISO 32000-2 Table 11). [`Default`] gives the +/// table's defaults. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CcittParams { + /// `/K`: negative for Group 4, 0 for Group 3 one-dimensional, positive for Group 3 mixed. + pub k: i64, + pub columns: i64, + /// `/Rows`: 0 when not given. + pub rows: i64, + pub end_of_line: bool, + pub encoded_byte_align: bool, + pub end_of_block: bool, + pub black_is_1: bool, +} + +impl Default for CcittParams { + fn default() -> Self { + CcittParams { k: 0, columns: 1728, rows: 0, end_of_line: false, encoded_byte_align: false, end_of_block: true, black_is_1: false } + } +} + +/// CCITT fax `data` decoded as `CCITTFaxDecode` gives it: rows of `columns` 1-bit samples, most +/// significant bit first, each starting on a new byte, with black as 0 (as 1 with `black_is_1`). +/// +/// At most `max_rows` rows are decoded (fewer when `rows` says so), so the output is bounded by +/// the caller. Damaged data gives the rows decoded before the damage, as viewers show them: a +/// caller that needs a number of rows checks it got them. +/// +/// Group 3 rows on bytes (`k` from 0, `encoded_byte_align`) are read by their end-of-line codes, +/// whether the fill starts each code on a byte or ends it on one, when `data` starts with one (T.4 +/// sends one before the first row when it sends them at all), and on bytes otherwise, whatever +/// `end_of_line` says. Group 4 is read as its parameters say. Data with codes but none before the +/// first row is read on bytes, as PdfCraft's renderer draws it; with `k` 0 and the fill ending each +/// code on a byte, that may be other rows than were coded. +/// +/// Errors: `columns` that aren't from 1 to [`MAX_CCITT_COLUMNS`] ([`FilterError::Unsupported`]). +pub fn decode_ccitt(data: &[u8], p: &CcittParams, max_rows: u32) -> Result, FilterError> { + let columns = u32::try_from(p.columns) + .ok() + .filter(|c| (1..=MAX_CCITT_COLUMNS).contains(c)) + .ok_or_else(|| FilterError::Unsupported(format!("CCITT fax rows {} samples wide", p.columns)))?; + let rows = u32::try_from(p.rows).ok().filter(|r| *r > 0).map_or(max_rows, |r| r.min(max_rows)); + if rows == 0 { + return Ok(Vec::new()); + } + let settings = hayro_ccitt::DecodeSettings { + columns, + rows, + end_of_block: p.end_of_block, + end_of_line: p.end_of_line, + // Group 3 rows on bytes may have end-of-line codes whatever `end_of_line` says (the filter + // always accepts them), with the fill starting each code on a byte or ending it on one (T.4's + // fill, as TIFF writes it). Read unaligned, the codes mark the rows either way; without + // codes, the rows are read on bytes. A reading that comes up short isn't tried the other + // way: data cut short would then read as other rows. + rows_are_byte_aligned: p.encoded_byte_align && (p.k < 0 || !starts_with_end_of_line(data)), + encoding: match p.k { + ..0 => hayro_ccitt::EncodingMode::Group4, + 0 => hayro_ccitt::EncodingMode::Group3_1D, + k => hayro_ccitt::EncodingMode::Group3_2D { k: u32::try_from(k).unwrap_or(u32::MAX) }, + }, + invert_black: false, + }; + // u32 to usize: wasm32 and up have at least 32-bit usize. + let mut out = Rows::new(columns as usize, rows as usize, p.black_is_1); + // The rows decoded before damaged data are kept. + let _ = hayro_ccitt::decode(data, &mut out, &mut hayro_ccitt::DecoderContext::new(settings)); + Ok(out.out) +} + +/// Whether CCITT fax `data` starts with an end-of-line code: a 1 after 11 zeros or more, fill +/// included. T.4 sends one before the first row when it sends them at all, and no other code starts +/// with more than eight zeros. +fn starts_with_end_of_line(data: &[u8]) -> bool { + let Some((i, b)) = data.iter().enumerate().find(|(_, b)| **b != 0) else { return false }; + i.saturating_mul(8).saturating_add(b.leading_zeros() as usize) >= 11 +} + +/// Rows of 1-bit samples filled from a decoder's pixels: `columns` a row, each row on a new byte, at +/// most `rows` rows. Pixels past a row's end and rows past the last are dropped. +struct Rows { + out: Vec, + row: Vec, + x: usize, + columns: usize, + rows: usize, + done: usize, + black_is_1: bool, +} + +impl Rows { + fn new(columns: usize, rows: usize, black_is_1: bool) -> Self { + Rows { out: Vec::new(), row: vec![0; columns.div_ceil(8)], x: 0, columns, rows, done: 0, black_is_1 } + } + + /// `count` pixels, white or black. + fn push(&mut self, white: bool, count: usize) { + let end = self.x.saturating_add(count); + if white != self.black_is_1 { + for x in self.x..end.min(self.columns) { + if let Some(b) = self.row.get_mut(x / 8) { + *b |= 0x80 >> (x % 8); + } + } + } + self.x = end; + } +} + +impl hayro_ccitt::Decoder for Rows { + fn push_pixel(&mut self, white: bool) { + self.push(white, 1); + } + fn push_pixel_chunk(&mut self, white: bool, chunks: u32) { + self.push(white, (chunks as usize).saturating_mul(8)); + } + fn next_line(&mut self) { + if self.done < self.rows { + self.out.extend_from_slice(&self.row); + self.done += 1; + } + self.row.fill(0); + self.x = 0; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The rows decoded by an independent decoder (`hayro-ccitt`), as 1 for white and 0 for black. + pub(crate) fn decode(data: &[u8], columns: u32, rows: u32) -> Vec> { + struct Rows(Vec>, Vec, usize); + impl hayro_ccitt::Decoder for Rows { + fn push_pixel(&mut self, white: bool) { + self.1.push(white); + } + fn push_pixel_chunk(&mut self, white: bool, chunks: u32) { + self.1.extend(std::iter::repeat_n(white, chunks as usize * 8)); + } + fn next_line(&mut self) { + let mut row = std::mem::take(&mut self.1); + row.truncate(self.2); + self.0.push(row); + } + } + let settings = hayro_ccitt::DecodeSettings { + columns, + rows, + end_of_block: true, + end_of_line: false, + rows_are_byte_aligned: false, + encoding: hayro_ccitt::EncodingMode::Group4, + invert_black: false, + }; + let mut out = Rows(Vec::new(), Vec::new(), columns as usize); + hayro_ccitt::decode(data, &mut out, &mut hayro_ccitt::DecoderContext::new(settings)).unwrap(); + out.0 + } + + /// The rows of packed samples `data`, as 1 for a 1 bit. + pub(crate) fn unpack(data: &[u8], columns: u32, rows: u32) -> Vec> { + let stride = (columns as usize).div_ceil(8); + (0..rows as usize).map(|y| (0..columns as usize).map(|x| data[y * stride + x / 8] & (0x80 >> (x % 8)) != 0).collect()).collect() + } + + #[test] + fn a_page_of_text_like_marks_round_trips_and_shrinks() { + let (w, h) = (1700u32, 220u32); + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + // Short black strokes in lines, like glyphs on a white page. + for y in (20..h as usize).step_by(40) { + for dy in 0..12 { + for x in (100..1600).step_by(9) { + if (x / 9 + dy) % 5 != 0 { + data[(y + dy) * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + } + let g4 = encode_g4(&data, w, h).unwrap(); + assert_eq!(decode(&g4, w, h), unpack(&data, w, h)); + assert!(g4.len() * 4 < data.len(), "{} bytes from {}", g4.len(), data.len()); + } + + #[test] + fn padding_bits_and_trailing_bytes_are_ignored() { + // 3 columns: the last 5 bits of each byte pad the row; set them to anything. + let data = [0b1010_1111, 0b0101_0000, 0b1110_1010, 0xAB, 0xCD]; + let g4 = encode_g4(&data, 3, 3).unwrap(); + assert_eq!(decode(&g4, 3, 3), [[true, false, true], [false, true, false], [true, true, true]]); + } + + #[test] + fn what_it_cannot_encode_is_an_error() { + assert!(matches!(encode_g4(&[0; 8], 0, 1), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_g4(&[0; 8], 8, 0), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_g4(&vec![0; 4097], MAX_G4_COLUMNS + 1, 1), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_g4(&[0; 3], 16, 2), Err(FilterError::Corrupt { .. })), "two rows of 16 need 4 bytes"); + assert!(matches!(encode_g4(&[], 1, u32::MAX), Err(FilterError::Corrupt { .. }))); + let widest = vec![0x55; (MAX_G4_COLUMNS as usize).div_ceil(8) * 2]; + let g4 = encode_g4(&widest, MAX_G4_COLUMNS, 2).unwrap(); + assert_eq!(decode(&g4, MAX_G4_COLUMNS, 2), unpack(&widest, MAX_G4_COLUMNS, 2)); + } + + /// Rows of `columns` samples built from `runs`: alternating white and black runs one longer + /// than those values (so never empty, even when shrinking makes them all 0), repeated until the + /// rows are full; or, with `noise`, the bytes of `runs` as they are. + fn bitmap(columns: u32, rows: u32, runs: &[u8], noise: bool) -> Vec { + let stride = (columns as usize).div_ceil(8); + let mut data = vec![0u8; stride * rows as usize]; + if runs.is_empty() { + return data; + } + if noise { + data.iter_mut().zip(runs.iter().cycle()).for_each(|(d, r)| *d = *r); + return data; + } + let (mut white, mut left, mut next) = (true, 1 + usize::from(runs[0]), 1); + for y in 0..rows as usize { + for x in 0..columns as usize { + if left == 0 { + white = !white; + left = 1 + usize::from(runs[next % runs.len()]); + next += 1; + } + if white { + data[y * stride + x / 8] |= 0x80 >> (x % 8); + } + left -= 1; + } + } + data + } + + fn g4(columns: u32) -> CcittParams { + CcittParams { k: -1, columns: i64::from(columns), ..CcittParams::default() } + } + + #[test] + fn decoding_gives_the_rows_back() { + // 3 columns, padded with anything: the decoded padding is 0. + let data = [0b1010_1111, 0b0101_0000, 0b1110_1010]; + let coded = encode_g4(&data, 3, 3).unwrap(); + assert_eq!(decode_ccitt(&coded, &g4(3), 3).unwrap(), [0b1010_0000, 0b0100_0000, 0b1110_0000]); + // /BlackIs1 gives black as 1. + let p = CcittParams { black_is_1: true, ..g4(3) }; + assert_eq!(decode_ccitt(&coded, &p, 3).unwrap(), [0b0100_0000, 0b1010_0000, 0b0000_0000]); + // At most /Rows rows, and at most max_rows. + assert_eq!(decode_ccitt(&coded, &CcittParams { rows: 2, ..g4(3) }, 3).unwrap().len(), 2); + assert_eq!(decode_ccitt(&coded, &g4(3), 1).unwrap().len(), 1); + assert_eq!(decode_ccitt(&coded, &CcittParams { rows: 3, ..g4(3) }, 1).unwrap().len(), 1, "max_rows bounds /Rows"); + assert_eq!(decode_ccitt(&coded, &g4(3), 0).unwrap(), [0u8; 0]); + // Damaged data gives the rows before the damage; none here. + assert!(decode_ccitt(&coded[..1], &g4(3), 3).unwrap().len() < 3); + } + + #[test] + fn what_it_cannot_decode_is_an_error() { + for columns in [0, -1, i64::from(MAX_CCITT_COLUMNS) + 1, i64::MAX] { + let p = CcittParams { columns, ..CcittParams::default() }; + assert!(matches!(decode_ccitt(&[0; 8], &p, 4), Err(FilterError::Unsupported(_))), "{columns}"); + } + assert_eq!(CcittParams::default().columns, 1728, "Table 11"); + } + + /// Group 3 data: rows of run lengths below 64, white first, coded one-dimensionally with T.4's + /// terminating codes (ITU-T T.4 Tables 2 and 3). Each row starts with an end-of-line code with + /// `eol`, then with `tag` a 1 bit (the next row one-dimensional, as `k` above 0 has it). Fill + /// bits (0s) go where `fill` says. + fn group_3(rows: &[Vec], eol: bool, tag: bool, fill: Fill) -> Vec { + // The codes for runs 0 to 63, in order. + const WHITE: &str = concat!( + "00110101 000111 0111 1000 1011 1100 1110 1111 10011 10100 00111 01000 001000 000011 110100 110101 101010 101011 ", + "0100111 0001100 0001000 0010111 0000011 0000100 0101000 0101011 0010011 0100100 0011000 00000010 00000011 00011010 ", + "00011011 00010010 00010011 00010100 00010101 00010110 00010111 00101000 00101001 00101010 00101011 00101100 00101101 ", + "00000100 00000101 00001010 00001011 01010010 01010011 01010100 01010101 00100100 00100101 01011000 01011001 01011010 ", + "01011011 01001010 01001011 00110010 00110011 00110100", + ); + const BLACK: &str = concat!( + "0000110111 010 11 10 011 0011 0010 00011 000101 000100 0000100 0000101 0000111 00000100 00000111 000011000 0000010111 ", + "0000011000 0000001000 00001100111 00001101000 00001101100 00000110111 00000101000 00000010111 00000011000 ", + "000011001010 000011001011 000011001100 000011001101 000001101000 000001101001 000001101010 000001101011 000011010010 ", + "000011010011 000011010100 000011010101 000011010110 000011010111 000001101100 000001101101 000011011010 000011011011 ", + "000001010100 000001010101 000001010110 000001010111 000001100100 000001100101 000001010010 000001010011 000000100100 ", + "000000110111 000000111000 000000100111 000000101000 000001011000 000001011001 000000101011 000000101100 000001011010 ", + "000001100110 000001100111", + ); + let code = |white: bool, run: u32| (if white { WHITE } else { BLACK }).split(' ').nth(run as usize).expect("a run below 64"); + let mut bits = String::new(); + for row in rows { + // The end-of-line code is 12 bits. + let ends_code = if fill == Fill::CodeEnd && eol { 12 } else { 0 }; + while fill != Fill::None && !(bits.len() + ends_code).is_multiple_of(8) { + bits.push('0'); + } + if eol { + bits.push_str("000000000001"); + } + if tag { + bits.push('1'); + } + row.iter().enumerate().for_each(|(i, run)| bits.push_str(code(i % 2 == 0, *run))); + } + bits.as_bytes().chunks(8).map(|c| c.iter().enumerate().fold(0u8, |b, (i, d)| b | (u8::from(*d == b'1') << (7 - i)))).collect() + } + + /// Where [`group_3`] puts fill bits: none; so each row, its end-of-line code first, starts on a + /// byte; or so each end-of-line code ends on one (T.4's fill, as TIFF writes it). + #[derive(Clone, Copy, Debug, PartialEq)] + enum Fill { + None, + RowStart, + CodeEnd, + } + + /// `rows` text-like rows of `columns` samples as runs, white first, from `seed`: long white runs + /// and short black ones; a row may start black. + fn text_like(seed: u64, columns: u32, rows: u32) -> Vec> { + let mut seed = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1; + let mut random = |n: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % n) as u32 + }; + (0..rows) + .map(|_| { + let (mut runs, mut left) = (Vec::new(), columns); + while left > 0 { + let run = match runs.len() % 2 { + 0 if runs.is_empty() => random(31), + 0 => 1 + random(30), + _ => 1 + random(5), + }; + runs.push(run.min(left)); + left -= run.min(left); + } + runs + }) + .collect() + } + + /// Rows of runs, white first, packed as `decode_ccitt` gives them (white 1). + fn pack(runs: &[Vec], columns: u32) -> Vec { + let stride = (columns as usize).div_ceil(8); + let mut out = vec![0u8; stride * runs.len()]; + for (y, row) in runs.iter().enumerate() { + let mut x = 0; + for (i, run) in row.iter().enumerate() { + if i % 2 == 0 { + (x..x + *run as usize).for_each(|x| out[y * stride + x / 8] |= 0x80 >> (x % 8)); + } + x += *run as usize; + } + } + out + } + + /// Group 3 rows on bytes in each layout, over many pictures, as how a misreading comes out + /// depends on the picture: no codes (as TIFF's Modified Huffman has it), and `end_of_line` + /// without them; codes, each starting on a byte or ending on one, `end_of_line` or not. Each + /// decodes to its picture, and a byte short to fewer rows, not to other rows read another way. + /// Group 4 rows on bytes keep their aligned read, `end_of_line` or not. + #[test] + fn group_3_rows_on_bytes_read_by_the_codes_in_the_data() { + for seed in 0..32u64 { + let (columns, rows) = (8 + (seed as u32 * 37) % 200, 2 + seed as u32 % 20); + let runs = text_like(seed, columns, rows); + let picture = pack(&runs, columns); + for k in [0, 1] { + for (codes, fill, end_of_line) in [ + (false, Fill::RowStart, false), + (false, Fill::RowStart, true), + (true, Fill::RowStart, false), + (true, Fill::RowStart, true), + (true, Fill::CodeEnd, false), + (true, Fill::CodeEnd, true), + ] { + let p = CcittParams { k, columns: i64::from(columns), end_of_line, encoded_byte_align: true, ..CcittParams::default() }; + let mut data = group_3(&runs, codes, k > 0, fill); + let case = format!("seed {seed}, {columns} × {rows}, k {k}, codes {codes}, fill {fill:?}, end_of_line {end_of_line}"); + assert_eq!(decode_ccitt(&data, &p, rows).unwrap(), picture, "{case}"); + data.pop(); + assert!(decode_ccitt(&data, &p, rows).unwrap().len() < picture.len(), "{case}, a byte short"); + } + } + } + // White rows, each the one code 1 (vertical, no change) and its fill. + for end_of_line in [false, true] { + let p = CcittParams { k: -1, columns: 64, end_of_line, encoded_byte_align: true, ..CcittParams::default() }; + assert_eq!(decode_ccitt(&[0x80; 16], &p, 16).unwrap(), [0xFF; 8 * 16], "Group 4, end_of_line {end_of_line}"); + } + } + + proptest::proptest! { + /// Any bitmap, of noise or of runs, decodes to its own bits, through an independent decoder + /// and through `decode_ccitt`. + #[test] + fn bitmaps_round_trip( + columns in 1u32..400, + rows in 1u32..24, + runs in proptest::collection::vec(0u8..=255, 1..48), + noise in proptest::bool::ANY, + ) { + let data = bitmap(columns, rows, &runs, noise); + let coded = encode_g4(&data, columns, rows).unwrap(); + proptest::prop_assert_eq!(decode(&coded, columns, rows), unpack(&data, columns, rows)); + let decoded = decode_ccitt(&coded, &g4(columns), rows).unwrap(); + proptest::prop_assert_eq!(unpack(&decoded, columns, rows), unpack(&data, columns, rows)); + } + + /// Whatever it is given, it returns. + #[test] + fn any_input_returns( + data in proptest::collection::vec(0u8..=255, 0..600), + columns in 0u32..40_000, + rows in 0u32..80, + k in -2i64..3, + end_of_line in proptest::bool::ANY, + encoded_byte_align in proptest::bool::ANY, + ) { + let _ = encode_g4(&data, columns, rows); + let p = CcittParams { k, columns: i64::from(columns), end_of_line, encoded_byte_align, ..CcittParams::default() }; + if let Ok(out) = decode_ccitt(&data, &p, rows) { + proptest::prop_assert!(out.len() <= (columns as usize).div_ceil(8) * rows as usize); + } + } + } +} diff --git a/crates/filters/src/jbig2.rs b/crates/filters/src/jbig2.rs new file mode 100644 index 0000000000..343fbbd27d --- /dev/null +++ b/crates/filters/src/jbig2.rs @@ -0,0 +1,368 @@ +//! JBIG2 encoding (ITU-T T.88 | ISO/IEC 14492), for `JBIG2Decode` (ISO 32000-2 §7.4.7), lossless: +//! a generic region, template 0 with its nominal adaptive pixels, typical prediction on, MQ +//! arithmetic coding ([`crate::mq`]); or the image's marks as symbols ([`symbols`]). Written from +//! the standard's 6.2.5 (coding a generic region), 7.2 (segment headers), 7.4.1, 7.4.6 and 7.4.8 +//! (the segments) and D.3 (the embedded organisation PDF uses). + +mod symbols; + +use crate::FilterError; +use crate::mq::{Context, MqEncoder}; + +/// The widest row [`encode_jbig2_generic`] encodes. JBIG2 allows 32-bit widths; this keeps a +/// row's buffers small and its positions in range. +pub const MAX_JBIG2_COLUMNS: u32 = 1 << 20; + +/// Template 0's pixels (Figure 3), as (x, y) offsets from the pixel being coded, each a bit of the +/// context in this order: the four on the current row, the five fixed ones on the row above, the +/// three on the row above that, then the adaptive pixels A1–A4 at their nominal places (Table 5). +const TEMPLATE: [(i32, i32); 16] = [ + (-1, 0), + (-2, 0), + (-3, 0), + (-4, 0), + (-2, -1), + (-1, -1), + (0, -1), + (1, -1), + (2, -1), + (-1, -2), + (0, -2), + (1, -2), + (3, -1), + (-3, -1), + (2, -2), + (-2, -2), +]; + +/// The context that codes SLTP when template 0 is used (Figure 8): those pixel values, in +/// [`TEMPLATE`]'s order. Figure 8 reads, by row from x = -4: `0 1 0 1` on the current row, +/// `0 1 1 0 0 1 0` (x = -3 to 3) above it, `1 0 0 1 1` (x = -2 to 2) above that. +const SLTP: [u8; 16] = [1, 0, 1, 0, 1, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1]; + +/// Columns of padding either side of a row, so every template pixel of a row's pixels is in it. +const PAD: usize = 4; + +/// `rows` rows of `columns` 1-bit PDF samples as lossless JBIG2, the smaller of two codings: +/// [`encode_jbig2_generic`]'s, or one that stores each distinct mark of the image once, as a +/// symbol, and places its copies (see [`symbols`]). Either way the stream is in the embedded +/// organisation and decodes to the same bits without `/JBIG2Globals`. +/// +/// Errors: as [`encode_jbig2_generic`]. +pub fn encode_jbig2(data: &[u8], columns: u32, rows: u32) -> Result, FilterError> { + let generic = encode_jbig2_generic(data, columns, rows)?; + // `encode_jbig2_generic` checked that the rows are there. + let stride = (columns as usize).div_ceil(8); + let samples = usize::try_from(rows).ok().and_then(|r| r.checked_mul(stride)).and_then(|n| data.get(..n)); + Ok(match samples.and_then(|s| symbols::encode(s, columns, rows)) { + Some(coded) if coded.len() < generic.len() => coded, + _ => generic, + }) +} + +/// `rows` rows of `columns` 1-bit PDF samples (most significant bit first, each row starting on +/// a new byte), as a JBIG2 stream in the embedded organisation: a page information segment and an +/// immediate lossless generic region segment, which `JBIG2Decode` decodes to the same bits +/// without `/JBIG2Globals`. +/// +/// A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, as a grey image's +/// samples have it. Bytes after the last row are ignored, as are the bits that pad a row to a whole +/// byte. +/// +/// Errors: no rows or columns, or rows wider than [`MAX_JBIG2_COLUMNS`] +/// ([`FilterError::Unsupported`]); fewer bytes than the rows need ([`FilterError::Corrupt`]). +pub fn encode_jbig2_generic(data: &[u8], columns: u32, rows: u32) -> Result, FilterError> { + if columns == 0 || rows == 0 || columns > MAX_JBIG2_COLUMNS { + return Err(FilterError::Unsupported(format!("JBIG2 encoding of {columns} × {rows} samples"))); + } + let width = columns as usize; + let stride = width.div_ceil(8); + let samples = usize::try_from(rows).ok().and_then(|r| r.checked_mul(stride)).and_then(|n| data.get(..n)).ok_or_else(|| FilterError::Corrupt { + filter: "JBIG2Decode", + detail: format!("{} bytes for {rows} rows of {columns} samples", data.len()), + })?; + let mut contexts: Vec = vec![0; 1 << 16]; + let mut mq = MqEncoder::new(); + let height = samples.len() / stride; + code_generic(&mut mq, &mut contexts, width, height, true, |y, row| { + let packed = samples.get(y * stride..).unwrap_or_default(); + for (x, pixel) in row.iter_mut().enumerate() { + let sample = packed.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0); + *pixel = u8::from(!sample); + } + }); + let region = mq.finish(); + let mut out = Vec::with_capacity(region.len() + 64); + segment(&mut out, 0, 48, &[], false, &page_information(columns, rows)); + segment(&mut out, 1, 39, &[], false, &generic_region(columns, rows, 0, 0, ®ion)); + Ok(out) +} + +/// A page information segment's data (7.4.8): the page's size, resolution unknown, eventually +/// lossless, default pixel 0 (white), OR combination, not striped. +fn page_information(columns: u32, rows: u32) -> Vec { + let mut page = Vec::with_capacity(19); + page.extend_from_slice(&columns.to_be_bytes()); + page.extend_from_slice(&rows.to_be_bytes()); + page.extend_from_slice(&[0; 8]); + page.push(0x01); + page.extend_from_slice(&[0, 0]); + page +} + +/// A region segment information field (7.4.1): a `width` × `height` region at (`x`, `y`) on the +/// page, combined with it by OR. +fn region_information(out: &mut Vec, width: u32, height: u32, x: u32, y: u32) { + for v in [width, height, x, y] { + out.extend_from_slice(&v.to_be_bytes()); + } + out.push(0); +} + +/// An immediate lossless generic region segment's data (7.4.6): its region information, flags +/// (MMR 0, template 0, TPGDON 1), the AT pixels at their nominal places, then `coded`. +fn generic_region(width: u32, height: u32, x: u32, y: u32, coded: &[u8]) -> Vec { + let mut data = Vec::with_capacity(26 + coded.len()); + region_information(&mut data, width, height, x, y); + data.push(0x08); + at_pixels(&mut data); + data.extend_from_slice(coded); + data +} + +/// Template 0's four AT pixels at their nominal places, as AT flags (7.4.6.3, 7.4.2.1.2). +fn at_pixels(out: &mut Vec) { + for (x, y) in &TEMPLATE[12..] { + out.push(*x as i8 as u8); + out.push(*y as i8 as u8); + } +} + +/// A segment (7.2): its header (number, type, the segments it refers to, page 1, data length), +/// then its data. `retained`: a later segment refers to this one. `refers_to` holds each +/// referred-to segment's number and whether a segment after this one refers to it too (its +/// retain bit, 7.2.4). Segment numbers here stay below 256, so each referred-to one takes a byte +/// (7.2.5); a segment refers to at most four, so their count and retain bits take one byte too. +fn segment(out: &mut Vec, number: u32, kind: u8, refers_to: &[(u8, bool)], retained: bool, data: &[u8]) { + out.extend_from_slice(&number.to_be_bytes()); + out.push(kind); + // At most four, so the count fits its three bits. + let refers_to = refers_to.get(..refers_to.len().min(4)).unwrap_or_default(); + let kept = refers_to.iter().enumerate().fold(0u8, |bits, (k, (_, kept))| bits | u8::from(*kept) << (k + 1)); + out.push((refers_to.len() as u8) << 5 | kept | u8::from(retained)); + out.extend(refers_to.iter().map(|(n, _)| *n)); + out.push(1); + // A segment's data never comes near 4 GiB here: a row is at most 1 MiB wide. + out.extend_from_slice(&u32::try_from(data.len()).unwrap_or(u32::MAX).to_be_bytes()); + out.extend_from_slice(data); +} + +/// Codes a `width` × `height` bitmap as a generic region (6.2.5.7) with template 0 and the nominal +/// AT pixels, its decisions in `contexts` (2^16 of them), with typical prediction when `typical` +/// (TPGDON). `fill(y, row)` sets row `y`'s `width` pixels, 1 for black. Pixels outside the bitmap +/// count as 0 (white), as the decoder takes them. +fn code_generic(mq: &mut MqEncoder, contexts: &mut [Context], width: usize, height: usize, typical: bool, mut fill: impl FnMut(usize, &mut [u8])) { + let sltp = SLTP.iter().enumerate().fold(0usize, |cx, (k, v)| cx | usize::from(*v) << k); + // The two rows above the current one and the current one, as JBIG2 pixels (1 black), padded. + let blank = vec![0u8; width + 2 * PAD]; + let (mut above2, mut above1) = (blank.clone(), blank.clone()); + let mut row = blank; + let mut ltp = false; + for y in 0..height { + if let Some(pixels) = row.get_mut(PAD..PAD + width) { + fill(y, pixels); + } + if typical { + // Typical prediction (6.2.5.5): a row the same as the one above is coded as one decision. + let same = row == above1; + if let Some(cx) = contexts.get_mut(sltp) { + mq.encode(cx, same != ltp); + } + ltp = same; + } + if !ltp { + for x in 0..width { + let at = |dx: i32, dy: i32| { + let line = match dy { + 0 => &row, + -1 => &above1, + _ => &above2, + }; + let i = (PAD + x).checked_add_signed(dx as isize); + i.and_then(|i| line.get(i)).copied().unwrap_or(0) + }; + let cx = TEMPLATE.iter().enumerate().fold(0usize, |cx, (k, (dx, dy))| cx | usize::from(at(*dx, *dy)) << k); + if let Some(state) = contexts.get_mut(cx) { + mq.encode(state, row.get(PAD + x) == Some(&1)); + } + } + } + std::mem::swap(&mut above2, &mut above1); + std::mem::swap(&mut above1, &mut row); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The rows an independent decoder (`hayro-jbig2`) reads from `data`, as PDF samples: true + /// for white (a JBIG2 0). + pub(super) fn decode(data: &[u8]) -> Vec> { + struct Rows(Vec>, Vec, usize); + impl hayro_jbig2::Decoder for Rows { + fn push_pixel(&mut self, black: bool) { + self.1.push(!black); + } + fn push_pixel_chunk(&mut self, black: bool, chunks: u32) { + self.1.extend(std::iter::repeat_n(!black, chunks as usize * 8)); + } + fn next_line(&mut self) { + let mut row = std::mem::take(&mut self.1); + row.truncate(self.2); + self.0.push(row); + } + } + let image = hayro_jbig2::Image::new_embedded(data, None).unwrap(); + let mut rows = Rows(Vec::new(), Vec::new(), image.width() as usize); + image.decode(&mut rows).unwrap(); + rows.0 + } + + /// The rows of packed samples `data`, true for a 1 bit. + pub(super) fn unpack(data: &[u8], columns: u32, rows: u32) -> Vec> { + let stride = (columns as usize).div_ceil(8); + (0..rows as usize).map(|y| (0..columns as usize).map(|x| data[y * stride + x / 8] & (0x80 >> (x % 8)) != 0).collect()).collect() + } + + /// Rows of `columns` samples from `runs`: alternating white and black runs one longer than + /// those values (never empty, even when shrinking makes them all 0), until the rows are full; + /// or, with `noise`, the bytes of `runs` as they are. + fn bitmap(columns: u32, rows: u32, runs: &[u8], noise: bool) -> Vec { + let stride = (columns as usize).div_ceil(8); + let mut data = vec![0u8; stride * rows as usize]; + if runs.is_empty() { + return data; + } + if noise { + data.iter_mut().zip(runs.iter().cycle()).for_each(|(d, r)| *d = *r); + return data; + } + let (mut white, mut left, mut next) = (true, 1 + usize::from(runs[0]), 1); + for y in 0..rows as usize { + for x in 0..columns as usize { + if left == 0 { + white = !white; + left = 1 + usize::from(runs[next % runs.len()]); + next += 1; + } + if white { + data[y * stride + x / 8] |= 0x80 >> (x % 8); + } + left -= 1; + } + } + data + } + + #[test] + fn a_page_of_text_like_marks_round_trips_and_shrinks() { + let (w, h) = (1700u32, 220u32); + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + for y in (20..h as usize).step_by(40) { + for dy in 0..12 { + for x in (100..1600).step_by(9) { + if (x / 9 + dy) % 5 != 0 { + data[(y + dy) * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + } + let jbig2 = encode_jbig2_generic(&data, w, h).unwrap(); + assert_eq!(decode(&jbig2), unpack(&data, w, h)); + let g4 = crate::encode_g4(&data, w, h).unwrap(); + assert!(jbig2.len() < g4.len(), "JBIG2 {} bytes, Group 4 {}", jbig2.len(), g4.len()); + } + + #[test] + fn edges_padding_and_typical_rows_round_trip() { + // 3 columns: the last 5 bits of each byte pad the row; set them to anything. + let data = [0b1010_1111, 0b0101_0000, 0b1110_1010, 0xAB, 0xCD]; + assert_eq!(decode(&encode_jbig2_generic(&data, 3, 3).unwrap()), [[true, false, true], [false, true, false], [true, true, true]]); + for (w, h, fill) in [(1, 1, 0x00), (1, 1, 0xFF), (13, 9, 0x00), (13, 9, 0xFF), (64, 40, 0x5A)] { + let data = vec![fill; (w as usize).div_ceil(8) * h as usize]; + assert_eq!(decode(&encode_jbig2_generic(&data, w, h).unwrap()), unpack(&data, w, h), "{w} × {h} of {fill:#04x}"); + } + } + + /// Packed samples from JBIG2 pixels (1 black): a 0 sample for each 1. + pub(super) fn samples(pixels: &[Vec]) -> Vec { + let width = pixels.first().map_or(0, Vec::len); + let stride = width.div_ceil(8); + let mut data = vec![0xFF; stride * pixels.len()]; + for (y, row) in pixels.iter().enumerate() { + for (x, p) in row.iter().enumerate() { + if *p == 1 { + data[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + data + } + + /// A row the same as the one above is typical (6.2.5.5): one decision, not a row of pixels. + #[test] + fn a_repeated_row_is_one_decision() { + let row: Vec = (0..800u32).map(|x| u8::from((x * 7919 + x / 3) % 5 < 2)).collect(); + let pixels = vec![row; 1000]; + let data = samples(&pixels); + crate::mq::DECISIONS.with(|n| n.set(0)); + let jbig2 = encode_jbig2_generic(&data, 800, 1000).unwrap(); + // The first row's 800 pixels, and one typical-row decision for each of the 1000 rows. + assert_eq!(crate::mq::DECISIONS.with(|n| n.get()), 800 + 1000); + assert_eq!(decode(&jbig2), unpack(&data, 800, 1000)); + } + + /// SLTP is coded in the context Figure 8 shows, which ordinary pixels can share: an image + /// tiled with that very neighbourhood decodes only if both coders use the same context. + #[test] + fn the_typical_row_flag_shares_figure_8s_context() { + // Figure 8, around a pixel at x = 4 of an 8-column tile: two rows up x = 2..6, one row up + // x = 1..7, the pixel's own row x = 0..3. + let tile = [[0, 0, 1, 0, 0, 1, 1, 0], [0, 0, 1, 1, 0, 0, 1, 0], [0, 1, 0, 1, 1, 0, 0, 0]]; + let pixels: Vec> = (0..90).map(|y| (0..400).map(|x| tile[y % 3][x % 8]).collect()).collect(); + let data = samples(&pixels); + assert_eq!(decode(&encode_jbig2_generic(&data, 400, 90).unwrap()), unpack(&data, 400, 90)); + } + + #[test] + fn what_it_cannot_encode_is_an_error() { + assert!(matches!(encode_jbig2_generic(&[0; 8], 0, 1), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_jbig2_generic(&[0; 8], 8, 0), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_jbig2_generic(&[0; 8], MAX_JBIG2_COLUMNS + 1, 1), Err(FilterError::Unsupported(_)))); + assert!(matches!(encode_jbig2_generic(&[0; 3], 16, 2), Err(FilterError::Corrupt { .. })), "two rows of 16 need 4 bytes"); + assert!(matches!(encode_jbig2_generic(&[], 1, u32::MAX), Err(FilterError::Corrupt { .. }))); + } + + proptest::proptest! { + /// Any bitmap, of noise or of runs, decodes to its own bits through an independent decoder. + #[test] + fn bitmaps_round_trip( + columns in 1u32..300, + rows in 1u32..24, + runs in proptest::collection::vec(0u8..=255, 1..48), + noise in proptest::bool::ANY, + ) { + let data = bitmap(columns, rows, &runs, noise); + let jbig2 = encode_jbig2_generic(&data, columns, rows).unwrap(); + proptest::prop_assert_eq!(decode(&jbig2), unpack(&data, columns, rows)); + } + + /// Whatever it is given, it returns. + #[test] + fn any_input_returns(data in proptest::collection::vec(0u8..=255, 0..600), columns in 0u32..5000, rows in 0u32..80) { + let _ = encode_jbig2_generic(&data, columns, rows); + } + } +} diff --git a/crates/filters/src/jbig2/symbols.rs b/crates/filters/src/jbig2/symbols.rs new file mode 100644 index 0000000000..e9fed24441 --- /dev/null +++ b/crates/filters/src/jbig2/symbols.rs @@ -0,0 +1,1029 @@ +//! JBIG2 symbol coding, lossless: each distinct mark of an image (a group of black pixels that +//! touch, side by side or at a corner) is stored once, as a symbol in a symbol dictionary, and +//! text regions place a copy of it wherever it occurs. Marks too large to be symbols (rules, +//! frames, pictures) are left to a generic region beside them, and so, in [`encode`]'s second +//! plan, are marks that occur only once. Only exact copies share a symbol, so the image decodes to the +//! same bits. Written from the committee draft's 6.4 (the text region decoding procedure), 6.5 +//! (the symbol dictionary decoding procedure), 7.4.2 and 7.4.3 (their segments) and Annex A (the +//! arithmetic integer coders). + +use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::ops::Range; + +use super::{at_pixels, code_generic, generic_region, page_information, region_information, segment}; +use crate::mq::{Context, MqEncoder}; + +/// The longest side of a mark stored as a symbol. A larger one is left to the generic region. +const MAX_SYMBOL_SIDE: u32 = 256; + +/// The most black runs looked at. Past it (halftones, noise) the image is left to generic coding, +/// which suits it better anyway; this bounds the memory the marks take. +const MAX_RUNS: usize = 1 << 20; + +/// The most distinct marks sorted into symbols, and the most pixels their bitmaps may hold +/// together: past either the image is left to generic coding. These bound the memory they take. +const MAX_SYMBOLS: usize = 1 << 16; +const MAX_SYMBOL_PIXELS: usize = 1 << 24; + +/// The most symbols one plan codes. The decoder PdfCraft renders JBIG2 with (`hayro-jbig2`) +/// refuses 32,768 symbols or more, and others may too, so a plan with more isn't made. +const MAX_CODED_SYMBOLS: usize = (1 << 15) - 1; + +/// The most symbol instances one text region places. The decoder PdfCraft renders JBIG2 with +/// refuses a text region of more than 10,000 (a page it then draws blank), so a page with more is +/// placed by several regions, each over a band of rows, all referring to the one dictionary. +const MAX_REGION_INSTANCES: usize = 10_000; + +// Segment numbers stay at most 256, so a referred-to one takes a byte (7.2.5): the page, the +// dictionary, a text region for each MAX_REGION_INSTANCES of at most MAX_RUNS marks, and the +// generic region. +const _: () = assert!(MAX_RUNS.div_ceil(MAX_REGION_INSTANCES) + 3 <= 256); + +/// The most rows symbol coded. With rows at most 2^20 samples wide, every coordinate and +/// difference coded then fits the 32-bit integers Annex A codes. +const MAX_ROWS: u32 = 1 << 24; + +/// The integer coders' ranges (Table A.1): how many 1 bits pick the range, how many bits give the +/// offset into it, and its first value. The last range has no 0 after its prefix. +const RANGES: [(u32, u32, u32); 6] = [(0, 2, 0), (1, 4, 4), (2, 6, 20), (3, 8, 84), (4, 12, 340), (5, 32, 4436)]; + +/// A run of black pixels on row `y`, from `x0` up to but not including `x1`. +#[derive(Clone, Copy, Debug)] +struct Run { + y: u32, + x0: u32, + x1: u32, +} + +/// A mark: its box (`x1` and `y1` just past it) and its runs, as a range of the run order +/// [`marks`] gives. +#[derive(Debug)] +struct Mark { + x0: u32, + x1: u32, + y0: u32, + y1: u32, + runs: Range, +} + +/// A distinct mark's bitmap, a byte a pixel (1 black), and how many marks are exact copies of it. +#[derive(Debug)] +struct Symbol { + width: u32, + height: u32, + pixels: Vec, + uses: u32, +} + +/// An image's marks, sorted out: its black runs, its marks with the run order they index, the +/// distinct marks of at most [`MAX_SYMBOL_SIDE`] a side as symbols, each such mark as (its +/// symbol, the mark), and the marks too large to be symbols. +struct Page { + runs: Vec, + marks: Vec, + order: Vec, + symbols: Vec, + copies: Vec<(u32, u32)>, + large: Vec, +} + +/// A symbol instance (6.4.5): the symbol, and where its bottom-left pixel goes on the page, as S +/// (the column) and T (the row). `width` and `height` are the symbol's. +#[derive(Clone, Copy, Debug)] +struct Instance { + id: u32, + s: u32, + t: u32, + width: u32, + height: u32, +} + +/// `rows` rows of `columns` 1-bit PDF samples (black 0), symbol coded: the smaller of two plans, +/// every mark of at most [`MAX_SYMBOL_SIDE`] a side as a symbol, or only those with a copy. +/// `None` when neither plan has from two to [`MAX_CODED_SYMBOLS`] symbols, or the image is too +/// large or too busy to look at. +pub(super) fn encode(samples: &[u8], columns: u32, rows: u32) -> Option> { + let page = page(samples, columns, rows)?; + [1, 2].into_iter().filter_map(|copies| plan(&page, columns, rows, copies, None)).min_by_key(Vec::len) +} + +/// The image's marks and symbols; `None` past the limits above. +fn page(samples: &[u8], columns: u32, rows: u32) -> Option { + if rows > MAX_ROWS { + return None; + } + let width = usize::try_from(columns).ok()?; + let stride = width.div_ceil(8); + if stride == 0 { + return None; + } + let runs = black_runs(samples, width, stride)?; + let (marks, order) = marks(&runs); + let mut page = Page { runs, marks, order, symbols: Vec::new(), copies: Vec::new(), large: Vec::new() }; + symbols(&mut page)?; + Some(page) +} + +/// The black runs of each row (0 samples), row by row and left to right; `None` past [`MAX_RUNS`]. +fn black_runs(samples: &[u8], width: usize, stride: usize) -> Option> { + let black = |row: &[u8], x: usize| row.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) == 0); + let mut runs = Vec::new(); + for (y, row) in samples.chunks_exact(stride).enumerate() { + let y = u32::try_from(y).ok()?; + let mut x = 0; + while x < width { + // Whole white bytes at once. + if x % 8 == 0 && row.get(x / 8) == Some(&0xFF) { + x += 8; + continue; + } + if !black(row, x) { + x += 1; + continue; + } + let start = x; + while x < width && black(row, x) { + x += 1; + } + if runs.len() == MAX_RUNS { + return None; + } + runs.push(Run { y, x0: u32::try_from(start).ok()?, x1: u32::try_from(x).ok()? }); + } + } + Some(runs) +} + +/// The marks the runs make, in the order their first runs come (so from the top), and the run +/// order: the runs' indexes, mark by mark, each mark's in row order. +fn marks(runs: &[Run]) -> (Vec, Vec) { + // Union–find over the runs; a set's root is its first run, as the larger root always joins + // the smaller. Runs on adjacent rows join when they overlap, counting a corner. + let mut parent: Vec = (0..runs.len()).map(|i| i as u32).collect(); + let (mut above, mut above_at, mut above_y): (&[Run], usize, Option) = (&[], 0, None); + let mut at = 0; + for row in runs.chunk_by(|a, b| a.y == b.y) { + let y = row.first().map(|r| r.y); + if above_y.and_then(|a| a.checked_add(1)) == y { + let mut i = 0; + for (j, b) in row.iter().enumerate() { + while above.get(i).is_some_and(|a| a.x1 < b.x0) { + i += 1; + } + let mut k = i; + while above.get(k).is_some_and(|a| a.x0 <= b.x1) { + union(&mut parent, above_at + k, at + j); + k += 1; + } + } + } + (above, above_at, above_y) = (row, at, y); + at += row.len(); + } + let mut mark_of = vec![0u32; runs.len()]; + let mut marks: Vec = Vec::new(); + let mut counts: Vec = Vec::new(); + for (i, r) in runs.iter().enumerate() { + let root = find_root(&mut parent, i); + let m = if root == i { + marks.push(Mark { x0: r.x0, x1: r.x1, y0: r.y, y1: r.y.saturating_add(1), runs: 0..0 }); + counts.push(0); + marks.len() - 1 + } else { + mark_of.get(root).map_or(0, |m| *m as usize) + }; + if let Some(slot) = mark_of.get_mut(i) { + *slot = m as u32; + } + if let (Some(mark), Some(count)) = (marks.get_mut(m), counts.get_mut(m)) { + mark.x0 = mark.x0.min(r.x0); + mark.x1 = mark.x1.max(r.x1); + mark.y1 = mark.y1.max(r.y.saturating_add(1)); + *count += 1; + } + } + let mut next = 0; + for (mark, count) in marks.iter_mut().zip(&counts) { + mark.runs = next..next + count; + next += count; + } + let mut order = vec![0u32; runs.len()]; + let mut fill: Vec = marks.iter().map(|m| m.runs.start).collect(); + for (i, m) in mark_of.iter().enumerate() { + if let Some(n) = fill.get_mut(*m as usize) { + if let Some(slot) = order.get_mut(*n) { + *slot = i as u32; + } + *n += 1; + } + } + (marks, order) +} + +/// The root of `i`'s set, halving the path to it as it goes. Parents are never above their +/// children, so the walk ends. +fn find_root(parent: &mut [u32], mut i: usize) -> usize { + loop { + let Some(&p) = parent.get(i) else { return i }; + let p = p as usize; + if p == i { + return i; + } + let grandparent = parent.get(p).copied().unwrap_or(p as u32); + if let Some(slot) = parent.get_mut(i) { + *slot = grandparent; + } + i = grandparent as usize; + } +} + +fn union(parent: &mut [u32], a: usize, b: usize) { + let (a, b) = (find_root(parent, a), find_root(parent, b)); + if let Some(slot) = parent.get_mut(a.max(b)) { + *slot = a.min(b) as u32; + } +} + +/// Each mark's own pixels in its box: those of its runs, not of other marks the box overlaps. +fn pixels(mark: &Mark, runs: &[Run], order: &[u32]) -> Vec { + // A mark's box holds its runs, so these don't go below 0; a symbol is at most 256 a side. + let width = mark.x1.saturating_sub(mark.x0) as usize; + let height = mark.y1.saturating_sub(mark.y0) as usize; + let mut pixels = vec![0u8; width * height]; + for r in order.get(mark.runs.clone()).unwrap_or_default().iter().filter_map(|i| runs.get(*i as usize)) { + let row = r.y.saturating_sub(mark.y0) as usize * width; + let from = row + r.x0.saturating_sub(mark.x0) as usize; + let to = row + r.x1.saturating_sub(mark.x0) as usize; + pixels.get_mut(from..to).unwrap_or_default().fill(1); + } + pixels +} + +/// Sorts the marks into symbols: exact copies share one. `None` past [`MAX_SYMBOLS`] or +/// [`MAX_SYMBOL_PIXELS`]. +fn symbols(page: &mut Page) -> Option<()> { + let mut by_hash: HashMap> = HashMap::new(); + let mut kept = 0usize; + for (m, mark) in page.marks.iter().enumerate() { + let m = u32::try_from(m).ok()?; + let (width, height) = (mark.x1.saturating_sub(mark.x0), mark.y1.saturating_sub(mark.y0)); + if width > MAX_SYMBOL_SIDE || height > MAX_SYMBOL_SIDE { + page.large.push(m); + continue; + } + let pixels = pixels(mark, &page.runs, &page.order); + let mut hasher = DefaultHasher::new(); + (width, height, &pixels).hash(&mut hasher); + let same = by_hash.entry(hasher.finish()).or_default(); + let symbols = &mut page.symbols; + let known = + same.iter().copied().find(|s| symbols.get(*s as usize).is_some_and(|s| (s.width, s.height) == (width, height) && s.pixels == pixels)); + let id = match known { + Some(id) => id, + None => { + kept = kept.checked_add(pixels.len())?; + if kept > MAX_SYMBOL_PIXELS || symbols.len() == MAX_SYMBOLS { + return None; + } + let id = u32::try_from(symbols.len()).ok()?; + symbols.push(Symbol { width, height, pixels, uses: 0 }); + same.push(id); + id + } + }; + if let Some(s) = symbols.get_mut(id as usize) { + s.uses = s.uses.saturating_add(1); + } + page.copies.push((id, m)); + } + Some(()) +} + +/// One plan's stream: the symbols with at least `copies` uses in a symbol dictionary, text +/// regions placing them (in strips of 2^`log_strips` rows, or whichever size codes smallest), and +/// the other marks in a generic region. `None` with fewer than two such symbols (their IDs would +/// take no bits at all, SBSYMCODELEN 0, a corner of the standard left alone here) or more than +/// [`MAX_CODED_SYMBOLS`]. +fn plan(page: &Page, columns: u32, rows: u32, copies: u32, log_strips: Option) -> Option> { + let symbol = |s: u32| page.symbols.get(s as usize); + // The dictionary's order (6.5.5): height classes from the shortest, each narrowest first. + let mut kept: Vec = (0..page.symbols.len() as u32).filter(|s| symbol(*s).is_some_and(|s| s.uses >= copies)).collect(); + if kept.len() < 2 || kept.len() > MAX_CODED_SYMBOLS { + return None; + } + kept.sort_by_key(|s| symbol(*s).map(|s| (s.height, s.width))); + let mut id = vec![None; page.symbols.len()]; + for (k, s) in kept.iter().enumerate() { + if let Some(slot) = id.get_mut(*s as usize) { + *slot = u32::try_from(k).ok(); + } + } + let mut instances = Vec::new(); + let mut left = page.large.clone(); + for (s, m) in &page.copies { + match (id.get(*s as usize).copied().flatten(), page.marks.get(*m as usize), symbol(*s)) { + (Some(id), Some(mark), Some(symbol)) => { + instances.push(Instance { id, s: mark.x0, t: mark.y1.saturating_sub(1), width: symbol.width, height: symbol.height }) + } + _ => left.push(*m), + } + } + let kept: Vec<&Symbol> = kept.iter().filter_map(|s| symbol(*s)).collect(); + let dictionary = symbol_dictionary(&kept)?; + let count = u32::try_from(kept.len()).ok()?; + // Bands of at most MAX_REGION_INSTANCES, from the top. + instances.sort_by_key(|i| (i.t, i.s)); + let mut regions = Vec::new(); + for band in instances.chunks_mut(MAX_REGION_INSTANCES) { + let top = band.iter().map(|i| i.t.saturating_add(1).saturating_sub(i.height)).min()?; + let height = band.iter().map(|i| i.t).max()?.checked_sub(top)?.checked_add(1)?; + regions.push(match log_strips { + Some(l) => text_region(band, count, columns, top, height, l)?, + None => (0..4).filter_map(|l| text_region(band, count, columns, top, height, l)).min_by_key(Vec::len)?, + }); + } + let mut out = Vec::new(); + segment(&mut out, 0, 48, &[], false, &page_information(columns, rows)); + segment(&mut out, 1, 0, &[], true, &dictionary); + let mut number = 2; + for (k, text) in regions.iter().enumerate() { + // The dictionary stays retained for the next region, until the last (7.2.4). + segment(&mut out, number, 7, &[(1, k + 1 < regions.len())], false, text); + number += 1; + } + if !left.is_empty() { + segment(&mut out, number, 39, &[], false, &leftover_region(&left, page)?); + } + Some(out) +} + +/// A symbol dictionary segment's data (7.4.2) defining `symbols` (in dictionary order) and +/// exporting them all: arithmetic coded, no refinement or aggregation, each bitmap a generic +/// region with template 0, its nominal AT pixels and no typical prediction (Table 16), the +/// bitmaps sharing one set of contexts. +fn symbol_dictionary(symbols: &[&Symbol]) -> Option> { + let count = u32::try_from(symbols.len()).ok()?; + let mut data = vec![0, 0]; + at_pixels(&mut data); + data.extend_from_slice(&count.to_be_bytes()); + data.extend_from_slice(&count.to_be_bytes()); + let mut mq = MqEncoder::new(); + let mut contexts: Vec = vec![0; 1 << 16]; + let (mut iadh, mut iadw, mut iaex) = (IntCoder::new(), IntCoder::new(), IntCoder::new()); + let mut height = 0; + for class in symbols.chunk_by(|a, b| a.height == b.height) { + let h = class.first()?.height; + iadh.encode(&mut mq, Some(int(i64::from(h) - i64::from(height))?)); + height = h; + let mut width = 0; + for s in class { + iadw.encode(&mut mq, Some(int(i64::from(s.width) - i64::from(width))?)); + width = s.width; + let w = s.width as usize; + code_generic(&mut mq, &mut contexts, w, s.height as usize, false, |y, row| { + let line = s.pixels.get(y * w..).unwrap_or_default(); + row.iter_mut().zip(line).for_each(|(p, v)| *p = *v); + }); + } + iadw.encode(&mut mq, None); + } + // The export flags (6.5.10): a run of no symbols not exported, then a run of all of them. + iaex.encode(&mut mq, Some(0)); + iaex.encode(&mut mq, Some(int(i64::from(count))?)); + data.extend_from_slice(&mq.finish()); + Some(data) +} + +/// An immediate lossless text region segment's data (7.4.3) placing `instances` in the band of +/// `height` rows from row `top` (which holds them): arithmetic coded, no refinement, strips of +/// 2^`log_strips` rows, each symbol's bottom-left pixel at (S, T) (REFCORNER BOTTOMLEFT, so a +/// line's letters share their T), not transposed, OR, default pixel 0, SBDSOFFSET 0. Sorts +/// `instances` into strips, each left to right. +fn text_region(instances: &mut [Instance], symbols: u32, columns: u32, top: u32, height: u32, log_strips: u32) -> Option> { + // LOGSBSTRIPS has two bits: strips of 1, 2, 4 or 8 rows. + if log_strips > 3 { + return None; + } + let strips = 1i64 << log_strips; + // T counts from the band's top row. + let t = |i: &Instance| i.t.saturating_sub(top); + instances.sort_by_key(|i| (t(i) >> log_strips, i.s)); + let mut data = Vec::new(); + region_information(&mut data, columns, height, 0, top); + data.extend_from_slice(&((log_strips as u16) << 2).to_be_bytes()); + data.extend_from_slice(&u32::try_from(instances.len()).ok()?.to_be_bytes()); + // SBSYMCODELEN (Table 31): ⌈log2 SBNUMSYMS⌉. + let length = symbols.checked_next_power_of_two()?.trailing_zeros(); + let mut ids: Vec = vec![0; 1 << length]; + let (mut iadt, mut iafs, mut iads, mut iait) = (IntCoder::new(), IntCoder::new(), IntCoder::new(), IntCoder::new()); + let mut mq = MqEncoder::new(); + // The initial STRIPT (6.4.5 step 2) is 0, so each strip's delta T is its first row over the + // strip size, from the strip before. + iadt.encode(&mut mq, Some(0)); + let (mut strip_t, mut first_s) = (0i64, 0i64); + for strip in instances.chunk_by(|a, b| t(a) >> log_strips == t(b) >> log_strips) { + let first = i64::from(t(strip.first()?) >> log_strips << log_strips); + iadt.encode(&mut mq, Some(int((first - strip_t) / strips)?)); + strip_t = first; + let mut cur_s = 0; + for (k, instance) in strip.iter().enumerate() { + let s = i64::from(instance.s); + if k == 0 { + iafs.encode(&mut mq, Some(int(s - first_s)?)); + first_s = s; + } else { + iads.encode(&mut mq, Some(int(s - cur_s)?)); + } + if strips > 1 { + iait.encode(&mut mq, Some(int(i64::from(t(instance)) - strip_t)?)); + } + encode_id(&mut mq, &mut ids, length, instance.id); + // The next S counts from this symbol's last column (6.4.5 step 3c x). + cur_s = s + i64::from(instance.width) - 1; + } + iads.encode(&mut mq, None); + } + data.extend_from_slice(&mq.finish()); + Some(data) +} + +/// A generic region segment's data holding the marks `left`, over their box. +fn leftover_region(left: &[u32], page: &Page) -> Option> { + let left: Vec<&Mark> = left.iter().filter_map(|m| page.marks.get(*m as usize)).collect(); + let x0 = left.iter().map(|m| m.x0).min()?; + let y0 = left.iter().map(|m| m.y0).min()?; + let x1 = left.iter().map(|m| m.x1).max()?; + let y1 = left.iter().map(|m| m.y1).max()?; + let (width, height) = (x1.saturating_sub(x0) as usize, y1.saturating_sub(y0) as usize); + let stride = width.div_ceil(8); + // Packed, 1 for black: no larger than the image's own samples. + let mut packed = vec![0u8; stride.checked_mul(height)?]; + for mark in &left { + for r in page.order.get(mark.runs.clone()).unwrap_or_default().iter().filter_map(|i| page.runs.get(*i as usize)) { + let row = r.y.saturating_sub(y0) as usize * stride; + for x in r.x0.saturating_sub(x0) as usize..r.x1.saturating_sub(x0) as usize { + if let Some(b) = packed.get_mut(row + x / 8) { + *b |= 0x80 >> (x % 8); + } + } + } + } + let mut mq = MqEncoder::new(); + let mut contexts: Vec = vec![0; 1 << 16]; + code_generic(&mut mq, &mut contexts, width, height, true, |y, row| { + let line = packed.get(y * stride..).unwrap_or_default(); + for (x, p) in row.iter_mut().enumerate() { + *p = u8::from(line.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0)); + } + }); + Some(generic_region(u32::try_from(width).ok()?, u32::try_from(height).ok()?, x0, y0, &mq.finish())) +} + +fn int(v: i64) -> Option { + i32::try_from(v).ok() +} + +/// An arithmetic integer coder other than IAID (A.2): its 512 contexts, indexed by PREV. +struct IntCoder([Context; 512]); + +impl IntCoder { + fn new() -> Self { + IntCoder([0; 512]) + } + + /// Codes `value`, or OOB for `None`. + fn encode(&mut self, mq: &mut MqEncoder, value: Option) { + for (cx, d) in decisions(value) { + if let Some(state) = self.0.get_mut(cx) { + mq.encode(state, d); + } + } + } +} + +/// The decisions that code `value`, or OOB for `None`, with their contexts (A.2, Table A.1): the +/// sign, the prefix that picks the range, then the offset into it, most significant bit first; +/// OOB is a negative 0. Each decision's context is PREV: a leading 1 and the decisions before it, +/// at most the last eight once there are more. +fn decisions(value: Option) -> Vec<(usize, bool)> { + let (negative, magnitude) = value.map_or((true, 0), |v| (v < 0, v.unsigned_abs())); + let (ones, bits, first) = + RANGES.into_iter().find(|(_, bits, first)| *bits == 32 || u64::from(magnitude) < u64::from(*first) + (1 << bits)).unwrap_or(RANGES[5]); + let offset = magnitude - first; + let mut d = vec![negative]; + d.extend(std::iter::repeat_n(true, ones as usize)); + if ones < 5 { + d.push(false); + } + d.extend((0..bits).rev().map(|i| offset >> i & 1 == 1)); + let mut prev = 1usize; + d.into_iter() + .map(|bit| { + let cx = prev; + prev = if prev < 256 { prev << 1 | usize::from(bit) } else { ((prev << 1 | usize::from(bit)) & 511) | 256 }; + (cx, bit) + }) + .collect() +} + +/// Codes symbol ID `id` in `length` bits, most significant first, through IAID (A.3): each +/// decision's context is a leading 1 and all the bits before it. +fn encode_id(mq: &mut MqEncoder, contexts: &mut [Context], length: u32, id: u32) { + let mut prev = 1usize; + for i in (0..length).rev() { + let d = id >> i & 1 == 1; + if let Some(state) = contexts.get_mut(prev) { + mq.encode(state, d); + } + prev = prev << 1 | usize::from(d); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::jbig2::tests::{decode, samples, unpack}; + use crate::jbig2::{encode_jbig2, encode_jbig2_generic}; + + type Bitmap = Vec>; + + /// A small linear congruential generator, so the pictures are the same every run. + struct Lcg(u64); + + impl Lcg { + fn below(&mut self, n: usize) -> usize { + self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + (self.0 >> 33) as usize % n.max(1) + } + } + + /// `count` different letter-like shapes: three strokes two pixels thick in a box of 6 to 18 + /// pixels a side, each after the first starting on one before it, so a shape is one mark; + /// trimmed to its pixels. + fn glyphs(count: usize, seed: u64) -> Vec { + let mut r = Lcg(seed); + let mut out: Vec = Vec::new(); + while out.len() < count { + let (w, h) = (6 + r.below(13), 6 + r.below(13)); + let mut g = vec![vec![0u8; w]; h]; + let (mut x, mut y) = (r.below(w), r.below(h)); + for _ in 0..3 { + let (dx, dy) = [(1, 0), (0, 1), (1, 1)][r.below(3)]; + let mut stroke = Vec::new(); + while x < w && y < h { + g[y][x] = 1; + g[y][(x + 1).min(w - 1)] = 1; + stroke.push((x, y)); + x += dx; + y += dy; + } + (x, y) = stroke[r.below(stroke.len())]; + } + let rows: Vec = (0..h).filter(|y| g[*y].contains(&1)).collect(); + let columns: Vec = (0..w).filter(|x| g.iter().any(|row| row[*x] == 1)).collect(); + let g: Bitmap = g[rows[0]..=rows[rows.len() - 1]].iter().map(|row| row[columns[0]..=columns[columns.len() - 1]].to_vec()).collect(); + if !out.contains(&g) { + out.push(g); + } + } + out + } + + /// Draws `glyph` with its top-left pixel at (`x`, `y`), clipped to the page, by OR. + fn draw(page: &mut Bitmap, glyph: &Bitmap, x: i64, y: i64) { + for (gy, row) in glyph.iter().enumerate() { + for (gx, p) in row.iter().enumerate() { + let (px, py) = (x + gx as i64, y + gy as i64); + if *p == 1 + && px >= 0 + && py >= 0 + && let Some(cell) = page.get_mut(py as usize).and_then(|r| r.get_mut(px as usize)) + { + *cell = 1; + } + } + } + } + + /// Lines of text: glyphs side by side, one to four pixels apart, each line's glyphs on its + /// baseline, the lines 30 rows apart; the first glyphs are the most common, as letters are. + fn text(columns: usize, rows: usize, glyphs: &[Bitmap], seed: u64) -> Bitmap { + let mut page = vec![vec![0u8; columns]; rows]; + let mut r = Lcg(seed); + for baseline in (24..rows).step_by(30) { + let mut x = 4 + r.below(8); + loop { + let common = r.below(glyphs.len()) + 1; + let g = &glyphs[r.below(common)]; + if x + g[0].len() > columns { + break; + } + draw(&mut page, g, x as i64, (baseline + 1 - g.len()) as i64); + x += g[0].len() + 1 + r.below(4); + } + } + page + } + + /// `n` glyphs anywhere, some over the edges, some over each other. + fn scatter(columns: usize, rows: usize, glyphs: &[Bitmap], seed: u64, n: usize) -> Bitmap { + let mut page = vec![vec![0u8; columns]; rows]; + let mut r = Lcg(seed); + for _ in 0..n { + let g = &glyphs[r.below(glyphs.len())]; + draw(&mut page, g, r.below(columns + 12) as i64 - 6, r.below(rows + 12) as i64 - 6); + } + page + } + + /// A segment's number and type, whether it is retained, and the segments it refers to, each + /// with its retain bit. + type Segment = (u32, u8, bool, Vec<(u8, bool)>); + + /// The segments of a stream in the embedded organisation, from their headers (7.2). + fn segments(data: &[u8]) -> Vec { + let (mut segments, mut at) = (Vec::new(), 0); + while at < data.len() { + let flags = data[at + 5]; + let refers = usize::from(flags >> 5); + let length_at = at + 6 + refers + 1; + let length = u32::from_be_bytes(data[length_at..length_at + 4].try_into().unwrap()) as usize; + let referred = data[at + 6..at + 6 + refers].iter().enumerate().map(|(k, n)| (*n, flags >> (k + 1) & 1 == 1)).collect(); + let number = u32::from_be_bytes(data[at..at + 4].try_into().unwrap()); + segments.push((number, data[at + 4] & 0x3F, flags & 1 == 1, referred)); + at = length_at + 4 + length; + } + segments + } + + fn segment_types(data: &[u8]) -> Vec { + segments(data).into_iter().map(|(_, kind, ..)| kind).collect() + } + + /// Every plan, at every strip size, decodes to the picture; returns how many plans there were. + fn every_plan_round_trips(picture: &Bitmap) -> usize { + let (columns, rows) = (picture[0].len() as u32, picture.len() as u32); + let data = samples(picture); + let page = page(&data, columns, rows).unwrap(); + let mut plans = 0; + for copies in [1, 2] { + for log_strips in 0..4 { + if let Some(coded) = plan(&page, columns, rows, copies, Some(log_strips)) { + assert_eq!(decode(&coded), unpack(&data, columns, rows), "{copies} copies, strips of {}", 1 << log_strips); + plans += 1; + } + } + } + plans + } + + /// Annex A.2's example (12 through IADW: the decisions 0101000, in contexts 1, 10, 101, …) + /// and Table A.1's codes. + #[test] + fn integers_are_coded_as_annex_a_says() { + assert_eq!(decisions(Some(12)), [(1, false), (2, true), (5, false), (10, true), (21, false), (42, false), (84, false)]); + let bits = |v| decisions(v).iter().map(|(_, d)| u8::from(*d)).collect::>(); + assert_eq!(bits(None), [1, 0, 0, 0], "OOB"); + assert_eq!(bits(Some(0)), [0, 0, 0, 0]); + assert_eq!(bits(Some(-1)), [1, 0, 0, 1]); + assert_eq!(bits(Some(-3)), [1, 0, 1, 1]); + assert_eq!(bits(Some(4)), [0, 1, 0, 0, 0, 0, 0]); + assert_eq!(bits(Some(-19)), [1, 1, 0, 1, 1, 1, 1]); + assert_eq!(bits(Some(83)), [0, 1, 1, 0, 1, 1, 1, 1, 1, 1]); + assert_eq!(bits(Some(4435))[..6], [0, 1, 1, 1, 1, 0]); + let big = bits(Some(4436)); + assert_eq!((big.len(), &big[..6]), (6 + 32, &[0, 1, 1, 1, 1, 1][..])); + assert!(big[6..].iter().all(|b| *b == 0)); + // PREV keeps a leading 1 and the last eight decisions, so it stays below 512, also when + // those eight are all 0 (PREV exactly 256), as in 4436's offset of 0. + for value in [Some(i32::MIN), Some(4436), Some(-4436), Some(4436 + 0x100), Some(i32::MAX), None] { + let contexts: Vec = decisions(value).iter().map(|(cx, _)| *cx).collect(); + assert!(contexts.iter().take(8).all(|cx| *cx < 256) && contexts.iter().skip(8).all(|cx| (256..512).contains(cx)), "{value:?}"); + } + assert!(decisions(Some(4436)).iter().any(|(cx, _)| *cx == 256)); + } + + /// Strips and first columns thousands of pixels apart need Annex A's 32-bit range: a mark at + /// the far right of a 12,000-pixel page, then one at the left below it (S back by 11,000), and + /// lines 5,000 rows apart. Each shape occurs twice, so both plans are made. + #[test] + fn coordinates_past_4435_round_trip() { + let shapes = glyphs(3, 4); + let mut picture = vec![vec![0u8; 12_000]; 40]; + for (k, x) in [11_000, 0, 5_000, 11_980].into_iter().enumerate() { + draw(&mut picture, &shapes[k % 2], x, 2 + 9 * k as i64); + } + assert_eq!(every_plan_round_trips(&picture), 8); + let mut tall = vec![vec![0u8; 30]; 10_050]; + for (k, y) in [0, 5_000, 10_030].into_iter().enumerate() { + draw(&mut tall, &shapes[k % 3], 3, y); + draw(&mut tall, &shapes[(k + 1) % 3], 15, y); + } + assert_eq!(every_plan_round_trips(&tall), 8); + } + + /// The padding bits of a row whose width isn't a multiple of 8 are ignored whatever they are: + /// black (0) padding, as many scans have, neither joins the marks nor stops symbol coding. + #[test] + fn black_padding_bits_are_ignored() { + for columns in [13, 241, 901] { + let picture = text(columns, 120, &glyphs(12, 6), 8); + let mut data = samples(&picture); + let stride = columns.div_ceil(8); + for row in data.chunks_mut(stride) { + row[stride - 1] &= 0xFF << (8 * stride - columns); + } + let (c, r) = (columns as u32, 120); + let page = page(&data, c, r).unwrap(); + assert!(page.marks.iter().all(|m| m.x1 as usize <= columns), "{columns} columns"); + for copies in [1, 2] { + if let Some(coded) = plan(&page, c, r, copies, None) { + assert_eq!(decode(&coded), unpack(&data, c, r), "{columns} columns"); + } + } + if columns > 13 { + assert_eq!(segment_types(&encode_jbig2(&data, c, r).unwrap())[1], 0, "{columns} columns: symbol coded"); + } + } + } + + /// Pixels touching only at a corner, down to the right or down to the left, are one mark + /// (8-connected); side by side with a white column between, two. A mark of 256 pixels a side + /// is a symbol; one 257 wide, or 257 tall, is left to the generic region. The diagonal and the + /// dot occur twice, so both plans are made. + #[test] + fn marks_join_at_corners_and_large_ones_are_left_out() { + let mut picture = vec![vec![0u8; 300]; 300]; + for i in 0..6 { + picture[10 + i][10 + i] = 1; + picture[10 + i][100 + i] = 1; + picture[10 + i][60 - i] = 1; + } + for row in &mut picture[0..257] { + row[280] = 1; + } + picture[30][10] = 1; + picture[30][12] = 1; + picture[40][0..256].fill(1); + picture[60][0..257].fill(1); + for row in &mut picture[44..300] { + row[290] = 1; + } + let data = samples(&picture); + let page = page(&data, 300, 300).unwrap(); + let boxes: Vec<(u32, u32, u32, u32)> = page.marks.iter().map(|m| (m.x0, m.y0, m.x1 - m.x0, m.y1 - m.y0)).collect(); + assert_eq!( + boxes, + [ + (280, 0, 1, 257), + (10, 10, 6, 6), + (55, 10, 6, 6), + (100, 10, 6, 6), + (10, 30, 1, 1), + (12, 30, 1, 1), + (0, 40, 256, 1), + (290, 44, 1, 256), + (0, 60, 257, 1) + ] + ); + let large: Vec<(u32, u32)> = page.large.iter().map(|m| (page.marks[*m as usize].x0, page.marks[*m as usize].y0)).collect(); + assert_eq!(large, [(280, 0), (0, 60)]); + assert_eq!(every_plan_round_trips(&picture), 8); + } + + /// Past 2^20 black runs or 2^24 rows the image is left to generic coding; at either cap it is + /// still looked at. + #[test] + fn busy_or_tall_images_are_left_to_generic_coding() { + // 0x55: four black pixels a byte, each its own run, under the same ones on the next row. + let columns = 1 << 20; + let row = vec![0x55u8; columns / 8]; + assert!(page(&row.repeat(2), columns as u32, 2).is_some(), "2^20 runs"); + assert!(page(&row.repeat(3), columns as u32, 3).is_none(), "1.5 × 2^20 runs"); + let tall = vec![0xFFu8; MAX_ROWS as usize + 1]; + assert!(page(&tall, 1, MAX_ROWS + 1).is_none()); + assert!(page(&tall, 1, MAX_ROWS).is_some()); + } + + /// A plan codes at most 32,767 symbols, which an independent decoder reads back; with more, + /// the second plan can still code the repeated ones. Past 65,536 distinct marks, or 2^24 + /// pixels of them, the image is left to generic coding; at either cap it is still looked at. + #[test] + fn too_many_symbols_are_left_to_generic_coding() { + // `n` distinct marks of 17 × 3 (full top and bottom rows joined by a code of 1 to n, in + // binary, on the row between), then `again` more with the codes 1 and 2 in turn. + let codes = |n: usize, again: usize| { + let (columns, rows) = (256 * 18, (n + again).div_ceil(256) * 4); + let mut picture = vec![vec![0u8; columns]; rows]; + for k in 0..n + again { + let (x, y) = (k % 256 * 18, k / 256 * 4); + let code = if k < n { k + 1 } else { (k - n) % 2 + 1 }; + picture[y][x..x + 17].fill(1); + picture[y + 2][x..x + 17].fill(1); + for b in (0..17).filter(|b| code >> b & 1 == 1) { + picture[y + 1][x + b] = 1; + } + } + (samples(&picture), columns as u32, rows as u32) + }; + let (data, columns, rows) = codes(MAX_CODED_SYMBOLS, 0); + let at_cap = page(&data, columns, rows).unwrap(); + assert_eq!(decode(&plan(&at_cap, columns, rows, 1, None).unwrap()), unpack(&data, columns, rows)); + // One more distinct mark, each once: neither plan is made. + let (data, columns, rows) = codes(MAX_CODED_SYMBOLS + 1, 0); + let past = page(&data, columns, rows).unwrap(); + assert!(plan(&past, columns, rows, 1, None).is_none() && plan(&past, columns, rows, 2, None).is_none()); + assert_eq!(encode_jbig2(&data, columns, rows).unwrap(), encode_jbig2_generic(&data, columns, rows).unwrap()); + // With two of them repeated, the second plan codes those two. + let (data, columns, rows) = codes(MAX_CODED_SYMBOLS + 1, 200); + let past = page(&data, columns, rows).unwrap(); + assert!(plan(&past, columns, rows, 1, None).is_none()); + assert_eq!(decode(&plan(&past, columns, rows, 2, None).unwrap()), unpack(&data, columns, rows)); + // Above a page of text whose letters repeat, that second plan is the one `encode_jbig2` + // keeps: the first, of every mark, would code too many symbols. + let mut picture: Bitmap = vec![vec![0u8; 256 * 18]; 512]; + for k in 0..=MAX_CODED_SYMBOLS { + let (x, y) = (k % 256 * 18, k / 256 * 4); + picture[y][x..x + 17].fill(1); + picture[y + 2][x..x + 17].fill(1); + for b in (0..17).filter(|b| (k + 1) >> b & 1 == 1) { + picture[y + 1][x + b] = 1; + } + } + picture.extend(text(256 * 18, 600, &glyphs(40, 7), 11)); + let data = samples(&picture); + let (columns, rows) = (picture[0].len() as u32, picture.len() as u32); + let coded = encode_jbig2(&data, columns, rows).unwrap(); + assert_eq!(segment_types(&coded)[1], 0, "symbol coded"); + assert_eq!(decode(&coded), unpack(&data, columns, rows)); + let (data, columns, rows) = codes(MAX_SYMBOLS, 0); + assert_eq!(page(&data, columns, rows).map(|p| p.symbols.len()), Some(MAX_SYMBOLS)); + let (data, columns, rows) = codes(MAX_SYMBOLS + 1, 0); + assert!(page(&data, columns, rows).is_none()); + // `n` black squares of 256 × 256, each with a white pixel in a different place: 2^16 + // pixels a symbol, so 256 of them are exactly the cap. + let squares = |n: usize| { + let (columns, rows) = (16 * 258, n.div_ceil(16) * 258); + let mut picture = vec![vec![0u8; columns]; rows]; + for k in 0..n { + let (x, y) = (k % 16 * 258, k / 16 * 258); + for row in &mut picture[y..y + 256] { + row[x..x + 256].fill(1); + } + picture[y + 1 + k / 254][x + 1 + k % 254] = 0; + } + (samples(&picture), columns as u32, rows as u32) + }; + let (data, columns, rows) = squares(256); + let at_cap = page(&data, columns, rows).unwrap(); + assert_eq!(at_cap.symbols.len(), 256); + assert_eq!(decode(&plan(&at_cap, columns, rows, 1, None).unwrap()), unpack(&data, columns, rows)); + let (data, columns, rows) = squares(257); + assert!(page(&data, columns, rows).is_none()); + } + + /// More than 10,000 marks are placed by several text regions, each of at most 10,000 (more + /// than the decoder PdfCraft renders with accepts in one), over bands of rows, all referring to + /// the dictionary, which each region but the last keeps retained; the generic region comes + /// after them, every segment numbered one more than the one before. + #[test] + fn more_than_10000_marks_are_placed_by_several_regions() { + // 25,000 copies of two 17 × 3 shapes, 256 a line, the lines 5 rows apart, and a bar too + // tall to be a symbol beside them. + let mut picture = vec![vec![0u8; 256 * 18 + 100]; 25_000usize.div_ceil(256) * 5]; + for k in 0..25_000 { + let (x, y) = (k % 256 * 18, k / 256 * 5); + picture[y][x..x + 17].fill(1); + picture[y + 2][x..x + 17].fill(1); + picture[y + 1][x + k % 2] = 1; + } + for row in &mut picture[0..300] { + row[256 * 18 + 50] = 1; + } + let data = samples(&picture); + let (columns, rows) = (picture[0].len() as u32, picture.len() as u32); + let coded = encode_jbig2(&data, columns, rows).unwrap(); + let region = |number, kept| (number, 7, false, vec![(1, kept)]); + assert_eq!( + segments(&coded), + [(0, 48, false, vec![]), (1, 0, true, vec![]), region(2, true), region(3, true), region(4, false), (5, 39, false, vec![])] + ); + assert_eq!(decode(&coded), unpack(&data, columns, rows)); + assert_eq!(every_plan_round_trips(&picture), 8); + } + + /// A page of text whose letters repeat exactly, as rendered text does, keeps each letter once: + /// a symbol dictionary and a text region, much smaller than the generic coding. + #[test] + fn repeated_marks_are_stored_once() { + let picture = text(1700, 400, &glyphs(40, 7), 11); + let data = samples(&picture); + let coded = encode_jbig2(&data, 1700, 400).unwrap(); + let generic = encode_jbig2_generic(&data, 1700, 400).unwrap(); + // The dictionary is retained for the text region, which refers to it (7.2.4, 7.2.5). + assert_eq!(segments(&coded), [(0, 48, false, vec![]), (1, 0, true, vec![]), (2, 7, false, vec![(1, false)])]); + assert_eq!(decode(&coded), unpack(&data, 1700, 400)); + assert!(coded.len() * 2 < generic.len(), "symbols {} bytes, generic {}", coded.len(), generic.len()); + assert_eq!(every_plan_round_trips(&picture), 8); + } + + /// Marks too large to be symbols (a frame, a rule) go to a generic region over their box, and + /// with the second plan so do marks that occur once; the page still decodes to its bits. + #[test] + fn large_and_lone_marks_are_left_to_a_generic_region() { + let mut picture = text(900, 300, &glyphs(25, 3), 5); + picture[10][100..800].fill(1); + picture[290][100..800].fill(1); + for row in &mut picture[10..=290] { + row[100] = 1; + row[799] = 1; + } + // A lone blob, unlike any letter. + for (y, row) in picture[150..170].iter_mut().enumerate() { + row[850 + y % 7] = 1; + } + let data = samples(&picture); + let coded = encode_jbig2(&data, 900, 300).unwrap(); + assert_eq!(segment_types(&coded), [48, 0, 7, 39]); + assert_eq!(decode(&coded), unpack(&data, 900, 300)); + assert_eq!(every_plan_round_trips(&picture), 8); + } + + /// Marks on every edge, marks touching only at a corner (one mark), a mark inside another's + /// box, and marks over each other's boxes. + #[test] + fn marks_at_the_edges_and_corners_round_trip() { + let dot = vec![vec![1u8, 1], vec![1, 1]]; + let ell = vec![vec![1u8, 0, 0], vec![1, 0, 0], vec![1, 1, 1]]; + let mut picture = vec![vec![0u8; 41]; 23]; + for (x, y) in [(0, 0), (39, 0), (0, 21), (39, 21), (20, 0), (0, 10), (39, 10), (20, 21)] { + draw(&mut picture, &dot, x, y); + } + for (x, y) in [(10, 5), (28, 14), (12, 14)] { + draw(&mut picture, &ell, x, y); + } + // Corners touching: one mark (`marks_join_at_corners_and_large_ones_are_left_out` checks it). + picture[3][6] = 1; + picture[4][7] = 1; + picture[5][8] = 1; + // A ring with a dot inside, and an ell over the ring's box. + for i in 0..7 { + picture[8][20 + i] = 1; + picture[14][20 + i] = 1; + picture[8 + i][20] = 1; + picture[8 + i][26] = 1; + } + draw(&mut picture, &dot, 22, 10); + draw(&mut picture, &ell, 25, 15); + assert_eq!(every_plan_round_trips(&picture), 8); + } + + /// One shape, however often, would need symbol IDs of no bits: that image is coded as a generic + /// region, as are a blank page and pure noise. + #[test] + fn one_shape_blank_pages_and_noise_are_coded_generically() { + let letter = &glyphs(1, 9)[..]; + let mut r = Lcg(1); + let noise: Bitmap = (0..60).map(|_| (0..200).map(|_| u8::from(r.below(2) == 1)).collect()).collect(); + for picture in [text(600, 200, letter, 2), vec![vec![0u8; 300]; 40], noise] { + let (columns, rows) = (picture[0].len() as u32, picture.len() as u32); + let data = samples(&picture); + assert_eq!(encode_jbig2(&data, columns, rows).unwrap(), encode_jbig2_generic(&data, columns, rows).unwrap()); + } + } + + proptest::proptest! { + /// Glyphs anywhere, over the edges and each other, with stray pixels: every plan decodes to + /// the picture, and `encode_jbig2` is never larger than the generic coding. + #[test] + fn scattered_marks_round_trip( + columns in 1usize..240, + rows in 1usize..120, + shapes in 2usize..10, + n in 0usize..80, + seed in proptest::num::u64::ANY, + stray in proptest::collection::vec((0usize..240, 0usize..120), 0..20), + ) { + let mut picture = scatter(columns, rows, &glyphs(shapes, seed), seed, n); + for (x, y) in stray { + if let Some(cell) = picture.get_mut(y).and_then(|r| r.get_mut(x)) { + *cell ^= 1; + } + } + every_plan_round_trips(&picture); + let data = samples(&picture); + let (c, r) = (columns as u32, rows as u32); + let coded = encode_jbig2(&data, c, r).unwrap(); + proptest::prop_assert!(coded.len() <= encode_jbig2_generic(&data, c, r).unwrap().len()); + proptest::prop_assert_eq!(decode(&coded), unpack(&data, c, r)); + } + + /// Whatever it is given, it returns. + #[test] + fn any_input_returns(data in proptest::collection::vec(0u8..=255, 0..600), columns in 0u32..5000, rows in 0u32..80) { + let _ = encode_jbig2(&data, columns, rows); + let _ = encode(&data, columns, rows); + } + } +} diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index 22a78aefdb..7eac1a83e1 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -6,7 +6,8 @@ //! TIFF/PNG predictors, `ASCIIHexDecode`, `ASCII85Decode`, `RunLengthDecode`). //! Image codecs (`DCTDecode`, `JPXDecode`, `JBIG2Decode`, `CCITTFaxDecode`), //! `Crypt` and unknown filters are reported as [`FilterError::Unsupported`] so -//! callers keep such data encoded. +//! callers keep such data encoded. 1-bit samples can be encoded as CCITT Group 4 +//! ([`encode_g4`]) or lossless JBIG2 ([`encode_jbig2`]). //! //! Every decoder is bounded by a caller-supplied `max_output` (decompression-bomb //! defence) and never panics on malformed input. @@ -15,13 +16,19 @@ mod ascii85; mod asciihex; +mod ccitt; mod flate; +mod jbig2; mod lzw; +mod mq; mod predictor; mod runlength; use std::borrow::Cow; +pub use ccitt::{CcittParams, MAX_CCITT_COLUMNS, MAX_G4_COLUMNS, decode_ccitt, encode_g4}; +pub use jbig2::{MAX_JBIG2_COLUMNS, encode_jbig2, encode_jbig2_generic}; + /// A PDF stream filter, identified by its `/Filter` name. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum Filter { diff --git a/crates/filters/src/mq.rs b/crates/filters/src/mq.rs new file mode 100644 index 0000000000..04dda2bd9a --- /dev/null +++ b/crates/filters/src/mq.rs @@ -0,0 +1,209 @@ +//! The MQ arithmetic encoder of JBIG2 (ITU-T T.88 | ISO/IEC 14492, Annex E), written from the +//! procedures in its Figures E.2–E.12 and the probability estimates of Table E.1. + +/// Table E.1: for each index, the LPS probability estimate Qe, the next index after an MPS and +/// after an LPS renormalisation, and whether an LPS switches the MPS sense. +const STATES: [(u32, u8, u8, bool); 47] = [ + (0x5601, 1, 1, true), + (0x3401, 2, 6, false), + (0x1801, 3, 9, false), + (0x0AC1, 4, 12, false), + (0x0521, 5, 29, false), + (0x0221, 38, 33, false), + (0x5601, 7, 6, true), + (0x5401, 8, 14, false), + (0x4801, 9, 14, false), + (0x3801, 10, 14, false), + (0x3001, 11, 17, false), + (0x2401, 12, 18, false), + (0x1C01, 13, 20, false), + (0x1601, 29, 21, false), + (0x5601, 15, 14, true), + (0x5401, 16, 14, false), + (0x5101, 17, 15, false), + (0x4801, 18, 16, false), + (0x3801, 19, 17, false), + (0x3401, 20, 18, false), + (0x3001, 21, 19, false), + (0x2801, 22, 19, false), + (0x2401, 23, 20, false), + (0x2201, 24, 21, false), + (0x1C01, 25, 22, false), + (0x1801, 26, 23, false), + (0x1601, 27, 24, false), + (0x1401, 28, 25, false), + (0x1201, 29, 26, false), + (0x1101, 30, 27, false), + (0x0AC1, 31, 28, false), + (0x09C1, 32, 29, false), + (0x08A1, 33, 30, false), + (0x0521, 34, 31, false), + (0x0441, 35, 32, false), + (0x02A1, 36, 33, false), + (0x0221, 37, 34, false), + (0x0141, 38, 35, false), + (0x0111, 39, 36, false), + (0x0085, 40, 37, false), + (0x0049, 41, 38, false), + (0x0025, 42, 39, false), + (0x0015, 43, 40, false), + (0x0009, 44, 41, false), + (0x0005, 45, 42, false), + (0x0001, 45, 43, false), + (0x5601, 46, 46, false), +]; + +#[cfg(test)] +thread_local! { + /// Decisions coded on this thread. + pub(crate) static DECISIONS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// A context's adaptive state: the index into [`STATES`] (low 7 bits) and the MPS sense (bit 7). +/// 0, the initial value, is index 0 with MPS 0 (E.2.8). +pub(crate) type Context = u8; + +/// The encoder's registers (E.2.1) and the bytes written so far. +pub(crate) struct MqEncoder { + a: u32, + c: u32, + ct: u32, + /// The output, after the byte before the first one (B at INITENC), which is 0x00 and is + /// dropped by [`MqEncoder::finish`]: a carry can't reach it before the first byte is out. + out: Vec, +} + +impl MqEncoder { + /// INITENC (Figure E.10). The byte before the first is 0x00, not 0xFF, so CT starts at 12. + pub(crate) fn new() -> Self { + MqEncoder { a: 0x8000, c: 0, ct: 12, out: vec![0] } + } + + /// The byte B, the last one written (or the one before the first). + fn b(&self) -> u8 { + self.out.last().copied().unwrap_or(0) + } + + /// ENCODE (Figures E.3–E.7): code decision `d` in context `cx`, updating its state. + pub(crate) fn encode(&mut self, cx: &mut Context, d: bool) { + #[cfg(test)] + DECISIONS.with(|n| n.set(n.get() + 1)); + let (index, mps) = (usize::from(*cx & 0x7F), *cx >> 7 == 1); + let Some(&(qe, nmps, nlps, switch)) = STATES.get(index) else { return }; + self.a -= qe; + if d == mps { + // CODEMPS. + if self.a & 0x8000 != 0 { + self.c += qe; + return; + } + if self.a < qe { + self.a = qe; + } else { + self.c += qe; + } + *cx = nmps | (*cx & 0x80); + } else { + // CODELPS. + if self.a < qe { + self.c += qe; + } else { + self.a = qe; + } + let mps = mps != switch; + *cx = nlps | (u8::from(mps) << 7); + } + self.renorm(); + } + + /// RENORME (Figure E.8). + fn renorm(&mut self) { + loop { + self.a <<= 1; + self.c <<= 1; + self.ct -= 1; + if self.ct == 0 { + self.byte_out(); + } + if self.a & 0x8000 != 0 { + break; + } + } + } + + /// BYTEOUT (Figure E.9), with bit stuffing after 0xFF and carry into the byte before. + fn byte_out(&mut self) { + if self.b() == 0xFF { + self.stuffed(); + } else if self.c < 0x800_0000 { + self.plain(); + } else { + let b = self.b().wrapping_add(1); + if let Some(last) = self.out.last_mut() { + *last = b; + } + if b == 0xFF { + self.c &= 0x7FF_FFFF; + self.stuffed(); + } else { + self.plain(); + } + } + } + + fn plain(&mut self) { + self.out.push(((self.c >> 19) & 0xFF) as u8); + self.c &= 0x7_FFFF; + self.ct = 8; + } + + fn stuffed(&mut self) { + self.out.push(((self.c >> 20) & 0xFF) as u8); + self.c &= 0xF_FFFF; + self.ct = 7; + } + + /// FLUSH (Figures E.11 and E.12): the final bytes, then the 0xFF 0xAC marker. + pub(crate) fn finish(mut self) -> Vec { + let top = self.c + self.a; + self.c |= 0xFFFF; + if self.c >= top { + self.c -= 0x8000; + } + self.c <<= self.ct; + self.byte_out(); + self.c <<= self.ct; + self.byte_out(); + if self.b() != 0xFF { + self.out.push(0xFF); + } + self.out.push(0xAC); + self.out.split_off(1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Annex H.2's test sequence: 256 decisions in one context give these 30 bytes. + #[test] + fn the_annex_h_test_sequence_encodes_to_its_bytes() { + let decisions = [ + 0x00, 0x02, 0x00, 0x51, 0x00, 0x00, 0x00, 0xC0, 0x03, 0x52, 0x87, 0x2A, 0xAA, 0xAA, 0xAA, 0xAA, 0x82, 0xC0, 0x20, 0x00, 0xFC, 0xD7, 0x9E, + 0xF6, 0xBF, 0x7F, 0xED, 0x90, 0x4F, 0x46, 0xA3, 0xBF, + ]; + let mut mq = MqEncoder::new(); + let mut cx = 0; + for byte in decisions { + for bit in (0..8).rev() { + mq.encode(&mut cx, byte >> bit & 1 == 1); + } + } + let expected = [ + 0x84, 0xC7, 0x3B, 0xFC, 0xE1, 0xA1, 0x43, 0x04, 0x02, 0x20, 0x00, 0x00, 0x41, 0x0D, 0xBB, 0x86, 0xF4, 0x31, 0x7F, 0xFF, 0x88, 0xFF, 0x37, + 0x47, 0x1A, 0xDB, 0x6A, 0xDF, 0xFF, 0xAC, + ]; + assert_eq!(mq.finish(), expected); + } +} 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/forms/README.md b/crates/forms/README.md index 93b65bd14e..ba9dc18f48 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -87,10 +87,15 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table left alone. `calculations_changed` compares two readings of a form to say which formulas an edit rewrote. - The calculation order (`/CO`, §12.7.3): `calculation_order` lists the calculated fields in the - order `recalculate` runs them, with the fields each formula reads (`Calculate::reads`, + order `recalculate` runs them, each with its field object (two can share a name in a malformed + form), with the fields each formula reads (`Calculate::reads`, `af::calculation_reads`; names built at run time aren't known) and what is wrong with the order: a field reading one that calculates later, a loop, a field `/CO` leaves out, and `/CO` - entries that calculate nothing. `CalculationOrder::fixed` is Fix order: a stable topological + entries that calculate nothing. Two issues are about the formula itself: it reads a field the + form doesn't have (`MissingField`; the field of that name and a field under it count as there, + as recalculation and `getField` find them, but not an Acrobat widget name such as `price.0`, + which neither resolves), or its script builds names at run time (`BuiltNames`, + `af::reads_built_names`), so its reads are incomplete. `CalculationOrder::fixed` is Fix order: a stable topological sort that keeps the current order wherever it can and leaves loops as they are. `set_calculation_order` writes `/CO` afresh. A field that gets a calculation joins the end of the order; one whose calculation changes keeps its place. @@ -108,3 +113,7 @@ Rich-text values and exact appearance metrics/layout remain incomplete. JavaScri execution and FDF/XFDF exchange are integrated through the engine; this crate provides field actions and native AF rules. `/MK /R` rotation (0, 90, 180, 270) is drawn into the appearance. + +## Button export values + +`FieldProps::export_value` edits a zero-based widget's `/Opt` value while preserving appearance names, `/AP`, `/AS`, `/V` and `/DV`. Widgets sharing an on-state remain consistent. Export-only changes do not regenerate appearances. Invalid indices, empty/Off values and ambiguous/conflicting mappings are rejected. `Field::export_values` resolves selected states for data export. diff --git a/crates/forms/proptest-regressions/af/tests.txt b/crates/forms/proptest-regressions/af/tests.txt new file mode 100644 index 0000000000..70c4d0b525 --- /dev/null +++ b/crates/forms/proptest-regressions/af/tests.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 81dc3893ad0b46189d4e80199f44f8e58fcf0d193f67e266e37f3846f756a11e # shrinks to names = [" "] diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index ee4aa18e3c..86c7971c31 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -19,10 +19,10 @@ mod script; #[cfg(test)] use calc::{Notation, eval_notation, notation_name, notation_tokens, unique}; -pub use calc::{calculate, calculation_reads, notation_js, rename_in_calculation}; +pub use calc::{calculate, calculation_reads, notation_js, reads_built_names, rename_in_calculation}; pub use format::{DATE_PRESETS, DateTime, TIME_PRESETS, format_date, format_value, js_number, keystroke, make_number, parse_date, validate}; use script::{Arg, call}; -pub use script::{calculate_js, format_js, parse_calculate, parse_format, parse_validate, validate_js}; +pub use script::{NOTATION_NOT_STORABLE, calculate_js, format_js, notation_storable, parse_calculate, parse_format, parse_validate, validate_js}; /// The Format tab. #[derive(Clone, Debug, PartialEq, Default)] diff --git a/crates/forms/src/af/calc.rs b/crates/forms/src/af/calc.rs index b3a1524a4f..d29956cbc2 100644 --- a/crates/forms/src/af/calc.rs +++ b/crates/forms/src/af/calc.rs @@ -206,6 +206,10 @@ enum Site { Commas(String), /// A name in the simplified field notation of a `BVCALC` comment. Notation(String), + /// A field read by a name built at run time: a `getField` whose argument is more than one + /// string literal (`getField("row" + i)`), or an `AFSimple_Calculate` list that isn't + /// written out. Which field it is isn't known. + Built, } impl Site { @@ -213,6 +217,7 @@ impl Site { match self { Site::Literal(name) | Site::Notation(name) => vec![name.as_str()], Site::Commas(list) => list.split(',').map(str::trim).filter(|n| !n.is_empty()).collect(), + Site::Built => Vec::new(), } } @@ -229,14 +234,16 @@ impl Site { js_str(&parts.join(",")) }) } + Site::Built => None, } } } /// Where a Calculate script names fields, in order: the simplified field notation in its comment /// (see [`notation_range`]; [`parse_calculate`] reads it there too), the field list of -/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal. -/// A name built at run time (`getField("row" + i)`) is not a site. +/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal. A name +/// built at run time (`getField("row" + i)`) is a [`Site::Built`], at its `getField` or +/// `AFSimple_Calculate`. fn calculation_sites(js: &str) -> Vec<(Range, Site)> { let mut sites = Vec::new(); let (tokens, comments) = js_tokens(js); @@ -255,35 +262,64 @@ fn calculation_sites(js: &str) -> Vec<(Range, Site)> { Some((range, Js::Str(text))) => Some((range.clone(), text.clone())), _ => None, }; + let built = |k: usize| tokens.get(k).map(|(range, _)| (range.clone(), Site::Built)); for k in 0..tokens.len() { - if ident(k, "getField") - && punct(k + 1, '(') - && punct(k + 3, ')') - && let Some((range, name)) = string(k + 2) - { - sites.push((range, Site::Literal(name))); + if ident(k, "getField") && punct(k + 1, '(') { + match string(k + 2) { + Some((range, name)) if punct(k + 3, ')') => sites.push((range, Site::Literal(name))), + _ => sites.extend(built(k)), + } } - if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(') && string(k + 2).is_some() && punct(k + 3, ',')) { + if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(')) { continue; } + if !(string(k + 2).is_some() && punct(k + 3, ',')) { + sites.extend(built(k)); + continue; + } + // One string of comma-separated names, when it is the whole list (not `"row" + suffix`). + // An argument after it leaves the list as written, as after an array: its names are read, + // and what follows is a name built at run time. if let Some((range, list)) = string(k + 4) { - sites.push((range, Site::Commas(list))); + if punct(k + 5, ')') || punct(k + 5, ',') { + sites.push((range, Site::Commas(list))); + } + if !punct(k + 5, ')') { + sites.extend(built(k)); + } continue; } - // `new Array("a", "b")` or `["a", "b"]`: its items, up to the first that isn't a string. - // Nothing else is read, so no token is read for two calls. - let first = if punct(k + 4, '[') { - k + 5 + // `new Array("a", "b")` or `["a", "b"]`: each item that is one string, and the list has + // to end the call. An item that is more than a string (`"row" + i`), or anything after + // the list (`.concat(more)`), is a name built at run time. Nothing else is read, so no + // token is read for two calls. + let (first, close) = if punct(k + 4, '[') { + (k + 5, ']') } else if ident(k + 4, "new") && ident(k + 5, "Array") && punct(k + 6, '(') { - k + 7 + (k + 7, ')') } else { + sites.extend(built(k)); continue; }; - for (range, token) in tokens.iter().skip(first) { - match token { - Js::Str(name) => sites.push((range.clone(), Site::Literal(name.clone()))), - Js::Punct(',') => {} - _ => break, + let mut j = first; + loop { + match tokens.get(j) { + // An item ends at a comma or the list's end, or where a script cut short ends. + Some((range, Js::Str(name))) if punct(j + 1, ',') || punct(j + 1, close) || tokens.get(j + 1).is_none() => { + sites.push((range.clone(), Site::Literal(name.clone()))); + j += 1; + } + Some((_, Js::Punct(','))) => j += 1, + Some((_, Js::Punct(c))) if *c == close => { + if !punct(j + 1, ')') { + sites.extend(built(k)); + } + break; + } + _ => { + sites.extend(built(k)); + break; + } } } } @@ -315,6 +351,12 @@ pub fn calculation_reads(js: &str) -> Vec { unique(sites.iter().flat_map(|(_, site)| site.names()).map(str::to_owned)) } +/// Whether a Calculate script also reads fields by names it builds at run time +/// (`getField("row" + i)`), which [`calculation_reads`] can't list. +pub fn reads_built_names(js: &str) -> bool { + calculation_sites(js).iter().any(|(_, site)| *site == Site::Built) +} + impl Calculate { /// The fields it reads, each once, in the order it first names them. A name also stands for /// the fields under it (`row` for `row.0` and `row.1`), as [`calculate`] reads them. diff --git a/crates/forms/src/af/script.rs b/crates/forms/src/af/script.rs index 9aed297b45..f15714c411 100644 --- a/crates/forms/src/af/script.rs +++ b/crates/forms/src/af/script.rs @@ -1,4 +1,4 @@ -use super::calc::{js_tokens, notation_js, notation_range}; +use super::calc::{js_tokens, notation_js, notation_range, notation_tokens}; use super::format::{DATE_PRESETS, TIME_PRESETS}; use super::{CalcOp, Calculate, Format, Validate}; @@ -255,7 +255,7 @@ pub fn parse_calculate(js: &str) -> Option { if js.contains("BVCALC") && let Some(range) = notation_range(js, &js_tokens(js).1) { - return Some(Calculate::Notation(js.get(range)?.trim().to_string())); + return Some(Calculate::Notation(notation_text(js.get(range)?).to_string())); } let args = call(js, "AFSimple_Calculate")?; let op = match args.first().and_then(Arg::string)?.as_str() { @@ -325,6 +325,29 @@ pub fn validate_js(v: &Validate) -> Option { } } +/// Why [`notation_storable`] refuses a notation, in the words the Calculate tab and the tools say it. +pub const NOTATION_NOT_STORABLE: &str = "the notation can't be stored as written: it is empty, or holds EVCALC or */, or ends in a lone \\"; + +/// Whether simplified field notation `expr` can be stored: the script keeps it in a comment, +/// between `BVCALC` and `EVCALC`, and it has to read back from there as the same names and +/// operators. One holding `EVCALC` or `*/` ends early, and one ending in a lone `\` escapes the +/// space after it. An empty one is refused too: it would be stored as `event.value = ;`. +pub fn notation_storable(expr: &str) -> bool { + let tokens = |s: &str| notation_tokens(s).into_iter().map(|(range, _)| s.get(range).unwrap_or_default().to_owned()).collect::>(); + let read = calculate_js(&Calculate::Notation(expr.to_owned())).and_then(|js| parse_calculate(&js)); + !tokens(expr).is_empty() && matches!(read, Some(Calculate::Notation(n)) if tokens(&n) == tokens(expr)) +} + +/// `src` from its first notation token to its last: the spaces around it left out, but not an +/// escaped space that ends the last name (`Total\ `), as `str::trim` would. +fn notation_text(src: &str) -> &str { + let tokens = notation_tokens(src); + match (tokens.first(), tokens.last()) { + (Some((first, _)), Some((last, _))) => src.get(first.start..last.end).unwrap_or_default(), + _ => "", + } +} + pub fn calculate_js(c: &Calculate) -> Option { match c { Calculate::None => None, diff --git a/crates/forms/src/af/tests.rs b/crates/forms/src/af/tests.rs index ff25e06968..79c5c7be02 100644 --- a/crates/forms/src/af/tests.rs +++ b/crates/forms/src/af/tests.rs @@ -156,6 +156,57 @@ fn a_regex_after_a_condition_is_not_read_as_code() { assert_eq!(renamed(divides), Some(divides.replace("\"a\"", "\"qty\""))); } +/// A `getField` whose argument is more than one string, or an `AFSimple_Calculate` list that +/// isn't written out, reads a field whose name isn't known until the script runs. +#[test] +fn names_built_at_run_time_are_noticed() { + for js in [ + "event.value = getField(\"row\" + i).value;", + "event.value = this.getField(name).value;", + "AFSimple_Calculate(\"SUM\", list);", + "AFSimple_Calculate(\"SUM\", new Array(\"a\", b));", + "AFSimple_Calculate(\"SUM\", [\"a\", prefix + \"b\"]);", + "AFSimple_Calculate(\"SUM\", new Array(\"a\", \"b\" + c));", + "AFSimple_Calculate(op, new Array(\"a\"));", + "AFSimple_Calculate(\"SUM\", \"row\" + suffix);", + "AFSimple_Calculate(\"SUM\", [\"a\"].concat(more));", + "AFSimple_Calculate(\"SUM\", [\"row\" + i]);", + ] { + assert!(reads_built_names(js), "{js}"); + } + // A string that is only the start of a name or a list isn't read, or renamed, as one. + for (js, reads) in [ + ("AFSimple_Calculate(\"SUM\", \"row\" + suffix);", vec![]), + ("AFSimple_Calculate(\"SUM\", [\"row\" + i]);", vec![]), + ("AFSimple_Calculate(\"SUM\", [\"a\", \"row\" + i]);", vec!["a"]), + ("AFSimple_Calculate(\"SUM\", [\"a\", \"b\"].concat(more));", vec!["a", "b"]), + ] { + assert_eq!(parse_calculate(js), None, "{js} is a script"); + assert_eq!(calculation_reads(js), reads, "{js}"); + assert_eq!(rename_in_calculation(js, "row", "line"), None, "{js}"); + } + for js in [ + "event.value = getField(\"a\").value + getField('b').value;", + "AFSimple_Calculate(\"SUM\", new Array(\"a\", \"b\"));", + "AFSimple_Calculate(\"SUM\", [\"a\", \"b\",]);", + "AFSimple_Calculate(\"SUM\", \"a, b\");", + "// getField(\"row\" + i)\nvar s = 'getField(name)'; event.value = 1;", + "/** BVCALC a + b EVCALC **/ event.value = getField(\"a\").value + getField(\"b\").value;", + ] { + assert!(!reads_built_names(js), "{js}"); + } + // The names it does know are still read and renamed. + let js = "event.value = getField(\"a\").value + getField(\"row\" + i).value + AFSimple_Calculate(\"SUM\", new Array(\"a\", b));"; + assert_eq!(calculation_reads(js), ["a"]); + assert_eq!(rename_in_calculation(js, "a", "qty"), Some(js.replace("\"a\"", "\"qty\""))); + // So are a list's, string or array, with another argument after it. + for js in ["AFSimple_Calculate(\"SUM\", \"a, b\", x);", "AFSimple_Calculate(\"SUM\", [\"a\", \"b\"], x);"] { + assert!(reads_built_names(js), "{js}"); + assert_eq!(calculation_reads(js), ["a", "b"], "{js}"); + assert_eq!(rename_in_calculation(js, "a", "qty"), Some(js.replace("\"a", "\"qty")), "{js}"); + } +} + /// Scripts are read in one pass: a line of unfinished regular expressions, or calls nested in /// one another's field lists, don't make a rename read the rest of the script again for each. #[test] @@ -166,6 +217,21 @@ fn hostile_scripts_are_read_in_one_pass() { assert_eq!(rename_in_calculation(&nested, "a", "b"), Some(format!("{}\"b\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000)))); } +/// A notation is stored only when it reads back from the script's comment as itself. +#[test] +fn a_notation_is_stored_only_when_it_reads_back() { + assert!(notation_storable("Price * Qty")); + assert!(notation_storable(" a + b "), "the spaces around it are left out when it is read"); + // A last name that ends in a space keeps it, escaped, when the notation is read back. + let total = "Qty * Total\\ "; + assert!(notation_storable(total)); + let read = parse_calculate(&calculate_js(&Calculate::Notation(total.into())).unwrap()); + assert_eq!(read.as_ref().map(Calculate::reads), Some(vec!["Qty".to_owned(), "Total ".to_owned()])); + for expr in ["", " ", "a EVCALC b", "a */ x /*", "a */ b", "a\\"] { + assert!(!notation_storable(expr), "{expr:?}"); + } +} + /// Notation is read through one lexer: field names with escapes, numbers and operators. #[test] fn notation_is_read_by_one_lexer() { @@ -213,6 +279,16 @@ proptest::proptest! { proptest::prop_assert_eq!(back, Some(js)); } + /// A notation of any names, written into a script, reads back from it as written. + #[test] + fn a_notation_reads_back_as_written(names in proptest::collection::vec("\\PC{1,8}", 1..5)) { + proptest::prop_assume!(!names.iter().any(|n| n.contains("VCALC"))); + let expr = names.iter().map(|n| notation_name(n)).collect::>().join(" + "); + let js = calculate_js(&Calculate::Notation(expr.clone())).unwrap(); + proptest::prop_assert_eq!(parse_calculate(&js), Some(Calculate::Notation(expr.clone()))); + proptest::prop_assert!(notation_storable(&expr), "{expr:?}"); + } + /// What a formula reads is the same whether it is read from the tab's settings or from /// the script they are written as. #[test] diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index c1bb5630a6..c9ff86d17a 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -40,9 +40,11 @@ pub fn fmt(v: f64) -> String { } /// The font size an auto-sized (`0 Tf`) text field uses for `text` in a `width` × `height` box -/// (inside the padding), measured by `width_of(text, size)`: at most 12 pt and at least 4 pt. -/// Single-line text shrinks until it fits the width; multiline text takes the largest size whose -/// wrapped lines fit the height. The page appearance and the on-screen editor share this. +/// (inside the padding), measured by `width_of(text, size)`, never under 4 pt. Single-line text +/// takes the size the height gives, as Acrobat Reader draws it however tall the box (the size is +/// implementation-defined, ISO 32000-2 §12.7.4.3), and shrinks until it fits the width; multiline +/// text takes the largest size up to 12 pt whose wrapped lines fit the height. The page +/// appearance and the on-screen editor share this. pub fn auto_text_size(text: &str, width: f64, height: f64, multiline: bool, width_of: impl Fn(&str, f64) -> f64) -> f64 { let wrap_width = if width.is_finite() { width.max(1.0) } else { 1.0 }; auto_text_size_by( @@ -54,6 +56,17 @@ pub fn auto_text_size(text: &str, width: f64, height: f64, multiline: bool, widt ) } +/// The automatic font size of a comb field (`Field::is_comb`) whose cells are `height` tall inside +/// the padding: 4 to 12 pt, as only the height bounds a one-character cell. The page appearance +/// and the on-screen editor share this. +pub fn comb_auto_text_size(height: f64) -> f64 { + if height.is_finite() { (height / 1.15).clamp(4.0, 12.0) } else { 4.0 } +} + +/// The tallest box single-line auto-size follows, as a sanity bound: the largest page side +/// ISO 32000-1 Annex C recommends. +pub(crate) const MAX_AUTO_HEIGHT: f64 = 14_400.0; + /// [`auto_text_size`] with the text measured by the caller: `line_width(size)` is its width on /// one line, `wrapped_height(size)` its height wrapped at `width`. The screen measures in its /// own font, whose widths and line breaks differ from the page's. @@ -67,7 +80,7 @@ pub fn auto_text_size_by(width: f64, height: f64, multiline: bool, line_width: i } return size; } - let size = (height / 1.15).clamp(4.0, 12.0); + let size = (height.min(MAX_AUTO_HEIGHT) / 1.15).max(4.0); let tw = line_width(size); if tw.is_finite() && tw > width { (size * width / tw).max(4.0) } else { size } } @@ -109,7 +122,7 @@ pub fn text_box(doc: &Document, f: &Field, w: &Widget) -> TextBox { size: (da.size > 0.0 && da.size.is_finite()).then_some(da.size), multiline, password: f.has(flags::PASSWORD), - comb: f.has(flags::COMB) && !multiline && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0), + comb: f.is_comb(), } } @@ -586,7 +599,7 @@ fn comb_dividers(doc: &Document, wd: &Dict, w: f64, h: f64, cells: usize) -> Str let inset = border_width(doc, wd); let lw = line_width(doc, wd); let Some(bc) = color_array(doc, mk.as_ref(), b"BC") else { return String::new() }; - if !(inset > 0.0 && inset.is_finite() && lw > 0.0 && lw.is_finite()) || !(2..=MAX_COMB_DIVIDERS).contains(&cells) { + if !(inset > 0.0 && inset.is_finite() && lw > 0.0 && lw.is_finite() && (2..=MAX_COMB_DIVIDERS).contains(&cells)) { return String::new(); } let cell = w / cells as f64; @@ -813,7 +826,7 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] } } _ => { - let comb = f.has(flags::COMB) && !f.has(flags::MULTILINE) && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0); + let comb = f.is_comb(); if f.kind == FieldKind::Text && f.has(flags::MULTILINE) { if size == 0.0 { size = auto_text_size(&text, inner_w, height - 2.0 * pad, true, width_of); @@ -840,9 +853,8 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] } } else { if size == 0.0 { - // Comb cells hold one character each, so only the height limits them. size = if comb { - ((height - 2.0 * pad) / 1.15).clamp(4.0, 12.0) + comb_auto_text_size(height - 2.0 * pad) } else { auto_text_size(&text, inner_w, height - 2.0 * pad, false, width_of) }; diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 79bf4eac1d..de597187d4 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -11,10 +11,13 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use crate::{Field, FieldKind, FormError, Widget, appearance, fields, flags, page_refs}; mod edit; +mod join; mod lifecycle; mod look; mod tree; +pub(crate) use tree::ancestor_sets; + pub use edit::set_props; pub use lifecycle::{add_field, delete_field, delete_field_object, delete_widget, duplicate_field}; pub(crate) use look::button_appearance; @@ -85,6 +88,9 @@ pub struct FieldProps { pub max_len: Option>, /// Choice options (display texts; export values equal them). pub options: Option>, + /// Change one button widget's export value without changing its appearance state. + /// Widgets sharing that state are kept in sync. The index is zero-based. + pub export_value: Option<(usize, String)>, /// Font size (0 = auto). pub font_size: Option, /// Position: move or resize one widget (its index in [`Field::widgets`]) to a new rect in diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index d61cfe28f2..2d05600ae2 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -1,5 +1,6 @@ +use super::join::{check_join, join_radio_group}; use super::look::{BorderStyle, da_string, patch_da, patch_widget_look, redraw_widgets}; -use super::tree::{attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited}; +use super::tree::{ancestor_sets, attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited}; use super::{Dict, Document, Field, FieldKind, FieldProps, FormError, MIN_FIELD_SIDE, ObjRef, Object, PdfString, fields, flags, invalid}; /// Swap width and height, keeping the center. `None` when that is not a usable widget. @@ -14,6 +15,97 @@ fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { (out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= MIN_FIELD_SIDE && out[3] - out[1] >= MIN_FIELD_SIDE).then_some(out) } +/// Re-point a choice field's `/I`, positions in `/Opt`, at the new list `exports` after `/Opt` was +/// replaced (ISO 32000-2 §12.7.5.4): the positions of the selected `values` still offered. `kept` +/// are the old `/I` positions carried into the new list, which tell apart options sharing an +/// export value; a selected value none of them covers takes its first position, by `set_value`'s +/// rule. Returns them. +fn repoint_selection(d: &mut Dict, values: &[String], exports: &[String], kept: &[usize]) -> Vec { + let mut indices: Vec = kept.iter().copied().filter(|&i| exports.get(i).is_some_and(|e| values.contains(e))).collect(); + for v in values { + if !indices.iter().any(|&i| exports.get(i) == Some(v)) { + indices.extend(exports.iter().position(|e| e == v)); + } + } + indices.sort_unstable(); + indices.dedup(); + if indices.is_empty() { + d.remove(b"I"); + } else { + d.set(b"I".to_vec(), Object::Array(indices.iter().map(|&i| Object::Int(i64::try_from(i).unwrap_or(i64::MAX))).collect())); + } + indices +} + +/// The positions in `/Opt` that a choice field's `/I` holds, those below `count`. +fn selected_indices(doc: &Document, field: ObjRef, count: usize) -> Vec { + let Some(i) = doc.get(field).as_dict().and_then(|d| d.get(b"I").cloned()) else { + return Vec::new(); + }; + let positions = doc.resolve(&i); + positions.as_array().map(|a| a.iter().filter_map(|o| usize::try_from(o.as_int()?).ok()).filter(|&n| n < count).collect()).unwrap_or_default() +} + +/// The items (export value, display text) "Sort items" orders: the new `props.options`, or else +/// the field's own, whose selection, `/I` included, moves with them. +fn items_to_sort(doc: &Document, f: &Field, props: &FieldProps) -> (Vec<(String, String)>, Vec) { + match &props.options { + Some(o) => (o.iter().map(|x| (x.clone(), x.clone())).collect(), Vec::new()), + None => (f.options.clone(), selected_indices(doc, f.obj, f.options.len())), + } +} + +/// Sort a choice field's items (export value, display text) by display text, ignoring case, and +/// write them as `/Opt`; export values stay with their items. Returns the export values in their +/// new order and the `old` positions carried through the sort. +fn write_sorted_opt(d: &mut Dict, items: &[(String, String)], old: &[usize]) -> (Vec, Vec) { + let mut order: Vec = (0..items.len()).collect(); + order.sort_by_key(|&i| items.get(i).map(|(_, display)| display.to_lowercase())); + let sorted: Vec<&(String, String)> = order.iter().filter_map(|&i| items.get(i)).collect(); + let opt = sorted + .iter() + .map(|(e, disp)| { + if e == disp { + Object::String(PdfString::text(disp)) + } else { + Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(disp))]) + } + }) + .collect(); + d.set(b"Opt".to_vec(), Object::Array(opt)); + let carried = old.iter().filter_map(|o| order.iter().position(|i| i == o)).collect(); + (sorted.into_iter().map(|(e, _)| e.clone()).collect(), carried) +} + +/// Build /Opt before modifying the document, preserving appearance states and selection. +fn button_export_options(f: &Field, index: usize, value: &str) -> Result, FormError> { + if !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) { + return invalid("only check boxes and radio buttons have an export value"); + } + if value.is_empty() || value == "Off" || value.len() > 4096 { + return invalid("export value must be non-empty, at most 4096 bytes, and not Off"); + } + let widget = f.widgets.get(index).ok_or_else(|| FormError::Invalid("button widget index is out of range".into()))?; + let state = widget.on_state.as_deref().ok_or_else(|| FormError::Invalid("button has no on appearance state".into()))?; + if !f.options.is_empty() && f.options.len() != f.widgets.len() { + return invalid("button export options do not match its widgets"); + } + let previous = f.export_of(index); + let mut out = Vec::with_capacity(f.widgets.len()); + for (i, w) in f.widgets.iter().enumerate() { + let export = f.export_of(i).ok_or_else(|| FormError::Invalid("button has no on appearance state".into()))?; + let same_state = w.on_state.as_deref() == Some(state); + if !same_state && (export == value || w.on_state.as_deref() == Some(value)) { + return invalid("export value conflicts with another button choice"); + } + if same_state && Some(export) != previous { + return invalid("widgets with the same appearance state have conflicting export values"); + } + out.push(Object::String(PdfString::text(if same_state { value } else { export }))); + } + Ok(out) +} + /// Change a field's properties (General and Options tabs) and redraw it. pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) { @@ -25,8 +117,18 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result Result Result Result> = None; + let mut kept = Vec::new(); + if let Some(options) = &export_options { + d.set(b"Opt".to_vec(), Object::Array(options.clone())); + } if let Some(opts) = &props.options { d.set(b"Opt".to_vec(), Object::Array(opts.iter().map(|o| Object::String(PdfString::text(o))).collect())); + new_exports = Some(opts.clone()); } // "Sort items": the list itself is kept in order (export values stay with their items). if ff & flags::SORT != 0 && matches!(f.kind, FieldKind::Combo | FieldKind::List) { - let mut items: Vec<(String, String)> = match &props.options { - Some(o) => o.iter().map(|x| (x.clone(), x.clone())).collect(), - None => f.options.clone(), - }; - items.sort_by_key(|(_, display)| display.to_lowercase()); - let opt = items - .iter() - .map(|(e, disp)| { - if e == disp { - Object::String(PdfString::text(disp)) - } else { - Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(disp))]) - } - }) - .collect(); - d.set(b"Opt".to_vec(), Object::Array(opt)); + let (exports, carried) = write_sorted_opt(d, &sort_items, &old_selection); + new_exports = Some(exports); + kept = carried; + } + if let Some(exports) = &new_exports + && matches!(f.kind, FieldKind::Combo | FieldKind::List) + { + list_picked = Some((repoint_selection(d, &f.value, exports, &kept), exports.len())); } if let Some(q) = props.quadding { d.set(b"Q".to_vec(), Object::Int(q)); @@ -167,6 +279,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result d.set(b"DV".to_vec(), Object::Name(crate::name_bytes(v))), Some(Some(v)) => d.set(b"DV".to_vec(), PdfString::text(v)), + // `/DV` is inheritable: with an ancestor's in place, removing the field's own would + // bring that one back, so the field keeps an empty default of its own instead. + Some(None) if parent_default && matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => { + d.set(b"DV".to_vec(), Object::Name(b"Off".to_vec())) + } + Some(None) if parent_default => d.set(b"DV".to_vec(), PdfString::text("")), Some(None) => { d.remove(b"DV"); } @@ -246,15 +364,20 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result { +/// Apply a new name from `props`: rename the field, move it under the parents a full name +/// names, or, for a radio button named after another radio group, pick that group to join. +/// Returns the field's new full name and the group it joins. +fn rename_field(doc: &mut Document, all: &[Field], f: &Field, name: &str, props: &FieldProps) -> Result<(String, Option), FormError> { let mut new_name = name.to_string(); + let mut join = None; if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) { // A full name under the same parents only renames the field's own part (the editor // shows nested fields by their full name): it isn't moved, so it keeps its place and @@ -263,7 +386,16 @@ fn rename_field(doc: &mut Document, all: &[Field], f: &Field, name: &str, props: (Some((parents, own)), Some((current, _))) if parents == current => own, _ => n, }; - if n.contains('.') { + let full = match f.name.rsplit_once('.') { + Some((parents, _)) if !n.contains('.') => format!("{parents}.{n}"), + _ => n.to_string(), + }; + let group = all.iter().find(|x| x.obj != f.obj && x.kind == FieldKind::Radio && x.name == full); + if let Some(group) = group.filter(|_| f.kind == FieldKind::Radio) { + check_join(doc, f, group)?; + join = Some(group.clone()); + new_name = full; + } else if n.contains('.') { // A full name: the field moves under the parent fields it names (#2696). let parts = name_parts(n)?; if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) { @@ -307,7 +439,7 @@ fn rename_field(doc: &mut Document, all: &[Field], f: &Field, name: &str, props: rename_in_calculations(doc, all, &f.name, &new_name)?; } } - Ok(new_name) + Ok((new_name, join)) } fn apply_widget_style_props(doc: &mut Document, f: &Field, name: &str, props: &FieldProps) -> Result<(), FormError> { @@ -359,6 +491,43 @@ fn apply_widget_style_props(doc: &mut Document, f: &Field, name: &str, props: &F Ok(()) } +/// Redraw the edited widgets. A radio button joining another group (`join`) is redrawn under its +/// old name `name` first; then its buttons move, and only they are redrawn as `new_name`, so the +/// group's own buttons keep their appearances. +fn redraw_or_join(doc: &mut Document, f: &Field, name: &str, new_name: &str, join: Option<&Field>, only: Option) -> Result<(), FormError> { + if let Some(group) = join { + redraw_widgets(doc, name, only)?; + join_radio_group(doc, f, group)?; + let moved: Vec = fields(doc) + .into_iter() + .find(|g| g.name == new_name) + .map(|g| g.widgets.iter().enumerate().filter(|(_, w)| f.widgets.iter().any(|m| m.obj == w.obj)).map(|(i, _)| i).collect()) + .unwrap_or_default(); + for index in moved { + redraw_widgets(doc, new_name, Some(index))?; + } + Ok(()) + } else { + redraw_widgets(doc, new_name, only) + } +} + +/// A list box scrolls, as for a new value, so its first row is still an option and the +/// selection is in view. Its rows are measured with the edited font size and rectangle. +fn scroll_list(doc: &mut Document, field: ObjRef, list_picked: Option<(Vec, usize)>) -> Result<(), FormError> { + if let Some((picked, count)) = list_picked + && let Some(g) = fields(doc).into_iter().find(|g| g.obj == field) + { + if let Some((old, new)) = crate::values::list_scroll(&g, &picked, count) + && old != new + { + doc.update_dict(field, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?; + } + crate::values::scroll_widgets(doc, &g, &picked, count)?; + } + Ok(()) +} + fn apply_late_widget_props( doc: &mut Document, f: &Field, diff --git a/crates/forms/src/author/join.rs b/crates/forms/src/author/join.rs new file mode 100644 index 0000000000..e9fedbd655 --- /dev/null +++ b/crates/forms/src/author/join.rs @@ -0,0 +1,129 @@ +//! Renaming a radio button to an existing radio group's name makes it a button of that group +//! (#4949): radio buttons are one group when they are widgets of one field (ISO 32000-2 +//! §12.7.5.2.4), and a button's export value is the name of its "on" appearance state. + +use std::collections::{HashMap, HashSet}; + +use super::lifecycle::{drop_from_calculation_order, split_merged, split_triggers}; +use super::tree::{detach, list_of}; +use super::{Dict, Document, Field, FormError, ObjRef, Object, invalid}; + +/// The keys a field that is also its own widget sheds when it becomes only a widget. Its `/AA` +/// keeps only the widget's own triggers. +const FIELD_ONLY_KEYS: [&[u8]; 15] = [b"T", b"FT", b"Kids", b"Ff", b"V", b"DV", b"Opt", b"TU", b"TM", b"MaxLen", b"Q", b"DS", b"RV", b"TI", b"I"]; + +/// Whether `field` holds `/Opt`, which its buttons' export values index (ISO 32000-2 §12.7.5.2). +fn has_opt(doc: &Document, field: ObjRef) -> bool { + doc.get(field).as_dict().is_some_and(|d| d.contains(b"Opt")) +} + +/// Refuse what [`join_radio_group`] can't do, before anything is written. +pub(super) fn check_join(doc: &Document, f: &Field, target: &Field) -> Result<(), FormError> { + for x in [f, target] { + if has_opt(doc, x.obj) { + return invalid(format!("{} lists its export values in /Opt, so its buttons can't move between groups", x.name)); + } + if x.widgets.is_empty() { + return invalid(format!("{} has no button", x.name)); + } + } + if target.locked() { + return invalid(format!("{} is locked: unlock it first", target.name)); + } + Ok(()) +} + +/// Move every button of radio field `f` into radio field `target`. Each keeps its export value +/// unless a button of `target` already has it; then it takes the first free "ChoiceN", as a +/// button added to a group does, and buttons of `f` that shared a value still share the new one. A moved button shows selected only if it is `target`'s +/// selected value. `f` leaves the form; its own value and field-level properties go with it, +/// and `target`'s stay. Call [`check_join`] first. +pub(super) fn join_radio_group(doc: &mut Document, f: &Field, target: &Field) -> Result<(), FormError> { + let theirs: HashSet = target.widgets.iter().filter_map(|w| w.on_state.clone()).collect(); + let mut taken = theirs.clone(); + taken.extend(f.widgets.iter().filter_map(|w| w.on_state.clone())); + // The group's value counts only when one of its buttons has it. + let selected = target.value.first().filter(|v| theirs.contains(*v)).cloned(); + // Each clashing value of `f` gets one fresh name, shared by every button that had it. Of + // `taken.len() + 1` names at least one is free, so the search always ends in one. + let mut renamed: HashMap = HashMap::new(); + for old in f.widgets.iter().filter_map(|w| w.on_state.as_deref()).filter(|s| theirs.contains(*s)) { + if renamed.contains_key(old) { + continue; + } + let Some(fresh) = (1..=taken.len().saturating_add(1)).map(|i| format!("Choice{i}")).find(|c| !taken.contains(c)) else { + return invalid(format!("no free export value for {old}")); + }; + taken.insert(fresh.clone()); + renamed.insert(old.to_owned(), fresh); + } + // A group that is its own one button gets a field with a separate widget first, so it can + // hold more than one. + if let [only] = target.widgets.as_slice() + && only.obj == target.obj + { + split_merged(doc, target, only)?; + } + detach(doc, f.obj)?; + // `f` is no longer a field, even when it stays on as a widget. + drop_from_calculation_order(doc, |r| r == f.obj)?; + let mut kids = Vec::with_capacity(f.widgets.len()); + for w in &f.widgets { + let mut d = doc.get(w.obj).as_dict().cloned().unwrap_or_default(); + if w.obj == f.obj { + for k in FIELD_ONLY_KEYS { + d.remove(k); + } + let aa = d.get(b"AA").and_then(|a| doc.resolve(a).as_dict().cloned()); + match aa.as_ref().map(split_triggers) { + Some((Some(widget_aa), _)) => d.set(b"AA".to_vec(), Object::Dict(widget_aa)), + Some((None, _)) => { + d.remove(b"AA"); + } + None => {} + } + } + let mut state = w.on_state.clone(); + if let Some(old) = w.on_state.as_deref() + && let Some(fresh) = renamed.get(old) + && rename_state(doc, &mut d, old, fresh) + { + state = Some(fresh.clone()); + } + let on = state.is_some() && state == selected; + let shown = match (&state, on) { + (Some(s), true) => Object::Name(crate::name_bytes(s)), + _ => Object::name("Off"), + }; + d.set(b"AS".to_vec(), shown); + d.set(b"Parent".to_vec(), Object::Ref(target.obj)); + doc.set(w.obj, Object::Dict(d)); + kids.push(Object::Ref(w.obj)); + } + // `/Kids` may be an indirect array: read through it so the group's own buttons stay. + let mut list = list_of(doc, target.obj, b"Kids"); + list.extend(kids); + doc.update_dict(target.obj, |d| d.set(b"Kids".to_vec(), Object::Array(list)))?; + Ok(()) +} + +/// Rename the appearance state `old` to `new` in the widget dictionary `d` (`/AP` `/N`, `/D`, +/// `/R`). A shared `/AP` or state dictionary is copied into `d` rather than renamed for its other +/// users. Whether `/N` had the state: when it didn't, nothing is changed. +fn rename_state(doc: &Document, d: &mut Dict, old: &str, new: &str) -> bool { + let (old, new) = (crate::name_bytes(old), crate::name_bytes(new)); + let Some(mut ap) = d.get(b"AP").and_then(|a| doc.resolve(a).as_dict().cloned()) else { return false }; + let states = |ap: &Dict, key: &[u8]| ap.get(key).and_then(|s| doc.resolve(s).as_dict().cloned()); + if !states(&ap, b"N").is_some_and(|n| n.contains(&old)) { + return false; + } + for key in [&b"N"[..], b"D", b"R"] { + let Some(mut sub) = states(&ap, key) else { continue }; + if let Some(v) = sub.remove(&old) { + sub.set(new.clone(), v); + ap.set(key.to_vec(), Object::Dict(sub)); + } + } + d.set(b"AP".to_vec(), Object::Dict(ap)); + true +} diff --git a/crates/forms/src/author/lifecycle.rs b/crates/forms/src/author/lifecycle.rs index 472f8223d3..f719292a80 100644 --- a/crates/forms/src/author/lifecycle.rs +++ b/crates/forms/src/author/lifecycle.rs @@ -285,7 +285,7 @@ fn drop_from_pages(doc: &mut Document, widgets: &std::collections::HashSet bool) -> Result<(), FormError> { +pub(super) fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> { let Some(root) = doc.root() else { return Ok(()) }; let Some(af_obj) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) }; let Some(mut af) = doc.resolve(&af_obj).as_dict().cloned() else { return Ok(()) }; @@ -307,7 +307,23 @@ fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool } /// Widget-only keys: they stay with each widget when a merged field is split. -const WIDGET_KEYS: [&[u8]; 12] = [b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"F", b"BS", b"Border", b"H", b"StructParent"]; +const WIDGET_KEYS: [&[u8]; 13] = [b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"F", b"BS", b"Border", b"H", b"StructParent", b"A"]; + +/// The `/AA` triggers of a widget annotation (ISO 32000-2 §12.6.3, Table 197). The rest (`K`, +/// `F`, `V`, `C`, Table 199) belong to the field. +const WIDGET_TRIGGERS: [&[u8]; 10] = [b"E", b"X", b"D", b"U", b"Fo", b"Bl", b"PO", b"PC", b"PV", b"PI"]; + +/// Split additional-actions dictionary `aa` into the widget's triggers and the field's. +/// An empty half is `None`. +pub(super) fn split_triggers(aa: &Dict) -> (Option, Option) { + let (mut widget, mut field) = (Dict::new(), Dict::new()); + for (k, v) in aa.iter() { + let to = if WIDGET_TRIGGERS.contains(&k.as_slice()) { &mut widget } else { &mut field }; + to.set(k.clone(), v.clone()); + } + let some = |d: Dict| (!d.is_empty()).then_some(d); + (some(widget), some(field)) +} /// Duplicate a field onto other pages (Acrobat: right-click a field ▸ Duplicate): each page /// gets a widget at the same place, belonging to the same field, so they share one value. @@ -328,40 +344,7 @@ pub fn duplicate_field(doc: &mut Document, name: &str, pages: &[usize]) -> Resul return Ok(0); } // A merged field/widget becomes a field with one widget kid. - let mut model_ref = model.obj; - if model.obj == f.obj { - let d = doc.get(f.obj).as_dict().cloned().unwrap_or_default(); - let mut kid = Dict::new(); - for k in WIDGET_KEYS { - if let Some(v) = d.get(k) { - kid.set(k.to_vec(), v.clone()); - } - } - kid.set(b"Parent".to_vec(), Object::Ref(f.obj)); - let kr = doc.add(Object::Dict(kid)); - doc.update_dict(f.obj, |d| { - for k in WIDGET_KEYS { - d.remove(k); - } - d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)])); - })?; - // The page lists the widget now, not the field. - if let Some(p) = model.page { - let pr = refs[p]; - let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()); - let swap = - |a: Vec| -> Vec { a.into_iter().map(|o| if o.as_ref() == Some(f.obj) { Object::Ref(kr) } else { o }).collect() }; - match annots { - Some(Object::Ref(ar)) => { - let a = doc.get(ar).as_array().cloned().unwrap_or_default(); - doc.set(ar, Object::Array(swap(a))); - } - Some(Object::Array(a)) => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(swap(a))))?, - _ => {} - } - } - model_ref = kr; - } + let model_ref = if model.obj == f.obj { split_merged(doc, &f, &model)? } else { model.obj }; let template = doc.get(model_ref).as_dict().cloned().unwrap_or_default(); let mut added = Vec::new(); for p in &targets { @@ -394,3 +377,52 @@ pub fn duplicate_field(doc: &mut Document, name: &str, pages: &[usize]) -> Resul redraw_field(doc, name)?; Ok(added.len()) } + +/// Split field `f`, which is also its only widget `model`, into a field and one widget kid that +/// takes the widget's keys and its place in the page's `/Annots`. Returns the kid. +pub(super) fn split_merged(doc: &mut Document, f: &Field, model: &Widget) -> Result { + let refs = page_refs(doc); + let d = doc.get(f.obj).as_dict().cloned().unwrap_or_default(); + let mut kid = Dict::new(); + for k in WIDGET_KEYS { + if let Some(v) = d.get(k) { + kid.set(k.to_vec(), v.clone()); + } + } + // The widget's own triggers go with it; the field's stay. + let aa = d.get(b"AA").and_then(|a| doc.resolve(a).as_dict().cloned()); + let (widget_aa, field_aa) = aa.as_ref().map(split_triggers).unwrap_or_default(); + if let Some(w) = widget_aa { + kid.set(b"AA".to_vec(), Object::Dict(w)); + } + kid.set(b"Parent".to_vec(), Object::Ref(f.obj)); + let kr = doc.add(Object::Dict(kid)); + doc.update_dict(f.obj, |d| { + for k in WIDGET_KEYS { + d.remove(k); + } + if aa.is_some() { + match field_aa { + Some(fa) => d.set(b"AA".to_vec(), Object::Dict(fa)), + None => { + d.remove(b"AA"); + } + } + } + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)])); + })?; + // The page lists the widget now, not the field. + if let Some(&pr) = model.page.and_then(|p| refs.get(p)) { + let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned()); + let swap = |a: Vec| -> Vec { a.into_iter().map(|o| if o.as_ref() == Some(f.obj) { Object::Ref(kr) } else { o }).collect() }; + match annots { + Some(Object::Ref(ar)) => { + let a = doc.get(ar).as_array().cloned().unwrap_or_default(); + doc.set(ar, Object::Array(swap(a))); + } + Some(Object::Array(a)) => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(swap(a))))?, + _ => {} + } + } + Ok(kr) +} diff --git a/crates/forms/src/author/tree.rs b/crates/forms/src/author/tree.rs index d8ca67ffb7..ce4ee687bb 100644 --- a/crates/forms/src/author/tree.rs +++ b/crates/forms/src/author/tree.rs @@ -187,7 +187,7 @@ pub(super) fn clash(name: &str, other: &str) -> Option { } /// The array `key` of `holder` (`/Fields` of the form, `/Kids` of a field). -fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec { +pub(super) fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec { let d = doc.get(holder).as_dict().cloned().unwrap_or_default(); d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() } @@ -317,6 +317,24 @@ pub(super) fn inherited(doc: &Document, field: ObjRef) -> Dict { add } +/// Whether an ancestor of `field` sets the inheritable `key`, which `field` would take on without +/// its own entry. +pub(crate) fn ancestor_sets(doc: &Document, field: ObjRef, key: &[u8]) -> bool { + let mut seen = std::collections::HashSet::new(); + let mut parent = doc.get(field).as_dict().and_then(|d| d.reference(b"Parent")); + while let Some(p) = parent { + if seen.len() > 64 || !seen.insert(p) { + return false; + } + let Some(d) = doc.get(p).as_dict().cloned() else { return false }; + if d.contains(key) { + return true; + } + parent = d.reference(b"Parent"); + } + false +} + /// Copy onto `field` what it inherits (`add`, from [`inherited`]), so it keeps it when it moves. pub(super) fn own_inherited(doc: &mut Document, field: ObjRef, add: &Dict) -> Result<(), FormError> { if add.iter().next().is_some() { diff --git a/crates/forms/src/calc_order.rs b/crates/forms/src/calc_order.rs index 4e7f0309e9..62c16ce67d 100644 --- a/crates/forms/src/calc_order.rs +++ b/crates/forms/src/calc_order.rs @@ -17,18 +17,24 @@ const MAX_LINKS: usize = 1 << 20; pub struct CalculatedField { /// The field's full name. pub name: String, + /// The field object. Two calculated fields can share a name in a malformed form; this tells + /// them apart. + pub obj: ObjRef, /// The fields its formula reads, each once, as the formula names them: a name also stands for /// the fields under it. A script's names built at run time (`getField("row" + i)`) aren't here. pub reads: Vec, /// `/CO` lists it. A field it leaves out calculates after the listed ones. pub listed: bool, - /// What is wrong with its place in the order. + /// What is wrong with its formula or its place in the order. pub issues: Vec, } -/// Something wrong with a calculated field's place in the calculation order. +/// Something wrong with a calculated field's formula or its place in the calculation order. #[derive(Clone, Debug, PartialEq)] pub enum CalculationIssue { + /// It reads `field`, which the form doesn't have (a field deleted, renamed outside PdfCraft, + /// or mistyped), so the formula gets no value there. + MissingField { field: String }, /// It reads `field`, which calculates after it, so it uses that field's previous value. RunsTooEarly { field: String }, /// Its formula reads its own result: it reads `fields`, which read it back (itself, when it @@ -37,6 +43,9 @@ pub enum CalculationIssue { /// `/CO` leaves it out. PdfCraft calculates it after the listed fields, but other PDF viewers /// may not calculate it at all. NotInOrder, + /// Its script also reads fields by names it builds at run time (`getField("row" + i)`). Those + /// aren't in `reads`, so the order isn't checked against them. + BuiltNames, } /// A form's calculation order and what is wrong with it. @@ -152,11 +161,14 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder { .collect(); let links = links(calculated.iter().map(|(f, _)| f.name.as_str()), &reads); let component = components(&links); + let mut all: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); + all.sort_unstable(); let name = |k: usize| calculated.get(k).map(|(f, _)| f.name.clone()).unwrap_or_default(); let mut out = Vec::with_capacity(calculated.len()); for (k, ((f, listed), reads)) in calculated.iter().zip(reads).enumerate() { let own = component.get(k).copied(); - let mut issues = Vec::new(); + let mut issues: Vec = + reads.iter().filter(|name| !found(&all, name)).map(|name| CalculationIssue::MissingField { field: name.clone() }).collect(); let read = links.get(k).map(Vec::as_slice).unwrap_or_default(); for &j in read { if j > k && component.get(j).copied() != own { @@ -172,12 +184,25 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder { if !listed { issues.push(CalculationIssue::NotInOrder); } - out.push(CalculatedField { name: f.name.clone(), reads, listed: *listed, issues }); + if f.actions.scripts.calculate.as_deref().is_some_and(crate::af::reads_built_names) { + issues.push(CalculationIssue::BuiltNames); + } + out.push(CalculatedField { name: f.name.clone(), obj: f.obj, reads, listed: *listed, issues }); } let fixed = fixed_order(&links, &component); CalculationOrder { fields: out, extra, fixed } } +/// Whether a formula's read `name` finds a field in `names` (sorted): the field of that name or a +/// field under it (`row` for `row.1`), as [`links`], [`crate::recalculate`] and the scripts' +/// `getField` look names up. Acrobat's widget names (`price.0`) aren't fields to any of them, so +/// a read of one gets no value and is missing too. +fn found(names: &[&str], name: &str) -> bool { + let prefix = format!("{name}."); + let under = names.partition_point(|n| *n < prefix.as_str()); + names.binary_search(&name).is_ok() || names.get(under).is_some_and(|n| n.starts_with(&prefix)) +} + /// For each calculated field (by its place in `names`), the places of the calculated fields it /// reads, sorted. A read name finds the field of that name and the fields under it, as /// [`crate::recalculate`] looks values up. @@ -383,6 +408,19 @@ mod tests { assert_eq!(links(names.into_iter(), &reads)[5], [0, 1, 2], "row, row.1 and row.10; not rows or row-1"); } + #[test] + fn a_read_name_finds_a_field_or_a_group() { + let mut names = vec!["price", "row.1", "row.2", "rows", "total"]; + names.sort_unstable(); + for name in ["price", "row", "row.1", "rows"] { + assert!(found(&names, name), "{name}"); + } + // A widget name finds nothing: recalculation and getField don't resolve it either. + for name in ["pric", "ro", "row.3", "price.", "price.0", "price.12", "total.0.1", ""] { + assert!(!found(&names, name), "{name}"); + } + } + /// Two thousand formulas each reading a group of two thousand fields would be four million /// reads; the checks stop at their cap. #[test] diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index c7f2f05113..775306d865 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -176,6 +176,12 @@ impl Field { self.flags & flag != 0 } + /// A comb field: the Comb flag set with a `/MaxLen`, neither multiline nor password (ISO + /// 32000-2 Table 228). Each cell holds one character. + pub fn is_comb(&self) -> bool { + self.has(flags::COMB) && !self.has(flags::MULTILINE) && !self.has(flags::PASSWORD) && self.max_len.is_some_and(|m| m > 0) + } + pub fn read_only(&self) -> bool { self.has(flags::READ_ONLY) } @@ -214,6 +220,15 @@ impl Field { self.widgets.iter().position(|w| w.on_state.as_deref() == Some(state)).and_then(|i| self.export_of(i)).unwrap_or(state) } + /// Values for data export, resolving button appearance names through /Opt. + pub fn export_values(&self) -> Vec { + if matches!(self.kind, FieldKind::CheckBox | FieldKind::Radio) { + self.value.iter().map(|v| self.export_for_state(v).to_string()).collect() + } else { + self.value.clone() + } + } + /// The value as one string: text, the state name, or the selected display texts. pub fn display_value(&self) -> String { match self.kind { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 49c9384b7b..cca4cb4881 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1711,6 +1711,73 @@ fn the_calculation_order_is_checked_fixed_and_kept() { assert_eq!(order.fixed(), ["sub", "total", "tax", "grand"], "only tax moves, to just after total"); } +/// `set_props` refuses a notation it couldn't store as written before it writes anything: the +/// tooltip given with it, which is written before the calculation, isn't set either. +#[test] +fn a_notation_that_cannot_be_stored_changes_nothing() { + use crate::af::Calculate; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (total) /Rect [50 700 140 711] /P 3 0 R >>".into(), + ]; + let mut doc = document(&objs); + for n in ["", "a EVCALC b", "a */ b", "a\\"] { + let props = FieldProps { tooltip: Some("Total".into()), calculate: Some(Calculate::Notation(n.into())), ..Default::default() }; + assert!(set_props(&mut doc, "total", &props).is_err(), "{n:?}"); + let all = fields(&doc); + let f = field(&all, "total"); + assert_eq!((f.tooltip.as_deref(), &f.actions.calculate), (None, &Calculate::None), "{n:?} changed nothing"); + } +} + +/// A formula that reads a field the form doesn't have is flagged, whichever kind of formula it +/// is: a field deleted from under a sum, a typo in notation, a script's `getField`. So is a +/// script that builds the names it reads. Neither is an order Fix order can fix. +#[test] +fn missing_fields_and_built_names_are_flagged() { + use crate::af::{CalcOp, Calculate}; + use crate::calc_order::CalculationIssue::{BuiltNames, MissingField}; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (i, name) in ["qty", "gone", "sum", "notation", "script", "built"].into_iter().enumerate() { + let y = 700.0 - 40.0 * i as f64; + add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap(); + } + let calculate = |c: Calculate| FieldProps { calculate: Some(c), ..FieldProps::default() }; + set_props(&mut doc, "sum", &calculate(Calculate::Simple { op: CalcOp::Sum, fields: vec!["qty".into(), "gone".into()] })).unwrap(); + set_props(&mut doc, "notation", &calculate(Calculate::Notation("qty * nope".into()))).unwrap(); + // qty has one widget, but a widget name is no field: recalculation and getField give qty.0 + // nothing, so it is as missing as qty.1. + let script = "event.value = getField(\"qty\").value + getField(\"ghost\").value + getField(\"qty.0\").value + getField(\"qty.1\").value;"; + set_field_script(&mut doc, "script", "calculate", Some(script)).unwrap(); + set_field_script(&mut doc, "built", "calculate", Some("event.value = getField(\"qty\").value + getField(\"row\" + 1).value;")).unwrap(); + let issues = |doc: &Document| { + let order = calculation_order(doc, &fields(doc)); + assert!(!order.fixable(), "nothing Fix order can fix"); + order.fields.into_iter().map(|f| (f.name, f.issues)).collect::>() + }; + let expected = |sum: Vec<_>| { + vec![ + ("sum".to_owned(), sum), + ("notation".to_owned(), vec![MissingField { field: "nope".into() }]), + ( + "script".to_owned(), + vec![MissingField { field: "ghost".into() }, MissingField { field: "qty.0".into() }, MissingField { field: "qty.1".into() }], + ), + ("built".to_owned(), vec![BuiltNames]), + ] + }; + assert_eq!(issues(&doc), expected(vec![])); + delete_field(&mut doc, "gone").unwrap(); + assert_eq!(issues(&doc), expected(vec![MissingField { field: "gone".into() }]), "a deleted field leaves the sum reading nothing"); + assert_eq!(issues(&reopen(&doc)), expected(vec![MissingField { field: "gone".into() }])); + let order = calculation_order(&doc, &fields(&doc)); + assert_eq!(order.fields[3].reads, ["qty"], "the names it does know are still read"); +} + /// `/CO` as other tools leave it: a calculated field missing, a field without a calculation, an /// entry repeated, a reference to nothing and a number. The order reads around them, and setting /// the order writes `/CO` afresh. @@ -1775,6 +1842,12 @@ fn two_fields_with_one_name_keep_their_places_in_the_order() { }; let row = |name: &str, reads: &str, ok: bool| (name.to_owned(), vec![reads.to_owned()], ok); assert_eq!(rows(&doc), [row("dup", "src", false), row("src", "x", true), row("dup", "x", true)]); + // Each row is its own field object, so a caller can find the right formula for each dup. + let all = fields(&doc); + for c in calculation_order(&doc, &all).fields { + let f = all.iter().find(|f| f.obj == c.obj).unwrap(); + assert_eq!((&f.name, f.actions.calculate.reads()), (&c.name, c.reads.clone())); + } let current: Vec = ["dup", "src", "dup"].map(String::from).into(); set_calculation_order(&mut doc, ¤t).unwrap(); assert_eq!(rows(&doc), [row("dup", "src", false), row("src", "x", true), row("dup", "x", true)], "the current order sets back unchanged"); @@ -1796,7 +1869,7 @@ fn two_fields_with_one_name_keep_their_places_in_the_order() { #[test] fn new_text_fields_fit_their_box() { // #1888: a fixed 12 pt was clipped in a short box. New text and date fields take Acrobat's - // Auto size: 12 pt where there is room, smaller where there isn't. + // Auto size: the size the box's height gives, smaller where the text is too wide. let mut doc = one_page(); let tf_size = |ap: &str| { ap.split("/Helv ") @@ -1815,7 +1888,9 @@ fn new_text_fields_fit_their_box() { } add_field(&mut doc, 0, [20.0, 50.0, 200.0, 72.0], &NewField::Text { multiline: false }, Some("tall")).unwrap(); set_value(&mut doc, "tall", &FieldValue::Text("Hello world".into())).unwrap(); - assert_eq!(tf_size(&ap(&doc, &field(&fields(&doc), "tall").widgets[0])), 12.0, "a box with room keeps the 12 pt look"); + // 22 pt tall, less a 1 pt border and 2 pt padding each side: 16 / 1.15. + let tall = tf_size(&ap(&doc, &field(&fields(&doc), "tall").widgets[0])); + assert!((tall - 16.0 / 1.15).abs() < 0.01, "a box with room fills its height, not 12 pt: {tall}"); // An explicit size still wins. set_props(&mut doc, "short", &FieldProps { font_size: Some(12.0), ..FieldProps::default() }).unwrap(); assert_eq!(tf_size(&ap(&doc, &field(&fields(&doc), "short").widgets[0])), 12.0); @@ -3225,8 +3300,12 @@ fn auto_text_size_fits_single_and_multiline_text() { assert_eq!(auto_text_size("Hi", 94.0, 8.0, false, helvetica_width), 8.0 / 1.15); let s = auto_text_size(long, 194.0, 8.0, false, helvetica_width); assert!(s > 4.0 && s < 8.0 / 1.15 && helvetica_width(long, s) <= 194.0 + 1e-9, "{s}"); - // Never past 12 pt or under 4 pt. - assert_eq!(auto_text_size("Hi", 500.0, 500.0, false, helvetica_width), 12.0); + // A tall single-line box gets text as tall as it allows (Acrobat has no 12 pt cap there), + // bounded by the largest page; never under 4 pt. + assert_eq!(auto_text_size("Hi", 500.0, 24.0, false, helvetica_width), 24.0 / 1.15); + assert_eq!(auto_text_size("", 500.0, 1e300, false, helvetica_width), crate::appearance::MAX_AUTO_HEIGHT / 1.15); + let s = auto_text_size("Hi", 500.0, 500.0, false, helvetica_width); + assert!(s > 12.0 && helvetica_width("Hi", s) <= 500.0 + 1e-9, "{s}"); assert_eq!(auto_text_size(&long.repeat(20), 20.0, 8.0, false, helvetica_width), 4.0); // Multiline: the largest size whose wrapped lines fit the height. let m = auto_text_size(long, 114.0, 44.0, true, helvetica_width); @@ -4834,3 +4913,575 @@ fn deleting_a_widget_that_is_not_there_changes_nothing() { assert_eq!(delete_widget(&mut doc, "size", usize::MAX), Err(FormError::Invalid(format!("size has no widget {}", usize::MAX)))); assert_eq!(field(&fields(&doc), "size").widgets.len(), 3); } +/// Two new radio groups on one page, "Group1" (export `a`) and "Group2" (export `b`). +fn two_radio_groups(a: &str, b: &str) -> Document { + let mut doc = one_page(); + let radio = |export: &str| NewField::Radio { group: None, export: export.into() }; + assert_eq!(add_field(&mut doc, 0, [50.0, 600.0, 64.0, 614.0], &radio(a), None).unwrap(), "Group1"); + assert_eq!(add_field(&mut doc, 0, [80.0, 600.0, 94.0, 614.0], &radio(b), None).unwrap(), "Group2"); + doc +} + +fn rename_to(doc: &mut Document, from: &str, to: &str) -> Result { + set_props(doc, from, &FieldProps { name: Some(to.into()), ..FieldProps::default() }) +} + +fn page_widgets(doc: &Document) -> Vec { + let page = page_refs(doc)[0]; + let annots = doc.get(page).as_dict().unwrap().get(b"Annots").cloned().unwrap(); + doc.resolve(&annots).as_array().unwrap().iter().filter_map(|o| o.as_ref()).collect() +} + +/// #4949: a radio button given its group's name is one of the group's buttons, so the group can +/// be built up by naming buttons alike, as in Acrobat. +#[test] +fn a_radio_button_renamed_to_a_groups_name_joins_the_group() { + let mut doc = two_radio_groups("Yes", "No"); + set_value(&mut doc, "Group1", &FieldValue::Radio(Some("Yes".into()))).unwrap(); + let before = page_widgets(&doc); + assert_eq!(rename_to(&mut doc, "Group2", "Group1").unwrap(), "Group1"); + assert_eq!(page_widgets(&doc), before, "the buttons stay on the page where they were"); + let mut doc = reopen(&doc); + let all = fields(&doc); + assert_eq!(all.iter().map(|f| f.name.as_str()).collect::>(), ["Group1"]); + let group = field(&all, "Group1"); + assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["Yes", "No"]); + assert_eq!(group.value, ["Yes"], "the group keeps its value"); + assert_eq!(group.widgets.iter().map(|w| w.state.clone()).collect::>(), [Some("Yes".into()), Some("Off".into())]); + assert_eq!(page_widgets(&doc), group.widgets.iter().map(|w| w.obj).collect::>()); + // Choosing the moved button turns the other off: they are one group. + set_value(&mut doc, "Group1", &FieldValue::Radio(Some("No".into()))).unwrap(); + let group = field(&fields(&doc), "Group1").clone(); + assert_eq!(group.widgets.iter().map(|w| w.state.clone()).collect::>(), [Some("Off".into()), Some("No".into())]); +} + +/// Joining redraws only the buttons that moved: the group's own keep their appearances, which may +/// be custom ones a redraw would replace. +#[test] +fn joining_a_group_leaves_its_own_buttons_appearances_as_they_are() { + let mut doc = two_radio_groups("Yes", "No"); + let own = field(&fields(&doc), "Group1").widgets[0].obj; + let look = |doc: &Document| doc.get(own).as_dict().and_then(|d| d.get(b"AP").cloned()); + let before = look(&doc); + assert!(before.is_some()); + rename_to(&mut doc, "Group2", "Group1").unwrap(); + assert_eq!(look(&doc), before); + let group = field(&fields(&doc), "Group1").clone(); + assert!(group.widgets[1].on_state.as_deref() == Some("No"), "the moved button is drawn with its states"); +} + +/// A joining button whose export value a button of the group already has takes the next free +/// "ChoiceN", in its appearance states too, so the two can be chosen apart. +#[test] +fn a_joining_radio_button_with_a_taken_export_value_takes_a_free_one() { + let mut doc = two_radio_groups("Choice1", "Choice1"); + rename_to(&mut doc, "Group2", "Group1").unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + let group = field(&all, "Group1"); + assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["Choice1", "Choice2"]); + let moved = doc.get(group.widgets[1].obj).as_dict().unwrap().clone(); + let normal = doc.resolve(moved.get(b"AP").unwrap()).as_dict().unwrap().get(b"N").cloned().unwrap(); + let normal = doc.resolve(&normal).as_dict().unwrap().clone(); + assert!(normal.contains(b"Choice2") && !normal.contains(b"Choice1")); +} + +/// A group whose `/Kids` is an indirect array keeps its own buttons when another joins it. +#[test] +fn a_group_with_indirect_kids_keeps_its_buttons_when_joined() { + let mut doc = two_radio_groups("Yes", "No"); + let group = field(&fields(&doc), "Group1").obj; + let kids = doc.get(group).as_dict().unwrap().get(b"Kids").cloned().unwrap(); + let held = doc.add(kids); + doc.update_dict(group, |d| d.set(b"Kids".to_vec(), Object::Ref(held))).unwrap(); + rename_to(&mut doc, "Group2", "Group1").unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + assert_eq!(field(&all, "Group1").widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["Yes", "No"]); +} + +/// Buttons of the joining group that share an export value (a button repeated on another page, +/// on together) still share it after the join, under one fresh name. +#[test] +fn joining_radio_buttons_that_share_a_value_keep_sharing_it() { + let mut doc = two_radio_groups("Choice1", "Choice1"); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let mut p = pdfcraft_cos::Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])); + let r = doc.add(Object::Dict(p)); + doc.update_dict(pages, |d| { + let mut kids = d.get(b"Kids").unwrap().as_array().unwrap().clone(); + kids.push(Object::Ref(r)); + d.set(b"Count".to_vec(), Object::Int(kids.len() as i64)); + d.set(b"Kids".to_vec(), Object::Array(kids)); + }) + .unwrap(); + assert_eq!(duplicate_field(&mut doc, "Group2", &[1]).unwrap(), 1); + rename_to(&mut doc, "Group2", "Group1").unwrap(); + let doc = reopen(&doc); + let all = fields(&doc); + let group = field(&all, "Group1"); + assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["Choice1", "Choice2", "Choice2"]); +} + +/// One page holding radio fields that are each their own only widget, as other writers make +/// one-button groups: `(name, export value, x)`. +fn merged_radios(specs: &[(&str, &str, f64)]) -> Document { + let mut doc = one_page(); + let page = page_refs(&doc)[0]; + let af = crate::author::ensure_form(&mut doc).unwrap(); + let mut made = Vec::new(); + for &(name, state, x) in specs { + let look = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"0 g".to_vec()))); + let mut n = pdfcraft_cos::Dict::new(); + n.set(state.as_bytes().to_vec(), Object::Ref(look)); + n.set(b"Off".to_vec(), Object::Ref(look)); + let mut ap = pdfcraft_cos::Dict::new(); + ap.set(b"N".to_vec(), Object::Dict(n)); + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Widget")); + d.set(b"FT".to_vec(), Object::name("Btn")); + d.set(b"Ff".to_vec(), Object::Int(i64::from(flags::RADIO))); + d.set(b"T".to_vec(), pdfcraft_cos::PdfString::text(name)); + d.set(b"Rect".to_vec(), Object::Array(vec![x.into(), 600.into(), (x + 14.0).into(), 614.into()])); + d.set(b"P".to_vec(), Object::Ref(page)); + d.set(b"AP".to_vec(), Object::Dict(ap)); + d.set(b"AS".to_vec(), Object::name("Off")); + let r = doc.add(Object::Dict(d)); + doc.update_dict(af, |a| { + let mut list = a.get(b"Fields").and_then(|l| l.as_array().cloned()).unwrap_or_default(); + list.push(Object::Ref(r)); + a.set(b"Fields".to_vec(), Object::Array(list)); + }) + .unwrap(); + made.push(r); + } + doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(made.iter().map(|r| Object::Ref(*r)).collect()))).unwrap(); + doc +} + +/// A radio field that is its own (only) widget, as other writers make one-button groups, can +/// both join a group and be joined. +#[test] +fn radio_fields_that_are_their_own_widget_join_and_are_joined() { + let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]); + assert_eq!(rename_to(&mut doc, "other", "solo").unwrap(), "solo"); + let doc = reopen(&doc); + let all = fields(&doc); + assert_eq!(all.iter().map(|f| f.name.as_str()).collect::>(), ["solo"]); + let group = field(&all, "solo"); + assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>(), ["A", "B"]); + // The joined field's widget became a kid; the moved one shed its field entries. + let kids: Vec = group.widgets.iter().map(|w| w.obj).collect(); + assert!(!kids.contains(&group.obj)); + let moved = doc.get(group.widgets[1].obj).as_dict().unwrap().clone(); + assert!(!moved.contains(b"T") && !moved.contains(b"FT") && moved.reference(b"Parent") == Some(group.obj)); + let mut on_page = page_widgets(&doc); + on_page.sort(); + let mut kids = kids; + kids.sort(); + assert_eq!(on_page, kids, "the page lists the widgets, not the field"); +} + +/// A group that is its own only widget keeps its button's actions when another button joins +/// it: the new widget takes `/A` and the widget triggers of `/AA`, and the field keeps its own. +#[test] +fn a_joined_merged_groups_button_keeps_its_actions() { + let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]); + let solo = field(&fields(&doc), "solo").obj; + let js = |code: &str| { + let mut a = pdfcraft_cos::Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), pdfcraft_cos::PdfString::text(code)); + Object::Dict(a) + }; + let mut aa = pdfcraft_cos::Dict::new(); + aa.set(b"Fo".to_vec(), js("focus();")); + aa.set(b"V".to_vec(), js("validate();")); + let aa = doc.add(Object::Dict(aa)); + doc.update_dict(solo, |d| { + d.set(b"A".to_vec(), js("up();")); + d.set(b"AA".to_vec(), Object::Ref(aa)); + }) + .unwrap(); + rename_to(&mut doc, "other", "solo").unwrap(); + let doc = reopen(&doc); + assert_eq!( + field_actions(&doc, "solo").unwrap(), + [(Trigger::MouseUp, FieldAction::JavaScript("up();".into())), (Trigger::OnFocus, FieldAction::JavaScript("focus();".into()))] + ); + let group = field(&fields(&doc), "solo").clone(); + let fd = doc.get(group.obj).as_dict().unwrap().clone(); + let field_aa = doc.resolve(fd.get(b"AA").unwrap()).as_dict().unwrap().clone(); + assert!(!fd.contains(b"A") && field_aa.contains(b"V") && !field_aa.contains(b"Fo"), "the field keeps only its own trigger"); + let wd = doc.get(group.widgets[0].obj).as_dict().unwrap().clone(); + let widget_aa = doc.resolve(wd.get(b"AA").unwrap()).as_dict().unwrap().clone(); + assert!(widget_aa.contains(b"Fo") && !widget_aa.contains(b"V")); +} + +/// A joining button that was its own field keeps its widget triggers and drops the field's. +#[test] +fn a_joining_merged_button_sheds_its_fields_triggers() { + let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]); + let other = field(&fields(&doc), "other").obj; + let js = |code: &str| { + let mut a = pdfcraft_cos::Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), pdfcraft_cos::PdfString::text(code)); + Object::Dict(a) + }; + let mut aa = pdfcraft_cos::Dict::new(); + aa.set(b"U".to_vec(), js("up();")); + aa.set(b"V".to_vec(), js("validate();")); + aa.set(b"C".to_vec(), js("calc();")); + let aa = doc.add(Object::Dict(aa)); + doc.update_dict(other, |d| d.set(b"AA".to_vec(), Object::Ref(aa))).unwrap(); + rename_to(&mut doc, "other", "solo").unwrap(); + let doc = reopen(&doc); + let group = field(&fields(&doc), "solo").clone(); + let moved = group.widgets.iter().find(|w| w.on_state.as_deref() == Some("B")).unwrap(); + let wd = doc.get(moved.obj).as_dict().unwrap().clone(); + let widget_aa = doc.resolve(wd.get(b"AA").unwrap()).as_dict().unwrap().clone(); + assert!(widget_aa.contains(b"U") && !widget_aa.contains(b"V") && !widget_aa.contains(b"C"), "only the widget's trigger stays"); + let mut once = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]); + let other = field(&fields(&once), "other").obj; + let mut aa = pdfcraft_cos::Dict::new(); + aa.set(b"V".to_vec(), js("validate();")); + once.update_dict(other, |d| d.set(b"AA".to_vec(), Object::Dict(aa))).unwrap(); + rename_to(&mut once, "other", "solo").unwrap(); + let once = reopen(&once); + let group = field(&fields(&once), "solo").clone(); + let moved = group.widgets.iter().find(|w| w.on_state.as_deref() == Some("B")).unwrap(); + let wd = once.get(moved.obj).as_dict().unwrap().clone(); + assert!(!wd.contains(b"AA"), "a widget left with no trigger has no /AA"); +} + +/// However many "ChoiceN" names a group already uses, a joining button with a clashing export +/// value gets a free one, so the two buttons can still be chosen apart. +#[test] +fn a_joining_button_gets_a_free_export_value_when_choice_names_run_long() { + let mut doc = two_radio_groups("Choice1", "Choice1"); + let group = field(&fields(&doc), "Group1").clone(); + let model = doc.get(group.widgets[0].obj).as_dict().unwrap().clone(); + let look = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"0 g".to_vec()))); + let mut kids = vec![Object::Ref(group.widgets[0].obj)]; + for i in 2..=10_000 { + let mut n = pdfcraft_cos::Dict::new(); + n.set(format!("Choice{i}").into_bytes(), Object::Ref(look)); + n.set(b"Off".to_vec(), Object::Ref(look)); + let mut ap = pdfcraft_cos::Dict::new(); + ap.set(b"N".to_vec(), Object::Dict(n)); + let mut w = model.clone(); + w.set(b"AP".to_vec(), Object::Dict(ap)); + w.remove(b"P"); + kids.push(Object::Ref(doc.add(Object::Dict(w)))); + } + doc.update_dict(group.obj, |d| d.set(b"Kids".to_vec(), Object::Array(kids))).unwrap(); + rename_to(&mut doc, "Group2", "Group1").unwrap(); + let group = field(&fields(&doc), "Group1").clone(); + let states: Vec = group.widgets.iter().filter_map(|w| w.on_state.clone()).collect(); + assert_eq!(states.len(), 10_001); + assert_eq!(states.last().map(String::as_str), Some("Choice10001")); +} + +/// Only a radio button joins by name: other clashes are refused as before, and a locked group +/// isn't changed. +#[test] +fn joining_by_name_is_only_for_radio_buttons_and_unlocked_groups() { + let mut doc = two_radio_groups("Yes", "No"); + add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("note")).unwrap(); + assert!(rename_to(&mut doc, "note", "Group1").is_err(), "a text field doesn't join a radio group"); + assert!(rename_to(&mut doc, "Group2", "note").is_err(), "a radio button doesn't join a text field"); + set_props(&mut doc, "Group1", &FieldProps { locked: Some(true), ..FieldProps::default() }).unwrap(); + assert!(rename_to(&mut doc, "Group2", "Group1").is_err(), "a locked group"); + assert_eq!(fields(&doc).len(), 3); +} + +/// `/I` of field `name`: the selected positions in `/Opt`, or `None` when absent. +fn selection_indices(doc: &Document, name: &str) -> Option> { + let f = field(&fields(doc), name).clone(); + let d = doc.get(f.obj).as_dict().cloned().unwrap(); + d.get(b"I").map(|i| doc.resolve(i).as_array().unwrap().iter().map(|o| o.as_int().unwrap()).collect()) +} + +#[test] +fn changing_a_lists_options_repoints_its_selected_indices() { + let mut doc = one_page(); + let list = + add_field(&mut doc, 0, [50.0, 600.0, 250.0, 680.0], &NewField::List { options: vec!["A".into(), "B".into(), "C".into()], multi: true }, None) + .unwrap(); + set_value(&mut doc, &list, &FieldValue::Choice(vec!["C".into(), "B".into()])).unwrap(); + assert_eq!(selection_indices(&doc, &list), Some(vec![1, 2])); + let f = field(&fields(&doc), &list).clone(); + doc.update_dict(f.obj, |d| d.set(b"TI".to_vec(), Object::Int(2))).unwrap(); + // Removing A moves B and C up one place. + let opts = |o: &[&str]| FieldProps { options: Some(o.iter().map(|s| s.to_string()).collect()), ..FieldProps::default() }; + set_props(&mut doc, &list, &opts(&["B", "C", "D"])).unwrap(); + assert_eq!(selection_indices(&doc, &list), Some(vec![0, 1])); + // Removing B leaves only C selected; the top row would point past the end. + set_props(&mut doc, &list, &opts(&["C"])).unwrap(); + assert_eq!(selection_indices(&doc, &list), Some(vec![0])); + assert_eq!(top_row(&doc, f.obj), Some(0), "the list scrolls back to its only row"); + // No selected value is offered any more. + set_props(&mut doc, &list, &opts(&["X", "Y"])).unwrap(); + assert_eq!(selection_indices(&doc, &list), None); + let doc = reopen(&doc); + assert_eq!(selection_indices(&doc, &list), None); +} + +fn top_row(doc: &Document, obj: ObjRef) -> Option { + doc.get(obj).as_dict().and_then(|d| d.get(b"TI")).and_then(|o| o.as_int()) +} + +#[test] +fn shrinking_a_lists_options_scrolls_a_widget_with_its_own_top_row() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [7 0 R] >>".into(), + "<< /Fields [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /FT /Ch /T (pick) /Opt [(A) (B) (C) (D) (E) (F)] /V (F) /I [5] /Kids [7 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 6 0 R /TI 5 /Rect [50 700 250 720] /P 3 0 R >>".into(), + ]; + let mut doc = document(&objs); + let widget = ObjRef::new(7, 0); + set_props(&mut doc, "pick", &FieldProps { options: Some(vec!["F".into(), "A".into()]), ..FieldProps::default() }).unwrap(); + assert_eq!(selection_indices(&doc, "pick"), Some(vec![0])); + // The widget's own /TI is read before the field's; row 5 no longer exists. + assert_eq!(top_row(&doc, widget), Some(0), "the widget scrolls to F, now row 0"); + let doc = reopen(&doc); + assert_eq!(top_row(&doc, widget), Some(0)); +} + +#[test] +fn each_widget_with_its_own_top_row_scrolls_by_itself() { + // Widget 7 shows from the field's top row; widget 8 keeps its own /TI 4. + let objs = |v: &str| -> Vec { + vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R] >>".into(), + "<< /Fields [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + format!("<< /FT /Ch /T (pick) /Opt [(A) (B) (C) (D) (E) (F)] {v} /Kids [7 0 R 8 0 R] >>"), + "<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [50 700 250 740] /P 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 6 0 R /TI 4 /Rect [50 600 250 620] /P 3 0 R >>".into(), + ] + }; + let (first, other) = (ObjRef::new(7, 0), ObjRef::new(8, 0)); + // The options shrink: the first widget already shows A, the other's row 4 is gone. + let mut doc = document(&objs("/V (A) /I [0]")); + set_props(&mut doc, "pick", &FieldProps { options: Some(vec!["A".into(), "B".into()]), ..FieldProps::default() }).unwrap(); + assert_eq!(top_row(&doc, first), None); + assert_eq!(top_row(&reopen(&doc), other), Some(0), "options: the other widget scrolls to A"); + // A new value the first widget already shows, below the other's view. + let mut doc = document(&objs("/V (B) /I [1]")); + set_value(&mut doc, "pick", &FieldValue::Choice(vec!["A".into()])).unwrap(); + assert_eq!(top_row(&doc, ObjRef::new(6, 0)), None, "the field's top row did not move"); + assert_eq!(top_row(&reopen(&doc), other), Some(0), "value: the other widget scrolls to A"); +} + +/// A list box's top row is chosen with the font size set in the same edit: at the larger size +/// fewer rows fit, so it scrolls further to keep the selection in view. +#[test] +fn a_list_boxs_top_row_follows_a_font_size_changed_with_its_items() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /FT /Ch /T (pick) /Opt [(A) (B) (C) (D) (E)] /V (C) /I [2] /Type /Annot /Subtype /Widget /Rect [50 700 250 740] /P 3 0 R >>".into(), + ]); + let items = ["Z", "A", "B", "C", "D"].map(String::from).to_vec(); + set_props(&mut doc, "pick", &FieldProps { options: Some(items), font_size: Some(20.0), ..FieldProps::default() }).unwrap(); + let doc = reopen(&doc); + let g = field(&fields(&doc), "pick").clone(); + let w = &g.widgets[0]; + let rows = w.list.unwrap(); + let shown = rows.top..rows.top + rows.visible(w.rect[3] - w.rect[1]); + assert!(shown.contains(&3), "C is row 3; rows {shown:?} show"); +} + +#[test] +fn sorting_a_combo_boxs_items_repoints_its_selected_index() { + let mut doc = one_page(); + let combo = add_field( + &mut doc, + 0, + [50.0, 600.0, 250.0, 620.0], + &NewField::Combo { options: vec!["Pear".into(), "apple".into(), "Fig".into()], editable: false }, + None, + ) + .unwrap(); + set_value(&mut doc, &combo, &FieldValue::Choice(vec!["Pear".into()])).unwrap(); + assert_eq!(selection_indices(&doc, &combo), Some(vec![0])); + set_props(&mut doc, &combo, &FieldProps { flags: vec![(flags::SORT, true)], ..FieldProps::default() }).unwrap(); + // apple, Fig, Pear: the selection follows Pear to the end. + assert_eq!(selection_indices(&doc, &combo), Some(vec![2])); + assert_eq!(field(&fields(&doc), &combo).value, vec!["Pear".to_string()]); +} + +#[test] +fn sorting_keeps_which_of_two_items_with_one_export_value_is_selected() { + let mut doc = one_page(); + let list = add_field(&mut doc, 0, [50.0, 600.0, 250.0, 680.0], &NewField::List { options: vec!["a".into()], multi: false }, None).unwrap(); + let f = field(&fields(&doc), &list).clone(); + let item = |e: &str, shown: &str| Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(shown))]); + // Zeta and Alpha both export "x"; `/I` says Zeta is the one selected. + doc.update_dict(f.obj, |d| { + d.set(b"Opt".to_vec(), Object::Array(vec![item("x", "Zeta"), item("x", "Alpha"), item("y", "Mid")])); + d.set(b"V".to_vec(), PdfString::text("x")); + d.set(b"I".to_vec(), Object::Array(vec![Object::Int(0)])); + }) + .unwrap(); + set_props(&mut doc, &list, &FieldProps { flags: vec![(flags::SORT, true)], ..FieldProps::default() }).unwrap(); + // Alpha, Mid, Zeta: the selection follows Zeta, not the first item exporting "x". + assert_eq!(selection_indices(&doc, &list), Some(vec![2])); +} + +#[test] +fn clearing_a_kids_default_does_not_bring_back_its_parents() { + let mut doc = fixture(); + let all = fields(&doc); + let city = field(&all, "address.city").clone(); + let parent = doc.get(city.obj).as_dict().unwrap().reference(b"Parent").unwrap(); + doc.update_dict(parent, |d| d.set(b"DV".to_vec(), PdfString::text("Main St"))).unwrap(); + set_props(&mut doc, "address.city", &FieldProps { default_value: Some(Some("Elm".into())), ..FieldProps::default() }).unwrap(); + assert_eq!(field(&fields(&doc), "address.city").default, ["Elm"]); + set_props(&mut doc, "address.city", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap(); + let mut doc = reopen(&doc); + let f = field(&fields(&doc), "address.city").clone(); + assert!(f.default.iter().all(String::is_empty), "no default, not the parent's: {:?}", f.default); + let own = doc.get(f.obj).as_dict().and_then(|d| d.get(b"DV")).and_then(|o| o.as_string()).map(PdfString::to_text); + assert_eq!(own.as_deref(), Some(""), "the kid holds an empty default of its own"); + set_value(&mut doc, "address.city", &FieldValue::Text("Oak".into())).unwrap(); + reset(&mut doc, None).unwrap(); + assert!(field(&fields(&doc), "address.city").value.iter().all(String::is_empty), "reset clears it"); + // Without a parent default, clearing still removes the key. + set_props(&mut doc, "name", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap(); + let name = field(&fields(&doc), "name").clone(); + assert!(doc.get(name.obj).as_dict().unwrap().get(b"DV").is_none()); +} + +#[test] +fn clearing_a_kids_value_does_not_bring_back_its_parents() { + // A text kid, then a list box kid, under a parent with both a value and a default. + for (ft, set) in [("/FT /Tx", FieldValue::Text("Oak".into())), ("/FT /Ch /Opt [(Main St) (Oak)]", FieldValue::Choice(vec!["Oak".into()]))] { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + format!("<< /T (address) {ft} /V (Main St) /DV (Main St) /Kids [6 0 R] >>"), + "<< /Type /Annot /Subtype /Widget /T (city) /Parent 5 0 R /Rect [10 10 110 60] /P 3 0 R >>".into(), + ]); + set_props(&mut doc, "address.city", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap(); + set_value(&mut doc, "address.city", &set).unwrap(); + assert_eq!(field(&fields(&doc), "address.city").value, ["Oak"], "{ft}"); + reset(&mut doc, None).unwrap(); + let doc = reopen(&doc); + let f = field(&fields(&doc), "address.city").clone(); + assert!(f.value.iter().all(String::is_empty), "{ft}: reset leaves it empty, not the parent's: {:?}", f.value); + let mut doc = doc; + set_value(&mut doc, "address.city", &set).unwrap(); + let clear = if ft.contains("Ch") { FieldValue::Choice(Vec::new()) } else { FieldValue::Text(String::new()) }; + set_value(&mut doc, "address.city", &clear).unwrap(); + let f = field(&fields(&reopen(&doc)), "address.city").clone(); + if ft.contains("Ch") { + assert!(f.value.is_empty(), "{ft}: clearing leaves no choice selected: {:?}", f.value); + } else { + assert!(f.value.iter().all(String::is_empty), "{ft}: clearing leaves it empty: {:?}", f.value); + } + } +} + +#[test] +fn clearing_a_buttons_default_under_a_parent_with_one_leaves_it_off() { + // A check box, then a radio button (flags 49152, inherited from the parent). + for flags in ["", "/Ff 49152"] { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + format!("<< /T (opts) /FT /Btn {flags} /DV /Yes /Kids [6 0 R] >>"), + "<< /Type /Annot /Subtype /Widget /T (a) /Parent 5 0 R /V /Off /AS /Off /Rect [10 10 25 25] /P 3 0 R /AP << /N << /Yes 7 0 R /Off 7 0 R >> >> >>".into(), + "<< /Length 0 >> +stream + +endstream".into(), + ]); + let kind = field(&fields(&doc), "opts.a").kind; + assert_eq!(kind == FieldKind::Radio, !flags.is_empty()); + set_props(&mut doc, "opts.a", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap(); + let f = field(&fields(&doc), "opts.a").clone(); + assert_eq!(doc.get(f.obj).as_dict().and_then(|d| d.get(b"DV")).and_then(|o| o.as_name()), Some(&b"Off"[..]), "{kind:?}"); + let on = if kind == FieldKind::Radio { FieldValue::Radio(Some("Yes".into())) } else { FieldValue::Check(true) }; + set_value(&mut doc, "opts.a", &on).unwrap(); + assert_eq!(field(&fields(&doc), "opts.a").value, ["Yes"]); + reset(&mut doc, None).unwrap(); + let f = field(&fields(&doc), "opts.a").clone(); + assert!(f.value.iter().all(String::is_empty), "{kind:?}: reset leaves it off, not the parent's Yes: {:?}", f.value); + } +} + +#[test] +fn editing_button_export_values_preserves_selection_and_appearance() { + let mut doc = fixture(); + set_value(&mut doc, "size", &FieldValue::Radio(Some("S".into()))).unwrap(); + let before = field(&fields(&doc), "size").clone(); + let appearances: Vec<_> = before.widgets.iter().map(|w| doc.get(w.obj).as_dict().unwrap().get(b"AP").cloned()).collect(); + set_props(&mut doc, "size", &FieldProps { export_value: Some((0, "Small å".into())), ..Default::default() }).unwrap(); + let after = field(&fields(&doc), "size").clone(); + assert_eq!(after.value, before.value); + assert_eq!(after.default, before.default); + assert_eq!(after.widgets, before.widgets); + assert_eq!(after.export_of(0), Some("Small å")); + assert_eq!(after.export_of(1), before.export_of(1)); + assert_eq!(after.export_values(), vec!["Small å"]); + assert_eq!(appearances, after.widgets.iter().map(|w| doc.get(w.obj).as_dict().unwrap().get(b"AP").cloned()).collect::>()); + let mut saved = reopen(&doc); + assert_eq!(field(&fields(&saved), "size").export_of(0), Some("Small å")); + set_value(&mut saved, "size", &FieldValue::Radio(Some("Small å".into()))).unwrap(); + reset(&mut saved, None).unwrap(); + assert_eq!(field(&fields(&saved), "size").value, before.default); + + set_value(&mut doc, "agree", &FieldValue::Check(true)).unwrap(); + set_props(&mut doc, "agree", &FieldProps { export_value: Some((0, "Accepted".into())), ..Default::default() }).unwrap(); + let check = field(&fields(&doc), "agree").clone(); + assert_eq!(check.value, vec!["Yes"]); + assert_eq!(check.export_values(), vec!["Accepted"]); + assert_eq!(field(&fields(&reopen(&doc)), "agree").export_of(0), Some("Accepted")); +} + +#[test] +fn invalid_button_export_edits_leave_document_unchanged() { + let mut doc = fixture(); + let before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + for (field_name, index, value) in [("size", 99, "new"), ("size", 0, ""), ("size", 0, "Off"), ("size", 0, "L"), ("name", 0, "new")] { + assert!(set_props(&mut doc, field_name, &FieldProps { export_value: Some((index, value.into())), ..Default::default() }).is_err()); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before); + } +} + +#[test] +fn locked_button_export_edits_leave_the_document_unchanged() { + for name in ["agree", "size"] { + let mut doc = fixture(); + set_props(&mut doc, name, &FieldProps { locked: Some(true), ..Default::default() }).unwrap(); + let before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let error = set_props( + &mut doc, + name, + &FieldProps { export_value: Some((0, "Changed".into())), tooltip: Some("Must not change".into()), ..Default::default() }, + ) + .unwrap_err(); + assert!(error.to_string().contains("locked")); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before); + } +} diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index 37df5502d1..e8615567b6 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -1,8 +1,8 @@ use super::appearances::{declares_pdfa, need_appearances}; use super::read::{acroform, field_flags, fields, page_refs}; use super::{ - Dict, Document, Field, FieldEvent, FieldKind, FieldValue, FormError, NoScripts, ObjRef, Object, PdfString, Scripts, af, appearance, author, - calc_order, flags, invalid, name_obj, scripting, + Dict, Document, Field, FieldEvent, FieldKind, FieldValue, FormError, NoScripts, ObjRef, Object, PdfString, Scripts, Widget, af, appearance, + author, calc_order, flags, invalid, name_obj, scripting, }; // ── writing ───────────────────────────────────────────────────────────────────────────────── @@ -210,8 +210,9 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Format)?; af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?; let t = &scripting::accept(doc, f, t, scripts)?; + let shadow = t.is_empty() && crate::author::ancestor_sets(doc, f.obj, b"V"); doc.update_dict(f.obj, |d| { - if t.is_empty() { + if t.is_empty() && !shadow { d.remove(b"V"); } else { d.set(b"V".to_vec(), PdfString::text(t)); @@ -291,16 +292,13 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr indices.sort_unstable_by_key(Object::as_int); // A list box scrolls (`/TI`) so the picked options are among the rows it draws, as // in other viewers: a pick below the last visible row would otherwise not show (#1524). - let top = match (f.kind, f.widgets.first()) { - (FieldKind::List, Some(w)) => w.list.map(|rows| { - let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] }; - let picked: Vec = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect(); - (rows.top, appearance::ListRows::top_showing(rows.top, &picked, rows.visible(height), f.options.len())) - }), - _ => None, - }; + let picked: Vec = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect(); + let top = list_scroll(f, &picked, f.options.len()); + // An empty value of its own keeps a parent field's /V from showing through. + let shadow = exports.is_empty() && crate::author::ancestor_sets(doc, f.obj, b"V"); doc.update_dict(f.obj, |d| { match exports.as_slice() { + [] if shadow => d.set(b"V".to_vec(), PdfString::text("")), [] => { d.remove(b"V"); } @@ -318,16 +316,7 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))); } })?; - // A widget with its own `/TI` (read before the field's) scrolls too. - if let Some((old, new)) = top - && old != new - { - for w in f.widgets.iter().filter(|w| w.obj != f.obj) { - if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI")) { - doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?; - } - } - } + scroll_widgets(doc, f, &picked, f.options.len())?; redraw(doc, f, &exports, scripts) } (FieldKind::PushButton, _) => invalid(format!("{:?} is a button; it has no value", f.name)), @@ -336,6 +325,35 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr } } +/// A list box's first visible row (`/TI`) before and after scrolling so the `picked` positions of +/// its `count` options are among the rows it draws, as in other viewers: a pick below the last +/// visible row would otherwise not show (#1524). `None` for any other field. +pub(crate) fn list_scroll(f: &Field, picked: &[usize], count: usize) -> Option<(usize, usize)> { + f.widgets.first().filter(|_| f.kind == FieldKind::List).and_then(|w| widget_scroll(w, picked, count)) +} + +/// A list box widget's first row before and after scrolling so `picked` shows. +fn widget_scroll(w: &Widget, picked: &[usize], count: usize) -> Option<(usize, usize)> { + w.list.map(|rows| { + let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] }; + (rows.top, appearance::ListRows::top_showing(rows.top, picked, rows.visible(height), count)) + }) +} + +/// Scroll every widget of `f` with its own `/TI` (read before the field's) by its own rows and +/// size, so `picked` shows in each. +pub(crate) fn scroll_widgets(doc: &mut Document, f: &Field, picked: &[usize], count: usize) -> Result<(), FormError> { + for w in f.widgets.iter().filter(|w| w.obj != f.obj) { + if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI")) + && let Some((old, new)) = widget_scroll(w, picked, count) + && old != new + { + doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?; + } + } + Ok(()) +} + /// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget. fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), FormError> { let v = on.unwrap_or("Off"); diff --git a/crates/optimize/Cargo.toml b/crates/optimize/Cargo.toml index 456ab4c991..d1a759d468 100644 --- a/crates/optimize/Cargo.toml +++ b/crates/optimize/Cargo.toml @@ -24,3 +24,4 @@ zune-jpeg = "0.5" [dev-dependencies] pdfcraft-render = { workspace = true } +proptest.workspace = true diff --git a/crates/optimize/README.md b/crates/optimize/README.md index cbe37f6b7b..9f96b17218 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -11,7 +11,9 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going ``` - **Presets:** Standard (Reduce File Size's choices: images above 225 ppi to 150 ppi, JPEG - medium) and Mobile (above 144 ppi to 96 ppi, JPEG high), as in Acrobat's Settings menu. + medium) and Mobile (above 144 ppi to 96 ppi, JPEG high), as in Acrobat's Settings menu. In + both, black-and-white images are averaged down to 300 ppi above 450 ppi, and stored as CCITT + Group 4 whenever that is smaller. - **Merge image fragments** (`fragments`, on by default): some PDF printers draw a page picture as thousands of images one pixel row high. Runs of upright 8-bit grey or RGB images in one colour space, sharing their left and right edges and stacked edge to edge (at least four, @@ -27,11 +29,64 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going measured (forms nested deeper, or drawn too many times), no image is resampled. Colour and grayscale images above a threshold are resampled with a bicubic (Catmull-Rom) filter to a target resolution and stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image. - An image is replaced only if the result is smaller. Left alone: CMYK and other colour spaces, - image masks, decode arrays, more than 8 bits, JPEG 2000, JBIG2 and CCITT. A JPEG is read with + An image is replaced only if the result is smaller. Left alone by these colour and grey + settings: CMYK and other colour spaces, image masks, decode arrays, more than 8 bits, JPEG 2000, + JBIG2 and CCITT (black-and-white images, CCITT among them, are below). A JPEG is read with its colour transform (an Adobe marker's, else `/ColorTransform`, ISO 32000-2 §7.4.8), so one stored untransformed keeps its colours; one whose marker and `/ColorTransform` disagree is left - alone, since readers differ on which wins. + alone, since readers differ on which wins. Samples are read with `/DecodeParms` given by + reference too, the numbers inside included (`Document::with_direct_parms`), here and below: + without them a predictor's tag bytes would shift every row of an image written back. +- **Monochrome images** (`Settings::mono`, `MonoSettings::REDUCE` by default: to 300 ppi above + 450 ppi, CCITT Group 4). Black-and-white images are a 1-bit image with one colour component + (grey, indexed, a separation) or an image mask, stored with no filter, with general-purpose ones + or with CCITT fax after any of those (decoded by `Document::decoded_image_within`, no further + than its rows can need; byte-aligned Group 3 with end-of-line codes but none before the first + row, not as T.4 sends them, is read as PdfCraft draws it, which at `/K 0` may be other rows than + were coded, and an image stored again keeps them). Packed to one bit first, unless a colour-key `/Mask` is in the image's + own values: eight-bit grey of only 0 and 255; eight-bit `DeviceRGB` of only black and white, + which becomes `DeviceGray` (not calibrated RGB or RGB with a profile, nor one drawn or listed + where `/DefaultGray` or `/DefaultRGB` is in force, which would then draw it otherwise, nor one + referred to from anywhere but the page and form resources the optimizer reads, where they may + be: an annotation's appearance or a pattern drawing it, directly or through resources or a form + it shares with a page, or another image's `/Alternates`; nor one whose soft mask has a + `/Matte`); and an indexed image of 2, 4 or 8 bits that uses only palette entries + 0 and 1, which keeps its palette, whatever its two colours. A decode array stays when it means + the same at one bit (grey's, or RGB's if the same for each component, as grey's), and an indexed + image's default one goes, as the default at one bit; otherwise the image is left alone. The grey + or colour settings take a packed image instead when they make it smaller still, its soft mask + counted (resampled with it there, kept as it is here), as they can a dithered scan by + downsampling it to JPEG. + - **Downsampling:** one whose effective resolution is above `above_ppi` is averaged down to + `target_ppi`: each new pixel takes the sample covering more of its area, and the dark one on a + tie, so a dark line half a new pixel wide stays: black in grey and the painted part of an image + mask (a 0, or a 1 with an inverting decode array), full ink in a separation (a 1, or a 0), and + entry 0 of an indexed image, whatever colour its palette gives it (the palette isn't read yet). + Not one with a soft mask, whose own size would have to follow; not a halftone or a dither, + most of whose new pixels would cover both black and white, so that taking the sample covering + more would turn its tones solid (a 50% screen black, a 25% one white); and not one wider than + a fax row (`pdfcraft_filters::MAX_CCITT_COLUMNS`), as the averaging keeps a counter for each + column. On a 600 ppi text page, Group 4 at 300 ppi is half the size. + - **Compression:** CCITT Group 4 (`pdfcraft_filters::encode_g4`), or 1-bit Flate when that is + smaller, which only a resampled image or one packed from more bits may be: a 1-bit image that + is only stored again becomes Group 4 or stays. Group 4 alone is only decoded to be resampled; + Group 3, or Group 4 inside another filter such as `ASCII85Decode`, is stored again as Group 4 + alone when that is smaller. Damaged Group 3 with end-of-line codes is stored as the decoder + recovers it, a bad row picked up again at the next code, as it reports no damage there; without + them, damage stops the decoding and the image is left alone. With Retain, a resampled image + keeps its kind of compression (CCITT, or Flate) and nothing else changes; an image of more bits + is then left to the grey or colour settings. Every change is kept only when it is smaller as + stored. + - Apart from the downsampling it is lossless: the samples keep their meaning, so the colour + space, decode array and `/Mask` stay, but for packed RGB, which becomes grey with one decode + pair. Group 4 wins on scans, whose ragged edges defeat Flate; on clean computer-drawn 1-bit + art Flate can be smaller. Not yet: inline images, an image's own `/SMask` or `/Mask` + image (only images drawn on a page are visited), and Group 4 for rows wider than 32,767 + samples. + + - Lossless JBIG2 uses bounded generic-region encoding or exact-copy symbol dictionaries when + smaller. No lossy symbol substitution is used. Export reads embedded images and bounded + shared globals. - **Discard objects:** thumbnails, alternate images, document tags, print settings. - **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex layer, that layer is removed when the result is smaller (the inner filters keep their encoded diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index b9761b85fc..3c3ae3c2d4 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -5,18 +5,25 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_content::{Matrix, Pieces}; use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; -use crate::{Compression, ImageSettings, OptimizeError, Report, Settings, Stage}; +use crate::{Compression, ImageSettings, MonoCompression, MonoSettings, OptimizeError, Report, Settings, Stage}; /// Images with more pixels than this are left alone (memory). const MAX_PIXELS: u64 = 80_000_000; /// The resources of a page, inherited from the page tree if the page has none. pub(crate) fn page_resources(doc: &Document, page: ObjRef) -> Option { + held_page_resources(doc, page).map(|(resources, _)| resources) +} + +/// [`page_resources`], and the object holding them: the page or the page-tree node they are +/// written in (inherited from), or the resources dictionary when it is an object of its own. +fn held_page_resources(doc: &Document, page: ObjRef) -> Option<(Dict, ObjRef)> { let mut node = Some(page); for _ in 0..64 { - let d = doc.get(node?).as_dict()?.clone(); + let n = node?; + let d = doc.get(n).as_dict()?.clone(); if let Some(r) = d.get(b"Resources") { - return doc.resolve(r).as_dict().cloned(); + return Some((doc.resolve(r).as_dict().cloned()?, r.as_ref().unwrap_or(n))); } node = d.reference(b"Parent"); } @@ -41,9 +48,10 @@ struct Walk<'a> { doc: &'a Document, /// The forms being walked right now, outermost first: a form drawing one of these is a cycle. path: Vec, - /// Forms with their own resources already walked, by the linear part of their matrix. Drawing - /// one again at the same scale (only moved) can't change any image's resolution. - walked: HashSet<(ObjRef, [u64; 4])>, + /// Forms with their own resources already walked, by the linear part of their matrix and + /// whether default colour spaces were in force. Drawing one again at the same scale (only + /// moved) can't change any image's resolution. + walked: HashSet<(ObjRef, [u64; 4], bool)>, /// Forms walked at least once. seen: HashSet, rewalks: usize, @@ -52,14 +60,56 @@ struct Walk<'a> { /// False once a use may have been missed (depth or work limit). complete: bool, out: HashMap, + /// Images drawn or listed at least once where `/DefaultGray` or `/DefaultRGB` is in force, by + /// object number: a reference with another generation reaches the same image. + defaults: HashSet, + /// The references the walk followed to reach its images, and the pages and page-tree nodes + /// whose resources it read. + read: Read, +} + +/// Where a walk read a reference: under a name in an `/XObject` dictionary (an image or a form), +/// as resources' `/XObject` dictionary, or as the `/Resources` of a page, node or form. +#[derive(Clone, PartialEq, Eq, Hash)] +enum Entry { + Name(Vec), + XObject, + Resources, +} + +/// The references a walk followed, by object number: (the object referred to, the object holding +/// the reference, where in it), and the pages and page-tree nodes whose resources it read. +#[derive(Default)] +struct Read { + entries: HashSet<(u32, u32, Entry)>, + roots: HashSet, } impl Walk<'_> { /// Walk one content stream, recording each image's displayed resolution (pixels per inch, the - /// smaller of the two axes) and descending into form XObjects. - fn content(&mut self, data: &[u8], resources: &Dict, ctm: Matrix) { + /// smaller of the two axes) and descending into form XObjects. `holder` is the object holding + /// `resources`. `defaults`: an enclosing stream's resources remap a device colour space. + fn content(&mut self, data: &[u8], resources: &Dict, holder: ObjRef, ctm: Matrix, defaults: bool) { let doc = self.doc; + let defaults = defaults || remaps_device_spaces(doc, resources); let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let xholder = match resources.get(b"XObject").and_then(Object::as_ref) { + Some(x) => { + self.read.entries.insert((x.num, holder.num, Entry::XObject)); + x + } + None => holder, + }; + // Every entry counts as read, drawn here or not: content using these resources (a Type3 + // glyph without its own, say) draws it under the same defaults. + for (name, v) in xobjects.iter() { + if let Some(r) = v.as_ref() { + self.read.entries.insert((r.num, xholder.num, Entry::Name(name.to_vec()))); + if defaults { + self.defaults.insert(r.num); + } + } + } let mut stack = vec![ctm]; for op in pdfcraft_content::parse(data).ops { let top = stack.last().copied().unwrap_or(ctm); @@ -90,8 +140,11 @@ impl Walk<'_> { let e = self.out.entry(r).or_insert(f64::INFINITY); *e = e.min(ppi); } + if defaults { + self.defaults.insert(r.num); + } } - Some(b"Form") => self.form(r, s, resources, top), + Some(b"Form") => self.form(r, s, (resources, holder), top, defaults), _ => {} } } @@ -100,8 +153,37 @@ impl Walk<'_> { } } - /// Descend into form `r` drawn with `ctm` from a stream whose resources are `resources`. - fn form(&mut self, r: ObjRef, s: &Stream, resources: &Dict, ctm: Matrix) { + /// Read the resources of page `p` and of each page-tree node above it, each of them a root: a + /// node's resources are its inheriting pages' (every page is walked), or no page's. + fn resources_above(&mut self, p: ObjRef) { + let doc = self.doc; + let mut node = Some(p); + for _ in 0..64 { + let Some(n) = node else { return }; + // A node met before had its own nodes above it read then. + if !self.read.roots.insert(n.num) { + return; + } + let Some(d) = doc.get(n).as_dict().cloned() else { return }; + if let Some(r) = d.get(b"Resources") + && let Some(resources) = doc.resolve(r).as_dict().cloned() + { + let holder = match r.as_ref() { + Some(res) => { + self.read.entries.insert((res.num, n.num, Entry::Resources)); + res + } + None => n, + }; + self.content(&[], &resources, holder, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]), false); + } + node = d.reference(b"Parent"); + } + } + + /// Descend into form `r` drawn with `ctm` from a stream whose resources are `resources`, with + /// the object holding them. + fn form(&mut self, r: ObjRef, s: &Stream, (resources, holder): (&Dict, ObjRef), ctm: Matrix, defaults: bool) { if self.path.contains(&r) { return; } @@ -116,7 +198,7 @@ impl Walk<'_> { // A form without resources uses its caller's, so the same form can draw other images. if own.is_some() { let [a, b, c, d, ..] = inner.0; - if !self.walked.insert((r, [a.to_bits(), b.to_bits(), c.to_bits(), d.to_bits()])) { + if !self.walked.insert((r, [a.to_bits(), b.to_bits(), c.to_bits(), d.to_bits()], defaults)) { return; } } @@ -136,17 +218,43 @@ impl Walk<'_> { return; }; self.path.push(r); - self.content(&data, own.as_ref().unwrap_or(resources), inner); + match &own { + Some(own) => { + let own_holder = match s.dict.get(b"Resources").and_then(Object::as_ref) { + Some(res) => { + self.read.entries.insert((res.num, r.num, Entry::Resources)); + res + } + None => r, + }; + self.content(&data, own, own_holder, inner, defaults); + } + None => self.content(&data, resources, holder, inner, defaults), + } self.path.pop(); } } +/// Whether `resources` remap a device colour space an image may be moved between, so that the +/// same samples would be drawn otherwise: `/DefaultGray` or `/DefaultRGB` (ISO 32000-2 §8.6.5.6). +fn remaps_device_spaces(doc: &Document, resources: &Dict) -> bool { + let spaces = resources.get(b"ColorSpace").map(|c| doc.resolve(c)); + spaces.as_deref().and_then(Object::as_dict).is_some_and(|c| c.contains(b"DefaultGray") || c.contains(b"DefaultRGB")) +} + /// The effective resolution (ppi) of every image drawn by `pages`: the smallest over its uses. /// /// If forms nest too deeply, are drawn too many times, or a page's content doesn't decode within /// its budget, every image is given `INFINITY` (never resampled), since its largest use may be /// among those missed. pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap { + walk_pages(doc, pages).0 +} + +/// [`effective_resolutions`], the images drawn or listed where `/DefaultGray` or `/DefaultRGB` is +/// in force, by object number (all of them when a use may have been missed), and the references +/// followed to reach them. +fn walk_pages(doc: &Document, pages: &[ObjRef]) -> (HashMap, HashSet, Read) { let mut walk = Walk { doc, path: Vec::new(), @@ -156,12 +264,15 @@ pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap HashMap bool { + let Object::Stream(s) = &*doc.get(r) else { return false }; + let cs = s.dict.get(b"ColorSpace").map(|cs| doc.resolve(cs)); + let codec = + s.filters().iter().any(|(f, _)| matches!(f, pdfcraft_filters::Filter::Dct | pdfcraft_filters::Filter::Jpx | pdfcraft_filters::Filter::Jbig2)); + s.dict.int(b"BitsPerComponent") == Some(8) + && !codec + && cs.as_deref().and_then(Object::as_name).is_some_and(|n| matches!(n, b"DeviceRGB" | b"RGB")) +} + +/// Call `f` with every reference in `o`, a dictionary's or array's included, a stream's dictionary +/// too, but not those in objects referred to. +fn each_ref(o: &Object, mut f: impl FnMut(ObjRef)) { + // Direct objects nest finitely (the parser builds trees), so this ends. + let mut stack = vec![o]; + while let Some(o) = stack.pop() { + match o { + Object::Ref(r) => f(*r), + Object::Dict(d) => stack.extend(d.iter().map(|(_, v)| v)), + Object::Array(a) => stack.extend(a), + Object::Stream(s) => stack.extend(s.dict.iter().map(|(_, v)| v)), + _ => {} + } + } +} + +/// The images among `images` (by object number) that may be drawn beyond what the walk read: +/// some object the document uses refers to the image, or to an object on the way to it from a +/// page (an `/XObject` dictionary, resources, a form), other than as the page and form resources +/// the walk read do. So an annotation's appearance, a pattern, a Type3 glyph or a soft mask's +/// group drawing it, directly or through resources or a form it shares with a page, counts, as +/// other default colour spaces may be in force there, and so does any other reference (another +/// image's `/Alternates`, say). Objects nothing reaches from the trailer don't count. +fn used_elsewhere(doc: &Document, images: &HashSet, read: &Read) -> HashSet { + if images.is_empty() { + return HashSet::new(); + } + let targets: HashSet = read.entries.iter().map(|(target, ..)| *target).collect(); + // References to each object the walk followed, by the object holding them. + let mut held: HashMap> = HashMap::new(); + let mut queue: Vec = Vec::new(); + let mut visited = HashSet::new(); + for (_, v) in doc.trailer().iter() { + each_ref(v, |r| queue.push(r.num)); + } + while let Some(num) = queue.pop() { + if !visited.insert(num) { + continue; + } + let obj = doc.get(ObjRef::new(num, doc.generation(num))); + each_ref(&obj, |r| { + if targets.contains(&r.num) { + *held.entry(r.num).or_default().entry(num).or_default() += 1; + } + if !visited.contains(&r.num) { + queue.push(r.num); + } + }); + } + let mut seen: HashMap<(u32, u32), usize> = HashMap::new(); + for (target, holder, _) in &read.entries { + *seen.entry((*target, *holder)).or_default() += 1; + } + // Covered: every reference to it is one the walk followed, held by a page or node, or by an + // object covered in turn. Everything the walk reached starts covered, and whatever has a + // reference it didn't follow, or is held by an object taken out, is taken out, until nothing + // more is. Content beyond what the walk read has to refer to an object on its way to the + // image, which takes out everything after it; a cycle the walk read whole stays covered. + let mut holds: HashMap> = HashMap::new(); + for (target, holders) in &held { + for h in holders.keys() { + holds.entry(*h).or_default().push(*target); + } + } + let mut covered: HashSet = held.keys().copied().collect(); + let passes = |t: u32, covered: &HashSet| { + held.get(&t) + .is_some_and(|holders| holders.iter().all(|(h, n)| seen.get(&(t, *h)) == Some(n) && (read.roots.contains(h) || covered.contains(h)))) + }; + let mut check: Vec = covered.iter().copied().collect(); + while let Some(t) = check.pop() { + if covered.contains(&t) && !passes(t, &covered) { + covered.remove(&t); + check.extend(holds.get(&t).into_iter().flatten().copied()); + } + } + images.iter().copied().filter(|i| !covered.contains(i)).collect() } /// Colour components of a colour space PdfCraft resamples: gray (1) or RGB (3). @@ -258,8 +460,11 @@ fn count_decoded(_n: u64) { DECODED_PIXELS.with(|c| c.set(c.get().saturating_add(_n))); } -/// 8-bit samples of an image (`n` components), decoded. `None` for a JPEG whose colour transform -/// readers may disagree about ([`ycc_encoded`]). +/// 8-bit samples of an image (`n` components), decoded, with `/DecodeParms` given by reference +/// too ([`Document::with_direct_parms`]): read without them, a predictor's tag bytes would shift +/// every row, and the image would be written back wrong. `None` for a JPEG whose colour transform +/// readers may disagree about ([`ycc_encoded`]), and for data that decodes past what the samples +/// can need ([`allowance`]). pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize) -> Option> { let (w, h) = (u32::try_from(s.dict.int(b"Width")?).ok()?, u32::try_from(s.dict.int(b"Height")?).ok()?); if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS { @@ -275,11 +480,19 @@ pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize) -> Option> { count_decoded(u64::from(w) * u64::from(h)); (v.len() == bytes).then_some(v) } else { - let v = s.decoded().ok()?; + let v = doc.with_direct_parms(s).decoded_within(allowance(bytes, h)?).ok()?; Some(v.get(..bytes)?.to_vec()) } } +/// How far an image of `bytes` samples in `rows` rows is decoded: a filter stage may produce up +/// to three bytes for each of them and of a PNG predictor's bytes a row (ASCIIHex text under +/// Flate, its two digits a byte broken into lines or spaced). More can only be a decompression +/// bomb, and the image is left alone. +fn allowance(bytes: usize, rows: u32) -> Option { + bytes.checked_add(usize::try_from(rows).ok()?)?.checked_mul(3)?.checked_add(4096) +} + /// Whether the samples of a three-component JPEG are YCbCr, to be converted to RGB (ISO 32000-2 /// §7.4.8). An Adobe APP14 marker's transform flag says so, overriding the filter's /// /ColorTransform; without a marker /ColorTransform does, 1 (YCbCr) by default. @@ -405,13 +618,29 @@ fn jpeg_samples(data: &[u8], w: usize, h: usize, n: usize, ycc: bool) -> Option< } pub(crate) fn resample(px: &[u8], n: usize, w: u32, h: u32, nw: u32, nh: u32) -> Option> { - use image::imageops::{FilterType, resize}; Some(match n { - 1 => resize(&image::GrayImage::from_raw(w, h, px.to_vec())?, nw, nh, FilterType::CatmullRom).into_raw(), - _ => resize(&image::RgbImage::from_raw(w, h, px.to_vec())?, nw, nh, FilterType::CatmullRom).into_raw(), + 1 => scaled(&image::GrayImage::from_raw(w, h, px.to_vec())?, nw, nh).into_raw(), + _ => scaled(&image::RgbImage::from_raw(w, h, px.to_vec())?, nw, nh).into_raw(), }) } +/// `img` scaled to `w` × `h`. Scaling goes down first, through a buffer of `img`'s width by `h` at +/// 16 bytes a pixel, which a soft mask of other proportions than its image can make larger than +/// any machine: 1,000,000 × 1 resampled to 4 × 100,000 asks for 1.6 TB, and that aborts the +/// process. Such a one is scaled across first, which holds no more pixels than one of the two has. +/// Past a quarter over the larger of them, so that rounding leaves a picture of the same shape as +/// it was. +fn scaled + 'static>(img: &image::ImageBuffer>, w: u32, h: u32) -> image::ImageBuffer> { + use image::imageops::{FilterType, resize}; + let (iw, ih) = (u64::from(img.width()), u64::from(img.height())); + let largest = (iw * ih).max(u64::from(w) * u64::from(h)); + if (iw * u64::from(h)).saturating_mul(4) > largest.saturating_mul(5) { + resize(&resize(img, w, img.height(), FilterType::CatmullRom), w, h, FilterType::CatmullRom) + } else { + resize(img, w, h, FilterType::CatmullRom) + } +} + fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option> { let mut out = Vec::new(); let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100)); @@ -532,6 +761,354 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us (after < before).then_some((new, smask)) } +/// Whether colour space `cs` has one colour component, so a 1-bit image in it has one bit per +/// pixel: grey, a separation, an indexed space or a DeviceN of one colourant. Of names, only +/// DeviceGray counts, with `/G`, its abbreviation, which readers take as grey too: another name +/// is no colour space on its own (`/Indexed` and `/CalGray` need their parameters), so it can only +/// name a resource, which might be RGB. +fn one_component(doc: &Document, cs: &Object) -> bool { + let cs = doc.resolve(cs); + match &*cs { + Object::Name(n) => matches!(n.as_slice(), b"DeviceGray" | b"G"), + Object::Array(a) => match a.first().and_then(Object::as_name) { + Some(b"CalGray" | b"Separation" | b"Indexed" | b"I") => true, + Some(b"ICCBased") => a.get(1).is_some_and(|icc| matches!(&*doc.resolve(icc), Object::Stream(s) if s.dict.int(b"N") == Some(1))), + Some(b"DeviceN") => a.get(1).is_some_and(|names| doc.resolve(names).as_array().is_some_and(|n| n.len() == 1)), + _ => false, + }, + _ => false, + } +} + +/// A pixel's samples as a 1 or a 0, or `None` for a colour that is neither. +type Pixel = fn(&[u8]) -> Option; + +/// The samples of a black-and-white image, as rows of 1-bit samples each starting on a new byte, +/// with its width and height, and, when they were packed from more bits, its dictionary as a 1-bit +/// image. These count: +/// - one bit per pixel in one colour component, or an image mask, stored with no filter, with +/// general-purpose ones, or with CCITT fax last ([`Document::decoded_image_within`], no further +/// than its rows can need); +/// - with `pack`, and with no colour-key `/Mask` (its ranges are in the image's own values): +/// - eight-bit grey (one component, not indexed) whose every sample is 0 or 255. A decode array +/// maps 0 and 255 at eight bits as it maps 0 and 1 at one bit, so it stays; +/// - eight-bit `DeviceRGB` whose every pixel is black or white, which becomes `DeviceGray`. A +/// decode array the same for all three components becomes that one pair; another stops it. +/// Calibrated RGB and RGB with a profile are left out, as the profile could make them other +/// than black and white; so is one drawn where `/DefaultGray` or `/DefaultRGB` is or may be in +/// force (`defaults`), which would draw it otherwise as grey, and one whose soft mask has a +/// `/Matte` of three components; +/// - an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1, which stay 0 and +/// 1 at one bit, whatever their colours, so the colour space stays. A decode array other than +/// the default stops it: in index units it would mean something else at one bit. +fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Vec, u32, u32, Option)> { + let d = &s.dict; + // Through u32 first, as everywhere here, so the product can't overflow. + let (w, h) = (u32::try_from(d.int(b"Width")?).ok()?, u32::try_from(d.int(b"Height")?).ok()?); + if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS { + return None; + } + let (wu, hu) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); + let stride = wu.div_ceil(8); + let bits = d.int(b"BitsPerComponent"); + let mask = matches!(d.get(b"ImageMask"), Some(Object::Bool(true))); + if (mask && bits.is_none_or(|b| b == 1)) || (bits == Some(1) && d.get(b"ColorSpace").is_some_and(|cs| one_component(doc, cs))) { + // No further than its rows can need: a fax image's code (the filters before its decoder + // produce it) takes at most about a byte a pixel and each row's end, as redaction allows. + let len = stride.checked_mul(hu)?; + let max = match s.filters().last() { + Some((pdfcraft_filters::Filter::CcittFax, _)) => wu.checked_mul(hu)?.checked_add(hu.checked_mul(4)?)?.checked_add(4096)?, + _ => allowance(len, h)?, + }; + let mut data = doc.decoded_image_within(s, max).ok()?; + if data.len() < len { + return None; + } + data.truncate(len); + return Some((data, w, h, None)); + } + let colour_key = d.get(b"Mask").is_some_and(|m| doc.resolve(m).as_array().is_some()); + if !pack || mask || colour_key { + return None; + } + let cs = d.get(b"ColorSpace")?; + // The decode array's numbers, each given directly or by reference, as integers or reals; `None` + // when it has something else. + let decode = match d.get(b"Decode").map(|v| doc.resolve(v)).as_deref() { + None => None, + Some(Object::Array(v)) => Some(v.iter().map(|e| doc.resolve(e).as_f64()).collect::>>()?), + Some(_) => return None, + }; + let mut packed = d.clone(); + packed.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + // Components a pixel, bits a component, and a pixel's samples as 1 or 0. + let (n, bpc, white): (usize, usize, Pixel) = match &*doc.resolve(cs) { + Object::Array(a) if matches!(a.first().and_then(Object::as_name), Some(b"Indexed" | b"I")) => { + let bpc = bits.filter(|b| matches!(b, 2 | 4 | 8))?; + match decode.as_deref() { + None => {} + Some([d0, d1]) if *d0 == 0.0 && *d1 == f64::from((1u32 << bpc) - 1) => { + packed.remove(b"Decode"); + } + Some(_) => return None, + } + (1, usize::try_from(bpc).ok()?, |v| match v { + [0] => Some(false), + [1] => Some(true), + _ => None, + }) + } + Object::Name(name) if matches!(name.as_slice(), b"DeviceRGB" | b"RGB") && bits == Some(8) => { + let matte = |m: &Object| matches!(&*doc.resolve(m), Object::Stream(m) if m.dict.contains(b"Matte")); + if defaults || d.get(b"SMask").is_some_and(matte) { + return None; + } + match decode.as_deref() { + None => {} + Some(v @ [d0, d1, ..]) if v.len() == 6 && v.chunks(2).all(|pair| pair == [*d0, *d1]) => { + let number = |n: f64| if n.fract() == 0.0 { Object::Int(n as i64) } else { Object::Real(n) }; + packed.set(b"Decode".to_vec(), Object::Array(vec![number(*d0), number(*d1)])); + } + Some(_) => return None, + } + packed.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + (3, 8, |v| match v { + [0, 0, 0] => Some(false), + [255, 255, 255] => Some(true), + _ => None, + }) + } + _ if bits == Some(8) && one_component(doc, cs) => (1, 8, |v| match v { + [0] => Some(false), + [255] => Some(true), + _ => None, + }), + _ => return None, + }; + let row_len = wu.checked_mul(n)?.checked_mul(bpc)?.div_ceil(8); + // Through its parameters given by reference too: without a PNG predictor's tag bytes taken + // off, rows of 0 and 255 (or palette entries 0 and 1) would still pass below, each shifted. + let samples = doc.with_direct_parms(s).decoded_within(allowance(row_len.checked_mul(hu)?, h)?).ok()?; + Some((packed_rows(&samples, row_len, wu, hu, (n, bpc, white))?, w, h, Some(packed))) +} + +/// `h` rows of `w` pixels, each row `row_len` bytes of `n` samples a pixel at `bpc` bits (8, or +/// a divisor of 8 with `n` 1), as rows of 1-bit samples each starting on a new byte: 1 where +/// `white` says the pixel is white. `None` when a pixel is neither, `samples` is short, or the +/// rows have another shape: no pixels in a row, other sample sizes, or too few bytes for their +/// pixels. +fn packed_rows(samples: &[u8], row_len: usize, w: usize, h: usize, (n, bpc, white): (usize, usize, Pixel)) -> Option> { + let stride = w.div_ceil(8); + let shape = (n > 0 && bpc == 8) || (n == 1 && matches!(bpc, 1 | 2 | 4)); + if stride == 0 || !shape || row_len < w.checked_mul(n)?.checked_mul(bpc)?.div_ceil(8) { + return None; + } + // From here no row is empty and x·n·bpc stays within one, and the output is no larger than + // the samples it is read from. + let samples = samples.get(..row_len.checked_mul(h)?)?; + let mut out = vec![0u8; stride.checked_mul(h)?]; + for (row, out_row) in samples.chunks(row_len).zip(out.chunks_mut(stride)) { + for x in 0..w { + let on = if bpc == 8 { + white(row.get(x * n..x * n + n)?)? + } else { + // Samples narrower than a byte, the first in its high bits; bpc divides 8. + let at = x * bpc; + white(&[(row.get(at / 8)? >> (8 - bpc - at % 8)) & ((1u8 << bpc) - 1)])? + }; + if on { + *out_row.get_mut(x / 8)? |= 0x80 >> (x % 8); + } + } + } + Some(out) +} + +/// Resolve polarity only for explicitly understood samples. Indexed palettes and custom +/// colour spaces stay at their original resolution rather than guessing which entry is dark. +fn dark_is_one(doc: &Document, d: &Dict) -> Option { + let number = |v: Option<&Object>| v.and_then(|v| doc.resolve(v).as_f64()).filter(|v| v.is_finite()); + let (d0, d1) = match d.get(b"Decode").map(|v| doc.resolve(v)).as_deref() { + Some(Object::Array(a)) if a.len() == 2 => number(a.first()).zip(number(a.get(1)))?, + None => (0.0, 1.0), + _ => return None, + }; + if d0 == d1 { + return None; + } + if matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) { + return Some(d1 < d0); + } + match d.get(b"ColorSpace").map(|cs| doc.resolve(cs)).as_deref() { + Some(Object::Name(n)) if matches!(n.as_slice(), b"DeviceGray" | b"G") => Some(d1 < d0), + _ => None, + } +} + +/// `w` × `h` 1-bit rows (each starting on a new byte) averaged down to `nw` × `nh`, no larger: +/// each new pixel takes the sample that covers more of its area, and the dark one when the two tie +/// (1 if `dark_is_one`, see [`dark_is_one`]), so a dark line half a new pixel wide stays. `None` +/// when the rows are short or the new size isn't smaller in both directions or empty. Also says +/// how many new pixels cover both samples. +#[cfg(test)] +pub(crate) fn downsample_bits(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_is_one: bool) -> Option<(Vec, u64)> { + downsample_bits_checked(data, w, h, nw, nh, dark_is_one).map(|(out, mixed, _)| (out, mixed)) +} + +fn downsample_bits_checked(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_is_one: bool) -> Option<(Vec, u64, bool)> { + if nw == 0 || nh == 0 || nw > w || nh > h { + return None; + } + let (stride, nstride) = (usize::try_from(w).ok()?.div_ceil(8), usize::try_from(nw).ok()?.div_ceil(8)); + let data = data.get(..stride.checked_mul(usize::try_from(h).ok()?)?)?; + let mut out = vec![0u8; nstride.checked_mul(usize::try_from(nh).ok()?)?]; + // In units of 1/nw of a source column, source column x spans [x·nw, (x+1)·nw) and new column i + // spans [i·w, (i+1)·w); rows likewise with h and nh. A new pixel's area is then w·h. + let (w, h, nw, nh) = (u64::from(w), u64::from(h), u64::from(nw), u64::from(nh)); + let overlap = |a0: u64, a1: u64, b0: u64, b1: u64| a1.min(b1).saturating_sub(a0.max(b0)); + // Per source column, the area its 1 samples cover in the new row. + let mut ones = vec![0u64; usize::try_from(w).ok()?]; + let mut mixed = 0u64; + let mut previous_mixed = vec![false; usize::try_from(nw).ok()?]; + let mut local_tone = false; + for j in 0..nh { + let mut left_mixed = false; + let mut above_left_mixed = false; + ones.fill(0); + let (y0, y1) = (j * h, (j + 1) * h); + for y in y0 / nh..y1.div_ceil(nh).min(h) { + let weight = overlap(y0, y1, y * nh, (y + 1) * nh); + let row = data.get(usize::try_from(y).ok()? * stride..)?; + for (x, sum) in ones.iter_mut().enumerate() { + if row.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0) { + *sum += weight; + } + } + } + let out_row = out.get_mut(usize::try_from(j).ok()? * nstride..)?; + for i in 0..nw { + let (x0, x1) = (i * w, (i + 1) * w); + let covered: u64 = (x0 / nw..x1.div_ceil(nw).min(w)) + .map(|x| overlap(x0, x1, x * nw, (x + 1) * nw) * usize::try_from(x).ok().and_then(|x| ones.get(x)).copied().unwrap_or(0)) + .sum(); + // Against what the 0 samples cover: covered is at most w·h, which can't overflow + // (w·h < 2^64 for u32 sizes). + let zeros = w * h - covered; + let is_mixed = covered > 0 && zeros > 0; + mixed += u64::from(is_mixed); + let column = usize::try_from(i).ok()?; + let above = *previous_mixed.get(column)?; + local_tone |= is_mixed && left_mixed && above && above_left_mixed; + *previous_mixed.get_mut(column)? = is_mixed; + left_mixed = is_mixed; + above_left_mixed = above; + if covered > zeros || (dark_is_one && covered == zeros) { + let i = usize::try_from(i).ok()?; + *out_row.get_mut(i / 8)? |= 0x80 >> (i % 8); + } + } + } + Some((out, mixed, local_tone)) +} + +/// A black-and-white image (see [`one_bit`]) resampled and stored again per `settings`, if that is +/// smaller as stored; `ppi` is its effective resolution, and `defaults` says it is drawn where +/// `/DefaultGray` or `/DefaultRGB` is or may be in force. Also says whether it was resampled. +/// +/// - **Resampled** ([`downsample_bits_checked`]) when its resolution is above `above_ppi`, to `target_ppi`, +/// unless it has a soft mask, whose own size would then have to follow, or is a halftone or a +/// dither: most of its new pixels would cover both black and white, and taking the sample that +/// covers more would turn its tones solid (a 50% screen black, a 25% one white). +/// - **Stored** as CCITT Group 4 with `CcittG4`, or as 1-bit Flate when that is smaller, which only +/// a resampled image or one packed from more bits may be: a 1-bit image that is only stored again +/// becomes Group 4 or stays. Group 4 alone is only decoded to be resampled; Group 3 is stored +/// again like any other. With `Retain`, a resampled image keeps its kind of compression +/// (CCITT, or Flate), and nothing else changes: an image of more bits stays as it is, for the +/// grey or colour settings. +/// +/// The samples keep their meaning, so the colour space, decode array and `/Mask` stay, but for +/// black-and-white RGB, which becomes grey with one decode pair, and an indexed image's default +/// decode array, which goes as the default at one bit. +fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSettings) -> Option<(Stream, bool)> { + let d = &s.dict; + let downsample = settings.downsample && ppi.is_finite() && ppi > settings.above_ppi && settings.target_ppi > 0.0 && !d.contains(b"SMask"); + let retain = settings.compression == MonoCompression::Retain; + let fax = doc.fax_params(s); + // Its kind of compression, which Retain keeps, behind any other filters. + let ccitt = fax.is_some(); + // Group 4 alone is only worth decoding to resample it. Group 3, or Group 4 behind + // ASCII85Decode, say, can be smaller stored again as Group 4 alone. + let g4_alone = fax.is_some_and(|p| p.k < 0) && s.filters().len() == 1; + if (retain || (g4_alone && settings.compression == MonoCompression::CcittG4)) && !downsample { + return None; + } + let (data, w, h, packed) = one_bit(doc, s, !retain, defaults)?; + let packed_from_more = packed.is_some(); + let k = settings.target_ppi / ppi; + // `downsample_bits` keeps a counter for each column: no more columns than a fax row may have, + // so a hostile strip of 80 million can't make it 640 MB. + let narrow = w <= pdfcraft_filters::MAX_CCITT_COLUMNS; + // At most `w` × `h`, as k < 1: no overflow. + let (nw, nh) = if downsample && narrow && k < 1.0 { + (((f64::from(w) * k).round() as u32).max(1), ((f64::from(h) * k).round() as u32).max(1)) + } else { + (w, h) + }; + let polarity = if defaults { None } else { dark_is_one(doc, packed.as_ref().unwrap_or(d)) }; + let averaged = if (nw, nh) != (w, h) && polarity.is_some() { + // The dark sample of the image as it ends up: packed RGB is grey, with grey's decode pair. + let (out, mixed, local_tone) = downsample_bits_checked(&data, w, h, nw, nh, polarity?)?; + // A halftone or dither: most new pixels cover both samples. Text covers both only along + // its edges. + // A dense 2x2 patch of mixed output samples can encode a local photo even when + // white margins dominate the page. Conservatively retain the original image. + (!local_tone && mixed.saturating_mul(2) <= u64::from(nw) * u64::from(nh)).then_some(out) + } else { + None + }; + let resampled = averaged.is_some(); + let (data, nw, nh) = match averaged { + Some(out) => (out, nw, nh), + None => (data, w, h), + }; + if (retain || (g4_alone && settings.compression == MonoCompression::CcittG4)) && !resampled { + return None; + } + let mut dict = packed.unwrap_or_else(|| d.clone()); + for key in [&b"Filter"[..], b"DecodeParms", b"Length", b"DL"] { + dict.remove(key); + } + dict.set(b"Width".to_vec(), Object::Int(i64::from(nw))); + dict.set(b"Height".to_vec(), Object::Int(i64::from(nh))); + let g4 = if settings.compression == MonoCompression::CcittG4 || (retain && ccitt) { + pdfcraft_filters::encode_g4(&data, nw, nh).ok().map(|coded| { + let mut dict = dict.clone(); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(i64::from(nw))); + parms.set(b"Rows".to_vec(), Object::Int(i64::from(nh))); + dict.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + Stream::from_raw(dict, coded) + }) + } else { + None + }; + let jbig = if settings.compression == MonoCompression::Jbig2 { + pdfcraft_filters::encode_jbig2(&data, nw, nh).ok().map(|coded| { + let mut dict = dict.clone(); + dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + Stream::from_raw(dict, coded) + }) + } else { + None + }; + let flate = ((resampled || packed_from_more) && !(retain && ccitt)).then(|| Stream::flate(dict, &data)); + // Smaller as stored, counting the dictionary: a tiny image can lose by Group 4's parameters. + let best = [g4, jbig, flate].into_iter().flatten().filter_map(|c| Some((crate::stored_len(&c)?, c))).min_by_key(|(len, _)| *len)?; + (best.0 < crate::stored_len(s)?).then_some((best.1, resampled)) +} + pub(crate) fn run( doc: &mut Document, pages: &[ObjRef], @@ -539,7 +1116,12 @@ pub(crate) fn run( report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool, ) -> Result<(), OptimizeError> { - let ppi = effective_resolutions(doc, pages); + let (ppi, mut defaults, read) = walk_pages(doc, pages); + // Black-and-white RGB becomes grey, which default colour spaces would draw otherwise: one used + // where the walk didn't look may be drawn so. + let packs = settings.mono.compression != MonoCompression::Retain; + let rgb: HashSet = ppi.keys().filter(|r| packs && device_rgb(doc, **r)).map(|r| r.num).collect(); + defaults.extend(used_elsewhere(doc, &rgb, &read)); // Soft masks are processed with their image, not on their own. let masks: HashSet = ppi .keys() @@ -551,6 +1133,7 @@ pub(crate) fn run( let mut refs: Vec<(ObjRef, f64)> = ppi.into_iter().filter(|(r, _)| !masks.contains(r)).collect(); refs.sort_by_key(|(r, _)| r.num); let total = refs.len(); + let mut jbig2 = false; for (done, (r, ppi)) in refs.into_iter().enumerate() { if !progress(Stage::Images { done, total }) { return Err(OptimizeError::Cancelled); @@ -558,9 +1141,53 @@ pub(crate) fn run( let obj = doc.get(r); let Object::Stream(s) = &*obj else { continue }; report.images += 1; - let Some(n) = s.dict.get(b"ColorSpace").and_then(|cs| components(doc, cs)) else { continue }; - let st = if n == 1 { &settings.gray } else { &settings.color }; - let Some((new, smask)) = process(doc, s, ppi, st, n) else { continue }; + // Packed from more bits a sample, it is lossless at one bit, but the grey or colour + // settings can make it smaller still (below). + let packed_from_more = s.dict.int(b"BitsPerComponent").is_some_and(|b| b > 1); + let (packed, mono) = match mono(doc, s, ppi, defaults.contains(&r.num), &settings.mono) { + Some(m) if packed_from_more => (Some(m), None), + m => (None, m), + }; + if let Some((new, resampled)) = mono { + report.image_bytes_before += s.raw.len(); + report.image_bytes_after += new.raw.len(); + if resampled { + report.images_resampled += 1; + } else { + report.images_recompressed += 1; + } + jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode"); + doc.set(r, Object::Stream(new)); + continue; + } + let n = s.dict.get(b"ColorSpace").and_then(|cs| components(doc, cs)); + let processed = n.and_then(|n| process(doc, s, ppi, if n == 1 { &settings.gray } else { &settings.color }, n)); + // A dithered scan, say, downsampled and JPEG-compressed: the smaller wins, counting a soft + // mask resampled with it. + let smask_len = |smask: Option<&(ObjRef, Stream)>| match smask { + Some((_, m)) => Some(m.raw.len()), + None => s.dict.reference(b"SMask").map(|m| match &*doc.get(m) { + Object::Stream(m) => m.raw.len(), + _ => 0, + }), + }; + let total = |new: &Stream, smask: Option<&(ObjRef, Stream)>| Some(crate::stored_len(new)?.saturating_add(smask_len(smask).unwrap_or(0))); + let packed_wins = packed.as_ref().is_some_and(|(p, _)| { + processed.as_ref().is_none_or(|(new, smask)| total(p, None).zip(total(new, smask.as_ref())).is_some_and(|(a, b)| a <= b)) + }); + if let Some((new, resampled)) = packed.filter(|_| packed_wins) { + jbig2 |= new.dict.name(b"Filter") == Some(b"JBIG2Decode"); + report.image_bytes_before += s.raw.len(); + report.image_bytes_after += new.raw.len(); + if resampled { + report.images_resampled += 1; + } else { + report.images_recompressed += 1; + } + doc.set(r, Object::Stream(new)); + continue; + } + let Some((new, smask)) = processed else { continue }; let resized = new.dict.int(b"Width") != s.dict.int(b"Width") || new.dict.int(b"Height") != s.dict.int(b"Height"); report.image_bytes_before += s.raw.len(); report.image_bytes_after += new.raw.len(); @@ -577,6 +1204,23 @@ pub(crate) fn run( if !progress(Stage::Images { done: total, total }) { return Err(OptimizeError::Cancelled); } + if jbig2 { + at_least_pdf_1_4(doc)?; + } + Ok(()) +} + +/// JBIG2Decode is PDF 1.4: a document whose header says less, and whose catalog doesn't say 1.4 +/// or more already (directly or by reference), gets `/Version /1.4` in its catalog (ISO 32000-2 +/// §7.7.2). A full save with object streams writes a 1.5 header anyway. +fn at_least_pdf_1_4(doc: &mut Document) -> Result<(), OptimizeError> { + let below = |v: &[u8]| v.len() < 3 || v < &b"1.4"[..]; + let Some(root) = doc.root() else { return Ok(()) }; + let version = doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned()); + let catalog = version.and_then(|v| doc.resolve(&v).as_name().map(<[u8]>::to_vec)); + if below(doc.version().as_bytes()) && catalog.as_deref().is_none_or(below) { + doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name("1.4")))?; + } Ok(()) } @@ -616,6 +1260,66 @@ mod dimension_tests { assert_eq!(DECODED_PIXELS.with(|c| c.get()), 0, "pixels decoded"); assert_eq!(pixels(&Document::new_empty(), &stream(1000, 1000), 1).map(|p| p.len()), Some(1000 * 1000)); } + + /// Resampling goes through no more than one of the two sizes holds, whatever their proportions: + /// the first three shapes asked for 160 GB and more on the way, and that aborted the process. + /// The last two would ask for 10 GB and 30 GB if they went across first, which is why the way + /// round depends on the shapes. A flat picture stays flat, and pictures of ordinary proportions + /// resample as they always did. + #[test] + fn resampling_across_proportions_takes_the_memory_of_neither_product() { + let shapes = [ + (100_000u32, 1u32, 1u32, 100_000u32, 1usize), + (1_000_000, 1, 4, 100_000, 1), + (100_000, 1, 1, 100_000, 3), + (1, 100_000, 100_000, 1, 1), + (1, 100_000, 100_000, 1, 3), + ]; + for (w, h, nw, nh, n) in shapes { + let px = vec![0x60; (w * h) as usize * n]; + let out = resample(&px, n, w, h, nw, nh).unwrap(); + assert_eq!(out.len(), (nw * nh) as usize * n); + assert!(out.iter().all(|v| v.abs_diff(0x60) <= 1), "{w} × {h} to {nw} × {nh}"); + } + let (w, h, nw, nh) = (40u32, 30u32, 20u32, 15u32); + let px: Vec = (0..w * h).map(|i| (i * 7 % 251) as u8).collect(); + let image = image::GrayImage::from_raw(w, h, px.clone()).unwrap(); + let direct = image::imageops::resize(&image, nw, nh, image::imageops::FilterType::CatmullRom).into_raw(); + assert_eq!(resample(&px, 1, w, h, nw, nh).unwrap(), direct, "as it was"); + } + + /// The long way round is for proportions far apart: up to a quarter over the larger picture the + /// buffer is no danger, and that scales as it always did, to the byte. Noise, as a flat picture comes + /// out the same either way. + #[test] + fn proportions_up_to_the_limit_resample_as_they_did() { + let noise = |w: u32, h: u32| -> Vec { + let mut seed = 0x2545_F491_4F6C_DD1Du64; + (0..w * h) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed as u8 + }) + .collect() + }; + let direct = |px: &[u8], w, h, nw, nh| { + let image = image::GrayImage::from_raw(w, h, px.to_vec()).unwrap(); + image::imageops::resize(&image, nw, nh, image::imageops::FilterType::CatmullRom).into_raw() + }; + // Each exactly a quarter over the larger: the source's size, then the target's. + for (w, h, nw, nh) in [(25, 16, 10, 20), (20, 5, 16, 25)] { + let px = noise(w, h); + assert_eq!(resample(&px, 1, w, h, nw, nh).unwrap(), direct(&px, w, h, nw, nh), "{w} × {h} to {nw} × {nh}"); + } + // A step past it goes the long way, which is how to tell. + let px = noise(25, 15); + assert_ne!(resample(&px, 1, 25, 15, 10, 20).unwrap(), direct(&px, 25, 15, 10, 20)); + // A picture of the size asked for comes through as it is. + let px = noise(20, 10); + assert_eq!(resample(&px, 1, 20, 10, 20, 10).unwrap(), px); + } } #[cfg(test)] @@ -631,3 +1335,136 @@ mod tests { assert!(data.is_none_or(|d| d.len() <= MAX_PAGE_DECODED + 300)); } } + +#[cfg(test)] +mod downsample_tests { + use super::{dark_is_one, downsample_bits}; + use pdfcraft_cos::{Dict, Document, Object}; + + /// [`downsample_bits`] the slow way: each new pixel's area against every source pixel's. + fn naive(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_one: bool) -> Vec { + let (stride, nstride) = ((w as usize).div_ceil(8), (nw as usize).div_ceil(8)); + let (w, h, nw, nh) = (u64::from(w), u64::from(h), u64::from(nw), u64::from(nh)); + let mut out = vec![0u8; nstride * nh as usize]; + for j in 0..nh { + for i in 0..nw { + let mut ones = 0; + for y in 0..h { + for x in 0..w { + let across = ((i + 1) * w).min((x + 1) * nw).saturating_sub((i * w).max(x * nw)); + let down = ((j + 1) * h).min((y + 1) * nh).saturating_sub((j * h).max(y * nh)); + if data[y as usize * stride + x as usize / 8] & (0x80 >> (x % 8)) != 0 { + ones += across * down; + } + } + } + if 2 * ones > w * h || (dark_one && 2 * ones == w * h) { + out[j as usize * nstride + i as usize / 8] |= 0x80 >> (i % 8); + } + } + } + out + } + + #[test] + fn each_new_pixel_takes_the_sample_covering_more_of_it() { + // 4 × 2 to 2 × 1: three 1s of four on the left, none on the right. + assert_eq!(downsample_bits(&[0b1100_0000, 0b1000_0000], 4, 2, 2, 1, false).map(|(out, _)| out), Some(vec![0b1000_0000])); + // A tie gives the dark sample: 0, black in grey, so a black line half a new pixel wide + // stays (the second of four white columns is black); a white one that thin on black doesn't. + assert_eq!(downsample_bits(&[0b1011_0000, 0b1011_0000], 4, 2, 2, 1, false).map(|(out, _)| out), Some(vec![0b0100_0000])); + assert_eq!(downsample_bits(&[0b0100_0000, 0b0100_0000], 4, 2, 2, 1, false).map(|(out, _)| out), Some(vec![0])); + // With 1 the dark sample (an inverting decode array, a separation), that line is a 1. + assert_eq!(downsample_bits(&[0b0100_0000, 0b0100_0000], 4, 2, 2, 1, true).map(|(out, _)| out), Some(vec![0b1000_0000])); + assert_eq!(downsample_bits(&[0b1011_0000, 0b1011_0000], 4, 2, 2, 1, true).map(|(out, _)| out), Some(vec![0b1100_0000])); + // The same size gives the same bits; padding bits are 0. + assert_eq!(downsample_bits(&[0b1010_1111], 3, 1, 3, 1, false).map(|(out, _)| out), Some(vec![0b1010_0000])); + // 3 to 2 across: the middle column counts half to each new one. + assert_eq!(downsample_bits(&[0b1100_0000], 3, 1, 2, 1, false).map(|(out, _)| out), Some(vec![0b1000_0000])); + } + + /// The dark sample: 0 in grey and of an image mask, 1 in a separation, each the other way round + /// with an inverting decode array; 0 for an indexed image, whatever its decode array. + #[test] + fn the_dark_sample_follows_the_colour_space_and_decode_array() { + let doc = Document::new_empty(); + let image = |entries: &[(&str, Object)]| { + let mut d = Dict::new(); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + d + }; + let inverted = ("Decode", Object::Array(vec![1.into(), 0.into()])); + let grey = ("ColorSpace", Object::name("DeviceGray")); + let mask = ("ImageMask", Object::Bool(true)); + let separation = + ("ColorSpace", Object::Array(vec![Object::name("Separation"), Object::name("Spot"), Object::name("DeviceCMYK"), Object::Null])); + let indexed = ("ColorSpace", Object::Array(vec![Object::name("Indexed"), Object::name("DeviceGray"), 1.into(), Object::Null])); + for (entries, dark_one) in [ + (vec![grey.clone()], Some(false)), + (vec![grey, inverted.clone()], Some(true)), + (vec![mask.clone()], Some(false)), + (vec![mask, inverted.clone()], Some(true)), + (vec![separation.clone()], None), + (vec![separation, inverted.clone()], None), + (vec![indexed.clone()], None), + (vec![indexed, inverted], None), + (vec![("ColorSpace", Object::name("DeviceGray")), ("Decode", Object::Array(vec![Object::name("x"), 0.into()]))], None), + ] { + assert_eq!(dark_is_one(&doc, &image(&entries)), dark_one, "{entries:?}"); + } + } + + #[test] + fn what_it_cannot_resample_is_none() { + assert_eq!(downsample_bits(&[0; 2], 4, 2, 5, 1, false).map(|(out, _)| out), None, "wider"); + assert_eq!(downsample_bits(&[0; 2], 4, 2, 2, 3, false).map(|(out, _)| out), None, "taller"); + assert_eq!(downsample_bits(&[0; 2], 4, 2, 0, 1, false).map(|(out, _)| out), None); + assert_eq!(downsample_bits(&[0; 1], 4, 2, 2, 1, false).map(|(out, _)| out), None, "short rows"); + assert_eq!(downsample_bits(&[], u32::MAX, u32::MAX, 1, 1, true).map(|(out, _)| out), None); + } + + proptest::proptest! { + /// Any bitmap, to any size no larger, with either sample the dark one, gives what the slow + /// way gives; all 1s stay all 1s. + #[test] + fn it_matches_the_slow_way( + w in 1u32..40, + h in 1u32..16, + nw_part in 0.0f64..1.0, + nh_part in 0.0f64..1.0, + seed in proptest::collection::vec(0u8..=255, 1..64), + dark_one: bool, + ) { + let (nw, nh) = (1 + (f64::from(w - 1) * nw_part) as u32, 1 + (f64::from(h - 1) * nh_part) as u32); + let len = (w as usize).div_ceil(8) * h as usize; + let data: Vec = seed.iter().cycle().take(len).copied().collect(); + proptest::prop_assert_eq!(downsample_bits(&data, w, h, nw, nh, dark_one).map(|(out, _)| out), Some(naive(&data, w, h, nw, nh, dark_one))); + let white = downsample_bits(&vec![0xFF; len], w, h, nw, nh, dark_one).map(|(out, _)| out).unwrap(); + proptest::prop_assert_eq!(white, naive(&vec![0xFF; len], w, h, nw, nh, dark_one)); + proptest::prop_assert!(naive(&vec![0xFF; len], w, h, nw, nh, dark_one).iter().any(|b| *b != 0)); + } + } +} + +#[cfg(test)] +mod pack_tests { + use super::{Pixel, packed_rows}; + + /// Rows of one pixel of one bit, 1 white: each packed as it is. + const WHITE_IS_1: Pixel = |v| Some(v == [1]); + + /// [`packed_rows`] packs rows it can read whole, and refuses any other shape, without reading + /// past a row or allocating more than its samples: no pixels in a row, no components, samples + /// of 3 bits, several components under 8 bits, rows shorter than their pixels, and a width far + /// past the samples. + #[test] + fn packed_rows_refuses_rows_it_cannot_read_whole() { + let samples = [0x80, 0x00, 0x80, 0x00]; + assert_eq!(packed_rows(&samples, 1, 1, 4, (1, 1, WHITE_IS_1)), Some(vec![0x80, 0x00, 0x80, 0x00])); + for (row_len, w, n, bpc) in [(1, 0, 1, 1), (1, 1, 0, 8), (1, 1, 1, 3), (1, 1, 2, 4), (1, 9, 1, 1), (1, usize::MAX / 8, 1, 1)] { + assert_eq!(packed_rows(&samples, row_len, w, 4, (n, bpc, WHITE_IS_1)), None, "row of {row_len} for {w} × {n} × {bpc}"); + } + } +} diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 9f9fcdf2be..76e30da7ac 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -7,10 +7,14 @@ //! - **Images:** each image's effective resolution is measured where pages draw it (the //! smallest over its uses, through form XObjects); colour and grayscale images above a //! threshold are resampled (bicubic) to a target resolution and recompressed as JPEG or Flate. -//! A new image replaces the old one only if it is smaller. Images PdfCraft can't decode -//! faithfully (CMYK and other colour spaces, masks, decode arrays other than the default, -//! JPEG 2000, JBIG2, CCITT, other than 8 bits, JPEGs whose Adobe marker and `/ColorTransform` -//! disagree) are left alone. +//! A new image replaces the old one only if it is smaller. The colour and grey settings leave +//! alone images they can't decode faithfully (CMYK and other colour spaces, masks, decode arrays +//! other than the default, JPEG 2000, JBIG2, CCITT, other than 8 bits, JPEGs whose Adobe marker +//! and `/ColorTransform` disagree). Black-and-white images (1-bit, image masks and CCITT fax, +//! Group 3 among them; and eight-bit grey and RGB of only black and white, and indexed images +//! that use only palette entries 0 and 1, packed to one bit) above a threshold are averaged down to a +//! target resolution, halftones and dithers aside, and are stored as CCITT Group 4 or lossless JBIG2, or 1-bit +//! Flate once resampled or packed, when that is smaller. //! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree), //! print settings. //! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted @@ -118,6 +122,37 @@ pub enum Compression { Retain, } +/// How black-and-white (1-bit) images are stored. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MonoCompression { + /// CCITT Group 4 (lossless), when smaller than the image as it is. + CcittG4, + /// JBIG2, lossless (a generic region, or symbols when the image's marks repeat), when smaller + /// than the image as it is. JBIG2Decode is PDF 1.4: an older document gets `/Version /1.4` in + /// its catalog. + Jbig2, + /// Keep each image's own compression. + Retain, +} + +/// How black-and-white images (1-bit, CCITT fax among them; and eight-bit grey and RGB of only black +/// and white, and indexed images that use only palette entries 0 and 1, packed to one bit), and +/// image masks, are resampled and stored. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MonoSettings { + /// Average images whose effective resolution is above `above_ppi` down to `target_ppi`. + pub downsample: bool, + pub target_ppi: f64, + pub above_ppi: f64, + pub compression: MonoCompression, +} + +impl MonoSettings { + /// Reduce File Size and the presets: to 300 ppi above 450 ppi, CCITT Group 4. Text in a scan + /// stays sharp at 300 ppi. + pub const REDUCE: MonoSettings = MonoSettings { downsample: true, target_ppi: 300.0, above_ppi: 450.0, compression: MonoCompression::CcittG4 }; +} + /// Acrobat's JPEG quality steps. pub const QUALITIES: [(&str, u8); 5] = [("Minimum", 25), ("Low", 45), ("Medium", 60), ("High", 80), ("Maximum", 95)]; @@ -147,6 +182,8 @@ pub struct Settings { pub merge_fragments: bool, pub color: ImageSettings, pub gray: ImageSettings, + /// Black-and-white images, and image masks. + pub mono: MonoSettings, pub discard_thumbnails: bool, pub discard_alternate_images: bool, pub discard_tags: bool, @@ -181,6 +218,7 @@ impl Default for Settings { merge_fragments: true, color: ImageSettings::REDUCE, gray: ImageSettings::REDUCE, + mono: MonoSettings::REDUCE, discard_thumbnails: true, discard_alternate_images: true, discard_tags: false, @@ -539,7 +577,7 @@ fn strip_ascii_wrapper(s: &Stream) -> Option { /// Bytes the stream object takes in the file: its dictionary with `/Length` set as the writer /// sets it, then its data. -fn stored_len(s: &Stream) -> Option { +pub(crate) fn stored_len(s: &Stream) -> Option { let mut dict = s.dict.clone(); dict.set(b"Length".to_vec(), Object::Int(i64::try_from(s.raw.len()).ok()?)); let mut out = Vec::new(); diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 3e69e27cb9..683cbd0684 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -5,7 +5,8 @@ use std::sync::Arc; use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; use pdfcraft_filters::{Filter, Params}; use pdfcraft_optimize::{ - Compatibility, Compression, ImageSettings, OptimizeError, Settings, SpaceCategory, audit_space, effective_resolutions, optimize, + Compatibility, Compression, ImageSettings, MonoCompression, MonoSettings, OptimizeError, Settings, SpaceCategory, audit_space, + effective_resolutions, optimize, }; fn image(doc: &mut Document, w: u32, h: u32, n: usize, jpeg: bool, smask: Option) -> ObjRef { @@ -218,6 +219,1862 @@ fn an_identity_decode_array_does_not_stop_reduce() { assert_eq!(stream(&doc, odd).dict.int(b"Width"), Some(900)); } +/// Rows of `w` 1-bit samples (1 white, 0 black), each starting on a byte: lines of text-like +/// glyphs; or, with `noise`, random bits. +fn bilevel(w: u32, h: u32, noise: bool) -> Vec { + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let mut black = |x: usize, y: usize| { + if x < w as usize && y < h as usize { + data[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + }; + if noise { + for y in 0..h as usize { + for x in 0..w as usize { + if next() & 1 == 0 { + black(x, y); + } + } + } + return data; + } + // Lines 24 rows apart, of glyphs drawn from a few shapes (a stem, a bowl, an arch, a dotted + // stem) at any column, 1–3 columns apart with a wider gap between words: text as a scanner + // sees it. Clean, identical rows would favour Flate; a scan's ragged edges favour Group 4. + for line in (4..h as usize).step_by(24) { + let mut x = 8; + while x + 16 < w as usize { + let wide = 5 + (next() % 4) as usize; + let shape = next() % 4; + // Now and then a row of a stroke is a column wider or narrower at an edge, as a scan's + // edges are. + let mut ragged = || match next() % 8 { + 0 => 0, + 1 => 2, + _ => 1, + }; + let mut fill = |x0: usize, x1: usize, y0: usize, y1: usize| { + for y in line + y0..line + y1 { + let (left, right) = ((x + x0 + 1).saturating_sub(ragged()), x + x1 + ragged()); + for xx in left..right.saturating_sub(1) { + black(xx, y); + } + } + }; + match shape { + 0 => fill(0, 2, 0, 14), + 1 => { + fill(0, wide, 4, 6); + fill(0, wide, 12, 14); + fill(0, 2, 4, 14); + fill(wide - 2, wide, 4, 14); + } + 2 => { + fill(0, 2, 4, 14); + fill(wide - 2, wide, 6, 14); + fill(0, wide - 1, 4, 6); + } + _ => { + fill(0, 2, 7, 14); + fill(0, 2, 2, 4); + } + } + x += wide + 2 + (next() % 2) as usize + if next() % 6 == 0 { 7 } else { 0 }; + } + } + data +} + +/// A 1-bit image of `data`, Flate-compressed (with `predictor`, a PNG predictor, if any), with +/// `entries` added to its dictionary. +fn mono_image(doc: &mut Document, w: u32, h: u32, data: &[u8], predictor: Option, entries: &[(&str, Object)]) -> ObjRef { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + let s = match predictor { + None => Stream::flate(d, data), + Some(predictor) => { + let params = pdfcraft_filters::Params { predictor, colors: 1, bits_per_component: 1, columns: w as i64, early_change: 1 }; + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(predictor)); + parms.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + parms.set(b"Columns".to_vec(), Object::Int(w as i64)); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + Stream::from_raw(d, pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, data).unwrap()) + } + }; + doc.add(Object::Stream(s)) +} + +fn render(doc: &Document) -> Vec { + let bytes = write_full(doc, &SaveOptions::default()).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default()); + let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + out.rgba.to_vec() +} + +/// Black-and-white images become CCITT Group 4, losslessly: the page looks the same to the pixel. +/// Grey, an image mask (with a decode array that inverts it), an indexed image of two colours and +/// one stored with a PNG predictor qualify; noise (bigger as G4), a 1-bit RGB image, one whose +/// colour space is a resource name, one wider than Group 4 encodes and a tiny one (bigger as +/// stored) are left alone, as is everything with `mono: Retain`. +#[test] +fn black_and_white_images_become_ccitt_group_4() { + let mut doc = Document::new_empty(); + let (w, h) = (1200, 480); + let text = bilevel(w, h, false); + let gray = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let decode = Object::Array(vec![1.into(), 0.into()]); + let mask = mono_image(&mut doc, w, h, &text, None, &[("ImageMask", Object::Bool(true)), ("Decode", decode.clone())]); + let indexed_cs = Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + 1.into(), + Object::String(pdfcraft_cos::PdfString::literal(vec![0xC0, 0x10, 0x10, 0xF8, 0xF0, 0xE0])), + ]); + let indexed = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", indexed_cs.clone())]); + let predicted = mono_image(&mut doc, w, h, &text, Some(15), &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let noise = + mono_image(&mut doc, 400, 200, &bilevel(400, 200, true), None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let rgb = mono_image( + &mut doc, + 400, + 160, + &bilevel(1200, 160, false), + None, + &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceRGB"))], + ); + let wide = mono_image( + &mut doc, + 40_000, + 2, + &bilevel(40_000, 2, false), + None, + &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))], + ); + // A bare /Indexed is no colour space on its own: it names the page's resource, here RGB, so + // these samples are three to a pixel. + let named = + mono_image(&mut doc, 400, 160, &bilevel(1200, 160, false), None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("Indexed"))]); + // 64 × 8 white: its Group 4 data is smaller than its Flate data, but not by the longer + // dictionary Group 4 needs. + let tiny = mono_image(&mut doc, 64, 8, &[0xFF; 64], None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let tiny_g4 = pdfcraft_filters::encode_g4(&[0xFF; 64], 64, 8).unwrap(); + assert!(tiny_g4.len() < stream(&doc, tiny).raw.len(), "{} against {}", tiny_g4.len(), stream(&doc, tiny).raw.len()); + let p = page( + &mut doc, + &[("G", gray), ("M", mask), ("I", indexed), ("P", predicted), ("N", noise), ("R", rgb), ("W", wide), ("T", tiny), ("X", named)], + "q 300 0 0 120 20 660 cm /G Do Q q 0 0.4 0.8 rg 300 0 0 120 320 660 cm /M Do Q q 300 0 0 120 20 520 cm /I Do Q \ + q 300 0 0 120 320 520 cm /P Do Q q 200 0 0 100 20 400 cm /N Do Q q 200 0 0 80 240 400 cm /R Do Q q 560 0 0 4 20 380 cm /W Do Q q 64 0 0 8 460 400 cm /T Do Q q 200 0 0 80 240 300 cm /X Do Q", + ); + doc.update_dict(p, |d| { + if let Some(Object::Dict(res)) = d.get_mut(b"Resources") { + let mut cs = Dict::new(); + cs.set(b"Indexed".to_vec(), Object::name("DeviceRGB")); + res.set(b"ColorSpace".to_vec(), Object::Dict(cs)); + } + }) + .unwrap(); + let before = render(&doc); + let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; + let untouched = [noise, rgb, wide, tiny, named].map(|r| raw(&doc, r)); + + let mut kept = doc.clone(); + let r = optimize(&mut kept, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + assert_eq!(r.images_recompressed, 0, "Retain keeps every image as it is"); + assert!([gray, mask, indexed, predicted].iter().all(|i| raw(&kept, *i) == raw(&doc, *i))); + + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(r.images_recompressed, 4); + assert!(r.image_bytes_after < r.image_bytes_before, "{} → {}", r.image_bytes_before, r.image_bytes_after); + for i in [gray, mask, indexed, predicted] { + let s = stream(&doc, i); + assert_eq!(s.dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..])); + let parms = s.dict.get(b"DecodeParms").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!((parms.int(b"K"), parms.int(b"Columns"), parms.int(b"Rows")), (Some(-1), Some(w as i64), Some(h as i64))); + } + assert_eq!(stream(&doc, mask).dict.get(b"Decode"), Some(&decode), "the decode array stays"); + assert_eq!(stream(&doc, indexed).dict.get(b"ColorSpace"), Some(&indexed_cs), "the colour space stays"); + assert_eq!([noise, rgb, wide, tiny, named].map(|r| raw(&doc, r)), untouched); + assert!(render(&doc) == before, "the page looks the same"); +} + +/// Which 1-bit images qualify for Group 4: those of one colour component, an ICC profile of one +/// component and a DeviceN of one colourant among them, but not one of three components or two +/// colourants (more than a bit a pixel); image masks of one bit, as `/BitsPerComponent 1` says or +/// by default, but not one that says 8; and images of up to 80 million pixels, not one row more. +/// The byte totals count exactly the images stored again. +#[test] +fn group_4_takes_images_of_one_bit_a_pixel_up_to_the_pixel_limit() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let icc = |doc: &mut Document, n: i64| { + let mut d = Dict::new(); + d.set(b"N".to_vec(), Object::Int(n)); + Object::Array(vec![Object::name("ICCBased"), Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, Vec::new()))))]) + }; + let device_n = |colourants: &[&str]| { + let mut tint = Dict::new(); + tint.set(b"FunctionType".to_vec(), Object::Int(2)); + tint.set(b"Domain".to_vec(), Object::Array(vec![0.into(), 1.into()])); + tint.set(b"N".to_vec(), Object::Int(1)); + Object::Array(vec![ + Object::name("DeviceN"), + Object::Array(colourants.iter().map(|c| Object::name(c)).collect()), + Object::name("DeviceGray"), + Object::Dict(tint), + ]) + }; + let one_bit = |cs: Object| [("BitsPerComponent", 1.into()), ("ColorSpace", cs)]; + let icc_1 = icc(&mut doc, 1); + let icc_1 = mono_image(&mut doc, w, h, &text, None, &one_bit(icc_1)); + let icc_3 = icc(&mut doc, 3); + let icc_3 = mono_image(&mut doc, w, h, &bilevel(3 * w, h, false), None, &one_bit(icc_3)); + let spot = mono_image(&mut doc, w, h, &text, None, &one_bit(device_n(&["Spot"]))); + let two_spots = mono_image(&mut doc, w, h, &bilevel(2 * w, h, false), None, &one_bit(device_n(&["Spot", "Other"]))); + let mask = |bits: i64| [("ImageMask", Object::Bool(true)), ("BitsPerComponent", bits.into())]; + let mask_1 = mono_image(&mut doc, w, h, &text, None, &mask(1)); + let mask_8 = mono_image(&mut doc, w, h, &bilevel(8 * w, h, false), None, &mask(8)); + // 10,000 × 8,000 is the limit itself. + let grey = one_bit(Object::name("DeviceGray")); + let at_limit = mono_image(&mut doc, 10_000, 8_000, &bilevel(10_000, 8_000, false), None, &grey); + let past_limit = mono_image(&mut doc, 10_000, 8_001, &bilevel(10_000, 8_001, false), None, &grey); + let all = [icc_1, icc_3, spot, two_spots, mask_1, mask_8, at_limit, past_limit]; + let names: Vec = (0..all.len()).map(|i| format!("Im{i}")).collect(); + // 200 × 80 pt is 432 ppi, 1800 × 1440 pt 400 ppi: below where monochrome downsampling starts. + let content: String = (0..all.len()) + .map(|i| { + if i < 6 { + format!("q 200 0 0 80 {} {} cm /Im{i} Do Q ", 6 + 200 * (i % 3), 690 - 100 * (i / 3)) + } else { + format!("q 1800 0 0 1440 0 0 cm /Im{i} Do Q ") + } + }) + .collect(); + page(&mut doc, &names.iter().map(String::as_str).zip(all).collect::>(), &content); + let converted = [icc_1, spot, mask_1, at_limit]; + let left = [icc_3, two_spots, mask_8, past_limit]; + let before = |doc: &Document, refs: &[ObjRef]| refs.iter().map(|r| stream(doc, *r).raw).collect::>(); + let (old, untouched) = (before(&doc, &converted), before(&doc, &left)); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(r.images_recompressed, converted.len(), "{r:?}"); + for i in converted { + assert_eq!(stream(&doc, i).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "{i:?}"); + } + assert_eq!(before(&doc, &left), untouched); + let new = before(&doc, &converted); + assert_eq!((r.image_bytes_before, r.image_bytes_after), (old.iter().map(|b| b.len()).sum(), new.iter().map(|b| b.len()).sum())); +} + +/// An image whose /DecodeParms are a reference, or hold numbers given by reference +/// (`/Predictor 9 0 R`, which `Dict::int` doesn't follow), is read through them, as one with them +/// direct: a predictor's tag bytes would otherwise shift every row, and the image would be stored +/// again wrong. Here a 1-bit image with a PNG predictor (stored again as Group 4), an eight-bit +/// grey one with a PNG predictor at 600 ppi (resampled), an eight-bit one of only black and white +/// with PNG predictor 10 (each row's tag a 0, so the shifted rows would still be black and white; +/// packed to one bit), and a two-entry indexed one the same way come out byte for byte as with +/// direct parameters. +#[test] +fn images_whose_decode_parms_are_references_come_out_as_with_them_direct() { + let build = |how: &str| { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let one_bit = mono_image(&mut doc, w, h, &bilevel(w, h, false), Some(15), &grey); + let black_and_white_samples = eight_bit(&bilevel(w, h, false), w, h); + let shades: Vec = (0..w * h).map(|i| ((i % w) * 255 / w) as u8 ^ ((i / w) as u8 & 0x0F)).collect(); + let params = pdfcraft_filters::Params { predictor: 15, colors: 1, bits_per_component: 8, columns: w as i64, early_change: 1 }; + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(15)); + parms.set(b"Columns".to_vec(), Object::Int(w as i64)); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::name("FlateDecode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let coded = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &shades).unwrap(); + let eight_bit = doc.add(Object::Stream(Stream::from_raw(d.clone(), coded))); + let params = pdfcraft_filters::Params { predictor: 10, ..params }; + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(10)); + parms.set(b"Columns".to_vec(), Object::Int(w as i64)); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let black_and_white = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &black_and_white_samples).unwrap(); + let packed = doc.add(Object::Stream(Stream::from_raw(d.clone(), black_and_white))); + let palette = vec![0, 0, 255, 255, 255, 255]; + d.set( + b"ColorSpace".to_vec(), + Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + 1.into(), + Object::String(pdfcraft_cos::PdfString::literal(palette)), + ]), + ); + let entries = samples_of(&bilevel(w, h, false), w, h, [&[0], &[1]], 8); + let entries = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &entries).unwrap(); + let indexed = doc.add(Object::Stream(Stream::from_raw(d, entries))); + for r in [one_bit, eight_bit, packed, indexed] { + let mut s = stream(&doc, r); + let Some(Object::Dict(mut parms)) = s.dict.get(b"DecodeParms").cloned() else { panic!("{:?}", s.dict) }; + let made = match how { + "whole" => Object::Ref(doc.add(Object::Dict(parms))), + "numbers" => { + for k in [&b"Predictor"[..], b"Columns"] { + let v = parms.get(k).cloned().unwrap(); + parms.set(k.to_vec(), Object::Ref(doc.add(v))); + } + Object::Dict(parms) + } + _ => continue, + }; + s.dict.set(b"DecodeParms".to_vec(), made); + doc.set(r, Object::Stream(s)); + } + // 300 × 120 pt is 288 ppi; 144 × 57.6 pt is 600 ppi. + page( + &mut doc, + &[("A", one_bit), ("B", eight_bit), ("C", packed), ("D", indexed)], + "q 300 0 0 120 20 600 cm /A Do Q q 144 0 0 57.6 20 400 cm /B Do Q q 300 0 0 120 20 200 cm /C Do Q \ + q 280 0 0 112 325 200 cm /D Do Q", + ); + let before = [one_bit, eight_bit, packed, indexed].map(|r| stream(&doc, r).raw); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (1, 3), "{r:?}"); + let after = [one_bit, eight_bit, packed, indexed].map(|r| stream(&doc, r)); + assert!(before.iter().zip(&after).all(|(b, a)| *b != a.raw), "all stored again"); + assert_eq!(after[0].dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..])); + for s in &after[2..] { + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), (Some(1), Some(&b"CCITTFaxDecode"[..]))); + } + after.map(|s| s.raw) + }; + let direct = build("direct"); + assert_eq!(build("whole"), direct, "the dictionary by reference"); + assert_eq!(build("numbers"), direct, "the numbers inside by reference"); +} + +/// An image whose data decodes far past what its samples can need (a decompression bomb) is left +/// alone rather than decoded up to the 1 GiB limit: a 1-bit one that would become Group 4, an +/// eight-bit one that would be resampled, and an eight-bit one of only black and white that would +/// be packed to one bit, each with four megabytes of zeros after its samples. Stored as ASCIIHex +/// text under Flate, broken into lines of 64 digits, their samples aren't a bomb. +#[test] +fn images_whose_data_decodes_far_past_their_samples_are_left_alone() { + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let shades: Vec = (0..w * h).map(|i| ((i % w) * 255 / w) as u8 ^ ((i / w) as u8 & 0x0F)).collect(); + for padding in [0, 4 << 20] { + let mut doc = Document::new_empty(); + let padded = |data: &[u8]| data.iter().copied().chain(std::iter::repeat_n(0, padding)).collect::>(); + let grey = |bits: i64| [("BitsPerComponent", bits.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let one_bit = mono_image(&mut doc, w, h, &padded(&text), None, &grey(1)); + let eight_bit = mono_image(&mut doc, w, h, &padded(&shades), None, &grey(8)); + let packed = mono_image(&mut doc, w, h, &padded(&self::eight_bit(&text, w, h)), None, &grey(8)); + // 300 × 120 pt is 288 ppi; 144 × 57.6 pt is 600 ppi. + page( + &mut doc, + &[("A", one_bit), ("B", eight_bit), ("C", packed)], + "q 300 0 0 120 20 600 cm /A Do Q q 144 0 0 57.6 20 400 cm /B Do Q q 300 0 0 120 20 200 cm /C Do Q", + ); + let before = [one_bit, eight_bit, packed].map(|r| stream(&doc, r).raw); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + let after = [one_bit, eight_bit, packed].map(|r| stream(&doc, r).raw); + if padding == 0 { + assert_eq!((r.images_resampled, r.images_recompressed), (1, 2), "{r:?}"); + } else { + assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}"); + assert_eq!(after, before); + } + } + let hex_lines = |data: &[u8]| { + let digits: Vec = data.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect(); + let mut text: Vec = digits.chunks(64).flat_map(|line| line.iter().copied().chain(*b"\n")).collect(); + text.push(b'>'); + pdfcraft_filters::encode_flate(&text) + }; + let mut doc = Document::new_empty(); + let as_hex = |doc: &mut Document, data: &[u8], bits: i64, (w, h): (u32, u32)| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(bits)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")])); + doc.add(Object::Stream(Stream::from_raw(d, hex_lines(data)))) + }; + // Large enough that its hex text in lines is more than twice its samples and a byte a row + // plus 4 KiB: 2560 × 1200, drawn at 400 ppi. + let page_scan = bilevel(2560, 1200, false); + let one_bit = as_hex(&mut doc, &page_scan, 1, (2560, 1200)); + let eight_bit = as_hex(&mut doc, &shades, 8, (w, h)); + let packed = as_hex(&mut doc, &self::eight_bit(&text, w, h), 8, (w, h)); + page( + &mut doc, + &[("A", one_bit), ("B", eight_bit), ("C", packed)], + "q 460.8 0 0 216 20 500 cm /A Do Q q 144 0 0 57.6 20 400 cm /B Do Q q 300 0 0 120 20 200 cm /C Do Q", + ); + optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(stream(&doc, one_bit).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "1-bit hex text in lines: Group 4"); + assert!(stream(&doc, eight_bit).dict.int(b"Width").is_some_and(|nw| nw < i64::from(w)), "8-bit hex text in lines: resampled"); + assert_eq!(stream(&doc, packed).dict.int(b"BitsPerComponent"), Some(1), "8-bit black-and-white hex text in lines: packed"); +} + +/// Monochrome Images keeping each image's compression (and resampling as by default). +const RETAIN: MonoSettings = MonoSettings { compression: MonoCompression::Retain, ..MonoSettings::REDUCE }; + +/// Eight-bit samples (0 or 255) from rows of one-bit ones. +fn eight_bit(bits: &[u8], w: u32, h: u32) -> Vec { + let stride = (w as usize).div_ceil(8); + (0..h as usize).flat_map(|y| (0..w as usize).map(move |x| if bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0 { 255 } else { 0 })).collect() +} + +/// Eight-bit samples of only 0 and 255, dithered (Floyd–Steinberg) from a smooth picture as a +/// scanner's halftone mode gives them: no shade but black and white, in short runs. +fn dithered(w: u32, h: u32) -> Vec { + let (w, h) = (w as usize, h as usize); + let mut level: Vec = (0..w * h).map(|i| 127.5 + 127.5 * ((i % w) as f32 / 37.0).sin() * ((i / w) as f32 / 53.0).cos()).collect(); + let mut out = vec![0u8; w * h]; + for y in 0..h { + for x in 0..w { + let i = y * w + x; + let v = if level[i] < 128.0 { 0.0 } else { 255.0 }; + out[i] = v as u8; + let e = level[i] - v; + if x + 1 < w { + level[i + 1] += e * 7.0 / 16.0; + } + if y + 1 < h { + if x > 0 { + level[i + w - 1] += e * 3.0 / 16.0; + } + level[i + w] += e * 5.0 / 16.0; + if x + 1 < w { + level[i + w + 1] += e / 16.0; + } + } + } + } + out +} + +/// A dithered scan stored as eight-bit grey of only black and white is smaller downsampled and +/// JPEG-compressed by the grey settings than packed to one bit, which can't shrink its dots: the +/// smaller wins, so Reduce File Size makes it no larger than with Monochrome Images on Retain. +/// Clean text is still packed to one bit at the same resolution. +#[test] +fn eight_bit_black_and_white_takes_the_smaller_of_one_bit_and_the_grey_settings() { + let (w, h) = (1200u32, 1500u32); + for (what, samples) in [("dither", dithered(w, h)), ("text", eight_bit(&bilevel(w, h, false), w, h))] { + let build = || { + let mut doc = Document::new_empty(); + let img = mono_image(&mut doc, w, h, &samples, None, &[("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray"))]); + // 288 × 360 pt is 300 ppi, above where the grey settings downsample. + page(&mut doc, &[("A", img)], "q 288 0 0 360 20 400 cm /A Do Q"); + (doc, img) + }; + let (mut doc, img) = build(); + optimize(&mut doc, &Settings::default()).unwrap(); + let (mut retained, _) = build(); + optimize(&mut retained, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + let (s, kept) = (stream(&doc, img), stream(&retained, img)); + let bits = if what == "dither" { 8 } else { 1 }; + assert_eq!(s.dict.int(b"BitsPerComponent"), Some(bits), "{what}: {} bytes, {} with Retain", s.raw.len(), kept.raw.len()); + if what == "dither" { + assert!(s.raw.len() <= kept.raw.len(), "{what}: {} bytes, {} with Retain", s.raw.len(), kept.raw.len()); + } + } +} + +/// The smaller of one bit and the grey settings counts the soft mask each leaves: packed, the +/// image keeps its mask as it is; resampled by the grey settings, the mask is resampled with it. +/// Clean text, 1200 × 1500 at 300 ppi, under a noisy mask. A mask as large: the grey settings +/// shrink it by far more than packing saves on the image, and win. A mask of 640 × 800 (a soft +/// mask may be another size than its image): resampled to the image's new size it shrinks only a +/// little, by less than the grey settings' JPEG adds over the packed image, and packing wins. +#[test] +fn eight_bit_black_and_white_with_a_soft_mask_counts_the_mask() { + let (w, h) = (1200u32, 1500u32); + for ((mw, mh), grey_wins) in [((w, h), true), ((640, 800), false)] { + let mut doc = Document::new_empty(); + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let noise: Vec = (0..mw * mh) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 24) as u8 + }) + .collect(); + let mask = mono_image(&mut doc, mw, mh, &noise, None, &[("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let grey = [("BitsPerComponent", 8.into()), ("ColorSpace", Object::name("DeviceGray")), ("SMask", Object::Ref(mask))]; + let img = mono_image(&mut doc, w, h, &eight_bit(&bilevel(w, h, false), w, h), None, &grey); + page(&mut doc, &[("A", img)], "q 288 0 0 360 20 400 cm /A Do Q"); + optimize(&mut doc, &Settings::default()).unwrap(); + let s = stream(&doc, img); + let expected = if grey_wins { (Some(8), Some(&b"DCTDecode"[..])) } else { (Some(1), Some(&b"CCITTFaxDecode"[..])) }; + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), expected, "mask {mw} × {mh}: {:?}", s.dict); + } +} + +/// Eight-bit grey images of only black and white (as some scanners and converters write scans) +/// become one-bit Group 4, losslessly: the page looks the same, an inverting decode array +/// included. Not touched by this: a grey image with any other shade, an indexed image (its samples +/// are palette indices), one masked by colour key, and everything with `mono: Retain`. +#[test] +fn eight_bit_black_and_white_images_become_one_bit() { + let mut doc = Document::new_empty(); + let (w, h) = (1200, 480); + let grey = eight_bit(&bilevel(w, h, false), w, h); + let image = |doc: &mut Document, data: &[u8], entries: &[(&str, Object)]| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Stream(Stream::flate(d, data))) + }; + let gray_cs = ("ColorSpace", Object::name("DeviceGray")); + let plain = image(&mut doc, &grey, std::slice::from_ref(&gray_cs)); + let decode = Object::Array(vec![1.into(), 0.into()]); + let inverted = image(&mut doc, &grey, &[gray_cs.clone(), ("Decode", decode.clone())]); + let mut shaded = grey.clone(); + shaded[1000] = 128; + let shade = image(&mut doc, &shaded, std::slice::from_ref(&gray_cs)); + let palette = Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceGray"), + 255.into(), + Object::String(pdfcraft_cos::PdfString::literal((0..=255u8).rev().collect::>())), + ]); + let indexed = image(&mut doc, &grey, &[("ColorSpace", palette)]); + let keyed = image(&mut doc, &grey, &[gray_cs, ("Mask", Object::Array(vec![0.into(), 0.into()]))]); + page( + &mut doc, + &[("A", plain), ("B", inverted), ("C", shade), ("D", indexed), ("E", keyed)], + "q 300 0 0 120 20 660 cm /A Do Q q 300 0 0 120 320 660 cm /B Do Q q 300 0 0 120 20 520 cm /C Do Q \ + q 300 0 0 120 320 520 cm /D Do Q q 300 0 0 120 20 380 cm /E Do Q", + ); + let before = render(&doc); + let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; + // Grey settings that leave grey images alone, so only this conversion acts. + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + let settings = Settings { gray: keep, ..Settings::default() }; + + let mut kept = doc.clone(); + let r = optimize(&mut kept, &Settings { mono: RETAIN, ..settings.clone() }).unwrap(); + assert_eq!(r.images_recompressed, 0, "Retain keeps every image as it is"); + + let untouched = [shade, indexed, keyed].map(|r| raw(&doc, r)); + let old: usize = [plain, inverted].map(|r| raw(&doc, r).len()).iter().sum(); + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!(r.images_recompressed, 2); + let new: usize = [plain, inverted].map(|r| raw(&doc, r).len()).iter().sum(); + assert_eq!((r.image_bytes_before, r.image_bytes_after), (old, new), "the byte totals count both"); + for i in [plain, inverted] { + let s = stream(&doc, i); + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter")), (Some(1), Some(&b"CCITTFaxDecode"[..]))); + } + assert_eq!(stream(&doc, inverted).dict.get(b"Decode"), Some(&decode), "the decode array stays"); + assert_eq!([shade, indexed, keyed].map(|r| raw(&doc, r)), untouched); + assert!(render(&doc) == before, "the page looks the same"); +} + +/// `bits` (1-bit rows) with each 0 as `values[0]` and each 1 as `values[1]`, at `bpc` bits a sample +/// (each row starting on a new byte). +fn samples_of(bits: &[u8], w: u32, h: u32, values: [&[u8]; 2], bpc: usize) -> Vec { + let (w, h) = (w as usize, h as usize); + let stride = w.div_ceil(8); + let mut out = Vec::new(); + for y in 0..h { + let row: Vec = (0..w).flat_map(|x| values[usize::from(bits[y * stride + x / 8] & (0x80 >> (x % 8)) != 0)].iter().copied()).collect(); + let mut packed = vec![0u8; (row.len() * bpc).div_ceil(8)]; + for (i, s) in row.iter().enumerate() { + packed[i * bpc / 8] |= if bpc == 8 { *s } else { s << (8 - bpc - i * bpc % 8) }; + } + out.extend(packed); + } + out +} + +/// Eight-bit RGB of only black and white becomes one-bit grey, and an indexed image that uses only +/// palette entries 0 and 1 becomes one bit with its palette (here blue and white), at 8 bits or 4, +/// both as Group 4 and losslessly: the page looks the same to the pixel. An RGB decode array the +/// same for each component becomes grey's, and an indexed image's default one goes. Not touched: +/// RGB with any other colour, a decode array that differs between components, a colour-key mask or +/// calibrated RGB; an indexed image that uses a third entry or has another decode array; and +/// everything with `mono: Retain`. +#[test] +fn rgb_and_two_colour_indexed_black_and_white_images_become_one_bit() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let image = |doc: &mut Document, data: &[u8], bpc: i64, cs: Object, entries: &[(&str, Object)]| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(bpc)); + d.set(b"ColorSpace".to_vec(), cs); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Stream(Stream::flate(d, data))) + }; + let rgb_cs = Object::name("DeviceRGB"); + let rgb_data = samples_of(&text, w, h, [&[0, 0, 0], &[255, 255, 255]], 8); + let rgb = image(&mut doc, &rgb_data, 8, rgb_cs.clone(), &[]); + let pairs = |p: [i64; 6]| Object::Array(p.into_iter().map(Object::Int).collect()); + let rgb_inverted = image(&mut doc, &rgb_data, 8, rgb_cs.clone(), &[("Decode", pairs([1, 0, 1, 0, 1, 0]))]); + let rgb_mixed = image(&mut doc, &rgb_data, 8, rgb_cs.clone(), &[("Decode", pairs([1, 0, 0, 1, 0, 1]))]); + let mut red = rgb_data.clone(); + red[3000..3003].copy_from_slice(&[255, 0, 0]); + let rgb_red = image(&mut doc, &red, 8, rgb_cs.clone(), &[]); + let mut white_point = Dict::new(); + white_point.set(b"WhitePoint".to_vec(), Object::Array(vec![Object::Real(0.9505), 1.into(), Object::Real(1.089)])); + let cal_rgb = image(&mut doc, &rgb_data, 8, Object::Array(vec![Object::name("CalRGB"), Object::Dict(white_point)]), &[]); + let rgb_keyed = image(&mut doc, &rgb_data, 8, rgb_cs, &[("Mask", pairs([0, 0, 0, 0, 0, 0]))]); + let palette = |entries: &[u8]| { + Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + (entries.len() as i64 / 3 - 1).into(), + Object::String(pdfcraft_cos::PdfString::literal(entries.to_vec())), + ]) + }; + let blue_white = palette(&[0, 0, 255, 255, 255, 255]); + let indices = samples_of(&text, w, h, [&[0], &[1]], 8); + let blue = image(&mut doc, &indices, 8, blue_white.clone(), &[]); + let default_decode = Object::Array(vec![0.into(), 255.into()]); + let blue_decoded = image(&mut doc, &indices, 8, blue_white.clone(), &[("Decode", default_decode)]); + let blue_4 = image(&mut doc, &samples_of(&text, w, h, [&[0], &[1]], 4), 4, blue_white.clone(), &[]); + let mut third = indices.clone(); + third[3000] = 2; + let three = image(&mut doc, &third, 8, palette(&[0, 0, 255, 255, 255, 255, 255, 0, 0]), &[]); + let other_decode = image(&mut doc, &indices, 8, blue_white.clone(), &[("Decode", Object::Array(vec![0.into(), 1.into()]))]); + let all = [rgb, rgb_inverted, rgb_mixed, rgb_red, cal_rgb, rgb_keyed, blue, blue_decoded, blue_4, three, other_decode]; + // 200 × 80 pt is 432 ppi: below where monochrome downsampling starts. + let names: Vec = (0..all.len()).map(|i| format!("Im{i}")).collect(); + let content: String = + (0..all.len()).map(|i| format!("q 200 0 0 80 {} {} cm /Im{i} Do Q ", 6 + 200 * (i % 3), 690 - 100 * (i / 3) as i64)).collect(); + page(&mut doc, &names.iter().map(String::as_str).zip(all).collect::>(), &content); + let before = render(&doc); + let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; + // Colour and grey settings that leave images alone, so only this conversion acts. + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + let settings = Settings { color: keep, gray: keep, ..Settings::default() }; + + let mut kept = doc.clone(); + let r = optimize(&mut kept, &Settings { mono: RETAIN, ..settings.clone() }).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "Retain keeps every image as it is"); + + let untouched = [rgb_mixed, rgb_red, cal_rgb, rgb_keyed, three, other_decode].map(|r| raw(&doc, r)); + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 5), "{r:?}"); + for (i, cs) in [ + (rgb, Object::name("DeviceGray")), + (rgb_inverted, Object::name("DeviceGray")), + (blue, blue_white.clone()), + (blue_decoded, blue_white.clone()), + (blue_4, blue_white), + ] { + let s = stream(&doc, i); + assert_eq!( + (s.dict.int(b"BitsPerComponent"), s.dict.name(b"Filter"), s.dict.get(b"ColorSpace")), + (Some(1), Some(&b"CCITTFaxDecode"[..]), Some(&cs)), + "{i:?}" + ); + assert_eq!(doc.decoded_image(&s).unwrap(), text, "{i:?}"); + let decode = if i == rgb_inverted { Some(Object::Array(vec![1.into(), 0.into()])) } else { None }; + assert_eq!(s.dict.get(b"Decode").cloned(), decode, "{i:?}"); + } + assert_eq!([rgb_mixed, rgb_red, cal_rgb, rgb_keyed, three, other_decode].map(|r| raw(&doc, r)), untouched); + assert!(render(&doc) == before, "the page looks the same"); +} + +/// Eight-bit black-and-white images in other one-component spaces are packed too: calibrated +/// grey, an ICC profile of one component, a separation, each keeping its colour space. Calibrated +/// RGB whose samples are only 0 and 1 is no indexed image, and stays as it is. +#[test] +fn eight_bit_black_and_white_in_other_one_component_spaces_is_packed() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let image = |doc: &mut Document, data: &[u8], cs: Object| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), cs); + doc.add(Object::Stream(Stream::flate(d, data))) + }; + let mut white_point = Dict::new(); + white_point.set(b"WhitePoint".to_vec(), Object::Array(vec![Object::Real(0.9505), 1.into(), Object::Real(1.089)])); + let mut profile = Dict::new(); + profile.set(b"N".to_vec(), Object::Int(1)); + let profile = doc.add(Object::Stream(Stream::from_raw(profile, Vec::new()))); + let mut tint = Dict::new(); + tint.set(b"FunctionType".to_vec(), Object::Int(2)); + tint.set(b"Domain".to_vec(), Object::Array(vec![0.into(), 1.into()])); + tint.set(b"N".to_vec(), Object::Int(1)); + let spaces = [ + Object::Array(vec![Object::name("CalGray"), Object::Dict(white_point.clone())]), + Object::Array(vec![Object::name("ICCBased"), Object::Ref(profile)]), + Object::Array(vec![Object::name("Separation"), Object::name("Spot"), Object::name("DeviceGray"), Object::Dict(tint)]), + ]; + let grey = samples_of(&text, w, h, [&[0], &[255]], 8); + let packed = spaces.clone().map(|cs| image(&mut doc, &grey, cs)); + let cal_rgb = Object::Array(vec![Object::name("CalRGB"), Object::Dict(white_point)]); + let cal_rgb = image(&mut doc, &samples_of(&text, w, h, [&[0, 0, 0], &[1, 1, 1]], 8), cal_rgb); + let names = ["A", "B", "C", "D"]; + // 200 × 80 pt is 432 ppi: below where monochrome downsampling starts. + let content: String = (0..4).map(|i| format!("q 200 0 0 80 6 {} cm /{} Do Q ", 690 - 100 * i, names[i])).collect(); + page(&mut doc, &names.into_iter().zip([packed[0], packed[1], packed[2], cal_rgb]).collect::>(), &content); + let untouched = stream(&doc, cal_rgb).raw; + // Colour and grey settings that leave images alone, so only the packing acts. + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + optimize(&mut doc, &Settings { color: keep, gray: keep, ..Settings::default() }).unwrap(); + for (r, cs) in packed.iter().zip(&spaces) { + let s = stream(&doc, *r); + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.get(b"ColorSpace")), (Some(1), Some(cs)), "{cs:?}"); + } + assert_eq!(stream(&doc, cal_rgb).raw, untouched, "calibrated RGB"); +} + +/// A resampled Group 4 image is stored as 1-bit Flate when that is smaller, as any resampled image +/// may be: here vertical stripes, whose identical rows Flate takes for almost nothing. +#[test] +fn a_resampled_group_4_image_is_stored_as_flate_when_smaller() { + let (w, h) = (1200u32, 480u32); + let stripes = vec![0x0Fu8; (w as usize).div_ceil(8) * h as usize]; + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let img = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_g4(&stripes, w, h).unwrap()))); + // 144 × 57.6 pt is 600 ppi. + page(&mut doc, &[("A", img)], "q 144 0 0 57.6 20 600 cm /A Do Q"); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(r.images_resampled, 1, "{r:?}"); + let s = stream(&doc, img); + assert_eq!((s.dict.int(b"Width"), s.dict.name(b"Filter")), (Some(600), Some(&b"FlateDecode"[..]))); +} + +/// A decode array is read as numbers, whichever way they are written: an indexed image's default +/// as `[0 255.0]` or with an entry by reference, and RGB's inverting pairs with a real among them, +/// pack as when written as integers. +#[test] +fn decode_arrays_read_as_numbers_however_written() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let image = |doc: &mut Document, data: &[u8], cs: Object, decode: Object| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), cs); + d.set(b"Decode".to_vec(), decode); + doc.add(Object::Stream(Stream::flate(d, data))) + }; + let palette = Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + 1.into(), + Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 255, 255, 255, 255])), + ]); + let indices = samples_of(&text, w, h, [&[0], &[1]], 8); + let max = doc.add(Object::Int(255)); + let real = image(&mut doc, &indices, palette.clone(), Object::Array(vec![0.into(), Object::Real(255.0)])); + let referred = image(&mut doc, &indices, palette, Object::Array(vec![0.into(), Object::Ref(max)])); + let rgb = samples_of(&text, w, h, [&[0, 0, 0], &[255, 255, 255]], 8); + let pairs = Object::Array(vec![1.into(), 0.into(), 1.into(), 0.into(), Object::Real(1.0), 0.into()]); + let inverted = image(&mut doc, &rgb, Object::name("DeviceRGB"), pairs); + page( + &mut doc, + &[("A", real), ("B", referred), ("C", inverted)], + "q 200 0 0 80 6 690 cm /A Do Q q 200 0 0 80 6 590 cm /B Do Q q 200 0 0 80 6 490 cm /C Do Q", + ); + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + optimize(&mut doc, &Settings { color: keep, gray: keep, ..Settings::default() }).unwrap(); + for r in [real, referred] { + let s = stream(&doc, r); + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.get(b"Decode")), (Some(1), None), "{r:?}"); + } + let s = stream(&doc, inverted); + let decode = Object::Array(vec![1.into(), 0.into()]); + assert_eq!((s.dict.int(b"BitsPerComponent"), s.dict.get(b"Decode")), (Some(1), Some(&decode))); +} + +/// With Retain, a Group 3 halftone above 450 ppi, which isn't resampled, stays Group 3 byte for +/// byte: Retain stores nothing again that it doesn't resample. +#[test] +fn retain_keeps_a_group_3_halftone_as_it_is() { + let (w, h) = (64u32, 64u32); + let a: Vec = vec![1; 64]; + let b: Vec = std::iter::once(0).chain(std::iter::repeat_n(1, 64)).collect(); + let runs: Vec<&[u32]> = (0..h).map(|y| if y % 2 == 0 { &a[..] } else { &b[..] }).collect(); + let g3 = group_3(&runs, false, None); + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(0)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let fax = doc.add(Object::Stream(Stream::from_raw(d, g3.clone()))); + // 7.68 pt for 64 samples is 600 ppi. + page(&mut doc, &[("A", fax)], "q 7.68 0 0 7.68 100 600 cm /A Do Q"); + assert_eq!(doc.decoded_image(&stream(&doc, fax)).unwrap().len(), 64 * 8, "the fixture decodes"); + let r = optimize(&mut doc, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}"); + assert_eq!(stream(&doc, fax).raw[..], g3[..]); +} + +/// Black-and-white RGB stays RGB where grey would be drawn otherwise: drawn, on any of its uses, +/// where `/DefaultGray` or `/DefaultRGB` is in force (ISO 32000-2 §8.6.5.6), from a page's +/// resources or a form's own, as grey would then go through DefaultGray, or no longer through +/// DefaultRGB; or with a soft mask whose `/Matte` has three components, one for each of RGB's. A +/// form drawn where they are in force counts too, though it has resources of its own. The same +/// image drawn plainly, or with a soft mask and no `/Matte`, becomes grey. +#[test] +fn black_and_white_rgb_stays_rgb_where_grey_would_be_drawn_otherwise() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let rgb_data = samples_of(&bilevel(w, h, false), w, h, [&[0, 0, 0], &[255, 255, 255]], 8); + let image = |doc: &mut Document, cs: &str, data: &[u8], entries: &[(&str, Object)]| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name(cs)); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Stream(Stream::flate(d, data))) + }; + let opaque = vec![255u8; (w * h) as usize]; + let plain_mask = image(&mut doc, "DeviceGray", &opaque, &[]); + let matte_mask = image(&mut doc, "DeviceGray", &opaque, &[("Matte", Object::Array(vec![0.into(); 3]))]); + let plain = image(&mut doc, "DeviceRGB", &rgb_data, &[]); + let masked = image(&mut doc, "DeviceRGB", &rgb_data, &[("SMask", Object::Ref(plain_mask))]); + let matted = image(&mut doc, "DeviceRGB", &rgb_data, &[("SMask", Object::Ref(matte_mask))]); + let [under_gray, under_rgb, shared, in_form, nested] = [(); 5].map(|_| image(&mut doc, "DeviceRGB", &rgb_data, &[])); + let mut white_point = Dict::new(); + white_point.set(b"WhitePoint".to_vec(), Object::Array(vec![Object::Real(0.9505), 1.into(), Object::Real(1.089)])); + let spaces = |key: &str| { + let family = if key == "DefaultGray" { "CalGray" } else { "CalRGB" }; + let mut c = Dict::new(); + c.set(key.as_bytes().to_vec(), Object::Array(vec![Object::name(family), Object::Dict(white_point.clone())])); + Object::Dict(c) + }; + let draw = |names: &[&str]| names.iter().enumerate().map(|(i, n)| format!("q 200 0 0 80 6 {} cm /{n} Do Q ", 690 - 100 * i)).collect::(); + let form = |doc: &mut Document, im: ObjRef, spaces: Option| { + let mut xobjects = Dict::new(); + xobjects.set(b"Im".to_vec(), Object::Ref(im)); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + if let Some(spaces) = spaces { + resources.set(b"ColorSpace".to_vec(), spaces); + } + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("XObject")); + f.set(b"Subtype".to_vec(), Object::name("Form")); + f.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + f.set(b"Resources".to_vec(), Object::Dict(resources)); + doc.add(Object::Stream(Stream::from_raw(f, draw(&["Im"]).into_bytes()))) + }; + // Drawn plainly first, then where DefaultGray is in force: the second walk must happen. + let plain_form = form(&mut doc, nested, None); + page(&mut doc, &[("A", plain), ("B", masked), ("C", matted), ("D", shared), ("E", plain_form)], &draw(&["A", "B", "C", "D", "E"])); + for (key, xobjects) in [("DefaultGray", vec![("A", under_gray), ("B", shared), ("C", plain_form)]), ("DefaultRGB", vec![("A", under_rgb)])] { + let names: Vec<&str> = xobjects.iter().map(|(n, _)| *n).collect(); + let p = page(&mut doc, &xobjects, &draw(&names)); + doc.update_dict(p, |d| { + let mut res = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap(); + res.set(b"ColorSpace".to_vec(), spaces(key)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + }) + .unwrap(); + } + // A form with its own resources, DefaultGray among them, on a page without any. + let gray_form = form(&mut doc, in_form, Some(spaces("DefaultGray"))); + page(&mut doc, &[("F", gray_form)], "/F Do"); + let kept = [under_gray, under_rgb, shared, in_form, nested, matted]; + let before = kept.map(|r| stream(&doc, r)); + // Colour and grey settings that leave images alone, so only this conversion acts. + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + let settings = Settings { color: keep, gray: keep, ..Settings::default() }; + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 2), "{r:?}"); + for i in [plain, masked] { + let s = stream(&doc, i); + assert_eq!((s.dict.get(b"ColorSpace"), s.dict.int(b"BitsPerComponent")), (Some(&Object::name("DeviceGray")), Some(1)), "{i:?}"); + } + for (i, b) in kept.iter().zip(&before) { + let s = stream(&doc, *i); + assert_eq!((&s.dict, &s.raw), (&b.dict, &b.raw), "{i:?}"); + } + + // A use that couldn't be followed (forms nested too deep) may be where DefaultGray is in force. + let mut doc = Document::new_empty(); + let img = image(&mut doc, "DeviceRGB", &rgb_data, &[]); + let deep = nested_forms(&mut doc, img, 13); + page(&mut doc, &[("Im", img), ("F", deep)], &draw(&["Im", "F"])); + let before = stream(&doc, img); + optimize(&mut doc, &settings).unwrap(); + assert_eq!(stream(&doc, img).raw, before.raw, "a use not followed"); +} + +/// An empty document, and a maker of images of only black and white as eight-bit `DeviceRGB`. +fn rgb_page_fixture() -> (Document, impl Fn(&mut Document) -> ObjRef) { + let (w, h) = (1200u32, 480u32); + let rgb_data = samples_of(&bilevel(w, h, false), w, h, [&[0, 0, 0], &[255, 255, 255]], 8); + let image = move |doc: &mut Document| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + doc.add(Object::Stream(Stream::flate(d, &rgb_data))) + }; + (Document::new_empty(), image) +} + +/// `q … cm /N Do Q` for each name, one under the other. +fn draw_each(names: &[&str]) -> String { + names.iter().enumerate().map(|(i, n)| format!("q 200 0 0 80 6 {} cm /{n} Do Q ", 690 - 100 * i)).collect() +} + +/// An `/XObject` dictionary of `names`. +fn xobject_dict(names: &[(&str, ObjRef)]) -> Dict { + let mut x = Dict::new(); + for (n, r) in names { + x.set(n.as_bytes().to_vec(), Object::Ref(*r)); + } + x +} + +/// A stream drawing `/Im` (or nothing) whose `/Resources` is `resources`, with `extra` in its +/// dictionary: a form, an appearance or a tiling pattern. +fn drawing(doc: &mut Document, resources: Option, extra: &[(&str, Object)]) -> ObjRef { + let mut f = Dict::new(); + f.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + if let Some(resources) = resources { + f.set(b"Resources".to_vec(), resources); + } + for (k, v) in extra { + f.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Stream(Stream::from_raw(f, draw_each(&["Im"]).into_bytes()))) +} + +fn form_entries() -> [(&'static str, Object); 2] { + [("Type", Object::name("XObject")), ("Subtype", Object::name("Form"))] +} + +/// Resources of `entries`. +fn resources_of(entries: Vec<(&str, Object)>) -> Dict { + let mut res = Dict::new(); + for (k, v) in entries { + res.set(k.as_bytes().to_vec(), v); + } + res +} + +/// Give page `p` an annotation whose normal appearance is `appearance`. +fn annotate(doc: &mut Document, p: ObjRef, appearance: ObjRef) { + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Ref(appearance)); + let mut annotation = Dict::new(); + annotation.set(b"Subtype".to_vec(), Object::name("Square")); + annotation.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 100.into(), 100.into()])); + annotation.set(b"AP".to_vec(), Object::Dict(ap)); + let annotation = doc.add(Object::Dict(annotation)); + let mut annots = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").and_then(|a| a.as_array().cloned())).unwrap_or_default(); + annots.push(Object::Ref(annotation)); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(annots))).unwrap(); +} + +/// `/DefaultGray` as a calibrated grey, in a `/ColorSpace` resource dictionary. +fn default_gray() -> Object { + let mut white_point = Dict::new(); + white_point.set(b"WhitePoint".to_vec(), Object::Array(vec![Object::Real(0.9505), 1.into(), Object::Real(1.089)])); + let mut c = Dict::new(); + c.set(b"DefaultGray".to_vec(), Object::Array(vec![Object::name("CalGray"), Object::Dict(white_point)])); + Object::Dict(c) +} + +/// Optimize with colour and grey settings that leave images alone, so only packing acts; the +/// report's images resampled and stored again. +fn pack_only(doc: &mut Document) -> (usize, usize) { + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + let r = optimize(doc, &Settings { color: keep, gray: keep, ..Settings::default() }).unwrap(); + (r.images_resampled, r.images_recompressed) +} + +/// Black-and-white RGB becomes grey for every use at once, so it stays RGB, byte for byte, when +/// any use may be beyond the page content and forms the optimizer reads, where other default +/// colour spaces may be in force: drawn by an annotation's appearance whose resources make +/// `/DefaultGray` the grey it would be drawn in; by a tiling pattern; through an `/XObject` +/// dictionary, or a form, that a page shares with such an appearance; +/// referred to with another generation by the page than by such an appearance; drawn plainly on +/// one page and, named with another generation, on a page whose resources set `/DefaultGray`; +/// drawn plainly and only listed (not drawn) where `/DefaultGray` is in force, under the same +/// generation or another, as a Type3 glyph without resources could draw it there; or held in an +/// array elsewhere (another image's `/Alternates`). +#[test] +fn black_and_white_rgb_used_beyond_the_page_content_read_stays_rgb() { + let (mut doc, image) = rgb_page_fixture(); + let [in_annotation, in_pattern, shared_xobjects, shared_form, other_generation, generation_under_defaults, listed, listed_as_1, in_array] = + [(); 9].map(|_| image(&mut doc)); + let gray_resources = |im: Object| Some(Object::Dict(resources_of(vec![("XObject", im), ("ColorSpace", default_gray())]))); + let direct = |im: ObjRef| Object::Dict(xobject_dict(&[("Im", im)])); + + let appearance = drawing(&mut doc, gray_resources(direct(in_annotation)), &form_entries()); + let pattern_entries = + [("PatternType", 1.into()), ("PaintType", 1.into()), ("TilingType", 1.into()), ("XStep", 612.into()), ("YStep", 792.into())]; + let pattern = drawing(&mut doc, Some(Object::Dict(resources_of(vec![("XObject", direct(in_pattern))]))), &pattern_entries); + let p = page(&mut doc, &[("A", in_annotation), ("B", in_pattern)], &(draw_each(&["A", "B"]) + "/Pattern cs /P scn 0 0 10 10 re f")); + annotate(&mut doc, p, appearance); + doc.update_dict(p, |d| { + let mut res = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap(); + let mut patterns = Dict::new(); + patterns.set(b"P".to_vec(), Object::Ref(pattern)); + res.set(b"Pattern".to_vec(), Object::Dict(patterns)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + }) + .unwrap(); + + // An /XObject dictionary of its own, read for a page, and a form drawn by a page, each also + // used by an appearance under DefaultGray. + let x = doc.add(Object::Dict(xobject_dict(&[("Im", shared_xobjects)]))); + let q = page(&mut doc, &[], &draw_each(&["Im"])); + doc.update_dict(q, |d| d.set(b"Resources".to_vec(), Object::Dict(resources_of(vec![("XObject", Object::Ref(x))])))).unwrap(); + let appearance = drawing(&mut doc, gray_resources(Object::Ref(x)), &form_entries()); + annotate(&mut doc, q, appearance); + let form = drawing(&mut doc, Some(Object::Dict(resources_of(vec![("XObject", direct(shared_form))]))), &form_entries()); + let q = page(&mut doc, &[("F", form)], &draw_each(&["F"])); + let appearance = drawing(&mut doc, gray_resources(Object::Dict(xobject_dict(&[("Im", form)]))), &form_entries()); + annotate(&mut doc, q, appearance); + + // The page names it with generation 1 (it is 0); the appearance with 0. + let q = page(&mut doc, &[("Im", ObjRef { generation: 1, ..other_generation })], &draw_each(&["Im"])); + let appearance = drawing(&mut doc, gray_resources(direct(other_generation)), &form_entries()); + annotate(&mut doc, q, appearance); + + // Drawn plainly as 0, and as 1 where DefaultGray is in force. + page(&mut doc, &[("Im", generation_under_defaults)], &draw_each(&["Im"])); + let q = page(&mut doc, &[], &draw_each(&["Im"])); + let gray = resources_of(vec![ + ("XObject", Object::Dict(xobject_dict(&[("Im", ObjRef { generation: 1, ..generation_under_defaults })]))), + ("ColorSpace", default_gray()), + ]); + doc.update_dict(q, |d| d.set(b"Resources".to_vec(), Object::Dict(gray))).unwrap(); + + // Drawn plainly, and only listed where DefaultGray is in force, as 0 and as 1. + page(&mut doc, &[("A", listed), ("B", listed_as_1)], &draw_each(&["A", "B"])); + let q = page(&mut doc, &[], ""); + let gray = resources_of(vec![ + ("XObject", Object::Dict(xobject_dict(&[("A", listed), ("B", ObjRef { generation: 1, ..listed_as_1 })]))), + ("ColorSpace", default_gray()), + ]); + doc.update_dict(q, |d| d.set(b"Resources".to_vec(), Object::Dict(gray))).unwrap(); + + // Another image drawn on the page lists it as an alternate. + let mut alternate = Dict::new(); + alternate.set(b"Image".to_vec(), Object::Ref(in_array)); + let holder = image(&mut doc); + doc.update_dict(holder, |d| { + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Alternates".to_vec(), Object::Array(vec![Object::Dict(alternate)])); + }) + .unwrap(); + page(&mut doc, &[("Im", in_array), ("Alt", holder)], &draw_each(&["Im"])); + + let kept = [in_annotation, in_pattern, shared_xobjects, shared_form, other_generation, generation_under_defaults, listed, listed_as_1, in_array]; + let before = kept.map(|r| stream(&doc, r)); + pack_only(&mut doc); + for (i, b) in kept.iter().zip(&before) { + let s = stream(&doc, *i); + assert_eq!((&s.dict, &s.raw), (&b.dict, &b.raw), "{i:?}"); + } +} + +/// Black-and-white RGB that the page content and forms the optimizer reads are the only uses of +/// becomes grey, however its resources are written: drawn on two pages, by a form, under two +/// names on one page; through resources that are an object of their own, shared by two pages, +/// whose `/XObject` dictionary is one too; through resources an object of their own with a direct +/// `/XObject` dictionary; through resources a page inherits from the page tree; by a form whose +/// `/Resources` is a reference; by a form without resources, through its page's; listed under a +/// second name nothing draws; through resources an object of their own that a page without +/// content lists too; and with an object nothing in the document reaches referring to it as well. +#[test] +fn black_and_white_rgb_drawn_only_where_the_optimizer_reads_becomes_grey() { + let (mut doc, image) = rgb_page_fixture(); + let [everywhere, shared_resources, resources_object, inherited, form_resources_object, no_resources, spare_name, no_contents, unreachable] = + [(); 9].map(|_| image(&mut doc)); + let set_resources = |doc: &mut Document, q: ObjRef, res: Object| doc.update_dict(q, |d| d.set(b"Resources".to_vec(), res)).unwrap(); + + let form = + drawing(&mut doc, Some(Object::Dict(resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", everywhere)])))]))), &form_entries()); + page(&mut doc, &[("E", everywhere), ("F", form)], &draw_each(&["E", "F"])); + page(&mut doc, &[("E", everywhere), ("G", everywhere)], &draw_each(&["E", "G"])); + + let x = doc.add(Object::Dict(xobject_dict(&[("Im", shared_resources)]))); + let res = doc.add(Object::Dict(resources_of(vec![("XObject", Object::Ref(x))]))); + for _ in 0..2 { + let q = page(&mut doc, &[], &draw_each(&["Im"])); + set_resources(&mut doc, q, Object::Ref(res)); + } + let res = doc.add(Object::Dict(resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", resources_object)])))]))); + let q = page(&mut doc, &[], &draw_each(&["Im"])); + set_resources(&mut doc, q, Object::Ref(res)); + + let q = page(&mut doc, &[], &draw_each(&["I"])); + doc.update_dict(q, |d| { + d.remove(b"Resources"); + }) + .unwrap(); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + set_resources(&mut doc, pages, Object::Dict(resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("I", inherited)])))]))); + + let res = doc.add(Object::Dict(resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", form_resources_object)])))]))); + let form = drawing(&mut doc, Some(Object::Ref(res)), &form_entries()); + page(&mut doc, &[("F", form)], &draw_each(&["F"])); + let form = drawing(&mut doc, None, &form_entries()); + page(&mut doc, &[("F", form), ("Im", no_resources)], &draw_each(&["F"])); + + page(&mut doc, &[("C", spare_name), ("D", spare_name)], &draw_each(&["C"])); + let res = doc.add(Object::Dict(resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", no_contents)])))]))); + for drawn in [true, false] { + let q = page(&mut doc, &[], &draw_each(&["Im"])); + set_resources(&mut doc, q, Object::Ref(res)); + if !drawn { + doc.update_dict(q, |d| { + d.remove(b"Contents"); + }) + .unwrap(); + } + } + + page(&mut doc, &[("Im", unreachable)], &draw_each(&["Im"])); + // A form nothing refers to, drawing it under DefaultGray. + let orphan = resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", unreachable)]))), ("ColorSpace", default_gray())]); + drawing(&mut doc, Some(Object::Dict(orphan)), &form_entries()); + + let grey = [everywhere, shared_resources, resources_object, inherited, form_resources_object, no_resources, spare_name, no_contents, unreachable]; + assert_eq!(pack_only(&mut doc), (0, grey.len())); + for i in grey { + let s = stream(&doc, i); + assert_eq!((s.dict.get(b"ColorSpace"), s.dict.int(b"BitsPerComponent")), (Some(&Object::name("DeviceGray")), Some(1)), "{i:?}"); + } +} + +/// LibreOffice writes one resources object for a page and its `/Pages` node, and a transparency +/// group's form whose `/Resources` is that same object, which the object lists in turn. Black- +/// and-white RGB in it becomes grey: every reference is one the page walk reads, the cycle +/// included. Drawn from outside as well, by an appearance under `/DefaultGray` that draws the +/// form, it stays RGB. +#[test] +fn black_and_white_rgb_in_resources_its_page_tree_and_a_form_share_becomes_grey() { + for outside in [false, true] { + let (mut doc, image) = rgb_page_fixture(); + let img = image(&mut doc); + let res = doc.add(Object::Dict(Dict::new())); + let form = drawing(&mut doc, Some(Object::Ref(res)), &form_entries()); + doc.update_dict(res, |d| d.set(b"XObject".to_vec(), Object::Dict(xobject_dict(&[("Im", img), ("Tr", form)])))).unwrap(); + let p = page(&mut doc, &[], &draw_each(&["Im", "Tr"])); + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + for holder in [p, pages] { + doc.update_dict(holder, |d| d.set(b"Resources".to_vec(), Object::Ref(res))).unwrap(); + } + if outside { + let gray = resources_of(vec![("XObject", Object::Dict(xobject_dict(&[("Im", form)]))), ("ColorSpace", default_gray())]); + let appearance = drawing(&mut doc, Some(Object::Dict(gray)), &form_entries()); + annotate(&mut doc, p, appearance); + } + let before = stream(&doc, img); + pack_only(&mut doc); + let s = stream(&doc, img); + if outside { + assert_eq!((&s.dict, &s.raw), (&before.dict, &before.raw), "drawn from outside too"); + } else { + assert_eq!((s.dict.get(b"ColorSpace"), s.dict.int(b"BitsPerComponent")), (Some(&Object::name("DeviceGray")), Some(1))); + } + } +} + +/// Dense local mixed-sample patches are retained conservatively by the monochrome settings. +/// One-bit Flate, CCITT and masks preserve samples; eight-bit bilevel inputs may be packed +/// losslessly. Disable the independent gray/color reduction for the exact-render comparison: +/// those settings can otherwise choose smaller grayscale/JPEG output for eight-bit sources. +/// The Retain case separately checks that gray/color reduction remains available. +#[test] +fn ambiguous_monochrome_text_is_recompressed_without_lossy_reduction() { + let mut doc = Document::new_empty(); + let (w, h) = (1200u32, 480u32); + let text = bilevel(w, h, false); + let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let g4 = |doc: &mut Document, entries: &[(&str, Object)]| { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("CCITTFaxDecode")])); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + d.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Dict(parms)])); + for (k, v) in entries { + d.set(k.as_bytes().to_vec(), v.clone()); + } + doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_g4(&text, w, h).unwrap()))) + }; + // Group 4 behind ASCIIHexDecode, its parameters second in the array. + let behind_hex = |doc: &mut Document| { + let r = g4(doc, &grey); + let s = stream(doc, r); + let mut d = s.dict.clone(); + let parms = d.get(b"DecodeParms").and_then(Object::as_array).and_then(|a| a.first()).cloned().unwrap(); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("CCITTFaxDecode")])); + d.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, parms])); + let hex = s.raw.iter().map(|b| format!("{b:02x}")).chain([">".to_string()]).collect::().into_bytes(); + doc.set(r, Object::Stream(Stream::from_raw(d, hex))); + r + }; + let flate = mono_image(&mut doc, w, h, &text, None, &grey); + let fax = g4(&mut doc, &grey); + let hex_fax = behind_hex(&mut doc); + let hex_at_400 = behind_hex(&mut doc); + let decode = Object::Array(vec![1.into(), 0.into()]); + let mask = mono_image(&mut doc, w, h, &text, None, &[("ImageMask", Object::Bool(true)), ("Decode", decode.clone())]); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let eight = doc.add(Object::Stream(Stream::flate(d.clone(), &eight_bit(&text, w, h)))); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + let rgb = doc.add(Object::Stream(Stream::flate(d.clone(), &samples_of(&text, w, h, [&[0, 0, 0], &[255, 255, 255]], 8)))); + d.set(b"Decode".to_vec(), Object::Array([1, 0, 1, 0, 1, 0].map(Object::Int).to_vec())); + let rgb_inverted = doc.add(Object::Stream(Stream::flate(d, &samples_of(&text, w, h, [&[255, 255, 255], &[0, 0, 0]], 8)))); + let at_400 = mono_image(&mut doc, w, h, &text, None, &grey); + let fax_at_400 = g4(&mut doc, &grey); + let mut sm = Dict::new(); + sm.set(b"Subtype".to_vec(), Object::name("Image")); + sm.set(b"Width".to_vec(), Object::Int(2)); + sm.set(b"Height".to_vec(), Object::Int(1)); + sm.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + sm.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let smask = doc.add(Object::Stream(Stream::flate(sm, &[255, 200]))); + let soft = mono_image(&mut doc, w, h, &text, None, &[grey[0].clone(), grey[1].clone(), ("SMask", Object::Ref(smask))]); + // 144 × 57.6 pt is 2 × 0.8 in: 600 ppi. 216 × 86.4 pt is 400 ppi. + page( + &mut doc, + &[ + ("A", flate), + ("B", fax), + ("C", mask), + ("D", eight), + ("E", at_400), + ("F", fax_at_400), + ("G", soft), + ("H", hex_fax), + ("I", hex_at_400), + ("J", rgb), + ("K", rgb_inverted), + ], + "q 144 0 0 57.6 20 700 cm /A Do Q q 144 0 0 57.6 200 700 cm /B Do Q q 0 0 1 rg 144 0 0 57.6 380 700 cm /C Do Q \ + q 144 0 0 57.6 20 600 cm /D Do Q q 216 0 0 86.4 200 580 cm /E Do Q q 216 0 0 86.4 20 480 cm /F Do Q \ + q 144 0 0 57.6 260 480 cm /G Do Q q 144 0 0 57.6 430 600 cm /H Do Q q 216 0 0 86.4 20 360 cm /I Do Q q 144 0 0 57.6 260 380 cm /J Do Q \ + q 144 0 0 57.6 430 480 cm /K Do Q", + ); + let before = render(&doc); + let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; + let size = |doc: &Document, r: ObjRef| { + let d = stream(doc, r).dict; + (d.int(b"Width").unwrap(), d.int(b"Height").unwrap()) + }; + // This ragged text fixture has dense local mixed-sample patches. The conservative + // tone guard retains it too: a false positive costs size, not document content. + + // The grey and colour settings left alone too: an image of more bits would otherwise take + // them when smaller. + let mut off = doc.clone(); + let keep = ImageSettings { downsample: false, target_ppi: 150.0, above_ppi: 225.0, compression: Compression::Retain }; + let mono = MonoSettings { downsample: false, ..MonoSettings::REDUCE }; + let r = optimize(&mut off, &Settings { mono, gray: keep, color: keep, ..Settings::default() }).unwrap(); + assert_eq!(r.images_resampled, 0, "{r:?}"); + assert!([flate, fax, mask, eight, hex_fax, rgb, rgb_inverted].iter().all(|i| size(&off, *i) == (1200, 480))); + + let mut kept = doc.clone(); + let r = optimize(&mut kept, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + // Not the inverted RGB: the colour settings leave an image with a decode array alone. + assert_eq!(r.images_resampled, 2, "{r:?}"); + assert_eq!(stream(&kept, flate).dict.name(b"Filter"), Some(&b"FlateDecode"[..]), "Retain: Flate stays Flate"); + for i in [fax, hex_fax] { + assert_eq!(stream(&kept, i).dict.get(b"Filter"), stream(&doc, i).dict.get(b"Filter"), "Retain keeps the exact filter chain"); + } + assert_eq!(raw(&kept, hex_at_400), raw(&doc, hex_at_400), "and nothing else changes"); + for i in [flate, fax, hex_fax] { + assert_eq!((size(&kept, i), kept.decoded_image(&stream(&kept, i)).unwrap()), ((1200, 480), text.clone()), "{i:?}"); + } + let bits = [eight, rgb, rgb_inverted].map(|i| stream(&kept, i).dict.int(b"BitsPerComponent")); + assert_eq!(bits, [Some(8); 3], "Retain leaves eight bits to the grey and colour settings"); + + let untouched = [fax_at_400].map(|r| raw(&doc, r)); + let hex_len = raw(&doc, hex_at_400).len(); + let r = optimize(&mut doc, &Settings { gray: keep, color: keep, ..Settings::default() }).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 9), "{r:?}"); + assert_eq!(stream(&doc, rgb).dict.get(b"ColorSpace"), Some(&Object::name("DeviceGray"))); + for i in [flate, fax, mask, eight, hex_fax, rgb] { + let s = stream(&doc, i); + assert_eq!((size(&doc, i), s.dict.int(b"BitsPerComponent")), ((1200, 480), if i == mask { None } else { Some(1) })); + assert!(matches!(s.filters().as_slice(), [(Filter::CcittFax | Filter::Flate, _)]), "{i:?}"); + assert_eq!(doc.decoded_image(&s).unwrap(), text.clone(), "{i:?}"); + } + assert_eq!(stream(&doc, mask).dict.get(b"Decode"), Some(&decode), "the decode array stays"); + // Grey now, with grey's decode pair, which makes 1 black: its ties are black too. + let s = stream(&doc, rgb_inverted); + let inverting = Object::Array(vec![1.into(), 0.into()]); + assert_eq!( + (size(&doc, rgb_inverted), s.dict.get(b"ColorSpace"), s.dict.get(b"Decode")), + ((1200, 480), Some(&Object::name("DeviceGray")), Some(&inverting)) + ); + assert_eq!(doc.decoded_image(&s).unwrap(), text.iter().map(|b| !b).collect::>(), "the same picture, ties black"); + for i in [at_400, soft, hex_at_400] { + assert_eq!((size(&doc, i), stream(&doc, i).dict.name(b"Filter")), ((1200, 480), Some(&b"CCITTFaxDecode"[..])), "{i:?}"); + } + assert!(raw(&doc, hex_at_400).len() < hex_len, "the ASCIIHexDecode is dropped"); + assert_eq!(doc.decoded_image(&stream(&doc, hex_at_400)).unwrap(), text, "and the picture kept"); + assert_eq!([fax_at_400].map(|r| raw(&doc, r)), untouched); + let after = render(&doc); + assert_eq!(after, before, "conservative retention preserves the rendered page exactly"); +} + +/// Monochrome downsampling leaves a halftone or a dither at its resolution, as most new pixels +/// would cover both black and white and taking the sample covering more would turn its tones +/// solid: a 50% checkerboard, a 25% ordered dither and an error-diffused one, each at 600 ppi. +/// Dense ragged text is retained too. Rows wider than a fax row (`MAX_CCITT_COLUMNS`) +/// are never averaged: the averaging keeps a counter for each column. +#[test] +fn monochrome_downsampling_leaves_halftones_and_overwide_rows_at_their_resolution() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let pattern = |white: &dyn Fn(usize, usize) -> bool| { + let mut data = vec![0u8; stride * h as usize]; + for y in 0..h as usize { + for x in 0..w as usize { + if white(x, y) { + data[y * stride + x / 8] |= 0x80 >> (x % 8); + } + } + } + data + }; + let diffused = dithered(w, h); + let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let mut doc = Document::new_empty(); + let text = mono_image(&mut doc, w, h, &bilevel(w, h, false), None, &grey); + let halftones = + [pattern(&|x, y| (x + y) % 2 == 0), pattern(&|x, y| x % 2 == 1 || y % 2 == 1), pattern(&|x, y| diffused[y * w as usize + x] == 255)] + .map(|data| mono_image(&mut doc, w, h, &data, None, &grey)); + // 144 × 57.6 pt is 600 ppi. + let names = ["T", "A", "B", "C"]; + let content: String = (0..4).map(|i| format!("q 144 0 0 57.6 {} 600 cm /{} Do Q ", 6 + 150 * i, names[i])).collect(); + page(&mut doc, &names.into_iter().zip([text, halftones[0], halftones[1], halftones[2]]).collect::>(), &content); + optimize(&mut doc, &Settings::default()).unwrap(); + let size = |r: ObjRef| { + let d = stream(&doc, r).dict; + (d.int(b"Width"), d.int(b"Height")) + }; + assert_eq!(size(text), (Some(1200), Some(480)), "ambiguous dense text is retained conservatively"); + for (i, r) in halftones.iter().enumerate() { + assert_eq!(size(*r), (Some(1200), Some(480)), "halftone {i}"); + } + + for (columns, resampled) in [(pdfcraft_filters::MAX_CCITT_COLUMNS, 1), (pdfcraft_filters::MAX_CCITT_COLUMNS + 1, 0)] { + let mut doc = Document::new_empty(); + let white = vec![0xFF; (columns as usize).div_ceil(8) * 2]; + let img = mono_image(&mut doc, columns, 2, &white, None, &grey); + // Far above 450 ppi both ways. + page(&mut doc, &[("A", img)], "q 612 0 0 0.1 0 400 cm /A Do Q"); + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(r.images_resampled, resampled, "{columns} columns: {r:?}"); + } +} + +/// A fax image behind a decompression bomb is left alone: the filters before its decoder may +/// produce no more than a fax code of its size can take (about a byte a pixel), not the 1 GiB +/// limit. Here a Group 4 page at 600 ppi behind Flate, with four megabytes of zeros after its +/// code; without them it is averaged down. +#[test] +fn a_fax_image_behind_a_decompression_bomb_is_left_alone() { + let (w, h) = (1200u32, 480u32); + let g4 = pdfcraft_filters::encode_g4(&bilevel(w, h, false), w, h).unwrap(); + for padding in [0, 4 << 20] { + let mut doc = Document::new_empty(); + let mut code = g4.clone(); + code.resize(g4.len() + padding, 0); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("CCITTFaxDecode")])); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + d.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(parms)])); + let img = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(&code)))); + // 144 × 57.6 pt is 600 ppi. + page(&mut doc, &[("A", img)], "q 144 0 0 57.6 20 600 cm /A Do Q"); + let before = stream(&doc, img).raw; + let r = optimize(&mut doc, &Settings::default()).unwrap(); + if padding == 0 { + assert_eq!(r.images_resampled, 0, "ambiguous local tones are retained: {r:?}"); + assert_eq!(doc.decoded_image(&stream(&doc, img)).unwrap(), bilevel(w, h, false)); + } else { + assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}"); + assert_eq!(stream(&doc, img).raw, before); + } + } +} + +/// A CCITT image `w` × `h` coded as Group 4 from `bits`, behind ASCIIHexDecode when `hex`. +fn g4_image(doc: &mut Document, bits: &[u8], (w, h): (u32, u32), hex: bool) -> ObjRef { + let g4 = pdfcraft_filters::encode_g4(bits, w, h).unwrap(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(-1)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + let data = if hex { + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("CCITTFaxDecode")])); + d.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(parms)])); + g4.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect() + } else { + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + g4 + }; + doc.add(Object::Stream(Stream::from_raw(d, data))) +} + +/// What Monochrome Images leaves alone and what it may still do. A Group 4 halftone above 450 ppi +/// isn't resampled, so it stays byte for byte: alone, by default; behind ASCIIHexDecode, with +/// Retain (by default it is stored again as Group 4 alone). Under Retain an ambiguous Flate scan +/// keeps its bytes and resolution though Group 4 would be smaller. A busy Group 4 image behind ASCIIHexDecode, whose +/// code is more than three times its raster, decodes within a fax code's bound and is stored +/// again as Group 4 alone. Sixteen-bit grey of only 0x0000 and 0xFFFF isn't eight-bit black and +/// white, and keeps its bytes. +#[test] +fn monochrome_images_keep_what_they_should() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let checker: Vec = (0..h as usize).flat_map(|y| vec![if y % 2 == 0 { 0xAA } else { 0x55 }; stride]).collect(); + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + // Twelve dark bands a row, their edges ragged by up to five samples, as a scan's are. + let mut ragged = vec![0xFFu8; stride * h as usize]; + for y in 0..h as usize { + for band in 0..12 { + let (x0, x1) = (band * 100 + (next() % 5) as usize, band * 100 + 50 + (next() % 5) as usize); + for x in x0..x1.min(w as usize) { + ragged[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + let busy: Vec = (0..256).flat_map(|y| [if y % 2 == 0 { 0xAA } else { 0xFF }; 32]).collect(); + let build = || { + let mut doc = Document::new_empty(); + let alone = g4_image(&mut doc, &checker, (w, h), false); + let wrapped = g4_image(&mut doc, &checker, (w, h), true); + let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]; + let scan = mono_image(&mut doc, w, h, &ragged, None, &grey); + let fax = g4_image(&mut doc, &busy, (256, 256), true); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(16)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let samples: Vec = eight_bit(&bilevel(w, h, false), w, h).iter().flat_map(|v| [*v, *v]).collect(); + let deep = doc.add(Object::Stream(Stream::flate(d, &samples))); + // 144 × 57.6 pt is 600 ppi; 46.08 pt for 256 samples is 400 ppi. + page( + &mut doc, + &[("A", alone), ("B", wrapped), ("C", scan), ("D", fax), ("E", deep)], + "q 144 0 0 57.6 20 700 cm /A Do Q q 144 0 0 57.6 200 700 cm /B Do Q q 144 0 0 57.6 20 600 cm /C Do Q \ + q 46.08 0 0 46.08 200 600 cm /D Do Q q 144 0 0 57.6 20 500 cm /E Do Q", + ); + (doc, [alone, wrapped, scan, fax, deep]) + }; + let raw = |doc: &Document, r: ObjRef| stream(doc, r).raw; + let filters = |doc: &Document, r: ObjRef| stream(doc, r).filters().len(); + + let (mut doc, [alone, wrapped, scan, fax, deep]) = build(); + let before = [alone, deep].map(|r| raw(&doc, r)); + 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!(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(); + let before = raw(&doc, wrapped); + let r = optimize(&mut doc, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + assert_eq!(raw(&doc, wrapped), before, "Retain: a wrapped Group 4 halftone stays as it is"); + let s = stream(&doc, scan); + assert_eq!((s.dict.int(b"Width"), s.dict.name(b"Filter")), (Some(1200), Some(&b"FlateDecode"[..])), "Retain: {r:?}"); + assert_eq!(doc.decoded_image(&s).unwrap(), ragged, "Retain preserves every sample"); +} + +/// `rows` coded as CCITT Group 3, one-dimensional (`/K 0`): each row its runs in turn, white first, +/// with ITU-T T.4's terminating codes for the few lengths used here. With `on_bytes`, each row +/// starts on a byte: `Some(false)`, with no end-of-line codes (TIFF's Modified Huffman); `Some(true)`, +/// after an end-of-line code that the fill ends on a byte (T.4's fill, as TIFF writes it). +fn group_3(rows: &[&[u32]], mixed: bool, on_bytes: Option) -> Vec { + let code = |white: bool, run: u32| match (white, run) { + (true, 0) => "00110101", + (true, 1) => "000111", + (false, 1) => "010", + (true, 8) => "10011", + (true, 16) => "101010", + (true, 32) => "00011011", + (false, 8) => "000101", + (false, 16) => "0000010111", + (false, 32) => "000001101010", + _ => panic!("no code here for a run of {run}"), + }; + let mut bits = String::new(); + for row in rows { + let codes = mixed || on_bytes == Some(true); + if on_bytes.is_some() { + // The end-of-line code is 12 bits. + while !(bits.len() + if codes { 12 } else { 0 }).is_multiple_of(8) { + bits.push('0'); + } + } + if codes { + bits.push_str("000000000001"); + } + // Mixed coding (/K above 0): a 1 bit after the code, the row one-dimensional. + if mixed { + bits.push('1'); + } + row.iter().enumerate().for_each(|(i, run)| bits.push_str(code(i % 2 == 0, *run))); + } + bits.as_bytes().chunks(8).map(|c| c.iter().enumerate().fold(0u8, |b, (i, d)| b | (u8::from(*d == b'1') << (7 - i)))).collect() +} + +/// A Group 3 fax image is stored again as Group 4 when that is smaller, the same picture (to the +/// pixel on the page, against the picture in Flate); with Retain it stays Group 3. One-dimensional (/K 0) and mixed (/K 1, with +/// end-of-line codes) alike, and rows on bytes, with no codes and with codes the fill ends on a byte +/// (without `/EndOfLine`, which reading by the flag misread). +#[test] +fn group_3_fax_images_become_group_4() { + let (w, h) = (64u32, 64u32); + let runs: Vec<&[u32]> = [&[32, 32][..], &[16; 4], &[8; 8], &[0, 16, 16, 32]].into_iter().flat_map(|r| std::iter::repeat_n(r, 16)).collect(); + // White is 1, as CCITTFaxDecode gives it without /BlackIs1. + let mut bits = Vec::new(); + for row in &runs { + let mut packed = [0u8; 8]; + let mut x = 0; + for (i, run) in row.iter().enumerate() { + for p in x..x + *run as usize { + if i % 2 == 0 { + packed[p / 8] |= 0x80 >> (p % 8); + } + } + x += *run as usize; + } + bits.extend(packed); + } + for (k, on_bytes) in [(0, None), (1, None), (0, Some(false)), (0, Some(true)), (1, Some(true))] { + let g3 = group_3(&runs, k > 0, on_bytes); + let (end_of_line, encoded_byte_align) = (k > 0 && on_bytes.is_none(), on_bytes.is_some()); + let p = pdfcraft_filters::CcittParams { k, columns: w.into(), end_of_line, encoded_byte_align, ..Default::default() }; + assert_eq!(pdfcraft_filters::decode_ccitt(&g3, &p, h).unwrap(), bits, "/K {k}, on bytes {on_bytes:?}: the Group 3 data is the picture"); + + let mut doc = Document::new_empty(); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + // The page with the picture in Flate, as it should look. + let mut control = Document::new_empty(); + let flate = control.add(Object::Stream(Stream::flate(d.clone(), &bits))); + page(&mut control, &[("A", flate)], "q 64 0 0 64 100 600 cm /A Do Q"); + d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode")); + let mut parms = Dict::new(); + parms.set(b"K".to_vec(), Object::Int(k)); + parms.set(b"EndOfLine".to_vec(), Object::Bool(end_of_line)); + parms.set(b"EncodedByteAlign".to_vec(), Object::Bool(encoded_byte_align)); + parms.set(b"Columns".to_vec(), Object::Int(w.into())); + parms.set(b"Rows".to_vec(), Object::Int(h.into())); + d.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let fax = doc.add(Object::Stream(Stream::from_raw(d, g3.clone()))); + page(&mut doc, &[("A", fax)], "q 64 0 0 64 100 600 cm /A Do Q"); + + let mut kept = doc.clone(); + optimize(&mut kept, &Settings { mono: RETAIN, ..Settings::default() }).unwrap(); + assert_eq!(stream(&kept, fax).raw[..], g3[..], "/K {k}, on bytes {on_bytes:?}: Retain keeps Group 3"); + + let r = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!((r.images_resampled, r.images_recompressed), (0, 1), "/K {k}, on bytes {on_bytes:?}: {r:?}"); + let s = stream(&doc, fax); + assert_eq!(doc.fax_params(&s).map(|p| p.k), Some(-1), "/K {k}, on bytes {on_bytes:?}: Group 4"); + assert!(s.raw.len() < g3.len(), "{} bytes, from {}", s.raw.len(), g3.len()); + assert_eq!(doc.decoded_image(&s).unwrap(), bits, "/K {k}, on bytes {on_bytes:?}: the same picture"); + assert!(render(&doc) == render(&control), "/K {k}, on bytes {on_bytes:?}: the page looks as the picture does"); + } +} + +/// The images of a document that are images, by object number. +fn image_refs(doc: &Document) -> Vec { + doc.object_numbers() + .into_iter() + .map(|num| ObjRef { num, generation: doc.generation(num) }) + .filter(|r| matches!(&*doc.get(*r), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image"))) + .collect() +} + +/// Black-and-white images as lossless JBIG2: the page looks the same to the pixel (so +/// JBIG2Decode gives black as 0, as the encoder assumes), and scan-like text comes out smaller +/// than as Group 4. JBIG2Decode is PDF 1.4, so a PDF 1.3 document gets `/Version /1.4` in its +/// catalog. +#[test] +fn black_and_white_images_become_jbig2() { + let mut doc = Document::new_empty(); + let (w, h) = (1200, 480); + let text = bilevel(w, h, false); + let gray = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + let decode = Object::Array(vec![1.into(), 0.into()]); + let mask = mono_image(&mut doc, w, h, &text, None, &[("ImageMask", Object::Bool(true)), ("Decode", decode.clone())]); + let indexed_cs = Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceRGB"), + 1.into(), + Object::String(pdfcraft_cos::PdfString::literal(vec![0xC0, 0x10, 0x10, 0xF8, 0xF0, 0xE0])), + ]); + let indexed = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", indexed_cs.clone())]); + page( + &mut doc, + &[("G", gray), ("M", mask), ("I", indexed)], + "q 300 0 0 120 20 660 cm /G Do Q q 0 0.4 0.8 rg 300 0 0 120 320 660 cm /M Do Q q 300 0 0 120 20 520 cm /I Do Q", + ); + // The same document, as PDF 1.3. + let mut bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + bytes[..8].copy_from_slice(b"%PDF-1.3"); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert_eq!(doc.version(), "1.3"); + let before = render(&doc); + + let mut g4 = doc.clone(); + let as_g4 = optimize(&mut g4, &Settings::default()).unwrap(); + let mut jbig2 = doc.clone(); + let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() }; + let r = optimize(&mut jbig2, &settings).unwrap(); + assert_eq!(r.images_recompressed, 3); + assert!(r.image_bytes_after < as_g4.image_bytes_after, "JBIG2 {} bytes, Group 4 {}", r.image_bytes_after, as_g4.image_bytes_after); + let images: Vec = image_refs(&jbig2).into_iter().map(|i| stream(&jbig2, i)).collect(); + assert_eq!(images.len(), 3); + for s in &images { + assert_eq!((s.dict.name(b"Filter"), s.dict.contains(b"DecodeParms")), (Some(&b"JBIG2Decode"[..]), false)); + } + assert!(images.iter().any(|s| s.dict.get(b"Decode") == Some(&decode)), "the decode array stays"); + let indexed_kept = + |s: &Stream| s.dict.get(b"ColorSpace").and_then(Object::as_array).and_then(|a| a.first()).and_then(Object::as_name) == Some(b"Indexed"); + assert!(images.iter().any(indexed_kept), "the colour space stays"); + let catalog = jbig2.root().and_then(|r| jbig2.get(r).as_dict().cloned()).unwrap(); + assert_eq!(catalog.name(b"Version"), Some(&b"1.4"[..])); + assert!(g4.root().and_then(|r| g4.get(r).as_dict().cloned()).is_some_and(|c| !c.contains(b"Version")), "Group 4 is PDF 1.0"); + assert!(render(&jbig2) == before, "the page looks the same"); + // The integration also supports changing an already-CCITT image to lossless JBIG2. + let mut from_g4 = g4.clone(); + let converted = optimize(&mut from_g4, &settings).unwrap(); + assert_eq!(converted.images_recompressed, 3); + assert!(render(&from_g4) == before, "CCITT decoded and re-encoded as JBIG2 keeps the picture"); +} + +/// A catalog `/Version` given by reference is read through it: a PDF 1.3 file whose catalog says +/// 1.7 by reference keeps 1.7 when its images become JBIG2, rather than being lowered to 1.4; one +/// whose catalog says 1.3 by reference gets 1.4. +#[test] +fn a_catalog_version_given_by_reference_is_read() { + for (catalog, after) in [("1.7", "1.7"), ("1.3", "1.4")] { + let mut doc = Document::new_empty(); + let (w, h) = (1200, 480); + let text = bilevel(w, h, false); + let im = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut doc, &[("Im", im)], "q 300 0 0 120 20 660 cm /Im Do Q"); + let version = doc.add(Object::name(catalog)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Version".to_vec(), Object::Ref(version))).unwrap(); + let mut bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + bytes[..8].copy_from_slice(b"%PDF-1.3"); + let mut doc = Document::open(Arc::new(bytes)).unwrap(); + let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() }; + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!(r.images_recompressed, 1); + let root = doc.root().unwrap(); + let v = doc.get(root).as_dict().and_then(|c| c.get(b"Version").cloned()).unwrap(); + assert_eq!(doc.resolve(&v).as_name(), Some(after.as_bytes()), "catalog {catalog} by reference"); + } +} + +/// Text as a renderer draws it at 300 dpi: lines 48 rows apart of a few letter shapes, each +/// `#` a 3 × 3 block, three columns apart with a wider gap between words, every copy of a letter +/// the same to the pixel. +fn rendered_text(w: u32, h: u32) -> Vec { + const LETTERS: [&[&str]; 5] = [ + &["##", "##", "##", "##", "##", "##", "##", "##", "##", "##"], + &["......", ".####.", "##..##", "##..##", "##..##", "##..##", ".####."], + &["##....", "##....", "##....", "#####.", "##..##", "##..##", "##..##", "#####."], + &["##..##", "##..##", "##..##", "##..##", ".#####"], + &["##", "##", "..", "##", "##", "##", "##", "##"], + ]; + let stride = (w as usize).div_ceil(8); + let mut data = vec![0xFF; stride * h as usize]; + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + for baseline in (32..h as usize).step_by(48) { + let mut x = 8; + while x + 20 < w as usize { + let letter = LETTERS[(next() % 5) as usize]; + for (dy, row) in letter.iter().enumerate() { + for (dx, c) in row.bytes().enumerate() { + for (py, px) in (0..9).map(|k| (baseline + 1 + 3 * dy + k / 3 - 3 * letter.len(), x + 3 * dx + k % 3)) { + if c == b'#' { + data[py * stride + px / 8] &= !(0x80 >> (px % 8)); + } + } + } + } + x += 3 * letter[0].len() + 3 + if next() % 5 == 0 { 12 } else { 0 }; + } + } + data +} + +/// Text whose letters repeat exactly, as rendered text does, becomes JBIG2 symbols: each letter +/// stored once and its copies placed, several times smaller than JBIG2's generic coding of the +/// same image, and the page looks the same. +#[test] +fn rendered_text_becomes_jbig2_symbols() { + let mut doc = Document::new_empty(); + let (w, h) = (600, 480); + let text = rendered_text(w, h); + let im = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + // One image pixel a device pixel at scale 1, so a wrong pixel shows. + page(&mut doc, &[("Im", im)], "q 600 0 0 480 6 300 cm /Im Do Q"); + let before = render(&doc); + let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() }; + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!(r.images_recompressed, 1); + let s = stream(&doc, im); + assert_eq!(s.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..])); + let generic = pdfcraft_filters::encode_jbig2_generic(&text, w, h).unwrap(); + assert!(s.raw.len() * 2 < generic.len(), "symbols {} bytes, generic {}", s.raw.len(), generic.len()); + assert!(render(&doc) == before, "the page looks the same"); +} + +/// A whole 300 dpi letter page of rendered text, more marks than one JBIG2 text region of the +/// decoder PdfCraft renders with takes, still looks the same as JBIG2 symbols. +#[test] +fn a_dense_page_of_rendered_text_looks_the_same_as_jbig2_symbols() { + let mut doc = Document::new_empty(); + let (w, h) = (2550, 3300); + let text = rendered_text(w, h); + let im = mono_image(&mut doc, w, h, &text, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut doc, &[("Im", im)], "q 612 0 0 792 0 0 cm /Im Do Q"); + let before = render(&doc); + let settings = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() }; + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!(r.images_recompressed, 1); + let s = stream(&doc, im); + assert_eq!(s.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..])); + assert!(s.raw.len() * 2 < pdfcraft_filters::encode_jbig2_generic(&text, w, h).unwrap().len(), "symbol coded"); + // The copies take several text regions (segment type 7, ISO/IEC 14492 7.3), read from the + // segment headers (7.2): number, flags, referred-to count and numbers, page, data length. + let mut regions = 0; + let mut at = 0; + while let Some(header) = s.raw.get(at..at + 6) { + let refers = usize::from(header[5] >> 5); + let length = u32::from_be_bytes(s.raw[at + 7 + refers..at + 11 + refers].try_into().unwrap()) as usize; + regions += usize::from(header[4] & 0x3F == 7); + at += 11 + refers + length; + } + assert!(regions > 1, "{regions} text regions"); + assert!(render(&doc) == before, "the page looks the same"); +} + #[test] fn settings_choose_what_happens() { let mut doc = Document::new_empty(); @@ -1536,3 +3393,88 @@ fn lzw_streams_that_cannot_be_read_or_are_not_asked_for_stay_unchanged() { assert_eq!(optimize(&mut doc, &Settings::default()).unwrap().lzw_streams, 1); assert_eq!(stream(&doc, c).decoded_strict().unwrap(), ops); } + +/// Unknown palette polarity is never guessed. Both palette orders and Decode directions +/// retain the original thin rule and exact samples through a full save and render. +#[test] +fn indexed_thin_rules_keep_their_samples_for_both_palette_orders_and_decodes() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + let mut gray_data = vec![0xFF; stride * h as usize]; + for y in 0..h as usize { + gray_data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8)); + } + let mut gray = Document::new_empty(); + let gray_image = mono_image(&mut gray, w, h, &gray_data, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut gray, &[("Im", gray_image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + let gray_render = render(&gray); + optimize(&mut gray, &Settings::default()).unwrap(); + let gray_stream = stream(&gray, gray_image); + let gray_samples = gray.decoded_image(&gray_stream).unwrap(); + assert_eq!((gray_stream.dict.int(b"Width"), gray_stream.dict.int(b"Height")), (Some(600), Some(240))); + assert!(gray_samples.iter().any(|b| *b != 0xFF), "the DeviceGray thin rule survives"); + for white_first in [false, true] { + for inverted in [false, true] { + let mut data = vec![0xFF; stride * h as usize]; + for y in 0..h as usize { + data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8)); + } + if white_first != inverted { + data.iter_mut().for_each(|b| *b = !*b); + } + let palette = if white_first { vec![255, 0] } else { vec![0, 255] }; + let cs = Object::Array(vec![ + Object::name("Indexed"), + Object::name("DeviceGray"), + 1.into(), + Object::String(pdfcraft_cos::PdfString::literal(palette)), + ]); + let mut entries = vec![("BitsPerComponent", 1.into()), ("ColorSpace", cs)]; + if inverted { + entries.push(("Decode", Object::Array(vec![1.into(), 0.into()]))); + } + let mut doc = Document::new_empty(); + let image = mono_image(&mut doc, w, h, &data, None, &entries); + page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + let before = render(&doc); + assert_eq!(before, gray_render, "the palette image is visually identical to DeviceGray"); + optimize(&mut doc, &Settings::default()).unwrap(); + let s = stream(&doc, image); + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(w.into()), Some(h.into()))); + assert_eq!(doc.decoded_image(&s).unwrap(), data); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(render(&saved), before, "palette white-first={white_first}, inverted={inverted}"); + } + } +} + +/// White margins must not hide a small screened photograph from the tone guard. +#[test] +fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margins() { + let (w, h) = (1200u32, 480u32); + let stride = (w as usize).div_ceil(8); + for side in [8usize, 40, 300] { + let mut data = vec![0xFF; stride * h as usize]; + for y in 40..40 + side { + for x in 100..100 + side { + if (x + y) % 2 == 0 { + data[y * stride + x / 8] &= !(0x80 >> (x % 8)); + } + } + } + // A separate one-pixel text/rule stroke makes this a mixed-content scan. + for y in 380..420 { + data[y * stride + 801 / 8] &= !(0x80 >> (801 % 8)); + } + let mut doc = Document::new_empty(); + let image = mono_image(&mut doc, w, h, &data, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))]); + page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q"); + let before = render(&doc); + optimize(&mut doc, &Settings::default()).unwrap(); + let s = stream(&doc, image); + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(w.into()), Some(h.into()))); + assert_eq!(doc.decoded_image(&s).unwrap(), data, "screened samples survive, not a solid patch"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(render(&saved), before, "local halftone {side}x{side}"); + } +} diff --git a/crates/organize/src/prune.rs b/crates/organize/src/prune.rs index d8db2f7ac4..5a2a319fb4 100644 --- a/crates/organize/src/prune.rs +++ b/crates/organize/src/prune.rs @@ -231,14 +231,15 @@ impl Pruner<'_> { } } -/// The page's content, concatenated (`/Contents` may be one stream or an array), decoded -/// against one page's [`DecodeBudget`]. An error when a stream can't be decoded or the budget -/// runs out: the page may draw names that can't be seen. +/// The page's content, concatenated (`/Contents` may be one stream or an array) with a space +/// between pieces as the renderer joins them, decoded against one page's [`DecodeBudget`]. An +/// error when a stream can't be decoded or the budget runs out: the page may draw names that +/// can't be seen. pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Result, CosError> { let mut out = Vec::new(); for data in DecodeBudget::page().decode_contents(doc, contents) { out.extend(data?); - out.push(b'\n'); + out.push(b' '); } Ok(out) } diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 64391bb82d..33dc262fce 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -3767,3 +3767,49 @@ fn bookmark_style_and_colour_are_set_as_the_table_says() { let got: Vec<_> = bookmarks(&saved).into_iter().map(|b| (b.color, b.bold, b.italic)).collect(); assert_eq!(got, [(Some([0.0, 0.0, 1.0]), true, true), (None, false, false)]); } + +/// #3502: a `/Contents` array's pieces are joined with a space, as the renderer joins them, so a +/// `%` comment at the end of a piece without a line break continues through the first line of the +/// next piece in both resource pruning (`extract_pages`) and `page_as_form`. +#[test] +fn contents_pieces_join_with_a_space_across_a_trailing_comment() { + let build_doc = |first_piece: &str| { + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R 5 0 R] /Resources 6 0 R >>".into(), // 3 + raw(first_piece), // 4 + raw("/ImHidden Do\n/ImShown Do"), // 5 + "<< /XObject << /ImHidden 7 0 R /ImShown 8 0 R >> >>".into(), // 6 + image(1, 1, 1), // 7 + image(1, 1, 1), // 8 + ]; + open(build(&b, "/Root 1 0 R")) + }; + + // Without a trailing newline in piece 1, `% comment` swallows `/ImHidden Do` on the first + // line of piece 2; only `/ImShown` is drawn. + let commented = build_doc("q Q % comment without newline"); + let extracted = full_roundtrip(&extract_pages(&commented, &[0]).unwrap()); + assert_eq!(xobject_names(&extracted, 0), [b"ImShown".to_vec()], "commented-out Do across piece boundary is pruned"); + + let mut form_dst = Document::new_empty(); + let (form_ref, _) = crate::page_as_form(&mut form_dst, &commented, 0).unwrap(); + let Object::Stream(form_stream) = &*form_dst.get(form_ref) else { panic!("form stream") }; + let form_bytes = form_stream.decoded().unwrap(); + let drawn: Vec> = pdfcraft_content::parse(&form_bytes) + .ops + .into_iter() + .filter(|op| op.op == b"Do") + .filter_map(|op| op.operands.first().and_then(Object::as_name).map(|n| n.to_vec())) + .collect(); + assert_eq!(drawn, [b"ImShown".to_vec()], "page_as_form keeps the first line of piece 2 inside the comment"); + + // Control: when piece 1 ends the comment with a newline, `/ImHidden Do` on piece 2's first + // line is a real operator and stays in both the extracted page and the form XObject. + let newline_ended = build_doc("q Q % comment with newline\n"); + let extracted_ctrl = full_roundtrip(&extract_pages(&newline_ended, &[0]).unwrap()); + let mut ctrl_names = xobject_names(&extracted_ctrl, 0); + ctrl_names.sort(); + assert_eq!(ctrl_names, [b"ImHidden".to_vec(), b"ImShown".to_vec()]); +} diff --git a/crates/preflight/Cargo.toml b/crates/preflight/Cargo.toml index 082e44fc5c..99a72d9b6c 100644 --- a/crates/preflight/Cargo.toml +++ b/crates/preflight/Cargo.toml @@ -14,7 +14,7 @@ workspace = true pdfcraft-content = { workspace = true } pdfcraft-cos = { workspace = true } pdfcraft-model = { workspace = true } -roxmltree = "0.20" +roxmltree = "0.21" [dev-dependencies] flate2 = { workspace = true } diff --git a/crates/preflight/src/xmp.rs b/crates/preflight/src/xmp.rs index 0aaab12b23..7a54d70a1b 100644 --- a/crates/preflight/src/xmp.rs +++ b/crates/preflight/src/xmp.rs @@ -34,7 +34,7 @@ fn parsed_identifier(xmp: &str, namespace: &str, local: &str) -> Result String { if s == "-0" { "0".into() } else { s.into() } } -/// The page's content streams decoded against `budget` and joined; streams that don't decode -/// are left out. `None` when the budget ran out, so part of the content is missing. +/// The page's content streams decoded against `budget` and joined with a space as the renderer +/// joins them; streams that don't decode are left out. `None` when the budget ran out, so part of +/// the content is missing. pub(crate) fn decoded_contents(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Option> { let Some(contents) = page.get(b"Contents") else { return Some(Vec::new()) }; let mut out = Vec::new(); @@ -25,7 +26,7 @@ pub(crate) fn decoded_contents(doc: &Document, page: &Dict, budget: &mut DecodeB match data { Ok(data) => { out.extend_from_slice(&data); - out.push(b'\n'); + out.push(b' '); } Err(_) if budget.remaining() == 0 => return None, Err(_) => {} diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index e6432d8aec..885756e8c6 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -1062,3 +1062,85 @@ fn a_booklet_prints_both_sides_on_the_short_edge() { assert_eq!(booklet_duplex(BookletSubset::FrontOnly), spool::Duplex::Off); assert_eq!(booklet_duplex(BookletSubset::BackOnly), spool::Duplex::Off); } + +/// #3502: `/Contents` array pieces are joined with a space, matching the renderer, so a `%` +/// comment at the end of a piece without a line break continues through the first line of the +/// next piece in both `impose` and `text_reads_up`. +#[test] +fn contents_pieces_join_with_a_space_across_a_trailing_comment() { + let build_doc = |first_piece: &[u8]| { + let mut doc = Document::new_empty(); + let font = doc.add( + [ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("Type1")), + (b"BaseFont".to_vec(), Object::name("Helvetica")), + ] + .into_iter() + .collect::(), + ); + let s1 = doc.add(Stream::from_raw(Dict::new(), first_piece.to_vec())); + // Line 1 of s2 draws sideways ("HIDDEN") and 4 characters reading up; line 2 draws upright ("SHOWN"). + let s2 = doc.add(Stream::from_raw( + Dict::new(), + b"BT /F1 18 Tf 0 1 -1 0 50 50 Tm (HIDDEN) Tj ET\nBT /F1 18 Tf 1 0 0 1 20 100 Tm (SHOWN) Tj ET".to_vec(), + )); + let fonts: Dict = [(b"F1".to_vec(), Object::Ref(font))].into_iter().collect(); + let res: Dict = [(b"Font".to_vec(), Object::Dict(fonts))].into_iter().collect(); + let pages_root = doc.add(Object::Null); + let page = doc.add( + [ + (b"Type".to_vec(), Object::name("Page")), + (b"Parent".to_vec(), Object::Ref(pages_root)), + (b"MediaBox".to_vec(), Object::Array([0.0, 0.0, 200.0, 300.0].into_iter().map(Object::Real).collect())), + (b"Resources".to_vec(), Object::Dict(res)), + (b"Contents".to_vec(), Object::Array(vec![Object::Ref(s1), Object::Ref(s2)])), + ] + .into_iter() + .collect::(), + ); + doc.set( + pages_root, + Object::Dict( + [ + (b"Type".to_vec(), Object::name("Pages")), + (b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])), + (b"Count".to_vec(), Object::Int(1)), + ] + .into_iter() + .collect(), + ), + ); + let cat = doc.add([(b"Type".to_vec(), Object::name("Catalog")), (b"Pages".to_vec(), Object::Ref(pages_root))].into_iter().collect::()); + doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(cat)); + doc + }; + + let form_strings = |imposed: Vec| -> Vec> { + let out = Document::open(Arc::new(imposed)).unwrap(); + let page = &pdfcraft_model::pages(&out)[0]; + let res = out.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = out.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let form_obj = out.resolve(xo.get(b"P0").unwrap()); + let Object::Stream(form) = &*form_obj else { panic!("form stream") }; + let body = form.decoded().unwrap(); + pdfcraft_content::parse(&body) + .ops + .into_iter() + .filter(|op| op.op == b"Tj") + .filter_map(|op| op.operands.first().and_then(Object::as_string).map(|s| s.bytes.to_vec())) + .collect() + }; + + // Without a newline at the end of s1, `% comment` swallows line 1 of s2 (`(HIDDEN) Tj`). + let commented = build_doc(b"q Q % trailing comment"); + assert!(!text_reads_up(&commented, 0), "commented-out vertical text on piece 2's first line is not counted"); + let printed = impose(&commented, &settings(vec![0], Layout::Size(SizeMode::Actual))).unwrap(); + assert_eq!(form_strings(printed), [b"SHOWN".to_vec()], "impose does not un-comment piece 2's first line"); + + // Control: when s1 terminates the comment with a newline, line 1 of s2 is drawn and counted. + let with_newline = build_doc(b"q Q % trailing comment\n"); + assert!(text_reads_up(&with_newline, 0), "6 vertical vs 5 horizontal chars when line 1 is not commented out"); + let printed_ctrl = impose(&with_newline, &settings(vec![0], Layout::Size(SizeMode::Actual))).unwrap(); + assert_eq!(form_strings(printed_ctrl), [b"HIDDEN".to_vec(), b"SHOWN".to_vec()]); +} diff --git a/crates/redact/Cargo.toml b/crates/redact/Cargo.toml index 49593855e2..8d1515d8a4 100644 --- a/crates/redact/Cargo.toml +++ b/crates/redact/Cargo.toml @@ -20,4 +20,5 @@ pdfcraft-xfa = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +pdfcraft-filters = { workspace = true } pdfcraft-testkit = { workspace = true } diff --git a/crates/redact/README.md b/crates/redact/README.md index 34c9f4fe1f..8383f0119f 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -18,10 +18,15 @@ clear_marks(&mut doc, None)?; // remove marks without app more than a fifth of its size (or a point) both ways; zero-size text goes when its origin is inside; - **images**: fully covered → removed; partly covered → a copy with the covered pixels cleared - (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx). Codecs PdfCraft can't - re-encode (DCT, JPX, JBIG2, CCITT) are removed whole (fail-closed); inline images under an - area are removed. Images with separate `/SMask` or `/Mask` data, and images whose data decodes - to more than twice their declared raster (plus a byte per row and 4 KiB), are removed whole; + (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx, and CCITT fax as the last + filter, decoded through `Document::decoded_image_within`; `/DecodeParms` given by reference, + numbers inside included, count). Codecs redaction doesn't decode (DCT, JPX, JBIG2) are removed + whole (fail-closed), and so is Group 3 on bytes (`/K` 0 or more, `/EncodedByteAlign`): PdfCraft's + renderer draws some of its layouts (the fill ending each end-of-line code on a byte) as other + rows than the decoder reads, so a mark over what is shown needn't cover what is cleared; inline images under an area are removed. Images with separate `/SMask` + or `/Mask` data, and images whose data decodes to more than three times their declared raster + and a byte per row, plus 4 KiB (ASCIIHex text broken into lines under Flate is a little over + twice; for a fax image's code, about a byte per pixel), are removed whole; cleared copies drop `/Alternates` so alternate pixels cannot survive; - **vectors**: covered paths are removed; partly covered paths and shadings are clipped so nothing paints inside the areas; diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 601700f257..f662849c12 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -1,8 +1,10 @@ //! Clearing the pixels of an image under redaction regions. The image is decoded, every pixel //! whose footprint intersects a region is set to zero (no ink for image masks), and the result is //! written as a new Flate image; the original object is untouched (other pages may use it). -//! Images whose codec PdfCraft can't decode (DCT, JPX, JBIG2, CCITT) return `None`, and the -//! caller removes the whole image instead; that is fail-closed. +//! CCITT fax images decode through `Document::decoded_image_within`, but for Group 3 on bytes, +//! which PdfCraft's renderer may draw as other rows. Images whose codec PdfCraft can't decode +//! (DCT, JPX, JBIG2), and Group 3 on bytes, return `None`, and the caller removes the whole image +//! instead; that is fail-closed. use pdfcraft_content::{Matrix, overlaps}; use pdfcraft_cos::{Document, Object, Stream}; @@ -51,15 +53,29 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] b"FlateDecode" | b"Fl" | b"LZWDecode" | b"LZW" | b"ASCII85Decode" | b"A85" | b"ASCIIHexDecode" | b"AHx" | b"RunLengthDecode" | b"RL" ) }; - let ok = match s.dict.get(b"Filter") { - None => true, - Some(Object::Name(n)) => supported(n), - Some(Object::Array(a)) => a.iter().all(|f| f.as_name().is_some_and(supported)), - _ => false, + // A CCITT fax image (its last filter) decodes through `Document::decoded_image_within`, after the + // filters before it, so a scan stored as Group 4 is cleared like any other image. + let fax = |f: &Object| f.as_name().is_some_and(|n| matches!(n, b"CCITTFaxDecode" | b"CCF")); + let readable = |f: &Object| f.as_name().is_some_and(supported); + let (ok, is_fax) = match s.dict.get(b"Filter") { + None => (true, false), + Some(f @ Object::Name(_)) => (readable(f) || fax(f), fax(f)), + Some(Object::Array(a)) => match a.split_last() { + Some((last, before)) if fax(last) => (before.iter().all(readable), true), + _ => (a.iter().all(readable), false), + }, + _ => (false, false), }; if !ok { return None; } + // Group 3 on bytes is removed whole for now. The decoder reads it by its end-of-line codes + // when the data starts with one, but PdfCraft's renderer draws such rows (the fill ending + // each code on a byte) as other rows, so the pixels a mark covers on screen needn't be the + // ones cleared. + if is_fax && doc.fax_params(s).is_none_or(|p| p.k >= 0 && p.encoded_byte_align) { + return None; + } let (w, h) = (s.dict.int(b"Width")?, s.dict.int(b"Height")?); if w <= 0 || h <= 0 || (w as u64) * (h as u64) > MAX_PIXELS { return None; @@ -78,17 +94,29 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] let row_bits = row.checked_mul(8)?; bytes.checked_mul(8)?; // Decode no further than the raster can need: a filter stage may legitimately produce up to - // twice it (ASCIIHex text under Flate) plus a PNG predictor's byte per row. Data past that - // can only be a decompression bomb; the image is then removed whole. - let allowance = bytes.checked_add(h)?.checked_mul(2)?.checked_add(4096)?; - let mut data = s.decoded_within(allowance).ok()?; + // three bytes for each of it and a PNG predictor's byte per row (ASCIIHex text under Flate, + // two digits a byte broken into lines or spaced), as the optimizer allows. A fax image's + // code, which the filters before its decoder produce, takes at most about a byte per pixel + // plus each row's end. Data past that can only be a decompression bomb; the image is then + // removed whole. + let allowance = if is_fax { + w.checked_mul(h)?.checked_add(h.checked_mul(4)?)?.checked_add(4096)? + } else { + bytes.checked_add(h)?.checked_mul(3)?.checked_add(4096)? + }; + // Through the document, which reads `/DecodeParms` given by reference (`Stream::decoded*` + // alone would skip such a predictor, and the cleared bytes would then miss the covered pixels). + let mut data = doc.decoded_image_within(s, allowance).ok()?; if data.len() < bytes { return None; } data.truncate(bytes); // Image masks: a sample of 0 paints unless /Decode is [1 0]; cleared samples must not paint. let clear_bit = if mask { - let inverted = s.dict.get(b"Decode").and_then(Object::as_array).and_then(|d| d.first()).and_then(Object::as_f64) == Some(1.0); + // The array, and its first number, given by reference too. + let decode = s.dict.get(b"Decode").map(|d| doc.resolve(d)); + let first = decode.as_deref().and_then(Object::as_array).and_then(|d| d.first()).map(|v| doc.resolve(v)); + let inverted = first.as_deref().and_then(Object::as_f64) == Some(1.0); !inverted } else { false diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 3baec29c2c..ac69c73acb 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -326,7 +326,10 @@ struct Piece { /// The interpreter state carried across the operators of a scope. struct Walk { gs: Gs, - stack: Vec, + /// With each saved state, the text and text line matrices: inside a text object `q`/`Q` + /// save and restore them too (ISO 32000-2 §9.4.1 as corrected by the PDF Association's + /// errata, issue 368), as the renderer does. + stack: Vec<(Gs, Matrix, Matrix)>, tm: Matrix, tlm: Matrix, fonts_res: Dict, @@ -437,10 +440,10 @@ impl Walk { fn graphics_state(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) { match op.op.as_slice() { - b"q" => self.stack.push(self.gs.clone()), + b"q" => self.stack.push((self.gs.clone(), self.tm, self.tlm)), b"Q" => { - if let Some(g) = self.stack.pop() { - self.gs = g; + if let Some((g, m, lm)) = self.stack.pop() { + (self.gs, self.tm, self.tlm) = (g, m, lm); } } b"cm" => { diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index c072d19db7..125f00fc5c 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -549,6 +549,193 @@ fn redaction_clears_pixels_intersected_without_covering_their_centres() { } } +/// An image whose PNG predictor is given by reference has its covered pixel cleared, and only +/// that one, whichever part is the reference: the dictionary, an array's entry, or the number +/// itself. `Stream::decoded` reads only a direct `/DecodeParms` with direct numbers, so the row +/// tags stayed in the data, the cleared byte drifted off the covered pixel and the covered sample +/// survived. +#[test] +fn an_image_whose_predictor_is_given_by_reference_has_the_covered_pixel_cleared() { + // Two rows of four grey samples, each behind a PNG "None" row tag. + let predicted = pdfcraft_filters::encode_flate(&[0, 10, 20, 30, 40, 0, 50, 60, 70, 80]); + let parms = "/Predictor 15 /Colors 1 /BitsPerComponent 8 /Columns 4"; + for (decode_parms, object8) in [ + ("8 0 R".to_owned(), format!("<< {parms} >>")), + ("[8 0 R]".to_owned(), format!("<< {parms} >>")), + ("<< /Predictor 8 0 R /Colors 1 /BitsPerComponent 8 /Columns 4 >>".to_owned(), "15".to_owned()), + ] { + let image = format!( + "/Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /DecodeParms {decode_parms}" + ); + let mut doc = one_page(b"q 40 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&image, &predicted), object8.into_bytes()]); + // The top row's second sample. + mark(&mut doc, 0, &[[12.0, 12.0, 18.0, 18.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.images_cleared, 1, "{decode_parms}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"PCRedacted1"), Some(vec![10, 0, 30, 40, 50, 60, 70, 80]), "{decode_parms}"); + } +} + +/// A CCITT fax image (a scan the optimizer or Create PDF stored as Group 4) has its covered +/// pixels cleared in a copy, like any image PdfCraft can decode, instead of being removed whole: +/// whether it sits behind another filter, with its parameters given by reference, or is a mask, +/// its decode array inverting it given directly, by reference or with its first number so. +#[test] +fn a_fax_image_has_its_covered_pixels_cleared_instead_of_being_removed() { + // 16 × 2, all 1 bits (white), or all 0 bits (a mask painting everywhere); the left half of + // the top row is covered. + let g4 = pdfcraft_filters::encode_g4(&[0xFF; 4], 16, 2).unwrap(); + let painted = pdfcraft_filters::encode_g4(&[0x00; 4], 16, 2).unwrap(); + let hex: Vec = g4.iter().map(|b| format!("{b:02x}")).collect::().bytes().chain(*b">").collect(); + let image = "/Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1"; + let parms = "<< /K -1 /Columns 16 /Rows 2 >>"; + for (dict, data, extra, cleared) in [ + (format!("{image} /ColorSpace /DeviceGray /Filter /CCITTFaxDecode /DecodeParms {parms}"), g4.clone(), vec![], [0x00, 0xFF, 0xFF, 0xFF]), + ( + format!("{image} /ColorSpace /DeviceGray /Filter [/ASCIIHexDecode /CCITTFaxDecode] /DecodeParms [null 8 0 R]"), + hex, + vec![parms.as_bytes().to_vec()], + [0x00, 0xFF, 0xFF, 0xFF], + ), + // A mask's 0 bits paint: cleared bits are set to 1, which paints nothing. + (format!("{image} /ImageMask true /Filter /CCITTFaxDecode /DecodeParms {parms}"), painted, vec![], [0xFF, 0x00, 0x00, 0x00]), + // With /Decode [1 0] a mask's 1 bits paint, and cleared bits are 0. + (format!("{image} /ImageMask true /Decode [1 0] /Filter /CCITTFaxDecode /DecodeParms {parms}"), g4.clone(), vec![], [0x00, 0xFF, 0xFF, 0xFF]), + ( + format!("{image} /ImageMask true /Decode 8 0 R /Filter /CCITTFaxDecode /DecodeParms {parms}"), + g4.clone(), + vec![b"[1 0]".to_vec()], + [0x00, 0xFF, 0xFF, 0xFF], + ), + ( + format!("{image} /ImageMask true /Decode [8 0 R 0] /Filter /CCITTFaxDecode /DecodeParms {parms}"), + g4.clone(), + vec![b"1".to_vec()], + [0x00, 0xFF, 0xFF, 0xFF], + ), + ] { + let mut objs = vec![stream(&dict, data)]; + objs.extend(extra); + let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", objs); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (1, 0), "{dict}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"PCRedacted1").as_deref(), Some(&cleared[..]), "{dict}"); + } +} + +/// Images whose data is more than twice their raster before its last filter are cleared, not +/// taken for decompression bombs: grey stored as ASCIIHex text broken into lines under Flate (two +/// digits a byte and a line break every 64), and RGB behind an array of filters, which a fax +/// image's bound (a byte a pixel) would refuse. +#[test] +fn images_behind_ascii_hex_in_lines_or_a_filter_array_are_cleared() { + let (w, h) = (1000usize, 200usize); + let grey: Vec = (0..w * h).map(|i| (i % 251) as u8).collect(); + let digits: Vec = grey.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect(); + let mut lines: Vec = digits.chunks(64).flat_map(|line| line.iter().copied().chain(*b"\n")).collect(); + lines.push(b'>'); + let rgb: Vec = (0..100 * 100 * 3).map(|i| (i % 253) as u8).collect(); + let rgb_hex: Vec = pdfcraft_filters::encode_flate(&rgb).iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect(); + for (dict, data) in [ + ( + format!( + "/Type /XObject /Subtype /Image /Width {w} /Height {h} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter [/FlateDecode /ASCIIHexDecode]" + ), + pdfcraft_filters::encode_flate(&lines), + ), + ( + "/Type /XObject /Subtype /Image /Width 100 /Height 100 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter [/ASCIIHexDecode /FlateDecode]" + .to_string(), + rgb_hex, + ), + ] { + let mut doc = one_page(b"q 100 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, data)]); + mark(&mut doc, 0, &[[0.0, 0.0, 10.0, 10.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (1, 0), "{dict}"); + } +} + +/// A fax image that can't be decoded in full is removed whole, as any other image is: one whose +/// code is cut short, and one behind a filter that inflates past what a code for its pixels can +/// take (a decompression bomb, which `decoded_image` alone would undo up to a gigabyte). +#[test] +fn a_fax_image_that_cannot_be_decoded_in_full_is_removed_whole() { + let g4 = pdfcraft_filters::encode_g4(&[0xFF; 4], 16, 2).unwrap(); + // A busy picture's code, cut off in its first row. + let busy = pdfcraft_filters::encode_g4(&[0xAA, 0xAA, 0x55, 0x55], 16, 2).unwrap(); + let short = busy.get(..1).unwrap().to_vec(); + // The real code, then four megabytes of padding the fax decoder would never read. + let mut padded = g4.clone(); + padded.resize(4 << 20, 0); + let bomb = pdfcraft_filters::encode_flate(&padded); + let image = "/Type /XObject /Subtype /Image /Width 16 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 1"; + let parms = "<< /K -1 /Columns 16 /Rows 2 >>"; + for (filters, data) in [("/CCITTFaxDecode /DecodeParms", short), ("[/FlateDecode /CCITTFaxDecode] /DecodeParms [null", bomb)] { + let close = if filters.ends_with("[null") { "]" } else { "" }; + let dict = format!("{image} /Filter {filters} {parms}{close}"); + let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, data)]); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (0, 1), "{dict}"); + } +} + +/// Group 3 on bytes is removed whole: the renderer draws some of its layouts as other rows than +/// the decoder reads, so the pixels a mark covers on screen needn't be the ones cleared. Group 3 +/// off bytes and Group 4 on bytes are cleared. Each picture is 16 × 2 white, each row a white run +/// of 16 (`101010`): off bytes; on bytes with no end-of-line codes; on bytes after codes whose fill +/// ends each on a byte, at `/K 0` and at `/K 1` (a 1 after each code), `/EncodedByteAlign` given by +/// reference there; and Group 4 rows on bytes, each the one code 1 (vertical, no change). +#[test] +fn group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared() { + let image = "/Type /XObject /Subtype /Image /Width 16 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /CCITTFaxDecode"; + for (k, on_bytes, parms, data, extra, removed) in [ + (0, false, "<< /K 0 /Columns 16 >>", vec![0xAA, 0xA0], vec![], false), + (0, true, "<< /K 0 /Columns 16 /EncodedByteAlign true >>", vec![0xA8, 0xA8], vec![], true), + (0, true, "<< /K 0 /Columns 16 /EncodedByteAlign true >>", vec![0x00, 0x01, 0xA8, 0x00, 0x01, 0xA8], vec![], true), + (1, true, "<< /K 1 /Columns 16 /EncodedByteAlign 8 0 R >>", vec![0x00, 0x01, 0xD4, 0x00, 0x01, 0xD4], vec![b"true".to_vec()], true), + (-1, true, "<< /K -1 /Columns 16 /EncodedByteAlign true >>", vec![0x80, 0x80], vec![], false), + ] { + let p = pdfcraft_filters::CcittParams { k, columns: 16, encoded_byte_align: on_bytes, ..Default::default() }; + assert_eq!(pdfcraft_filters::decode_ccitt(&data, &p, 2).unwrap(), [0xFF; 4], "{parms}: the picture is white"); + let dict = format!("{image} /DecodeParms {parms}"); + let mut objs = vec![stream(&dict, data)]; + objs.extend(extra); + let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", objs); + mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], ""); + let report = apply(&mut doc, None).unwrap(); + if removed { + assert_eq!((report.images_cleared, report.images_removed), (0, 1), "{parms}"); + } else { + assert_eq!((report.images_cleared, report.images_removed), (1, 0), "{parms}"); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"PCRedacted1").as_deref(), Some(&[0x00, 0xFF, 0xFF, 0xFF][..]), "{parms}"); + } + } +} + +/// A busy fax image is cleared, though its code is bigger than three times its raster (the bound +/// for other images): a code takes up to about a byte per pixel, and the fax image gets that much. +#[test] +fn a_busy_fax_image_behind_another_filter_is_cleared() { + // 256 × 256, rows of a one-pixel checkerboard between white ones: each checkered row is coded + // afresh against the white one above it, about four bits a pixel. + let rows: Vec = (0..256).flat_map(|y| [if y % 2 == 0 { 0xAA } else { 0xFF }; 32]).collect(); + let g4 = pdfcraft_filters::encode_g4(&rows, 256, 256).unwrap(); + let raster = 32 * 256; + assert!(g4.len() > (raster + 256) * 3 + 4096, "{} bytes: within the bound for other images", g4.len()); + let dict = "/Type /XObject /Subtype /Image /Width 256 /Height 256 /ColorSpace /DeviceGray /BitsPerComponent 1 \ + /Filter [/FlateDecode /CCITTFaxDecode] /DecodeParms [null << /K -1 /Columns 256 /Rows 256 >>]"; + let mut doc = one_page(b"q 256 0 0 256 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(dict, pdfcraft_filters::encode_flate(&g4))]); + mark(&mut doc, 0, &[[0.0, 0.0, 10.0, 10.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.images_cleared, report.images_removed), (1, 0)); +} + /// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through /// before (inherited from the /Pages node, or shared with another page) still named the removed /// image and form, so a full-rewrite save kept them. 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 36299d74fd..85867770fc 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -3591,6 +3591,86 @@ fn jbig2_symbol_dictionaries_that_never_advance_terminate() { } } +/// A JBIG2 stream can put a region, a reference or a symbol at the very end of what an `i32` holds, +/// where adding to it overflowed: a panic wherever overflow is checked, a build's own tests included. +/// A region or a symbol out there is off the page, so nothing draws. Vendored hayro-jbig2 patch: the arithmetic wraps, as +/// a release build's did, or in `Bitmap::combine` saturates, which comes to the same. Three of the text +/// streams are arithmetic-coded: one symbol on a strip of -2^31 + 1, with its bottom left as the corner +/// placed, one on a strip of -2^31, and one refined by an offset of `i32::MIN`, which reads nothing of +/// the old symbol and still draws. Two are Huffman-coded, each with its first S at an offset of +/// 0x7FFFFFFF on a table's 32-bit range line, past what an `i32` holds, and are refused further on. +#[test] +fn jbig2_placements_at_the_end_of_an_i32_draw_nothing() { + const FAR_CORNER: &str = "0000000030000100000013000000100000000c0000000000000000420000000000010000010000001e000003fffdff02fefefe00000001000000014fdc3ce0205df55c36bfffac00000002062001010000001f000000100000000c000000000000000004000000000001969747cbe88bffac"; + const STRIP: &str = "0000000030000100000013000000100000000c0000000000000000420000000000010000010000001e000003fffdff02fefefe00000001000000014fdda25e46956d3e0d3fffac00000002062001010000001f000000100000000c000000000000000004001000000001969747cbe5dbffac"; + const UPPER_LINE: &str = "0000000030000100000013000000100000000c0000000000000000420000000000010000010000001d000003fffdff02fefefe00000001000000014fdaea38c99641e0c8ffac000000020620010100000038000000100000000c00000000000000000400110000000000010100000000000000000000000000000000003f7fffffff0000000000000000"; + const LOWER_LINE: &str = "0000000030000100000013000000100000000c0000000000000000420000000000010000010000001d000003fffdff02fefefe00000001000000014fdaea38c99641e0c8ffac000000020620010100000038000000100000000c00000000000000000400110000000000010100000000000000000000000000000000003e7fffffff0000000000000000"; + const OFFSET: &str = "0000000030000100000013000000100000000c0000000000000000420000000000010000010000001e000003fffdff02fefefe00000001000000014fdda9172f614a037ddfffac0000000206200101000000260000000800000006000000000000000004801200000001a9695690b3e5f44a2f105e30d3ffac"; + let segment = |number: u32, kind: u8, data: &[u8]| { + let mut out = number.to_be_bytes().to_vec(); + out.extend_from_slice(&[kind, 0, 1]); + out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes()); + out.extend_from_slice(data); + out + }; + let side = 8u32.to_be_bytes(); + let page = |width: u32| { + let mut page = [width.to_be_bytes(), side].concat(); + page.extend_from_slice(&[0; 8]); // resolution + page.extend_from_slice(&[0, 0, 0]); // flags, striping + segment(0, 48, &page) + }; + let region = |width: u32, kind: u8, x: u32, y: u32, header: &[u8]| { + let mut region = [width.to_be_bytes(), side, x.to_be_bytes(), y.to_be_bytes()].concat(); + region.push(0); // combination operator + region.extend_from_slice(header); + region.extend_from_slice(&[0x5a; 64]); + [page(width), segment(1, kind, ®ion)].concat() + }; + let generic = |x, y| region(8, 38, x, y, &[0, 3, 0xff, 0xfd, 0xff, 2, 0xfe, 0xfe, 0xfe]); // template 0, AT pixels + // Immediate refinements of the page: template 0 with its usual AT pixels, with others, with typical + // prediction, and template 1. + let refined = |width, x, y, form: usize| { + region(width, 42, x, y, [&[0, 0xff, 0xff, 0xff, 0xff][..], &[0, 0xff, 0xff, 127, 127], &[2, 0xff, 0xff, 0xff, 0xff], &[1]][form]) + }; + let hex = |text: &str| (0..text.len() / 2).map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).unwrap()).collect::>(); + let ink = |jbig2: Vec, width: u32, height: u32| { + let content = b"q 20 0 0 20 5 5 cm /Im1 Do Q"; + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes()); + pdf.extend_from_slice(content); + pdf.extend_from_slice(b"\nendstream endobj\n"); + pdf.extend_from_slice( + format!("5 0 obj << /Type /XObject /Subtype /Image /Width {width} /Height {height} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", jbig2.len()) + .as_bytes(), + ); + pdf.extend_from_slice(&jbig2); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + page.rgba.as_chunks::<4>().0.iter().filter(|p| p[0] < 128).count() + }; + assert!(ink(generic(2, 0), 8, 8) > 0, "a region on the page draws"); + for form in 0..4 { + assert!(ink(refined(8, 0, 0, form), 8, 8) > 0, "a refinement on the page draws: {form}"); + } + let end = 0x7FFF_FFFFu32; + for (x, y) in [(end, 0), (0, end), (end - 7, 0), (0, end - 7), (end - 129, 0), (0, end - 129)] { + assert_eq!(ink(generic(x, y), 8, 8), 0, "generic at {x:#x}, {y:#x}"); + for form in 0..4 { + assert_eq!(ink(refined(8, x, y, form), 8, 8), 0, "refinement {form} at {x:#x}, {y:#x}"); + } + } + // Past a 32-bit word of the row, where the reference is read again. + assert_eq!(ink(refined(64, 0, end, 0), 64, 8), 0, "a wide refinement"); + assert_eq!(ink(hex(FAR_CORNER), 16, 12), 0, "a symbol's far side past the end"); + assert_eq!(ink(hex(STRIP), 16, 12), 0, "a strip of -2^31"); + assert!(ink(hex(OFFSET), 16, 12) > 0, "a symbol refined by an offset of i32::MIN"); + assert_eq!(ink(hex(UPPER_LINE), 16, 12), 0, "an upper range line past an i32"); + assert_eq!(ink(hex(LOWER_LINE), 16, 12), 0, "a lower range line past an i32"); +} + /// Item 1 of storytold/pdfcraft#623: a page's content used to be cut at `MAX_DECODED_STREAM`, /// so everything after the cut was lost (the text and the title block of the A1 sample). Content /// that expands by a few times over is read to its end, however large it is. @@ -7004,6 +7084,10 @@ mod default_colour_spaces; // (vendored hayro-syntax patch 28, hayro-interpret patch 55). mod devicen_inks; +// #3776: isolated DeviceCMYK groups blend in CMYK (vendored hayro-interpret patch 70, hayro +// patch 27). +mod isolated_cmyk_groups; + /// #3109 / #3116: clipping-only text (`7 Tr`) installs a clip for subsequent painting /// but draws no pixels itself. #[test] @@ -7035,3 +7119,246 @@ fn test_clip_only_text_raster_preserves_clipping_without_painting() { assert!(pixels.contains(&[0, 0, 255, 255]), "fill shows through the 7 Tr clip"); 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. +#[test] +fn a_malformed_token_does_not_end_the_content_stream() { + // One token between two text objects. `BEFORE` is drawn before it, `AFTER` after. + let page = |token: &str| { + let stream = format!("BT /F1 8 Tf 2 30 Td (BEFORE) Tj ET {token} BT /F1 8 Tf 2 10 Td (AFTER) Tj ET"); + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 60 40] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> endobj\n5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", stream.len()).as_bytes()); + pdf.extend_from_slice(stream.as_bytes()); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() }); + assert!(text.error.is_none(), "{token}: {:?}", text.error); + text.text.expect("a text layer").plain_text() + }; + + for token in [ + // A `re` with three operands: the typed iterator used to abort on a path operator it + // could not dispatch, which is what #3568 reports. + "20 10 10 re", + // Eleven operands: under the 256 the stack holds, so this one runs on its own last + // operand. Past the limit is covered by + // `an_operator_past_the_operand_limit_reads_its_own_operands` below. + "0 0 0 0 0 0 0 0 0 0 0 g", + // A stray delimiter: the lexer could not read an operator and jumped to the end. + ")", + // The same with no space after it. Stepping over the regular characters that follow a + // delimiter ate the next operator, so the page lost its second text object + // (#3636 review); only the delimiter is stepped over. + ")BT ET", + // A malformed `#` escape in a name, so neither an object nor an operator read. + "/C#2 gs", + // And the well-formed forms still work. + "20 10 10 5 re n", + "0.5 g", + ] { + let text = page(token); + assert!(text.contains("BEFORE"), "{token}: {text:?}"); + assert!(text.contains("AFTER"), "{token}: the rest of the page was lost: {text:?}"); + assert!(text.find("BEFORE") < text.find("AFTER"), "{token}: the page is read in order: {text:?}"); + } +} + +/// An operator preceded by more operands than the stack holds is applied to its own operands, +/// which are the ones nearest it, rather than to whichever of the strays survived. The parser +/// had a branch meant to drop such an instruction whole, dead because `Stack::push` always +/// reported success (noted in review of #3636); reviving it loses legitimate content, which +/// `an_operator_with_hundreds_of_operands_does_not_end_the_content` measures, so the dead branch +/// is gone and this pins what the ring buffer does instead. +#[test] +fn an_operator_past_the_operand_limit_reads_its_own_operands() { + // The strays are `1` and the operator's own operand is `0`, so the two answers differ: white + // if the operator read a stray, black if it read its own. + let page = |strays: usize| { + let stream = format!("{}0 g 0 0 20 20 re f", "1 ".repeat(strays)); + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] /Contents 4 0 R >> endobj\n4 0 obj << /Length {} >> stream\n{stream}\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + stream.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() }); + assert!(p.error.is_none(), "{strays} strays: {:?}", p.error); + p.rgba.as_chunks::<4>().0.to_vec() + }; + let all_black = |pixels: &[[u8; 4]]| pixels.iter().all(|p| *p == [0, 0, 0, 255]); + + // None, a few, the limit, and past it: the fill is the black the `g` asked for every time. + for strays in [0, 5, 255, 256, 257, 600] { + assert!(all_black(&page(strays)), "{strays} stray operands: the `g` reads its own operand"); + } +} + +/// Content after an inline image whose data holds `EI` is still read. This pins the property +/// but does **not** exercise the look-ahead gating added for #3636's review: accepting a false +/// `EI` truncates the image, while the text after the real one is read either way, and this +/// assertion passes with the gating removed. Showing the truncation needs valid ASCII85 data +/// that contains `EI`, which this fixture does not have. +#[test] +fn content_after_an_inline_image_holding_ei_is_still_read() { + // An ASCII85 image whose data holds `EI\n`, then an `re` with three operands and twenty + // `q`s -- enough steps to satisfy the look-ahead's counter if the bad `re` were skipped -- + // before the real `EI`. `AFTER` is drawn once the image is over. + let stream = b"q 100 0 0 100 10 10 cm BI /W 4 /H 4 /CS /G /BPC 8 /F /A85 ID EI +20 10 10 re q q q q q q q q q q q q q q q q q q q q EI Q BT /F1 8 Tf 2 4 Td (AFTER) Tj ET" + .to_vec(); + let mut pdf = + b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 120 120] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> endobj\n5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n" + .to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", stream.len()).as_bytes()); + pdf.extend_from_slice(&stream); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() }); + assert!(text.error.is_none(), "{:?}", text.error); + let page = text.text.expect("a text layer").plain_text(); + // The image ran to the real `EI`, so the text after it is the page's only text: the first + // `EI` inside the data was not taken for the end. + assert!(page.contains("AFTER"), "the content after the image was lost: {page:?}"); +} 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/raster/tests/isolated_cmyk_groups.rs b/crates/render/src/raster/tests/isolated_cmyk_groups.rs new file mode 100644 index 0000000000..c9cc9aa492 --- /dev/null +++ b/crates/render/src/raster/tests/isolated_cmyk_groups.rs @@ -0,0 +1,282 @@ +//! Isolated transparency groups whose colour space is `DeviceCMYK` blend in CMYK (ISO 32000-2 +//! §11.3.4, §11.3.5.3). + +use crate::{PageRenderer, RenderConfig, RenderRequest, RenderedPage, RequestKind}; +use std::sync::Arc; + +/// A print test file's patch of blend modes in isolated groups whose colour space is +/// `DeviceCMYK` drew an X in nearly every cell. The elements of a group blend in the +/// group's colour space, and a subtractive space's components are complemented before the +/// blend function and back after it (ISO 32000-2 §11.3.4); of the nonseparable modes, +/// §11.3.5.3 blends C, M and Y as complemented R, G and B and takes K from the backdrop +/// (from the source for Luminosity). PdfCraft blended the colours converted to RGB, so +/// magenta under black with Difference came out magenta instead of green. Vendored +/// hayro-interpret patch (70) and hayro patch (27): an isolated group (§11.4.7: its own +/// transparent backdrop) blending in DeviceCMYK is blended in CMYK. +/// +/// Each cell is a square painted in the CMYK the blend should give, with a smaller isolated +/// group on it whose two elements are tint A and then tint B with the cell's blend mode: +/// drawn right, the group is the colour of the square around it. +#[test] +fn isolated_groups_blend_in_device_cmyk() { + // (blend mode, tint A, tint B, A then B blended in DeviceCMYK, worked out by hand) + type Cell = (&'static str, [f32; 4], [f32; 4], [f32; 4]); + let cells: [Cell; 7] = [ + // |(1,0,1,1) − (1,1,1,0)| = (0,1,0,1), complemented back: green. + ("Difference", [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0], [1.0, 0.0, 1.0, 0.0]), + ("Exclusion", [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0], [1.0, 0.0, 1.0, 0.0]), + ("Multiply", [0.0, 0.0, 1.0, 0.0], [0.0, 0.5, 0.0, 0.0], [0.0, 0.5, 1.0, 0.0]), + ("Screen", [0.0, 1.0, 1.0, 0.0], [0.5, 0.5, 1.0, 0.0], [0.0, 0.5, 1.0, 0.0]), + ("Darken", [0.0, 0.0, 1.0, 0.0], [0.0, 0.5, 0.0, 0.0], [0.0, 0.5, 1.0, 0.0]), + // Hue of (0,1,1) at the saturation and luminosity of (1,0.5,1): (0.355,0.855,0.855); + // K is the backdrop's. + ("Hue", [0.0, 0.5, 0.0, 0.3], [1.0, 0.0, 0.0, 0.0], [0.645, 0.145, 0.145, 0.3]), + // (1,0,1) at the luminosity of (0,1,0), clipped: (1,0.3051,1); K is the source's. + ("Luminosity", [0.0, 1.0, 0.0, 0.3], [1.0, 0.0, 1.0, 0.0], [0.0, 0.6949, 0.0, 0.0]), + ]; + let tint = |t: [f32; 4]| format!("{} {} {} {} k", t[0], t[1], t[2], t[3]); + // `group`: the cell group's /Group entries; `b`: what the second element paints. + let render = |group: &str, b: &dyn Fn([f32; 4]) -> String| { + let (mut page, mut objects, mut names) = (String::new(), String::new(), String::new()); + for (i, (mode, ta, tb, expected)) in cells.iter().enumerate() { + let x = 10 + i * 50; + page += &format!("{} {x} 10 40 40 re f /G{i} Do\n", tint(*expected)); + let rect = format!("{} 20 20 20 re f", x + 10); + let n = 10 + i * 3; + for (k, paint) in [(n, tint(*ta)), (n + 1, b(*tb))] { + let body = format!("{paint} {rect}"); + objects += &format!( + "{k} 0 obj << /Type /XObject /Subtype /Form /BBox [{x} 10 {} 50] /Group << /S /Transparency /CS /DeviceCMYK >> /Length {} >> stream\n{body}\nendstream endobj\n", + x + 40, + body.len() + ); + } + let body = "/E1 Do /BM gs /E2 Do"; + objects += &format!( + "{} 0 obj << /Type /XObject /Subtype /Form /BBox [{x} 10 {} 50] /Group << /S /Transparency {group} >> /Resources << /XObject << /E1 {n} 0 R /E2 {} 0 R >> /ExtGState << /BM << /BM /{mode} >> >> >> /Length {} >> stream\n{body}\nendstream endobj\n", + n + 2, + x + 40, + n + 1, + body.len() + ); + names += &format!("/G{i} {} 0 R ", n + 2); + } + 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 370 60] /Group << /S /Transparency /CS /DeviceCMYK >> /Contents 4 0 R /Resources << /XObject << {names}>> >> >> endobj +4 0 obj << /Length {} >> stream +{page} +endstream endobj +{objects}trailer << /Root 1 0 R >> +%%EOF", + page.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(p.error.is_none(), "{group}: {:?}", p.error); + p + }; + let px = |p: &RenderedPage, x: usize, y: u32| p.rgba[((y * p.width + x as u32) * 4) as usize..][..3].to_vec(); + let distance = |a: &[u8], b: &[u8]| a.iter().zip(b).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); + + let p = render("/CS /DeviceCMYK /I true", &tint); + for (i, (mode, ..)) in cells.iter().enumerate() { + let (group, square) = (px(&p, 30 + i * 50, 30), px(&p, 14 + i * 50, 30)); + // The square is a solid CMYK fill; the group lands within rounding of it. + assert!(distance(&group, &square) <= 3, "{mode}: group {group:?}, square {square:?}"); + } + + // A group whose contents have no ink tints (here the second element is RGB) is + // composited in RGB exactly as before: the same as a group blending in DeviceRGB. + let rgb = |_: [f32; 4]| "1 0 0 rg".to_string(); + let cmyk_group = render("/CS /DeviceCMYK /I true", &rgb); + let rgb_group = render("/CS /DeviceRGB /I true", &rgb); + assert_eq!(cmyk_group.rgba, rgb_group.rgba, "contents without tints are drawn as before"); +} + +/// Two cases from the review of #3777. A group wider than about two of vello_cpu's tiles lost +/// its blend mode in the tiles a paint covered completely, which drew black over magenta at +/// 300 dpi where Difference gives green; each pass now draws inside a layer. And a nested +/// isolated group that names another blending space (here DeviceRGB) blends in that space +/// (ISO 32000-2 §11.3.4), so the enclosing CMYK group is drawn as before; a non-isolated +/// one ignores its /CS and blends in the enclosing group's CMYK (Table 145). +#[test] +fn isolated_cmyk_groups_blend_at_any_size_and_respect_nested_spaces() { + let render = |inner_cs: Option<&str>, scale: f32| { + // `inner_cs`: the nested group's /CS and /I entries. + let group = "0 1 0 0 k 0 0 200 200 re f /Diff gs 0 0 0 1 k 0 0 200 200 re f"; + let (outer_body, extra) = match inner_cs { + None => (group.to_string(), String::new()), + Some(cs) => ( + "/In Do".to_string(), + format!( + "6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency {cs} >> /Resources << /ExtGState << /Diff << /BM /Difference >> >> >> /Length {} >> stream\n{group}\nendstream endobj\n", + group.len() + ), + ), + }; + let page = "q 1 0 0 1 10 70 cm /X0 Do Q 1 0 1 0 k 10 10 200 50 re f"; + 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 220 280] /Contents 4 0 R /Resources << /XObject << /X0 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{page} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS /DeviceCMYK /I true >> /Resources << /XObject << /In 6 0 R >> /ExtGState << /Diff << /BM /Difference >> >> >> /Length {} >> stream +{outer_body} +endstream endobj +{extra}trailer << /Root 1 0 R >> +%%EOF", + page.len(), + outer_body.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale, tag: 0, ..Default::default() }); + assert!(p.error.is_none(), "{:?}", p.error); + p + }; + let px = |p: &RenderedPage, x: f32, y: f32, scale: f32| { + let (x, y) = ((x * scale) as u32, (y * scale) as u32); + p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec() + }; + let distance = |a: &[u8], b: &[u8]| a.iter().zip(b).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); + + // 200 pt at scale 4 is 800 px: the centre lies in tiles the paints cover completely. + for scale in [1.0, 4.0] { + let p = render(None, scale); + let strip = px(&p, 110.0, 245.0, scale); + for (x, y) in [(110.0, 110.0), (15.0, 15.0), (110.0, 205.0)] { + let group = px(&p, x, y, scale); + assert!(distance(&group, &strip) <= 3, "scale {scale} at ({x}, {y}): group {group:?}, strip {strip:?}"); + } + } + + // A nested DeviceRGB group: the whole CMYK group falls back, so the page is drawn exactly + // as when the enclosing group is not offered to the CMYK path at all. + let nested_rgb = render(Some("/CS /DeviceRGB /I true"), 1.0); + let green = px(&nested_rgb, 110.0, 245.0, 1.0); + assert!(distance(&px(&nested_rgb, 110.0, 110.0, 1.0), &green) > 100, "the nested group blends in RGB, not CMYK"); + // A nested DeviceCMYK group stays in the CMYK path. + let nested_cmyk = render(Some("/CS /DeviceCMYK /I true"), 1.0); + assert!(distance(&px(&nested_cmyk, 110.0, 110.0, 1.0), &green) <= 3); + // A non-isolated nested group's /CS is ignored (ISO 32000-2 Table 145): it blends in the + // enclosing group's CMYK, even when it names DeviceRGB. + let non_isolated_rgb = render(Some("/CS /DeviceRGB /I false /K false"), 1.0); + assert!(distance(&px(&non_isolated_rgb, 110.0, 110.0, 1.0), &green) <= 3, "a non-isolated nested group inherits CMYK"); +} + +/// An element of a knockout group is first drawn in erase mode, which clears what the group +/// drew under the element's shape. An isolated `DeviceCMYK` group nested in that element must +/// clear too: when the erase pass drew it through the CMYK path, it painted over the red +/// beneath instead of clearing it. The page must match the same element with its nested group +/// blending in `DeviceRGB`, which never takes the CMYK path. +#[test] +fn an_isolated_cmyk_group_inside_a_knockout_element_is_erased_like_any_other() { + let render = |inner_cs: &str| { + let knockout = "1 0 0 rg 20 20 160 160 re f /El Do"; + let element = "/Cm Do"; + let inner = "/A gs 0 1 0 0 k 60 60 80 80 re f"; + 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 200] /Contents 4 0 R /Resources << /XObject << /Ko 5 0 R >> >> >> endobj +4 0 obj << /Length 7 >> stream +/Ko Do +endstream endobj +5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /I true /K true >> /Resources << /XObject << /El 6 0 R >> >> /Length {} >> stream +{knockout} +endstream endobj +6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency >> /Resources << /XObject << /Cm 7 0 R >> >> /Length {} >> stream +{element} +endstream endobj +7 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS {inner_cs} /I true >> /Resources << /ExtGState << /A << /ca 0.5 >> >> >> /Length {} >> stream +{inner} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + knockout.len(), + element.len(), + inner.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(p.error.is_none(), "{inner_cs}: {:?}", p.error); + // The centre of the inner square, (100, 100), is also the centre of the page. + p.rgba[((100 * p.width + 100) * 4) as usize..][..3].to_vec() + }; + let rgb = render("/DeviceRGB"); + let cmyk = render("/DeviceCMYK"); + assert!(rgb[2] > 150, "the RGB control shows half-opacity magenta over white, not red: {rgb:?}"); + assert!(rgb.iter().zip(&cmyk).all(|(a, b)| a.abs_diff(*b) <= 3), "CMYK {cmyk:?}, RGB {rgb:?}"); +} + +/// A minimal page with `page` as its content, a shading pattern `/P` and an isolated +/// `DeviceCMYK` group `/G` over `bbox` drawing `group`; the RGB of device pixel (`x`, `y`). +fn cmyk_group_pixel(media: [u32; 2], page: &str, bbox: [u32; 4], group: &str, group_resources: &str, (x, y): (u32, u32)) -> Vec { + 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 {} {}] /Contents 4 0 R /Resources << /XObject << /G 5 0 R >> /Pattern << /P 6 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{page} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Form /BBox [{} {} {} {}] /Group << /S /Transparency /CS /DeviceCMYK /I true >> /Resources << {group_resources} >> /Length {} >> stream +{group} +endstream endobj +6 0 obj << /Type /Pattern /PatternType 2 /Matrix [2 0 0 2 30 40] /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 10 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> >> >> endobj +trailer << /Root 1 0 R >> +%%EOF", + media[0], + media[1], + page.len(), + bbox[0], + bbox[1], + bbox[2], + bbox[3], + group.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(p.error.is_none(), "{:?}", p.error); + p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec() +} + +/// From the review of #5228: a shading pattern painted before the group left its pattern +/// transform on the context, and the finished group was drawn through it, so the cyan half +/// of the group showed magenta. The group must look the same whether or not a pattern was +/// painted first. +#[test] +fn a_pattern_painted_before_an_isolated_cmyk_group_does_not_move_it() { + let group = "0 1 0 0 k 20 20 30 60 re f 1 0 0 0 k 50 20 30 60 re f"; + let render = |page: &str| cmyk_group_pixel([100, 100], page, [20, 20, 80, 80], group, "", (55, 50)); + let after_pattern = render("/Pattern cs /P scn 0 0 5 5 re f /G Do"); + let alone = render("/G Do"); + assert!(alone[2] > 150 && alone[0] < 100, "the control is cyan: {alone:?}"); + assert!(alone.iter().zip(&after_pattern).all(|(a, b)| a.abs_diff(*b) <= 3), "after a pattern {after_pattern:?}, alone {alone:?}"); +} + +/// From the review of #5228: the Color blend mode over an isolated group's empty backdrop +/// keeps the source colour (ISO 32000-2 §11.3.5.3 with nothing beneath). The K ink pass +/// draws gray, and vello_cpu's nonseparable blend of a gray darkened some K levels to +/// near black. Every 8-bit K level is checked against a plain fill of the same ink. +#[test] +fn the_color_blend_mode_over_an_empty_cmyk_group_keeps_every_k_level() { + let mut wrong = Vec::new(); + for level in 0..=255u8 { + let k = format!("0 0 0 {} k", f32::from(level) / 255.0); + let page = format!("{k} 0 0 20 20 re f /G Do"); + let group = format!("/B gs {k} 20 0 20 20 re f"); + let plain = cmyk_group_pixel([40, 20], &page, [20, 0, 40, 20], &group, "/ExtGState << /B << /BM /Color >> >>", (10, 10)); + let blended = cmyk_group_pixel([40, 20], &page, [20, 0, 40, 20], &group, "/ExtGState << /B << /BM /Color >> >>", (30, 10)); + if plain.iter().zip(&blended).any(|(a, b)| a.abs_diff(*b) > 3) { + wrong.push((level, plain, blended)); + } + } + assert!(wrong.is_empty(), "K levels drawn wrong (level, plain, blended): {wrong:?}"); +} 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/render/src/text.rs b/crates/render/src/text.rs index 054262ee35..50257d8248 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -385,7 +385,7 @@ pub use layout::layout; use layout::{from_upright, to_upright}; use layout::{is_rtl, union}; #[cfg(test)] -use order::{Run, Scratch, collide, restarts_run}; +use order::{Run, Scratch, Seg, collide, restarts_run}; #[cfg(test)] mod tests; diff --git a/crates/render/src/text/layout.rs b/crates/render/src/text/layout.rs index 00b45e266c..99afc0f8a3 100644 --- a/crates/render/src/text/layout.rs +++ b/crates/render/src/text/layout.rs @@ -1,5 +1,5 @@ use super::device::{DIRECTION_TOLERANCE, Drawn, WORD_GAP}; -use super::order::{NONE, REDRAW_CANDIDATES, Run, Scratch, Seg, fingerprint, place_glyphs, redraws, restarts_run, reverse_rtl}; +use super::order::{NONE, REDRAW_CANDIDATES, Run, Scratch, Seg, fingerprint, is_script_on, place_glyphs, redraws, restarts_run, reverse_rtl}; use super::{HashMap, PageText, TextGlyph}; /// Glyphs in reading order, as indices into the page's glyphs, with their lines and word gaps. @@ -177,14 +177,19 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { let mut furthest_progress = 0.0; for i in 0..n { let g = glyphs[i].rect; + let hi = height(i); let cont = segs.last().is_some_and(|s| { let Some(p) = s.first.end.checked_sub(1) else { return false }; - let ph = height(p).min(height(i)); + let ph = height(p).min(hi); + let sh = s.h.min(hi); let gap = g[0] - glyphs[p].rect[2]; // A sub- or superscript is a smaller glyph on a shifted baseline: its em box centre sits off the line by // more than half its own height, but by less than half the height of the full-size glyph beside it. - let centre_limit = if gap.abs() < ph { height(p).max(height(i)) * 0.5 } else { ph * 0.5 }; - (cy(i) - cy(p)).abs() < centre_limit && gap < ph * 3.0 && g[0] > glyphs[p].rect[0] - ph * 2.0 + let centre_limit = if gap.abs() < ph { height(p).max(hi) * 0.5 } else { ph * 0.5 }; + let same_line = ((cy(i) - cy(p)).abs() < centre_limit && gap < ph * 3.0) + || ((cy(i) - s.cy()).abs() < sh * 0.3 && (s.h / hi - 1.0).abs() < 0.35 && gap < sh * 3.0); + let on_seg = gap < s.h * 1.2 && (is_script_on(&s.bbox, s.h, &g, hi) || is_script_on(&g, hi, &s.bbox, s.h)); + (same_line || on_seg) && g[0] > glyphs[p].rect[0] - sh * 2.0 }); let reset = cont && segs.last().is_some_and(|s| restarts_run(&glyphs, s.first.start, i, furthest_progress)); if cont @@ -192,11 +197,10 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { && let Some(s) = segs.last_mut() { s.first.end = i + 1; - union(&mut s.bbox, &g); - s.h = s.h.max(height(i)); + s.extend_box(&g, hi); furthest_progress = furthest_progress.max((g[0] - run_start_x).abs()).max((g[2] - run_start_x).abs()); } else { - segs.push(Seg::new(Run { start: i, end: i + 1 }, g, height(i))); + segs.push(Seg::new(Run { start: i, end: i + 1 }, g, hi)); run_start_x = g[0]; furthest_progress = 0.0; } @@ -245,7 +249,8 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { kept_runs }; segs.truncate(retained); - // Merge same-baseline segments that nearly touch. + // Merge same-baseline segments that nearly touch, then attach any remaining superscript or + // subscript segments to the closest host line segment they sit on. let mut merged = true; while merged { merged = false; @@ -266,6 +271,30 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { } } } + if !merged { + let mut b = 0; + while b < segs.len() { + let sb = &segs[b]; + let best_host = (0..segs.len()) + .filter(|&a| { + if a == b { + return false; + } + let sa = &segs[a]; + let gap = (sb.bbox[0] - sa.bbox[2]).max(sa.bbox[0] - sb.bbox[2]); + sa.bbox[2] - sa.bbox[0] > sb.bbox[2] - sb.bbox[0] && gap < sa.h * 1.2 && is_script_on(&sa.bbox, sa.h, &sb.bbox, sb.h) + }) + .min_by(|&a1, &a2| (segs[a1].cy() - sb.cy()).abs().total_cmp(&(segs[a2].cy() - sb.cy()).abs())); + if let Some(a) = best_host { + let sb = segs.remove(b); + let a = if b < a { a - 1 } else { a }; + segs[a].merge(sb); + merged = true; + } else { + b += 1; + } + } + } } // 2. Blocks. diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index d8cb65f32c..efcabb5f1a 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -1,5 +1,5 @@ use super::TextGlyph; -use super::layout::{is_rtl, union}; +use super::layout::is_rtl; /// Glyphs `start..end` of the glyph list [`arrange`] lays out, drawn one after another. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -37,6 +37,32 @@ struct SegExtra { more: Vec, } +/// Whether `(script_bbox, script_h)` is a superscript or subscript on `(host_bbox, host_h)`. +pub(super) fn is_script_on(host_bbox: &[f32; 4], host_h: f32, script_bbox: &[f32; 4], script_h: f32) -> bool { + let host_cy = (host_bbox[1] + host_bbox[3]) / 2.0; + let script_cy = (script_bbox[1] + script_bbox[3]) / 2.0; + let script_w = script_bbox[2] - script_bbox[0]; + let host_w = host_bbox[2] - host_bbox[0]; + let smaller = script_h >= host_h * 0.45 && script_w < host_h * 3.0 && (script_h < host_h * 0.9 || (script_h <= host_h && host_w > script_w)); + if !smaller { + return false; + } + if script_cy < host_cy { + // Superscript: raised above the host baseline, with its bottom inside the host line. + host_cy - script_cy >= host_h * 0.12 + && host_cy - script_cy < host_h * 0.7 + && host_bbox[3] - script_bbox[3] >= host_h * 0.15 + && script_bbox[3] > host_bbox[1] + host_h * 0.25 + } else { + // Subscript: lowered below the host baseline, with its centre still inside the host line. + script_cy - host_cy >= host_h * 0.12 + && script_cy - host_cy < host_h * 0.5 + && script_bbox[3] > host_bbox[3] + && host_bbox[3] > script_bbox[1] + script_h * 0.5 + && script_bbox[1] >= host_bbox[1] - script_h * 0.2 + } +} + impl Seg { pub(super) fn new(first: Run, bbox: [f32; 4], h: f32) -> Self { Seg { first, extra: None, bbox, h } @@ -50,9 +76,25 @@ impl Seg { std::iter::once(self.first).chain(self.extra.iter().flat_map(|e| e.more.iter().copied())) } + /// Extend `self`'s box and height with `(bbox, h)`. A smaller script (superscript or + /// subscript) widens the line horizontally without pulling its vertical baseline band up or + /// down; when a line begins with a script and continues with taller body text, the body text's + /// vertical band becomes the line's. + pub(super) fn extend_box(&mut self, bbox: &[f32; 4], h: f32) { + if h > self.h * 1.15 || is_script_on(bbox, h, &self.bbox, self.h) { + self.bbox[1] = bbox[1]; + self.bbox[3] = bbox[3]; + } else if !(self.h > h * 1.15 || is_script_on(&self.bbox, self.h, bbox, h)) { + self.bbox[1] = self.bbox[1].min(bbox[1]); + self.bbox[3] = self.bbox[3].max(bbox[3]); + } + self.bbox[0] = self.bbox[0].min(bbox[0]); + self.bbox[2] = self.bbox[2].max(bbox[2]); + self.h = self.h.max(h); + } + pub(super) fn merge(&mut self, other: Seg) { - union(&mut self.bbox, &other.bbox); - self.h = self.h.max(other.h); + self.extend_box(&other.bbox, other.h); let extra = self.extra.get_or_insert_with(Default::default); extra.more.push(other.first); if let Some(o) = other.extra { @@ -274,8 +316,10 @@ pub(super) fn collide(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, h: f } budget -= 1; let (ra, rb) = (union_find(&mut sc.parent, ri), union_find(&mut sc.parent, rj)); + let hj = height(j); + let script = is_script_on(&glyphs[i].rect, hi, &glyphs[j].rect, hj) || is_script_on(&glyphs[j].rect, hj, &glyphs[i].rect, hi); let overlap = x1.min(glyphs[j].rect[2]) - x0(j); - if ra != rb && overlap > RUN_COLLISION * hi.min(height(j)) { + if ra != rb && !script && overlap > RUN_COLLISION * hi.min(hj) { sc.parent[ra] = rb; collided = true; } diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 836157e738..a1af42a8d0 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -1760,6 +1760,137 @@ fn truetype_without_an_encoding_reads_its_codes_as_win_ansi() { assert_eq!(ocr_layer_text(32, true), expected, "nonsymbolic, /ToUnicode for ASCII only"); } +/// Issue #2929: a footnote reference (superscript) on a line stays in that line instead of +/// splitting out as a raised segment that steals the block's tail and pushes the host line +/// after the paragraph. +#[test] +fn paragraph_selection_includes_lines_with_footnote_references() { + for inline in [true, false] { + let ops = if inline { + "BT /F1 12 Tf 1 0 0 1 40 300 Tm (First line of the paragraph) Tj \ + 1 0 0 1 40 285 Tm (Second line with a note) Tj \ + /F1 8 Tf 5.5 Ts (1) Tj 0 Ts \ + /F1 12 Tf ( and more text) Tj \ + 1 0 0 1 40 270 Tm (Third line of the paragraph) Tj \ + /F1 7 Tf 1 0 0 1 40 104 Tm (1) Tj \ + /F1 10 Tf 1 0 0 1 47 100 Tm (Footnote at the bottom) Tj ET" + } else { + // Superscripts drawn in a separate pass after the body text. + "BT /F1 12 Tf 1 0 0 1 40 300 Tm (First line of the paragraph) Tj \ + 1 0 0 1 40 285 Tm (Second line with a note) Tj \ + 1 0 0 1 173 285 Tm (and more text) Tj \ + 1 0 0 1 40 270 Tm (Third line of the paragraph) Tj \ + /F1 10 Tf 1 0 0 1 47 100 Tm (Footnote at the bottom) Tj \ + /F1 8 Tf 1 0 0 1 165 290.5 Tm (1) Tj \ + /F1 7 Tf 1 0 0 1 40 104 Tm (1) Tj ET" + }; + let text = page_glyphs(ops); + let start = text.nearest(40.0, 95.0).unwrap(); + let end = text.nearest(185.0, 128.0).unwrap(); + assert_eq!( + text.text_of(start..end + 1), + "First line of the paragraph\nSecond line with a note1 and more text\nThird line of the paragraph", + "inline={inline}" + ); + assert_eq!(text.line_rects(start..end + 1).len(), 3, "three selected lines, inline={inline}"); + assert_eq!( + text.plain_text(), + "First line of the paragraph\nSecond line with a note1 and more text\nThird line of the paragraph\n1 Footnote at the bottom", + "inline={inline}" + ); + } + + // Superscript on the first line, multi-character bracketed reference `[12]` on the second + // line, and subscript on the third line (both inline and out-of-order). + for inline in [true, false] { + let ops = if inline { + "BT /F1 12 Tf 1 0 0 1 40 300 Tm (Opening claim) Tj \ + /F1 8 Tf 5.5 Ts (1) Tj 0 Ts \ + /F1 12 Tf ( in the first line) Tj \ + 1 0 0 1 40 285 Tm (Middle citation) Tj \ + /F1 8 Tf 5.5 Ts ([12]) Tj 0 Ts \ + /F1 12 Tf ( continues here) Tj \ + 1 0 0 1 40 270 Tm (Formula H) Tj \ + /F1 8 Tf -3 Ts (2) Tj 0 Ts \ + /F1 12 Tf (O in the third line) Tj ET" + } else { + "BT /F1 12 Tf 1 0 0 1 40 300 Tm (Opening claim) Tj \ + 1 0 0 1 125 300 Tm (in the first line) Tj \ + 1 0 0 1 40 285 Tm (Middle citation) Tj \ + 1 0 0 1 135 285 Tm (continues here) Tj \ + 1 0 0 1 40 270 Tm (Formula H) Tj \ + 1 0 0 1 101 270 Tm (O in the third line) Tj \ + /F1 8 Tf 1 0 0 1 117 305.5 Tm (1) Tj \ + 1 0 0 1 117 290.5 Tm ([12]) Tj \ + 1 0 0 1 96.5 267 Tm (2) Tj ET" + }; + let text = page_glyphs(ops); + assert_eq!( + text.plain_text(), + "Opening claim1 in the first line\nMiddle citation[12] continues here\nFormula H2O in the third line", + "inline={inline}" + ); + assert_eq!(text.line_rects(0..text.glyphs.len()).len(), 3, "inline={inline}"); + } + + // Justified wide gap (18pt = 1.5 * h) after an inline superscript stays on the same line, + // and an out-of-order superscript kerned slightly over punctuation does not break the host + // word via overprint collision. + let justified = page_glyphs( + "BT /F1 12 Tf 1 0 0 1 40 300 Tm (Justified clause.) Tj \ + /F1 8 Tf 5.5 Ts (1) Tj 0 Ts \ + /F1 12 Tf 1 0 0 1 151 300 Tm (Next sentence on the same line) Tj \ + 1 0 0 1 40 285 Tm (Kerned clause.) Tj \ + 1 0 0 1 130 285 Tm (continues cleanly) Tj \ + /F1 8 Tf 1 0 0 1 118 290.5 Tm (2) Tj ET", + ); + assert_eq!(justified.plain_text(), "Justified clause.1 Next sentence on the same line\nKerned clause.2 continues cleanly"); + assert_eq!(justified.line_rects(0..justified.glyphs.len()).len(), 2); + + // A line that begins with a raised superscript marker and continues with taller body text + // adopts the body text's vertical band (Seg::extend_box first branch) so a later + // out-of-order superscript on that line still measures against the body baseline. + let leading_script = page_glyphs( + "BT /F1 8 Tf 1 0 0 1 40 300 Tm 6 Ts (1) Tj 0 Ts \ + /F1 12 Tf ( See the appendix) Tj \ + 1 0 0 1 146 300 Tm (for details) Tj \ + /F1 8 Tf 1 0 0 1 139 303.6 Tm (2) Tj ET", + ); + assert_eq!(leading_script.plain_text(), "1 See the appendix2 for details"); + let marker = leading_script.glyphs.first().unwrap().rect; + let body = leading_script.glyphs.iter().find(|g| g.text == "S").unwrap().rect; + let last = leading_script.glyphs.last().unwrap().rect; + let center = |r: [f32; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); + let (start_x, start_y) = center(marker); + let (end_x, end_y) = center(last); + let start = leading_script.nearest(start_x, start_y).unwrap(); + let end = leading_script.nearest(end_x, end_y).unwrap(); + assert_eq!(leading_script.text_of(start..end + 1), "1 See the appendix2 for details"); + let rects = leading_script.line_rects(start..end + 1); + assert_eq!(rects.len(), 1); + assert!(marker[1] < body[1] && marker[3] < body[3], "marker must be raised above the body"); + let close = |actual: f32, expected: f32| assert!((actual - expected).abs() < 0.001, "{actual} != {expected}"); + // Highlight rectangles cover the actual glyphs, including the raised marker. They do + // not expose the segment's baseline band used for line grouping. + for (actual, expected) in rects[0].into_iter().zip([marker[0], marker[1], last[2], body[3]]) { + close(actual, expected); + } + close(rects[0][3] - rects[0][1], 14.8); + close(body[3] - 0.2 * 12.0, 100.0); // 300 pt PDF baseline on a 400 pt page. + + // Pin that internal band too: replacing the first extend_box condition with `false` + // must fail here even when plain text and highlight rectangles remain unchanged. + let mut segment = Seg::new(Run { start: 0, end: 1 }, marker, marker[3] - marker[1]); + segment.extend_box(&body, body[3] - body[1]); + for (actual, expected) in segment.bbox.into_iter().zip([marker[0], body[1], body[2], body[3]]) { + close(actual, expected); + } + close(segment.h, 12.0); + close(segment.bbox[3] - segment.bbox[1], 12.0); + close(segment.bbox[3] - 0.2 * segment.h, 100.0); + close(segment.cy(), (body[1] + body[3]) / 2.0); +} + #[test] fn text_outside_the_crop_box_is_clipped() { use lopdf::{Document, Object, Stream, dictionary}; diff --git a/crates/render/tests/it/knockout_backdrop.rs b/crates/render/tests/it/knockout_backdrop.rs new file mode 100644 index 0000000000..1b647b6169 --- /dev/null +++ b/crates/render/tests/it/knockout_backdrop.rs @@ -0,0 +1,190 @@ +//! #4886: a non-isolated knockout group (`/I false /K true`) starts from the backdrop under it +//! (ISO 32000-2 §11.4.7, §11.4.8), so each element blends with the page there, in its own blend +//! mode, and replaces the elements before it under its shape. White with `/BM /Difference` over +//! magenta is green; drawn into a layer of its own starting transparent, it stayed white. + +use std::sync::Arc; + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; + +const MAGENTA: [u8; 3] = [255, 0, 255]; +const GREEN: [u8; 3] = [0, 255, 0]; +const WHITE: [u8; 3] = [255, 255, 255]; + +fn pdf(objects: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +fn stream(dict: &str, content: &str) -> String { + format!("<< {dict} /Length {} >>\nstream\n{content}\nendstream", content.len()) +} + +/// Renders a 200 x 200 page: magenta, then the group `/Fk` with group attributes `group` and +/// content `content`. Its resources have `/D` (`/BM /Difference`), `/N` (`/BM /Normal`), `/H` +/// (`/ca 0.5`), `/W`, a white square from 40 to 160 in a group of its own, `/Nest`, a group +/// holding a non-isolated knockout group with a white Difference square from 80 to 120, and +/// `/Img`, a white 1 x 1 image, `/Img2`, a blue 2 x 1 image whose soft mask is opaque on the +/// left and transparent on the right, `/St`, a 2 x 1 stencil mask painting its left pixel, and +/// the shading `/Sh0`, a mesh whose one triangle lies off the page, and `/Sh1`, a red mesh +/// triangle from (90, 90) to (110, 90) and (100, 110). +/// Returns a lookup of the colour at PDF point (x, y). +fn render(group: &str, content: &str) -> impl Fn(u32, u32) -> [u8; 3] { + let gs = "/ExtGState << /D << /BM /Difference >> /H << /ca 0.5 >> /N << /BM /Normal >> >>"; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Group << /S /Transparency /CS /DeviceRGB >> /Resources << /XObject << /Fk 5 0 R >> >> >>" + .to_string(), + stream("", "1 0 1 rg 0 0 200 200 re f /Fk Do"), + stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS /DeviceRGB {group} >> /Resources << {gs} /XObject << /W 6 0 R /Nest 7 0 R /Img 9 0 R /Img2 10 0 R /St 12 0 R >> /Shading << /Sh0 13 0 R /Sh1 14 0 R >> >>"), + content, + ), + stream("/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency >>", "1 1 1 rg 40 40 120 120 re f"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency >> /Resources << /XObject << /K2 8 0 R >> >>", "/K2 Do"), + stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /I false /K true >> /Resources << {gs} >>"), + "/D gs 1 1 1 rg 80 80 40 40 re f", + ), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode", "FFFFFF>"), + stream( + "/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 11 0 R /Filter /ASCIIHexDecode", + "0000FF0000FF>", + ), + stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode", "FF00>"), + stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ImageMask true /BitsPerComponent 1 /Filter /ASCIIHexDecode", "40>"), + stream( + "/ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [300 310 300 310 0 1 0 1 0 1] /Filter /ASCIIHexDecode", + "000000FF0000 00FF00FF0000 0000FFFF0000>", + ), + stream( + "/ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Filter /ASCIIHexDecode", + "005A5AFF0000 006E5AFF0000 00646EFF0000>", + ), + ]; + let mut renderer = PageRenderer::new(Arc::new(pdf(&objects)), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(page.error.is_none(), "{content}: {:?}", page.error); + let (rgba, width) = (page.rgba.to_vec(), page.width); + move |x, y| { + // Pixel rows run top down. + let offset = usize::try_from(((200 - y) * width + x) * 4).unwrap(); + [rgba[offset], rgba[offset + 1], rgba[offset + 2]] + } +} + +fn check(at: &impl Fn(u32, u32) -> [u8; 3], x: u32, y: u32, want: [u8; 3], what: &str) { + let got = at(x, y); + assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 2), "{what} at ({x}, {y}): {got:?}, expected {want:?}"); +} + +const NON_ISOLATED: &str = "/I false /K true"; +const SQUARES: &str = "/D gs 1 1 1 rg 20 20 110 110 re f 70 70 110 110 re f"; + +#[test] +fn a_non_isolated_knockout_group_blends_each_element_with_the_page() { + let squares = render(NON_ISOLATED, SQUARES); + check(&squares, 40, 40, GREEN, "first square"); + check(&squares, 160, 160, GREEN, "second square"); + // The second square blends with the page, not with the first: green, not magenta again. + check(&squares, 100, 100, GREEN, "overlap"); + check(&squares, 190, 10, MAGENTA, "page outside the group's elements"); +} + +#[test] +fn the_last_element_over_a_point_decides_its_blend_mode() { + // Normal blue at 50 % after a Difference square: blue over the page where they overlap. + let blue_last = render(NON_ISOLATED, "/D gs 1 1 1 rg 20 20 110 110 re f /N gs 0 0 1 rg q /H gs 70 70 110 110 re f Q"); + check(&blue_last, 40, 40, GREEN, "Difference alone"); + check(&blue_last, 100, 100, [128, 0, 255], "Normal blue last"); + check(&blue_last, 160, 160, [128, 0, 255], "Normal blue alone"); + // The other way round: the Difference square replaces the blue and blends with the page. + let white_last = render(NON_ISOLATED, "q /H gs 0 0 1 rg 20 20 110 110 re f Q /D gs 1 1 1 rg 70 70 110 110 re f"); + check(&white_last, 40, 40, [128, 0, 255], "Normal blue alone"); + check(&white_last, 100, 100, GREEN, "Difference last"); +} + +#[test] +fn a_nested_group_in_a_non_isolated_knockout_group_blends_with_the_page() { + let nested = render(NON_ISOLATED, "0 0 1 rg 20 20 160 160 re f /D gs /W Do"); + check(&nested, 100, 100, GREEN, "nested group drawn with Difference over the knocked-out blue"); + check(&nested, 30, 30, [0, 0, 255], "blue beside the nested group"); +} + +#[test] +fn an_isolated_knockout_group_still_starts_transparent() { + let isolated = render("/I true /K true", SQUARES); + check(&isolated, 40, 40, WHITE, "isolated: nothing to blend with"); + check(&isolated, 100, 100, WHITE, "isolated overlap"); +} + +#[test] +fn an_image_and_a_partly_transparent_element_blend_with_the_page() { + let image = render(NON_ISOLATED, "/D gs q 120 0 0 120 40 40 cm /Img Do Q"); + check(&image, 100, 100, GREEN, "white image drawn with Difference"); + check(&image, 20, 20, MAGENTA, "page beside the image"); + // Difference at 50 %: half the page, half the blend result (green). + let half = render(NON_ISOLATED, "/D gs /H gs 1 1 1 rg 40 40 120 120 re f"); + check(&half, 100, 100, [128, 128, 128], "Difference at 50 %"); +} + +/// A knockout group nested in another one's element is drawn first in that element's erase +/// pass, which must leave the outer group's passes alone (review of #4886's fix). +#[test] +fn a_knockout_group_inside_an_element_keeps_the_outer_groups_passes() { + let nested = render(NON_ISOLATED, "/D gs 1 1 1 rg 10 10 50 50 re f /N gs /Nest Do /D gs 1 1 1 rg 140 140 50 50 re f"); + check(&nested, 30, 30, GREEN, "Difference square before the nested groups"); + check(&nested, 165, 165, GREEN, "Difference square after the nested groups"); +} + +/// A masked image (or, below, a stencil) in another pass than its own replaces what is under its mask +/// there too: a blue image over a white Difference square stays blue where its mask is opaque, +/// and leaves the square to blend with the page where it is transparent (review of #4886's fix). +#[test] +fn a_masked_image_replaces_what_is_under_its_mask_in_every_pass() { + let masked = render(NON_ISOLATED, "/D gs 1 1 1 rg 40 40 120 120 re f /N gs q 120 0 0 120 40 40 cm /Img2 Do Q"); + check(&masked, 60, 100, [0, 0, 255], "image over the square, mask opaque"); + check(&masked, 140, 100, GREEN, "square under the transparent half of the mask"); +} + +#[test] +fn a_stencil_replaces_what_is_under_it_in_every_pass() { + let stencil = render(NON_ISOLATED, "/D gs 1 1 1 rg 40 40 120 120 re f /N gs 0 0 1 rg q 120 0 0 120 40 40 cm /St Do Q"); + check(&stencil, 60, 100, [0, 0, 255], "stencil over the square"); + check(&stencil, 140, 100, GREEN, "square beside the stencil"); +} + +/// A shading that paints nothing (a mesh off the page) replaces nothing in another pass either. +#[test] +fn a_shading_that_paints_nothing_clears_nothing_in_another_pass() { + let mesh = render(NON_ISOLATED, "0 0 1 rg 20 20 160 160 re f /D gs /Sh0 sh"); + check(&mesh, 100, 100, [0, 0, 255], "blue under an off-page Difference mesh"); +} + +/// A shading replaces only what is under the part it paints: a small mesh triangle over a +/// square leaves the rest of the square, in its own pass, in another pass and in an isolated +/// knockout group. +#[test] +fn a_small_mesh_replaces_only_what_is_under_its_triangle() { + let square = "0 0 1 rg 20 20 160 160 re f"; + for (group, mode) in [(NON_ISOLATED, ""), (NON_ISOLATED, "/D gs"), ("/K true", "")] { + let content = format!("{square} {mode} /Sh1 sh"); + let mesh = render(group, &content); + check(&mesh, 40, 40, [0, 0, 255], &format!("{group} {mode}: square beside the triangle")); + if mode.is_empty() { + check(&mesh, 100, 97, [255, 0, 0], &format!("{group}: triangle")); + } + } +} diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index 7c396b6f50..5323279ca1 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -3,10 +3,15 @@ mod cid_fallback_query; mod inline_image_ei; mod jbig2_invariants; +mod knockout_backdrop; mod luminosity_group_space; mod named_form_properties; +mod nonseparable_gray; +mod open_type_cff_simple_font; mod search; mod separation_all; +mod stray_q_in_a_form; +mod stroke_degenerate_cubic; mod substitute_widths; mod tiling_cell_placement; mod type3_text_clip; diff --git a/crates/render/tests/it/nonseparable_gray.rs b/crates/render/tests/it/nonseparable_gray.rs new file mode 100644 index 0000000000..34847f9520 --- /dev/null +++ b/crates/render/tests/it/nonseparable_gray.rs @@ -0,0 +1,95 @@ +//! The nonseparable blend modes keep grays gray (ISO 32000-2 §11.3.5.3). vello_cpu 0.0.8's +//! `clip_color` divided 0 by 0 for grays that rounding left just outside 0..1 and drew them black +//! under `Color`. Upstream's report also has black under `Luminosity` over a gray going wrong; that +//! case already renders correctly here, so its test guards rather than reproduces. + +use std::sync::Arc; + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; + +fn pdf(objects: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +fn stream(dict: &str, content: &str) -> String { + format!("<< {dict} /Length {} >>\nstream\n{content}\nendstream", content.len()) +} + +/// Renders an isolated group holding a 16 × 16 grid of 6 pt cells, one per gray level, each +/// drawn by `cell(level, "x y 6 6 re")` with `/B` (`/BM /{mode} /ca {alpha}`) available, and +/// returns each level's centre pixel. +fn gray_ramp(mode: &str, alpha: f32, cell: impl Fn(u8, &str) -> String) -> Vec<(u8, [u8; 3])> { + let mut content = String::new(); + for level in 0..=255_u8 { + let (x, y) = (2 + u32::from(level % 16) * 6, 2 + u32::from(level / 16) * 6); + content.push_str(&cell(level, &format!("{x} {y} 6 6 re"))); + content.push('\n'); + } + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /XObject << /Fm0 5 0 R >> >> >>".to_string(), + stream("", "/Fm0 Do"), + stream( + &format!( + "/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true >> /Resources << /ExtGState << /B << /BM /{mode} /ca {alpha} >> >> >>" + ), + &content, + ), + ]; + let mut renderer = PageRenderer::new(Arc::new(pdf(&objects)), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(page.error.is_none(), "{:?}", page.error); + (0..=255_u8) + .map(|level| { + let (x, y) = (5 + u32::from(level % 16) * 6, 100 - 5 - u32::from(level / 16) * 6); + let offset = usize::try_from((y * page.width + x) * 4).unwrap(); + (level, [page.rgba[offset], page.rgba[offset + 1], page.rgba[offset + 2]]) + }) + .collect() +} + +fn gray(level: u8) -> f32 { + f32::from(level) / 255.0 +} + +fn assert_ramp(what: &str, got: &[(u8, [u8; 3])], expected: impl Fn(u8) -> f32) { + let wrong: Vec<_> = got.iter().filter(|(level, rgb)| rgb.iter().any(|&c| (f32::from(c) - expected(*level)).abs() > 2.0)).collect(); + assert!(wrong.is_empty(), "{what}: {} levels off, (level, pixel): {wrong:?}", wrong.len()); +} + +#[test] +fn every_gray_under_color_over_nothing_keeps_its_lightness() { + // With nothing beneath it the blend result is the source (§11.4.7): each gray at half + // opacity, over the white page. + let got = gray_ramp("Color", 0.5, |level, rect| format!("/B gs {} g {rect} f", gray(level))); + assert_ramp("Color", &got, |level| (f32::from(level) + 255.0) / 2.0); +} + +#[test] +fn black_under_luminosity_halves_every_opaque_gray() { + // B(Cb, Cs) = SetLum(Cb, Lum(Cs)) is black for a black source, so at half opacity the + // result is half the backdrop's gray. + let got = gray_ramp("Luminosity", 0.5, |level, rect| format!("q {} g {rect} f /B gs 0 g {rect} f Q", gray(level))); + assert_ramp("Luminosity", &got, |level| f32::from(level) / 2.0); +} + +#[test] +fn a_gray_into_an_empty_group_keeps_its_lightness_in_every_nonseparable_mode() { + for mode in ["Color", "Luminosity", "Hue", "Saturation"] { + let got = gray_ramp(mode, 1.0, |level, rect| format!("/B gs {} g {rect} f", gray(level))); + assert_ramp(mode, &got, f32::from); + } +} diff --git a/crates/render/tests/it/open_type_cff_simple_font.rs b/crates/render/tests/it/open_type_cff_simple_font.rs new file mode 100644 index 0000000000..7770e591c8 --- /dev/null +++ b/crates/render/tests/it/open_type_cff_simple_font.rs @@ -0,0 +1,92 @@ +//! A `Type1` font whose `FontFile3` has subtype `OpenType` (ISO 32000-2 §9.9) keeps its CFF +//! program inside an OpenType table directory; it is drawn with that program, not with a +//! standard-font stand-in (hayro-interpret patch (69)). +//! +//! The program is the renderer's own bundled fixed-pitch CFF face, so no font file is added. +//! Drawn right, the bare program and the same program in an OpenType wrapper give the same +//! pixels; the stand-in a serif font falls back to is a different face. + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest}; +use std::sync::Arc; + +const CFF: &[u8] = include_bytes!("../../../../vendor/hayro-interpret/assets/FoxitFixed.pfb"); + +/// `cff` in the smallest OpenType wrapper: an `OTTO` table directory with one `CFF ` table. +fn open_type(cff: &[u8]) -> Vec { + let mut out = b"OTTO".to_vec(); + out.extend_from_slice(&[0, 1, 0, 16, 0, 0, 0, 0]); + out.extend_from_slice(b"CFF "); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&28u32.to_be_bytes()); + out.extend_from_slice(&u32::try_from(cff.len()).unwrap().to_be_bytes()); + out.extend_from_slice(cff); + out +} + +/// A page showing "AgW" at 60 pt in a serif `Type1` font whose `FontFile3` is `program` of +/// `subtype`, or which embeds no program when `None`. +fn page(program: Option<(&[u8], &str)>) -> Vec { + let file = if program.is_some() { " /FontFile3 7 0 R" } else { "" }; + let body = "BT /F1 60 Tf 10 30 Td (AgW) Tj ET"; + let mut objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 120] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Synthetic-Semibold /Encoding /WinAnsiEncoding /FirstChar 65 /LastChar 103 /Widths [600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600] /FontDescriptor 6 0 R >>".to_vec(), + format!("<< /Type /FontDescriptor /FontName /Synthetic-Semibold /Flags 34 /FontBBox [0 -200 1000 900] /ItalicAngle 0 /Ascent 800 /Descent -200 /CapHeight 700 /StemV 120{file} >>").into_bytes(), + ]; + if let Some((program, subtype)) = program { + let mut stream = format!("<< /Subtype /{subtype} /Length {} >>\nstream\n", program.len()).into_bytes(); + stream.extend_from_slice(program); + stream.extend_from_slice(b"\nendstream"); + objects.push(stream); + } + + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(object); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +fn pixels(bytes: Vec) -> Vec { + let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); + let image = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(image.error.is_none(), "{:?}", image.error); + image.rgba.to_vec() +} + +fn inked(rgba: &[u8]) -> usize { + rgba.chunks(4).filter(|p| p.first().is_some_and(|r| *r < 128)).count() +} + +#[test] +fn an_open_type_wrapped_cff_program_draws_like_the_bare_program() { + let bare = pixels(page(Some((CFF, "Type1C")))); + assert!(inked(&bare) > 200, "the bare CFF program drew no text"); + let wrapped = pixels(page(Some((&open_type(CFF), "OpenType")))); + assert!(wrapped == bare, "the OpenType-wrapped program drew a different face: the font fell back"); +} + +/// A wrapper with no `CFF ` table still draws, with the stand-in a font without a program +/// gets, rather than failing the page or reading the program anyway. +#[test] +fn a_wrapper_without_a_cff_table_falls_back() { + let mut broken = open_type(CFF); + broken.get_mut(12..16).unwrap().copy_from_slice(b"glyf"); + let rgba = pixels(page(Some((&broken, "OpenType")))); + assert!(inked(&rgba) > 200, "the stand-in drew no text"); + assert!(rgba == pixels(page(None)), "the broken wrapper drew another face than the stand-in"); + assert!(rgba != pixels(page(Some((CFF, "Type1C")))), "the broken wrapper drew the embedded program"); +} diff --git a/crates/render/tests/it/stray_q_in_a_form.rs b/crates/render/tests/it/stray_q_in_a_form.rs new file mode 100644 index 0000000000..33ace89f37 --- /dev/null +++ b/crates/render/tests/it/stray_q_in_a_form.rs @@ -0,0 +1,141 @@ +//! Contributor-original synthetic pages: a form XObject whose content has more `Q` than `q`. +//! A content stream balances its own `q` and `Q` (ISO 32000-2 §8.4.2, §8.10.1), so a `Q` with +//! nothing of its own stream to restore is ignored and cannot pop the states of the page that +//! drew the form (vendored hayro-interpret patch 68). Each case is paired with the same page +//! drawing a balanced form, which must come out the same. +use pdfcraft_render::{PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind}; +use std::sync::Arc; + +/// Forms /B (balanced), /S2 (two stray `Q`s) and /S3 (three), each moving its own pen. +const FORMS: [(&str, &[u8]); 3] = + [("B", b"q BT /F1 12 Tf 200 0 Td ET Q"), ("S2", b"BT /F1 12 Tf 200 0 Td ET Q Q"), ("S3", b"BT /F1 12 Tf 200 0 Td ET Q Q Q")]; + +/// A page that shifts and clips inside `q … Q`, draws form `form` there, then text and a fill +/// that depend on the shift and the clip, and after its `Q` text that depends on neither. +fn page(form: &str, in_text: bool) -> String { + let draw = if in_text { + format!("BT /F1 12 Tf 72 300 Td (AA) Tj /{form} Do (SECRET) Tj ET") + } else { + format!("BT /F1 12 Tf 72 300 Td (AA) Tj ET /{form} Do BT /F1 12 Tf 72 280 Td (SECRET) Tj ET") + }; + format!("q 1 0 0 1 20 -100 cm 0 0 200 400 re W n {draw} 0 0 1 rg 0 0 400 50 re f Q BT /F1 12 Tf 72 360 Td (TAIL) Tj ET") +} + +/// One 400 × 400 page per entry of `contents`, with Helvetica as /F1 and the forms of `FORMS`. +fn pdf(contents: &[String]) -> Arc> { + let first_page = 4 + FORMS.len(); + let n = contents.len(); + let kids: String = (0..n).map(|i| format!("{} 0 R ", first_page + 2 * i)).collect(); + let xobjects: String = FORMS.iter().enumerate().map(|(i, (name, _))| format!("/{name} {} 0 R ", 4 + i)).collect(); + let mut objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + format!("<< /Type /Pages /Kids [{kids}] /Count {n} >>").into_bytes(), + format!("<< /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << {xobjects}>> >>").into_bytes(), + ]; + for (_, body) in FORMS { + objects.push(stream(b"/Type /XObject /Subtype /Form /BBox [0 0 400 400] /Resources 3 0 R", body)); + } + for (i, content) in contents.iter().enumerate() { + objects.push( + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Resources 3 0 R /Contents {} 0 R >>", first_page + 1 + 2 * i).into_bytes(), + ); + objects.push(stream(b"", content.as_bytes())); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(object); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + Arc::new(out) +} + +fn stream(dict: &[u8], body: &[u8]) -> Vec { + let mut s = b"<< ".to_vec(); + s.extend_from_slice(dict); + s.extend_from_slice(format!(" /Length {} >>\nstream\n", body.len()).as_bytes()); + s.extend_from_slice(body); + s.extend_from_slice(b"\nendstream"); + s +} + +fn request(page: usize, kind: RequestKind) -> RenderRequest { + RenderRequest { page, kind, tile: None, scale: 1.0, tag: 0, preview: false } +} + +fn texts(contents: &[String]) -> Vec> { + let mut renderer = PageRenderer::new(pdf(contents), RenderConfig::default()); + (0..contents.len()) + .map(|page| { + let out = renderer.render(request(page, RequestKind::Text)); + assert!(out.error.is_none(), "page {} text error: {:?}", page + 1, out.error); + out.text.expect("page text") + }) + .collect() +} + +/// The boxes of the glyphs of `word`'s one match on the page. +fn boxes(text: &PageText, word: &str) -> Vec<[f32; 4]> { + let hits = text.find(word); + assert_eq!(hits.len(), 1, "one {word} in {:?}", text.plain_text()); + hits[0].clone().map(|i| text.glyphs[i].rect).collect() +} + +fn assert_same_boxes(a: &[[f32; 4]], b: &[[f32; 4]]) { + assert_eq!(a.len(), b.len()); + for (x, y) in a.iter().zip(b) { + assert!(x.iter().zip(y).all(|(p, q)| (p - q).abs() < 0.01), "{x:?} != {y:?}"); + } +} + +fn assert_text_like_the_balanced_form(in_text: bool) { + let pages = texts(&[page("B", in_text), page("S2", in_text), page("S3", in_text)]); + for (stray, name) in pages[1..].iter().zip(["S2", "S3"]) { + for word in ["SECRET", "TAIL"] { + let (got, want) = (boxes(stray, word), boxes(&pages[0], word)); + assert_eq!(got.len(), want.len(), "{name}: {word}"); + assert_same_boxes(&got, &want); + } + } +} + +#[test] +fn a_forms_stray_q_does_not_undo_the_pages_cm() { + assert_text_like_the_balanced_form(false); +} + +#[test] +fn a_forms_stray_q_inside_a_text_object_does_not_undo_the_pages_cm() { + assert_text_like_the_balanced_form(true); +} + +#[test] +fn a_forms_stray_q_does_not_undo_the_pages_clip() { + let contents = [page("B", false), page("S2", false), page("S3", false)]; + let mut renderer = PageRenderer::new(pdf(&contents), RenderConfig::default()); + let balanced = renderer.render(request(0, RequestKind::Pixels)); + assert!(balanced.error.is_none()); + for i in 1..contents.len() { + let stray = renderer.render(request(i, RequestKind::Pixels)); + assert!(stray.error.is_none()); + assert_eq!((stray.width, stray.height), (balanced.width, balanced.height)); + assert!(stray.rgba == balanced.rgba, "page {} draws differently from the balanced form", i + 1); + } +} + +#[test] +fn a_balanced_q_inside_a_form_still_restores() { + // The balanced form's pen move stays inside its own `q … Q`, so SECRET sits where the page + // put it: 72 + 20 from the page's `cm`, and to the right of AA. + let pages = texts(&[page("B", false)]); + let secret = boxes(&pages[0], "SECRET"); + assert!((secret[0][0] - 92.0).abs() < 0.5, "SECRET starts at {}", secret[0][0]); +} diff --git a/crates/render/tests/it/stroke_degenerate_cubic.rs b/crates/render/tests/it/stroke_degenerate_cubic.rs new file mode 100644 index 0000000000..ff0e2542f9 --- /dev/null +++ b/crates/render/tests/it/stroke_degenerate_cubic.rs @@ -0,0 +1,51 @@ +//! A contributor-original synthetic page reproducing #4907: an outline that ends in a cubic whose +//! control points all sit 0.001 pt from its start was not drawn at all, because the stroker put +//! NaN into its outline and the rasterizer dropped the whole path. +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest}; +use std::sync::Arc; + +/// A one-page 100 × 20 pt PDF drawing `content`, with a cross-reference table. +fn pdf(content: &str) -> Vec { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 20] /Contents 4 0 R >>".to_string(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +/// The red channel of the pixel at (`x`, `y`) pt from the top left of `content` rendered at 1×. +fn red_at(content: &str, x: usize, y: usize) -> u8 { + let mut renderer = PageRenderer::new(Arc::new(pdf(content)), RenderConfig::default()); + let out = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + let at = (y * out.width as usize + x) * 4; + *out.rgba.get(at).expect("the pixel lies on the page") +} + +#[test] +fn a_stroke_ending_in_a_tiny_degenerate_cubic_is_drawn() { + // The line runs from (10, 10) to about (84, 9.5) in page space; (50, 10) from the top left is + // inside its 2 pt width. The cubic's exact coordinates are what trigger the NaN. The control, + // without the cubic, is the baseline: it draws with or without the fix. + let path = "0 0 m 73.763 -0.536 l 73.764 -0.536 73.764 -0.536 73.764 -0.536 c S"; + let control = "0 0 m 73.763 -0.536 l S"; + for content in [control, path] { + let content = format!("0 G 2 w 1 0 0 1 10 10 cm {content}"); + let red = red_at(&content, 50, 10); + assert!(red < 64, "`{content}` left the line's pixel at red {red}, want black"); + } +} 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 e801d45a45..5e325c059c 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -31,6 +31,37 @@ impl Default for PdfCraftApp { } impl PdfCraftApp { + /// The window's title and, on the web, the page's, from the active document (#3640). Done in + /// `logic` too, which also runs while the window or browser tab is hidden: a document opened + /// then (a `?file=` download finishing) still names the page an embedding host reads. + fn sync_titles(&mut self, ctx: &egui::Context) { + // The window shows the active document's name (or title, if it asks for that), with • + // while it has unsaved changes, as the other Craft apps write it (#3640). Unsaved means + // what closing would ask about, so a draft not yet applied counts too. + let dirty = self.active.is_some_and(|i| self.has_unsaved_work(i)); + let mark = if dirty { " •" } else { "" }; + let titled = |name: Option| { + name.or_else(|| self.combine_showing().then(|| tl!("Combine files").to_owned())) + .map_or_else(|| "PdfCraft".to_owned(), |name| format!("{name}{mark} — PdfCraft")) + }; + let title = titled(self.active.and_then(|i| crate::canvas::tab_label(self, i))); + // The web page's title is what an embedding host reads to tell which file opened, so it + // names the file even when the document asks to show its own title. + let page_title = titled(self.active.and_then(|i| self.views.get(i)).and_then(|v| self.session.get(v.id)).map(|d| d.name.clone())); + if title != self.window_title { + ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); + self.window_title = title; + } + if page_title != self.page_title { + // eframe's web runner ignores Title: set the page's own. + #[cfg(target_arch = "wasm32")] + if let Some(document) = web_sys::window().and_then(|w| w.document()) { + document.set_title(&page_title); + } + self.page_title = page_title; + } + } + /// Construct an application with the default session, panels, theme and control state. pub fn new() -> Self { Self { @@ -60,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")] @@ -73,6 +105,7 @@ impl PdfCraftApp { ui_scale_applied: None, flatten_fill_sign_on_save: false, fill_text_size: fill_sign::TEXT_SIZE, + fill_text_color: [0.0; 3], dialog: None, updates: updates::Updates::default(), printer_source: None, @@ -101,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(), @@ -192,13 +231,13 @@ 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, field_table_draft: None, form_preview: false, window_title: String::new(), + page_title: String::new(), recovery: None, recoverable: Vec::new(), recovery_keys: Default::default(), @@ -226,6 +265,7 @@ impl PdfCraftApp { text_style_draft: Default::default(), boxes_draft: Default::default(), marks_draft: Default::default(), + unmarked_page: Default::default(), export_draft: Default::default(), export_status: None, signature: None, @@ -320,6 +360,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 { @@ -528,6 +608,7 @@ impl eframe::App for PdfCraftApp { // Last, after everything that can open or answer the "save changes?" question. self.attend_save_prompt(ctx); self.sync_native_menu(ctx); + self.sync_titles(ctx); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { @@ -547,16 +628,7 @@ impl eframe::App for PdfCraftApp { for view in &mut self.views { view.begin_render_frame(); } - // The window shows the active document's name (or title, if it asks for that). - let title = self - .active - .and_then(|i| crate::canvas::tab_label(self, i)) - .or_else(|| self.combine_showing().then(|| tl!("Combine files").to_owned())) - .map_or_else(|| "PdfCraft".to_owned(), |name| format!("{name} — PdfCraft")); - if title != self.window_title { - ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); - self.window_title = title; - } + self.sync_titles(&ctx); // The accessible window takes the same name, so a screen reader (AT-SPI on Linux, UIA on // Windows) can tell it apart (#4614). egui builds the root node afresh every pass. ctx.accesskit_node_builder(egui::accesskit_root_id(), |root| root.set_label(self.window_title.as_str())); 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 c94e77ffbb..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")?; } @@ -127,6 +130,9 @@ impl PdfCraftApp { } "none" => None, _ => Some(Dialog::About), + }; + if let Some(Dialog::Marks(kind)) = self.dialog { + self.marks_draft.opened(kind); } } ("tools", _) if value == "full" => self.expand_all_tools(), 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 cdc696f95b..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. @@ -44,12 +43,16 @@ impl PdfCraftApp { if let Some(i) = self.active { // Select all belongs to the document or page grid, unless a text field or // overlay owns the keyboard. Other canvas shortcuts keep their own handling. + let pages_owns_keyboard = + self.right == Some(RightPanel::Pages) && self.views[i].pages_focus_id.is_some_and(|id| ctx.memory(|m| m.has_focus(id))); if self.dialog.is_none() && !self.palette_open - && !ctx.egui_wants_keyboard_input() + && (pages_owns_keyboard || !ctx.egui_wants_keyboard_input()) && ctx.input_mut(|input| input.consume_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, Key::A))) { - if self.is_preparing() { + if pages_owns_keyboard { + self.views[i].select_all_pages(); + } else if self.is_preparing() { if let Some(doc) = self.session.get(self.views[i].id) { prepare::select_all(&mut self.views[i], &doc.form); } @@ -74,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 f64ba6d892..7d3fd6b948 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -139,9 +139,12 @@ impl PdfCraftApp { }); // Set apart: one more key inside the macro above passes serde_json's recursion limit. v["default_page_gaps"] = serde_json::Value::Bool(self.view_defaults.show_page_gaps); + v["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); @@ -205,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); } @@ -261,6 +276,14 @@ impl PdfCraftApp { if let Some(size) = v["fill_text_size"].as_f64().and_then(fill_sign::valid_text_size) { self.fill_text_size = size; } + if let Ok(color) = serde_json::from_value::<[f64; 3]>(v["fill_text_color"].clone()) + && let Some(color) = fill_sign::valid_text_color(color) + { + 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 91d2c41367..7a0976644b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -29,6 +29,7 @@ mod navigation; mod organize; pub(super) mod page_cursor; pub(super) mod page_pointer; +pub(super) mod page_router; pub mod scroll_speed; mod selection; pub(super) mod signature_drag; @@ -83,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)] @@ -463,6 +633,8 @@ pub struct DocView { pub org_drag: Option>, /// Pages panel: the pages being dragged to a new place. pub panel_drag: Option>, + /// Transient keyboard owner for this document's Pages sidebar. + pub(crate) pages_focus_id: Option, /// Marquee Zoom / Snapshot: the rectangle being dragged (page, start), and a finished one. pub marquee: Option<(usize, Pos2)>, pub marquee_done: Option, @@ -473,6 +645,11 @@ pub struct DocView { pub(crate) signature_drag: crate::signature_drag::SignatureDrag, /// What the page modules claim for the cursor this frame; `document_area` sets it once. pub(crate) cursor: crate::page_cursor::PageCursor, + /// Who the press the pages hold belongs to. + pub(crate) router: crate::page_router::PageRouter, + /// Escape cancelled the press the pages hold: the page ignores it until the button is up, so + /// its release neither clicks nor ends a drag. + pub(crate) press_cancelled: bool, /// A non-edit action requested by the organize toolbar, handled by the app. pub pending_action: Option, /// The grid gap the next inserted files go to (set by a "+" between pages); otherwise they @@ -561,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 { @@ -587,6 +862,7 @@ impl DocView { self.org_drag = None; self.panel_drag = None; self.selection_drag = false; + self.router.cancel(); self.auto_scroll.cancel(); self.prepare.cancel_pointer_gesture(); self.content.cancel_pointer_gesture(); @@ -720,12 +996,15 @@ impl DocView { sign: Default::default(), org_drag: None, panel_drag: None, + pages_focus_id: None, marquee: None, marquee_done: None, fill_text: None, fill_signature_page: None, signature_drag: Default::default(), cursor: Default::default(), + router: Default::default(), + press_cancelled: false, } } @@ -850,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, @@ -871,7 +1151,9 @@ struct PageEnv<'a> { visible_pages: &'a Vec, y_shift: f32, 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. @@ -889,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, @@ -914,7 +1197,7 @@ struct PageFrame<'a> { current_overlap: f32, current_height: f32, pressed_page: Option, - calc_hover: Option, + calc_hover: Option, } /// Visible heights (points) closer than this are equal: one page size can meet the viewport a @@ -932,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; @@ -939,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); @@ -974,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 => { @@ -991,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; @@ -1027,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. @@ -1052,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, ); } } @@ -1114,12 +1491,43 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< tool, units, fill_text_size, + fill_text_color, .. } = frame.env; let PageSlot { i, xf, .. } = *slot; let PageFrame { resp, ptr, pointer, .. } = *frame; let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + let (lines, images) = + if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { edit_inventory(view, doc, i) } else { Default::default() }; + let field_tool = match tool { + QuickTool::Field(f) => Some(f), + _ => None, + }; + // While a table is being set up, nothing else is drawn or moved. + let prepare_allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none(); + let hits = PageHits { + page: i, + xf: &xf, + info, + tool, + pcx: &pcx, + form, + can_fill, + can_modify, + editing_content, + preparing, + field_tool, + prepare_allowed, + added, + lines: &lines, + images: &images, + clip: ui.clip_rect(), + }; + if let Some(o) = ptr.pressed().filter(|o| xf.rect.contains(*o)) { + let at = hits.at(view, o); + view.router.press(i, at, tool); + } if let QuickTool::Measure(measure_tool) = tool { crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units); } @@ -1131,7 +1539,8 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< && let Some(p) = ptr.within(xf.rect) { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); - if resp.clicked() { + // The tool's own click: nothing drawn over the page here holds the press. + if resp.clicked() && view.router.owner(i).is_none() { // Centred on the click, upright as the page is shown. let (vx, vy) = xf.screen_to_view(p); let (w, h) = kind.size(); @@ -1155,7 +1564,8 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< && let Some(p) = ptr.within(xf.rect) { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); - if resp.clicked() { + // The tool's own click: nothing drawn over the page here holds the press. + if resp.clicked() && view.router.owner(i).is_none() { // Centred on the click at its natural size (the engine sizes it). let (vx, vy) = xf.screen_to_view(p); let u = info.pages[i].view_to_user(vx, vy); @@ -1180,6 +1590,7 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< crate::fill_sign::PageInput { tool: ft, text_size: fill_text_size, + text_color: fill_text_color, signature: signature.as_ref(), initials: initials.as_ref(), preview: frame.signature_preview, @@ -1215,22 +1626,13 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< frame.out.open_boxes = true; } let on_field = if preparing { - let field_tool = match tool { - QuickTool::Field(f) => Some(f), - _ => None, - }; - // While a table is being set up, nothing else is drawn or moved. - let allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none(); - let o = crate::prepare::page_input(cx, form, field_tool, allowed, view); + let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, view); frame.out.field_props |= o.properties; frame.out.field_placed |= o.placed; frame.out.open_table = frame.out.open_table.or(o.table); o.consumed || field_tool.is_some() } else { - tool == QuickTool::Select - && !(editing_content && can_modify && crate::content_ui::select_grabs(&xf, ptr, i, info, added, view)) - && !comments::select_grabs(&pcx, view) - && crate::forms_ui::page_input(cx, form, can_fill, view) + tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, view) }; // Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat). let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed; @@ -1242,26 +1644,14 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< }; crate::redact_ui::page_input(cx, over_text, view) }; - let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, ptr, i, info, view); - let on_objects = !image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view); - let lines = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { - let generation = doc.edit_generation(); - match view.edit_lines.get(&i) { - Some((g, l)) if *g == generation => l.clone(), - _ => { - let l = doc.text_blocks(i); - view.edit_lines.insert(i, (generation, l.clone())); - l - } - } - } else { - Vec::new() - }; + let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view); + // Add text acts on the page's own paragraphs over them, and on the one its press was on until + // the button is up: a drag that leaves the box still ends, and no new box takes it over. let over_source = tool == QuickTool::AddText - && pointer.is_some_and(|p| { - !crate::content_ui::hits_added(&xf, info, i, added, p) - && lines.iter().any(|line| xf.user_rect(info, i, line.rect.map(|v| v as f32)).expand(2.0).contains(p)) - }); + && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Paragraph { .. })) + || pointer.is_some_and(|p| { + !crate::content_ui::hits_added(&xf, info, i, added, p) && crate::edit_text_ui::over_paragraph(&xf, info, i, &lines, p) + })); let source_blocked = over_source && view.content.hold_blocked(); if over_source && resp.clicked() && !source_blocked { if let Some(edit) = view.content.finish_draft(added) { @@ -1288,18 +1678,9 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< // instead of vanishing until the pointer comes back. let others_own_pointer = on_objects || on_content; let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && { - let generation = doc.edit_generation(); - 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 - } - }; // Editable text takes precedence over background/overlapping artwork. Both // inventories are painted every frame, even when text owns the pointer. - let text_used = crate::edit_text_ui::page_input(cx, &lines, view, !others_own_pointer && !image_owns_pointer); + let text_used = crate::edit_text_ui::page_input(cx, &lines, view, !others_own_pointer); let image_cx = crate::edit_text_ui::ImageCx { ui, resp, @@ -1307,11 +1688,20 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< ptr, page: i, info, - allow_interaction: !others_own_pointer && (image_owns_pointer || !text_used), + 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() @@ -1322,9 +1712,149 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame< view.links.context = Some((i, l.index)); } let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, resp, &pcx, view); + // The cursor and the tip from what lies under the pointer, after the tools claimed theirs (the + // last claim on a layer stands). + if frame.env.unobstructed + && let Some(p) = pointer + { + for h in hits.at(view, p) { + view.cursor.claim_with_tip(h.layer, h.icon, h.tip); + } + } PageInput { consumed, area_tool } } +/// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged. +fn edit_inventory( + view: &mut DocView, + doc: &pdfcraft_engine::Document, + i: usize, +) -> (Vec, Vec) { + let generation = doc.edit_generation(); + let lines = match view.edit_lines.get(&i) { + Some((g, l)) if *g == generation => l.clone(), + _ => { + let l = doc.text_blocks(i); + view.edit_lines.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, + xf: &'a PageXform, + info: &'a DocInfo, + tool: QuickTool, + pcx: &'a comments::PageCx<'a>, + form: &'a [pdfcraft_engine::FormField], + can_fill: bool, + can_modify: bool, + /// Edit a PDF is open: added text and pictures can be picked up. + editing_content: bool, + preparing: bool, + field_tool: Option, + /// Prepare may draw and move fields (not while a table is being set up). + prepare_allowed: bool, + added: &'a [pdfcraft_engine::Added], + lines: &'a [pdfcraft_engine::TextBlock], + images: &'a [pdfcraft_engine::PageImage], + /// The pages' visible area (the picture's rotate knob stays inside it). + clip: Rect, +} + +impl PageHits<'_> { + /// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text + /// on top of artwork takes the press), added content, the objects selected together and the + /// open paragraph's frame, the selected picture's grips over them all; then form fields + /// (Prepare's, being moved, over the comments; filled ones under added content and the + /// comments that move). + fn at(&self, view: &DocView, p: Pos2) -> Vec { + let PageHits { + page, + xf, + info, + tool, + pcx, + form, + can_fill, + can_modify, + editing_content, + preparing, + field_tool, + prepare_allowed, + added, + lines, + 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 { + crate::forms_ui::hit(xf, info, page, form, can_fill, p) + } else { + None + }; + // With Add text, only the paragraphs and pictures not under added content. + let editing = + can_modify && (tool == QuickTool::EditText || tool == QuickTool::AddText && !crate::content_ui::hits_added(xf, info, page, added, p)); + let (pictures, paragraphs, objects, grips) = if editing { + let open = view.line_editor.as_ref().filter(|e| e.page == page); + let frame = open + .filter(|e| !e.has_changes()) + .and_then(|e| lines.get(e.block)) + .map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0)); + // Add text acts on the page's paragraphs only in their boxes, edges included (it + // finishes the box being typed there, `over_source`): not on pictures, nor beside a + // paragraph, where a click starts a new box. + let edit_text = tool == QuickTool::EditText; + let on_paragraphs = edit_text || crate::edit_text_ui::over_paragraph(xf, info, page, lines, p); + ( + edit_text.then(|| crate::edit_text_ui::picture_hit(xf, page, info, images, view, p)).flatten(), + on_paragraphs.then(|| crate::edit_text_ui::hit(xf, info, page, lines, open.map(|e| e.block), p)).flatten(), + edit_text.then(|| crate::object_ui::hit(view, xf, info, page, frame, p)).flatten(), + edit_text.then(|| crate::edit_text_ui::picture_handle_hit(clip, xf, page, info, images, view, p)).flatten(), + ) + } else { + (None, None, None, None) + }; + // Added text and pictures, over the page's own paragraphs and under the objects selected + // together. + let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); + let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten(); + [link_hit(info, xf, page, p), comments::hit(pcx, view, p), pictures, paragraphs, added, objects, grips, fields] + .into_iter() + .flatten() + .collect() + } +} + /// Text layer: find matches, selection, I-beam and drag-to-select. fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { let PageEnv { info, selects_text, tool, .. } = frame.env; @@ -1426,8 +1956,10 @@ fn update_text_cursor(view: &mut DocView, gesture: &TextGesture<'_>) { } fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { + // A press held by what keeps its drag (a link, a comment that moves) selects no text; one on + // text markup lets its drag select the text underneath. let press_here = gesture.ptr.origin.is_some_and(|origin| gesture.rect.contains(origin)); - if gesture.ptr.pressed().is_some() && press_here && !gesture.over_link { + if gesture.ptr.pressed().is_some() && press_here && !view.router.keeps_drag() { let now = ui.input(|input| input.time); let (max_delay, max_dist) = ui.ctx().options(|options| (options.input_options.max_double_click_delay, options.input_options.max_click_dist)); let consecutive = now - view.last_press_time <= max_delay && view.last_press_pos.is_some_and(|pos| pos.distance(gesture.pointer) <= max_dist); @@ -1442,7 +1974,7 @@ fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, view.selection = Some(Selection::run(gesture.page, first, last)); } } - if gesture.resp.drag_started() && press_here && !gesture.over_link { + if gesture.resp.drag_started() && press_here && !view.router.keeps_drag() { view.actions_dismissed = None; view.selection_drag = true; let origin = gesture.ptr.origin.unwrap_or(gesture.pointer); @@ -1514,7 +2046,7 @@ fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageTex && let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor)) { view.selection = Some(Selection::run(gesture.page, first, last)); - } else if gesture.resp.clicked() && view.clicks < 2 && !gesture.over_link { + } else if gesture.resp.clicked() && view.clicks < 2 && !view.router.keeps_drag() { let shift = ui.input(|input| input.modifiers.shift); match ( view.selection.as_mut().filter(|selection| shift && selection.page == gesture.page).map(|selection| &mut selection.span), @@ -1559,9 +2091,12 @@ fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_ } crate::image_pick::drop_stale(doc, view); crate::image_pick::paint(painter, &xf, i, info, view); - comments::page_after_text(resp, &pcx, view); - if tool == QuickTool::Redact && can_modify { - crate::redact_ui::after_text(resp, i, info, view); + // A cancelled press ends no selection: no markup or redaction is made from it. + if !view.press_cancelled { + comments::page_after_text(resp, &pcx, view); + if tool == QuickTool::Redact && can_modify { + crate::redact_ui::after_text(resp, i, info, view); + } } if input.area_tool { crate::redact_ui::paint(painter, i, view, pointer); @@ -1586,8 +2121,8 @@ fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_ } if calc_order { let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); - if let Some(name) = crate::calc_order_ui::paint_page(tool_cx, form, &view.calc_order, generation, selected) { - frame.calc_hover = Some(name); + if let Some(field) = crate::calc_order_ui::paint_page(tool_cx, form, &view.calc_order, generation, selected) { + frame.calc_hover = Some(field); } } } else { @@ -1601,26 +2136,17 @@ fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_ paint_field_highlights(painter, &xf, info, form.as_slice(), i, view.highlight_fields); // Links: the hand and the link's target, which count only for the tools that follow or // edit links (`Layer::reached_by`). Tips draw above the palette, so none shows while it is - // open, even beside it. - if unobstructed && let Some(p) = pointer { - for l in info.links.iter().filter(|l| l.page == i) { - let sr = xf.user_rect(info, i, l.rect); - if sr.contains(p) { - let label = match &l.target { - LinkTarget::Page(n, _) => { - crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]) - } - LinkTarget::Uri(u) => u.clone(), - LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(), - LinkTarget::RemoteGoTo { file, .. } => file.clone(), - LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), - }; - view.cursor.claim_with_tip(Layer::Link, egui::CursorIcon::PointingHand, Some(label)); - if resp.clicked() && tool == QuickTool::Select && !input.consumed { - frame.out.clicked_link = Some(l.target.clone()); - } - } - } + // open, even beside it. A click follows the link its press was on, released over it: one that + // slips off the link first follows nothing, as a button's does. + if unobstructed + && resp.clicked() + && tool == QuickTool::Select + && !input.consumed + && let Some(owner @ crate::page_router::Owner::Link { index }) = view.router.owner(i) + && pointer.and_then(|p| link_hit(info, &xf, i, p)).is_some_and(|h| h.owner == owner) + && let Some(l) = info.links.get(index) + { + frame.out.clicked_link = Some(l.target.clone()); } // Comments say what they are with any tool, beside its cursor, as Acrobat's do. comments::describe(&pcx, view); @@ -1737,12 +2263,18 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i } let slot = PageSlot { i, r, xf }; - let input = page_tool_input(ui, view, frame, &slot); + // A cancelled press takes nothing until the button is up. + let input = if view.press_cancelled { PageInput { consumed: true, area_tool: false } } else { page_tool_input(ui, view, frame, &slot) }; let painter = ui.painter(); let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; let preview_target = (matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten(); view.signature_drag.prepare(ui.ctx(), doc, preview_target, scale); view.signature_drag.paint(painter, &pcx, &view.comments, view.pending_edit.as_ref()); + // Any other comment being moved: drawn where it goes, over the page that still shows it + // where it was. + let stale = view.signature_drag.moving_page().is_some_and(|p| view.page_out_of_date(p)); + view.signature_drag.prepare_move(ui.ctx(), doc, &view.comments, view.pending_edit.as_ref(), stale, scale); + view.signature_drag.paint_move(painter, &pcx, &view.comments, view.pending_edit.as_ref()); // Any press on the page ends Select All of the document: text read or not, any tool, // on a link too. @@ -1754,6 +2286,44 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i page_overlays(ui, view, frame, &slot, &input); } +/// The link under `p` on `page`: the hand, and where a click goes. Only the Select tool follows +/// links and the Link tool edits them (`Layer::reached_by`). +fn link_hit(info: &DocInfo, xf: &PageXform, page: usize, p: Pos2) -> Option { + let (index, l) = info.links.iter().enumerate().rfind(|(_, l)| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?; + let label = match &l.target { + LinkTarget::Page(n, _) => crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]), + LinkTarget::Uri(u) => u.clone(), + LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(), + LinkTarget::RemoteGoTo { file, .. } => file.clone(), + LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), + }; + let owner = crate::page_router::Owner::Link { index }; + Some(crate::page_router::Hit { tip: Some(label), ..crate::page_router::Hit::new(owner, Layer::Link, egui::CursorIcon::PointingHand) }) +} + +/// Escape while the pages hold a press abandons the gesture it drives, whichever tool's: nothing is +/// placed, drawn, moved, resized or opened, and the press is ignored until the button is up. The +/// frame of the release still belongs to the cancelled press. +fn cancel_on_escape(ctx: &egui::Context, view: &mut DocView, ptr: &PagePointer) { + // Over once the button is up and its release seen, or when a new press comes (its release and + // the next press can share a frame). + let new_press = matches!(ptr.edges.last(), Some(crate::page_pointer::Edge::Press(_))); + if view.press_cancelled && (new_press || !ptr.any_down && ptr.edges.is_empty()) { + view.press_cancelled = false; + } + if !view.press_cancelled && ptr.origin.is_some() && ptr.primary_down && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) + { + // A text selection being dragged goes with its drag: the markup, redaction or Replace + // Text that its end would make is not made. + if view.selection_drag { + view.clear_selection(); + } + view.cancel_pointer_gestures(); + view.press_cancelled = true; + } +} + +#[allow(clippy::cognitive_complexity)] fn draw_pages( ui: &mut egui::Ui, viewport: Rect, @@ -1787,6 +2357,8 @@ fn draw_pages( prefs, preparing, selection_wheel, + ppp, + rects, single_paging, tool, unobstructed, @@ -1833,6 +2405,18 @@ fn draw_pages( // The pointer as the pages see it: nothing over them (a dialog from the frame it opens), // or a press they hold. The page modules read it rather than egui's raw pointer state. let ptr = PagePointer::read(ui, &resp, modal); + cancel_on_escape(ui.ctx(), view, &ptr); + view.router.settle(ptr.any_down, !ptr.edges.is_empty()); + // A new press is no longer the last one's, wherever it lands (the page it is on gives it + // its owner; in the gap between pages it has none). + if ptr.pressed().is_some() { + view.router.cancel(); + // The page widget is not focusable. Explicitly return keyboard ownership from Pages + // when the user presses the document, without stealing focus through a modal. + if let Some(id) = view.pages_focus_id { + ui.memory_mut(|m| m.surrender_focus(id)); + } + } let pointer = ptr.pos; // A press on a page makes it the current one (as in Acrobat), so a page command with // no selection (Rotate, Delete) acts on the page the reader pointed at, not on a page @@ -1841,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, @@ -1870,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); @@ -2003,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); @@ -2040,8 +2671,28 @@ fn draw_pages( ui.separator(); } } - canvas_action = comments::context_menu(ui, view, info, 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, @@ -2055,6 +2706,7 @@ fn draw_pages( open_initials, open_signature, open_table, + page_action, primary_down: ptr.primary_down, refused, stamp_placed, @@ -2165,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 @@ -2798,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 { @@ -2856,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; } @@ -3011,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, @@ -3032,10 +3694,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { visible_pages: &visible_pages, y_shift, 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 { @@ -3051,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, @@ -3090,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); @@ -3137,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, @@ -3158,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, @@ -3172,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, @@ -3241,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/bars.rs b/crates/ui-egui/src/canvas/bars.rs index b48027ae35..2472366672 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -459,6 +459,14 @@ pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { app.apply_edit(Edit::FontSizeAnnotation { page, index: annotation, size: app.fill_text_size }); } } + if matches!(current_fill, Some(crate::fill_sign::FillTool::Text | crate::fill_sign::FillTool::Date)) { + ui.menu_button(tl!("Colour"), |ui| { + if let Some(color) = comments::swatch_grid(ui, Some(app.fill_text_color)) { + app.fill_text_color = color; + ui.close(); + } + }); + } if let QuickTool::Comment(tool) = app.quick_tool { let (r, _) = ui.allocate_exact_size(vec2(32.0, 9.0), Sense::hover()); ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.divider)); 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/crop.rs b/crates/ui-egui/src/canvas/crop.rs index dd88db1a19..bdd1e9e135 100644 --- a/crates/ui-egui/src/canvas/crop.rs +++ b/crates/ui-egui/src/canvas/crop.rs @@ -38,15 +38,18 @@ pub(crate) fn page_input(ui: &egui::Ui, input: CropPageInput<'_>) -> bool { if !allowed { return false; } - if resp.drag_started() + // The tool's own press, of the primary button: nothing drawn over the page here holds it. + if resp.drag_started_by(egui::PointerButton::Primary) + && view.router.owner(page).is_none() && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) { view.crop_drag = Some((page, o)); } if let Some((p, start)) = view.crop_drag && p == page - && let Some(end) = pointer { + // Where the pointer is, or where it went down when it is off the window. + let end = pointer.unwrap_or(start); let end = Pos2::new(end.x.clamp(xf.rect.left(), xf.rect.right()), end.y.clamp(xf.rect.top(), xf.rect.bottom())); let r = Rect::from_two_pos(start, end); // Dim what will be cut away. @@ -60,7 +63,7 @@ pub(crate) fn page_input(ui: &egui::Ui, input: CropPageInput<'_>) -> bool { painter.rect_filled(band, CornerRadius::ZERO, Color32::from_black_alpha(70)); } painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, Color32::from_rgb(0x14, 0x73, 0xE6)), egui::StrokeKind::Middle); - if resp.drag_stopped() { + if resp.drag_stopped() || !ptr.primary_down { view.crop_drag = None; if r.width() >= 4.0 && r.height() >= 4.0 { let (a, b) = (to_user(xf, info, page, r.left_top()), to_user(xf, info, page, r.right_bottom())); 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/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index 46468a112d..731b2c2586 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -369,10 +369,14 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s view.rotate_view(false); } zoom_shortcuts(view, &pressed); - if pressed(cmd(Key::OpenBracket)) { + // Alt+← / Alt+→ (⌥ on macOS) too, as in Acrobat and browsers, back to where a link was + // followed from (#3632). Consumed here, before ← / → turn the page. egui matches the pattern + // logically, so an extra Shift is ignored and Shift+Alt+← goes back as well (harmless). + let alt = |k| KeyboardShortcut::new(Modifiers::ALT, k); + if pressed(cmd(Key::OpenBracket)) || pressed(alt(Key::ArrowLeft)) { view.view_history(false); } - if pressed(cmd(Key::CloseBracket)) { + if pressed(cmd(Key::CloseBracket)) || pressed(alt(Key::ArrowRight)) { view.view_history(true); } let key = |k| (arrows || !matches!(k, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown)) && ctx.input(|i| i.key_pressed(k)); 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_cursor.rs b/crates/ui-egui/src/canvas/page_cursor.rs index 382f5efb63..e40dc1c4f3 100644 --- a/crates/ui-egui/src/canvas/page_cursor.rs +++ b/crates/ui-egui/src/canvas/page_cursor.rs @@ -20,18 +20,19 @@ use crate::QuickTool; pub(crate) enum Layer { /// The page under the active tool: the crosshair it places or draws with, its I-beam or hand. ToolArea, - /// The page's text, to select, and Edit text's paragraphs. + /// The page's text, to select; Edit text's paragraphs, and its pictures beneath them. Text, /// Links. Link, - /// Comments: notes, shapes and markup. + /// Text markup (highlights, underlines…), which lies on the text: under a form field. Comment, /// Form fields. Field, - /// What a drag moves whole: signatures and Fill & Sign's marks, added text and images, - /// selected objects, pictures in Edit text. + /// What a drag moves whole: notes, shapes, signatures and Fill & Sign's marks, added text and + /// pictures, the objects selected together, Prepare's fields. Placed, - /// The grips of the selected item: resize, rewrap and crop handles, paragraph edges. + /// The grips of the selected item: resize, rewrap, rotate and crop handles, paragraph edges, + /// the frame of the paragraph open for typing. Handle, /// An open editor: a paragraph being typed in, a picture being cropped. Editor, @@ -45,9 +46,10 @@ const LAYERS: [Layer; 9] = impl Layer { /// Whether a press with `tool` acts on what lies on this layer, so that the layer's claims - /// count. Only the Select tool follows links (the Link tool edits them) and picks up - /// comments: every other tool draws, places or selects text over them. The other layers are - /// claimed only by the modules of the tools that act on them, so they count for every tool. + /// count. Only the Select tool follows links (the Link tool edits them) and selects text + /// markup: every other tool draws, places or selects text over them. The other layers are + /// claimed only by the modules of the tools that act on them (each module's hit test filters + /// by tool), so they count for every tool. pub(crate) fn reached_by(self, tool: QuickTool) -> bool { match self { Layer::Link => matches!(tool, QuickTool::Select | QuickTool::Link), 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_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index b405832ab0..2cbde2b995 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -361,3 +361,79 @@ fn page_modules_claim_the_cursor_and_never_set_it() { ); assert!(problems.is_empty(), "{}", problems.join("\n")); } + +/// Hit producers are called only by the central routing table, not again by an input handler. +const PAGE_HIT_CALLS: [&str; 8] = [ + "prepare::hit(", + "forms_ui::hit(", + "comments::hit(", + "edit_text_ui::hit(", + "edit_text_ui::picture_hit(", + "edit_text_ui::picture_handle_hit(", + "object_ui::hit(", + "content_ui::hit(", +]; + +fn routed_press_handler(code: &str) -> bool { + let code = code.lines().filter(|line| !line.trim_start().starts_with("//")).collect::>().join("\n"); + code.contains("router.owner(") + && !PAGE_HIT_CALLS.iter().any(|call| code.contains(call)) + && !uses(&code, "hit(") + && !uses(&code, "body_hit(") + && !uses(&code, "handle_hit(") +} + +/// Source-level guard for the Page presses rule, alongside the raw-pointer and cursor guards. +/// Runtime routing, capture lifetime and Escape behavior have separate behavioral tests. +#[test] +fn page_modules_take_presses_from_the_router_without_repeating_hit_tests() { + let mut problems = misuses( + &PAGE_HIT_CALLS, + &[("canvas.rs", "at", "the central PageHits table")], + "repeats a module hit test outside PageHits::at; use the captured router owner", + "no longer collects module hits", + ); + problems.extend(misuses( + &["router.press("], + &[("canvas.rs", "page_tool_input", "the central press capture")], + "captures a page press outside the central routing path", + "no longer captures page presses", + )); + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + for (file, function) in [ + ("tools/forms_ui.rs", "page_input"), + ("tools/prepare.rs", "page_input"), + ("tools/content_ui.rs", "page_input"), + ("tools/edit_text_ui.rs", "page_input"), + ("tools/edit_text_ui/image.rs", "image_input"), + ("tools/object_ui.rs", "input"), + ("tools/comments/input.rs", "select_input"), + ] { + let source = std::fs::read_to_string(src.join(file)).expect("page input module"); + let body = source.lines().skip_while(|line| declares(line) != Some(function)).take_while(|line| *line != "}").collect::>().join("\n"); + // Forms reuse their hit test for hover text and to require release over the captured + // widget. Neither chooses the press owner. Pin both calls to the current pointer `p`, + // not the press origin, and reject an additional call or a changed argument. + let body = if file == "tools/forms_ui.rs" { + let call = "hit(xf, info, page, form, allowed, p)"; + assert_eq!(body.matches(call).count(), 2, "only the existing hover and release checks are exempt"); + body.replace(call, "hover_or_release_check") + } else { + body + }; + if !routed_press_handler(&body) { + problems.push(format!("src/{file}: {function} must consume the captured router owner without repeating a hit test")); + } + } + assert!(problems.is_empty(), "{}", problems.join("\n")); +} + +#[test] +fn the_page_press_guard_rejects_missing_owners_and_second_hit_tests() { + assert!(routed_press_handler("let owner = view.router.owner(page);")); + assert!(!routed_press_handler("// view.router.owner(page)\nlet owner = None;")); + assert!(!routed_press_handler("let owner = view.router.owner(page);\nlet again = cx.hit(origin);")); + for call in PAGE_HIT_CALLS { + assert!(!routed_press_handler(&format!("let owner = view.router.owner(page);\n{call}origin);")), "{call}"); + } +} diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs new file mode 100644 index 0000000000..8b91cc865c --- /dev/null +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -0,0 +1,196 @@ +//! Who a press on the pages belongs to. +//! +//! Each page module answers "what of mine is under this point?" in one place, its hit test, and +//! the answer is a [`Hit`]: what is there ([`Owner`]), on which [`Layer`], with which cursor and +//! tip. The pointer's hits set the cursor and the tip; on the frame of a press, the hits at the +//! press point decide who captures it ([`PageRouter::press`]), by the same table: the highest layer +//! the active tool reaches. Until the button is up only the owner acts on the press, its drag and +//! its release, so what the cursor promised is what the press does, and nothing takes a gesture +//! back halfway. + +use egui::CursorIcon; + +use crate::QuickTool; +use crate::page_cursor::Layer; + +/// What a hit is on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Owner { + /// A comment (or a Fill & Sign mark), by its `/Annots` index, and the part of it. + Comment { index: usize, part: CommentPart }, + /// A link, by its index in the document's links. + Link { index: usize }, + /// A form field's widget, by the field's index in the form and the widget's in the field. + Field { field: usize, widget: usize }, + /// Prepare Form: a field's widget, to select and move. + PrepareField { field: usize, widget: usize }, + /// Prepare Form: a corner handle of the selected field, to resize it. + PrepareCorner { left: bool, top: bool }, + /// Edit text: a paragraph, by its index among the page's, and what of it a press takes. + Paragraph { block: usize, part: ParagraphPart }, + /// Edit text: a picture, by its index among the page's, and what of it a press takes. + Picture { index: usize, part: PicturePart }, + /// Edit text: the objects selected together, or the frame of the paragraph open for typing. + Objects { part: ObjectsPart }, + /// Added text or a picture placed on the page, by its index among the page's, and a corner of + /// the selected one (left, top) or the item itself. + Added { index: usize, corner: Option<(bool, bool)> }, +} + +/// The part of a picture a hit is on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PicturePart { + /// The picture: a click selects it, a drag moves the selected one. + Body, + /// 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, +} + +/// What of the objects a hit is on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ObjectsPart { + /// The objects selected together: a drag moves them all. + Selection, + /// The frame drawn around the paragraph open for typing: a click selects the box, a drag moves it. + Frame, +} + +/// The part of a paragraph a hit is on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ParagraphPart { + /// The paragraph: a drag moves it, a click opens it for typing. + Body, + /// Its left or right edge (on screen): a drag rewraps it. + Left, + Right, +} + +/// The part of a comment a hit is on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CommentPart { + /// The comment itself: a drag moves it, a click selects it. + Body, + /// A resize handle of the selected comment: (-1, 0 or 1) across, then down. + Handle(i8, i8), + /// A point of the selected line, polygon or cloud. + Point(usize), +} + +/// What lies under a point, for one page module. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Hit { + pub owner: Owner, + pub layer: Layer, + pub icon: CursorIcon, + /// Beside the pointer where this hit wins (a link's target). + pub tip: Option, + /// A drag from here is not this owner's: a click on a highlight selects it, a drag across it + /// selects the text underneath. + pub declines_drag: bool, +} + +impl Hit { + pub(crate) fn new(owner: Owner, layer: Layer, icon: CursorIcon) -> Self { + Hit { owner, layer, icon, tip: None, declines_drag: false } + } +} + +/// The hit that wins among `hits` with `tool`: the highest layer the tool reaches, the last of +/// equals (modules are asked bottom up, in paint order). +pub(crate) fn top(hits: impl IntoIterator, tool: QuickTool) -> Option { + hits.into_iter().filter(|h| h.layer.reached_by(tool)).fold(None, |best: Option, h| match best { + Some(b) if b.layer > h.layer => Some(b), + _ => Some(h), + }) +} + +/// A press the pages hold, and who it belongs to. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Capture { + pub page: usize, + pub owner: Owner, + pub declines_drag: bool, +} + +/// The press the pages hold, from its frame until the button is up. +#[derive(Debug, Default)] +pub(crate) struct PageRouter { + capture: Option, +} + +impl PageRouter { + /// A press on `page`: `hits` at the press point decide who it belongs to. A press nobody's + /// hit claims is held by nobody (the page's text, or a tool that acts anywhere on the page). + pub(crate) fn press(&mut self, page: usize, hits: impl IntoIterator, tool: QuickTool) { + self.capture = top(hits, tool).map(|h| Capture { page, owner: h.owner, declines_drag: h.declines_drag }); + } + + /// Who holds the press on `page`. + pub(crate) fn owner(&self, page: usize) -> Option { + self.capture.as_ref().filter(|c| c.page == page).map(|c| c.owner) + } + + /// Whether the press is held by someone who keeps its drag: then the page's text is not + /// selected by it. + pub(crate) fn keeps_drag(&self) -> bool { + self.capture.as_ref().is_some_and(|c| !c.declines_drag) + } + + /// The press is over once the button is up and its release has been seen. + pub(crate) fn settle(&mut self, any_down: bool, edges: bool) { + if !any_down && !edges { + self.capture = None; + } + } + + /// Drops the press (Escape, a dialog, automation's cancel). + pub(crate) fn cancel(&mut self) { + self.capture = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hit(layer: Layer, index: usize) -> Hit { + Hit::new(Owner::Comment { index, part: CommentPart::Body }, layer, CursorIcon::Move) + } + + #[test] + fn the_highest_reached_layer_wins_and_the_last_of_equals() { + let hits = [hit(Layer::Comment, 0), hit(Layer::Link, 1), hit(Layer::Comment, 2)]; + assert_eq!(top(hits.clone(), QuickTool::Select).map(|h| h.owner), Some(hits[2].owner)); + // A stamp reaches neither links nor comments. + let stamp = QuickTool::Stamp(pdfcraft_engine::StampKind::DynApproved); + assert_eq!(top(hits, stamp), None); + assert_eq!(top([hit(Layer::Placed, 3), hit(Layer::Comment, 4)], stamp).map(|h| h.owner), Some(hit(Layer::Placed, 3).owner)); + } + + #[test] + fn a_press_is_held_until_the_button_is_up_and_its_release_seen() { + let mut r = PageRouter::default(); + r.press(2, [hit(Layer::Comment, 7)], QuickTool::Select); + assert_eq!(r.owner(2), Some(hit(Layer::Comment, 7).owner)); + assert_eq!(r.owner(1), None, "only on its page"); + assert!(r.keeps_drag()); + r.settle(false, true); + assert!(r.owner(2).is_some(), "the release frame still belongs to the press"); + r.settle(false, false); + assert_eq!(r.owner(2), None); + r.press(0, [], QuickTool::Select); + assert_eq!(r.owner(0), None, "a press nobody claims is held by nobody"); + assert!(!r.keeps_drag()); + } + + #[test] + fn a_highlight_lets_its_drag_go_to_the_text() { + let mut r = PageRouter::default(); + r.press(0, [Hit { declines_drag: true, ..hit(Layer::Comment, 0) }], QuickTool::Select); + assert!(r.owner(0).is_some() && !r.keeps_drag()); + r.cancel(); + assert_eq!(r.owner(0), None); + } +} diff --git a/crates/ui-egui/src/canvas/selection.rs b/crates/ui-egui/src/canvas/selection.rs index cfc5967bcf..c2b0545df6 100644 --- a/crates/ui-egui/src/canvas/selection.rs +++ b/crates/ui-egui/src/canvas/selection.rs @@ -93,13 +93,17 @@ impl DocView { Some(s.text(t)).filter(|x| !x.is_empty()) } - /// Select all: every page in Organize, or every word on the current page. + /// Select every page without navigating; shared by Organize and the Pages sidebar. + pub fn select_all_pages(&mut self) -> bool { + self.selected = (0..self.page_count).collect(); + self.select_anchor = (self.page_count > 0).then_some(self.current); + !self.selected.is_empty() + } + + /// Select all words on the current page, or every page in Organize. pub fn select_all(&mut self) -> bool { if self.organize { - self.selected = (0..self.page_count).collect(); - // Keep the current page as the anchor for the next Shift-click. - self.select_anchor = (self.page_count > 0).then_some(self.current); - return !self.selected.is_empty(); + return self.select_all_pages(); } // Scrolling continuously, Select All takes the whole document's text (#2639); one page at // a time, the page shown. diff --git a/crates/ui-egui/src/canvas/signature_drag.rs b/crates/ui-egui/src/canvas/signature_drag.rs index e574d68ec4..c706b8a113 100644 --- a/crates/ui-egui/src/canvas/signature_drag.rs +++ b/crates/ui-egui/src/canvas/signature_drag.rs @@ -1,5 +1,6 @@ //! Cached page and stamp layers: a stamp (an image signature, a Fill & Sign mark, any other stamp) -//! follows each pointer frame, moved or resized, without editing the PDF. +//! follows each pointer frame, moved or resized, without editing the PDF. Any other comment +//! follows the pointer while it is moved: its own layer is drawn where it goes. use egui::{ColorImage, TextureHandle, TextureOptions, pos2, vec2}; use pdfcraft_engine::{Document, Edit}; @@ -16,6 +17,20 @@ pub(crate) struct SignatureDrag { key: Option<(u64, usize, usize)>, aspect_ratio: Option, layers: Option, + /// The comment being moved that has no stamp layers: (edit generation, page, index), and + /// the comment alone, drawn over the page where it goes. + moving: Option<((u64, usize, usize), Moving)>, + /// The comment being moved whose layer couldn't be made (nothing to draw, or a document that + /// can't be read): only its frame follows the pointer, and the layer isn't made again on + /// every frame of the drag. + no_layer: Option<(u64, usize, usize)>, +} + +struct Moving { + /// The comment's layer: `from` is its box when the move started. + layer: StampLayer, + /// Released and applied: keep it where it went until the page shows it there. + settling: bool, } struct Layers { @@ -49,6 +64,8 @@ struct StampLayer { tag: Option, /// The rendered stamp: its tag, and the view-space box its pixels cover. pixels: Option<(u64, TextureHandle, [f32; 4])>, + /// The tag whose render failed: not waited for, and asked for again only at another scale. + failed: Option, } impl SignatureDrag { @@ -75,7 +92,7 @@ impl SignatureDrag { let front = Front::Image { texture, opacity: preview.opacity, turn: preview.turn }; self.layers = Some(Layers::new(front, preview.renderer())); } else if let Ok(Some(preview)) = doc.stamp_preview(page, index) { - let stamp = StampLayer { renderer: preview.stamp_renderer(), from: a.rect, tag: None, pixels: None }; + let stamp = StampLayer { renderer: preview.stamp_renderer(), from: a.rect, tag: None, pixels: None, failed: None }; self.layers = Some(Layers::new(Front::Stamp(Box::new(stamp)), preview.background_renderer())); } } @@ -109,29 +126,14 @@ impl SignatureDrag { let mut pending = layers.received_tag != Some(tag); // The stamp itself is small: it stays sharp at every zoom. if let Front::Stamp(stamp) = &mut layers.front { - let Some((scale, tile)) = stamp_tile(p, stamp.from, scale) else { + match stamp.update(ctx, page, p, scale) { + Some(waiting) => pending |= waiting, // Nothing to lay over the page: the frame alone follows the pointer, as before. - self.layers = None; - return; - }; - let tag = (scale * 1000.0) as u64; - if stamp.tag != Some(tag) { - stamp.renderer.set_queue(vec![RenderRequest { page, scale, tag, tile: Some(tile), ..Default::default() }]); - stamp.tag = Some(tag); - } - if let Some(out) = stamp.renderer.try_recv() - && out.request.tag == tag - { - let (Some(t), None) = (out.request.tile, &out.error) else { + None => { self.layers = None; return; - }; - let s = out.request.scale; - let area = [t.x as f32 / s, t.y as f32 / s, t.x.saturating_add(t.w) as f32 / s, t.y.saturating_add(t.h) as f32 / s]; - let image = crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba); - stamp.pixels = Some((tag, ctx.load_texture("signature-drag-stamp", image, TextureOptions::LINEAR), area)); + } } - pending |= stamp.pixels.as_ref().is_none_or(|(received, ..)| *received != tag); } if pending { ctx.request_repaint_after(std::time::Duration::from_millis(16)); @@ -150,6 +152,14 @@ impl SignatureDrag { /// Movement/resizing only change this stamp's geometry, so its layers are reusable. pub fn committed(&mut self, edit: &Edit, generation: u64) { + if let Some((key, m)) = self.moving.as_mut() + && matches!(edit, Edit::MoveAnnotation { page, index, .. } if (*page, *index) == (key.1, key.2)) + { + key.0 = generation; + m.settling = true; + } else { + self.moving = None; + } if let Edit::MoveAnnotation { page, index, .. } | Edit::ResizeAnnotation { page, index, .. } = edit && self.contains(*page, *index) { @@ -158,7 +168,7 @@ impl SignatureDrag { layers.settling = true; } } else { - *self = Self::default(); + *self = Self { moving: self.moving.take(), ..Self::default() }; } } @@ -199,26 +209,133 @@ impl SignatureDrag { *turn, egui::Color32::from_white_alpha((opacity * 255.0).round() as u8), ), - Front::Stamp(stamp) => { - let Some((_, texture, area)) = &stamp.pixels else { return }; - // Where the stamp's pixels were rendered, stretched as its box is from `from` to `rect`. - let from = cx.xf.user_rect(cx.info, page, stamp.from); - if from.width() < 0.01 || from.height() < 0.01 { - return; + Front::Stamp(stamp) => stamp.paint(&painter, cx, page, rect), + } + } + + /// The page of the comment being moved, whose raster [`Self::prepare_move`] asks about. + pub fn moving_page(&self) -> Option { + self.moving.as_ref().map(|((_, page, _), _)| *page) + } + + /// While a comment without stamp layers is moved, its own layer: rendered when the move + /// starts, kept after release until the page's raster shows it where it went (`stale`: the + /// page is still drawn as before its last change). + pub fn prepare_move(&mut self, ctx: &egui::Context, doc: &Document, cv: &CommentView, pending: Option<&Edit>, stale: bool, scale: f32) { + let generation = doc.edit_generation(); + let dragged = match cv.gesture { + Some(Gesture::Move { page, index, .. }) if !self.contains(page, index) => Some((generation, page, index)), + _ => None, + }; + let keep = |key: (u64, usize, usize), m: &Moving| { + let (_, page, index) = key; + dragged == Some(key) + || matches!(pending, Some(Edit::MoveAnnotation { page: p, index: i, .. }) if (*p, *i) == (page, index)) + || m.settling && stale + }; + if self.moving.as_ref().is_some_and(|(key, m)| !keep(*key, m)) { + self.moving = None; + } + if self.no_layer != dragged { + self.no_layer = None; + } + if self.moving.is_none() + && self.no_layer.is_none() + && let Some(key @ (_, page, index)) = dragged + && let Some(a) = doc.info.annotations.iter().find(|a| a.page == page && a.index == index && !a.locked) + { + // Made once per drag: it copies and writes out the whole document. + match doc.comment_layer(page, &[index]) { + Ok(Some(alone)) => { + // The box it is drawn in: a comment kept upright on a turned page (NoRotate) + // isn't drawn in its /Rect, and the frame it follows is that box too. + let rotation = doc.info.pages.get(page).map_or(0, |p| p.rotation); + let layer = StampLayer { renderer: alone.renderer(), from: a.shown_rect(rotation), tag: None, pixels: None, failed: None }; + self.moving = Some((key, Moving { layer, settling: false })); } - let stretch = vec2(rect.width() / from.width(), rect.height() / from.height()); - let mut mesh = egui::Mesh::with_texture(texture.id()); - for (u, v) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] { - let (x, y) = (area[0] + u * (area[2] - area[0]), area[1] + v * (area[3] - area[1])); - let at = cx.xf.norm_to_screen(x / cx.xf.pw, y / cx.xf.ph); - let pos = rect.min + (at - from.min) * stretch; - mesh.vertices.push(egui::epaint::Vertex { pos, uv: pos2(u, v), color: egui::Color32::WHITE }); - } - mesh.add_triangle(0, 1, 2); - mesh.add_triangle(0, 2, 3); - painter.add(egui::Shape::mesh(mesh)); + _ => self.no_layer = Some(key), } } + let Some(((_, page, _), m)) = self.moving.as_mut() else { return }; + let Some(p) = doc.info.pages.get(*page) else { return }; + match m.layer.update(ctx, *page, p, scale) { + Some(pending) => { + if pending { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } + // Nothing to lay over the page at this scale (no box, or the render failed): the + // frame alone follows the pointer, as before. The layer stays, so another scale + // renders it again without making it anew. + None => m.layer.pixels = None, + } + } + + /// The comment being moved, drawn where it goes (as far as its frame has gone). + pub fn paint_move(&self, painter: &egui::Painter, cx: &PageCx<'_>, cv: &CommentView, pending: Option<&Edit>) { + let Some(((_, page, index), m)) = &self.moving else { return }; + if *page != cx.page || cx.hidden { + return; + } + let Some(a) = cx.get(*index) else { return }; + let shift = painter.ctx().input(|i| i.modifiers.shift); + // As far as its frame has gone from where the move started (once applied, its box is + // already where it went). + let rect = cx.adjusted_rect(a, cv.gesture.as_ref(), cx.ptr.pos, shift, pending); + let from = cx.xf.user_rect(cx.info, *page, m.layer.from); + let painter = painter.with_clip_rect(painter.clip_rect().intersect(cx.xf.rect)); + m.layer.paint(&painter, cx, *page, from.translate(rect.min - from.min)); + } +} + +impl StampLayer { + /// Render the layer's tile at `scale` (or less, within [`MAX_STAMP_SIDE`]) and take it when + /// it arrives. `Some(true)` while the pixels at this scale are still on their way, `None` + /// when there is nothing to draw. + fn update(&mut self, ctx: &egui::Context, page: usize, p: &PageInfo, scale: f32) -> Option { + let (scale, tile) = stamp_tile(p, self.from, scale)?; + let tag = (scale * 1000.0) as u64; + if self.tag != Some(tag) { + self.renderer.set_queue(vec![RenderRequest { page, scale, tag, tile: Some(tile), ..Default::default() }]); + self.tag = Some(tag); + } + if self.failed == Some(tag) { + return None; + } + if let Some(out) = self.renderer.try_recv() + && out.request.tag == tag + { + let (Some(t), None) = (out.request.tile, &out.error) else { + self.failed = Some(tag); + return None; + }; + let s = out.request.scale; + let area = [t.x as f32 / s, t.y as f32 / s, t.x.saturating_add(t.w) as f32 / s, t.y.saturating_add(t.h) as f32 / s]; + let image = crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba); + self.pixels = Some((tag, ctx.load_texture("signature-drag-stamp", image, TextureOptions::LINEAR), area)); + } + Some(self.pixels.as_ref().is_none_or(|(received, ..)| *received != tag)) + } + + /// The layer's pixels where its box goes on screen, `rect`: moved, and stretched as its box + /// is from `from` to `rect`. + fn paint(&self, painter: &egui::Painter, cx: &PageCx<'_>, page: usize, rect: egui::Rect) { + let Some((_, texture, area)) = &self.pixels else { return }; + let from = cx.xf.user_rect(cx.info, page, self.from); + if from.width() < 0.01 || from.height() < 0.01 { + return; + } + let stretch = vec2(rect.width() / from.width(), rect.height() / from.height()); + let mut mesh = egui::Mesh::with_texture(texture.id()); + for (u, v) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] { + let (x, y) = (area[0] + u * (area[2] - area[0]), area[1] + v * (area[3] - area[1])); + let at = cx.xf.norm_to_screen(x / cx.xf.pw, y / cx.xf.ph); + let pos = rect.min + (at - from.min) * stretch; + mesh.vertices.push(egui::epaint::Vertex { pos, uv: pos2(u, v), color: egui::Color32::WHITE }); + } + mesh.add_triangle(0, 1, 2); + mesh.add_triangle(0, 2, 3); + painter.add(egui::Shape::mesh(mesh)); } } @@ -244,3 +361,58 @@ fn stamp_tile(p: &PageInfo, rect: [f32; 4], scale: f32) -> Option<(f32, Tile)> { let (right, bottom) = (((x1 * scale).ceil() + 1.0).min(width).max(left + 1.0), ((y1 * scale).ceil() + 1.0).min(height).max(top + 1.0)); Some((scale, Tile { x: left as u32, y: top as u32, w: (right - left) as u32, h: (bottom - top) as u32 })) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + /// One page with one comment that has nothing to draw: a sound annotation without an + /// appearance, for which none is built. + fn silent() -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Sound /Rect [50 50 70 70] /Contents (hush) >>", + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for 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 + } + + /// A moved comment whose own layer can't be made is asked for once per drag, not on every + /// pointer frame (making it copies and writes out the whole document); only its frame follows + /// the pointer. Once the drag ends, the next one asks again. + #[test] + fn a_comment_with_nothing_to_draw_is_prepared_once_per_drag() { + let mut session = pdfcraft_engine::Session::new(); + let id = session.open("silent.pdf", None, Arc::new(silent()), None).expect("opens"); + let doc = session.get(id).expect("open"); + let index = doc.info.annotations.iter().find(|a| a.subtype == "Sound").expect("listed").index; + let ctx = egui::Context::default(); + let mut cv = CommentView::default(); + cv.gesture = Some(Gesture::Move { page: 0, index, from: pos2(0.0, 0.0) }); + let mut drag = SignatureDrag::default(); + let key = Some((doc.edit_generation(), 0, index)); + drag.prepare_move(&ctx, doc, &cv, None, false, 1.0); + assert!(drag.moving.is_none(), "nothing to draw"); + assert_eq!(drag.no_layer, key, "remembered for this drag"); + drag.prepare_move(&ctx, doc, &cv, None, false, 1.0); + assert!(drag.moving.is_none() && drag.no_layer == key, "and not made again"); + cv.gesture = None; + drag.prepare_move(&ctx, doc, &cv, None, false, 1.0); + assert_eq!(drag.no_layer, None, "forgotten when the drag ends"); + } +} 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 8e530ac58e..159adf2b7e 100644 --- a/crates/ui-egui/src/canvas/zoom_snap.rs +++ b/crates/ui-egui/src/canvas/zoom_snap.rs @@ -19,19 +19,22 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p if pointer.is_some_and(|p| xf.rect.contains(p)) { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - if resp.drag_started() + // The tool's own press, of the primary button: nothing drawn over the page here holds it. + if resp.drag_started_by(egui::PointerButton::Primary) + && view.router.owner(page).is_none() && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) { view.marquee = Some((page, o)); } if let Some((p, start)) = view.marquee && p == page - && let Some(end) = pointer { + // Where the pointer is, or where it went down when it is off the window. + let end = pointer.unwrap_or(start); let end = Pos2::new(end.x.clamp(xf.rect.left(), xf.rect.right()), end.y.clamp(xf.rect.top(), xf.rect.bottom())); let r = Rect::from_two_pos(start, end); ui.painter().rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, Color32::from_rgb(0x14, 0x73, 0xE6)), egui::StrokeKind::Middle); - if resp.drag_stopped() { + if resp.drag_stopped() || !ptr.primary_down { view.marquee = None; let (a, b) = (xf.screen_to_view(r.left_top()), xf.screen_to_view(r.right_bottom())); view.marquee_done = Some((page, r, [a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)])); @@ -142,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/commands.rs b/crates/ui-egui/src/commands.rs index d8c51b0e97..08d2040e23 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -83,7 +83,11 @@ impl PdfCraftApp { if self.combine_showing() && matches!(spec.needs, commands::Needs::Undo | commands::Needs::Redo) { return self.combine_can_undo(spec.needs == commands::Needs::Undo); } - commands::is_enabled(spec, &self.session, self.active_ids().map(|(_, id)| id)) + // Undo also takes back typing in a box open on the page, and Redo what Undo took there, + // with no document step. + (commands::is_enabled(spec, &self.session, self.active_ids().map(|(_, id)| id)) + || spec.needs == commands::Needs::Undo && self.page_typing_unapplied() + || spec.needs == commands::Needs::Redo && self.open_box_can_redo()) && (spec.id != "edit.copy" || self .active @@ -489,6 +493,7 @@ impl PdfCraftApp { K::Background }; self.marks_draft.replace = id.ends_with(".update"); + self.marks_draft.opened(kind); self.marks_draft.preview = None; self.dialog = Some(Dialog::Marks(kind)); } @@ -797,7 +802,16 @@ impl PdfCraftApp { if matches!(spec.id, "view.previous_view" | "view.next_view") && !self.command_enabled(spec) { continue; } - if typing && !spec.in_text { + // A box typed into on the page (a paragraph being edited, added text) keeps Undo and + // Redo only while it holds typing the page doesn't have, or Redo has typing to put + // back; then they reach the document. ⌘⇧Z is also ⌘Z to the Undo shortcut, so a + // Redo the box keeps is never taken as Undo. + let page_box = matches!(spec.id, "edit.undo" | "edit.redo") + && self.page_box_focused(ctx) + && !self.page_typing_unapplied() + && !(spec.id == "edit.redo" && self.page_box_can_redo(ctx)) + && !(spec.id == "edit.undo" && ctx.input(|i| i.modifiers.shift)); + if typing && !spec.in_text && !page_box { continue; } let Some(key) = Key::from_name(s.key) else { continue }; 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 e607bfa690..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 } @@ -1463,6 +1469,13 @@ fn dialog_shortcuts(ui: &mut egui::Ui) -> bool { let pair = |a, b| format!("{} / {}", key(a), key(b)); // Key names, translated where a catalog has them (not scanned as UI literals). let named = |k: &str| tl!(k).to_string(); + // Alt+← / Alt+→ (`canvas::shortcuts`): not a registered command, which has no Alt. + let alt_arrows = if crate::commands::mac_shortcuts(ui.ctx()) { + "⌥← / ⌥→".to_owned() + } else { + let alt = crate::i18n::key_name("Alt"); + format!("{alt}+← / {alt}+→") + }; let scroll = crate::i18n::fmt(tl!("Zoom in / out (also pinch or {key}-scroll)"), &[("key", crate::commands::command_modifier_label(ui.ctx()))]); for (k, v) in [ (pair(FIND_NEXT, FIND_PREV), tl!("Next / previous match").to_string()), @@ -1476,6 +1489,7 @@ fn dialog_shortcuts(ui: &mut egui::Ui) -> bool { (pair(ROTATE_CW, ROTATE_CCW), tl!("Rotate view").to_string()), (format!("{} / {}", crate::i18n::key_name("Home"), crate::i18n::key_name("End")), tl!("First / last page").to_string()), (format!("← / →, {}", pair(PAGE_PREV, PAGE_NEXT)), tl!("Previous / next page").to_string()), + (alt_arrows, format!("{} / {}", tl!("Previous view"), tl!("Next view"))), (named("V"), tl!("Select (V)").to_string()), (named("H / Space (hold)"), tl!("Hand (H)").to_string()), (crate::i18n::key_name("Delete").to_string(), tl!("Delete selected pages (Organize)").to_string()), @@ -1498,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| { @@ -1545,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()); @@ -1561,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; @@ -1730,15 +1833,25 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) { let form = |app: &PdfCraftApp| app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|doc| doc.form.clone()); // A rename also renames the field in the formulas that read it. let before = props.name.as_ref().and_then(|_| form(app)); - if app.apply_edit(Edit::SetFieldProps { name: d.field.clone(), props: Box::new(props) }) - && let Some(i) = app.active - { + // The widget being edited, by object: a radio button that joins a group by its new name + // is appended after the group's own buttons, so its index changes. + let widget = before.as_ref().and_then(|all| all.iter().find(|f| f.name == d.field)?.widgets.get(d.widget).map(|w| w.obj)); + let applied = app.apply_edit(Edit::SetFieldProps { name: d.field.clone(), props: Box::new(props) }); + if !applied && app.active_ids().is_some() { + // A refused edit (its reason is in the notice) leaves the dialog open with what was + // typed into it, as the several-fields dialog does, so nothing is lost meanwhile. + app.field_props = Some(d); + req.close = false; + } else if applied && let Some(i) = app.active { // Keep the (possibly renamed) field selected. // A dotted name is the full name; a plain one renames the field within its parent. let typed = d.name.trim(); let prefix = d.field.rsplit_once('.').filter(|_| !typed.contains('.')).map(|(p, _)| format!("{p}.")).unwrap_or_default(); let name = format!("{prefix}{typed}"); - if let (Some(before), Some(after)) = (before, form(app)) { + let after = form(app); + let index = + widget.and_then(|obj| after.as_ref()?.iter().find(|f| f.name == name)?.widgets.iter().position(|w| w.obj == obj)).unwrap_or(d.widget); + if let (Some(before), Some(after)) = (before, after) { let n = pdfcraft_engine::form_calculations_changed(&before, &after).iter().filter(|f| **f != name).count(); if n > 0 { app.notify(crate::i18n::trn( @@ -1749,7 +1862,7 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) { )); } } - app.views[i].prepare.selected = Some((name, d.widget)); + app.views[i].prepare.selected = Some((name, index)); } } } @@ -1823,6 +1936,7 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: app.view_draft = None; app.custom_draft = None; app.bulk_field_props = None; + app.unmarked_page = Default::default(); } else { app.dialog = Some(req.next.unwrap_or(dialog)); } 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/dialogs/marks_ui.rs b/crates/ui-egui/src/dialogs/marks_ui.rs index 437d45e9fe..085c581119 100644 --- a/crates/ui-egui/src/dialogs/marks_ui.rs +++ b/crates/ui-egui/src/dialogs/marks_ui.rs @@ -3,8 +3,10 @@ //! page the range selects, with the marks laid out on it. "Update" opens the same dialog in //! replace mode. +use std::collections::{HashMap, HashSet}; + use egui::{Align, Color32, CornerRadius, Layout, Rect, Stroke, pos2, vec2}; -use pdfcraft_engine::{Background, Edit, FontFamily, HeaderFooter, MarkKind, Watermark}; +use pdfcraft_engine::{Backdrop, Background, DocId, Edit, FontFamily, HeaderFooter, MarkKind, Watermark}; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, icons, widgets}; @@ -12,6 +14,9 @@ use crate::{PdfCraftApp, icons, widgets}; /// How far a watermark may be moved from the page centre, in points. const MAX_OFFSET: f64 = 20_000.0; +/// The preview page's longer side, in points. +const PREVIEW_SIDE: f32 = 190.0; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Subset { All, @@ -72,6 +77,122 @@ impl PageRange { } } +/// How many pages a frame the Background dialog checks for a backdrop of their own, so a long +/// document never stalls the dialog. +const BACKDROP_PAGES_PER_FRAME: usize = 24; + +/// Which pages paint their own backdrop (it hides a background), for the document and edit +/// generation they were checked in. Filled a few pages a frame. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct BackdropCheck { + key: Option<(DocId, u64)>, + /// Per page: `None` until checked. + pages: Vec>>, +} + +/// What the check found among the pages a background goes on. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BackdropCount { + /// Pages (0-based, ascending) with a white backdrop that can be replaced. + pub fills: Vec, + /// Pages (0-based, ascending) covered by a picture (scans): the background stays hidden there. + pub images: Vec, + /// Pages not checked yet. + pub pending: usize, +} + +impl BackdropCheck { + /// Check up to [`BACKDROP_PAGES_PER_FRAME`] more of `pages` (0-based) and sort them. + pub fn update(&mut self, doc: &pdfcraft_engine::Document, pages: &[usize]) -> BackdropCount { + let key = (doc.id, doc.edit_generation()); + let count = doc.info.pages.len(); + if self.key != Some(key) || self.pages.len() != count { + self.key = Some(key); + self.pages = vec![None; count]; + } + // One batch a frame: finding a page walks the page tree, so the batch shares one walk. + let mut batch = Vec::with_capacity(BACKDROP_PAGES_PER_FRAME); + for &p in pages { + if batch.len() >= BACKDROP_PAGES_PER_FRAME { + break; + } + if matches!(self.pages.get(p), Some(None)) && !batch.contains(&p) { + batch.push(p); + } + } + if !batch.is_empty() { + for (p, found) in batch.iter().zip(doc.page_backdrops(&batch)) { + if let Some(slot) = self.pages.get_mut(*p) { + *slot = Some(found); + } + } + } + let mut n = BackdropCount::default(); + for &p in pages { + let Some(slot) = self.pages.get(p) else { continue }; + match slot { + None => n.pending += 1, + Some(Some(Backdrop::Fill)) => n.fills.push(p), + Some(Some(Backdrop::Image)) => n.images.push(p), + Some(None) => {} + } + } + for list in [&mut n.fills, &mut n.images] { + list.sort_unstable(); + list.dedup(); + } + n + } + + /// What the check found on `page` (0-based) of this version of `doc`: `None` until it has + /// looked, `Some(None)` for a page with no backdrop of its own. + pub fn found(&self, doc: &pdfcraft_engine::Document, page: usize) -> Option> { + if self.key != Some((doc.id, doc.edit_generation())) { + return None; + } + self.pages.get(page).copied().flatten() + } +} + +impl MarksDraft { + /// The dialog for `kind` is opening. Other settings carry over from last time, but replacing + /// a page's own backdrop starts on: Update restores backdrops before it re-applies, so a + /// page whose backdrop was replaced shows none and the checkbox stays hidden, and a choice + /// left off from an earlier dialog would hide the background again unseen. + pub fn opened(&mut self, kind: MarkKind) { + if kind == MarkKind::Background { + self.bg.replace_backdrop = true; + } + } +} + +/// How many runs of pages a message lists before it stops with "…". +const LISTED_PAGE_RUNS: usize = 8; + +/// After Remove background: the pages (0-based, ascending) whose own white backdrop stays off, +/// because they changed after the background was added (`pdfcraft_engine::EditNotice`). +pub(crate) fn backdrops_left_off_message(pages: &[usize]) -> String { + pages_phrase( + pages, + tl!("Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper."), + tl!("Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper."), + ) +} + +/// `one` or `many` with `{pages}` replaced by `pages` (0-based, ascending) as 1-based runs +/// ("1-3, 6"), so the message says which pages it means. +fn pages_phrase(pages: &[usize], one: &str, many: &str) -> String { + // `count` 0 never matches a list that isn't empty, so the list is always written out. + let runs = crate::combine_ui::range_of(pages, 0); + let mut parts: Vec<&str> = runs.split(", ").take(LISTED_PAGE_RUNS + 1).collect(); + if parts.len() > LISTED_PAGE_RUNS { + parts.truncate(LISTED_PAGE_RUNS); + parts.push("…"); + } + let template = if pages.len() == 1 { one } else { many }; + crate::i18n::fmt(template, &[("pages", &parts.join(", "))]) +} + #[derive(Clone, Debug, PartialEq)] pub struct MarksDraft { pub hf: HeaderFooter, @@ -90,6 +211,8 @@ pub struct MarksDraft { pub use_file: bool, pub file: Option<(String, std::sync::Arc>)>, pub file_page: usize, + /// Background: pages that paint their own backdrop. + pub backdrops: BackdropCheck, /// The page (0-based) the preview was stepped to; `None` previews the current page. Reset /// each time the dialog opens. pub preview: Option, @@ -110,6 +233,7 @@ impl Default for MarksDraft { use_file: false, file: None, file_page: 1, + backdrops: BackdropCheck::default(), preview: None, } } @@ -152,7 +276,7 @@ fn preview( draw: impl FnOnce(&egui::Painter, Rect, f32), ) { let (w, h) = (size.0.max(1.0), size.1.max(1.0)); - let s = (190.0 / w.max(h)) as f32; + let s = (f64::from(PREVIEW_SIDE) / w.max(h)) as f32; let (r, _) = ui.allocate_exact_size(vec2(210.0, 210.0), egui::Sense::hover()); let page = Rect::from_center_size(r.center(), vec2(w as f32 * s, h as f32 * s)); let painter = ui.painter_at(r); @@ -180,6 +304,132 @@ fn behind(color: [f64; 3], opacity: f64) -> Color32 { rgb32(color.map(|c| 1.0 - a * (1.0 - c))) } +/// The preview's tint for a background colour on a page whose own backdrop is `found` (`None` +/// when it has none, or the check hasn't reached it): no colour where the page will still hide +/// the background after OK, because a picture covers it (a scan) or it keeps its white backdrop +/// with Replace unticked. +fn background_tint(d: &MarksDraft, found: Option) -> Color32 { + let hidden = match found { + Some(Backdrop::Image) => true, + Some(Backdrop::Fill) => !d.bg.replace_backdrop, + None => false, + }; + if hidden { Color32::WHITE } else { behind(d.bg.color, d.bg.opacity) } +} + +/// In Update, the pages previewed without the marks the dialog replaces. Their thumbnails still +/// have them, so the new marks would be drawn over the old ones, and a new background colour +/// would tint the old one. The document without them is made once per dialog (it costs about a +/// save). The page shown is drawn from it straight away, at the preview's small size, so neither +/// a blank sheet nor the old marks show while it renders; the pages a step goes to next render +/// ahead, off the UI thread (not on the web, where the pool renders on it), so stepping rarely +/// waits. +#[derive(Default)] +pub(crate) struct UnmarkedPage { + /// The document, its edit generation and the kind of mark taken away. + key: Option<(pdfcraft_engine::DocId, u64, MarkKind)>, + pages: Option, + /// Renders the pages next to the one shown, ahead. None on the web, where the pool renders on + /// the UI thread inside `try_recv`: it would draw them in the same frame too. + renderer: Option, + /// The pages rendered, with the scale (×1000) they were rendered at: the page shown and the + /// pages next to it. + textures: HashMap, + /// Pages that failed to render when shown, so they aren't tried every frame: their + /// thumbnails show instead, the other pages still don't. + failed: HashSet, + /// The pages asked of the renderer ahead, and not back yet. Nothing waits for them: a step + /// is a frame, which takes what has come back first and draws the rest itself. + ahead: Vec<(usize, u64)>, + /// Pages (at a scale) that came back from rendering ahead with an error, not asked ahead + /// again: the error needn't be the page's, so drawing it when shown still decides. + not_ahead: HashSet<(usize, u64)>, +} + +impl UnmarkedPage { + /// The picture to preview `page` on in place of its thumbnail, or `None` for the thumbnail: + /// not Update, no marks of `kind` in the document, the pages can't be prepared, or `page` + /// fails to render and was never drawn (a picture of it at another scale is shown rather than + /// the old marks). `near` are the pages a step goes to from it, rendered ahead. + fn picture( + &mut self, + ctx: &egui::Context, + (doc, id): (&pdfcraft_engine::Document, pdfcraft_engine::DocId), + page: usize, + near: &[usize], + kind: MarkKind, + replace: bool, + ) -> Option { + let key = replace.then(|| (id, doc.edit_generation(), kind)); + if self.key != key { + *self = Self { key, ..Default::default() }; + if replace { + self.pages = doc.without_marks(kind).ok().flatten(); + self.renderer = self.pages.as_ref().filter(|_| !cfg!(target_arch = "wasm32")).map(|p| p.renderer()); + } + } + self.pages.as_ref()?; + let near = if self.renderer.is_some() { near } else { &[][..] }; + // At the preview's size in pixels. + let scale = |page: usize| { + let p = doc.info.pages.get(page)?; + let s = PREVIEW_SIDE * ctx.pixels_per_point() / p.width.max(p.height).max(1.0); + Some((s, (s * 1000.0) as u64)) + }; + let load = |ctx: &egui::Context, out: pdfcraft_render::RenderedPage| { + let image = crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba); + ctx.load_texture("marks-preview-unmarked", image, egui::TextureOptions::LINEAR) + }; + while let Some(out) = self.renderer.as_ref().and_then(|r| r.try_recv()) { + let at = (out.request.page, out.request.tag); + self.ahead.retain(|a| *a != at); + // An error rendering ahead needn't be the page's (the pool retires its parser, a + // request is cancelled): the page is drawn here when it is shown, and that decides. + // It isn't asked ahead again, so a page that always fails isn't rendered every frame. + if out.error.is_none() { + self.textures.insert(at.0, (at.1, load(ctx, out))); + } else { + self.not_ahead.insert(at); + } + } + let (s, tag) = scale(page)?; + if !self.failed.contains(&page) + && self.textures.get(&page).is_none_or(|(t, _)| *t != tag) + && let Some(pages) = self.pages.as_mut() + { + match pages.render_page(page, s) { + Ok(out) => { + self.textures.insert(page, (tag, load(ctx, out))); + } + Err(_) => { + self.failed.insert(page); + } + } + } + // Ahead: the pages a step goes to, not rendered yet at their scale. + let wanted: Vec<(usize, u64)> = near + .iter() + .filter(|p| !self.failed.contains(p)) + .filter_map(|&p| scale(p).map(|(_, t)| (p, t))) + .filter(|at| !self.not_ahead.contains(at)) + .filter(|(p, t)| self.textures.get(p).is_none_or(|(have, _)| have != t)) + .collect(); + if let Some(renderer) = self.renderer.as_ref() + && !wanted.is_empty() + && wanted != self.ahead + { + let requests = wanted + .iter() + .filter_map(|&(p, _)| scale(p).map(|(s, tag)| pdfcraft_render::RenderRequest { page: p, scale: s, tag, ..Default::default() })); + renderer.set_queue(requests.collect()); + self.ahead = wanted; + } + self.textures.retain(|p, _| *p == page || near.contains(p)); + // A picture rendered at another scale beats the thumbnail with the old marks. + self.textures.get(&page).map(|(_, t)| t.id()) + } +} + /// The page (0-based) the preview shows: the current one when the range selects it, otherwise the /// first page the range selects (the current one again when it selects none). fn preview_page(range: &PageRange, current: usize, count: usize) -> usize { @@ -370,6 +620,9 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M d.bg.opacity = pct / 100.0; } }); + if let Some(doc) = app.session.get(id) { + backdrop_options(ui, t, d, doc, count); + } } } ui.add_space(8.0); @@ -378,14 +631,19 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M ui.add_space(8.0); // The preview: the current page (or the page stepped to), or the first one the range selects // if it leaves that out, drawn from its thumbnail at its displayed size (after /Rotate), the - // frame the engine lays the marks out in. + // frame the engine lays the marks out in. Update draws it without the marks it replaces. let target = shown_preview(&d.range, &mut d.preview, current, count); let page = doc.info.pages.get(target).map(|p| (p.width as f64, p.height as f64)).unwrap_or((612.0, 792.0)); - let picture = app.views.get_mut(i).and_then(|view| { - view.need_thumbnail(target, true); - view.thumb_id(target) + let near: Vec = [false, true].into_iter().filter_map(|forward| step_preview(&d.range, target, count, forward)).collect(); + let unmarked = app.unmarked_page.picture(ui.ctx(), (doc, id), target, &near, kind, d.replace); + let picture = unmarked.or_else(|| { + app.views.get_mut(i).and_then(|view| { + view.need_thumbnail(target, true); + view.thumb_id(target) + }) }); - let tint = if kind == MarkKind::Background && !d.use_file { behind(d.bg.color, d.bg.opacity) } else { Color32::WHITE }; + let tint = + if kind == MarkKind::Background && !d.use_file { background_tint(d, d.backdrops.found(doc, target).flatten()) } else { Color32::WHITE }; let hf = d.hf.clone(); let size_h = page.1.max(1.0); let (wm, fit) = (d.wm.clone(), d.fit); @@ -578,6 +836,35 @@ fn file_source(ui: &mut egui::Ui, d: &mut MarksDraft, kind: MarkKind) -> bool { browse } +/// Pages that paint their own backdrop would hide the background: offer to replace a white one, +/// and say where a picture (a scan) will still hide it. +fn backdrop_options(ui: &mut egui::Ui, t: &Tokens, d: &mut MarksDraft, doc: &pdfcraft_engine::Document, count: usize) { + let n = d.backdrops.update(doc, &d.range.pages(count)); + if n.pending > 0 { + ui.label(egui::RichText::new(tl!("Checking pages…")).small().color(t.text_faint)); + ui.ctx().request_repaint(); + } + if !n.fills.is_empty() { + ui.add_space(4.0); + let label = pages_phrase( + &n.fills, + tl!("Page {pages} paints its own white backdrop, which would hide the background. Replace it"), + tl!("Pages {pages} paint their own white backdrop, which would hide the background. Replace it"), + ); + ui.add(widgets::checkbox(&mut d.bg.replace_backdrop, &label)); + ui.label(egui::RichText::new(tl!("Removing the background puts the page's own backdrop back.")).small().color(t.text_faint)); + } + if !n.images.is_empty() { + ui.add_space(4.0); + let text = pages_phrase( + &n.images, + tl!("A picture covers page {pages} (a scan?), so the background won't show there."), + tl!("Pictures cover pages {pages} (scans?), so the background won't show there."), + ); + ui.label(egui::RichText::new(text).small().color(crate::content_ui::UNDRAWABLE)); + } +} + /// The edit the dialog's OK makes. pub fn edit(d: &MarksDraft, kind: MarkKind, count: usize) -> Edit { let pages = d.range.pages(count); @@ -612,6 +899,18 @@ mod tests { assert_eq!(undrawable(MarkKind::Background, &d), None); } + #[test] + fn backdrop_messages_name_their_pages() { + let phrase = |pages: &[usize]| pages_phrase(pages, "Page {pages}", "Pages {pages}"); + assert_eq!(phrase(&[2]), "Page 3"); + assert_eq!(phrase(&[0, 1, 2, 5]), "Pages 1-3, 6"); + assert_eq!(phrase(&[0, 1]), "Pages 1-2", "a whole document is still listed"); + let every_other: Vec = (0..40).step_by(2).collect(); + assert_eq!(phrase(&every_other), "Pages 1, 3, 5, 7, 9, 11, 13, 15, …", "a long list stops"); + let runs: Vec = (0..LISTED_PAGE_RUNS * 2).step_by(2).collect(); + assert_eq!(phrase(&runs), "Pages 1, 3, 5, 7, 9, 11, 13, 15", "exactly the limit: no …"); + } + #[test] fn page_ranges_with_subsets() { let r = PageRange::default(); @@ -682,6 +981,19 @@ mod tests { assert_eq!(behind([0.2, 0.4, 0.6], f64::NAN), behind([0.2, 0.4, 0.6], 1.0), "opaque, as the engine has it"); } + #[test] + fn the_preview_shows_a_background_only_where_ok_lets_it_show() { + let mut d = MarksDraft::default(); + let colour = behind(d.bg.color, d.bg.opacity); + assert_ne!(colour, Color32::WHITE); + assert_eq!(background_tint(&d, None), colour, "no backdrop of its own"); + assert_eq!(background_tint(&d, Some(Backdrop::Fill)), colour, "its white backdrop is replaced"); + assert_eq!(background_tint(&d, Some(Backdrop::Image)), Color32::WHITE, "a picture covers it"); + d.bg.replace_backdrop = false; + assert_eq!(background_tint(&d, Some(Backdrop::Fill)), Color32::WHITE, "its white backdrop stays and hides it"); + assert_eq!(background_tint(&d, None), colour, "unticked changes nothing without a backdrop"); + } + #[test] fn the_watermark_edit_carries_the_offset() { let mut d = MarksDraft::default(); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index ab6d49f421..d4520a15b4 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -27,20 +27,27 @@ fn new_text_typed(v: &crate::DocView) -> bool { v.content.draft.as_ref().is_some_and(|d| d.index.is_none() && !d.text.trim().is_empty()) } -/// Keeps the pages (or comment) the user acted on selected, where they now are. -fn select_after_edit(view: &mut crate::DocView, edit: Edit, info: &pdfcraft_render::DocInfo, quick_tool: crate::QuickTool) { +/// Keeps the pages (or comment) the user acted on selected, where they now are. `annots_before` +/// 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, + info: &pdfcraft_render::DocInfo, + annots_before: &[pdfcraft_render::Annotation], + quick_tool: crate::QuickTool, +) { 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). @@ -64,7 +71,7 @@ fn select_after_edit(view: &mut crate::DocView, edit: Edit, info: &pdfcraft_rend view.comments.open_editor(a.page, n, String::new()); } } - Edit::DeleteAnnotation { .. } => view.comments.selected = None, + Edit::DeleteAnnotation { page, index } => view.comments.annotation_deleted(annots_before, &info.annotations, (*page, *index)), _ => {} } } @@ -105,11 +112,36 @@ 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 { (None, 0, false) }; + // A deletion moves the comments after it on the page: these say where the selected one, + // and the one open in the panel's editor, went. + let annots_before = match (&edit, self.session.get(id)) { + (Edit::DeleteAnnotation { page, .. }, Some(d)) => d.info.annotations.iter().filter(|a| a.page == *page).cloned().collect(), + _ => Vec::new(), + }; match self.session.apply(id, edit.clone()) { Ok(()) => { let Some(doc) = self.session.get(id) else { return true }; @@ -153,14 +185,22 @@ impl PdfCraftApp { view.comments.comment_added(); } // Keep the pages the user acted on selected, where they now are. - select_after_edit(view, edit, info, 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; } - // Text saved as typed but drawn with `?` (its font can't draw it): said, not silent. - // Before the scripts' output, so an alert they raise still shows. + // Text saved as typed but drawn with `?` (its font can't draw it), or pages whose + // own backdrop stays off: said, not silent. Before the scripts' output, so an alert + // they raise still shows. if let Some(n) = self.session.get(id).and_then(|d| d.edit_notices.first()).cloned() { - self.notify(crate::content_ui::undrawn_message(&n)); + self.notify(match &n { + pdfcraft_engine::EditNotice::Undrawn(t) => crate::content_ui::undrawn_message(t), + pdfcraft_engine::EditNotice::BackdropsLeftOff { pages } => crate::marks_ui::backdrops_left_off_message(pages), + }); } let out = self.session.take_js_output(id); self.handle_js(id, out); @@ -170,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 @@ -198,28 +241,106 @@ impl PdfCraftApp { } if self.combine_showing() { self.combine_history_step(true); - } else { + } else if !self.undo_in_page_box() { self.history_step(true); } } + /// Undo from the menu or toolbar while a box on the page holds typing the page doesn't have + /// yet: the box takes the step, as it does for the key, one step of its typing at a time. + /// Applying the typing first was a new edit, which dropped the document's Redo, and then the + /// typing was taken back all at once. True when the box took the step. + fn undo_in_page_box(&mut self) -> bool { + let Some(ctx) = self.ctx.clone() else { return false }; + let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return false }; + let doc = view.id; + if let Some(ed) = view.line_editor.as_mut().filter(|ed| ed.has_unsaved_text()) { + ed.claim_typing(&ctx, doc); + let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, true) else { return false }; + let before = std::mem::replace(&mut ed.text, text); + ed.selection = None; + ed.follow_typed_rows(&ctx, &before); + return true; + } + if let Some(d) = view.content.draft.as_mut().filter(|d| d.has_unapplied_typing()) { + d.claim_typing(&ctx, doc); + let Some(text) = typing_step(&ctx, crate::content_ui::input_id(), &d.text, true) else { return false }; + let before = std::mem::replace(&mut d.text, text); + d.selection = None; + crate::edit_text_ui::follow_typing(&ctx, crate::content_ui::input_id(), &before, &d.text, &mut [&mut d.style.aligns]); + return true; + } + false + } + pub fn redo(&mut self) { if self.combine_showing() { self.combine_history_step(false); - } else { + } else if !self.redo_in_page_box() { self.history_step(false); } } + /// Redo from the menu or toolbar while a box on the page has typing of its own: the step is + /// the box's, as the key's is. What Undo took back in the box comes back; typing the page + /// doesn't have yet stays as it is, since applying it first is a new edit, which drops the + /// document's Redo. True when the box took the step. + fn redo_in_page_box(&mut self) -> bool { + let Some(ctx) = self.ctx.clone() else { return false }; + let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return false }; + let doc = view.id; + if let Some(ed) = view.line_editor.as_mut() { + ed.claim_typing(&ctx, doc); + let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, false) else { return ed.has_unsaved_text() }; + let before = std::mem::replace(&mut ed.text, text); + ed.selection = None; + ed.follow_typed_rows(&ctx, &before); + return true; + } + if let Some(d) = view.content.draft.as_mut() { + d.claim_typing(&ctx, doc); + let Some(text) = typing_step(&ctx, crate::content_ui::input_id(), &d.text, false) else { return d.has_unapplied_typing() }; + let before = std::mem::replace(&mut d.text, text); + d.selection = None; + crate::edit_text_ui::follow_typing(&ctx, crate::content_ui::input_id(), &before, &d.text, &mut [&mut d.style.aligns]); + return true; + } + false + } + fn history_step(&mut self, undo: bool) { let Some((i, id)) = self.active_ids() else { return }; + 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.selected = None; let label = crate::i18n::edit_label(&label); if undo { self.notify_fmt("Undid {label}", &[("label", &label)]); @@ -231,6 +352,90 @@ impl PdfCraftApp { } } + /// Before Undo or Redo: a paragraph or added text box open on the page shows the document as + /// it is before the step, so it is applied as clicking away applies it, then closed. Left + /// open, it kept showing what the step took away, and wrote from it when it was next applied. + /// What was typed is then the step Undo takes back. False (nothing is undone, and the box + /// stays open) while it holds text the standard fonts can't draw, or text that couldn't be + /// applied: it is shown, never lost. + pub(crate) fn close_text_editors(&mut self, i: usize, id: pdfcraft_engine::DocId) -> bool { + let Some(view) = self.views.get_mut(i) else { return true }; + if let Some(c) = view.content.blocked() { + view.content.hold_blocked(); + self.notify(crate::content_ui::undrawable_message(c)); + return false; + } + if let Some(ed) = view.line_editor.take() + && let Some(edit) = crate::edit_text_ui::finished_edit(ed.clone()) + && !self.apply_edit(edit) + { + if let Some(view) = self.views.get_mut(i) { + view.line_editor = Some(ed); + } + return false; + } + let added = self.session.get(id).map(|d| d.added().to_vec()).unwrap_or_default(); + let Some(view) = self.views.get_mut(i) else { return true }; + // Finishing new text also asks for it to be selected once it exists: put that back too. + let (draft, select_added) = (view.content.draft.clone(), view.content.select_added); + if let Some(edit) = view.content.finish_draft(&added) + && !self.apply_edit(edit) + { + if let Some(view) = self.views.get_mut(i) { + (view.content.draft, view.content.select_added) = (draft, select_added); + } + return false; + } + true + } + + /// Whether the keyboard is in a box typed into on the page: a paragraph being edited, or added + /// text. + pub(crate) fn page_box_focused(&self, ctx: &egui::Context) -> bool { + let focused = ctx.memory(|m| m.focused()); + focused == Some(crate::edit_text_ui::input_id()) || focused == Some(crate::content_ui::input_id()) + } + + /// Whether the focused box on the page has typing its own Redo would put back: after Undo in + /// the box restores what the page has, Redo still belongs to the box, not the document. The + /// same test egui makes when it handles the key. + pub(crate) fn page_box_can_redo(&self, ctx: &egui::Context) -> bool { + let Some(view) = self.active.and_then(|i| self.views.get(i)) else { return false }; + let focused = ctx.memory(|m| m.focused()); + let (id, text) = if focused == Some(crate::edit_text_ui::input_id()) + && let Some(ed) = &view.line_editor + { + (crate::edit_text_ui::input_id(), &ed.text) + } else if focused == Some(crate::content_ui::input_id()) + && let Some(d) = &view.content.draft + { + (crate::content_ui::input_id(), &d.text) + } else { + return false; + }; + has_typing_to_redo(ctx, id, text) + } + + /// Whether the box open on the page has a Redo of its own, the step + /// [`Self::redo_in_page_box`] takes for Redo from the menu or toolbar: focus has moved to the + /// menu by then, so unlike [`Self::page_box_can_redo`] this doesn't ask for it. + pub(crate) fn open_box_can_redo(&self) -> bool { + let Some(ctx) = &self.ctx else { return false }; + let Some(view) = self.active.and_then(|i| self.views.get(i)) else { return false }; + if let Some(ed) = &view.line_editor { + return has_typing_to_redo(ctx, crate::edit_text_ui::input_id(), &ed.text); + } + view.content.draft.as_ref().is_some_and(|d| has_typing_to_redo(ctx, crate::content_ui::input_id(), &d.text)) + } + + /// Typing in a box on the page (a paragraph being edited, added text) that the page doesn't + /// have yet: Undo and Redo keys take it back in the box first. + pub(crate) fn page_typing_unapplied(&self) -> bool { + self.active.and_then(|i| self.views.get(i)).is_some_and(|v| { + v.line_editor.as_ref().is_some_and(|ed| ed.has_unsaved_text()) || v.content.draft.as_ref().is_some_and(|d| d.has_unapplied_typing()) + }) + } + /// Apply any edit a view queued this frame (organize toolbar, keys). /// Returns false when a field refused the typing queued for it (its editor is open again). pub(crate) fn process_pending_edits(&mut self) -> bool { @@ -579,7 +784,7 @@ impl PdfCraftApp { } let author = self.comment_prefs.author.clone(); let rotation = self.session.get(id).map_or(0, |doc| crate::comments::page_rotation(&doc.info, tb.page)); - let edit = crate::fill_sign::typed(tb.page, tb.at, &text, tb.size, rotation, &author); + let edit = crate::fill_sign::typed_with_color(tb.page, tb.at, &text, tb.size, rotation, &author, tb.color); if self.apply_edit(edit) { if let Some(view) = self.views.get_mut(i) { view.fill_text = None; @@ -625,6 +830,48 @@ impl PdfCraftApp { } } +fn rect_matches(a: [f64; 4], b: [f64; 4]) -> bool { + a.iter().zip(b.iter()).all(|(x, y)| (x - y).abs() < 1e-3) +} + +fn re_resolve_image_selection( + view: &mut crate::DocView, + page: usize, + target: &pdfcraft_engine::PageImage, + ambiguous_before: bool, + page_images: &[pdfcraft_engine::PageImage], +) { + let exact_matches: Vec = 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 { @@ -747,6 +994,26 @@ fn bookmark_at<'a>(items: &'a [pdfcraft_render::OutlineItem], path: &[usize]) -> if rest.is_empty() { Some(item) } else { bookmark_at(&item.children, rest) } } +/// Whether the page text box `id`, showing `text`, has typing its own Redo would put back: the +/// test egui makes when it handles the key. +fn has_typing_to_redo(ctx: &egui::Context, id: egui::Id, text: &str) -> bool { + egui::TextEdit::load_state(ctx, id) + .is_some_and(|state| state.cursor.char_range().is_some_and(|range| state.undoer().has_redo(&(range, text.to_owned())))) +} + +/// The box's own Undo (or Redo), as the key does it: the box's text one step back (or forward) in +/// its typing, with the box's history and cursor moved along. None when there is no such step. +fn typing_step(ctx: &egui::Context, id: egui::Id, text: &str, undo: bool) -> Option { + let mut state = egui::TextEdit::load_state(ctx, id)?; + let mut undoer = state.undoer(); + let current = (state.cursor.char_range()?, text.to_owned()); + let (range, stepped) = if undo { undoer.undo(¤t) } else { undoer.redo(¤t) }?.clone(); + state.set_undoer(undoer); + state.cursor.set_char_range(Some(range)); + egui::TextEdit::store_state(ctx, id, state); + Some(stepped) +} + #[cfg(test)] mod tests; @@ -860,7 +1127,16 @@ mod revert_draft_tests { fn text_typed_into_a_new_text_box_counts_as_unsaved_work() { use crate::content_ui::{TextDraft, default_style}; let mut app = app(); - let draft = |text: &str| TextDraft { page: 0, index: None, rect: [0.0; 4], text: text.into(), style: default_style(), focus: true }; + let draft = |text: &str| TextDraft { + page: 0, + index: None, + rect: [0.0; 4], + text: text.into(), + style: default_style(), + focus: true, + selection: None, + typing_from: None, + }; assert!(!app.has_unsaved_work(0)); app.views[0].content.draft = Some(draft(" \n")); assert!(!app.has_unsaved_work(0)); @@ -885,7 +1161,16 @@ mod revert_draft_tests { let mut app = PdfCraftApp::new(); app.open_bytes("source.pdf", Some(path.clone()), bytes).unwrap(); let rect = [72.0, 72.0, 272.0, 92.0]; - app.views[0].content.draft = Some(TextDraft { page: 0, index: None, rect, text: "Typed".into(), style: default_style(), focus: true }); + app.views[0].content.draft = Some(TextDraft { + page: 0, + index: None, + rect, + text: "Typed".into(), + style: default_style(), + focus: true, + selection: None, + typing_from: None, + }); let ctx = egui::Context::default(); if quit { assert!(!app.request_quit(&ctx)); diff --git a/crates/ui-egui/src/editing/tests.rs b/crates/ui-egui/src/editing/tests.rs index 87de7e8b87..76886ff54f 100644 --- a/crates/ui-egui/src/editing/tests.rs +++ b/crates/ui-egui/src/editing/tests.rs @@ -296,6 +296,46 @@ fn one_page_pdf() -> Vec { out } +/// `one_page_pdf`, opening without a password, whose permissions refuse changes. +fn no_changes_pdf() -> Vec { + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(one_page_pdf())).unwrap(); + let encryption = pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1 ^ 8, + encrypt_metadata: true, + seed: [4; 32], + }; + doc.set_encryption(&encryption).unwrap(); + pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() +} + +/// Undo with a new text box open on a document that refuses changes: the box is put back as +/// it was. Finishing it had asked for the new item to be selected, and that request stayed +/// behind once the add was refused. +#[test] +fn a_refused_text_box_is_put_back_whole() { + let mut app = crate::PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("a.pdf", None, no_changes_pdf()).unwrap(); + let style = crate::content_ui::default_style(); + app.views[0].content.draft = Some(crate::content_ui::TextDraft { + page: 0, + index: None, + rect: [20.0, 20.0, 120.0, 40.0], + text: "Typed".into(), + style, + focus: false, + selection: None, + typing_from: None, + }); + app.undo(); + let content = &app.views[0].content; + assert_eq!(content.draft.as_ref().map(|d| d.text.as_str()), Some("Typed"), "the refused box stays open"); + assert_eq!(content.select_added, None, "nothing was added to select"); +} + #[test] fn save_commits_the_open_text_box() { let dir = staging_dir("open-text-box"); @@ -305,8 +345,16 @@ fn save_commits_the_open_text_box() { app.open_bytes("a.pdf", None, one_page_pdf()).unwrap(); app.save_override = Some(dest.to_string_lossy().into_owned()); let style = crate::content_ui::default_style(); - app.views[0].content.draft = - Some(crate::content_ui::TextDraft { page: 0, index: None, rect: [20.0, 20.0, 120.0, 40.0], text: "Typed text".into(), style, focus: true }); + app.views[0].content.draft = Some(crate::content_ui::TextDraft { + page: 0, + index: None, + rect: [20.0, 20.0, 120.0, 40.0], + text: "Typed text".into(), + style, + focus: true, + selection: None, + typing_from: None, + }); assert!(app.save_active(SaveTarget::InPlace)); let id = app.views[0].id; 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 3e6d735523..babaa33002 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -541,6 +541,12 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. يقرأ {names} التي تقرؤه بدورها. لا يمكن لأي ترتيب إصلاح الحلقة: غيّر إحدى الصيغ. Not in order خارج الترتيب Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. غير موجود في ترتيب الحساب المحفوظ في ملف PDF: قد لا تحسبه برامج عرض PDF الأخرى. + Reads {name}, which the form doesn't have, so it gets no value there. يقرأ {name}، وهو غير موجود في النموذج، لذا لا يحصل منه على أي قيمة. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. يقرأ سكربته أيضًا حقولًا يبني أسماءها أثناء التشغيل، لذا لا يمكن التحقق من هذه القراءات. + Missing field حقل مفقود + Unchecked reads قراءات غير مفحوصة + {field} reads fields named as it runs يقرأ {field} حقولًا تُسمّى أثناء التشغيل + Some names are built as it runs تُبنى بعض الأسماء أثناء التشغيل Back to Prepare a form العودة إلى تجهيز نموذج No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. لا يحسب أي حقل في هذا النموذج قيمته بعد. امنح حقلًا عملية حساب في علامة التبويب «الحساب» ضمن خصائصه، وسيظهر هنا. {names} and {last} {names} و{last} @@ -597,6 +603,9 @@ Clean Up تنظيف Color Images الصور الملونة Grayscale Images الصور الرمادية + Monochrome Images الصور أحادية اللون + CCITT Group 4 CCITT المجموعة 4 + JBIG2 (lossless) JBIG2 (بدون فقدان) Bicubic downsampling to تقليل العينات بطريقة Bicubic إلى for images above للصور التي تتجاوز Compression الضغط @@ -814,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 لاحقًا @@ -866,6 +870,14 @@ Applies to 1 page يُطبَّق على صفحة واحدة Applies to {n} pages يُطبَّق على {n} صفحات Replaces the existing one on those pages. يستبدل الموجود على تلك الصفحات. + Checking pages… جارٍ فحص الصفحات… + Page {pages} paints its own white backdrop, which would hide the background. Replace it الصفحة {pages} ترسم تعبئة بيضاء خاصة بها ستُخفي الخلفية. استبدالها + Pages {pages} paint their own white backdrop, which would hide the background. Replace it الصفحات {pages} ترسم تعبئة بيضاء خاصة بها ستُخفي الخلفية. استبدالها + Removing the background puts the page's own backdrop back. إزالة الخلفية تعيد التعبئة البيضاء الخاصة بالصفحة. + A picture covers page {pages} (a scan?), so the background won't show there. تغطي صورة الصفحة {pages} (مسح ضوئي؟)، لذا لن تظهر الخلفية فيها. + Pictures cover pages {pages} (scans?), so the background won't show there. تغطي صور الصفحات {pages} (مسح ضوئي؟)، لذا لن تظهر الخلفية فيها. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. تغيّرت الصفحة {pages} بعد إضافة الخلفية، لذا تبقى تعبئتها البيضاء الخاصة متوقفة. تبدو كما هي على الورق الأبيض. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. تغيّرت الصفحات {pages} بعد إضافة الخلفية، لذا تبقى تعبئتها البيضاء الخاصة متوقفة. تبدو كما هي على الورق الأبيض. PDF or image PDF أو صورة No file chosen لم يُختر ملف Page number رقم الصفحة @@ -2426,6 +2438,11 @@ animation effects Off معطّلة @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} يُحسب قبل {fields}، التي يقرؤها.|{field} يُحسب قبل {fields}، الذي يقرؤه.|{field} يُحسب قبل {fields}، اللذين يقرؤهما.|{field} يُحسب قبل {fields}، التي يقرؤها.|{field} يُحسب قبل {fields}، التي يقرؤها.|{field} يُحسب قبل {fields}، التي يقرؤها. @plural {n} more field calculates too early.|{n} more fields calculate too early. {n} من الحقول الأخرى تُحسب مبكرًا جدًا.|حقل آخر ({n}) يُحسب مبكرًا جدًا.|حقلان آخران ({n}) يُحسبان مبكرًا جدًا.|{n} حقول أخرى تُحسب مبكرًا جدًا.|{n} حقلًا آخر يُحسب مبكرًا جدًا.|{n} من الحقول الأخرى تُحسب مبكرًا جدًا. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} غير موجودة في الترتيب المحفوظ في PDF، لذا قد لا تحسبها عارضات PDF الأخرى.|{fields} غير موجود في الترتيب المحفوظ في PDF، لذا قد لا تحسبه عارضات PDF الأخرى.|{fields} غير موجودين في الترتيب المحفوظ في PDF، لذا قد لا تحسبهما عارضات PDF الأخرى.|{fields} غير موجودة في الترتيب المحفوظ في PDF، لذا قد لا تحسبها عارضات PDF الأخرى.|{fields} غير موجودة في الترتيب المحفوظ في PDF، لذا قد لا تحسبها عارضات PDF الأخرى.|{fields} غير موجودة في الترتيب المحفوظ في PDF، لذا قد لا تحسبها عارضات PDF الأخرى. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} من الصيغ تقرأ حقولًا غير موجودة في النموذج|صيغة واحدة ({n}) تقرأ حقلًا غير موجود في النموذج|صيغتان ({n}) تقرآن حقولًا غير موجودة في النموذج|{n} صيغ تقرأ حقولًا غير موجودة في النموذج|{n} صيغة تقرأ حقولًا غير موجودة في النموذج|{n} من الصيغ تقرأ حقولًا غير موجودة في النموذج +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. يقرأ {field} الحقول {fields}، وهي غير موجودة في النموذج.|يقرأ {field} الحقل {fields}، وهو غير موجود في النموذج.|يقرأ {field} الحقلين {fields}، وهما غير موجودين في النموذج.|يقرأ {field} الحقول {fields}، وهي غير موجودة في النموذج.|يقرأ {field} الحقول {fields}، وهي غير موجودة في النموذج.|يقرأ {field} الحقول {fields}، وهي غير موجودة في النموذج. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} من الصيغ الأخرى تقرأ حقولًا مفقودة.|صيغة أخرى ({n}) تقرأ حقلًا مفقودًا.|صيغتان أخريان ({n}) تقرآن حقولًا مفقودة.|{n} صيغ أخرى تقرأ حقولًا مفقودة.|{n} صيغة أخرى تقرأ حقولًا مفقودة.|{n} من الصيغ الأخرى تقرأ حقولًا مفقودة. +@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} بعض أسماء الحقول أثناء التشغيل، لذا لا يتم التحقق من هذه القراءات.|يبني {fields} بعض أسماء الحقول أثناء التشغيل، لذا لا يتم التحقق من هذه القراءات.|يبني {fields} بعض أسماء الحقول أثناء التشغيل، لذا لا يتم التحقق من هذه القراءات.|يبني {fields} بعض أسماء الحقول أثناء التشغيل، لذا لا يتم التحقق من هذه القراءات.|يبني {fields} بعض أسماء الحقول أثناء التشغيل، لذا لا يتم التحقق من هذه القراءات.|يبني {fields} بعض أسماء الحقول أثناء التشغيل، لذا لا يتم التحقق من هذه القراءات. +@plural {fields} is missing|{fields} are missing {fields} مفقودة|{fields} مفقود|{fields} مفقودان|{fields} مفقودة|{fields} مفقودة|{fields} مفقودة Scan مسح ضوئي Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. وقّع على ورقة بيضاء والتقط لها صورة في إضاءة ساطعة ومتساوية (استخدم الفلاش). اختر الصورة أو أفلتها هنا: يعثر PdfCraft على التوقيع ويزيل الورق. Choose photo… اختيار صورة… @@ -2775,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 النماذج والتوقيعات @@ -2821,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 اليوم @@ -2833,3 +2876,15 @@ certificate Version الإصدار Rotate the selected pages clockwise تدوير الصفحات المحددة باتجاه عقارب الساعة Extract the selected pages… استخراج الصفحات المحددة… Delete the selected pages حذف الصفحات المحددة + +# 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 ec0339f18a..48af0db09a 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -202,6 +202,12 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Чете {names}, които на свой ред го четат. Никой ред не може да поправи цикъл: променете една от формулите. Not in order Извън реда Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Липсва в реда на изчисляване, записан в PDF файла: други програми за PDF може да не го изчисляват. + Reads {name}, which the form doesn't have, so it gets no value there. Чете {name}, което го няма във формуляра, затова не получава стойност оттам. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Скриптът му чете и полета, чиито имена съставя по време на изпълнение, затова тези четения не могат да бъдат проверени. + Missing field Липсващо поле + Unchecked reads Непроверени четения + {field} reads fields named as it runs {field} чете полета, именувани по време на изпълнение + Some names are built as it runs Някои имена се съставят по време на изпълнение Back to Prepare a form Назад към „Подготви формуляр“ No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Все още нито едно поле в този формуляр не изчислява стойността си. Задайте изчисление на поле в раздела „Изчисляване“ на свойствата му и то ще се покаже тук. {names} and {last} {names} и {last} @@ -259,6 +265,9 @@ Clean Up Почистване Color Images Цветни изображения Grayscale Images Изображения в сива скала + Monochrome Images Монохромни изображения + CCITT Group 4 CCITT група 4 + JBIG2 (lossless) JBIG2 (без загуби) Bicubic downsampling to Бикубично понижаване на резолюцията до for images above за изображения над Compression Компресия @@ -477,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 По-късно @@ -528,6 +532,14 @@ Applies to 1 page Прилага се към 1 страница Applies to {n} pages Прилага се към {n} страници Replaces the existing one on those pages. Заменя съществуващия на тези страници. + Checking pages… Проверка на страниците… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Страница {pages} има собствено бяло запълване, което би скрило фона. Замяна + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Страници {pages} имат собствено бяло запълване, което би скрило фона. Замяна + Removing the background puts the page's own backdrop back. Премахването на фона връща собственото бяло запълване на страницата. + A picture covers page {pages} (a scan?), so the background won't show there. Изображение покрива страница {pages} (сканиране?), затова фонът няма да се вижда там. + Pictures cover pages {pages} (scans?), so the background won't show there. Изображения покриват страници {pages} (сканирания?), затова фонът няма да се вижда там. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Страница {pages} е променена след добавянето на фона, затова собственото ѝ бяло запълване остава изключено. На бяла хартия изглежда по същия начин. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Страници {pages} са променени след добавянето на фона, затова собственото им бяло запълване остава изключено. На бяла хартия изглеждат по същия начин. PDF or image PDF или изображение No file chosen Не е избран файл Page number Номер на страница @@ -2368,6 +2380,11 @@ animation effects Off Изключени @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} се изчислява преди {fields}, което чете.|{field} се изчислява преди {fields}, които чете. @plural {n} more field calculates too early.|{n} more fields calculate too early. Още {n} поле се изчислява твърде рано.|Още {n} полета се изчисляват твърде рано. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} липсва в реда, запазен в PDF файла, затова други програми за PDF може да не го изчислят.|{fields} липсват в реда, запазен в PDF файла, затова други програми за PDF може да не ги изчислят. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} формула чете поле, което го няма във формуляра|{n} формули четат полета, които ги няма във формуляра +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} чете {fields}, което го няма във формуляра.|{field} чете {fields}, които ги няма във формуляра. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Още {n} формула чете липсващо поле.|Още {n} формули четат липсващи полета. +@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} съставя някои имена на полета по време на изпълнение, затова тези четения не се проверяват.|{fields} съставят някои имена на полета по време на изпълнение, затова тези четения не се проверяват. +@plural {fields} is missing|{fields} are missing {fields} липсва|{fields} липсват Scan Сканиране Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Подпишете се на лист бяла хартия и го снимайте на ярка, равномерна светлина (със светкавица). Изберете снимката или я пуснете тук: PdfCraft открива подписа и премахва хартията. Choose photo… Избор на снимка… @@ -2415,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} проблема с немаркирано съдържание. @@ -2461,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 Формуляри и подписи @@ -2508,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 Днес @@ -2520,3 +2564,15 @@ animation effects Off Изключени Rotate the selected pages clockwise Завъртане на избраните страници по часовниковата стрелка Extract the selected pages… Извличане на избраните страници… Delete the selected pages Изтриване на избраните страници + +# 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 0cacae3c2c..54901bb5a4 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -560,6 +560,9 @@ Clean Up Neteja Color Images Imatges en color Grayscale Images Imatges en escala de grisos + Monochrome Images Imatges monocromes + CCITT Group 4 CCITT grup 4 + JBIG2 (lossless) JBIG2 (sense pèrdua) Bicubic downsampling to Reducció bicúbica de resolució a for images above per a imatges de més de Compression Compressió @@ -774,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 @@ -825,6 +823,14 @@ Applies to 1 page S'aplica a 1 pàgina Applies to {n} pages S'aplica a {n} pàgines Replaces the existing one on those pages. Substitueix l'existent en aquestes pàgines. + Checking pages… S'estan comprovant les pàgines… + Page {pages} paints its own white backdrop, which would hide the background. Replace it La pàgina {pages} té el seu propi farciment blanc, que amagaria el fons. Substitueix-lo + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Les pàgines {pages} tenen el seu propi farciment blanc, que amagaria el fons. Substitueix-lo + Removing the background puts the page's own backdrop back. En eliminar el fons, es restaura el farciment blanc propi de la pàgina. + A picture covers page {pages} (a scan?), so the background won't show there. Una imatge cobreix la pàgina {pages} (un escaneig?), així que el fons no s'hi veurà. + Pictures cover pages {pages} (scans?), so the background won't show there. Hi ha imatges que cobreixen les pàgines {pages} (escanejos?), així que el fons no s'hi veurà. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. La pàgina {pages} ha canviat després d'afegir el fons, així que el seu propi farciment blanc continua desactivat. Es veu igual sobre paper blanc. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Les pàgines {pages} han canviat després d'afegir el fons, així que el seu propi farciment blanc continua desactivat. Es veuen igual sobre paper blanc. PDF or image PDF o imatge No file chosen Cap fitxer triat Page number Número de pàgina @@ -2720,6 +2726,12 @@ theme Appearance Aspecte Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Not in order Not in order Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. + Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. + Missing field Missing field + 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 Back to Prepare a form Back to Prepare a form No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. {names} and {last} {names} and {last} @@ -2748,6 +2760,11 @@ theme Appearance Aspecte @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. @plural {n} more field calculates too early.|{n} more fields calculate too early. {n} more field calculates too early.|{n} more fields calculate too early. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. +@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 You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Bulleted list Bulleted list @@ -2798,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 @@ -2818,3 +2862,13 @@ theme Appearance Aspecte Rotate the selected pages clockwise Gira les pàgines seleccionades en sentit horari Extract the selected pages… Extreu les pàgines seleccionades… Delete the selected pages Suprimeix les pàgines seleccionades + +# 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 71c0c62869..7138dc2c1f 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -567,6 +567,9 @@ Clean Up پاککردنەوە Color Images وێنە ڕەنگاوڕەنگەکان Grayscale Images وێنە خۆڵەمێشییەکان + Monochrome Images وێنە یەکڕەنگەکان + CCITT Group 4 CCITT گرووپی 4 + JBIG2 (lossless) JBIG2 (بێ لەدەستدان) Bicubic downsampling to کەمکردنەوەی نموونە بە شێوازی Bicubic بۆ for images above بۆ ئەو وێنانەی سەرووی Compression پەستاندن @@ -784,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 دواتر @@ -835,6 +833,14 @@ Applies to 1 page بۆ 1 پەڕە جێبەجێ دەکرێت Applies to {n} pages بۆ {n} پەڕە جێبەجێ دەکرێت Replaces the existing one on those pages. ئەوەی ئێستا هەیە لەسەر ئەو پەڕانە دەگۆڕێت. + Checking pages… پشکنینی پەڕەکان… + Page {pages} paints its own white backdrop, which would hide the background. Replace it پەڕەی {pages} پڕکردنەوەیەکی سپیی خۆی هەیە کە پاشبنەماکە دەشارێتەوە. جێگرتنەوە + Pages {pages} paint their own white backdrop, which would hide the background. Replace it پەڕەکانی {pages} پڕکردنەوەیەکی سپیی خۆیان هەیە کە پاشبنەماکە دەشارێتەوە. جێگرتنەوە + Removing the background puts the page's own backdrop back. لابردنی پاشبنەما پڕکردنەوە سپییەکەی خودی پەڕەکە دەگەڕێنێتەوە. + A picture covers page {pages} (a scan?), so the background won't show there. وێنەیەک پەڕەی {pages} دادەپۆشێت (سکان؟)، بۆیە پاشبنەماکە لەوێ دەرناکەوێت. + Pictures cover pages {pages} (scans?), so the background won't show there. وێنەکان پەڕەکانی {pages} دادەپۆشن (سکان؟)، بۆیە پاشبنەماکە لەوێ دەرناکەوێت. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. پەڕەی {pages} دوای زیادکردنی پاشبنەما گۆڕا، بۆیە پڕکردنەوە سپییەکەی خۆی کوژاوە دەمێنێتەوە. لەسەر کاغەزی سپی هەمان شێوەیە. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. پەڕەکانی {pages} دوای زیادکردنی پاشبنەما گۆڕان، بۆیە پڕکردنەوە سپییەکانی خۆیان کوژاوە دەمێننەوە. لەسەر کاغەزی سپی هەمان شێوەن. PDF or image PDF یان وێنە No file chosen هیچ فایلێک هەڵنەبژێردراوە Page number ژمارەی پەڕە @@ -2729,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 فۆڕمەکان و واژووەکان @@ -2749,3 +2762,47 @@ certificate Version وەشان Rotate the selected pages clockwise پەڕە هەڵبژێردراوەکان بە ئاڕاستەی میلی کاتژمێر بسوڕێنە Extract the selected pages… پەڕە هەڵبژێردراوەکان دەربهێنە… Delete the selected pages پەڕە هەڵبژێردراوەکان بسڕەوە + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply هەڵبژاردەکانی ئەم وەڵامە +# Untranslated: English text until a translator fills these in. + Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. + Missing field Missing field + 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 +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. +@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 ed6d051b57..51a569f4fc 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -625,6 +625,12 @@ key Space Mezerník Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Čte {names}, která ho čtou zpět. Žádné pořadí smyčku neopraví: změňte jeden ze vzorců. Not in order Mimo pořadí Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Chybí v pořadí výpočtů uloženém v PDF: jiné prohlížeče PDF ho možná nepočítají. + Reads {name}, which the form doesn't have, so it gets no value there. Čte {name}, které formulář nemá, takže odtud nedostane žádnou hodnotu. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Jeho skript čte i pole, jejichž názvy sestavuje za běhu, takže tato čtení nelze zkontrolovat. + Missing field Chybí pole + Unchecked reads Neověřená čtení + {field} reads fields named as it runs {field} čte pole pojmenovaná za běhu + Some names are built as it runs Některé názvy se sestavují za běhu Back to Prepare a form Zpět na Připravit formulář No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Žádné pole tohoto formuláře zatím nepočítá svou hodnotu. Zadejte poli výpočet na kartě Výpočet v jeho vlastnostech a zobrazí se zde. {names} and {last} {names} a {last} @@ -681,6 +687,9 @@ key Space Mezerník Clean Up Vyčištění Color Images Barevné obrázky Grayscale Images Obrázky ve stupních šedi + Monochrome Images Monochromatické obrázky + CCITT Group 4 CCITT skupina 4 + JBIG2 (lossless) JBIG2 (bezeztrátová) Bicubic downsampling to Bikubické převzorkování na for images above u obrázků nad Compression Komprese @@ -893,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 @@ -944,6 +948,14 @@ key Space Mezerník Applies to 1 page Týká se 1 stránky Applies to {n} pages Počet dotčených stránek: {n} Replaces the existing one on those pages. Nahradí stávající na těchto stránkách. + Checking pages… Kontrola stránek… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Stránka {pages} má vlastní bílou výplň, která by zakryla pozadí. Nahradit ji + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Stránky {pages} mají vlastní bílou výplň, která by zakryla pozadí. Nahradit ji + Removing the background puts the page's own backdrop back. Odebráním pozadí se vrátí vlastní bílá výplň stránky. + A picture covers page {pages} (a scan?), so the background won't show there. Stránku {pages} pokrývá obrázek (sken?), takže pozadí na ní nebude vidět. + Pictures cover pages {pages} (scans?), so the background won't show there. Stránky {pages} pokrývají obrázky (skeny?), takže pozadí na nich nebude vidět. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Stránka {pages} se po přidání pozadí změnila, proto její vlastní bílá výplň zůstává vypnutá. Na bílém papíře vypadá stejně. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Stránky {pages} se po přidání pozadí změnily, proto jejich vlastní bílá výplň zůstává vypnutá. Na bílém papíře vypadají stejně. PDF or image PDF nebo obrázek No file chosen Není vybrán žádný soubor Page number Číslo stránky @@ -2479,6 +2491,11 @@ animation effects Off Vypnuto @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} se počítá před {fields}, které čte.|{field} se počítá před {fields}, která čte.|{field} se počítá před {fields}, která čte. @plural {n} more field calculates too early.|{n} more fields calculate too early. Ještě {n} pole se počítá příliš brzy.|Ještě {n} pole se počítají příliš brzy.|Ještě {n} polí se počítá příliš brzy. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} chybí v pořadí uloženém v PDF, takže ho jiné prohlížeče PDF nemusí vypočítat.|{fields} chybí v pořadí uloženém v PDF, takže je jiné prohlížeče PDF nemusí vypočítat.|{fields} chybí v pořadí uloženém v PDF, takže je jiné prohlížeče PDF nemusí vypočítat. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} vzorec čte pole, které formulář nemá|{n} vzorce čtou pole, která formulář nemá|{n} vzorců čte pole, která formulář nemá +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} čte {fields}, které formulář nemá.|{field} čte {fields}, která formulář nemá.|{field} čte {fields}, která formulář nemá. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Ještě {n} vzorec čte chybějící pole.|Ještě {n} vzorce čtou chybějící pole.|Ještě {n} vzorců čte chybějící pole. +@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} sestavuje některé názvy polí za běhu, takže tato čtení nejsou ověřena.|{fields} sestavují některé názvy polí za běhu, takže tato čtení nejsou ověřena.|{fields} sestavují některé názvy polí za běhu, takže tato čtení nejsou ověřena. +@plural {fields} is missing|{fields} are missing {fields} chybí|{fields} chybí|{fields} chybí Scan Skenovat Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Podepište se na bílý list papíru a vyfoťte ho při jasném, rovnoměrném světle (použijte blesk). Vyberte fotografii nebo ji sem přetáhněte: PdfCraft najde podpis a odstraní papír. Choose photo… Vybrat fotografii… @@ -2572,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 @@ -2776,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) @@ -2852,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 @@ -2864,3 +2908,15 @@ certificate Version Verze Rotate the selected pages clockwise Otočit vybrané stránky po směru hodinových ručiček Extract the selected pages… Extrahovat vybrané stránky… Delete the selected pages Odstranit vybrané stránky + +# 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 8586a21d43..e8e1f8b555 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -514,6 +514,12 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Liest {names}, die es wieder lesen. Keine Reihenfolge kann eine Schleife beheben: Ändere eine der Formeln. Not in order Nicht in der Reihenfolge Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Fehlt in der im PDF gespeicherten Berechnungsreihenfolge: Andere PDF-Viewer berechnen es womöglich nicht. + Reads {name}, which the form doesn't have, so it gets no value there. Liest {name}, das es im Formular nicht gibt, und erhält dort deshalb keinen Wert. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Sein Skript liest auch Felder, deren Namen es erst beim Ausführen bildet. Diese Lesezugriffe lassen sich nicht prüfen. + Missing field Feld fehlt + Unchecked reads Ungeprüfte Lesezugriffe + {field} reads fields named as it runs {field} liest Felder, deren Namen erst beim Ausführen entstehen + Some names are built as it runs Manche Namen entstehen erst beim Ausführen Back to Prepare a form Zurück zu „Formular vorbereiten“ No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Noch berechnet kein Feld dieses Formulars seinen Wert. Gib einem Feld auf dem Tab „Berechnen“ seiner Eigenschaften eine Berechnung, dann erscheint es hier. {names} and {last} {names} und {last} @@ -570,6 +576,9 @@ Clean Up Aufräumen Color Images Farbbilder Grayscale Images Graustufenbilder + Monochrome Images Monochrome Bilder + CCITT Group 4 CCITT Gruppe 4 + JBIG2 (lossless) JBIG2 (verlustfrei) Bicubic downsampling to Bikubische Neuberechnung auf for images above für Bilder über Compression Komprimierung @@ -792,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 @@ -844,6 +848,14 @@ Applies to 1 page Gilt für 1 Seite Applies to {n} pages Gilt für {n} Seiten Replaces the existing one on those pages. Ersetzt das Vorhandene auf diesen Seiten. + Checking pages… Seiten werden geprüft… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Seite {pages} hat eine eigene weiße Fläche, die den Hintergrund verdecken würde. Ersetzen + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Die Seiten {pages} haben eine eigene weiße Fläche, die den Hintergrund verdecken würde. Ersetzen + Removing the background puts the page's own backdrop back. Wird der Hintergrund entfernt, kehrt die eigene weiße Fläche der Seite zurück. + A picture covers page {pages} (a scan?), so the background won't show there. Ein Bild bedeckt Seite {pages} (ein Scan?), daher ist der Hintergrund dort nicht zu sehen. + Pictures cover pages {pages} (scans?), so the background won't show there. Bilder bedecken die Seiten {pages} (Scans?), daher ist der Hintergrund dort nicht zu sehen. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Seite {pages} wurde nach dem Hinzufügen des Hintergrunds geändert, daher bleibt ihre eigene weiße Fläche ausgeschaltet. Auf weißem Papier sieht sie gleich aus. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Seiten {pages} wurden nach dem Hinzufügen des Hintergrunds geändert, daher bleiben ihre eigenen weißen Flächen ausgeschaltet. Auf weißem Papier sehen sie gleich aus. PDF or image PDF oder Bild No file chosen Keine Datei ausgewählt Page number Seitenzahl @@ -2406,6 +2418,11 @@ animation effects Off Aus @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} wird vor {fields} berechnet, das es liest.|{field} wird vor {fields} berechnet, die es liest. @plural {n} more field calculates too early.|{n} more fields calculate too early. {n} weiteres Feld wird zu früh berechnet.|{n} weitere Felder werden zu früh berechnet. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} fehlt in der im PDF gespeicherten Reihenfolge, daher berechnen andere PDF-Viewer es womöglich nicht.|{fields} fehlen in der im PDF gespeicherten Reihenfolge, daher berechnen andere PDF-Viewer sie womöglich nicht. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} Formel liest ein Feld, das es im Formular nicht gibt|{n} Formeln lesen Felder, die es im Formular nicht gibt +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} liest {fields}, das es im Formular nicht gibt.|{field} liest {fields}, die es im Formular nicht gibt. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} weitere Formel liest ein fehlendes Feld.|{n} weitere Formeln lesen fehlende Felder. +@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} bildet manche Feldnamen erst beim Ausführen, daher werden diese Lesezugriffe nicht geprüft.|{fields} bilden manche Feldnamen erst beim Ausführen, daher werden diese Lesezugriffe nicht geprüft. +@plural {fields} is missing|{fields} are missing {fields} fehlt|{fields} fehlen Scan Scannen Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Unterschreiben Sie auf einem weißen Blatt Papier und fotografieren Sie es bei hellem, gleichmäßigem Licht (mit Blitz). Wählen Sie das Foto aus oder ziehen Sie es hierher: PdfCraft findet die Unterschrift und entfernt das Papier. Choose photo… Foto auswählen… @@ -2499,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 @@ -2546,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 @@ -2558,3 +2602,15 @@ animation effects Off Aus Rotate the selected pages clockwise Ausgewählte Seiten im Uhrzeigersinn drehen Extract the selected pages… Ausgewählte Seiten extrahieren… Delete the selected pages Ausgewählte Seiten löschen + +# 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 e3eac77caa..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 κανένα @@ -438,6 +433,14 @@ Applies to 1 page Εφαρμόζεται σε 1 σελίδα Applies to {n} pages Εφαρμόζεται σε {n} σελίδες Replaces the existing one on those pages. Αντικαθιστά το υπάρχον στοιχείο στις συγκεκριμένες σελίδες. + Checking pages… Έλεγχος σελίδων… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Η σελίδα {pages} έχει δική της λευκή γέμιση, που θα έκρυβε το φόντο. Αντικατάσταση + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Οι σελίδες {pages} έχουν δική τους λευκή γέμιση, που θα έκρυβε το φόντο. Αντικατάσταση + Removing the background puts the page's own backdrop back. Η αφαίρεση του φόντου επαναφέρει τη λευκή γέμιση της σελίδας. + A picture covers page {pages} (a scan?), so the background won't show there. Μια εικόνα καλύπτει τη σελίδα {pages} (σάρωση;), οπότε το φόντο δεν θα φαίνεται εκεί. + Pictures cover pages {pages} (scans?), so the background won't show there. Εικόνες καλύπτουν τις σελίδες {pages} (σαρώσεις;), οπότε το φόντο δεν θα φαίνεται εκεί. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Η σελίδα {pages} άλλαξε μετά την προσθήκη του φόντου, οπότε η δική της λευκή γέμιση μένει απενεργοποιημένη. Σε λευκό χαρτί φαίνεται ίδια. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Οι σελίδες {pages} άλλαξαν μετά την προσθήκη του φόντου, οπότε οι δικές τους λευκές γεμίσεις μένουν απενεργοποιημένες. Σε λευκό χαρτί φαίνονται ίδιες. PDF or image PDF ή εικόνα No file chosen Δεν έχει επιλεγεί αρχείο Scale relative to target page Κλίμακα ως προς τη σελίδα προορισμού @@ -1852,6 +1855,12 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Διαβάζει τα {names}, που με τη σειρά τους το διαβάζουν. Καμία σειρά δεν διορθώνει έναν βρόχο: αλλάξτε έναν από τους τύπους. Not in order Εκτός σειράς Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Λείπει από τη σειρά υπολογισμού που είναι αποθηκευμένη στο PDF: άλλα προγράμματα προβολής PDF ίσως δεν το υπολογίζουν. + Reads {name}, which the form doesn't have, so it gets no value there. Διαβάζει το {name}, που δεν υπάρχει στη φόρμα, οπότε δεν παίρνει τιμή από εκεί. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Η δέσμη ενεργειών του διαβάζει επίσης πεδία των οποίων τα ονόματα σχηματίζει κατά την εκτέλεση, οπότε αυτές οι αναγνώσεις δεν μπορούν να ελεγχθούν. + Missing field Λείπει πεδίο + Unchecked reads Μη ελεγμένες αναγνώσεις + {field} reads fields named as it runs Το {field} διαβάζει πεδία που ονομάζονται κατά την εκτέλεση + Some names are built as it runs Ορισμένα ονόματα σχηματίζονται κατά την εκτέλεση Back to Prepare a form Επιστροφή στην Προετοιμασία φόρμας No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Κανένα πεδίο αυτής της φόρμας δεν υπολογίζει ακόμη την τιμή του. Δώστε σε ένα πεδίο υπολογισμό στην καρτέλα Υπολογισμός των ιδιοτήτων του και θα εμφανιστεί εδώ. {names} and {last} {names} και {last} @@ -1906,6 +1915,9 @@ Clean Up Εκκαθάριση Color Images Έγχρωμες εικόνες Grayscale Images Εικόνες σε κλίμακα του γκρι + Monochrome Images Μονόχρωμες εικόνες + CCITT Group 4 CCITT Ομάδα 4 + JBIG2 (lossless) JBIG2 (χωρίς απώλειες) Bicubic downsampling to Δικυβική μείωση ανάλυσης σε for images above για εικόνες πάνω από ZIP ZIP @@ -2402,6 +2414,11 @@ animation effects Off Ανενεργά @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} υπολογίζεται πριν από {fields}, που διαβάζει.|{field} υπολογίζεται πριν από {fields}, που διαβάζει. @plural {n} more field calculates too early.|{n} more fields calculate too early. Ακόμη {n} πεδίο υπολογίζεται πολύ νωρίς.|Ακόμη {n} πεδία υπολογίζονται πολύ νωρίς. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} λείπει από τη σειρά που είναι αποθηκευμένη στο PDF, οπότε άλλα προγράμματα PDF ίσως δεν το υπολογίσουν.|{fields} λείπουν από τη σειρά που είναι αποθηκευμένη στο PDF, οπότε άλλα προγράμματα PDF ίσως δεν τα υπολογίσουν. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} τύπος διαβάζει πεδίο που δεν υπάρχει στη φόρμα|{n} τύποι διαβάζουν πεδία που δεν υπάρχουν στη φόρμα +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. Το {field} διαβάζει το {fields}, που δεν υπάρχει στη φόρμα.|Το {field} διαβάζει τα {fields}, που δεν υπάρχουν στη φόρμα. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Ακόμη {n} τύπος διαβάζει πεδίο που λείπει.|Ακόμη {n} τύποι διαβάζουν πεδία που λείπουν. +@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} σχηματίζει ορισμένα ονόματα πεδίων κατά την εκτέλεση, οπότε αυτές οι αναγνώσεις δεν ελέγχονται.|Τα {fields} σχηματίζουν ορισμένα ονόματα πεδίων κατά την εκτέλεση, οπότε αυτές οι αναγνώσεις δεν ελέγχονται. +@plural {fields} is missing|{fields} are missing Το {fields} λείπει|Τα {fields} λείπουν @plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} όνομα πεδίου συγκρούεται με προηγούμενα αρχεία και θα μετονομαστεί: {list}|{n} ονόματα πεδίων συγκρούονται με προηγούμενα αρχεία και θα μετονομαστούν: {list} Scan Σάρωση Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Υπογράψτε σε ένα λευκό φύλλο χαρτιού και φωτογραφίστε το σε έντονο, ομοιόμορφο φως (χρησιμοποιήστε το φλας). Επιλέξτε τη φωτογραφία ή αφήστε την εδώ: το PdfCraft βρίσκει την υπογραφή και αφαιρεί το χαρτί. @@ -2504,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 Φόρμες και υπογραφές @@ -2551,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 Σήμερα @@ -2563,3 +2607,15 @@ animation effects Off Ανενεργά Rotate the selected pages clockwise Περιστροφή των επιλεγμένων σελίδων δεξιόστροφα Extract the selected pages… Εξαγωγή των επιλεγμένων σελίδων… Delete the selected pages Διαγραφή των επιλεγμένων σελίδων + +# 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 0d4e505a70..1a695aae4d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -537,6 +537,12 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Lee {names}, que a su vez lo leen. Ningún orden puede corregir un bucle: cambia una de las fórmulas. Not in order Fuera del orden Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Falta en el orden de cálculo guardado en el PDF: otros visores de PDF podrían no calcularlo. + Reads {name}, which the form doesn't have, so it gets no value there. Lee {name}, que no existe en el formulario, así que no obtiene ningún valor de ahí. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Su script también lee campos cuyos nombres construye mientras se ejecuta, así que esas lecturas no se pueden comprobar. + Missing field Falta un campo + Unchecked reads Lecturas sin comprobar + {field} reads fields named as it runs {field} lee campos nombrados mientras se ejecuta + Some names are built as it runs Algunos nombres se construyen al ejecutarse Back to Prepare a form Volver a Preparar un formulario No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Ningún campo de este formulario calcula todavía su valor. Dale a un campo un cálculo en la pestaña Calcular de sus propiedades y aparecerá aquí. {names} and {last} {names} y {last} @@ -593,6 +599,9 @@ Clean Up Limpieza Color Images Imágenes en color Grayscale Images Imágenes en escala de grises + Monochrome Images Imágenes monocromas + CCITT Group 4 CCITT grupo 4 + JBIG2 (lossless) JBIG2 (sin pérdida) Bicubic downsampling to Reducción de resolución bicúbica a for images above para imágenes por encima de Compression Compresión @@ -808,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 @@ -860,6 +864,14 @@ Applies to 1 page Se aplica a 1 página Applies to {n} pages Se aplica a {n} páginas Replaces the existing one on those pages. Reemplaza al existente en esas páginas. + Checking pages… Comprobando páginas… + Page {pages} paints its own white backdrop, which would hide the background. Replace it La página {pages} tiene su propio relleno blanco, que ocultaría el fondo. Reemplazarlo + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Las páginas {pages} tienen su propio relleno blanco, que ocultaría el fondo. Reemplazarlo + Removing the background puts the page's own backdrop back. Al quitar el fondo, se restaura el relleno blanco propio de la página. + A picture covers page {pages} (a scan?), so the background won't show there. Una imagen cubre la página {pages} (¿un escaneo?), así que el fondo no se verá ahí. + Pictures cover pages {pages} (scans?), so the background won't show there. Hay imágenes que cubren las páginas {pages} (¿escaneos?), así que el fondo no se verá ahí. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. La página {pages} cambió después de añadir el fondo, así que su propio relleno blanco sigue desactivado. Se ve igual sobre papel blanco. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Las páginas {pages} cambiaron después de añadir el fondo, así que sus propios rellenos blancos siguen desactivados. Se ven igual sobre papel blanco. PDF or image PDF o imagen No file chosen Ningún archivo elegido Page number Número de página @@ -2000,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 @@ -2541,6 +2560,11 @@ animation effects Off Desactivados @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} se calcula antes que {fields}, que lee.|{field} se calcula antes que {fields}, que lee. @plural {n} more field calculates too early.|{n} more fields calculate too early. {n} campo más se calcula demasiado pronto.|{n} campos más se calculan demasiado pronto. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} falta en el orden guardado en el PDF, así que otros visores de PDF podrían no calcularlo.|{fields} faltan en el orden guardado en el PDF, así que otros visores de PDF podrían no calcularlos. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} fórmula lee un campo que no existe en el formulario|{n} fórmulas leen campos que no existen en el formulario +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} lee {fields}, que no existe en el formulario.|{field} lee {fields}, que no existen en el formulario. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} fórmula más lee un campo que falta.|{n} fórmulas más leen campos que faltan. +@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} construye algunos nombres de campo al ejecutarse, así que esas lecturas no se comprueban.|{fields} construyen algunos nombres de campo al ejecutarse, así que esas lecturas no se comprueban. +@plural {fields} is missing|{fields} are missing Falta {fields}|Faltan {fields} Scan Escanear Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Firma en una hoja de papel blanco y hazle una foto con luz intensa y uniforme (usa el flash). Elige la foto o suéltala aquí: PdfCraft encuentra la firma y elimina el papel. Choose photo… Elegir foto… @@ -2662,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 @@ -2817,7 +2860,6 @@ theme Appearance Aspecto Import Importar Move Mover Duplicate Duplicar - Paste Pegar Select all Seleccionar todo Refresh Actualizar Next Siguiente @@ -2868,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 @@ -2880,3 +2924,15 @@ theme Appearance Aspecto Rotate the selected pages clockwise Girar las páginas seleccionadas en sentido horario Extract the selected pages… Extraer las páginas seleccionadas… Delete the selected pages Eliminar las páginas seleccionadas + +# 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 5cfc217533..1dcb047198 100644 --- a/crates/ui-egui/src/i18n/fa.tsv +++ b/crates/ui-egui/src/i18n/fa.tsv @@ -538,6 +538,12 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. {names} را می‌خواند که به نوبه خود آن را می‌خوانند. هیچ ترتیبی حلقه را اصلاح نمی‌کند: یکی از فرمول‌ها را تغییر دهید. Not in order نامرتب Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. در ترتیب محاسب‌های که در PDF ذخیره شده وجود ندارد: سایر نمایشگر‌های PDF ممکن است آن را محاسبه نکنند. + Reads {name}, which the form doesn't have, so it gets no value there. {name} را می‌خواند که در فرم وجود ندارد، پس از آن مقداری نمی‌گیرد. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. اسکریپت آن فیلد‌هایی را هم می‌خواند که نامشان را هنگام اجرا می‌سازد، پس این خواندن‌ها قابل بررسی نیستند. + Missing field فیلد ناموجود + Unchecked reads خواندن‌های بررسی‌نشده + {field} reads fields named as it runs {field} فیلد‌هایی را می‌خواند که نامشان هنگام اجرا ساخته می‌شود + Some names are built as it runs برخی نام‌ها هنگام اجرا ساخته می‌شوند Back to Prepare a form بازگشت به آماده‌سازی فرم No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. هنوز هیچ فیلدی در این فرم مقدارش را محاسبه نمی‌کند. به یک فیلد در برگه محاسبه از ویژگی‌هایش یک محاسبه بدهید تا اینجا نمایش داده شود. {names} and {last} {names} و {last} @@ -594,6 +600,9 @@ Clean Up پاک‌سازی Color Images تصاویر رنگی Grayscale Images تصاویر خاکستری + Monochrome Images تصاویر تک‌رنگ + CCITT Group 4 CCITT گروه ۴ + JBIG2 (lossless) JBIG2 (بدون اتلاف) Bicubic downsampling to نمونه برداری کاهشی دو مکعبی به for images above برای تصاویر بالا‌تر از Compression فشرده‌سازی @@ -809,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 بعداً @@ -2514,6 +2518,11 @@ animation effects Off خاموش @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} پیش از {fields} محاسبه می‌شود، در حالی که آن را می‌خواند.|{field} پیش از {fields} محاسبه می‌شود، در حالی که آن‌ها را می‌خواند. @plural {n} more field calculates too early.|{n} more fields calculate too early. {n} فیلد دیگر زود‌تر از موعد محاسبه می‌شود.|{n} فیلد دیگر زود‌تر از موعد محاسبه می‌شوند. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} در ترتیب ذخیره‌شده در PDF وجود ندارد، بنابراین سایر نمایشگر‌های PDF ممکن است آن را محاسبه نکنند.|{fields} در ترتیب ذخیره‌شده در PDF وجود ندارند، بنابراین سایر نمایشگر‌های PDF ممکن است آن‌ها را محاسبه نکنند. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} فرمول فیلدی را می‌خواند که در فرم وجود ندارد|{n} فرمول فیلد‌هایی را می‌خوانند که در فرم وجود ندارند +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} {fields} را می‌خواند که در فرم وجود ندارد.|{field} {fields} را می‌خواند که در فرم وجود ندارند. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} فرمول دیگر یک فیلد ناموجود را می‌خواند.|{n} فرمول دیگر فیلد‌های ناموجود را می‌خوانند. +@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} برخی نام‌های فیلد را هنگام اجرا می‌سازد، پس این خواندن‌ها بررسی نمی‌شوند.|{fields} برخی نام‌های فیلد را هنگام اجرا می‌سازند، پس این خواندن‌ها بررسی نمی‌شوند. +@plural {fields} is missing|{fields} are missing {fields} وجود ندارد|{fields} وجود ندارند Scan اسکن Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. روی یک برگ کاغذ سفید امضا کنید و از آن در نور روشن و یکنواخت عکس بگیرید (از فلاش استفاده کنید). عکس را انتخاب کنید یا آن را اینجا رها کنید: PdfCraft امضا را پیدا می‌کند و کاغذ را حذف می‌کند. Choose photo… انتخاب عکس… @@ -2804,3 +2813,13 @@ theme Appearance ظاهر two cells would both be named {name} دو خانه هر دو با نام {name} نام‌گذاری می‌شوند a field named {name} already exists فیلدی با نام {name} از قبل وجود دارد Today امروز + +# 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 a542ee9b91..0ba2f47d06 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -560,6 +560,9 @@ Clean Up Siivoa Color Images Värikuvat Grayscale Images Harmaasävykuvat + Monochrome Images Mustavalkokuvat + CCITT Group 4 CCITT ryhmä 4 + JBIG2 (lossless) JBIG2 (häviötön) Bicubic downsampling to Bikuubinen alinäytteistys tarkkuuteen for images above kuville, joiden tarkkuus yli Compression Pakkaus @@ -773,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 @@ -824,6 +822,14 @@ Applies to 1 page Koskee yhtä sivua Applies to {n} pages Koskee {n} sivua Replaces the existing one on those pages. Korvaa näiden sivujen nykyisen. + Checking pages… Tarkistetaan sivuja… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Sivulla {pages} on oma valkoinen täyttö, joka peittäisi taustan. Korvaa se + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Sivuilla {pages} on oma valkoinen täyttö, joka peittäisi taustan. Korvaa ne + Removing the background puts the page's own backdrop back. Taustan poistaminen palauttaa sivun oman valkoisen täytön. + A picture covers page {pages} (a scan?), so the background won't show there. Kuva peittää sivun {pages} (skannaus?), joten tausta ei näy siinä. + Pictures cover pages {pages} (scans?), so the background won't show there. Kuvat peittävät sivut {pages} (skannauksia?), joten tausta ei näy niissä. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Sivu {pages} muuttui taustan lisäämisen jälkeen, joten sen oma valkoinen täyttö pysyy pois päältä. Valkoisella paperilla se näyttää samalta. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Sivut {pages} muuttuivat taustan lisäämisen jälkeen, joten niiden omat valkoiset täytöt pysyvät pois päältä. Valkoisella paperilla ne näyttävät samalta. PDF or image PDF tai kuva No file chosen Tiedostoa ei ole valittu Page number Sivunumero @@ -2732,6 +2738,12 @@ theme Appearance Teema Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Lukee kenttiä {names}, jotka lukevat sitä takaisin. Mikään järjestys ei korjaa silmukkaa: muuta jotakin kaavoista. Not in order Ei järjestyksessä Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Puuttuu PDF:ään tallennetusta laskentajärjestyksestä: muut PDF-katseluohjelmat eivät ehkä laske sitä. + Reads {name}, which the form doesn't have, so it gets no value there. Lukee kenttää {name}, jota lomakkeessa ei ole, joten se ei saa siitä arvoa. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Sen komentosarja lukee myös kenttiä, joiden nimet se muodostaa suorituksen aikana, joten näitä viittauksia ei voi tarkistaa. + Missing field Puuttuva kenttä + Unchecked reads Tarkistamattomat viittaukset + {field} reads fields named as it runs {field} lukee kenttiä, joiden nimet muodostuvat suorituksen aikana + Some names are built as it runs Osa nimistä muodostuu suorituksen aikana Back to Prepare a form Takaisin kohtaan Valmistele lomake No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Mikään tämän lomakkeen kenttä ei vielä laske arvoaan. Anna kentälle laskenta sen ominaisuuksien Laskenta-välilehdellä, niin se näkyy tässä. {names} and {last} {names} ja {last} @@ -2759,6 +2771,11 @@ theme Appearance Teema @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} lasketaan ennen lukemaansa kenttää {fields}.|{field} lasketaan ennen lukemiaan kenttiä {fields}. @plural {n} more field calculates too early.|{n} more fields calculate too early. {n} muu kenttä lasketaan liian aikaisin.|{n} muuta kenttää lasketaan liian aikaisin. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} puuttuu PDF:ään tallennetusta järjestyksestä, joten muut PDF-katseluohjelmat eivät ehkä laske sitä.|{fields} puuttuvat PDF:ään tallennetusta järjestyksestä, joten muut PDF-katseluohjelmat eivät ehkä laske niitä. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} kaava lukee kenttää, jota lomakkeessa ei ole|{n} kaavaa lukee kenttiä, joita lomakkeessa ei ole +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} lukee kenttää {fields}, jota lomakkeessa ei ole.|{field} lukee kenttiä {fields}, joita lomakkeessa ei ole. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} muu kaava lukee puuttuvaa kenttää.|{n} muuta kaavaa lukee puuttuvia kenttiä. +@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} muodostaa osan kenttien nimistä suorituksen aikana, joten näitä viittauksia ei tarkisteta.|{fields} muodostavat osan kenttien nimistä suorituksen aikana, joten näitä viittauksia ei tarkisteta. +@plural {fields} is missing|{fields} are missing {fields} puuttuu|{fields} puuttuvat Bulleted list Luettelomerkit Numbered list Numeroitu luettelo Alignment Tasaus @@ -2807,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 @@ -2827,3 +2871,13 @@ theme Appearance Teema Rotate the selected pages clockwise Kierrä valittuja sivuja myötäpäivään Extract the selected pages… Pura valitut sivut… Delete the selected pages Poista valitut sivut + +# 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 61c8b5171d..613e0d7107 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -514,6 +514,12 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Lit {names}, qui le lisent en retour. Aucun ordre ne peut corriger une boucle : modifiez l'une des formules. Not in order Hors de l'ordre Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Absent de l'ordre de calcul enregistré dans le PDF : d'autres lecteurs PDF risquent de ne pas le calculer. + Reads {name}, which the form doesn't have, so it gets no value there. Lit {name}, qui n'existe pas dans le formulaire : aucune valeur n'en provient. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Son script lit aussi des champs dont il construit les noms à l'exécution : ces lectures ne peuvent pas être vérifiées. + Missing field Champ manquant + Unchecked reads Lectures non vérifiées + {field} reads fields named as it runs {field} lit des champs nommés à l'exécution + Some names are built as it runs Certains noms sont construits à l'exécution Back to Prepare a form Retour à Préparer un formulaire No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Aucun champ de ce formulaire ne calcule encore sa valeur. Donnez un calcul à un champ dans l'onglet Calculer de ses propriétés, et il apparaîtra ici. {names} and {last} {names} et {last} @@ -570,6 +576,9 @@ Clean Up Nettoyer Color Images Images couleur Grayscale Images Images en niveaux de gris + Monochrome Images Images monochromes + CCITT Group 4 CCITT groupe 4 + JBIG2 (lossless) JBIG2 (sans perte) Bicubic downsampling to Sous-échantillonnage bicubique à for images above pour les images au-dessus de Compression Compression @@ -788,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 @@ -840,6 +844,14 @@ Applies to 1 page S'applique à 1 page Applies to {n} pages S'applique à {n} pages Replaces the existing one on those pages. Remplace celui qui existe déjà sur ces pages. + Checking pages… Vérification des pages… + Page {pages} paints its own white backdrop, which would hide the background. Replace it La page {pages} a son propre remplissage blanc, qui masquerait l'arrière-plan. Le remplacer + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Les pages {pages} ont leur propre remplissage blanc, qui masquerait l'arrière-plan. Le remplacer + Removing the background puts the page's own backdrop back. Supprimer l'arrière-plan rétablit le remplissage blanc propre à la page. + A picture covers page {pages} (a scan?), so the background won't show there. Une image couvre la page {pages} (une numérisation ?), l'arrière-plan n'y sera donc pas visible. + Pictures cover pages {pages} (scans?), so the background won't show there. Des images couvrent les pages {pages} (des numérisations ?), l'arrière-plan n'y sera donc pas visible. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. La page {pages} a changé après l'ajout de l'arrière-plan, son propre remplissage blanc reste donc désactivé. Elle paraît identique sur papier blanc. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Les pages {pages} ont changé après l'ajout de l'arrière-plan, leurs propres remplissages blancs restent donc désactivés. Elles paraissent identiques sur papier blanc. PDF or image PDF ou image No file chosen Aucun fichier choisi Page number Numéro de page @@ -2405,6 +2417,11 @@ animation effects Off Désactivés @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} est calculé avant {fields}, qu'il lit.|{field} est calculé avant {fields}, qu'il lit. @plural {n} more field calculates too early.|{n} more fields calculate too early. {n} autre champ est calculé trop tôt.|{n} autres champs sont calculés trop tôt. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} manque dans l'ordre enregistré dans le PDF, donc d'autres lecteurs PDF risquent de ne pas le calculer.|{fields} manquent dans l'ordre enregistré dans le PDF, donc d'autres lecteurs PDF risquent de ne pas les calculer. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formule lit un champ absent du formulaire|{n} formules lisent des champs absents du formulaire +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} lit {fields}, absent du formulaire.|{field} lit {fields}, absents du formulaire. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} autre formule lit un champ manquant.|{n} autres formules lisent des champs manquants. +@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} construit certains noms de champs à l'exécution : ces lectures ne sont pas vérifiées.|{fields} construisent certains noms de champs à l'exécution : ces lectures ne sont pas vérifiées. +@plural {fields} is missing|{fields} are missing {fields} est absent|{fields} sont absents Scan Numériser Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Signez une feuille de papier blanc et prenez-la en photo sous une lumière vive et uniforme (utilisez le flash). Choisissez la photo ou déposez-la ici : PdfCraft trouve la signature et retire le papier. Choose photo… Choisir une photo… @@ -2498,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 @@ -2615,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) @@ -2811,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 @@ -2823,3 +2866,15 @@ certificate Version Version Rotate the selected pages clockwise Faire pivoter les pages sélectionnées dans le sens horaire Extract the selected pages… Extraire les pages sélectionnées… Delete the selected pages Supprimer les pages sélectionnées + +# 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 e9d627aca9..3b38f82719 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -544,6 +544,9 @@ Clean Up सफ़ाई Color Images रंगीन छवियाँ Grayscale Images ग्रेस्केल छवियाँ + Monochrome Images मोनोक्रोम छवियाँ + CCITT Group 4 CCITT समूह 4 + JBIG2 (lossless) JBIG2 (हानिरहित) Bicubic downsampling to बाइक्यूबिक डाउनसैंपलिंग तक for images above इससे बड़ी छवियों के लिए Compression संपीड़न @@ -757,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 बाद में @@ -808,6 +806,14 @@ Applies to 1 page 1 पृष्ठ पर लागू Applies to {n} pages {n} पृष्ठों पर लागू Replaces the existing one on those pages. उन पृष्ठों पर मौजूदा वाले को बदल देता है। + Checking pages… पृष्ठों की जाँच हो रही है… + Page {pages} paints its own white backdrop, which would hide the background. Replace it पृष्ठ {pages} की अपनी सफ़ेद भराई है, जो पृष्ठभूमि को छिपा देगी। इसे बदलें + Pages {pages} paint their own white backdrop, which would hide the background. Replace it पृष्ठ {pages} की अपनी सफ़ेद भराई है, जो पृष्ठभूमि को छिपा देगी। इसे बदलें + Removing the background puts the page's own backdrop back. पृष्ठभूमि हटाने पर पृष्ठ की अपनी सफ़ेद भराई वापस आ जाती है। + A picture covers page {pages} (a scan?), so the background won't show there. एक छवि पृष्ठ {pages} को ढकती है (स्कैन?), इसलिए वहाँ पृष्ठभूमि नहीं दिखेगी। + Pictures cover pages {pages} (scans?), so the background won't show there. छवियाँ पृष्ठ {pages} को ढकती हैं (स्कैन?), इसलिए वहाँ पृष्ठभूमि नहीं दिखेगी। + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. पृष्ठभूमि जोड़ने के बाद पृष्ठ {pages} बदल गया, इसलिए उसकी अपनी सफ़ेद भराई बंद रहती है। सफ़ेद कागज़ पर यह वैसा ही दिखता है। + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. पृष्ठभूमि जोड़ने के बाद पृष्ठ {pages} बदल गए, इसलिए उनकी अपनी सफ़ेद भराई बंद रहती है। सफ़ेद कागज़ पर वे वैसे ही दिखते हैं। PDF or image पीडीएफ़ या छवि No file chosen कोई फ़ाइल नहीं चुनी गई Page number पृष्ठ संख्या @@ -2721,6 +2727,12 @@ theme Appearance स्वरूप Links on page Links on page Loop Loop Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. + Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. + Missing field Missing field + 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 Move only what must move, so every field calculates after the fields it reads. Move only what must move, so every field calculates after the fields it reads. Moves only what must move Moves only what must move No field reads it No field reads it @@ -2774,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 आज @@ -2794,3 +2831,13 @@ theme Appearance स्वरूप Rotate the selected pages clockwise चयनित पृष्ठों को दक्षिणावर्त घुमाएँ Extract the selected pages… चयनित पृष्ठ निकालें… Delete the selected pages चयनित पृष्ठ हटाएँ + +# 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 6b37b9169b..7d2f8fbf4c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -512,6 +512,12 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Ezeket olvassa: {names}, amelyek visszaolvassák. Hurkot semmilyen sorrend nem javít meg: módosítsd az egyik képletet. Not in order Nincs a sorrendben Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Hiányzik a PDF-ben mentett számítási sorrendből: más PDF-megjelenítők talán nem számítják ki. + Reads {name}, which the form doesn't have, so it gets no value there. Ezt olvassa: {name}, amely nincs az űrlapon, így onnan nem kap értéket. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. A szkriptje olyan mezőket is olvas, amelyek nevét futás közben állítja össze, így ezek az olvasások nem ellenőrizhetők. + Missing field Hiányzó mező + Unchecked reads Ellenőrizetlen olvasások + {field} reads fields named as it runs {field} futás közben elnevezett mezőket olvas + Some names are built as it runs Néhány név futás közben áll össze Back to Prepare a form Vissza az Űrlap előkészítéséhez No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Ennek az űrlapnak még egyik mezője sem számítja ki az értékét. Adj egy mezőnek számítást a tulajdonságai Számítás lapján, és itt megjelenik. {names} and {last} {names} és {last} @@ -568,6 +574,9 @@ Clean Up Tisztítás Color Images Színes képek Grayscale Images Szürkeárnyalatos képek + Monochrome Images Monokróm képek + CCITT Group 4 CCITT 4. csoport + JBIG2 (lossless) JBIG2 (veszteségmentes) Bicubic downsampling to Kettős köbös újramintázás erre for images above az ennél nagyobb képeknél Compression Tömörítés @@ -785,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 @@ -837,6 +841,14 @@ Applies to 1 page 1 oldalra vonatkozik Applies to {n} pages {n} oldalra vonatkozik Replaces the existing one on those pages. Lecseréli a meglévőt ezeken az oldalakon. + Checking pages… Oldalak ellenőrzése… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Ennek az oldalnak saját fehér kitöltése van, amely eltakarná a hátteret: {pages}. Csere + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Ezeknek az oldalaknak saját fehér kitöltése van, amely eltakarná a hátteret: {pages}. Csere + Removing the background puts the page's own backdrop back. A háttér eltávolítása visszaállítja az oldal saját fehér kitöltését. + A picture covers page {pages} (a scan?), so the background won't show there. Egy kép fedi le ezt az oldalt: {pages} (szkennelt?), így ott nem látszik a háttér. + Pictures cover pages {pages} (scans?), so the background won't show there. Képek fedik le ezeket az oldalakat: {pages} (szkenneltek?), így ott nem látszik a háttér. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Ez az oldal megváltozott a háttér hozzáadása után, ezért a saját fehér kitöltése kikapcsolva marad: {pages}. Fehér papíron ugyanúgy néz ki. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Ezek az oldalak megváltoztak a háttér hozzáadása után, ezért a saját fehér kitöltésük kikapcsolva marad: {pages}. Fehér papíron ugyanúgy néznek ki. PDF or image PDF vagy kép No file chosen Nincs fájl kiválasztva Page number Oldalszám @@ -2404,6 +2416,11 @@ animation effects Off Ki @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} előbb számítódik, mint {fields}, amelyet olvas.|{field} előbb számítódik, mint {fields}, amelyeket olvas. @plural {n} more field calculates too early.|{n} more fields calculate too early. További {n} mező túl korán számítódik.|További {n} mező túl korán számítódik. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} hiányzik a PDF-ben mentett sorrendből, ezért más PDF-megjelenítők esetleg nem számítják ki.|{fields} hiányoznak a PDF-ben mentett sorrendből, ezért más PDF-megjelenítők esetleg nem számítják ki őket. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} képlet olyan mezőt olvas, amely nincs az űrlapon|{n} képlet olyan mezőket olvas, amelyek nincsenek az űrlapon +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} ezt olvassa: {fields}, amely nincs az űrlapon.|{field} ezeket olvassa: {fields}, amelyek nincsenek az űrlapon. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. További {n} képlet hiányzó mezőt olvas.|További {n} képlet hiányzó mezőket olvas. +@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} futás közben állít össze néhány mezőnevet, így ezek az olvasások nincsenek ellenőrizve.|{fields} futás közben állítanak össze néhány mezőnevet, így ezek az olvasások nincsenek ellenőrizve. +@plural {fields} is missing|{fields} are missing {fields} hiányzik|{fields} hiányoznak Scan Beolvasás Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Írja alá a nevét egy fehér papírlapon, és fényképezze le erős, egyenletes fényben (vakuval). Válassza ki a fényképet, vagy húzza ide: a PdfCraft megkeresi az aláírást, és eltávolítja a papírt. Choose photo… Fénykép kiválasztása… @@ -2497,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 @@ -2544,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 @@ -2556,3 +2600,15 @@ animation effects Off Ki Rotate the selected pages clockwise A kijelölt oldalak forgatása az óramutató járásával megegyezően Extract the selected pages… A kijelölt oldalak kibontása… Delete the selected pages A kijelölt oldalak törlése + +# 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 292b336e10..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 @@ -946,6 +942,14 @@ Replacement text Teks pengganti Replacement: pages of {name} Pengganti: halaman dari {name} Replaces the existing one on those pages. Menggantikan yang ada di halaman-halaman itu. + Checking pages… Memeriksa halaman… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Halaman {pages} memiliki isian putihnya sendiri, yang akan menutupi latar belakang. Ganti + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Halaman-halaman {pages} memiliki isian putihnya sendiri, yang akan menutupi latar belakang. Ganti + Removing the background puts the page's own backdrop back. Menghapus latar belakang akan mengembalikan isian putih milik halaman. + A picture covers page {pages} (a scan?), so the background won't show there. Sebuah gambar menutupi halaman {pages} (hasil pindai?), jadi latar belakang tidak akan terlihat di sana. + Pictures cover pages {pages} (scans?), so the background won't show there. Gambar menutupi halaman-halaman {pages} (hasil pindai?), jadi latar belakang tidak akan terlihat di sana. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Halaman {pages} berubah setelah latar belakang ditambahkan, jadi isian putihnya sendiri tetap mati. Di kertas putih tampilannya sama. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Halaman {pages} berubah setelah latar belakang ditambahkan, jadi isian putihnya sendiri tetap mati. Di kertas putih tampilannya sama. Report Laporan Report… Laporan… Requires user to enter a password for: Memerlukan pengguna untuk memasukkan kata sandi untuk: @@ -1270,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 @@ -2055,6 +2058,9 @@ comment menu Edit Edit Formulas and similar non-text elements need alternate text. Add alternate text to them. Rumus dan elemen non-teks serupa memerlukan teks alternatif. Tambahkan teks alternatif ke elemen tersebut. Gray Abu-abu Grayscale Images Gambar Skala Abu-abu + Monochrome Images Gambar Monokrom + CCITT Group 4 CCITT Grup 4 + JBIG2 (lossless) JBIG2 (tanpa kehilangan) Green Hijau Green on black Hijau di atas hitam H / Space (hold) H / Space (tahan) @@ -2722,6 +2728,12 @@ theme Appearance Tampilan Links on page Links on page Loop Loop Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. + Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. + Missing field Missing field + 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 Move only what must move, so every field calculates after the fields it reads. Move only what must move, so every field calculates after the fields it reads. Moves only what must move Moves only what must move No field reads it No field reads it @@ -2775,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 @@ -2795,3 +2832,13 @@ theme Appearance Tampilan Rotate the selected pages clockwise Putar halaman yang dipilih searah jarum jam Extract the selected pages… Ekstrak halaman yang dipilih… Delete the selected pages Hapus halaman yang dipilih + +# 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 20729bae6a..a43bbbff7e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -538,6 +538,12 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Legge {names}, che a loro volta lo leggono. Nessun ordine può correggere un ciclo: modifica una delle formule. Not in order Fuori ordine Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Manca nell'ordine di calcolo salvato nel PDF: altri visualizzatori PDF potrebbero non calcolarlo. + Reads {name}, which the form doesn't have, so it gets no value there. Legge {name}, che non esiste nel modulo, quindi da lì non riceve alcun valore. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Il suo script legge anche campi i cui nomi costruisce durante l'esecuzione, quindi queste letture non possono essere verificate. + Missing field Campo mancante + Unchecked reads Letture non verificate + {field} reads fields named as it runs {field} legge campi nominati durante l'esecuzione + Some names are built as it runs Alcuni nomi vengono costruiti durante l'esecuzione Back to Prepare a form Torna a Prepara un modulo No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Nessun campo di questo modulo calcola ancora il proprio valore. Assegna un calcolo a un campo nella scheda Calcolo delle sue proprietà e comparirà qui. {names} and {last} {names} e {last} @@ -594,6 +600,9 @@ Clean Up Pulizia Color Images Immagini a colori Grayscale Images Immagini in scala di grigi + Monochrome Images Immagini monocromatiche + CCITT Group 4 CCITT gruppo 4 + JBIG2 (lossless) JBIG2 (senza perdita) Bicubic downsampling to Sottocampionamento bicubico a for images above per immagini oltre Compression Compressione @@ -811,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 @@ -863,6 +867,14 @@ Applies to 1 page Si applica a 1 pagina Applies to {n} pages Si applica a {n} pagine Replaces the existing one on those pages. Sostituisce l'elemento esistente su quelle pagine. + Checking pages… Verifica delle pagine… + Page {pages} paints its own white backdrop, which would hide the background. Replace it La pagina {pages} ha un proprio riempimento bianco, che nasconderebbe lo sfondo. Sostituiscilo + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Le pagine {pages} hanno un proprio riempimento bianco, che nasconderebbe lo sfondo. Sostituiscilo + Removing the background puts the page's own backdrop back. Rimuovendo lo sfondo, il riempimento bianco della pagina viene ripristinato. + A picture covers page {pages} (a scan?), so the background won't show there. Un'immagine copre la pagina {pages} (una scansione?), quindi lì lo sfondo non sarà visibile. + Pictures cover pages {pages} (scans?), so the background won't show there. Delle immagini coprono le pagine {pages} (scansioni?), quindi lì lo sfondo non sarà visibile. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. La pagina {pages} è cambiata dopo l'aggiunta dello sfondo, quindi il suo riempimento bianco resta disattivato. Su carta bianca appare uguale. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Le pagine {pages} sono cambiate dopo l'aggiunta dello sfondo, quindi i loro riempimenti bianchi restano disattivati. Su carta bianca appaiono uguali. PDF or image PDF o immagine No file chosen Nessun file scelto Page number Numero di pagina @@ -2414,6 +2426,11 @@ animation effects Off Disattivati @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} viene calcolato prima di {fields}, che legge.|{field} viene calcolato prima di {fields}, che legge. @plural {n} more field calculates too early.|{n} more fields calculate too early. Ancora {n} campo viene calcolato troppo presto.|Altri {n} campi vengono calcolati troppo presto. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} manca nell'ordine salvato nel PDF, quindi altri visualizzatori PDF potrebbero non calcolarlo.|{fields} mancano nell'ordine salvato nel PDF, quindi altri visualizzatori PDF potrebbero non calcolarli. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formula legge un campo che non esiste nel modulo|{n} formule leggono campi che non esistono nel modulo +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} legge {fields}, che non esiste nel modulo.|{field} legge {fields}, che non esistono nel modulo. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Ancora {n} formula legge un campo mancante.|Altre {n} formule leggono campi mancanti. +@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} costruisce alcuni nomi di campo durante l'esecuzione, quindi queste letture non vengono verificate.|{fields} costruiscono alcuni nomi di campo durante l'esecuzione, quindi queste letture non vengono verificate. +@plural {fields} is missing|{fields} are missing {fields} manca|{fields} mancano Scan Scansiona Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Firma un foglio di carta bianca e fotografalo con una luce intensa e uniforme (usa il flash). Scegli la foto o trascinala qui: PdfCraft trova la firma e rimuove la carta. Choose photo… Scegli foto… @@ -2710,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 @@ -2777,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 @@ -2789,3 +2833,15 @@ certificate SHA-256 digest Impronta SHA-256 Rotate the selected pages clockwise Ruota le pagine selezionate in senso orario Extract the selected pages… Estrai le pagine selezionate… Delete the selected pages Elimina le pagine selezionate + +# 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 1ed4d43b6c..879a68aff1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -541,6 +541,12 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. {names} を読み取り、それらがこのフィールドを読み返します。循環はどの順序でも直せません。いずれかの数式を変更してください。 Not in order 順序にない Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDF に保存された計算順序にありません。ほかの PDF ビューアーでは計算されない可能性があります。 + Reads {name}, which the form doesn't have, so it gets no value there. フォームにない {name} を読み取るため、そこからは値を得られません。 + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. スクリプトが実行中に名前を組み立てるフィールドも読み取るため、それらの読み取りは確認できません。 + Missing field フィールドがない + Unchecked reads 未確認の読み取り + {field} reads fields named as it runs {field} は実行中に名前が決まるフィールドを読み取ります + Some names are built as it runs 一部の名前は実行中に組み立てられます Back to Prepare a form 「フォームを作成」に戻る No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. このフォームには値を計算するフィールドがまだありません。フィールドのプロパティの[計算]タブで計算を設定すると、ここに表示されます。 {names} and {last} {names}、{last} @@ -597,6 +603,9 @@ Clean Up クリーンアップ Color Images カラー画像 Grayscale Images グレースケール画像 + Monochrome Images 白黒画像 + CCITT Group 4 CCITT グループ 4 + JBIG2 (lossless) JBIG2(可逆) Bicubic downsampling to バイキュービック法でダウンサンプル: for images above 対象画像の解像度: Compression 圧縮 @@ -815,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 後で @@ -867,6 +871,14 @@ Applies to 1 page 1 ページに適用 Applies to {n} pages {n} ページに適用 Replaces the existing one on those pages. 対象ページの既存の項目を置き換えます。 + Checking pages… ページを確認しています… + Page {pages} paints its own white backdrop, which would hide the background. Replace it {pages} ページには独自の白い塗りがあり、背景が隠れてしまいます。置き換える + Pages {pages} paint their own white backdrop, which would hide the background. Replace it {pages} ページに独自の白い塗りがあり、背景が隠れてしまいます。置き換える + Removing the background puts the page's own backdrop back. 背景を削除すると、ページ独自の白い塗りが元に戻ります。 + A picture covers page {pages} (a scan?), so the background won't show there. {pages} ページは画像(スキャン?)で覆われているため、背景は表示されません。 + Pictures cover pages {pages} (scans?), so the background won't show there. {pages} ページが画像(スキャン?)で覆われているため、そこでは背景は表示されません。 + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. 背景を追加した後に {pages} ページが変更されたため、そのページ独自の白い塗りはオフのままです。白い紙では見た目は変わりません。 + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. 背景を追加した後に {pages} ページが変更されたため、それらのページ独自の白い塗りはオフのままです。白い紙では見た目は変わりません。 PDF or image PDF または画像 No file chosen ファイルが選択されていません Page number ページ番号 @@ -2405,6 +2417,11 @@ animation effects Off オフ @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} は、読み取る {fields} より先に計算されます。 @plural {n} more field calculates too early.|{n} more fields calculate too early. さらに {n} 個のフィールドが早すぎます。 @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} は PDF に保存された順序にないため、他の PDF ビューアーでは計算されない場合があります。 +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} 個の数式がフォームにないフィールドを読み取ります +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} はフォームにない {fields} を読み取ります。 +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. ほかに {n} 個の数式が存在しないフィールドを読み取ります。 +@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} は一部のフィールド名を実行中に組み立てるため、それらの読み取りは確認されません。 +@plural {fields} is missing|{fields} are missing {fields} がありません Scan スキャン Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. 白い紙に署名し、明るく均一な光の下で写真を撮ってください(フラッシュを使用)。写真を選択するか、ここにドロップしてください。PdfCraft が署名を見つけ、紙の部分を取り除きます。 Choose photo… 写真を選択… @@ -2498,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 フォームと署名 @@ -2545,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 今日 @@ -2557,3 +2601,15 @@ animation effects Off オフ Rotate the selected pages clockwise 選択したページを時計回りに回転 Extract the selected pages… 選択したページを抽出… Delete the selected pages 選択したページを削除 + +# 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 9022c3591c..673ae57b31 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -501,6 +501,12 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. {names} өрістерін оқиды, олар оны қайта оқиды. Ешбір рет циклды түзете алмайды: формулалардың бірін өзгертіңіз. Not in order Ретте жоқ Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDF ішінде сақталған есептеу ретінде жоқ: басқа PDF қарау құралдары оны есептемеуі мүмкін. + Reads {name}, which the form doesn't have, so it gets no value there. {name} өрісін оқиды, бірақ ол пішінде жоқ, сондықтан одан мән алмайды. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Оның скрипті атауларын орындалу кезінде құрастыратын өрістерді де оқиды, сондықтан бұл оқуларды тексеру мүмкін емес. + Missing field Өріс жоқ + Unchecked reads Тексерілмеген оқулар + {field} reads fields named as it runs {field} орындалу кезінде аталатын өрістерді оқиды + Some names are built as it runs Кейбір атаулар орындалу кезінде құрастырылады Back to Prepare a form Форманы дайындауға оралу No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Бұл формада мәнін есептейтін өріс әлі жоқ. Өріс сипаттарының «Есептеу» қойындысында оған есептеу беріңіз, сонда ол осында көрсетіледі. {names} and {last} {names} және {last} @@ -557,6 +563,9 @@ Clean Up Тазалау Color Images Түсті кескіндер Grayscale Images Сұр реңкті кескіндер + Monochrome Images Монохромды кескіндер + CCITT Group 4 CCITT 4-топ + JBIG2 (lossless) JBIG2 (шығынсыз) Bicubic downsampling to Бикубтық төмендету for images above мұнан асатын кескіндер үшін Compression Сығымдау @@ -771,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 Кейінірек @@ -822,6 +826,14 @@ Applies to 1 page 1 бетке қолданылады Applies to {n} pages {n} бетке қолданылады Replaces the existing one on those pages. Осы беттерде бар элементті ауыстырады. + Checking pages… Беттер тексерілуде… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Өз ақ толтыруы фонды жасыратын бет: {pages}. Ауыстыру + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Өз ақ толтыруы фонды жасыратын беттер: {pages}. Ауыстыру + Removing the background puts the page's own backdrop back. Фонды жою беттің өз ақ толтыруын қайтарады. + A picture covers page {pages} (a scan?), so the background won't show there. Сурет жауып тұрған бет (сканер ме?): {pages}; онда фон көрінбейді. + Pictures cover pages {pages} (scans?), so the background won't show there. Суреттер жауып тұрған беттер (сканер ме?): {pages}; онда фон көрінбейді. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Фон қосылғаннан кейін өзгерген бет, сондықтан оның өз ақ толтыруы өшірулі қалады: {pages}. Ақ қағазда бірдей көрінеді. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Фон қосылғаннан кейін өзгерген беттер, сондықтан олардың өз ақ толтырулары өшірулі қалады: {pages}. Ақ қағазда бірдей көрінеді. PDF or image PDF немесе сурет No file chosen Файл таңдалмады Page number Бет нөмірі @@ -2392,6 +2404,11 @@ animation effects Off Өшірулі @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} өзі оқитын {fields} өрісінен бұрын есептеледі.|{field} өзі оқитын {fields} өрістерінен бұрын есептеледі. @plural {n} more field calculates too early.|{n} more fields calculate too early. Тағы {n} өріс тым ерте есептеледі.|Тағы {n} өріс тым ерте есептеледі. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} PDF-те сақталған ретте жоқ, сондықтан басқа PDF қаралымдары оны есептемеуі мүмкін.|{fields} PDF-те сақталған ретте жоқ, сондықтан басқа PDF қаралымдары оларды есептемеуі мүмкін. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} формула пішінде жоқ өрісті оқиды|{n} формула пішінде жоқ өрістерді оқиды +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} пішінде жоқ {fields} өрісін оқиды.|{field} пішінде жоқ {fields} өрістерін оқиды. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Тағы {n} формула жоқ өрісті оқиды.|Тағы {n} формула жоқ өрістерді оқиды. +@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} кейбір өріс атауларын орындалу кезінде құрастырады, сондықтан бұл оқулар тексерілмейді.|{fields} кейбір өріс атауларын орындалу кезінде құрастырады, сондықтан бұл оқулар тексерілмейді. +@plural {fields} is missing|{fields} are missing {fields} жоқ|{fields} жоқ @plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} өріс атауы алдыңғы файлдармен қайшы келеді және қайта аталады: {list}|{n} өріс атауы алдыңғы файлдармен қайшы келеді және қайта аталады: {list} Scan Сканерлеу Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Ақ қағаз парағына қол қойып, оны жарық әрі біркелкі жарықта суретке түсіріңіз (жарқылды қосыңыз). Суретті таңдаңыз немесе осында тастаңыз: PdfCraft қолтаңбаны тауып, қағазды алып тастайды. @@ -2496,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 Пішіндер және қолтаңбалар @@ -2543,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 Бүгін @@ -2555,3 +2599,15 @@ animation effects Off Өшірулі Rotate the selected pages clockwise Таңдалған беттерді сағат тілімен бұру Extract the selected pages… Таңдалған беттерді шығарып алу… Delete the selected pages Таңдалған беттерді жою + +# 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 09b9cad9fa..c57b7e4cd2 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -544,6 +544,9 @@ Clean Up 정리 Color Images 컬러 이미지 Grayscale Images 회색조 이미지 + Monochrome Images 흑백 이미지 + CCITT Group 4 CCITT 그룹 4 + JBIG2 (lossless) JBIG2(무손실) Bicubic downsampling to 다음 값으로 바이큐빅 다운샘플링 for images above 다음 값을 초과하는 이미지에 적용 Compression 압축 @@ -757,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 나중에 @@ -808,6 +806,14 @@ Applies to 1 page 1개 페이지에 적용됩니다 Applies to {n} pages {n}개 페이지에 적용됩니다 Replaces the existing one on those pages. 해당 페이지의 기존 항목을 대체합니다. + Checking pages… 페이지 확인 중… + Page {pages} paints its own white backdrop, which would hide the background. Replace it {pages}페이지에는 배경을 가리는 자체 흰색 채우기가 있습니다. 바꾸기 + Pages {pages} paint their own white backdrop, which would hide the background. Replace it {pages}페이지에 배경을 가리는 자체 흰색 채우기가 있습니다. 바꾸기 + Removing the background puts the page's own backdrop back. 배경을 제거하면 페이지 자체의 흰색 채우기가 복원됩니다. + A picture covers page {pages} (a scan?), so the background won't show there. 이미지가 {pages}페이지를 덮고 있어(스캔본?) 배경이 보이지 않습니다. + Pictures cover pages {pages} (scans?), so the background won't show there. 이미지들이 {pages}페이지를 덮고 있어(스캔본?) 배경이 보이지 않습니다. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. 배경을 추가한 뒤 {pages}페이지가 바뀌어서 자체 흰색 채우기가 꺼진 채로 남습니다. 흰 종이에서는 똑같이 보입니다. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. 배경을 추가한 뒤 {pages}페이지가 바뀌어서 자체 흰색 채우기가 꺼진 채로 남습니다. 흰 종이에서는 똑같이 보입니다. PDF or image PDF 또는 이미지 No file chosen 선택한 파일 없음 Page number 페이지 번호 @@ -2721,6 +2727,12 @@ theme Appearance 모양 Links on page Links on page Loop Loop Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. + Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. + Missing field Missing field + 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 Move only what must move, so every field calculates after the fields it reads. Move only what must move, so every field calculates after the fields it reads. Moves only what must move Moves only what must move No field reads it No field reads it @@ -2774,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 오늘 @@ -2794,3 +2831,13 @@ theme Appearance 모양 Rotate the selected pages clockwise 선택한 페이지를 시계 방향으로 회전 Extract the selected pages… 선택한 페이지 추출… Delete the selected pages 선택한 페이지 삭제 + +# 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 964467d823..3be898ead2 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -565,6 +565,9 @@ Clean Up ອະນາໄມ Color Images ຮູບພາບສີ Grayscale Images ຮູບພາບລະດັບສີເທົາ + Monochrome Images ຮູບພາບຂາວດຳ + CCITT Group 4 CCITT ກຸ່ມ 4 + JBIG2 (lossless) JBIG2 (ບໍ່ສູນເສຍ) Bicubic downsampling to ຫຼຸດຄວາມລະອຽດແບບ Bicubic ລົງເປັນ: for images above ສຳລັບຮູບພາບທີ່ເກີນ: Compression ການບີບອັດ @@ -783,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 ພາຍຫຼັງ @@ -834,6 +832,14 @@ Applies to 1 page ນຳໃຊ້ກັບ 1 ໜ້າ Applies to {n} pages ນຳໃຊ້ກັບ {n} ໜ້າ Replaces the existing one on those pages. ປ່ຽນແທນສິ່ງທີ່ມີຢູ່ແລ້ວໃນໜ້າເຫຼົ່ານັ້ນ. + Checking pages… ກຳລັງກວດສອບໜ້າ… + Page {pages} paints its own white backdrop, which would hide the background. Replace it ໜ້າ {pages} ມີພື້ນສີຂາວຂອງມັນເອງ ເຊິ່ງຈະບັງພື້ນຫຼັງ. ປ່ຽນແທນ + Pages {pages} paint their own white backdrop, which would hide the background. Replace it ໜ້າ {pages} ມີພື້ນສີຂາວຂອງພວກມັນເອງ ເຊິ່ງຈະບັງພື້ນຫຼັງ. ປ່ຽນແທນ + Removing the background puts the page's own backdrop back. ການລຶບພື້ນຫຼັງຈະເອົາພື້ນສີຂາວເດີມຂອງໜ້າກັບຄືນມາ. + A picture covers page {pages} (a scan?), so the background won't show there. ມີຮູບພາບປົກໜ້າ {pages} ຢູ່ (ພາບສະແກນ?), ສະນັ້ນພື້ນຫຼັງຈະບໍ່ສະແດງຢູ່ບ່ອນນັ້ນ. + Pictures cover pages {pages} (scans?), so the background won't show there. ມີຮູບພາບຫຼາຍຮູບປົກໜ້າ {pages} ຢູ່ (ພາບສະແກນ?), ສະນັ້ນພື້ນຫຼັງຈະບໍ່ສະແດງຢູ່ບ່ອນນັ້ນ. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. ໜ້າ {pages} ປ່ຽນໄປຫຼັງຈາກເພີ່ມພື້ນຫຼັງ, ສະນັ້ນພື້ນສີຂາວຂອງມັນເອງຍັງປິດຢູ່. ເທິງເຈ້ຍສີຂາວມັນເບິ່ງຄືເກົ່າ. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. ໜ້າ {pages} ປ່ຽນໄປຫຼັງຈາກເພີ່ມພື້ນຫຼັງ, ສະນັ້ນພື້ນສີຂາວຂອງພວກມັນເອງຍັງປິດຢູ່. ເທິງເຈ້ຍສີຂາວພວກມັນເບິ່ງຄືເກົ່າ. PDF or image PDF ຫຼື ຮູບພາບ No file chosen ຍັງບໍ່ໄດ້ເລືອກໄຟລ໌ Page number ໝາຍເລກໜ້າ @@ -2445,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 ຟອມ & ລາຍເຊັນ @@ -2465,3 +2478,43 @@ animation effects Off ປິດ Rotate the selected pages clockwise ໝູນໜ້າທີ່ເລືອກຕາມເຂັມໂມງ Extract the selected pages… ແຍກໜ້າທີ່ເລືອກອອກ… Delete the selected pages ລຶບໜ້າທີ່ເລືອກ + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply ຕົວເລືອກຂອງການຕອບກັບນີ້ +# Untranslated: English text until a translator fills these in. + Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. + Missing field Missing field + 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 43749f4ec8..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 @@ -701,6 +698,12 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Skaito {names}, kurie savo ruožtu skaito jį. Jokia tvarka neištaisys ciklo: pakeiskite vieną iš formulių. Not in order Ne tvarkoje Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Trūksta PDF faile įrašytoje skaičiavimo tvarkoje: kitos PDF peržiūros programos gali jo neskaičiuoti. + Reads {name}, which the form doesn't have, so it gets no value there. Skaito {name}, kurio formoje nėra, todėl iš ten negauna jokios reikšmės. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Jo scenarijus taip pat skaito laukus, kurių pavadinimus sudaro vykdymo metu, todėl šių skaitymų patikrinti negalima. + Missing field Trūksta lauko + Unchecked reads Nepatikrinti skaitymai + {field} reads fields named as it runs {field} skaito laukus, pavadinamus vykdymo metu + Some names are built as it runs Kai kurie pavadinimai sudaromi vykdymo metu Back to Prepare a form Grįžti į „Rengti formą“ No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Nė vienas šios formos laukas dar neskaičiuoja savo reikšmės. Nustatykite laukui skaičiavimą jo savybių kortelėje „Skaičiavimas“, ir jis bus rodomas čia. {names} and {last} {names} ir {last} @@ -910,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 @@ -999,6 +1001,14 @@ Replacement text Pakaitinis tekstas Replacement: pages of {name} Pakaitinis dokumentas: {name} puslapiai Replaces the existing one on those pages. Tuose puslapiuose pakeičia esamą. + Checking pages… Tikrinami puslapiai… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Puslapis su savo baltu užpildu, kuris paslėptų foną: {pages}. Pakeisti + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Puslapiai su savo baltu užpildu, kuris paslėptų foną: {pages}. Pakeisti + Removing the background puts the page's own backdrop back. Pašalinus foną, grąžinamas paties puslapio baltas užpildas. + A picture covers page {pages} (a scan?), so the background won't show there. Paveikslėliu padengtas puslapis (nuskaitytas?): {pages}; fonas ten nesimatys. + Pictures cover pages {pages} (scans?), so the background won't show there. Paveikslėliais padengti puslapiai (nuskaityti?): {pages}; fonas ten nesimatys. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Puslapis pasikeitė po fono pridėjimo, todėl jo baltas užpildas lieka išjungtas: {pages}. Ant balto popieriaus jis atrodo taip pat. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Puslapiai pasikeitė po fono pridėjimo, todėl jų balti užpildai lieka išjungti: {pages}. Ant balto popieriaus jie atrodo taip pat. Report Ataskaita Report… Ataskaita… Requires user to enter a password for: Naudotojas turi įvesti slaptažodį, kad galėtų: @@ -1330,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 @@ -2123,6 +2132,9 @@ comment menu Edit Redaguoti Formulas and similar non-text elements need alternate text. Add alternate text to them. Formulėms ir panašiems netekstiniams elementams reikia alternatyviojo teksto. Pridėkite jį. Gray Pilka Grayscale Images Pilkų atspalvių paveikslėliai + Monochrome Images Vienspalviai paveikslėliai + CCITT Group 4 CCITT 4 grupė + JBIG2 (lossless) JBIG2 (be nuostolių) Green Žalia Green on black Žalia juodame fone H / Space (hold) H / tarpo klavišas (laikyti) @@ -2668,6 +2680,11 @@ certificate SHA-256 digest SHA-256 santrauka @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} apskaičiuojamas anksčiau už {fields}, kurį skaito.|{field} apskaičiuojamas anksčiau už {fields}, kuriuos skaito.|{field} apskaičiuojamas anksčiau už {fields}, kuriuos skaito. @plural {n} more field calculates too early.|{n} more fields calculate too early. Dar {n} laukas apskaičiuojamas per anksti.|Dar {n} laukai apskaičiuojami per anksti.|Dar {n} laukų apskaičiuojama per anksti. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} nėra PDF faile įrašytoje tvarkoje, todėl kitos PDF peržiūros programos gali jo neapskaičiuoti.|{fields} nėra PDF faile įrašytoje tvarkoje, todėl kitos PDF peržiūros programos gali jų neapskaičiuoti.|{fields} nėra PDF faile įrašytoje tvarkoje, todėl kitos PDF peržiūros programos gali jų neapskaičiuoti. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formulė skaito lauką, kurio formoje nėra|{n} formulės skaito laukus, kurių formoje nėra|{n} formulių skaito laukus, kurių formoje nėra +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} skaito {fields}, kurio formoje nėra.|{field} skaito {fields}, kurių formoje nėra.|{field} skaito {fields}, kurių formoje nėra. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Dar {n} formulė skaito trūkstamą lauką.|Dar {n} formulės skaito trūkstamus laukus.|Dar {n} formulių skaito trūkstamus laukus. +@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} kai kuriuos laukų pavadinimus sudaro vykdymo metu, todėl šie skaitymai netikrinami.|{fields} kai kuriuos laukų pavadinimus sudaro vykdymo metu, todėl šie skaitymai netikrinami.|{fields} kai kuriuos laukų pavadinimus sudaro vykdymo metu, todėl šie skaitymai netikrinami. +@plural {fields} is missing|{fields} are missing Trūksta {fields}|Trūksta {fields}|Trūksta {fields} key Ctrl Ctrl key Shift Shift key Delete Delete @@ -2748,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 @@ -2756,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 @@ -2764,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 @@ -2806,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 @@ -2818,3 +2861,15 @@ theme Appearance Išvaizda Rotate the selected pages clockwise Pasukti pasirinktus puslapius pagal laikrodžio rodyklę Extract the selected pages… Išskleisti pasirinktus puslapius… Delete the selected pages Ištrinti pasirinktus puslapius + +# 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 6f1739974a..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 @@ -955,6 +951,14 @@ Replacement text Erstatningstekst Replacement: pages of {name} Erstatning: sider fra {name} Replaces the existing one on those pages. Erstatter den eksisterende på disse sidene. + Checking pages… Kontrollerer sidene… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Side {pages} maler sin egen hvite flate, som ville skjult bakgrunnen. Erstatt den + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Sidene {pages} maler sine egne hvite flater, som ville skjult bakgrunnen. Erstatt dem + Removing the background puts the page's own backdrop back. Når bakgrunnen fjernes, kommer sidens egen hvite flate tilbake. + A picture covers page {pages} (a scan?), so the background won't show there. Et bilde dekker side {pages} (en skanning?), så bakgrunnen vises ikke der. + Pictures cover pages {pages} (scans?), so the background won't show there. Bilder dekker sidene {pages} (skanninger?), så bakgrunnen vises ikke der. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Side {pages} ble endret etter at bakgrunnen ble lagt til, så sidens egen hvite flate forblir slått av. På hvitt papir ser den lik ut. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Sidene {pages} ble endret etter at bakgrunnen ble lagt til, så sidenes egne hvite flater forblir slått av. På hvitt papir ser de like ut. Report Rapport Report… Rapport… Requires user to enter a password for: Krever at brukeren skriver inn et passord for: @@ -1281,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 @@ -2060,6 +2063,9 @@ Formulas and similar non-text elements need alternate text. Add alternate text to them. Formler og lignende elementer som ikke er tekst, trenger alternativ tekst. Legg til alternativ tekst på dem. Gray Grå Grayscale Images Gråtonebilder + Monochrome Images Svart-hvitt-bilder + CCITT Group 4 CCITT gruppe 4 + JBIG2 (lossless) JBIG2 (tapsfri) Green Grønn Green on black Grønn på svart H / Space (hold) H / Mellomrom (hold) @@ -2718,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 @@ -2733,3 +2746,47 @@ key Space Mellomrom Rotate the selected pages clockwise Roter de valgte sidene med klokken Extract the selected pages… Trekk ut de valgte sidene… Delete the selected pages Slett de valgte sidene + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply Alternativer for dette svaret +# Untranslated: English text until a translator fills these in. + Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. + Missing field Missing field + 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 +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. +@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 963d717584..e7e6b9d357 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -536,6 +536,12 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Odczytuje {names}, które z kolei odczytują je. Żadna kolejność nie naprawi pętli: zmień jedną z formuł. Not in order Poza kolejnością Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Brakuje go w kolejności obliczeń zapisanej w pliku PDF: inne przeglądarki PDF mogą go nie obliczać. + Reads {name}, which the form doesn't have, so it gets no value there. Odczytuje {name}, którego nie ma w formularzu, więc nie dostaje stamtąd żadnej wartości. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Jego skrypt odczytuje też pola, których nazwy tworzy w trakcie działania, więc tych odczytów nie da się sprawdzić. + Missing field Brak pola + Unchecked reads Niesprawdzone odczyty + {field} reads fields named as it runs {field} odczytuje pola nazywane w trakcie działania + Some names are built as it runs Niektóre nazwy powstają w trakcie działania Back to Prepare a form Wróć do „Przygotuj formularz” No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Żadne pole tego formularza nie oblicza jeszcze swojej wartości. Nadaj polu obliczenie na karcie Obliczenia w jego właściwościach, a pojawi się tutaj. {names} and {last} {names} i {last} @@ -592,6 +598,9 @@ Clean Up Porządkowanie Color Images Obrazy kolorowe Grayscale Images Obrazy w skali szarości + Monochrome Images Obrazy monochromatyczne + CCITT Group 4 CCITT grupa 4 + JBIG2 (lossless) JBIG2 (bezstratna) Bicubic downsampling to Zmniejsz rozdzielczość metodą dwusześcienną do for images above dla obrazów powyżej Compression Kompresja @@ -807,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 @@ -858,6 +862,14 @@ Applies to 1 page Dotyczy 1 strony Applies to {n} pages Liczba objętych stron: {n} Replaces the existing one on those pages. Zastępuje istniejący element na tych stronach. + Checking pages… Sprawdzanie stron… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Strona z własnym białym wypełnieniem, które zasłoniłoby tło: {pages}. Zastąp + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Strony z własnym białym wypełnieniem, które zasłoniłoby tło: {pages}. Zastąp + Removing the background puts the page's own backdrop back. Usunięcie tła przywraca własne białe wypełnienie strony. + A picture covers page {pages} (a scan?), so the background won't show there. Strona zakryta obrazem (skan?): {pages}; tło nie będzie tam widoczne. + Pictures cover pages {pages} (scans?), so the background won't show there. Strony zakryte obrazami (skany?): {pages}; tło nie będzie tam widoczne. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Strona zmieniona po dodaniu tła, więc jej własne białe wypełnienie pozostaje wyłączone: {pages}. Na białym papierze wygląda tak samo. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Strony zmienione po dodaniu tła, więc ich własne białe wypełnienia pozostają wyłączone: {pages}. Na białym papierze wyglądają tak samo. PDF or image PDF lub obraz No file chosen Nie wybrano pliku Page number Numer strony @@ -2373,6 +2385,11 @@ animation effects Off Wyłączone @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} jest obliczane przed {fields}, które odczytuje.|{field} jest obliczane przed {fields}, które odczytuje.|{field} jest obliczane przed {fields}, które odczytuje. @plural {n} more field calculates too early.|{n} more fields calculate too early. Jeszcze {n} pole jest obliczane za wcześnie.|Jeszcze {n} pola są obliczane za wcześnie.|Jeszcze {n} pól jest obliczanych za wcześnie. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. W kolejności zapisanej w pliku PDF brakuje {fields}, więc inne przeglądarki PDF mogą go nie obliczać.|W kolejności zapisanej w pliku PDF brakuje {fields}, więc inne przeglądarki PDF mogą ich nie obliczać.|W kolejności zapisanej w pliku PDF brakuje {fields}, więc inne przeglądarki PDF mogą ich nie obliczać. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formuła odczytuje pole, którego nie ma w formularzu|{n} formuły odczytują pola, których nie ma w formularzu|{n} formuł odczytuje pola, których nie ma w formularzu +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} odczytuje {fields}, którego nie ma w formularzu.|{field} odczytuje {fields}, których nie ma w formularzu.|{field} odczytuje {fields}, których nie ma w formularzu. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Jeszcze {n} formuła odczytuje brakujące pole.|Jeszcze {n} formuły odczytują brakujące pola.|Jeszcze {n} formuł odczytuje brakujące pola. +@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} tworzy część nazw pól w trakcie działania, więc te odczyty nie są sprawdzane.|{fields} tworzą część nazw pól w trakcie działania, więc te odczyty nie są sprawdzane.|{fields} tworzą część nazw pól w trakcie działania, więc te odczyty nie są sprawdzane. +@plural {fields} is missing|{fields} are missing Brak {fields}|Brak {fields}|Brak {fields} Scan Skanuj Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Podpisz się na białej kartce i zrób jej zdjęcie przy jasnym, równomiernym świetle (użyj lampy błyskowej). Wybierz zdjęcie lub upuść je tutaj: PdfCraft znajdzie podpis i usunie papier. Choose photo… Wybierz zdjęcie… @@ -2474,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 @@ -2641,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 @@ -2653,3 +2697,15 @@ animation effects Off Wyłączone Rotate the selected pages clockwise Obróć zaznaczone strony w prawo Extract the selected pages… Wyodrębnij zaznaczone strony… Delete the selected pages Usuń zaznaczone strony + +# 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 c34ae83bc5..3c4d0537cd 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -159,6 +159,12 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Lê {names}, que o leem de volta. Nenhuma ordem resolve um ciclo: altere uma das fórmulas. Not in order Fora da ordem Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Ausente da ordem de cálculo salva no PDF: outros leitores de PDF podem não calculá-lo. + Reads {name}, which the form doesn't have, so it gets no value there. Lê {name}, que não existe no formulário, então não recebe nenhum valor dali. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Seu script também lê campos cujos nomes monta durante a execução, então essas leituras não podem ser verificadas. + Missing field Campo ausente + Unchecked reads Leituras não verificadas + {field} reads fields named as it runs {field} lê campos nomeados durante a execução + Some names are built as it runs Alguns nomes são montados durante a execução Back to Prepare a form Voltar para Preparar um formulário No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Nenhum campo deste formulário calcula seu valor ainda. Dê um cálculo a um campo na aba Calcular das propriedades dele, e ele aparecerá aqui. {names} and {last} {names} e {last} @@ -216,6 +222,9 @@ Clean Up Limpar Color Images Imagens coloridas Grayscale Images Imagens em escala de cinza + Monochrome Images Imagens monocromáticas + CCITT Group 4 CCITT grupo 4 + JBIG2 (lossless) JBIG2 (sem perdas) Bicubic downsampling to Redução bicúbica de amostragem para for images above para imagens acima de Compression Compactação @@ -432,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 @@ -484,6 +488,14 @@ Applies to 1 page Aplica-se a 1 página Applies to {n} pages Aplica-se a {n} páginas Replaces the existing one on those pages. Substitui o existente nessas páginas. + Checking pages… Verificando páginas… + Page {pages} paints its own white backdrop, which would hide the background. Replace it A página {pages} tem um preenchimento branco próprio, que esconderia o plano de fundo. Substituí-lo + Pages {pages} paint their own white backdrop, which would hide the background. Replace it As páginas {pages} têm um preenchimento branco próprio, que esconderia o plano de fundo. Substituí-lo + Removing the background puts the page's own backdrop back. Remover o plano de fundo restaura o preenchimento branco da página. + A picture covers page {pages} (a scan?), so the background won't show there. Uma imagem cobre a página {pages} (uma digitalização?), então o plano de fundo não aparecerá nela. + Pictures cover pages {pages} (scans?), so the background won't show there. Imagens cobrem as páginas {pages} (digitalizações?), então o plano de fundo não aparecerá nelas. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. A página {pages} mudou depois que o plano de fundo foi adicionado, então seu preenchimento branco próprio continua desligado. Em papel branco ela parece igual. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. As páginas {pages} mudaram depois que o plano de fundo foi adicionado, então seus preenchimentos brancos próprios continuam desligados. Em papel branco elas parecem iguais. PDF or image PDF ou imagem No file chosen Nenhum arquivo escolhido Page number Número da página @@ -2419,6 +2431,11 @@ animation effects Off Desativados @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} é calculado antes de {fields}, que ele lê.|{field} é calculado antes de {fields}, que ele lê. @plural {n} more field calculates too early.|{n} more fields calculate too early. Mais {n} campo é calculado cedo demais.|Mais {n} campos são calculados cedo demais. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} não está na ordem salva no PDF, então outros visualizadores de PDF podem não calculá-lo.|{fields} não estão na ordem salva no PDF, então outros visualizadores de PDF podem não calculá-los. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} fórmula lê um campo que não existe no formulário|{n} fórmulas leem campos que não existem no formulário +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} lê {fields}, que não existe no formulário.|{field} lê {fields}, que não existem no formulário. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Mais {n} fórmula lê um campo ausente.|Mais {n} fórmulas leem campos ausentes. +@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} monta alguns nomes de campo durante a execução, então essas leituras não são verificadas.|{fields} montam alguns nomes de campo durante a execução, então essas leituras não são verificadas. +@plural {fields} is missing|{fields} are missing {fields} não existe|{fields} não existem Scan Digitalizar Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Assine uma folha de papel branco e tire uma foto dela com luz forte e uniforme (use o flash). Escolha a foto ou solte-a aqui: o PdfCraft encontra a assinatura e remove o papel. Choose photo… Escolher foto… @@ -2512,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 @@ -2830,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 @@ -2842,3 +2886,15 @@ theme Appearance Aparência Rotate the selected pages clockwise Girar as páginas selecionadas no sentido horário Extract the selected pages… Extrair as páginas selecionadas… Delete the selected pages Excluir as páginas selecionadas + +# 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 78d2298b93..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 @@ -955,6 +951,14 @@ Replacement text Text de înlocuire Replacement: pages of {name} Înlocuire: pagini din {name} Replaces the existing one on those pages. Înlocuiește-l pe cel existent pe acele pagini. + Checking pages… Se verifică paginile… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Pagina {pages} are o umplere albă proprie, care ar ascunde fundalul. Înlocuiește-o + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Paginile {pages} au o umplere albă proprie, care ar ascunde fundalul. Înlocuiește-o + Removing the background puts the page's own backdrop back. Eliminarea fundalului readuce umplerea albă proprie a paginii. + A picture covers page {pages} (a scan?), so the background won't show there. O imagine acoperă pagina {pages} (o scanare?), deci fundalul nu se va vedea acolo. + Pictures cover pages {pages} (scans?), so the background won't show there. Imagini acoperă paginile {pages} (scanări?), deci fundalul nu se va vedea acolo. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Pagina {pages} s-a schimbat după adăugarea fundalului, așa că umplerea ei albă proprie rămâne oprită. Pe hârtie albă arată la fel. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Paginile {pages} s-au schimbat după adăugarea fundalului, așa că umplerile lor albe proprii rămân oprite. Pe hârtie albă arată la fel. Report Raport Report… Raport… Requires user to enter a password for: Utilizatorul trebuie să introducă o parolă pentru: @@ -1279,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 @@ -2066,6 +2069,9 @@ comment menu Edit Editare Formulas and similar non-text elements need alternate text. Add alternate text to them. Formulele și alte elemente non-text similare au nevoie de text alternativ. Adăugați-le text alternativ. Gray Gri Grayscale Images Imagini în tonuri de gri + Monochrome Images Imagini monocrome + CCITT Group 4 CCITT grupa 4 + JBIG2 (lossless) JBIG2 (fără pierderi) Green Verde Green on black Verde pe negru H / Space (hold) H / Spațiu (ținut apăsat) @@ -2723,6 +2729,12 @@ theme Appearance Aspect Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Citește {names}, care îl citesc la rândul lor. Nicio ordine nu poate rezolva o buclă: modificați una dintre formule. Not in order În afara ordinii Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Lipsește din ordinea de calcul salvată în PDF: alte aplicații de vizualizare PDF s-ar putea să nu-l calculeze. + Reads {name}, which the form doesn't have, so it gets no value there. Citește {name}, care nu există în formular, deci nu primește nicio valoare de acolo. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Scriptul său citește și câmpuri ale căror nume le construiește în timpul rulării, deci aceste citiri nu pot fi verificate. + Missing field Câmp lipsă + Unchecked reads Citiri neverificate + {field} reads fields named as it runs {field} citește câmpuri numite în timpul rulării + Some names are built as it runs Unele nume sunt construite în timpul rulării Back to Prepare a form Înapoi la Pregătire formular No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Niciun câmp din acest formular nu își calculează încă valoarea. Adăugați un calcul unui câmp în fila Calcul din proprietățile lui și va apărea aici. {names} and {last} {names} și {last} @@ -2750,6 +2762,11 @@ theme Appearance Aspect @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} se calculează înaintea câmpului {fields}, pe care îl citește.|{field} se calculează înaintea câmpurilor {fields}, pe care le citește.|{field} se calculează înaintea câmpurilor {fields}, pe care le citește. @plural {n} more field calculates too early.|{n} more fields calculate too early. Încă {n} câmp se calculează prea devreme.|Încă {n} câmpuri se calculează prea devreme.|Încă {n} de câmpuri se calculează prea devreme. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} lipsește din ordinea salvată în PDF, deci alte aplicații de vizualizare PDF s-ar putea să nu-l calculeze.|{fields} lipsesc din ordinea salvată în PDF, deci alte aplicații de vizualizare PDF s-ar putea să nu le calculeze.|{fields} lipsesc din ordinea salvată în PDF, deci alte aplicații de vizualizare PDF s-ar putea să nu le calculeze. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formulă citește un câmp care nu există în formular|{n} formule citesc câmpuri care nu există în formular|{n} de formule citesc câmpuri care nu există în formular +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} citește {fields}, care nu există în formular.|{field} citește {fields}, care nu există în formular.|{field} citește {fields}, care nu există în formular. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Încă {n} formulă citește un câmp lipsă.|Încă {n} formule citesc câmpuri lipsă.|Încă {n} de formule citesc câmpuri lipsă. +@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} construiește unele nume de câmpuri în timpul rulării, deci aceste citiri nu sunt verificate.|{fields} construiesc unele nume de câmpuri în timpul rulării, deci aceste citiri nu sunt verificate.|{fields} construiesc unele nume de câmpuri în timpul rulării, deci aceste citiri nu sunt verificate. +@plural {fields} is missing|{fields} are missing {fields} lipsește|{fields} lipsesc|{fields} lipsesc Bulleted list Listă cu marcatori Numbered list Listă numerotată Alignment Aliniere @@ -2798,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 @@ -2818,3 +2862,13 @@ theme Appearance Aspect Rotate the selected pages clockwise Rotește paginile selectate în sens orar Extract the selected pages… Extrage paginile selectate… Delete the selected pages Șterge paginile selectate + +# 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 274232d586..46ff335c81 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -534,6 +534,12 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Читает {names}, которые в свою очередь читают его. Никакой порядок не исправит цикл: измените одну из формул. Not in order Вне порядка Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Отсутствует в порядке вычислений, сохранённом в PDF: другие программы просмотра PDF могут его не вычислять. + Reads {name}, which the form doesn't have, so it gets no value there. Читает {name}, которого нет в форме, поэтому не получает оттуда значения. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Его скрипт также читает поля, имена которых составляет во время выполнения, поэтому эти чтения нельзя проверить. + Missing field Нет поля + Unchecked reads Непроверенные чтения + {field} reads fields named as it runs {field} читает поля, имена которых задаются при выполнении + Some names are built as it runs Некоторые имена составляются при выполнении Back to Prepare a form Назад к «Подготовить форму» No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Ни одно поле этой формы пока не вычисляет своё значение. Задайте полю вычисление на вкладке «Вычислить» его свойств, и оно появится здесь. {names} and {last} {names} и {last} @@ -590,6 +596,9 @@ Clean Up Очистить Color Images Цветные изображения Grayscale Images Изображения в оттенках серого + Monochrome Images Монохромные изображения + CCITT Group 4 CCITT группа 4 + JBIG2 (lossless) JBIG2 (без потерь) Bicubic downsampling to Бикубическое уменьшение до for images above для изображений выше Compression Сжатие @@ -808,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 Позже @@ -860,6 +864,14 @@ Applies to 1 page Применяется к 1 странице Applies to {n} pages Применяется к {n} страницам Replaces the existing one on those pages. Заменяет существующий на этих страницах. + Checking pages… Проверка страниц… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Страница с собственной белой заливкой, которая скроет фон: {pages}. Заменить + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Страницы с собственной белой заливкой, которая скроет фон: {pages}. Заменить + Removing the background puts the page's own backdrop back. При удалении фона собственная белая заливка страницы возвращается. + A picture covers page {pages} (a scan?), so the background won't show there. Страница, закрытая изображением (скан?): {pages}; фон там не будет виден. + Pictures cover pages {pages} (scans?), so the background won't show there. Страницы, закрытые изображениями (сканы?): {pages}; фон там не будет виден. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Страница изменена после добавления фона, поэтому её собственная белая заливка остаётся выключенной: {pages}. На белой бумаге она выглядит так же. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Страницы изменены после добавления фона, поэтому их собственные белые заливки остаются выключенными: {pages}. На белой бумаге они выглядят так же. PDF or image PDF или изображение No file chosen Файл не выбран Page number Номер страницы @@ -2318,6 +2330,11 @@ animation effects Off Выкл @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} вычисляется раньше {fields}, которое оно читает.|{field} вычисляется раньше {fields}, которые оно читает.|{field} вычисляется раньше {fields}, которые оно читает. @plural {n} more field calculates too early.|{n} more fields calculate too early. Ещё {n} поле вычисляется слишком рано.|Ещё {n} поля вычисляются слишком рано.|Ещё {n} полей вычисляются слишком рано. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} нет в порядке, сохранённом в PDF, поэтому другие программы просмотра PDF могут его не вычислить.|{fields} нет в порядке, сохранённом в PDF, поэтому другие программы просмотра PDF могут их не вычислить.|{fields} нет в порядке, сохранённом в PDF, поэтому другие программы просмотра PDF могут их не вычислить. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} формула читает поле, которого нет в форме|{n} формулы читают поля, которых нет в форме|{n} формул читают поля, которых нет в форме +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} читает {fields}, которого нет в форме.|{field} читает {fields}, которых нет в форме.|{field} читает {fields}, которых нет в форме. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Ещё {n} формула читает отсутствующее поле.|Ещё {n} формулы читают отсутствующие поля.|Ещё {n} формул читают отсутствующие поля. +@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} составляет некоторые имена полей при выполнении, поэтому эти чтения не проверяются.|{fields} составляют некоторые имена полей при выполнении, поэтому эти чтения не проверяются.|{fields} составляют некоторые имена полей при выполнении, поэтому эти чтения не проверяются. +@plural {fields} is missing|{fields} are missing {fields} отсутствует|{fields} отсутствуют|{fields} отсутствуют Scan Сканировать Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Распишитесь на листе белой бумаги и сфотографируйте его при ярком равномерном освещении (со вспышкой). Выберите фото или перетащите его сюда: PdfCraft найдёт подпись и уберёт бумагу. Choose photo… Выбрать фото… @@ -2411,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 Формы и подписи @@ -2458,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 Сегодня @@ -2470,3 +2514,15 @@ animation effects Off Выкл Rotate the selected pages clockwise Повернуть выбранные страницы по часовой стрелке Extract the selected pages… Извлечь выбранные страницы… Delete the selected pages Удалить выбранные страницы + +# 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 420e6a5996..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} @@ -891,6 +888,9 @@ Grab bag Razno Gray Siva Grayscale Images Sivinske slike + Monochrome Images Enobarvne slike + CCITT Group 4 CCITT skupina 4 + JBIG2 (lossless) JBIG2 (brez izgub) Green Zelena H / Space (hold) H / preslednica (drži) HTML web page Spletna stran HTML @@ -1027,6 +1027,12 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Bere {names}, ki ga berejo nazaj. Noben vrstni red ne popravi zanke: spremenite eno od formul. Not in order Ni v vrstnem redu Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Manjka v vrstnem redu izračunov, shranjenem v PDF-ju: drugi pregledovalniki PDF ga morda ne izračunajo. + Reads {name}, which the form doesn't have, so it gets no value there. Bere {name}, ki ga v obrazcu ni, zato od tam ne dobi vrednosti. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Njegov skript bere tudi polja, katerih imena sestavi med izvajanjem, zato teh branj ni mogoče preveriti. + Missing field Manjka polje + Unchecked reads Nepreverjena branja + {field} reads fields named as it runs {field} bere polja, poimenovana med izvajanjem + Some names are built as it runs Nekatera imena se sestavijo med izvajanjem Back to Prepare a form Nazaj na Pripravi obrazec No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Nobeno polje tega obrazca še ne izračuna svoje vrednosti. Polju dodelite izračun na zavihku Izračunaj v njegovih lastnostih in prikazalo se bo tukaj. {names} and {last} {names} in {last} @@ -1359,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 @@ -1527,6 +1532,14 @@ Replacement text Nadomestno besedilo Replacement: pages of {name} Zamenjava: strani datoteke {name} Replaces the existing one on those pages. Nadomesti obstoječe na teh straneh. + Checking pages… Preverjanje strani… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Stran z lastnim belim polnilom, ki bi skrilo ozadje: {pages}. Zamenjaj + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Strani z lastnim belim polnilom, ki bi skrilo ozadje: {pages}. Zamenjaj + Removing the background puts the page's own backdrop back. Odstranitev ozadja povrne lastno belo polnilo strani. + A picture covers page {pages} (a scan?), so the background won't show there. Stran, ki jo prekriva slika (skeniranje?): {pages}; ozadje tam ne bo vidno. + Pictures cover pages {pages} (scans?), so the background won't show there. Strani, ki jih prekrivajo slike (skeniranja?): {pages}; ozadje tam ne bo vidno. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Stran se je spremenila po dodajanju ozadja, zato njeno lastno belo polnilo ostane izklopljeno: {pages}. Na belem papirju je videti enako. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Strani so se spremenile po dodajanju ozadja, zato njihova lastna bela polnila ostanejo izklopljena: {pages}. Na belem papirju so videti enako. Replacing images arrives on the web with image file pickers Zamenjava slik bo v spletni različici na voljo z izbirniki slikovnih datotek Replacing images on page {p} arrives on the web with image pickers ({i}) Zamenjava slik na strani {p} bo v spletni različici na voljo z izbirniki slik ({i}) Reply Odgovori @@ -2046,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 @@ -2409,6 +2421,11 @@ animation effects Off Izklopljeni @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} se izračuna pred {fields}, ki ga bere.|{field} se izračuna pred {fields}, ki ju bere.|{field} se izračuna pred {fields}, ki jih bere.|{field} se izračuna pred {fields}, ki jih bere. @plural {n} more field calculates too early.|{n} more fields calculate too early. Še {n} polje se izračuna prezgodaj.|Še {n} polji se izračunata prezgodaj.|Še {n} polja se izračunajo prezgodaj.|Še {n} polj se izračuna prezgodaj. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} manjka v vrstnem redu, shranjenem v PDF-ju, zato ga drugi pregledovalniki PDF morda ne bodo izračunali.|{fields} manjkata v vrstnem redu, shranjenem v PDF-ju, zato ju drugi pregledovalniki PDF morda ne bodo izračunali.|{fields} manjkajo v vrstnem redu, shranjenem v PDF-ju, zato jih drugi pregledovalniki PDF morda ne bodo izračunali.|{fields} manjkajo v vrstnem redu, shranjenem v PDF-ju, zato jih drugi pregledovalniki PDF morda ne bodo izračunali. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formula bere polje, ki ga v obrazcu ni|{n} formuli bereta polja, ki ju v obrazcu ni|{n} formule berejo polja, ki jih v obrazcu ni|{n} formul bere polja, ki jih v obrazcu ni +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} bere {fields}, ki ga v obrazcu ni.|{field} bere {fields}, ki ju v obrazcu ni.|{field} bere {fields}, ki jih v obrazcu ni.|{field} bere {fields}, ki jih v obrazcu ni. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Še {n} formula bere manjkajoče polje.|Še {n} formuli bereta manjkajoča polja.|Še {n} formule berejo manjkajoča polja.|Še {n} formul bere manjkajoča polja. +@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} nekatera imena polj sestavi med izvajanjem, zato ta branja niso preverjena.|{fields} nekatera imena polj sestavita med izvajanjem, zato ta branja niso preverjena.|{fields} nekatera imena polj sestavijo med izvajanjem, zato ta branja niso preverjena.|{fields} nekatera imena polj sestavijo med izvajanjem, zato ta branja niso preverjena. +@plural {fields} is missing|{fields} are missing {fields} manjka|{fields} manjkata|{fields} manjkajo|{fields} manjka @plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} ime polja se ujema z imeni iz prejšnjih datotek in bo preimenovano: {list}|{n} imeni polj se ujemata z imeni iz prejšnjih datotek in bosta preimenovani: {list}|{n} imena polj se ujemajo z imeni iz prejšnjih datotek in bodo preimenovana: {list}|{n} imen polj se ujema z imeni iz prejšnjih datotek in bo preimenovanih: {list} Scan Skeniraj Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Podpišite se na list belega papirja in ga fotografirajte v močni, enakomerni svetlobi (uporabite bliskavico). Izberite fotografijo ali jo spustite sem: PdfCraft poišče podpis in odstrani papir. @@ -2503,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 @@ -2550,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 @@ -2562,3 +2606,15 @@ animation effects Off Izklopljeni Rotate the selected pages clockwise Zavrti izbrane strani v smeri urnega kazalca Extract the selected pages… Izvleci izbrane strani… Delete the selected pages Izbriši izbrane strani + +# 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 ba7c9dc5b3..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} @@ -880,6 +877,9 @@ Grab bag Të ndryshme Gray Gri Grayscale Images Imazhe në nuanca gri + Monochrome Images Imazhe bardhezi + CCITT Group 4 CCITT grupi 4 + JBIG2 (lossless) JBIG2 (pa humbje) Green Jeshile H / Space (hold) H / Hapësira (mbaje shtypur) HTML web page Faqe interneti HTML @@ -1314,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 @@ -1479,6 +1478,14 @@ Replacement text Teksti zëvendësues Replacement: pages of {name} Zëvendësimi: faqet e {name} Replaces the existing one on those pages. Zëvendëson atë ekzistues në ato faqe. + Checking pages… Po kontrollohen faqet… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Faqja {pages} ka mbushjen e vet të bardhë, e cila do ta fshihte sfondin. Zëvendësoje + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Faqet {pages} kanë mbushjen e tyre të bardhë, e cila do ta fshihte sfondin. Zëvendësoje + Removing the background puts the page's own backdrop back. Heqja e sfondit e rikthen mbushjen e bardhë të faqes. + A picture covers page {pages} (a scan?), so the background won't show there. Një imazh e mbulon faqen {pages} (skanim?), prandaj sfondi nuk do të duket aty. + Pictures cover pages {pages} (scans?), so the background won't show there. Imazhe mbulojnë faqet {pages} (skanime?), prandaj sfondi nuk do të duket aty. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Faqja {pages} ndryshoi pasi u shtua sfondi, ndaj mbushja e saj e bardhë mbetet e fikur. Në letër të bardhë duket njësoj. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Faqet {pages} ndryshuan pasi u shtua sfondi, ndaj mbushjet e tyre të bardha mbeten të fikura. Në letër të bardhë duken njësoj. Replacing images arrives on the web with image file pickers Zëvendësimi i imazheve vjen në ueb me zgjedhësit e skedarëve të imazhit Replacing images on page {p} arrives on the web with image pickers ({i}) Zëvendësimi i imazheve në faqen {p} vjen në ueb me zgjedhësit e imazhit ({i}) Reply Përgjigju @@ -1991,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 @@ -2379,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. @@ -2409,6 +2415,17 @@ animation effects Off Joaktiv Mark as artifact Mark as artifact Merge image fragments Merge image fragments Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. + Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. + Missing field Missing field + 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 +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. +@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 Mobile Mobile Move only what must move, so every field calculates after the fields it reads. Move only what must move, so every field calculates after the fields it reads. Moves only what must move Moves only what must move @@ -2446,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 @@ -2524,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 @@ -2544,3 +2587,13 @@ animation effects Off Joaktiv Rotate the selected pages clockwise Rrotullo faqet e përzgjedhura në drejtim të orës Extract the selected pages… Nxirr faqet e përzgjedhura… Delete the selected pages Fshi faqet e përzgjedhura + +# 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 7f339989ee..cac3c73204 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -518,6 +518,12 @@ comment menu Edit సవరించు Reads {names}, which read it back. No order can fix a loop: change one of the formulas. {names}ను చదువుతుంది, అవి దీన్ని తిరిగి చదువుతాయి. ఏ క్రమమూ లూప్‌ను సరిచేయదు: సూత్రాలలో ఒకదాన్ని మార్చండి. Not in order క్రమంలో లేదు Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDFలో సేవ్ చేసిన గణన క్రమంలో లేదు: ఇతర PDF వీక్షకులు దీన్ని లెక్కించకపోవచ్చు. + Reads {name}, which the form doesn't have, so it gets no value there. {name}ను చదువుతుంది, కానీ అది ఫారమ్‌లో లేదు, కాబట్టి దాని నుండి విలువ రాదు. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. దీని స్క్రిప్ట్ నడుస్తున్నప్పుడు పేర్లను కూర్చే ఫీల్డ్‌లను కూడా చదువుతుంది, కాబట్టి ఆ చదువులను తనిఖీ చేయలేము. + Missing field ఫీల్డ్ లేదు + Unchecked reads తనిఖీ చేయని చదువులు + {field} reads fields named as it runs {field} నడుస్తున్నప్పుడు పేరు పెట్టే ఫీల్డ్‌లను చదువుతుంది + Some names are built as it runs కొన్ని పేర్లు నడుస్తున్నప్పుడు కూర్చబడతాయి Back to Prepare a form ‘ఫారాన్ని సిద్ధం చేయి’కి తిరిగి వెళ్లు No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. ఈ ఫారంలో ఏ ఫీల్డ్ ఇంకా తన విలువను గణించదు. ఫీల్డ్ లక్షణాలలోని గణన ట్యాబ్‌లో దానికి గణన ఇవ్వండి, అది ఇక్కడ కనిపిస్తుంది. {names} and {last} {names} మరియు {last} @@ -574,6 +580,9 @@ comment menu Edit సవరించు Clean Up చక్కదిద్దడం Color Images రంగు చిత్రాలు Grayscale Images గ్రేస్కేల్ చిత్రాలు + Monochrome Images మోనోక్రోమ్ చిత్రాలు + CCITT Group 4 CCITT గ్రూప్ 4 + JBIG2 (lossless) JBIG2 (నష్టరహితం) Bicubic downsampling to బైక్యూబిక్ డౌన్‌శాంప్లింగ్, లక్ష్యం: for images above ఈ రిజల్యూషన్ మించిన చిత్రాలకు: Compression కుదింపు @@ -789,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 తర్వాత @@ -840,6 +844,14 @@ comment menu Edit సవరించు Applies to 1 page 1 పేజీకి వర్తిస్తుంది Applies to {n} pages {n} పేజీలకు వర్తిస్తుంది Replaces the existing one on those pages. ఆ పేజీలలో ఇప్పటికే ఉన్నదాన్ని భర్తీ చేస్తుంది. + Checking pages… పేజీలను తనిఖీ చేస్తోంది… + Page {pages} paints its own white backdrop, which would hide the background. Replace it పేజీ {pages}కి సొంత తెల్లని నింపు ఉంది, అది నేపథ్యాన్ని దాచేస్తుంది. భర్తీ చేయండి + Pages {pages} paint their own white backdrop, which would hide the background. Replace it పేజీలు {pages}కి సొంత తెల్లని నింపు ఉంది, అది నేపథ్యాన్ని దాచేస్తుంది. భర్తీ చేయండి + Removing the background puts the page's own backdrop back. నేపథ్యాన్ని తీసివేస్తే పేజీ సొంత తెల్లని నింపు తిరిగి వస్తుంది. + A picture covers page {pages} (a scan?), so the background won't show there. పేజీ {pages}ని ఒక చిత్రం కప్పుతోంది (స్కాన్?), కాబట్టి అక్కడ నేపథ్యం కనిపించదు. + Pictures cover pages {pages} (scans?), so the background won't show there. పేజీలు {pages}ని చిత్రాలు కప్పుతున్నాయి (స్కాన్‌లు?), కాబట్టి అక్కడ నేపథ్యం కనిపించదు. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. నేపథ్యాన్ని జోడించిన తర్వాత పేజీ {pages} మారింది, కాబట్టి దాని సొంత తెల్లని నింపు ఆపివేయబడే ఉంటుంది. తెల్ల కాగితంపై అది అలాగే కనిపిస్తుంది. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. నేపథ్యాన్ని జోడించిన తర్వాత పేజీలు {pages} మారాయి, కాబట్టి వాటి సొంత తెల్లని నింపులు ఆపివేయబడే ఉంటాయి. తెల్ల కాగితంపై అవి అలాగే కనిపిస్తాయి. PDF or image PDF లేదా చిత్రం No file chosen ఫైల్ ఏదీ ఎంచుకోలేదు Page number పేజీ సంఖ్య @@ -2306,6 +2318,11 @@ animation effects Off ఆఫ్ @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} తాను చదివే {fields} కంటే ముందు గణించబడుతుంది.|{field} తాను చదివే {fields} కంటే ముందు గణించబడుతుంది. @plural {n} more field calculates too early.|{n} more fields calculate too early. మరో {n} ఫీల్డ్ చాలా ముందుగా గణించబడుతుంది.|మరో {n} ఫీల్డ్‌లు చాలా ముందుగా గణించబడతాయి. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} PDFలో సేవ్ చేసిన క్రమంలో లేదు, కాబట్టి ఇతర PDF వ్యూయర్‌లు దాన్ని గణించకపోవచ్చు.|{fields} PDFలో సేవ్ చేసిన క్రమంలో లేవు, కాబట్టి ఇతర PDF వ్యూయర్‌లు వాటిని గణించకపోవచ్చు. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} సూత్రం ఫారమ్‌లో లేని ఫీల్డ్‌ను చదువుతుంది|{n} సూత్రాలు ఫారమ్‌లో లేని ఫీల్డ్‌లను చదువుతాయి +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} ఫారమ్‌లో లేని {fields}‌ను చదువుతుంది.|{field} ఫారమ్‌లో లేని {fields}‌ను చదువుతుంది. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. మరో {n} సూత్రం లేని ఫీల్డ్‌ను చదువుతుంది.|మరో {n} సూత్రాలు లేని ఫీల్డ్‌లను చదువుతాయి. +@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} నడుస్తున్నప్పుడు కొన్ని ఫీల్డ్ పేర్లను కూర్చుతుంది, కాబట్టి ఆ చదువులు తనిఖీ చేయబడవు.|{fields} నడుస్తున్నప్పుడు కొన్ని ఫీల్డ్ పేర్లను కూర్చుతాయి, కాబట్టి ఆ చదువులు తనిఖీ చేయబడవు. +@plural {fields} is missing|{fields} are missing {fields} లేదు|{fields} లేవు Scan స్కాన్ Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. తెల్ల కాగితంపై సంతకం చేసి, ప్రకాశవంతమైన, సమానమైన వెలుతురులో దాని ఫోటో తీయండి (ఫ్లాష్ ఉపయోగించండి). ఫోటోను ఎంచుకోండి లేదా ఇక్కడ వదలండి: PdfCraft సంతకాన్ని కనుగొని కాగితాన్ని తొలగిస్తుంది. Choose photo… ఫోటో ఎంచుకోండి… @@ -2407,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 ఫారమ్‌లు & సంతకాలు @@ -2454,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 ఈరోజు @@ -2466,3 +2510,15 @@ animation effects Off ఆఫ్ Rotate the selected pages clockwise ఎంచుకున్న పేజీలను సవ్య దిశలో తిప్పు Extract the selected pages… ఎంచుకున్న పేజీలను సంగ్రహించు… Delete the selected pages ఎంచుకున్న పేజీలను తొలగించు + +# 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 2c9b892ed7..72fecec870 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -554,6 +554,9 @@ Clean Up ล้างข้อมูล Color Images รูปภาพสี Grayscale Images รูปภาพระดับสีเทา + Monochrome Images รูปภาพขาวดำ + CCITT Group 4 CCITT กลุ่ม 4 + JBIG2 (lossless) JBIG2 (ไม่สูญเสียข้อมูล) Bicubic downsampling to ลดความละเอียดแบบ Bicubic เป็น for images above สำหรับรูปภาพที่สูงกว่า Compression การบีบอัด @@ -768,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 ภายหลัง @@ -819,6 +817,14 @@ Applies to 1 page มีผลกับ 1 หน้า Applies to {n} pages มีผลกับ {n} หน้า Replaces the existing one on those pages. แทนที่ของเดิมในหน้าเหล่านั้น + Checking pages… กำลังตรวจสอบหน้า… + Page {pages} paints its own white backdrop, which would hide the background. Replace it หน้า {pages} มีพื้นสีขาวของตัวเองซึ่งจะบังพื้นหลัง แทนที่ + Pages {pages} paint their own white backdrop, which would hide the background. Replace it หน้า {pages} มีพื้นสีขาวของตัวเองซึ่งจะบังพื้นหลัง แทนที่ทั้งหมด + Removing the background puts the page's own backdrop back. การเอาพื้นหลังออกจะคืนพื้นสีขาวเดิมของหน้า + A picture covers page {pages} (a scan?), so the background won't show there. มีรูปภาพปิดหน้า {pages} อยู่ (ภาพสแกน?) จึงจะมองไม่เห็นพื้นหลังที่นั่น + Pictures cover pages {pages} (scans?), so the background won't show there. มีรูปภาพหลายรูปปิดหน้า {pages} อยู่ (ภาพสแกน?) จึงจะมองไม่เห็นพื้นหลังที่นั่น + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. หน้า {pages} เปลี่ยนไปหลังจากเพิ่มพื้นหลัง พื้นสีขาวของตัวเองจึงยังปิดอยู่ บนกระดาษสีขาวจะดูเหมือนเดิม + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. หน้า {pages} เปลี่ยนไปหลังจากเพิ่มพื้นหลัง พื้นสีขาวของหน้าเหล่านั้นจึงยังปิดอยู่ บนกระดาษสีขาวจะดูเหมือนเดิม PDF or image PDF หรือรูปภาพ No file chosen ยังไม่ได้เลือกไฟล์ Page number เลขหน้า @@ -2705,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 วันนี้ @@ -2720,3 +2733,42 @@ theme Appearance ลักษณะที่ปรากฏ Rotate the selected pages clockwise หมุนหน้าที่เลือกตามเข็มนาฬิกา Extract the selected pages… แยกหน้าที่เลือกออก… Delete the selected pages ลบหน้าที่เลือก + +# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed) + Options for this reply ตัวเลือกของการตอบกลับนี้ +# Untranslated: English text until a translator fills these in. + Reads {name}, which the form doesn't have, so it gets no value there. Reads {name}, which the form doesn't have, so it gets no value there. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Its script also reads fields whose names it builds as it runs, so those reads can't be checked. + Missing field Missing field + 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 a2ecaea179..1dba482650 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -499,6 +499,12 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. {names} alanlarını okur, onlar da bu alanı okur. Hiçbir sıra bir döngüyü düzeltemez: formüllerden birini değiştirin. Not in order Sırada değil Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDF'te kayıtlı hesaplama sırasında yok: diğer PDF görüntüleyicileri bunu hesaplamayabilir. + Reads {name}, which the form doesn't have, so it gets no value there. Formda olmayan {name} alanını okur, bu yüzden oradan değer alamaz. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Komut dosyası, adlarını çalışırken oluşturduğu alanları da okur; bu okumalar denetlenemez. + Missing field Eksik alan + Unchecked reads Denetlenmeyen okumalar + {field} reads fields named as it runs {field}, çalışırken adlandırılan alanları okuyor + Some names are built as it runs Bazı adlar çalışırken oluşturuluyor Back to Prepare a form Form hazırla'ya geri dön No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Bu formdaki hiçbir alan henüz değerini hesaplamıyor. Bir alana özelliklerinin Hesapla sekmesinde bir hesaplama verin, burada görünür. {names} and {last} {names} ve {last} @@ -555,6 +561,9 @@ Clean Up Temizle Color Images Renkli Resimler Grayscale Images Gri Tonlamalı Resimler + Monochrome Images Tek Renkli Resimler + CCITT Group 4 CCITT Grup 4 + JBIG2 (lossless) JBIG2 (kayıpsız) Bicubic downsampling to Şu değere bikübik alt örnekleme: for images above bundan büyük resimler için: Compression Sıkıştırma @@ -770,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 @@ -821,6 +825,14 @@ Applies to 1 page 1 sayfaya uygulanır Applies to {n} pages {n} sayfaya uygulanır Replaces the existing one on those pages. Bu sayfalardaki mevcut olanın yerini alır. + Checking pages… Sayfalar denetleniyor… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Arka planı gizleyecek kendi beyaz dolgusu olan sayfa: {pages}. Değiştir + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Arka planı gizleyecek kendi beyaz dolgusu olan sayfalar: {pages}. Değiştir + Removing the background puts the page's own backdrop back. Arka planı kaldırmak, sayfanın kendi beyaz dolgusunu geri getirir. + A picture covers page {pages} (a scan?), so the background won't show there. Resimle kaplı sayfa (tarama mı?): {pages}; arka plan orada görünmez. + Pictures cover pages {pages} (scans?), so the background won't show there. Resimlerle kaplı sayfalar (tarama mı?): {pages}; arka plan orada görünmez. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Arka plan eklendikten sonra değişen sayfa, bu yüzden kendi beyaz dolgusu kapalı kalıyor: {pages}. Beyaz kâğıtta aynı görünür. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Arka plan eklendikten sonra değişen sayfalar, bu yüzden kendi beyaz dolguları kapalı kalıyor: {pages}. Beyaz kâğıtta aynı görünürler. PDF or image PDF veya resim No file chosen Dosya seçilmedi Page number Sayfa numarası @@ -2390,6 +2402,11 @@ animation effects Off Kapalı @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} önce hesaplanıyor; okuduğu {fields} daha sonra hesaplanıyor. @plural {n} more field calculates too early.|{n} more fields calculate too early. {n} alan daha çok erken hesaplanıyor. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields}, PDF'te kayıtlı sırada yok; bu yüzden diğer PDF görüntüleyicileri hesaplamayabilir. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} formül formda olmayan alanları okuyor +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field}, formda olmayan {fields} alanını okuyor. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} formül daha eksik alanları okuyor. +@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}, bazı alan adlarını çalışırken oluşturuyor; bu okumalar denetlenmiyor. +@plural {fields} is missing|{fields} are missing {fields} eksik @plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} alan adı önceki dosyalarla çakışıyor ve yeniden adlandırılacak: {list} Scan Tara Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Beyaz bir kâğıda imzanızı atın ve parlak, eşit ışıkta fotoğrafını çekin (flaş kullanın). Fotoğrafı seçin veya buraya bırakın: PdfCraft imzayı bulur ve kâğıdı kaldırır. @@ -2494,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 @@ -2541,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 @@ -2553,3 +2597,15 @@ animation effects Off Kapalı Rotate the selected pages clockwise Seçili sayfaları saat yönünde döndür Extract the selected pages… Seçili sayfaları ayıkla… Delete the selected pages Seçili sayfaları sil + +# 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 9c76868839..297e1e7fa3 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -539,6 +539,12 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Читає {names}, які своєю чергою читають його. Жоден порядок не виправить цикл: змініть одну з формул. Not in order Поза порядком Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Відсутнє в порядку обчислень, збереженому в PDF: інші переглядачі PDF можуть його не обчислювати. + Reads {name}, which the form doesn't have, so it gets no value there. Читає {name}, якого немає у формі, тому не отримує звідти значення. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Його сценарій також читає поля, імена яких складає під час виконання, тому ці читання неможливо перевірити. + Missing field Немає поля + Unchecked reads Неперевірені читання + {field} reads fields named as it runs {field} читає поля, імена яких задаються під час виконання + Some names are built as it runs Деякі імена складаються під час виконання Back to Prepare a form Назад до «Підготувати форму» No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Жодне поле цієї форми поки не обчислює своє значення. Задайте полю обчислення на вкладці «Обчислення» його властивостей, і воно з’явиться тут. {names} and {last} {names} і {last} @@ -595,6 +601,9 @@ Clean Up Очищення Color Images Кольорові зображення Grayscale Images Зображення у відтінках сірого + Monochrome Images Монохромні зображення + CCITT Group 4 CCITT група 4 + JBIG2 (lossless) JBIG2 (без втрат) Bicubic downsampling to Бікубічне зниження роздільної здатності до for images above для зображень із роздільною здатністю понад Compression Стиснення @@ -812,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 Пізніше @@ -864,6 +868,14 @@ Applies to 1 page Застосовується до 1 сторінки Applies to {n} pages Застосовується до {n} сторінок Replaces the existing one on those pages. Замінює наявний елемент на цих сторінках. + Checking pages… Перевірка сторінок… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Сторінка з власною білою заливкою, яка приховає тло: {pages}. Замінити + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Сторінки з власною білою заливкою, яка приховає тло: {pages}. Замінити + Removing the background puts the page's own backdrop back. Вилучення тла повертає власну білу заливку сторінки. + A picture covers page {pages} (a scan?), so the background won't show there. Сторінка, закрита зображенням (скан?): {pages}; тло там не буде видно. + Pictures cover pages {pages} (scans?), so the background won't show there. Сторінки, закриті зображеннями (скани?): {pages}; тло там не буде видно. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Сторінка змінена після додавання тла, тому її власна біла заливка залишається вимкненою: {pages}. На білому папері вона виглядає так само. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Сторінки змінені після додавання тла, тому їхні власні білі заливки залишаються вимкненими: {pages}. На білому папері вони виглядають так само. PDF or image PDF або зображення No file chosen Файл не вибрано Page number Номер сторінки @@ -2427,6 +2439,11 @@ animation effects Off Вимкнено @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} обчислюється раніше за {fields}, яке воно читає.|{field} обчислюється раніше за {fields}, які воно читає.|{field} обчислюється раніше за {fields}, які воно читає. @plural {n} more field calculates too early.|{n} more fields calculate too early. Ще {n} поле обчислюється зарано.|Ще {n} поля обчислюються зарано.|Ще {n} полів обчислюються зарано. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} немає в порядку, збереженому в PDF, тож інші переглядачі PDF можуть його не обчислити.|{fields} немає в порядку, збереженому в PDF, тож інші переглядачі PDF можуть їх не обчислити.|{fields} немає в порядку, збереженому в PDF, тож інші переглядачі PDF можуть їх не обчислити. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} формула читає поле, якого немає у формі|{n} формули читають поля, яких немає у формі|{n} формул читають поля, яких немає у формі +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} читає {fields}, якого немає у формі.|{field} читає {fields}, яких немає у формі.|{field} читає {fields}, яких немає у формі. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Ще {n} формула читає відсутнє поле.|Ще {n} формули читають відсутні поля.|Ще {n} формул читають відсутні поля. +@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} складає деякі імена полів під час виконання, тому ці читання не перевіряються.|{fields} складають деякі імена полів під час виконання, тому ці читання не перевіряються.|{fields} складають деякі імена полів під час виконання, тому ці читання не перевіряються. +@plural {fields} is missing|{fields} are missing Немає {fields}|Немає {fields}|Немає {fields} Scan Сканувати Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Розпишіться на аркуші білого паперу та сфотографуйте його за яскравого рівномірного освітлення (зі спалахом). Виберіть фото або перетягніть його сюди: PdfCraft знайде підпис і прибере папір. Choose photo… Вибрати фото… @@ -2520,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 Форми й підписи @@ -2567,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 Сьогодні @@ -2579,3 +2623,15 @@ animation effects Off Вимкнено Rotate the selected pages clockwise Повернути вибрані сторінки за годинниковою стрілкою Extract the selected pages… Витягнути вибрані сторінки… Delete the selected pages Видалити вибрані сторінки + +# 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 973ba870ea..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 @@ -697,6 +694,12 @@ theme Appearance Giao diện Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Đọc {names}, các trường này lại đọc ngược nó. Không thứ tự nào sửa được vòng lặp: hãy đổi một trong các công thức. Not in order Không có trong thứ tự Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Không có trong thứ tự tính toán lưu trong PDF: các trình xem PDF khác có thể không tính trường này. + Reads {name}, which the form doesn't have, so it gets no value there. Đọc {name}, nhưng biểu mẫu không có trường này, nên không nhận được giá trị nào từ đó. + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. Tập lệnh của nó còn đọc các trường có tên được tạo ra khi chạy, nên không thể kiểm tra các lần đọc đó. + Missing field Thiếu trường + Unchecked reads Lần đọc chưa kiểm tra + {field} reads fields named as it runs {field} đọc các trường được đặt tên khi chạy + Some names are built as it runs Một số tên được tạo ra khi chạy Back to Prepare a form Quay lại Chuẩn bị biểu mẫu No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. Chưa có trường nào trong biểu mẫu này tự tính giá trị. Hãy đặt phép tính cho một trường ở thẻ Tính toán trong thuộc tính của nó, trường đó sẽ hiện ở đây. {names} and {last} {names} và {last} @@ -904,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 @@ -992,6 +994,14 @@ theme Appearance Giao diện Replacement text Văn bản được thay vào Replacement: pages of {name} Thay thế: các trang của {name} Replaces the existing one on those pages. Thay thế cái hiện có trên các trang đó. + Checking pages… Đang kiểm tra các trang… + Page {pages} paints its own white backdrop, which would hide the background. Replace it Trang {pages} có lớp tô trắng riêng, sẽ che mất nền. Thay thế + Pages {pages} paint their own white backdrop, which would hide the background. Replace it Các trang {pages} có lớp tô trắng riêng, sẽ che mất nền. Thay thế + Removing the background puts the page's own backdrop back. Gỡ nền sẽ khôi phục lớp tô trắng riêng của trang. + A picture covers page {pages} (a scan?), so the background won't show there. Một hình ảnh phủ kín trang {pages} (bản quét?), nên nền sẽ không hiện ở đó. + Pictures cover pages {pages} (scans?), so the background won't show there. Hình ảnh phủ kín các trang {pages} (bản quét?), nên nền sẽ không hiện ở đó. + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. Trang {pages} đã thay đổi sau khi thêm nền, nên lớp tô trắng riêng của nó vẫn tắt. Trên giấy trắng trông vẫn như cũ. + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. Các trang {pages} đã thay đổi sau khi thêm nền, nên lớp tô trắng riêng của chúng vẫn tắt. Trên giấy trắng trông vẫn như cũ. Report Báo cáo Report… Báo cáo… Requires user to enter a password for: Yêu cầu người dùng nhập mật khẩu cho: @@ -1319,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 @@ -2111,6 +2120,9 @@ comment menu Edit Sửa Formulas and similar non-text elements need alternate text. Add alternate text to them. Công thức và các phần tử không phải văn bản tương tự cần có văn bản thay thế. Hãy thêm văn bản thay thế cho chúng. Gray Xám Grayscale Images Ảnh thang xám + Monochrome Images Ảnh đơn sắc + CCITT Group 4 CCITT nhóm 4 + JBIG2 (lossless) JBIG2 (không mất dữ liệu) Green Xanh lá Green on black Xanh lá trên nền đen H / Space (hold) H / Space (giữ) @@ -2656,6 +2668,11 @@ certificate SHA-256 digest Giá trị băm SHA-256 @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} được tính trước {fields} mà nó đọc. @plural {n} more field calculates too early.|{n} more fields calculate too early. Còn {n} trường khác được tính quá sớm. @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} không có trong thứ tự lưu trong PDF, nên các trình xem PDF khác có thể không tính. +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} công thức đọc trường không có trong biểu mẫu +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} đọc {fields}, nhưng biểu mẫu không có. +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. Còn {n} công thức khác đọc trường bị thiếu. +@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} tạo một số tên trường khi chạy, nên các lần đọc đó không được kiểm tra. +@plural {fields} is missing|{fields} are missing Thiếu {fields} key Ctrl Ctrl key Shift Shift key Delete Delete @@ -2685,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ý @@ -2772,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 @@ -2809,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 @@ -2824,3 +2868,15 @@ key Space Space Rotate the selected pages clockwise Xoay các trang đã chọn theo chiều kim đồng hồ Extract the selected pages… Trích xuất các trang đã chọn… Delete the selected pages Xóa các trang đã chọn + +# 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 d77c8f6425..1425da8d0d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -208,6 +208,12 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. 读取 {names},而它们又读取此字段。任何顺序都无法修正循环:请修改其中一个公式。 Not in order 不在顺序中 Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDF 中保存的计算顺序缺少此字段:其他 PDF 查看器可能不会计算它。 + Reads {name}, which the form doesn't have, so it gets no value there. 读取表单中不存在的 {name},因此从中得不到值。 + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. 其脚本还会读取运行时才拼出名称的字段,因此无法检查这些读取。 + Missing field 缺少字段 + Unchecked reads 未检查的读取 + {field} reads fields named as it runs {field} 读取运行时才确定名称的字段 + Some names are built as it runs 部分名称在运行时才拼出 Back to Prepare a form 返回准备表单 No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. 此表单中还没有计算自身值的字段。在字段属性的“计算”选项卡中为字段设置计算后,它会显示在这里。 {names} and {last} {names}和{last} @@ -265,6 +271,9 @@ Clean Up 清理 Color Images 彩色图像 Grayscale Images 灰度图像 + Monochrome Images 单色图像 + CCITT Group 4 CCITT 第 4 组 + JBIG2 (lossless) JBIG2(无损) Bicubic downsampling to 双三次降采样至 for images above (高于此分辨率的图像) Compression 压缩 @@ -483,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 稍后 @@ -535,6 +539,14 @@ Applies to 1 page 应用于 1 页 Applies to {n} pages 应用于 {n} 页 Replaces the existing one on those pages. 替换这些页面上的现有内容。 + Checking pages… 正在检查页面… + Page {pages} paints its own white backdrop, which would hide the background. Replace it 第 {pages} 页有自己的白色填充,会遮住背景。替换它 + Pages {pages} paint their own white backdrop, which would hide the background. Replace it 第 {pages} 页各有自己的白色填充,会遮住背景。替换它们 + Removing the background puts the page's own backdrop back. 移除背景后,页面自己的白色填充会恢复。 + A picture covers page {pages} (a scan?), so the background won't show there. 第 {pages} 页被一张图片覆盖(扫描件?),因此那里不会显示背景。 + Pictures cover pages {pages} (scans?), so the background won't show there. 第 {pages} 页被图片覆盖(扫描件?),因此那里不会显示背景。 + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. 第 {pages} 页在添加背景后有改动,因此其自身的白色填充保持关闭。在白纸上看起来一样。 + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. 第 {pages} 页在添加背景后有改动,因此它们自身的白色填充保持关闭。在白纸上看起来一样。 PDF or image PDF 或图像 No file chosen 未选择文件 Page number 页码 @@ -2369,6 +2381,11 @@ animation effects Off 关 @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} 在它读取的 {fields} 之前计算。 @plural {n} more field calculates too early.|{n} more fields calculate too early. 另有 {n} 个字段计算过早。 @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} 不在 PDF 中保存的顺序里,其他 PDF 查看器可能不会计算。 +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} 个公式读取了表单中不存在的字段 +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} 读取表单中不存在的 {fields}。 +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. 另有 {n} 个公式读取了缺失的字段。 +@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} 在运行时才拼出部分字段名称,因此这些读取不会被检查。 +@plural {fields} is missing|{fields} are missing 缺少 {fields} Scan 扫描 Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. 在白纸上签名,并在明亮、均匀的光线下拍照(使用闪光灯)。选择照片或将其拖放到此处:PdfCraft 会找到签名并去除纸张背景。 Choose photo… 选择照片… @@ -2462,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 表单和签名 @@ -2509,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 今天 @@ -2521,3 +2565,15 @@ animation effects Off 关 Rotate the selected pages clockwise 顺时针旋转所选页面 Extract the selected pages… 提取所选页面… Delete the selected pages 删除所选页面 + +# 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 ce3b939f1d..644ac99d54 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -164,6 +164,12 @@ Reads {names}, which read it back. No order can fix a loop: change one of the formulas. 讀取 {names},而它們又讀取此欄位。任何順序都無法修正迴圈:請修改其中一個公式。 Not in order 不在順序中 Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. PDF 中儲存的計算順序缺少此欄位:其他 PDF 檢視器可能不會計算它。 + Reads {name}, which the form doesn't have, so it gets no value there. 讀取表單中不存在的 {name},因此從中得不到值。 + Its script also reads fields whose names it builds as it runs, so those reads can't be checked. 其指令碼還會讀取執行時才組出名稱的欄位,因此無法檢查這些讀取。 + Missing field 缺少欄位 + Unchecked reads 未檢查的讀取 + {field} reads fields named as it runs {field} 讀取執行時才確定名稱的欄位 + Some names are built as it runs 部分名稱在執行時才組出 Back to Prepare a form 返回準備表單 No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. 此表單中還沒有計算自身值的欄位。在欄位內容的「計算」索引標籤中為欄位設定計算後,它會顯示在這裡。 {names} and {last} {names}和{last} @@ -221,6 +227,9 @@ Clean Up 清理 Color Images 彩色圖片 Grayscale Images 灰階圖片 + Monochrome Images 單色圖片 + CCITT Group 4 CCITT 第 4 組 + JBIG2 (lossless) JBIG2(無損) Bicubic downsampling to 雙三次降取樣至 for images above (高於此解析度的圖片) Compression 壓縮 @@ -436,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 稍後 @@ -487,6 +491,14 @@ Applies to 1 page 套用於 1 頁 Applies to {n} pages 套用於 {n} 頁 Replaces the existing one on those pages. 替換這些頁面上的現有內容。 + Checking pages… 正在檢查頁面… + Page {pages} paints its own white backdrop, which would hide the background. Replace it 第 {pages} 頁有自己的白色填色,會遮住背景。取代它 + Pages {pages} paint their own white backdrop, which would hide the background. Replace it 第 {pages} 頁各有自己的白色填色,會遮住背景。取代它們 + Removing the background puts the page's own backdrop back. 移除背景後,頁面自己的白色填色會恢復。 + A picture covers page {pages} (a scan?), so the background won't show there. 第 {pages} 頁被一張圖片覆蓋(掃描檔?),因此那裡不會顯示背景。 + Pictures cover pages {pages} (scans?), so the background won't show there. 第 {pages} 頁被圖片覆蓋(掃描檔?),因此那裡不會顯示背景。 + Page {pages} changed after the background was added, so its own white backdrop stays off. It looks the same on white paper. 第 {pages} 頁在加入背景後有變更,因此其本身的白色填色維持關閉。在白紙上看起來一樣。 + Pages {pages} changed after the background was added, so their own white backdrops stay off. They look the same on white paper. 第 {pages} 頁在加入背景後有變更,因此它們本身的白色填色維持關閉。在白紙上看起來一樣。 PDF or image PDF 或圖片 No file chosen 未選擇檔案 Page number 頁碼 @@ -2268,6 +2280,11 @@ animation effects Off 關 @plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} 在它讀取的 {fields} 之前計算。 @plural {n} more field calculates too early.|{n} more fields calculate too early. 另有 {n} 個欄位計算過早。 @plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} 不在 PDF 中儲存的順序裡,其他 PDF 檢視器可能不會計算。 +@plural {n} formula reads a field the form doesn't have|{n} formulas read fields the form doesn't have {n} 個公式讀取了表單中不存在的欄位 +@plural {field} reads {fields}, which the form doesn't have.|{field} reads {fields}, which the form doesn't have. {field} 讀取表單中不存在的 {fields}。 +@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. 另有 {n} 個公式讀取了缺少的欄位。 +@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} 在執行時才組出部分欄位名稱,因此這些讀取不會被檢查。 +@plural {fields} is missing|{fields} are missing 缺少 {fields} Scan 掃描 Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. 在白紙上簽名,並在明亮、均勻的光線下拍照(使用閃光燈)。選擇相片或將其拖放到此處:PdfCraft 會找到簽名並去除紙張背景。 Choose photo… 選擇相片… @@ -2448,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 表單與簽名 @@ -2495,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 今天 @@ -2507,3 +2551,15 @@ animation effects Off 關 Rotate the selected pages clockwise 順時針旋轉所選頁面 Extract the selected pages… 擷取所選頁面… Delete the selected pages 刪除所選頁面 + +# 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 99a667cd43..326cd68ca4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -9,6 +9,15 @@ // The settings object in `persist` is one `json!` literal past the default limit of 128. #![recursion_limit = "256"] +/// PdfCraft as craft-update knows it, with its 128 px icon and accent colour: the update +/// prompt shows both, and an AppImage's menu entry gets the icon. +#[cfg(not(target_arch = "wasm32"))] +pub const UPDATE_APP: craft_update::app::App = craft_update::app::App { + icon_png: Some(include_bytes!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.pdfcraft.png")), + accent: Some([0xb7, 0x02, 0x28]), + ..craft_update::suite::PDFCRAFT +}; + use crate::control::Host as _; /// Translate an English UI string into the current language (see [`i18n`]). @@ -27,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; @@ -36,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; @@ -68,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}; @@ -77,6 +91,7 @@ use canvas::fling; use canvas::image_pick; use canvas::page_cursor; use canvas::page_pointer; +use canvas::page_router; pub use canvas::scroll_speed; use canvas::signature_drag; use canvas::wheel_boost; @@ -229,6 +244,7 @@ struct PendingOpen { after_open: Option, recovered: Option, then: Option<&'static str>, + display_override: Option<(canvas::PageLayout, bool)>, } pub struct PdfCraftApp { @@ -281,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, @@ -305,6 +323,8 @@ pub struct PdfCraftApp { pub flatten_fill_sign_on_save: bool, /// Point size for new Fill & Sign text and dates, kept across launches. pub fill_text_size: f64, + /// Colour for new Fill & Sign text and dates, kept across launches. + pub fill_text_color: [f64; 3], pub dialog: Option, /// How to ask for the latest release (the desktop app sets it; see `updates`). pub(crate) updates: updates::Updates, @@ -356,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). @@ -528,8 +557,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. @@ -569,6 +596,8 @@ pub struct PdfCraftApp { fonts_cjk: theme::CjkPreference, /// The window title last sent to the platform. pub window_title: String, + /// The web page's title last set: the window title, but always with the file's name. + pub page_title: String, /// The UI control channel, when enabled (`--control`; off by default). control: Option, /// A bookmark being renamed in the Bookmarks panel: (path, text so far). @@ -584,6 +613,8 @@ pub struct PdfCraftApp { pub boxes_draft: pageboxes::BoxesDraft, /// Header & footer / watermark / background dialog state. pub marks_draft: marks_ui::MarksDraft, + /// The Update dialogs' preview page without the marks they replace. + pub(crate) unmarked_page: marks_ui::UnmarkedPage, /// Export dialog settings. pub export_draft: export_ui::ExportDraft, /// A running export's progress. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b6dcc67858..49f2aeba1a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -256,9 +256,13 @@ fn english(labeled: Labeled<'_>) -> Option { } fn translate(english: &'static str) -> String { - // The bundled UI fonts do not all cover the menu's mathematical ellipsis. + // craft_menu's bar writes "⋯" (U+22EF, MIDLINE HORIZONTAL ELLIPSIS) itself, for the menu of + // titles that don't fit: a sentinel to translate, not an English phrase, and the only one of + // its four reserved labels no font this app ships or falls back to has a glyph for. + // MissingGlyphPolicy::Panic (egui 0.37) crashes on it. The ordinary ellipsis every other + // truncated label in this app already uses is in the bundled faces. if english == "\u{22ef}" { - return "...".to_owned(); + return "…".to_owned(); } tl!(english).to_owned() } @@ -662,6 +666,22 @@ mod tests { bar.find(command).unwrap_or_else(|| panic!("{} is in the menus", command.key())) } + /// The bar's own overflow-menu sentinel is not rendered as typed: no font this app ships or + /// falls back to has U+22EF, and egui 0.37's `MissingGlyphPolicy::Panic` crashes on it when + /// the menu bar is narrow enough to need it. The editing tests that narrow the window while a + /// paragraph is open reproduce this end to end. The other three reserved labels are plain + /// ASCII, so untranslated is safe for them. + #[test] + fn the_overflow_menu_sentinel_is_not_passed_through_untranslated() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let translated = translate("⋯"); + assert_ne!(translated, "⋯", "no installed font has this glyph"); + assert_eq!(translated, "…", "the ordinary ellipsis the rest of the app already uses"); + for label in ["Menu", "Scroll menu up", "Scroll menu down"] { + assert_eq!(translate(label), label, "plain ASCII reserved labels need no substitution"); + } + } + #[test] fn every_menu_command_is_listed_once_with_its_shortcut() { let (app, ctx) = app(&["a.pdf"]); 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/comments_panel.rs b/crates/ui-egui/src/panels/comments_panel.rs index 294d90f2b6..c549ed8e3e 100644 --- a/crates/ui-egui/src/panels/comments_panel.rs +++ b/crates/ui-egui/src/panels/comments_panel.rs @@ -3,7 +3,8 @@ //! Header "Comments N" (comments, as Acrobat counts them) with "N replies" under it and search; //! an "Add a comment" box that posts a sticky note on the current page; cards grouped by page. //! Selecting a card selects the comment on the page and opens its reply box and "…" menu (Edit, -//! Set status, Delete). Status replies show as a badge. +//! Set status, Delete), and gives each reply a "…" of its own (Edit, Copy text, Delete). Status +//! replies show as a badge. use std::collections::HashMap; @@ -114,13 +115,14 @@ fn any_contains<'a>(fields: impl IntoIterator, q: &str) -> bool pub(crate) fn show( ui: &mut egui::Ui, t: &Tokens, - info: &DocInfo, + doc: &pdfcraft_engine::Document, view: &mut DocView, prefs: &CommentPrefs, allowed: bool, nav: &mut Option