mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-06 02:21:45 +00:00
Merge origin/main (9ce6d89f) into feat/prc-reader
ATTRIBUTION.md regenerated with `cargo xtask assets --write` (255 on main + this branch's one asset). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
commit
43b95919bb
152 files changed
+4403
-95028
No files matched your search
@@ -3,6 +3,9 @@
|
||||
# signatures) and assets whose SHA-256 ATTRIBUTION.toml records (cargo xtask assets).
|
||||
* -text
|
||||
|
||||
# The craft-i18n pin: CI and the build read it as one SHA, so it stays LF everywhere (readers trim it too).
|
||||
craft-i18n.rev text eol=lf
|
||||
|
||||
# Not text at all: no textual diffs or merges either.
|
||||
*.pdf binary
|
||||
*.p12 binary
|
||||
|
||||
@@ -52,14 +52,14 @@ Anything else is forbidden, including assets whose licence you cannot prove from
|
||||
- path or URL, title, author;
|
||||
- source (URL, with version or commit);
|
||||
- SPDX licence, and the licence file in the repo;
|
||||
- SHA-256, except for translation catalogs (see below);
|
||||
- SHA-256;
|
||||
- how it is used.
|
||||
- **Translation catalogs** (`crates/ui-egui/src/i18n/*.tsv`, `kind = "translation"`) are project files under the project licence. Their entries carry no `sha256`: git history records every change and who made it. Every other kind (fonts, images, data) is hashed.
|
||||
- **Translations** live in craft-i18n (`locales/<code>/common.tsv` and `pdfcraft.tsv`), embedded at build time from the checkout pinned in `craft-i18n.rev`; PdfCraft keeps no catalogs. `ATTRIBUTION.toml` records them as one `[[build_input]]` (`kind = "translation"`); craft-i18n's own `ATTRIBUTION.md` credits the translators. Every `[[asset]]` (fonts, images, data) is hashed.
|
||||
- **`ATTRIBUTION.md`** is generated from it (`cargo xtask assets --write`). Never edit it by hand.
|
||||
- **`NOTICE`** keeps the licence notices that the licences require.
|
||||
- **`cargo xtask assets`** runs in `cargo xtask ci`. It fails if:
|
||||
- an asset file in the repository has no entry;
|
||||
- an entry's hash is missing or does not match (translation catalogs carry none);
|
||||
- an entry's hash is missing or does not match;
|
||||
- a licence is not on the allowlist;
|
||||
- a licence file is missing;
|
||||
- an image, icon or font names Adobe as author or source, except a font licensed under OFL-1.1 alone.
|
||||
|
||||
+4
-37
@@ -2,40 +2,12 @@
|
||||
|
||||
<!-- Generated from ATTRIBUTION.toml by `cargo xtask assets --write`. Do not edit by hand. -->
|
||||
|
||||
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset but the translation catalogs, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
|
||||
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
|
||||
|
||||
## In this repository (290)
|
||||
## In this repository (256)
|
||||
|
||||
| Asset | Title | Author | Licence | Source | Used for |
|
||||
|---|---|---|---|---|---|
|
||||
| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/fa.tsv` | Persian (Farsi) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed | Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC 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 |
|
||||
@@ -287,17 +259,11 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `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 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 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 |
|
||||
|
||||
## Compiled in through dependencies (6)
|
||||
|
||||
@@ -339,10 +305,11 @@ Fonts are fetched by `cargo xtask demo-pdf` into `target/demo-fonts/`, OCR model
|
||||
| `text-detection.onnx` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) |
|
||||
| `text-recognition.onnx` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) |
|
||||
|
||||
## Optional build inputs (1)
|
||||
## Optional build inputs (2)
|
||||
|
||||
Not in this repository and never downloaded by it: compiled in only when the build sets the option (official releases do). Each input attributes its own files.
|
||||
|
||||
| Input | Option | Title | Author | Licence | Source | Attribution | Used for |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `craft-i18n` | `CRAFT_I18N_DIR` | Interface translations: the craft-i18n catalogs (locales/<code>/common.tsv and pdfcraft.tsv) of the 34 languages PdfCraft offers besides English | The craft-i18n translators, including every translator of PdfCraft's former in-repo catalogs (crates/ui-egui/src/i18n/*.tsv, migrated at pdfcraft@ecdd1e6) | MIT OR Apache-2.0 | https://github.com/storytold/craft-i18n (pinned by commit in craft-i18n.rev, which CI, releases and the flatpak manifests read) | https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md | Interface text in languages other than English, embedded by crates/ui-egui/build.rs and looked up through craft-libs' craft-i18n crate; a build without CRAFT_I18N_DIR is English only. Clean-room translations from PdfCraft's English interface labels |
|
||||
| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1 |
|
||||
+12
-341
@@ -1,296 +1,15 @@
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/el.tsv"
|
||||
title = "Greek interface translation catalog"
|
||||
author = "Camthalion77 and contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
|
||||
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
|
||||
#
|
||||
# [[asset]] a file in this repository (committed or vendored); `sha256` must match the file.
|
||||
# Translation catalogs (kind = "translation", crates/ui-egui/src/i18n/*.tsv) are project files
|
||||
# under the project licence and carry no `sha256`: git history records their provenance.
|
||||
# [[bundled]] a file compiled into PdfCraft by a Cargo dependency (its crate version must match Cargo.lock).
|
||||
# [[fetched]] downloaded at build time by xtask (never committed), pinned by `sha256`.
|
||||
# [[build_input]] an optional build input outside this repository (e.g. craft-fonts via CRAFT_FONTS_DIR);
|
||||
# [[build_input]] an optional build input outside this repository (craft-fonts via CRAFT_FONTS_DIR, craft-i18n via CRAFT_I18N_DIR);
|
||||
# never committed or downloaded here, its own repository attributes each file.
|
||||
#
|
||||
# licence: SPDX expression from the allowlist in AGENTS.md §1.2. adobe_data = true marks the closed list of
|
||||
# Adobe-authored non-visual technical data allowed by AGENTS.md §1.1.
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/zh-hans.tsv"
|
||||
title = "Simplified Chinese interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
|
||||
title = "Traditional Chinese (Taiwan) interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/cs.tsv"
|
||||
title = "Czech interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pt-br.tsv"
|
||||
title = "Brazilian Portuguese interface translation catalog"
|
||||
author = "André Cardoso (andrecards), PdfCraft contributors"
|
||||
source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/es.tsv"
|
||||
title = "Spanish interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fr.tsv"
|
||||
title = "French interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/de.tsv"
|
||||
title = "German interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/lo.tsv"
|
||||
title = "Lao interface translation catalog"
|
||||
author = "@bounkirdni-2025"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/te.tsv"
|
||||
title = "Telugu interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/it.tsv"
|
||||
title = "Italian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pl.tsv"
|
||||
title = "Polish interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ru.tsv"
|
||||
title = "Russian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/hu.tsv"
|
||||
title = "Hungarian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/bg.tsv"
|
||||
title = "Bulgarian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/kk.tsv"
|
||||
title = "Kazakh interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ja.tsv"
|
||||
title = "Japanese interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/uk.tsv"
|
||||
title = "Ukrainian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ar.tsv"
|
||||
title = "Arabic interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ckb.tsv"
|
||||
title = "Central Kurdish (Sorani) interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fa.tsv"
|
||||
title = "Persian (Farsi) interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/tr.tsv"
|
||||
title = "Turkish interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/lt.tsv"
|
||||
title = "Lithuanian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/hi.tsv"
|
||||
title = "Hindi interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/id.tsv"
|
||||
title = "Indonesian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ko.tsv"
|
||||
title = "Korean interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ro.tsv"
|
||||
title = "Romanian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fi.tsv"
|
||||
title = "Finnish interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/accessibility.svg"
|
||||
title = "Lucide icon \"accessibility\""
|
||||
@@ -3392,6 +3111,17 @@ kind = "model"
|
||||
usage = "Scan & OCR: reads the words it finds (cargo xtask models)"
|
||||
sha256 = "86c145c2edb96c8caed5b1ebb8f44d706408922211451c309c625157dd6061c5"
|
||||
|
||||
[[build_input]]
|
||||
name = "craft-i18n"
|
||||
option = "CRAFT_I18N_DIR"
|
||||
title = "Interface translations: the craft-i18n catalogs (locales/<code>/common.tsv and pdfcraft.tsv) of the 34 languages PdfCraft offers besides English"
|
||||
author = "The craft-i18n translators, including every translator of PdfCraft's former in-repo catalogs (crates/ui-egui/src/i18n/*.tsv, migrated at pdfcraft@ecdd1e6)"
|
||||
source = "https://github.com/storytold/craft-i18n (pinned by commit in craft-i18n.rev, which CI, releases and the flatpak manifests read)"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
attribution = "https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md"
|
||||
kind = "translation"
|
||||
usage = "Interface text in languages other than English, embedded by crates/ui-egui/build.rs and looked up through craft-libs' craft-i18n crate; a build without CRAFT_I18N_DIR is English only. Clean-room translations from PdfCraft's English interface labels"
|
||||
|
||||
[[build_input]]
|
||||
name = "craft-fonts"
|
||||
option = "CRAFT_FONTS_DIR"
|
||||
@@ -3425,26 +3155,6 @@ kind = "icon"
|
||||
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
|
||||
sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/vi.tsv"
|
||||
title = "Vietnamese interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/sl.tsv"
|
||||
title = "Slovene interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
|
||||
path = "vendor/vello_common/assets/probe.png"
|
||||
@@ -3457,16 +3167,6 @@ kind = "image"
|
||||
usage = "Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file"
|
||||
sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/nb.tsv"
|
||||
title = "Norwegian Bokmål interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/superscript.svg"
|
||||
title = "Lucide icon \"superscript\""
|
||||
@@ -3511,32 +3211,3 @@ kind = "icon"
|
||||
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
|
||||
sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/sq.tsv"
|
||||
title = "Albanian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/th.tsv"
|
||||
title = "Thai interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ca.tsv"
|
||||
title = "Catalan interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n.rs"
|
||||
Generated
+37
-36
@@ -4208,7 +4208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-update",
|
||||
@@ -4232,7 +4232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-a11y"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4244,7 +4244,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-annot"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -4255,7 +4255,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-audit"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4266,7 +4266,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-automation"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"flate2",
|
||||
@@ -4288,7 +4288,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cli"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"pdfcraft-audit",
|
||||
@@ -4306,11 +4306,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-compare"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-content"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -4318,7 +4318,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cos"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hayro-syntax",
|
||||
@@ -4333,7 +4333,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-create"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"hayro-ccitt",
|
||||
@@ -4352,7 +4352,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-crypt"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"md-5",
|
||||
@@ -4364,7 +4364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-edit"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4382,7 +4382,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-engine"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
@@ -4426,14 +4426,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-export"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-filters"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
@@ -4446,7 +4446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-fonts"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"pdfcraft-cos",
|
||||
@@ -4456,7 +4456,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-forms"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4469,11 +4469,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-geom"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-js"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"chrono",
|
||||
@@ -4481,7 +4481,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-measure"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4494,14 +4494,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-model"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"pdfcraft-cos",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ocr"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"ocrs",
|
||||
"pdfcraft-fonts",
|
||||
@@ -4512,7 +4512,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-optimize"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"pdfcraft-annot",
|
||||
@@ -4528,11 +4528,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-oracle"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-organize"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4543,7 +4543,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-platform"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
@@ -4562,7 +4562,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"pdfcraft-content",
|
||||
@@ -4573,7 +4573,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-print"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-print",
|
||||
"pdfcraft-content",
|
||||
@@ -4585,7 +4585,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-redact"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4602,7 +4602,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-render"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"flate2",
|
||||
@@ -4621,7 +4621,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-sign"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aws-lc-rs",
|
||||
@@ -4657,17 +4657,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-testkit"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ui-egui"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"base64 0.23.1",
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"craft-i18n",
|
||||
"craft-keymap",
|
||||
"craft-menu",
|
||||
"craft-quit",
|
||||
@@ -4715,7 +4716,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-web"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4727,7 +4728,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfa"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"image",
|
||||
@@ -4741,7 +4742,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfdf"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-cos",
|
||||
|
||||
+38
-34
@@ -5,7 +5,7 @@ exclude = ["vendor"]
|
||||
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -19,6 +19,10 @@ craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0
|
||||
# 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 = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
|
||||
craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", default-features = false }
|
||||
# Interface translations: lookup, fallback, CLDR plurals and language names over the craft-i18n
|
||||
# catalogs that `crates/ui-egui/build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, at
|
||||
# the commit in `craft-i18n.rev`). Already in the tree through craft-ui.
|
||||
craft-i18n = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
|
||||
# Shared system spooler; document composition remains in pdfcraft-print.
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
|
||||
# Whether a path stored in a document may be opened (GoToR link targets).
|
||||
@@ -26,39 +30,39 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["egui"] }
|
||||
# internal
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.17.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.17.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.17.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.17.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.17.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.17.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.17.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.17.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.17.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.17.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.17.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.17.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.17.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.17.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.17.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.17.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.17.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.17.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.17.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.17.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.17.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.17.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.17.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.17.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.17.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.17.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.17.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.17.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.17.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.17.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.17.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.17.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.17.0", default-features = false }
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.18.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.18.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.18.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.18.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.18.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.18.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.18.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.18.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.18.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.18.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.18.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.18.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.18.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.18.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.18.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.18.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.18.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.18.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.18.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.18.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.18.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.18.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.18.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.18.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.18.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.18.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.18.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.18.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.18.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.18.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.18.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.18.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.18.0", default-features = false }
|
||||
|
||||
# pdf
|
||||
hayro = "0.7.1"
|
||||
|
||||
+14
@@ -1,5 +1,9 @@
|
||||
# PdfCraft roadmap
|
||||
|
||||
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 latest comments: explicit image-paste events, mixed-size field refresh, and documented mixed-script normalization with Undo; estimates unchanged)
|
||||
@@ -219,6 +223,14 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-11-03 (M5, #5850 review):** Resolve indirect IT when recognizing typewriter contents and Fill & Sign marks. Comment listing and property lookup retain their existing direct-Subtype behavior. A real double-click Edit composer preserves the typewriter’s text as written; ordinary comment edits keep trimming. Regressions cover direct Subtype with indirect intent, save/reopen, raw posted contents and same-index comments on different pages. The existing live-movement test now commits its setup with an outside click rather than Enter. Estimates unchanged.
|
||||
|
||||
- **2026-11-03 (M5, #5850):** Fill & Sign Enter inserts a line break in a growing editor; clicking outside commits and Escape cancels. Nonempty text keeps leading, internal and trailing line breaks. The headless fill_sign_add path sizes its typewriter box for the same wrapped lines so saved appearances show every row. Production-order keyboard and engine save/reopen/render regressions exercise the UI and headless callers. Estimates unchanged.
|
||||
|
||||
- **2026-11-03 (M7, #5835):** In image crop mode, a right-click on the image, its crop box or its handles keeps the crop and opens no menu, so the menu no longer switches as the pointer moves. A right-click elsewhere clears the selection and opens the page menu. UI tests cover all four page rotations, overlapping images, a 45° image, and a check that the Done/Cancel/Remove Crop pills at a page edge still keep the crop.
|
||||
|
||||
- **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer.
|
||||
|
||||
- **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged.
|
||||
|
||||
- **2026-11-01 (M14, #5572):** Preferences ▸ Documents and view sets how many recent files to keep, from 0 to 50 with 12 by default. At 0 Home has no Recent section and File ▸ Open Recent is disabled. The list is cut when Preferences closes, so the number can pass through 0 on its way to another.
|
||||
@@ -745,6 +757,8 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. |
|
||||
| 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. |
|
||||
| 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. |
|
||||
| 2026-10-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and estimates unchanged. |
|
||||
| 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. |
|
||||
|
||||
@@ -663,6 +663,29 @@ fn the_desktop_entry_names_the_window_class() {
|
||||
assert_eq!(class, Some(super::APP_ID));
|
||||
}
|
||||
|
||||
/// Both flatpak manifests build with the craft-i18n commit `craft-i18n.rev` pins: CI and
|
||||
/// releases read the file, and the manifests have to name the commit themselves. The pin may
|
||||
/// have a trailing newline or CRLF (a Windows checkout), so it is trimmed, as every reader does.
|
||||
#[test]
|
||||
fn the_flatpak_manifests_pin_the_craft_i18n_rev() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let rev = std::fs::read_to_string(root.join("craft-i18n.rev")).unwrap();
|
||||
let rev = rev.trim();
|
||||
assert!(rev.len() == 40 && rev.bytes().all(|b| b.is_ascii_hexdigit()), "craft-i18n.rev holds one full SHA: {rev:?}");
|
||||
for manifest in ["ai.storyteller.pdfcraft.yml", "ai.storyteller.pdfcraft.bundle.yml"] {
|
||||
let text = std::fs::read_to_string(root.join("packaging/linux/flatpak").join(manifest)).unwrap();
|
||||
let source =
|
||||
text.split("url: https://github.com/storytold/craft-i18n").nth(1).unwrap_or_else(|| panic!("{manifest}: a craft-i18n git source"));
|
||||
let source = source.split("- type:").next().unwrap_or(source);
|
||||
let commit = source
|
||||
.lines()
|
||||
.find_map(|l| l.trim().strip_prefix("commit:"))
|
||||
.unwrap_or_else(|| panic!("{manifest}: the craft-i18n source pins a commit"));
|
||||
assert_eq!(commit.trim().trim_matches(['\'', '"']), rev, "{manifest}");
|
||||
assert!(text.contains("CRAFT_I18N_DIR: /run/build/pdfcraft/craft-i18n"), "{manifest}: the build names the catalogs");
|
||||
}
|
||||
}
|
||||
|
||||
// Regression test for #246: `eprintln!` panics when standard error is a broken pipe (a GUI
|
||||
// launch on Windows), and a panic inside the panic hook aborts the process. The start-up
|
||||
// path and the hook must write to standard error without it.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
dfbe955b1ba4aa84bb34c80c45b9d917e8da4704
|
||||
+78
-92
@@ -125,9 +125,9 @@ pub(crate) fn elements(doc: &Document) -> Vec<Element> {
|
||||
}
|
||||
|
||||
/// The marked-content ids an element owns directly.
|
||||
fn mcids(d: &Dict) -> Vec<i64> {
|
||||
fn mcids(doc: &Document, d: &Dict) -> Vec<i64> {
|
||||
let mut out = Vec::new();
|
||||
let mut push = |o: &Object| match o {
|
||||
let mut push = |o: &Object| match &*doc.resolve(o) {
|
||||
Object::Int(n) => out.push(*n),
|
||||
Object::Dict(m) if m.name(b"Type") == Some(b"MCR") => {
|
||||
if let Some(n) = m.get(b"MCID").and_then(Object::as_int) {
|
||||
@@ -323,7 +323,7 @@ pub fn figures(doc: &Document) -> Vec<Figure> {
|
||||
let mut wanted: HashMap<usize, HashSet<i64>> = HashMap::new();
|
||||
for (e, page) in &figures {
|
||||
if let Some(p) = page {
|
||||
wanted.entry(*p).or_default().extend(mcids(&e.dict));
|
||||
wanted.entry(*p).or_default().extend(mcids(doc, &e.dict));
|
||||
}
|
||||
}
|
||||
let bounds: HashMap<usize, HashMap<i64, [f64; 4]>> =
|
||||
@@ -333,7 +333,7 @@ pub fn figures(doc: &Document) -> Vec<Figure> {
|
||||
.map(|(e, page)| {
|
||||
let alt = e.dict.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text()));
|
||||
let on_page = page.and_then(|p| bounds.get(&p));
|
||||
let bbox = on_page.and_then(|b| mcids(&e.dict).iter().filter_map(|id| b.get(id)).fold(None, |acc, b| Some(union(acc, *b))));
|
||||
let bbox = on_page.and_then(|b| mcids(doc, &e.dict).iter().filter_map(|id| b.get(id)).fold(None, |acc, b| Some(union(acc, *b))));
|
||||
Figure { obj: e.object, page, alt, bbox }
|
||||
})
|
||||
.collect()
|
||||
@@ -363,52 +363,73 @@ pub fn set_alt(doc: &mut Document, obj: ObjRef, alt: Option<&str>) -> Result<(),
|
||||
}
|
||||
|
||||
/// The page's content entries: one stream, or the elements of a contents array.
|
||||
fn content_entries(doc: &Document, page: &Dict) -> Option<Vec<Object>> {
|
||||
let c = page.get(b"Contents")?;
|
||||
Some(match &*doc.resolve(c) {
|
||||
fn content_entries(doc: &Document, page: &Dict) -> Vec<Object> {
|
||||
let Some(c) = page.get(b"Contents") else { return Vec::new() };
|
||||
match &*doc.resolve(c) {
|
||||
Object::Array(a) => a.clone(),
|
||||
Object::Stream(_) => vec![c.clone()],
|
||||
_ => return None,
|
||||
})
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn marked(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &HashSet<i64>) -> bool {
|
||||
op.op == b"BDC" && bdc_mcid(doc, props, op).is_some_and(|n| ids.contains(&n))
|
||||
}
|
||||
type DecodedStream = (Object, Option<(Stream, Vec<u8>)>);
|
||||
|
||||
/// Turn the figure's marked-content blocks into artifacts, rewriting only the streams that
|
||||
/// contain them. Other content streams, and unknown entries on a rewritten stream, stay.
|
||||
fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: &HashSet<i64>) -> Result<bool, AltError> {
|
||||
let Some(entries) = content_entries(doc, &page.dict) else { return Ok(false) };
|
||||
let props = page_properties(doc, &page.dict);
|
||||
let mut streams: Vec<Option<(Stream, Vec<u8>)>> = Vec::with_capacity(entries.len());
|
||||
/// Decode the page's content streams within the page decode budget. `None` when a stream is
|
||||
/// rejected because the budget is exhausted.
|
||||
fn decode_page_streams(doc: &Document, page: &Dict) -> Option<Vec<DecodedStream>> {
|
||||
let entries = content_entries(doc, page);
|
||||
let mut streams = Vec::with_capacity(entries.len());
|
||||
let mut budget = DecodeBudget::page();
|
||||
for entry in &entries {
|
||||
match &*doc.resolve(entry) {
|
||||
for entry in entries {
|
||||
let decoded = match &*doc.resolve(&entry) {
|
||||
Object::Stream(s) => {
|
||||
let data = match budget.decode(s) {
|
||||
Ok(data) => data,
|
||||
// Content past the budget may hold more of the figure: editing the rest
|
||||
// would leave that part marked as a figure the tree no longer has.
|
||||
Err(_) if budget.exhausted() => return Err(AltError::TooLarge),
|
||||
// Content past the budget may hold more of the tag: editing the rest
|
||||
// would leave that part marked as a tag the tree no longer has.
|
||||
Err(_) if budget.exhausted() => return None,
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
streams.push(Some((s.clone(), data)));
|
||||
Some((s.clone(), data))
|
||||
}
|
||||
_ => streams.push(None),
|
||||
_ => None,
|
||||
};
|
||||
streams.push((entry, decoded));
|
||||
}
|
||||
Some(streams)
|
||||
}
|
||||
|
||||
fn marked_id(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &HashSet<i64>) -> Option<i64> {
|
||||
(op.op == b"BDC").then(|| bdc_mcid(doc, props, op)).flatten().filter(|n| ids.contains(n))
|
||||
}
|
||||
|
||||
/// Turn the tag's marked-content blocks into artifacts, rewriting only the streams that
|
||||
/// contain them. Other content streams, and unknown entries on a rewritten stream, stay.
|
||||
/// With `require_all`, every id in `ids` must appear on the page before any stream is rewritten.
|
||||
fn artifact_marked_content(
|
||||
doc: &mut Document,
|
||||
page: &pdfcraft_model::Page,
|
||||
streams: Vec<DecodedStream>,
|
||||
ids: &HashSet<i64>,
|
||||
require_all: bool,
|
||||
) -> Result<bool, pdfcraft_cos::CosError> {
|
||||
let props = page_properties(doc, &page.dict);
|
||||
let decoded: Vec<&[u8]> = streams.iter().filter_map(|(_, s)| s.as_ref().map(|(_, bytes)| bytes.as_slice())).collect();
|
||||
let pieces = pdfcraft_content::Pieces::join(&decoded);
|
||||
let mut found = HashSet::new();
|
||||
let mut edits = Vec::new();
|
||||
for op in pieces.parse().ops {
|
||||
if let Some(id) = marked_id(doc, &props, &op, ids) {
|
||||
found.insert(id);
|
||||
edits.push((op.span, b"/Artifact BMC".to_vec()));
|
||||
}
|
||||
}
|
||||
let decoded: Vec<&[u8]> = streams.iter().flatten().map(|(_, bytes)| bytes.as_slice()).collect();
|
||||
let pieces = pdfcraft_content::Pieces::join(&decoded);
|
||||
let edits: Vec<_> =
|
||||
pieces.parse().ops.into_iter().filter(|op| marked(doc, &props, op, ids)).map(|op| (op.span, b"/Artifact BMC".to_vec())).collect();
|
||||
if edits.is_empty() {
|
||||
if if require_all { found != *ids } else { found.is_empty() } {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut spliced = pieces.splice(edits).into_iter();
|
||||
let mut next = Vec::with_capacity(entries.len());
|
||||
let mut changed = false;
|
||||
for (entry, stream) in entries.into_iter().zip(streams) {
|
||||
let mut next = Vec::with_capacity(streams.len());
|
||||
for (entry, stream) in streams {
|
||||
let Some((stream, old)) = stream else {
|
||||
next.push(entry);
|
||||
continue;
|
||||
@@ -421,7 +442,6 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids:
|
||||
next.push(entry);
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
let mut dict = stream.dict;
|
||||
dict.remove(b"Length");
|
||||
dict.remove(b"Filter");
|
||||
@@ -431,28 +451,27 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids:
|
||||
// appear elsewhere in this array or on another page, where it must stay unchanged.
|
||||
next.push(Object::Ref(doc.add(stream)));
|
||||
}
|
||||
if changed {
|
||||
let contents = match next.as_slice() {
|
||||
[one] => one.clone(),
|
||||
_ => Object::Array(next),
|
||||
};
|
||||
doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), contents))?;
|
||||
}
|
||||
Ok(changed)
|
||||
let contents = match next.as_slice() {
|
||||
[one] => one.clone(),
|
||||
_ => Object::Array(next),
|
||||
};
|
||||
doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), contents))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Decorative figure: its marked content on the page becomes an artifact, and the element
|
||||
/// leaves the structure tree (and the parent tree).
|
||||
pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError> {
|
||||
let d = check_figure(doc, obj)?;
|
||||
let ids: HashSet<i64> = mcids(&d).into_iter().collect();
|
||||
let ids: HashSet<i64> = mcids(doc, &d).into_iter().collect();
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|e| e.object == obj).and_then(|e| e.page));
|
||||
if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty())
|
||||
&& artifact_marked_content(doc, page, &ids)?
|
||||
{
|
||||
if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) {
|
||||
let streams = decode_page_streams(doc, &page.dict).ok_or(AltError::TooLarge)?;
|
||||
// The parent tree no longer points those ids at the figure.
|
||||
if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) {
|
||||
if artifact_marked_content(doc, page, streams, &ids, false)?
|
||||
&& let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int)
|
||||
{
|
||||
clear_parent_tree(doc, key, obj)?;
|
||||
}
|
||||
}
|
||||
@@ -484,54 +503,23 @@ pub fn mark_tag_artifact(doc: &mut Document, obj: ObjRef) -> Result<(), Artifact
|
||||
if has_child_structure(doc, &element.dict) {
|
||||
return Err(ArtifactError::HasChildren);
|
||||
}
|
||||
let ids: HashSet<i64> = mcids(&element.dict).into_iter().collect();
|
||||
let ids: HashSet<i64> = mcids(doc, &element.dict).into_iter().collect();
|
||||
if ids.is_empty() {
|
||||
return Err(ArtifactError::NoMarkedContent);
|
||||
}
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
let page_ref = element.page.ok_or(ArtifactError::NoPage)?;
|
||||
let page = pages.iter().find(|page| page.obj == page_ref).ok_or(ArtifactError::NoPage)?;
|
||||
let mut content = Vec::new();
|
||||
if let Some(c) = page.dict.get(b"Contents") {
|
||||
let mut budget = DecodeBudget::page();
|
||||
for data in budget.decode_contents(doc, c) {
|
||||
match data {
|
||||
Ok(data) => {
|
||||
content.extend_from_slice(&data);
|
||||
content.push(b' ');
|
||||
}
|
||||
// The page's /Contents is replaced with what was decoded: content past the
|
||||
// budget would be dropped from the page.
|
||||
Err(_) if budget.exhausted() => return Err(ArtifactError::TooLarge),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ops = pdfcraft_content::parse(&content).ops;
|
||||
let mut found = HashSet::new();
|
||||
for op in &mut ops {
|
||||
let id = (op.op == b"BDC")
|
||||
.then(|| match op.operands.get(1) {
|
||||
Some(Object::Dict(mark)) => mark.get(b"MCID").and_then(Object::as_int),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.filter(|id| ids.contains(id));
|
||||
if let Some(id) = id {
|
||||
*op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]);
|
||||
found.insert(id);
|
||||
}
|
||||
}
|
||||
if found != ids {
|
||||
let parent = element.dict.get(b"P").and_then(Object::as_ref).ok_or(ArtifactError::NoParent)?;
|
||||
let replacement = replacement_kids(doc, parent, obj)?;
|
||||
let streams = decode_page_streams(doc, &page.dict).ok_or(ArtifactError::TooLarge)?;
|
||||
if !artifact_marked_content(doc, page, streams, &ids, true)? {
|
||||
return Err(ArtifactError::ContentNotFound);
|
||||
}
|
||||
let stream = doc.add(Object::Stream(Stream::flate(Dict::new(), &pdfcraft_content::serialize_ops(&ops))));
|
||||
doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), Object::Ref(stream)))?;
|
||||
if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) {
|
||||
clear_parent_tree(doc, key, obj).map_err(|e| ArtifactError::ParentTree(e.to_string()))?;
|
||||
}
|
||||
let parent = element.dict.get(b"P").and_then(Object::as_ref).ok_or(ArtifactError::NoParent)?;
|
||||
remove_from_parent(doc, parent, obj)?;
|
||||
doc.update_dict(parent, |p| p.set(b"K".to_vec(), replacement))?;
|
||||
// Keep the retired object valid if another object holds an out-of-tree reference to it.
|
||||
doc.update_dict(obj, |d| {
|
||||
d.remove(b"P");
|
||||
@@ -551,20 +539,18 @@ fn has_child_structure(doc: &Document, tag: &Dict) -> bool {
|
||||
tag.get(b"K").is_some_and(|items| child(doc, items))
|
||||
}
|
||||
|
||||
fn remove_from_parent(doc: &mut Document, parent: ObjRef, obj: ObjRef) -> Result<(), ArtifactError> {
|
||||
fn replacement_kids(doc: &Document, parent: ObjRef, obj: ObjRef) -> Result<Object, ArtifactError> {
|
||||
let kids = doc.get(parent).as_dict().and_then(|p| p.get(b"K").cloned()).ok_or(ArtifactError::NoParent)?;
|
||||
let replacement = match kids {
|
||||
match kids {
|
||||
Object::Array(items) => {
|
||||
if items.iter().filter(|item| item.as_ref() == Some(obj)).count() != 1 {
|
||||
return Err(ArtifactError::AmbiguousParent);
|
||||
}
|
||||
Object::Array(items.into_iter().filter(|item| item.as_ref() != Some(obj)).collect())
|
||||
Ok(Object::Array(items.into_iter().filter(|item| item.as_ref() != Some(obj)).collect()))
|
||||
}
|
||||
Object::Ref(r) if r == obj => Object::Array(Vec::new()),
|
||||
_ => return Err(ArtifactError::NoParent),
|
||||
};
|
||||
doc.update_dict(parent, |p| p.set(b"K".to_vec(), replacement))?;
|
||||
Ok(())
|
||||
Object::Ref(r) if r == obj => Ok(Object::Array(Vec::new())),
|
||||
_ => Err(ArtifactError::NoParent),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace `obj` by null in the page's parent-tree array (`key`), in a flat /Nums tree.
|
||||
|
||||
@@ -1017,6 +1017,76 @@ fn named_property_lists_are_found_and_marked_decorative_like_inline_ones() {
|
||||
assert!(props.contains(b"P0") && props.contains(b"P1") && props.contains(b"Other"), "resources are left as they were");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_tag_artifact_finds_named_property_lists_and_indirect_mcrs() {
|
||||
use pdfcraft_cos::{Dict, ObjRef, Object};
|
||||
let mut doc = named_figures_doc();
|
||||
let mcr = doc.add(Object::Dict([(b"Type".to_vec(), Object::name("MCR")), (b"MCID".to_vec(), Object::Int(0))].into_iter().collect::<Dict>()));
|
||||
doc.update_dict(ObjRef::new(7, 0), |d| d.set(b"K".to_vec(), Object::Ref(mcr))).unwrap();
|
||||
assert_eq!(figures(&doc)[0].bbox, Some([10.0, 150.0, 50.0, 170.0]), "indirect MCR is resolved for figure bounds");
|
||||
|
||||
for (tag, gone) in [(ObjRef::new(7, 0), "/P0"), (ObjRef::new(8, 0), "/P1")] {
|
||||
mark_tag_artifact(&mut doc, tag).unwrap();
|
||||
let pages = pdfcraft_model::pages(&doc);
|
||||
let c = doc.resolve(pages[0].dict.get(b"Contents").unwrap());
|
||||
let Object::Stream(c) = &*c else { panic!("one content stream") };
|
||||
let text = String::from_utf8(c.decoded().unwrap()).unwrap();
|
||||
assert!(text.contains("/Artifact BMC") && !text.contains(gone), "{text}");
|
||||
}
|
||||
assert_eq!(tags(&doc).into_iter().map(|t| t.object.num).collect::<Vec<_>>(), [6]);
|
||||
let nums = doc.get(ObjRef::new(9, 0)).as_dict().and_then(|d| d.get(b"Nums").cloned());
|
||||
assert_eq!(nums, Some(Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Null, Object::Null])])));
|
||||
let r = check(&doc, &Options { rules: [Rule::FiguresAltText, Rule::TaggedContent].into(), pages: None });
|
||||
assert_eq!(failed(&r), Vec::<Rule>::new(), "{:?}", r.results);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_tag_artifact_validates_parent_and_all_mcids_before_mutating_document() {
|
||||
use pdfcraft_cos::{ObjRef, Object};
|
||||
|
||||
// Missing /P on the tag must fail before touching /Contents or /ParentTree.
|
||||
let mut no_parent = figures_doc();
|
||||
let before_contents = no_parent.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned();
|
||||
let before_pt = no_parent.get(ObjRef::new(9, 0)).as_ref().clone();
|
||||
no_parent
|
||||
.update_dict(ObjRef::new(7, 0), |d| {
|
||||
d.remove(b"P");
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(mark_tag_artifact(&mut no_parent, ObjRef::new(7, 0)), Err(ArtifactError::NoParent));
|
||||
assert_eq!(no_parent.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents);
|
||||
assert_eq!(*no_parent.get(ObjRef::new(9, 0)), before_pt);
|
||||
|
||||
// Duplicate entry in parent's /K must fail with AmbiguousParent before touching /Contents or /ParentTree.
|
||||
let mut dup_parent = figures_doc();
|
||||
dup_parent
|
||||
.update_dict(ObjRef::new(6, 0), |d| {
|
||||
d.set(b"K".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0)), Object::Ref(ObjRef::new(7, 0))]));
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(mark_tag_artifact(&mut dup_parent, ObjRef::new(7, 0)), Err(ArtifactError::AmbiguousParent));
|
||||
assert_eq!(dup_parent.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents);
|
||||
assert_eq!(*dup_parent.get(ObjRef::new(9, 0)), before_pt);
|
||||
|
||||
// Partial MCID match (MCID 0 present, MCID 99 absent) must fail with ContentNotFound in
|
||||
// mark_tag_artifact without mutating /Contents, while mark_decorative still converts MCID 0.
|
||||
let mut partial = figures_doc();
|
||||
partial.update_dict(ObjRef::new(7, 0), |d| d.set(b"K".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(99)]))).unwrap();
|
||||
assert_eq!(mark_tag_artifact(&mut partial, ObjRef::new(7, 0)), Err(ArtifactError::ContentNotFound));
|
||||
assert_eq!(partial.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents);
|
||||
assert_eq!(*partial.get(ObjRef::new(9, 0)), before_pt);
|
||||
mark_decorative(&mut partial, ObjRef::new(7, 0)).unwrap();
|
||||
assert_ne!(partial.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents);
|
||||
assert_ne!(*partial.get(ObjRef::new(9, 0)), before_pt);
|
||||
|
||||
// When none of a figure's MCIDs appear on the page, mark_decorative leaves /Contents and /ParentTree untouched.
|
||||
let mut absent = figures_doc();
|
||||
absent.update_dict(ObjRef::new(7, 0), |d| d.set(b"K".to_vec(), Object::Int(99))).unwrap();
|
||||
mark_decorative(&mut absent, ObjRef::new(7, 0)).unwrap();
|
||||
assert_eq!(absent.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents").cloned(), before_contents);
|
||||
assert_eq!(*absent.get(ObjRef::new(9, 0)), before_pt);
|
||||
}
|
||||
|
||||
fn ref_(n: u32) -> Option<pdfcraft_cos::ObjRef> {
|
||||
Some(pdfcraft_cos::ObjRef::new(n, 0))
|
||||
}
|
||||
|
||||
@@ -99,14 +99,20 @@ fn shown_strings(ops: &[Op]) -> Vec<String> {
|
||||
|
||||
#[test]
|
||||
fn mark_decorative_leaves_the_other_stream_untouched() {
|
||||
let mut doc = tagged_figure_two_streams("0 0 1 rg 300 600 100 50 re f", "BT /F1 12 Tf 72 500 Td (After) Tj ET");
|
||||
let before = doc.get(ObjRef::new(11, 0)).clone();
|
||||
mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds");
|
||||
assert!(figures(&doc).is_empty());
|
||||
assert_eq!(*doc.get(ObjRef::new(11, 0)), *before, "the second stream holds no figure and keeps its object");
|
||||
let page = doc.get(ObjRef::new(3, 0));
|
||||
let contents = page.as_dict().unwrap().get(b"Contents").unwrap().as_array().unwrap();
|
||||
assert_eq!(contents[1].as_ref(), Some(ObjRef::new(11, 0)), "the untouched stream must not be replaced by a re-encoded copy");
|
||||
for use_artifact in [false, true] {
|
||||
let mut doc = tagged_figure_two_streams("0 0 1 rg 300 600 100 50 re f", "BT /F1 12 Tf 72 500 Td (After) Tj ET");
|
||||
let before = doc.get(ObjRef::new(11, 0)).clone();
|
||||
if use_artifact {
|
||||
mark_tag_artifact(&mut doc, ObjRef::new(8, 0)).expect("mark tag artifact succeeds");
|
||||
} else {
|
||||
mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds");
|
||||
}
|
||||
assert!(figures(&doc).is_empty());
|
||||
assert_eq!(*doc.get(ObjRef::new(11, 0)), *before, "the second stream holds no figure and keeps its object");
|
||||
let page = doc.get(ObjRef::new(3, 0));
|
||||
let contents = page.as_dict().unwrap().get(b"Contents").unwrap().as_array().unwrap();
|
||||
assert_eq!(contents[1].as_ref(), Some(ObjRef::new(11, 0)), "the untouched stream must not be replaced by a re-encoded copy");
|
||||
}
|
||||
}
|
||||
|
||||
/// A and B supply different operands to R's BDC. Reusing R must not let the first
|
||||
|
||||
@@ -47,8 +47,12 @@ pub(crate) fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option<String>
|
||||
Object::Name(n) => String::from_utf8_lossy(n).into_owned(),
|
||||
_ => return None,
|
||||
};
|
||||
let s = s.trim_matches('\0').trim().to_string();
|
||||
(!s.is_empty()).then_some(s)
|
||||
let s = s.trim_matches('\0');
|
||||
// Typewriter outer whitespace belongs to the text (keep in step with render's inspector).
|
||||
let name_is = |key: &[u8], expected: &[u8]| d.get(key).is_some_and(|o| doc.resolve(o).as_name() == Some(expected));
|
||||
let keep_breaks = key == b"Contents" && name_is(b"Subtype", b"FreeText") && name_is(b"IT", b"FreeTextTypeWriter");
|
||||
let s = if keep_breaks { s } else { s.trim() };
|
||||
(!s.trim().is_empty()).then(|| s.to_owned())
|
||||
}
|
||||
|
||||
/// Whether an annotation subtype is a comment. Links, form widgets, pop-ups and the non-markup
|
||||
|
||||
@@ -137,11 +137,11 @@ pub fn is_fill_sign(doc: &Document, d: &Dict) -> bool {
|
||||
if matches!(d.get(b"PCFillSign").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) {
|
||||
return true;
|
||||
}
|
||||
let subtype = d.name(b"Subtype").unwrap_or_default();
|
||||
if subtype == b"FreeText" && d.name(b"IT") == Some(b"FreeTextTypeWriter") {
|
||||
let name_is = |key: &[u8], expected: &[u8]| d.get(key).is_some_and(|o| doc.resolve(o).as_name() == Some(expected));
|
||||
if name_is(b"Subtype", b"FreeText") && name_is(b"IT", b"FreeTextTypeWriter") {
|
||||
return true;
|
||||
}
|
||||
if subtype == b"Stamp" {
|
||||
if name_is(b"Subtype", b"Stamp") {
|
||||
if matches!(d.name(b"Name"), Some(b"PCCheck" | b"PCCross" | b"PCDot" | b"PCLine" | b"PCTypedSignature")) {
|
||||
return true;
|
||||
}
|
||||
@@ -151,7 +151,7 @@ pub fn is_fill_sign(doc: &Document, d: &Dict) -> bool {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
subtype == b"Ink" && text_value(doc, d, b"Subj").as_deref() == Some("Signature")
|
||||
name_is(b"Subtype", b"Ink") && text_value(doc, d, b"Subj").as_deref() == Some("Signature")
|
||||
}
|
||||
|
||||
pub(crate) fn annotation_flags(doc: &Document, d: &Dict) -> i64 {
|
||||
|
||||
@@ -2250,3 +2250,27 @@ fn freehand_highlight_is_compatible_ink_with_marker_blending() {
|
||||
assert!((a - b).abs() < 1e-4, "expected [10, 90, 130, 110], got {rect_from_border:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indirect_typewriter_names_keep_outer_whitespace_in_summaries() {
|
||||
let mut doc = fixture();
|
||||
let intent = Object::Ref(doc.add(Object::name("FreeTextTypeWriter")));
|
||||
let page = page_refs(&doc).unwrap()[0];
|
||||
let mut entries = annots(&doc, page);
|
||||
let Object::Dict(annotation) = &mut entries[0] else { panic!("fixture annotation is inline") };
|
||||
annotation.set(b"Subtype".to_vec(), Object::name("FreeText"));
|
||||
annotation.set(b"IT".to_vec(), intent);
|
||||
let contents = " \nAda\n\nLovelace\n\n ";
|
||||
annotation.set(b"Contents".to_vec(), PdfString::text(contents));
|
||||
set_annots(&mut doc, page, entries).unwrap();
|
||||
let check = |doc: &Document| {
|
||||
let list = summaries(doc);
|
||||
let summary = list.iter().find(|a| a.page == 0 && a.index == 0).expect("direct Subtype is listed");
|
||||
assert_eq!(summary.contents.as_deref(), Some(contents), "indirect typewriter intent preserves outer whitespace");
|
||||
assert_eq!(summary.subtype, "FreeText");
|
||||
assert_eq!(summary.intent.as_deref(), Some("FreeTextTypeWriter"));
|
||||
assert!(summary.fill_sign, "indirect intent still identifies a Fill & Sign mark");
|
||||
};
|
||||
check(&doc);
|
||||
check(&reopen(&doc));
|
||||
}
|
||||
+19
-18
@@ -15,7 +15,7 @@
|
||||
//! the joined stream)`; rectangles inside a form XObject carry the `Do` operator's position.
|
||||
//! Positions compare lexicographically in paint order.
|
||||
|
||||
use pdfcraft_content::{Matrix, Op, Pieces, parse};
|
||||
use pdfcraft_content::{Matrix, Op, Pieces, ops};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
|
||||
|
||||
use crate::AuditError;
|
||||
@@ -123,12 +123,13 @@ impl<'a> Walker<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walk one operator slice with the given graphics state. `at_do` is the `Do` position to
|
||||
/// Walk one stream's operators, read one at a time (a stream of millions of short operators
|
||||
/// is never held whole), with the given graphics state. `at_do` is the `Do` position to
|
||||
/// stamp on rectangles found inside a form XObject, or `None` at page level (where each
|
||||
/// fill operator's own position is used).
|
||||
fn walk_ops(
|
||||
&mut self,
|
||||
ops: &[Op],
|
||||
ops: impl Iterator<Item = Op>,
|
||||
gs: &mut Gs,
|
||||
stack: &mut Vec<Gs>,
|
||||
resources: &Dict,
|
||||
@@ -136,8 +137,9 @@ impl<'a> Walker<'a> {
|
||||
at_do: Option<(usize, usize)>,
|
||||
) -> Result<(), AuditError> {
|
||||
let mut path = Path::Empty;
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
for (i, op) in ops.enumerate() {
|
||||
self.bump()?;
|
||||
let op = &op;
|
||||
// Paint position of this operator: its own `(piece, joined index)` at page level,
|
||||
// the enclosing `Do`'s position inside a form XObject.
|
||||
let pos = match at_do {
|
||||
@@ -257,8 +259,9 @@ impl<'a> Walker<'a> {
|
||||
/// changes inside never leak out; every rectangle found is stamped with the `Do`
|
||||
/// position `pos`.
|
||||
fn walk_xobject(&mut self, name: &[u8], gs: &Gs, resources: &Dict, depth: usize, pos: (usize, usize)) -> Result<(), AuditError> {
|
||||
let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
|
||||
let Some(entry) = xobjects.get(name) else { return Ok(()) };
|
||||
// Borrowed, not copied: a `Do` must not cost the size of the whole XObject table.
|
||||
let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o));
|
||||
let Some(entry) = xobjects.as_deref().and_then(Object::as_dict).and_then(|d| d.get(name)) else { return Ok(()) };
|
||||
let resolved = self.doc.resolve(entry);
|
||||
let Object::Stream(stream) = &*resolved else { return Ok(()) };
|
||||
let dict = &stream.dict;
|
||||
@@ -274,18 +277,18 @@ impl<'a> Walker<'a> {
|
||||
(v.len() == 6 && v.iter().all(|x| x.is_finite())).then(|| Matrix([v[0], v[1], v[2], v[3], v[4], v[5]]))
|
||||
})
|
||||
.unwrap_or(Matrix::IDENTITY);
|
||||
let sub_resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
|
||||
let (sub_resources, empty) = (dict.get(b"Resources").map(|o| self.doc.resolve(o)), Dict::new());
|
||||
let sub_resources = sub_resources.as_deref().and_then(Object::as_dict).unwrap_or(&empty);
|
||||
if !self.budget.enter_form() {
|
||||
return Err(AuditError::TooManyForms);
|
||||
}
|
||||
// A form stream that cannot be decoded fails the audit: it might hide a cover.
|
||||
let data = self.budget.decode_strict(stream)?;
|
||||
let ops = parse(&data).ops;
|
||||
// The XObject matrix maps form space into the user space active at `Do`.
|
||||
let mut sub_gs = gs.clone();
|
||||
sub_gs.ctm = matrix.then(&gs.ctm);
|
||||
let mut sub_stack = Vec::new();
|
||||
self.walk_ops(&ops, &mut sub_gs, &mut sub_stack, &sub_resources, depth + 1, Some(pos))
|
||||
self.walk_ops(ops(&data), &mut sub_gs, &mut sub_stack, sub_resources, depth + 1, Some(pos))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,13 +301,10 @@ fn user_point(gs: &Gs, op: &Op) -> Option<[f64; 2]> {
|
||||
|
||||
/// The non-stroking alpha (`/ca`) of a named ExtGState, defaulting to opaque.
|
||||
fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 {
|
||||
let alpha = resources
|
||||
.get(b"ExtGState")
|
||||
.map(|o| doc.resolve(o))
|
||||
.and_then(|o| o.as_dict().cloned())
|
||||
.and_then(|d| d.get(name).map(|o| doc.resolve(o)))
|
||||
.and_then(|o| o.as_dict().cloned())
|
||||
.and_then(|d| d.get(b"ca").map(|o| doc.resolve(o)).and_then(|o| o.as_f64()));
|
||||
// Borrowed, not copied: a `gs` must not cost the size of the whole ExtGState table.
|
||||
let states = resources.get(b"ExtGState").map(|o| doc.resolve(o));
|
||||
let state = states.as_deref().and_then(Object::as_dict).and_then(|d| d.get(name)).map(|o| doc.resolve(o));
|
||||
let alpha = state.as_deref().and_then(Object::as_dict).and_then(|d| d.get(b"ca").map(|o| doc.resolve(o)).and_then(|o| o.as_f64()));
|
||||
alpha.filter(|a| a.is_finite()).map(|a| a.clamp(0.0, 1.0)).unwrap_or(1.0)
|
||||
}
|
||||
|
||||
@@ -330,10 +330,11 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result<Vec<FilledRect>, Audi
|
||||
}
|
||||
}
|
||||
let pieces = Pieces::join(&streams);
|
||||
let parsed = pieces.parse();
|
||||
let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0, budget };
|
||||
let mut gs = Gs::default();
|
||||
let mut stack = Vec::new();
|
||||
w.walk_ops(&parsed.ops, &mut gs, &mut stack, &resources, 0, None)?;
|
||||
// Read one operator at a time: the joined stream can hold millions of them within the
|
||||
// decode budget, and the walk only ever needs the current one.
|
||||
w.walk_ops(ops(pieces.data()), &mut gs, &mut stack, &resources, 0, None)?;
|
||||
Ok(w.out)
|
||||
}
|
||||
+38
-19
@@ -99,6 +99,9 @@ const COVER_FRACTION: f64 = 0.4;
|
||||
const WORD_FRACTION: f64 = 0.5;
|
||||
/// WCAG contrast ratio below which the text is unreadable against the cover.
|
||||
const COVER_CONTRAST: f64 = 3.0;
|
||||
/// Bytes of a hidden line's text read (copied or trimmed) that cost one comparison: a byte scan
|
||||
/// is far cheaper than a box comparison, but a long padded line under many covers adds up.
|
||||
const TEXT_BYTES_PER_COMPARISON: usize = 16;
|
||||
|
||||
/// Audit every page for faux redactions and unapplied redaction marks. Fails closed: any
|
||||
/// content that cannot be decoded, or that needs more work or report than the audit's bounds, is
|
||||
@@ -157,15 +160,24 @@ impl Budget {
|
||||
cover: &[f64; 4],
|
||||
hides: impl Fn(&TextLine) -> bool,
|
||||
) -> Result<Option<String>, AuditError> {
|
||||
self.comparisons = self.comparisons.saturating_add(self.page_cost);
|
||||
if self.comparisons > self.limits.comparisons {
|
||||
return Err(AuditError::TooManyComparisons);
|
||||
}
|
||||
self.spend(self.page_cost)?;
|
||||
if !copying {
|
||||
return Ok(lines.iter().any(|line| hides(line) && !line.text.trim().is_empty()).then(String::new));
|
||||
let mut hidden = false;
|
||||
for line in lines.iter().filter(|l| hides(l)) {
|
||||
// Trimming walks the line's text (padding and all): it costs its bytes.
|
||||
self.spend(line.text.len() / TEXT_BYTES_PER_COMPARISON)?;
|
||||
if !line.text.trim().is_empty() {
|
||||
hidden = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Ok(hidden.then(String::new));
|
||||
}
|
||||
let mut covered = String::new();
|
||||
for line in lines.iter().filter(|l| hides(l)) {
|
||||
// Copying and trimming the line walks its text once per cover: a long padded
|
||||
// line under many covers would otherwise be read far more than its runs suggest.
|
||||
self.spend(line.text.len() / TEXT_BYTES_PER_COMPARISON)?;
|
||||
if !covered.is_empty() {
|
||||
covered.push(' ');
|
||||
}
|
||||
@@ -174,11 +186,21 @@ impl Budget {
|
||||
Ok((!covered.trim().is_empty()).then_some(covered))
|
||||
}
|
||||
|
||||
/// `n` units of work done against the comparison bound.
|
||||
fn spend(&mut self, n: usize) -> Result<(), AuditError> {
|
||||
self.comparisons = self.comparisons.saturating_add(n);
|
||||
if self.comparisons > self.limits.comparisons {
|
||||
return Err(AuditError::TooManyComparisons);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The text a finding reports for `covered`: none when copying is withheld. Charges what it
|
||||
/// reports against the output bound.
|
||||
/// reports, and the finding's own record, against the output bound: a finding with no text
|
||||
/// (copying withheld, or a shared annotation list on many pages) still takes memory.
|
||||
fn report_text(&mut self, covered: String, copying: bool) -> Result<String, AuditError> {
|
||||
let reported = if copying { covered } else { String::new() };
|
||||
self.covered_text = self.covered_text.saturating_add(reported.len());
|
||||
self.covered_text = self.covered_text.saturating_add(reported.len()).saturating_add(std::mem::size_of::<AuditFinding>());
|
||||
if self.covered_text > self.limits.covered_text {
|
||||
return Err(AuditError::TooMuchOutput);
|
||||
}
|
||||
@@ -321,17 +343,14 @@ enum AnnotationCover {
|
||||
fn annots(doc: &Document, page: &Dict) -> Result<Vec<AnnotationCover>, AuditError> {
|
||||
let Some(a) = page.get(b"Annots") else { return Ok(Vec::new()) };
|
||||
let resolved = doc.resolve(a);
|
||||
let items: Vec<pdfcraft_cos::Object> = match &*resolved {
|
||||
pdfcraft_cos::Object::Array(items) => items.clone(),
|
||||
// A missing /Annots means no marks; a malformed one fails the audit.
|
||||
_ => return Err(AuditError::BadAnnots),
|
||||
};
|
||||
// A missing /Annots means no marks; a malformed one fails the audit.
|
||||
let Some(items) = resolved.as_array() else { return Err(AuditError::BadAnnots) };
|
||||
let mut out = Vec::new();
|
||||
for a in &items {
|
||||
let dict = match &*doc.resolve(a) {
|
||||
pdfcraft_cos::Object::Dict(d) => d.clone(),
|
||||
_ => continue,
|
||||
};
|
||||
for a in items {
|
||||
// Borrowed from the resolved object: one large dictionary listed many times is not
|
||||
// copied once per listing.
|
||||
let resolved = doc.resolve(a);
|
||||
let Some(dict) = resolved.as_dict() else { continue };
|
||||
let v: Vec<f64> = match dict.get(b"Rect") {
|
||||
None => continue,
|
||||
Some(r) => doc.resolve(r).as_array().map(|arr| arr.iter().filter_map(|o| doc.resolve(o).as_f64()).collect()).unwrap_or_default(),
|
||||
@@ -340,7 +359,7 @@ fn annots(doc: &Document, page: &Dict) -> Result<Vec<AnnotationCover>, AuditErro
|
||||
continue;
|
||||
}
|
||||
let rect = [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])];
|
||||
match subtype(doc, &dict)?.as_deref() {
|
||||
match subtype(doc, dict)?.as_deref() {
|
||||
Some(b"Redact") => out.push(AnnotationCover::Redact(rect)),
|
||||
Some(b"Square") => {
|
||||
let flags = dict.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
|
||||
@@ -349,7 +368,7 @@ fn annots(doc: &Document, page: &Dict) -> Result<Vec<AnnotationCover>, AuditErro
|
||||
}
|
||||
let alpha = dict.get(b"CA").map(|o| doc.resolve(o)).and_then(|o| o.as_f64()).unwrap_or(1.0);
|
||||
if alpha >= 1.0
|
||||
&& let Some(rgb) = annotation_color(doc, &dict, b"IC")
|
||||
&& let Some(rgb) = annotation_color(doc, dict, b"IC")
|
||||
{
|
||||
out.push(AnnotationCover::Square { rect, rgb });
|
||||
}
|
||||
|
||||
@@ -720,9 +720,11 @@ fn runs_without_words_count_against_the_comparison_bound() {
|
||||
let doc = open(one_page(&content, ""));
|
||||
let lines = pdfcraft_edit::glyph_text_lines(&doc, &pdfcraft_model::pages(&doc)[0]).expect("text");
|
||||
assert_eq!(lines.iter().map(|l| (l.ink.len(), l.runs().count())).collect::<Vec<_>>(), [(1, 51)]);
|
||||
let limits = crate::Limits { comparisons: 50, covered_text: usize::MAX };
|
||||
// Plus the hidden line's text, read once to copy it.
|
||||
let text = lines[0].text.len() / crate::TEXT_BYTES_PER_COMPARISON;
|
||||
let limits = crate::Limits { comparisons: 50 + text, covered_text: usize::MAX };
|
||||
assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons)));
|
||||
let limits = crate::Limits { comparisons: 51, covered_text: usize::MAX };
|
||||
let limits = crate::Limits { comparisons: 51 + text, covered_text: usize::MAX };
|
||||
assert_eq!(crate::audit(&doc, limits).expect("within the budget").len(), 1);
|
||||
}
|
||||
|
||||
@@ -746,14 +748,18 @@ fn copy_restricted_documents_withhold_covered_text() {
|
||||
let findings = audit_redactions(&user).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "the cover is still reported: {findings:?}");
|
||||
assert_eq!(findings[0].covered_text, "");
|
||||
// The output bound counts the text reported, so withheld text costs nothing.
|
||||
let nothing = crate::Limits { comparisons: usize::MAX, covered_text: 0 };
|
||||
// The output bound counts the text reported and the finding's record, so withheld text
|
||||
// costs only the record.
|
||||
let record = std::mem::size_of::<AuditFinding>();
|
||||
let nothing = crate::Limits { comparisons: usize::MAX, covered_text: record };
|
||||
assert_eq!(crate::audit(&user, nothing).expect("withheld text is not output").len(), 1);
|
||||
let none = crate::Limits { covered_text: record - 1, ..nothing };
|
||||
assert!(matches!(crate::audit(&user, none), Err(crate::AuditError::TooMuchOutput)));
|
||||
let owner = Document::open_with_password(bytes, Some("owner")).expect("open");
|
||||
let findings = audit_redactions(&owner).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].covered_text, "SECRET DATA 123");
|
||||
let exact = crate::Limits { comparisons: usize::MAX, covered_text: "SECRET DATA 123".len() };
|
||||
let exact = crate::Limits { comparisons: usize::MAX, covered_text: "SECRET DATA 123".len() + record };
|
||||
assert!(crate::audit(&owner, exact).is_ok());
|
||||
let short = crate::Limits { covered_text: exact.covered_text - 1, ..exact };
|
||||
assert!(matches!(crate::audit(&owner, short), Err(crate::AuditError::TooMuchOutput)));
|
||||
@@ -820,3 +826,52 @@ fn form_entries_past_the_page_allowance_fail_the_audit() {
|
||||
let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect();
|
||||
assert!(matches!(crate::cover::filled_rects(&doc, &page), Err(crate::AuditError::TooManyForms)));
|
||||
}
|
||||
|
||||
/// Copying a covered line's text walks all of it, padding included, so a long padded line under
|
||||
/// many covers costs its bytes once per cover, not one run per cover (CodeRabbit).
|
||||
#[test]
|
||||
fn covered_text_bytes_count_against_the_comparison_bound() {
|
||||
// One run: 20 KiB of spaces around a word, which trims to the word alone.
|
||||
let mut content = b"BT /F1 24 Tf 100 700 Td (".to_vec();
|
||||
content.extend_from_slice(&[b' '; 10_000]);
|
||||
content.extend_from_slice(b"SECRET");
|
||||
content.extend_from_slice(&[b' '; 10_000]);
|
||||
content.extend_from_slice(b") Tj ET\n0 g\n");
|
||||
for _ in 0..10 {
|
||||
content.extend_from_slice(b"-1 688 1000000 32 re f\n");
|
||||
}
|
||||
let doc = open(one_page(&content, ""));
|
||||
let lines = pdfcraft_edit::glyph_text_lines(&doc, &pdfcraft_model::pages(&doc)[0]).expect("text");
|
||||
assert_eq!(lines.len(), 1);
|
||||
let bytes = lines[0].text.len();
|
||||
assert!(bytes > 20_000, "{bytes} bytes of text");
|
||||
// Each cover: one comparison for the line, plus its bytes when it is hidden.
|
||||
let exact = crate::Limits { comparisons: 10 * (1 + bytes / crate::TEXT_BYTES_PER_COMPARISON), covered_text: usize::MAX };
|
||||
let findings = crate::audit(&doc, exact).expect("within the budget");
|
||||
assert_eq!(findings.len(), 10, "{findings:?}");
|
||||
assert!(findings.iter().all(|f| f.covered_text == "SECRET"), "{findings:?}");
|
||||
let short = crate::Limits { comparisons: exact.comparisons - 1, ..exact };
|
||||
assert!(matches!(crate::audit(&doc, short), Err(crate::AuditError::TooManyComparisons)));
|
||||
}
|
||||
|
||||
/// One annotation list shared by many pages makes a finding on each: every finding's record
|
||||
/// counts against the output bound, text or no text (CodeRabbit).
|
||||
#[test]
|
||||
fn every_finding_record_counts_against_the_output_bound() {
|
||||
let content = b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n";
|
||||
let mut b = Builder::new();
|
||||
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
||||
b.add("<< /Type /Pages /Kids [3 0 R 7 0 R 8 0 R] /Count 3 >>");
|
||||
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots 6 0 R >>");
|
||||
b.add_stream(content);
|
||||
b.add(FONT);
|
||||
b.add("[<< /Type /Annot /Subtype /Redact /Rect [95 685 200 725] >>]");
|
||||
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots 6 0 R >>");
|
||||
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots 6 0 R >>");
|
||||
let doc = open(b.build());
|
||||
let one = "SECRET".len() + std::mem::size_of::<AuditFinding>();
|
||||
let exact = crate::Limits { comparisons: usize::MAX, covered_text: 3 * one };
|
||||
assert_eq!(crate::audit(&doc, exact).expect("within the budget").len(), 3);
|
||||
let short = crate::Limits { covered_text: 3 * one - 1, ..exact };
|
||||
assert!(matches!(crate::audit(&doc, short), Err(crate::AuditError::TooMuchOutput)));
|
||||
}
|
||||
@@ -5,6 +5,8 @@ Agent control for PdfCraft: a headless tool table over the engine, and an opt-in
|
||||
- **Layer:** L7 (architecture §13), but headless. It depends on `engine`, `render` and `organize`, never on a UI toolkit.
|
||||
- **Status:** over a hundred tools; `pdfcraft-cli tools` lists the current set with their schemas (the count isn't repeated here, so it can't go stale). They cover inspection, rendering, text, page edits and organizing, page labels, bookmarks, comments and their review, form filling and authoring (fields, properties, scripts, data exchange, detection), redaction and sanitizing, password protection and signatures, calibrated 2D measurements and CSV export, adding text, images, links and stamps, optimizing, OCR, printing, export, metadata, undo/redo, save, combine, extract and split. Comment tools take geometry in the same top-left-origin points as everything else, and `comment_add` can mark text by searching for it (`find`). The MCP server also serves the open documents as resources, page images included. The running app's UI is driven separately, through the `ui.*` control channel in `pdfcraft-ui-egui`.
|
||||
|
||||
`fill_sign_add` text accepts line breaks, including blank lines. Its typewriter box grows to hold all wrapped rows, and saving/reopening preserves the contents and appearance.
|
||||
|
||||
## API
|
||||
|
||||
```rust
|
||||
|
||||
@@ -6,6 +6,13 @@ impl Automation {
|
||||
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, rtf or md)")))?;
|
||||
let html_mode = a.opt_str("html_mode")?.unwrap_or("rich");
|
||||
let format = match (format, html_mode) {
|
||||
(pdfcraft_engine::compare::OfficeFormat::Html, "rich") => pdfcraft_engine::compare::OfficeFormat::Html,
|
||||
(pdfcraft_engine::compare::OfficeFormat::Html, "simple") => pdfcraft_engine::compare::OfficeFormat::HtmlSimple,
|
||||
(_, "rich") => format,
|
||||
(_, _) => return Err(ToolError::InvalidArgs("html_mode is only available for HTML exports (rich or simple)".into())),
|
||||
};
|
||||
let export = self.doc_to_copy(a)?.export_office_checked(format);
|
||||
write_atomic(&path, &export.bytes)?;
|
||||
// Text left out to keep a hostile page bounded is said, not silent (#3120).
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
use std::io::Write;
|
||||
|
||||
use pdfcraft_engine::{Edit, HeaderFooter};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::error::{Result, ToolError, failed};
|
||||
use super::json::Args;
|
||||
use super::paths::read_input;
|
||||
use super::{Arc, Automation, Path, PathBuf};
|
||||
|
||||
struct FilePlan {
|
||||
input: String,
|
||||
source: PathBuf,
|
||||
password: Option<String>,
|
||||
output: PathBuf,
|
||||
pages: usize,
|
||||
first: u64,
|
||||
last: u64,
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
pub(crate) fn doc_bates_multi(&mut self, args: &Args) -> Result<Value> {
|
||||
let files = args.0.get("files").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("files must be an array".into()))?;
|
||||
if files.is_empty() || files.len() > 1000 {
|
||||
return Err(ToolError::InvalidArgs("files must contain 1 to 1000 entries".into()));
|
||||
}
|
||||
let start = match args.0.get("start_number") {
|
||||
None | Some(Value::Null) => 1,
|
||||
Some(value) => value
|
||||
.as_u64()
|
||||
.filter(|n| *n >= 1)
|
||||
.ok_or_else(|| ToolError::InvalidArgs("start_number must be a whole number of at least 1".into()))?,
|
||||
};
|
||||
let digits = args.opt_int("digits")?.unwrap_or(6);
|
||||
let digits =
|
||||
u8::try_from(digits).ok().filter(|d| (1..=12).contains(d)).ok_or_else(|| ToolError::InvalidArgs("digits must be from 1 to 12".into()))?;
|
||||
let prefix = args.opt_str("prefix")?.unwrap_or("");
|
||||
let suffix = args.opt_str("suffix")?.unwrap_or("");
|
||||
// `#` separates the token's fields and `>>` ends it: either would cut the token short.
|
||||
if prefix.len() > 128 || suffix.len() > 128 || [prefix, suffix].iter().any(|s| s.contains('#') || s.contains(">>")) {
|
||||
return Err(ToolError::InvalidArgs("prefix and suffix must be at most 128 bytes and cannot contain # or >>".into()));
|
||||
}
|
||||
let dry_run = args.opt_bool("dry_run")?.unwrap_or(false);
|
||||
|
||||
// Resolve and reserve the complete path list before opening or writing anything.
|
||||
let mut sources = Vec::with_capacity(files.len());
|
||||
let mut targets = Vec::with_capacity(files.len());
|
||||
let mut passwords = Vec::with_capacity(files.len());
|
||||
for entry in files {
|
||||
let input = entry
|
||||
.get("input")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| ToolError::InvalidArgs("each file needs a non-empty input path".into()))?;
|
||||
let output = entry
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| ToolError::InvalidArgs("each file needs a non-empty output path".into()))?;
|
||||
let source = self.resolve(input, false)?;
|
||||
let target = self.resolve(output, true)?;
|
||||
if target.exists() {
|
||||
return Err(failed(format!("output already exists; refusing to replace it: {output}")));
|
||||
}
|
||||
let parent = target.parent().ok_or_else(|| failed(format!("output has no parent directory: {output}")))?;
|
||||
if !parent.is_dir() {
|
||||
return Err(failed(format!("output directory does not exist: {}", parent.display())));
|
||||
}
|
||||
sources.push((input.to_string(), source));
|
||||
targets.push((output.to_string(), target));
|
||||
passwords.push(entry.get("password").and_then(Value::as_str).map(str::to_owned));
|
||||
}
|
||||
let input_paths: std::collections::HashSet<_> = sources.iter().map(|(_, path)| path.clone()).collect();
|
||||
let mut output_paths = std::collections::HashSet::new();
|
||||
for (name, path) in &targets {
|
||||
if input_paths.contains(path) {
|
||||
return Err(failed(format!("output {name} names an input file; choose a separate output path")));
|
||||
}
|
||||
if !output_paths.insert(path.clone()) {
|
||||
return Err(failed(format!("two files use the same output path: {name}")));
|
||||
}
|
||||
}
|
||||
|
||||
let mut plans = Vec::with_capacity(files.len());
|
||||
let mut next = start;
|
||||
let mut input_bytes = 0u64;
|
||||
for (index, ((input_name, input_path), (_, output_path))) in sources.iter().zip(&targets).enumerate() {
|
||||
let size = std::fs::metadata(input_path).map_err(failed)?.len();
|
||||
input_bytes = input_bytes
|
||||
.checked_add(size)
|
||||
.filter(|total| *total <= 512 * 1024 * 1024)
|
||||
.ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?;
|
||||
let bytes = read_input(input_path)?;
|
||||
input_bytes = input_bytes
|
||||
.saturating_sub(size)
|
||||
.checked_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX))
|
||||
.filter(|total| *total <= 512 * 1024 * 1024)
|
||||
.ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?;
|
||||
let display_name = input_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let password = passwords.get(index).and_then(Option::as_deref);
|
||||
let id = self.session.open(display_name, Some(input_path.to_string_lossy().into_owned()), Arc::new(bytes), password).map_err(failed)?;
|
||||
let pages =
|
||||
self.session.get(id).map(|doc| doc.info.pages.len()).ok_or_else(|| failed("opened document disappeared during Bates preflight"))?;
|
||||
self.session.close(id);
|
||||
if pages == 0 {
|
||||
return Err(failed(format!("{input_name} has no pages to number")));
|
||||
}
|
||||
let count = u64::try_from(pages).map_err(|_| failed(format!("page count is too large for {input_name}")))?;
|
||||
let last = next.checked_add(count.saturating_sub(1)).ok_or_else(|| failed(format!("Bates number range overflows for {input_name}")))?;
|
||||
plans.push(FilePlan {
|
||||
input: input_name.clone(),
|
||||
source: input_path.clone(),
|
||||
password: passwords.get(index).cloned().flatten(),
|
||||
output: output_path.clone(),
|
||||
pages,
|
||||
first: next,
|
||||
last,
|
||||
});
|
||||
if index + 1 < sources.len() {
|
||||
next = last.checked_add(1).ok_or_else(|| failed(format!("Bates sequence overflows after {input_name}")))?;
|
||||
}
|
||||
}
|
||||
|
||||
let intervals: Vec<Value> = plans
|
||||
.iter()
|
||||
.zip(&targets)
|
||||
.map(|(p, (name, _))| json!({"input":p.input,"output":name,"pages":p.pages,"first":p.first,"last":p.last}))
|
||||
.collect();
|
||||
if dry_run {
|
||||
return Ok(json!({"dry_run":true,"files":intervals}));
|
||||
}
|
||||
|
||||
let mut staged = Vec::with_capacity(plans.len());
|
||||
let mut results = Vec::with_capacity(plans.len());
|
||||
let mut output_bytes = 0u64;
|
||||
for plan in &plans {
|
||||
let source_bytes = match read_input(&plan.source) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()}));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let name = plan.source.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let id = match self.session.open(name, Some(plan.source.to_string_lossy().into_owned()), Arc::new(source_bytes), plan.password.as_deref())
|
||||
{
|
||||
Ok(id) => id,
|
||||
Err(error) => {
|
||||
results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()}));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if self.session.get(id).is_none_or(|doc| doc.info.pages.len() != plan.pages) {
|
||||
self.session.close(id);
|
||||
results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"input changed after preflight; page count differs"}));
|
||||
continue;
|
||||
}
|
||||
let template = format!("<<Bates Number#{digits}#{}#{prefix}#{suffix}>>", plan.first);
|
||||
let mut settings = HeaderFooter::default();
|
||||
settings.text[4] = template;
|
||||
let pages: Vec<usize> = (0..plan.pages).collect();
|
||||
let applied = self.session.apply(id, Edit::AddHeaderFooter { pages, settings, replace: false });
|
||||
let result = applied.and_then(|()| self.session.save_full_bytes(id));
|
||||
match result {
|
||||
Ok(bytes) => {
|
||||
let total = output_bytes.saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
|
||||
if total > 512 * 1024 * 1024 {
|
||||
results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"combined Bates output exceeds the 512 MiB safety limit"}));
|
||||
} else {
|
||||
output_bytes = total;
|
||||
staged.push((plan.output.clone(), bytes));
|
||||
}
|
||||
}
|
||||
Err(error) => results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})),
|
||||
}
|
||||
self.session.close(id);
|
||||
}
|
||||
for (path, bytes) in staged {
|
||||
match publish_new(&path, &bytes) {
|
||||
Ok(()) => {
|
||||
let plan = plans.iter().find(|p| p.output == path);
|
||||
if let Some(plan) = plan {
|
||||
results.push(json!({"input":plan.input,"output":path,"status":"ok","first":plan.first,"last":plan.last,"pages":plan.pages}));
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let plan = plans.iter().find(|p| p.output == path);
|
||||
if let Some(plan) = plan {
|
||||
results.push(json!({"input":plan.input,"output":path,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
results.sort_by_key(|result| plans.iter().position(|plan| result["output"] == json!(plan.output)).unwrap_or(usize::MAX));
|
||||
let completed = results.iter().filter(|r| r["status"] == "ok").count();
|
||||
let failed_count = results.len().saturating_sub(completed);
|
||||
Ok(json!({"dry_run":false,"completed":completed,"failed":failed_count,"files":results}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish a PDF atomically while refusing a target created by another process after preflight.
|
||||
fn publish_new(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
let parent = path.parent().ok_or_else(|| failed("output has no parent directory"))?;
|
||||
let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(failed)?;
|
||||
temp.write_all(bytes).map_err(failed)?;
|
||||
temp.as_file().sync_all().map_err(failed)?;
|
||||
temp.persist_noclobber(path).map(|_| ()).map_err(|error| {
|
||||
if error.error.kind() == std::io::ErrorKind::AlreadyExists {
|
||||
failed(format!("output appeared after preflight; refusing to replace it: {}", path.display()))
|
||||
} else {
|
||||
failed(error.error)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -539,8 +539,10 @@ impl Automation {
|
||||
return self.apply(a, edit);
|
||||
}
|
||||
let text_at = |t: &str| {
|
||||
let w = (pdfcraft_engine::annot_text::text_width(t, size) + 8.0).clamp(20.0, 600.0);
|
||||
let h = size * 1.2 + 6.0;
|
||||
let longest = t.lines().map(|line| pdfcraft_engine::annot_text::text_width(line, size)).fold(0.0, f64::max);
|
||||
let w = (longest + 8.0).clamp(20.0, 600.0);
|
||||
let rows = pdfcraft_engine::annot_text::wrap(t, size, w - 4.0).len().max(1);
|
||||
let h = rows as f64 * size * 1.2 + 6.0;
|
||||
let rect = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]);
|
||||
Shape::Typewriter { rect, font_size: size }
|
||||
};
|
||||
|
||||
@@ -53,6 +53,7 @@ impl Automation {
|
||||
tools::check_args(def, args)?;
|
||||
let a = Args(args);
|
||||
let out = match name {
|
||||
"doc_bates_multi" => self.doc_bates_multi(&a)?,
|
||||
"doc_open" => self.doc_open(&a)?,
|
||||
"doc_batch" => self.doc_batch(&a)?,
|
||||
"doc_list" => json!({ "documents": self.session.docs().iter().map(summary).collect::<Vec<_>>() }),
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
mod a11y;
|
||||
mod batch;
|
||||
mod bates;
|
||||
mod comments;
|
||||
mod content;
|
||||
mod conventions;
|
||||
|
||||
@@ -201,8 +201,8 @@ impl Automation {
|
||||
}
|
||||
|
||||
pub(crate) fn doc_remove_hidden(&mut self, a: &Args) -> Result<Value> {
|
||||
let edit = match a.get("categories") {
|
||||
None => Edit::Sanitize,
|
||||
let which = match a.get("categories") {
|
||||
None => None,
|
||||
Some(_) => {
|
||||
let which = a
|
||||
.strs("categories")?
|
||||
@@ -212,10 +212,24 @@ impl Automation {
|
||||
if which.is_empty() {
|
||||
return Err(ToolError::InvalidArgs("categories is empty".into()));
|
||||
}
|
||||
Some(which)
|
||||
}
|
||||
};
|
||||
// One scan, for this check and for what was removed. The removal refuses when it counts
|
||||
// every listed category as 0 (an AcroForm with no fields counts 1), with an error about
|
||||
// redaction marks; this reads the same scan, so it refuses exactly then. A document that
|
||||
// can't be edited has no scan: `apply` says why.
|
||||
let info = self.doc(a)?.hidden_info();
|
||||
let edit = match which {
|
||||
None => Edit::Sanitize,
|
||||
Some(which) => {
|
||||
if !info.is_empty() && which.iter().all(|h| info.iter().find(|c| c.0 == *h).is_none_or(|c| c.1 == 0)) {
|
||||
return Err(failed("nothing to remove: the listed categories hold nothing (see doc_hidden_info)"));
|
||||
}
|
||||
Edit::RemoveHidden { which }
|
||||
}
|
||||
};
|
||||
let before: usize = self.doc(a)?.hidden_info().iter().map(|c| c.1).sum();
|
||||
let before: usize = info.iter().map(|c| c.1).sum();
|
||||
let mut out = self.apply(a, edit)?;
|
||||
let after: usize = self.doc(a)?.hidden_info().iter().map(|c| c.1).sum();
|
||||
out["removed"] = json!(before.saturating_sub(after));
|
||||
|
||||
@@ -121,6 +121,14 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
let save_out = json!({ "type": "string", "description": "File to write. Omit to open the result as a new unsaved document instead." });
|
||||
let open = json!({ "type": "boolean", "description": "Also open the result as a new document (default: only when out is omitted)." });
|
||||
vec![
|
||||
t("doc_bates_multi", "Add Bates numbering across files", "Apply one consecutive Bates sequence to an ordered list of PDFs. Each file entry has input and output paths and an optional open or permissions password. The operation first validates every input/output and computes inclusive numeric intervals; dry_run returns that allocation without writing. Existing output files are never replaced. On application, files that cannot be edited or saved are reported individually and later files continue.")
|
||||
.destructive()
|
||||
.with(schema(json!({
|
||||
"files": {"type":"array","minItems":1,"maxItems":1000,"description":"PDFs in Bates sequence order.","items":{"type":"object","properties":{"input":{"type":"string","minLength":1,"description":"Input PDF path."},"output":{"type":"string","minLength":1,"description":"New output PDF path; must not exist."},"password":{"type":"string","description":"Open or permissions password, if needed."}},"required":["input","output"],"additionalProperties":false}},
|
||||
"start_number": {"type":"integer","minimum":1,"description":"First number in the sequence (default 1)."}, "digits": {"type":"integer","minimum":1,"maximum":12,"description":"Zero-padded number width (default 6)."},
|
||||
"prefix": {"type":"string","maxLength":128,"description":"Text before the number: at most 128 UTF-8 bytes, without # or >>."}, "suffix": {"type":"string","maxLength":128,"description":"Text after the number: at most 128 UTF-8 bytes, without # or >>."},
|
||||
"dry_run": {"type":"boolean","description":"Return each file's inclusive number interval without writing (default false)."}
|
||||
}), &["files"])),
|
||||
t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi; black-and-white images go to the larger of ppi and 300) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.")
|
||||
.with(schema(json!({
|
||||
"paths": {"type":"array","items":{"type":"string"},"minItems":1,"maxItems":1000},
|
||||
@@ -562,7 +570,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"doc_remove_hidden",
|
||||
"Remove hidden information / Sanitize",
|
||||
"Remove the listed categories (see doc_hidden_info), or every category when none are listed (Sanitize Document). Form fields are flattened so their values stay visible. The next save rewrites the whole file. Undoable until saved.",
|
||||
"Remove the listed categories (see doc_hidden_info), or every category when none are listed (Sanitize Document). Fails when the listed categories hold nothing. Form fields are flattened so their values stay visible. The next save rewrites the whole file. Undoable until saved.",
|
||||
)
|
||||
.destructive()
|
||||
.with(schema(
|
||||
@@ -1194,9 +1202,9 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
|
||||
&["doc", "field", "event"],
|
||||
)),
|
||||
t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit, or that have pictures past what one export may hold or decode, are listed in truncated_pages, with a warning; what was left out is missing.")
|
||||
t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. HTML uses rich styling by default; set html_mode to simple for CSS-free headings, paragraphs, emphasis and tables. Simple HTML keeps text and inline images but omits colour and layout styling. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit, or that have pictures past what one export may hold or decode, are listed in truncated_pages, with a warning; what was left out is missing.")
|
||||
.cmd("export.docx")
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "html_mode": { "type": "string", "enum": ["rich", "simple"], "default": "rich" } }), &["doc", "path"])),
|
||||
t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.")
|
||||
.ro()
|
||||
.cmd("standards.pdfa")
|
||||
|
||||
@@ -2832,6 +2832,74 @@ fn headers_watermarks_and_backgrounds_through_tools() {
|
||||
assert!(matches!(refused, Err(ToolError::InvalidArgs(e)) if e.contains("Courier")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bates_numbers_continue_across_files_and_report_preflight_and_permission_failures() {
|
||||
let dir = workdir("bates-multi");
|
||||
let files = json!([
|
||||
{"input":"b.pdf","output":"b-numbered.pdf"},
|
||||
{"input":"a.pdf","output":"a-numbered.pdf"}
|
||||
]);
|
||||
let mut a = auto(&dir);
|
||||
let allocation = ok(&mut a, "doc_bates_multi", json!({"files":files,"start_number":50,"dry_run":true}));
|
||||
assert_eq!(allocation["files"][0]["first"], 50);
|
||||
assert_eq!(allocation["files"][0]["last"], 51);
|
||||
assert_eq!(allocation["files"][1]["first"], 52);
|
||||
assert_eq!(allocation["files"][1]["last"], 54);
|
||||
assert!(!dir.join("a-numbered.pdf").exists());
|
||||
|
||||
let applied = ok(&mut a, "doc_bates_multi", json!({"files":files,"start_number":17,"prefix":"CASE-"}));
|
||||
assert_eq!(applied["completed"], 2);
|
||||
let mut out = auto(&dir);
|
||||
let first = ok(&mut out, "doc_open", json!({"path":"a-numbered.pdf"}))["doc"].as_u64().unwrap();
|
||||
let second = ok(&mut out, "doc_open", json!({"path":"b-numbered.pdf"}))["doc"].as_u64().unwrap();
|
||||
assert!(page_text(&mut out, second)[0].contains("CASE-000017"));
|
||||
assert!(page_text(&mut out, first)[0].contains("CASE-000019"));
|
||||
|
||||
let overflow = err(&mut a, "doc_bates_multi", json!({"files":[{"input":"b.pdf","output":"overflow.pdf"}],"start_number":u64::MAX}));
|
||||
assert!(overflow.to_string().contains("overflows"));
|
||||
assert!(!dir.join("overflow.pdf").exists());
|
||||
// A range may end at u64::MAX itself.
|
||||
let at_max = ok(&mut a, "doc_bates_multi", json!({"files":[{"input":"b.pdf","output":"max.pdf"}],"start_number":u64::MAX - 1,"dry_run":true}));
|
||||
assert_eq!(at_max["files"][0]["last"], u64::MAX);
|
||||
for bad in
|
||||
[json!({"start_number":-3}), json!({"start_number":2.5}), json!({"start_number":"7"}), json!({"prefix":"CASE>>"}), json!({"suffix":"A>>B"})]
|
||||
{
|
||||
let mut args = json!({"files":[{"input":"b.pdf","output":"bad.pdf"}],"dry_run":true});
|
||||
args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone());
|
||||
assert!(a.call("doc_bates_multi", &args).is_err(), "{bad}");
|
||||
}
|
||||
|
||||
use pdfcraft_engine::{Changes, Edit, Protection, Session};
|
||||
let mut session = Session::new();
|
||||
let id = session.open("locked.pdf", None, std::sync::Arc::new(fixture(1)), None).unwrap();
|
||||
session
|
||||
.apply(
|
||||
id,
|
||||
Edit::Protect(Protection {
|
||||
open_password: Some("open".into()),
|
||||
permissions_password: Some("owner".into()),
|
||||
changes: Changes::None,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("locked.pdf"), session.save_bytes(id).unwrap().as_ref()).unwrap();
|
||||
let result = ok(
|
||||
&mut a,
|
||||
"doc_bates_multi",
|
||||
json!({"files":[
|
||||
{"input":"b.pdf","output":"partial-numbered.pdf"},
|
||||
{"input":"locked.pdf","output":"locked-numbered.pdf","password":"open"}
|
||||
]}),
|
||||
);
|
||||
assert_eq!(result["completed"], 1);
|
||||
assert_eq!(result["failed"], 1);
|
||||
assert_eq!(result["files"][1]["first"], 3);
|
||||
assert!(result["files"][1]["error"].as_str().unwrap().contains("owner password"), "{result}");
|
||||
assert!(dir.join("partial-numbered.pdf").exists());
|
||||
assert!(!dir.join("locked-numbered.pdf").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shrinking_header_and_footer_moves_the_page_content_out_of_their_way_through_tools() {
|
||||
let dir = workdir("hf-shrink");
|
||||
@@ -4670,12 +4738,23 @@ fn removing_hidden_information_through_tools() {
|
||||
assert_eq!(r["undo"], "Remove hidden information");
|
||||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 0);
|
||||
assert!(count(&ok(&mut a, "doc_hidden_info", json!({ "doc": doc })), "metadata") >= 1, "only comments went");
|
||||
// With none left, removing them again says so, and Optimize discarding them still runs.
|
||||
let again = a.call("doc_remove_hidden", &json!({ "doc": doc, "categories": ["comments"] }));
|
||||
assert!(matches!(&again, Err(ToolError::Failed(m)) if m.starts_with("nothing to remove")), "{again:?}");
|
||||
let optimized = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "optimized.pdf", "discard": ["comments", "bookmarks"] }));
|
||||
assert_eq!(optimized["discarded"], json!([]), "{optimized}");
|
||||
let r = ok(&mut a, "doc_remove_hidden", json!({ "doc": doc }));
|
||||
assert_eq!(r["undo"], "Sanitize document");
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "clean.pdf" }));
|
||||
let bytes = std::fs::read(dir.join("clean.pdf")).unwrap();
|
||||
assert!(!bytes.windows(11).any(|w| w == b"Secret plan"), "the old revision is gone");
|
||||
assert!(matches!(a.call("doc_remove_hidden", &json!({ "doc": doc, "categories": ["nonsense"] })), Err(ToolError::InvalidArgs(_))));
|
||||
// A document that can't be edited has no scan: the refusal says why, not that nothing is there.
|
||||
let junk = [vec![b'x'; 5000], std::fs::read(dir.join("a.pdf")).unwrap()].concat();
|
||||
std::fs::write(dir.join("junk.pdf"), junk).unwrap();
|
||||
let read_only = ok(&mut a, "doc_open", json!({ "path": "junk.pdf" }))["doc"].as_u64().unwrap();
|
||||
let refused = a.call("doc_remove_hidden", &json!({ "doc": read_only, "categories": ["comments"] }));
|
||||
assert!(matches!(&refused, Err(ToolError::Failed(m)) if m.contains("can't be edited")), "{refused:?}");
|
||||
}
|
||||
|
||||
/// A one-page PDF whose text "TOP SECRET" sits under an opaque black rectangle.
|
||||
@@ -7604,6 +7683,17 @@ fn exporting_to_word_html_and_rtf() {
|
||||
}
|
||||
let html = std::fs::read_to_string(dir.join("a.html")).unwrap();
|
||||
assert!(html.contains("Page 1") && html.contains("Page 3") && html.matches("<hr>").count() == 2, "{html}");
|
||||
ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "simple.html", "html_mode": "simple" }));
|
||||
let simple_html = std::fs::read_to_string(dir.join("simple.html")).unwrap();
|
||||
assert!(
|
||||
simple_html.contains("Page 1")
|
||||
&& !simple_html.contains("<style")
|
||||
&& !simple_html.contains("style=")
|
||||
&& !simple_html.contains("<span")
|
||||
&& !simple_html.contains("<div")
|
||||
);
|
||||
assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "bad.html", "html_mode": "compact" })).is_err());
|
||||
assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "bad.docx", "html_mode": "simple" })).is_err());
|
||||
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"));
|
||||
@@ -10523,3 +10613,32 @@ fn mixed_script_tools_normalize_but_undo_restores_the_exact_mixture() {
|
||||
assert_eq!(page_text(&mut a, reopened)[0], plain);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_fill_sign_engine_round_trip_preserves_lines_and_appearance() {
|
||||
let dir = workdir("fill-sign-multiline");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap();
|
||||
let text = "\nAda\n\nLovelace\n";
|
||||
ok(&mut a, "fill_sign_add", json!({"doc":doc,"page":1,"type":"text","at":[20,20],"text":text}));
|
||||
let id = pdfcraft_engine::DocId(doc);
|
||||
let annot = &a.session().get(id).unwrap().info.annotations[0];
|
||||
assert_eq!(annot.contents.as_deref(), Some(text), "typewriter contents keep outer line breaks");
|
||||
assert!(annot.rect[3] - annot.rect[1] >= 66.0, "five text lines need their full height, including blank lines: {:?}", annot.rect);
|
||||
let pixels = a.render_pixels(doc, 1, 1.0).unwrap();
|
||||
// The fixture's page text is at y=150; only the typewriter draws at y=65..80.
|
||||
assert!(
|
||||
(65..80).any(|y| (20..80).any(|x| {
|
||||
let i = (y * pixels.0 as usize + x) * 4;
|
||||
pixels.2[i..i + 3].iter().all(|v| *v < 100)
|
||||
})),
|
||||
"the lower nonempty line appears inside its box"
|
||||
);
|
||||
let rendered = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap();
|
||||
ok(&mut a, "doc_save", json!({"doc":doc,"path":"multiline.pdf"}));
|
||||
let mut re = auto(&dir);
|
||||
let reopened = ok(&mut re, "doc_open", json!({"path":"multiline.pdf"}))["doc"].as_u64().unwrap();
|
||||
assert_eq!(re.session().get(pdfcraft_engine::DocId(reopened)).unwrap().info.annotations[0].contents.as_deref(), Some(text));
|
||||
assert_eq!(rendered, re.call("page_render", &json!({"doc":reopened,"page":1,"dpi":72})).unwrap(), "saved appearances are identical");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -27,7 +27,7 @@ pub(crate) use state::ObjectReader;
|
||||
use state::{ObjStm, Slot};
|
||||
pub use state::{ReadState, SourceIdentity};
|
||||
#[cfg(test)]
|
||||
use state::{SECTION_READS, objstm_member, owned_bytes};
|
||||
use state::{SECTION_READS, held_bytes, objstm_member};
|
||||
use xref::XrefIndex;
|
||||
pub use xref::{Revision, XrefEntry};
|
||||
|
||||
@@ -47,7 +47,7 @@ pub struct Document {
|
||||
/// `fill_xref_gaps`.
|
||||
located: Arc<BTreeMap<u32, (u64, u16)>>,
|
||||
repair_log: Arc<Vec<String>>,
|
||||
cache: Arc<Mutex<HashMap<u32, Arc<Object>>>>,
|
||||
cache: Arc<Mutex<state::ObjectCache>>,
|
||||
objstms: Arc<Mutex<HashMap<u32, Arc<ObjStm>>>>,
|
||||
/// What `/JBIG2Globals` streams of the file decode to, by object number: the images of a scan
|
||||
/// share them, and each would otherwise decode them again (see `Document::jbig2_globals`).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::reader::find;
|
||||
use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, objstm_member, owned_bytes};
|
||||
use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, held_bytes, objstm_member};
|
||||
use super::xref::{Revision, XrefEntry};
|
||||
use super::{
|
||||
Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, MAX_JBIG2_GLOBALS, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared,
|
||||
@@ -399,8 +399,9 @@ impl Document {
|
||||
let obj = Arc::new(obj);
|
||||
// The editing cache is bounded like the scan reader's: re-parsing a file-backed view
|
||||
// is cheap, and returned objects outlive eviction.
|
||||
self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone());
|
||||
self.evict_editor_cache_if_over_budget(num, &obj)?;
|
||||
let owned = held_bytes(&obj, &self.data, 0);
|
||||
self.cache.lock().map_err(|_| CosError::Poisoned)?.insert(num, obj.clone(), owned);
|
||||
self.evict_editor_cache_if_over_budget(num)?;
|
||||
Ok(obj)
|
||||
}
|
||||
|
||||
@@ -409,10 +410,10 @@ impl Document {
|
||||
/// document cannot pin its whole object graph. Only owned (decrypted/copied) bytes
|
||||
/// count; file-backed views re-parse for free.
|
||||
///
|
||||
/// Worst case per document: input bytes + up to `EDITOR_CACHE_OBJECTS` ×
|
||||
/// `EDITOR_LARGE_OBJECT` (1024 × 256 KB = 256 MB) of small objects between full
|
||||
/// accounting passes + `EDITOR_OBJSTM_BYTES` (32 MB) + unsaved `overlay` edits.
|
||||
/// The count cap always holds; byte accounting between passes is approximate.
|
||||
/// Worst case per document: input bytes + `EDITOR_CACHE_BYTES` (32 MB) of owned object
|
||||
/// data + `EDITOR_OBJSTM_BYTES` (32 MB) + unsaved `overlay` edits. Each object is measured
|
||||
/// once, as it is cached ([`ObjectCache`](super::state::ObjectCache)), so both caps hold
|
||||
/// after every insertion.
|
||||
/// Clones share these caches, so one clone's eviction drops another's hot set;
|
||||
/// that only costs re-parsing, never correctness. Eviction keeps just the newcomer:
|
||||
/// file-backed views re-parse cheaply and editor working sets stay far below 1024.
|
||||
@@ -425,37 +426,13 @@ impl Document {
|
||||
|
||||
const EDITOR_OBJSTM_BYTES: usize = 32 * 1024 * 1024;
|
||||
|
||||
/// Newcomers above this force a full accounting pass even below the count cap:
|
||||
/// `EDITOR_CACHE_BYTES / 128`, so 128 large newcomers always trigger a pass before the
|
||||
/// byte budget can double.
|
||||
const EDITOR_LARGE_OBJECT: usize = 256 * 1024;
|
||||
|
||||
fn evict_editor_cache_if_over_budget(&self, fresh_num: u32, fresh: &Object) -> Result<(), CosError> {
|
||||
// Fast path: the full fold is O(cache) per insert, so skip it for a small newcomer
|
||||
// below the count cap. `len` is copied out first; nothing here holds the lock.
|
||||
let len = self.cache.lock().map_err(|_| CosError::Poisoned)?.len();
|
||||
if len <= Self::EDITOR_CACHE_OBJECTS && owned_bytes(fresh, &self.data, 0) <= Self::EDITOR_LARGE_OBJECT {
|
||||
return Ok(());
|
||||
}
|
||||
// Walk a snapshot off-lock; re-lock only to evict.
|
||||
let live: Vec<(u32, Arc<Object>)> = self.cache.lock().map_err(|_| CosError::Poisoned)?.iter().map(|(n, o)| (*n, o.clone())).collect();
|
||||
let data = &self.data;
|
||||
let owned: usize = live.iter().fold(0usize, |n, (_, o)| n.saturating_add(owned_bytes(o, data, 0)));
|
||||
if live.len() <= Self::EDITOR_CACHE_OBJECTS && owned <= Self::EDITOR_CACHE_BYTES {
|
||||
return Ok(());
|
||||
}
|
||||
// Re-check under the final lock: another thread may have inserted or evicted since
|
||||
// the snapshot. Keep its newcomers, and skip the eviction if back within budget.
|
||||
let snapshot: std::collections::HashSet<u32> = live.into_iter().map(|(n, _)| n).collect();
|
||||
fn evict_editor_cache_if_over_budget(&self, fresh_num: u32) -> Result<(), CosError> {
|
||||
let mut cache = self.cache.lock().map_err(|_| CosError::Poisoned)?;
|
||||
if cache.len() <= Self::EDITOR_CACHE_OBJECTS {
|
||||
let data = &self.data;
|
||||
let owned: usize = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0)));
|
||||
if owned <= Self::EDITOR_CACHE_BYTES {
|
||||
return Ok(());
|
||||
}
|
||||
if cache.len() <= Self::EDITOR_CACHE_OBJECTS && cache.owned() <= Self::EDITOR_CACHE_BYTES {
|
||||
return Ok(());
|
||||
}
|
||||
cache.retain(|n, _| *n == fresh_num || !snapshot.contains(n));
|
||||
// Eviction keeps just the newcomer: file-backed views re-parse cheaply.
|
||||
cache.retain(|n| *n == fresh_num);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -163,11 +163,10 @@ impl ObjectReader {
|
||||
// input bytes, which the document keeps anyway, so they don't count; decrypted
|
||||
// streams and strings do. Checked after failed loads too: resolving a /Length may
|
||||
// have cached large objects.
|
||||
if let Ok(mut cache) = self.document.cache.lock() {
|
||||
let data = &self.document.data;
|
||||
if cache.len() >= Self::OBJECTS || cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, data, 0))) > Self::STREAM_BYTES {
|
||||
cache.clear();
|
||||
}
|
||||
if let Ok(mut cache) = self.document.cache.lock()
|
||||
&& (cache.len() >= Self::OBJECTS || cache.owned() > Self::STREAM_BYTES)
|
||||
{
|
||||
cache.clear();
|
||||
}
|
||||
object
|
||||
}
|
||||
@@ -179,26 +178,96 @@ thread_local! {
|
||||
pub(super) static SECTION_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Nesting [`owned_bytes`] walks: past the parser's own nesting limit, so every parsed object
|
||||
/// Parsed objects by number, each with what it holds ([`held_bytes`]) measured once, when it goes
|
||||
/// in, and their sum kept beside them.
|
||||
///
|
||||
/// Both caches are bounded by those bytes. Summing them on every read walked every cached object
|
||||
/// whole each time: a file whose page dictionaries carry kilobytes of private data (InDesign's
|
||||
/// `/PieceInfo`) took seconds to open, almost all of it re-measuring objects already measured
|
||||
/// (#5873).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ObjectCache {
|
||||
entries: HashMap<u32, (Arc<Object>, usize)>,
|
||||
owned: usize,
|
||||
}
|
||||
|
||||
impl ObjectCache {
|
||||
pub(crate) fn get(&self, num: &u32) -> Option<&Arc<Object>> {
|
||||
self.entries.get(num).map(|(object, _)| object)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn contains_key(&self, num: &u32) -> bool {
|
||||
self.entries.contains_key(num)
|
||||
}
|
||||
|
||||
/// Keep `object` as `num`, which holds `owned` bytes (what [`held_bytes`] says of it).
|
||||
pub(crate) fn insert(&mut self, num: u32, object: Arc<Object>, owned: usize) {
|
||||
if let Some((_, before)) = self.entries.insert(num, (object, owned)) {
|
||||
self.owned = self.owned.saturating_sub(before);
|
||||
}
|
||||
self.owned = self.owned.saturating_add(owned);
|
||||
}
|
||||
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// The bytes the cached objects hold between them.
|
||||
pub(crate) fn owned(&self) -> usize {
|
||||
self.owned
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
self.owned = 0;
|
||||
}
|
||||
|
||||
pub(crate) fn retain(&mut self, mut keep: impl FnMut(&u32) -> bool) {
|
||||
self.entries.retain(|num, _| keep(num));
|
||||
self.owned = self.entries.values().fold(0usize, |n, (_, owned)| n.saturating_add(*owned));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn values(&self) -> impl Iterator<Item = &Arc<Object>> {
|
||||
self.entries.values().map(|(object, _)| object)
|
||||
}
|
||||
}
|
||||
|
||||
/// Nesting [`held_bytes`] walks: past the parser's own nesting limit, so every parsed object
|
||||
/// is measured in full.
|
||||
const OWNED_BYTES_DEPTH: usize = 256;
|
||||
|
||||
/// Bytes `object` holds of its own: string and stream data that is not a view into `data`
|
||||
/// (the input file). An object nested deeper than [`OWNED_BYTES_DEPTH`] cannot be measured
|
||||
/// and counts as too large to keep.
|
||||
pub(super) fn owned_bytes(object: &Object, data: &Arc<Vec<u8>>, depth: usize) -> usize {
|
||||
/// What keeping `object` parsed costs: string and stream data that is not a view into `data`
|
||||
/// (the input file), and the parsed tree around it -- an [`Object`] per value and a key per
|
||||
/// dictionary entry. An object nested deeper than [`OWNED_BYTES_DEPTH`] cannot be measured and
|
||||
/// counts as too large to keep.
|
||||
///
|
||||
/// The tree counts. Measured by its strings and streams alone, a page dictionary carrying
|
||||
/// InDesign's `/PieceInfo` -- thousands of small numeric arrays, parsed into tens of thousands of
|
||||
/// values -- weighed nothing, so the caches kept a thousand of them: a 35 MB file held 1.5 GB
|
||||
/// (#5873).
|
||||
pub(super) fn held_bytes(object: &Object, data: &Arc<Vec<u8>>, depth: usize) -> usize {
|
||||
if depth > OWNED_BYTES_DEPTH {
|
||||
return usize::MAX;
|
||||
}
|
||||
let node = std::mem::size_of::<Object>();
|
||||
// A key, its slot beside the value, and room in the index a large dictionary keeps.
|
||||
let entry = |key: &[u8]| key.len().saturating_add(std::mem::size_of::<(crate::Name, Object)>()).saturating_add(16);
|
||||
let dict =
|
||||
|d: &crate::Dict, start: usize| d.iter().fold(start, |n, (k, v)| n.saturating_add(entry(k)).saturating_add(held_bytes(v, data, depth + 1)));
|
||||
match object {
|
||||
Object::String(s) => s.bytes.len(),
|
||||
Object::Stream(s) => {
|
||||
let own = if s.raw.shares(data) { 0 } else { s.raw.len() };
|
||||
s.dict.iter().fold(own, |n, (_, v)| n.saturating_add(owned_bytes(v, data, depth + 1)))
|
||||
}
|
||||
Object::Array(a) => a.iter().fold(0usize, |n, v| n.saturating_add(owned_bytes(v, data, depth + 1))),
|
||||
Object::Dict(d) => d.iter().fold(0usize, |n, (_, v)| n.saturating_add(owned_bytes(v, data, depth + 1))),
|
||||
_ => 0,
|
||||
Object::String(s) => node.saturating_add(s.bytes.len()),
|
||||
Object::Name(n) => node.saturating_add(n.len()),
|
||||
Object::Stream(s) => dict(&s.dict, node.saturating_add(if s.raw.shares(data) { 0 } else { s.raw.len() })),
|
||||
Object::Array(a) => a.iter().fold(node, |n, v| n.saturating_add(held_bytes(v, data, depth + 1))),
|
||||
Object::Dict(d) => dict(d, node),
|
||||
_ => node,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1109,7 +1109,8 @@ fn temporary_reader_keeps_file_views_and_evicts_data_of_its_own() {
|
||||
// Data of its own (decrypted streams, strings) counts: past the budget the cache is
|
||||
// cleared after the next read, including a failed one.
|
||||
let owned = Object::Stream(crate::Stream::from_raw(Dict::new(), vec![b'y'; ObjectReader::STREAM_BYTES + 1]));
|
||||
reader.document.cache.lock().unwrap().insert(3, Arc::new(owned));
|
||||
let size = held_bytes(&owned, &reader.document.data, 0);
|
||||
reader.document.cache.lock().unwrap().insert(3, Arc::new(owned), size);
|
||||
assert_eq!(*reader.get(ObjRef::new(99, 0)), Object::Null);
|
||||
assert!(reader.document.cache.lock().unwrap().is_empty(), "owned data over budget is evicted");
|
||||
}
|
||||
@@ -1192,13 +1193,14 @@ fn editor_cache_counts_deeply_nested_strings() {
|
||||
source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap();
|
||||
let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap());
|
||||
let doc = Document::open(bytes).unwrap();
|
||||
assert_eq!(owned_bytes(&nested, &doc.data, 0), 1 << 20);
|
||||
let held = held_bytes(&nested, &doc.data, 0);
|
||||
assert!((1 << 20..(1 << 20) + 4096).contains(&held), "the string and a little tree: {held}");
|
||||
let root = doc.get(doc.root().unwrap());
|
||||
for reference in root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap() {
|
||||
let Object::Ref(reference) = reference else { panic!("indirect object") };
|
||||
drop(doc.get(*reference));
|
||||
let cache = doc.cache.lock().unwrap();
|
||||
let owned = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, &doc.data, 0)));
|
||||
let owned = cache.values().fold(0usize, |n, o| n.saturating_add(held_bytes(o, &doc.data, 0)));
|
||||
let strings = cache.values().filter(|o| o.as_array().is_some()).count();
|
||||
assert!(strings << 20 <= Document::EDITOR_CACHE_BYTES + (1 << 20), "{strings} MiB strings cached, {owned} bytes counted");
|
||||
}
|
||||
@@ -1240,8 +1242,8 @@ fn editor_cache_stays_bounded() {
|
||||
|
||||
#[test]
|
||||
fn editor_cache_byte_bound_fires() {
|
||||
// 40 × 1 MB strings: far below the count cap, above the 32 MB byte budget. Each
|
||||
// newcomer exceeds EDITOR_LARGE_OBJECT, so every insert takes a full accounting pass.
|
||||
// 40 × 1 MB strings: far below the count cap, above the 32 MB byte budget, which is
|
||||
// checked after every insertion.
|
||||
let mut source = Document::new_empty();
|
||||
let objects: Vec<_> = (0..40)
|
||||
.map(|i| {
|
||||
@@ -1701,3 +1703,50 @@ fn a_stream_whose_length_is_itself_and_never_ends_loads_quickly() {
|
||||
});
|
||||
rx.recv_timeout(std::time::Duration::from_secs(20)).expect("loading must not retry exponentially");
|
||||
}
|
||||
|
||||
/// The cache keeps the sum of what its objects hold as they come and go, so neither reader has
|
||||
/// to measure the cache again on every read (#5873).
|
||||
#[test]
|
||||
fn the_object_cache_keeps_its_total_as_objects_come_and_go() {
|
||||
let data = Arc::new(Vec::new());
|
||||
let one = Arc::new(Object::String(crate::PdfString::literal(vec![b'x'; 100])));
|
||||
let two = Arc::new(Object::Array(vec![Object::Int(1); 10]));
|
||||
let (a, b) = (held_bytes(&one, &data, 0), held_bytes(&two, &data, 0));
|
||||
let mut cache = super::state::ObjectCache::default();
|
||||
cache.insert(1, one.clone(), a);
|
||||
cache.insert(2, two, b);
|
||||
assert_eq!(cache.owned(), a + b);
|
||||
cache.insert(2, one, a);
|
||||
assert_eq!(cache.owned(), a + a, "a replaced object no longer counts");
|
||||
cache.retain(|n| *n == 1);
|
||||
assert_eq!((cache.len(), cache.owned()), (1, a));
|
||||
cache.clear();
|
||||
assert_eq!((cache.len(), cache.owned()), (0, 0));
|
||||
}
|
||||
|
||||
/// A dictionary of numbers owns no string or stream bytes, but parsed it is tens of thousands
|
||||
/// of values: InDesign's per-page `/PieceInfo`. Counting only strings and streams, the editing
|
||||
/// cache kept a thousand such pages and a 35 MB book held 1.5 GB (#5873). What the parsed tree
|
||||
/// holds counts now, so the byte budget bounds them.
|
||||
#[test]
|
||||
fn the_editor_cache_charges_a_dictionary_for_the_values_it_holds() {
|
||||
let entries: String = (0..2000).map(|i| format!("/{i}[{i} 141 3 116.22 -375.993 1.0]")).collect();
|
||||
let dict = format!("<< /PieceInfo << /InDesign << /Map << {entries} >> >> >> >>");
|
||||
let count = 300;
|
||||
let refs: String = (0..count).map(|i| format!("{} 0 R ", i + 2)).collect();
|
||||
let mut bodies = vec![format!("<< /Type /Catalog /Extension [{refs}] >>")];
|
||||
bodies.extend((0..count).map(|_| dict.clone()));
|
||||
let bodies: Vec<&str> = bodies.iter().map(String::as_str).collect();
|
||||
let doc = Document::open(Arc::new(build(&bodies, "/Root 1 0 R"))).unwrap();
|
||||
let one = doc.get(crate::ObjRef::new(2, 0));
|
||||
let held = held_bytes(&one, &doc.data, 0);
|
||||
assert!(held > 2000 * std::mem::size_of::<Object>(), "the parsed tree counts: {held} bytes");
|
||||
for i in 0..count {
|
||||
drop(doc.get(crate::ObjRef::new(i as u32 + 2, 0)));
|
||||
}
|
||||
let cache = doc.cache.lock().unwrap();
|
||||
assert!(cache.owned() <= Document::EDITOR_CACHE_BYTES, "{} bytes held", cache.owned());
|
||||
assert!(cache.len() < count, "the byte budget evicted some, below the count cap: {} kept", cache.len());
|
||||
let sum = cache.values().fold(0usize, |n, o| n.saturating_add(held_bytes(o, &doc.data, 0)));
|
||||
assert_eq!(cache.owned(), sum, "the kept total is the cache's own");
|
||||
}
|
||||
@@ -341,7 +341,11 @@ impl<'a> Lexer<'a> {
|
||||
let s = std::str::from_utf8(tok).map_err(|_| self.err("bad number"))?;
|
||||
if s.contains('.') {
|
||||
// Tolerate forms like "--5", "5-" or "1.2.3" by parsing the longest valid prefix.
|
||||
let cleaned: String = s.trim_start_matches('+').replacen("--", "-", 1);
|
||||
// Only a doubled sign needs a cleaned copy: a page of InDesign `/PieceInfo` is
|
||||
// thousands of plain reals, and building a copy and a substring search for each
|
||||
// was a large part of reading one (#5873).
|
||||
let t = s.trim_start_matches('+');
|
||||
let cleaned = if t.as_bytes().windows(2).any(|w| w == b"--") { std::borrow::Cow::Owned(t.replacen("--", "-", 1)) } else { t.into() };
|
||||
let v = cleaned.get(..real_prefix_len(cleaned.as_bytes())).and_then(|p| parse_counted::<f64>(p).ok());
|
||||
return Ok(Object::Real(v.unwrap_or(0.0)));
|
||||
}
|
||||
@@ -707,4 +711,13 @@ mod tests {
|
||||
let mut lx = Lexer::new(two.as_bytes(), 0);
|
||||
assert!(lx.object().is_ok() && lx.object().is_ok());
|
||||
}
|
||||
|
||||
/// A plain real is read without a cleaned copy of its token; the forms that need one read
|
||||
/// as they did (#5873).
|
||||
#[test]
|
||||
fn reals_read_the_same_with_and_without_a_doubled_sign() {
|
||||
for (token, want) in [("1.5", 1.5), ("-375.993", -375.993), ("+2.25", 2.25), ("--5.5", -5.5), ("1.2.3", 1.2), ("-.5", -0.5), ("0.0", 0.0)] {
|
||||
assert_eq!(Lexer::new(token.as_bytes(), 0).object().unwrap(), Object::Real(want), "{token}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-25
@@ -23,29 +23,48 @@ pub const LETTER: (f64, f64) = (612.0, 792.0);
|
||||
/// The largest page side PDF allows (ISO 32000-2 Annex C: 14 400 units).
|
||||
const MAX_SIDE: f64 = 14_400.0;
|
||||
|
||||
fn add_page(doc: &mut Document, w: f64, h: f64, resources: Dict, content: Option<Vec<u8>>) -> Result<ObjRef, CreateError> {
|
||||
// `Document::new_empty` always has a page tree.
|
||||
let pages = doc
|
||||
.root()
|
||||
.and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages")))
|
||||
.ok_or_else(|| CreateError::Invalid("the new document has no page tree".into()))?;
|
||||
let mut page = Dict::new();
|
||||
page.set(b"Type".to_vec(), Object::name("Page"));
|
||||
page.set(b"Parent".to_vec(), Object::Ref(pages));
|
||||
page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), Object::Real(w), Object::Real(h)]));
|
||||
page.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
if let Some(c) = content {
|
||||
let s = doc.add(Object::Stream(Stream::flate(Dict::new(), &c)));
|
||||
page.set(b"Contents".to_vec(), Object::Ref(s));
|
||||
/// The page tree of a document being made, with the pages added to it so far: its `/Kids` are
|
||||
/// written once, by [`Pages::finish`]. Adding each page to the tree in the document would copy every
|
||||
/// page before it, which a text of a million form feeds makes quadratic.
|
||||
struct Pages {
|
||||
tree: ObjRef,
|
||||
kids: Vec<Object>,
|
||||
}
|
||||
|
||||
impl Pages {
|
||||
/// The page tree of `doc`, with the pages it has.
|
||||
fn of(doc: &Document) -> Result<Self, CreateError> {
|
||||
// `Document::new_empty` always has a page tree.
|
||||
let tree = doc
|
||||
.root()
|
||||
.and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages")))
|
||||
.ok_or_else(|| CreateError::Invalid("the new document has no page tree".into()))?;
|
||||
let kids = doc.get(tree).as_dict().and_then(|d| d.get(b"Kids")).and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
||||
Ok(Self { tree, kids })
|
||||
}
|
||||
|
||||
fn add(&mut self, doc: &mut Document, w: f64, h: f64, resources: Dict, content: Option<Vec<u8>>) {
|
||||
let mut page = Dict::new();
|
||||
page.set(b"Type".to_vec(), Object::name("Page"));
|
||||
page.set(b"Parent".to_vec(), Object::Ref(self.tree));
|
||||
page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), Object::Real(w), Object::Real(h)]));
|
||||
page.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
if let Some(c) = content {
|
||||
let s = doc.add(Object::Stream(Stream::flate(Dict::new(), &c)));
|
||||
page.set(b"Contents".to_vec(), Object::Ref(s));
|
||||
}
|
||||
self.kids.push(Object::Ref(doc.add(page)));
|
||||
}
|
||||
|
||||
/// Writes the pages added into the tree.
|
||||
fn finish(self, doc: &mut Document) -> Result<(), CreateError> {
|
||||
let count = i64::try_from(self.kids.len()).map_err(|_| CreateError::Invalid("too many pages".into()))?;
|
||||
doc.update_dict(self.tree, |d| {
|
||||
d.set(b"Count".to_vec(), Object::Int(count));
|
||||
d.set(b"Kids".to_vec(), Object::Array(self.kids));
|
||||
})
|
||||
.map_err(|e| CreateError::Invalid(e.to_string()))
|
||||
}
|
||||
let r = doc.add(page);
|
||||
let _ = doc.update_dict(pages, |d| {
|
||||
let mut kids = d.get(b"Kids").and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
||||
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));
|
||||
});
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn set_title(doc: &mut Document, title: &str) {
|
||||
@@ -65,9 +84,11 @@ pub fn blank(width: f64, height: f64, pages: usize) -> Result<Document, CreateEr
|
||||
return Err(CreateError::Invalid("invalid page size or count".into()));
|
||||
}
|
||||
let mut doc = Document::new_empty();
|
||||
let mut tree = Pages::of(&doc)?;
|
||||
for _ in 0..pages {
|
||||
add_page(&mut doc, width, height, Dict::new(), None)?;
|
||||
tree.add(&mut doc, width, height, Dict::new(), None);
|
||||
}
|
||||
tree.finish(&mut doc)?;
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
@@ -675,6 +696,7 @@ pub fn from_images_with_resolution(images: &[(String, Vec<u8>)], resolution: Ima
|
||||
return Err(CreateError::Invalid("no images".into()));
|
||||
}
|
||||
let mut doc = Document::new_empty();
|
||||
let mut tree = Pages::of(&doc)?;
|
||||
let mut profiles = Vec::new();
|
||||
for img in images.iter().map(|(name, bytes)| embed(name, bytes, usize::MAX)).collect::<Result<Vec<_>, _>>()?.into_iter().flatten() {
|
||||
let dpi = match resolution {
|
||||
@@ -692,8 +714,9 @@ pub fn from_images_with_resolution(images: &[(String, Vec<u8>)], resolution: Ima
|
||||
let mut res = Dict::new();
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xobj));
|
||||
let content = format!("q {w:.3} 0 0 {h:.3} 0 0 cm /Im0 Do Q\n").into_bytes();
|
||||
add_page(&mut doc, w, h, res, Some(content))?;
|
||||
tree.add(&mut doc, w, h, res, Some(content));
|
||||
}
|
||||
tree.finish(&mut doc)?;
|
||||
if let Some((name, _)) = images.first() {
|
||||
set_title(&mut doc, name.rsplit_once('.').map_or(name.as_str(), |(s, _)| s));
|
||||
}
|
||||
@@ -787,6 +810,7 @@ fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result
|
||||
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
|
||||
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||||
let fr = doc.add(font);
|
||||
let mut tree = Pages::of(&doc)?;
|
||||
let mut page_lines: Vec<Vec<String>> = vec![Vec::new()];
|
||||
for l in lines.iter().take(lines.len().saturating_sub(1)) {
|
||||
if l == "\u{c}" || page_lines.last().is_some_and(|p| p.len() >= per_page) {
|
||||
@@ -810,8 +834,9 @@ fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result
|
||||
fonts.set(b"F1".to_vec(), Object::Ref(fr));
|
||||
let mut res = Dict::new();
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
add_page(&mut doc, w, h, res, Some(c))?;
|
||||
tree.add(&mut doc, w, h, res, Some(c));
|
||||
}
|
||||
tree.finish(&mut doc)?;
|
||||
set_title(&mut doc, title);
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
@@ -124,6 +124,27 @@ fn text_is_wrapped_and_paginated() {
|
||||
assert!(content(p.last().unwrap()).contains("(After a form feed) Tj"), "a form feed starts a page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_of_many_form_feeds_makes_its_pages_in_linear_time() {
|
||||
// Each form feed starts a page: adding each to the page tree copied all the pages before it,
|
||||
// which made forty thousand of them take over a minute in a debug build, against about two
|
||||
// seconds when the tree is written once.
|
||||
let text = "x\u{c}".repeat(40_000);
|
||||
let start = std::time::Instant::now();
|
||||
let doc = from_text("many", &text, LETTER, 11.0).unwrap();
|
||||
let took = start.elapsed();
|
||||
let tree = doc.get(doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap());
|
||||
let tree = tree.as_dict().unwrap();
|
||||
assert_eq!(tree.get(b"Kids").and_then(|k| k.as_array()).map(|a| a.len()), Some(40_001));
|
||||
assert_eq!(tree.int(b"Count"), Some(40_001));
|
||||
assert!(took < std::time::Duration::from_secs(30), "{took:?}");
|
||||
// Blank documents and pages made from images keep their pages, in order.
|
||||
let doc = reopen(&blank(200.0, 300.0, 3).unwrap());
|
||||
assert_eq!(pages(&doc).len(), 3);
|
||||
let doc = reopen(&from_images(&[("a.png".into(), png_bytes(false)), ("b.png".into(), png_bytes(true))]).unwrap());
|
||||
assert_eq!(pages(&doc).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_keeps_leading_spaces_and_tabs() {
|
||||
let doc = reopen(&from_text("t", " indented\n\ttabbed\nplain", LETTER, 11.0).unwrap());
|
||||
|
||||
@@ -584,6 +584,10 @@ fn sequence_end(ops: &[Op], i: usize, end: usize, opens: &[&str], close: &str) -
|
||||
end
|
||||
}
|
||||
|
||||
/// The stacking units of `ops[scan_start..scan_end]` that Arrange moves past one another, in
|
||||
/// paint order: a `q … Q`, `BT … ET` or marked-content sequence up to its matching close (or the
|
||||
/// end of the scan), a single `Do`, `sh` or inline image, or a run of other operators that ends
|
||||
/// at a path-painting operator or before the next unit. Units that paint nothing are left out.
|
||||
pub(crate) fn content_units(ops: &[Op], scan_start: usize, scan_end: usize) -> Vec<std::ops::Range<usize>> {
|
||||
let mut units = Vec::new();
|
||||
let mut i = scan_start;
|
||||
@@ -598,9 +602,9 @@ pub(crate) fn content_units(ops: &[Op], scan_start: usize, scan_end: usize) -> V
|
||||
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") {
|
||||
} else if op.is("BI") || op.is("Do") || op.is("sh") {
|
||||
// The parser emits a whole inline image (`BI` … `ID` … `EI`) as one `BI` op, so it is a
|
||||
// unit of its own (#3146).
|
||||
i.saturating_add(1)
|
||||
} else {
|
||||
let mut j = i;
|
||||
|
||||
@@ -286,7 +286,12 @@ pub fn expand(template: &str, page_number: u32, count: usize, bates: u64, cx: &C
|
||||
let digits = parts.get(1).and_then(|d| d.parse::<usize>().ok()).unwrap_or(6).clamp(1, 15);
|
||||
format!("{}{:0digits$}{}", parts.get(3).unwrap_or(&""), bates, parts.get(4).unwrap_or(&""))
|
||||
}
|
||||
_ => format!("<<{token}>>"),
|
||||
_ => {
|
||||
// Not a token: emit one `<` and retry, so a later token is still found.
|
||||
out.push('<');
|
||||
rest = &rest[start + 1..];
|
||||
continue;
|
||||
}
|
||||
};
|
||||
out.push_str(&value);
|
||||
rest = &after[end + 2..];
|
||||
@@ -302,3 +307,16 @@ pub(crate) fn bates_start(template: &str) -> Option<u64> {
|
||||
let t = &t[..t.find(">>")?];
|
||||
t.split('#').nth(2).and_then(|s| s.parse().ok()).or(Some(1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod expand_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn expand_token_after_extra_angle_brackets() {
|
||||
let cx = Context::default();
|
||||
assert_eq!(expand("<<<1>>>", 3, 9, 1, &cx), "<3>");
|
||||
assert_eq!(expand("<< <<1>>", 3, 9, 1, &cx), "<< 3");
|
||||
assert_eq!(expand("<<x>> <<1>>", 3, 9, 1, &cx), "<<x>> 3");
|
||||
}
|
||||
}
|
||||
@@ -9813,6 +9813,133 @@ fn content_units_does_not_swallow_subsequent_units_on_standalone_gs() {
|
||||
assert_eq!(units.len(), 2);
|
||||
}
|
||||
|
||||
/// A 1 × 1 grey inline image whose one sample is `data`.
|
||||
fn inline_image(data: char) -> String {
|
||||
format!("BI /W 1 /H 1 /CS /G /BPC 8 ID {data} EI")
|
||||
}
|
||||
|
||||
/// What page 0 paints, in order: `BI x` for an inline image with sample `x`, `Do x,y` for an
|
||||
/// XObject placed by a `cm` translating to x,y, and `Tj` for text. A `cm` lasts until its `Q`, so an
|
||||
/// XObject moved out of its `q`/`Q` wrapper shows as a bare `Do `.
|
||||
fn painted(doc: &Document) -> Vec<String> {
|
||||
let mut at = vec![String::new()];
|
||||
pdfcraft_content::parse(&page_content_bytes(doc, 0))
|
||||
.ops
|
||||
.iter()
|
||||
.filter_map(|op| match op.op.as_slice() {
|
||||
b"q" => {
|
||||
at.push(at.last().unwrap().clone());
|
||||
None
|
||||
}
|
||||
b"Q" => {
|
||||
at.pop();
|
||||
None
|
||||
}
|
||||
b"cm" => {
|
||||
let m = op.nums::<6>().unwrap();
|
||||
*at.last_mut().unwrap() = format!("{},{}", m[4], m[5]);
|
||||
None
|
||||
}
|
||||
b"Do" => Some(format!("Do {}", at.last().unwrap())),
|
||||
b"BI" => Some(format!("BI {}", String::from_utf8_lossy(&op.inline.as_ref().unwrap().1))),
|
||||
b"Tj" => Some("Tj".into()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The index in [`images::page_images`] of the image whose box starts at `x`,`y`.
|
||||
fn image_at(doc: &Document, x: f64, y: f64) -> usize {
|
||||
images::page_images(doc, 0).unwrap().iter().position(|i| (i.rect[0] - x).abs() < 1e-6 && (i.rect[1] - y).abs() < 1e-6).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_units_keep_each_top_level_inline_image_to_itself() {
|
||||
// The parser emits a whole inline image as one `BI` op; its unit must not run on to the end
|
||||
// (#3146). Inline images at the start, side by side and at the end are one unit each.
|
||||
let content = format!(
|
||||
"{} q 200 0 0 100 100 100 cm /Im0 Do Q {} {} q 10 0 0 10 300 300 cm /Im0 Do Q {}",
|
||||
inline_image('a'),
|
||||
inline_image('b'),
|
||||
inline_image('c'),
|
||||
inline_image('d')
|
||||
);
|
||||
let ops = pdfcraft_content::parse(content.as_bytes()).ops;
|
||||
let units = images::content_units(&ops, 0, ops.len());
|
||||
assert_eq!(units, [0..1, 1..5, 5..6, 6..7, 7..11, 11..12]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_units_keep_a_nested_inline_image_inside_its_enclosing_unit() {
|
||||
// A `BI` inside `q`/`Q` or a marked-content sequence stays part of that sequence's unit.
|
||||
let content = format!("q {} Q /Span << /MCID 0 >> BDC {} EMC {}", inline_image('a'), inline_image('b'), inline_image('c'));
|
||||
let ops = pdfcraft_content::parse(content.as_bytes()).ops;
|
||||
let units = images::content_units(&ops, 0, ops.len());
|
||||
assert_eq!(units, [0..3, 3..6, 6..7]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_backward_after_a_top_level_inline_image_moves_one_step() {
|
||||
// Before #3146 the inline image's unit swallowed everything after it, so SendBackward on a later
|
||||
// image acted like SendToBack: it must step behind the text only.
|
||||
let content = format!(
|
||||
"{} 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",
|
||||
inline_image('x')
|
||||
);
|
||||
let mut doc = image_page_with(&content);
|
||||
let front = image_at(&doc, 200.0, 200.0);
|
||||
arrange(&mut doc, 0, front, images::ArrangeOrder::SendBackward).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(painted(&doc), ["BI x", "Do 100,100", "Do 200,200", "Tj"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrange_steps_an_image_past_one_inline_image_at_a_time() {
|
||||
// Before #3146 BringForward on B, inside the first inline image's unit, put it in front of
|
||||
// everything. One step forward is past the next inline image only, and one step back again
|
||||
// is behind that inline image only.
|
||||
let content = format!(
|
||||
"q 100 0 0 100 100 100 cm /Im0 Do Q {} q 150 0 0 150 200 200 cm /Im0 Do Q {} BT /F1 12 Tf 72 700 Td (Text) Tj ET q 50 0 0 50 300 300 cm /Im0 Do Q",
|
||||
inline_image('a'),
|
||||
inline_image('b')
|
||||
);
|
||||
let mut doc = image_page_with(&content);
|
||||
let b = image_at(&doc, 200.0, 200.0);
|
||||
arrange(&mut doc, 0, b, images::ArrangeOrder::BringForward).unwrap();
|
||||
let mut doc = reopen(&doc);
|
||||
assert_eq!(painted(&doc), ["Do 100,100", "BI a", "BI b", "Do 200,200", "Tj", "Do 300,300"]);
|
||||
let b = image_at(&doc, 200.0, 200.0);
|
||||
arrange(&mut doc, 0, b, images::ArrangeOrder::SendBackward).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(painted(&doc), ["Do 100,100", "BI a", "Do 200,200", "BI b", "Tj", "Do 300,300"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrange_around_inline_images_at_the_ends_of_the_stream() {
|
||||
// Before #3146 BringToFront on the last image after an inline image "succeeded" without moving
|
||||
// it. It is already in front, so it is refused and nothing changes.
|
||||
let content =
|
||||
format!("{} q 100 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Text) Tj ET q 150 0 0 150 200 200 cm /Im0 Do Q", inline_image('a'));
|
||||
let mut doc = image_page_with(&content);
|
||||
let before = page_content_bytes(&doc, 0);
|
||||
let front = image_at(&doc, 200.0, 200.0);
|
||||
assert!(arrange(&mut doc, 0, front, images::ArrangeOrder::BringToFront).is_err());
|
||||
assert!(arrange(&mut doc, 0, front, images::ArrangeOrder::BringForward).is_err());
|
||||
assert_eq!(page_content_bytes(&doc, 0), before);
|
||||
// Send to back goes behind the inline image that starts the stream.
|
||||
arrange(&mut doc, 0, front, images::ArrangeOrder::SendToBack).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(painted(&doc), ["Do 200,200", "BI a", "Do 100,100", "Tj"]);
|
||||
|
||||
// Bring to front goes past the inline image that ends the stream.
|
||||
let content = format!("q 100 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Text) Tj ET {}", inline_image('z'));
|
||||
let mut doc = image_page_with(&content);
|
||||
let back = image_at(&doc, 100.0, 100.0);
|
||||
arrange(&mut doc, 0, back, images::ArrangeOrder::BringToFront).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(painted(&doc), ["Tj", "BI z", "Do 100,100"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_inline_soft_masks_have_distinct_identities() {
|
||||
let doc = Document::new_empty();
|
||||
|
||||
@@ -140,6 +140,8 @@ pub enum OfficeFormat {
|
||||
Docx,
|
||||
Pptx,
|
||||
Html,
|
||||
/// CSS-free HTML that preserves text and inline images but drops colours and layout styling.
|
||||
HtmlSimple,
|
||||
Rtf,
|
||||
Markdown,
|
||||
}
|
||||
@@ -150,6 +152,7 @@ impl OfficeFormat {
|
||||
OfficeFormat::Docx => "docx",
|
||||
OfficeFormat::Pptx => "pptx",
|
||||
OfficeFormat::Html => "html",
|
||||
OfficeFormat::HtmlSimple => "html",
|
||||
OfficeFormat::Rtf => "rtf",
|
||||
OfficeFormat::Markdown => "md",
|
||||
}
|
||||
@@ -301,6 +304,7 @@ impl crate::Document {
|
||||
OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title),
|
||||
OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title),
|
||||
OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(),
|
||||
OfficeFormat::HtmlSimple => pdfcraft_export::html_simple(&pages, &title).into_bytes(),
|
||||
OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(),
|
||||
OfficeFormat::Markdown => pdfcraft_export::markdown(&pages).into_bytes(),
|
||||
};
|
||||
|
||||
@@ -92,7 +92,24 @@ impl OptimizeJob {
|
||||
pdfcraft_xfa::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
let mut step = |stage| if progress(stage) { Ok(()) } else { Err(EditError::Cancelled) };
|
||||
step(OptimizeStage::Discarding)?;
|
||||
let discarded = if discard.is_empty() { Vec::new() } else { pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard)? };
|
||||
let discarded = if discard.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
// Every ticked category, as `remove_hidden` decides between them: with form fields
|
||||
// ticked, discarding hidden text also takes the hidden text the flattened appearances
|
||||
// bring onto the page, which no scan before flattening counts. It refuses when its scan
|
||||
// counts every one as 0, which would end the whole optimization, and may have changed
|
||||
// the copy by then (an empty outline root goes with bookmarks), so the copy from before
|
||||
// is kept instead: nothing is discarded.
|
||||
let before = cos.clone();
|
||||
match pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard) {
|
||||
Err(pdfcraft_redact::RedactError::NothingToApply) => {
|
||||
cos = before;
|
||||
Vec::new()
|
||||
}
|
||||
r => r?,
|
||||
}
|
||||
};
|
||||
let report = optimize::optimize_with_progress(&mut cos, &settings, &mut |s| {
|
||||
step(match s {
|
||||
optimize::Stage::Fragments => OptimizeStage::Fragments,
|
||||
|
||||
@@ -55,7 +55,10 @@ impl Document {
|
||||
if w <= 0 || h <= 0 || w > i64::from(MAX_SIDE) || h > i64::from(MAX_SIDE) || w.saturating_mul(h) > MAX_PIXELS as i64 {
|
||||
return Err(SignatureImageError::PlacedSize.to_string());
|
||||
}
|
||||
let (_, bytes) = pdfcraft_create::image_file(&editor.cos, picture)?;
|
||||
// Decoded within an allowance sized for the small picture a signature is, not the
|
||||
// document's: a stream that inflates past it is refused before it is held whole.
|
||||
let (_, bytes) =
|
||||
pdfcraft_create::image_file_within(&editor.cos, picture, &mut pdfcraft_cos::ImageBudget::with_work(SIGNATURE_IMAGE_WORK))?;
|
||||
let image = SignatureImage::from_bytes(&bytes).map_err(|e| e.to_string())?;
|
||||
let opacity =
|
||||
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;
|
||||
@@ -90,6 +93,9 @@ pub fn upright_box(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Opti
|
||||
/// Saved signatures live in settings, so both source and normalized PNG are bounded.
|
||||
pub const MAX_SIGNATURE_IMAGE_BYTES: usize = 4 << 20;
|
||||
const MAX_PIXELS: u64 = 4 << 20;
|
||||
/// Steps of decoding the signature's picture may take: every stage of its filters, for a
|
||||
/// picture of at most [`MAX_PIXELS`] samples, fits in it many times over.
|
||||
const SIGNATURE_IMAGE_WORK: u64 = 1 << 28;
|
||||
const MAX_SIDE: u32 = 4096;
|
||||
/// Larger sources (scans, phone photos) are decoded within these bounds, then scaled down.
|
||||
const MAX_SOURCE_SIDE: u32 = 8192;
|
||||
|
||||
@@ -3610,6 +3610,66 @@ fn create_and_reduce() {
|
||||
assert_eq!(page_texts(&s3, r), ["Page 1", "Page 1"]);
|
||||
}
|
||||
|
||||
/// Discarding a category that holds nothing discards nothing; it used to end the whole
|
||||
/// optimization with an error about redaction marks.
|
||||
#[test]
|
||||
fn optimize_discarding_an_empty_category_still_runs() {
|
||||
let (s, id) = session_with(2);
|
||||
let (bytes, report) = s.optimized_bytes(id, &optimize::Settings::default(), &[crate::Hidden::Bookmarks, crate::Hidden::Comments]).unwrap();
|
||||
assert!(bytes.starts_with(b"%PDF-"));
|
||||
assert!(report.discarded.is_empty(), "{:?}", report.discarded);
|
||||
// An outline root with no entries is no bookmarks: it stays, as Remove Hidden Information,
|
||||
// which refuses then, leaves it.
|
||||
let outline = pdfcraft_testkit::Pdf::new()
|
||||
.obj("<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R /PageMode /UseOutlines >>")
|
||||
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>")
|
||||
.obj("<< /Type /Page /Parent 2 0 R >>")
|
||||
.obj("<< /Type /Outlines /Count 0 >>")
|
||||
.build();
|
||||
let mut s = Session::new();
|
||||
let id = s.open("outline.pdf", None, Arc::new(outline), None).unwrap();
|
||||
let (bytes, report) = s.optimized_bytes(id, &optimize::Settings::default(), &[crate::Hidden::Bookmarks]).unwrap();
|
||||
assert!(report.discarded.is_empty(), "{:?}", report.discarded);
|
||||
let saved = pdfcraft_cos::Document::open(bytes).unwrap();
|
||||
let catalog = saved.get(saved.root().unwrap()).as_dict().cloned().unwrap();
|
||||
assert!(catalog.contains(b"Outlines") && catalog.contains(b"PageMode"), "{catalog:?}");
|
||||
// Beside an empty tick, one that holds something is discarded, and only that one.
|
||||
let noted = pdfcraft_testkit::Pdf::new()
|
||||
.obj("<< /Type /Catalog /Pages 2 0 R >>")
|
||||
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>")
|
||||
.obj("<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>")
|
||||
.obj("<< /Type /Annot /Subtype /Text /Rect [20 20 40 40] /Contents (internal note) >>")
|
||||
.build();
|
||||
let mut s = Session::new();
|
||||
let id = s.open("noted.pdf", None, Arc::new(noted), None).unwrap();
|
||||
let (_, report) = s.optimized_bytes(id, &optimize::Settings::default(), &[crate::Hidden::Bookmarks, crate::Hidden::Comments]).unwrap();
|
||||
assert_eq!(report.discarded, vec![(crate::Hidden::Comments, 1)]);
|
||||
// A field whose appearance draws text invisibly: flattening it brings that text onto the page,
|
||||
// which no scan before counts as hidden, and discarding hidden text takes it too.
|
||||
let form = pdfcraft_testkit::Pdf::new()
|
||||
.obj("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/F1 0 Tf 0 g) >> >>")
|
||||
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>")
|
||||
.obj("<< /Type /Page /Parent 2 0 R /Annots [4 0 R] /Resources << /Font << /F1 6 0 R >> >> /Contents 7 0 R >>")
|
||||
.obj("<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (shown) /Rect [100 100 300 130] /P 3 0 R /F 4 /AP << /N 5 0 R >> >>")
|
||||
.stream(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 200 30] /Resources << /Font << /F1 6 0 R >> >>",
|
||||
"/Tx BMC BT /F1 12 Tf 2 10 Td (shown) Tj ET BT /F1 12 Tf 3 Tr 2 20 Td (HIDDENSECRET) Tj ET EMC",
|
||||
)
|
||||
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
|
||||
.stream("", "BT /F1 12 Tf 72 700 Td (Page text) Tj ET")
|
||||
.build();
|
||||
let mut s = Session::new();
|
||||
let id = s.open("form.pdf", None, Arc::new(form), None).unwrap();
|
||||
let (bytes, _) = s.optimized_bytes(id, &optimize::Settings::default(), &[crate::Hidden::FormFields, crate::Hidden::HiddenText]).unwrap();
|
||||
let saved = pdfcraft_cos::Document::open(bytes).unwrap();
|
||||
for (r, object) in saved.scan_objects() {
|
||||
if let pdfcraft_cos::Object::Stream(st) = &*object {
|
||||
let data = st.decoded().unwrap_or_default();
|
||||
assert!(!data.windows(12).any(|w| w == b"HIDDENSECRET"), "the hidden text stays in {r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_optimize_job_reports_its_stages_and_can_be_cancelled() {
|
||||
use crate::optimizer::OptimizeStage;
|
||||
@@ -10191,3 +10251,20 @@ fn image_transform_engine_refuses_singular_matrices_without_mutation() {
|
||||
assert_eq!(s.get(id).unwrap().bytes, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_sign_multiline_text_survives_engine_save_reopen_and_render() {
|
||||
let (mut session, id) = rotated_blank(0);
|
||||
let text = "\nAda\n\nLovelace\n";
|
||||
let shape = Shape::Typewriter { rect: text_box_at(0, [20.0, 250.0], 120.0, 66.0).unwrap(), font_size: 10.0 };
|
||||
let style = Style::default_for(&shape);
|
||||
session.apply(id, Edit::AddAnnotation(NewAnnotation { page: 0, shape, style, contents: text.into(), author: "Test".into() })).unwrap();
|
||||
assert_eq!(session.get(id).unwrap().info.annotations[0].contents.as_deref(), Some(text), "engine keeps outer typewriter line breaks");
|
||||
let before = render_page(&session, id);
|
||||
assert!(before.rgba.as_chunks::<4>().0.iter().any(|p| p[..3] == [0, 0, 0]), "the appearance contains ink");
|
||||
let bytes = session.save_bytes(id).unwrap();
|
||||
let mut reopened = Session::new();
|
||||
let again = reopened.open("multiline.pdf", None, bytes, None).unwrap();
|
||||
assert_eq!(reopened.get(again).unwrap().info.annotations[0].contents.as_deref(), Some(text), "reopening preserves all blank lines");
|
||||
assert_eq!(before.rgba, render_page(&reopened, again).rgba, "the saved appearance is identical");
|
||||
}
|
||||
@@ -38,11 +38,23 @@ pub(crate) fn base64(data: &[u8]) -> String {
|
||||
/// One HTML file: headings and paragraphs, tables as real `<table>` elements, images inline, a
|
||||
/// rule between pages.
|
||||
pub fn html(pages: &[Page], title: &str) -> String {
|
||||
html_with_mode(pages, title, false)
|
||||
}
|
||||
|
||||
/// A simple HTML export with no CSS; page layout and colours are omitted.
|
||||
pub fn html_simple(pages: &[Page], title: &str) -> String {
|
||||
html_with_mode(pages, title, true)
|
||||
}
|
||||
|
||||
fn html_with_mode(pages: &[Page], title: &str, simple: bool) -> String {
|
||||
let mut s = format!(
|
||||
"<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n<style>body{{max-width:46em;margin:2em auto;font-family:sans-serif;line-height:1.45}}\
|
||||
img{{max-width:100%}}hr{{border:0;border-top:1px solid #ccc;margin:2em 0}}\
|
||||
table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;padding:2px 6px;vertical-align:top}}</style>\n</head>\n<body>\n",
|
||||
esc(title)
|
||||
"<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n{}\n</head>\n<body>\n",
|
||||
esc(title),
|
||||
if simple {
|
||||
""
|
||||
} else {
|
||||
"<style>body{max-width:46em;margin:2em auto;font-family:sans-serif;line-height:1.45}img{max-width:100%}hr{border:0;border-top:1px solid #ccc;margin:2em 0}table{border-collapse:collapse;margin:1em 0}td,th{border:1px solid #999;padding:2px 6px;vertical-align:top}</style>"
|
||||
}
|
||||
);
|
||||
for it in items(pages) {
|
||||
match it {
|
||||
@@ -59,13 +71,13 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi
|
||||
}
|
||||
// The paragraph has its first run's colour; a run in another colour says so.
|
||||
let colour = rgb8(r.block.color);
|
||||
if colour != rgb8(first.color) {
|
||||
if !simple && colour != rgb8(first.color) {
|
||||
let [red, green, blue] = colour;
|
||||
run = format!("<span style=\"color:#{red:02X}{green:02X}{blue:02X}\">{run}</span>");
|
||||
}
|
||||
t.push_str(&run);
|
||||
}
|
||||
let style = hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\""));
|
||||
let style = if simple { String::new() } else { hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) };
|
||||
match lvl {
|
||||
0 => s.push_str(&format!("<p{style}>{t}</p>\n")),
|
||||
l => s.push_str(&format!("<h{l}{style}>{t}</h{l}>\n")),
|
||||
@@ -84,7 +96,7 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi
|
||||
body = format!("<strong>{body}</strong>");
|
||||
}
|
||||
let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() };
|
||||
let style = hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\""));
|
||||
let style = if simple { String::new() } else { hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) };
|
||||
s.push_str(&format!("<td{span}{style}>{body}</td>"));
|
||||
}
|
||||
s.push_str("</tr>\n");
|
||||
|
||||
@@ -27,9 +27,9 @@ pub(crate) use as_docx::hex;
|
||||
#[cfg(test)]
|
||||
use as_docx::{MAX_MEDIA, docx_table};
|
||||
pub(crate) use as_html::esc;
|
||||
pub use as_html::html;
|
||||
#[cfg(test)]
|
||||
use as_html::{allowed, base64};
|
||||
pub use as_html::{html, html_simple};
|
||||
pub use as_markdown::markdown;
|
||||
#[cfg(test)]
|
||||
use as_markdown::{emphasis, paragraph};
|
||||
|
||||
@@ -85,6 +85,24 @@ fn html_has_headings_paragraphs_and_images_in_order() {
|
||||
assert_eq!(base64(b"Ma"), "TWE=");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_html_has_no_css_or_layout_elements() {
|
||||
let mut p = page();
|
||||
let mut unicode = block("naïve 日本語.", 72.0, 300.0, 300.0, 311.0);
|
||||
unicode.bold = true;
|
||||
unicode.italic = true;
|
||||
p.blocks.push(unicode);
|
||||
let source = html_simple(&[p, table_page()], "Simple export");
|
||||
for banned in ["<style", "style=", "<div", "<span"] {
|
||||
assert!(!source.contains(banned), "{banned} in {source}");
|
||||
}
|
||||
for wanted in ["<h1", "<p>", "<strong>", "<em>", "<table", "naïve 日本語."] {
|
||||
assert!(source.contains(wanted), "no {wanted} in {source}");
|
||||
}
|
||||
// The rich export of the same pages keeps its stylesheet: only simple mode drops it.
|
||||
assert!(html(&[page()], "Rich").contains("<style"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docx_is_a_word_package() {
|
||||
let d = docx(&[page()], "Report");
|
||||
|
||||
@@ -227,4 +227,22 @@ mod tests {
|
||||
assert_eq!(parse_num("12abc"), Some(12.0));
|
||||
assert_eq!(parse_num("abc"), None);
|
||||
}
|
||||
|
||||
/// A number prints to at most fifteen significant digits, so the binary noise of a value
|
||||
/// with six digits before the point (279893.88 is 279893.880000000004656… as a double) stays
|
||||
/// hidden, while small values keep their twelve decimals.
|
||||
#[test]
|
||||
fn num_text_prints_at_most_fifteen_significant_digits() {
|
||||
assert_eq!(num_text(279_893.88), "279893.88");
|
||||
assert_eq!(num_text(-279_893.88), "-279893.88");
|
||||
assert_eq!(num_text(279_893.88 + 291_774.12 + 304_171.06 + 317_107.44 + 273_970.85), "1466917.35");
|
||||
assert_eq!(num_text(1_000_000.0 / 3.0), "333333.333333333");
|
||||
assert_eq!(num_text(1.0 / 3.0), "0.333333333333");
|
||||
assert_eq!(num_text(0.3 - 0.1 - 0.2), "0", "noise below twelve decimals is zero");
|
||||
// Past fifteen digits before the point no decimals are left, and the integer keeps its zeros.
|
||||
assert_eq!(num_text(1e15 + 0.5), "1000000000000000");
|
||||
assert_eq!(num_text(1e20), "100000000000000000000");
|
||||
assert_eq!(num_text(-1e20), "-100000000000000000000");
|
||||
assert_eq!(num_text(f64::MAX).len(), 309);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,11 @@ pub(super) enum Val {
|
||||
Sink,
|
||||
}
|
||||
|
||||
/// A number as FormCalc prints it: integers without a fraction, otherwise up to 15 digits.
|
||||
/// A number as FormCalc prints it: integers without a fraction, otherwise to at most 12
|
||||
/// decimals and 15 significant digits, without trailing zeros. 15 digits is the most a double
|
||||
/// holds for every value, so a value typed with up to 15 digits prints back as typed
|
||||
/// (`279893.88 * 1` is `279893.88`, not `279893.880000000005`), and the binary noise past
|
||||
/// them never shows. The 12 decimals hide noise left by sums that should be zero.
|
||||
pub(super) fn num_text(n: f64) -> String {
|
||||
if !n.is_finite() {
|
||||
return if n.is_nan() {
|
||||
@@ -35,8 +39,12 @@ pub(super) fn num_text(n: f64) -> String {
|
||||
if n.fract() == 0.0 && n.abs() < 1e15 {
|
||||
return format!("{}", n as i64);
|
||||
}
|
||||
let s = format!("{n:.12}");
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
// Digits before the point (at most 309 for a finite double); 1 for |n| < 1.
|
||||
let int_digits = format!("{:.0}", n.abs().trunc()).len();
|
||||
let decimals = 15usize.saturating_sub(int_digits).min(12);
|
||||
let s = format!("{n:.decimals$}");
|
||||
// Only a fraction loses its trailing zeros: with no decimals left they belong to the integer.
|
||||
let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.') } else { s.as_str() };
|
||||
if s.is_empty() || s == "-0" { "0".into() } else { s.to_string() }
|
||||
}
|
||||
|
||||
|
||||
@@ -1026,6 +1026,20 @@ mod formcalc {
|
||||
assert_eq!(calc(r#"Format("9.9", -1.5)"#), "-1.5");
|
||||
}
|
||||
|
||||
/// A calculated amount shows the number it is, not the binary noise past a double's 15
|
||||
/// significant digits: 279893.88 is stored as 279893.880000000004656…, which twelve fixed
|
||||
/// decimals printed as `279893.880000000005`. Small values keep their twelve decimals.
|
||||
#[test]
|
||||
fn calculated_numbers_print_at_most_fifteen_significant_digits() {
|
||||
assert_eq!(calc("1 * 279893.88"), "279893.88");
|
||||
assert_eq!(calc("1 * 291774.12"), "291774.12");
|
||||
assert_eq!(calc("279893.88 + 291774.12 + 304171.06 + 317107.44 + 273970.85"), "1466917.35");
|
||||
assert_eq!(calc("1000000 / 3"), "333333.333333333");
|
||||
assert_eq!(calc("1000 / 3"), "333.333333333333");
|
||||
assert_eq!(calc("1 / 3"), "0.333333333333");
|
||||
assert_eq!(calc("0.1 + 0.2"), "0.3");
|
||||
}
|
||||
|
||||
/// Assigning one long value to many fields at once stops at the run's byte budget and is
|
||||
/// charged per copy, and a reset list names each field once.
|
||||
#[test]
|
||||
|
||||
@@ -28,9 +28,10 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
|
||||
If a use can't be
|
||||
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.
|
||||
stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image. A
|
||||
stencil `/Mask` has its own resolution, so the picture changes under it and it stays as it is.
|
||||
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,
|
||||
settings: CMYK and other colour spaces, image masks, colour-key masks, decode arrays, more than 8 bits, JPEG 2000,
|
||||
JBIG2 and CCITT (black-and-white images, CCITT and JBIG2 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
|
||||
|
||||
@@ -717,6 +717,11 @@ fn image_stream(old: &Dict, px: &[u8], n: usize, w: u32, h: u32, compression: Co
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `/Mask` value is a stencil mask: an image that says `/ImageMask true`.
|
||||
fn is_stencil(doc: &Document, mask: &Object) -> bool {
|
||||
matches!(&*doc.resolve(mask), Object::Stream(s) if matches!(s.dict.get(b"ImageMask"), Some(Object::Bool(true))))
|
||||
}
|
||||
|
||||
/// One image: resample and/or recompress per `settings`; `ppi` is its effective resolution.
|
||||
/// Returns the new image and its new soft mask, if the result is smaller.
|
||||
fn process(
|
||||
@@ -728,8 +733,11 @@ fn process(
|
||||
budget: &mut ImageBudget,
|
||||
) -> Option<(Stream, Option<(ObjRef, Stream)>)> {
|
||||
let d = &s.dict;
|
||||
// A colour-key mask names sample values, which resampling and JPEG would change. A stencil mask
|
||||
// is an image of its own, with its own resolution (ISO 32000-2 §8.9.6.3): the picture can change
|
||||
// under it and its edges stay as they are.
|
||||
if d.get(b"Decode").is_some_and(|a| !is_identity_decode(doc, a, n))
|
||||
|| d.contains(b"Mask")
|
||||
|| d.get(b"Mask").is_some_and(|m| !is_stencil(doc, m))
|
||||
|| d.name(b"ImageMask").is_some()
|
||||
|| matches!(d.get(b"ImageMask"), Some(Object::Bool(true)))
|
||||
{
|
||||
|
||||
@@ -3788,3 +3788,102 @@ fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margin
|
||||
assert_eq!(render(&saved), before, "local halftone {side}x{side}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-bit stencil of `w` × `h`, solid but for a hole in its middle.
|
||||
fn stencil(doc: &mut Document, w: u32, h: u32) -> ObjRef {
|
||||
let row = (w as usize).div_ceil(8);
|
||||
let mut bits = vec![0u8; row * h as usize];
|
||||
for y in (h as usize / 3)..(2 * h as usize / 3) {
|
||||
for x in (w as usize / 3)..(2 * w as usize / 3) {
|
||||
bits[y * row + x / 8] |= 0x80 >> (x % 8);
|
||||
}
|
||||
}
|
||||
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"ImageMask".to_vec(), Object::Bool(true));
|
||||
d.set(b"Width".to_vec(), Object::Int(i64::from(w)));
|
||||
d.set(b"Height".to_vec(), Object::Int(i64::from(h)));
|
||||
d.set(b"BitsPerComponent".to_vec(), Object::Int(1));
|
||||
doc.add(Object::Stream(Stream::flate(d, &bits)))
|
||||
}
|
||||
|
||||
/// A picture with a stencil mask is made smaller like any other: the mask has a resolution of its
|
||||
/// own, so it stays exactly as it was and the hole it cuts stays where it was.
|
||||
#[test]
|
||||
fn a_picture_with_a_stencil_mask_is_downsampled_under_its_mask() {
|
||||
let mut doc = Document::new_empty();
|
||||
let mask = stencil(&mut doc, 1200, 1200);
|
||||
let img = image(&mut doc, 1200, 1200, 3, true, None);
|
||||
let mut s = stream(&doc, img);
|
||||
s.dict.set(b"Mask".to_vec(), Object::Ref(mask));
|
||||
doc.set(img, Object::Stream(s));
|
||||
let mask_before = stream(&doc, mask);
|
||||
// 1200 pixels over two inches: 600 ppi.
|
||||
page(&mut doc, &[("Im0", img)], "0 0 1 rg 0 0 612 792 re f q 144 0 0 144 100 100 cm /Im0 Do Q");
|
||||
let before = render(&doc);
|
||||
let report = optimize(&mut doc, &Settings::default()).unwrap();
|
||||
assert_eq!(report.images_resampled, 1, "{report:?}");
|
||||
let after = stream(&doc, img);
|
||||
assert_eq!(after.dict.int(b"Width"), Some(300), "150 ppi");
|
||||
assert_eq!(after.dict.reference(b"Mask"), Some(mask), "the mask stays on it");
|
||||
assert_eq!(stream(&doc, mask), mask_before, "and doesn't change");
|
||||
let rendered = render(&doc);
|
||||
assert_eq!(rendered.len(), before.len());
|
||||
let changed = before.iter().zip(&rendered).filter(|(a, b)| a.abs_diff(**b) > 96).count();
|
||||
assert!(changed * 200 < before.len(), "the masked picture looks the same: {changed} of {} samples far off", before.len());
|
||||
// The hole, the middle third of the picture on the 612 by 792 page, still shows what's behind it.
|
||||
// Its first pixel all round is left out: the coarser picture blends into that a little.
|
||||
for y in 597..643 {
|
||||
for x in 149..195 {
|
||||
let i = (y * 612 + x) * 4;
|
||||
assert_eq!(rendered[i..i + 4], before[i..i + 4], "hole pixel ({x}, {y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour-key mask names sample values: resampling or JPEG would make holes where there were none
|
||||
/// and fill those there were, so the picture stays as it is, however the array is written. A mask
|
||||
/// that can't be read as a stencil is no reason to change the picture either.
|
||||
#[test]
|
||||
fn a_picture_whose_mask_is_not_a_stencil_is_left_alone() {
|
||||
type Mask = fn(&mut Document) -> Object;
|
||||
fn key() -> Object {
|
||||
Object::Array([250, 255, 250, 255, 250, 255].map(Object::Int).to_vec())
|
||||
}
|
||||
fn not_a_stencil(doc: &mut Document, change: fn(&mut Dict)) -> Object {
|
||||
let mask = stencil(doc, 1200, 1200);
|
||||
let mut s = stream(doc, mask);
|
||||
change(&mut s.dict);
|
||||
doc.set(mask, Object::Stream(s));
|
||||
Object::Ref(mask)
|
||||
}
|
||||
let masks: [(&str, Mask); 5] = [
|
||||
("direct array", |_| key()),
|
||||
("array by reference", |doc| Object::Ref(doc.add(key()))),
|
||||
("nothing there", |doc| Object::Ref(doc.add(Object::Null))),
|
||||
("an image that says it isn't a stencil", |doc| {
|
||||
not_a_stencil(doc, |d| {
|
||||
d.set(b"ImageMask".to_vec(), Object::Bool(false));
|
||||
})
|
||||
}),
|
||||
("an image that doesn't say it is one", |doc| {
|
||||
not_a_stencil(doc, |d| {
|
||||
d.remove(b"ImageMask");
|
||||
})
|
||||
}),
|
||||
];
|
||||
for (what, mask) in masks {
|
||||
let mut doc = Document::new_empty();
|
||||
let img = image(&mut doc, 1200, 1200, 3, true, None);
|
||||
let mut s = stream(&doc, img);
|
||||
let mask = mask(&mut doc);
|
||||
s.dict.set(b"Mask".to_vec(), mask);
|
||||
doc.set(img, Object::Stream(s));
|
||||
let before = stream(&doc, img);
|
||||
page(&mut doc, &[("Im0", img)], "q 144 0 0 144 100 100 cm /Im0 Do Q");
|
||||
let report = optimize(&mut doc, &Settings::default()).unwrap();
|
||||
assert_eq!(report.images_resampled + report.images_recompressed, 0, "{what}: {report:?}");
|
||||
assert_eq!(stream(&doc, img), before, "{what}");
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,9 @@ Headless `doc_print` defaults are unchanged.
|
||||
`spool` delegates discovery, driver options, preferences and submission to
|
||||
[`craft-print`](https://github.com/storytold/craft-libs/tree/main/crates/print).
|
||||
PdfCraft retains page selection, imposition, annotations and print-permission checks.
|
||||
CUPS jobs stream through stdin with `fit-to-page=false`. Windows renders sheets through
|
||||
CUPS jobs stream through stdin with `fit-to-page=false`; on macOS they also carry the job
|
||||
name and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`) as the system print dialog
|
||||
sends them, which some vendors' filters need (#5743). Windows renders sheets through
|
||||
`Windows.Data.Pdf` and spools them at their physical PDF size with per-page paper and
|
||||
orientation, zero added margins and hardware-edge clipping. Long-edge and short-edge duplex
|
||||
use the corresponding Windows binding edges. Random, exclusively created Windows job files
|
||||
|
||||
@@ -143,14 +143,62 @@ pub(crate) fn printer_options_via_within(
|
||||
options
|
||||
}
|
||||
|
||||
/// The keys the macOS print dialog names a job's title and owner under.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
const JOB_NAME_KEY: &str = "com.apple.print.JobInfo.PMJobName";
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
const JOB_OWNER_KEY: &str = "com.apple.print.JobInfo.PMJobOwner";
|
||||
|
||||
/// `text` as a job option's value: `lp_args` passes on only printable ASCII, up to 64 bytes,
|
||||
/// without `/ : = "`, and CUPS would take `'` and `\` as quoting and `{` as the start of a
|
||||
/// collection, so all of these become `_`. Text with nothing left of it, such as a title in
|
||||
/// another script, takes `fallback`: the job must still name something. So does a bare `true` or
|
||||
/// `false`, which CUPS would send as a boolean, not the name a filter reads.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
fn job_option_value(text: &str, fallback: &str) -> String {
|
||||
let kept = |c: char| c.is_ascii_graphic() && !matches!(c, '/' | ':' | '=' | '"' | '\'' | '\\' | '{');
|
||||
let value: String = text.chars().take(64).map(|c| if kept(c) { c } else { '_' }).collect();
|
||||
if !text.chars().any(kept) || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("false") {
|
||||
return fallback.to_string();
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
/// `job` naming its title and `owner` as a job from the macOS print dialog does. `lp` sends
|
||||
/// neither, and a driver's filter can depend on them: Kyocera's crashes without them, so nothing
|
||||
/// prints, while the same file sent with both does (#5743). A value the caller set stands: the
|
||||
/// last of a repeated option wins in CUPS, which compares their names without case.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
pub(crate) fn with_job_info(job: &Job, owner: Option<&str>) -> Job {
|
||||
let mut named = job.clone();
|
||||
for (key, value) in [(JOB_NAME_KEY, job.title.as_str()), (JOB_OWNER_KEY, owner.unwrap_or_default())] {
|
||||
if !job.options.iter().any(|(k, _)| k.eq_ignore_ascii_case(key)) {
|
||||
named.options.push((key.to_string(), job_option_value(value, "PdfCraft")));
|
||||
}
|
||||
}
|
||||
named
|
||||
}
|
||||
|
||||
/// The job as the system spooler takes it.
|
||||
fn spooled(job: &Job) -> Job {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
with_job_info(job, std::env::var("USER").ok().as_deref())
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
job.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submit(pdf: &[u8], job: &Job) -> Result<String, crate::PrintError> {
|
||||
craft_print::submit(pdf, job).map_err(|e| crate::PrintError::Spool(e.to_string()))
|
||||
craft_print::submit(pdf, &spooled(job)).map_err(|e| crate::PrintError::Spool(e.to_string()))
|
||||
}
|
||||
|
||||
/// [`submit`], told `(sent, sheets)` as each sheet reaches the spooler (Windows; CUPS takes the
|
||||
/// whole PDF at once and reports nothing).
|
||||
pub fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usize, usize)) -> Result<String, crate::PrintError> {
|
||||
craft_print::submit_with_progress(pdf, job, progress).map_err(|e| crate::PrintError::Spool(e.to_string()))
|
||||
craft_print::submit_with_progress(pdf, &spooled(job), progress).map_err(|e| crate::PrintError::Spool(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn open_printer_preferences(printer: &str) -> Result<(), crate::PrintError> {
|
||||
|
||||
@@ -305,6 +305,107 @@ fn spooler_arguments_and_printer_list() {
|
||||
assert_eq!(lp_args(&Job::default())[0], "-n", "no -d: the default printer");
|
||||
}
|
||||
|
||||
/// The job name and owner, read from the `lp` arguments, since `lp_args` drops a value it can't
|
||||
/// pass on: the job must reach the spooler named, whatever its title holds (#5743).
|
||||
#[test]
|
||||
fn a_job_names_its_title_and_owner_to_the_spooler() {
|
||||
let args_for = |title: &str, owner: Option<&str>| {
|
||||
let job = Job { title: title.into(), ..Job::default() };
|
||||
lp_args(&spool::with_job_info(&job, owner))
|
||||
};
|
||||
let option = |args: &[String], key: &str| {
|
||||
let prefix = format!("com.apple.print.JobInfo.{key}=");
|
||||
args.iter().find_map(|a| a.strip_prefix(prefix.as_str()).map(str::to_string))
|
||||
};
|
||||
let args = args_for("memo.pdf", Some("sam"));
|
||||
assert_eq!(option(&args, "PMJobName").as_deref(), Some("memo.pdf"), "{args:?}");
|
||||
assert_eq!(option(&args, "PMJobOwner").as_deref(), Some("sam"), "{args:?}");
|
||||
// The title keeps its place as `-t`, in full.
|
||||
assert!(args_for("Q3 report: a/b=\"c\".pdf", None).windows(2).any(|w| w[0] == "-t" && w[1] == "Q3 report: a/b=\"c\".pdf"));
|
||||
|
||||
let named = args_for("Q3 report: a/b=\"c\".pdf", None);
|
||||
assert_eq!(option(&named, "PMJobName").as_deref(), Some("Q3_report__a_b__c_.pdf"), "what lp can't be given becomes _");
|
||||
assert_eq!(option(&named, "PMJobOwner").as_deref(), Some("PdfCraft"), "no owner known: the filter still gets one");
|
||||
|
||||
for nothing_left in ["", " ", "見積書", "/:=\""] {
|
||||
assert_eq!(option(&args_for(nothing_left, Some("")), "PMJobName").as_deref(), Some("PdfCraft"), "{nothing_left:?}");
|
||||
}
|
||||
assert_eq!(option(&args_for("見積書2.pdf", None), "PMJobName").as_deref(), Some("___2.pdf"), "a part of it is kept");
|
||||
let long = option(&args_for(&format!("a{}", "é".repeat(200)), None), "PMJobName").unwrap_or_default();
|
||||
assert_eq!(long.len(), 64, "cut to what lp_args takes, on a multi-byte title too: {long}");
|
||||
|
||||
// What CUPS would quote away or send as another type, as this Mac's libcups does.
|
||||
for (title, name) in [("it's.pdf", "it_s.pdf"), (r"a\b.pdf", "a_b.pdf"), ("{draft}.pdf", "_draft}.pdf"), ("a{b", "a_b")] {
|
||||
assert_eq!(option(&args_for(title, None), "PMJobName").as_deref(), Some(name), "{title:?}");
|
||||
}
|
||||
for boolean in ["true", "TRUE", "false", "False"] {
|
||||
assert_eq!(option(&args_for(boolean, Some(boolean)), "PMJobName").as_deref(), Some("PdfCraft"), "{boolean:?}");
|
||||
assert_eq!(option(&args_for(boolean, Some(boolean)), "PMJobOwner").as_deref(), Some("PdfCraft"), "{boolean:?}");
|
||||
}
|
||||
assert_eq!(option(&args_for("true.pdf", None), "PMJobName").as_deref(), Some("true.pdf"), "only the bare word is a boolean");
|
||||
}
|
||||
|
||||
/// A job's own values stand: CUPS keeps the last of a repeated option, ignoring the case of its
|
||||
/// name, so a default after them would replace what a `doc_print` caller set (#5743).
|
||||
#[test]
|
||||
fn a_job_keeps_the_title_and_owner_it_already_sets() {
|
||||
let values = |job: &Job, key: &str| -> Vec<String> {
|
||||
let found = spool::with_job_info(job, Some("sam")).options;
|
||||
found.into_iter().filter(|(k, _)| k.eq_ignore_ascii_case(key)).map(|(_, v)| v).collect()
|
||||
};
|
||||
let (name, owner) = ("com.apple.print.JobInfo.PMJobName", "com.apple.print.JobInfo.PMJobOwner");
|
||||
let job = Job { title: "memo.pdf".into(), options: vec![(name.to_string(), "Mine".to_string())], ..Job::default() };
|
||||
assert_eq!(values(&job, name), ["Mine"]);
|
||||
assert_eq!(values(&job, owner), ["sam"], "the one it left out still gets its default");
|
||||
|
||||
let job = Job { title: "memo.pdf".into(), options: vec![(owner.to_ascii_uppercase(), "Theirs".to_string())], ..Job::default() };
|
||||
assert_eq!(values(&job, owner), ["Theirs"]);
|
||||
assert_eq!(values(&job, name), ["memo.pdf"]);
|
||||
}
|
||||
|
||||
/// Both ways to submit reach `lp` with the job named. `lp` comes from `PATH`, which only a child
|
||||
/// process can change here, so the test runs itself again with a stand-in that records its
|
||||
/// arguments; no job reaches a real spooler (#5743).
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn a_submitted_job_reaches_lp_named() {
|
||||
const TEST: &str = "tests::a_submitted_job_reaches_lp_named";
|
||||
if let Ok(way) = std::env::var("PDFCRAFT_SUBMIT_TO_STAND_IN") {
|
||||
let job = Job { title: "memo.pdf".into(), ..Job::default() };
|
||||
let sent = match way.as_str() {
|
||||
"submit" => spool::submit(b"%PDF-1.7", &job),
|
||||
_ => spool::submit_with_progress(b"%PDF-1.7", &job, &mut |_, _| {}),
|
||||
};
|
||||
assert!(sent.is_ok(), "{sent:?}");
|
||||
return;
|
||||
}
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-lp-stand-in-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
let lp = dir.join("lp");
|
||||
let recorded = dir.join("args");
|
||||
let script =
|
||||
format!("#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\n/bin/cat > /dev/null\necho 'request id is stand-in-1 (1 file(s))'\n", recorded.display());
|
||||
std::fs::write(&lp, script).expect("stand-in");
|
||||
std::fs::set_permissions(&lp, std::fs::Permissions::from_mode(0o700)).expect("executable");
|
||||
for way in ["submit", "submit_with_progress"] {
|
||||
let _ = std::fs::remove_file(&recorded);
|
||||
let this = std::env::current_exe().expect("this test binary");
|
||||
let child = std::process::Command::new(this)
|
||||
.args(["--exact", TEST, "--nocapture"])
|
||||
.env("PDFCRAFT_SUBMIT_TO_STAND_IN", way)
|
||||
.env("PATH", &dir)
|
||||
.env("USER", "sam")
|
||||
.output()
|
||||
.expect("run the test again");
|
||||
assert!(child.status.success(), "{way}: {}", String::from_utf8_lossy(&child.stdout));
|
||||
let args = std::fs::read_to_string(&recorded).expect("lp was run");
|
||||
assert!(args.lines().any(|a| a == "com.apple.print.JobInfo.PMJobName=memo.pdf"), "{way}: {args}");
|
||||
assert!(args.lines().any(|a| a == "com.apple.print.JobInfo.PMJobOwner=sam"), "{way}: {args}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lpstat_output_is_untranslated() {
|
||||
// A localized lpstat (here Polish) is unreadable to parse_lpstat...
|
||||
|
||||
@@ -861,6 +861,11 @@ fn xobject(
|
||||
}
|
||||
let mut dict = s.dict.clone();
|
||||
dict.remove(b"Length");
|
||||
if new_data.is_some() {
|
||||
// As for page content streams: the source text of an added item (Edit ▸ Add
|
||||
// content) would keep what the rewrite removed.
|
||||
dict.remove(b"PCAdded");
|
||||
}
|
||||
let mut cleaned_res = res.clone();
|
||||
// Install rewritten nested forms before finding live property names: a new form may
|
||||
// still inherit its caller's resources, and its new Do name must resolve here.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Marked content (MCIDs) that applying redactions changed is found by comparing each MCID's
|
||||
//! operators before and after. Elements owning such content lose their alternate text, actual
|
||||
//! text and expansion (which may spell out the removed words);
|
||||
//! text, title and expansion (which may spell out the removed words), as do their ancestors;
|
||||
//! references to marked content that is now empty are dropped from the element.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -281,7 +281,7 @@ pub(crate) fn clean_within(
|
||||
}
|
||||
doc.update_dict(r, |e| {
|
||||
if sensitive {
|
||||
for key in [&b"Alt"[..], b"ActualText", b"E"] {
|
||||
for key in DESCRIPTIONS {
|
||||
e.remove(key);
|
||||
}
|
||||
}
|
||||
@@ -321,8 +321,10 @@ impl Above {
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys of an element that describe its content.
|
||||
const DESCRIPTIONS: [&[u8]; 3] = [b"Alt", b"ActualText", b"E"];
|
||||
/// The keys of an element that describe its content, and so may repeat what was removed: one
|
||||
/// policy for the element that owned it, the elements above it and elements written in place.
|
||||
/// The title (`/T`) is included, as producers often fill it with the element's text.
|
||||
const DESCRIPTIONS: [&[u8]; 4] = [b"Alt", b"ActualText", b"T", b"E"];
|
||||
|
||||
/// Each element's parents as the walk found them: through `/K`, which `/P` may not agree with.
|
||||
struct Parents(HashMap<ObjRef, Vec<Above>>);
|
||||
@@ -539,7 +541,7 @@ pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet<ObjRef>
|
||||
// An element that lost an annotation keeps no text describing it, even with other
|
||||
// kids left: a link's alternate text often repeats the URL, an annotation's its
|
||||
// comment. Elements above it may describe it too.
|
||||
for key in [&b"Alt"[..], b"ActualText", b"T", b"E"] {
|
||||
for key in DESCRIPTIONS {
|
||||
e.remove(key);
|
||||
}
|
||||
})?;
|
||||
|
||||
@@ -3871,3 +3871,29 @@ fn a_reply_on_another_page_to_a_removed_popup_goes_too() {
|
||||
assert!(!bytes.windows(gone.len()).any(|w| w == gone), "{} survived", String::from_utf8_lossy(gone));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rewritten_form_xobject_drops_the_source_text_of_added_content() {
|
||||
// A form drawing invisible text whose dictionary repeats it in /PCAdded.
|
||||
let form = stream(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >> /PCMark /Added /PCAdded << /Kind /Text /Text (LEAK_FORM_SOURCE) >>",
|
||||
b"BT /F1 10 Tf 3 Tr 10 100 Td (HIDDEN) Tj ET BT /F1 10 Tf 10 200 Td (Shown) Tj ET",
|
||||
);
|
||||
let mut doc = one_page(b"/X Do", "/XObject << /X 7 0 R >>", vec![form]);
|
||||
sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap();
|
||||
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||||
assert!(!bytes.windows(16).any(|w| w == b"LEAK_FORM_SOURCE"), "the form's added-content source text survived");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_redacted_form_xobject_drops_the_source_text_of_added_content() {
|
||||
let form = stream(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >> /PCMark /Added /PCAdded << /Kind /Text /Text (AB1234CD) >>",
|
||||
b"BT /F1 10 Tf 10 100 Td (AB1234CD) Tj ET",
|
||||
);
|
||||
let mut doc = one_page(b"/X Do", "/XObject << /X 7 0 R >>", vec![form]);
|
||||
mark(&mut doc, 0, &[[20.0, 95.0, 40.0, 110.0]], "");
|
||||
apply(&mut doc, None).unwrap();
|
||||
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||||
assert!(!bytes.windows(4).any(|w| w == b"1234"), "redacted text left in the saved file");
|
||||
}
|
||||
@@ -62,3 +62,61 @@ fn a_link_element_that_keeps_its_text_loses_the_description_of_its_removed_annot
|
||||
let link = doc.get(ObjRef::new(11, 0)).as_dict().cloned().unwrap();
|
||||
assert_eq!(link.get(b"K"), Some(&Object::Array(vec![Object::Int(0)])), "the element keeps its text");
|
||||
}
|
||||
|
||||
/// [`sectioned`], with titles that repeat the secret on the paragraph that owns it, on its section
|
||||
/// and on the document element above both.
|
||||
fn titled() -> Document {
|
||||
let mut doc = sectioned("11 0 R");
|
||||
let title = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t.as_bytes().to_vec()));
|
||||
for (n, t) in [(8, "LEAK_DOC_TITLE"), (9, "LEAK_OWN_TITLE"), (11, "LEAK_SECT_TITLE"), (10, "Public title")] {
|
||||
doc.update_dict(ObjRef::new(n, 0), |d| d.set(b"T".to_vec(), title(t))).unwrap();
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn titles_repeating_redacted_content_go_from_the_owner_and_its_ancestors() {
|
||||
let mut doc = titled();
|
||||
mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], "");
|
||||
apply(&mut doc, None).unwrap();
|
||||
assert!(!full_save_holds(&doc, b"LEAK_"), "a structure title repeating the redacted word survived");
|
||||
let public = doc.get(ObjRef::new(10, 0)).as_dict().cloned().unwrap();
|
||||
assert!(public.contains(b"T"), "an element beside it keeps its own title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn titles_repeating_hidden_text_go_from_the_owner_and_its_ancestors() {
|
||||
use crate::sanitize::{Hidden, remove_hidden};
|
||||
let mut doc = titled();
|
||||
let hidden =
|
||||
b"/P <</MCID 0>> BDC BT /F1 10 Tf 3 Tr 10 200 Td (SECRET) Tj ET EMC /P <</MCID 1>> BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC";
|
||||
doc.set(ObjRef::new(4, 0), Object::Stream(Stream::from_raw(Dict::new(), hidden.to_vec())));
|
||||
remove_hidden(&mut doc, &[Hidden::HiddenText]).unwrap();
|
||||
assert!(!full_save_holds(&doc, b"SECRET"), "the hidden text survived");
|
||||
assert!(!full_save_holds(&doc, b"LEAK_"), "a structure title repeating the hidden text survived");
|
||||
let public = doc.get(ObjRef::new(10, 0)).as_dict().cloned().unwrap();
|
||||
assert!(public.contains(b"T"), "an element beside it keeps its own title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ancestor_title_describing_a_removed_annotation_goes() {
|
||||
use crate::sanitize::{Hidden, remove_hidden};
|
||||
let mut doc = pdf(vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 8 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 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] /StructParents 0 >>"
|
||||
.to_vec(),
|
||||
stream("", b"/Link << /MCID 0 >> BDC BT /F1 10 Tf 10 230 Td (See the site) Tj ET EMC"),
|
||||
FONT.replace("95 0 R", "12 0 R").into_bytes(),
|
||||
b"<< /Type /Annot /Subtype /Link /Rect [10 228 80 240] /StructParent 1 /A << /S /URI /URI (https://example.org/) >> >>".to_vec(),
|
||||
b"null".to_vec(),
|
||||
b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [0 [11 0 R] 1 11 0 R] >> /ParentTreeNextKey 2 >>".to_vec(),
|
||||
b"<< /Type /StructElem /S /Document /P 8 0 R /K 10 0 R >>".to_vec(),
|
||||
// Only a title above the link element repeats the address.
|
||||
b"<< /Type /StructElem /S /P /P 9 0 R /T (LEAK_PARENT_TITLE) /K 11 0 R >>".to_vec(),
|
||||
b"<< /Type /StructElem /S /Link /P 10 0 R /Pg 3 0 R /K [0 << /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(),
|
||||
widths(),
|
||||
]);
|
||||
remove_hidden(&mut doc, &[Hidden::LinksActionsScripts]).unwrap();
|
||||
assert!(!full_save_holds(&doc, b"LEAK_"), "an ancestor's title describing the removed link survived");
|
||||
}
|
||||
@@ -288,11 +288,23 @@ impl<'a> Inspector<'a> {
|
||||
}
|
||||
|
||||
pub(super) fn text(&self, d: &Dictionary, key: &[u8]) -> Option<String> {
|
||||
let s = trimmed_text(self.resolve(d.get(key).ok()?))?;
|
||||
(!s.is_empty()).then_some(s)
|
||||
// Typewriter outer whitespace belongs to the text (keep in step with annot's summaries).
|
||||
let s = if key == b"Contents"
|
||||
&& self.name(d, b"Subtype").as_deref() == Some("FreeText")
|
||||
&& self.name(d, b"IT").as_deref() == Some("FreeTextTypeWriter")
|
||||
{
|
||||
match self.resolve(d.get(key).ok()?) {
|
||||
Object::String(bytes, _) => text_string(bytes).trim_matches('\0').to_owned(),
|
||||
Object::Name(bytes) => String::from_utf8_lossy(bytes).trim_matches('\0').to_owned(),
|
||||
_ => return None,
|
||||
}
|
||||
} else {
|
||||
trimmed_text(self.resolve(d.get(key).ok()?))?
|
||||
};
|
||||
(!s.trim().is_empty()).then_some(s)
|
||||
}
|
||||
|
||||
/// [`Self::text`], converted and spent from `budget` as [`spend`] does, so text that trims
|
||||
/// Whitespace-trimmed metadata, spent from `budget` as [`spend`] does, so text that trims
|
||||
/// short or to nothing still spends its raw length.
|
||||
pub(super) fn budgeted_text(&self, d: &Dictionary, key: &[u8], budget: &mut usize) -> Option<String> {
|
||||
let o = self.resolve(d.get(key).ok()?);
|
||||
|
||||
@@ -1870,3 +1870,45 @@ trailer << /Root 1 0 R >>
|
||||
assert!((p.width - 3874.96).abs() < 0.1, "width expected ~3874.96, got {}", p.width);
|
||||
assert!((p.height - 5493.54).abs() < 0.1, "height expected ~5493.54, got {}", p.height);
|
||||
}
|
||||
|
||||
/// Inspection keeps every object it reads, converted, for as long as it runs, and nothing it
|
||||
/// reports comes from `/PieceInfo`. InDesign writes kilobytes of that private data into every
|
||||
/// page, and a 1,412-page book held over a gigabyte of it (#5873). It is left out of what
|
||||
/// inspection keeps, and what inspection reports is the same as for the page without it.
|
||||
#[test]
|
||||
fn inspection_keeps_no_private_page_data() {
|
||||
use lopdf::dictionary;
|
||||
let make = |private: bool| -> Vec<u8> {
|
||||
let mut doc = Document::with_version("1.7");
|
||||
let pages_id = doc.new_object_id();
|
||||
let mut page = dictionary! {
|
||||
"Type" => "Page",
|
||||
"Parent" => pages_id,
|
||||
"MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()],
|
||||
"Rotate" => 90,
|
||||
};
|
||||
if private {
|
||||
let map: Dictionary = (0..50).map(|i| (i.to_string(), Object::Array(vec![Object::Integer(i), Object::Real(116.22)]))).collect();
|
||||
page.set("PieceInfo", dictionary! { "InDesign" => dictionary! { "PageItemUIDToLocationDataMap" => map } });
|
||||
}
|
||||
let page_id = doc.add_object(page);
|
||||
doc.objects.insert(pages_id, dictionary! { "Type" => "Pages", "Kids" => vec![page_id.into()], "Count" => 1 }.into());
|
||||
let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
|
||||
doc.trailer.set("Root", catalog);
|
||||
let mut out = Vec::new();
|
||||
doc.save_to(&mut out).unwrap();
|
||||
out
|
||||
};
|
||||
let (with, without) = (make(true), make(false));
|
||||
let lazy = LazyStructure::new(Arc::new(with.clone()), None).unwrap();
|
||||
let page = lazy.get_object((2, 0)).unwrap().as_dict().unwrap();
|
||||
assert!(!page.has(b"PieceInfo"), "the private data is not kept");
|
||||
assert!(page.has(b"MediaBox") && page.has(b"Rotate") && page.has(b"Parent"), "the rest of the page is");
|
||||
|
||||
let report = |bytes: Vec<u8>| {
|
||||
let mut info = inspect(Arc::new(bytes), None).unwrap();
|
||||
info.file_size = 0;
|
||||
format!("{info:?}")
|
||||
};
|
||||
assert_eq!(report(with), report(without), "what inspection reports does not change");
|
||||
}
|
||||
@@ -8218,6 +8218,61 @@ fn japanese_substitute_spaces_keep_pdf_widths_and_unicode() {
|
||||
}
|
||||
}
|
||||
|
||||
/// One code, `<0010>`, in a Type 0 font named `base_font` that the PDF doesn't embed, with a
|
||||
/// `/ToUnicode` that maps it to the UTF-16 `unicode`. 0x10 is the hyphen's glyph index in Arial,
|
||||
/// and Identity-H with an identity `/CIDToGIDMap` makes the code that index, as Word writes it.
|
||||
fn soft_hyphen_pdf(base_font: &str, unicode: &str) -> Vec<u8> {
|
||||
let content = "BT /F1 60 Tf 10 20 Td <0010> Tj ET";
|
||||
let cmap = format!(
|
||||
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap
|
||||
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
|
||||
/CMapName /Adobe-Identity-UCS def /CMapType 2 def
|
||||
1 begincodespacerange <0000> <FFFF> endcodespacerange
|
||||
1 beginbfchar <0010> <{unicode}> endbfchar
|
||||
endcmap CMapName currentdict /CMap defineresource pop end end"
|
||||
);
|
||||
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 60 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 /Identity-H /DescendantFonts [6 0 R] /ToUnicode 8 0 R >> endobj
|
||||
6 0 obj << /Type /Font /Subtype /CIDFontType2 /BaseFont /{base_font} /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >>
|
||||
/FontDescriptor 7 0 R /CIDToGIDMap /Identity /DW 1000 /W [16 [333]] >> endobj
|
||||
7 0 obj << /Type /FontDescriptor /FontName /{base_font} /Flags 32 /FontBBox [-665 -325 2000 1040] /ItalicAngle 0 /Ascent 728 /Descent -210 /CapHeight 716 /StemV 80 >> endobj
|
||||
8 0 obj << /Length {} >> stream
|
||||
{cmap}
|
||||
endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
content.len(),
|
||||
cmap.len()
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
/// #5888: Windows exporters map the hyphen glyph of an Arial they don't embed to U+00AD. The
|
||||
/// standard substitute has no soft hyphen, so code 0x0010 was taken as its glyph 16 and drew
|
||||
/// `Ë` (`!` in bold). It draws the substitute's hyphen now, and the text keeps the soft hyphen.
|
||||
#[test]
|
||||
fn a_soft_hyphen_a_substitute_lacks_draws_its_hyphen() {
|
||||
for base_font in ["ArialMT", "Arial-BoldMT"] {
|
||||
let soft = soft_hyphen_pdf(base_font, "00AD");
|
||||
let out = render_first_page(soft.clone());
|
||||
let expected = render_first_page(soft_hyphen_pdf(base_font, "002D"));
|
||||
assert!(expected.rgba.as_chunks::<4>().0.iter().any(|pixel| pixel[0] < 128), "{base_font}: the hyphen control must draw ink");
|
||||
assert!(out.rgba.as_ref() == expected.rgba.as_ref(), "{base_font}: a soft hyphen draws as the substitute's hyphen");
|
||||
let mut renderer = PageRenderer::new(Arc::new(soft), RenderConfig::default());
|
||||
let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() });
|
||||
assert!(text.error.is_none(), "{:?}", text.error);
|
||||
let glyphs: String = text.text.unwrap().glyphs.iter().map(|glyph| glyph.text.as_str()).collect();
|
||||
assert_eq!(glyphs, "\u{ad}", "{base_font}: drawing the hyphen must not rewrite Unicode");
|
||||
}
|
||||
}
|
||||
|
||||
/// Half-width katakana are common in Japanese documents set in a non-embedded Mincho font
|
||||
/// such as HeiseiMin-W3. The Mincho substitute must have those glyphs: one that lacks them
|
||||
/// (Shippori Mincho) draws them as .notdef.
|
||||
|
||||
@@ -187,9 +187,20 @@ fn convert_dict(dict: &pdfcraft_cos::Dict, depth: usize) -> Result<Dictionary> {
|
||||
if depth > 100 {
|
||||
return Err(Error::RecursionLimit);
|
||||
}
|
||||
dict.iter().map(|(key, value)| Ok((key.clone(), convert(value, depth + 1)?))).collect::<Result<Vec<_>>>().map(|items| items.into_iter().collect())
|
||||
dict.iter()
|
||||
.filter(|(key, _)| key.as_slice() != PRIVATE_DATA)
|
||||
.map(|(key, value)| Ok((key.clone(), convert(value, depth + 1)?)))
|
||||
.collect::<Result<Vec<_>>>()
|
||||
.map(|items| items.into_iter().collect())
|
||||
}
|
||||
|
||||
/// `/PieceInfo`: an application's private data about a page, form or document (ISO 32000-1
|
||||
/// §14.5), left out of what inspection keeps. Nothing here reads it, and every object read is
|
||||
/// kept converted for the inspection's lifetime: InDesign writes kilobytes of it into every page,
|
||||
/// and a 1,412-page book held over a gigabyte of it (#5873). The file and the editing document are
|
||||
/// untouched; this is the read-only view inspection walks.
|
||||
const PRIVATE_DATA: &[u8] = b"PieceInfo";
|
||||
|
||||
fn convert(object: &CosObject, depth: usize) -> Result<Object> {
|
||||
if depth > 100 {
|
||||
return Err(Error::RecursionLimit);
|
||||
|
||||
@@ -14,6 +14,8 @@ craft-ui = { workspace = true }
|
||||
# The menus: native on macOS, an egui bar elsewhere, from one definition (menus.rs).
|
||||
craft-menu = { workspace = true }
|
||||
craft-keymap = { workspace = true }
|
||||
# Interface translations over the embedded craft-i18n catalogs (`build.rs`, `src/i18n.rs`).
|
||||
craft-i18n = { workspace = true }
|
||||
# Quit from the menus asks the window to close, so the unsaved-changes check runs; on macOS a window
|
||||
# close also quits through `terminate:` (the Touch Bar crash, #1136).
|
||||
craft-quit = { workspace = true, features = ["egui"] }
|
||||
|
||||
+84
-1
@@ -1,4 +1,5 @@
|
||||
//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time.
|
||||
//! Bake the contributor credits into the binary as static Rust tables, and embed the craft-i18n
|
||||
//! interface catalogs. Nothing is read at run time.
|
||||
//!
|
||||
//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose people.toml
|
||||
//! entry is verified.
|
||||
@@ -6,6 +7,12 @@
|
||||
//! JSON says the entry was verified (`people_entry`).
|
||||
//!
|
||||
//! A missing or malformed file gives empty tables (or no names) and a warning.
|
||||
//!
|
||||
//! - `CRAFT_I18N_DIR`, a craft-i18n checkout (storytold/craft-i18n, at the SHA in
|
||||
//! `craft-i18n.rev`): `craft_i18n.rs` holds `CRAFT_I18N`, one (locale, shared catalog, PdfCraft
|
||||
//! catalog) entry per locale that has a `pdfcraft.tsv`. Unset, or not a checkout (a warning),
|
||||
//! it is empty and the interface is English only; with `CRAFT_I18N_REQUIRED=1` (CI and releases)
|
||||
//! that is an error instead. Recipe: craft-i18n's `docs/integration.md`.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
@@ -23,6 +30,82 @@ fn main() {
|
||||
if let Err(e) = std::fs::write(&out, generate(&json, &people)) {
|
||||
println!("cargo::warning=writing {}: {e}", out.display());
|
||||
}
|
||||
let out = out.with_file_name("craft_i18n.rs");
|
||||
if let Err(e) = std::fs::write(&out, craft_i18n()) {
|
||||
println!("cargo::error=writing {}: {e}", out.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// The catalog this app reads in each locale: `locales/<locale>/pdfcraft.tsv`.
|
||||
const APP_CATALOG: &str = "pdfcraft.tsv";
|
||||
|
||||
/// `CRAFT_I18N` from `CRAFT_I18N_DIR`: the `Embedded` list the craft-i18n crate reads.
|
||||
fn craft_i18n() -> String {
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_I18N_DIR");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_I18N_REQUIRED");
|
||||
let required = std::env::var("CRAFT_I18N_REQUIRED").is_ok_and(|v| v.trim() == "1");
|
||||
let entries = match std::env::var_os("CRAFT_I18N_DIR").filter(|d| !d.is_empty()) {
|
||||
// Not canonicalized: on Windows that gives `\\?\C:\…`, which `include_str!` can't read.
|
||||
Some(dir) => match locales(&std::path::PathBuf::from(dir).join("locales")) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
let level = if required { "error" } else { "warning" };
|
||||
println!("cargo::{level}=CRAFT_I18N_DIR: {e}; the interface is English only");
|
||||
Vec::new()
|
||||
}
|
||||
},
|
||||
None => {
|
||||
if required {
|
||||
println!("cargo::error=CRAFT_I18N_REQUIRED=1 but CRAFT_I18N_DIR is unset: a release must embed the translations");
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let mut o = String::from("/// (locale, shared catalog, PdfCraft's catalog), one entry per locale; `\"\"` for a missing file.\n");
|
||||
o.push_str("pub static CRAFT_I18N: &craft_i18n::Embedded = &[\n");
|
||||
for (code, shared, app) in entries {
|
||||
// `{:?}` escapes the path (Windows backslashes) as a Rust string literal.
|
||||
let shared = shared.map_or_else(|| "\"\"".to_string(), |p| format!("include_str!({p:?})"));
|
||||
let _ = writeln!(o, " ({code:?}, {shared}, include_str!({app:?})),");
|
||||
}
|
||||
o.push_str("];\n");
|
||||
o
|
||||
}
|
||||
|
||||
/// One embedded locale: its code, its `common.tsv` if it has one, and its PdfCraft catalog.
|
||||
type Locale = (String, Option<std::path::PathBuf>, std::path::PathBuf);
|
||||
|
||||
/// Every locale under `dir` with PdfCraft's catalog, by code. An error when `dir` isn't a
|
||||
/// craft-i18n `locales/` directory.
|
||||
fn locales(dir: &std::path::Path) -> Result<Vec<Locale>, String> {
|
||||
// Build scripts run in the crate's directory and `include_str!` resolves against the
|
||||
// generated file, so a relative path would mean two different places.
|
||||
if !dir.is_absolute() {
|
||||
return Err(format!("{} is not an absolute path", dir.display()));
|
||||
}
|
||||
// Before read_dir, so a checkout that appears later reruns the script.
|
||||
println!("cargo::rerun-if-changed={}", dir.display());
|
||||
let list = std::fs::read_dir(dir).map_err(|e| format!("{} is not a craft-i18n checkout ({e})", dir.display()))?;
|
||||
let mut out = Vec::new();
|
||||
for entry in list.flatten() {
|
||||
let path = entry.path();
|
||||
let Some(code) = path.file_name().and_then(|n| n.to_str()).map(str::to_string) else { continue };
|
||||
let app = path.join(APP_CATALOG);
|
||||
if !app.is_file() {
|
||||
continue;
|
||||
}
|
||||
println!("cargo::rerun-if-changed={}", app.display());
|
||||
let shared = Some(path.join("common.tsv")).filter(|p| p.is_file());
|
||||
if let Some(p) = &shared {
|
||||
println!("cargo::rerun-if-changed={}", p.display());
|
||||
}
|
||||
out.push((code, shared, app));
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(format!("{} has no locale with a {APP_CATALOG}", dir.display()));
|
||||
}
|
||||
out.sort();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn read<T>(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result<T, String>) -> Option<T> {
|
||||
|
||||
@@ -99,6 +99,7 @@ impl PdfCraftApp {
|
||||
#[cfg(target_os = "linux")]
|
||||
desktop_theme_enabled: false,
|
||||
scroll_speed: scroll_speed::ScrollSpeed::default(),
|
||||
invert_middle_drag: false,
|
||||
animation_effects: motion::AnimationEffects::default(),
|
||||
motion: motion::Motion::default(),
|
||||
language: i18n::AUTO.to_string(),
|
||||
|
||||
@@ -50,6 +50,9 @@ impl PdfCraftApp {
|
||||
("scroll_speed", _) => {
|
||||
self.scroll_speed = scroll_speed::ScrollSpeed::parse(value).ok_or("scroll_speed must be slow, normal, or fast")?;
|
||||
}
|
||||
("invert_middle_drag", _) => {
|
||||
self.invert_middle_drag = value.parse::<bool>().map_err(|_| "invert_middle_drag must be true or false")?;
|
||||
}
|
||||
("animation_effects", _) => {
|
||||
self.animation_effects = motion::AnimationEffects::parse(value).ok_or("animation_effects must be system, on, or off")?;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ impl PdfCraftApp {
|
||||
"pinned_folders": self.pinned.folders,
|
||||
"theme": self.theme_preference,
|
||||
"scroll_speed": self.scroll_speed,
|
||||
"invert_middle_drag": self.invert_middle_drag,
|
||||
"animation_effects": self.animation_effects,
|
||||
"default_mode": self.default_mode,
|
||||
"default_layout": canvas::display_token(self.view_defaults.layout, self.view_defaults.continuous),
|
||||
@@ -159,6 +160,9 @@ impl PdfCraftApp {
|
||||
pub fn restore(&mut self, json: &str) {
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(json) else { return };
|
||||
if let Ok(mut bar) = serde_json::from_value::<crate::hotbar::Hotbar>(v["hotbar"].clone()) {
|
||||
if v["hotbar"].get("placed").is_none() {
|
||||
bar.adopt_legacy();
|
||||
}
|
||||
bar.normalize();
|
||||
self.hotbar = bar;
|
||||
}
|
||||
@@ -237,6 +241,9 @@ impl PdfCraftApp {
|
||||
if let Ok(speed) = serde_json::from_value::<scroll_speed::ScrollSpeed>(v["scroll_speed"].clone()) {
|
||||
self.scroll_speed = speed;
|
||||
}
|
||||
if let Some(invert) = v["invert_middle_drag"].as_bool() {
|
||||
self.invert_middle_drag = invert;
|
||||
}
|
||||
if let Ok(effects) = serde_json::from_value::<motion::AnimationEffects>(v["animation_effects"].clone()) {
|
||||
self.animation_effects = effects;
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ use crate::theme::{self, Tokens};
|
||||
use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets};
|
||||
|
||||
mod area;
|
||||
pub(super) mod autoscroll;
|
||||
mod bars;
|
||||
pub(crate) mod autoscroll;
|
||||
pub(crate) mod bars;
|
||||
mod cache;
|
||||
pub(super) mod crop;
|
||||
mod find;
|
||||
@@ -253,6 +253,22 @@ impl ImageObb {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether pointer `p` is on an image being cropped, its crop box or its handles (#5835).
|
||||
pub(crate) fn crop_contains_point(
|
||||
img: &pdfcraft_engine::PageImage,
|
||||
crop: &crate::edit_text_ui::CropMode,
|
||||
xf: &PageXform,
|
||||
info: &DocInfo,
|
||||
page: usize,
|
||||
p: Pos2,
|
||||
) -> bool {
|
||||
let obb = ImageObb::from_image(img, xf, info, page);
|
||||
let full_obb = ImageObb::from_matrix(img.matrix, xf, info, page);
|
||||
crate::edit_text_ui::quad_contains(obb.corners(), p)
|
||||
|| crate::edit_text_ui::quad_contains(full_obb.corners(), p)
|
||||
|| crate::edit_text_ui::crop_controls_contain(img, crop, xf, info, page, p)
|
||||
}
|
||||
|
||||
/// The page display as it was before full screen, put back on leaving it
|
||||
/// ([`DocView::enter_presentation`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -2441,10 +2457,23 @@ fn draw_pages(
|
||||
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();
|
||||
// Crop controls own the click even when another image overlaps them.
|
||||
if view.image_selection.as_ref().is_some_and(|s| {
|
||||
s.page == page_idx
|
||||
&& s.crop
|
||||
.as_ref()
|
||||
.zip(images.get(s.index))
|
||||
.is_some_and(|(crop, img)| crop_contains_point(img, crop, &xf, info, page_idx, p))
|
||||
}) {
|
||||
over_image = true;
|
||||
break;
|
||||
}
|
||||
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) {
|
||||
let selected_crop = view.image_selection.as_ref().is_some_and(|s| s.page == page_idx && s.index == idx && s.crop.is_some());
|
||||
let hit = if selected_crop { crate::edit_text_ui::quad_contains(obb.corners(), p) } else { obb.contains(b, p) };
|
||||
if hit {
|
||||
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));
|
||||
@@ -2557,6 +2586,22 @@ fn draw_pages(
|
||||
view.current = current;
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
let is_crop_over_image = tool == QuickTool::EditText
|
||||
&& view.image_selection.clone().is_some_and(|sel| {
|
||||
let Some(ref crop) = sel.crop else { return false };
|
||||
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 };
|
||||
crop_contains_point(img, crop, &xf, info, sel.page, p)
|
||||
});
|
||||
if is_crop_over_image {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(crate::edit_text_ui::image_menu_id(), false));
|
||||
egui::Popup::close_all(ui.ctx());
|
||||
ui.close();
|
||||
return;
|
||||
}
|
||||
// Preparing a form: the selected field's menu.
|
||||
if preparing && let Some((name, wi)) = view.prepare.selected.clone() {
|
||||
// Several fields: Align, Center, Distribute, Set Fields to Same Size.
|
||||
@@ -3338,9 +3383,9 @@ fn pinch_zoom(ui: &egui::Ui, view: &mut DocView, zoom_delta: f32, navigation_rec
|
||||
/// The middle-button auto-scroll's delta, and the edge scroll a drawing in progress asks for: the
|
||||
/// pointer in a band along any edge (or just past it) scrolls the view that way, so a point
|
||||
/// beyond what is shown can be reached without letting go of the tool.
|
||||
fn auto_scroll_deltas(ui: &egui::Ui, view: &mut DocView, avail: Rect, unobstructed: bool) -> (Vec2, Vec2) {
|
||||
fn auto_scroll_deltas(ui: &egui::Ui, view: &mut DocView, avail: Rect, unobstructed: bool, invert: bool) -> (Vec2, Vec2) {
|
||||
let auto_delta = if unobstructed {
|
||||
view.auto_scroll.update(ui, avail, false)
|
||||
view.auto_scroll.update(ui, avail, false, invert)
|
||||
} else {
|
||||
view.auto_scroll.cancel();
|
||||
Vec2::ZERO
|
||||
@@ -3517,11 +3562,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let modal = app.modal_open();
|
||||
let unobstructed = !modal && !app.palette_open;
|
||||
let organize_zoom = app.organize_zoom;
|
||||
let invert_middle = app.invert_middle_drag;
|
||||
let view = &mut app.views[index];
|
||||
if view.organize {
|
||||
// One remembered grid size for every document, kept in the settings.
|
||||
view.set_grid_zoom(organize_zoom);
|
||||
organize_grid(view, doc, unobstructed, app.show_page_edge_shadows, ui, &t);
|
||||
organize_grid(view, doc, unobstructed, app.show_page_edge_shadows, invert_middle, ui, &t);
|
||||
app.organize_zoom = view.grid_zoom();
|
||||
return;
|
||||
}
|
||||
@@ -3541,7 +3587,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let present = presentation_click_state(ui, view, navigation_rect, navigation_enabled && app.quick_tool == QuickTool::Select);
|
||||
pinch_zoom(ui, view, zoom_delta, navigation_rect, navigation_enabled);
|
||||
view.viewport_screen = avail;
|
||||
let (auto_delta, edge_delta) = auto_scroll_deltas(ui, view, avail, unobstructed);
|
||||
let (auto_delta, edge_delta) = auto_scroll_deltas(ui, view, avail, unobstructed, invert_middle);
|
||||
|
||||
let content_w = view.content_width(info, avail.width());
|
||||
let rects = view.layout(info, content_w, ui.ctx().pixels_per_point());
|
||||
@@ -4062,7 +4108,6 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui,
|
||||
// The page nearest the left edge, whose margin the bar sits in.
|
||||
let page_left = app.views[index].screen_rects.iter().map(|(_, r)| r.left()).reduce(f32::min);
|
||||
quick_bar(app, avail, page_left, ui);
|
||||
crate::hotbar::show(app, avail, ui.ctx());
|
||||
}
|
||||
if let Some((source, file, destination)) = remote_open {
|
||||
app.open_remote_link(source, file, destination);
|
||||
|
||||
@@ -46,15 +46,18 @@ impl AutoScroll {
|
||||
}
|
||||
|
||||
/// Run before the document's widgets. Starting is restricted to the unobstructed viewport;
|
||||
/// once started, moving outside that viewport still controls the speed.
|
||||
pub(crate) fn update(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool) -> Vec2 {
|
||||
self.update_for_platform(ui, viewport, organize, cfg!(target_os = "linux"))
|
||||
/// once started, moving outside that viewport still controls the speed. `invert` is
|
||||
/// Preferences ▸ Invert middle-button drag: the returned delta scrolls the other way.
|
||||
pub(crate) fn update(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool, invert: bool) -> Vec2 {
|
||||
self.update_for_platform(ui, viewport, organize, cfg!(target_os = "linux"), invert)
|
||||
}
|
||||
|
||||
fn update_for_platform(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool, supported: bool) -> Vec2 {
|
||||
if !supported {
|
||||
return self.update_drag(ui, viewport);
|
||||
}
|
||||
fn update_for_platform(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool, supported: bool, invert: bool) -> Vec2 {
|
||||
let delta = if supported { self.update_latched(ui, viewport, organize) } else { self.update_drag(ui, viewport) };
|
||||
oriented(delta, invert)
|
||||
}
|
||||
|
||||
fn update_latched(&mut self, ui: &egui::Ui, viewport: egui::Rect, organize: bool) -> Vec2 {
|
||||
let ctx = ui.ctx();
|
||||
let (pointer, middle_press, middle_down, middle_released, cancel, cancel_button, dt) = ctx.input(|i| {
|
||||
let cancel_button = [PointerButton::Primary, PointerButton::Secondary, PointerButton::Extra1, PointerButton::Extra2]
|
||||
@@ -237,6 +240,11 @@ pub(crate) fn edge_delta(y: f32, viewport: egui::Rect, dt: f32) -> f32 {
|
||||
-distance.clamp(-MAX_DISPLACEMENT, MAX_DISPLACEMENT) * EDGE_SPEED * dt.clamp(0.0, 0.05)
|
||||
}
|
||||
|
||||
/// The middle button's delta, reversed when the user inverted it.
|
||||
fn oriented(delta: Vec2, invert: bool) -> Vec2 {
|
||||
if invert { -delta } else { delta }
|
||||
}
|
||||
|
||||
fn scroll_delta(displacement: f32, dt: f32) -> f32 {
|
||||
if !displacement.is_finite() || !dt.is_finite() {
|
||||
return 0.0;
|
||||
@@ -261,10 +269,15 @@ mod tests {
|
||||
|
||||
/// Run one frame of the non-Linux path; `check` sees the pan delta inside the frame.
|
||||
fn drag_frame(ctx: &Context, scroll: &mut AutoScroll, events: Vec<Event>, focused: bool, check: impl Fn(&AutoScroll, Vec2)) {
|
||||
frame(ctx, scroll, events, focused, (false, false), check);
|
||||
}
|
||||
|
||||
/// Run one frame; `mode` is (latched auto-scroll supported, inverted).
|
||||
fn frame(ctx: &Context, scroll: &mut AutoScroll, events: Vec<Event>, focused: bool, mode: (bool, bool), check: impl Fn(&AutoScroll, Vec2)) {
|
||||
let mut output = ctx.run_ui(
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))), events, focused, ..Default::default() },
|
||||
|ui| {
|
||||
let delta = scroll.update_for_platform(ui, ui.max_rect(), false, false);
|
||||
let delta = scroll.update_for_platform(ui, ui.max_rect(), false, mode.0, mode.1);
|
||||
check(scroll, delta);
|
||||
},
|
||||
);
|
||||
@@ -272,6 +285,40 @@ mod tests {
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// The pan delta after pressing at `p` and moving the pointer by `by`.
|
||||
fn pan_by(by: Vec2, invert: bool) -> Vec2 {
|
||||
let (ctx, mut scroll, p) = (Context::default(), AutoScroll::default(), egui::pos2(100.0, 100.0));
|
||||
frame(&ctx, &mut scroll, vec![], true, (false, invert), |_, _| {});
|
||||
frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true)], true, (false, invert), |_, _| {});
|
||||
let moved = std::cell::Cell::new(Vec2::ZERO);
|
||||
frame(&ctx, &mut scroll, vec![Event::PointerMoved(p + by)], true, (false, invert), |_, d| moved.set(d));
|
||||
moved.get()
|
||||
}
|
||||
|
||||
/// The auto-scroll delta in the frame after latching at `p`, with the pointer `dy` below it.
|
||||
fn latched_by(dy: f32, invert: bool) -> Vec2 {
|
||||
let (ctx, mut scroll, p) = (Context::default(), AutoScroll::default(), egui::pos2(100.0, 100.0));
|
||||
frame(&ctx, &mut scroll, vec![], true, (true, invert), |_, _| {});
|
||||
frame(&ctx, &mut scroll, vec![Event::PointerMoved(p), middle(p, true), middle(p, false)], true, (true, invert), |_, _| {});
|
||||
let moved = std::cell::Cell::new(Vec2::ZERO);
|
||||
frame(&ctx, &mut scroll, vec![Event::PointerMoved(p + vec2(0.0, dy))], true, (true, invert), |s, d| {
|
||||
assert!(s.active(), "the click latched auto-scroll");
|
||||
moved.set(d);
|
||||
});
|
||||
moved.get()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverting_the_middle_button_reverses_the_pan_and_the_auto_scroll() {
|
||||
let by = vec2(-30.0, 40.0);
|
||||
assert_eq!(pan_by(by, false), by, "by default the page follows the pointer");
|
||||
assert_eq!(pan_by(by, true), -by, "inverted, the view moves the way the pointer goes");
|
||||
let (normal, inverted) = (latched_by(60.0, false), latched_by(60.0, true));
|
||||
assert!(normal.y < 0.0, "by default, below the anchor scrolls down: {normal:?}");
|
||||
assert_eq!(inverted, -normal, "inverted, the same distance scrolls the other way");
|
||||
assert_eq!(latched_by(10.0, true), Vec2::ZERO, "the dead zone still holds still");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn middle_drag_pans_one_to_one_and_owns_the_button_until_release() {
|
||||
let ctx = Context::default();
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use super::{
|
||||
Color32, CornerRadius, DocInfo, DocView, Edit, PdfCraftApp, Pos2, QuickTool, Rect, Sense, SnapshotKind, Stroke, Tokens, comments, icons, theme,
|
||||
vec2,
|
||||
};
|
||||
use super::{Color32, CornerRadius, DocInfo, DocView, PdfCraftApp, Pos2, Rect, Sense, SnapshotKind, Stroke, Tokens, icons, vec2};
|
||||
|
||||
/// Whether `p` lies in the bottom-left quadrant of `area`, where the page size shows (#1855).
|
||||
pub(super) fn in_size_hint_zone(area: Rect, p: Pos2) -> bool {
|
||||
@@ -277,258 +274,23 @@ fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, te
|
||||
}
|
||||
|
||||
/// The bar's narrowest form: one 32 pt button, 6 pt margins and a 1 pt border each side.
|
||||
const QUICK_BAR_WIDTH: f32 = 46.0;
|
||||
const QUICK_BAR_EDGE: f32 = 14.0;
|
||||
pub(crate) const QUICK_BAR_WIDTH: f32 = 46.0;
|
||||
pub(crate) const QUICK_BAR_EDGE: f32 = 14.0;
|
||||
|
||||
/// How far from the side panel the quick-action bar stands, given the width of the margin between
|
||||
/// the panel and the page and the bar's own width: centred in the margin when it holds the bar,
|
||||
/// else 14 pt in (a page filling the width). With no page on screen the caller passes a margin of 0,
|
||||
/// which is 14 pt too.
|
||||
fn quick_bar_offset(margin: f32, width: f32) -> f32 {
|
||||
pub(crate) fn quick_bar_offset(margin: f32, width: f32) -> f32 {
|
||||
// shortcut: centred in at most 100 pt of margin, so a wide margin keeps the bar by the panel.
|
||||
// `f32::min` returns the other operand for NaN, which would pass the fit check below.
|
||||
let margin = if margin.is_nan() { 0.0 } else { margin.min(100.0) };
|
||||
if margin >= width + 2.0 * 8.0 { (margin - width) / 2.0 } else { QUICK_BAR_EDGE }
|
||||
}
|
||||
|
||||
/// The floating quick-action bar in the margin between the side panel and the page.
|
||||
/// The unified quick-action bar and hotbar in the margin or docked to an edge.
|
||||
pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, page_left: Option<f32>, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut offset = vec2(0.0, 0.0);
|
||||
if app.hotbar.full_side {
|
||||
match app.hotbar.dock.as_deref() {
|
||||
Some("left") => offset.x += app.hotbar.button_size + 14.0,
|
||||
Some("top") => offset.y += app.hotbar.button_size + 14.0,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let id = egui::Id::unique("quick-bar");
|
||||
// Last frame's bar: Fill & Sign's Text and Date tools add a wider row of controls to it.
|
||||
let width = ui.ctx().memory(|m| m.area_rect(id)).map_or(QUICK_BAR_WIDTH, |bar| bar.width().max(QUICK_BAR_WIDTH));
|
||||
// The margin starts past a hotbar docked on the left.
|
||||
let x = quick_bar_offset(page_left.map_or(0.0, |left| left - area.left() - offset.x), width);
|
||||
let pos = area.left_top() + vec2(x, QUICK_BAR_EDGE) + offset;
|
||||
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| {
|
||||
egui::Frame::NONE
|
||||
.fill(t.card)
|
||||
.stroke(Stroke::new(1.0, t.border))
|
||||
.corner_radius(CornerRadius::same(12))
|
||||
.shadow(egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(if t.dark() { 100 } else { 28 }) })
|
||||
.inner_margin(egui::Margin::same(6))
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
ui.vertical(|ui| {
|
||||
if icons::button(ui, "mouse-pointer-2", 32.0, app.quick_tool == QuickTool::Select, tl!("Select (V)")).clicked() {
|
||||
app.quick_tool = QuickTool::Select;
|
||||
}
|
||||
if icons::button(ui, "hand", 32.0, app.quick_tool == QuickTool::Hand, tl!("Hand (H)")).clicked() {
|
||||
app.quick_tool = QuickTool::Hand;
|
||||
}
|
||||
// Comment ▸, Highlight ▸, Draw ▸ (Acrobat's comment toolbar groups). Clicking a
|
||||
// group selects its last-used tool; clicking it again opens the flyout.
|
||||
for g in 0..comments::GROUPS.len() {
|
||||
let current = app.comment_prefs.group_tool[g];
|
||||
let active = matches!(app.quick_tool, QuickTool::Comment(t) if t.group() == g);
|
||||
let resp = icons::button(ui, current.icon(), 32.0, active, tl!(current.label()));
|
||||
if comments::GROUPS[g].len() == 1 {
|
||||
if resp.clicked() {
|
||||
app.execute(current.command());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A small corner triangle marks the flyout.
|
||||
let r = resp.rect;
|
||||
let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)];
|
||||
ui.painter().add(egui::Shape::convex_polygon(tri.to_vec(), if active { t.accent } else { t.text_faint }, Stroke::NONE));
|
||||
let open = (resp.clicked() && active) || resp.secondary_clicked();
|
||||
if resp.clicked() && !active {
|
||||
app.execute(current.command());
|
||||
}
|
||||
egui::Popup::menu(&resp)
|
||||
.open_memory(open.then_some(egui::SetOpenCommand::Toggle))
|
||||
.align(egui::RectAlign::RIGHT_START)
|
||||
.gap(6.0)
|
||||
.show(|ui| {
|
||||
for tool in comments::GROUPS[g] {
|
||||
let on = app.quick_tool == QuickTool::Comment(*tool);
|
||||
let (row, click) = ui.allocate_exact_size(vec2(180.0, 28.0), Sense::click());
|
||||
let press = theme::Press::track(ui, &click);
|
||||
press.wash(ui, row, 4, on);
|
||||
let body = row.translate(press.offset());
|
||||
icons::paint(ui, Rect::from_min_size(body.min + vec2(8.0, 6.0), vec2(16.0, 16.0)), tool.icon(), 16.0, t.text);
|
||||
let reserve = if on { 28.0 } else { 8.0 };
|
||||
crate::widgets::paint_left(
|
||||
ui,
|
||||
body.left_center() + vec2(34.0, 0.0),
|
||||
tl!(tool.label()),
|
||||
theme::regular(13.0),
|
||||
t.text,
|
||||
(row.width() - 34.0 - reserve).max(0.0),
|
||||
);
|
||||
if on {
|
||||
icons::paint(
|
||||
ui,
|
||||
Rect::from_min_size(body.right_top() + vec2(-24.0, 7.0), vec2(14.0, 14.0)),
|
||||
"check",
|
||||
14.0,
|
||||
t.accent,
|
||||
);
|
||||
}
|
||||
let click = theme::hand(click);
|
||||
let info = tl!(tool.label()).to_string();
|
||||
click.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, info.clone()));
|
||||
if click.clicked() {
|
||||
app.execute(tool.command());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Edit text & images, while the Edit workspace is open (Acrobat's Edit toolbar)
|
||||
// or the tool is in use.
|
||||
if app.mode == crate::Mode::Edit || app.quick_tool == QuickTool::EditText {
|
||||
let on = app.quick_tool == QuickTool::EditText;
|
||||
if icons::button(ui, "text-cursor-input", 32.0, on, tl!("Edit text & images")).clicked() {
|
||||
if on {
|
||||
app.put_down_edit_text();
|
||||
} else {
|
||||
app.quick_tool = QuickTool::EditText;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fill & Sign ▸ (text, marks, date, signature).
|
||||
let current_fill = match app.quick_tool {
|
||||
QuickTool::Fill(f) => Some(f),
|
||||
_ => None,
|
||||
};
|
||||
let shown = current_fill.unwrap_or(crate::fill_sign::FillTool::Text);
|
||||
let resp = icons::button(
|
||||
ui,
|
||||
if current_fill.is_some() { shown.icon() } else { "pen-line" },
|
||||
32.0,
|
||||
current_fill.is_some(),
|
||||
tl!("Fill & Sign"),
|
||||
);
|
||||
let r = resp.rect;
|
||||
let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)];
|
||||
ui.painter().add(egui::Shape::convex_polygon(
|
||||
tri.to_vec(),
|
||||
if current_fill.is_some() { t.accent } else { t.text_faint },
|
||||
Stroke::NONE,
|
||||
));
|
||||
if resp.clicked() && current_fill.is_none() {
|
||||
app.execute(shown.command());
|
||||
}
|
||||
let open = (resp.clicked() && current_fill.is_some()) || resp.secondary_clicked();
|
||||
egui::Popup::menu(&resp)
|
||||
.open_memory(open.then_some(egui::SetOpenCommand::Toggle))
|
||||
.align(egui::RectAlign::RIGHT_START)
|
||||
.gap(6.0)
|
||||
.show(|ui| {
|
||||
for tool in crate::fill_sign::FILL_TOOLS {
|
||||
if matches!(tool, crate::fill_sign::FillTool::Signature | crate::fill_sign::FillTool::Initials) {
|
||||
continue;
|
||||
}
|
||||
let on = current_fill == Some(tool);
|
||||
let (row, click) = ui.allocate_exact_size(vec2(180.0, 28.0), Sense::click());
|
||||
let press = theme::Press::track(ui, &click);
|
||||
press.wash(ui, row, 4, on);
|
||||
let body = row.translate(press.offset());
|
||||
icons::paint(ui, Rect::from_min_size(body.min + vec2(8.0, 6.0), vec2(16.0, 16.0)), tool.icon(), 16.0, t.text);
|
||||
let reserve = if on { 28.0 } else { 8.0 };
|
||||
crate::widgets::paint_left(
|
||||
ui,
|
||||
body.left_center() + vec2(34.0, 0.0),
|
||||
tl!(tool.label()),
|
||||
theme::regular(13.0),
|
||||
t.text,
|
||||
(row.width() - 34.0 - reserve).max(0.0),
|
||||
);
|
||||
if on {
|
||||
icons::paint(
|
||||
ui,
|
||||
Rect::from_min_size(body.right_top() + vec2(-24.0, 7.0), vec2(14.0, 14.0)),
|
||||
"check",
|
||||
14.0,
|
||||
t.accent,
|
||||
);
|
||||
}
|
||||
let click = theme::hand(click);
|
||||
let info = tl!(tool.label()).to_string();
|
||||
click.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, info.clone()));
|
||||
if click.clicked() {
|
||||
app.execute(tool.command());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if let Some(command) = crate::fill_sign::signature_entries(ui, app, &t) {
|
||||
app.execute(command);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if matches!(current_fill, Some(crate::fill_sign::FillTool::Text | crate::fill_sign::FillTool::Date)) {
|
||||
ui.label(tl!("Font size:"));
|
||||
let size = ui.add(
|
||||
egui::DragValue::new(&mut app.fill_text_size)
|
||||
.range(crate::fill_sign::MIN_TEXT_SIZE..=crate::fill_sign::MAX_TEXT_SIZE)
|
||||
.speed(0.25)
|
||||
.suffix(" pt"),
|
||||
);
|
||||
// #1274: the size also applies to the Fill & Sign text that is selected,
|
||||
// once per change (a typed value, or the end of a drag), so a drag adds
|
||||
// one undo step and not one per frame.
|
||||
if ((size.changed() && !size.dragged()) || size.drag_stopped())
|
||||
&& let Some((index, id)) = app.active_ids()
|
||||
&& let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected)
|
||||
&& app.session.get(id).is_some_and(|doc| {
|
||||
doc.allows_annotation()
|
||||
&& doc
|
||||
.info
|
||||
.annotations
|
||||
.iter()
|
||||
.any(|a| a.page == page && a.index == annotation && a.fill_sign && a.subtype == "FreeText" && !a.locked)
|
||||
})
|
||||
{
|
||||
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));
|
||||
if let Some(color) = comments::quick_bar_controls(ui, tool, &mut app.comment_prefs)
|
||||
&& let Some((index, id)) = app.active_ids()
|
||||
&& let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected)
|
||||
&& app.session.get(id).is_some_and(|doc| {
|
||||
doc.allows_annotation()
|
||||
&& doc
|
||||
.info
|
||||
.annotations
|
||||
.iter()
|
||||
.any(|a| a.page == page && a.index == annotation && !a.is_reply() && comments::made_by(a, tool))
|
||||
})
|
||||
{
|
||||
app.apply_edit(Edit::StyleAnnotation {
|
||||
page,
|
||||
index: annotation,
|
||||
color: Some(color),
|
||||
opacity: None,
|
||||
width: None,
|
||||
endings: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
crate::hotbar::show(app, area, page_left, ui);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -279,7 +279,11 @@ impl DocView {
|
||||
let Some(forward) = self.wheel.feed(unit, dy, phase, now, can_turn) else { return false };
|
||||
let before = self.current;
|
||||
self.step_page(forward);
|
||||
self.current != before
|
||||
if self.current == before {
|
||||
self.wheel.unturn();
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// One wheel event in a non-continuous view whose page is taller than the window, where the
|
||||
@@ -299,6 +303,7 @@ impl DocView {
|
||||
let before = self.current;
|
||||
self.step_page(forward);
|
||||
if self.current == before {
|
||||
self.wheel.unturn();
|
||||
return false;
|
||||
}
|
||||
if !forward {
|
||||
|
||||
@@ -272,6 +272,7 @@ pub(super) fn organize_grid(
|
||||
doc: &pdfcraft_engine::Document,
|
||||
unobstructed: bool,
|
||||
show_page_edge_shadows: bool,
|
||||
invert_middle: bool,
|
||||
ui: &mut egui::Ui,
|
||||
t: &Tokens,
|
||||
) {
|
||||
@@ -288,7 +289,7 @@ pub(super) fn organize_grid(
|
||||
let viewport = ui.available_rect_before_wrap();
|
||||
view.viewport_screen = viewport;
|
||||
let auto_delta = if unobstructed {
|
||||
view.auto_scroll.update(ui, viewport, true)
|
||||
view.auto_scroll.update(ui, viewport, true, invert_middle)
|
||||
} else {
|
||||
view.auto_scroll.cancel();
|
||||
Vec2::ZERO
|
||||
|
||||
@@ -73,6 +73,11 @@ impl WheelPager {
|
||||
self.accum = 0.0;
|
||||
}
|
||||
|
||||
/// Reset any turned state when a turn did not happen (e.g. at document boundary).
|
||||
pub(crate) fn unturn(&mut self) {
|
||||
self.turned = None;
|
||||
}
|
||||
|
||||
/// Whether the current gesture already turned a page: the rest of it must not pan the page
|
||||
/// just shown away from its top (or bottom).
|
||||
pub(crate) fn gesture_turned(&self) -> bool {
|
||||
@@ -144,6 +149,9 @@ impl WheelPager {
|
||||
};
|
||||
if notch {
|
||||
if !can_turn {
|
||||
// A notch is a gesture of its own, so one that only pans ends the gesture a
|
||||
// previous notch turned; otherwise `gesture_turned` keeps swallowing the wheel (#5713).
|
||||
self.turned = None;
|
||||
return None;
|
||||
}
|
||||
// Every notch turns a page; a smooth tail of the same gesture won't add another.
|
||||
|
||||
@@ -878,6 +878,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn localized_shortcut_labels_follow_language_and_platform() {
|
||||
if !crate::i18n::needs_catalogs() {
|
||||
return;
|
||||
}
|
||||
let previous = crate::i18n::current();
|
||||
let ctx = egui::Context::default();
|
||||
for os in [OperatingSystem::Windows, OperatingSystem::Nix] {
|
||||
|
||||
@@ -20,6 +20,9 @@ pub(super) mod protect;
|
||||
pub(super) mod standards_ui;
|
||||
pub(super) mod word_count_ui;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(crate) use prompts::{PromptButton, save_prompt_key};
|
||||
use prompts::{full_screen_prompt, link_prompt, password, remote_link_prompt, save_prompt, unlock};
|
||||
|
||||
@@ -1371,20 +1374,26 @@ fn dialog_preferences(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendin
|
||||
// (which only applies when the settings overflow it) opens the dialog at full size.
|
||||
// A scrollbar of its own: the floating one lies over the right edge of the theme
|
||||
// cards and other full-width rows (#3557).
|
||||
preferences_scroll(ui, max, |ui| preferences::preferences_body(ui, app, &t));
|
||||
req.close = preferences::preferences_buttons(ui);
|
||||
false
|
||||
}
|
||||
|
||||
fn preferences_scroll(ui: &mut egui::Ui, max: f32, add_contents: impl FnOnce(&mut egui::Ui)) {
|
||||
ui.scope(|ui| {
|
||||
ui.spacing_mut().scroll.floating = false;
|
||||
ui.spacing_mut().scroll.bar_inner_margin = 14.0;
|
||||
ui.spacing_mut().scroll.bar_outer_margin = 2.0;
|
||||
ui.style_mut().scroll_animation = egui::style::ScrollAnimation::duration(0.25);
|
||||
if ui.style().scroll_animation != egui::style::ScrollAnimation::none() {
|
||||
ui.style_mut().scroll_animation = egui::style::ScrollAnimation::duration(0.25);
|
||||
}
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("preferences-dialog-scroll")
|
||||
.max_height(max)
|
||||
.min_scrolled_height(max)
|
||||
.animated(true)
|
||||
.show(ui, |ui| preferences::preferences_body(ui, app, &t));
|
||||
.show(ui, add_contents);
|
||||
});
|
||||
req.close = preferences::preferences_buttons(ui);
|
||||
false
|
||||
}
|
||||
|
||||
/// `Dialog::NumberPages` content. Returns `false` when it has its own buttons and no footer is wanted.
|
||||
|
||||
@@ -22,14 +22,12 @@ pub(crate) const PREFERENCES_MIN_SCROLL: f32 = 60.0;
|
||||
///
|
||||
/// Draws a rounded container with options inside. The active option is highlighted
|
||||
/// with the accent colour. Emits AccessKit RadioButton widget info with the option's label.
|
||||
#[expect(dead_code, reason = "convenience helpers for non-searched segmented pill controls")]
|
||||
pub fn segmented_pill_choice<T: PartialEq + Copy>(ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str)]) -> bool {
|
||||
let with_a11y: Vec<(T, &str, &str)> = options.iter().map(|&(v, l)| (v, l, l)).collect();
|
||||
segmented_pill_choice_a11y(ui, t, current, &with_a11y)
|
||||
}
|
||||
|
||||
/// A segmented pill selection bar with separate accessibility labels.
|
||||
#[expect(dead_code, reason = "convenience helpers for non-searched segmented pill controls")]
|
||||
pub fn segmented_pill_choice_a11y<T: PartialEq + Copy>(ui: &mut egui::Ui, t: &Tokens, current: &mut T, options: &[(T, &str, &str)]) -> bool {
|
||||
segmented_pill_choice_a11y_query(ui, t, current, options, "").0
|
||||
}
|
||||
@@ -759,7 +757,11 @@ fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens,
|
||||
});
|
||||
ctx.label_secondary(ui, tl!("For page sizes and margins."), t);
|
||||
ui.add_space(6.0);
|
||||
scrolling(ui, app, t, ctx);
|
||||
}
|
||||
|
||||
/// The scroll speed and the middle button's direction, in Card 3.
|
||||
fn scrolling(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut PrefSearchContext) {
|
||||
ui.horizontal(|ui| {
|
||||
ctx.label_primary(ui, tl!("Scroll speed (mouse wheel and arrow keys)"), t);
|
||||
ctx.pill_choice(
|
||||
@@ -773,6 +775,9 @@ fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens,
|
||||
],
|
||||
);
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ctx.checkbox(ui, &mut app.invert_middle_drag, tl!("Invert middle-button drag"), t);
|
||||
ctx.label_secondary(ui, tl!("Panning or auto-scrolling with the middle (wheel) button moves the page the other way."), t);
|
||||
}
|
||||
|
||||
/// Content for Card 4: Identity
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
#[test]
|
||||
fn preferences_scroll_preserves_disabled_animation() {
|
||||
for animated in [false, true] {
|
||||
let scroll = Rc::new(Cell::new(false));
|
||||
let requested = scroll.clone();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(600.0, 400.0)).with_step_dt(1.0 / 60.0).build_ui(move |ui| {
|
||||
super::preferences_scroll(ui, 200.0, |ui| {
|
||||
ui.add_space(1000.0);
|
||||
let target = ui.label("Scroll target");
|
||||
if requested.replace(false) {
|
||||
ui.scroll_to_rect(target.rect, Some(egui::Align::BOTTOM));
|
||||
}
|
||||
ui.add_space(300.0);
|
||||
});
|
||||
});
|
||||
h.ctx.all_styles_mut(|style| {
|
||||
style.scroll_animation = if animated { egui::style::ScrollAnimation::default() } else { egui::style::ScrollAnimation::none() };
|
||||
});
|
||||
h.run_steps(4);
|
||||
let before = h.get_by_label("Scroll target").rect().top();
|
||||
scroll.set(true);
|
||||
h.run_steps(4);
|
||||
let early = h.get_by_label("Scroll target").rect().top();
|
||||
h.run_steps(20);
|
||||
let settled = h.get_by_label("Scroll target").rect().top();
|
||||
assert!((before - settled).abs() > 50.0, "the target must scroll: {before} -> {settled}");
|
||||
assert_eq!((early - settled).abs() > 1.0, animated, "animated: {animated}, early: {early}, settled: {settled}");
|
||||
}
|
||||
}
|
||||
@@ -1092,6 +1092,9 @@ mod revert_draft_tests {
|
||||
/// interface language; a drawable value says nothing.
|
||||
#[test]
|
||||
fn a_value_its_font_cannot_draw_is_kept_and_explained() {
|
||||
if !crate::i18n::needs_catalogs() {
|
||||
return;
|
||||
}
|
||||
let mut app = app();
|
||||
let fill =
|
||||
|app: &mut PdfCraftApp, text: &str| app.apply_edit(Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text(text.into()) });
|
||||
|
||||
@@ -317,6 +317,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn translated_card_description_leaves_room_for_the_action() {
|
||||
if !crate::i18n::needs_catalogs() {
|
||||
return;
|
||||
}
|
||||
let de = crate::i18n::Lang::from_code("de").unwrap();
|
||||
crate::i18n::set_current(de);
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
+504
-62
@@ -1,9 +1,10 @@
|
||||
//! A movable, persistent set of shortcuts to the shared command registry.
|
||||
use egui::{Rect, Sense, vec2};
|
||||
//! A movable, persistent set of shortcuts to the shared command registry and core document tools.
|
||||
use egui::{Color32, CornerRadius, Rect, Sense, Stroke, vec2};
|
||||
use pdfcraft_engine::Edit;
|
||||
use pdfcraft_engine::commands::{COMMANDS, command};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{PdfCraftApp, icons, theme::Tokens};
|
||||
use crate::{PdfCraftApp, QuickTool, comments, icons, theme, theme::Tokens};
|
||||
|
||||
/// Persistent state and UI state for the customizable document hotbar.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -16,6 +17,8 @@ pub(crate) struct Hotbar {
|
||||
pub button_size: f32,
|
||||
pub dock: Option<String>,
|
||||
pub full_side: bool,
|
||||
#[serde(default)]
|
||||
pub placed: bool,
|
||||
#[serde(skip)]
|
||||
search: String,
|
||||
#[serde(skip)]
|
||||
@@ -23,15 +26,16 @@ pub(crate) struct Hotbar {
|
||||
}
|
||||
|
||||
impl Default for Hotbar {
|
||||
/// Create a default empty horizontal floating hotbar.
|
||||
/// Create a default vertical floating hotbar positioned in the document margin.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tools: Vec::new(),
|
||||
position: [0.5, 0.0],
|
||||
vertical: false,
|
||||
position: [0.0, 0.0],
|
||||
vertical: true,
|
||||
button_size: 32.0,
|
||||
dock: None,
|
||||
full_side: false,
|
||||
placed: false,
|
||||
search: String::new(),
|
||||
picker_open: false,
|
||||
}
|
||||
@@ -51,6 +55,24 @@ impl Hotbar {
|
||||
for coordinate in &mut self.position {
|
||||
*coordinate = if coordinate.is_finite() { coordinate.clamp(0.0, 1.0) } else { 0.5 };
|
||||
}
|
||||
if self.dock.is_some() || self.position != [0.0, 0.0] {
|
||||
self.placed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Adopt preferences saved before the hotbar absorbed the quick-action bar (#5890), which have
|
||||
/// no `placed` key. A bar left at that release's default (floating, horizontal, top centre)
|
||||
/// moves to the new default in the left margin; any other spot is one the user chose, so it
|
||||
/// is kept, including the top-left corner.
|
||||
pub fn adopt_legacy(&mut self) {
|
||||
if self.dock.is_none() && !self.vertical && self.position == [0.5, 0.0] {
|
||||
self.position = [0.0, 0.0];
|
||||
self.vertical = true;
|
||||
self.full_side = false;
|
||||
self.placed = false;
|
||||
} else {
|
||||
self.placed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse and validate a comma-separated list of command identifiers for the hotbar.
|
||||
@@ -68,6 +90,7 @@ impl Hotbar {
|
||||
pub fn place(&mut self, side: &str) -> Result<(), String> {
|
||||
if side == "float" {
|
||||
self.dock = None;
|
||||
self.placed = true;
|
||||
return Ok(());
|
||||
}
|
||||
let (position, vertical) = match side {
|
||||
@@ -80,12 +103,13 @@ impl Hotbar {
|
||||
self.dock = Some(side.to_owned());
|
||||
self.position = position;
|
||||
self.vertical = vertical;
|
||||
self.placed = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the hotbar settings gear button and open the tool picker when clicked.
|
||||
fn settings_button(ui: &mut egui::Ui, cell: f32, picker_open: &mut bool) {
|
||||
fn settings_button(ui: &mut egui::Ui, cell: f32, picker_open: &mut bool) -> bool {
|
||||
let settings = ui
|
||||
.push_id("hotbar-settings", |ui| {
|
||||
icons::button(ui, "settings", cell, false, tl!("Hotbar settings")).on_hover_text(tl!("Hotbar settings / add tools"))
|
||||
@@ -93,6 +117,25 @@ fn settings_button(ui: &mut egui::Ui, cell: f32, picker_open: &mut bool) {
|
||||
.inner;
|
||||
if settings.clicked() {
|
||||
*picker_open = true;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine where flyout popup menus should open from a toolbar button based on docking and layout.
|
||||
fn popup_align_for(dock: Option<&str>, vertical: bool) -> egui::RectAlign {
|
||||
match dock {
|
||||
Some("right") => egui::RectAlign::LEFT_START,
|
||||
Some("bottom") => egui::RectAlign::TOP_START,
|
||||
Some("top") => egui::RectAlign::BOTTOM_START,
|
||||
_ => {
|
||||
if vertical {
|
||||
egui::RectAlign::RIGHT_START
|
||||
} else {
|
||||
egui::RectAlign::BOTTOM_START
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +146,7 @@ fn matches_search(spec: &pdfcraft_engine::commands::CommandSpec, query: &str) ->
|
||||
tl!(spec.label).to_lowercase().contains(&search) || spec.label.to_lowercase().contains(&search) || spec.id.to_lowercase().contains(&search)
|
||||
}
|
||||
|
||||
/// Render the configured tool shortcut buttons and their removal context menus.
|
||||
/// Render the configured custom tool shortcut buttons and their removal context menus.
|
||||
fn tool_buttons(ui: &mut egui::Ui, app: &PdfCraftApp, tools: &[String], cell: f32, run: &mut Option<&'static str>, remove: &mut Option<usize>) {
|
||||
for (index, id) in tools.iter().enumerate() {
|
||||
let Some(spec) = command(id) else { continue };
|
||||
@@ -121,30 +164,279 @@ fn tool_buttons(ui: &mut egui::Ui, app: &PdfCraftApp, tools: &[String], cell: f3
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the floating or side-docked hotbar and its tool picker window inside `area`.
|
||||
pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, ctx: &egui::Context) {
|
||||
/// Render core quick tools: Select, Hand, Comment groups, Edit text, and Fill & Sign.
|
||||
fn core_tool_buttons(ui: &mut egui::Ui, app: &mut PdfCraftApp, cell: f32, popup_align: egui::RectAlign, t: &Tokens) {
|
||||
if icons::button(ui, "mouse-pointer-2", cell, app.quick_tool == QuickTool::Select, tl!("Select (V)")).clicked() {
|
||||
app.quick_tool = QuickTool::Select;
|
||||
}
|
||||
if icons::button(ui, "hand", cell, app.quick_tool == QuickTool::Hand, tl!("Hand (H)")).clicked() {
|
||||
app.quick_tool = QuickTool::Hand;
|
||||
}
|
||||
// Comment ▸, Highlight ▸, Draw ▸ (Acrobat's comment toolbar groups). Clicking a
|
||||
// group selects its last-used tool; clicking it again opens the flyout.
|
||||
for g in 0..comments::GROUPS.len() {
|
||||
let current = app.comment_prefs.group_tool[g];
|
||||
let active = matches!(app.quick_tool, QuickTool::Comment(t) if t.group() == g);
|
||||
let resp = icons::button(ui, current.icon(), cell, active, tl!(current.label()));
|
||||
if comments::GROUPS[g].len() == 1 {
|
||||
if resp.clicked() {
|
||||
app.execute(current.command());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A small corner triangle marks the flyout.
|
||||
let r = resp.rect;
|
||||
let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)];
|
||||
ui.painter().add(egui::Shape::convex_polygon(tri.to_vec(), if active { t.accent } else { t.text_faint }, Stroke::NONE));
|
||||
let open = (resp.clicked() && active) || resp.secondary_clicked();
|
||||
if resp.clicked() && !active {
|
||||
app.execute(current.command());
|
||||
}
|
||||
egui::Popup::menu(&resp).open_memory(open.then_some(egui::SetOpenCommand::Toggle)).align(popup_align).gap(6.0).show(|ui| {
|
||||
for tool in comments::GROUPS[g] {
|
||||
let on = app.quick_tool == QuickTool::Comment(*tool);
|
||||
let (row, click) = ui.allocate_exact_size(vec2(180.0, 28.0), Sense::click());
|
||||
let press = theme::Press::track(ui, &click);
|
||||
press.wash(ui, row, 4, on);
|
||||
let body = row.translate(press.offset());
|
||||
icons::paint(ui, Rect::from_min_size(body.min + vec2(8.0, 6.0), vec2(16.0, 16.0)), tool.icon(), 16.0, t.text);
|
||||
let reserve = if on { 28.0 } else { 8.0 };
|
||||
crate::widgets::paint_left(
|
||||
ui,
|
||||
body.left_center() + vec2(34.0, 0.0),
|
||||
tl!(tool.label()),
|
||||
theme::regular(13.0),
|
||||
t.text,
|
||||
(row.width() - 34.0 - reserve).max(0.0),
|
||||
);
|
||||
if on {
|
||||
icons::paint(ui, Rect::from_min_size(body.right_top() + vec2(-24.0, 7.0), vec2(14.0, 14.0)), "check", 14.0, t.accent);
|
||||
}
|
||||
let click = theme::hand(click);
|
||||
let info = tl!(tool.label()).to_string();
|
||||
click.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, info.clone()));
|
||||
if click.clicked() {
|
||||
app.execute(tool.command());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Edit text & images, while the Edit workspace is open (Acrobat's Edit toolbar)
|
||||
// or the tool is in use.
|
||||
if app.mode == crate::Mode::Edit || app.quick_tool == QuickTool::EditText {
|
||||
let on = app.quick_tool == QuickTool::EditText;
|
||||
if icons::button(ui, "text-cursor-input", cell, on, tl!("Edit text & images")).clicked() {
|
||||
if on {
|
||||
app.put_down_edit_text();
|
||||
} else {
|
||||
app.quick_tool = QuickTool::EditText;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fill & Sign ▸ (text, marks, date, signature).
|
||||
let current_fill = match app.quick_tool {
|
||||
QuickTool::Fill(f) => Some(f),
|
||||
_ => None,
|
||||
};
|
||||
let shown = current_fill.unwrap_or(crate::fill_sign::FillTool::Text);
|
||||
let resp = icons::button(ui, if current_fill.is_some() { shown.icon() } else { "pen-line" }, cell, current_fill.is_some(), tl!("Fill & Sign"));
|
||||
let r = resp.rect;
|
||||
let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)];
|
||||
ui.painter().add(egui::Shape::convex_polygon(tri.to_vec(), if current_fill.is_some() { t.accent } else { t.text_faint }, Stroke::NONE));
|
||||
if resp.clicked() && current_fill.is_none() {
|
||||
app.execute(shown.command());
|
||||
}
|
||||
let open = (resp.clicked() && current_fill.is_some()) || resp.secondary_clicked();
|
||||
egui::Popup::menu(&resp).open_memory(open.then_some(egui::SetOpenCommand::Toggle)).align(popup_align).gap(6.0).show(|ui| {
|
||||
for tool in crate::fill_sign::FILL_TOOLS {
|
||||
if matches!(tool, crate::fill_sign::FillTool::Signature | crate::fill_sign::FillTool::Initials) {
|
||||
continue;
|
||||
}
|
||||
let on = current_fill == Some(tool);
|
||||
let (row, click) = ui.allocate_exact_size(vec2(180.0, 28.0), Sense::click());
|
||||
let press = theme::Press::track(ui, &click);
|
||||
press.wash(ui, row, 4, on);
|
||||
let body = row.translate(press.offset());
|
||||
icons::paint(ui, Rect::from_min_size(body.min + vec2(8.0, 6.0), vec2(16.0, 16.0)), tool.icon(), 16.0, t.text);
|
||||
let reserve = if on { 28.0 } else { 8.0 };
|
||||
crate::widgets::paint_left(
|
||||
ui,
|
||||
body.left_center() + vec2(34.0, 0.0),
|
||||
tl!(tool.label()),
|
||||
theme::regular(13.0),
|
||||
t.text,
|
||||
(row.width() - 34.0 - reserve).max(0.0),
|
||||
);
|
||||
if on {
|
||||
icons::paint(ui, Rect::from_min_size(body.right_top() + vec2(-24.0, 7.0), vec2(14.0, 14.0)), "check", 14.0, t.accent);
|
||||
}
|
||||
let click = theme::hand(click);
|
||||
let info = tl!(tool.label()).to_string();
|
||||
click.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, info.clone()));
|
||||
if click.clicked() {
|
||||
app.execute(tool.command());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if let Some(command) = crate::fill_sign::signature_entries(ui, app, t) {
|
||||
app.execute(command);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Extra controls rendered below/beside the buttons when a comment or fill tool is active.
|
||||
fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, vertical: bool) {
|
||||
let current_fill = match app.quick_tool {
|
||||
QuickTool::Fill(f) => Some(f),
|
||||
_ => None,
|
||||
};
|
||||
if matches!(current_fill, Some(crate::fill_sign::FillTool::Text | crate::fill_sign::FillTool::Date)) {
|
||||
if vertical {
|
||||
ui.label(tl!("Font size:"));
|
||||
}
|
||||
let size = ui.add(
|
||||
egui::DragValue::new(&mut app.fill_text_size)
|
||||
.range(crate::fill_sign::MIN_TEXT_SIZE..=crate::fill_sign::MAX_TEXT_SIZE)
|
||||
.speed(0.25)
|
||||
.suffix(" pt"),
|
||||
);
|
||||
if ((size.changed() && !size.dragged()) || size.drag_stopped())
|
||||
&& let Some((index, id)) = app.active_ids()
|
||||
&& let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected)
|
||||
&& app.session.get(id).is_some_and(|doc| {
|
||||
doc.allows_annotation()
|
||||
&& doc
|
||||
.info
|
||||
.annotations
|
||||
.iter()
|
||||
.any(|a| a.page == page && a.index == annotation && a.fill_sign && a.subtype == "FreeText" && !a.locked)
|
||||
})
|
||||
{
|
||||
app.apply_edit(Edit::FontSizeAnnotation { page, index: annotation, size: app.fill_text_size });
|
||||
}
|
||||
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, _) = if vertical {
|
||||
ui.allocate_exact_size(vec2(crate::canvas::bars::QUICK_BAR_WIDTH, 9.0), Sense::hover())
|
||||
} else {
|
||||
ui.allocate_exact_size(vec2(9.0, crate::canvas::bars::QUICK_BAR_WIDTH), Sense::hover())
|
||||
};
|
||||
if vertical {
|
||||
ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.divider));
|
||||
} else {
|
||||
ui.painter().vline(r.center().x, r.y_range().shrink(6.0), Stroke::new(1.0, t.divider));
|
||||
}
|
||||
if let Some(color) = comments::quick_bar_controls(ui, tool, &mut app.comment_prefs)
|
||||
&& let Some((index, id)) = app.active_ids()
|
||||
&& let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected)
|
||||
&& app.session.get(id).is_some_and(|doc| {
|
||||
doc.allows_annotation()
|
||||
&& doc
|
||||
.info
|
||||
.annotations
|
||||
.iter()
|
||||
.any(|a| a.page == page && a.index == annotation && !a.is_reply() && !a.locked && comments::made_by(a, tool))
|
||||
})
|
||||
{
|
||||
app.apply_edit(Edit::StyleAnnotation { page, index: annotation, color: Some(color), opacity: None, width: None, endings: None });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a modern iOS-style pill toggle switch.
|
||||
fn toggle_switch(ui: &mut egui::Ui, on: &mut bool, label: &str, t: &Tokens) -> egui::Response {
|
||||
let width = 34.0;
|
||||
let height = 18.0;
|
||||
let (rect, mut response) = ui.allocate_exact_size(vec2(width, height), Sense::click());
|
||||
if response.clicked() {
|
||||
*on = !*on;
|
||||
response.mark_changed();
|
||||
}
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Switch, ui.is_enabled(), *on, label));
|
||||
|
||||
if ui.is_rect_visible(rect) {
|
||||
let radius = height / 2.0;
|
||||
let bg_color = if *on { t.accent } else { t.border };
|
||||
ui.painter().rect(rect, CornerRadius::same(radius as u8), bg_color, Stroke::NONE, egui::StrokeKind::Inside);
|
||||
let circle_radius = radius - 2.5;
|
||||
let circle_x = if *on { rect.max.x - radius } else { rect.min.x + radius };
|
||||
ui.painter().circle_filled(egui::pos2(circle_x, rect.center().y), circle_radius, Color32::WHITE);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
/// Render the dock placement choice: [ Float | Left | Right | Top | Bottom ], scrolling sideways
|
||||
/// when the picker is narrower than the row.
|
||||
fn segmented_dock_picker(ui: &mut egui::Ui, bar: &mut Hotbar, t: &Tokens) {
|
||||
let options = [
|
||||
(None, tl!("Float")),
|
||||
(Some("left"), tl!("Left")),
|
||||
(Some("right"), tl!("Right")),
|
||||
(Some("top"), tl!("Top")),
|
||||
(Some("bottom"), tl!("Bottom")),
|
||||
];
|
||||
let mut side = options.iter().map(|(side, _)| *side).find(|side| *side == bar.dock.as_deref()).flatten();
|
||||
let changed = egui::ScrollArea::horizontal()
|
||||
.id_salt("hotbar-dock")
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| crate::dialogs::preferences::segmented_pill_choice(ui, t, &mut side, &options))
|
||||
.inner;
|
||||
if changed {
|
||||
// `place` accepts every side listed above.
|
||||
let _ = bar.place(side.unwrap_or("float"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the unified floating or side-docked toolbar, combining quick actions, core tools, and custom shortcuts.
|
||||
pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, page_left: Option<f32>, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let mut bar = std::mem::take(&mut app.hotbar);
|
||||
let t = Tokens::get(ctx);
|
||||
let id = egui::Id::unique("custom-hotbar");
|
||||
bar.normalize();
|
||||
let t = Tokens::get(&ctx);
|
||||
let id = egui::Id::unique("quick-bar");
|
||||
let full_side = bar.full_side && bar.dock.is_some();
|
||||
let bounds = if full_side { area } else { area.shrink(10.0) };
|
||||
if let Some(side) = bar.dock.clone() {
|
||||
let _ = bar.place(&side);
|
||||
}
|
||||
let cell = bar.button_size;
|
||||
let mut size = ctx.data_mut(|data| data.get_temp::<egui::Vec2>(id)).unwrap_or(vec2(90.0, 44.0));
|
||||
let popup_align = popup_align_for(bar.dock.as_deref(), bar.vertical);
|
||||
let mut size = ctx.data_mut(|data| data.get_temp::<egui::Vec2>(id)).unwrap_or(vec2(crate::canvas::bars::QUICK_BAR_WIDTH, 220.0));
|
||||
if full_side {
|
||||
size = if bar.vertical { vec2(cell + 12.0, bounds.height()) } else { vec2(bounds.width(), cell + 12.0) };
|
||||
}
|
||||
let travel = (bounds.size() - size).max(vec2(0.0, 0.0));
|
||||
let pos = bounds.min + vec2(travel.x * bar.position[0], travel.y * bar.position[1]);
|
||||
let default_x = crate::canvas::bars::quick_bar_offset(page_left.map_or(0.0, |left| left - area.left()), size.x);
|
||||
let pos = if full_side {
|
||||
bounds.min + vec2(travel.x * bar.position[0], travel.y * bar.position[1])
|
||||
} else if bar.dock.is_none() && !bar.placed {
|
||||
area.left_top() + vec2(default_x, crate::canvas::bars::QUICK_BAR_EDGE)
|
||||
} else {
|
||||
bounds.min + vec2(travel.x * bar.position[0], travel.y * bar.position[1])
|
||||
};
|
||||
let mut run = None;
|
||||
let was_picker_open = bar.picker_open;
|
||||
let mut picker_open = bar.picker_open;
|
||||
let shown = egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).constrain_to(bounds).show(ctx, |ui| {
|
||||
let mut settings_clicked = false;
|
||||
let shown = egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).constrain_to(bounds).show(&ctx, |ui| {
|
||||
let frame = if full_side {
|
||||
egui::Frame::NONE.fill(t.chrome).corner_radius(egui::CornerRadius::ZERO).inner_margin(6)
|
||||
} else {
|
||||
egui::Frame::new().fill(t.card).stroke(egui::Stroke::new(1.0, t.border)).corner_radius(10).inner_margin(6)
|
||||
egui::Frame::new()
|
||||
.fill(t.card)
|
||||
.stroke(egui::Stroke::new(1.0, t.border))
|
||||
.corner_radius(CornerRadius::same(12))
|
||||
.shadow(egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(if t.dark() { 100 } else { 28 }) })
|
||||
.inner_margin(6)
|
||||
};
|
||||
let frame_resp = frame.show(ui, |ui| {
|
||||
if full_side {
|
||||
@@ -160,23 +452,33 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, ctx: &egui::Context) {
|
||||
if full_side {
|
||||
if bar.vertical {
|
||||
ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| {
|
||||
settings_button(ui, cell, &mut picker_open);
|
||||
settings_clicked |= settings_button(ui, cell, &mut picker_open);
|
||||
ui.add_space(4.0);
|
||||
let remaining = ui.available_height().max(cell);
|
||||
egui::ScrollArea::vertical().max_width(cell).max_height(remaining).show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().max_height(remaining).show(ui, |ui| {
|
||||
ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| {
|
||||
tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove);
|
||||
core_tool_buttons(ui, app, cell, popup_align, &t);
|
||||
active_tool_controls(ui, app, &t, true);
|
||||
if !bar.tools.is_empty() {
|
||||
ui.separator();
|
||||
tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
settings_button(ui, cell, &mut picker_open);
|
||||
settings_clicked |= settings_button(ui, cell, &mut picker_open);
|
||||
ui.add_space(4.0);
|
||||
let remaining = ui.available_width().max(cell);
|
||||
egui::ScrollArea::horizontal().max_height(cell).max_width(remaining).show(ui, |ui| {
|
||||
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove);
|
||||
core_tool_buttons(ui, app, cell, popup_align, &t);
|
||||
active_tool_controls(ui, app, &t, false);
|
||||
if !bar.tools.is_empty() {
|
||||
ui.separator();
|
||||
tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -198,21 +500,44 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, ctx: &egui::Context) {
|
||||
grip.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, drag_label));
|
||||
if grip.dragged() && bar.dock.is_none() {
|
||||
let delta = grip.drag_delta();
|
||||
if !bar.placed {
|
||||
let cur_x = (pos.x - bounds.min.x).max(0.0);
|
||||
let cur_y = (pos.y - bounds.min.y).max(0.0);
|
||||
bar.position[0] = (cur_x / travel.x.max(1.0)).clamp(0.0, 1.0);
|
||||
bar.position[1] = (cur_y / travel.y.max(1.0)).clamp(0.0, 1.0);
|
||||
}
|
||||
bar.position[0] = (bar.position[0] + delta.x / travel.x.max(1.0)).clamp(0.0, 1.0);
|
||||
bar.position[1] = (bar.position[1] + delta.y / travel.y.max(1.0)).clamp(0.0, 1.0);
|
||||
bar.placed = true;
|
||||
}
|
||||
let scroll_max = if bar.vertical { (bounds.height() - cell - 50.0).max(cell) } else { (bounds.width() - cell - 50.0).max(cell) };
|
||||
let scroll = if bar.vertical {
|
||||
egui::ScrollArea::vertical().max_width(cell).max_height(scroll_max)
|
||||
} else {
|
||||
egui::ScrollArea::horizontal().max_height(cell).max_width(scroll_max)
|
||||
};
|
||||
scroll.auto_shrink([true, true]).show(ui, |ui| {
|
||||
ui.with_layout(layout, |ui| {
|
||||
tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove);
|
||||
if bar.vertical {
|
||||
let fixed_overhead = grip_size.y + cell + 24.0;
|
||||
let max_tools_length = (bounds.height() - fixed_overhead).max(cell);
|
||||
egui::ScrollArea::vertical().max_height(max_tools_length).show(ui, |ui| {
|
||||
ui.with_layout(layout, |ui| {
|
||||
core_tool_buttons(ui, app, cell, popup_align, &t);
|
||||
active_tool_controls(ui, app, &t, true);
|
||||
if !bar.tools.is_empty() {
|
||||
ui.separator();
|
||||
tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
settings_button(ui, cell, &mut picker_open);
|
||||
settings_clicked |= settings_button(ui, cell, &mut picker_open);
|
||||
} else {
|
||||
core_tool_buttons(ui, app, cell, popup_align, &t);
|
||||
active_tool_controls(ui, app, &t, false);
|
||||
if !bar.tools.is_empty() {
|
||||
ui.separator();
|
||||
let scroll_max = (bounds.width() - cell - 50.0).max(cell);
|
||||
egui::ScrollArea::horizontal().max_height(cell).max_width(scroll_max).show(ui, |ui| {
|
||||
ui.with_layout(layout, |ui| {
|
||||
tool_buttons(ui, app, &bar.tools, cell, &mut run, &mut remove);
|
||||
});
|
||||
});
|
||||
}
|
||||
settings_clicked |= settings_button(ui, cell, &mut picker_open);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(index) = remove {
|
||||
@@ -241,50 +566,132 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, ctx: &egui::Context) {
|
||||
});
|
||||
let picker_id = egui::Id::unique("hotbar-picker");
|
||||
// A remembered collapsed state can survive disabling the collapse affordance.
|
||||
let mut expanded = egui::collapsing_header::CollapsingState::load_with_default_open(ctx, picker_id.with("collapsing"), true);
|
||||
let mut expanded = egui::collapsing_header::CollapsingState::load_with_default_open(&ctx, picker_id.with("collapsing"), true);
|
||||
expanded.set_open(true);
|
||||
expanded.store(ctx);
|
||||
expanded.store(&ctx);
|
||||
let screen = ctx.content_rect().shrink(12.0);
|
||||
let picker_x = if pos.x + cell + 380.0 < screen.right() { pos.x + cell + 20.0 } else { pos.x - 360.0 };
|
||||
let picker_pos = egui::pos2(
|
||||
picker_x.clamp(screen.left(), (screen.right() - 350.0).max(screen.left())),
|
||||
pos.y.clamp(screen.top(), (screen.bottom() - 640.0).max(screen.top())),
|
||||
);
|
||||
egui::Window::new(tl!("Hotbar tools"))
|
||||
let margin_x = 14.0;
|
||||
let card_margin_total = margin_x * 2.0;
|
||||
let card_width = 280.0f32.min((screen.width() - card_margin_total).max(180.0));
|
||||
let outer_width = card_width + card_margin_total;
|
||||
let bar_rect = shown.response.rect;
|
||||
let picker_x =
|
||||
if bar_rect.right() + outer_width + 10.0 < screen.right() { bar_rect.right() + 14.0 } else { bar_rect.left() - outer_width - 14.0 };
|
||||
let picker_y = if bar.dock.as_deref() == Some("top") {
|
||||
(bar_rect.bottom() + 10.0).clamp(screen.top(), (screen.bottom() - 480.0).max(screen.top()))
|
||||
} else if bar.dock.as_deref() == Some("bottom") {
|
||||
(bar_rect.top() - 500.0).clamp(screen.top(), (screen.bottom() - 480.0).max(screen.top()))
|
||||
} else {
|
||||
bar_rect.top().clamp(screen.top(), (screen.bottom() - 480.0).max(screen.top()))
|
||||
};
|
||||
let picker_pos = egui::pos2(picker_x.clamp(screen.left(), (screen.right() - outer_width).max(screen.left())), picker_y);
|
||||
let mut close_dialog = false;
|
||||
let picker_resp = egui::Window::new(tl!("Hotbar tools"))
|
||||
.id(picker_id)
|
||||
.order(egui::Order::Foreground)
|
||||
.open(&mut picker_open)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.title_bar(false)
|
||||
.fixed_pos(picker_pos)
|
||||
.max_height((screen.height() - 24.0).max(180.0))
|
||||
.vscroll(true)
|
||||
.show(ctx, |ui| {
|
||||
ui.set_min_width(260.0);
|
||||
ui.add(egui::Slider::new(&mut bar.button_size, 24.0..=64.0).text(tl!("Toolbar size")).suffix(" px"));
|
||||
ui.add_enabled(bar.dock.is_none(), crate::widgets::checkbox(&mut bar.vertical, tl!("Vertical layout")));
|
||||
if ui.selectable_label(bar.dock.is_none(), tl!("Float (drag anywhere)")).clicked() {
|
||||
let _ = bar.place("float");
|
||||
}
|
||||
ui.label(tl!("Stick to a side:"));
|
||||
.frame(
|
||||
egui::Frame::NONE
|
||||
.fill(t.card)
|
||||
.stroke(Stroke::new(1.0, t.border))
|
||||
.corner_radius(CornerRadius::same(10))
|
||||
.shadow(egui::Shadow { offset: [0, 6], blur: 20, spread: 0, color: Color32::from_black_alpha(if t.dark() { 120 } else { 35 }) })
|
||||
.inner_margin(egui::Margin::symmetric(14, 12)),
|
||||
)
|
||||
.show(&ctx, |ui| {
|
||||
ui.set_width(card_width);
|
||||
|
||||
// Title bar: "Hotbar Tools" on the left, "X" close button on the right
|
||||
ui.horizontal(|ui| {
|
||||
for (label, side) in [(tl!("Left"), "left"), (tl!("Right"), "right"), (tl!("Top"), "top"), (tl!("Bottom"), "bottom")] {
|
||||
if ui.selectable_label(bar.dock.as_deref() == Some(side), label).clicked() {
|
||||
let _ = bar.place(side);
|
||||
ui.label(egui::RichText::new(tl!("Hotbar tools")).size(14.0).strong().color(t.text));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if icons::button(ui, "x", 16.0, false, tl!("Close menu")).clicked() {
|
||||
close_dialog = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(10.0);
|
||||
|
||||
// Section 1: Layout & Sizing
|
||||
ui.label(egui::RichText::new(tl!("Layout & Sizing")).size(13.0).strong().color(t.text));
|
||||
ui.add_space(6.0);
|
||||
|
||||
// Toolbar size slider row
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Toolbar size")).size(12.5).color(t.text));
|
||||
let slider_width = (ui.available_width() - 55.0).clamp(50.0, 140.0);
|
||||
ui.spacing_mut().slider_width = slider_width;
|
||||
ui.add(egui::Slider::new(&mut bar.button_size, 24.0..=64.0).show_value(false));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(egui::RichText::new(format!("{:.0} px", bar.button_size)).size(12.0).color(t.text_muted));
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Vertical layout toggle switch row
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_enabled_ui(bar.dock.is_none(), |ui| {
|
||||
let label = tl!("Vertical layout");
|
||||
toggle_switch(ui, &mut bar.vertical, label, &t);
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new(label).size(12.5).color(t.text));
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(10.0);
|
||||
|
||||
// Stick to a side section with segmented control
|
||||
ui.label(egui::RichText::new(tl!("Stick to a side:")).size(12.5).color(t.text));
|
||||
ui.add_space(6.0);
|
||||
segmented_dock_picker(ui, &mut bar, &t);
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Full-side strip checkbox
|
||||
let strip_hint = tl!("Extend the docked toolbar along the entire side. Choose a side above to enable.");
|
||||
ui.add_enabled(bar.dock.is_some(), crate::widgets::checkbox(&mut bar.full_side, tl!("Full-side strip")))
|
||||
.on_hover_text(strip_hint)
|
||||
.on_disabled_hover_text(strip_hint);
|
||||
if ui.button(tl!("Close menu")).clicked() {
|
||||
ui.close();
|
||||
}
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.separator();
|
||||
ui.label(tl!("Add tools to hotbar"));
|
||||
ui.add(egui::TextEdit::singleline(&mut bar.search).hint_text(tl!("Search all tools…")));
|
||||
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
|
||||
ui.add_space(10.0);
|
||||
|
||||
// Section 2: Add Tools to Hotbar
|
||||
ui.label(egui::RichText::new(tl!("Add Tools to Hotbar")).size(13.0).strong().color(t.text));
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Search bar pill
|
||||
let search_frame = egui::Frame::NONE
|
||||
.fill(t.field)
|
||||
.stroke(Stroke::new(1.0, t.border))
|
||||
.corner_radius(CornerRadius::same(8))
|
||||
.inner_margin(egui::Margin::symmetric(10, 6));
|
||||
search_frame.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
ui.add(icons::image("search", 14.0, t.text_faint));
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut bar.search)
|
||||
.hint_text(tl!("Search all tools…"))
|
||||
.frame(egui::Frame::NONE)
|
||||
.desired_width(ui.available_width()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Scrollable tools list with checkboxes
|
||||
let max_list_height = (screen.height() - 340.0).clamp(120.0, 220.0);
|
||||
egui::ScrollArea::vertical().max_height(max_list_height).auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 5.0;
|
||||
for spec in COMMANDS {
|
||||
if !matches_search(spec, &bar.search) {
|
||||
continue;
|
||||
@@ -299,9 +706,23 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
});
|
||||
ctx.data_mut(|data| data.insert_temp(id, shown.response.rect.size()));
|
||||
let clicked_outside = was_picker_open
|
||||
&& !settings_clicked
|
||||
&& picker_resp.as_ref().is_some_and(|wr| {
|
||||
wr.response.clicked_elsewhere()
|
||||
|| ctx.input(|i| i.pointer.any_click() && !i.pointer.interact_pos().is_some_and(|p| wr.response.rect.contains(p)))
|
||||
});
|
||||
if clicked_outside || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
close_dialog = true;
|
||||
}
|
||||
if close_dialog {
|
||||
picker_open = false;
|
||||
}
|
||||
ctx.data_mut(|data| {
|
||||
data.insert_temp(id, shown.response.rect.size());
|
||||
data.insert_temp(egui::Id::unique("custom-hotbar"), shown.response.rect.size());
|
||||
});
|
||||
bar.picker_open = picker_open;
|
||||
app.hotbar = bar;
|
||||
if let Some(id) = run {
|
||||
@@ -358,8 +779,29 @@ mod tests {
|
||||
assert_eq!(app.hotbar.position, [0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotbar_restore_moves_legacy_default_into_margin_and_keeps_chosen_spots() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.restore(r#"{"hotbar":{"tools":["comment.ink"],"position":[0.5,0.0],"vertical":false,"button_size":40.0,"dock":null}}"#);
|
||||
assert_eq!(app.hotbar.position, [0.0, 0.0]);
|
||||
assert!(app.hotbar.vertical);
|
||||
assert!(!app.hotbar.placed);
|
||||
assert_eq!(app.hotbar.tools, ["comment.ink"]);
|
||||
assert_eq!(app.hotbar.button_size, 40.0);
|
||||
app.restore(r#"{"hotbar":{"position":[0.0,0.0],"vertical":false,"dock":null}}"#);
|
||||
assert_eq!(app.hotbar.position, [0.0, 0.0]);
|
||||
assert!(!app.hotbar.vertical);
|
||||
assert!(app.hotbar.placed, "a legacy bar dragged to the corner stays there");
|
||||
app.restore(r#"{"hotbar":{"position":[0.5,0.0],"vertical":false,"dock":null,"placed":true}}"#);
|
||||
assert_eq!(app.hotbar.position, [0.5, 0.0], "current preferences are not migrated");
|
||||
assert!(!app.hotbar.vertical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hotbar_search_matches_translated_and_canonical_labels() {
|
||||
if !crate::i18n::needs_catalogs() {
|
||||
return;
|
||||
}
|
||||
let ink = command("comment.ink").unwrap();
|
||||
crate::i18n::set_current(crate::i18n::lang_from_tag("fr").unwrap());
|
||||
assert_eq!(tl!(ink.label), "Dessiner à main levée");
|
||||
|
||||
+231
-279
@@ -1,19 +1,28 @@
|
||||
//! UI localisation. Strings in code stay English and are the default lookup keys; a per-language
|
||||
//! catalog (`*.tsv`, see `ja.tsv` for the format) maps them to display text at render time. Command
|
||||
//! ids, document text, file names, the control channel, the CLI and MCP never see translated text,
|
||||
//! so agents and scripts are unaffected. A string without a translation is shown in English, so
|
||||
//! coverage can grow incrementally.
|
||||
//! UI localisation. Strings in code stay English and are the default lookup keys; the craft-i18n
|
||||
//! catalogs map them to display text at render time. Command ids, document text, file names, the
|
||||
//! control channel, the CLI and MCP never see translated text, so agents and scripts are
|
||||
//! unaffected. A string without a translation is shown in English, so coverage can grow
|
||||
//! incrementally, and a missing translation never fails a build, a test or a release.
|
||||
//!
|
||||
//! The system is the one PhotoCraft uses (`photocraft/crates/ui-egui/src/i18n`), so catalogs and
|
||||
//! habits carry over between the two apps.
|
||||
//! The catalogs live in [storytold/craft-i18n](https://github.com/storytold/craft-i18n)
|
||||
//! (`locales/<code>/common.tsv`, shared by the Crafting Apps, and `pdfcraft.tsv`), not in this
|
||||
//! repository. `build.rs` embeds them from `CRAFT_I18N_DIR` (CI and releases check out the commit
|
||||
//! in `craft-i18n.rev`); without it the build is English only. Lookup, fallback (PdfCraft's rows
|
||||
//! over shared ones), plural forms (CLDR) and language names are the `craft-i18n` crate's
|
||||
//! (craft-libs). Which languages are offered, how the system locale picks one, right to left and
|
||||
//! `{name}` placeholders stay here.
|
||||
//!
|
||||
//! # Adding or changing a translation
|
||||
//! In craft-i18n: add rows to `locales/<code>/pdfcraft.tsv` (its `docs/integration.md`). They show
|
||||
//! here once `craft-i18n.rev` moves to a commit that has them. A feature PR here adds the English
|
||||
//! only.
|
||||
//!
|
||||
//! # Adding a language
|
||||
//! 1. Add `xx.tsv` next to `ja.tsv` (copy its header; translate from the *meaning* of the English
|
||||
//! text, clean-room, see `ja.tsv`).
|
||||
//! 2. Add one row to [`LANGUAGES`] (code, native name, catalog, plural rule).
|
||||
//! 1. craft-i18n gets `locales/<code>/pdfcraft.tsv` with its `@id language.name` row.
|
||||
//! 2. Add one row to [`LANGUAGES`] (code, right to left), and its English name to
|
||||
//! [`Lang::english_name`].
|
||||
//!
|
||||
//! That is all: the Preferences dropdown, the system-locale match and the catalog tests (parse,
|
||||
//! placeholders, plural forms) pick it up from the registry.
|
||||
//! The Preferences dropdown and the system-locale match pick it up from there.
|
||||
//!
|
||||
//! # Looking strings up
|
||||
//! - `tl!` / [`t`]: a plain string in the current language. [`tr`]: in a given one.
|
||||
@@ -23,323 +32,173 @@
|
||||
//! - [`trn`]: plural-aware (`{n}` is filled in). [`fmt`]: fill `{name}` placeholders after [`tr`];
|
||||
//! translators may reorder placeholders freely.
|
||||
|
||||
mod catalog;
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::sync::OnceLock;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{LazyLock, OnceLock};
|
||||
|
||||
use catalog::Catalog;
|
||||
use craft_i18n::I18n;
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/craft_i18n.rs"));
|
||||
|
||||
/// The embedded catalogs, offering exactly the languages in [`LANGUAGES`]. Built on first use; a
|
||||
/// locale is parsed on its first lookup.
|
||||
static I18N: LazyLock<I18n> = LazyLock::new(|| {
|
||||
let offered: Vec<&str> = LANGUAGES.iter().map(|l| l.code).collect();
|
||||
let (i18n, problems) = I18n::new(CRAFT_I18N, &offered);
|
||||
for problem in problems {
|
||||
log::warn!("interface catalogs: {problem}");
|
||||
}
|
||||
i18n
|
||||
});
|
||||
|
||||
/// The `language` preference value that follows the system locale.
|
||||
pub const AUTO: &str = "auto";
|
||||
|
||||
/// One supported UI language.
|
||||
/// One UI language PdfCraft offers, when its catalogs are embedded: what the app needs beyond
|
||||
/// them. Its name and translations come from craft-i18n.
|
||||
pub struct LangInfo {
|
||||
/// BCP 47 code, lowercase (`ja`, `zh-hans`, `pt-br`). Also the `language` preference value.
|
||||
/// BCP 47 code, lowercase (`ja`, `zh-hans`, `pt-br`). Also the `language` preference value and
|
||||
/// the craft-i18n locale.
|
||||
pub code: &'static str,
|
||||
/// The language's name in itself, shown in the Preferences dropdown.
|
||||
pub name: &'static str,
|
||||
/// Catalog file contents (empty for the built-in English).
|
||||
pub source: &'static str,
|
||||
/// Plural form index for a count (English: 0 = one, 1 = other; Japanese: always 0). A catalog's
|
||||
/// `@plural` entries list one form per index.
|
||||
pub plural: fn(u64) -> usize,
|
||||
/// The language is written right to left. Catalogs stay in logical order: the egui 0.37 fork
|
||||
/// owns bidi shaping/reordering (E5/E12), so preordering here would reverse Arabic twice.
|
||||
pub rtl: bool,
|
||||
catalog: OnceLock<Catalog>,
|
||||
}
|
||||
|
||||
fn plural_one_other(n: u64) -> usize {
|
||||
usize::from(n != 1)
|
||||
}
|
||||
/// How many languages [`LANGUAGES`] lists.
|
||||
const LANGUAGE_COUNT: usize = 35;
|
||||
|
||||
fn plural_none(_: u64) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
/// Romanian (CLDR `ro`): 1 is `one`; 0 and numbers whose last two digits are 1–19 (2, 19, 101,
|
||||
/// 119) are `few`; everything else (20, 100, 120) is `other`, the form with "de" ("20 de pagini").
|
||||
fn plural_ro(n: u64) -> usize {
|
||||
if n == 1 {
|
||||
0
|
||||
} else if n == 0 || (1..=19).contains(&(n % 100)) {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
/// Lithuanian: one (ending in 1 except 11), few (ending in 2–9 except 12–19), other otherwise.
|
||||
fn plural_lithuanian(n: u64) -> usize {
|
||||
match (n % 10, n % 100) {
|
||||
(1, 11) => 2,
|
||||
(1, _) => 0,
|
||||
(2..=9, 12..=19) => 2,
|
||||
(2..=9, _) => 1,
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Czech: 1 → one, 2–4 → few, everything else (0, 5+) → other.
|
||||
fn plural_cs(n: u64) -> usize {
|
||||
match n {
|
||||
1 => 0,
|
||||
2..=4 => 1,
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Portuguese: 0 and 1 take the singular, everything else the plural.
|
||||
fn plural_pt(n: u64) -> usize {
|
||||
usize::from(n > 1)
|
||||
}
|
||||
|
||||
/// French (CLDR `fr`): 0 and 1 take the singular, everything else the plural.
|
||||
fn plural_fr(n: u64) -> usize {
|
||||
usize::from(n > 1)
|
||||
}
|
||||
|
||||
/// Russian: 1 (but not 11) is `one`, 2–4 (but not 12–14) `few`, everything else `many`.
|
||||
fn plural_russian(n: u64) -> usize {
|
||||
match n % 100 {
|
||||
11..=14 => 2,
|
||||
_ => match n % 10 {
|
||||
1 => 0,
|
||||
2..=4 => 1,
|
||||
_ => 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Ukrainian (CLDR `uk`, integer counts): one (1, 21), few (2–4, 22–24), many (0, 5–19).
|
||||
/// Counts ending in 11–14 always take many, regardless of the last digit.
|
||||
fn plural_ukrainian(n: u64) -> usize {
|
||||
match n % 100 {
|
||||
11..=14 => 2,
|
||||
_ => match n % 10 {
|
||||
1 => 0,
|
||||
2..=4 => 1,
|
||||
_ => 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Arabic (CLDR `ar`, integer counts): zero (0), one (1), two (2), few (3–10, 103–110),
|
||||
/// many (11–99, 111–199), other (100–102, 200–202).
|
||||
fn plural_arabic(n: u64) -> usize {
|
||||
match (n, n % 100) {
|
||||
(0, _) => 0,
|
||||
(1, _) => 1,
|
||||
(2, _) => 2,
|
||||
(_, 3..=10) => 3,
|
||||
(_, 11..=99) => 4,
|
||||
_ => 5,
|
||||
}
|
||||
}
|
||||
|
||||
/// Polish integer counts: 1 is one; endings 2–4 except 12–14 are few; the rest are many.
|
||||
fn plural_polish(n: u64) -> usize {
|
||||
match (n, n % 100, n % 10) {
|
||||
(1, _, _) => 0,
|
||||
(_, 12..=14, _) => 2,
|
||||
(_, _, 2..=4) => 1,
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Slovene (CLDR `sl`): one (1, 101, 201 ...) | two (2, 102 ...) | few (3, 4, 103 ...) | other.
|
||||
fn plural_sl(n: u64) -> usize {
|
||||
match n % 100 {
|
||||
1 => 0,
|
||||
2 => 1,
|
||||
3 | 4 => 2,
|
||||
_ => 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 35] = [
|
||||
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, rtl: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "ja", name: "日本語", source: include_str!("i18n/ja.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() },
|
||||
/// The languages PdfCraft offers. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; LANGUAGE_COUNT] = [
|
||||
LangInfo { code: "en", rtl: false },
|
||||
LangInfo { code: "ja", rtl: false },
|
||||
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` locales resolve here (see `candidates`).
|
||||
LangInfo {
|
||||
code: "zh-hans",
|
||||
name: "简体中文",
|
||||
source: include_str!("i18n/zh-hans.tsv"),
|
||||
plural: plural_none,
|
||||
rtl: false,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
LangInfo { code: "zh-hans", rtl: false },
|
||||
// Traditional Chinese in the vocabulary used in Taiwan; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*`
|
||||
// locales all resolve here (see `candidates`).
|
||||
LangInfo {
|
||||
code: "zh-hant",
|
||||
name: "繁體中文",
|
||||
source: include_str!("i18n/zh-hant.tsv"),
|
||||
plural: plural_none,
|
||||
rtl: false,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
LangInfo { code: "cs", name: "Čeština", source: include_str!("i18n/cs.tsv"), plural: plural_cs, rtl: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "zh-hant", rtl: false },
|
||||
LangInfo { code: "cs", rtl: false },
|
||||
// Brazilian Portuguese; `pt`, `pt-BR` and `pt-PT` locales all resolve here (see `candidates`).
|
||||
LangInfo {
|
||||
code: "pt-br",
|
||||
name: "Português (Brasil)",
|
||||
source: include_str!("i18n/pt-br.tsv"),
|
||||
plural: plural_pt,
|
||||
rtl: false,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
LangInfo { code: "pt-br", rtl: false },
|
||||
// German (informal "du"); every `de-*` locale (`de-DE`, `de-AT`, `de-CH` ...) resolves here.
|
||||
LangInfo { code: "de", name: "Deutsch", source: include_str!("i18n/de.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "de", rtl: false },
|
||||
// Spanish (European vocabulary); every `es-*` locale (`es-ES`, `es-MX`, `es-419` ...) resolves here.
|
||||
LangInfo { code: "es", name: "Español", source: include_str!("i18n/es.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "es", rtl: false },
|
||||
// French; every `fr-*` locale (`fr-FR`, `fr-CA`, `fr-BE` ...) resolves here.
|
||||
LangInfo { code: "fr", name: "Français", source: include_str!("i18n/fr.tsv"), plural: plural_fr, rtl: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "fr", rtl: false },
|
||||
// Russian; every `ru-*` locale (`ru-RU`, `ru-BY`, `ru-KZ` ...) resolves here.
|
||||
LangInfo {
|
||||
code: "ru", name: "Русский", source: include_str!("i18n/ru.tsv"), plural: plural_russian, rtl: false, catalog: OnceLock::new()
|
||||
},
|
||||
LangInfo { code: "ru", rtl: false },
|
||||
// Kazakh; every `kk-*` locale (`kk-KZ`, `kk-KZ.UTF-8`) resolves here.
|
||||
LangInfo {
|
||||
code: "kk", name: "Қазақша", source: include_str!("i18n/kk.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new()
|
||||
},
|
||||
LangInfo { code: "kk", rtl: false },
|
||||
// Bulgarian; every `bg-*` locale (`bg-BG`) resolves here.
|
||||
LangInfo {
|
||||
code: "bg", name: "Български", source: include_str!("i18n/bg.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new()
|
||||
},
|
||||
LangInfo { code: "bg", rtl: false },
|
||||
// Telugu; every `te-*` locale (`te-IN`) resolves here.
|
||||
LangInfo {
|
||||
code: "te", name: "తెలుగు", source: include_str!("i18n/te.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new()
|
||||
},
|
||||
LangInfo { code: "te", rtl: false },
|
||||
// Hungarian; every `hu-*` locale (`hu-HU`) resolves here.
|
||||
LangInfo { code: "hu", name: "Magyar", source: include_str!("i18n/hu.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "hu", rtl: false },
|
||||
// Ukrainian; every `uk-*` locale (`uk-UA`, `uk_UA.UTF-8`) resolves here.
|
||||
LangInfo {
|
||||
code: "uk", name: "Українська", source: include_str!("i18n/uk.tsv"), plural: plural_ukrainian, rtl: false, catalog: OnceLock::new()
|
||||
},
|
||||
LangInfo { code: "uk", rtl: false },
|
||||
// Italian; every `it-*` locale (`it-IT`) resolves here.
|
||||
LangInfo { code: "it", name: "Italiano", source: include_str!("i18n/it.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "it", rtl: false },
|
||||
// Arabic (Modern Standard, Western digits); every `ar-*` locale (`ar-MA`, `ar-EG`, `ar-SA` ...)
|
||||
// resolves here. Right to left: see `LangInfo::rtl`.
|
||||
LangInfo { code: "ar", name: "العربية", source: include_str!("i18n/ar.tsv"), plural: plural_arabic, rtl: true, catalog: OnceLock::new() },
|
||||
LangInfo { code: "ar", rtl: true },
|
||||
// Central Kurdish (Sorani); every `ckb-*` locale (`ckb-IQ`, `ckb-IR`) resolves here. Right to
|
||||
// left like Arabic: see `LangInfo::rtl`. CLDR `ckb` counts have two forms (one, other).
|
||||
LangInfo {
|
||||
code: "ckb",
|
||||
name: "کوردیی ناوەندی",
|
||||
source: include_str!("i18n/ckb.tsv"),
|
||||
plural: plural_one_other,
|
||||
rtl: true,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
LangInfo { code: "ckb", rtl: true },
|
||||
// Persian (Farsi); every `fa-*` locale (`fa-IR`, `fa_IR.UTF-8`, `fa-AF` ...) resolves here. Right
|
||||
// to left like Arabic: see `LangInfo::rtl`. CLDR `fa` counts have two forms (one: i = 0 or n = 1,
|
||||
// other), which for whole counts is the French rule, so `@plural` entries list two forms.
|
||||
LangInfo { code: "fa", name: "فارسی", source: include_str!("i18n/fa.tsv"), plural: plural_fr, rtl: true, catalog: OnceLock::new() },
|
||||
LangInfo { code: "fa", rtl: true },
|
||||
// Polish; every `pl-*` locale (`pl-pl`, `pl_pl.UTF-8`) resolves here.
|
||||
LangInfo { code: "pl", name: "Polski", source: include_str!("i18n/pl.tsv"), plural: plural_polish, rtl: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "pl", rtl: false },
|
||||
// Slovene; every `sl-*` locale (`sl-SI`) resolves here. Four plural forms (one, two, few, other).
|
||||
LangInfo { code: "sl", name: "Slovenščina", source: include_str!("i18n/sl.tsv"), plural: plural_sl, rtl: false, catalog: OnceLock::new() },
|
||||
LangInfo {
|
||||
code: "el", name: "Ελληνικά", source: include_str!("i18n/el.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new()
|
||||
},
|
||||
// Turkish; every `tr-*` locale (`tr-TR`, `tr_TR.UTF-8`) resolves here.
|
||||
LangInfo { code: "tr", name: "Türkçe", source: include_str!("i18n/tr.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "sl", rtl: false },
|
||||
LangInfo { code: "el", rtl: false },
|
||||
// Turkish; every `tr-*` locale (`tr-TR`, `tr_TR.UTF-8`) resolves here. CLDR `tr` has two forms
|
||||
// (one, other); the catalog's counted nouns read the same in both, so its `@plural` rows repeat
|
||||
// the one form.
|
||||
LangInfo { code: "tr", rtl: false },
|
||||
// Lithuanian; every `lt-*` locale (`lt-LT`, `lt_LT.UTF-8`) resolves here. Three plural forms (one, few, other).
|
||||
LangInfo { code: "lt", name: "Lietuvių", source: include_str!("i18n/lt.tsv"), plural: plural_lithuanian, rtl: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "lt", rtl: false },
|
||||
// Indonesian; every `id-*` locale (`id-ID`, `id_ID.UTF-8`) resolves here. CLDR `id` counts have
|
||||
// one form ("other"), so `@plural` entries list a single form.
|
||||
LangInfo { code: "id", name: "Bahasa Indonesia", source: include_str!("i18n/id.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "id", rtl: false },
|
||||
// Vietnamese; every `vi-*` locale (`vi-VN`, `vi_VN.UTF-8`) resolves here. CLDR `vi` counts have
|
||||
// one form ("other"), so `@plural` entries list a single form.
|
||||
LangInfo { code: "vi", name: "Tiếng Việt", source: include_str!("i18n/vi.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "vi", rtl: false },
|
||||
// Finnish; every `fi-*` locale (`fi-FI`, `fi_FI.UTF-8`) resolves here. Two plural forms (one, other):
|
||||
// the partitive singular follows any number but 1 (`1 sivu`, `2 sivua`).
|
||||
LangInfo { code: "fi", name: "Suomi", source: include_str!("i18n/fi.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "fi", rtl: false },
|
||||
// Albanian; every `sq-*` locale (`sq-AL`, `sq_AL.UTF-8`, `sq-XK`) resolves here. One/other plural, like English.
|
||||
LangInfo { code: "sq", name: "Shqip", source: include_str!("i18n/sq.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "sq", rtl: false },
|
||||
// Thai; every `th-*` locale (`th-TH`, `th_TH.UTF-8`) resolves here. No grammatical plural.
|
||||
LangInfo { code: "th", name: "ไทย", source: include_str!("i18n/th.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "th", rtl: false },
|
||||
// Catalan; every `ca-*` locale (`ca-ES`, `ca_ES.UTF-8`, `ca_ES@valencia`) resolves here.
|
||||
LangInfo { code: "ca", name: "Català", source: include_str!("i18n/ca.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "ca", rtl: false },
|
||||
// Romanian; every `ro-*` locale (`ro-RO`, `ro_RO.UTF-8`, `ro-MD`) resolves here. Three plural forms (one, few, other).
|
||||
LangInfo { code: "ro", name: "Română", source: include_str!("i18n/ro.tsv"), plural: plural_ro, rtl: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "ro", rtl: false },
|
||||
// Lao; every `lo-*` locale (`lo-LA`, `lo_LA.UTF-8` ...) resolves here.
|
||||
LangInfo {
|
||||
code: "lo", name: "ພາສາລາວ", source: include_str!("i18n/lo.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new()
|
||||
},
|
||||
LangInfo { code: "lo", rtl: false },
|
||||
// Hindi; every `hi-*` locale (`hi-IN`, `hi_IN.UTF-8`) resolves here. CLDR `hi` has "one"
|
||||
// (0 and 1) and "other", but the catalog's nouns are the same in both ("{n} पृष्ठ"), so
|
||||
// `@plural` entries list a single form.
|
||||
LangInfo {
|
||||
code: "hi", name: "हिन्दी", source: include_str!("i18n/hi.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new()
|
||||
},
|
||||
// (0 and 1) and "other", but the catalog's nouns are the same in both ("{n} पृष्ठ"), so its
|
||||
// `@plural` rows repeat the one form.
|
||||
LangInfo { code: "hi", rtl: false },
|
||||
// Korean; every `ko-*` locale (`ko-KR`, `ko_KR.UTF-8`) resolves here. CLDR `ko` counts have
|
||||
// one form ("other"), so `@plural` entries list a single form.
|
||||
LangInfo { code: "ko", name: "한국어", source: include_str!("i18n/ko.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() },
|
||||
LangInfo { code: "ko", rtl: false },
|
||||
// Norwegian Bokmål; every `nb-*` locale resolves here, and so do general Norwegian (`no-*`) and
|
||||
// Nynorsk (`nn-*`), which have no catalog of their own (see `candidates`).
|
||||
LangInfo {
|
||||
code: "nb",
|
||||
name: "Norsk bokmål",
|
||||
source: include_str!("i18n/nb.tsv"),
|
||||
plural: plural_one_other,
|
||||
rtl: false,
|
||||
catalog: OnceLock::new(),
|
||||
},
|
||||
LangInfo { code: "nb", rtl: false },
|
||||
];
|
||||
|
||||
impl LangInfo {
|
||||
/// How many plural forms the language's `@plural` entries list.
|
||||
fn plural_forms(&self) -> usize {
|
||||
(0..=1000).map(self.plural).max().unwrap_or(0).saturating_add(1)
|
||||
}
|
||||
/// Per language of [`LANGUAGES`], the (context, source) keys of PdfCraft's own catalog as written
|
||||
/// (escaped), read on first use ([`Lang::has_own`]).
|
||||
static OWN_ROWS: [OnceLock<HashSet<(&'static str, &'static str)>>; LANGUAGE_COUNT] = [const { OnceLock::new() }; LANGUAGE_COUNT];
|
||||
|
||||
/// Return the parsed catalogue, building it on first use and warning about entries that fall back to
|
||||
/// English.
|
||||
fn catalog(&self) -> &Catalog {
|
||||
self.catalog.get_or_init(|| {
|
||||
let (catalog, errors) = Catalog::parse(self.source, self.plural_forms());
|
||||
for error in errors {
|
||||
log::warn!("{} interface catalog: {error}; that entry shows in English", self.code);
|
||||
}
|
||||
// Catalogs keep logical text: the fork shapes and reorders each run once. Accessibility
|
||||
// names, copied text and search therefore see the same logical string as the source.
|
||||
catalog
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A language the UI can be shown in (a handle into [`LANGUAGES`]).
|
||||
/// A language the UI can be shown in: English, or one of [`LANGUAGES`] whose catalogs are
|
||||
/// embedded.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Lang(&'static LangInfo);
|
||||
pub struct Lang {
|
||||
lang: craft_i18n::Lang,
|
||||
/// The catalogs it is looked up in: the embedded ones ([`I18N`]), or a test fixture's.
|
||||
catalogs: Option<&'static I18n>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Lang {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Lang({})", self.0.code)
|
||||
write!(f, "Lang({})", self.code())
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Lang {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.0.code == other.0.code
|
||||
self.code() == other.code()
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Lang {}
|
||||
|
||||
impl Lang {
|
||||
pub const EN: Lang = Lang(&LANGUAGES[0]);
|
||||
pub const EN: Lang = Lang { lang: craft_i18n::Lang::EN, catalogs: None };
|
||||
|
||||
pub fn code(self) -> &'static str {
|
||||
self.0.code
|
||||
fn i18n(self) -> &'static I18n {
|
||||
self.catalogs.unwrap_or_else(|| LazyLock::force(&I18N))
|
||||
}
|
||||
|
||||
/// A language by its exact code.
|
||||
fn info(self) -> Option<&'static LangInfo> {
|
||||
let code = self.code();
|
||||
LANGUAGES.iter().find(|l| l.code == code)
|
||||
}
|
||||
|
||||
pub fn code(self) -> &'static str {
|
||||
self.i18n().code(self.lang)
|
||||
}
|
||||
|
||||
/// A language by its exact code (any case): one of [`LANGUAGES`] whose catalogs are embedded.
|
||||
pub fn from_code(code: &str) -> Option<Lang> {
|
||||
LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code)).map(Lang)
|
||||
let info = LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code))?;
|
||||
I18N.from_code(info.code).map(|lang| Lang { lang, catalogs: None })
|
||||
}
|
||||
|
||||
/// Resolve the `language` preference: a language code, or [`AUTO`] (and anything unknown,
|
||||
@@ -348,9 +207,10 @@ impl Lang {
|
||||
Lang::from_code(pref).unwrap_or_else(system_lang)
|
||||
}
|
||||
|
||||
/// Every registered language.
|
||||
/// Every language the interface can be shown in: [`LANGUAGES`] in order, those whose catalogs
|
||||
/// are embedded (English alone in a build without craft-i18n).
|
||||
pub fn all() -> impl Iterator<Item = Lang> {
|
||||
LANGUAGES.iter().map(Lang)
|
||||
LANGUAGES.iter().filter_map(|l| Lang::from_code(l.code))
|
||||
}
|
||||
|
||||
/// Every registered language in the order a picker lists them: English first (the source
|
||||
@@ -372,8 +232,10 @@ impl Lang {
|
||||
std::iter::once(Lang::EN).chain(rest).collect()
|
||||
}
|
||||
|
||||
/// The language's name in itself (its `@id language.name` row), shown in the Preferences
|
||||
/// dropdown.
|
||||
pub fn name(self) -> &'static str {
|
||||
self.0.name
|
||||
self.i18n().name(self.lang)
|
||||
}
|
||||
|
||||
/// The language's name in English, used by [`picker_name`](Self::picker_name) when this
|
||||
@@ -417,7 +279,7 @@ impl Lang {
|
||||
"ko" => "Korean",
|
||||
// Only reached if a language is added to `LANGUAGES` without an English name; the
|
||||
// code still tells the reader which entry it is.
|
||||
_ => self.0.code,
|
||||
code => code,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,12 +294,46 @@ impl Lang {
|
||||
/// Whether the language is written right to left. Lookups stay logical; the egui fork
|
||||
/// shapes and reorders each run for display (see [`LangInfo::rtl`]).
|
||||
pub fn rtl(self) -> bool {
|
||||
self.0.rtl
|
||||
self.info().is_some_and(|l| l.rtl)
|
||||
}
|
||||
|
||||
fn catalog(self) -> &'static Catalog {
|
||||
self.0.catalog()
|
||||
/// Whether PdfCraft's own catalog (`pdfcraft.tsv`, not the shared `common.tsv`) has the row
|
||||
/// (`context`, `source`). The shared catalog's `@id` rows are keyed by the other apps' command
|
||||
/// ids, which can name another command (in Bulgarian and Indonesian its `file.close` row holds
|
||||
/// an id rather than a label), so command-id lookups ask this; every `@id` row PdfCraft had is
|
||||
/// in its own catalog. A test fixture has no shared catalog: its rows are its own.
|
||||
fn has_own(self, context: &str, source: &str) -> bool {
|
||||
if let Some(fixture) = self.catalogs {
|
||||
return fixture.get(self.lang, context, source).is_some();
|
||||
}
|
||||
let code = self.code();
|
||||
let Some(slot) = LANGUAGES.iter().position(|l| l.code == code).and_then(|i| OWN_ROWS.get(i)) else { return false };
|
||||
let rows: &HashSet<(&str, &str)> = slot.get_or_init(|| {
|
||||
let app = CRAFT_I18N.iter().find(|(c, ..)| c.eq_ignore_ascii_case(code)).map_or("", |(_, _, app)| *app);
|
||||
app.lines()
|
||||
.filter(|l| !l.starts_with('#'))
|
||||
.filter_map(|l| {
|
||||
let mut columns = l.split('\t');
|
||||
Some((columns.next()?, columns.next()?))
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let escape = |s: &str| s.replace('\\', "\\\\").replace('\n', "\\n").replace('\t', "\\t");
|
||||
rows.contains(&(escape(context).as_str(), escape(source).as_str()))
|
||||
}
|
||||
|
||||
/// The embedded catalog texts this language reads, the shared one then PdfCraft's; none for
|
||||
/// English. For checks that the interface fonts draw every translation.
|
||||
pub fn catalog_texts(self) -> impl Iterator<Item = &'static str> {
|
||||
let code = self.code();
|
||||
CRAFT_I18N.iter().filter(move |(c, ..)| c.eq_ignore_ascii_case(code)).flat_map(|(_, shared, app)| [*shared, *app])
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this build embeds the craft-i18n catalogs (`CRAFT_I18N_DIR` was set). Without them the
|
||||
/// interface is English only; tests that need a translation skip with a note.
|
||||
pub fn catalogs_embedded() -> bool {
|
||||
!CRAFT_I18N.is_empty()
|
||||
}
|
||||
|
||||
/// The canonical `language` preference for user input: [`AUTO`] or a registered code (any case).
|
||||
@@ -500,10 +396,8 @@ pub fn system_lang() -> Lang {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn detect_system_lang() -> Lang {
|
||||
for var in ["LC_ALL", "LC_MESSAGES", "LANG"] {
|
||||
if let Some(l) = std::env::var(var).ok().and_then(|v| language_override(&v)) {
|
||||
return l;
|
||||
}
|
||||
if let Some(l) = env_lang(|var| std::env::var(var).ok()) {
|
||||
return l;
|
||||
}
|
||||
// Apps started from the Finder don't inherit LANG: use the macOS preferred-languages list.
|
||||
// The absolute path keeps a `defaults` earlier on PATH from running; any failure means English.
|
||||
@@ -521,6 +415,13 @@ fn detect_system_lang() -> Lang {
|
||||
Lang::EN
|
||||
}
|
||||
|
||||
/// The language the locale variables name, `LC_ALL` first, then `LC_MESSAGES`, then `LANG`;
|
||||
/// `var` reads one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn env_lang(var: impl Fn(&str) -> Option<String>) -> Option<Lang> {
|
||||
["LC_ALL", "LC_MESSAGES", "LANG"].into_iter().find_map(|name| var(name).and_then(|v| language_override(&v)))
|
||||
}
|
||||
|
||||
/// A named locale can override the display language. On Windows, the neutral
|
||||
/// C/POSIX locale inherited from a launcher says nothing about the user's UI language.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -622,7 +523,7 @@ pub fn current() -> Lang {
|
||||
|
||||
/// Does `lang` have a catalog entry for this plain string? (English never does: it is the source.)
|
||||
pub fn has(lang: Lang, s: &str) -> bool {
|
||||
lang.catalog().plain(s).is_some()
|
||||
lang.i18n().get(lang.lang, "", s).is_some()
|
||||
}
|
||||
|
||||
/// Translate an English UI string into the current language ([`tr`] with [`current`]).
|
||||
@@ -633,12 +534,13 @@ pub fn t(s: &str) -> &str {
|
||||
/// The name of a keyboard key, distinct from a command such as Delete. Missing key-name
|
||||
/// translations use the English key name, never a plain command translation.
|
||||
pub fn key_name(s: &str) -> &str {
|
||||
current().catalog().contextual("key", s).unwrap_or(s)
|
||||
let lang = current();
|
||||
lang.i18n().get(lang.lang, "key", s).unwrap_or(s)
|
||||
}
|
||||
|
||||
/// Translate an English UI string; unknown strings come back unchanged.
|
||||
pub fn tr(lang: Lang, s: &str) -> &str {
|
||||
lang.catalog().plain(s).unwrap_or(s)
|
||||
lang.i18n().t(lang.lang, s)
|
||||
}
|
||||
|
||||
/// How the layers below say their own sentences in the current language.
|
||||
@@ -654,16 +556,41 @@ pub fn words(wording: &str) -> String {
|
||||
|
||||
/// Like [`tr`], for an English string that needs a disambiguating `context`.
|
||||
pub fn tr_ctx<'a>(lang: Lang, context: &str, s: &'a str) -> &'a str {
|
||||
lang.catalog().contextual(context, s).unwrap_or_else(|| tr(lang, s))
|
||||
lang.i18n().tc(lang.lang, context, s)
|
||||
}
|
||||
|
||||
/// A string keyed by its command id, falling back to the translation of the English `label`.
|
||||
/// A string keyed by its command id, falling back to the translation of the English `label`. Only
|
||||
/// PdfCraft's own `@id` rows count ([`own_id`]).
|
||||
pub fn tr_id<'a>(lang: Lang, id: &str, label: &'a str) -> &'a str {
|
||||
lang.catalog().id(id).unwrap_or_else(|| tr(lang, label))
|
||||
if own_id(lang, id, label) { lang.i18n().tid(lang.lang, id, label) } else { tr(lang, label) }
|
||||
}
|
||||
|
||||
/// Whether `lang` has PdfCraft's own `@id` row for `id` ([`Lang::has_own`]: command ids are each
|
||||
/// app's own) and it keeps the `{placeholders}` and trailing `…` of the English `label`. craft-i18n
|
||||
/// can't check that, because an `@id` row's source is the id, so a row that doesn't is skipped
|
||||
/// here and the label's translation shows instead.
|
||||
fn own_id(lang: Lang, id: &str, label: &str) -> bool {
|
||||
lang.has_own("@id", id) && lang.i18n().get(lang.lang, "@id", id).is_some_and(|translated| same_shape(translated, label))
|
||||
}
|
||||
|
||||
/// Whether `translated` has the `{placeholders}` (in any order) and the trailing `…` of `english`,
|
||||
/// the check craft-i18n makes on every row whose source is English text, and no `{` left open or
|
||||
/// `}` without its `{`.
|
||||
fn same_shape(translated: &str, english: &str) -> bool {
|
||||
let placeholders = |text: &str| -> Option<Vec<String>> {
|
||||
let mut names = text.split('{').skip(1).map(|p| p.split_once('}').map(|(name, _)| name.to_owned())).collect::<Option<Vec<_>>>()?;
|
||||
if text.matches('}').count() != names.len() {
|
||||
return None;
|
||||
}
|
||||
names.sort_unstable();
|
||||
Some(names)
|
||||
};
|
||||
translated.ends_with('…') == english.ends_with('…') && placeholders(translated).is_some_and(|names| Some(names) == placeholders(english))
|
||||
}
|
||||
|
||||
/// Fill `{name}` placeholders in one pass. Unknown placeholders are left as written, and inserted
|
||||
/// values are never read as templates again, so a file name containing `{n}` stays intact.
|
||||
/// values are never read as templates again, so a file name containing `{n}` stays intact. Unlike
|
||||
/// the crate's `fmt` it adds no bidi isolates: the egui fork orders each run for display.
|
||||
pub fn fmt(template: &str, args: &[(&str, &str)]) -> String {
|
||||
let mut out = String::with_capacity(template.len());
|
||||
let mut rest = template;
|
||||
@@ -728,12 +655,14 @@ pub fn edit_label(label: &pdfcraft_engine::Label) -> String {
|
||||
|
||||
/// A command label such as "Undo Insert pages from a.pdf" in the current language. A catalog
|
||||
/// whose word order doesn't put the verb first translates "Undo {action}" and "Redo {action}"
|
||||
/// as whole phrases; otherwise the translated verb goes before the action.
|
||||
/// as whole phrases, both of them; otherwise the translated verb goes before the action. (The
|
||||
/// shared catalog has "Undo {action}" without "Redo {action}" in some languages, where one phrase
|
||||
/// alone would word Undo and Redo differently.)
|
||||
pub fn command_label(text: &str) -> String {
|
||||
for (prefix, template) in [("Undo", "Undo {action}"), ("Redo", "Redo {action}")] {
|
||||
if let Some(action) = text.strip_prefix(prefix).and_then(|tail| tail.strip_prefix(' ')) {
|
||||
let action = action_label(action);
|
||||
if has(current(), template) {
|
||||
if has(current(), "Undo {action}") && has(current(), "Redo {action}") {
|
||||
return fmt(t(template), &[("action", &action)]);
|
||||
}
|
||||
return format!("{} {action}", t(prefix));
|
||||
@@ -742,20 +671,22 @@ pub fn command_label(text: &str) -> String {
|
||||
t(text).to_owned()
|
||||
}
|
||||
|
||||
/// A registered command's menu label: its `@id` catalog entry if there is one, otherwise
|
||||
/// [`command_label`] of the current English label.
|
||||
/// A registered command's menu label: its `@id` entry in PdfCraft's own catalog if there is one
|
||||
/// that keeps the label's shape ([`own_id`]), otherwise [`command_label`] of the current English
|
||||
/// label.
|
||||
pub fn menu_label(id: &str, label: &str) -> String {
|
||||
match current().catalog().id(id) {
|
||||
let lang = current();
|
||||
match own_id(lang, id, label).then(|| lang.i18n().get(lang.lang, "@id", id)).flatten() {
|
||||
Some(translated) => translated.to_owned(),
|
||||
None => command_label(label),
|
||||
}
|
||||
}
|
||||
|
||||
/// A plural-aware message: `one`/`other` are the English forms (with `{n}` where the count goes).
|
||||
/// The form is the language's CLDR category; `{n}` is written in `0`–`9` without grouping or
|
||||
/// isolate marks, as PdfCraft always has.
|
||||
pub fn trn(lang: Lang, n: u64, one: &str, other: &str) -> String {
|
||||
let idx = (lang.0.plural)(n);
|
||||
let text = lang.catalog().plural(one, other, idx).unwrap_or(if n == 1 { one } else { other });
|
||||
fmt(text, &[("n", &n.to_string())])
|
||||
lang.i18n().trn_plain(lang.lang, n, one, other)
|
||||
}
|
||||
|
||||
/// `name` lowercased with the Latin accents that occur in language names folded away, so a
|
||||
@@ -779,6 +710,27 @@ fn name_sort_key(name: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A test language whose PdfCraft catalog is `source` (catalog format), so tests of lookup code
|
||||
/// don't depend on the embedded catalogs' wording. `code` picks the CLDR plural rules.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn fixture_lang(code: &'static str, source: &'static str) -> Lang {
|
||||
let embedded: &'static craft_i18n::Embedded = Box::leak(Box::new([(code, "", source)]));
|
||||
let (i18n, _) = I18n::new(embedded, &[code]);
|
||||
let i18n: &'static I18n = Box::leak(Box::new(i18n));
|
||||
let lang = i18n.from_code(code).unwrap_or(craft_i18n::Lang::EN);
|
||||
Lang { lang, catalogs: Some(i18n) }
|
||||
}
|
||||
|
||||
/// For a test that needs the embedded catalogs: `false`, with a note, when none were embedded
|
||||
/// (`CRAFT_I18N_DIR` unset), so the test can skip; the interface is then English only.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn needs_catalogs() -> bool {
|
||||
if !catalogs_embedded() {
|
||||
eprintln!("skipped: built without craft-i18n (set CRAFT_I18N_DIR to a craft-i18n checkout); the interface is English only");
|
||||
}
|
||||
catalogs_embedded()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,175 +0,0 @@
|
||||
//! Parser and lookup tables for translation catalogs (`*.tsv`, format documented in `ja.tsv`).
|
||||
//!
|
||||
//! Validation is strict (unknown escapes, unknown `@` contexts, placeholder, ellipsis and plural
|
||||
//! form mismatches, duplicates), but a bad line is only skipped and reported: the rest of the
|
||||
//! catalog still loads and the skipped string shows in English. The tests insist the bundled
|
||||
//! catalogs have no errors at all.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// A catalog entry as read from the file.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
pub context: String,
|
||||
pub source: String,
|
||||
pub translation: String,
|
||||
}
|
||||
|
||||
/// A parsed catalog. Values are owned for the life of the process (catalogs are built once).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Catalog {
|
||||
/// context-free strings: English source → translation (the hot path, looked up every frame)
|
||||
plain: HashMap<String, String>,
|
||||
/// strings with a disambiguating context: context → source → translation
|
||||
contextual: HashMap<String, HashMap<String, String>>,
|
||||
/// command-id keyed strings
|
||||
ids: HashMap<String, String>,
|
||||
/// plural messages: `one|other` → forms
|
||||
plurals: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
fn unescape(text: &str) -> Result<String, String> {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut chars = text.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
match chars.next() {
|
||||
Some('n') => out.push('\n'),
|
||||
Some('t') => out.push('\t'),
|
||||
Some('\\') => out.push('\\'),
|
||||
Some(other) => return Err(format!("unknown escape \\{other}; use \\n, \\t or \\\\")),
|
||||
None => return Err("trailing backslash; use \\\\ for a literal backslash".into()),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `{name}` placeholders of a template, sorted (translators may reorder them).
|
||||
pub fn placeholders(text: &str) -> Vec<&str> {
|
||||
let mut names = Vec::new();
|
||||
let mut rest = text;
|
||||
while let Some((_, after_open)) = rest.split_once('{') {
|
||||
let Some((name, after_close)) = after_open.split_once('}') else { break };
|
||||
names.push(name);
|
||||
rest = after_close;
|
||||
}
|
||||
names.sort_unstable();
|
||||
names
|
||||
}
|
||||
|
||||
/// Check one entry against its English source. `forms` is the language's number of plural forms.
|
||||
fn validate(entry: &Entry, forms: usize) -> Result<(), String> {
|
||||
let Entry { context, source, translation } = entry;
|
||||
match context.as_str() {
|
||||
// The source is a command id, not a template; the bundled-catalog tests check the id
|
||||
// exists and compare the translation with that command's English label.
|
||||
"@id" => Ok(()),
|
||||
"@plural" => {
|
||||
let english: Vec<&str> = source.split('|').collect();
|
||||
let [one, other] = english.as_slice() else { return Err("plural source must be one|other".into()) };
|
||||
if one.is_empty() || other.is_empty() || placeholders(one) != placeholders(other) {
|
||||
return Err("English plural forms must be nonempty and have matching placeholders".into());
|
||||
}
|
||||
let translated: Vec<&str> = translation.split('|').collect();
|
||||
if translated.len() != forms || translated.iter().any(|form| form.is_empty()) {
|
||||
return Err(format!("expected {forms} nonempty plural forms"));
|
||||
}
|
||||
if translated.iter().any(|form| placeholders(form) != placeholders(other)) {
|
||||
return Err("plural placeholders differ from the English source".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ if context.starts_with('@') => Err(format!("unknown reserved context {context:?}")),
|
||||
_ => {
|
||||
if placeholders(source) != placeholders(translation) {
|
||||
return Err("placeholders differ from the English source".into());
|
||||
}
|
||||
if source.ends_with('…') != translation.ends_with('…') {
|
||||
return Err("trailing ellipsis differs from the English source".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_line(line: &str, forms: usize) -> Result<Entry, String> {
|
||||
let mut columns = line.split('\t');
|
||||
let (Some(context), Some(source), Some(translation), None) = (columns.next(), columns.next(), columns.next(), columns.next()) else {
|
||||
return Err("expected `context<TAB>source<TAB>translation`".into());
|
||||
};
|
||||
let entry = Entry { context: unescape(context)?, source: unescape(source)?, translation: unescape(translation)? };
|
||||
if entry.source.is_empty() || entry.translation.is_empty() {
|
||||
return Err("source and translation must be nonempty".into());
|
||||
}
|
||||
validate(&entry, forms)?;
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Read the entries of a catalog file for a language with `forms` plural forms. Malformed lines
|
||||
/// and duplicates (the first entry wins) are returned as errors and skipped, so a bad translation
|
||||
/// never breaks the UI.
|
||||
pub fn parse_entries(text: &str, forms: usize) -> (Vec<Entry>, Vec<String>) {
|
||||
let mut entries = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for (index, line) in text.lines().enumerate() {
|
||||
if line.trim().is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let line_no = index.saturating_add(1);
|
||||
match parse_line(line, forms) {
|
||||
Ok(entry) if !seen.insert((entry.context.clone(), entry.source.clone())) => {
|
||||
errors.push(format!("line {line_no}: duplicate key {:?} / {:?}", entry.context, entry.source));
|
||||
}
|
||||
Ok(entry) => entries.push(entry),
|
||||
Err(error) => errors.push(format!("line {line_no}: {error}")),
|
||||
}
|
||||
}
|
||||
(entries, errors)
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// Build a catalog from its valid entries; the errors of the skipped lines come back too.
|
||||
pub fn parse(text: &str, forms: usize) -> (Catalog, Vec<String>) {
|
||||
let (entries, errors) = parse_entries(text, forms);
|
||||
let mut c = Catalog::default();
|
||||
for Entry { context, source, translation } in entries {
|
||||
match context.as_str() {
|
||||
"" => {
|
||||
c.plain.insert(source, translation);
|
||||
}
|
||||
"@id" => {
|
||||
c.ids.insert(source, translation);
|
||||
}
|
||||
"@plural" => {
|
||||
c.plurals.insert(source, translation.split('|').map(str::to_string).collect());
|
||||
}
|
||||
_ => {
|
||||
c.contextual.entry(context).or_default().insert(source, translation);
|
||||
}
|
||||
}
|
||||
}
|
||||
(c, errors)
|
||||
}
|
||||
|
||||
pub fn plain(&self, s: &str) -> Option<&str> {
|
||||
self.plain.get(s).map(String::as_str)
|
||||
}
|
||||
|
||||
pub fn contextual(&self, ctx: &str, s: &str) -> Option<&str> {
|
||||
self.contextual.get(ctx)?.get(s).map(String::as_str)
|
||||
}
|
||||
|
||||
pub fn id(&self, id: &str) -> Option<&str> {
|
||||
self.ids.get(id).map(String::as_str)
|
||||
}
|
||||
|
||||
/// The plural form `index` of the message whose English forms are `one|other`.
|
||||
pub fn plural(&self, one: &str, other: &str, index: usize) -> Option<&str> {
|
||||
let forms = self.plurals.get(&format!("{one}|{other}"))?;
|
||||
forms.get(index.min(forms.len().saturating_sub(1))).map(String::as_str)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user