mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
Merge branch 'main' into fix/xml-form-text-nodes
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- name: nix flake check --no-build
|
||||
run: |
|
||||
if [ -f flake.nix ]; then
|
||||
nix flake check --no-build --all-systems
|
||||
else
|
||||
echo "No flake.nix present in this repository; skipping check."
|
||||
fi
|
||||
+1
-1
@@ -28,7 +28,6 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `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 | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.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 | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.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 | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.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 | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
@@ -278,6 +277,7 @@ 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/pdf.rs): the legacy adbe.x509.rsa_sha1 format |
|
||||
| `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `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 | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.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 | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.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 |
|
||||
|
||||
|
||||
+29
-28
@@ -30,7 +30,7 @@ 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/mod.rs"
|
||||
sha256 = "1de29a6cc089be31cb6206bf41e0715f8ddcf74ef887b45ccee235d743bed223"
|
||||
sha256 = "dbfebf4e9b2e9ac31ecf72202c2098173e72ebfaf90e9fee8f0563a6734fe31f"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
|
||||
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "8a8aed18bd8fceabd1389580361ba75b0dccf141d845650a353d8dfe19c95536"
|
||||
sha256 = "898a5240fa59cac42ef6b99cb60bcfca0094ac27370e1c8d020bd27936b56517"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pt-br.tsv"
|
||||
@@ -63,7 +63,7 @@ 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/mod.rs"
|
||||
sha256 = "1c197ac045810950aceb733152a296f2224f38857b1169032fdc7092ab4aa3f7"
|
||||
sha256 = "b4745c94797575529cd0e78c18c71fa2c0dfddfbf19b9c40b41dc2057c6f6543"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/es.tsv"
|
||||
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "a9cf0e062b006b40f549ce2316b88d4b0196d70bd9cecd66c12d980d699bf679"
|
||||
sha256 = "2acb3f6522d6d1f744343f474fe4c51ebec610a888a7fa83fc9422228e6fa3c1"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fr.tsv"
|
||||
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "ef5c84bd13ef8318f7deab7f42136cfe3c14cbef6fd5231014b94167baaecd2a"
|
||||
sha256 = "1b739478a4f186a49900cce84edbe136206a53b46340322df4caf77bb3fb5b12"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/de.tsv"
|
||||
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "be689af8bb22537879269ad9ab9bdcc4b730665cf0b27c6bf4bad560cacf3b69"
|
||||
sha256 = "da3b71608ff7fe6e230b076207cee96ac01f66e068ca1be83af1ad56ac9c60eb"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/te.tsv"
|
||||
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "425c42acb8d98ac31b216b13c05a285e0676318dd379dcfb9d57cc7329e9d7a5"
|
||||
sha256 = "5e84916e3ad8670216360695689fe8fd624365a29886f66a3c655ba126892f5b"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pl.tsv"
|
||||
@@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "ebe7aa9152baff38ae7e36aeb4f6a7ed2198166ab93425a612a66d53e43cb363"
|
||||
sha256 = "084f4ea06f31c632ad90b0e87f66240ce5b5dc012ac7770987d44918ee66f452"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/hu.tsv"
|
||||
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "69d2da21949f02c3c40f71159e377eef2fdd1a87f749d6c504e1d151ef33f643"
|
||||
sha256 = "973cd90ec34c8a973d146a0e2c73c1d632e17357c3d87ccf10c59e64d12c1587"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/bg.tsv"
|
||||
@@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "98359685aa555f391846f2259ac302ce1234c7a180fb4abe07c36e0c1c62211e"
|
||||
sha256 = "485f85d4053c9661ddda2ed9394a0644c77b489feb598d02f12ef209e11f2ee3"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/kk.tsv"
|
||||
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "9f6296d9c79c848167fc80bb4cfaacc9e60f72edb70cc1d70f7b6088ada83578"
|
||||
sha256 = "24524864022ab2abeff68b5979519eee4e92134d0c4053d40c741bc7f01c249c"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ja.tsv"
|
||||
@@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "3528e85afd4117de2c22b9f37d32a380a073a49834fa5a3063d21b803606ffbf"
|
||||
sha256 = "bc6d123bdb34d5d7c22717fed1a86ff20e7e493acc1e155e61e61e2dc74459aa"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/uk.tsv"
|
||||
@@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "4db7e23074d44305f4584e2cd8b9a0fc35b433cb187dad493c8f7d78a8cd4ad2"
|
||||
sha256 = "82678dbe730f56062037ea78de165b2c016b3fa670fb1cbfc7cd713eca900bac"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ar.tsv"
|
||||
@@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "2af01de9509db4b4ae61193f41ff0f7f3b70ca5f91cd2afda667e90d34980b25"
|
||||
sha256 = "304d661b27cdb5246f6b2974b4b5e78667f0928cda3588440be3d8f35f254679"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/tr.tsv"
|
||||
@@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "de988b6959572931c4878a0588109c236bf8c5d46ef57c7a406b6ab42de51339"
|
||||
sha256 = "05e2b7d5401166d362ba055120fba9a1645d6ec324997cf670e19fafcb0c54cf"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/lt.tsv"
|
||||
@@ -228,20 +228,9 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "62151d79be5089d964876b6569d490ee3fdacef4388684b574be9627b1354e92"
|
||||
sha256 = "17c8579e0e9d1524ffbeb87e7b30c14562dbaac64400826ee786cecfe31648fa"
|
||||
|
||||
|
||||
[[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"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "f9002d56e44e49429e0cdeabb10525f79f32831b94233f3c0a8a0ecb202850d5"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/accessibility.svg"
|
||||
title = "Lucide icon \"accessibility\""
|
||||
@@ -3354,6 +3343,17 @@ 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"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "2d1c4b4276d02512a4e20101be95a1671de5c4f5cdfd661887af5ebab0db06a6"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/sl.tsv"
|
||||
title = "Slovene interface translation catalog"
|
||||
@@ -3363,9 +3363,10 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "5881cc7fbfe9da5f5f611309dacf267f95db3a143b916a113b649839019b292c"
|
||||
sha256 = "8f8a259264cda06eb6d9a9bf91dc7d873a8561213a9a9e7acbd32623bef40be0"
|
||||
|
||||
[[asset]]
|
||||
|
||||
path = "vendor/vello_common/assets/probe.png"
|
||||
title = "vello_common probe reference image (rendered from probe.rgba)"
|
||||
author = "Linebender vello contributors"
|
||||
|
||||
@@ -177,6 +177,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-12 (M14, #1395):** XDG desktops use the configuration base for app.ron, safely copying existing preferences without overwriting a destination. Original preferences, logs, recovery and digital IDs are retained; failed migration falls back to the original location. Portable and non-XDG locations remain unchanged; estimates unchanged.
|
||||
|
||||
- **2026-10-12 (M10, Windows print progress, follow-up to #944):** While a job spools, the progress card counts its sheets ("Sending sheet 2 of 3 to Office…") as craft-print reports them (`submit_with_progress`, storytold/craft-libs#27), and steps aside for another long job's card. Pins craft-libs 666c358. Estimates unchanged.
|
||||
- **2026-10-15 (M2, renderer speed):** Three vendored hayro-interpret patches (31–33), pixel for pixel the same over the first three pages of every pdf.js corpus file. A sampled (Type 0) function keeps its samples in one flat array instead of a hash map rebuilt every time a shading using it is painted: `bug1721218` (InDesign) draws in 0.49 s (was 1.34 s), and a 2,097,152-sample ramp painted 30 times in under a second (was 38 s). One decode of a JPEG or Flate image serves every size it is drawn at (only JPX decodes for a size), so zooming a 300-dpi scan in and out costs 35–40% less render work. Coons and tensor patch meshes copy their vertices' colours less: mesh-gradient pages draw 10–16% faster (page 8 of #1584's annual report 773 → 696 ms). Estimates unchanged.
|
||||
- 2026-10-12 · M3: selected page text can be copied from Edit and the canvas context menu (#1225). Five focused regressions, full CI and native Unicode clipboard checks pass; floating Copy shares the document permission gate. Estimates unchanged.
|
||||
|
||||
- **2026-10-13 (M2, page-label prefixes):** Preserve literal leading and trailing whitespace in page-label prefixes without changing generic metadata normalization. Nine original public-inspection cases cover prefix spaces, encodings and empty-prefix controls. Existing empty-prefix fallback remains a compatibility policy, not a conformance claim. Estimates unchanged.
|
||||
@@ -287,6 +288,7 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
- **2026-10-11 (M5, partial comment imports):** Replacing an editable named comment through XFDF or FDF (including an indirect name in the source PDF) preserves its object identity, so existing replies and review statuses remain attached after saving, reopening and exporting again. Replaced pop-ups are still removed. Locked comments, redaction marks and their pop-ups stay unchanged, including when their names are indirect; imported redactions remain visible. Synthetic round-trip tests cover both formats, incremental and full saves; milestone and effort estimates unchanged.
|
||||
|
||||
- **2026-10-15 (M9, #1895):** Preserve original signature artwork and show validation through a separate page overlay. Generic in-place appearance rewriting is deferred: neither a caption substring nor a unique drawing establishes an icon role. Signed regressions cover chamfered borders, mixed signer/caption text, image/text exports, both print modes and saves. Snapshot reuse and trust/cache refresh remain. Baked Not Verified captions are not replaced; #79 remains open. Estimates unchanged.
|
||||
- **2026-10-10 (M9, signature graphics):** A visible digital signature can show a graphic where the signer's name is large, as Acrobat's Configure Signature Appearance does: the saved Fill & Sign signature (drawn strokes smoothed as ink, typed outlines in the script font, or an image) or an imported PNG/JPEG. Sign as previews it and remembers the choice for the next signing; `sign_document` takes `graphic` (`image`, `text` or `strokes`). Graphics are checked and capped before drawing (non-finite or unbounded points, mismatched pixel buffers, oversized images are refused with an error the caller can act on); images keep their alpha as a soft mask. `sign.signature-appearance` stays partial (no named saved appearances or PDF-page graphics). Overall estimates unchanged.
|
||||
- **2026-10-11 (M14, absolute interface sizes):** A chosen Interface size is now absolute, as PhotoCraft's UI Scale is: "100%" is one screen pixel per point whatever scale the display reports, so on a 1080p screen whose system reports 200 % it draws at a true 100 % (#456, reported after the first change). Auto still keeps the display's scale and adds GNOME's text scaling, and its menu entry shows the size it draws at. The menu offers PhotoCraft's sizes (75–300 %).
|
||||
- **2026-10-11 (automation, #971):** `text_find` reads pages in growing batches (8, doubling to 128) and stops once `limit` is reached, so a bounded search on a long document no longer extracts and caches every page first. Results say `complete`, and a capped one gives `next` (`start_page`, `skip`) to continue. Still open from #971: a byte budget for the automation text cache and per-page warnings for pages whose text can't be extracted.
|
||||
@@ -495,6 +497,7 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. 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-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
|
||||
| 2026-10-13 | minor | M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. Estimates unchanged. |
|
||||
|
||||
@@ -16,9 +16,9 @@ It reports two kinds of finding:
|
||||
A properly applied redaction removes the text, so it never flags. Page backgrounds, table
|
||||
shading and highlights don't either: they're painted before the text, or light against it.
|
||||
|
||||
A rectangle covers a text line when it hides at least 40% of the area of the words the line
|
||||
draws, or at least 40% of the line's box together with a positive-area intersection with one
|
||||
of its words. The gap an applied redaction leaves (a `TJ` adjustment where the text was) is in the line's box but
|
||||
A rectangle covers a text line when it hides at least half of one of the line's words, at
|
||||
least 40% of the area of the words the line draws, or at least 40% of the line's box together
|
||||
with a positive-area intersection with one of its words. The gap an applied redaction leaves (a `TJ` adjustment where the text was) is in the line's box but
|
||||
holds no word, so a redaction box over it alone does not count.
|
||||
|
||||
Limits, by design and documented rather than hidden:
|
||||
@@ -29,8 +29,8 @@ Limits, by design and documented rather than hidden:
|
||||
- covers inside form XObjects are found, but only text drawn directly in the page content is
|
||||
checked under them;
|
||||
- annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign;
|
||||
- a cover that hides less than 40% of a line's box and less than 40% of its words (a short
|
||||
value in the middle of a long line) is not flagged.
|
||||
- a cover that hides less than half of every word it touches, less than 40% of a line's box and
|
||||
less than 40% of its words (a sliver of a long value) is not flagged.
|
||||
|
||||
It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper
|
||||
than 32 or in a cycle, or a walk past the operator bound is an error, never "no findings".
|
||||
|
||||
+10
-1
@@ -87,6 +87,9 @@ pub enum AuditError {
|
||||
/// Fraction of a text line's ink (the area of its words), or of its box, that must lie under a
|
||||
/// cover to count as hidden (see [`covers`]).
|
||||
const COVER_FRACTION: f64 = 0.4;
|
||||
/// Fraction of one word's area that must lie under a cover to count as hidden on its own, however
|
||||
/// long the line around it (see [`covers`]).
|
||||
const WORD_FRACTION: f64 = 0.5;
|
||||
/// WCAG contrast ratio below which the text is unreadable against the cover.
|
||||
const COVER_CONTRAST: f64 = 3.0;
|
||||
|
||||
@@ -138,7 +141,10 @@ fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool {
|
||||
rect.pos > line.paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST
|
||||
}
|
||||
|
||||
/// Does `cover` hide a substantial part of what `line` draws? Two ways, either is enough:
|
||||
/// Does `cover` hide a substantial part of what `line` draws? Three ways, any is enough:
|
||||
///
|
||||
/// - Half of one word lies under the cover. A box over one number in a long line hides a few
|
||||
/// percent of the line, and the number all the same (#2662).
|
||||
///
|
||||
/// - 40% of the area of the line's words (its `ink`) lies under the cover. Measured on the words,
|
||||
/// not the box: an applied redaction leaves a gap that draws nothing (a `TJ` adjustment where
|
||||
@@ -155,6 +161,9 @@ fn covers(cover: &[f64; 4], line: &TextLine) -> bool {
|
||||
let total: f64 = boxes.iter().map(area).sum();
|
||||
if total > 0.0 { boxes.iter().map(hidden).sum::<f64>() / total } else { 0.0 }
|
||||
};
|
||||
if line.ink.iter().any(|w| area(w) > 0.0 && hidden(w) / area(w) >= WORD_FRACTION) {
|
||||
return true;
|
||||
}
|
||||
let inked = fraction(&line.ink) >= COVER_FRACTION;
|
||||
let boxed = fraction(std::slice::from_ref(&line.rect)) >= COVER_FRACTION;
|
||||
let word_intersects = || line.ink.iter().any(|w| hidden(w) > 0.0);
|
||||
|
||||
@@ -175,6 +175,47 @@ fn unapplied_redact_mark_is_found() {
|
||||
assert!(findings[0].covered_text.contains("DO NOT SHARE"));
|
||||
}
|
||||
|
||||
/// #2662: a box over one number of a long line hides a few percent of the line, and all of the
|
||||
/// number. Found as a cover, and as an unapplied mark over the same word.
|
||||
#[test]
|
||||
fn a_cover_over_one_word_of_a_long_line_is_found() {
|
||||
// Helvetica 11: "The contract value is " ends near x 132, "98765" spans about 132-163.
|
||||
let line = b"BT /F1 11 Tf 30 100 Td (The contract value is 98765 and the rest of this long line continues here.) Tj ET
|
||||
";
|
||||
let mut content = line.to_vec();
|
||||
content.extend_from_slice(
|
||||
b"0 g
|
||||
131 96 33 14 re f
|
||||
",
|
||||
);
|
||||
let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].kind, FindingKind::CoveredText);
|
||||
assert!(findings[0].covered_text.contains("98765"), "{findings:?}");
|
||||
|
||||
let mut b = Builder::new();
|
||||
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
||||
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>");
|
||||
b.add_stream(line);
|
||||
b.add(FONT);
|
||||
b.add("<< /Type /Annot /Subtype /Redact /Rect [131 96 164 110] >>");
|
||||
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].kind, FindingKind::UnappliedMark);
|
||||
}
|
||||
|
||||
/// A box that only grazes the words beside it (less than half of each) is not a cover.
|
||||
#[test]
|
||||
fn a_box_grazing_a_word_is_not_flagged() {
|
||||
let content = b"BT /F1 11 Tf 30 100 Td (The contract value is 98765 and the rest of this long line continues here.) Tj ET
|
||||
0 g
|
||||
131 96 6 14 re f
|
||||
";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_before_text_is_not_flagged() {
|
||||
// An opaque dark rectangle painted *before* the text is a background, not a cover.
|
||||
@@ -303,15 +344,17 @@ fn partial_value_is_found(rotated: bool) {
|
||||
/// Keep the historic detections with several covers, lines and text-spacing states.
|
||||
#[test]
|
||||
fn faux_redactions_with_multiple_covers_lines_and_spacing_are_found() {
|
||||
let cases: [&[u8]; 4] = [
|
||||
b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj ET\n0 g\n72 738 65 16 re f\n140 738 40 16 re f\n",
|
||||
b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj 0 -15 Td (Account: 987654321) Tj ET\n0 g\n115 723 100 30 re f\n",
|
||||
b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n",
|
||||
b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n",
|
||||
// The second box of the first case hides most of the number (less than 40% of the line): a
|
||||
// finding of its own since covers are also measured per word (#2662).
|
||||
let cases: [(&[u8], usize); 4] = [
|
||||
(b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj ET\n0 g\n72 738 65 16 re f\n140 738 40 16 re f\n", 2),
|
||||
(b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj 0 -15 Td (Account: 987654321) Tj ET\n0 g\n115 723 100 30 re f\n", 1),
|
||||
(b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n", 1),
|
||||
(b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n", 1),
|
||||
];
|
||||
for content in cases {
|
||||
for (content, expected) in cases {
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{}: {findings:?}", String::from_utf8_lossy(content));
|
||||
assert_eq!(findings.len(), expected, "{}: {findings:?}", String::from_utf8_lossy(content));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,17 @@ fn result(auto: &mut Automation, args: Value) -> Value {
|
||||
|
||||
// Fresh fixtures include the current timestamp; retain their bytes for output comparisons.
|
||||
fn pdf() -> Vec<u8> {
|
||||
Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone()
|
||||
// Reuse one synthetic document so byte comparisons cannot straddle the
|
||||
// writer's modification-time clock. Each caller gets an independent buffer.
|
||||
static PDF: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
|
||||
PDF.get_or_init(|| Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone()).clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_fixture_remains_identical_across_metadata_clock_tick() {
|
||||
let before = pdf();
|
||||
std::thread::sleep(std::time::Duration::from_millis(1100));
|
||||
assert_eq!(before, pdf());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -27,7 +27,10 @@ pub struct ExportSource {
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The current state, for exporting on another thread.
|
||||
/// What an export reads, for exporting on another thread. These are the displayed
|
||||
/// bytes, so they may carry missing comment appearances PdfCraft drew. Signature
|
||||
/// artwork stays original; viewer validation badges are not document content and
|
||||
/// do not enter image/text exports or Print as Image. Saving uses the working file.
|
||||
pub fn export_source(&self) -> ExportSource {
|
||||
ExportSource {
|
||||
bytes: self.display.clone(),
|
||||
|
||||
+40
-28
@@ -872,23 +872,29 @@ fn is_placeholder_title(title: &str) -> bool {
|
||||
address || t == "untitled"
|
||||
}
|
||||
|
||||
/// What is displayed: the working file, plus (in memory only, never saved) the appearances
|
||||
/// PdfCraft draws for comments that have none ([`pdfcraft_annot::with_missing_appearances`]).
|
||||
/// The working file is unchanged, and so is everything that reads it (saving, signatures, …).
|
||||
/// The working file plus appearances for comments that have none. Signature artwork
|
||||
/// is preserved; validation badges belong to the viewer and never enter these bytes,
|
||||
/// which image/text exports and Print as Image also consume.
|
||||
fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc<Vec<u8>>) -> Arc<Vec<u8>> {
|
||||
let Some(e) = editor else { return bytes.clone() };
|
||||
let Some(display) = pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances) else {
|
||||
return bytes.clone();
|
||||
};
|
||||
match write_incremental(&display, &SaveOptions::default()) {
|
||||
Ok(written) => Arc::new(written),
|
||||
Err(err) => {
|
||||
e.cos.note_repair(format!("missing comment appearances could not be drawn ({err})"));
|
||||
bytes.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The reader shows the same pages in `a` and `b`: count, displayed size and rotation.
|
||||
fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo]) -> bool {
|
||||
a.len() == b.len()
|
||||
&& a.iter().zip(b).all(|(x, y)| (x.width - y.width).abs() < 0.01 && (x.height - y.height).abs() < 0.01 && x.rotation == y.rotation)
|
||||
}
|
||||
|
||||
fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc<Vec<u8>>) -> Arc<Vec<u8>> {
|
||||
let Some(display) = editor.and_then(|e| pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances)) else {
|
||||
return bytes.clone();
|
||||
};
|
||||
// Writing fails only as saving the document would; the page then shows as it always did.
|
||||
write_incremental(&display, &SaveOptions::default()).map(Arc::new).unwrap_or_else(|_| bytes.clone())
|
||||
}
|
||||
|
||||
/// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for
|
||||
/// comments that have none. Render paths outside the app, such as the CLI, use this so their page
|
||||
/// images agree with the app and independent viewers. It never changes the source document, and an
|
||||
@@ -3206,8 +3212,8 @@ fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result<Vec<Stri
|
||||
}
|
||||
|
||||
/// Validate the signature fields of `cos` (written as `bytes`).
|
||||
fn signatures_of(cos: &pdfcraft_cos::Document, bytes: &[u8], trust: &TrustStore, cache: &pdfcraft_sign::DigestCache) -> Arc<Vec<SignatureInfo>> {
|
||||
Arc::new(pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache))
|
||||
fn signatures_of(cos: &pdfcraft_cos::Document, bytes: &[u8], trust: &TrustStore, cache: &pdfcraft_sign::DigestCache) -> Vec<SignatureInfo> {
|
||||
pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache)
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -3532,6 +3538,8 @@ impl Session {
|
||||
Ok(Err(e)) => (None, Some(e.to_string())),
|
||||
Err(_) => (None, Some("the document structure could not be read for editing".into())),
|
||||
};
|
||||
let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default());
|
||||
let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default();
|
||||
let display = display_bytes(editor.as_mut(), &bytes);
|
||||
let renderer = RenderPool::new(display.clone(), render_threads(), config.clone());
|
||||
let (form, adopted) = editor
|
||||
@@ -3553,8 +3561,6 @@ impl Session {
|
||||
};
|
||||
e.cos.note_repair(line);
|
||||
}
|
||||
let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default());
|
||||
let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default();
|
||||
Document {
|
||||
id: DocId(0),
|
||||
name,
|
||||
@@ -3571,7 +3577,7 @@ impl Session {
|
||||
form: Arc::new(form),
|
||||
marker_scan: OnceLock::new(),
|
||||
links: OnceLock::new(),
|
||||
signatures,
|
||||
signatures: Arc::new(signatures),
|
||||
trust: trust.clone(),
|
||||
sig_cache,
|
||||
editor,
|
||||
@@ -3869,7 +3875,8 @@ impl Session {
|
||||
doc.info.producer = info("Producer");
|
||||
doc.info.file_size = bytes.len();
|
||||
if !doc.signatures.is_empty() {
|
||||
doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache);
|
||||
let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache);
|
||||
doc.signatures = Arc::new(signatures);
|
||||
}
|
||||
doc.bytes = bytes;
|
||||
return Ok(());
|
||||
@@ -3912,10 +3919,9 @@ impl Session {
|
||||
}
|
||||
doc.info.file_size = bytes.len();
|
||||
doc.form = form;
|
||||
if !doc.signatures.is_empty() {
|
||||
doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache);
|
||||
}
|
||||
let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) };
|
||||
doc.display = display_bytes(Some(editor), &bytes);
|
||||
doc.signatures = Arc::new(signatures);
|
||||
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
|
||||
doc.bytes = bytes;
|
||||
Ok(())
|
||||
@@ -3958,10 +3964,9 @@ impl Session {
|
||||
doc.marker_scan = OnceLock::new();
|
||||
}
|
||||
doc.info.file_size = bytes.len();
|
||||
if !doc.signatures.is_empty() {
|
||||
doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache);
|
||||
}
|
||||
let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) };
|
||||
doc.display = display_bytes(Some(editor), &bytes);
|
||||
doc.signatures = Arc::new(signatures);
|
||||
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
|
||||
doc.bytes = bytes;
|
||||
doc.fonts_stale = true;
|
||||
@@ -4034,8 +4039,9 @@ impl Session {
|
||||
if invalidate_links {
|
||||
doc.links = OnceLock::new();
|
||||
}
|
||||
doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache);
|
||||
let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache);
|
||||
doc.display = display_bytes(Some(editor), &bytes);
|
||||
doc.signatures = Arc::new(signatures);
|
||||
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
|
||||
doc.bytes = bytes;
|
||||
Ok(())
|
||||
@@ -4767,14 +4773,20 @@ impl Session {
|
||||
&self.trust.certs
|
||||
}
|
||||
|
||||
/// Replace the trusted certificates and revalidate every open document's signatures.
|
||||
/// Replace the trusted certificates, revalidate every open document's signatures,
|
||||
/// and rebuild its display copy and renderer to match (working file untouched,
|
||||
/// generation/dirty unchanged: trust alone is not an edit).
|
||||
pub fn set_trusted_certificates(&mut self, certs: Vec<pdfcraft_sign::Certificate>) {
|
||||
self.trust = Arc::new(TrustStore { certs });
|
||||
for doc in &mut self.docs {
|
||||
doc.trust = self.trust.clone();
|
||||
if let Some(e) = doc.editor.as_ref() {
|
||||
doc.signatures = signatures_of(&e.cos, &doc.bytes, &doc.trust, &doc.sig_cache);
|
||||
}
|
||||
let Some(editor) = doc.editor.as_mut() else { continue };
|
||||
// Keep verdict refresh and renderer refresh together. Viewer caches are
|
||||
// invalidated by the UI trust path; trust alone changes no document artwork.
|
||||
let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache) };
|
||||
doc.display = display_bytes(Some(editor), &doc.bytes);
|
||||
doc.signatures = Arc::new(signatures);
|
||||
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2763,6 +2763,400 @@ fn signing_saving_trusting_and_commenting_afterwards() {
|
||||
assert!(!s.get(id).unwrap().dirty && s.get(id).unwrap().can_undo().map(str::to_owned) == before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validated_signatures_without_layers_keep_their_appearance() {
|
||||
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
|
||||
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
|
||||
// After the test certificate's start date (2026-10-02).
|
||||
let mut s = Session::new().with_clock(|| 1_800_000_000);
|
||||
let id = s.open("fixture.pdf", None, Arc::new(fixture(1)), None).unwrap();
|
||||
let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() };
|
||||
let signed = s.sign(id, &digital_id, opts).unwrap();
|
||||
s.mark_signed(id, signed, None).unwrap();
|
||||
s.set_trusted_certificates(vec![digital_id.certificate.clone()]);
|
||||
let doc = s.get(id).unwrap();
|
||||
let sig = &doc.signatures[0];
|
||||
assert_eq!(sig.status, SignatureStatus::Valid);
|
||||
// Our own flat appearance has no validation layers: nothing is substituted, and
|
||||
// the display copy carries no generated caption.
|
||||
let display = doc.display.clone();
|
||||
assert!(!display.windows(b"Signature valid".len()).any(|w| w == b"Signature valid"));
|
||||
// With nothing to draw, the display copy aliases the file exactly as before.
|
||||
assert!(Arc::ptr_eq(&doc.display, &doc.bytes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_pages_render_trust_save_and_reopen() {
|
||||
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
|
||||
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
|
||||
// After the test certificate's start date (2026-10-02).
|
||||
let mut s = Session::new().with_clock(|| 1_800_000_000);
|
||||
let id = s.open("fixture.pdf", None, Arc::new(fixture(1)), None).unwrap();
|
||||
let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() };
|
||||
let signed = s.sign(id, &digital_id, opts).unwrap();
|
||||
// The signed bytes open elsewhere too: validity is in the file, not the session.
|
||||
let mut t = Session::new().with_clock(|| 1_800_000_000);
|
||||
let tid = t.open("signed.pdf", None, signed.clone(), None).unwrap();
|
||||
assert!(t.get(tid).unwrap().signatures.iter().any(|x| x.signed && x.status == SignatureStatus::Unknown));
|
||||
s.mark_signed(id, signed, None).unwrap();
|
||||
// The signed page renders, from the file and from the display copy alike.
|
||||
for bytes in [s.get(id).unwrap().bytes.clone(), s.get(id).unwrap().display.clone()] {
|
||||
let mut renderer = pdfcraft_render::PageRenderer::new(bytes, s.get(id).unwrap().config.clone());
|
||||
let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
assert!(rendered.error.is_none(), "{:?}", rendered.error);
|
||||
assert!(rendered.width > 0 && rendered.height > 0);
|
||||
}
|
||||
// Trusting the signer makes it valid; saving keeps it valid across sessions.
|
||||
s.set_trusted_certificates(vec![digital_id.certificate.clone()]);
|
||||
assert_eq!(s.get(id).unwrap().signatures[0].status, SignatureStatus::Valid);
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
let mut u = Session::new().with_clock(|| 1_800_000_000);
|
||||
u.set_trusted_certificates(vec![digital_id.certificate.clone()]);
|
||||
let uid = u.open("saved.pdf", None, saved.clone(), None).unwrap();
|
||||
assert_eq!(u.get(uid).unwrap().signatures[0].status, SignatureStatus::Valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_documents_round_trip_behind_a_user_password() {
|
||||
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
|
||||
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
|
||||
// After the test certificate's start date (2026-10-02).
|
||||
let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap();
|
||||
doc.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm: pdfcraft_cos::Algorithm::Aes128,
|
||||
user_password: "user",
|
||||
owner_password: "owner",
|
||||
permissions: -1,
|
||||
encrypt_metadata: true,
|
||||
seed: [7; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let encrypted = Arc::new(pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap());
|
||||
// Wrong passwords refuse; the right one signs, validates, saves and reopens.
|
||||
let mut e = Session::new().with_clock(|| 1_800_000_000);
|
||||
assert!(e.open("enc.pdf", None, encrypted.clone(), Some("wrong")).is_err(), "wrong passwords refuse");
|
||||
let eid = e.open("enc.pdf", None, encrypted, Some("user")).unwrap();
|
||||
let signed = e.sign(eid, &digital_id, SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() }).unwrap();
|
||||
e.mark_signed(eid, signed, None).unwrap();
|
||||
e.set_trusted_certificates(vec![digital_id.certificate.clone()]);
|
||||
assert_eq!(e.get(eid).unwrap().signatures[0].status, SignatureStatus::Valid);
|
||||
let saved = e.save_bytes(eid).unwrap();
|
||||
let mut f = Session::new().with_clock(|| 1_800_000_000);
|
||||
f.set_trusted_certificates(vec![digital_id.certificate.clone()]);
|
||||
assert!(f.open("enc-saved.pdf", None, saved.clone(), Some("wrong")).is_err());
|
||||
let fid = f.open("enc-saved.pdf", None, saved.clone(), Some("user")).unwrap();
|
||||
assert_eq!(f.get(fid).unwrap().signatures[0].status, SignatureStatus::Valid);
|
||||
}
|
||||
|
||||
/// ByteRange placeholder values: fixed width, patched after writing.
|
||||
const LAYERED_BR_MARK: [i64; 3] = [1_111_111_111, 2_222_222_222, 3_333_333_333];
|
||||
|
||||
/// A one-page file whose signature field carries a layered appearance on the
|
||||
/// page (background, words, vector glyph, border, unverified caption): object
|
||||
/// numbers 1 catalog, 2 pages, 3 page, 4 field, 5 AcroForm, 6 /N, 7 frame,
|
||||
/// 8 background, 9 words, 10 glyph, 11 caption, 12 border, 13 Helvetica,
|
||||
/// 14 page content. The field is unsigned: `sign_prepared` adds the value.
|
||||
fn layered_signable_fixture() -> Vec<u8> {
|
||||
fn stream(bbox: &str, resources: &str, ops: &str) -> String {
|
||||
format!("<< /Type /XObject /Subtype /Form /BBox {bbox} /Length {} /Resources {resources} >>\nstream\n{ops}\nendstream", ops.len())
|
||||
}
|
||||
let helv = "<< /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>";
|
||||
let objs = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 5 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 14 0 R /Resources << /Font << /F1 13 0 R >> >> /Annots [4 0 R] >>"
|
||||
.to_string(),
|
||||
"<< /FT /Sig /T (Signature1) /Subtype /Widget /Rect [10 10 110 60] /P 3 0 R /AP << /N 6 0 R >> >>".to_string(),
|
||||
"<< /Fields [4 0 R] /SigFlags 3 >>".to_string(),
|
||||
stream("[0 0 50 30]", "<< /XObject << /frame 7 0 R >> >>", "q /frame Do Q"),
|
||||
stream(
|
||||
"[0 0 50 30]",
|
||||
"<< /XObject << /bg 8 0 R /words 9 0 R /glyph 10 0 R /bd 12 0 R /cap 11 0 R >> >>",
|
||||
"q /bg Do /words Do /glyph Do /bd Do /cap Do Q",
|
||||
),
|
||||
stream("[0 0 100 100]", "<< >>", "0 g 0 0 100 100 re f"),
|
||||
stream("[0 0 100 100]", helv, "BT /F1 10 Tf 5 50 Td (Signed by Test) Tj ET"),
|
||||
stream("[0 0 50 30]", "<< >>", "0.8 0.1 0.1 RG 3 w 5 5 m 20 5 l 12 22 l h S"),
|
||||
stream("[0 21 50 30]", helv, "BT /F1 9 Tf 2 22 Td (Signature Not Verified) Tj ET"),
|
||||
stream("[0 0 100 100]", "<< >>", "0 g 0 0 100 100 re f"),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
|
||||
"<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 150 Td (Contract text) Tj ET\nendstream".to_string(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (k, o) in objs.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", k + 1).as_bytes());
|
||||
}
|
||||
let start = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n", objs.len() + 1).as_bytes());
|
||||
out.extend_from_slice(b"0000000000 65535 f \n");
|
||||
for o in &offs {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `haystack` holds `needle`.
|
||||
fn bytes_has(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
!needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle)
|
||||
}
|
||||
|
||||
/// Sign `doc`, whose field already carries its layered appearance, without
|
||||
/// touching that appearance: reserve `/ByteRange` and `/Contents`, hash the
|
||||
/// ranges and embed a detached CMS over them. The signed bytes keep the
|
||||
/// prepared appearance, so they validate exactly like a product signer's file.
|
||||
fn sign_prepared(doc: &mut pdfcraft_cos::Document, id: &sign::pkcs12::DigitalId, date: &str) -> Vec<u8> {
|
||||
use pdfcraft_cos::{Dict, Object, PdfString};
|
||||
let reserve = 8192 + id.certificate.raw.len() + id.chain.iter().map(|c| c.raw.len()).sum::<usize>();
|
||||
let mut v = Dict::new();
|
||||
v.set(b"Type".to_vec(), Object::name("Sig"));
|
||||
v.set(b"Filter".to_vec(), Object::name("Adobe.PPKLite"));
|
||||
v.set(b"SubFilter".to_vec(), Object::name("ETSI.CAdES.detached"));
|
||||
v.set(b"ByteRange".to_vec(), Object::Array([0].iter().chain(LAYERED_BR_MARK.iter()).map(|n| Object::Int(*n)).collect()));
|
||||
v.set(b"Contents".to_vec(), Object::String(PdfString { bytes: vec![0; reserve], hex: true }));
|
||||
v.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec()));
|
||||
let sig = doc.add(Object::Dict(v));
|
||||
// The field by name, never by number: a full save (encryption, repairs) is
|
||||
// free to renumber objects, and a hardcoded reference would stamp the value
|
||||
// onto whatever moves into that slot.
|
||||
let root = doc.root().expect("catalog");
|
||||
let af = doc.get(root).as_dict().expect("catalog dict").get(b"AcroForm").cloned().expect("AcroForm");
|
||||
let fields = doc.resolve(&af).as_dict().expect("AcroForm dict").get(b"Fields").cloned().expect("Fields");
|
||||
let field = doc.resolve(&fields).as_array().expect("Fields array").iter().filter_map(|o| o.as_ref()).next().expect("signature field");
|
||||
doc.update_dict(field, |d| d.set(b"V".to_vec(), Object::Ref(sig))).unwrap();
|
||||
let save = pdfcraft_cos::SaveOptions { object_streams: false, ..pdfcraft_cos::SaveOptions::default() };
|
||||
let mut out = pdfcraft_cos::write_incremental(doc, &save).unwrap();
|
||||
let mark = format!("0 {} {} {}", LAYERED_BR_MARK[0], LAYERED_BR_MARK[1], LAYERED_BR_MARK[2]);
|
||||
let br = out.windows(mark.len()).position(|w| w == mark.as_bytes()).expect("ByteRange placeholder");
|
||||
let start = out[..br].windows(3).rposition(|w| w == b"obj").expect("signature object");
|
||||
let end = br + out[br..].windows(6).position(|w| w == b"endobj").expect("signature end");
|
||||
let mut zeros = vec![b'<'];
|
||||
zeros.extend(std::iter::repeat_n(b'0', reserve * 2));
|
||||
zeros.push(b'>');
|
||||
let region = &out[start..end];
|
||||
let c = region.windows(zeros.len()).position(|w| w == zeros.as_slice()).expect("Contents placeholder");
|
||||
let (cstart, cend) = (start + c, start + c + zeros.len());
|
||||
let mut br_text = format!("0 {cstart} {cend} {}", out.len() - cend).into_bytes();
|
||||
assert!(br_text.len() <= mark.len(), "ByteRange patch fits its placeholder");
|
||||
br_text.resize(mark.len(), b' ');
|
||||
out[br..br + mark.len()].copy_from_slice(&br_text);
|
||||
let alg = id.key.preferred_digest();
|
||||
let digest = alg.digest(&[&out[..cstart], &out[cend..]]);
|
||||
let cms = sign::cms::sign_detached(&id.key, &id.certificate, &id.chain, alg, &digest).unwrap();
|
||||
assert!(cms.len() <= reserve, "signature fits its placeholder");
|
||||
let hex: Vec<u8> = cms.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect();
|
||||
out[cstart + 1..cstart + 1 + hex.len()].copy_from_slice(&hex);
|
||||
out
|
||||
}
|
||||
|
||||
/// Replace one synthetic appearance stream while retaining its resource dictionary.
|
||||
fn signature_layer_ops(doc: &mut pdfcraft_cos::Document, number: u32, ops: &[u8]) {
|
||||
let r = pdfcraft_cos::ObjRef::new(number, 0);
|
||||
let dict = doc.resolve(&pdfcraft_cos::Object::Ref(r)).as_dict().unwrap().clone();
|
||||
doc.set(r, pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::from_raw(dict, ops.to_vec())));
|
||||
}
|
||||
|
||||
fn printed_pixels(bytes: Vec<u8>) -> (u32, u32, Vec<u8>) {
|
||||
let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), Default::default());
|
||||
let r = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
assert!(r.error.is_none(), "{:?}", r.error);
|
||||
(r.width, r.height, r.rgba.to_vec())
|
||||
}
|
||||
|
||||
/// Validation belongs to viewer chrome, not to document artwork or export sources.
|
||||
fn assert_signature_artwork_survives_trust(mut cos: pdfcraft_cos::Document, expected_text: &[&str]) {
|
||||
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
|
||||
let identity = sign::pkcs12::open(&p12, "test").unwrap();
|
||||
let anchor = identity.chain.first().cloned().unwrap_or_else(|| identity.certificate.clone());
|
||||
let signed = Arc::new(sign_prepared(&mut cos, &identity, "D:20261002120000+01'00'"));
|
||||
let mut session = Session::new().with_clock(|| 1_800_000_000);
|
||||
let id = session.open("artwork.pdf", None, signed.clone(), None).unwrap();
|
||||
let original_display = session.get(id).unwrap().display.clone();
|
||||
let mut exporter = export::Exporter::new(session.get(id).unwrap());
|
||||
let original_text = exporter.text_of(&[0]).unwrap();
|
||||
let original_png = exporter.png(0, 72.0).unwrap();
|
||||
for text in expected_text {
|
||||
assert!(original_text.contains(text), "fixture text {text:?} must be exported: {original_text:?}");
|
||||
}
|
||||
let mut settings = print::Settings { pages: vec![0], paper: (200.0, 200.0), ..Default::default() };
|
||||
let original_vector_print = printed_pixels(session.print_pdf(id, &settings).unwrap());
|
||||
settings.raster = Some(print::Raster { dpi: 72.0, grayscale: false });
|
||||
let original_raster_print = printed_pixels(session.print_pdf(id, &settings).unwrap());
|
||||
session.set_trusted_certificates(vec![anchor.clone()]);
|
||||
let doc = session.get(id).unwrap();
|
||||
assert_eq!(doc.signatures[0].status, SignatureStatus::Valid);
|
||||
assert!(doc.display == original_display, "trust must preserve all original appearance bytes");
|
||||
let mut exporter = export::Exporter::new(doc);
|
||||
assert_eq!(exporter.text_of(&[0]).unwrap(), original_text, "text exports retain signer details and baked caption");
|
||||
assert_eq!(exporter.png(0, 72.0).unwrap(), original_png, "image exports exclude the viewer overlay");
|
||||
assert_eq!(printed_pixels(session.print_pdf(id, &settings).unwrap()), original_raster_print, "Print as Image retains document artwork");
|
||||
settings.raster = None;
|
||||
assert_eq!(printed_pixels(session.print_pdf(id, &settings).unwrap()), original_vector_print);
|
||||
assert_eq!(session.save_bytes(id).unwrap(), signed, "unchanged saves retain the signed file exactly");
|
||||
session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Preserved artwork".into() }).unwrap();
|
||||
let saved = session.save_bytes(id).unwrap();
|
||||
assert!(saved.starts_with(&signed), "a real save appends without replacing signed appearance streams");
|
||||
let mut reopened = Session::new().with_clock(|| 1_800_000_000);
|
||||
reopened.set_trusted_certificates(vec![anchor]);
|
||||
let id = reopened.open("saved.pdf", None, saved, None).unwrap();
|
||||
let doc = reopened.get(id).unwrap();
|
||||
assert_eq!(doc.signatures[0].status, SignatureStatus::Valid);
|
||||
let mut exporter = export::Exporter::new(doc);
|
||||
assert_eq!(exporter.text_of(&[0]).unwrap(), original_text);
|
||||
assert_eq!(exporter.png(0, 72.0).unwrap(), original_png);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chamfered_signature_borders_survive_trust_exports_printing_and_save() {
|
||||
for raster_glyph in [false, true] {
|
||||
let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap();
|
||||
signature_layer_ops(&mut doc, 12, b"0 G 1 w 10 1 m 90 1 l 99 10 l 99 90 l 90 99 l 10 99 l 1 90 l 1 10 l h S");
|
||||
signature_layer_ops(&mut doc, 10, b"0 G 2 w 0 0 50 30 re S");
|
||||
if raster_glyph {
|
||||
let mut image = pdfcraft_cos::Dict::new();
|
||||
image.set(b"Subtype", pdfcraft_cos::Object::name("Image"));
|
||||
image.set(b"Width", pdfcraft_cos::Object::Int(8));
|
||||
image.set(b"Height", pdfcraft_cos::Object::Int(8));
|
||||
image.set(b"ColorSpace", pdfcraft_cos::Object::name("DeviceRGB"));
|
||||
image.set(b"BitsPerComponent", pdfcraft_cos::Object::Int(8));
|
||||
let image = doc.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::from_raw(image, vec![0; 8 * 8 * 3])));
|
||||
let mut xobjects = pdfcraft_cos::Dict::new();
|
||||
xobjects.set(b"im", pdfcraft_cos::Object::Ref(image));
|
||||
xobjects.set(b"unused", pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(8, 0)));
|
||||
let mut resources = pdfcraft_cos::Dict::new();
|
||||
resources.set(b"XObject", pdfcraft_cos::Object::Dict(xobjects));
|
||||
doc.update_dict(pdfcraft_cos::ObjRef::new(10, 0), |d| d.set(b"Resources", pdfcraft_cos::Object::Dict(resources))).unwrap();
|
||||
signature_layer_ops(&mut doc, 10, b"q 20 0 0 20 5 5 cm /im Do Q");
|
||||
}
|
||||
assert_signature_artwork_survives_trust(doc, &["Not Verified"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_signature_caption_details_survive_trust_exports_printing_and_save() {
|
||||
let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap();
|
||||
signature_layer_ops(&mut doc, 11, b"BT /F1 5 Tf 2 27 Td (Alice Signer) Tj 0 -3 Td (alice@example.org) Tj 0 -3 Td (Signature Not Verified) Tj ET");
|
||||
assert_signature_artwork_survives_trust(doc, &["Alice Signer", "alice@example.org", "Not Verified"]);
|
||||
}
|
||||
|
||||
/// A real layered signature through `Session`: validity, original artwork,
|
||||
/// rendering, saving and reopening. `password` opens the signed bytes.
|
||||
fn check_layered_round_trip(signed: Arc<Vec<u8>>, password: Option<&str>, anchor: &sign::x509::Certificate) {
|
||||
let mut s = Session::new().with_clock(|| 1_800_000_000);
|
||||
let vid = s.open("layered.pdf", None, signed.clone(), password).unwrap();
|
||||
let doc = s.get(vid).unwrap();
|
||||
let sig = &doc.signatures[0];
|
||||
assert!(sig.signed && sig.field == "Signature1" && sig.page == Some(0) && sig.visible);
|
||||
assert_eq!(sig.status, SignatureStatus::Unknown, "untrusted at first");
|
||||
// Trust changes the verdict while preserving every appearance byte. The page
|
||||
// overlay is UI chrome and is not part of the document's display/export source.
|
||||
s.set_trusted_certificates(vec![anchor.clone()]);
|
||||
let doc = s.get(vid).unwrap();
|
||||
assert_eq!(doc.signatures[0].status, SignatureStatus::Valid);
|
||||
assert!(Arc::ptr_eq(&doc.display, &doc.bytes), "trust does not build a signature display copy");
|
||||
assert!(bytes_has(&doc.display, b"Not Verified"));
|
||||
assert!(!bytes_has(&doc.display, b"Signature valid"));
|
||||
let mut renderer = pdfcraft_render::PageRenderer::new(doc.display.clone(), doc.config.clone());
|
||||
let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
assert!(rendered.error.is_none(), "{:?}", rendered.error);
|
||||
// Saving keeps the original appearance and the signature: the validated
|
||||
// state never reaches the file.
|
||||
let saved = s.save_bytes(vid).unwrap();
|
||||
assert!(bytes_has(&saved, b"Not Verified"), "saved bytes keep the baked appearance");
|
||||
assert!(!bytes_has(&saved, b"Signature valid"));
|
||||
let mut t = Session::new().with_clock(|| 1_800_000_000);
|
||||
t.set_trusted_certificates(vec![anchor.clone()]);
|
||||
let tid = t.open("layered-saved.pdf", None, saved.clone(), password).unwrap();
|
||||
let rsig = &t.get(tid).unwrap().signatures[0];
|
||||
assert_eq!(rsig.status, SignatureStatus::Valid);
|
||||
}
|
||||
|
||||
/// A trusted layered signature whose original appearance remains document artwork.
|
||||
fn trusted_layered_session() -> (Session, DocId) {
|
||||
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
|
||||
let id = sign::pkcs12::open(&p12, "test").unwrap();
|
||||
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
|
||||
let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap();
|
||||
let signed = Arc::new(sign_prepared(&mut doc, &id, "D:20261002120000+01'00'"));
|
||||
let mut s = Session::new().with_clock(|| 1_800_000_000);
|
||||
let vid = s.open("layered.pdf", None, signed, None).unwrap();
|
||||
s.set_trusted_certificates(vec![anchor]);
|
||||
assert_eq!(s.get(vid).unwrap().signatures[0].status, SignatureStatus::Valid);
|
||||
(s, vid)
|
||||
}
|
||||
|
||||
/// Metadata edits retain the original caption; the UI can still show its verdict badge.
|
||||
#[test]
|
||||
fn a_metadata_edit_keeps_the_original_signature_appearance() {
|
||||
let (mut s, vid) = trusted_layered_session();
|
||||
s.apply(vid, Edit::SetInfo { key: "Title".into(), value: "Contract".into() }).unwrap();
|
||||
let doc = s.get(vid).unwrap();
|
||||
assert_eq!(doc.signatures[0].status, SignatureStatus::Valid);
|
||||
assert!(bytes_has(&doc.display, b"Not Verified"));
|
||||
assert!(!bytes_has(&doc.display, b"Signature valid"));
|
||||
}
|
||||
|
||||
/// An unchanged document never reaches the writer, so the edit is what makes this the
|
||||
/// place the display copy would leak if saving ever read it.
|
||||
#[test]
|
||||
fn saving_after_an_edit_keeps_the_baked_appearance() {
|
||||
let (mut s, vid) = trusted_layered_session();
|
||||
s.apply(vid, Edit::SetInfo { key: "Title".into(), value: "Contract".into() }).unwrap();
|
||||
let saved = s.save_bytes(vid).unwrap();
|
||||
assert!(bytes_has(&saved, b"Not Verified"), "the writer keeps the baked appearance: {saved:?}");
|
||||
assert!(!bytes_has(&saved, b"Signature valid"), "the validated state never reaches the file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_layered_signatures_keep_their_original_appearance() {
|
||||
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
|
||||
let id = sign::pkcs12::open(&p12, "test").unwrap();
|
||||
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
|
||||
let date = "D:20261002120000+01'00'";
|
||||
let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap();
|
||||
let signed = Arc::new(sign_prepared(&mut doc, &id, date));
|
||||
check_layered_round_trip(signed, None, &anchor);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_layered_signatures_round_trip_behind_a_user_password() {
|
||||
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
|
||||
let id = sign::pkcs12::open(&p12, "test").unwrap();
|
||||
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
|
||||
let date = "D:20261002120000+01'00'";
|
||||
let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap();
|
||||
doc.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm: pdfcraft_cos::Algorithm::Aes128,
|
||||
user_password: "user",
|
||||
owner_password: "owner",
|
||||
permissions: -1,
|
||||
encrypt_metadata: true,
|
||||
seed: [7; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let encrypted = Arc::new(pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap());
|
||||
let mut doc = pdfcraft_cos::Document::open_with_password(encrypted, Some("user")).unwrap();
|
||||
let signed = Arc::new(sign_prepared(&mut doc, &id, date));
|
||||
let mut probe = Session::new().with_clock(|| 1_800_000_000);
|
||||
assert!(probe.open("enc.pdf", None, signed.clone(), Some("wrong")).is_err(), "wrong passwords refuse");
|
||||
// Encrypted appearance bytes remain unchanged through trust and save/reopen.
|
||||
let mut s = Session::new().with_clock(|| 1_800_000_000);
|
||||
let vid = s.open("enc.pdf", None, signed.clone(), Some("user")).unwrap();
|
||||
assert_eq!(s.get(vid).unwrap().signatures[0].status, SignatureStatus::Unknown);
|
||||
s.set_trusted_certificates(vec![anchor.clone()]);
|
||||
let doc = s.get(vid).unwrap();
|
||||
assert_eq!(doc.signatures[0].status, SignatureStatus::Valid);
|
||||
assert!(Arc::ptr_eq(&doc.display, &doc.bytes), "encrypted signature artwork remains original");
|
||||
let saved = s.save_bytes(vid).unwrap();
|
||||
let mut t = Session::new().with_clock(|| 1_800_000_000);
|
||||
t.set_trusted_certificates(vec![anchor.clone()]);
|
||||
assert!(t.open("enc-saved.pdf", None, saved.clone(), Some("wrong")).is_err());
|
||||
let tid = t.open("enc-saved.pdf", None, saved.clone(), Some("user")).unwrap();
|
||||
assert_eq!(t.get(tid).unwrap().signatures[0].status, SignatureStatus::Valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "timing probe"]
|
||||
fn probe_edit_latency_on_a_large_signed_document() {
|
||||
|
||||
@@ -506,7 +506,8 @@ mod cups_options {
|
||||
let Some(record) = std::env::var_os(RECORD) else { return };
|
||||
let args: Vec<String> = std::env::args().skip_while(|a| a != "--").skip(1).collect();
|
||||
std::fs::write(std::path::Path::new(&record).join("args"), args.join("\n")).unwrap();
|
||||
println!("{}", std::env::var(ANSWER).unwrap());
|
||||
// Serial libtest prints its test label without a newline before this helper's stdout.
|
||||
println!("\n{}", std::env::var(ANSWER).unwrap());
|
||||
}
|
||||
|
||||
fn command(record: &std::path::Path, answer: &str) -> std::process::Command {
|
||||
|
||||
@@ -4651,6 +4651,76 @@ trailer << /Root 1 0 R >>
|
||||
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws");
|
||||
}
|
||||
|
||||
/// A sampled function kept its samples in a hash map keyed by each point of its grid, built
|
||||
/// again every time a shading using it was painted (vendored hayro-interpret patch 31).
|
||||
/// InDesign pages paint 4096-sample gradient ramps dozens of times, and `bug1721218` in the
|
||||
/// pdf.js corpus spent most of its 1.5 s hashing them. The samples are now one flat array
|
||||
/// indexed by strides: this page paints a 2,097,152-sample ramp (the most a table may hold)
|
||||
/// 30 times, which took 38 seconds through the hash map.
|
||||
#[test]
|
||||
fn shadings_with_large_sampled_functions_paint_quickly() {
|
||||
const SAMPLES: usize = 1 << 21;
|
||||
let samples = zlib_zeros(SAMPLES);
|
||||
// Black ramp on the left half, 30 times over; a red square after it.
|
||||
let content = "q 0 0 20 40 re W n /Sh0 sh Q\n".repeat(30) + "1 0 0 rg 36 36 4 4 re f";
|
||||
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> >> >> endobj\n".to_vec();
|
||||
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", content.len()).as_bytes());
|
||||
pdf.extend_from_slice(b"5 0 obj << /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 40 0] /Function 6 0 R >> endobj\n");
|
||||
pdf.extend_from_slice(
|
||||
format!("6 0 obj << /FunctionType 0 /Domain [0 1] /Range [0 1] /Size [{SAMPLES}] /BitsPerSample 8 /Filter /FlateDecode /Length {} >> stream\n", samples.len())
|
||||
.as_bytes(),
|
||||
);
|
||||
pdf.extend_from_slice(&samples);
|
||||
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(render_first_page(pdf));
|
||||
});
|
||||
let page =
|
||||
rx.recv_timeout(std::time::Duration::from_secs(10)).expect("painting a large sampled function must not rebuild a hash map each time");
|
||||
assert_eq!(rgb_at(&page.rgba, 40, 5, 20), [0, 0, 0], "the ramp is drawn");
|
||||
assert_eq!(rgb_at(&page.rgba, 40, 30, 20), [255, 255, 255], "outside the clip");
|
||||
assert_eq!(rgb_at(&page.rgba, 40, 38, 2), [255, 0, 0], "the rest of the page draws");
|
||||
}
|
||||
|
||||
/// Patch 31's flat table gives every sampled function the values the hash map gave: points
|
||||
/// on the grid, multilinear interpolation between them (first input varying fastest), and no
|
||||
/// value where a sample the interpolation needs is missing from a short stream.
|
||||
#[test]
|
||||
fn sampled_functions_interpolate_their_grid_as_before() {
|
||||
use hayro::hayro_interpret::Function;
|
||||
use hayro_syntax::object::{FromBytes, Object};
|
||||
|
||||
// Size [3 2], two outputs: out0 = 40 i + 60 j (linear), out1 = 100 i j (bilinear only).
|
||||
let mut samples = Vec::new();
|
||||
for j in 0..2u8 {
|
||||
for i in 0..3u8 {
|
||||
samples.extend_from_slice(&[40 * i + 60 * j, 100 * i * j]);
|
||||
}
|
||||
}
|
||||
let function = |data: &[u8]| {
|
||||
let mut bytes =
|
||||
format!("<< /FunctionType 0 /Domain [0 1 0 1] /Range [0 1 0 1] /Size [3 2] /BitsPerSample 8 /Length {} >> stream\n", data.len())
|
||||
.into_bytes();
|
||||
bytes.extend_from_slice(data);
|
||||
bytes.extend_from_slice(b"\nendstream");
|
||||
bytes
|
||||
};
|
||||
let whole = function(&samples);
|
||||
let f = Function::new(&Object::from_bytes(&whole).expect("a stream object")).expect("a sampled function");
|
||||
for (x, y) in [(0.0, 0.0), (1.0, 1.0), (0.5, 0.0), (0.25, 0.5), (0.75, 0.25), (0.1, 0.9), (1.0, 0.3)] {
|
||||
let (gi, gj) = (2.0 * x, y);
|
||||
let got = f.eval([x, y].into_iter().collect()).expect("every sample is present");
|
||||
let want = [(40.0 * gi + 60.0 * gj) / 255.0, 100.0 * gi.min(1.0) * gj / 255.0 + 100.0 * (gi - 1.0).max(0.0) * gj / 255.0];
|
||||
assert!(got.len() == 2 && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-5), "({x}, {y}): got {got:?}, want {want:?}");
|
||||
}
|
||||
// Only the j = 0 row: points on it evaluate, points needing j = 1 have no value.
|
||||
let short = function(&samples[..6]);
|
||||
let f = Function::new(&Object::from_bytes(&short).expect("a stream object")).expect("a sampled function");
|
||||
assert_eq!(f.eval([0.5, 0.0].into_iter().collect()).map(|v| v.to_vec()), Some(vec![40.0 / 255.0, 0.0]));
|
||||
assert_eq!(f.eval([0.5, 0.5].into_iter().collect()), None);
|
||||
}
|
||||
|
||||
/// From `cargo xtask fuzz`: a mesh shading whose points lie far off the page (a 147-byte
|
||||
/// Coons patch stream decoded onto ±40000) was sampled pixel by pixel over its whole
|
||||
/// bounding box, up to 65536 × 65536 entries: a synthetic page passed 6 GB in six seconds.
|
||||
@@ -4743,6 +4813,54 @@ trailer << /Root 1 0 R >>
|
||||
assert!(middle != [255, 255, 255, 255] && middle[3] == 255, "a small patch is shaded: {middle:?}");
|
||||
}
|
||||
|
||||
/// Coons and tensor-product patches put each corner's colour at its corner and blend them
|
||||
/// in between. Patch 33 interpolates each point of a patch's 20 × 20 triangle grid once
|
||||
/// instead of once per cell touching it, and moves vertices instead of copying them; these
|
||||
/// colours (taken before that change) pin that every vertex still gets its own point's colour.
|
||||
#[test]
|
||||
fn patch_meshes_keep_their_corner_colours() {
|
||||
// Points on a 4 × 4 grid over the 40 pt page, in stream order: the 12 boundary points
|
||||
// (both patch types), then a tensor patch's four interior ones.
|
||||
let grid = |x: u8, y: u8| [x * 85, y * 85];
|
||||
let boundary = [(0, 0), (0, 1), (0, 2), (0, 3), (1, 3), (2, 3), (3, 3), (3, 2), (3, 1), (3, 0), (2, 0), (1, 0)];
|
||||
let interior = [(1, 1), (1, 2), (2, 2), (2, 1)];
|
||||
let colours = [255u8, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0];
|
||||
let patch = |points: &[(u8, u8)]| {
|
||||
let mut data = vec![0u8];
|
||||
data.extend(points.iter().flat_map(|(x, y)| grid(*x, *y)));
|
||||
data.extend_from_slice(&colours);
|
||||
data
|
||||
};
|
||||
let render = |shading_type: u8, data: Vec<u8>| {
|
||||
let content = b"/Sh0 sh";
|
||||
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> >> >> endobj\n".to_vec();
|
||||
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes());
|
||||
pdf.extend_from_slice(content);
|
||||
pdf.extend_from_slice(format!("\nendstream endobj\n5 0 obj << /ShadingType {shading_type} /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 40 0 40 0 1 0 1 0 1] /Length {} >> stream\n", data.len()).as_bytes());
|
||||
pdf.extend_from_slice(&data);
|
||||
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
|
||||
render_first_page(pdf)
|
||||
};
|
||||
let coons = render(6, patch(&boundary));
|
||||
let tensor = render(7, patch(&[&boundary[..], &interior[..]].concat()));
|
||||
// Near each corner (top-left, bottom-left, bottom-right, top-right in the raster), the
|
||||
// centre, and a point between them.
|
||||
let want = [
|
||||
((5, 5), [32, 199, 28]),
|
||||
((5, 34), [217, 61, 5]),
|
||||
((34, 34), [217, 190, 32]),
|
||||
((34, 5), [32, 61, 190]),
|
||||
((20, 20), [127, 128, 64]),
|
||||
((12, 27), [172, 110, 25]),
|
||||
];
|
||||
for (what, page) in [("Coons", &coons), ("tensor", &tensor)] {
|
||||
for ((x, y), colour) in want {
|
||||
let got = rgb_at(&page.rgba, 40, x, y);
|
||||
assert!(got.iter().zip(colour).all(|(g, w)| g.abs_diff(w) <= 1), "{what} patch at ({x}, {y}): got {got:?}, want {colour:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The other half of the mesh-shading patch: ten thousand triangles that each cover half
|
||||
/// of this 1000 × 1000 canvas walked their bounding boxes ten thousand times over (ten
|
||||
/// billion visits). The visit budget stops that work at eight per pixel plus four per
|
||||
@@ -5979,6 +6097,81 @@ endstream endobj
|
||||
assert_eq!(rgb_at(&out.rgba, w, 100, 35), [255, 0, 0]);
|
||||
}
|
||||
|
||||
/// The decoded-image cache keyed every decode by the size the image is drawn at, but only
|
||||
/// a JPX stream decodes differently for a size: a scan was decoded again at every zoom step
|
||||
/// and kept once per zoom level (vendored hayro-interpret patch 32). An image drawn at five
|
||||
/// sizes is now decoded once, unless JPX decodes it or its soft mask.
|
||||
#[test]
|
||||
fn decoded_images_serve_every_size_they_are_drawn_at() {
|
||||
use hayro::hayro_interpret::font::Glyph;
|
||||
use hayro::hayro_interpret::hayro_syntax::Pdf;
|
||||
use hayro::hayro_interpret::{
|
||||
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, Paint, PathDrawMode, SoftMask, TransformExt, interpret_page,
|
||||
};
|
||||
use kurbo::{Affine, BezPath, Rect};
|
||||
use std::io::Write;
|
||||
|
||||
/// Asks for every raster image at the size it is drawn at, as the renderer does, and
|
||||
/// counts the decoded images it was handed.
|
||||
#[derive(Default)]
|
||||
struct Draws(usize);
|
||||
impl<'a> Device<'a> for Draws {
|
||||
fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
|
||||
fn set_blend_mode(&mut self, _: BlendMode) {}
|
||||
fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {}
|
||||
fn push_clip_path(&mut self, _: &ClipPath) {}
|
||||
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'a>>, _: BlendMode) {}
|
||||
fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {}
|
||||
fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) {
|
||||
let [a, b, c, d, ..] = transform.as_coeffs();
|
||||
let target = ((a.hypot(b) * f64::from(image.width())).ceil() as u32, (c.hypot(d) * f64::from(image.height())).ceil() as u32);
|
||||
if let Image::Raster(r) = image {
|
||||
r.with_rgba(|_, _| self.0 += 1, Some(target));
|
||||
}
|
||||
}
|
||||
fn pop_clip_path(&mut self) {}
|
||||
fn pop_transparency_group(&mut self) {}
|
||||
}
|
||||
|
||||
let flate = |data: &[u8]| {
|
||||
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
z.write_all(data).unwrap();
|
||||
z.finish().unwrap()
|
||||
};
|
||||
let pixels = flate(&(0..64 * 64 * 3).map(|i| (i % 251) as u8).collect::<Vec<_>>());
|
||||
let image = |extra: &str| {
|
||||
format!(
|
||||
"<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode {extra}/Length {} >>",
|
||||
pixels.len()
|
||||
)
|
||||
};
|
||||
let (plain, jpx_masked) = (image(""), image("/SMask 7 0 R "));
|
||||
let jpx_mask =
|
||||
"<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /JPXDecode /Length 4 >>";
|
||||
// `name` drawn at five sizes, from 10 to 40 pt square.
|
||||
let decodes = |name: &str| {
|
||||
let content = [(10, 0, 0), (20, 12, 0), (30, 34, 0), (40, 66, 0), (25, 100, 40)]
|
||||
.map(|(s, x, y)| format!("q {s} 0 0 {s} {x} {y} cm /{name} Do Q"))
|
||||
.join(" ");
|
||||
let bytes = image_cache_pdf(
|
||||
&content,
|
||||
"<< /XObject << /A 5 0 R /B 6 0 R >> >>",
|
||||
&[(&plain, &pixels), (&jpx_masked, &pixels), (jpx_mask, b"\0\0\0\0")],
|
||||
);
|
||||
let parsed = Pdf::new(Arc::new(bytes)).expect("parses");
|
||||
let page = &parsed.pages()[0];
|
||||
let cache = InterpreterCache::new();
|
||||
let initial = page.initial_transform(true).to_kurbo();
|
||||
let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 130.0, 70.0), &cache, page.xref(), InterpreterSettings::default());
|
||||
let mut device = Draws::default();
|
||||
interpret_page(page, &mut ctx, &mut device);
|
||||
assert_eq!(device.0, 5, "every draw of /{name} was handed its pixels");
|
||||
cache.decoded_image_decodes()
|
||||
};
|
||||
assert_eq!(decodes("A"), 1, "a Flate image decodes the same at every size");
|
||||
assert_eq!(decodes("B"), 5, "a JPX soft mask decodes for the size it is drawn at");
|
||||
}
|
||||
|
||||
/// Render `pdf`'s first page at 72 dpi with no time limit beyond the test's own.
|
||||
fn render_first_page(pdf: Vec<u8>) -> RenderedPage {
|
||||
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
|
||||
|
||||
@@ -468,7 +468,7 @@ impl SignatureInfo {
|
||||
}
|
||||
}
|
||||
|
||||
fn text(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
|
||||
pub(crate) fn text(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
|
||||
let o = doc.resolve(d.get(key)?);
|
||||
let s = match &*o {
|
||||
Object::String(s) => s.to_text(),
|
||||
|
||||
@@ -299,6 +299,74 @@ pub struct RasterMemory {
|
||||
pub pending_requests: usize,
|
||||
}
|
||||
|
||||
/// A historical snapshot opened from another document: View signed version opens the bytes
|
||||
/// a signature covers, View revision an earlier incremental revision. Repeats reuse the tab,
|
||||
/// and the tab and notice bar name what it is, instead of piling up identical tabs.
|
||||
/// What a historical snapshot shows: the bytes a signature covers, or an earlier
|
||||
/// incremental revision.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SnapshotKind {
|
||||
SignedVersion,
|
||||
Revision,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Snapshot {
|
||||
pub kind: SnapshotKind,
|
||||
/// The 1-based revision shown.
|
||||
pub revision: usize,
|
||||
/// The name of the document it was opened from.
|
||||
pub from: String,
|
||||
}
|
||||
|
||||
/// A validation badge drawn over a signature widget: the widget rectangle (user space), the verdict it shows, and its hover text.
|
||||
pub(crate) struct SignatureBadge {
|
||||
pub rect: [f64; 4],
|
||||
pub status: pdfcraft_engine::SignatureStatus,
|
||||
pub summary: &'static str,
|
||||
}
|
||||
|
||||
/// The validation badges drawn over one page's signature widgets: signed, visible widgets
|
||||
/// on this page with coordinates that survive the cast to screen space. Hostile files can
|
||||
/// carry NaN, infinite or gigantic rectangles, which must never reach the painter.
|
||||
pub(crate) fn signature_badges(signatures: &[pdfcraft_engine::SignatureInfo], page: usize) -> Vec<SignatureBadge> {
|
||||
signatures
|
||||
.iter()
|
||||
.filter(|s| s.signed && s.visible && s.page == Some(page))
|
||||
.filter_map(|s| {
|
||||
s.rect.filter(|r| r.iter().all(|v| v.is_finite() && v.abs() < f32::MAX as f64)).map(|rect| SignatureBadge {
|
||||
rect,
|
||||
status: s.status,
|
||||
summary: s.summary(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The name shown for a document tab, the window title and the Combine picker.
|
||||
/// A snapshot reads like its source document, plus the snapshot suffix that tells
|
||||
/// the tabs apart. Without a title the file name (which already carries the suffix)
|
||||
/// is enough.
|
||||
pub(crate) fn tab_label(app: &PdfCraftApp, index: usize) -> Option<String> {
|
||||
let view = app.views.get(index)?;
|
||||
let doc = app.session.get(view.id)?;
|
||||
let Some(snap) = view.snapshot.as_ref() else { return Some(doc.display_name()) };
|
||||
Some(snapshot_tab_name(&doc.display_name(), &doc.name, snap))
|
||||
}
|
||||
|
||||
/// A snapshot's tab name from its source title, file name and marker. Pure so the
|
||||
/// tab strip, window title and picker cannot disagree with each other.
|
||||
pub(crate) fn snapshot_tab_name(title: &str, file_name: &str, snapshot: &Snapshot) -> String {
|
||||
if title == file_name {
|
||||
return file_name.into();
|
||||
}
|
||||
if snapshot.kind == SnapshotKind::SignedVersion {
|
||||
format!("{title} (signed version)")
|
||||
} else {
|
||||
format!("{title} (revision {})", snapshot.revision)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DocView {
|
||||
pub id: DocId,
|
||||
pub zoom: f32,
|
||||
@@ -320,6 +388,11 @@ pub struct DocView {
|
||||
/// The file changed on disk while this document had unsaved changes: the notice bar asks
|
||||
/// before replacing it (#431).
|
||||
pub disk_changed: bool,
|
||||
/// The snapshot notice bar's Close was clicked: the tab closes at the top of the next
|
||||
/// frame, where no document borrow is held (the bar itself can't close it).
|
||||
pub close_requested: bool,
|
||||
/// A historical snapshot of another document (see [`Snapshot`]): `None` for real documents.
|
||||
pub snapshot: Option<Snapshot>,
|
||||
/// Two-page view: show the first page alone, as a cover (View ▸ Page display).
|
||||
pub cover: bool,
|
||||
/// Previous view / Next view: pages visited before (and after, once going back).
|
||||
@@ -623,6 +696,8 @@ impl DocView {
|
||||
notice_dismissed: false,
|
||||
scripts_off_noted: false,
|
||||
disk_changed: false,
|
||||
close_requested: false,
|
||||
snapshot: None,
|
||||
cover: false,
|
||||
back: Vec::new(),
|
||||
forward: Vec::new(),
|
||||
@@ -744,10 +819,11 @@ impl DocView {
|
||||
}
|
||||
|
||||
/// The document was replaced by a newer version of its file (#431). The reader's place
|
||||
/// stays: zoom, layout, rotation, page, scroll, view history and the find bar, and the page
|
||||
/// images on screen until their replacements are drawn, so nothing flashes. Everything that
|
||||
/// pointed into the old version (a selected comment, a field being typed in, text being
|
||||
/// edited, a drag, page selection) starts afresh, so no stale index reaches the new one.
|
||||
/// stays: zoom, layout, rotation, page, scroll, view history and the find bar, the snapshot
|
||||
/// marker and its pending close, and the page images on screen until their replacements
|
||||
/// are drawn, so nothing flashes. Everything that pointed into the old version (a selected
|
||||
/// comment, a field being typed in, text being edited, a drag, page selection) starts
|
||||
/// afresh, so no stale index reaches the new one.
|
||||
pub fn document_replaced(&mut self, info: &DocInfo) {
|
||||
let mut fresh = Self::new(self.id, info, ViewDefaults::default());
|
||||
fresh.zoom = self.zoom;
|
||||
@@ -762,6 +838,8 @@ impl DocView {
|
||||
fresh.shown = self.shown;
|
||||
fresh.highlight_fields = self.highlight_fields;
|
||||
fresh.notice_dismissed = self.notice_dismissed;
|
||||
fresh.snapshot = self.snapshot.clone();
|
||||
fresh.close_requested = self.close_requested;
|
||||
fresh.cover = self.cover;
|
||||
fresh.back = std::mem::take(&mut self.back);
|
||||
fresh.forward = std::mem::take(&mut self.forward);
|
||||
@@ -2204,6 +2282,19 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
{
|
||||
f.in_panel = false;
|
||||
}
|
||||
// The snapshot notice bar's Close was clicked: close here, where no document borrow is
|
||||
// held. The tab is looked up by id: indices may have shifted since the click, and the
|
||||
// flag rides its own view, so a stale flag can never close the wrong tab.
|
||||
let pending = app.views.get(index).filter(|v| v.close_requested).map(|v| v.id).and_then(|id| app.views.iter().position(|v| v.id == id));
|
||||
if let Some(i) = pending {
|
||||
app.views[i].close_requested = false;
|
||||
if app.has_unsaved_work(i) {
|
||||
app.close_request = Some(crate::CloseRequest::Tab(app.views[i].id));
|
||||
} else {
|
||||
app.close_tab(i);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(doc) = app.session.get(app.views[index].id) else { return };
|
||||
let info = &doc.info;
|
||||
if info.pages.is_empty() {
|
||||
@@ -2233,6 +2324,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
Some(Notice::Repairs) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Advanced)),
|
||||
Some(Notice::Security) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Security)),
|
||||
Some(Notice::Unlock) => app.unlock_prompt = Some(crate::UnlockPrompt::new(doc, None)),
|
||||
// Flags the view: the tab closes at the top of the next frame, where no document
|
||||
// borrow is held.
|
||||
Some(Notice::Close) => {
|
||||
if let Some(view) = app.views.get_mut(index) {
|
||||
view.close_requested = true;
|
||||
}
|
||||
}
|
||||
Some(Notice::Signatures) => app.right = Some(RightPanel::Signatures),
|
||||
Some(Notice::FieldHighlights(on)) => app.view_defaults.highlight_fields = on,
|
||||
Some(Notice::Console) => app.dialog = Some(crate::Dialog::JsConsole),
|
||||
@@ -2535,6 +2633,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
};
|
||||
let mut open_signature = false;
|
||||
let mut hover_text: Option<(Pos2, String)> = None;
|
||||
let mut badge_hover: Option<(Pos2, String)> = None;
|
||||
// A dialog, save prompt or palette covers the page. Hover tips are drawn above those
|
||||
// windows, so a comment under the pointer would otherwise float over the dialog.
|
||||
let page_covered = app.dialog.is_some()
|
||||
@@ -2636,6 +2735,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
view.links.context = None;
|
||||
}
|
||||
for &i in &visible_pages {
|
||||
badge_hover = None;
|
||||
let r = snap_to_pixels(rects[i].translate(origin.to_vec2()), ppp);
|
||||
if !r.intersects(visible.expand(400.0)) {
|
||||
continue;
|
||||
@@ -3292,6 +3392,32 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
view.flash = None;
|
||||
}
|
||||
}
|
||||
// Validation is viewer chrome over the signer's original artwork, which
|
||||
// may still say "Signature Not Verified". These badges never enter document
|
||||
// streams, image/text exports, vector printing or Print as Image.
|
||||
for badge in signature_badges(&doc.signatures, i) {
|
||||
let [x0, y0, x1, y1] = badge.rect;
|
||||
let sr = xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]);
|
||||
let (icon, color) = crate::sign_ui::signature_status_style(badge.status);
|
||||
painter.rect_stroke(sr, CornerRadius::same(2), Stroke::new(1.5, color), egui::StrokeKind::Outside);
|
||||
// A widget too small for the mark keeps its outline: the verdict still shows.
|
||||
if sr.width() < 4.0 || sr.height() < 4.0 {
|
||||
continue;
|
||||
}
|
||||
// Big enough to read at a glance, small enough to leave the signer's own
|
||||
// text visible around it.
|
||||
let side = (sr.width().min(sr.height()) * 0.8).clamp(24.0, 192.0);
|
||||
let mark = Rect::from_center_size(sr.center(), vec2(side, side));
|
||||
icons::paint(ui, mark, icon, side * 0.64, color);
|
||||
if let Some(p) = pointer.filter(|p| mark.expand(2.0).contains(*p)) {
|
||||
badge_hover = Some((p, badge.summary.to_string()));
|
||||
}
|
||||
}
|
||||
// Overlapping widgets paint in order, so the topmost badge owns the hover; an
|
||||
// earlier link or annotation hover still wins over any badge.
|
||||
if hover_text.is_none() {
|
||||
hover_text = badge_hover.take();
|
||||
}
|
||||
}
|
||||
// ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag
|
||||
// scrolls the page itself (#721). After the page loop, so it keeps scrolling once the
|
||||
@@ -4102,10 +4228,13 @@ enum Notice {
|
||||
Console,
|
||||
/// Unlock… (enter the permissions password).
|
||||
Unlock,
|
||||
/// Close this historical snapshot tab (View signed version / View revision).
|
||||
Close,
|
||||
}
|
||||
|
||||
/// The notice bar above the pages: the signature status first (Acrobat's signature bar), then
|
||||
/// a form's scripts having been turned off, security, forms and warnings.
|
||||
/// The notice bar above the pages: a file changed on disk first, then what a historical
|
||||
/// snapshot is, then the signature status bar, a form's scripts having been turned
|
||||
/// off, security, forms and warnings.
|
||||
fn notices(
|
||||
view: &mut DocView,
|
||||
doc: &pdfcraft_engine::Document,
|
||||
@@ -4140,6 +4269,30 @@ fn notices(
|
||||
});
|
||||
return reload.then_some(Notice::Reload);
|
||||
}
|
||||
// A historical snapshot (View signed version / View revision): what it is, above the
|
||||
// signature and security bars, so the tab is never mistaken for the live document.
|
||||
if let Some(snap) = view.snapshot.as_ref() {
|
||||
let mut close = false;
|
||||
let text = if snap.kind == SnapshotKind::SignedVersion {
|
||||
crate::i18n::fmt(
|
||||
tl!("You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original."),
|
||||
&[("from", &snap.from), ("rev", &snap.revision.to_string())],
|
||||
)
|
||||
} else {
|
||||
crate::i18n::fmt(
|
||||
tl!("You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original."),
|
||||
&[("rev", &snap.revision.to_string()), ("from", &snap.from)],
|
||||
)
|
||||
};
|
||||
notice_bar(ui, t, "clock", t.accent_text, text, |ui| {
|
||||
if crate::widgets::pill_button(ui, tl!("Close"), true).clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
if close {
|
||||
return Some(Notice::Close);
|
||||
}
|
||||
}
|
||||
if let Some((icon, color, template, arg)) = signed {
|
||||
let mut open = false;
|
||||
notice_bar(ui, t, icon, color, crate::i18n::fmt(tl!(template), &[("by", &arg)]), |ui| {
|
||||
@@ -4997,6 +5150,84 @@ mod tests {
|
||||
assert_eq!(annotation_hover_text(&annotation(true)), None);
|
||||
}
|
||||
|
||||
fn signature(signed: bool, visible: bool, page: Option<usize>, rect: Option<[f64; 4]>) -> pdfcraft_engine::SignatureInfo {
|
||||
pdfcraft_engine::SignatureInfo {
|
||||
field: "Signature1".into(),
|
||||
signed,
|
||||
page,
|
||||
rect,
|
||||
visible,
|
||||
signer: None,
|
||||
certificate: None,
|
||||
chain: Vec::new(),
|
||||
date: None,
|
||||
signing_time: None,
|
||||
reason: None,
|
||||
location: None,
|
||||
contact: None,
|
||||
sub_filter: None,
|
||||
doc_timestamp: false,
|
||||
certify: None,
|
||||
revision: 1,
|
||||
signed_len: 0,
|
||||
digest: None,
|
||||
algorithm: None,
|
||||
timestamp: false,
|
||||
timestamp_time: None,
|
||||
status: pdfcraft_engine::SignatureStatus::Valid,
|
||||
modification: pdfcraft_engine::sign::Modification::None,
|
||||
details: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_badges_keep_signed_visible_widgets_on_the_page() {
|
||||
let rect = [10.0, 10.0, 100.0, 50.0];
|
||||
let signatures =
|
||||
vec![signature(true, true, Some(0), Some(rect)), signature(true, true, Some(1), Some(rect)), signature(true, true, Some(0), Some(rect))];
|
||||
let badges = signature_badges(&signatures, 0);
|
||||
assert_eq!(badges.len(), 2);
|
||||
assert_eq!(badges[0].rect, rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_badges_skip_unsigned_invisible_and_unplaced_fields() {
|
||||
let rect = [10.0, 10.0, 100.0, 50.0];
|
||||
let signatures = vec![
|
||||
signature(false, true, Some(0), Some(rect)),
|
||||
signature(true, false, Some(0), Some(rect)),
|
||||
signature(true, true, Some(0), None),
|
||||
signature(true, true, Some(0), Some(rect)),
|
||||
];
|
||||
let badges = signature_badges(&signatures, 0);
|
||||
assert_eq!(badges.len(), 1);
|
||||
assert_eq!(badges[0].rect, rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_badges_skip_rectangles_that_cannot_reach_the_screen() {
|
||||
let hostile = vec![
|
||||
[f64::NAN, 10.0, 100.0, 50.0],
|
||||
[10.0, f64::INFINITY, 100.0, 50.0],
|
||||
[10.0, 10.0, f64::NEG_INFINITY, 50.0],
|
||||
[10.0, 10.0, 1e300, 50.0],
|
||||
[10.0, 10.0, 100.0, 50.0],
|
||||
];
|
||||
let signatures: Vec<_> = hostile.into_iter().map(|rect| signature(true, true, Some(0), Some(rect))).collect();
|
||||
let badges = signature_badges(&signatures, 0);
|
||||
assert_eq!(badges.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_tabs_read_like_their_source_plus_the_suffix() {
|
||||
let snap = Snapshot { kind: SnapshotKind::SignedVersion, revision: 1, from: "report.pdf".into() };
|
||||
assert_eq!(snapshot_tab_name("Report", "report.pdf", &snap), "Report (signed version)");
|
||||
let rev = Snapshot { kind: SnapshotKind::Revision, revision: 2, from: "report.pdf".into() };
|
||||
assert_eq!(snapshot_tab_name("Report", "report.pdf", &rev), "Report (revision 2)");
|
||||
assert_eq!(snapshot_tab_name("report.pdf", "report.pdf", &snap), "report.pdf");
|
||||
assert_eq!(snapshot_tab_name("report (revision 2).pdf", "report (revision 2).pdf", &rev), "report (revision 2).pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grayscale_converts_colours_to_their_luminance() {
|
||||
let img = egui::ColorImage::new([2, 1], vec![Color32::from_rgb(255, 0, 0), Color32::from_rgba_premultiplied(0, 51, 102, 128)]);
|
||||
|
||||
@@ -77,9 +77,9 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
// Tabs shrink to share the strip (names end in "…") and scroll only once they reach
|
||||
// their narrowest; then arrows either side say so and step through them.
|
||||
let mut natural: Vec<f32> = Vec::with_capacity(app.views.len() + 1);
|
||||
for v in &app.views {
|
||||
if let Some(doc) = app.session.get(v.id) {
|
||||
natural.push(tab_natural_width(ui, &t, &doc.display_name()));
|
||||
for i in 0..app.views.len() {
|
||||
if let Some(name) = crate::canvas::tab_label(app, i) {
|
||||
natural.push(tab_natural_width(ui, &t, &name));
|
||||
}
|
||||
}
|
||||
if app.combine_tab.open {
|
||||
@@ -125,7 +125,8 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let mut dragged = None;
|
||||
for i in 0..app.views.len() {
|
||||
let Some(doc) = app.session.get(app.views[i].id) else { continue };
|
||||
let (name, dirty) = (doc.display_name(), doc.dirty);
|
||||
let Some(name) = crate::canvas::tab_label(app, i) else { continue };
|
||||
let dirty = doc.dirty;
|
||||
// A tab showing the document's title names its file on hover.
|
||||
let file = (name != doc.name).then_some(doc.name.as_str());
|
||||
let response = tab(
|
||||
|
||||
@@ -733,7 +733,7 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let edge = ui.max_rect().right();
|
||||
add_menu(app, ui);
|
||||
let open_docs: Vec<(usize, String)> =
|
||||
app.views.iter().enumerate().filter_map(|(i, v)| app.session.get(v.id).map(|d| (i, d.display_name()))).collect();
|
||||
app.views.iter().enumerate().filter_map(|(i, _)| crate::canvas::tab_label(app, i).map(|name| (i, name))).collect();
|
||||
let response = ui.add_enabled_ui(!open_docs.is_empty(), |ui| widgets::icon_pill(ui, "file-plus", tl!("Add open documents"), false)).inner;
|
||||
response.clone().on_disabled_hover_text(tl!("No documents are open"));
|
||||
egui::Popup::menu(&response).show(|ui| {
|
||||
|
||||
@@ -513,7 +513,7 @@ impl PdfCraftApp {
|
||||
}
|
||||
"sign.validate" => {
|
||||
let certs = self.session.trusted_certificates().to_vec();
|
||||
self.session.set_trusted_certificates(certs);
|
||||
self.apply_trusted_certificates(certs);
|
||||
self.choose_right_panel(Some(RightPanel::Signatures));
|
||||
}
|
||||
"sign.panel" => self.choose_right_panel(Some(RightPanel::Signatures)),
|
||||
|
||||
@@ -2402,6 +2402,9 @@ animation effects Off معطّلة
|
||||
Move tag earlier نقل العلامة للأعلى
|
||||
Move tag later نقل العلامة للأسفل
|
||||
|
||||
# Historical snapshot notice bar (View signed version / View revision).
|
||||
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. أنت تعرض النسخة الموقعة من “{from}” (المراجعة {rev}). هذه لقطة: التغييرات هنا لن تؤثر على الأصل.
|
||||
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. أنت تعرض المراجعة {rev} من “{from}”. هذه لقطة: التغييرات هنا لن تؤثر على الأصل.
|
||||
# Completion: the rows the newer catalogs (lt, cs, es …) grew — certificates,
|
||||
# digital-signature verification detail, accessibility checks, calendar weekdays
|
||||
# and the newer plural counts — same clean-room Arabic as the rest of the file.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2343,6 +2343,10 @@ animation effects Off Изключени
|
||||
This document is trying to open a local file: Този документ се опитва да отвори локален файл:
|
||||
Move tag earlier Преместване на етикета по-рано
|
||||
Move tag later Преместване на етикета по-късно
|
||||
|
||||
# Historical snapshot notice bar (View signed version / View revision).
|
||||
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Разглеждате подписаната версия на „{from}“ (редакция {rev}). Това е моментна снимка: промените тук няма да засегнат оригинала.
|
||||
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Разглеждате редакция {rev} на „{from}“. Това е моментна снимка: промените тук няма да засегнат оригинала.
|
||||
Turn upside down (180°) Обърни наопаки (180°)
|
||||
4 x 6 in label (101.6 x 152.4 mm) Етикет 4 × 6 in (101,6 × 152,4 mm)
|
||||
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Изрязва всяка страница до отпечатаната ѝ област, завърта я изправено и я побира в листа без разтягане. Един етикет на лист.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -2454,6 +2454,10 @@ animation effects Off Vypnuto
|
||||
This document is trying to open a local file: Tento dokument se pokouší otevřít místní soubor:
|
||||
Move tag earlier Přesunout značku dříve
|
||||
Move tag later Přesunout značku později
|
||||
|
||||
# Historical snapshot notice bar (View signed version / View revision).
|
||||
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Prohlížíte si podepsanou verzi dokumentu „{from}“ (revize {rev}). Je to snímek: změny zde neovlivní originál.
|
||||
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Prohlížíte si revizi {rev} dokumentu „{from}“. Je to snímek: změny zde neovlivní originál.
|
||||
Turn upside down (180°) Otočit vzhůru nohama (180°)
|
||||
4 x 6 in label (101.6 x 152.4 mm) Štítek 4 × 6 in (101,6 × 152,4 mm)
|
||||
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Ořízne každou stránku na potištěnou oblast, otočí ji svisle a přizpůsobí listu bez deformace. Jeden štítek na list.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2381,6 +2381,10 @@ animation effects Off Aus
|
||||
This document is trying to open a local file: Dieses Dokument versucht, eine lokale Datei zu öffnen:
|
||||
Move tag earlier Tag früher verschieben
|
||||
Move tag later Tag später verschieben
|
||||
|
||||
# Historical snapshot notice bar (View signed version / View revision).
|
||||
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Sie sehen die signierte Version von „{from}“ (Revision {rev}). Es handelt sich um eine Momentaufnahme: Änderungen hier wirken sich nicht auf das Original aus.
|
||||
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Sie sehen Revision {rev} von „{from}“. Es handelt sich um eine Momentaufnahme: Änderungen hier wirken sich nicht auf das Original aus.
|
||||
Turn upside down (180°) Auf den Kopf drehen (180°)
|
||||
4 x 6 in label (101.6 x 152.4 mm) Etikett 4 × 6 Zoll (101,6 × 152,4 mm)
|
||||
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Schneidet jede Seite auf ihren bedruckten Bereich zu, richtet sie auf und passt sie ohne Verzerrung an das Blatt an. Ein Etikett pro Blatt.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
@@ -2568,3 +2568,7 @@ animation effects Off Desactivados
|
||||
Make properties default Usar como propiedades predeterminadas
|
||||
Pages selected Páginas seleccionadas
|
||||
Page properties Propiedades de página
|
||||
|
||||
# Historical snapshot notice bar (View signed version / View revision).
|
||||
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Estás viendo la versión firmada de «{from}» (revisión {rev}). Es una instantánea: los cambios aquí no afectarán al original.
|
||||
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Estás viendo la revisión {rev} de «{from}». Es una instantánea: los cambios aquí no afectarán al original.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 60.
|
@@ -2380,6 +2380,10 @@ animation effects Off Désactivés
|
||||
This document is trying to open a local file: Ce document tente d’ouvrir un fichier local :
|
||||
Move tag earlier Déplacer la balise plus tôt
|
||||
Move tag later Déplacer la balise plus tard
|
||||
|
||||
# Historical snapshot notice bar (View signed version / View revision).
|
||||
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Vous consultez la version signée de «{from}» (révision {rev}). Il s'agit d'un instantané : les modifications effectuées ici n'affecteront pas l'original.
|
||||
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Vous consultez la révision {rev} de «{from}». Il s'agit d'un instantané : les modifications effectuées ici n'affecteront pas l'original.
|
||||
Turn upside down (180°) Retourner (180°)
|
||||
4 x 6 in label (101.6 x 152.4 mm) Étiquette 4 × 6 po (101,6 × 152,4 mm)
|
||||
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Rogne chaque page à sa zone imprimée, la redresse et l’ajuste à la feuille sans la déformer. Une étiquette par feuille.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
@@ -2379,6 +2379,10 @@ animation effects Off Ki
|
||||
This document is trying to open a local file: Ez a dokumentum egy helyi fájlt próbál megnyitni:
|
||||
Move tag earlier Címke korábbra helyezése
|
||||
Move tag later Címke későbbre helyezése
|
||||
|
||||
# Historical snapshot notice bar (View signed version / View revision).
|
||||
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. A(z) „{from}“ aláírt változatát (a {rev}. változatot) tekinti meg. Ez egy pillanatkép: az itteni módosítások nem érintik az eredetit.
|
||||
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. A(z) „{from}“ {rev}. változatát tekinti meg. Ez egy pillanatkép: az itteni módosítások nem érintik az eredetit.
|
||||
Turn upside down (180°) Fejjel lefelé fordítás (180°)
|
||||
4 x 6 in label (101.6 x 152.4 mm) 4 × 6 hüvelykes címke (101,6 × 152,4 mm)
|
||||
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Minden oldalt a nyomtatott területére vág, függőlegesre fordítja, és torzítás nélkül a laphoz igazítja. Laponként egy címke.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
@@ -2389,6 +2389,10 @@ animation effects Off Disattivati
|
||||
This document is trying to open a local file: Questo documento sta tentando di aprire un file locale:
|
||||
Move tag earlier Sposta tag prima
|
||||
Move tag later Sposta tag dopo
|
||||
|
||||
# Historical snapshot notice bar (View signed version / View revision).
|
||||
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Stai visualizzando la versione firmata di “{from}” (revisione {rev}). È un'istantanea: le modifiche qui non influiscono sull'originale.
|
||||
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Stai visualizzando la revisione {rev} di “{from}”. È un'istantanea: le modifiche qui non influiscono sull'originale.
|
||||
Turn upside down (180°) Capovolgi (ruota di 180°)
|
||||
4 x 6 in label (101.6 x 152.4 mm) Etichetta 4×6 - 101,6 × 152,4 mm
|
||||
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Ritaglia l'area stampata di ogni pagina, la raddrizza e la adatta al foglio senza deformarla. Un'etichetta per foglio.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2380,6 +2380,10 @@ animation effects Off オフ
|
||||
This document is trying to open a local file: この文書はローカルファイルを開こうとしています:
|
||||
Move tag earlier タグを前へ移動
|
||||
Move tag later タグを後へ移動
|
||||
|
||||
# Historical snapshot notice bar (View signed version / View revision).
|
||||
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. 「{from}」の署名済みバージョン(リビジョン {rev})を表示しています。これはスナップショットであり、ここでの変更は元の文書に影響しません。
|
||||
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. 「{from}」のリビジョン {rev} を表示しています。これはスナップショットであり、ここでの変更は元の文書に影響しません。
|
||||
Turn upside down (180°) 上下反転 (180°)
|
||||
4 x 6 in label (101.6 x 152.4 mm) 4 × 6 インチ ラベル (101.6 × 152.4 mm)
|
||||
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. 各ページを印刷領域で切り抜き、正しい向きに回転して、伸縮させずに用紙に合わせます。1 枚に 1 ラベル。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2378,6 +2378,10 @@ animation effects Off Өшірулі
|
||||
This document is trying to open a local file: Бұл құжат жергілікті файлды ашуға әрекеттеніп жатыр:
|
||||
Move tag earlier Тегті ертерек жылжыту
|
||||
Move tag later Тегті кейінірек жылжыту
|
||||
|
||||
# Historical snapshot notice bar (View signed version / View revision).
|
||||
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Сіз „{from}“ құжатының қол қойылған нұсқасын ({rev} нұсқа) қарап отырсыз. Бұл сақталған көшірме: мұндағы өзгерістер түпнұсқаға әсер етпейді.
|
||||
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Сіз „{from}“ құжатының {rev} нұсқасын қарап отырсыз. Бұл сақталған көшірме: мұндағы өзгерістер түпнұсқаға әсер етпейді.
|
||||
Turn upside down (180°) Төңкеру (180°)
|
||||
4 x 6 in label (101.6 x 152.4 mm) 4 × 6 дюйм жапсырма (101,6 × 152,4 мм)
|
||||
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Әр бетті басылған аймағына дейін қиып, тік бұрып, созбай параққа сыйғызады. Әр параққа бір жапсырма.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 825 and column 9.
|
@@ -2624,6 +2624,10 @@ key Space Tarpas
|
||||
This document is trying to open a local file: Šis dokumentas bando atverti vietinį failą:
|
||||
Move tag earlier Perkelti žymą anksčiau
|
||||
Move tag later Perkelti žymą vėliau
|
||||
|
||||
# Historical snapshot notice bar (View signed version / View revision).
|
||||
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Jūs peržiūrite pasirašytą „{from}“ versiją ({rev} versija). Tai momentinė kopija: čia atlikti pakeitimai originalo nepaveiks.
|
||||
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Jūs peržiūrite „{from}“ {rev} versiją. Tai momentinė kopija: čia atlikti pakeitimai originalo nepaveiks.
|
||||
Turn upside down (180°) Apversti aukštyn kojomis (180°)
|
||||
4 x 6 in label (101.6 x 152.4 mm) 4 × 6 col. etiketė (101,6 × 152,4 mm)
|
||||
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Apkerpa kiekvieną puslapį iki spausdinamos srities, pasuka tiesiai ir pritaiko lapui neiškraipydama. Viena etiketė lape.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 404 and column 9.
|
@@ -2394,6 +2394,10 @@ animation effects Off Desativados
|
||||
This document is trying to open a local file: Este documento está tentando abrir um arquivo local:
|
||||
Move tag earlier Mover tag para antes
|
||||
Move tag later Mover tag para depois
|
||||
|
||||
# Historical snapshot notice bar (View signed version / View revision).
|
||||
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Você está vendo a versão assinada de “{from}” (revisão {rev}). É um instantâneo: as alterações aqui não afetarão o original.
|
||||
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Você está vendo a revisão {rev} de “{from}”. É um instantâneo: as alterações aqui não afetarão o original.
|
||||
Turn upside down (180°) Virar de cabeça para baixo (180°)
|
||||
4 x 6 in label (101.6 x 152.4 mm) Etiqueta 4 × 6 pol (101,6 × 152,4 mm)
|
||||
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Recorta cada página na área impressa, deixa-a na vertical e ajusta-a à folha sem distorcer. Uma etiqueta por folha.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2293,6 +2293,10 @@ animation effects Off Выкл
|
||||
This document is trying to open a local file: Этот документ пытается открыть локальный файл:
|
||||
Move tag earlier Переместить тег раньше
|
||||
Move tag later Переместить тег позже
|
||||
|
||||
# Historical snapshot notice bar (View signed version / View revision).
|
||||
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Вы просматриваете подписанную версию документа «{from}» (ревизия {rev}). Это снимок: изменения здесь не затронут оригинал.
|
||||
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Вы просматриваете ревизию {rev} документа «{from}». Это снимок: изменения здесь не затронут оригинал.
|
||||
Turn upside down (180°) Перевернуть (180°)
|
||||
4 x 6 in label (101.6 x 152.4 mm) Этикетка 4 × 6 дюйма (101,6 × 152,4 мм)
|
||||
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Обрезает каждую страницу по напечатанной области, разворачивает её прямо и вписывает в лист без искажений. Одна этикетка на лист.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2385,6 +2385,10 @@ animation effects Off Izklopljeni
|
||||
This document is trying to open a local file: Ta dokument poskuša odpreti lokalno datoteko:
|
||||
Move tag earlier Premakni oznako prej
|
||||
Move tag later Premakni oznako pozneje
|
||||
|
||||
# Historical snapshot notice bar (View signed version / View revision).
|
||||
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Ogledate si podpisano različico dokumenta „{from}“ (revizija {rev}). To je posnetek: spremembe tukaj ne bodo vplivale na izvirnik.
|
||||
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Ogledate si revizijo {rev} dokumenta „{from}“. To je posnetek: spremembe tukaj ne bodo vplivale na izvirnik.
|
||||
Turn upside down (180°) Obrni na glavo (180°)
|
||||
4 x 6 in label (101.6 x 152.4 mm) Nalepka 4 × 6 in (101,6 × 152,4 mm)
|
||||
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Vsako stran obreže na natisnjeno območje, jo poravna pokonci in prilagodi listu brez raztegovanja. Ena nalepka na list.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2376,6 +2376,10 @@ animation effects Off Kapalı
|
||||
This document is trying to open a local file: Bu belge yerel bir dosya açmaya çalışıyor:
|
||||
Move tag earlier Etiketi daha erken taşı
|
||||
Move tag later Etiketi daha geç taşı
|
||||
|
||||
# Historical snapshot notice bar (View signed version / View revision).
|
||||
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. “{from}” belgesinin imzalanmış sürümünü (düzeltme {rev}) görüntülüyorsunuz. Bu bir anlık görüntüdür: buradaki değişiklikler özgün belgeyi etkilemez.
|
||||
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. “{from}” belgesinin {rev}. düzeltmesini görüntülüyorsunuz. Bu bir anlık görüntüdür: buradaki değişiklikler özgün belgeyi etkilemez.
|
||||
Turn upside down (180°) Baş aşağı çevir (180°)
|
||||
4 x 6 in label (101.6 x 152.4 mm) 4 × 6 inç etiket (101,6 × 152,4 mm)
|
||||
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Her sayfayı basılı alanına göre kırpar, dik çevirir ve germeden sayfaya sığdırır. Sayfa başına bir etiket.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 824 and column 9.
|
@@ -2402,6 +2402,10 @@ animation effects Off Вимкнено
|
||||
This document is trying to open a local file: Цей документ намагається відкрити локальний файл:
|
||||
Move tag earlier Перемістити тег раніше
|
||||
Move tag later Перемістити тег пізніше
|
||||
|
||||
# Historical snapshot notice bar (View signed version / View revision).
|
||||
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Ви переглядаєте підписану версію документа «{from}» (редакція {rev}). Це знімок: зміни тут не вплинуть на оригінал.
|
||||
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Ви переглядаєте редакцію {rev} документа «{from}». Це знімок: зміни тут не вплинуть на оригінал.
|
||||
Turn upside down (180°) Перевернути (180°)
|
||||
4 x 6 in label (101.6 x 152.4 mm) Етикетка 4 × 6 дюйма (101,6 × 152,4 мм)
|
||||
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Обрізає кожну сторінку за надрукованою областю, розвертає її прямо та вписує в аркуш без спотворень. Одна етикетка на аркуш.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2681,3 +2681,7 @@ key Space Space
|
||||
Words Từ
|
||||
“{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one. “{name}” đã thay đổi trên đĩa sau khi được mở hoặc lưu lần cuối. Lưu lúc này sẽ thay thế phiên bản mới hơn đó bằng phiên bản này.
|
||||
@id file.word_count Đếm từ…
|
||||
|
||||
# Historical snapshot notice bar (View signed version / View revision).
|
||||
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Bạn đang xem phiên bản đã ký của “{from}” (bản sửa đổi {rev}). Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc.
|
||||
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Bạn đang xem bản sửa đổi {rev} của “{from}”. Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 490 and column 12.
|
@@ -2344,6 +2344,10 @@ animation effects Off 关
|
||||
This document is trying to open a local file: 此文档正尝试打开本地文件:
|
||||
Move tag earlier 将标签前移
|
||||
Move tag later 将标签后移
|
||||
|
||||
# Historical snapshot notice bar (View signed version / View revision).
|
||||
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. 您正在查看“{from}”的已签名版本(修订版本 {rev})。这是快照:在此处所做的更改不会影响原始文档。
|
||||
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. 您正在查看“{from}”的修订版本 {rev}。这是快照:在此处所做的更改不会影响原始文档。
|
||||
Turn upside down (180°) 上下颠倒 (180°)
|
||||
4 x 6 in label (101.6 x 152.4 mm) 4 × 6 英寸标签 (101.6 × 152.4 毫米)
|
||||
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. 将每页裁切到其打印区域,转正后不拉伸地适合纸张。每张纸一个标签。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2437,7 +2437,7 @@ impl PdfCraftApp {
|
||||
}
|
||||
if let Ok(pems) = serde_json::from_value::<Vec<String>>(v["trusted"].clone()) {
|
||||
let certs = pems.iter().filter_map(|p| pdfcraft_engine::sign::x509::load_certificates(p.as_bytes()).ok()).flatten().collect();
|
||||
self.session.set_trusted_certificates(certs);
|
||||
self.apply_trusted_certificates(certs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3085,8 +3085,7 @@ impl eframe::App for PdfCraftApp {
|
||||
// The window shows the active document's name (or title, if it asks for that).
|
||||
let title = self
|
||||
.active
|
||||
.and_then(|i| self.session.get(self.views[i].id))
|
||||
.map(|d| d.display_name())
|
||||
.and_then(|i| crate::canvas::tab_label(self, i))
|
||||
.or_else(|| self.combine_showing().then(|| tl!("Combine files").to_owned()))
|
||||
.map_or_else(|| "PdfCraft".to_owned(), |name| format!("{name} — PdfCraft"));
|
||||
if title != self.window_title {
|
||||
|
||||
@@ -608,3 +608,65 @@ fn tool_picker_keeps_picked_recipient_when_other_open_is_ready() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
fn inline_tool_open_keeps_its_command_for_pdf_conversion_and_password_retry() {
|
||||
for (name, bytes, encrypted) in
|
||||
[("plain.pdf", fixture(1), false), ("converted.txt", b"Text to export".to_vec(), false), ("locked.pdf", encrypted_tool_fixture(), true)]
|
||||
{
|
||||
let path = std::env::temp_dir().join(format!("pdfcraft-inline-tool-{}-{name}", std::process::id()));
|
||||
std::fs::write(&path, bytes).expect("write selected file");
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").expect("language preference");
|
||||
assert!(app.ctx.is_none(), "this exercises the no-context inline path");
|
||||
app.open_path_then(&path.to_string_lossy(), Some("export.text"));
|
||||
if encrypted {
|
||||
assert!(app.dialog.is_none());
|
||||
assert_eq!(app.password_prompt.as_ref().expect("password prompt").then, Some("export.text"));
|
||||
app.submit_password(Some("wrong".into()));
|
||||
assert!(app.password_prompt.as_ref().expect("wrong-password prompt").error.is_some());
|
||||
assert!(app.dialog.is_none());
|
||||
app.submit_password(Some("tool-password".into()));
|
||||
}
|
||||
assert_eq!(app.views.len(), 1);
|
||||
assert_eq!(app.active, Some(0));
|
||||
assert!(matches!(app.dialog, Some(crate::Dialog::Export(_))), "the inline {name} retains its export command");
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
|
||||
fn startup_files_wait_for_absolute_link_consent_and_preparation() {
|
||||
let (mut h, _) = harness();
|
||||
let source = h.state().views[0].id;
|
||||
let path = std::env::temp_dir().join(format!("pdfcraft-open-consented-startup-{}.pdf", std::process::id()));
|
||||
std::fs::write(&path, fixture(2)).expect("write linked PDF");
|
||||
h.state_mut().open_remote_link(source, path.to_string_lossy().into_owned(), pdfcraft_render::RemoteDestination::Default);
|
||||
h.state().startup_inbox.lock().expect("startup queue").push(("startup.pdf".into(), fixture(1)));
|
||||
h.step();
|
||||
assert!(h.state().pending_remote_link.is_some());
|
||||
assert_eq!(h.state().startup_inbox.lock().expect("startup queue").len(), 1);
|
||||
assert_eq!(h.state().views.len(), 1, "startup cannot replace the consent interaction");
|
||||
|
||||
let (gate, started, release, finished) = open_worker::prepare_gate();
|
||||
h.state_mut().open_prepare_gate = Some(gate);
|
||||
h.state_mut().resolve_pending_remote_link(true);
|
||||
started.recv_timeout(Duration::from_secs(5)).expect("worker reached prepare barrier");
|
||||
h.step();
|
||||
let queued = h.state().startup_inbox.lock().expect("startup queue").len();
|
||||
let visible = h.state().views.len();
|
||||
// Always release before asserting: a failure must not strand the worker at the barrier.
|
||||
release.send(()).expect("release prepare barrier");
|
||||
finished.recv_timeout(Duration::from_secs(5)).expect("worker finished preparation");
|
||||
assert_eq!(queued, 1, "startup bytes wait while the approved linked PDF prepares");
|
||||
assert_eq!(visible, 1);
|
||||
h.step();
|
||||
let app = h.state();
|
||||
assert_eq!(app.views.len(), 3);
|
||||
assert_eq!(app.session.get(app.views[1].id).expect("linked document").path.as_deref(), path.to_str());
|
||||
assert_eq!(app.session.get(app.views[2].id).expect("startup document").name, "startup.pdf");
|
||||
assert_eq!(app.active, Some(1), "the user's approved navigation keeps focus");
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
@@ -778,7 +778,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
app.cert_viewer = Some(crate::sign_ui::CertViewer { chain, selected: 0, tab: crate::sign_ui::CertTab::Summary });
|
||||
app.dialog = Some(crate::Dialog::CertificateViewer);
|
||||
}
|
||||
Some(crate::sign_ui::PanelAction::ViewSigned(len)) => app.view_signed_version(len),
|
||||
Some(crate::sign_ui::PanelAction::ViewSigned { len, revision }) => app.view_signed_version(len, revision),
|
||||
Some(crate::sign_ui::PanelAction::Sign(field)) => {
|
||||
let page = app.views[index].current;
|
||||
app.start_signing(page, None, Some(field), None);
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use egui::{Align, Color32, CornerRadius, Layout, Pos2, Rect, Stroke, vec2};
|
||||
use pdfcraft_engine::sign::{self, Appearance, AppearanceLabels, Certificate, DigitalId, Modification, Name, PrivateKey};
|
||||
use pdfcraft_engine::sign::{self, Appearance, AppearanceLabels, Certificate, DigitalId, Name, PrivateKey};
|
||||
use pdfcraft_engine::{SignOptions, SignatureImage, SignatureInfo, SignatureStatus};
|
||||
|
||||
use crate::canvas::{DocView, PageXform};
|
||||
use crate::canvas::{DocView, PageXform, Snapshot, SnapshotKind};
|
||||
use crate::fill_sign::SavedSig;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{PdfCraftApp, icons, widgets};
|
||||
@@ -528,36 +528,100 @@ impl PdfCraftApp {
|
||||
if !certs.iter().any(|c| c.raw == cert.raw) {
|
||||
certs.push(cert);
|
||||
}
|
||||
self.apply_trusted_certificates(certs);
|
||||
}
|
||||
|
||||
/// Replace the trust store and redraw from the rebuilt display copy. One helper
|
||||
/// so no caller can revalidate without invalidating: trust changes verdicts,
|
||||
/// verdicts choose what the display copy shows, and the old rasters would
|
||||
/// otherwise linger while the badge already follows the new verdict.
|
||||
/// Trust alone is not an edit: nothing becomes dirty.
|
||||
pub fn apply_trusted_certificates(&mut self, certs: Vec<Certificate>) {
|
||||
self.session.set_trusted_certificates(certs);
|
||||
for view in &mut self.views {
|
||||
view.invalidate_content();
|
||||
}
|
||||
}
|
||||
|
||||
/// A file name without its PDF extension, for snapshot tab names.
|
||||
fn file_stem(name: &str) -> String {
|
||||
name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string()
|
||||
}
|
||||
|
||||
/// Open saved revision `n` (1 = the oldest) of the active document as a new document.
|
||||
/// A repeat reuses the tab: every click used to open another identical one.
|
||||
pub fn open_revision(&mut self, n: usize) {
|
||||
let Some((_, id)) = self.active_ids() else { return };
|
||||
let Some(doc) = self.session.get(id) else { return };
|
||||
// The revision's byte range, as `Session::open_revision` reads it. An unknown `n`
|
||||
// skips the lookup: the whole file could otherwise match the latest revision's tab
|
||||
// instead of erroring.
|
||||
let ends = doc.revision_ends();
|
||||
if let Some(&end) = n.checked_sub(1).and_then(|k| ends.get(k)) {
|
||||
let end = end.min(doc.bytes.len());
|
||||
if let Some(index) = self.find_snapshot(SnapshotKind::Revision, &doc.name, &doc.bytes[..end]) {
|
||||
self.active = Some(index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let from = doc.name.clone();
|
||||
match self.session.open_revision(id, n) {
|
||||
Ok(new) => {
|
||||
// The reading-colours preference is app-wide: the newly opened revision starts with it.
|
||||
self.session.set_reading_colors(new, self.reading_colors);
|
||||
let Some(doc) = self.session.get(new) else { return };
|
||||
self.views.push(DocView::new(new, &doc.info, self.view_defaults));
|
||||
let mut view = DocView::new(new, &doc.info, self.view_defaults);
|
||||
view.snapshot = Some(Snapshot { kind: SnapshotKind::Revision, revision: n, from });
|
||||
self.views.push(view);
|
||||
self.active = Some(self.views.len() - 1);
|
||||
}
|
||||
Err(e) => self.notify_fmt("Couldn't open revision {n}: {e}", &[("n", &n.to_string()), ("e", &e.to_string())]),
|
||||
}
|
||||
}
|
||||
|
||||
/// A tab already showing these exact snapshot bytes: its index, so a repeat
|
||||
/// switches to it instead of opening another identical tab. An exact match on
|
||||
/// the source document wins, so the banner names the file opened from; a
|
||||
/// bytes-only match still reuses the tab (incremental saves append, so one
|
||||
/// file's revision can be byte-identical to another tab's, and a snapshot's
|
||||
/// own repeats name the snapshot itself).
|
||||
fn find_snapshot(&self, kind: SnapshotKind, from: &str, bytes: &[u8]) -> Option<usize> {
|
||||
let mut fallback = None;
|
||||
for (i, v) in self.views.iter().enumerate() {
|
||||
let same_kind = v.snapshot.as_ref().is_some_and(|s| s.kind == kind);
|
||||
let same_bytes = same_kind && self.session.get(v.id).is_some_and(|d| d.bytes.as_slice() == bytes);
|
||||
if !same_bytes {
|
||||
continue;
|
||||
}
|
||||
if v.snapshot.as_ref().is_some_and(|s| s.from == from) {
|
||||
return Some(i);
|
||||
}
|
||||
fallback.get_or_insert(i);
|
||||
}
|
||||
fallback
|
||||
}
|
||||
|
||||
/// Open the signed version of a signature as a new document (View signed version).
|
||||
pub fn view_signed_version(&mut self, len: usize) {
|
||||
/// A repeat reuses the tab: every click used to open another identical one.
|
||||
pub fn view_signed_version(&mut self, len: usize, revision: usize) {
|
||||
let Some((_, id)) = self.active_ids() else { return };
|
||||
let Some(doc) = self.session.get(id) else { return };
|
||||
let bytes = doc.bytes[..len.min(doc.bytes.len())].to_vec();
|
||||
let name = format!("{} (signed version)", doc.name.trim_end_matches(".pdf"));
|
||||
let end = len.min(doc.bytes.len());
|
||||
if let Some(index) = self.find_snapshot(SnapshotKind::SignedVersion, &doc.name, &doc.bytes[..end]) {
|
||||
self.active = Some(index);
|
||||
return;
|
||||
}
|
||||
let from = doc.name.clone();
|
||||
let bytes = doc.bytes[..end].to_vec();
|
||||
let name = format!("{} (signed version)", Self::file_stem(&from));
|
||||
match self.session.open(format!("{name}.pdf"), None, std::sync::Arc::new(bytes), doc.password.clone().as_deref()) {
|
||||
Ok(new) => {
|
||||
// The reading-colours preference is app-wide: the newly opened copy starts with it.
|
||||
self.session.set_reading_colors(new, self.reading_colors);
|
||||
let Some(doc) = self.session.get(new) else { return };
|
||||
self.views.push(DocView::new(new, &doc.info, self.view_defaults));
|
||||
let mut view = DocView::new(new, &doc.info, self.view_defaults);
|
||||
view.snapshot = Some(Snapshot { kind: SnapshotKind::SignedVersion, revision, from });
|
||||
self.views.push(view);
|
||||
self.active = Some(self.views.len() - 1);
|
||||
}
|
||||
Err(e) => self.notify_fmt("Couldn't open the signed version: {e}", &[("e", &e.to_string())]),
|
||||
@@ -1084,11 +1148,16 @@ fn status_icon(s: &SignatureInfo) -> (&'static str, Color32) {
|
||||
if !s.signed {
|
||||
return ("pen-line", Color32::from_gray(0x80));
|
||||
}
|
||||
match (s.status, &s.modification) {
|
||||
(SignatureStatus::Valid, Modification::None) => ("circle-check", Color32::from_rgb(0x2D, 0x9D, 0x78)),
|
||||
(SignatureStatus::Valid, _) => ("circle-check", Color32::from_rgb(0x2D, 0x9D, 0x78)),
|
||||
(SignatureStatus::Unknown, _) => ("triangle-alert", Color32::from_rgb(0xE6, 0x86, 0x19)),
|
||||
(SignatureStatus::Invalid, _) => ("circle-x", Color32::from_rgb(0xD7, 0x37, 0x3F)),
|
||||
signature_status_style(s.status)
|
||||
}
|
||||
|
||||
/// One look per signature verdict, shared by the Signatures panel and the page badges so
|
||||
/// the two can't drift apart.
|
||||
pub(crate) fn signature_status_style(status: SignatureStatus) -> (&'static str, Color32) {
|
||||
match status {
|
||||
SignatureStatus::Valid => ("circle-check", Color32::from_rgb(0x2D, 0x9D, 0x78)),
|
||||
SignatureStatus::Unknown => ("triangle-alert", Color32::from_rgb(0xE6, 0x86, 0x19)),
|
||||
SignatureStatus::Invalid => ("circle-x", Color32::from_rgb(0xD7, 0x37, 0x3F)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1098,7 +1167,10 @@ pub enum PanelAction {
|
||||
Validate,
|
||||
GoTo(usize),
|
||||
Trust(Box<Certificate>),
|
||||
ViewSigned(usize),
|
||||
ViewSigned {
|
||||
len: usize,
|
||||
revision: usize,
|
||||
},
|
||||
Sign(String),
|
||||
ExportCertificate(Box<Certificate>),
|
||||
/// Certificate Viewer for the signer's chain (signer first).
|
||||
@@ -1231,7 +1303,7 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, sigs: &[SignatureInfo], expan
|
||||
action = Some(PanelAction::Trust(Box::new(c.clone())));
|
||||
}
|
||||
if ui.button(tl!("View signed version")).clicked() {
|
||||
action = Some(PanelAction::ViewSigned(s.signed_len));
|
||||
action = Some(PanelAction::ViewSigned { len: s.signed_len, revision: s.revision });
|
||||
}
|
||||
if let Some(c) = &s.certificate
|
||||
&& ui.button(tl!("Show certificate…")).clicked()
|
||||
@@ -1424,6 +1496,13 @@ mod tests {
|
||||
Ok(PathBuf::from(&app.digital_ids[i].path))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_status_style_gives_each_verdict_its_panel_icon() {
|
||||
assert_eq!(signature_status_style(SignatureStatus::Valid).0, "circle-check");
|
||||
assert_eq!(signature_status_style(SignatureStatus::Unknown).0, "triangle-alert");
|
||||
assert_eq!(signature_status_style(SignatureStatus::Invalid).0, "circle-x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_digital_id_skips_names_already_taken_without_writing_through_them() {
|
||||
let dir = scratch();
|
||||
|
||||
@@ -132,7 +132,7 @@ fn reading_colours_seed_on_open() {
|
||||
assert_ne!(revision, id);
|
||||
assert_eq!(h.state().session.reading_colors(revision), Some(ReadingColors::YELLOW_ON_BLACK));
|
||||
let len = h.state().session.get(id).unwrap().bytes.len();
|
||||
h.state_mut().view_signed_version(len);
|
||||
h.state_mut().view_signed_version(len, 1);
|
||||
let signed = h.state().views.last().unwrap().id;
|
||||
assert_ne!(signed, id);
|
||||
assert_eq!(h.state().session.reading_colors(signed), Some(ReadingColors::YELLOW_ON_BLACK));
|
||||
|
||||
@@ -152,11 +152,16 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() {
|
||||
h.get_by_label_contains("Signed by Grace Hopper").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Signature validity is UNKNOWN:");
|
||||
// Seed a find so the trust path proves it invalidates the view: trust rebuilds
|
||||
// the display copy, and the next frame must draw from it without any zoom change.
|
||||
h.state_mut().views[0].find = Some(pdfcraft_ui_egui::canvas::Find { matches: vec![(0, 0..5)], ..Default::default() });
|
||||
h.get_by_label("Add to trusted certificates").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Signed and all signatures are valid.");
|
||||
let s = h.state();
|
||||
assert_eq!(s.session.get(s.views[0].id).unwrap().signatures[0].status, SignatureStatus::Valid);
|
||||
assert!(s.views[0].find.as_ref().is_some_and(|f| f.matches.is_empty()), "trust invalidates the view without zooming");
|
||||
assert!(!s.session.get(s.views[0].id).unwrap().dirty, "trust alone is not an edit");
|
||||
// Show certificate: the Certificate Viewer.
|
||||
h.get_by_label("Show certificate…").click();
|
||||
h.run_steps(3);
|
||||
@@ -182,8 +187,60 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() {
|
||||
assert_eq!(s.views.len(), 2);
|
||||
let v = s.session.get(s.views[1].id).unwrap();
|
||||
assert!(v.name.contains("signed version") && v.signatures.iter().any(|x| x.signed));
|
||||
assert!(s.views[0].snapshot.is_none());
|
||||
assert!(s.views[1].snapshot.as_ref().is_some_and(|snap| snap.kind == pdfcraft_ui_egui::canvas::SnapshotKind::SignedVersion));
|
||||
// A repeat click reuses the snapshot tab instead of opening another identical one.
|
||||
h.get_by_label("View signed version").click();
|
||||
h.run_steps(3);
|
||||
let s = h.state();
|
||||
assert_eq!((s.views.len(), s.active), (2, Some(1)));
|
||||
// A repeat from the original tab reuses it too.
|
||||
h.state_mut().active = Some(0);
|
||||
h.run_steps(1);
|
||||
h.get_by_label("View signed version").click();
|
||||
h.run_steps(3);
|
||||
let s = h.state();
|
||||
assert_eq!((s.views.len(), s.active), (2, Some(1)));
|
||||
// The snapshot tab says what it is.
|
||||
h.get_by_label_contains("You are viewing the signed version of");
|
||||
// Closing a clean snapshot closes the tab with no save prompt. The bar's own
|
||||
// Close is addressed from its message, because the tab chrome has a Close button
|
||||
// with the same label.
|
||||
let label = h.get_by_label_contains("You are viewing the signed version of");
|
||||
let label_row = label.rect().y_range();
|
||||
let mut node = label;
|
||||
let mut close = None;
|
||||
for _ in 0..4 {
|
||||
if let Some(pill) = node.query_by_label("Close") {
|
||||
close = Some(pill);
|
||||
break;
|
||||
}
|
||||
let Some(up) = node.parent() else { break };
|
||||
node = up;
|
||||
}
|
||||
let close = close.expect("the notice bar holds its own Close");
|
||||
// The bar's Close sits in the bar's row and the tab chrome's sits above it. Without
|
||||
// this the walk could reach the chrome's button, which closes the same tab, and the
|
||||
// test would pass with the bar's own button never pressed.
|
||||
assert!(label_row.contains(close.rect().center().y), "the Close found is the bar's own");
|
||||
close.click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views.len(), 1);
|
||||
assert!(h.state().close_request.is_none());
|
||||
// Closing a dirty snapshot asks about saving instead of closing at once.
|
||||
h.get_by_label("View signed version").click();
|
||||
h.run_steps(3);
|
||||
let id = h.state().views[1].id;
|
||||
h.state_mut().session.apply(id, pdfcraft_engine::Edit::SetInfo { key: "Title".into(), value: "Dirty".into() }).unwrap();
|
||||
h.state_mut().views[1].close_requested = true;
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views.len(), 2);
|
||||
h.get_by_label("Save changes to “contract_signed (signed version).pdf” before closing?");
|
||||
h.get_by_label("Don't save").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views.len(), 1);
|
||||
// Trusted certificates and the ID list persist.
|
||||
let saved = s.persist();
|
||||
let saved = h.state().persist();
|
||||
let mut fresh = PdfCraftApp::new();
|
||||
fresh.set_option("language", "en").unwrap();
|
||||
fresh.restore(&saved);
|
||||
|
||||
@@ -347,6 +347,7 @@ fn precise_touchpad_wheel_scrolls_one_to_one() {
|
||||
|
||||
/// Steps `frames` frames and returns how far the document scrolled down over them, from the
|
||||
/// pages on screen in each pair of consecutive frames (a page can leave the screen on the way).
|
||||
/// Only the non-macOS glide tests below use it, hence the matching gate.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn scroll_over(h: &mut Harness<'static, PdfCraftApp>, frames: usize) -> f32 {
|
||||
let tops = |h: &Harness<'static, PdfCraftApp>| (0..3).map(|p| rect(h, p).map(|r| r.min.y)).collect::<Vec<_>>();
|
||||
@@ -364,6 +365,7 @@ fn scroll_over(h: &mut Harness<'static, PdfCraftApp>, frames: usize) -> f32 {
|
||||
/// A two-finger swipe down the page, 25 points a frame for six frames, lifted the frame after
|
||||
/// (the axis stop Wayland sends), at 300 % so the document is long enough to glide in: how far
|
||||
/// it scrolled by the frame after the lift (the page is drawn a frame behind its scroll).
|
||||
/// Same gate as its callers below.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn swipe_and_lift(h: &mut Harness<'static, PdfCraftApp>) -> f32 {
|
||||
let at = h.state().views[0].viewport_rect().center();
|
||||
|
||||
@@ -857,6 +857,49 @@ fn an_earlier_revision_opens_from_document_properties() {
|
||||
let doc = s.session.get(s.views[s.active.unwrap()].id).unwrap();
|
||||
assert_eq!(doc.name, "updated (revision 1).pdf");
|
||||
assert_eq!(doc.info_value("Title"), None, "as it was before the update");
|
||||
// A repeat reuses the revision tab instead of opening another identical one.
|
||||
let views = s.views.len();
|
||||
let ids: Vec<_> = h.state().views.iter().map(|v| v.id).collect();
|
||||
let original = {
|
||||
let s = h.state();
|
||||
ids.iter().position(|id| s.session.get(*id).is_some_and(|d| d.name == "updated.pdf")).unwrap()
|
||||
};
|
||||
h.state_mut().active = Some(original);
|
||||
h.state_mut().open_revision(1);
|
||||
h.run_steps(1);
|
||||
let s = h.state();
|
||||
assert_eq!(s.views.len(), views);
|
||||
assert!(
|
||||
s.views[s.active.unwrap()]
|
||||
.snapshot
|
||||
.as_ref()
|
||||
.is_some_and(|snap| snap.kind == pdfcraft_ui_egui::canvas::SnapshotKind::Revision && snap.revision == 1)
|
||||
);
|
||||
// An unknown revision errors without opening a tab.
|
||||
h.state_mut().active = Some(original);
|
||||
h.state_mut().open_revision(99);
|
||||
h.run_steps(1);
|
||||
let s = h.state();
|
||||
assert_eq!(s.views.len(), views);
|
||||
assert!(h.state().toast.as_ref().is_some_and(|t| t.0.starts_with("Couldn't open revision 99")), "unknown revisions error without opening a tab");
|
||||
// Another tab over the same original shares the snapshot: b.pdf's first revision is
|
||||
// byte-identical to updated.pdf's (incremental saves append), so it reuses the tab.
|
||||
let ids: Vec<_> = h.state().views.iter().map(|v| v.id).collect();
|
||||
let other = {
|
||||
let s = h.state();
|
||||
ids.iter().position(|id| s.session.get(*id).is_some_and(|d| d.name == "b.pdf")).unwrap()
|
||||
};
|
||||
h.state_mut().active = Some(other);
|
||||
h.state_mut().open_revision(1);
|
||||
h.run_steps(1);
|
||||
let s = h.state();
|
||||
assert_eq!(s.views.len(), views);
|
||||
assert!(
|
||||
s.views[s.active.unwrap()]
|
||||
.snapshot
|
||||
.as_ref()
|
||||
.is_some_and(|snap| snap.kind == pdfcraft_ui_egui::canvas::SnapshotKind::Revision && snap.revision == 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+4
-2
@@ -3,6 +3,7 @@
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks on slow frames and coalesced text selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-15 · **Change:** minor (M2: sampled functions index a flat table, one image decode serves every zoom level, patch meshes copy less; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
@@ -123,9 +124,8 @@ total matches the parity estimate.
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.<br>Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Vertical-writing text is still refused, and the error no longer claims the text was under the mark (#1340). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
|
||||
| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.<br>Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
|
||||
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
|
||||
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
|
||||
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import |
|
||||
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
|
||||
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA |
|
||||
@@ -180,6 +180,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. |
|
||||
| 2026-10-15 | minor | M9 #1895: generic appearance rewriting deferred; separate validation overlays preserve original artwork, exports and printing. Baked captions remain unchanged (#79 open). Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M7 local candidate: text drafts grow and save their width; single visual rows grow across style boundaries; both text tools retype added items and Add Text edits source text. Added-item textless previews are read-only and covered by UI/tool regressions. Estimates unchanged. |
|
||||
@@ -191,6 +192,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
||||
| 2026-10-14 | minor | M14: Italian catalog completed (2,528 entries) and dropped from the ≈ 2,000–2,140-entry group; estimates unchanged |
|
||||
| 2026-10-12 | minor | M4 #1310 #1311 #1312: undo back to the opened file is clean, Revert returns to the last saved file, and the next save keeps that revision. Estimates unchanged. |
|
||||
| 2026-10-14 | minor | M5: reconciled PR #1457 with upstream main, preserving comment submission shortcuts and upstream roadmap progress. Estimates unchanged. |
|
||||
| 2026-10-15 | minor | M2: three vendored hayro-interpret patches (31–33): sampled functions index a flat table (`bug1721218` 1.34 → 0.49 s), one JPEG or Flate decode serves every size an image is drawn at (35–40% less render work zooming a scan), patch meshes copy their vertices less (10–16% on mesh-gradient pages); pixel for pixel the same over the pdf.js corpus. Estimates unchanged. |
|
||||
| 2026-10-13 | minor | M2/M6: images with at most 16 bits of samples a pixel (8-bit Separation/DeviceN, 1-4-bit gray or indexed) convert once per pixel value (hayro-interpret patch 28), so spot-colour form backgrounds draw in milliseconds instead of seconds; widgets without `/P` are placed in one pass; the Fields panel no longer searches the form per row and draws only the rows in view. Estimates unchanged. |
|
||||
| 2026-10-13 | minor | M3: native-open admission preserves modal/picker ownership; regressions cover queued replies and one-shot picker wakes. Estimates unchanged |
|
||||
| 2026-10-12 | minor | M13: CLI check directory identity, with overlap and symlink regressions; estimates unchanged |
|
||||
|
||||
@@ -4404,8 +4404,8 @@ milestone = "M2"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/render/src/raster.rs::widget_appearance_states"]
|
||||
notes = ""
|
||||
evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/engine/src/tests.rs::validated_signatures_without_layers_keep_their_appearance", "crates/engine/src/tests.rs::real_layered_signatures_keep_their_original_appearance", "crates/engine/src/tests.rs::real_layered_signatures_round_trip_behind_a_user_password", "crates/engine/src/tests.rs::a_metadata_edit_keeps_the_original_signature_appearance", "crates/engine/src/tests.rs::saving_after_an_edit_keeps_the_baked_appearance", "crates/engine/src/tests.rs::chamfered_signature_borders_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::mixed_signature_caption_details_survive_trust_exports_printing_and_save"]
|
||||
notes = "widgets retain their original artwork. Signature validation is a separate viewer page overlay; it is never written into document streams or included in image/text exports, vector printing or Print as Image. Saves retain signed appearance bytes. Generic in-place appearance rewriting is deferred: no supported producer template is positively identified, and caption text plus drawing uniqueness does not establish an icon role. Baked Not Verified captions remain unchanged; issue #79 is not fully resolved."
|
||||
|
||||
[[feature]]
|
||||
id = "form.fill-text"
|
||||
@@ -5812,8 +5812,8 @@ milestone = "M8"
|
||||
status = "partial"
|
||||
commands = []
|
||||
tools = ["doc_audit_redactions"]
|
||||
evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "apps/pdfcraft-cli/tests/audit.rs::audit_rectangles_match_the_tool_in_top_left_points"]
|
||||
notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover over less than 40% of both a line's box and its words (a short value in the middle of a long line) is still not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools."
|
||||
evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/audit/src/tests.rs::a_cover_over_one_word_of_a_long_line_is_found", "crates/audit/src/tests.rs::a_box_grazing_a_word_is_not_flagged", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "apps/pdfcraft-cli/tests/audit.rs::audit_rectangles_match_the_tool_in_top_left_points"]
|
||||
notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover hiding half of any one word counts too (#2662), so a box over one number in a long line, or an unapplied mark over it, is found; a sliver under half of every word it touches and under 40% of the line is not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools."
|
||||
|
||||
[[feature]]
|
||||
id = "protect.remove-hidden-on-close"
|
||||
@@ -6224,8 +6224,8 @@ milestone = "M9"
|
||||
status = "shipped"
|
||||
commands = ["sign.panel"]
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/ui-egui/tests/signing.rs::certifying_without_a_visible_signature"]
|
||||
notes = "signed/valid, problems, invalid and certified bars"
|
||||
evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting", "crates/ui-egui/tests/signing.rs::certifying_without_a_visible_signature", "crates/ui-egui/src/canvas.rs::signature_badges_keep_signed_visible_widgets_on_the_page"]
|
||||
notes = "signed/valid, problems, invalid and certified bars; visible signed widgets also carry a separate viewer validation overlay. Trust and explicit validation invalidate view caches without editing document artwork; baked appearance captions remain unchanged."
|
||||
|
||||
[[feature]]
|
||||
id = "sign.signature-properties"
|
||||
@@ -6333,7 +6333,7 @@ status = "shipped"
|
||||
commands = ["sign.panel"]
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/signing.rs::drawing_a_signature_creating_an_id_signing_and_trusting"]
|
||||
notes = ""
|
||||
notes = "repeats reuse the tab, whose name keeps the (signed version) suffix instead of the document title; the tab carries a notice bar naming the revision, with a Close that asks about saving first. Still missing: the snapshot is a full editable document rather than a read-only view, and there is no side-by-side compare (sign.compare-signed-version)"
|
||||
|
||||
[[feature]]
|
||||
id = "sign.compare-signed-version"
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+11
-1
@@ -72,7 +72,8 @@ pub(crate) struct DecodedImageCache {
|
||||
/// - the dictionary's bytes (`dict_hash`: `/Decode`, `/ColorSpace`, `/SMask`, `/Mask`,
|
||||
/// `/BitsPerComponent`, filters, …) and its object (`obj`: the decryption key depends on it);
|
||||
/// - whether it is decoded as a mask, a stencil mask, raster or mask output, interpolation,
|
||||
/// the decode target size, and preview or final quality.
|
||||
/// the decode target size (only for an image a JPX stream decodes, which is the only
|
||||
/// decode that reads it: PdfCraft patch 32), and preview or final quality.
|
||||
///
|
||||
/// A transfer function (graphics state `/TR`) also changes the pixels and has no cheap key, so
|
||||
/// an image drawn under one is not cached; neither is an inline image, whose dictionary lives in
|
||||
@@ -95,6 +96,8 @@ pub(crate) struct DecodedImageKey {
|
||||
struct DecodedImageCacheInner {
|
||||
map: HashMap<DecodedImageKey, Option<Box<dyn Any + Send + Sync>>>,
|
||||
bytes: usize,
|
||||
/// PdfCraft patch (32): how many keyed decodes ran (cache misses).
|
||||
decodes: usize,
|
||||
}
|
||||
|
||||
impl DecodedImageCache {
|
||||
@@ -105,6 +108,12 @@ impl DecodedImageCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// PdfCraft patch (32): how many decodes the cache was asked to keep (its misses), for
|
||||
/// diagnostics and tests. 0 when the lock is poisoned.
|
||||
pub(crate) fn decodes(&self) -> usize {
|
||||
self.inner.lock().map(|locked| locked.decodes).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The same cache, for a preview (`true`) or a final render.
|
||||
pub(crate) fn for_render(&self, preview: bool) -> Self {
|
||||
Self {
|
||||
@@ -141,6 +150,7 @@ impl DecodedImageCache {
|
||||
let Ok(mut locked) = self.inner.lock() else {
|
||||
return val;
|
||||
};
|
||||
locked.decodes = locked.decodes.saturating_add(1);
|
||||
// Another thread may have decoded it meanwhile: keep the first copy.
|
||||
if let Some(existing) = locked.map.get(&key)
|
||||
&& let Some(existing) = existing.as_ref().and_then(|val| val.downcast_ref::<T>())
|
||||
|
||||
+6
@@ -140,6 +140,12 @@ impl<'a> InterpreterCache<'a> {
|
||||
decoded_cache: DecodedImageCache::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// PdfCraft patch (32): how many images were decoded through this cache (its misses, not
|
||||
/// counting images that are never cached), for diagnostics and tests.
|
||||
pub fn decoded_image_decodes(&self) -> usize {
|
||||
self.decoded_cache.decodes()
|
||||
}
|
||||
}
|
||||
|
||||
/// A per-page interpretation context that borrows shared data from an [`InterpreterCache`].
|
||||
|
||||
Vendored
+66
-37
@@ -3,7 +3,7 @@
|
||||
use crate::color::{AlphaColor, ColorComponents, ColorSpace};
|
||||
use crate::interpret::state::ActiveTransferFunction;
|
||||
use crate::pattern::ShadingPattern;
|
||||
use crate::shading::{ShadingFunction, ShadingType, Triangle};
|
||||
use crate::shading::{ShadingFunction, ShadingType, Triangle, TriangleVertex};
|
||||
use kurbo::{Affine, Point};
|
||||
use rustc_hash::FxHashMap;
|
||||
use smallvec::{ToSmallVec, smallvec};
|
||||
@@ -102,7 +102,7 @@ impl ShadingPattern {
|
||||
}
|
||||
triangles.clear();
|
||||
patch.to_triangles(&mut triangles);
|
||||
if !sampler.sample(&triangles, full_transform) {
|
||||
if !sampler.sample_owned(&mut triangles, full_transform) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,7 @@ impl ShadingPattern {
|
||||
}
|
||||
triangles.clear();
|
||||
patch.to_triangles(&mut triangles);
|
||||
if !sampler.sample(&triangles, full_transform) {
|
||||
if !sampler.sample_owned(&mut triangles, full_transform) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -268,47 +268,62 @@ impl TriangleSampler {
|
||||
|
||||
/// Sample `triangles`; `false` once a budget is spent (the rest is left unsampled).
|
||||
fn sample(&mut self, triangles: &[Triangle], transform: Affine) -> bool {
|
||||
if !self.take_page_budget(triangles.len()) {
|
||||
return false;
|
||||
}
|
||||
triangles.iter().all(|t| {
|
||||
self.sample_one(transformed(
|
||||
t.p0.clone(),
|
||||
t.p1.clone(),
|
||||
t.p2.clone(),
|
||||
transform,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// PdfCraft patch (33): [`Self::sample`] for triangles made for this sampling alone (a
|
||||
/// patch's), moving their vertices instead of copying each one's colours (`triangles` is
|
||||
/// left empty).
|
||||
fn sample_owned(&mut self, triangles: &mut Vec<Triangle>, transform: Affine) -> bool {
|
||||
if !self.take_page_budget(triangles.len()) {
|
||||
return false;
|
||||
}
|
||||
triangles
|
||||
.drain(..)
|
||||
.all(|t| self.sample_one(transformed(t.p0, t.p1, t.p2, transform)))
|
||||
}
|
||||
|
||||
/// Take `count` triangles from the page's budget; `false` (and none left) when it is short.
|
||||
fn take_page_budget(&mut self, count: usize) -> bool {
|
||||
let page_left = MESH_TRIANGLES_LEFT.with(core::cell::Cell::get);
|
||||
if page_left < triangles.len() as u64 {
|
||||
if page_left < count as u64 {
|
||||
MESH_TRIANGLES_LEFT.with(|n| n.set(0));
|
||||
warn!("page mesh triangle budget exceeded; the rest of the shading is not drawn");
|
||||
return false;
|
||||
}
|
||||
MESH_TRIANGLES_LEFT.with(|n| n.set(page_left - triangles.len() as u64));
|
||||
for t in triangles {
|
||||
let t = {
|
||||
let p0 = transform * t.p0.point;
|
||||
let p1 = transform * t.p1.point;
|
||||
let p2 = transform * t.p2.point;
|
||||
MESH_TRIANGLES_LEFT.with(|n| n.set(page_left - count as u64));
|
||||
true
|
||||
}
|
||||
|
||||
let mut v0 = t.p0.clone();
|
||||
v0.point = p0;
|
||||
let mut v1 = t.p1.clone();
|
||||
v1.point = p1;
|
||||
let mut v2 = t.p2.clone();
|
||||
v2.point = p2;
|
||||
/// Sample one triangle in device space; `false` once the visit budget is spent.
|
||||
fn sample_one(&mut self, t: Triangle) -> bool {
|
||||
// Only the part of the triangle inside `area`; only such triangles add visits.
|
||||
let bbox = t.bounding_box().intersect(self.area);
|
||||
if bbox.width() <= 0.0 || bbox.height() <= 0.0 {
|
||||
return true;
|
||||
}
|
||||
self.visits_left = self.visits_left.saturating_add(MESH_VISITS_PER_TRIANGLE);
|
||||
|
||||
Triangle::new(v0, v1, v2)
|
||||
};
|
||||
|
||||
// Only the part of the triangle inside `area`; only such triangles add visits.
|
||||
let bbox = t.bounding_box().intersect(self.area);
|
||||
if bbox.width() <= 0.0 || bbox.height() <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
self.visits_left = self.visits_left.saturating_add(MESH_VISITS_PER_TRIANGLE);
|
||||
|
||||
for y in (bbox.y0.floor() as u16)..(bbox.y1.ceil() as u16) {
|
||||
for x in (bbox.x0.floor() as u16)..(bbox.x1.ceil() as u16) {
|
||||
if self.visits_left == 0 {
|
||||
warn!("mesh shading visit budget exceeded; the rest is not drawn");
|
||||
return false;
|
||||
}
|
||||
self.visits_left -= 1;
|
||||
let point = Point::new(x as f64, y as f64);
|
||||
if t.contains_point(point) {
|
||||
self.map.insert((x, y), t.interpolate(point));
|
||||
}
|
||||
for y in (bbox.y0.floor() as u16)..(bbox.y1.ceil() as u16) {
|
||||
for x in (bbox.x0.floor() as u16)..(bbox.x1.ceil() as u16) {
|
||||
if self.visits_left == 0 {
|
||||
warn!("mesh shading visit budget exceeded; the rest is not drawn");
|
||||
return false;
|
||||
}
|
||||
self.visits_left -= 1;
|
||||
let point = Point::new(x as f64, y as f64);
|
||||
if t.contains_point(point) {
|
||||
self.map.insert((x, y), t.interpolate(point));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +331,20 @@ impl TriangleSampler {
|
||||
}
|
||||
}
|
||||
|
||||
/// The triangle `v0`, `v1`, `v2` with its points mapped through `transform` (its colours as
|
||||
/// they are).
|
||||
fn transformed(
|
||||
mut v0: TriangleVertex,
|
||||
mut v1: TriangleVertex,
|
||||
mut v2: TriangleVertex,
|
||||
transform: Affine,
|
||||
) -> Triangle {
|
||||
v0.point = transform * v0.point;
|
||||
v1.point = transform * v1.point;
|
||||
v2.point = transform * v2.point;
|
||||
Triangle::new(v0, v1, v2)
|
||||
}
|
||||
|
||||
fn encode_function_shading(domain: &[f32; 4], function: &ShadingFunction) -> EncodedShadingType {
|
||||
let domain = kurbo::Rect::new(
|
||||
domain[0] as f64,
|
||||
|
||||
+68
-175
@@ -3,14 +3,18 @@ use hayro_syntax::bit_reader::BitReader;
|
||||
use hayro_syntax::object::Array;
|
||||
use hayro_syntax::object::Stream;
|
||||
use hayro_syntax::object::dict::keys::{BITS_PER_SAMPLE, DECODE, ENCODE, SIZE};
|
||||
use smallvec::{SmallVec, ToSmallVec, smallvec};
|
||||
use std::collections::HashMap;
|
||||
use smallvec::SmallVec;
|
||||
|
||||
/// A type 0 function (sampled function).
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Type0 {
|
||||
sizes: IntVec,
|
||||
table: HashMap<Key, IntVec>,
|
||||
/// PdfCraft patch (31): the samples in stream order, `range.len()` to a grid point, the
|
||||
/// first input varying fastest. They were a hash map keyed by each grid point, built again
|
||||
/// every time a shading using the function was painted.
|
||||
samples: Vec<u32>,
|
||||
/// PdfCraft patch (31): how many grid points one step along each input moves.
|
||||
strides: SmallVec<[usize; 4]>,
|
||||
clamper: Clamper,
|
||||
range: TupleVec,
|
||||
bits_per_sample: u8,
|
||||
@@ -85,14 +89,22 @@ impl Type0 {
|
||||
data.truncate(num_expected_entries);
|
||||
}
|
||||
|
||||
let table = build_table(&data, &sizes, range.len())?;
|
||||
// Saturating: the grid has at most `MAX_SAMPLE_ENTRIES` points unless a size is 0, and
|
||||
// then no point is on it (`entry` checks each coordinate against its size first).
|
||||
let mut strides = SmallVec::with_capacity(sizes.len());
|
||||
let mut stride = 1usize;
|
||||
for size in &sizes {
|
||||
strides.push(stride);
|
||||
stride = stride.saturating_mul(*size as usize);
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
sizes,
|
||||
clamper,
|
||||
range,
|
||||
bits_per_sample,
|
||||
table,
|
||||
samples: data,
|
||||
strides,
|
||||
encode,
|
||||
decode,
|
||||
})
|
||||
@@ -122,29 +134,16 @@ impl Type0 {
|
||||
|
||||
let in_prev = key.iter().map(|v| v.floor() as u32).collect::<IntVec>();
|
||||
let in_next = key.iter().map(|v| v.ceil() as u32).collect::<IntVec>();
|
||||
let mut coord: IntVec = in_prev.clone();
|
||||
|
||||
let interpolator = Interpolator::new(
|
||||
key.clone().to_smallvec(),
|
||||
in_prev,
|
||||
in_next,
|
||||
self.sizes.clone(),
|
||||
self.range.len(),
|
||||
);
|
||||
|
||||
let interpolated = interpolator.interpolate(&self.table)?;
|
||||
let interpolated = self.interpolate(&key, &in_prev, &in_next, &mut coord, 0)?;
|
||||
|
||||
// PdfCraft patch (31): `2_u32.pow(32)` overflowed (a panic with overflow checks on).
|
||||
let max_sample = ((1u64 << self.bits_per_sample) - 1) as f32;
|
||||
let mut out = interpolated
|
||||
.iter()
|
||||
.zip(self.decode.iter())
|
||||
.map(|(x, decode)| {
|
||||
interpolate(
|
||||
*x,
|
||||
0.0,
|
||||
(2_u32.pow(self.bits_per_sample as u32) - 1) as f32,
|
||||
decode.0,
|
||||
decode.1,
|
||||
)
|
||||
})
|
||||
.map(|(x, decode)| interpolate(*x, 0.0, max_sample, decode.0, decode.1))
|
||||
.collect::<SmallVec<_>>();
|
||||
|
||||
self.clamper.clamp_output(&mut out);
|
||||
@@ -157,95 +156,57 @@ type FloatVec = SmallVec<[f32; 4]>;
|
||||
type IntVec = SmallVec<[u32; 4]>;
|
||||
|
||||
// See <https://github.com/apache/pdfbox/blob/bb778d4784f354c36ce032e91a0cee2169a4c598/pdfbox/src/main/java/org/apache/pdfbox/pdmodel/common/function/PDFunctionType0.java#L252>
|
||||
struct Interpolator {
|
||||
input: FloatVec,
|
||||
sizes: IntVec,
|
||||
in_prev: IntVec,
|
||||
in_next: IntVec,
|
||||
out_len: usize,
|
||||
}
|
||||
|
||||
impl Interpolator {
|
||||
fn new(
|
||||
input: FloatVec,
|
||||
in_prev: IntVec,
|
||||
in_next: IntVec,
|
||||
sizes: IntVec,
|
||||
out_len: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
input,
|
||||
in_prev,
|
||||
in_next,
|
||||
sizes,
|
||||
out_len,
|
||||
}
|
||||
}
|
||||
|
||||
fn interpolate(&self, table: &HashMap<Key, IntVec>) -> Option<FloatVec> {
|
||||
self.interpolate_inner(smallvec![0; self.input.len()], 0, table)
|
||||
}
|
||||
|
||||
fn interpolate_inner(
|
||||
impl Type0 {
|
||||
/// PdfCraft patch (31): the value at `input` between the grid points `in_prev` and
|
||||
/// `in_next`, interpolating the inputs from `step` on (the earlier ones are placed in
|
||||
/// `coord`). The same multilinear interpolation, in the same order, as the hash-map
|
||||
/// version, so the results are bit for bit the same; one level of recursion per input,
|
||||
/// as before.
|
||||
fn interpolate(
|
||||
&self,
|
||||
mut coord: IntVec,
|
||||
input: &[f32],
|
||||
in_prev: &[u32],
|
||||
in_next: &[u32],
|
||||
coord: &mut IntVec,
|
||||
step: usize,
|
||||
table: &HashMap<Key, IntVec>,
|
||||
) -> Option<FloatVec> {
|
||||
if step == self.input.len() - 1 {
|
||||
if self.in_prev[step] == self.in_next[step] {
|
||||
coord[step] = self.in_prev[step];
|
||||
let (Some(x), Some(prev), Some(next)) =
|
||||
(input.get(step), in_prev.get(step), in_next.get(step))
|
||||
else {
|
||||
// Every input placed: the grid point itself.
|
||||
return Some(self.entry(coord)?.iter().map(|n| *n as f32).collect());
|
||||
};
|
||||
let (x, prev, next) = (*x, *prev, *next);
|
||||
|
||||
Some(
|
||||
table
|
||||
.get(&Key::from_raw(&self.sizes, &coord))?
|
||||
.clone()
|
||||
.iter()
|
||||
.map(|n| *n as f32)
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
coord[step] = self.in_prev[step];
|
||||
let val1 = table.get(&Key::from_raw(&self.sizes, &coord))?;
|
||||
coord[step] = self.in_next[step];
|
||||
let val2 = table.get(&Key::from_raw(&self.sizes, &coord))?;
|
||||
let mut out = smallvec![0.0; self.out_len];
|
||||
|
||||
for i in 0..self.out_len {
|
||||
out[i] = interpolate(
|
||||
self.input[step],
|
||||
self.in_prev[step] as f32,
|
||||
self.in_next[step] as f32,
|
||||
val1[i] as f32,
|
||||
val2[i] as f32,
|
||||
);
|
||||
}
|
||||
|
||||
Some(out)
|
||||
}
|
||||
} else if self.in_prev[step] == self.in_next[step] {
|
||||
coord[step] = self.in_prev[step];
|
||||
self.interpolate_inner(coord, step + 1, table)
|
||||
} else {
|
||||
coord[step] = self.in_prev[step];
|
||||
let val1 = self.interpolate_inner(coord.clone(), step + 1, table)?;
|
||||
coord[step] = self.in_next[step];
|
||||
let val2 = self.interpolate_inner(coord, step + 1, table)?;
|
||||
|
||||
let mut out = smallvec![0.0; self.out_len];
|
||||
|
||||
for i in 0..self.out_len {
|
||||
out[i] = interpolate(
|
||||
self.input[step],
|
||||
self.in_prev[step] as f32,
|
||||
self.in_next[step] as f32,
|
||||
val1[i],
|
||||
val2[i],
|
||||
);
|
||||
}
|
||||
|
||||
Some(out)
|
||||
*coord.get_mut(step)? = prev;
|
||||
let val1 = self.interpolate(input, in_prev, in_next, coord, step + 1)?;
|
||||
if prev == next {
|
||||
return Some(val1);
|
||||
}
|
||||
*coord.get_mut(step)? = next;
|
||||
let val2 = self.interpolate(input, in_prev, in_next, coord, step + 1)?;
|
||||
|
||||
Some(
|
||||
val1.iter()
|
||||
.zip(val2.iter())
|
||||
.map(|(a, b)| interpolate(x, prev as f32, next as f32, *a, *b))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// PdfCraft patch (31): the samples of grid point `coord`, or `None` where it is off the
|
||||
/// grid or the stream ended before it (where the hash map had no entry).
|
||||
fn entry(&self, coord: &[u32]) -> Option<&[u32]> {
|
||||
let mut index = 0usize;
|
||||
for ((c, size), stride) in coord.iter().zip(&self.sizes).zip(&self.strides) {
|
||||
if c >= size {
|
||||
return None;
|
||||
}
|
||||
index = index.checked_add((*c as usize).checked_mul(*stride)?)?;
|
||||
}
|
||||
let n = self.range.len();
|
||||
let start = index.checked_mul(n)?;
|
||||
self.samples.get(start..start.checked_add(n)?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,71 +218,3 @@ impl Interpolator {
|
||||
/// values, 17^5 points with 4 outputs 5.7 million values.
|
||||
const MAX_SAMPLE_VALUES: usize = 1 << 23;
|
||||
const MAX_SAMPLE_ENTRIES: usize = 1 << 21;
|
||||
|
||||
fn build_table(data: &[u32], sizes: &[u32], n: usize) -> Option<HashMap<Key, IntVec>> {
|
||||
let mut key = Key::new(sizes);
|
||||
let mut table = HashMap::new();
|
||||
|
||||
let mut first = true;
|
||||
|
||||
for b in data.chunks_exact(n) {
|
||||
if !first {
|
||||
key.increment();
|
||||
}
|
||||
|
||||
table.insert(key.clone(), b.to_smallvec());
|
||||
|
||||
first = false;
|
||||
}
|
||||
|
||||
Some(table)
|
||||
}
|
||||
|
||||
/// A sampled function consists of a (possibly) multi-dimensional table that we can index
|
||||
/// into. We do this by representing the entries as a flat list of vectors, where each
|
||||
/// element in the vector represents the value of the key in that specific dimension.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct Key {
|
||||
sizes: SmallVec<[u32; 4]>,
|
||||
parts: SmallVec<[u32; 4]>,
|
||||
}
|
||||
|
||||
impl Key {
|
||||
fn new(sizes: &[u32]) -> Self {
|
||||
let parts = smallvec![0; sizes.len()];
|
||||
|
||||
Self {
|
||||
sizes: sizes.to_smallvec(),
|
||||
parts,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_raw(sizes: &[u32], parts: &[u32]) -> Self {
|
||||
Self {
|
||||
sizes: sizes.to_smallvec(),
|
||||
parts: parts.to_smallvec(),
|
||||
}
|
||||
}
|
||||
|
||||
fn increment(&mut self) -> Option<()> {
|
||||
self.increment_index(0)
|
||||
}
|
||||
|
||||
fn increment_index(&mut self, index: usize) -> Option<()> {
|
||||
let size = *self.sizes.get(index).or_else(|| {
|
||||
error!("overflowed key in sampled function");
|
||||
|
||||
None
|
||||
})?;
|
||||
let val = self.parts.get_mut(index)?;
|
||||
|
||||
if *val >= (size - 1) {
|
||||
*val = 0;
|
||||
self.increment_index(index + 1)?;
|
||||
} else {
|
||||
*val += 1;
|
||||
}
|
||||
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
|
||||
+13
-21
@@ -731,6 +731,10 @@ where
|
||||
{
|
||||
const GRID_SIZE: usize = 20;
|
||||
let mut grid = vec![vec![Point::ZERO; GRID_SIZE]; GRID_SIZE];
|
||||
// PdfCraft patch (33): each grid point's colours, interpolated once. Every cell below
|
||||
// interpolated its four corners itself, so an inner point was interpolated four times
|
||||
// (1,444 interpolations a patch instead of 400) for the same values.
|
||||
let mut colors = vec![vec![ColorComponents::new(); GRID_SIZE]; GRID_SIZE];
|
||||
|
||||
// Create grid by mapping unit square coordinates.
|
||||
for i in 0..GRID_SIZE {
|
||||
@@ -741,6 +745,7 @@ where
|
||||
// Map unit square coordinate to patch coordinate.
|
||||
let unit_point = Point::new(u, v);
|
||||
grid[i][j] = map_coordinate(unit_point);
|
||||
colors[i][j] = interpolate(unit_point);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -751,32 +756,26 @@ where
|
||||
let p01 = grid[i][j + 1];
|
||||
let p11 = grid[i + 1][j + 1];
|
||||
|
||||
// Calculate unit square coordinates for color interpolation.
|
||||
let u0 = i as f64 / (GRID_SIZE - 1) as f64;
|
||||
let u1 = (i + 1) as f64 / (GRID_SIZE - 1) as f64;
|
||||
let v0 = j as f64 / (GRID_SIZE - 1) as f64;
|
||||
let v1 = (j + 1) as f64 / (GRID_SIZE - 1) as f64;
|
||||
|
||||
// Create triangle vertices with interpolated colors.
|
||||
// Create triangle vertices with their grid points' colors.
|
||||
let v00 = TriangleVertex {
|
||||
flag: 0,
|
||||
point: p00,
|
||||
colors: interpolate(Point::new(u0, v0)),
|
||||
colors: colors[i][j].clone(),
|
||||
};
|
||||
let v10 = TriangleVertex {
|
||||
flag: 0,
|
||||
point: p10,
|
||||
colors: interpolate(Point::new(u1, v0)),
|
||||
colors: colors[i + 1][j].clone(),
|
||||
};
|
||||
let v01 = TriangleVertex {
|
||||
flag: 0,
|
||||
point: p01,
|
||||
colors: interpolate(Point::new(u0, v1)),
|
||||
colors: colors[i][j + 1].clone(),
|
||||
};
|
||||
let v11 = TriangleVertex {
|
||||
flag: 0,
|
||||
point: p11,
|
||||
colors: interpolate(Point::new(u1, v1)),
|
||||
colors: colors[i + 1][j + 1].clone(),
|
||||
};
|
||||
|
||||
let inflate_point = |p: Point, mid: Point| -> Point {
|
||||
@@ -795,16 +794,9 @@ where
|
||||
triangle
|
||||
};
|
||||
|
||||
buffer.push(inflate(Triangle::new(
|
||||
v00.clone(),
|
||||
v10.clone(),
|
||||
v01.clone(),
|
||||
)));
|
||||
buffer.push(inflate(Triangle::new(
|
||||
v10.clone(),
|
||||
v11.clone(),
|
||||
v01.clone(),
|
||||
)));
|
||||
// PdfCraft patch (33): the second triangle takes the vertices the first copied.
|
||||
buffer.push(inflate(Triangle::new(v00, v10.clone(), v01.clone())));
|
||||
buffer.push(inflate(Triangle::new(v10, v11, v01)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
-1
@@ -434,11 +434,28 @@ impl<'a> ImageXObject<'a> {
|
||||
is_mask: self.is_mask,
|
||||
is_stencil_mask: self.is_stencil_mask,
|
||||
interpolate: self.interpolate,
|
||||
target: target_dimension,
|
||||
// PdfCraft patch (32): see `decode_reads_target`.
|
||||
target: if self.decode_reads_target() {
|
||||
target_dimension
|
||||
} else {
|
||||
None
|
||||
},
|
||||
preview: cache.preview,
|
||||
})
|
||||
}
|
||||
|
||||
/// PdfCraft patch (32): whether decoding this image depends on the size it is drawn at.
|
||||
/// Only the JPX filter decodes at a reduced resolution for a target size, in the image
|
||||
/// itself or in the soft mask or mask decoded with it; every other filter decodes the whole
|
||||
/// image whatever the target. Keyed by the target, a scan was decoded again at every zoom
|
||||
/// step and kept once per zoom level; without it, one decode serves them all.
|
||||
fn decode_reads_target(&self) -> bool {
|
||||
let dict = self.stream.dict();
|
||||
uses_jpx(&self.stream)
|
||||
|| dict.get::<Stream<'_>>(SMASK).is_some_and(|s| uses_jpx(&s))
|
||||
|| dict.get::<Stream<'_>>(MASK).is_some_and(|s| uses_jpx(&s))
|
||||
}
|
||||
|
||||
pub(crate) fn width(&self) -> u32 {
|
||||
self.width
|
||||
}
|
||||
@@ -467,6 +484,20 @@ fn decoded_mask_size(mask: &DecodedMask) -> usize {
|
||||
mask.luma.width as usize * mask.luma.height as usize
|
||||
}
|
||||
|
||||
/// PdfCraft patch (32): whether `stream` names the JPX filter, as a name or in an array, under
|
||||
/// `/Filter` or its inline abbreviation `/F`. Errs towards `true`: a name the decoder would
|
||||
/// ignore still counts, which only keeps the target in the key as before.
|
||||
fn uses_jpx(stream: &Stream<'_>) -> bool {
|
||||
let dict = stream.dict();
|
||||
let is_jpx = |name: &Name<'_>| name.deref() == JPX_DECODE;
|
||||
[F, FILTER].into_iter().any(|key| {
|
||||
dict.get::<Name<'_>>(key).is_some_and(|name| is_jpx(&name))
|
||||
|| dict
|
||||
.get::<Array<'_>>(key)
|
||||
.is_some_and(|filters| filters.iter::<Name<'_>>().any(|name| is_jpx(&name)))
|
||||
})
|
||||
}
|
||||
|
||||
/// PdfCraft patch: pixel bytes held by a decoded raster image and its mask.
|
||||
fn decoded_raster_size(raster: &DecodedRaster) -> usize {
|
||||
let image = match &raster.image {
|
||||
|
||||
Reference in New Issue
Block a user