mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 17:01:23 +00:00
Merge branch 'storytold:main' into fix/js-bracket-depth-regex
This commit is contained in:
378 files changed
+39659
-97625
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
|
||||
|
||||
@@ -42,3 +42,4 @@ jobs:
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
|
||||
@@ -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.
|
||||
|
||||
+6
-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 (289)
|
||||
## In this repository (257)
|
||||
|
||||
| Asset | Title | Author | Licence | Source | Used for |
|
||||
|---|---|---|---|---|---|
|
||||
| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/fa.tsv` | Persian (Farsi) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed | Persian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n.rs |
|
||||
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC 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 |
|
||||
@@ -231,6 +203,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
|
||||
| `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) |
|
||||
| `crates/fonts/src/std14_metrics.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. Data only: the code that reads these tables lives in std14.rs, so that ordinary edits do not churn this file's hash. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. |
|
||||
| `crates/prc/src/acof/table.rs` | PRC Double prefix-code table (as a Rust table) | Kristof Ralovich (prc-rs); the code table itself is defined by the PRC format (ISO 14739-1, working draft clause 11.17.2) | MIT | Rows taken from prc-rs src/double.rs at commit 287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d (https://github.com/ralovich/prc-rs), mechanically reformatted into (code length, code, entry) tuples; checked to be a complete prefix code by the tests in crates/prc/src/acof.rs. Data only: the code that reads the table lives in acof.rs. The MIT notice is kept in the file header. | Decodes every PRC Double (coordinates, transforms, colours) in 3D annotations' PRC streams. Non-visual data. |
|
||||
| `vendor/hayro-cmap/assets/cmaps.brotli` | Adobe CMap resources (predefined CJK CMaps) | Adobe | BSD-3-Clause | https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap 0.1.0 assets/) | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1 |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
|
||||
@@ -286,17 +259,12 @@ 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/a-large-small.svg` | Lucide icon "a-large-small" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/a-large-small.svg) | UI icon, embedded via crates/ui-egui/src/widgets/icon_data.rs |
|
||||
| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `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)
|
||||
|
||||
@@ -338,10 +306,11 @@ Fonts are fetched by `cargo xtask demo-pdf` into `target/demo-fonts/`, OCR model
|
||||
| `text-detection.onnx` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) |
|
||||
| `text-recognition.onnx` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) |
|
||||
|
||||
## Optional build inputs (1)
|
||||
## Optional build inputs (2)
|
||||
|
||||
Not in this repository and never downloaded by it: compiled in only when the build sets the option (official releases do). Each input attributes its own files.
|
||||
|
||||
| Input | Option | Title | Author | Licence | Source | Attribution | Used for |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `craft-i18n` | `CRAFT_I18N_DIR` | Interface translations: the craft-i18n catalogs (locales/<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 |
|
||||
+34
-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\""
|
||||
@@ -2437,6 +2156,17 @@ adobe_data = true
|
||||
usage = "Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree."
|
||||
sha256 = "f225e0b044875b8fa6375b6b5ad935f0d99fcc1ad03ecf9f0baae2cedbfca4d2"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/prc/src/acof/table.rs"
|
||||
title = "PRC Double prefix-code table (as a Rust table)"
|
||||
author = "Kristof Ralovich (prc-rs); the code table itself is defined by the PRC format (ISO 14739-1, working draft clause 11.17.2)"
|
||||
source = "Rows taken from prc-rs src/double.rs at commit 287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d (https://github.com/ralovich/prc-rs), mechanically reformatted into (code length, code, entry) tuples; checked to be a complete prefix code by the tests in crates/prc/src/acof.rs. Data only: the code that reads the table lives in acof.rs. The MIT notice is kept in the file header."
|
||||
licence = "MIT"
|
||||
licence_file = "licenses/prc-rs-LICENSE.txt"
|
||||
kind = "data"
|
||||
usage = "Decodes every PRC Double (coordinates, transforms, colours) in 3D annotations' PRC streams. Non-visual data."
|
||||
sha256 = "8e705c5cde6a3ef778e8aca69759e38845b228b3f7f12949e587b19855d2114c"
|
||||
|
||||
[[bundled]]
|
||||
crate = "epaint_default_fonts"
|
||||
version = "0.37.0"
|
||||
@@ -3381,6 +3111,17 @@ kind = "model"
|
||||
usage = "Scan & OCR: reads the words it finds (cargo xtask models)"
|
||||
sha256 = "86c145c2edb96c8caed5b1ebb8f44d706408922211451c309c625157dd6061c5"
|
||||
|
||||
[[build_input]]
|
||||
name = "craft-i18n"
|
||||
option = "CRAFT_I18N_DIR"
|
||||
title = "Interface translations: the craft-i18n catalogs (locales/<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"
|
||||
@@ -3414,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"
|
||||
@@ -3446,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\""
|
||||
@@ -3489,6 +3200,17 @@ kind = "icon"
|
||||
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
|
||||
sha256 = "d344d9f9222f6c1b698c0c82a753a2212d9bd7785f06c00cd6e4910fe2fee8f8"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/a-large-small.svg"
|
||||
title = "Lucide icon \"a-large-small\""
|
||||
author = "Lucide Contributors (portions Cole Bemis, Feather)"
|
||||
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/a-large-small.svg)"
|
||||
licence = "ISC AND MIT"
|
||||
licence_file = "assets/icons/LICENSE-lucide.txt"
|
||||
kind = "icon"
|
||||
usage = "UI icon, embedded via crates/ui-egui/src/widgets/icon_data.rs"
|
||||
sha256 = "d4985fc71174d108e322c3b25cb6abe2837a115fe3f239a1918645db436743ec"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/move-horizontal.svg"
|
||||
title = "Lucide icon \"move-horizontal\""
|
||||
@@ -3500,32 +3222,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"
|
||||
@@ -23,7 +23,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
|
||||
- **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone.
|
||||
- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it.
|
||||
- **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim(Layer::…, icon)`, `crates/ui-egui/src/canvas/page_cursor.rs`), never set it (`set_cursor_icon`, `cursor_icon =`, `on_hover_cursor`); `document_area` sets it once, from the claim on the highest layer the active tool reaches. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it.
|
||||
- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the router says the press is its own (`view.router.owner(page)`), never by testing the press point again or by asking another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_modules_take_presses_from_the_router_without_repeating_hit_tests` guards the centralized hit calls and captured-owner handlers; runtime routing tests cover gesture behavior.
|
||||
- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the press is its own or, for the active tool, nobody's: the page view tells it which (`PagePress`: its own, nobody's, or another page's), and it never tests the press point again or asks another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it.
|
||||
- **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it.
|
||||
- **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code:
|
||||
- No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why.
|
||||
|
||||
Generated
+69
-36
@@ -1303,6 +1303,15 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-winprint-gdi"
|
||||
version = "0.1.0"
|
||||
@@ -4208,7 +4217,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-update",
|
||||
@@ -4232,7 +4241,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-a11y"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4244,7 +4253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-annot"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -4255,7 +4264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-audit"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4266,7 +4275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-automation"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"flate2",
|
||||
@@ -4277,6 +4286,8 @@ dependencies = [
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-organize",
|
||||
"pdfcraft-platform",
|
||||
"pdfcraft-prc",
|
||||
"pdfcraft-prc-render",
|
||||
"pdfcraft-render",
|
||||
"pdfcraft-xfa",
|
||||
"png 0.18.1",
|
||||
@@ -4288,7 +4299,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cli"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"pdfcraft-audit",
|
||||
@@ -4306,11 +4317,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-compare"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-content"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -4318,10 +4329,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cos"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hayro-syntax",
|
||||
"js-sys",
|
||||
"pdfcraft-crypt",
|
||||
"pdfcraft-filters",
|
||||
"pdfcraft-oracle",
|
||||
@@ -4329,11 +4341,12 @@ dependencies = [
|
||||
"proptest",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-create"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"hayro-ccitt",
|
||||
@@ -4352,7 +4365,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-crypt"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"md-5",
|
||||
@@ -4364,7 +4377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-edit"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4382,7 +4395,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-engine"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
@@ -4426,14 +4439,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-export"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-filters"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
@@ -4446,7 +4459,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-fonts"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"pdfcraft-cos",
|
||||
@@ -4456,7 +4469,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-forms"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4469,11 +4482,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-geom"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-js"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"chrono",
|
||||
@@ -4481,7 +4494,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-measure"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4494,14 +4507,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-model"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"pdfcraft-cos",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ocr"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"ocrs",
|
||||
"pdfcraft-fonts",
|
||||
@@ -4512,7 +4525,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-optimize"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"pdfcraft-annot",
|
||||
@@ -4528,11 +4541,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-oracle"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-organize"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4543,7 +4556,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-platform"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
@@ -4551,9 +4564,27 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-prc"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-prc-render"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"pdfcraft-prc",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"pdfcraft-content",
|
||||
@@ -4564,7 +4595,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-print"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"craft-print",
|
||||
"pdfcraft-content",
|
||||
@@ -4576,7 +4607,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-redact"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4593,7 +4624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-render"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"flate2",
|
||||
@@ -4612,7 +4643,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-sign"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aws-lc-rs",
|
||||
@@ -4648,17 +4679,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-testkit"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ui-egui"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"base64 0.23.1",
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"craft-i18n",
|
||||
"craft-keymap",
|
||||
"craft-menu",
|
||||
"craft-quit",
|
||||
@@ -4706,8 +4738,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-web"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
@@ -4718,7 +4751,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfa"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"image",
|
||||
@@ -4732,7 +4765,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfdf"
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-cos",
|
||||
|
||||
+40
-34
@@ -5,7 +5,7 @@ exclude = ["vendor"]
|
||||
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.18.0"
|
||||
version = "0.18.1"
|
||||
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,41 @@ 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.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 }
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.18.1" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.18.1" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.18.1" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.18.1" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.18.1" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.18.1" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.18.1" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.18.1" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.18.1" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.18.1" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.18.1" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.18.1" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.18.1" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.18.1" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.18.1" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.18.1" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.18.1" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.18.1" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.18.1" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.18.1" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.18.1" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.18.1" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.18.1" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.18.1" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.18.1" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.18.1" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.18.1" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.18.1" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.18.1" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.18.1" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.18.1" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.18.1" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.18.1", default-features = false }
|
||||
pdfcraft-prc = { path = "crates/prc", version = "0.18.1" }
|
||||
pdfcraft-prc-render = { path = "crates/prc-render", version = "0.18.1" }
|
||||
|
||||
# pdf
|
||||
hayro = "0.7.1"
|
||||
|
||||
@@ -88,6 +88,13 @@ destination = "licenses/egui-emoji-icon-font-mit-license.txt"
|
||||
sha256 = "b9d2c1d909aa149996fd4c91dcb92b2362a04431640c1d200959da94caf8cde1"
|
||||
origin = "https://raw.githubusercontent.com/emilk/egui/0.36.2/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt"
|
||||
|
||||
[[document]]
|
||||
source = "licenses/prc-rs-LICENSE.txt"
|
||||
licences = ["MIT"]
|
||||
destination = "licenses/prc-rs-LICENSE.txt"
|
||||
sha256 = "ddb846513d92153d8fa16b18ed3e7c86953a582f0d2f49f02b29f1bd569dac58"
|
||||
origin = "https://raw.githubusercontent.com/ralovich/prc-rs/287b671bb5b31b05efb5ec1b98f7c359b9e4dd7d/LICENSE"
|
||||
|
||||
[[document]]
|
||||
source = "vendor/hayro-interpret/LICENSE-APACHE"
|
||||
licences = ["Apache-2.0"]
|
||||
|
||||
@@ -94,7 +94,7 @@ Real-world typography: world scripts, vertical Japanese, colour emoji, gradients
|
||||
<td width="33%" valign="top">
|
||||
|
||||
### Fearless
|
||||
Every save appends your changes and leaves the original bytes untouched. Writes are atomic, undo runs deep, and nothing is lost if you close by mistake.
|
||||
Ordinary saves append your changes and leave the original bytes untouched. Edits to damaged objects whose xref and header generations disagree use a full rewrite, keeping both accepted references connected to the same repaired object. Writes are atomic, undo runs deep, and nothing is lost if you close by mistake.
|
||||
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
|
||||
+41
-1
@@ -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)
|
||||
@@ -17,10 +21,17 @@
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M2, PR #4172: extend on-screen CJK fallback to Combine thumbnails and figure previews; recognise FangSong and subset-prefixed Founder names; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (typed small Edit font sizes and Fill & Sign text/date colour, #2444/#2496; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** patch (M1, #1143: repaired aliases survive preflight, snapshots and normalized saves; retained materialized revisions report generation disagreements; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M1, #1143: explicit live xref/header generation repair policy and bounded diagnostics; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: refresh object-generation reference identity against current upstream; estimates unchanged)
|
||||
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M2: scans keep their strokes at screen size and are smoothed when zoomed; M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (web: same-document host mode on the canvas, `data-host="page"`; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged)
|
||||
@@ -219,10 +230,21 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-28 (M10, #4119 partial):** Scan & OCR ▸ Enhance scanned file cleans scanned pages in place: background removal (level 0–100, as the camera clean-up), edge shadow removal (book gutters, copier borders) and despeckle. Each scan image is decoded, cleaned and written back into its own object at the same size and colour space (JPEG stays JPEG, others lossless, 1-bit stays 1-bit), so the OCR text layer, comments and links stay; one undo step, shared images once, unsupported images (CMYK, indexed, JPEG 2000, JBIG2, CCITT, broken data) reported. `page_enhance_scan` drives it headlessly. Not yet an option of Recognize text. Estimates unchanged.
|
||||
- **2026-11-03 (M11, shared JBIG2 dictionary):** With Monochrome Images set to JBIG2, Optimize has up to 16 JBIG2 images share one symbol dictionary (`/JBIG2Globals`), the letters found on more than one page stored once, when that is smaller (`pdfcraft_filters::encode_jbig2_shared`). Eight pages of 10 pt text rendered at 300 dpi take 41% less in Helvetica and 42% in Times than coded alone, 10% in Courier; groups of 16 save 45%, of 32 only 47%. Only images redaction can store again without the dictionary share one (image masks and DeviceGray named directly, at most 64 Mi pixels), and redacting one stores the rest of its group so, which the group bounds to 15.
|
||||
|
||||
- **2026-11-03 (M5, #5850 review):** Resolve indirect IT when recognizing typewriter contents and Fill & Sign marks. Comment listing and property lookup retain their existing direct-Subtype behavior. A real double-click Edit composer preserves the typewriter’s text as written; ordinary comment edits keep trimming. Regressions cover direct Subtype with indirect intent, save/reopen, raw posted contents and same-index comments on different pages. The existing live-movement test now commits its setup with an outside click rather than Enter. Estimates unchanged.
|
||||
|
||||
- **2026-11-03 (M5, #5850):** Fill & Sign Enter inserts a line break in a growing editor; clicking outside commits and Escape cancels. Nonempty text keeps leading, internal and trailing line breaks. The headless fill_sign_add path sizes its typewriter box for the same wrapped lines so saved appearances show every row. Production-order keyboard and engine save/reopen/render regressions exercise the UI and headless callers. Estimates unchanged.
|
||||
|
||||
- **2026-11-03 (M7, #5835):** In image crop mode, a right-click on the image, its crop box or its handles keeps the crop and opens no menu, so the menu no longer switches as the pointer moves. A right-click elsewhere clears the selection and opens the page menu. UI tests cover all four page rotations, overlapping images, a 45° image, and a check that the Done/Cancel/Remove Crop pills at a page edge still keep the crop.
|
||||
|
||||
- **2026-11-02 (M12, #1297):** `model_add` embeds a U3D or PRC file, recognised by its header, as a 3D annotation with a chosen activation and a poster (an image or PDF page fitted in the box, or a labelled frame); an older file's catalog `/Version` is raised to what the model needs. Views, a rendered poster and a UI command are later steps. Estimates unchanged.
|
||||
|
||||
- **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer.
|
||||
|
||||
- **2026-11-02 (M14, #5737, #5460):** Preferences can turn off the update check at start. It stays on by default, and Help ▸ Check for updates still asks.
|
||||
|
||||
- **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged.
|
||||
|
||||
- **2026-11-01 (M14, #5572):** Preferences ▸ Documents and view sets how many recent files to keep, from 0 to 50 with 12 by default. At 0 Home has no Recent section and File ▸ Open Recent is disabled. The list is cut when Preferences closes, so the number can pass through 0 on its way to another.
|
||||
@@ -249,8 +271,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-26 (M2, PR #4172):** Reconciled current main, preserving its Japanese serif/weight selection and both branches' progress records. Installed CJK viewer and preview fallback remains display-only; estimates unchanged.
|
||||
|
||||
- **2026-10-29 (M7, #2517):** Widening a paragraph rejoins a wrap with the number of spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split. The count survives an incremental save and a full rewrite, and a width-only `text_edit` uses it. Estimates unchanged.
|
||||
- **2026-10-29 (M2):** Scanned pages look like the paper again. An image that doesn't ask for /Interpolate was shrunk by nearest-neighbour (since #635), which kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes; it is now area-averaged to whole device pixels (on a 13-page 300 dpi scan every sample at 96–192 dpi is within 1 of the exact area average; it was up to 255 off). Enlarged, an image of page-scan size is smoothed bilinearly, so a zoomed scan has no staircases; icons, QR codes and pixel art keep hard pixels. Estimates unchanged.
|
||||
- **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged.
|
||||
- **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged.
|
||||
- **2026-10-25 (web, same-document host mode):** A page that loads the PdfCraft web module into its own document (no iframe) can opt into the host bridge with `data-host="page"` on the app's canvas — the exact value `page`; any other value keeps the normal app with downloads, unless a same-origin framed `?host=parent` selects the parent bridge (unframed or cross-origin, `?host=parent` also keeps the normal app). It is the `?host=parent` bridge with the transport swapped: the same five messages travel as non-bubbling `CustomEvent`s on the canvas (`detail` holds the parent messages' fields without `type`; the host answers with a `pdfcraft:open` event), so saves reach the host instead of downloading and dirty and start-up-failure reporting are unchanged. Unit tests cover the opt-in rule; no parity or estimate changes.
|
||||
- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
|
||||
- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
|
||||
- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.
|
||||
@@ -279,6 +303,11 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
Estimates unchanged.
|
||||
- **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged.
|
||||
- **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged.
|
||||
- **2026-10-24 (M5, border shape):** Text boxes and Fill & Sign's typed text take an oval or a circle for their border (Comment Properties ▸ Border shape, Fill & Sign's toolbar): the text box keeps its place and the shape goes around it, the text centred, a circle snug around the text (its box no wider than the text needs); stored as /RD and /PCBorderShape and drawn in the appearance. comment_add / comment_edit / fill_sign_add `border_shape`. One new label in every catalog. Synthetic annot, UI and automation regressions. Estimates unchanged.
|
||||
- **2026-10-24 (M5, Escape in Fill & Sign):** Escape leaves a Fill & Sign tool as it leaves a comment tool: the first press closes the open type box, the next goes back to Select, so the Text tool no longer opens a box at every click with no way out by the keyboard. A synthetic UI regression covers the Text and Checkmark tools. Estimates unchanged.
|
||||
- **2026-10-23 (M5, ortho):** Shift while drawing holds a line or arrow level or plumb, whichever is nearer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before; dragging an end or corner with Shift is held the same way (it turned to 45° steps). `measure::snap::ortho`; `comment_add ortho`. Estimates unchanged.
|
||||
- **2026-10-24 (M3, arrow keys on a wide page):** → / ← scroll a page wider than the window (a plan zoomed in) sideways a line at a time and turn the page at its edge, as ↓ / ↑ do in single-page view; ⌘→ / ⌘← turn it at once, and a page that fits across turns as before (#185). A synthetic viewer regression covers both. Estimates unchanged.
|
||||
- **2026-10-24 (M5, part opacity):** Comment Properties sets a text box's text, border and fill, and a shape's line and fill, each at an opacity of its own (a see-through fill with an opaque line on a plan); stored as /CA, /ca (PDF 2.0) and /PCTextOpacity and drawn with separate graphics states, so other viewers show it. Make Properties Default and the settings carry it; comment_add / comment_edit `fill_opacity`, `text_opacity`. Four new labels in every catalog. Synthetic annot, UI and automation regressions. Estimates unchanged.
|
||||
- **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen.
|
||||
- **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged.
|
||||
|
||||
@@ -310,6 +339,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-20 (M12, #3673):** Character encoding accepts Identity-H/V fonts with Adobe CJK collections and recognizes predefined non-Identity CMaps independently. Unknown CMaps and foreign registries remain refused; synthetic checker and read-only automation regressions passed after failing before the fix. Per-glyph mapping validation and embedded-font extraction remain separate. Estimates unchanged.
|
||||
- **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged.
|
||||
- **2026-10-19 (M6, text field table):** Prepare a form ▸ Text field table drags out an area and adds rows and columns of named text fields as one undoable edit, laid out on the page as displayed, with a live preview, per-column name patterns and widths, and refusal before anything is added when a cell would be too small or a name taken; `form_add_field_table` does the same headlessly. `form.multiple-copies` is partial: copying an existing field into a grid is still missing. Estimates unchanged.
|
||||
- **2026-10-17 (research):** Added a [PDF workflow research reference](docs/research/pdf-workflows/README.md): dated public-source locators, 20 workflow research domains, a pinned feature mapping and interaction evidence. Reference documentation; no feature statuses, milestone priorities or estimates changed.
|
||||
|
||||
- **2026-10-16 (M7, #2516):** A paragraph number from `text_paragraphs` keeps naming the paragraph it listed after edits regroup the page: `text_edit`, `text_edit_layout` and `page_render`'s `hide_text_block` find it again by its text and box, and refuse one that was merged into a neighbour (the edited paragraph included) instead of rewriting the paragraph that took its number (`stale_paragraphs` lists the numbers gone). The inline editor's switch-paragraph click holds the same key. No stable identity across save and reopen yet (#2508).
|
||||
- **2026-10-19 (M7, #3591):** A background added to a page that already has one is painted over the old one instead of going under it and staying hidden. Behind the content, backgrounds come first, in the order added, and then behind-content watermarks, so a watermark added after a background no longer disappears. Synthetic regressions in `crates/edit` check the order. Estimates unchanged.
|
||||
@@ -321,9 +351,13 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
- **2026-10-20 (M3, #3541/#3620):** Comment hover follows the individual marked lines instead of the enclosing multiline rectangle, and overlapping comments follow paint order. Synthetic UI regressions cover all four text-markup kinds, page/view rotations, neighboring highlights, legacy rectangle fallback and unchanged PDF bytes. Estimates unchanged.
|
||||
- **2026-10-20 (M10, #3532 partial):** Vector printing retains existing widget artwork when its appearance lacks Subtype. A print-owned copy adds Form dispatch metadata. The source stream and all explicit subtypes stay unchanged. Synthetic source/output raster checks cover resources, selected/shared appearances, placement, flags and content modes. Appearance regeneration and physical printer validation remain outside this fix. Estimates unchanged.
|
||||
- **2026-10-20 (M5, several comments):** Comment Properties opened on one of several selected comments, the context menu's Colour and the quick bar's colour change all of them as one undo step, each where the property applies (a font size to text boxes, a fill to shapes, a width to lines and shapes); one that stays locked keeps its look. Copied Fill & Sign ticks, crosses, dots and lines hang on the pointer as themselves rather than as empty boxes, and copied text boxes show their text (`FillMark::strokes` is shared by the appearance and the outline). Estimates unchanged.
|
||||
- **2026-10-19 (M5, comments between documents):** Comments copy and paste, within and between documents: ⌘C with comments selected copies them as XFDF (each with its replies) to the app's and the system clipboard, ⌘V / Edit ▸ Paste comments / the page menu pastes them onto the current page as new comments under names of their own (`Edit::PasteComments`, `pdfcraft_xfdf::import_comments` with a target page and fresh names, so nothing is replaced and pasting twice gives two sets; `export_xfdf_comments` writes a selection with its replies). Several comments select at once (⌘-click, ⇧-click, on the page and in the panel); Delete removes them as one step and a drag moves them together. Connected lines, polygons and clouds finish from the right-click menu's Complete (Undo last point, Cancel drawing beside it), as other editors do. Tools `comments_copy` / `comments_paste`. Estimates unchanged.
|
||||
|
||||
- **2026-10-20 (M5, arrow keys move comments):** With a comment selected, the arrow keys move it a screen pixel and ⌘/Ctrl or Shift and an arrow ten, instead of scrolling the view or turning the page; up is up on screen at any zoom and rotation, and each press is one undo step. Text markup and locked comments stay put. Estimates unchanged.
|
||||
|
||||
- **2026-10-20 (M1, PR #1143 review):** Repair-aware dictionary/preflight edits and snapshot restoration preserve both accepted generations of a damaged live slot. Such edits use the existing full-save fallback; classic and packed rewrites coalesce aliases so page-tree parent links retain one object identity. Explicit generation replacement remains single-generation. Materialized xref diagnostics inspect retained primary and hybrid rows before folding, retaining the 256-object cap plus summary and discarding abandoned-index notes. Synthetic red/green controls cover preflight, undo restoration, equal values, explicit narrowing, encryption packing and four save variants. Estimates unchanged.
|
||||
|
||||
- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
|
||||
|
||||
- **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged.
|
||||
@@ -387,6 +421,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged.
|
||||
- **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged.
|
||||
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
|
||||
- **2026-10-18 (M1, PR #1143 repair policy):** A live in-file reference resolves when its generation matches the xref row or a same-number header at that indexed offset. Neither-match and free slots remain null, including when old object bytes remain. Opening records bounded xref/header disagreements without parsing object bodies or expanding compact xref rows. Synthetic controls cover read order, clones, overlays, dictionary edits, full classic/packed saves and an independent reader of incremental output. **Correction to the 2026-10-15 note below:** its async-opening-test sentence was not evidenced by this COS change; no fresh async-opening or GUI validation is claimed. The full-reference stream-length recursion key is defensive hardening, not an independently isolated regression fix. Estimates unchanged.
|
||||
- **2026-10-15 (M1, PR #1143 refresh):** COS reference lookups and full saves distinguish complete object references, so stale generations cannot expose replacement content. Snapshot restoration also keeps edits to objects with nonzero generations before incremental saves. Synthetic regressions cover caches, overlays, snapshots, stream lengths and classic/packed saves. Current upstream async-opening test corrections are included. Estimates unchanged.
|
||||
- **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are unchanged.
|
||||
- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged.
|
||||
- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor).
|
||||
@@ -749,6 +785,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. |
|
||||
@@ -763,6 +801,7 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
| 2026-10-20 | minor | M6, #3502: form detection preserves comments across content streams; estimates unchanged. |
|
||||
| 2026-10-20 | minor | M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged. |
|
||||
| 2026-10-20 | minor | M11 #3507: image merging preserves comments and state across content streams. Estimates unchanged. |
|
||||
| 2026-10-18 | minor | M1, #1143: a live reference may match the xref generation or a same-number header at its indexed offset; neither-match and free slots remain null. Bounded repair diagnostics and synthetic cache, free-slot, edit and classic/packed save regressions; estimates unchanged. |
|
||||
| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
|
||||
| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
|
||||
| 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. |
|
||||
@@ -773,6 +812,7 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. |
|
||||
| 2026-10-16 | minor | M14: Czech catalog completed (2,674 entries) and "tag" terminology unified. Estimates unchanged. |
|
||||
| 2026-10-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged |
|
||||
| 2026-10-15 | minor | M1: refresh object-generation reference identity, retaining current upstream progress and regression tests. Estimates unchanged. |
|
||||
| 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. |
|
||||
| 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. |
|
||||
| 2026-10-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. Estimates unchanged. |
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//! The exit codes the CLI has today: `ExitCode::SUCCESS` (0) or `ExitCode::FAILURE` (1).
|
||||
//!
|
||||
//! Pinned here so that the exit-code contract is a visible, reviewed change when it comes: a
|
||||
//! failed file, an outside-root path and a usage error all exit 1 today.
|
||||
|
||||
use serde_json::Value;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
|
||||
|
||||
/// A fresh folder for one test, so the tests can run in parallel and leave nothing behind.
|
||||
fn workdir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-exit-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// `batch` with `args`, run from the working directory `dir`.
|
||||
fn batch(dir: &Path, args: &[&str]) -> Output {
|
||||
Command::new(BIN).current_dir(dir).arg("batch").args(args).output().unwrap()
|
||||
}
|
||||
|
||||
/// `run --script --report` from the working directory `dir`: one JSON document for the run.
|
||||
fn run_report(dir: &Path, script: &Path) -> Output {
|
||||
Command::new(BIN).current_dir(dir).args(["run", "--script"]).arg(script).arg("--report").output().unwrap()
|
||||
}
|
||||
|
||||
/// The one JSON document a command prints on stdout.
|
||||
fn reported(output: &Output) -> Value {
|
||||
serde_json::from_slice(&output.stdout).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_exits_0_when_every_file_succeeds() {
|
||||
let dir = workdir("batch-ok");
|
||||
std::fs::write(dir.join("page.txt"), "Exit code page").unwrap();
|
||||
let output = batch(&dir, &["page.txt", "--steps", "convert", "--out-dir", "out"]);
|
||||
assert_eq!(output.status.code(), Some(0), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let report = reported(&output);
|
||||
assert_eq!(report["completed"], 1, "{report}");
|
||||
assert_eq!(report["failed"], 0, "{report}");
|
||||
assert!(dir.join("out/page.pdf").exists(), "the converted page is written");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_exits_1_when_one_file_fails() {
|
||||
let dir = workdir("batch-fail");
|
||||
std::fs::write(dir.join("bad.pdf"), "broken").unwrap();
|
||||
let output = batch(&dir, &["bad.pdf", "--steps", "convert", "--out-dir", "out"]);
|
||||
assert_eq!(output.status.code(), Some(1), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let report = reported(&output);
|
||||
assert_eq!(report["failed"], 1, "{report}");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_usage_error_exits_1_and_creates_nothing() {
|
||||
let dir = workdir("batch-usage");
|
||||
std::fs::write(dir.join("page.txt"), "Exit code page").unwrap();
|
||||
// Baseline: a usage error exits 1 today. A later change to the exit-code contract will
|
||||
// update this assertion on purpose.
|
||||
let output = batch(&dir, &["page.txt", "--out-dir", "out"]);
|
||||
assert_eq!(output.status.code(), Some(1), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(output.stdout.is_empty(), "a usage error prints to stderr, not a report on stdout");
|
||||
assert!(!output.stderr.is_empty(), "a usage error says what is wrong");
|
||||
assert!(!dir.join("out").exists(), "a refused batch creates no output");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_outside_root_exits_1_and_creates_nothing() {
|
||||
let dir = workdir("batch-outside-root");
|
||||
let output = Command::new(BIN)
|
||||
.current_dir(&dir)
|
||||
.args(["batch", "../outside.pdf", "--steps", "convert", "--out-dir", "out", "--root"])
|
||||
.arg(&dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(1), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("is outside the allowed directory"), "the input is refused by the root: {stderr}");
|
||||
assert!(output.stdout.is_empty(), "a refused batch prints no report");
|
||||
assert!(!dir.join("out").exists(), "a refused batch creates no output");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_script_report_exits_0_when_ok_and_1_when_a_step_fails() {
|
||||
let dir = workdir("run-report");
|
||||
let script = dir.join("steps.json");
|
||||
let blank = serde_json::json!({ "tool": "doc_create", "args": { "from": "blank", "width": 100, "height": 100 } });
|
||||
let steps = serde_json::json!([blank.clone(), { "tool": "no_such_tool", "args": {} }]);
|
||||
std::fs::write(&script, steps.to_string()).unwrap();
|
||||
|
||||
let failing = run_report(&dir, &script);
|
||||
assert_eq!(failing.status.code(), Some(1), "{}", String::from_utf8_lossy(&failing.stderr));
|
||||
let report = reported(&failing);
|
||||
assert_eq!(report["ok"], false, "{report}");
|
||||
assert_eq!(report["steps"][1]["status"], "error", "{report}");
|
||||
|
||||
std::fs::write(&script, serde_json::json!([blank]).to_string()).unwrap();
|
||||
let good = run_report(&dir, &script);
|
||||
assert_eq!(good.status.code(), Some(0), "{}", String::from_utf8_lossy(&good.stderr));
|
||||
let report = reported(&good);
|
||||
assert_eq!(report["ok"], true, "{report}");
|
||||
assert_eq!(report["steps"][0]["status"], "ok", "{report}");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
mod audit;
|
||||
mod automation_roots;
|
||||
mod batch;
|
||||
mod batch_exit;
|
||||
mod check;
|
||||
mod edit;
|
||||
mod extract;
|
||||
|
||||
@@ -11,10 +11,12 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
pdfcraft-ui-egui = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] }
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget", "CustomEvent", "CustomEventInit"] }
|
||||
js-sys = "0.3"
|
||||
+81
-110
@@ -5,18 +5,11 @@
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
use eframe::wasm_bindgen::JsCast;
|
||||
use pdfcraft_ui_egui::PdfCraftApp;
|
||||
|
||||
let options = eframe::WebOptions { renderer: eframe::Renderer::Glow, ..Default::default() };
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(canvas) = web_sys::window()
|
||||
.and_then(|w| w.document())
|
||||
.and_then(|d| d.get_element_by_id("pdfcraft"))
|
||||
.and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(canvas) = app_canvas() else { return };
|
||||
let started = eframe::WebRunner::new()
|
||||
.start(
|
||||
canvas,
|
||||
@@ -54,11 +47,11 @@ fn main() {
|
||||
// eframe never raises a close request on a browser reload or tab close, so `guard_quit` never
|
||||
// prompts; ask the browser to confirm instead while a document has unsaved work (#812).
|
||||
warn_before_unload(app.unsaved_flag.clone());
|
||||
// `?host=parent`: a same-origin page embedding PdfCraft in an iframe (such
|
||||
// as the Nextcloud app) passes files in and takes saves back (`host` below).
|
||||
if query_param("host").as_deref() == Some("parent")
|
||||
&& let Some(bridge) = host::Bridge::new()
|
||||
{
|
||||
// Host modes (`host` below, over `craft-web-host`): `?host=parent`, a
|
||||
// same-origin page embedding PdfCraft in an iframe (such as the Nextcloud
|
||||
// app), or `data-host="page"` on the canvas, a host that runs the module in
|
||||
// its own document.
|
||||
if let Some(bridge) = app_canvas().as_ref().and_then(host::Bridge::connect) {
|
||||
// `?author=`: the host's name for the user signs their comments,
|
||||
// unless they chose a name in PdfCraft (the web default is "Guest").
|
||||
if let Some(author) = query_param("author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty())
|
||||
@@ -81,9 +74,7 @@ fn main() {
|
||||
.await;
|
||||
if let Err(e) = started {
|
||||
eframe::web_sys::console::error_1(&e);
|
||||
if query_param("host").as_deref() == Some("parent")
|
||||
&& let Some(bridge) = host::Bridge::new()
|
||||
{
|
||||
if let Some(bridge) = app_canvas().as_ref().and_then(host::Bridge::connect) {
|
||||
bridge.post_failed(&e.as_string().unwrap_or_else(|| format!("{e:?}")));
|
||||
}
|
||||
}
|
||||
@@ -123,136 +114,116 @@ async fn fetch_bytes(url: &str) -> Result<Vec<u8>, String> {
|
||||
Ok(js_sys::Uint8Array::new(&buf).to_vec())
|
||||
}
|
||||
|
||||
/// The `postMessage` bridge to a same-origin parent page. Messages are plain objects with a
|
||||
/// `type`:
|
||||
/// The app's canvas: the one element `index.html` and every embedding page must provide, with
|
||||
/// the id the app looks up.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn app_canvas() -> Option<web_sys::HtmlCanvasElement> {
|
||||
use eframe::wasm_bindgen::JsCast;
|
||||
web_sys::window()?.document()?.get_element_by_id("pdfcraft").and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
|
||||
}
|
||||
|
||||
/// Host modes: how the page embedding PdfCraft passes files in and takes saves back. The messages
|
||||
/// and the two transports are `craft-web-host`'s (storytold/craft-libs), the same contract as the
|
||||
/// other Craft apps' bridges, with the `pdfcraft:` prefix. This module is PdfCraft's part of it.
|
||||
///
|
||||
/// - `pdfcraft:ready` (to the parent): the app is listening; send files now.
|
||||
/// - `pdfcraft:open` (from the parent): `{ name, bytes }`, where `bytes` is an `ArrayBuffer` or
|
||||
/// `Uint8Array` holding a PDF. It opens in a tab, like a `?file=` URL.
|
||||
/// - `pdfcraft:save` (to the parent): `{ name, saveAs, bytes }` when the user saves; `bytes` is
|
||||
/// a transferred `ArrayBuffer`. The parent stores the file (and reports its own errors).
|
||||
/// - `pdfcraft:dirty` (to the parent): `{ dirty }` whenever unsaved work appears or goes away.
|
||||
/// - `pdfcraft:failed` (to the parent): `{ error }` when the app couldn't start.
|
||||
///
|
||||
/// Only the parent window on the app's own origin is heard or answered. With a parent on
|
||||
/// another origin there is no bridge: the app runs as if unframed and saves download.
|
||||
/// - `?host=parent` is the `postMessage` bridge for a same-origin page that embeds PdfCraft in an
|
||||
/// iframe (such as the Nextcloud app). With a parent on another origin there is no bridge: the
|
||||
/// app runs as if unframed and saves download (#2167). `data-host="page"` on the app's canvas
|
||||
/// is for a host that loads the module into its own document, with no iframe: the same messages
|
||||
/// travel as DOM `CustomEvent`s on that canvas. `?host=parent` wins when both are present.
|
||||
/// - `pdfcraft:ready` once the app is listening; send files now. PdfCraft reads bytes, so it
|
||||
/// says `file: false` and a host sends `bytes`.
|
||||
/// - `pdfcraft:open` (from the host): `{ name, bytes }` holding a PDF. It opens in a tab, like a
|
||||
/// `?file=` URL; a file over [`host::MAX_OPEN_BYTES`] is refused before it is copied.
|
||||
/// - `pdfcraft:save` (to the host): `{ name, saveAs, bytes }` when the user saves. The host stores
|
||||
/// the file (and reports its own errors).
|
||||
/// - `pdfcraft:dirty` whenever unsaved work appears or goes away, `pdfcraft:failed` when the app
|
||||
/// couldn't start.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host {
|
||||
use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure};
|
||||
use std::rc::Rc;
|
||||
|
||||
use craft_web_host::web::{FileData, Host, Message};
|
||||
|
||||
/// The app id in the host messages (`pdfcraft:ready`).
|
||||
const APP: &str = "pdfcraft";
|
||||
|
||||
/// The name a file gets when the host sent none (the crate says `document` for a blank name).
|
||||
const DEFAULT_NAME: &str = "document.pdf";
|
||||
|
||||
/// The largest file the host may hand over to open, checked before it is copied (the app's own readers have their own limits).
|
||||
const MAX_OPEN_BYTES: u64 = 512 << 20;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
window: web_sys::Window,
|
||||
parent: web_sys::Window,
|
||||
origin: String,
|
||||
host: Rc<Host>,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent
|
||||
/// is on another origin: messages only go to this origin, so the browser would drop
|
||||
/// every save without an error while the document showed as saved (#2167).
|
||||
pub fn new() -> Option<Self> {
|
||||
let window = web_sys::window()?;
|
||||
let parent = window.parent().ok().flatten()?;
|
||||
if js_sys::Object::is(&parent, &window) {
|
||||
return None;
|
||||
}
|
||||
let origin = window.location().origin().ok()?;
|
||||
// Reading another origin's location throws, which is `None` too.
|
||||
if parent.location().origin().ok()? != origin {
|
||||
return None;
|
||||
}
|
||||
Some(Self { window, parent, origin })
|
||||
}
|
||||
|
||||
fn post(&self, message: &js_sys::Object, transfer: Option<&JsValue>) -> Result<(), String> {
|
||||
let sent = match transfer {
|
||||
Some(t) => self.parent.post_message_with_transfer(message, &self.origin, &js_sys::Array::of1(t)),
|
||||
None => self.parent.post_message(message, &self.origin),
|
||||
};
|
||||
sent.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
|
||||
/// The bridge for this page, or `None` when PdfCraft isn't hosted: no `?host=parent` and
|
||||
/// no `data-host="page"` on the canvas, an unframed `?host=parent` page, or a parent on
|
||||
/// another origin (messages only go to this origin, so the browser would drop every save
|
||||
/// without an error while the document showed as saved, #2167).
|
||||
pub fn connect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
|
||||
Some(Self { host: Rc::new(Host::connect(APP, canvas)?) })
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
if let Some(m) = message(&[("type", "pdfcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]) {
|
||||
let _ = self.post(&m, None);
|
||||
}
|
||||
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
}
|
||||
|
||||
/// The app couldn't start (no WebGL, for example): let the parent say so.
|
||||
/// The app couldn't start (no WebGL, for example): let the host say so.
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
if let Some(m) = message(&[("type", "pdfcraft:failed".into()), ("error", error.into())]) {
|
||||
let _ = self.post(&m, None);
|
||||
}
|
||||
let _ = self.host.post_failed(error, None);
|
||||
}
|
||||
|
||||
/// Hear `pdfcraft:open` from the parent and queue the file like a `?file=` download.
|
||||
/// Hear `pdfcraft:open` and queue the file like a `?file=` download.
|
||||
pub fn listen(&self, inbox: pdfcraft_ui_egui::Inbox, ctx: egui::Context) {
|
||||
let parent = self.parent.clone();
|
||||
let origin = self.origin.clone();
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
|
||||
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
|
||||
return;
|
||||
self.host.listen(move |message| {
|
||||
if let Message::Open { name, data } = message {
|
||||
queue_open(&name, &data, &inbox, &ctx);
|
||||
}
|
||||
let data = event.data();
|
||||
if string(&data, "type").as_deref() != Some("pdfcraft:open") {
|
||||
return;
|
||||
}
|
||||
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "document.pdf".to_string());
|
||||
let Ok(bytes) = js_sys::Reflect::get(&data, &JsValue::from_str("bytes")) else { return };
|
||||
let bytes = if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
|
||||
array.to_vec()
|
||||
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
|
||||
js_sys::Uint8Array::new(&bytes).to_vec()
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
if let Ok(mut q) = inbox.lock() {
|
||||
q.push((name, bytes));
|
||||
}
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
|
||||
// The listener lives as long as the page.
|
||||
on_message.forget();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_fn(&self) -> pdfcraft_ui_egui::HostSaveFn {
|
||||
let bridge = self.clone();
|
||||
Box::new(move |name: &str, bytes: &[u8], save_as: bool| {
|
||||
let buffer = js_sys::Uint8Array::from(bytes).buffer();
|
||||
let m =
|
||||
message(&[("type", "pdfcraft:save".into()), ("name", name.into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())])
|
||||
.ok_or_else(|| "couldn't build the message".to_string())?;
|
||||
bridge.post(&m, Some(&buffer))
|
||||
})
|
||||
let host = self.host.clone();
|
||||
Box::new(move |name: &str, bytes: &[u8], save_as: bool| host.post_save(name, bytes, None, save_as))
|
||||
}
|
||||
|
||||
pub fn dirty_fn(&self) -> pdfcraft_ui_egui::HostDirtyFn {
|
||||
let bridge = self.clone();
|
||||
let host = self.host.clone();
|
||||
// A host starts out clean: the first report is a change only when it is dirty.
|
||||
let mut last = false;
|
||||
Box::new(move |dirty: bool| {
|
||||
if dirty != last {
|
||||
last = dirty;
|
||||
if let Some(m) = message(&[("type", "pdfcraft:dirty".into()), ("dirty", dirty.into())]) {
|
||||
let _ = bridge.post(&m, None);
|
||||
}
|
||||
host.post_dirty(dirty);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
|
||||
let m = js_sys::Object::new();
|
||||
for (key, value) in fields {
|
||||
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
|
||||
/// The `pdfcraft:open` payload, `{ name, bytes }` where `bytes` holds a PDF: queue it like a
|
||||
/// `?file=` download and wake the app. The crate has cleaned the name (and cut it to 255
|
||||
/// characters); a blank one comes as `document` and becomes `document.pdf`.
|
||||
fn queue_open(name: &str, data: &FileData, inbox: &pdfcraft_ui_egui::Inbox, ctx: &egui::Context) {
|
||||
let name = if name == "document" { DEFAULT_NAME } else { name }.to_string();
|
||||
let FileData::Bytes(view) = data else {
|
||||
eframe::web_sys::console::warn_1(&format!("PdfCraft: host file {name} came as a File; PdfCraft reads bytes only; not opened").into());
|
||||
return;
|
||||
};
|
||||
// A file from the host is untrusted input: refuse an oversized one before copying it into the app's memory.
|
||||
if data.size() > MAX_OPEN_BYTES as f64 {
|
||||
eframe::web_sys::console::warn_1(
|
||||
&format!("PdfCraft: host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20).into(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
fn string(data: &JsValue, key: &str) -> Option<String> {
|
||||
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
|
||||
if let Ok(mut q) = inbox.lock() {
|
||||
q.push((name, view.to_vec()));
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,18 @@
|
||||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-a-large-small"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m15 16 2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16" />
|
||||
<path d="M15.697 14h5.606" />
|
||||
<path d="m2 16 4.039-9.69a.5.5 0 0 1 .923 0L11 16" />
|
||||
<path d="M3.304 13h6.392" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 475 B |
@@ -183,3 +183,6 @@ sync_github_name = true
|
||||
|
||||
[people.Combiler01]
|
||||
sync_github_name = true
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
@@ -0,0 +1 @@
|
||||
575cfdef63227b14daf173b8cc87928bfdfa325e
|
||||
+189
-74
@@ -232,6 +232,31 @@ fn rg_stroke(c: Rgb) -> String {
|
||||
format!("{} {} {} RG\n", n(c[0]), n(c[1]), n(c[2]))
|
||||
}
|
||||
|
||||
/// A FreeText's border colour where it has its own: the stroking colour in `/DA` (`RG`, or `G`
|
||||
/// for grey). `None`: the border takes the text colour.
|
||||
pub fn da_border(d: &Dict) -> Option<Rgb> {
|
||||
let da = d.get(b"DA").and_then(|o| o.as_string()).map(|s| String::from_utf8_lossy(&s.bytes).into_owned())?;
|
||||
let toks: Vec<&str> = da.split_whitespace().collect();
|
||||
let num = |i: usize| toks.get(i).and_then(|t| t.parse::<f64>().ok()).filter(|x| x.is_finite());
|
||||
let mut col = None;
|
||||
for (i, t) in toks.iter().enumerate() {
|
||||
match *t {
|
||||
"RG" if i >= 3 => {
|
||||
if let (Some(r), Some(g), Some(b)) = (num(i - 3), num(i - 2), num(i - 1)) {
|
||||
col = Some([r, g, b].map(|x| x.clamp(0.0, 1.0)));
|
||||
}
|
||||
}
|
||||
"G" if i >= 1 => {
|
||||
if let Some(g) = num(i - 1) {
|
||||
col = Some([g.clamp(0.0, 1.0); 3]);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
col
|
||||
}
|
||||
|
||||
/// `/DA` of a text box: (text colour, font size). Defaults to black 12 pt.
|
||||
pub fn parse_da(d: &Dict) -> (Rgb, f64) {
|
||||
let da = d.get(b"DA").and_then(|o| o.as_string()).map(|s| String::from_utf8_lossy(&s.bytes).into_owned()).unwrap_or_default();
|
||||
@@ -262,23 +287,51 @@ pub fn parse_da(d: &Dict) -> (Rgb, f64) {
|
||||
(col.map(|x| x.clamp(0.0, 1.0)), size.min(400.0))
|
||||
}
|
||||
|
||||
fn ext_gstate(opacity: f64, multiply: bool) -> Option<Dict> {
|
||||
if opacity >= 1.0 && !multiply {
|
||||
/// How opaque a comment's parts are drawn, 0–1: its line or border (`/CA`); its fill or
|
||||
/// background (`/ca`, which PDF 2.0 gives the non-stroking operations; without it, as the line);
|
||||
/// and a text box's text (`/PCTextOpacity`, PdfCraft's own; without it, as the fill, since text
|
||||
/// is filled too). A comment with only `/CA` is drawn at that opacity throughout, as before.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PartOpacity {
|
||||
pub line: f64,
|
||||
pub fill: f64,
|
||||
pub text: f64,
|
||||
}
|
||||
|
||||
/// The parts' opacities of the comment `d` (see [`PartOpacity`]).
|
||||
pub fn part_opacity(d: &Dict) -> PartOpacity {
|
||||
let get = |key: &[u8]| d.get(key).and_then(|o| o.as_f64()).filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0));
|
||||
let line = get(b"CA").unwrap_or(1.0);
|
||||
let fill = get(b"ca").unwrap_or(line);
|
||||
let text = get(b"PCTextOpacity").unwrap_or(fill);
|
||||
PartOpacity { line, fill, text }
|
||||
}
|
||||
|
||||
/// `/GS0`: the line's opacity for strokes and the fill's for everything filled; `/GS1`, where the
|
||||
/// text's differs from the fill's: the text's for its glyphs.
|
||||
fn ext_gstate(parts: PartOpacity, multiply: bool) -> Option<Dict> {
|
||||
if parts.line >= 1.0 && parts.fill >= 1.0 && parts.text >= 1.0 && !multiply {
|
||||
return None;
|
||||
}
|
||||
let mut gs = Dict::new();
|
||||
gs.set(b"Type".to_vec(), Object::name("ExtGState"));
|
||||
if multiply {
|
||||
gs.set(b"BM".to_vec(), Object::name("Multiply"));
|
||||
}
|
||||
gs.set(b"CA".to_vec(), Object::Real(opacity));
|
||||
gs.set(b"ca".to_vec(), Object::Real(opacity));
|
||||
let gs = |stroke: f64, fill: f64| {
|
||||
let mut gs = Dict::new();
|
||||
gs.set(b"Type".to_vec(), Object::name("ExtGState"));
|
||||
if multiply {
|
||||
gs.set(b"BM".to_vec(), Object::name("Multiply"));
|
||||
}
|
||||
gs.set(b"CA".to_vec(), Object::Real(stroke));
|
||||
gs.set(b"ca".to_vec(), Object::Real(fill));
|
||||
Object::Dict(gs)
|
||||
};
|
||||
let mut res = Dict::new();
|
||||
res.set(b"GS0".to_vec(), Object::Dict(gs));
|
||||
res.set(b"GS0".to_vec(), gs(parts.line, parts.fill));
|
||||
if parts.text != parts.fill {
|
||||
res.set(b"GS1".to_vec(), gs(parts.line, parts.text));
|
||||
}
|
||||
Some(res)
|
||||
}
|
||||
|
||||
fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream {
|
||||
pub(crate) fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
@@ -288,6 +341,18 @@ fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream {
|
||||
Stream::flate(d, content)
|
||||
}
|
||||
|
||||
/// The `cm` that draws a picture into `at`: an image (its unit square) or a form XObject whose
|
||||
/// `/Matrix` maps it to `size` points (`None`, or a side that is not positive, draws nothing).
|
||||
pub(crate) fn picture_place(at: [f64; 4], image: bool, size: Option<(f64, f64)>) -> Option<String> {
|
||||
let [x0, y0, x1, y1] = at;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
if image {
|
||||
return Some(format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)));
|
||||
}
|
||||
let (sw, sh) = size.filter(|(sw, sh)| *sw > 0.0 && *sh > 0.0)?;
|
||||
Some(format!("{} 0 0 {} {} {} cm", n(w / sw), n(h / sh), n(x0), n(y0)))
|
||||
}
|
||||
|
||||
/// The page `/Rotate` an image signature's picture is turned back by (`/PCPictureRotate`, written
|
||||
/// when the signature is added to a turned page): 0, 90, 180 or 270.
|
||||
pub(crate) fn picture_turn(d: &Dict) -> i64 {
|
||||
@@ -301,10 +366,18 @@ fn stamp_turn(d: &Dict) -> i64 {
|
||||
|
||||
/// The page `/Rotate` a text box's or Fill & Sign text's lines are turned back by
|
||||
/// (`/PCTextRotate`, written when it is added to a turned page).
|
||||
fn text_turn(d: &Dict) -> i64 {
|
||||
pub(crate) fn text_turn(d: &Dict) -> i64 {
|
||||
d.get(b"PCTextRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90
|
||||
}
|
||||
|
||||
/// A FreeText's `/RD`: how far its text box lies inside `/Rect` from the left, bottom, right and
|
||||
/// top. A distance a hair below 0 (rounding) reads as 0; `None` when one is negative or they can't
|
||||
/// be read.
|
||||
pub(crate) fn inset(d: &Dict) -> Option<[f64; 4]> {
|
||||
let rd: [f64; 4] = nums(d, b"RD")?.try_into().ok()?;
|
||||
rd.iter().all(|x| x.is_finite() && *x > -0.001).then(|| rd.map(|x| x.max(0.0)))
|
||||
}
|
||||
|
||||
/// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that
|
||||
/// they can't, which would come out as `?`: what it is (for the message) and the character. The
|
||||
/// text of a text box, callout or Fill & Sign text, a line's caption, a measurement's label, a
|
||||
@@ -325,7 +398,7 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> {
|
||||
}
|
||||
|
||||
/// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding.
|
||||
fn helvetica_resource() -> Object {
|
||||
pub(crate) fn helvetica_resource() -> Object {
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
@@ -379,7 +452,9 @@ struct AppearanceContext<'a> {
|
||||
subtype: &'a [u8],
|
||||
rect: [f64; 4],
|
||||
cloudy: bool,
|
||||
/// The line's opacity (`parts.line`).
|
||||
opacity: f64,
|
||||
parts: PartOpacity,
|
||||
stroke: Option<Rgb>,
|
||||
width: f64,
|
||||
}
|
||||
@@ -396,7 +471,7 @@ impl AppearanceDrawing {
|
||||
fn new(context: &AppearanceContext<'_>) -> Self {
|
||||
let mut drawing = Self::default();
|
||||
if let Some(gs) = ext_gstate(
|
||||
context.opacity,
|
||||
context.parts,
|
||||
context.subtype == b"Highlight" || (context.subtype == b"Ink" && context.dict.name(b"IT") == Some(b"FreehandHighlight")),
|
||||
) {
|
||||
drawing.resources.set(b"ExtGState".to_vec(), Object::Dict(gs));
|
||||
@@ -443,7 +518,8 @@ fn appearance_context(d: &Dict) -> Option<AppearanceContext<'_>> {
|
||||
subtype,
|
||||
rect,
|
||||
cloudy,
|
||||
opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0),
|
||||
opacity: part_opacity(d).line,
|
||||
parts: part_opacity(d),
|
||||
stroke: color(d, b"C")?,
|
||||
width: border_width(d),
|
||||
})
|
||||
@@ -786,15 +862,12 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -
|
||||
// the standard counter-rotation as its form `/Matrix`: viewers fit the turned
|
||||
// bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed.
|
||||
if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) {
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true)));
|
||||
let place = if image {
|
||||
format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0))
|
||||
} else {
|
||||
let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?;
|
||||
format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0))
|
||||
};
|
||||
let size = nums(d, b"PCPictureSize").and_then(|s| match s.as_slice() {
|
||||
[w, h] if *w > 0.0 && *h > 0.0 => Some((*w, *h)),
|
||||
_ => None,
|
||||
});
|
||||
let place = picture_place(rect, image, size)?;
|
||||
c.push_str(&format!("q {place} /Pic Do Q\n"));
|
||||
let mut xo = Dict::new();
|
||||
xo.set(b"Pic".to_vec(), Object::Ref(pic));
|
||||
@@ -836,46 +909,25 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -
|
||||
let [x0, y0, x1, y1] = draw;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let lw = w.min(h) * 0.12;
|
||||
// The strokes come from `FillMark::strokes`, which a copy's outline draws too.
|
||||
let path = |mark: crate::FillMark| {
|
||||
let ops: Vec<String> = mark
|
||||
.strokes(draw)
|
||||
.iter()
|
||||
.flat_map(|s| s.iter().enumerate().map(|(i, p)| format!("{} {} {}", n(p[0]), n(p[1]), if i == 0 { "m" } else { "l" })))
|
||||
.collect();
|
||||
format!("{} S\n", ops.join(" "))
|
||||
};
|
||||
match name {
|
||||
b"PCCheck" => c.push_str(&format!(
|
||||
"{}{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n",
|
||||
rg_stroke(col),
|
||||
n(lw),
|
||||
n(x0 + w * 0.15),
|
||||
n(y0 + h * 0.5),
|
||||
n(x0 + w * 0.4),
|
||||
n(y0 + h * 0.2),
|
||||
n(x0 + w * 0.88),
|
||||
n(y0 + h * 0.85)
|
||||
)),
|
||||
b"PCCross" => c.push_str(&format!(
|
||||
"{}{} w 1 J\n{} {} m {} {} l {} {} m {} {} l S\n",
|
||||
rg_stroke(col),
|
||||
n(lw),
|
||||
n(x0 + w * 0.18),
|
||||
n(y0 + h * 0.18),
|
||||
n(x1 - w * 0.18),
|
||||
n(y1 - h * 0.18),
|
||||
n(x0 + w * 0.18),
|
||||
n(y1 - h * 0.18),
|
||||
n(x1 - w * 0.18),
|
||||
n(y0 + h * 0.18)
|
||||
)),
|
||||
b"PCCheck" => c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(lw), path(crate::FillMark::Check))),
|
||||
b"PCCross" => c.push_str(&format!("{}{} w 1 J\n{}", rg_stroke(col), n(lw), path(crate::FillMark::Cross))),
|
||||
b"PCDot" => {
|
||||
let r = w.min(h) * 0.3;
|
||||
c.push_str(&rg(col));
|
||||
c.push_str(&ellipse(x0 + w / 2.0 - r, y0 + h / 2.0 - r, x0 + w / 2.0 + r, y0 + h / 2.0 + r));
|
||||
c.push_str("f\n");
|
||||
}
|
||||
b"PCLine" => c.push_str(&format!(
|
||||
"{}{} w 1 J\n{} {} m {} {} l S\n",
|
||||
rg_stroke(col),
|
||||
n(h.clamp(0.5, 2.0)),
|
||||
n(x0),
|
||||
n(y0 + h / 2.0),
|
||||
n(x1),
|
||||
n(y0 + h / 2.0)
|
||||
)),
|
||||
b"PCLine" => c.push_str(&format!("{}{} w 1 J\n{}", rg_stroke(col), n(h.clamp(0.5, 2.0)), path(crate::FillMark::Line))),
|
||||
other => {
|
||||
// Only stamps PdfCraft made: others keep their own artwork.
|
||||
if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) {
|
||||
@@ -893,14 +945,59 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -
|
||||
Some(form(rect, c.as_bytes(), res))
|
||||
}
|
||||
|
||||
/// The oval or circle (`shape`) around a text box whose whole box is `full`: its background
|
||||
/// and border go into `c`; returns the text box inside it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn shaped_border(
|
||||
d: &Dict,
|
||||
shape: crate::BorderShape,
|
||||
full: [f64; 4],
|
||||
turn: i64,
|
||||
bw: f64,
|
||||
bg: Option<Rgb>,
|
||||
line_color: Rgb,
|
||||
c: &mut String,
|
||||
) -> Option<[f64; 4]> {
|
||||
// Laid out as displayed: a quarter turn swaps the sides' differences.
|
||||
let rect = match inset(d) {
|
||||
Some(rd) => {
|
||||
let (dx, dy) = if matches!(turn, 90 | 270) { (rd[1], rd[0]) } else { (rd[0], rd[1]) };
|
||||
[full[0] + dx, full[1] + dy, full[2] - dx, full[3] - dy]
|
||||
}
|
||||
None => shape.inside(full, bw, full),
|
||||
};
|
||||
if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
|
||||
return None;
|
||||
}
|
||||
let h = bw / 2.0;
|
||||
let [x0, y0, x1, y1] = [full[0] + h, full[1] + h, full[2] - h, full[3] - h];
|
||||
let path = match shape {
|
||||
crate::BorderShape::Circle => {
|
||||
let (cx, cy, r) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, (x1 - x0).min(y1 - y0) / 2.0);
|
||||
ellipse(cx - r, cy - r, cx + r, cy + r)
|
||||
}
|
||||
_ => ellipse(x0, y0, x1, y1),
|
||||
};
|
||||
if let Some(bg) = bg {
|
||||
c.push_str(&format!("{}{path}f\n", rg(bg)));
|
||||
}
|
||||
if bw > 0.0 {
|
||||
c.push_str(&format!("{}{} w\n{}{path}S\n[] 0 d\n", rg_stroke(line_color), n(bw), dash(d)));
|
||||
}
|
||||
Some(rect)
|
||||
}
|
||||
|
||||
fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option<Stream> {
|
||||
let d = context.dict;
|
||||
let parts = context.parts;
|
||||
let rect = context.rect;
|
||||
let stroke = context.stroke;
|
||||
let mut c = std::mem::take(&mut drawing.content);
|
||||
let mut res = std::mem::take(&mut drawing.resources);
|
||||
let _tail = std::mem::take(&mut drawing.tail);
|
||||
let (text_color, size) = parse_da(d);
|
||||
// The box's border and a callout's leader line: their own colour, else the text's.
|
||||
let line_color = da_border(d).unwrap_or(text_color);
|
||||
let bg = stroke;
|
||||
let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 };
|
||||
// A callout: the leader line (arrowhead at its first point), and the text box inside
|
||||
@@ -939,7 +1036,7 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
|
||||
if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw)));
|
||||
c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(line_color), rg(bg.unwrap_or([1.0; 3])), n(lw)));
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" }));
|
||||
}
|
||||
@@ -948,26 +1045,40 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if let Some(bg) = bg {
|
||||
c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1])));
|
||||
}
|
||||
if bw > 0.0 {
|
||||
let h = bw / 2.0;
|
||||
c.push_str(&format!(
|
||||
"{}{} w\n{}{} {} {} {} re S\n[] 0 d\n",
|
||||
rg_stroke(text_color),
|
||||
n(bw),
|
||||
dash(d),
|
||||
n(rect[0] + h),
|
||||
n(rect[1] + h),
|
||||
n(rect[2] - rect[0] - bw),
|
||||
n(rect[3] - rect[1] - bw)
|
||||
));
|
||||
// An oval or a circle (`/PCBorderShape`) around the text box, which is `/Rect` inset by
|
||||
// `/RD` and keeps its place: background and border take the shape, the text is laid out
|
||||
// in the text box as in a rectangle.
|
||||
let shape = crate::BorderShape::of(d);
|
||||
if shape != crate::BorderShape::Rectangle {
|
||||
rect = shaped_border(d, shape, full, turn, bw, bg, line_color, &mut c)?;
|
||||
} else {
|
||||
if let Some(bg) = bg {
|
||||
c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1])));
|
||||
}
|
||||
if bw > 0.0 {
|
||||
let h = bw / 2.0;
|
||||
c.push_str(&format!(
|
||||
"{}{} w\n{}{} {} {} {} re S\n[] 0 d\n",
|
||||
rg_stroke(line_color),
|
||||
n(bw),
|
||||
dash(d),
|
||||
n(rect[0] + h),
|
||||
n(rect[1] + h),
|
||||
n(rect[2] - rect[0] - bw),
|
||||
n(rect[3] - rect[1] - bw)
|
||||
));
|
||||
}
|
||||
}
|
||||
let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default();
|
||||
let pad = 2.0 + bw;
|
||||
let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0);
|
||||
let q = d.int(b"Q").unwrap_or(0);
|
||||
// In an oval or a circle the lines are centred in the text box, up and down too.
|
||||
let round = shape != crate::BorderShape::Rectangle;
|
||||
let q = if round { 1 } else { d.int(b"Q").unwrap_or(0) };
|
||||
// The text at its own opacity, where it has one.
|
||||
if parts.text != parts.fill {
|
||||
c.push_str("/GS1 gs\n");
|
||||
}
|
||||
c.push_str(&format!(
|
||||
"{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}",
|
||||
n(rect[0]),
|
||||
@@ -978,8 +1089,12 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
|
||||
rg(text_color)
|
||||
));
|
||||
let mut out = c.into_bytes();
|
||||
let lines = wrap(&text, size, width);
|
||||
let mut y = rect[3] - pad - size * 0.9;
|
||||
for line in wrap(&text, size, width) {
|
||||
if round {
|
||||
y -= ((rect[3] - rect[1] - 2.0 * pad - lines.len() as f64 * size * 1.2) / 2.0).max(0.0);
|
||||
}
|
||||
for line in lines {
|
||||
if y < rect[1] - size {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Dict, Object, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi};
|
||||
use super::{Dict, Object, PartOpacity, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi};
|
||||
|
||||
/// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp
|
||||
/// another program wrote without an appearance. Names are matched byte for byte, as PDF names
|
||||
@@ -51,7 +51,7 @@ pub(crate) fn display_stamp(d: &Dict) -> Option<Stream> {
|
||||
// A malformed `/C` falls back to the stamp's own colour rather than hiding the stamp.
|
||||
let stroke = color(d, b"C").flatten();
|
||||
let mut res = Dict::new();
|
||||
if let Some(gs) = ext_gstate(opacity, false) {
|
||||
if let Some(gs) = ext_gstate(PartOpacity { line: opacity, fill: opacity, text: opacity }, false) {
|
||||
res.set(b"ExtGState".to_vec(), Object::Dict(gs));
|
||||
}
|
||||
// A stamp whose intent (`/IT`, PDF 2.0) is an image or a snapshot has no `/Name` to default.
|
||||
|
||||
+14
-13
@@ -1,6 +1,6 @@
|
||||
use super::draw::draw_appearance;
|
||||
use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots};
|
||||
use super::modify::{n, rich_text};
|
||||
use super::modify::{free_text_da, n, rich_text, set_part_keys, shape_text_box};
|
||||
use super::types::{
|
||||
FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style,
|
||||
};
|
||||
@@ -214,8 +214,13 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
let rect = rect_for(&new.shape, style)?;
|
||||
let mut d = base_dict(new.shape.subtype(), rect, page, &new.contents, &new.author, meta);
|
||||
d.set(b"Subj".to_vec(), PdfString::text(subject(&new.shape)));
|
||||
let opacity = style.opacity.clamp(0.0, 1.0);
|
||||
set_non_opaque_style(&mut d, opacity);
|
||||
if style.fill_opacity.into_iter().chain(style.text_opacity).any(|o| !o.is_finite()) {
|
||||
return Err(AnnotError::Invalid("invalid opacity".into()));
|
||||
}
|
||||
let line = style.opacity.clamp(0.0, 1.0);
|
||||
let fill = style.fill_opacity.map_or(line, |o| o.clamp(0.0, 1.0));
|
||||
let text = style.text_opacity.map_or(fill, |o| o.clamp(0.0, 1.0));
|
||||
set_part_keys(&mut d, appearance::PartOpacity { line, fill, text });
|
||||
let border = |d: &mut Dict| {
|
||||
let mut bs = Dict::new();
|
||||
bs.set(b"W".to_vec(), Object::Real(style.width.max(0.0)));
|
||||
@@ -380,20 +385,22 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
d.set(b"IT".to_vec(), Object::name("FreeTextTypeWriter"));
|
||||
}
|
||||
let size = if font_size.is_finite() && *font_size > 0.0 { font_size.clamp(1.0, 400.0) } else { 12.0 };
|
||||
let [r, g, b] = style.color.map(|x| x.clamp(0.0, 1.0));
|
||||
d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes()));
|
||||
d.set(b"DA".to_vec(), free_text_da(style.color, style.border_color, size));
|
||||
d.set(b"Q".to_vec(), Object::Int(0));
|
||||
if let Some(f) = style.fill {
|
||||
d.set(b"C".to_vec(), rgb(f));
|
||||
}
|
||||
border(&mut d);
|
||||
rich_text(&mut d);
|
||||
// The text is laid out for the page as displayed, like a stamp's label.
|
||||
if !matches!(new.shape, Shape::Callout { .. }) {
|
||||
// The text is laid out for the page as displayed, like a stamp's label.
|
||||
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
|
||||
if matches!(turn, 90 | 180 | 270) {
|
||||
d.set(b"PCTextRotate".to_vec(), Object::Int(turn));
|
||||
}
|
||||
// An oval or a circle goes around the text box, its text wrapped as displayed:
|
||||
// after `/PCTextRotate`, which swaps the box's sides on a quarter-turned page.
|
||||
shape_text_box(&mut d, rect, style.border_shape, style.width);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -421,7 +428,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
}
|
||||
|
||||
fn validate_style(style: &Style) -> Result<(), AnnotError> {
|
||||
if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() {
|
||||
if !finite(&style.color) || style.border_color.is_some_and(|c| !finite(&c)) || !style.opacity.is_finite() || !style.width.is_finite() {
|
||||
return Err(AnnotError::Invalid("invalid style".into()));
|
||||
}
|
||||
Ok(())
|
||||
@@ -443,12 +450,6 @@ fn set_redaction_properties(dict: &mut Dict, style: &Style, quads: &[[f64; 8]],
|
||||
}
|
||||
}
|
||||
|
||||
fn set_non_opaque_style(dict: &mut Dict, opacity: f64) {
|
||||
if opacity < 1.0 {
|
||||
dict.set(b"CA".to_vec(), Object::Real(opacity));
|
||||
}
|
||||
}
|
||||
|
||||
fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots: &mut Vec<Object>) -> Result<(), AnnotError> {
|
||||
let Some(rect) = rect else { return Ok(()) };
|
||||
let mut popup = Dict::new();
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::create::finite;
|
||||
use super::draw::set_appearance;
|
||||
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs};
|
||||
use super::modify::{fillable, touch, unlocked};
|
||||
use super::types::{FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb};
|
||||
use super::types::{BorderShape, FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb};
|
||||
use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance};
|
||||
|
||||
// ── reading ─────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -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
|
||||
@@ -213,6 +217,15 @@ pub struct Props {
|
||||
pub fill: Option<Rgb>,
|
||||
/// Text boxes, typewriter text and callouts: the `/DA` font size in points.
|
||||
pub font_size: Option<f64>,
|
||||
/// Text boxes, typewriter text and callouts: the border's own colour (`None`: the text's).
|
||||
pub border_color: Option<Rgb>,
|
||||
/// Text boxes and typewriter text: the border's shape (callouts have none to choose).
|
||||
pub border_shape: Option<BorderShape>,
|
||||
/// How opaque the fill is drawn, for a comment with a fill control (`opacity` is then its
|
||||
/// line's or border's).
|
||||
pub fill_opacity: Option<f64>,
|
||||
/// How opaque a text box's, typewriter text's or callout's text is drawn.
|
||||
pub text_opacity: Option<f64>,
|
||||
}
|
||||
|
||||
/// The current properties of the comment at `(page, index)`.
|
||||
@@ -235,7 +248,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
author: text_value(doc, d, b"T").unwrap_or_default(),
|
||||
subject: text_value(doc, d, b"Subj").unwrap_or_default(),
|
||||
color,
|
||||
opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0),
|
||||
opacity: appearance::part_opacity(d).line,
|
||||
width,
|
||||
icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)),
|
||||
modified: text_value(doc, d, b"M"),
|
||||
@@ -244,6 +257,10 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
fillable: fillable(&subtype),
|
||||
fill: fill_of(d),
|
||||
font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1),
|
||||
border_color: (subtype == "FreeText").then(|| appearance::da_border(d)).flatten(),
|
||||
border_shape: (subtype == "FreeText" && !d.contains(b"CL")).then(|| BorderShape::of(d)),
|
||||
fill_opacity: fillable(&subtype).then(|| appearance::part_opacity(d).fill),
|
||||
text_opacity: (subtype == "FreeText").then(|| appearance::part_opacity(d).text),
|
||||
subtype,
|
||||
endings: line_endings_of(d),
|
||||
})
|
||||
|
||||
+15
-4
@@ -22,7 +22,9 @@ mod create;
|
||||
mod draw;
|
||||
mod info;
|
||||
mod lookup;
|
||||
mod model3d;
|
||||
mod modify;
|
||||
mod threed;
|
||||
mod types;
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -36,14 +38,17 @@ use lookup::{annots, page_ref};
|
||||
pub use lookup::{
|
||||
has_visible_fill_sign, is_fill_sign, isolate_annotation, isolate_annotations, page_refs, picture_rotation, set_annots, signature_image,
|
||||
};
|
||||
pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations};
|
||||
pub(crate) use modify::n;
|
||||
pub use modify::{
|
||||
add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents,
|
||||
set_fill, set_font_size, set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups,
|
||||
add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation,
|
||||
set_border_color, set_border_shape, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect,
|
||||
set_review_state, set_style, with_replies_and_popups,
|
||||
};
|
||||
pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model};
|
||||
pub use types::{
|
||||
AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape,
|
||||
StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad,
|
||||
AttachIcon, BorderShape, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb,
|
||||
Shape, StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad,
|
||||
};
|
||||
|
||||
pub mod appearance;
|
||||
@@ -70,6 +75,12 @@ pub enum AnnotError {
|
||||
/// with a font that has those characters. The message names the character.
|
||||
#[error("{0}")]
|
||||
Undrawable(String),
|
||||
/// A 3D model file that is neither U3D (starts with `U3D` and a zero byte) nor PRC (starts `PRC`).
|
||||
#[error("not a 3D model: the file must be U3D (ECMA-363) or PRC (ISO 14739-1)")]
|
||||
NotAModel,
|
||||
/// A 3D model larger than `MAX_MODEL_BYTES`.
|
||||
#[error("the 3D model is {size} bytes; the largest accepted is {max}")]
|
||||
ModelTooLarge { size: usize, max: usize },
|
||||
#[error("{0}")]
|
||||
Cos(#[from] pdfcraft_cos::CosError),
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Reading the model a 3D annotation shows (ISO 32000-2 §13.6.3 and §13.6.4): its `/3DD` is a
|
||||
//! 3D stream, or a 3D reference dictionary naming the stream another annotation shares.
|
||||
|
||||
use super::lookup::{annots, page_ref};
|
||||
use super::threed::MAX_MODEL_BYTES;
|
||||
use super::{AnnotError, Dict, Document, Object};
|
||||
|
||||
/// The format of a 3D stream, from its `/Subtype`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Model3dKind {
|
||||
/// Product Representation Compact (ISO 14739-1).
|
||||
Prc,
|
||||
/// Universal 3D (ECMA-363).
|
||||
U3d,
|
||||
}
|
||||
|
||||
/// A 3D annotation's model: its format and the decoded stream.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Model3d {
|
||||
pub kind: Model3dKind,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The `/Annots` positions of the 3D annotations on 0-based `page`, in order.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AnnotError::NoPageTree`] or [`AnnotError::NoSuchPage`].
|
||||
pub fn model_3d_annotations(doc: &Document, page: usize) -> Result<Vec<usize>, AnnotError> {
|
||||
let list = annots(doc, page_ref(doc, page)?);
|
||||
Ok(list.iter().enumerate().filter(|(_, a)| doc.dict(a).is_some_and(|d| d.name(b"Subtype") == Some(b"3D"))).map(|(i, _)| i).collect())
|
||||
}
|
||||
|
||||
/// The model of the 3D annotation at `/Annots` position `index` on 0-based `page`, decoded.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AnnotError::NoSuchAnnotation`] when there is no annotation there; [`AnnotError::Invalid`]
|
||||
/// when it is not a 3D annotation, has no readable 3D stream, names a format other than PRC or
|
||||
/// U3D, or decodes past [`MAX_MODEL_BYTES`].
|
||||
pub fn model_3d(doc: &Document, page: usize, index: usize) -> Result<Model3d, AnnotError> {
|
||||
let list = annots(doc, page_ref(doc, page)?);
|
||||
let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||||
let annot = doc.dict(entry).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
|
||||
let at = || format!("annotation {} on page {}", index.saturating_add(1), page.saturating_add(1));
|
||||
if annot.name(b"Subtype") != Some(b"3D") {
|
||||
return Err(AnnotError::Invalid(format!("{} is not a 3D annotation", at())));
|
||||
}
|
||||
let stream = stream_of(doc, &annot).ok_or_else(|| AnnotError::Invalid(format!("{} has no 3D stream", at())))?;
|
||||
let Object::Stream(s) = stream.as_ref() else {
|
||||
return Err(AnnotError::Invalid(format!("{} has no 3D stream", at())));
|
||||
};
|
||||
let kind = match s.dict.name(b"Subtype") {
|
||||
Some(b"PRC") => Model3dKind::Prc,
|
||||
Some(b"U3D") => Model3dKind::U3d,
|
||||
other => {
|
||||
let name = other.map_or_else(|| "none".to_owned(), |n| String::from_utf8_lossy(n).into_owned());
|
||||
return Err(AnnotError::Invalid(format!("{}'s 3D stream is of an unknown format ({name})", at())));
|
||||
}
|
||||
};
|
||||
let data = doc
|
||||
.with_direct_parms(s)
|
||||
.decoded_within(MAX_MODEL_BYTES)
|
||||
.map_err(|e| AnnotError::Invalid(format!("{}'s 3D stream could not be decoded: {e}", at())))?;
|
||||
Ok(Model3d { kind, data })
|
||||
}
|
||||
|
||||
/// What an annotation's `/3DD` names: the 3D stream itself, or the `/3D` entry of a 3D reference
|
||||
/// dictionary. A reference is followed once; the caller refuses anything that is not a stream.
|
||||
fn stream_of(doc: &Document, annot: &Dict) -> Option<std::sync::Arc<Object>> {
|
||||
let obj = doc.resolve(annot.get(b"3DD")?);
|
||||
match obj.as_ref() {
|
||||
Object::Dict(d) if d.name(b"Type") == Some(b"3DRef") => Some(doc.resolve(d.get(b"3D")?)),
|
||||
_ => Some(obj),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,88 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A one-page document whose page has `annots` as its `/Annots`, plus `extra` objects from 4 on.
|
||||
fn doc(annots: &str, extra: &[Vec<u8>]) -> Document {
|
||||
let mut objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".to_vec(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /Annots {annots} >>").into_bytes(),
|
||||
];
|
||||
objs.extend(extra.iter().cloned());
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).expect("opens")
|
||||
}
|
||||
|
||||
fn stream(dict: &str, body: &[u8]) -> Vec<u8> {
|
||||
let mut s = format!("<< {dict} /Length {} >>\nstream\n", body.len()).into_bytes();
|
||||
s.extend_from_slice(body);
|
||||
s.extend_from_slice(b"\nendstream");
|
||||
s
|
||||
}
|
||||
|
||||
fn annot(dd: &str) -> Vec<u8> {
|
||||
format!("<< /Type /Annot /Subtype /3D /Rect [0 0 100 100] /3DD {dd} >>").into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_3d_stream_is_read_through_its_filters() {
|
||||
let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /PRC /Filter /ASCIIHexDecode", b"505243>")]);
|
||||
assert_eq!(model_3d(&d, 0, 0), Ok(Model3d { kind: Model3dKind::Prc, data: b"PRC".to_vec() }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_3dref_leads_to_the_stream_it_names() {
|
||||
let d = doc("[4 0 R]", &[annot("<< /Type /3DRef /3D 5 0 R >>"), stream("/Type /3D /Subtype /PRC", b"PRC")]);
|
||||
assert_eq!(model_3d(&d, 0, 0).map(|m| m.data), Ok(b"PRC".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_u3d_stream_is_read_and_says_so() {
|
||||
let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /U3D", b"U3D")]);
|
||||
assert_eq!(model_3d(&d, 0, 0).map(|m| m.kind), Ok(Model3dKind::U3d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_3d_annotation_without_a_stream_is_invalid() {
|
||||
let d = doc("[<< /Type /Annot /Subtype /3D /Rect [0 0 1 1] >>]", &[]);
|
||||
assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_format_is_invalid() {
|
||||
let d = doc("[4 0 R]", &[annot("5 0 R"), stream("/Type /3D /Subtype /STL", b"x")]);
|
||||
assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("unknown format (STL)")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_3dref_is_followed_once_and_only_to_a_stream() {
|
||||
// A reference naming another reference, or one that names itself, is damage: no stream.
|
||||
let d = doc("[4 0 R]", &[annot("5 0 R"), b"<< /Type /3DRef /3D 6 0 R >>".to_vec(), b"<< /Type /3DRef /3D 5 0 R >>".to_vec()]);
|
||||
assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream")));
|
||||
// The stream is under `/3D`; a `/3DD` inside the reference is not the standard's key.
|
||||
let d = doc("[4 0 R]", &[annot("<< /Type /3DRef /3DD 5 0 R >>"), stream("/Type /3D /Subtype /PRC", b"PRC")]);
|
||||
assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("no 3D stream")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_annotation_is_not_a_3d_one() {
|
||||
let d = doc("[<< /Type /Annot /Subtype /Square /Rect [0 0 1 1] >> 4 0 R]", &[annot("5 0 R"), stream("/Subtype /PRC", b"PRC")]);
|
||||
assert!(matches!(model_3d(&d, 0, 0), Err(AnnotError::Invalid(m)) if m.contains("not a 3D annotation")));
|
||||
assert_eq!(model_3d(&d, 0, 2), Err(AnnotError::NoSuchAnnotation { page: 0, index: 2 }));
|
||||
assert_eq!(model_3d_annotations(&d, 0), Ok(vec![1]));
|
||||
assert_eq!(model_3d_annotations(&d, 1), Err(AnnotError::NoSuchPage(1)));
|
||||
}
|
||||
+264
-22
@@ -5,8 +5,8 @@ use super::draw::{draw_appearance, redraw_or_restore, set_appearance};
|
||||
use super::info::{line_endings_of, text_value};
|
||||
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots};
|
||||
use super::types::{
|
||||
FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style,
|
||||
StyleChange, TextReplacement,
|
||||
BorderShape, FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb,
|
||||
Shape, Style, StyleChange, TextReplacement,
|
||||
};
|
||||
use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance};
|
||||
|
||||
@@ -159,10 +159,13 @@ pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl Int
|
||||
}
|
||||
|
||||
/// Change a comment's text. Text boxes are redrawn to show it, and their rectangle follows the
|
||||
/// new text: the wrap width and top edge stay, the height fits the wrapped lines.
|
||||
/// new text: the wrap width and top edge stay, the height fits the wrapped lines. A circle snug
|
||||
/// around its text stays snug around the new text, centred where it was.
|
||||
pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
let free_text = annot_dict(doc, r).name(b"Subtype") == Some(b"FreeText");
|
||||
let d = annot_dict(doc, r);
|
||||
let free_text = d.name(b"Subtype") == Some(b"FreeText");
|
||||
let snug = free_text && is_snug(&d);
|
||||
doc.update_dict(r, |d| {
|
||||
d.set(b"Contents".to_vec(), PdfString::text(text));
|
||||
// The rich-text version follows the new plain text (text boxes), or would contradict it.
|
||||
@@ -177,8 +180,16 @@ pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, m
|
||||
// The box grows and shrinks with the text instead of clipping it. A locked box keeps its
|
||||
// text editable (as in Acrobat), so the re-fit ignores the lock. New text is drawn even
|
||||
// where the standard fonts can't draw some of it (marked and reported, see
|
||||
// `draw_appearance`).
|
||||
match fitted_box(doc, r, text) {
|
||||
// `draw_appearance`). A circle snug around its text stays snug around the new text; one
|
||||
// made larger by hand keeps its size, only growing for more lines.
|
||||
let fit = if snug {
|
||||
snug_refit(doc, r)
|
||||
} else {
|
||||
let d = annot_dict(doc, r);
|
||||
let circle = text_box_of(&d).filter(|_| BorderShape::of(&d) == BorderShape::Circle);
|
||||
fitted_box(doc, r, text).map(|fit| circle.map_or(fit, |old| [fit[0], fit[1].min(old[1]), fit[2], fit[3]]))
|
||||
};
|
||||
match fit {
|
||||
Some(rect) => apply_text_box(doc, r, rect, meta, true)?,
|
||||
None => draw_appearance(doc, r)?,
|
||||
}
|
||||
@@ -197,11 +208,12 @@ fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> {
|
||||
let rect = nums(b"Rect").filter(|v| v.len() == 4 && v.iter().all(|x| x.is_finite()))?;
|
||||
let rect = [rect[0], rect[1], rect[2], rect[3]];
|
||||
// A callout re-fits its text box (`/Rect` inset by `/RD`); the leader line keeps its place.
|
||||
// So does a text box inside an oval or a circle.
|
||||
let tb = if d.contains(b"CL") {
|
||||
let rd = nums(b"RD").filter(|v| v.len() == 4 && v.iter().all(|x| *x >= 0.0))?;
|
||||
[rect[0] + rd[0], rect[1] + rd[1], rect[2] - rd[2], rect[3] - rd[3]]
|
||||
} else {
|
||||
rect
|
||||
text_box_of(&d).unwrap_or(rect)
|
||||
};
|
||||
let (x0, top) = (tb[0].min(tb[2]), tb[1].max(tb[3]));
|
||||
let w = tb[2] - tb[0];
|
||||
@@ -388,9 +400,134 @@ pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], m
|
||||
if !matches!(subtype.as_str(), "Square" | "Circle" | "FreeText") {
|
||||
return Err(AnnotError::Invalid(format!("{subtype} comments can't be resized")));
|
||||
}
|
||||
// An oval's or a circle's frame is resized: its text box follows inside.
|
||||
let shape = if subtype == "FreeText" { BorderShape::of(&d) } else { BorderShape::Rectangle };
|
||||
let rect = match (shape, text_box_of(&d)) {
|
||||
(BorderShape::Rectangle, _) | (_, None) => rect,
|
||||
// A circle's text box keeps its proportions and reaches the circle with its longer sides;
|
||||
// the circle then goes around it again, never closer than its text allows.
|
||||
(BorderShape::Circle, Some(was)) => {
|
||||
let (o, w) = (normalize(rect), normalize(was));
|
||||
let (cx, cy, r) = ((o[0] + o[2]) / 2.0, (o[1] + o[3]) / 2.0, (o[2] - o[0]).min(o[3] - o[1]) / 2.0);
|
||||
let (wx, wy) = ((w[2] - w[0]) / 2.0, (w[3] - w[1]) / 2.0);
|
||||
let k = r / wx.max(wy).max(1e-6);
|
||||
let (ix, iy) = ((wx * k).max(0.5), (wy * k).max(0.5));
|
||||
[cx - ix, cy - iy, cx + ix, cy + iy]
|
||||
}
|
||||
(shape, Some(was)) => shape.inside(rect, border_width_of(&d), was),
|
||||
};
|
||||
apply_text_box(doc, r, rect, meta, false)
|
||||
}
|
||||
|
||||
/// A FreeText's text box: `/Rect` inset by `/RD` for a callout or a text box inside an oval or a
|
||||
/// circle, else `/Rect`. `None` when they can't be read.
|
||||
pub(crate) fn text_box_of(d: &Dict) -> Option<[f64; 4]> {
|
||||
let nums = |key: &[u8]| -> Option<Vec<f64>> { d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect() };
|
||||
let rect = normalize(four(&nums(b"Rect")?)?);
|
||||
if !d.contains(b"CL") && BorderShape::of(d) == BorderShape::Rectangle {
|
||||
return Some(rect);
|
||||
}
|
||||
let rd = appearance::inset(d)?;
|
||||
let tb = [rect[0] + rd[0], rect[1] + rd[1], rect[2] - rd[2], rect[3] - rd[3]];
|
||||
(tb[2] - tb[0] >= 1.0 && tb[3] - tb[1] >= 1.0).then_some(tb)
|
||||
}
|
||||
|
||||
/// Give text box or typewriter text `d`, whose text box is `tb`, the border `shape` (`width`
|
||||
/// wide): `/Rect` around it, `/RD` and `/PCBorderShape`, or for a rectangle `/Rect` the text
|
||||
/// box itself again.
|
||||
pub(crate) fn shape_text_box(d: &mut Dict, tb: [f64; 4], shape: BorderShape, width: f64) {
|
||||
// A circle goes around the text as the box lays it out.
|
||||
let text = (shape == BorderShape::Circle).then(|| text_extent(d, displayed_sides(d, tb).0 - 2.0 * (2.0 + width.max(0.0))));
|
||||
let outer = shape.around(tb, width, text);
|
||||
d.set(b"Rect".to_vec(), num_array(&outer));
|
||||
if shape == BorderShape::Rectangle {
|
||||
d.remove(b"PCBorderShape");
|
||||
d.remove(b"RD");
|
||||
} else {
|
||||
d.set(b"PCBorderShape".to_vec(), Object::name(shape.name()));
|
||||
// A circle touching the text box's sides would put them a rounding error outside it.
|
||||
let tb = normalize(tb);
|
||||
let rd = [tb[0] - outer[0], tb[1] - outer[1], outer[2] - tb[2], outer[3] - tb[3]].map(|x| x.max(0.0));
|
||||
d.set(b"RD".to_vec(), num_array(&rd));
|
||||
}
|
||||
}
|
||||
|
||||
/// The width of the widest line and the height of the lines of FreeText `d`'s text, as its
|
||||
/// appearance lays the text out `wrap` wide.
|
||||
fn text_extent(d: &Dict, wrap: f64) -> [f64; 2] {
|
||||
let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default();
|
||||
let (_, size) = appearance::parse_da(d);
|
||||
let lines = appearance::wrap(&text, size, wrap.max(1.0));
|
||||
let widest = lines.iter().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max);
|
||||
[widest, lines.len().max(1) as f64 * size * 1.2]
|
||||
}
|
||||
|
||||
/// The width and height of FreeText `d`'s text box `tb` as displayed: swapped on a page turned by
|
||||
/// a quarter, where its text is laid out upright.
|
||||
fn displayed_sides(d: &Dict, tb: [f64; 4]) -> (f64, f64) {
|
||||
let tb = normalize(tb);
|
||||
let (w, h) = (tb[2] - tb[0], tb[3] - tb[1]);
|
||||
if matches!(appearance::text_turn(d), 90 | 270) { (h, w) } else { (w, h) }
|
||||
}
|
||||
|
||||
/// FreeText `d`'s text box `tb`, with a border `width` wide, made as large as its text wrapped
|
||||
/// `wrap` wide needs (its widest line and its lines, inside the padding a box sized to its text
|
||||
/// has) and centred where it was: a circle goes around this, not around a box wider than its text.
|
||||
fn snug_text_box(d: &Dict, tb: [f64; 4], width: f64, wrap: f64) -> [f64; 4] {
|
||||
let tb = normalize(tb);
|
||||
let pad = 2.0 + width.max(0.0);
|
||||
let [w, h] = text_extent(d, wrap);
|
||||
let (hx, hy) = (w / 2.0 + pad + 2.0, h / 2.0 + pad + 1.0);
|
||||
let (hx, hy) = if matches!(appearance::text_turn(d), 90 | 270) { (hy, hx) } else { (hx, hy) };
|
||||
let (cx, cy) = ((tb[0] + tb[2]) / 2.0, (tb[1] + tb[3]) / 2.0);
|
||||
[cx - hx, cy - hy, cx + hx, cy + hy]
|
||||
}
|
||||
|
||||
/// Whether FreeText `d` is a circle whose text box is snug around its text ([`snug_text_box`]):
|
||||
/// it then follows its text as that changes, where one made larger by hand keeps its size.
|
||||
fn is_snug(d: &Dict) -> bool {
|
||||
let Some(tb) = text_box_of(d).filter(|_| BorderShape::of(d) == BorderShape::Circle) else { return false };
|
||||
let width = border_width_of(d);
|
||||
let s = snug_text_box(d, tb, width, displayed_sides(d, tb).0 - 2.0 * (2.0 + width));
|
||||
let tb = normalize(tb);
|
||||
((s[2] - s[0]) - (tb[2] - tb[0])).abs() < 0.5 && ((s[3] - s[1]) - (tb[3] - tb[1])).abs() < 0.5
|
||||
}
|
||||
|
||||
/// The text box of a circle snug around its text for the text and size it has now: its lines up to
|
||||
/// 300 pt wide, centred where it was.
|
||||
fn snug_refit(doc: &Document, r: ObjRef) -> Option<[f64; 4]> {
|
||||
let d = annot_dict(doc, r);
|
||||
let width = border_width_of(&d);
|
||||
Some(snug_text_box(&d, text_box_of(&d)?, width, 300.0 - 2.0 * (2.0 + width)))
|
||||
}
|
||||
|
||||
/// Give a text box or typewriter text an oval, a circle or a rectangle for its border, and redraw
|
||||
/// it. Its text box keeps its place; the oval or circle goes around it. For a circle the text box
|
||||
/// first shrinks to its text, its lines wrapped as they are.
|
||||
pub fn set_border_shape(doc: &mut Document, page: usize, index: usize, shape: BorderShape, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
unlocked(doc, r)?;
|
||||
let d = annot_dict(doc, r);
|
||||
if d.name(b"Subtype") != Some(b"FreeText") || d.contains(b"CL") {
|
||||
return Err(AnnotError::Invalid("only text boxes and typewriter text have a border shape".into()));
|
||||
}
|
||||
let tb = text_box_of(&d).ok_or_else(|| AnnotError::Invalid("the text box can't be read".into()))?;
|
||||
let tb = match shape {
|
||||
BorderShape::Circle => {
|
||||
let width = border_width_of(&d);
|
||||
snug_text_box(&d, tb, width, displayed_sides(&d, tb).0 - 2.0 * (2.0 + width))
|
||||
}
|
||||
_ => tb,
|
||||
};
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
shape_text_box(d, tb, shape, border_width_of(d));
|
||||
touch(d, meta);
|
||||
})?;
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
|
||||
/// At most this many points reshape a comment (a drawn polygon has a handful).
|
||||
const MAX_POINTS: usize = 100_000;
|
||||
|
||||
@@ -503,6 +640,8 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne
|
||||
}
|
||||
None => (rect, None),
|
||||
};
|
||||
let (shape, width) = (BorderShape::of(&d), border_width_of(&d));
|
||||
let free_text = d.name(b"Subtype") == Some(b"FreeText");
|
||||
doc.update_dict(r, |d| {
|
||||
d.set(b"Rect".to_vec(), num_array(&outer));
|
||||
match &cl {
|
||||
@@ -510,6 +649,8 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne
|
||||
d.set(b"CL".to_vec(), num_array(l));
|
||||
d.set(b"RD".to_vec(), num_array(&[rect[0] - outer[0], rect[1] - outer[1], outer[2] - rect[2], outer[3] - rect[3]]));
|
||||
}
|
||||
// An oval or a circle goes around the text box again.
|
||||
None if free_text => shape_text_box(d, rect, shape, width),
|
||||
None => {
|
||||
d.remove(b"RD");
|
||||
}
|
||||
@@ -528,6 +669,8 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne
|
||||
}
|
||||
}
|
||||
|
||||
/// The border width as the appearance draws it: `/BS /W`, else the third number of `/Border`
|
||||
/// (another app's), else 1.
|
||||
fn border_width_of(d: &Dict) -> f64 {
|
||||
appearance::border_width(d)
|
||||
}
|
||||
@@ -558,7 +701,8 @@ fn ink_rect_for_width(d: &Dict, old_w: f64, w: f64) -> Option<[f64; 4]> {
|
||||
Some([inner[0] - base[0] - new_half, inner[1] - base[1] - new_half, inner[2] + base[2] + new_half, inner[3] + base[3] + new_half])
|
||||
}
|
||||
|
||||
/// Change a comment's colour, opacity, line width and/or line endings, and redraw it.
|
||||
/// Change a comment's colour, opacity (of the whole comment), line width and/or line endings, and
|
||||
/// redraw it.
|
||||
pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let StyleChange { color, opacity, width, endings } = change;
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
@@ -581,25 +725,34 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha
|
||||
}
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
let free_text = subtype == "FreeText";
|
||||
// Typewriter text has no box of its own: a border goes around its text, so the box grows
|
||||
// (or shrinks) by the change on every side and the text keeps its place and its lines.
|
||||
// So does a text box inside an oval or a circle, which goes around it again.
|
||||
let shape = if free_text { BorderShape::of(&d) } else { BorderShape::Rectangle };
|
||||
let typewriter = free_text && (d.name(b"IT") == Some(b"FreeTextTypeWriter") || shape != BorderShape::Rectangle) && !d.contains(b"CL");
|
||||
let regrow = width.filter(|_| typewriter).and_then(|w| {
|
||||
let by = w.max(0.0) - if d.contains(b"BS") || d.contains(b"Border") { border_width_of(&d) } else { 0.0 };
|
||||
let rect = grow(text_box_of(&d)?, by);
|
||||
(by != 0.0 && finite(&rect) && rect[2] - rect[0] >= 1.0 && rect[3] - rect[1] >= 1.0).then_some((rect, w.max(0.0)))
|
||||
});
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
if let Some((rect, w)) = regrow {
|
||||
shape_text_box(d, rect, shape, w);
|
||||
}
|
||||
if let Some(c) = color {
|
||||
if free_text {
|
||||
let (_, size) = appearance::parse_da(d);
|
||||
let [r, g, b] = c.map(|x| x.clamp(0.0, 1.0));
|
||||
d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes()));
|
||||
d.set(b"DA".to_vec(), free_text_da(c, appearance::da_border(d), size));
|
||||
rich_text(d);
|
||||
} else {
|
||||
d.set(b"C".to_vec(), rgb(c));
|
||||
}
|
||||
}
|
||||
// The whole comment: its fill and text drop opacities of their own.
|
||||
if let Some(o) = opacity {
|
||||
let o = o.clamp(0.0, 1.0);
|
||||
if o < 1.0 {
|
||||
d.set(b"CA".to_vec(), Object::Real(o));
|
||||
} else {
|
||||
d.remove(b"CA");
|
||||
}
|
||||
set_part_keys(d, appearance::PartOpacity { line: o, fill: o, text: o });
|
||||
}
|
||||
if let Some(w) = width {
|
||||
let old_w = border_width_of(d);
|
||||
@@ -629,6 +782,37 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
|
||||
/// A FreeText's `/DA`: the text colour, the border's own colour when it has one (the stroking
|
||||
/// colour, `RG`), Helvetica at `size`.
|
||||
pub(crate) fn free_text_da(text: Rgb, border: Option<Rgb>, size: f64) -> PdfString {
|
||||
let [r, g, b] = text.map(|x| x.clamp(0.0, 1.0));
|
||||
let stroke = border.map(|c| c.map(|x| x.clamp(0.0, 1.0))).map(|[r, g, b]| format!("{} {} {} RG ", n(r), n(g), n(b))).unwrap_or_default();
|
||||
PdfString::literal(format!("{} {} {} rg {stroke}/Helv {} Tf", n(r), n(g), n(b), n(size)).into_bytes())
|
||||
}
|
||||
|
||||
/// Give a text box, typewriter text or callout a border colour of its own (`None`: the text's
|
||||
/// again), and redraw it.
|
||||
pub fn set_border_color(doc: &mut Document, page: usize, index: usize, color: Option<Rgb>, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
unlocked(doc, r)?;
|
||||
let d = annot_dict(doc, r);
|
||||
if d.name(b"Subtype") != Some(b"FreeText") {
|
||||
return Err(AnnotError::Invalid("only text boxes, typewriter text and callouts have a border colour".into()));
|
||||
}
|
||||
if color.is_some_and(|c| !finite(&c)) {
|
||||
return Err(AnnotError::Invalid("invalid colour".into()));
|
||||
}
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
let (text, size) = appearance::parse_da(d);
|
||||
d.set(b"DA".to_vec(), free_text_da(text, color, size));
|
||||
rich_text(d);
|
||||
touch(d, meta);
|
||||
})?;
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
|
||||
/// Change the font size of a text box, typewriter text or callout (`/DA` keeps its colour; the
|
||||
/// rich-text strings follow) and redraw it. The box grows to hold the wrapped text at the new
|
||||
/// size and never shrinks, so a box the user drew large stays large. Sizes are 1 to 400 pt.
|
||||
@@ -643,26 +827,27 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m
|
||||
return Err(AnnotError::Invalid("font size must be 1\u{2013}400 pt".into()));
|
||||
}
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
let snug = is_snug(&d);
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
let (col, _) = appearance::parse_da(d);
|
||||
d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(col[0]), n(col[1]), n(col[2]), n(size)).into_bytes()));
|
||||
d.set(b"DA".to_vec(), free_text_da(col, appearance::da_border(d), size));
|
||||
rich_text(d);
|
||||
touch(d, meta);
|
||||
})?;
|
||||
let text = text_value(doc, &annot_dict(doc, r), b"Contents").unwrap_or_default();
|
||||
let result = match fitted_box(doc, r, &text) {
|
||||
Some(fit) => {
|
||||
let result = match (snug, if snug { snug_refit(doc, r) } else { fitted_box(doc, r, &text) }) {
|
||||
// A circle snug around its text stays snug around it at the new size.
|
||||
(true, Some(fit)) => apply_text_box(doc, r, fit, meta, false),
|
||||
(false, Some(fit)) => {
|
||||
let cur = annot_dict(doc, r);
|
||||
let nums: Vec<f64> =
|
||||
cur.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default();
|
||||
// Keep the top and sides; only the bottom edge moves, and only downwards.
|
||||
match four(&nums).map(normalize) {
|
||||
match text_box_of(&cur) {
|
||||
Some(old) if !cur.contains(b"CL") => apply_text_box(doc, r, [fit[0], fit[1].min(old[1]), fit[2], fit[3]], meta, false),
|
||||
_ => apply_text_box(doc, r, fit, meta, false),
|
||||
}
|
||||
}
|
||||
None => redraw_or_restore(doc, r, before.clone()),
|
||||
(_, None) => redraw_or_restore(doc, r, before.clone()),
|
||||
};
|
||||
if result.is_err() {
|
||||
doc.set(r, Object::Dict(before));
|
||||
@@ -707,6 +892,63 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
|
||||
/// Draw a rectangle's, oval's, polygon's or text box's fill, or a text box's text, at an
|
||||
/// opacity of its own (0–1), its line or border keeping the comment's (`/CA`), and redraw it.
|
||||
/// `None` leaves that part as it is drawn now. The fill goes in `/ca` (PDF 2.0's opacity for
|
||||
/// what is filled), the text in `/PCTextOpacity`, each only where it differs from the one it
|
||||
/// would otherwise follow.
|
||||
pub fn set_part_opacity(doc: &mut Document, page: usize, index: usize, fill: Option<f64>, text: Option<f64>, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
unlocked(doc, r)?;
|
||||
let d = annot_dict(doc, r);
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
|
||||
if fill.is_some() && !fillable(&subtype) {
|
||||
return Err(AnnotError::Invalid("only rectangles, ovals, polygons and text boxes have a fill of their own".into()));
|
||||
}
|
||||
if text.is_some() && subtype != "FreeText" {
|
||||
return Err(AnnotError::Invalid("only text boxes, typewriter text and callouts have text of their own".into()));
|
||||
}
|
||||
if fill.into_iter().chain(text).any(|o| !o.is_finite()) {
|
||||
return Err(AnnotError::Invalid("invalid opacity".into()));
|
||||
}
|
||||
// Check before changing anything: a stale appearance would contradict the new opacity.
|
||||
if appearance::build(&d).is_none() {
|
||||
return Err(AnnotError::Unsupported(subtype));
|
||||
}
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
let mut parts = appearance::part_opacity(d);
|
||||
if let Some(f) = fill {
|
||||
parts.fill = f.clamp(0.0, 1.0);
|
||||
}
|
||||
if let Some(t) = text {
|
||||
parts.text = t.clamp(0.0, 1.0);
|
||||
}
|
||||
set_part_keys(d, parts);
|
||||
touch(d, meta);
|
||||
})?;
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
|
||||
/// Write a comment's parts' opacities (see [`appearance::PartOpacity`]): the line's as `/CA`, the
|
||||
/// fill's as `/ca` and the text's as `/PCTextOpacity`, each only where it differs from the one
|
||||
/// it would otherwise follow (the fill the line, the text the fill), so a comment drawn at one
|
||||
/// opacity keeps just `/CA`.
|
||||
pub(crate) fn set_part_keys(d: &mut Dict, parts: appearance::PartOpacity) {
|
||||
let appearance::PartOpacity { line, fill, text } = parts;
|
||||
let mut put = |key: &[u8], value: f64, unless: f64| {
|
||||
if value == unless {
|
||||
d.remove(key);
|
||||
} else {
|
||||
d.set(key.to_vec(), Object::Real(value));
|
||||
}
|
||||
};
|
||||
put(b"CA", line, 1.0);
|
||||
put(b"ca", fill, line);
|
||||
put(b"PCTextOpacity", text, fill);
|
||||
}
|
||||
|
||||
/// Grow a line or polyline's rectangle so a non-`None` ending is not clipped. Idempotent.
|
||||
fn ensure_ending_room(d: &mut Dict) {
|
||||
let subtype = d.name(b"Subtype").unwrap_or_default();
|
||||
|
||||
+532
-3
@@ -2,6 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{Document, ObjRef, Object, SaveOptions, write_incremental};
|
||||
|
||||
use super::modify::text_box_of;
|
||||
use super::*;
|
||||
|
||||
/// Two pages. Page 1 has an inline Square annotation in a direct /Annots array; page 2 has an
|
||||
@@ -40,7 +41,7 @@ fn new(page: usize, shape: Shape) -> NewAnnotation {
|
||||
NewAnnotation {
|
||||
page,
|
||||
shape,
|
||||
style: Style { color: [0.0, 0.4, 1.0], opacity: 1.0, width: 2.0, fill: None },
|
||||
style: Style { color: [0.0, 0.4, 1.0], opacity: 1.0, width: 2.0, fill: None, ..Style::default() },
|
||||
contents: "hello".into(),
|
||||
author: "Ada".into(),
|
||||
}
|
||||
@@ -356,6 +357,296 @@ fn restyle_and_resize_redraw_the_appearance() {
|
||||
assert!(matches!(set_rect(&mut doc, 0, l, [0.0, 0.0, 5.0, 5.0], &meta("")), Err(AnnotError::Invalid(_))));
|
||||
}
|
||||
|
||||
/// Typed text or a text box takes an oval or a circle for its border: the text box keeps its
|
||||
/// place and the shape goes around it (`/Rect` grows, `/RD` holds the text box, `/PCBorderShape`
|
||||
/// the shape); back to a rectangle, it is the text box it was. A resized frame takes its text box
|
||||
/// along, a thicker border leaves it where it is.
|
||||
#[test]
|
||||
fn text_boxes_take_an_oval_or_a_circle_around_their_text() {
|
||||
let close = |a: &[f64], b: &[f64]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01);
|
||||
let nums = |d: &Dict, k: &[u8]| -> Vec<f64> {
|
||||
d.get(k).and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default()
|
||||
};
|
||||
// Where the text starts: its first line's text matrix.
|
||||
let text_at = |doc: &Document, d: &Dict| ap_content(doc, d).lines().find(|l| l.ends_with(" Tm") || l.contains(" Tm ")).map(str::to_owned);
|
||||
let mut doc = fixture();
|
||||
let mut tw = new(0, Shape::Typewriter { rect: [100.0, 100.0, 200.0, 120.0], font_size: 10.0 });
|
||||
tw.style.width = 1.0;
|
||||
let t = add_annotation(&mut doc, &tw, &meta("t")).unwrap();
|
||||
// In an oval or a circle the text is centred in its text box (left-aligned in a box): one
|
||||
// line of "hello" at 10 pt in the 100 × 20 text box centred on (150, 110), 3 pt from its top.
|
||||
let centred = format!("1 0 0 1 {} {} Tm (hello) Tj", n(150.0 - appearance::text_width("hello", 10.0) / 2.0), n(107.0));
|
||||
assert!(text_at(&doc, &list(&doc, 0)[t]).is_some_and(|l| l.starts_with("1 0 0 1 103 ")), "a box: left-aligned");
|
||||
set_border_shape(&mut doc, 0, t, BorderShape::Oval, &meta("")).unwrap();
|
||||
let doc2 = reopen(&doc);
|
||||
let d = &list(&doc2, 0)[t];
|
||||
// Around the 100 × 20 text box, centred: √2 its size, two points more for the 1 pt border.
|
||||
let (ax, ay) = (50.0 * std::f64::consts::SQRT_2 + 2.0, 10.0 * std::f64::consts::SQRT_2 + 2.0);
|
||||
assert!(close(&rect(d), &[150.0 - ax, 110.0 - ay, 150.0 + ax, 110.0 + ay]), "{:?}", rect(d));
|
||||
assert!(close(&nums(d, b"RD"), &[ax - 50.0, ay - 10.0, ax - 50.0, ay - 10.0]), "{:?}", nums(d, b"RD"));
|
||||
assert_eq!(d.name(b"PCBorderShape"), Some(&b"Oval"[..]));
|
||||
let ap = ap_content(&doc2, d);
|
||||
assert!(ap.contains(" c\n") && ap.contains("S\n") && !ap.contains("re S"), "an oval, not a box: {ap}");
|
||||
assert_eq!(text_at(&doc2, d).as_deref(), Some(centred.as_str()), "the text keeps its place");
|
||||
assert_eq!(props(&doc2, 0, t).unwrap().border_shape, Some(BorderShape::Oval));
|
||||
// A circle around the text: the text box shrinks to "hello" with its padding, centred where it
|
||||
// was, and the circle touches its sides (the text a point clear of the border inside).
|
||||
set_border_shape(&mut doc, 0, t, BorderShape::Circle, &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[t];
|
||||
let hw = appearance::text_width("hello", 10.0) / 2.0;
|
||||
let r = (hw + 5.0).max(hw.hypot(6.0) + 2.0);
|
||||
assert!(close(&rect(d), &[150.0 - r, 110.0 - r, 150.0 + r, 110.0 + r]), "{:?}", rect(d));
|
||||
assert!(close(&nums(d, b"RD"), &[r - hw - 5.0, r - 10.0, r - hw - 5.0, r - 10.0]), "{:?}", nums(d, b"RD"));
|
||||
assert_eq!(text_at(&doc, d).as_deref(), Some(centred.as_str()));
|
||||
// A thicker border: the typed text's box grows by the change on every side, so its text keeps
|
||||
// its place inside the thicker line (as with a rectangle), and the circle goes around that.
|
||||
set_style(&mut doc, 0, t, StyleChange::new(None, None, Some(3.0), None), &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[t];
|
||||
assert_eq!(text_at(&doc, d).as_deref(), Some(centred.as_str()));
|
||||
let r3 = (hw + 7.0).max(hw.hypot(6.0) + 4.0);
|
||||
assert!(close(&rect(d), &[150.0 - r3, 110.0 - r3, 150.0 + r3, 110.0 + r3]), "{:?}", rect(d));
|
||||
// Its frame resized: the text box follows inside, keeping its proportions, its longer sides
|
||||
// reaching the circle.
|
||||
set_rect(&mut doc, 0, t, [150.0 - 2.0 * r3, 110.0 - 2.0 * r3, 150.0 + 2.0 * r3, 110.0 + 2.0 * r3], &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[t];
|
||||
let rd = nums(d, b"RD");
|
||||
let tb = [rect(d)[0] + rd[0], rect(d)[1] + rd[1], rect(d)[2] - rd[2], rect(d)[3] - rd[3]];
|
||||
assert!(((tb[2] - tb[0]) / (tb[3] - tb[1]) - (hw + 7.0) / 12.0).abs() < 0.01, "the text box keeps its proportions: {tb:?}");
|
||||
assert!(close(&rect(d), &[150.0 - 2.0 * r3, 110.0 - 2.0 * r3, 150.0 + 2.0 * r3, 110.0 + 2.0 * r3]), "{:?}", rect(d));
|
||||
// A rectangle again: the text box it has, nothing around it.
|
||||
set_border_shape(&mut doc, 0, t, BorderShape::Rectangle, &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[t];
|
||||
assert!(close(&rect(d), &tb), "{:?} vs {tb:?}", rect(d));
|
||||
assert!(!d.contains(b"RD") && !d.contains(b"PCBorderShape"));
|
||||
assert!(ap_content(&doc, d).contains("re S"));
|
||||
|
||||
// A filled text box made with an oval: the background takes the shape too.
|
||||
let mut tb = new(0, Shape::TextBox { rect: [300.0, 500.0, 400.0, 540.0], font_size: 12.0 });
|
||||
(tb.style.fill, tb.style.width, tb.style.border_shape) = (Some([1.0, 1.0, 0.6]), 1.0, BorderShape::Oval);
|
||||
let b = add_annotation(&mut doc, &tb, &meta("b")).unwrap();
|
||||
let d = &list(&doc, 0)[b];
|
||||
assert_eq!(d.name(b"PCBorderShape"), Some(&b"Oval"[..]));
|
||||
let ap = ap_content(&doc, d);
|
||||
assert!(ap.contains("1 1 0.6 rg") && ap.contains(" c\nh\nf\n") && !ap.contains(" re f"), "{ap}");
|
||||
// A callout's border stays a rectangle.
|
||||
let c = add_annotation(
|
||||
&mut doc,
|
||||
&new(
|
||||
0,
|
||||
Shape::Callout {
|
||||
rect: [300.0, 300.0, 400.0, 340.0],
|
||||
knee: [280.0, 320.0],
|
||||
point: [250.0, 280.0],
|
||||
ending: LineEnding::OpenArrow,
|
||||
font_size: 10.0,
|
||||
},
|
||||
),
|
||||
&meta("c"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(set_border_shape(&mut doc, 0, c, BorderShape::Oval, &meta("")), Err(AnnotError::Invalid(_))));
|
||||
assert_eq!(props(&doc, 0, c).unwrap().border_shape, None);
|
||||
assert_eq!(BorderShape::parse("CIRCLE"), Some(BorderShape::Circle));
|
||||
}
|
||||
|
||||
/// A circle goes snug around short text, as a number on a drawing is circled: the text box is no
|
||||
/// wider than its text needs and the circle touches its sides, where one through the box's corners
|
||||
/// stood well clear. It follows the text and its size as they change; one made larger by hand
|
||||
/// keeps its size.
|
||||
#[test]
|
||||
fn a_circle_goes_snug_around_its_text_and_follows_it() {
|
||||
let close = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01;
|
||||
let mut doc = fixture();
|
||||
// "3" at 10 pt with a 1 pt border, in the box Fill & Sign sizes to it: the digit with 2 pt of
|
||||
// padding and 4 pt to spare across, a line and 6 pt down, the border around that.
|
||||
let w3 = appearance::text_width("3", 10.0);
|
||||
let mut typed = new(0, Shape::Typewriter { rect: [100.0, 680.0, 110.0 + w3, 700.0], font_size: 10.0 });
|
||||
(typed.contents, typed.style.width, typed.style.border_shape) = ("3".into(), 1.0, BorderShape::Circle);
|
||||
let t = add_annotation(&mut doc, &typed, &meta("t")).unwrap();
|
||||
let frame = |doc: &Document| {
|
||||
let r = rect(&list(doc, 0)[t]);
|
||||
((r[2] - r[0], r[3] - r[1]), ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0))
|
||||
};
|
||||
let centre = (105.0 + w3 / 2.0, 690.0);
|
||||
// As tall as the box, 20 pt across: through the corners it was 29.
|
||||
assert!(close(frame(&doc).0, (20.0, 20.0)) && close(frame(&doc).1, centre), "{:?}", frame(&doc));
|
||||
// The text box Fill & Sign gives a box to type into, 40 pt wide: a circle goes snug around its
|
||||
// text there too.
|
||||
let mut wide = typed.clone();
|
||||
wide.shape = Shape::Typewriter { rect: [300.0, 680.0, 342.0, 700.0], font_size: 10.0 };
|
||||
wide.style.border_shape = BorderShape::Rectangle;
|
||||
let b = add_annotation(&mut doc, &wide, &meta("b")).unwrap();
|
||||
set_border_shape(&mut doc, 0, b, BorderShape::Circle, &meta("")).unwrap();
|
||||
let r = rect(&list(&doc, 0)[b]);
|
||||
assert!(close((r[2] - r[0], r[3] - r[1]), (20.0, 20.0)), "{r:?}");
|
||||
// More text: the circle grows with it, about the same centre; less, and it shrinks back.
|
||||
set_contents(&mut doc, 0, t, "123", &meta("")).unwrap();
|
||||
let w = appearance::text_width("123", 10.0) / 2.0;
|
||||
let d = 2.0 * (w + 5.0).max(10.0).max(w.hypot(6.0) + 2.0);
|
||||
assert!(close(frame(&doc).0, (d, d)) && close(frame(&doc).1, centre), "{:?}", frame(&doc));
|
||||
set_contents(&mut doc, 0, t, "3", &meta("")).unwrap();
|
||||
assert!(close(frame(&doc).0, (20.0, 20.0)) && close(frame(&doc).1, centre), "{:?}", frame(&doc));
|
||||
// A larger font: a larger circle, still snug.
|
||||
set_font_size(&mut doc, 0, t, 20.0, &meta("")).unwrap();
|
||||
let w = appearance::text_width("3", 20.0) / 2.0;
|
||||
let d = 2.0 * (w + 5.0).max(16.0).max(w.hypot(12.0) + 2.0);
|
||||
assert!(close(frame(&doc).0, (d, d)) && close(frame(&doc).1, centre), "{:?}", frame(&doc));
|
||||
set_font_size(&mut doc, 0, t, 10.0, &meta("")).unwrap();
|
||||
assert!(close(frame(&doc).0, (20.0, 20.0)), "{:?}", frame(&doc));
|
||||
// Made larger by hand, it keeps its size as its text changes.
|
||||
set_rect(&mut doc, 0, t, [centre.0 - 20.0, centre.1 - 20.0, centre.0 + 20.0, centre.1 + 20.0], &meta("")).unwrap();
|
||||
assert!(close(frame(&doc).0, (40.0, 40.0)), "{:?}", frame(&doc));
|
||||
set_contents(&mut doc, 0, t, "4", &meta("")).unwrap();
|
||||
assert!(close(frame(&doc).0, (40.0, 40.0)) && close(frame(&doc).1, centre), "{:?}", frame(&doc));
|
||||
}
|
||||
|
||||
/// A circle around a line wider than it is tall touches its text box's sides: `/RD` is 0 there,
|
||||
/// not a rounding error below it, and a hair below 0 still reads. (A text box that couldn't be
|
||||
/// read stopped following its text, and its text wrapped in a narrower one.) The circle keeps its
|
||||
/// centre as its text changes.
|
||||
#[test]
|
||||
fn a_circle_touching_its_text_box_keeps_following_its_text() {
|
||||
let mut doc = fixture();
|
||||
// Sized to its text: 2 pt of padding and half a point to spare across, a line down.
|
||||
let (w, h) = (appearance::text_width("Testtext", 12.0) + 4.5, 12.0 * 1.11 + 4.0);
|
||||
let mut b = new(0, Shape::TextBox { rect: [210.79, 700.0 - h, 210.79 + w, 700.0], font_size: 12.0 });
|
||||
(b.contents, b.style.width) = ("Testtext".into(), 0.0);
|
||||
let t = add_annotation(&mut doc, &b, &meta("t")).unwrap();
|
||||
// Circle, then a 1 pt border, which the circle grows by.
|
||||
set_border_shape(&mut doc, 0, t, BorderShape::Circle, &meta("")).unwrap();
|
||||
set_style(&mut doc, 0, t, StyleChange::new(None, None, Some(1.0), None), &meta("")).unwrap();
|
||||
let lines = |doc: &Document| ap_content(doc, &list(doc, 0)[t]).matches(" Tj").count();
|
||||
let frame = |doc: &Document| {
|
||||
let r = rect(&list(doc, 0)[t]);
|
||||
((r[2] - r[0], r[3] - r[1]), ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0))
|
||||
};
|
||||
let rd = |doc: &Document| -> Vec<f64> { list(doc, 0)[t].get(b"RD").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect() };
|
||||
assert!(rd(&doc).iter().all(|x| *x >= 0.0) && rd(&doc)[0] < 0.01, "touching the sides: {:?}", rd(&doc));
|
||||
assert_eq!(lines(&doc), 1, "Testtext on one line");
|
||||
let (wide, centre) = frame(&doc);
|
||||
set_contents(&mut doc, 0, t, "KRS", &meta("")).unwrap();
|
||||
let (small, at) = frame(&doc);
|
||||
assert!(small.0 < wide.0 - 10.0 && (small.0 - small.1).abs() < 0.01, "a smaller circle: {small:?} from {wide:?}");
|
||||
assert!((at.0 - centre.0).abs() < 0.01 && (at.1 - centre.1).abs() < 0.01, "about its centre: {at:?} {centre:?}");
|
||||
assert_eq!(lines(&doc), 1);
|
||||
// A hair below 0, as rounding left it: still read, so the circle follows its text again.
|
||||
let (_, r) = annot_ref(&mut doc, 0, t).unwrap();
|
||||
let mut was = rd(&doc);
|
||||
was[0] = -2.8e-14;
|
||||
doc.update_dict(r, |d| d.set(b"RD".to_vec(), num_array(&was))).unwrap();
|
||||
assert!(text_box_of(&list(&doc, 0)[t]).is_some());
|
||||
set_contents(&mut doc, 0, t, "Testtext", &meta("")).unwrap();
|
||||
assert!((frame(&doc).0.0 - wide.0).abs() < 0.01 && lines(&doc) == 1, "{:?}", frame(&doc));
|
||||
assert!(rd(&doc).iter().all(|x| *x >= 0.0), "{:?}", rd(&doc));
|
||||
}
|
||||
|
||||
/// On a page turned a quarter, typed text is laid out as the page is shown (`/PCTextRotate`), and a
|
||||
/// circle made with it goes around the text as shown: as large as around the same text on an
|
||||
/// upright page. (Made before the turn was set, its text wrapped at the box's displayed height and
|
||||
/// the circle went around several lines.)
|
||||
#[test]
|
||||
fn a_circle_made_on_a_turned_page_is_sized_as_on_an_upright_one() {
|
||||
let w = appearance::text_width("12345", 10.0);
|
||||
let typed = |rect: [f64; 4]| {
|
||||
let mut t = new(0, Shape::Typewriter { rect, font_size: 10.0 });
|
||||
(t.contents, t.style.width, t.style.border_shape) = ("12345".into(), 1.0, BorderShape::Circle);
|
||||
t
|
||||
};
|
||||
let size = |doc: &Document, i: usize| {
|
||||
let r = rect(&list(doc, 0)[i]);
|
||||
(r[2] - r[0], r[3] - r[1])
|
||||
};
|
||||
let mut upright = fixture();
|
||||
let u = add_annotation(&mut upright, &typed([100.0, 680.0, 110.0 + w, 700.0]), &meta("u")).unwrap();
|
||||
for turn in [90, 270] {
|
||||
let mut doc = fixture();
|
||||
let page = page_refs(&doc).unwrap()[0];
|
||||
doc.update_dict(page, |d| d.set(b"Rotate".to_vec(), Object::Int(turn))).unwrap();
|
||||
// The same box as shown: its sides swapped in user space.
|
||||
let t = add_annotation(&mut doc, &typed([100.0, 680.0, 120.0, 690.0 + w]), &meta("t")).unwrap();
|
||||
assert_eq!(list(&doc, 0)[t].get(b"PCTextRotate").and_then(Object::as_int), Some(turn));
|
||||
let (turned, straight) = (size(&doc, t), size(&upright, u));
|
||||
assert!((turned.0 - straight.0).abs() < 0.01 && (turned.1 - straight.1).abs() < 0.01, "{turn}°: {turned:?}, upright {straight:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A rectangle's fill, and a text box's border, background and text, each drawn at an opacity of
|
||||
/// its own: the line's in `/CA`, the fill's in `/ca` (PDF 2.0), the text's in `/PCTextOpacity`, and
|
||||
/// in the appearance `/GS0` (strokes at the line's, fills at the fill's) and `/GS1` for the text.
|
||||
#[test]
|
||||
fn parts_are_drawn_at_opacities_of_their_own() {
|
||||
let num = |d: &Dict, k: &[u8]| d.get(k).and_then(|o| o.as_f64());
|
||||
let gstate = |doc: &Document, d: &Dict, name: &[u8]| -> Option<(f64, f64)> {
|
||||
let n = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.reference(b"N")))?;
|
||||
let obj = doc.get(n);
|
||||
let Object::Stream(s) = &*obj else { return None };
|
||||
let res = s.dict.get(b"Resources").map(|r| doc.resolve(r))?;
|
||||
let gs = res.as_dict()?.get(b"ExtGState").map(|g| doc.resolve(g))?;
|
||||
let gs = gs.as_dict()?.get(name).map(|g| doc.resolve(g))?;
|
||||
let gs = gs.as_dict()?;
|
||||
Some((gs.get(b"CA")?.as_f64()?, gs.get(b"ca")?.as_f64()?))
|
||||
};
|
||||
let mut doc = fixture();
|
||||
let r = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("r")).unwrap();
|
||||
set_fill(&mut doc, 0, r, Some([1.0, 1.0, 0.0]), &meta("")).unwrap();
|
||||
set_part_opacity(&mut doc, 0, r, Some(0.3), None, &meta("")).unwrap();
|
||||
let doc2 = reopen(&doc);
|
||||
let d = &list(&doc2, 0)[r];
|
||||
assert_eq!((num(d, b"CA"), num(d, b"ca")), (None, Some(0.3)), "an opaque line, a fill of its own");
|
||||
assert_eq!(gstate(&doc2, d, b"GS0"), Some((1.0, 0.3)));
|
||||
let ap = ap_content(&doc2, d);
|
||||
assert!(ap.starts_with("/GS0 gs") && ap.contains("\nB\n"), "filled and stroked in one go: {ap}");
|
||||
let p = props(&doc2, 0, r).unwrap();
|
||||
assert_eq!((p.opacity, p.fill_opacity, p.text_opacity), (1.0, Some(0.3), None));
|
||||
// The whole comment's opacity (StyleAnnotation, the quick bar): the fill goes along again.
|
||||
set_style(&mut doc, 0, r, StyleChange::new(None, Some(0.6), None, None), &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[r];
|
||||
assert_eq!((num(d, b"CA"), num(d, b"ca")), (Some(0.6), None));
|
||||
assert_eq!(props(&doc, 0, r).unwrap().fill_opacity, Some(0.6));
|
||||
|
||||
// A text box with its border opaque, its background at 20 % and its text opaque, as made.
|
||||
let mut tb = new(0, Shape::TextBox { rect: [200.0, 600.0, 400.0, 650.0], font_size: 12.0 });
|
||||
tb.style.fill = Some([1.0, 1.0, 0.6]);
|
||||
tb.style.width = 1.0;
|
||||
(tb.style.fill_opacity, tb.style.text_opacity) = (Some(0.2), Some(1.0));
|
||||
let t = add_annotation(&mut doc, &tb, &meta("t")).unwrap();
|
||||
let doc2 = reopen(&doc);
|
||||
let d = &list(&doc2, 0)[t];
|
||||
assert_eq!((num(d, b"CA"), num(d, b"ca"), num(d, b"PCTextOpacity")), (None, Some(0.2), Some(1.0)));
|
||||
assert_eq!((gstate(&doc2, d, b"GS0"), gstate(&doc2, d, b"GS1")), (Some((1.0, 0.2)), Some((1.0, 1.0))));
|
||||
let ap = ap_content(&doc2, d);
|
||||
let (gs1, text) = (ap.find("/GS1 gs\n").expect("the text's own state, an operator of its own"), ap.find("BT").expect("text"));
|
||||
assert!(ap.find(" re f").is_some_and(|fill| fill < gs1) && gs1 < text, "background at 20 %, then the text at its own: {ap}");
|
||||
// A new fill opacity leaves the text as it is drawn.
|
||||
set_part_opacity(&mut doc, 0, t, Some(0.5), None, &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[t];
|
||||
assert_eq!((num(d, b"ca"), num(d, b"PCTextOpacity")), (Some(0.5), Some(1.0)));
|
||||
let p = props(&doc, 0, t).unwrap();
|
||||
assert_eq!((p.opacity, p.fill_opacity, p.text_opacity), (1.0, Some(0.5), Some(1.0)));
|
||||
// All at one opacity again: only /CA stays.
|
||||
set_part_opacity(&mut doc, 0, t, Some(1.0), Some(1.0), &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[t];
|
||||
assert_eq!((num(d, b"CA"), num(d, b"ca"), num(d, b"PCTextOpacity")), (None, None, None));
|
||||
|
||||
// Only the parts a comment has, and finite values; nothing changes when refused.
|
||||
let l = add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::Line { from: [0.0, 0.0], to: [10.0, 10.0], start: LineEnding::None, end: LineEnding::None }),
|
||||
&meta("l"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(set_part_opacity(&mut doc, 0, l, Some(0.5), None, &meta("")), Err(AnnotError::Invalid(_))), "a line has no fill");
|
||||
assert!(matches!(set_part_opacity(&mut doc, 0, r, None, Some(0.5), &meta("")), Err(AnnotError::Invalid(_))), "a rectangle has no text");
|
||||
let before = list(&doc, 0)[r].clone();
|
||||
assert!(matches!(set_part_opacity(&mut doc, 0, r, Some(f64::NAN), None, &meta("")), Err(AnnotError::Invalid(_))));
|
||||
assert_eq!(list(&doc, 0)[r], before);
|
||||
// Another app's comment with /CA alone is drawn at it throughout, as before.
|
||||
assert_eq!(
|
||||
appearance::part_opacity(&Dict::from_iter([(b"CA".to_vec(), Object::Real(0.4))])),
|
||||
appearance::PartOpacity { line: 0.4, fill: 0.4, text: 0.4 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_can_be_filled_and_unfilled() {
|
||||
let mut doc = fixture();
|
||||
@@ -591,6 +882,85 @@ fn fill_and_sign_items_are_drawn() {
|
||||
assert!(set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")).is_err());
|
||||
}
|
||||
|
||||
/// A border chosen for typewriter text goes around it in the text's colour. Changing it grows or
|
||||
/// shrinks the box on every side, so the text stays where it was typed; a text box keeps its box.
|
||||
#[test]
|
||||
fn a_typewriter_border_goes_around_its_text_which_stays_put() {
|
||||
let mut doc = fixture();
|
||||
let shape = Shape::Typewriter { rect: [100.0, 700.0, 200.0, 716.0], font_size: 11.0 };
|
||||
let style = Style { width: 1.0, ..Style::default_for(&shape) };
|
||||
let t = add_annotation(&mut doc, &NewAnnotation { page: 1, shape, style, contents: "Ada".into(), author: "Ada".into() }, &meta("x")).unwrap();
|
||||
// The text starts 2 pt inside the border: 100 + 1 + 2 across, 716 - 1 - 2 - 0.9 em down.
|
||||
let at = "1 0 0 1 103 703.1 Tm";
|
||||
let d = &list(&doc, 1)[t];
|
||||
let ap = ap_content(&doc, d);
|
||||
assert!(ap.contains("1 w\n") && ap.contains(" re S"), "a 1 pt border: {ap}");
|
||||
assert!(ap.contains(at), "{ap}");
|
||||
for (width, expected) in [(2.0, [99.0, 699.0, 201.0, 717.0]), (0.0, [101.0, 701.0, 199.0, 715.0])] {
|
||||
set_style(&mut doc, 1, t, StyleChange::new(None, None, Some(width), None), &meta("")).unwrap();
|
||||
let d = &list(&doc, 1)[t];
|
||||
assert_eq!(rect(d), expected, "{width} pt border");
|
||||
let ap = ap_content(&doc, d);
|
||||
assert!(ap.contains(at), "{width} pt: the text keeps its place: {ap}");
|
||||
assert_eq!(ap.contains(" re S"), width > 0.0, "{width} pt: {ap}");
|
||||
}
|
||||
let shape = Shape::TextBox { rect: [100.0, 600.0, 200.0, 650.0], font_size: 11.0 };
|
||||
let b = add_annotation(&mut doc, &new(1, shape), &meta("b")).unwrap();
|
||||
set_style(&mut doc, 1, b, StyleChange::new(None, None, Some(3.0), None), &meta("")).unwrap();
|
||||
assert_eq!(rect(&list(&doc, 1)[b]), [100.0, 600.0, 200.0, 650.0], "a text box keeps the box it was drawn with");
|
||||
// Another app's typed text with its width in /Border (no /BS): 4 pt, so 2 pt shrinks the box
|
||||
// by 2 on every side, as it is drawn.
|
||||
let shape = Shape::Typewriter { rect: [100.0, 500.0, 200.0, 530.0], font_size: 11.0 };
|
||||
let o = add_annotation(
|
||||
&mut doc,
|
||||
&NewAnnotation { page: 1, style: Style::default_for(&shape), shape: shape.clone(), contents: "Bo".into(), author: "Ada".into() },
|
||||
&meta("o"),
|
||||
)
|
||||
.unwrap();
|
||||
let (_, r) = annot_ref(&mut doc, 1, o).unwrap();
|
||||
doc.update_dict(r, |d| {
|
||||
d.remove(b"BS");
|
||||
d.set(b"Border".to_vec(), num_array(&[0.0, 0.0, 4.0]));
|
||||
})
|
||||
.unwrap();
|
||||
set_style(&mut doc, 1, o, StyleChange::new(None, None, Some(2.0), None), &meta("")).unwrap();
|
||||
assert_eq!(rect(&list(&doc, 1)[o]), [102.0, 502.0, 198.0, 528.0], "the imported 4 pt border is read from /Border");
|
||||
// A border colour that isn't a colour is refused before anything is written.
|
||||
let mut bad = NewAnnotation { page: 1, style: Style::default_for(&shape), shape, contents: "x".into(), author: "Ada".into() };
|
||||
bad.style.border_color = Some([f64::NAN, 0.0, 0.0]);
|
||||
let before = list(&doc, 1).len();
|
||||
assert!(matches!(add_annotation(&mut doc, &bad, &meta("n")), Err(AnnotError::Invalid(_))));
|
||||
assert_eq!(list(&doc, 1).len(), before);
|
||||
}
|
||||
|
||||
/// Text keeps its colour and the border gets its own: the stroking colour in `/DA`, which a new
|
||||
/// text colour or font size keeps, and which `None` gives back to the text's.
|
||||
#[test]
|
||||
fn a_text_border_can_have_its_own_colour() {
|
||||
let mut doc = fixture();
|
||||
let shape = Shape::Typewriter { rect: [100.0, 700.0, 200.0, 716.0], font_size: 11.0 };
|
||||
let style = Style { color: [0.0, 0.5, 0.0], width: 1.0, border_color: Some([1.0, 0.0, 0.0]), ..Style::default_for(&shape) };
|
||||
let t = add_annotation(&mut doc, &NewAnnotation { page: 1, shape, style, contents: "Ada".into(), author: "Ada".into() }, &meta("x")).unwrap();
|
||||
let da = |doc: &Document| text(&list(doc, 1)[t], b"DA");
|
||||
assert_eq!(da(&doc), "0 0.5 0 rg 1 0 0 RG /Helv 11 Tf");
|
||||
let ap = ap_content(&doc, &list(&doc, 1)[t]);
|
||||
assert!(ap.contains("1 0 0 RG\n1 w"), "the border in red: {ap}");
|
||||
assert!(ap.contains("0 0.5 0 rg"), "the text in green: {ap}");
|
||||
assert_eq!(props(&doc, 1, t).unwrap().border_color, Some([1.0, 0.0, 0.0]));
|
||||
set_style(&mut doc, 1, t, StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap();
|
||||
assert_eq!(da(&doc), "0 0 1 rg 1 0 0 RG /Helv 11 Tf", "a new text colour keeps the border's");
|
||||
set_font_size(&mut doc, 1, t, 14.0, &meta("")).unwrap();
|
||||
assert_eq!(da(&doc), "0 0 1 rg 1 0 0 RG /Helv 14 Tf", "so does a new size");
|
||||
set_border_color(&mut doc, 1, t, Some([0.5, 0.5, 0.5]), &meta("")).unwrap();
|
||||
assert!(ap_content(&doc, &list(&doc, 1)[t]).contains("0.5 0.5 0.5 RG\n1 w"));
|
||||
set_border_color(&mut doc, 1, t, None, &meta("")).unwrap();
|
||||
assert_eq!(da(&doc), "0 0 1 rg /Helv 14 Tf");
|
||||
assert!(ap_content(&doc, &list(&doc, 1)[t]).contains("0 0 1 RG\n1 w"), "the text's colour again");
|
||||
assert_eq!(props(&doc, 1, t).unwrap().border_color, None);
|
||||
let m = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] }), &meta("m")).unwrap();
|
||||
assert!(set_border_color(&mut doc, 0, m, Some([1.0, 0.0, 0.0]), &meta("")).is_err(), "a rectangle's colour is its border");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_subject_and_note_icon_change() {
|
||||
let mut doc = fixture();
|
||||
@@ -873,8 +1243,8 @@ fn cloud_ys(path: &str) -> Vec<f64> {
|
||||
fn replace_text_groups_a_strikeout_and_a_caret() {
|
||||
let mut doc = fixture();
|
||||
let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]];
|
||||
let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None };
|
||||
let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None };
|
||||
let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None, ..Style::default() };
|
||||
let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None, ..Style::default() };
|
||||
let i = add_text_replacement(
|
||||
&mut doc,
|
||||
TextReplacement {
|
||||
@@ -2250,3 +2620,162 @@ fn freehand_highlight_is_compatible_ink_with_marker_blending() {
|
||||
assert!((a - b).abs() < 1e-4, "expected [10, 90, 130, 110], got {rect_from_border:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A 3D annotation is PDF 1.6 and a PRC model 1.7 (ISO 32000-2 §13.6): an older document's
|
||||
/// catalog says so, and a newer one is left as it is.
|
||||
#[test]
|
||||
fn a_3d_model_raises_an_older_document_to_its_version() {
|
||||
let catalog_version = |doc: &Document| {
|
||||
let root = doc.root().unwrap();
|
||||
doc.get(root).as_dict().unwrap().name(b"Version").map(<[u8]>::to_vec)
|
||||
};
|
||||
let add = |doc: &mut Document, model: &[u8]| {
|
||||
let new = NewModel3d { page: 0, rect: [100.0, 100.0, 300.0, 300.0], model, activation: Activation3d::default(), poster: None, contents: "" };
|
||||
add_3d_model(doc, &new, &meta("m")).unwrap();
|
||||
};
|
||||
let mut doc = fixture();
|
||||
add(&mut doc, b"U3D\0rest");
|
||||
assert_eq!(catalog_version(&reopen(&doc)), None, "a 1.7 file needs nothing for U3D");
|
||||
for (model, version) in [(&b"U3D\0rest"[..], &b"1.6"[..]), (b"PRC rest", b"1.7")] {
|
||||
let mut doc = fixture();
|
||||
doc.set_version("1.4");
|
||||
add(&mut doc, model);
|
||||
assert_eq!(catalog_version(&reopen(&doc)).as_deref(), Some(version));
|
||||
}
|
||||
let mut doc = fixture();
|
||||
doc.set_version("2.0");
|
||||
add(&mut doc, b"PRC rest");
|
||||
assert_eq!(catalog_version(&reopen(&doc)), None, "a 2.0 file takes PRC as it is");
|
||||
}
|
||||
|
||||
/// Before 2.0, PRC is an Adobe extension to PDF 1.7 (§7.12): a PRC model declares it, keeping
|
||||
/// other extensions and a later ADBE level; U3D and a 2.0 file need nothing.
|
||||
#[test]
|
||||
fn a_prc_model_declares_the_adobe_extension_before_pdf_2() {
|
||||
let adbe = |doc: &Document| {
|
||||
let root = doc.root().unwrap();
|
||||
let ext = doc.get(root).as_dict().unwrap().get(b"Extensions").map(|e| doc.resolve(e).as_dict().unwrap().clone());
|
||||
ext.map(|e| {
|
||||
let adbe = e.get(b"ADBE").map(|a| a.as_dict().unwrap().clone());
|
||||
(e.get(b"XYZ").is_some(), adbe.map(|a| (a.name(b"BaseVersion").map(<[u8]>::to_vec), a.get(b"ExtensionLevel").and_then(Object::as_int))))
|
||||
})
|
||||
};
|
||||
let add = |doc: &mut Document, model: &[u8]| {
|
||||
let new = NewModel3d { page: 0, rect: [100.0, 100.0, 300.0, 300.0], model, activation: Activation3d::default(), poster: None, contents: "" };
|
||||
add_3d_model(doc, &new, &meta("m")).unwrap();
|
||||
};
|
||||
let mut doc = fixture();
|
||||
add(&mut doc, b"PRC rest");
|
||||
assert_eq!(adbe(&reopen(&doc)), Some((false, Some((Some(b"1.7".to_vec()), Some(1))))));
|
||||
let mut doc = fixture();
|
||||
add(&mut doc, b"U3D | ||||