fix(ui): validated signature display, snapshot tab reuse (#1895)

* fix(ui): validated signature display, snapshot tab reuse

Signature widgets with layered appearances baked the unverified state
into the page, so a valid signature kept reading 'Signature Not Verified'
while the banner and panel said valid. Validated verdicts are now drawn
into the icon and caption layers in the display copy only: the signed
bytes are untouched, saving keeps the original appearance, and files
without those layers or without a valid verdict render exactly as before.

Other valid signatures still get the verdict mark over their widget,
sharing one style with the Signatures panel, and hide it again once the
appearance itself shows the verdict.

View signed version and View revision opened a new tab on every click,
and every tab showed the document title, so repeats piled up identical
tabs. Repeats now reuse the snapshot tab, snapshot tabs read like their
source plus the suffix in the tab strip, window title and Combine picker,
and carry a notice bar naming the revision with a Close that asks about
saving first.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* Address review comments on validated appearances and trust refresh

Count every field when checking shared appearance streams, so a
caption shared with an unvalidated field is never rewritten for the
valid one alone.

Pick the icon slot by drawing instead of length, and only when a
single layer qualifies. Bare rectangles never count as the icon, and
ambiguous or missing slots fall back to the overlay badge.

Revalidate and invalidate together through one helper, so trusting a
certificate always drops rendered pages. Trust alone still marks
nothing dirty.

Tests: mixed-verdict sharing, longer decorative borders, competing
and doubled layers, capture-before preservation, trust invalidation
without zooming, and signed Session round trips with rendering,
saving and passwords.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* Prove the icon slot geometrically and cover real layered signatures

A lone line drawing is still not the icon: frames and borders span the
box while the status mark lives in a corner of it. The icon now has to
sit strictly inside the appearance frame and cover at most half of it;
anything else falls back to the overlay badge. Widgets without a
measurable frame veto the rewrite the same way.

Tests: parsed extents and inhabitant checks as units, both reported
border repros plus a frameless widget, and real layered signatures
built with cms::sign_detached through Session open, trust, rendering,
save and reopen, plain and password-protected.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* Identify the icon by shape instead of geometry

A lone line drawing is still not the icon, but size and position were
the wrong signals: the real status mark is large, and geometry blocked
it. Frames, borders and panels are rectangles by nature while the mark
is not, so a drawing made only of axis-aligned rectangles is decoration
however it is stroked. Singularity, shared-stream and template checks
are unchanged; anything ambiguous still falls back to the overlay badge.

Tests: rectangularity as a unit, nested line-drawn frames, and a
triangular fixture mark in place of the rectangular one.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* Budget the appearance walk and keep the marks with the bytes

The walk that finds a signature's icon and caption capped depth,
layers and fields, but not the work: a dictionary whose entries all
point at the next one is visited once per entry per level, so a small
hostile file never finishes opening. The walk now counts its visits
and gives up, and a field whose tree cannot be read to the end stops
the whole pass, because a layer we never read may be the one two
fields share and a rewritten shared layer puts one field's verdict on
another field's page. The sharing count is exact again with that: two
widgets of one field pointing at the same appearance claim it once,
or the field vetoes its own rewrite.

A mark used to outlive the bytes it described. A failed display write
left the page showing the file's own caption while the verdict still
claimed its state was drawn; a metadata edit dropped the mark while
the page kept drawing the state, so the badge landed on top of it. The
write fallback now drops the marks and logs the loss, and metadata
carries them over.

Tests: a self-referential appearance tree, which never finished before
the budget, two widgets sharing one appearance, a metadata edit that
keeps its mark, a save that goes through the writer instead of
short-circuiting on the opened file, and the snapshot bar's Close
button pressed for real.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* Cite the new regressions in parity, restore main's installer note

The appearance entry now cites the bounded walk and the save through
the writer, and says what a tree that cannot be read to the end does.
The installer entry carried main's older text, which still claims
signed installers are planned; main's newer note is taken verbatim.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: preserve rounded decoration beside raster status glyphs

* M9: place appearance progress beside signature history

* M9: retain appearances with ambiguous curved layers

* M9: keep signature verdicts in viewer overlays

Defer generic appearance rewriting: caption text and a unique drawing do not establish an icon role. Preserve original artwork for saves, image and text exports, and both print modes while retaining snapshot reuse and trust refresh. Cover chamfered borders and mixed signer/caption streams through signed Session round trips.

---------

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
This commit is contained in:
Chiranjeevi U
2026-10-15 19:16:48 -06:00
committed by GitHub
co-authored by skylar taylor-barrick
parent f65f733041
commit 5fdd820bdb
38 changed files with 993 additions and 98 deletions
+1 -1
View File
@@ -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
View File
@@ -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"
+2
View File
@@ -287,6 +287,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.
@@ -494,6 +495,7 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
| 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. |
+4 -1
View File
@@ -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
View File
@@ -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());
}
}
+394
View File
@@ -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() {
+1 -1
View File
@@ -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(),
+237 -6
View File
@@ -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)]);
+5 -4
View File
@@ -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(
+1 -1
View File
@@ -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| {
+1 -1
View File
@@ -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)),
+3
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+4
View File
@@ -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.
+2 -3
View File
@@ -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 {
+1 -1
View File
@@ -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);
+93 -14
View File
@@ -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();
+1 -1
View File
@@ -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));
+58 -1
View File
@@ -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);
+2
View File
@@ -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();
+43
View File
@@ -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]
+2 -2
View File
@@ -124,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 +179,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| Date | Change | Summary |
|---|---|---|
| 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. |
+5 -5
View File
@@ -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"
@@ -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"