Merge branch 'main' into fix/csv-tab-field-names

This commit is contained in:
Mex
2026-10-18 11:14:16 +02:00
committed by GitHub
121 changed files with 5421 additions and 720 deletions
+33
View File
@@ -0,0 +1,33 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
nix flake check --no-build --all-systems
else
echo "No flake.nix present in this repository; skipping check."
fi
+1 -1
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 |
+33 -32
View File
@@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "cefa955aa92790d6773cb1d1e15e19019b3165a00528d57d6617362e1d1988ab"
sha256 = "a3234684d5318ce38213c79533d3eebb9c635497ba9e00fb9ca7ebe2fad9d73d"
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
@@ -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 = "c5e0206846b009bcb366d092c3d64fd8b9e42d29d5406fb73ae36ecb1f2083d5"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "0b0dafa3806a6b3508684f64e1f07b435113f8efd0e59201d12250c63c1d9547"
sha256 = "349a2ead371bdbfeefb9ab2279c676b205cefe163cbef174f8123fe4bf739ab9"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.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 = "cb332907792d4aa760ca9862160e24a7b469afdbcfb93235e99deb8d58604ae2"
[[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 = "ebd3a12e471ba9d9a69e6532fbac2d027dec3c91b79cdfbb1c6c386d13de3bd6"
[[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 = "f5e0553165ab0223bfbc809b1630170d1af11d6546ae5a682d4090104b6e4a05"
[[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 = "dd2171a0a940a84e512fd6408a11cddf104be897ec4ab670c5ef6f1781ccb671"
[[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 = "1b9b3d0d6dab626c3acb8c9aa64d64e56b98eb3f2faf71dcb1957067e72706db"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "989d2e5f57cf22c21a85951835ca9fbd05e314da67eb4c17332df1f03679fa00"
sha256 = "668f23f4095120343e3f0910dc8f5844765370fae40400c48d685cad20dcf60e"
[[asset]]
path = "crates/ui-egui/src/i18n/it.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 = "02f3fd4b2721b0ea9c01c99d477a0339a28180514568179d3fc420e7fb81c671"
[[asset]]
path = "crates/ui-egui/src/i18n/pl.tsv"
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "988bcf2e0322a46c0464ff7b91ab5d90193b0ea7f454812d12595eb843de6fc7"
sha256 = "26eb438c309d34e55eba959a21f139db91fa5fd1d6d4b7c3d1cd0fa79a60a5b0"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.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 = "c04b6d0320bce999b33bd189bffcec8a196d4169e56cc65675dc920c5a671dac"
[[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 = "37b042977d934b0b550e5f52faa5d90abbcb9e7ff61ff9588021610ce709e474"
[[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 = "3d1f1d2558eec1271fa653bc38616feeefc6559deeb2a0cdbf07d67634e9ac97"
[[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 = "03071249bd6c13f79da72262415e484fb1aa36098a37a49094b9dab58cebc4e2"
[[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 = "c0ba73b27b95b275f5eebc040ab1c3c6c3b227c63e1c637b4cdb27d0a9264a8b"
[[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 = "e3af76714ee9899328a453ec83195f4dc462fe48ba445fbe3688c777e74bc9dc"
[[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 = "0855d7f785c433799da83400ebc932f987eb543008486bc4f553aadb4102c2ba"
[[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 = "884ab18d60db52f65cd0e30530e196c73c0e627f450bc948517e2f4b7deeb04b"
[[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 = "c805a2c6da08e77a9a7455856d82c168cf6f353d96e1dc140fcc8898f12ba538"
[[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 = "11619064ae9edaeee5d601b7008e40095a40375a3e1a298155d59cb758fe7d34"
[[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 = "89f2c7495cfb0fc9d36d35b95c056dbeda18ccae521250babac719ff5df62d89"
[[asset]]
path = "vendor/vello_common/assets/probe.png"
title = "vello_common probe reference image (rendered from probe.rgba)"
author = "Linebender vello contributors"
Generated
+42 -36
View File
@@ -1142,6 +1142,11 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "craft-print"
version = "0.1.0"
@@ -3933,7 +3938,7 @@ dependencies = [
[[package]]
name = "pdfcraft"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"craft-quit",
"eframe",
@@ -3958,7 +3963,7 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3970,7 +3975,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -3981,7 +3986,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3992,7 +3997,7 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"base64 0.22.1",
"flate2",
@@ -4013,7 +4018,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cli"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"base64 0.22.1",
"pdfcraft-audit",
@@ -4029,18 +4034,18 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
version = "0.8.0"
version = "0.9.0"
[[package]]
name = "pdfcraft-content"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-cos"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"hayro-syntax",
"pdfcraft-crypt",
@@ -4054,7 +4059,7 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"encoding_rs",
"hayro-syntax",
@@ -4070,7 +4075,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"aes",
"md-5",
@@ -4082,7 +4087,7 @@ dependencies = [
[[package]]
name = "pdfcraft-edit"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"hayro-syntax",
"kurbo 0.13.1",
@@ -4097,9 +4102,10 @@ dependencies = [
[[package]]
name = "pdfcraft-engine"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"chrono",
"craft-docpath",
"icu_properties",
"idna",
"image",
@@ -4135,14 +4141,14 @@ dependencies = [
[[package]]
name = "pdfcraft-export"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"flate2",
"proptest",
@@ -4152,7 +4158,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -4162,7 +4168,7 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4174,18 +4180,18 @@ dependencies = [
[[package]]
name = "pdfcraft-geom"
version = "0.8.0"
version = "0.9.0"
[[package]]
name = "pdfcraft-js"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"boa_engine",
]
[[package]]
name = "pdfcraft-measure"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4198,14 +4204,14 @@ dependencies = [
[[package]]
name = "pdfcraft-model"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"ocrs",
"pdfcraft-fonts",
@@ -4215,7 +4221,7 @@ dependencies = [
[[package]]
name = "pdfcraft-optimize"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"image",
"pdfcraft-annot",
@@ -4228,11 +4234,11 @@ dependencies = [
[[package]]
name = "pdfcraft-oracle"
version = "0.8.0"
version = "0.9.0"
[[package]]
name = "pdfcraft-organize"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4243,7 +4249,7 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"async-channel",
"futures-lite",
@@ -4253,7 +4259,7 @@ dependencies = [
[[package]]
name = "pdfcraft-preflight"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -4262,7 +4268,7 @@ dependencies = [
[[package]]
name = "pdfcraft-print"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"craft-print",
"pdfcraft-content",
@@ -4273,7 +4279,7 @@ dependencies = [
[[package]]
name = "pdfcraft-redact"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4289,7 +4295,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"bytemuck",
"flate2",
@@ -4308,7 +4314,7 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"aes",
"aws-lc-rs",
@@ -4342,11 +4348,11 @@ dependencies = [
[[package]]
name = "pdfcraft-testkit"
version = "0.8.0"
version = "0.9.0"
[[package]]
name = "pdfcraft-ui-egui"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"arboard",
"base64 0.22.1",
@@ -4390,7 +4396,7 @@ dependencies = [
[[package]]
name = "pdfcraft-web"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"eframe",
"egui",
@@ -4402,7 +4408,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"base64 0.22.1",
"image",
@@ -4416,7 +4422,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfdf"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
+36 -34
View File
@@ -5,7 +5,7 @@ exclude = ["vendor"]
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
[workspace.package]
version = "0.8.0"
version = "0.9.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -18,40 +18,42 @@ craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0",
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" }
# Shared system spooler; document composition remains in pdfcraft-print.
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" }
# Whether a path stored in a document may be opened (GoToR link targets).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# internal
pdfcraft-geom = { path = "crates/geom", version = "0.8.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.8.0" }
pdfcraft-cos = { path = "crates/cos", version = "0.8.0" }
pdfcraft-crypt = { path = "crates/crypt", version = "0.8.0" }
pdfcraft-organize = { path = "crates/organize", version = "0.8.0" }
pdfcraft-measure = { path = "crates/measure", version = "0.8.0" }
pdfcraft-annot = { path = "crates/annot", version = "0.8.0" }
pdfcraft-audit = { path = "crates/audit", version = "0.8.0" }
pdfcraft-fonts = { path = "crates/fonts", version = "0.8.0" }
pdfcraft-model = { path = "crates/model", version = "0.8.0" }
pdfcraft-content = { path = "crates/content", version = "0.8.0" }
pdfcraft-redact = { path = "crates/redact", version = "0.8.0" }
pdfcraft-print = { path = "crates/print", version = "0.8.0" }
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.8.0" }
pdfcraft-sign = { path = "crates/sign", version = "0.8.0" }
pdfcraft-optimize = { path = "crates/optimize", version = "0.8.0" }
pdfcraft-a11y = { path = "crates/a11y", version = "0.8.0" }
pdfcraft-oracle = { path = "crates/oracle", version = "0.8.0" }
pdfcraft-testkit = { path = "crates/testkit", version = "0.8.0" }
pdfcraft-ocr = { path = "crates/ocr", version = "0.8.0" }
pdfcraft-js = { path = "crates/js", version = "0.8.0" }
pdfcraft-compare = { path = "crates/compare", version = "0.8.0" }
pdfcraft-preflight = { path = "crates/preflight", version = "0.8.0" }
pdfcraft-export = { path = "crates/export", version = "0.8.0" }
pdfcraft-edit = { path = "crates/edit", version = "0.8.0" }
pdfcraft-create = { path = "crates/create", version = "0.8.0" }
pdfcraft-forms = { path = "crates/forms", version = "0.8.0" }
pdfcraft-xfa = { path = "crates/xfa", version = "0.8.0" }
pdfcraft-render = { path = "crates/render", version = "0.8.0" }
pdfcraft-engine = { path = "crates/engine", version = "0.8.0" }
pdfcraft-platform = { path = "crates/platform", version = "0.8.0" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.8.0" }
pdfcraft-automation = { path = "crates/automation", version = "0.8.0", default-features = false }
pdfcraft-geom = { path = "crates/geom", version = "0.9.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.9.0" }
pdfcraft-cos = { path = "crates/cos", version = "0.9.0" }
pdfcraft-crypt = { path = "crates/crypt", version = "0.9.0" }
pdfcraft-organize = { path = "crates/organize", version = "0.9.0" }
pdfcraft-measure = { path = "crates/measure", version = "0.9.0" }
pdfcraft-annot = { path = "crates/annot", version = "0.9.0" }
pdfcraft-audit = { path = "crates/audit", version = "0.9.0" }
pdfcraft-fonts = { path = "crates/fonts", version = "0.9.0" }
pdfcraft-model = { path = "crates/model", version = "0.9.0" }
pdfcraft-content = { path = "crates/content", version = "0.9.0" }
pdfcraft-redact = { path = "crates/redact", version = "0.9.0" }
pdfcraft-print = { path = "crates/print", version = "0.9.0" }
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.9.0" }
pdfcraft-sign = { path = "crates/sign", version = "0.9.0" }
pdfcraft-optimize = { path = "crates/optimize", version = "0.9.0" }
pdfcraft-a11y = { path = "crates/a11y", version = "0.9.0" }
pdfcraft-oracle = { path = "crates/oracle", version = "0.9.0" }
pdfcraft-testkit = { path = "crates/testkit", version = "0.9.0" }
pdfcraft-ocr = { path = "crates/ocr", version = "0.9.0" }
pdfcraft-js = { path = "crates/js", version = "0.9.0" }
pdfcraft-compare = { path = "crates/compare", version = "0.9.0" }
pdfcraft-preflight = { path = "crates/preflight", version = "0.9.0" }
pdfcraft-export = { path = "crates/export", version = "0.9.0" }
pdfcraft-edit = { path = "crates/edit", version = "0.9.0" }
pdfcraft-create = { path = "crates/create", version = "0.9.0" }
pdfcraft-forms = { path = "crates/forms", version = "0.9.0" }
pdfcraft-xfa = { path = "crates/xfa", version = "0.9.0" }
pdfcraft-render = { path = "crates/render", version = "0.9.0" }
pdfcraft-engine = { path = "crates/engine", version = "0.9.0" }
pdfcraft-platform = { path = "crates/platform", version = "0.9.0" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.9.0" }
pdfcraft-automation = { path = "crates/automation", version = "0.9.0", default-features = false }
# pdf
hayro = "0.7.1"
+24
View File
@@ -3,6 +3,12 @@
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -158,6 +164,11 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-10-15 (M6, CSV tab-containing headers):** CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Synthetic export/import and incremental/full save regressions cover header tabs, quotes and line breaks, plus ordinary TSV controls. Formula-cell policy is unchanged. Estimates unchanged.
- **2026-10-18 (M3, #3149):** Page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged.
- **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged.
- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged.
- **2026-10-15 (M6, #1917 refresh):** Integrated upstream `99941daa` while retaining the XML/XFDF attribute whitespace correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
- **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged.
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
@@ -169,6 +180,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged.
- **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design.
- **2026-10-13 (M6, data exchange fidelity):** XML and XFDF exports preserve tabs and line breaks in field names and comment metadata using character references. Saved-PDF regressions cover distinct names that XML would normalize to the same attribute value, restored field values and replacement of the original comment identities. Estimates unchanged.
- **2026-10-14 (M1, Flate fallback):** Raw deflate whose first bytes resemble a zlib header no longer loses a decoding-limit error during fallback. Strict and tolerant filter regressions and a COS stream regression cover refusal at the caller limit and successful decoding within it. No parity status or estimate changes.
- **2026-10-12 (M6, XML field names):** AcroForm XML data export constrains generated element names to XML 1.0 NCNames, so Latin-1 names such as `Area²` and `Aµ` produce readable data files. Original PDF field names travel through the existing `xfdf:original` attribute; previously valid tags, including Greek and CJK, keep their spelling. Saved-PDF regressions cover invalid prefixes, combining letters and normalized-name collisions. Estimates unchanged.
- **2026-10-12 (M6, CSV carriage returns):** Form-data CSV export quotes carriage returns in field names and values, so a trailing carriage return cannot turn into a record separator and disappear on reimport. Saved-PDF round-trip regressions cover CR, LF, CRLF, commas and quotes; estimates unchanged.
- **2026-10-13 (community, M14, #1553 review):** Relative Fontconfig includes resolve beside each including config, including nested configuration selected through `FONTCONFIG_PATH`. Directory includes accept only digit-prefixed `*.conf` in lexical order. Isolated regressions reproduce both review findings; configuration size/count/depth limits, cycles, symlinked config and valid reset ordering are covered. Estimates unchanged.
@@ -177,6 +191,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-12 (M14, #1395):** XDG desktops use the configuration base for app.ron, safely copying existing preferences without overwriting a destination. Original preferences, logs, recovery and digital IDs are retained; failed migration falls back to the original location. Portable and non-XDG locations remain unchanged; estimates unchanged.
- **2026-10-12 (M10, Windows print progress, follow-up to #944):** While a job spools, the progress card counts its sheets ("Sending sheet 2 of 3 to Office…") as craft-print reports them (`submit_with_progress`, storytold/craft-libs#27), and steps aside for another long job's card. Pins craft-libs 666c358. Estimates unchanged.
- **2026-10-15 (M2, renderer speed):** Three vendored hayro-interpret patches (31–33), pixel for pixel the same over the first three pages of every pdf.js corpus file. A sampled (Type 0) function keeps its samples in one flat array instead of a hash map rebuilt every time a shading using it is painted: `bug1721218` (InDesign) draws in 0.49 s (was 1.34 s), and a 2,097,152-sample ramp painted 30 times in under a second (was 38 s). One decode of a JPEG or Flate image serves every size it is drawn at (only JPX decodes for a size), so zooming a 300-dpi scan in and out costs 35–40% less render work. Coons and tensor patch meshes copy their vertices' colours less: mesh-gradient pages draw 10–16% faster (page 8 of #1584's annual report 773 → 696 ms). Estimates unchanged.
- 2026-10-12 · M3: selected page text can be copied from Edit and the canvas context menu (#1225). Five focused regressions, full CI and native Unicode clipboard checks pass; floating Copy shares the document permission gate. Estimates unchanged.
- **2026-10-13 (M2, page-label prefixes):** Preserve literal leading and trailing whitespace in page-label prefixes without changing generic metadata normalization. Nine original public-inspection cases cover prefix spaces, encodings and empty-prefix controls. Existing empty-prefix fallback remains a compatibility policy, not a conformance claim. Estimates unchanged.
@@ -287,6 +302,7 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-11 (M5, partial comment imports):** Replacing an editable named comment through XFDF or FDF (including an indirect name in the source PDF) preserves its object identity, so existing replies and review statuses remain attached after saving, reopening and exporting again. Replaced pop-ups are still removed. Locked comments, redaction marks and their pop-ups stay unchanged, including when their names are indirect; imported redactions remain visible. Synthetic round-trip tests cover both formats, incremental and full saves; milestone and effort estimates unchanged.
- **2026-10-15 (M9, #1895):** Preserve original signature artwork and show validation through a separate page overlay. Generic in-place appearance rewriting is deferred: neither a caption substring nor a unique drawing establishes an icon role. Signed regressions cover chamfered borders, mixed signer/caption text, image/text exports, both print modes and saves. Snapshot reuse and trust/cache refresh remain. Baked Not Verified captions are not replaced; #79 remains open. Estimates unchanged.
- **2026-10-10 (M9, signature graphics):** A visible digital signature can show a graphic where the signer's name is large, as Acrobat's Configure Signature Appearance does: the saved Fill & Sign signature (drawn strokes smoothed as ink, typed outlines in the script font, or an image) or an imported PNG/JPEG. Sign as previews it and remembers the choice for the next signing; `sign_document` takes `graphic` (`image`, `text` or `strokes`). Graphics are checked and capped before drawing (non-finite or unbounded points, mismatched pixel buffers, oversized images are refused with an error the caller can act on); images keep their alpha as a soft mask. `sign.signature-appearance` stays partial (no named saved appearances or PDF-page graphics). Overall estimates unchanged.
- **2026-10-11 (M14, absolute interface sizes):** A chosen Interface size is now absolute, as PhotoCraft's UI Scale is: "100%" is one screen pixel per point whatever scale the display reports, so on a 1080p screen whose system reports 200 % it draws at a true 100 % (#456, reported after the first change). Auto still keeps the display's scale and adds GNOME's text scaling, and its menu entry shows the size it draws at. The menu offers PhotoCraft's sizes (75–300 %).
- **2026-10-11 (automation, #971):** `text_find` reads pages in growing batches (8, doubling to 128) and stops once `limit` is reached, so a bounded search on a long document no longer extracts and caches every page first. Results say `complete`, and a capped one gives `next` (`start_page`, `skip`) to continue. Still open from #971: a byte budget for the automation text cache and per-page warnings for pages whose text can't be extracted.
@@ -495,6 +511,11 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. |
| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. |
| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. |
| 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged |
| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. |
| 2026-10-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. |
@@ -515,7 +536,10 @@ Newest first: the date, what moved, and the new overall estimate.
| 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; assessment, gaps and milestones moved to `docs/`; log sorted newest first and a stray merged entry dated |
| 2026-10-07 | minor | 806 tracked features, 51.0% shipped, effort-weighted ≈ 30–35%, 600–1,100 h |
| 2026-10-05 | major | Honest assessment and "Where we're lacking" added |
| 2026-10-12 | minor | M8: cleanup removes replacement descriptions only from changed marked-content spans, preserving surviving accessibility text. Estimates unchanged. |
| 2026-10-12 | minor | M8: image redaction removes alternate images and unsupported masks only on affected images. Estimates unchanged. |
- **2026-10-12 (M8):** Redaction cleans indirect and form structure references, preserving surviving form tags.
- **2026-10-13 (M7):** Edit a PDF ▸ Add content: Header and footer, Watermark and Background open an Add / Update / Remove menu (Update and Remove enabled only when the document has that mark), as Acrobat's Watermark control does. The catalogue gains `ToolItem::actions`, so any tool can list more commands (#1192).
- **2026-10-18 (M8, #1350):** Published the history-preserving integration; simplified the inherited-form resource guard for Rust 1.96 Clippy. Formatting, 64 redaction tests, scoped Clippy, layering, assets and the scoped wasm check pass locally. CI remains pending; estimates unchanged.
+4 -1
View File
@@ -231,7 +231,10 @@ fn main() -> eframe::Result {
// running and exits (#282, #317, #1726). Any option means the caller wants this launch's own
// window.
let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images;
let instance = match single_instance::claim(&single_instance::absolute(&files), may_hand_off) {
// Absolute for this instance too: links relative to a document resolve against its folder,
// never the working directory.
let files = single_instance::absolute(&files);
let instance = match single_instance::claim(&files, may_hand_off) {
single_instance::Claim::HandedOff => return Ok(()),
single_instance::Claim::Primary(server) => Some(server),
single_instance::Claim::Alone => None,
+5 -5
View File
@@ -16,9 +16,9 @@ It reports two kinds of finding:
A properly applied redaction removes the text, so it never flags. Page backgrounds, table
shading and highlights don't either: they're painted before the text, or light against it.
A rectangle covers a text line when it hides at least 40% of the area of the words the line
draws, or at least 40% of the line's box together with a positive-area intersection with one
of its words. The gap an applied redaction leaves (a `TJ` adjustment where the text was) is in the line's box but
A rectangle covers a text line when it hides at least half of one of the line's words, at
least 40% of the area of the words the line draws, or at least 40% of the line's box together
with a positive-area intersection with one of its words. The gap an applied redaction leaves (a `TJ` adjustment where the text was) is in the line's box but
holds no word, so a redaction box over it alone does not count.
Limits, by design and documented rather than hidden:
@@ -29,8 +29,8 @@ Limits, by design and documented rather than hidden:
- covers inside form XObjects are found, but only text drawn directly in the page content is
checked under them;
- annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign;
- a cover that hides less than 40% of a line's box and less than 40% of its words (a short
value in the middle of a long line) is not flagged.
- a cover that hides less than half of every word it touches, less than 40% of a line's box and
less than 40% of its words (a sliver of a long value) is not flagged.
It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper
than 32 or in a cycle, or a walk past the operator bound is an error, never "no findings".
+10 -1
View File
@@ -87,6 +87,9 @@ pub enum AuditError {
/// Fraction of a text line's ink (the area of its words), or of its box, that must lie under a
/// cover to count as hidden (see [`covers`]).
const COVER_FRACTION: f64 = 0.4;
/// Fraction of one word's area that must lie under a cover to count as hidden on its own, however
/// long the line around it (see [`covers`]).
const WORD_FRACTION: f64 = 0.5;
/// WCAG contrast ratio below which the text is unreadable against the cover.
const COVER_CONTRAST: f64 = 3.0;
@@ -138,7 +141,10 @@ fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool {
rect.pos > line.paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST
}
/// Does `cover` hide a substantial part of what `line` draws? Two ways, either is enough:
/// Does `cover` hide a substantial part of what `line` draws? Three ways, any is enough:
///
/// - Half of one word lies under the cover. A box over one number in a long line hides a few
/// percent of the line, and the number all the same (#2662).
///
/// - 40% of the area of the line's words (its `ink`) lies under the cover. Measured on the words,
/// not the box: an applied redaction leaves a gap that draws nothing (a `TJ` adjustment where
@@ -155,6 +161,9 @@ fn covers(cover: &[f64; 4], line: &TextLine) -> bool {
let total: f64 = boxes.iter().map(area).sum();
if total > 0.0 { boxes.iter().map(hidden).sum::<f64>() / total } else { 0.0 }
};
if line.ink.iter().any(|w| area(w) > 0.0 && hidden(w) / area(w) >= WORD_FRACTION) {
return true;
}
let inked = fraction(&line.ink) >= COVER_FRACTION;
let boxed = fraction(std::slice::from_ref(&line.rect)) >= COVER_FRACTION;
let word_intersects = || line.ink.iter().any(|w| hidden(w) > 0.0);
+79 -7
View File
@@ -175,6 +175,47 @@ fn unapplied_redact_mark_is_found() {
assert!(findings[0].covered_text.contains("DO NOT SHARE"));
}
/// #2662: a box over one number of a long line hides a few percent of the line, and all of the
/// number. Found as a cover, and as an unapplied mark over the same word.
#[test]
fn a_cover_over_one_word_of_a_long_line_is_found() {
// Helvetica 11: "The contract value is " ends near x 132, "98765" spans about 132-163.
let line = b"BT /F1 11 Tf 30 100 Td (The contract value is 98765 and the rest of this long line continues here.) Tj ET
";
let mut content = line.to_vec();
content.extend_from_slice(
b"0 g
131 96 33 14 re f
",
);
let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].kind, FindingKind::CoveredText);
assert!(findings[0].covered_text.contains("98765"), "{findings:?}");
let mut b = Builder::new();
b.add("<< /Type /Catalog /Pages 2 0 R >>");
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>");
b.add_stream(line);
b.add(FONT);
b.add("<< /Type /Annot /Subtype /Redact /Rect [131 96 164 110] >>");
let findings = audit_redactions(&open(b.build())).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].kind, FindingKind::UnappliedMark);
}
/// A box that only grazes the words beside it (less than half of each) is not a cover.
#[test]
fn a_box_grazing_a_word_is_not_flagged() {
let content = b"BT /F1 11 Tf 30 100 Td (The contract value is 98765 and the rest of this long line continues here.) Tj ET
0 g
131 96 6 14 re f
";
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
assert!(findings.is_empty(), "{findings:?}");
}
#[test]
fn background_before_text_is_not_flagged() {
// An opaque dark rectangle painted *before* the text is a background, not a cover.
@@ -303,15 +344,17 @@ fn partial_value_is_found(rotated: bool) {
/// Keep the historic detections with several covers, lines and text-spacing states.
#[test]
fn faux_redactions_with_multiple_covers_lines_and_spacing_are_found() {
let cases: [&[u8]; 4] = [
b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj ET\n0 g\n72 738 65 16 re f\n140 738 40 16 re f\n",
b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj 0 -15 Td (Account: 987654321) Tj ET\n0 g\n115 723 100 30 re f\n",
b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n",
b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n",
// The second box of the first case hides most of the number (less than 40% of the line): a
// finding of its own since covers are also measured per word (#2662).
let cases: [(&[u8], usize); 4] = [
(b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj ET\n0 g\n72 738 65 16 re f\n140 738 40 16 re f\n", 2),
(b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj 0 -15 Td (Account: 987654321) Tj ET\n0 g\n115 723 100 30 re f\n", 1),
(b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n", 1),
(b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n", 1),
];
for content in cases {
for (content, expected) in cases {
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
assert_eq!(findings.len(), 1, "{}: {findings:?}", String::from_utf8_lossy(content));
assert_eq!(findings.len(), expected, "{}: {findings:?}", String::from_utf8_lossy(content));
}
}
@@ -457,3 +500,32 @@ fn forms_nested_past_depth_limit_fail_closed() {
let result = audit_redactions(&open(nested_forms(33)));
assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}");
}
#[test]
fn comment_across_streams_neither_invents_text_nor_a_cover() {
let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Visible text) Tj ET % note", b"0 g 70 690 200 40 re f"));
assert!(audit_redactions(&doc).unwrap().is_empty(), "commented cover must not be reported");
let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Before comment) Tj ET % note", b"BT /F1 24 Tf 72 650 Td (After comment) Tj ET"));
let text: String = pdfcraft_edit::text_lines(&doc, 0).unwrap().into_iter().map(|line| line.text).collect();
assert!(!text.contains("After comment"), "editor exposed phantom text: {text}");
}
#[test]
fn reading_blocks_respect_stream_comments_and_split_operands() {
let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Before comment) Tj ET % note", b"BT /F1 24 Tf 72 650 Td (After comment) Tj ET"));
let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect();
assert_eq!(text, "Before comment");
let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Split operand)", b"Tj ET"));
let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect();
assert_eq!(text, "Split operand");
// Marked content opened in one stream applies to text in the next: its /ActualText replaces
// the glyphs, as when the streams are read joined.
for (s1, s2) in [
(b"BT /F1 24 Tf 72 700 Td /Span <</ActualText (Replacement text)".as_slice(), b">> BDC (Glyphs) Tj EMC ET".as_slice()),
(b"BT /F1 24 Tf 72 700 Td /Span <</ActualText (Replacement text)>> BDC", b"(Glyphs) Tj EMC ET"),
] {
let doc = open(two_streams(s1, s2));
let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect();
assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1));
}
}
+9 -4
View File
@@ -918,11 +918,16 @@ impl Automation {
let list: Vec<Value> = found
.iter()
.map(|(p, c)| {
let kind = match c.kind {
pdfcraft_engine::detect::Kind::Text => "text",
pdfcraft_engine::detect::Kind::CheckBox => "checkbox",
let (kind, comb) = match c.kind {
pdfcraft_engine::detect::Kind::Text => ("text", None),
pdfcraft_engine::detect::Kind::CheckBox => ("checkbox", None),
pdfcraft_engine::detect::Kind::Comb { cells } => ("text", Some(cells)),
};
json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) })
let mut v = json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) });
if let Some(cells) = comb {
v["comb"] = json!(cells);
}
v
})
.collect();
if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() {
+1 -1
View File
@@ -138,7 +138,7 @@ impl Automation {
.added()
.iter()
.filter(|x| x.page == page)
.nth((index - 1).max(0) as usize)
.nth(usize::try_from(index.saturating_sub(1)).unwrap_or(usize::MAX))
.filter(|_| index >= 1)
.ok_or_else(|| failed(format!("page {} has no added item {index} (see content_list)", page + 1)))?;
Ok((page, index as usize - 1, it.content.clone(), ph))
+26
View File
@@ -283,6 +283,13 @@ impl Automation {
let to = self.position(&a, "to")?;
self.apply(&a, Edit::MovePages { pages, to })?
}
"page_reverse" => {
let pages = match a.opt_ints("pages")? {
Some(_) => self.pages(&a, "pages")?,
None => (0..self.doc(&a)?.info.pages.len()).collect(),
};
self.apply(&a, Edit::ReversePages { pages })?
}
"page_insert_blank" => self.insert_blank(&a)?,
"page_insert_file" => self.insert_file(&a)?,
"page_extract" => self.page_extract(&a)?,
@@ -3101,4 +3108,23 @@ mod tests {
std::fs::set_permissions(&target, perms).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
/// A recovered document, or a new one, rotated and then undone still has never been saved:
/// `doc_close` refuses it unless told to discard it (#2346 review).
#[test]
fn closing_a_recovered_or_new_document_after_undo_is_refused() {
let mut a = Automation::new();
let recovered = a.session.open("Report.pdf", None, Arc::new(numbered_pages(2)), None).unwrap();
a.session.mark_recovered(recovered, Some("Report.pdf".into()));
let new = a.session.open_new("Combined.pdf", Arc::new(numbered_pages(2))).unwrap();
for id in [recovered, new] {
json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90}));
json(&mut a, "edit_undo", json!({"doc": id.0}));
let refused = a.call("doc_close", &json!({"doc": id.0}));
assert!(matches!(&refused, Err(e) if e.to_string().contains("unsaved changes")), "{id:?}: {refused:?}");
assert!(a.session.get(id).is_some(), "still open");
json(&mut a, "doc_close", json!({"doc": id.0, "discard_changes": true}));
assert!(a.session.get(id).is_none());
}
}
}
+4 -1
View File
@@ -224,6 +224,9 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "pages": pages("to move"), "to": { "type": "integer", "minimum": 1 } }),
&["doc", "pages", "to"],
)),
t("page_reverse", "Reverse pages", "Reverse the order of pages among the positions they occupy: the listed pages (at least two), or the whole document when pages is omitted. Undoable.")
.cmd("page.reverse")
.with(schema(json!({ "doc": doc(), "pages": pages("to reverse (default: all)") }), &["doc"])),
t("page_insert_blank", "Insert a blank page", "Insert a blank page so it becomes page `at`. Size defaults to the neighbouring page. Undoable.")
.cmd("page.insert_blank")
.with(schema(
@@ -1098,7 +1101,7 @@ pub fn tools() -> Vec<ToolDef> {
t("doc_compare_mark", "Mark differences as comments", "Add the differences from other (older) to doc (newer) as comments in doc: highlights over replaced (blue) and inserted (green) text, notes where text was deleted (red), authored Compare. Undoable.")
.cmd("doc.compare")
.with(schema(json!({ "doc": doc(), "other": { "type": "integer" } }), &["doc", "other"])),
t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, small squares for check boxes; on scanned pages read from the pixels) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left).")
t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, rows of comb boxes, small squares for check boxes; on scanned pages read from the pixels) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left); a row of comb boxes is one text field with comb set to its number of cells, created with the comb flag and that character limit.")
.cmd("form.detect")
.with(schema(json!({ "doc": doc(), "pages": pages("to look at (default: all)"), "add": { "type": "boolean" } }), &["doc"])),
t("form_actions", "Field actions", "Field Properties ▸ Actions: the field's action for each trigger (mouse_up, mouse_down, mouse_enter, mouse_exit, on_focus, on_blur).")
+68
View File
@@ -2082,6 +2082,21 @@ fn orphan_form_fields_through_tools() {
assert_ne!(v, "Filled by an agent");
}
#[test]
fn reversing_pages_through_tools() {
let dir = workdir("reverse");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "page_reverse", json!({ "doc": doc }));
assert_eq!(page_text(&mut a, doc), ["Page 3", "Page 2", "Page 1"], "no pages reverses the whole document");
ok(&mut a, "page_reverse", json!({ "doc": doc, "pages": [1, 2] }));
assert_eq!(page_text(&mut a, doc), ["Page 2", "Page 3", "Page 1"], "only the listed pages swap");
ok(&mut a, "edit_undo", json!({ "doc": doc }));
assert_eq!(page_text(&mut a, doc), ["Page 3", "Page 2", "Page 1"]);
assert!(matches!(a.call("page_reverse", &json!({ "doc": doc, "pages": [4] })), Err(ToolError::InvalidArgs(_))));
assert!(matches!(a.call("page_reverse", &json!({ "doc": doc, "pages": [2] })), Err(ToolError::Failed(_))), "one page has nothing to reverse");
}
#[test]
fn duplicating_and_cropping_through_tools() {
let dir = workdir("boxes");
@@ -3582,6 +3597,24 @@ fn adding_content_through_tools() {
assert!(matches!(a.call("content_delete", &json!({ "doc": doc, "page": 1, "index": 5 })), Err(ToolError::Failed(_))));
}
/// An out-of-range `index` (including `i64::MIN`, which overflowed `index - 1`) is a readable
/// failure for `content_update` and `content_delete`, not a panic.
#[test]
fn content_index_out_of_range_is_an_error_not_a_panic() {
let dir = workdir("content-index-range");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "hi", "at": [10, 10] }));
for index in [i64::MIN, i64::MIN + 1, -1, 0, i64::MAX] {
for tool in ["content_delete", "content_update"] {
let args = json!({ "doc": doc, "page": 1, "index": index, "text": "x" });
let args = if tool == "content_delete" { json!({ "doc": doc, "page": 1, "index": index }) } else { args };
assert!(matches!(a.call(tool, &args), Err(ToolError::Failed(_))), "{tool} index {index}");
}
}
assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 1);
}
/// Text styling on an added image is refused (#1331), as image transforms are on text, and the
/// refusal leaves the content, the dirty flag and the undo and redo steps as they were.
#[test]
@@ -4959,6 +4992,41 @@ fn detecting_form_fields_through_tools() {
assert_eq!(f["fields"][1]["value"], "Lisbon", "{f}");
}
#[test]
fn detecting_comb_boxes_through_tools() {
let mut body = String::from("BT /F1 10 Tf 40 105 Td (Postcode) Tj ET\n");
for i in 0..5 {
body.push_str(&format!("{} 100 16 20 re S\n", 120 + i * 16));
}
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>".to_string(),
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(),
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let dir = workdir("detect-comb");
std::fs::write(dir.join("comb.pdf"), pdf).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "comb.pdf" }))["doc"].as_u64().unwrap();
let r = ok(&mut a, "form_detect_fields", json!({ "doc": doc }));
assert_eq!(r["fields"], json!([{ "page": 1, "kind": "text", "comb": 5, "name": "Postcode", "rect": [120.0, 100.0, 200.0, 120.0] }]), "{r}");
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "Postcode": "75001" } }));
assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "75001");
}
#[test]
fn comparing_documents_through_tools() {
let dir = workdir("compare");
+11 -1
View File
@@ -11,7 +11,17 @@ fn result(auto: &mut Automation, args: Value) -> Value {
// Fresh fixtures include the current timestamp; retain their bytes for output comparisons.
fn pdf() -> Vec<u8> {
Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone()
// Reuse one synthetic document so byte comparisons cannot straddle the
// writer's modification-time clock. Each caller gets an independent buffer.
static PDF: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
PDF.get_or_init(|| Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone()).clone()
}
#[test]
fn pdf_fixture_remains_identical_across_metadata_clock_tick() {
let before = pdf();
std::thread::sleep(std::time::Duration::from_millis(1100));
assert_eq!(before, pdf());
}
#[test]
+67 -5
View File
@@ -60,6 +60,10 @@ pub struct Parsed {
pub ops: Vec<Op>,
/// Bytes that could not be read as operands or operators.
pub skipped: usize,
/// For [`Pieces::parse`]: per piece, whether a comment runs into it from the piece before
/// and whether one runs out of it into the next. Only boundaries are recorded, so the size
/// depends on the number of pieces, not of comments.
comment_edges: Vec<(bool, bool)>,
}
fn is_ws(b: u8) -> bool {
@@ -72,12 +76,25 @@ fn is_delim(b: u8) -> bool {
/// Parse a (decoded) content stream.
pub fn parse(data: &[u8]) -> Parsed {
let mut out = Parsed::default();
parse_pieces(data, &[])
}
/// Parse `data`, recording which of the piece boundaries `ends` (as in [`Pieces`]) a comment
/// crosses.
fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed {
let mut out = Parsed { comment_edges: vec![(false, false); ends.len()], ..Parsed::default() };
let mut boundary = 0;
let mut lx = Lexer::new(data, 0);
let mut operands: Vec<Object> = Vec::new();
let mut start: Option<usize> = None;
loop {
let trivia = lx.pos;
lx.skip_ws();
// Only the whitespace the lexer skips here holds top-level comments: a `%` inside a
// string, a name, an array or dictionary operand or inline image data is not one.
if boundary < ends.len() {
comment_edges(data, trivia..lx.pos, ends, &mut boundary, &mut out.comment_edges);
}
let Some(&b) = data.get(lx.pos) else { break };
let at = lx.pos;
let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit();
@@ -148,6 +165,39 @@ pub fn parse(data: &[u8]) -> Parsed {
out
}
/// Record the piece boundaries crossed by the comments in `trivia` (skipped whitespace and
/// comments). `boundary` is the first boundary not yet passed; boundaries only move forward.
fn comment_edges(data: &[u8], trivia: std::ops::Range<usize>, ends: &[usize], boundary: &mut usize, edges: &mut [(bool, bool)]) {
let mut at = trivia.start;
while at < trivia.end {
if data.get(at) != Some(&b'%') {
at += 1;
continue;
}
let comment = at;
while at < trivia.end && !matches!(data.get(at), Some(b'\r' | b'\n')) {
at += 1;
}
// The separator of piece `boundary` is the last byte before its end.
while let Some(&end) = ends.get(*boundary) {
let separator = end.saturating_sub(1);
if separator >= at {
break;
}
if separator >= comment {
if let Some(edge) = edges.get_mut(*boundary) {
edge.1 = true;
}
// Includes a comment whose end of line is the next piece's first byte.
if let Some(edge) = edges.get_mut(boundary.saturating_add(1)) {
edge.0 = true;
}
}
*boundary += 1;
}
}
}
/// Read an inline image starting after `BI`: (parameters, data, position after `EI`). `Err` holds
/// the position after the `EI` (or the end of the stream) of an image whose parameters can't be read.
fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec<u8>, usize), usize> {
@@ -305,7 +355,9 @@ pub fn serialize_ops(ops: &[Op]) -> Vec<u8> {
/// A page's content streams joined into the one stream they are. Writers may split it between
/// any two tokens (ISO 32000-2 §7.8.2), so an operator's operands can end one piece and its
/// keyword start the next: parse the pieces joined ([`Pieces::parse`]) and map positions back
/// with [`Pieces::piece_of`]. A newline separates the pieces so tokens never run together.
/// with [`Pieces::piece_of`]. A space separates the pieces, as renderers join them (hayro,
/// MuPDF and Poppler alike): tokens never run together, and a comment at the end of a piece
/// runs on into the next one, up to its first end of line.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Pieces {
data: Vec<u8>,
@@ -319,7 +371,7 @@ impl Pieces {
let mut ends = Vec::with_capacity(pieces.len());
for p in pieces {
data.extend_from_slice(p.as_ref());
data.push(b'\n');
data.push(b' ');
ends.push(data.len());
}
Pieces { data, ends }
@@ -341,12 +393,22 @@ impl Pieces {
/// Parse the pieces as one stream.
pub fn parse(&self) -> Parsed {
parse(&self.data)
parse_pieces(&self.data, &self.ends)
}
/// Per piece, from `parsed` (this piece set's [`Pieces::parse`]): whether a comment runs
/// into it from the piece before, and whether one runs out of it into the next. A writer
/// that rewrites a piece without its comments must keep both: end the incoming comment
/// before its first operator, and leave a comment open at its end.
pub fn comment_edges(&self, parsed: &Parsed) -> Vec<(bool, bool)> {
let mut edges = parsed.comment_edges.clone();
edges.resize(self.len(), (false, false));
edges
}
/// The piece holding joined position `at` (the last piece for positions past the end).
pub fn piece_of(&self, at: usize) -> usize {
self.ends.iter().position(|&e| at < e).unwrap_or(self.ends.len().saturating_sub(1))
self.ends.partition_point(|&e| e <= at).min(self.ends.len().saturating_sub(1))
}
/// The pieces an operator spans: (the one its first token is in, the one its keyword is in).
+105
View File
@@ -283,3 +283,108 @@ fn pieces_parse_as_one_stream_and_splice_back() {
assert_eq!(out.len(), 3);
assert!(Pieces::join::<&[u8]>(&[]).splice([(0..1, b"x".to_vec())]).is_empty());
}
#[test]
fn stream_boundary_comment_matches_space_concatenation() {
for eol in [b"\n".as_slice(), b"\r", b"\r\n"] {
let mut tail = b"(HIDDEN) Tj".to_vec();
tail.extend(eol);
tail.extend(b"(LIVE) Tj");
let pieces = Pieces::join(&[b"BT % comment".as_slice(), b"(ALSO HIDDEN) Tj", &tail, b"ET"]);
let parsed = pieces.parse();
let shown: Vec<_> = parsed.ops.iter().filter(|op| op.is("Tj")).collect();
assert_eq!(shown.len(), 1);
assert_eq!(shown[0].operands[0].as_string().unwrap().bytes, b"LIVE");
}
}
#[test]
fn comment_edges_terminate_at_next_piece_byte_zero() {
for eol in [b"\n".as_slice(), b"\r", b"\r\n"] {
let mut next = eol.to_vec();
next.extend_from_slice(b"q Q");
let pieces = Pieces::join(&[b"% outgoing".as_slice(), next.as_slice()]);
let parsed = pieces.parse();
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false)], "EOL {eol:?}");
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), vec![b"q".as_slice(), b"Q"]);
}
}
#[test]
fn comment_edges_span_empty_pieces_and_end_of_content() {
let pieces = Pieces::join(&[b"q % outgoing".as_slice(), b"", b"", b"ignored\nQ"]);
let parsed = pieces.parse();
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, true), (true, true), (true, false)]);
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), vec![b"q".as_slice(), b"Q"]);
let pieces = Pieces::join(&[b"% outgoing".as_slice(), b"", b"still ignored"]);
let parsed = pieces.parse();
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, true), (true, true)]);
assert!(parsed.ops.is_empty());
let pieces = Pieces::join::<&[u8]>(&[]);
assert!(pieces.comment_edges(&pieces.parse()).is_empty());
}
#[test]
fn comment_edges_ignore_percent_inside_operands_and_image_data() {
for first in [
b"(literal % not a comment) Tj".as_slice(),
b"/Percent#25Name MP",
b"[(literal % not a comment)] TJ",
b"/P << /ActualText (literal % not a comment) >> BDC EMC",
b"BI /W 1 /H 1 /BPC 8 /CS /G ID % EI",
] {
let pieces = Pieces::join(&[first, b"q Q"]);
let parsed = pieces.parse();
assert_eq!(pieces.comment_edges(&parsed), vec![(false, false), (false, false)], "{first:?}");
assert_eq!(parsed.ops.last().unwrap().op, b"Q");
assert_eq!(parsed.skipped, 0);
}
}
#[test]
fn compound_operand_comment_split_is_owned_by_spanning_operator() {
for parts in [[b"[(A) % ignored".as_slice(), b"still ignored\n(B)] TJ"], [b"/P << /MCID 0 % ignored".as_slice(), b"still ignored\n>> BDC"]] {
let pieces = Pieces::join(&parts);
let parsed = pieces.parse();
// The object lexer consumes this nested comment. Its containing operator spans
// both pieces, which forces both to be rewritten together by the redactor.
assert_eq!(pieces.comment_edges(&parsed), vec![(false, false), (false, false)]);
assert_eq!(parsed.ops.len(), 1);
assert_eq!(pieces.pieces_of(&parsed.ops[0]), (0, 1));
assert_eq!(parsed.skipped, 0);
}
}
#[test]
fn comment_edges_do_not_confuse_local_comments_with_boundary_comments() {
let pieces = Pieces::join(&[b"% local\nq % crossing".as_slice(), b"ignored\n% local again\nQ\n% final local\n", b"q Q"]);
let parsed = pieces.parse();
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false), (false, false)]);
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), vec![b"q".as_slice(), b"Q", b"q", b"Q"]);
}
#[test]
fn comment_edge_storage_depends_on_piece_count_not_comment_count() {
let mut first = b"%\n".repeat(16_384);
first.extend_from_slice(b"q % crossing");
let pieces = Pieces::join(&[first.as_slice(), b"ignored\nQ"]);
let parsed = pieces.parse();
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false)]);
assert_eq!(parsed.ops.len(), 2);
// Also exercise public parsing without piece-boundary metadata.
assert_eq!(parse(&first).ops.len(), 1);
}
#[test]
fn piece_lookup_preserves_exact_boundary_and_empty_piece_mapping() {
// Joined layout: q<space><space>Q<space>; separator belongs to prior piece.
let pieces = Pieces::join(&[b"q".as_slice(), b"", b"Q"]);
for (at, expected) in [(0, 0), (1, 0), (2, 1), (3, 2), (4, 2), (5, 2), (usize::MAX, 2)] {
assert_eq!(pieces.piece_of(at), expected, "position {at}");
}
let empty = Pieces::join::<&[u8]>(&[]);
assert_eq!(empty.piece_of(0), 0);
assert_eq!(empty.piece_of(usize::MAX), 0);
}
+162 -1
View File
@@ -337,6 +337,40 @@ impl PartialEq for SourceIdentity {
impl Eq for SourceIdentity {}
/// What a document reads as: its source ([`SourceIdentity`]), its trailer and every object changed
/// since it was opened. A clone, or a snapshot that undo restores, has the same state, and every
/// change makes a different one. Changed objects are compared as allocations, which the state
/// keeps alive, so a later object can't take an address one of them had.
#[derive(Clone)]
pub struct ReadState {
source: SourceIdentity,
trailer: Dict,
overlay: BTreeMap<u32, Slot>,
}
impl ReadState {
/// Whether `doc` reads as this state.
pub fn matches(&self, doc: &Document) -> bool {
self.source == doc.source_identity()
&& self.trailer == doc.trailer
&& self.overlay.len() == doc.overlay.len()
&& self.overlay.iter().zip(&doc.overlay).all(|((a, x), (b, y))| {
a == b
&& match (x, y) {
(Slot::Set(g, o), Slot::Set(h, p)) => g == h && Arc::ptr_eq(o, p),
(Slot::Freed(g), Slot::Freed(h)) => g == h,
_ => false,
}
})
}
}
impl std::fmt::Debug for ReadState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ReadState").field("changed_objects", &self.overlay.len()).finish_non_exhaustive()
}
}
#[derive(Clone)]
pub struct Document {
data: Arc<Vec<u8>>,
@@ -801,6 +835,12 @@ impl Document {
SourceIdentity(Arc::downgrade(&self.source_identity))
}
/// What the document reads as now (see [`ReadState`]): unlike [`Self::source_identity`], it
/// changes with every edit, so a value derived from the document can be cached against it.
pub fn read_state(&self) -> ReadState {
ReadState { source: self.source_identity(), trailer: self.trailer.clone(), overlay: self.overlay.clone() }
}
pub fn version(&self) -> &str {
&self.version
}
@@ -1113,7 +1153,19 @@ impl Document {
if let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) {
return Ok(s.clone());
}
let Object::Stream(s) = &*self.try_get(num)? else { return Err(CosError::MissingObject(num)) };
// A file entry that cannot place the stream (one that says it is itself compressed
// inside another stream, as in #2367, or points nowhere) is tried by the stream's own
// header, as a plain object with a wrong offset is. An edited or freed stream, in the
// overlay or in a saved revision's table, is what that says it is: the file's copy
// must not come back.
let loaded = match self.try_get(num) {
Ok(o) if matches!(*o, Object::Stream(_)) => o,
_ if self.overlay.contains_key(&num) || matches!(self.xref_entry(num), Some(XrefEntry::Free { .. })) => {
return Err(CosError::MissingObject(num));
}
_ => Arc::new(self.scan_for(num).map(|(id, o)| self.decrypted(id, o)).ok_or(CosError::MissingObject(num))?),
};
let Object::Stream(s) = &*loaded else { return Err(CosError::MissingObject(num)) };
let n = s.dict.int(b"N").unwrap_or(0).clamp(0, 1_000_000) as usize;
let first = s.dict.int(b"First").unwrap_or(0).max(0) as usize;
let data = s.decoded_within(self.stream_limit)?;
@@ -1935,6 +1987,28 @@ const MAX_XREF_OBJECTS: usize = 8_388_607;
mod tests {
use super::*;
#[test]
fn a_read_state_matches_a_clone_and_no_changed_document() {
let mut doc = Document::new_empty();
let a = doc.add(Object::Int(1));
let state = doc.read_state();
let snapshot = doc.clone();
assert!(state.matches(&doc) && state.matches(&snapshot));
// The same value set again is still a change: another allocation.
doc.set(a, Object::Int(1));
assert!(!state.matches(&doc) && state.matches(&snapshot));
let edited = doc.read_state();
doc.free(a);
assert!(!edited.matches(&doc));
let mut trailer_changed = snapshot.clone();
trailer_changed.trailer_mut().set(b"Info".to_vec(), Object::Null);
assert!(!state.matches(&trailer_changed));
// Another document's state never matches, even with the same changes.
let mut other = Document::new_empty();
other.add(Object::Int(1));
assert!(!other.read_state().matches(&snapshot));
}
/// Build a classic-xref file from object bodies (object i+1 = bodies[i]).
pub(crate) fn build(bodies: &[&str], trailer: &str) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
@@ -2535,6 +2609,93 @@ mod tests {
assert!(old.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"Lang").is_none());
}
/// qpdf's `xref-compressed-in-compressed.pdf` in miniature: object 3 is an object stream
/// holding object 4, and the cross-reference stream says object 3 itself lives in stream 99.
fn compressed_in_compressed_fixture() -> Document {
let mut out = b"%PDF-1.5\n".to_vec();
let at = |out: &mut Vec<u8>, num: u32, body: &str| {
let off = out.len();
out.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes());
off as u16
};
let o1 = at(&mut out, 1, "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>");
let o2 = at(&mut out, 2, "<< /Type /Pages /Kids [] /Count 0 >>");
let inner = "<< /Type /Outlines /Count 0 >>";
at(&mut out, 3, &format!("<< /Type /ObjStm /N 1 /First 4 /Length {} >>\nstream\n4 0 {inner}\nendstream", 4 + inner.len()));
// /W [1 2 1]: object 3 is said to live in stream 99 (which does not exist), 4 in stream 3.
let rows: Vec<[u8; 4]> = vec![
[0, 0, 0, 255],
[1, 0, o1 as u8, 0],
[1, 0, o2 as u8, 0],
[2, 0, 99, 0],
[2, 0, 3, 0],
[1, (out.len() >> 8) as u8, out.len() as u8, 0],
];
let rows: Vec<u8> = rows.concat();
let xref = out.len();
out.extend_from_slice(
format!("5 0 obj\n<< /Type /XRef /W [1 2 1] /Index [0 6] /Size 6 /Root 1 0 R /Length {} >>\nstream\n", rows.len()).as_bytes(),
);
out.extend_from_slice(&rows);
out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref}\n%%EOF\n").as_bytes());
Document::open(Arc::new(out)).unwrap()
}
/// An object stream whose own cross-reference entry says it is compressed inside another
/// stream (which the format forbids, and which does not exist here: qpdf's
/// `xref-compressed-in-compressed.pdf`, #2367) could not be loaded, so every object in it
/// read as null and a full save wrote `/Outlines null`. The stream is now found by its own
/// header, as a plain object with a wrong offset is.
#[test]
fn an_object_stream_marked_as_compressed_is_found_by_its_header() {
let doc = compressed_in_compressed_fixture();
assert_eq!(doc.revisions().len(), 1, "the chain itself reads: {:?}", doc.repair_log());
let outlines = doc.get(ObjRef::new(4, 0));
assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "{outlines:?}");
let saved = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap();
let back = Document::open(Arc::new(saved)).unwrap();
let catalog = back.get(back.root().unwrap());
let outlines = back.resolve(catalog.as_dict().unwrap().get(b"Outlines").unwrap());
assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "the full save keeps it: {outlines:?}");
}
/// The header fallback is for file entries only: once the stream is freed (or replaced) in
/// the overlay, its objects must not come back from the file's copy of it.
#[test]
fn a_freed_object_stream_is_not_found_by_its_header() {
let doc = compressed_in_compressed_fixture();
assert!(doc.get(ObjRef::new(4, 0)).as_dict().is_some());
let mut edited = doc.clone();
edited.free(ObjRef::new(3, 0));
let fresh = edited.object_reader().document;
assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0)));
let mut replaced = doc.clone();
replaced.set(ObjRef::new(3, 0), Object::Int(0));
let fresh = replaced.object_reader().document;
assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0)));
}
/// The same once the free is saved: the revision's table says the stream is free, and
/// neither reading the file back nor rewriting it in full brings its objects back.
#[test]
fn a_container_freed_in_a_saved_revision_is_not_found_by_its_header() {
let mut edited = compressed_in_compressed_fixture();
edited.free(ObjRef::new(3, 0));
let incremental = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap();
let reopened = Document::open(Arc::new(incremental)).unwrap();
assert_eq!(reopened.revisions().len(), 2);
assert!(matches!(reopened.xref_entry(3), Some(XrefEntry::Free { .. })));
assert!(matches!(*reopened.get(ObjRef::new(4, 0)), Object::Null), "{:?}", reopened.get(ObjRef::new(4, 0)));
for object_streams in [true, false] {
let opts = crate::SaveOptions { object_streams, ..Default::default() };
let full = crate::write_full(&reopened, &opts).unwrap();
let back = Document::open(Arc::new(full)).unwrap();
let catalog = back.get(back.root().unwrap());
let outlines = catalog.as_dict().unwrap().get(b"Outlines").map(|o| back.resolve(o));
assert!(outlines.as_ref().is_none_or(|o| matches!(**o, Object::Null)), "the full save keeps it out: {outlines:?}");
}
}
#[test]
fn damaged_xref_is_reconstructed() {
let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
+1 -1
View File
@@ -22,7 +22,7 @@ mod security;
mod writer;
pub use bytes::Bytes;
pub use document::{Document, Revision, SourceIdentity, XrefEntry};
pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry};
pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream};
pub use parser::{Lexer, is_whitespace, parse_indirect};
pub use pdfcraft_crypt::{
+13
View File
@@ -482,6 +482,19 @@ mod tests {
assert_eq!(stream.decoded_strict().unwrap(), b"abc");
}
#[test]
fn raw_flate_fallback_streams_preserve_the_caller_limit() {
// Raw deflate with a stored byte and a final empty block. 78 01 also
// passes the zlib header check, so decoding must retry the whole input.
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let stream = Stream::from_raw(dict, vec![0x78, 0x01, 0x00, 0xfe, 0xff, b'A', 0x01, 0x00, 0x00, 0xff, 0xff]);
let result = stream.decoded_within(0);
assert!(matches!(&result, Err(CosError::Filter(detail)) if detail.contains("exceeds the limit of 0 bytes")), "{result:?}");
assert_eq!(stream.decoded_within(1).unwrap(), b"A");
assert_eq!(stream.decoded_strict().unwrap(), b"A");
}
#[test]
fn first_filter_decodes_only_the_outer_wrapper() {
let data = "0 0 m 100 100 l S\n".repeat(64).into_bytes();
+2 -1
View File
@@ -5,6 +5,7 @@
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
mod extract;
mod upright_image;
pub use extract::{ExtractedImage, ImageExport, extract_images, image_file};
use pdfcraft_fonts::{literal, win_ansi, wrap};
@@ -528,7 +529,7 @@ pub fn source_kind(name: &str, bytes: &[u8]) -> Option<SourceKind> {
/// Detect the image format from its bytes; a TIFF may hold several pages.
fn embed(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
if bytes.starts_with(&[0xFF, 0xD8]) {
Ok(vec![jpeg(name, bytes)?])
Ok(vec![jpeg(name, &upright_image::upright(bytes))?])
} else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Ok(vec![png_image(name, bytes)?])
} else if is_tiff(bytes) {
+33
View File
@@ -949,3 +949,36 @@ fn cmyk_and_ycck_jpegs_export_in_the_colours_the_page_shows() {
}
}
}
/// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90 degrees
/// clockwise).
pub(crate) fn phone_jpeg(orientation: u8, dpi: u16) -> Vec<u8> {
use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit};
let mut jpeg = Vec::new();
let mut encoder = JpegEncoder::new(&mut jpeg);
encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches });
encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap();
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]);
let mut out = jpeg[..2].to_vec();
out.extend_from_slice(&[0xff, 0xe1]);
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
out.extend_from_slice(&exif);
out.extend_from_slice(&jpeg[2..]);
out
}
#[test]
fn a_jpeg_with_an_exif_orientation_becomes_an_upright_page() {
let doc = reopen(&from_images(&[("photo.jpg".into(), phone_jpeg(6, 144)), ("plain.jpg".into(), phone_jpeg(1, 72))]).unwrap());
// Stored 60 x 20 px at 144 x 72 dpi; turned it is 20 x 60 px at 72 x 144 dpi: 20 x 30 pt.
let m0 = media(&pages(&doc)[0]);
assert!((m0[2] - 20.0).abs() < 0.01 && (m0[3] - 30.0).abs() < 0.01, "{m0:?}");
let img = image_of(&doc, 0);
assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(20), Some(60)));
// "As stored" stays as it is.
let m1 = media(&pages(&doc)[1]);
assert!((m1[2] - 60.0).abs() < 0.01 && (m1[3] - 20.0).abs() < 0.01, "{m1:?}");
let img = image_of(&doc, 1);
assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(60), Some(20)));
}
@@ -1,6 +1,6 @@
//! Photos from phones are stored sideways with an EXIF Orientation tag. PDF image XObjects have
//! no such tag, so a JPEG embedded as it is would show up turned. Images placed into a page
//! (stamps, added images, replacements) go through here first.
//! no such tag, so a JPEG embedded as it is would show up turned. Every JPEG embedded by this
//! crate (pages made from images, stamps, added images, replacements) goes through here first.
use std::borrow::Cow;
use std::io::Cursor;
@@ -79,24 +79,7 @@ fn jfif_density(bytes: &[u8]) -> Option<PixelDensity> {
#[cfg(test)]
mod tests {
use super::*;
use crate::{Edit, MarkFile};
/// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90
/// degrees clockwise).
fn phone_jpeg(orientation: u8, dpi: u16) -> Vec<u8> {
let mut jpeg = Vec::new();
let mut encoder = JpegEncoder::new(&mut jpeg);
encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches });
encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap();
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]);
let mut out = jpeg[..2].to_vec();
out.extend_from_slice(&[0xff, 0xe1]);
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
out.extend_from_slice(&exif);
out.extend_from_slice(&jpeg[2..]);
out
}
use crate::tests::phone_jpeg;
#[test]
fn orientation_is_applied_and_plain_images_are_left_alone() {
@@ -111,23 +94,4 @@ mod tests {
assert!(matches!(upright(b"\x89PNG\r\n\x1a\n"), Cow::Borrowed(_)));
assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_)));
}
#[test]
fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() {
let mut session = crate::Session::new();
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
let id = session.open_new("form.pdf", blank).unwrap();
// A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright.
// Named "Signature" so `signature_image` finds its picture.
let file = MarkFile { name: "photo.jpg".into(), bytes: std::sync::Arc::new(phone_jpeg(6, 144)), page: 0 };
let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() };
session.apply(id, stamp).unwrap();
let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos;
let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap();
let obj = cos.get(picture);
let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() };
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60)));
let rect = pdfcraft_annot::summaries(cos)[0].rect;
assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}");
}
}
+12 -3
View File
@@ -784,7 +784,16 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
return Err(EditError::Invalid("the image move is too large".into()));
};
plans.remove(&(stream, index));
clips.insert((stream, at), c);
// A `cm` an earlier move put before the clip takes this move too: `c · prev` is the
// same CTM as `prev` followed by another `cm c`, without one more operator per move.
let folded = at
.checked_sub(1)
.and_then(|i| Some((i, c.then(&Matrix(ops.get(i).filter(|op| op.is("cm"))?.nums::<6>()?)))))
.filter(|(_, m)| m.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12));
match folded {
Some((i, m)) => clips.insert((stream, i), (m, false)),
None => clips.insert((stream, at), (c, true)),
};
}
// An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another.
let mut folds = std::collections::BTreeMap::new();
@@ -799,8 +808,8 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
folds.insert(at, folded);
}
let changed = crate::text::splice(&pieces, &ops, |index| {
if let Some(c) = clips.remove(&(stream, index)) {
return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], true);
if let Some((c, keep)) = clips.remove(&(stream, index)) {
return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], keep);
}
if let Some(m) = folds.remove(&index) {
return (vec![Op::new("cm", m.0.map(Object::Real).to_vec())], false);
+188 -17
View File
@@ -327,7 +327,37 @@ fn add_resources(doc: &mut Document, page: &pdfcraft_model::Page, opacity: Optio
}
const WRAP_OPEN: &[u8] = b"q %PdfCraft\n";
const WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n";
/// Starts with an end of line, so a comment left open at the end of the content before it
/// can't swallow the `Q`. Older files have the close without it.
const WRAP_CLOSE: &[u8] = b"\nQ %PdfCraft\n";
const OLD_WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n";
/// Whether `bytes` are a wrapper's close: [`WRAP_CLOSE`] after a `Q` for each `q` the wrapped
/// content left open, or [`OLD_WRAP_CLOSE`].
fn is_wrap_close(bytes: &[u8]) -> bool {
let mut rest = bytes;
loop {
if rest == WRAP_CLOSE || rest == OLD_WRAP_CLOSE {
return true;
}
match rest.strip_prefix(b"\nQ".as_slice()) {
Some(shorter) => rest = shorter,
None => return false,
}
}
}
/// A `Wrap`-tagged stream's role from its exact bytes: `Some(true)` for an open, `Some(false)`
/// for a close.
fn wrapper_role(doc: &Document, o: &Object) -> Option<bool> {
if stream_tag(doc, o).as_deref() != Some("Wrap") {
return None;
}
let Object::Stream(stream) = &*doc.resolve(o) else { return None };
// Wrappers are a few bytes; one that decodes to more isn't PdfCraft's.
let bytes = stream.decoded_within(64 * 1024).ok()?;
if bytes == WRAP_OPEN { Some(true) } else { is_wrap_close(&bytes).then_some(false) }
}
#[cfg(test)]
fn stream_bytes(doc: &Document, o: &Object) -> Option<Vec<u8>> {
@@ -350,27 +380,138 @@ fn tagged(tag: &str) -> Dict {
d
}
/// What placing a mark on top needs, from the page's actual operators (not the `/PCMark` tags,
/// which another writer may keep, copy or forge).
enum Isolation {
/// The content parses cleanly with balanced `q`/`Q` and closed text objects and paths.
/// `suffix`: the stream from which the content leaves graphics state set (or a comment open)
/// that a mark on top must not inherit, so it needs a wrapper from there to the end.
/// `wrappers`: PdfCraft wrapper pairs (open, close) as stream indexes.
Clean { suffix: Option<usize>, wrappers: Vec<(usize, usize)> },
/// The content could not be read that precisely (it doesn't decode, is too large, or has
/// stray bytes, unbalanced `q`/`Q` or an unfinished text object or path). `unclosed` is
/// the count of `q` left open at the end, when known.
Unknown { unclosed: usize },
}
const ISOLATION_MAX_STREAMS: usize = 100_000;
const ISOLATION_MAX_BYTES: usize = 128 * 1024 * 1024;
const ISOLATION_MAX_OPS: usize = 1_000_000;
const ISOLATION_MAX_DEPTH: usize = 10_000;
fn isolation(doc: &Document, list: &[Object]) -> Isolation {
let unknown = Isolation::Unknown { unclosed: 0 };
if list.len() > ISOLATION_MAX_STREAMS {
return unknown;
}
let mut remaining = ISOLATION_MAX_BYTES;
let mut data = Vec::with_capacity(list.len());
for object in list {
let resolved = doc.resolve(object);
let Object::Stream(stream) = &*resolved else { return unknown };
let Ok(bytes) = stream.decoded_within(remaining) else { return unknown };
let Some(left) = remaining.checked_sub(bytes.len().saturating_add(1)) else { return unknown };
remaining = left;
data.push(bytes);
}
let pieces = pdfcraft_content::Pieces::join(&data);
let parsed = pieces.parse();
if parsed.skipped != 0 || parsed.ops.len() > ISOLATION_MAX_OPS {
return unknown;
}
let edges = pieces.comment_edges(&parsed);
let (mut text_open, mut path_open, mut dirty, mut malformed) = (false, false, false, false);
let mut stack: Vec<usize> = Vec::new();
let mut split = 0;
let mut previous_last = None;
let mut wrappers = Vec::new();
for op in &parsed.ops {
let (first, last) = pieces.pieces_of(op);
// A wrapper can only go in before a whole stream, at the top level, between operators,
// and where no comment runs into the stream (it would swallow the wrapper's `q`).
if stack.is_empty()
&& !text_open
&& !path_open
&& !dirty
&& previous_last.is_none_or(|previous| first > previous)
&& !edges.get(first).is_some_and(|edge| edge.0)
{
split = first;
}
previous_last = Some(last);
match op.op.as_slice() {
b"BT" if !text_open => text_open = true,
b"ET" if text_open => text_open = false,
b"BT" | b"ET" => malformed = true,
b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*" => path_open = true,
b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => path_open = false,
_ => {}
}
match op.op.as_slice() {
b"q" if op.operands.is_empty() && stack.len() < ISOLATION_MAX_DEPTH => stack.push(first),
b"Q" if op.operands.is_empty() => match stack.pop() {
Some(open) => {
// Only a standalone PdfCraft wrapper pair may later be removed again.
let wrap = |i: usize| list.get(i).is_some_and(|o| stream_tag(doc, o).as_deref() == Some("Wrap"));
if wrap(open)
&& wrap(first)
&& data.get(open).is_some_and(|d| d == WRAP_OPEN)
&& data.get(first).is_some_and(|d| is_wrap_close(d))
{
wrappers.push((open, first));
}
}
None => malformed = true,
},
b"q" | b"Q" => malformed = true,
_ if stack.is_empty() => dirty = true,
_ => {}
}
}
if malformed || text_open || path_open || !stack.is_empty() {
return Isolation::Unknown { unclosed: if malformed { 0 } else { stack.len() } };
}
let comment_open = edges.last().is_some_and(|edge| edge.1);
Isolation::Clean { suffix: (dirty || comment_open).then_some(split), wrappers }
}
/// Put a mark's content on a page: behind (prepended) or on top (appended, after wrapping the
/// original content in q/Q).
fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec<u8>, behind: bool) -> Result<(), EditError> {
place_tagged(doc, page, kind.tag(), content, behind)
}
/// Put content on a page, tagged `tag` (see [`place`]).
/// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the
/// content before leaves set (a `cm`, a clip, text state…) must not reach the mark, including
/// content appended after an earlier PdfCraft wrapper by another tool (#3084): the stretch of
/// content that leaves state set is wrapped in q/Q, once.
fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec<u8>, behind: bool) -> Result<(), EditError> {
let mut list = contents(doc, page)?;
let wrap = if behind {
None
} else {
match isolation(doc, &list) {
Isolation::Clean { suffix, .. } => suffix.map(|split| (split, 0)),
// Content that can't be read precisely is wrapped whole, as before: after the
// behind-marks at the start. A `q` it leaves open is closed at its end, where
// closing it changes nothing already drawn.
Isolation::Unknown { unclosed } => {
let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()];
let split = list.iter().position(|o| !stream_tag(doc, o).is_some_and(|t| behind_kinds.contains(&t.as_str()))).unwrap_or(list.len());
(split < list.len()).then_some((split, unclosed))
}
}
};
let mark = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content))));
if behind {
list.insert(0, mark);
} else {
let wrapped = list.iter().any(|o| stream_tag(doc, o).as_deref() == Some("Wrap"));
let original = list.iter().any(|o| stream_tag(doc, o).is_none());
if !wrapped && original {
if let Some((split, unclosed)) = wrap {
let mut close_bytes = b"\nQ".repeat(unclosed);
close_bytes.extend_from_slice(WRAP_CLOSE);
let open = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_OPEN.to_vec()))));
let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_CLOSE.to_vec()))));
// Behind-marks stay outside the wrapper.
let split = list.iter().position(|o| stream_tag(doc, o).is_none()).unwrap_or(list.len());
list.insert(split, open);
let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), close_bytes))));
list.insert(split.min(list.len()), open);
list.push(close);
}
list.push(mark);
@@ -613,8 +754,9 @@ pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, repl
Ok(())
}
/// Remove marks of `kind` from `pages`; returns how many were removed. When no mark is left on
/// top of a page, the q/Q wrapper goes too, restoring the original content list.
/// Remove marks of `kind` from `pages`; returns how many were removed. Wrappers left with
/// nothing after them go too, restoring the original content list; a wrapper that content
/// after it still relies on (another tool's appended stream) stays.
pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Result<usize, EditError> {
let all = page_list(doc);
check(pages, all.len())?;
@@ -625,12 +767,41 @@ pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Resu
let before = list.len();
let mut kept: Vec<Object> = list.into_iter().filter(|o| stream_tag(doc, o).as_deref() != Some(kind.tag())).collect();
removed += before - kept.len();
// Marks on top come after the closing wrapper; without any, the wrapper has no purpose.
let is_wrap = |o: &Object| stream_tag(doc, o).as_deref() == Some("Wrap");
let close = kept.iter().rposition(&is_wrap);
let marks_on_top = close.is_some_and(|c| kept[c + 1..].iter().any(|o| stream_tag(doc, o).is_some_and(|t| t != "Wrap")));
if !marks_on_top {
kept.retain(|o| !is_wrap(o));
// Strip only wrapper pairs whose close ends the list (after stripping the pairs after
// it): nothing comes after them to need the state they restore. Pairs come from the
// operators; for content that can't be read that precisely, from the wrapper streams
// alone, so updating a mark there doesn't nest another wrapper each time.
if before != kept.len() {
let wrappers = match isolation(doc, &kept) {
Isolation::Clean { wrappers, .. } => wrappers,
Isolation::Unknown { .. } => {
let mut opens = Vec::new();
let mut pairs = Vec::new();
for (i, o) in kept.iter().enumerate() {
match wrapper_role(doc, o) {
Some(true) => opens.push(i),
Some(false) => pairs.extend(opens.pop().map(|open| (open, i))),
None => {}
}
}
pairs
}
};
let pairs: std::collections::HashMap<usize, usize> = wrappers.into_iter().map(|(open, close)| (close, open)).collect();
let mut discarded = std::collections::HashSet::new();
let mut end = kept.len();
while let Some(close) = end.checked_sub(1) {
if discarded.contains(&close) {
end = close;
} else if let Some(&open) = pairs.get(&close) {
discarded.insert(open);
discarded.insert(close);
end = close;
} else {
break;
}
}
kept = kept.into_iter().enumerate().filter(|(i, _)| !discarded.contains(i)).map(|(_, o)| o).collect();
}
if before != kept.len() {
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept)))?;
+250 -1
View File
@@ -77,7 +77,8 @@ fn header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content()
let s0 = streams(&doc, 0);
// q-wrapper, original, Q-wrapper, header/footer.
assert_eq!(s0.len(), 4, "{s0:?}");
assert_eq!((s0[0].as_str(), s0[2].as_str()), ("q %PdfCraft\n", "Q %PdfCraft\n"));
// The close starts on a new line, so a comment the original ends in can't swallow it.
assert_eq!((s0[0].as_str(), s0[2].as_str()), ("q %PdfCraft\n", "\nQ %PdfCraft\n"));
let mark = &s0[3];
assert!(mark.contains("/PCMark /HeaderFooter") && mark.contains("(Page 1 of 3) Tj") && mark.contains("(Confidential \\(draft\\)) Tj"), "{mark}");
assert!(mark.contains("1 0 0 1 0 0 cm"), "upright page: identity");
@@ -1920,6 +1921,31 @@ fn repeated_image_edits_change_one_transform_instead_of_nesting() {
}
}
#[test]
fn repeated_group_moves_of_a_cropped_image_change_one_clip_transform() {
// The crop is smaller than the image, so a clip left behind would shrink `visible_rect`.
let mut doc = image_page_with("q 150 120 100 50 re W n 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET");
let operators = |doc: &Document| pdfcraft_content::parse(&page_content_bytes(doc, 0)).ops.len();
let start = images::page_images(&doc, 0).unwrap()[0].clone();
let mut first = 0;
for i in 0..20 {
images::translate_images(&mut doc, 0, &[0], [1.0, -2.0]).unwrap();
if i == 0 {
first = operators(&doc);
}
}
let moved = |r: [f64; 4]| [r[0] + 20.0, r[1] - 40.0, r[2] + 20.0, r[3] - 40.0];
let now = images::page_images(&doc, 0).unwrap()[0].clone();
assert!(close(now.rect, moved(start.rect)), "{:?}", now.rect);
assert!(close(now.visible_rect.unwrap(), moved(start.visible_rect.unwrap())), "the crop moves with the image: {:?}", now.visible_rect);
assert_eq!(operators(&doc), first, "twenty group moves add no more operators than one");
assert_eq!(q_depth(&doc), 1);
let doc = reopen(&doc);
assert!(close(images::page_images(&doc, 0).unwrap()[0].visible_rect.unwrap(), moved(start.visible_rect.unwrap())));
let caption: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert_eq!(caption, ["Caption"]);
}
#[test]
fn page_images_move_turn_replace_and_delete() {
let mut doc = image_page();
@@ -4249,3 +4275,226 @@ fn japanese_paragraphs_wrap_between_characters_without_closing_marks_at_a_row_st
}
}
}
/// Small graphics-state oracle for synthetic streams: every tracked state change must
/// have been restored when a probe XObject would draw. No external assets are involved.
fn probe_states(doc: &Document) -> Vec<std::collections::BTreeMap<Vec<u8>, Vec<Object>>> {
let bytes = streams(doc, 0);
let joined = pdfcraft_content::Pieces::join(&bytes);
let mut state = std::collections::BTreeMap::new();
let mut stack = Vec::new();
let mut probes = Vec::new();
for op in joined.parse().ops {
match op.op.as_slice() {
b"q" => stack.push(state.clone()),
b"Q" => state = stack.pop().expect("balanced graphics state"),
b"cm" | b"W" | b"W*" | b"Tz" | b"Tc" | b"Tw" | b"Tr" | b"cs" => {
state.insert(op.op, op.operands);
}
b"Do" if op.name(0) == Some(b"PCProbe") => probes.push(state.clone()),
_ => {}
}
}
probes
}
fn isolation_fixture(parts: &[(&str, &str)]) -> Document {
let mut doc = fixture();
let page = page_list(&doc).remove(0);
let list = parts
.iter()
.map(|(tag, bytes)| {
Object::Ref(doc.add(Object::Stream(Stream::from_raw(if tag.is_empty() { Dict::new() } else { tagged(tag) }, bytes.as_bytes().to_vec()))))
})
.collect();
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list))).unwrap();
doc
}
#[test]
fn new_marks_isolate_state_appended_after_old_wrappers() {
for dirt in ["0.5 0 0 0.5 0 0 cm", "0 0 200 200 re W n", "50 Tz", "5 Tc 20 Tw", "3 Tr", "/Pattern cs"] {
for tag in ["", "OCR", "Wrap"] {
let mut doc = isolation_fixture(&[
("Wrap", "q %PdfCraft\n"),
("", "2 0 0 2 0 0 cm"),
("Wrap", "Q %PdfCraft\n"),
("Added", "q /PCProbe Do Q"),
(tag, dirt),
]);
let before = probe_states(&doc);
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
let after = probe_states(&reopen(&doc));
assert_eq!(&after[..before.len()], &before, "existing content must not change");
assert!(after.last().unwrap().is_empty(), "tag={tag}, dirt={dirt}: {after:?}");
}
}
}
#[test]
fn new_marks_isolate_dirty_tagged_only_pages() {
let mut doc = isolation_fixture(&[("OCR", "50 Tz 5 Tc 20 Tw")]);
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
assert!(probe_states(&doc).last().unwrap().is_empty());
}
#[test]
fn new_marks_do_not_accumulate_wrappers_for_self_contained_marks() {
let mut doc = isolation_fixture(&[("", "2 0 0 2 0 0 cm")]);
for _ in 0..10 {
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
}
assert_eq!(streams(&doc, 0).len(), 13);
assert!(probe_states(&doc).iter().all(|s| s.is_empty()));
}
#[test]
fn new_marks_close_wrapper_after_an_unterminated_comment() {
let mut doc = isolation_fixture(&[("", "50 Tz % no final newline")]);
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
// Check literal concatenation too: a separator must exist in the saved stream itself,
// not just in the inspection parser, or the comment can swallow the closing Q.
let bytes = streams(&doc, 0).concat();
let parsed = pdfcraft_content::parse(bytes.as_bytes());
assert_eq!(parsed.ops.iter().filter(|op| op.is("Q")).count(), 2);
assert!(probe_states(&doc).last().unwrap().is_empty());
}
#[test]
fn new_marks_wrap_unbalanced_graphics_state_whole_without_changing_it() {
// Content that can't be isolated precisely is wrapped whole, as before #3084, instead of
// refusing: redaction stamps its boxes after removing the text, so it can't back out.
for (data, probe_clean) in [("q 50 Tz", true), ("50 Tz q", true), ("q q Q", true), ("Q 50 Tz", false), ("q Q Q 50 Tz", false)] {
let mut doc = isolation_fixture(&[("Wrap", data)]);
let page = page_list(&doc).remove(0);
place_tagged(&mut doc, &page, "Probe", b"q /PCProbe Do Q".to_vec(), false).unwrap();
let after = streams(&doc, 0);
assert_eq!(after.len(), 4, "{data}: {after:?}");
assert_eq!((after[0].as_str(), after[1].as_str()), ("q %PdfCraft\n", data), "{data}");
// A `q` the content leaves open is closed after it, so the mark starts clean. A stray
// `Q` pops the wrapper itself: nothing placed after it can undo that.
let probe = probe_states_lenient(&doc);
assert_eq!(probe.last().unwrap().is_empty(), probe_clean, "{data}: {after:?}");
}
}
/// [`probe_states`] for unbalanced content: a `Q` with nothing saved leaves the state as is,
/// as renderers do.
fn probe_states_lenient(doc: &Document) -> Vec<std::collections::BTreeMap<Vec<u8>, Vec<Object>>> {
let joined = pdfcraft_content::Pieces::join(&streams(doc, 0));
let mut state = std::collections::BTreeMap::new();
let mut stack = Vec::new();
let mut probes = Vec::new();
for op in joined.parse().ops {
match op.op.as_slice() {
b"q" => stack.push(state.clone()),
b"Q" => state = stack.pop().unwrap_or(state),
b"cm" | b"W" | b"W*" | b"Tz" | b"Tc" | b"Tw" | b"Tr" | b"cs" => {
state.insert(op.op, op.operands);
}
b"Do" if op.name(0) == Some(b"PCProbe") => probes.push(state.clone()),
_ => {}
}
}
probes
}
#[test]
fn new_marks_preserve_cross_stream_operands_and_q_pairs() {
let mut doc = isolation_fixture(&[("", "q 2 0 0 2"), ("", "0 0 cm Q 50"), ("", "Tz")]);
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
assert!(probe_states(&doc).last().unwrap().is_empty());
}
#[test]
fn removing_marks_keeps_wrappers_needed_by_later_external_content() {
let mut doc = isolation_fixture(&[
("Wrap", "q %PdfCraft\n"),
("", "2 0 0 2 0 0 cm"),
("Wrap", "Q %PdfCraft\n"),
("HeaderFooter", "q /PCProbe Do Q"),
("", "/PCProbe Do"),
]);
remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap();
assert_eq!(probe_states(&doc).len(), 1);
assert!(probe_states(&doc)[0].is_empty(), "the external content still needs the original reset");
}
#[test]
fn new_marks_wrap_unfinished_text_paths_and_operands_whole() {
for data in ["BT", "ET", "BT BT ET", "0 0 10 10 re", "q 0 0 m Q", "50"] {
let mut doc = isolation_fixture(&[("Background", "q Q"), ("", data)]);
let page = page_list(&doc).remove(0);
place_tagged(&mut doc, &page, "Probe", b"q /PCProbe Do Q".to_vec(), false).unwrap();
// The behind-mark stays outside the wrapper and the content itself is untouched.
assert_eq!(streams(&doc, 0), ["q Q", "q %PdfCraft\n", data, "\nQ %PdfCraft\n", "q /PCProbe Do Q"], "{data}");
}
}
#[test]
fn new_marks_do_not_reveal_cross_stream_comments() {
let mut doc = isolation_fixture(&[("", "q 50 Tz % comment continues"), ("", "Q 20 Tc\nQ 5 Tw")]);
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
assert!(probe_states(&doc).last().unwrap().is_empty());
let joined = pdfcraft_content::Pieces::join(&streams(&doc, 0));
assert!(!joined.parse().ops.iter().any(|op| op.is("Tc")), "commented content must remain inert");
}
#[test]
fn new_marks_follow_clean_streams_ending_in_comments() {
let mut doc = isolation_fixture(&[("Added", "q 50 Tz Q % no newline")]);
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
assert_eq!(probe_states(&doc).len(), 1, "the new mark must not be swallowed by a comment");
assert!(probe_states(&doc)[0].is_empty());
}
#[test]
fn removing_marks_does_not_strip_arbitrary_wrap_tagged_content() {
let mut doc = isolation_fixture(&[("Wrap", "q /PCProbe Do Q"), ("HeaderFooter", "q Q")]);
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1);
assert_eq!(probe_states(&doc).len(), 1);
}
#[test]
fn removing_marks_strips_nested_trailing_wrapper_pairs() {
let mut doc = isolation_fixture(&[
("Wrap", "q %PdfCraft\n"),
("Wrap", "q %PdfCraft\n"),
("", "2 0 0 2 0 0 cm"),
("Wrap", "Q %PdfCraft\n"),
("Wrap", "Q %PdfCraft\n"),
("HeaderFooter", "q Q"),
]);
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1);
assert_eq!(streams(&doc, 0), ["2 0 0 2 0 0 cm"]);
}
#[test]
fn updating_marks_on_unreadable_content_does_not_nest_wrappers() {
for data in ["q 50 Tz", "Q 50 Tz", "BT"] {
let mut doc = isolation_fixture(&[("", data)]);
let page = page_list(&doc).remove(0);
for _ in 0..3 {
place_tagged(&mut doc, &page, MarkKind::HeaderFooter.tag(), b"q /PCProbe Do Q".to_vec(), false).unwrap();
assert_eq!(streams(&doc, 0).len(), 4, "{data}: {:?}", streams(&doc, 0));
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1);
assert_eq!(streams(&doc, 0), [data], "{data}: the original list comes back");
}
}
}
#[test]
fn removing_marks_strips_wrappers_that_closed_open_state() {
let mut doc = isolation_fixture(&[("", "q 2 0 0 2 0 0 cm")]);
stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap();
assert!(probe_states(&doc).last().unwrap().is_empty());
// Now balanced, the page reads cleanly: the close (with its extra `Q`) still pairs.
let page = page_list(&doc).remove(0);
place_tagged(&mut doc, &page, MarkKind::Watermark.tag(), b"q /PCProbe Do Q\n".to_vec(), false).unwrap();
assert_eq!(streams(&doc, 0).len(), 5, "no second wrapper: {:?}", streams(&doc, 0));
let mut doc = isolation_fixture(&[("", "q 2 0 0 2 0 0 cm")]);
let page = page_list(&doc).remove(0);
place_tagged(&mut doc, &page, MarkKind::Watermark.tag(), b"q /PCProbe Do Q\n".to_vec(), false).unwrap();
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Watermark).unwrap(), 1);
assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]);
}
+18 -6
View File
@@ -1237,12 +1237,14 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
path: Vec<pdfcraft_cos::ObjRef>,
visits: usize,
}
fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: usize) {
/// `stream` gives the stream number of the operator at an index of `ops`.
fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: &dyn Fn(usize) -> usize) {
let fonts_res = resources.get(b"Font").map(|f| w.doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let mut cache = HashMap::new();
let mut invoked = Vec::new();
let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), true);
let mut last = None;
let mut last_stream = None;
let mut forms = invoked.into_iter().peekable();
for s in shown {
while let Some((_, name, ts)) = forms.next_if(|f| f.0 < s.op) {
@@ -1250,7 +1252,12 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
// Text after a form starts a new line.
last = None;
}
add_shown(&mut w.lines, &mut last, s, stream);
let si = stream(s.op);
// Text from another stream starts a new line, as when each stream was read alone.
if last_stream.replace(si).is_some_and(|prev| prev != si) {
last = None;
}
add_shown(&mut w.lines, &mut last, s, si);
}
for (_, name, ts) in forms {
enter(w, resources, &name, ts);
@@ -1266,7 +1273,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
if let Some(r) = form.obj {
w.path.push(r);
}
walk(w, &ops, &form.resources, &mut carry, stream);
walk(w, &ops, &form.resources, &mut carry, &|_| stream);
if form.obj.is_some() {
w.path.pop();
}
@@ -1276,9 +1283,14 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
let streams = content_streams(doc, &p.dict);
let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), visits: 0 };
let mut carry = Carry::new();
for (si, (_, data)) in streams.into_iter().enumerate() {
walk(&mut w, &parse(&data).ops, &res, &mut carry, si);
}
// Read the page's streams as the one stream they are, in one pass, as rendering and editing
// do: a comment, an operator's operands or a marked-content sequence (its /ActualText too)
// can run on from one stream into the next. Text still counts as the stream its operator
// starts in, which keeps lines from different streams apart.
let pieces = Pieces::join(&streams.into_iter().map(|(_, data)| data).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let stream_of = |i: usize| ops.get(i).map_or(0, |op| pieces.pieces_of(op).0);
walk(&mut w, &ops, &res, &mut carry, &stream_of);
let mut lines = w.lines;
// A visual-order Indic font draws pre-base vowel signs before their consonant.
for l in lines.iter_mut().filter(|l| l.visual_order && !l.actual_text) {
+1
View File
@@ -9,6 +9,7 @@ license.workspace = true
workspace = true
[dependencies]
craft-docpath = { workspace = true }
pdfcraft-measure = { workspace = true }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff"] }
pdfcraft-render = { workspace = true }
+1
View File
@@ -206,6 +206,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready),
item("Crop pages", "crop", "page.crop", Ready),
item("Duplicate pages", "copy-plus", "page.duplicate", Ready),
item("Reverse pages", "arrow-up-down", "page.reverse", Ready),
item("Page labels", "tag", "page.number", Ready),
item("Page transitions", "presentation", "page.transitions", Planned("M4")),
],
+1
View File
@@ -362,6 +362,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("page.insert_blank", "Insert blank page", PAGES, None, Assembly, "file-plus"),
c("page.rotate_dialog", "Rotate pages…", PAGES, None, Assembly, "rotate-cw"),
c("page.duplicate", "Duplicate pages", PAGES, None, Assembly, "copy-plus"),
c("page.reverse", "Reverse pages", PAGES, None, Assembly, "arrow-up-down"),
c("page.crop", "Crop pages", PAGES, None, Assembly, "crop"),
c("page.boxes", "Set page boxes…", PAGES, None, Assembly, "square-dashed-mouse-pointer"),
c("page.insert", "Insert pages from file…", PAGES, None, Assembly, "file-input"),
+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(),
+18 -13
View File
@@ -6,7 +6,7 @@
//! Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript is [`Session::set_javascript`]; with it
//! off, scripts don't run and AF calls keep working natively.
use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, ScriptResult, Scripts};
use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts};
pub use pdfcraft_js::{Limits, Outcome, Request};
use crate::{DocId, Edit, EditError, Session};
@@ -343,19 +343,24 @@ impl Session {
let names = found.iter().map(|(_, c, _)| c.name.clone()).collect();
let mut edits = Vec::with_capacity(found.len());
for (page, c, turn) in found {
// A turned field keeps its place: the rectangle is already the one on the page.
let props = (turn != 0).then(|| pdfcraft_forms::FieldProps { rotation: Some((0, turn)), rect: Some((0, c.rect)), ..Default::default() });
let tall = if turn % 180 == 0 { c.rect[3] - c.rect[1] } else { c.rect[2] - c.rect[0] };
edits.push(Edit::AddField {
page,
rect: c.rect,
kind: match c.kind {
pdfcraft_forms::detect::Kind::Text => pdfcraft_forms::NewField::Text { multiline: tall > 30.0 },
pdfcraft_forms::detect::Kind::CheckBox => pdfcraft_forms::NewField::CheckBox,
},
name: Some(c.name.clone()),
});
if let Some(props) = props {
let (kind, comb) = match c.kind {
pdfcraft_forms::detect::Kind::Text => (pdfcraft_forms::NewField::Text { multiline: tall > 30.0 }, None),
pdfcraft_forms::detect::Kind::CheckBox => (pdfcraft_forms::NewField::CheckBox, None),
pdfcraft_forms::detect::Kind::Comb { cells } => (pdfcraft_forms::NewField::Text { multiline: false }, Some(cells)),
};
let mut props = FieldProps::default();
if turn != 0 {
// A turned field keeps its place: the rectangle is already the one on the page.
props.rotation = Some((0, turn));
props.rect = Some((0, c.rect));
}
if let Some(cells) = comb {
props.max_len = Some(Some(cells));
props.flags = vec![(pdfcraft_forms::flags::COMB, true)];
}
edits.push(Edit::AddField { page, rect: c.rect, kind, name: Some(c.name.clone()) });
if turn != 0 || comb.is_some() {
edits.push(Edit::SetFieldProps { name: c.name, props: Box::new(props) });
}
}
+101 -47
View File
@@ -36,7 +36,6 @@ pub mod signature_image;
pub mod signature_scan;
pub mod text_fonts;
mod text_preview;
mod upright_image;
pub mod word_count;
pub mod xfa;
@@ -306,6 +305,10 @@ struct Editor {
saved: Arc<Vec<u8>>,
/// Passwords that open [`Self::saved`].
saved_keys: Keys,
/// [`Self::saved`] was never written where the document belongs: a new document (combine,
/// extract, create) or one restored from recovery. It stays unsaved until a save, whatever
/// undo brings back, or closing it would lose the whole document without asking.
unsaved_file: bool,
undo: Vec<Snapshot>,
redo: Vec<Snapshot>,
keys: Keys,
@@ -317,15 +320,15 @@ struct Editor {
text_blocks: TextBlocksCache,
}
/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the unmodified document state they
/// were read from. Opening a paragraph and applying its edit both need them, and grouping a long
/// page is not cheap. Only an unmodified document is cached: its identity changes on every edit,
/// undo and save (each reopens the working file), so a stale entry never matches.
/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the document state they were read
/// from. Opening a paragraph and applying its edit both need them, and grouping a long page is not
/// cheap. The state ([`pdfcraft_cos::ReadState`]) changes with every edit and save and returns
/// with undo, so a stale entry never matches, also after the document has been edited.
#[derive(Default)]
struct TextBlocksCache(std::sync::Mutex<Option<CachedTextBlocks>>);
/// The document state, the page (0-based) and its text blocks.
type CachedTextBlocks = (pdfcraft_cos::SourceIdentity, usize, Arc<Vec<pdfcraft_edit::TextBlock>>);
type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc<Vec<pdfcraft_edit::TextBlock>>);
impl Clone for TextBlocksCache {
fn clone(&self) -> Self {
@@ -337,20 +340,16 @@ impl Clone for TextBlocksCache {
impl Editor {
/// The text blocks on `page` (0-based), from the cache when the document hasn't changed.
fn text_blocks(&self, page: usize) -> Result<Arc<Vec<pdfcraft_edit::TextBlock>>, EditError> {
let source = (!self.cos.is_modified()).then(|| self.cos.source_identity());
if let Some(source) = &source
&& let Ok(cache) = self.text_blocks.0.lock()
&& let Some((s, p, blocks)) = cache.as_ref()
&& s == source
if let Ok(cache) = self.text_blocks.0.lock()
&& let Some((state, p, blocks)) = cache.as_ref()
&& *p == page
&& state.matches(&self.cos)
{
return Ok(blocks.clone());
}
let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?);
if let Some(source) = source
&& let Ok(mut cache) = self.text_blocks.0.lock()
{
*cache = Some((source, page, blocks.clone()));
if let Ok(mut cache) = self.text_blocks.0.lock() {
*cache = Some((self.cos.read_state(), page, blocks.clone()));
}
Ok(blocks)
}
@@ -872,23 +871,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
@@ -978,6 +983,27 @@ fn render_threads() -> usize {
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1
}
/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy
/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its
/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties`
/// is left alone.
fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) {
use pdfcraft_cos::{Dict, ObjRef, Object};
let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return };
let Some(mut root) = cos.get(root_ref).as_dict().cloned() else { return };
let Some(properties) = root.get(b"OCProperties").cloned() else { return };
let Some(mut properties) = cos.resolve(&properties).as_dict().cloned() else { return };
let mut config = properties.get(b"D").and_then(|d| cos.resolve(d).as_dict().cloned()).unwrap_or_else(Dict::new);
let refs =
|visible: bool| Object::Array(layers.iter().filter(|l| l.visible == visible).map(|l| Object::Ref(ObjRef::new(l.id.0, l.id.1))).collect());
config.set(b"BaseState".to_vec(), Object::name("ON"));
config.set(b"ON".to_vec(), refs(true));
config.set(b"OFF".to_vec(), refs(false));
properties.set(b"D".to_vec(), Object::Dict(config));
root.set(b"OCProperties".to_vec(), Object::Dict(properties));
cos.set(root_ref, Object::Dict(root));
}
/// Render `doc` with the layer visibility in its `info.layers`.
fn use_layer_choices(doc: &mut Document) {
let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect();
@@ -1241,6 +1267,10 @@ pub enum Edit {
pages: Vec<usize>,
to: usize,
},
/// `pages` reversed among the positions they occupy.
ReversePages {
pages: Vec<usize>,
},
InsertBlankPage {
at: usize,
width: f64,
@@ -1771,6 +1801,7 @@ pub const EDIT_LABELS: &[&str] = &[
"Delete pages",
"Move page",
"Move pages",
"Reverse pages",
"Insert blank page",
"Duplicate page",
"Duplicate pages",
@@ -1910,6 +1941,7 @@ impl Edit {
Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()),
Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()),
Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()),
Edit::ReversePages { .. } => Label::of("Reverse pages"),
Edit::InsertBlankPage { .. } => Label::of("Insert blank page"),
Edit::DuplicatePages { pages } => plural("Duplicate page", "Duplicate pages", pages.len()),
Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()),
@@ -2082,6 +2114,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
Edit::RotatePages { .. }
| Edit::DeletePages { .. }
| Edit::MovePages { .. }
| Edit::ReversePages { .. }
| Edit::InsertBlankPage { .. }
| Edit::DuplicatePages { .. }
| Edit::ReplacePages { .. }
@@ -2292,6 +2325,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?,
Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?,
Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?,
Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?,
Edit::InsertBlankPage { at, width, height } => {
pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?;
}
@@ -3206,8 +3240,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 {
@@ -3520,6 +3554,7 @@ impl Session {
cos,
saved,
saved_keys: keys.clone(),
unsaved_file: false,
undo: Vec::new(),
redo: Vec::new(),
keys,
@@ -3532,6 +3567,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 +3590,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 +3606,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,
@@ -3817,7 +3852,7 @@ impl Session {
/// file is clean; undo past a later save is not, because the restored snapshot's own base
/// is an earlier revision.
fn sync_dirty(doc: &mut Document) {
doc.dirty = doc.editor.as_ref().is_some_and(|e| e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved));
doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved));
}
/// Use the current state's password for viewing (protection edits change it).
@@ -3869,7 +3904,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 +3948,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 +3993,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 +4068,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(())
@@ -4102,6 +4137,7 @@ impl Session {
pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?;
editor.saved = bytes;
editor.saved_keys = keys;
editor.unsaved_file = false;
}
if let Some(p) = path {
doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone());
@@ -4165,7 +4201,7 @@ impl Session {
editor.undo.clear();
editor.redo.clear();
Self::adopt_keys(doc);
doc.dirty = false;
Self::sync_dirty(doc);
doc.generation += 1;
Self::refresh(doc).inspect_err(|_| Self::show_released(doc))
}
@@ -4326,12 +4362,18 @@ impl Session {
};
return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string()));
}
let cos = match doc.editor.as_ref() {
let mut cos = match doc.editor.as_ref() {
Some(e) => e.cos.clone(),
None => {
pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))?
}
};
// #3046: the layers print as they are shown. Once the Layers panel (or a set-layer
// action) has changed one, the print copy's default configuration says which are on, so a
// hidden layer stays off on paper too; untouched, the document's own configuration prints.
if !doc.config.layers.is_empty() {
set_default_layer_state(&mut cos, &doc.info.layers);
}
pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string()))
}
@@ -4639,6 +4681,9 @@ impl Session {
let id = self.open(name, None, bytes, None)?;
if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) {
d.dirty = true;
if let Some(e) = d.editor.as_mut() {
e.unsaved_file = true;
}
// Unsaved work with no edit yet still needs its first recovery snapshot.
d.generation += 1;
}
@@ -4691,6 +4736,9 @@ impl Session {
if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) {
d.path = path;
d.dirty = true;
if let Some(e) = d.editor.as_mut() {
e.unsaved_file = true;
}
d.generation += 1;
d.snapshot_generation = d.generation; // its bytes are already in the recovery store
}
@@ -4767,14 +4815,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());
}
}
@@ -4894,7 +4948,7 @@ fn embed_image(
name: &str,
bytes: &[u8],
) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> {
pdfcraft_create::image_xobject(doc, name, &upright_image::upright(bytes))
pdfcraft_create::image_xobject(doc, name, bytes)
}
/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image.
+57 -24
View File
@@ -137,7 +137,8 @@ pub enum RemoteDocumentPathError {
Empty,
TooLong,
Hidden,
UrlOrNetwork,
/// A URL, a network or device path, or a path that climbs above its root.
NotLocal(craft_docpath::Refused),
MissingSourcePath,
/// The target doesn't end in `.pdf`.
NotPdf,
@@ -155,7 +156,7 @@ impl std::fmt::Display for RemoteDocumentPathError {
Self::Empty => f.write_str("the file specification is empty"),
Self::TooLong => f.write_str("the file specification is too long"),
Self::Hidden => f.write_str("the file specification contains hidden or control characters"),
Self::UrlOrNetwork => f.write_str("only local document paths are supported"),
Self::NotLocal(r) => write!(f, "only local document paths are supported: {r}"),
Self::MissingSourcePath => f.write_str("a relative link needs a source document path"),
Self::NotPdf => f.write_str("the linked file is not a .pdf file"),
Self::NotAFile => f.write_str("the linked path is not a regular file"),
@@ -173,7 +174,9 @@ pub fn remote_document_path_is_absolute(raw: &str) -> bool {
}
/// Resolve a PDF remote-Go-To file specification to a local path without opening it.
/// Relative paths are based on the source document; an in-memory source has no fallback to CWD.
/// Relative paths are based on the source document, which should be an absolute path; an
/// in-memory source has no fallback to CWD. URLs (including `file:`), network and device paths are
/// refused ([`craft_docpath::resolve`]).
pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result<PathBuf, RemoteDocumentPathError> {
if raw.is_empty() || raw.trim().is_empty() {
return Err(RemoteDocumentPathError::Empty);
@@ -184,25 +187,22 @@ pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result<
if raw.chars().any(hides_text) {
return Err(RemoteDocumentPathError::Hidden);
}
if raw.starts_with("//") || raw.starts_with("\\\\") || raw.starts_with("/\\") || raw.starts_with("\\/") {
return Err(RemoteDocumentPathError::UrlOrNetwork);
}
let path = Path::new(raw);
let drive_absolute = remote_document_path_is_absolute(raw);
// A GoToR file specification is a path, not a URL: any scheme of two or more characters is
// refused (one letter is a drive), `file:` and names such as `custom:target.pdf` included.
if let Some((scheme, _)) = raw.split_once(':') {
let valid_scheme = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
let is_scheme = scheme.len() > 1
&& scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
&& scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
let windows_drive = scheme.len() == 1 && drive_absolute;
if valid_scheme && !windows_drive {
return Err(RemoteDocumentPathError::UrlOrNetwork);
if is_scheme {
return Err(RemoteDocumentPathError::NotLocal(craft_docpath::Refused::Url));
}
}
if drive_absolute {
return Ok(path.to_path_buf());
let path = craft_docpath::resolve(raw, source).map_err(RemoteDocumentPathError::NotLocal)?;
// A relative result would be read against the working directory.
if !path.is_absolute() {
return Err(RemoteDocumentPathError::MissingSourcePath);
}
let source = source.ok_or(RemoteDocumentPathError::MissingSourcePath)?;
let parent = source.parent().ok_or(RemoteDocumentPathError::MissingSourcePath)?;
Ok(parent.join(path))
Ok(path)
}
/// Largest linked PDF a GoToR link may open (1 GiB, the largest size the large-file work measures).
@@ -503,23 +503,56 @@ mod tests {
use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve};
use std::path::Path;
let source = Path::new("/docs/chapter/source.pdf");
assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(Path::new("/docs/chapter/../target.pdf")));
let source = std::env::temp_dir().join("chapter").join("source.pdf");
let source = source.as_path();
let folder = source.parent().unwrap();
assert_eq!(resolve(Some(source), "target.pdf"), Ok(folder.join("target.pdf")));
assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(folder.parent().unwrap().join("target.pdf").as_path()));
#[cfg(unix)]
assert_eq!(resolve(Some(source), "/manual/target.pdf").as_deref(), Ok(Path::new("/manual/target.pdf")));
assert_eq!(resolve(None, "target.pdf"), Err(E::MissingSourcePath));
assert_eq!(resolve(Some(Path::new("source.pdf")), "target.pdf"), Err(E::MissingSourcePath), "a relative source is not used");
assert_eq!(resolve(Some(source), ""), Err(E::Empty));
assert_eq!(resolve(Some(source), " "), Err(E::Empty));
for url in ["file:///tmp/a.pdf", "https://example.org/a.pdf", "smb://host/share/a.pdf", "ftp://host/a.pdf", "custom:target.pdf"] {
assert_eq!(resolve(Some(source), url), Err(E::UrlOrNetwork), "{url}");
}
for network_path in ["//server/share/a.pdf", "\\\\server\\share\\a.pdf", "\\\\?\\C:\\a.pdf", "\\\\.\\device\\a.pdf", "/\\server/share/a.pdf"]
for url in
["file:///tmp/a.pdf", "file://h/s/a.pdf", "https://example.org/a.pdf", "smb://host/share/a.pdf", "ftp://host/a.pdf", "custom:target.pdf"]
{
assert_eq!(resolve(Some(source), network_path), Err(E::UrlOrNetwork), "{network_path:?}");
assert!(matches!(resolve(Some(source), url), Err(E::NotLocal(_))), "{url}");
}
assert_eq!(resolve(Some(source), "folder/hidden\u{200B}.pdf"), Err(E::Hidden));
assert_eq!(resolve(Some(source), &"a".repeat(super::MAX_REMOTE_DOCUMENT_PATH_BYTES + 1)), Err(E::TooLong));
}
#[test]
fn remote_document_paths_to_other_computers_and_devices_are_refused() {
use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve};
let source = std::env::temp_dir().join("chapter").join("source.pdf");
let source = source.as_path();
// Refused before any filesystem access: none of these exist, and a stat of a network path
// would contact its host.
for not_local in [
"//server/share/a.pdf",
"\\\\server\\share\\a.pdf",
"\\\\h\\s\\a.pdf",
"\\\\?\\UNC\\h\\s\\a.pdf",
"\\??\\UNC\\h\\s\\a.pdf",
"/??/x",
"\\??\\C:\\a.pdf",
"\\\\.\\device\\a.pdf",
"/\\server/share/a.pdf",
"NUL.pdf",
] {
assert!(matches!(resolve(Some(source), not_local), Err(E::NotLocal(_))), "{not_local:?}");
}
// Linux reads `//tmp/x` as `/tmp/x`, but on Windows it names a share.
#[cfg(unix)]
{
let doubled = format!("/{}", source.with_file_name("a.pdf").display());
assert!(matches!(resolve(Some(source), &doubled), Err(E::NotLocal(_))), "{doubled}");
}
}
#[test]
fn urls_follow_the_artcraft_scheme() {
assert_eq!(super::APP_PAGE, format!("{}/apps/{}", super::WEBSITE, super::APP));
+560
View File
@@ -1987,6 +1987,59 @@ fn oversized_redaction_overlay_preserves_the_document_and_undo() {
assert_eq!(page_texts(&s, id), ["Page 1"]);
}
/// #3046: a layer hidden in the Layers panel stays off on paper, as it is on screen; with the
/// layers untouched, the document's own configuration prints.
#[test]
fn printing_keeps_hidden_layers_hidden() {
let content = b"/OC /A BDC 1 0 0 rg 10 10 80 80 re f EMC /OC /B BDC 0 1 0 rg 110 10 80 80 re f EMC";
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 6 0 R] >> >> >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R /Resources << /Properties << /A 5 0 R /B 6 0 R >> >> >>".to_vec(),
[format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content, b"\nendstream"].concat(),
b"<< /Type /OCG /Name (Layer A) >>".to_vec(),
b"<< /Type /OCG /Name (Layer B) >>".to_vec(),
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
pdf.extend_from_slice(o);
pdf.extend_from_slice(b"\nendobj\n");
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
// Red and green pixels on the first printed sheet.
let printed = |s: &Session, id: DocId| {
let bytes = s.print_pdf(id, &print::Settings { pages: vec![0], ..Default::default() }).unwrap();
let mut out = Session::new();
let printed = out.open("print.pdf", None, Arc::new(bytes), None).unwrap();
let page = render_page(&out, printed);
let px = page.rgba.as_chunks::<4>().0;
let red = px.iter().filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count();
let green = px.iter().filter(|p| p[1] > 200 && p[0] < 60 && p[2] < 60).count();
(red, green)
};
let mut s = Session::new();
let id = s.open("layers.pdf", None, Arc::new(pdf), None).unwrap();
let (red, green) = printed(&s, id);
assert!(red > 100 && green > 100, "untouched layers both print: {red} red, {green} green");
let a = s.get(id).unwrap().info.layers.iter().position(|l| l.name == "Layer A").unwrap();
assert!(s.set_layer_visible(id, a, false));
let (red, green) = printed(&s, id);
assert_eq!(red, 0, "Layer A was hidden, so it doesn't print");
assert!(green > 100, "Layer B still prints: {green} green");
assert!(s.set_layer_visible(id, a, true));
let (red, _) = printed(&s, id);
assert!(red > 100, "shown again, Layer A prints again: {red} red");
}
#[test]
fn printing_lays_out_sheets_that_render() {
let (s, id) = session_with(5);
@@ -2763,6 +2816,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() {
@@ -3244,6 +3691,34 @@ fn detecting_fields_on_a_scanned_form() {
}
}
#[test]
fn detecting_comb_boxes_adds_one_comb_field_per_row() {
// The rows from #1887: five 18 x 22 cells and ten 16 x 20 cells, each a stroked `re`.
let mut body = String::from("BT /F1 10 Tf 40 205 Td (Postcode A) Tj ET BT /F1 10 Tf 40 105 Td (Postcode B) Tj ET\n");
for i in 0..5 {
body.push_str(&format!("{} 200 18 22 re S\n", 120 + i * 18));
}
for i in 0..10 {
body.push_str(&format!("{} 100 16 20 re S\n", 120 + i * 16));
}
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("comb.pdf", None, Arc::new(one_line_page("/Font << /F1 4 0 R >>", &body)), None).unwrap();
let found: Vec<(String, pdfcraft_forms::detect::Kind)> = s.detect_fields(id, &[]).into_iter().map(|(_, c)| (c.name, c.kind)).collect();
assert_eq!(
found,
[
("Postcode A".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 5 }),
("Postcode B".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 10 })
]
);
s.auto_detect_fields(id, &[]).unwrap();
let d = s.get(id).unwrap();
let combs: Vec<(&str, FormFieldKind, bool, Option<usize>)> =
d.form.iter().map(|f| (f.name.as_str(), f.kind, f.has(pdfcraft_forms::flags::COMB), f.max_len)).collect();
assert_eq!(combs, [("Postcode A", FormFieldKind::Text, true, Some(5)), ("Postcode B", FormFieldKind::Text, true, Some(10))]);
assert_eq!(d.can_undo(), Some("Detect form fields"));
}
#[test]
fn comparing_two_versions_of_a_document() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
@@ -5407,3 +5882,88 @@ fn cropping_an_existing_image_clips_it_and_moves_with_it() {
// No area, or a box off the image, is refused.
assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([10.0, 10.0, 10.0, 50.0]) }).is_err());
}
/// A 60 x 20 JPEG at 144 x 72 dpi, tagged with EXIF Orientation 6 (shown turned 90 degrees clockwise).
fn phone_jpeg() -> Vec<u8> {
use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit};
let mut jpeg = Vec::new();
let mut encoder = JpegEncoder::new(&mut jpeg);
encoder.set_pixel_density(PixelDensity { density: (144, 72), unit: PixelDensityUnit::Inches });
encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap();
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
exif.extend_from_slice(&[6, 0, 0, 0, 0, 0, 0, 0]);
let mut out = jpeg[..2].to_vec();
out.extend_from_slice(&[0xff, 0xe1]);
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
out.extend_from_slice(&exif);
out.extend_from_slice(&jpeg[2..]);
out
}
#[test]
fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() {
let mut session = Session::new();
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
let id = session.open_new("form.pdf", blank).unwrap();
// A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright.
// Named "Signature" so `signature_image` finds its picture.
let file = MarkFile { name: "photo.jpg".into(), bytes: Arc::new(phone_jpeg()), page: 0 };
let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() };
session.apply(id, stamp).unwrap();
let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos;
let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap();
let obj = cos.get(picture);
let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() };
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60)));
let rect = pdfcraft_annot::summaries(cos)[0].rect;
assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}");
}
/// Opening a paragraph and applying its edit read the page's text blocks; after an edit they are
/// still read once per document state, and undo and redo never get another state's blocks.
#[test]
fn text_blocks_stay_cached_after_an_edit_and_follow_undo_and_redo() {
let (mut session, id) = session_with(1);
let blocks = |s: &Session| s.get(id).unwrap().editor.as_ref().unwrap().text_blocks(0).unwrap();
let before = blocks(&session);
assert_eq!(before[0].editor_text, "Page 1");
let edit = Edit::EditTextBlock { page: 0, block: 0, text: "Edited".into(), style: pdfcraft_edit::BlockStyle::default() };
session.apply(id, edit).unwrap();
let edited = blocks(&session);
assert_eq!(edited[0].editor_text, "Edited");
assert!(Arc::ptr_eq(&edited, &blocks(&session)), "the edited page is grouped once");
session.undo(id).unwrap();
assert_eq!(blocks(&session)[0].editor_text, "Page 1");
session.redo(id).unwrap();
let redone = blocks(&session);
assert_eq!(redone[0].editor_text, "Edited");
assert!(Arc::ptr_eq(&redone, &blocks(&session)));
}
/// A new document, or one restored from recovery, has never been written where it belongs: undo
/// back to the bytes it was opened from must not make it clean, or closing it would discard the
/// whole document without asking. Saving makes it clean; Revert of a saved one too.
#[test]
fn undo_never_makes_an_unsaved_document_clean() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let new = s.open_new("Combined.pdf", Arc::new(fixture(2))).unwrap();
s.apply(new, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
s.undo(new).unwrap();
assert!(s.get(new).unwrap().dirty, "a new document is unsaved until it is saved");
assert_eq!(s.autosave_snapshots().len(), 1, "and still needs recovery");
s.revert(new).unwrap();
assert!(s.get(new).unwrap().dirty, "Revert goes back to the created bytes, which aren't saved anywhere");
let recovered = s.open("Report.pdf", None, Arc::new(fixture(2)), None).unwrap();
s.mark_recovered(recovered, Some("Report.pdf".into()));
s.apply(recovered, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
s.undo(recovered).unwrap();
assert!(s.get(recovered).unwrap().dirty, "the recovered work was never saved");
let saved = s.save_bytes(new).unwrap();
s.mark_saved(new, saved, Some("Combined.pdf".into())).unwrap();
assert!(!s.get(new).unwrap().dirty);
s.apply(new, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
s.undo(new).unwrap();
assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean");
}
+1 -1
View File
@@ -24,7 +24,7 @@ encode_flate(data) -> Vec<u8> // zlib, level 6
- `chain` is applied first to last, as in a `/Filter` array.
- Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. Decryption belongs to `crypt`.
- `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`.
- `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`. This also holds when Flate retries raw deflate after a misleading zlib header, in both strict and tolerant decoding.
- `decode_tolerant` turns `Corrupt` into "what was decoded so far" plus `partial = true`, then runs the rest of the chain on it (predictors are applied to partial output too). `LimitExceeded` and `Unsupported` are still errors.
## Behaviour
+6 -1
View File
@@ -26,7 +26,12 @@ pub(crate) fn decode(data: &[u8], max: usize) -> Step {
Some((_, flg)) if flg & 0x20 != 0 => Err(Failure::corrupt(NAME, "zlib preset dictionary is not allowed in PDF", Vec::new())),
Some(_) => match inflate_raw(&data[2..], max) {
// A header that only looked valid: retry the whole input as raw deflate.
Err(f) if f.partial.is_empty() && matches!(f.error, crate::FilterError::Corrupt { .. }) => inflate_raw(data, max).map_err(|_| f),
Err(f) if f.partial.is_empty() && matches!(f.error, crate::FilterError::Corrupt { .. }) => match inflate_raw(data, max) {
// Keep the original corruption diagnostic only if the retry is corrupt
// too. A limit error must remain fatal even for tolerant decoding.
Err(retry) if matches!(retry.error, crate::FilterError::Corrupt { .. }) => Err(f),
retry => retry,
},
r => r,
},
None => inflate_raw(data, max),
+20
View File
@@ -295,6 +295,26 @@ mod tests {
assert_eq!(decode(&chain, &hex, 100).unwrap().len(), 100);
}
#[test]
fn raw_flate_fallback_preserves_output_limits() {
// RFC 1951 stored blocks: the first is nonfinal with LEN=257 and
// NLEN=!257, followed by a final empty block. Its ignored alignment
// bits make the first two bytes also look like a valid zlib header.
let data = vec![b'A'; 257];
let mut raw = vec![0x78, 0x01, 0x01, 0xfe, 0xfe];
raw.extend_from_slice(&data);
raw.extend_from_slice(&[0x01, 0x00, 0x00, 0xff, 0xff]);
let chain = [(Filter::Flate, Params::default())];
for max in [0, 1, 128, 256] {
assert!(matches!(decode(&chain, &raw, max), Err(FilterError::LimitExceeded(m)) if m == max));
assert!(matches!(decode_tolerant(&chain, &raw, max), Err(FilterError::LimitExceeded(m)) if m == max));
}
for max in [257, 258] {
assert_eq!(decode(&chain, &raw, max).unwrap(), data);
assert_eq!(decode_tolerant(&chain, &raw, max).unwrap(), (data.clone(), false));
}
}
#[test]
fn tolerant_flate_returns_partial() {
let data: Vec<u8> = (0..20_000u32).map(|i| (i % 97) as u8 ^ (i / 300) as u8).collect();
+74 -2
View File
@@ -1,8 +1,8 @@
//! What content editing and redaction need from a PDF font: how to split a string into character
//! codes, each code's advance width, the glyph height (ascent, descent), and what each code means
//! (Unicode) — and back, which text the font can show. Widths come from the font dictionary
//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); a standard-14 font with no `/Widths` uses the exact
//! published metrics ([`crate::Std14`]).
//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); a simple font with no `/Widths` uses its embedded
//! sfnt program's advances, else the exact published standard-14 metrics ([`crate::Std14`]).
//!
//! Meanings come from `/ToUnicode`, else the encoding (`/Encoding` base and `/Differences` glyph
//! names, ISO 32000-2 Annex D). A code may deliberately have **no** meaning: where the font's own
@@ -167,6 +167,44 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec<u64> {
}
}
/// A simple font with no `/Widths` is drawn (and found by text search) with its embedded
/// program's own advances, so measure with them too; a standard-14 guess from the name can be
/// wider and leave the end of a line outside a redaction mark (#2130). The glyph is looked up
/// the way the renderer does: the encoding's character in the Unicode cmap, else the code itself
/// (as in a symbolic cmap), else `.notdef`. Only sfnt programs are read; others keep the guess.
fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) {
use skrifa::instance::{LocationRef, Size};
use skrifa::{GlyphId, MetadataProvider as _, raw::TableProvider as _};
let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return };
for key in [b"FontFile2".as_slice(), b"FontFile3".as_slice()] {
let Some(object) = descriptor.get(key) else { continue };
let object = doc.resolve(object);
let Object::Stream(stream) = object.as_ref() else { continue };
let Ok(bytes) = stream.decoded_within(16 << 20) else { continue };
let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue };
let upem = f64::from(face.metrics(Size::unscaled(), LocationRef::default()).units_per_em);
if upem == 0.0 {
continue;
}
let advances = face.glyph_metrics(Size::unscaled(), LocationRef::default());
let (charmap, cmap) = (face.charmap(), face.cmap().ok());
let found = |g: Option<GlyphId>| g.filter(|g| *g != GlyphId::NOTDEF);
m.widths = (0..=255u32)
.map(|code| {
let glyph = found(m.unicode.encoding.get(&code).and_then(|t| only_char(t)).and_then(|c| charmap.map(c)))
.or_else(|| [0, 0xF000, 0xF100, 0xF200].into_iter().find_map(|o| found(cmap.as_ref()?.map_codepoint(code + o))));
// A subset holds only the glyphs it draws: 0 keeps `has_glyph` false for the rest.
match glyph {
None if m.subset => 0.0,
_ => advances.advance_width(glyph.unwrap_or(GlyphId::NOTDEF)).map_or(0.0, |w| f64::from(w) * 1000.0 / upem),
}
})
.collect();
m.first = 0;
return;
}
}
/// Replace an existing cmap directory entry with a bounded Unicode format-12 table.
/// Checked ranges prevent damaged sfnt offsets from indexing outside the source program.
fn append_editor_cmap(bytes: &mut Vec<u8>, mappings: &std::collections::BTreeMap<u32, u32>) -> Option<()> {
@@ -607,6 +645,9 @@ impl Metrics {
Codes::Ranges(r) => r.last().map_or(2, |x| x.0),
};
m.unicode = unicode_map(doc, font, m.composite);
if m.std14.is_some() {
embedded_widths(doc, font, &mut m);
}
let mut reverse = HashMap::new();
for (&code, text) in m.unicode.explicit.iter().map(|(c, (_, t))| (c, t)).chain(m.unicode.encoding.iter()) {
if let Some(ch) = only_char(text)
@@ -1218,6 +1259,37 @@ mod tests {
assert!(embedded_sfnt(&doc, &Dict::new()).is_none());
}
/// #2130: with no `/Widths`, an embedded program's advances win over a guess from the name
/// ("Mono" would mean Courier, 600 units for every glyph).
#[test]
fn a_font_without_widths_measures_with_its_embedded_program() {
use pdfcraft_cos::Stream;
use skrifa::MetadataProvider as _;
let mut doc = Document::new_empty();
let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
let face = skrifa::FontRef::new(bytes).unwrap();
let (size, location) = (skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default());
let upem = f64::from(face.metrics(size, location).units_per_em);
let advance = |c: char| f64::from(face.glyph_metrics(size, location).advance_width(face.charmap().map(c).unwrap()).unwrap()) / upem;
let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes)));
let mut descriptor = Dict::new();
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
let f = font(
&mut doc,
vec![
("Subtype", Object::name("TrueType")),
("BaseFont", Object::name("HouseMono")),
("Encoding", Object::name("WinAnsiEncoding")),
("FontDescriptor", Object::Dict(descriptor)),
],
);
let m = Metrics::from_dict(&doc, &f);
for c in ['i', 'l', 'W', ' '] {
assert!((m.width(u32::from(c)) - advance(c)).abs() < 1e-9, "{c}: {} vs {}", m.width(u32::from(c)), advance(c));
}
assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6");
}
#[test]
fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() {
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
+82 -7
View File
@@ -1,6 +1,6 @@
//! Prepare a form ▸ automatic field detection: find where a printed form expects answers —
//! underscore runs, rules (horizontal lines), empty boxes and small squares — and name each new
//! field from the label next to it (Acrobat's auto-naming).
//! underscore runs, rules (horizontal lines), empty boxes, rows of comb cells and small squares —
//! and name each new field from the label next to it (Acrobat's auto-naming).
//!
//! [`detect`] is a pure function over the page's words and drawn shapes; [`page_shapes`] reads
//! the shapes from a page's content, [`raster::shapes`] from a picture of the page (a scan).
@@ -30,6 +30,11 @@ pub struct Shapes {
pub enum Kind {
Text,
CheckBox,
/// A row of touching cells, one character each: a text field with the comb flag and
/// `cells` as its character limit.
Comb {
cells: usize,
},
}
#[derive(Clone, Debug, PartialEq)]
@@ -40,6 +45,10 @@ pub struct Candidate {
pub name: String,
}
/// The most points a subpath may have and still be read as a rectangle: its four corners plus
/// points along its sides (#2942: a cell drawn with a midpoint on each vertical side).
const MAX_RECT_POINTS: usize = 16;
/// The rectangles and horizontal segments a page draws (stroked or filled), in user space.
#[derive(Default)]
struct DetectionPath {
@@ -65,12 +74,33 @@ impl DetectionPath {
return None;
}
let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6;
let mut points = self.points.as_slice();
let mut points = self.points.clone();
let repeated_end = points.len() > 1 && near(points[0], *points.last()?);
if repeated_end {
points = &points[..points.len() - 1];
points.pop();
}
if points.len() != 4 || !(self.closed || implicitly_closed || repeated_end) {
if !(self.closed || implicitly_closed || repeated_end) {
return None;
}
// A point on a straight side, between its neighbours, doesn't change the shape: drop it,
// so a cell drawn with a midpoint on a side is the rectangle it looks like (#2942). A
// point that turns back along the side is not between its neighbours and stays, so a
// path that doubles back is still not a rectangle.
let between = |a: f64, m: f64, b: f64| a.min(b) - 1e-6 <= m && m <= a.max(b) + 1e-6;
let mut i = 0;
while points.len() > 4 && i < points.len() {
let n = points.len();
let (Some(&a), Some(&m), Some(&b)) = (points.get((i + n - 1) % n), points.get(i), points.get((i + 1) % n)) else { break };
let on_row = (a[1] - m[1]).abs() < 1e-6 && (m[1] - b[1]).abs() < 1e-6 && between(a[0], m[0], b[0]);
let on_column = (a[0] - m[0]).abs() < 1e-6 && (m[0] - b[0]).abs() < 1e-6 && between(a[1], m[1], b[1]);
if on_row || on_column {
points.remove(i);
i = 0;
} else {
i += 1;
}
}
if points.len() != 4 {
return None;
}
let mut horizontal = [false; 4];
@@ -157,7 +187,7 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
let (bx, by) = ctm.apply(x, y);
let b = [bx, by];
if !path.complex {
if path.points.len() >= 5 {
if path.points.len() > MAX_RECT_POINTS {
path.complex = true;
} else {
if path.points.is_empty() {
@@ -284,6 +314,42 @@ fn label_for(words: &[Word], rect: [f64; 4], right_of: bool) -> String {
clean(&on.iter().map(|w| w.text.as_str()).collect::<Vec<_>>().join(" "))
}
/// Rows of two or more touching cells of one size, as (the row's rect, cell count). Two is
/// enough: dates split into day and month groups of two cells. Cells are square or taller
/// than wide; an empty table of wider cells is not a comb.
fn combs(boxes: &[[f64; 4]]) -> Vec<([f64; 4], usize)> {
let cells: Vec<[f64; 4]> = boxes
.iter()
.copied()
.filter(|r| {
let (w, h) = (r[2] - r[0], r[3] - r[1]);
(5.0..=30.0).contains(&w) && (8.0..=40.0).contains(&h) && w <= h * 1.2
})
.collect();
// `b` comes right after `a` in a row: same size, its left edge on a's right edge.
let follows = |a: &[f64; 4], b: &[f64; 4]| {
(b[0] - a[2]).abs() <= 1.0 && (b[1] - a[1]).abs() <= 0.5 && (b[3] - a[3]).abs() <= 0.5 && ((b[2] - b[0]) - (a[2] - a[0])).abs() <= 0.5
};
let mut out = Vec::new();
for first in &cells {
if cells.iter().any(|a| follows(a, first)) {
continue;
}
let (mut last, mut n) = (first, 1usize);
// Each step moves at least 4 pt right, so this ends; the bound is for safety.
while let Some(b) = cells.iter().find(|b| follows(last, b))
&& n < cells.len()
{
last = b;
n += 1;
}
if n >= 2 {
out.push(([first[0], first[1], last[2], first[3]], n));
}
}
out
}
/// Propose fields for a page. `existing` are the page's widget rectangles (no candidate
/// overlaps them); names are unique against `taken`.
pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[String]) -> Vec<Candidate> {
@@ -304,6 +370,15 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S
let l = label_for(&labels, rect, false);
push(Kind::Text, rect, l, &mut out);
}
// Comb boxes (postcodes, dates, IBANs): one text field per row of cells, found before
// check boxes so the cells don't each become one.
for (rect, cells) in combs(&shapes.boxes) {
if labels.iter().any(|x| overlaps(x.rect, rect)) {
continue;
}
let l = label_for(&labels, rect, false);
push(Kind::Comb { cells }, rect, l, &mut out);
}
// Small squares: check boxes, labelled by the text to their right.
for r in &shapes.boxes {
let (w, h) = (r[2] - r[0], r[3] - r[1]);
@@ -356,7 +431,7 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S
for c in &mut out {
let base = if c.name.is_empty() {
match c.kind {
Kind::Text => "Text".to_string(),
Kind::Text | Kind::Comb { .. } => "Text".to_string(),
Kind::CheckBox => "Check Box".to_string(),
}
} else {
+71
View File
@@ -1411,6 +1411,54 @@ fn detection_finds_blanks_rules_boxes_and_names_them() {
assert!(again.is_empty(), "{again:?}");
}
#[test]
fn detection_makes_a_row_of_touching_cells_one_comb_field() {
use crate::detect::*;
let w = |t: &str, x0: f64, y0: f64, x1: f64| Word { text: t.into(), rect: [x0, y0, x1, y0 + 10.0] };
let words = vec![
w("Postcode", 40.0, 205.0, 85.0),
w("A", 88.0, 205.0, 95.0),
w("Postcode", 40.0, 105.0, 85.0),
w("B", 88.0, 105.0, 95.0),
w("Yes", 70.0, 41.0, 90.0),
];
let row = |n: usize, x: f64, y: f64, cw: f64, ch: f64| (0..n).map(move |i| [x + i as f64 * cw, y, x + (i + 1) as f64 * cw, y + ch]);
// 18 x 22 cells (too tall for a check box), 16 x 20 cells (each one check-box sized) drawn
// right to left, and a lone square.
let mut boxes: Vec<[f64; 4]> = row(5, 120.0, 200.0, 18.0, 22.0).collect();
boxes.extend(row(10, 120.0, 100.0, 16.0, 20.0).rev());
boxes.push([50.0, 40.0, 62.0, 52.0]);
let found = detect(&words, &Shapes { boxes, rules: Vec::new() }, &[], &[]);
let got: Vec<(&str, &Kind, [f64; 4])> = found.iter().map(|c| (c.name.as_str(), &c.kind, c.rect)).collect();
assert_eq!(
got,
[
("Postcode A", &Kind::Comb { cells: 5 }, [120.0, 200.0, 210.0, 222.0]),
("Postcode B", &Kind::Comb { cells: 10 }, [120.0, 100.0, 280.0, 120.0]),
("Yes", &Kind::CheckBox, [50.0, 40.0, 62.0, 52.0]),
],
"{found:?}"
);
// Squares with a gap between them stay check boxes, and so do touching squares of
// different sizes.
let apart = Shapes {
boxes: vec![[50.0, 40.0, 62.0, 52.0], [66.0, 40.0, 78.0, 52.0], [80.0, 40.0, 92.0, 52.0], [92.0, 40.0, 102.0, 52.0]],
rules: Vec::new(),
};
let kinds: Vec<Kind> = detect(&[], &apart, &[], &[]).into_iter().map(|c| c.kind).collect();
assert_eq!(kinds, [Kind::CheckBox, Kind::CheckBox, Kind::CheckBox, Kind::CheckBox]);
// An empty table of cells wider than tall (an attendance sheet) is not a comb.
let grid = |cw: f64, ch: f64| {
(0..3).flat_map(move |r| {
(0..3).map(move |c| [100.0 + c as f64 * cw, 300.0 + r as f64 * ch, 100.0 + (c + 1) as f64 * cw, 300.0 + (r + 1) as f64 * ch])
})
};
for (cw, ch) in [(24.0, 18.0), (28.0, 20.0)] {
let table = detect(&[], &Shapes { boxes: grid(cw, ch).collect(), rules: Vec::new() }, &[], &[]);
assert!(table.is_empty(), "{cw} x {ch}: {table:?}");
}
}
#[test]
fn page_shapes_reads_boxes_and_rules() {
let mut doc = fixture();
@@ -2023,6 +2071,29 @@ fn page_shapes_accepts_rectangle_closure_and_paint_variants() {
}
}
/// #2942: a cell drawn with a point in the middle of each vertical side (the French 2042 form's
/// letter cells), or on every side, is the rectangle it looks like. A point that turns back
/// along a side is not on the way between its neighbours, so such a path is still not a box.
#[test]
fn page_shapes_reads_rectangles_with_points_along_their_sides() {
for content in [
"100 100 m 111.1 100 l 111.1 107.35 l 111.1 114.7 l 100 114.7 l 100 107.35 l h f",
"100 100 m 105 100 l 111.1 100 l 111.1 107.35 l 111.1 114.7 l 105 114.7 l 100 114.7 l 100 107.35 l h S",
"100 107.35 m 100 100 l 111.1 100 l 111.1 114.7 l 100 114.7 l h f",
] {
let shapes = detection_test_shapes(content.as_bytes());
assert_eq!(shapes.boxes, [[100.0, 100.0, 111.1, 114.7]], "{content}");
}
for content in [
// Out past the corner and back: a spike, not a side.
"100 100 m 130 100 l 111.1 100 l 111.1 114.7 l 100 114.7 l h S",
// A sixth point off the sides.
"100 100 m 111.1 100 l 115 107 l 111.1 114.7 l 100 114.7 l 100 107.35 l h S",
] {
assert!(detection_test_shapes(content.as_bytes()).boxes.is_empty(), "{content}");
}
}
#[test]
fn page_shapes_rejects_open_diagonal_curved_and_complex_rectangles() {
for content in [
+25 -2
View File
@@ -215,7 +215,10 @@ fn num(v: f64) -> String {
/// WinAnsiEncoding), each word stretched to its box so selection and search highlight the
/// right place. Marked content `/OCR` so it can be told apart from the page's own text.
pub fn text_layer(words: &[PlacedWord]) -> Vec<u8> {
let mut out = b"/OCR BMC\nBT\n3 Tr\n/PCHelv 1 Tf\n".to_vec();
// `3 Tr` and the font are graphics state, which `ET` doesn't reset: without `q … Q` they
// would stay set for every later content stream on the page, so text added after OCR (a
// header or footer) drew invisibly (#3029).
let mut out = b"/OCR BMC\nq\nBT\n3 Tr\n/PCHelv 1 Tf\n".to_vec();
for w in words {
let width = helvetica_width(&w.text, 1.0);
let height = w.up[0].hypot(w.up[1]);
@@ -235,7 +238,7 @@ pub fn text_layer(words: &[PlacedWord]) -> Vec<u8> {
out.extend(pdfcraft_fonts::literal(&pdfcraft_fonts::win_ansi(&w.text)));
out.extend_from_slice(b" Tj\n");
}
out.extend_from_slice(b"ET\nEMC\n");
out.extend_from_slice(b"ET\nQ\nEMC\n");
out
}
@@ -272,6 +275,26 @@ mod tests {
assert!(s.starts_with("/OCR BMC") && s.ends_with("EMC\n"));
}
/// #3029: the layer's invisible rendering mode and font end with the layer. Every operator
/// that sets them sits inside a `q … Q` pair that the layer closes itself, so content drawn
/// after it on the page starts from the graphics state it had before.
#[test]
fn the_layer_restores_the_graphics_state_it_changes() {
let w = PlacedWord { text: "Hi".into(), origin: [10.0, 20.0], across: [30.0, 0.0], up: [0.0, 9.3] };
let s = String::from_utf8(text_layer(&[w])).unwrap();
let mut depth = 0_i32;
for token in s.split_ascii_whitespace() {
match token {
"q" => depth += 1,
"Q" => depth -= 1,
"Tr" | "Tf" | "Tm" => assert!(depth > 0, "`{token}` outside q … Q in {s:?}"),
_ => {}
}
assert!(depth >= 0, "unbalanced Q in {s:?}");
}
assert_eq!(depth, 0, "the layer leaves a q open: {s:?}");
}
/// A word box is tight around the ink, so the text's size and baseline come from the letters:
/// capitals, lowercase without ascenders, descenders.
#[test]
+20
View File
@@ -472,6 +472,26 @@ pub fn move_pages(doc: &mut Document, indices: &[usize], to: usize) -> Result<()
rebuild(doc, &rest)
}
/// Reverse the order of the pages at `indices` among the positions they occupy; every other
/// page stays where it is. Fewer than two distinct pages is refused, since nothing would change.
pub fn reverse_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> {
let mut all = walk(doc)?;
check(indices, all.len())?;
let mut slots = indices.to_vec();
slots.sort_unstable();
slots.dedup();
if slots.len() < 2 {
return Err(OrganizeError::Invalid("reversing needs at least two pages".into()));
}
let pages: Vec<(ObjRef, Dict)> = slots.iter().filter_map(|i| all.get(*i).cloned()).collect();
for (slot, page) in slots.iter().zip(pages.into_iter().rev()) {
if let Some(entry) = all.get_mut(*slot) {
*entry = page;
}
}
rebuild(doc, &all)
}
/// Duplicate pages: copies of `indices` (in order) are inserted after the last of them, sharing
/// fonts and images with the originals.
pub fn duplicate_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> {
+19
View File
@@ -244,6 +244,25 @@ fn move_pages_reorders() {
assert_eq!(labels(&doc), ["Page 2", "Page 3", "Page 1"]);
}
#[test]
fn reverse_pages_reorders() {
let mut doc = open(fixture());
reverse_pages(&mut doc, &[0, 1, 2]).unwrap();
assert_eq!(labels(&doc), ["Page 3", "Page 2", "Page 1"]);
// Unsorted, repeated indices name the same slots; page 1's slot stays put.
reverse_pages(&mut doc, &[2, 1, 2]).unwrap();
let doc = save_and_reopen(&doc);
assert_eq!(labels(&doc), ["Page 3", "Page 1", "Page 2"]);
}
#[test]
fn cannot_reverse_missing_or_fewer_than_two_pages() {
let mut doc = open(fixture());
assert_eq!(reverse_pages(&mut doc, &[0, 3]), Err(OrganizeError::NoSuchPage(3)));
assert!(matches!(reverse_pages(&mut doc, &[1, 1]), Err(OrganizeError::Invalid(_))));
assert!(!doc.is_modified(), "failed operations leave the document untouched");
}
#[test]
fn inserts_blank_page() {
let mut doc = open(fixture());
+2 -1
View File
@@ -506,7 +506,8 @@ mod cups_options {
let Some(record) = std::env::var_os(RECORD) else { return };
let args: Vec<String> = std::env::args().skip_while(|a| a != "--").skip(1).collect();
std::fs::write(std::path::Path::new(&record).join("args"), args.join("\n")).unwrap();
println!("{}", std::env::var(ANSWER).unwrap());
// Serial libtest prints its test label without a newline before this helper's stdout.
println!("\n{}", std::env::var(ANSWER).unwrap());
}
fn command(record: &std::path::Path, answer: &str) -> std::process::Command {
+6 -1
View File
@@ -25,7 +25,7 @@ clear_marks(&mut doc, None)?; // remove marks without app
- **vectors**: covered paths are removed; partly covered paths and shadings are clipped so
nothing paints inside the areas;
- **form XObjects**: rewritten recursively into new objects (pages sharing the original keep it);
- **annotations** whose rectangle overlaps (with their pop-ups), and **form fields** with a
- **annotations** whose rectangle overlaps (with their pop-ups and their replies, `/IRT`), and **form fields** with a
widget under a mark;
- the marks themselves, replaced by boxes in their fill colour (and overlay text) drawn into the
page as a `/PCMark /Redaction` stream.
@@ -44,6 +44,11 @@ This limits appearance generation; it is not a limit on the input PDF or the who
Structure cleanup resolves named MCIDs and indirect content references. Form content
references are updated to the cleaned copy; unchanged sibling descriptions remain.
Replacement descriptions are removed only from marked-content spans changed by cleanup.
Unchanged spans retain their descriptions; unused property entries are retired during redaction
and hidden-content cleanup, including aliases in shared/inherited resources. Descriptions still
used by surviving pages or forms are kept.
A redacted page that inherits `/Resources` from the page tree gets them pinned (by reference;
a direct inherited dictionary is made one shared object first) before it is rewritten. The parent's
`/Resources` is then retired unless another page still inherits it, so it cannot keep a removed
+248 -18
View File
@@ -21,7 +21,7 @@ use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object, Stream};
use crate::{RedactError, Report, image, page_streams, tags};
use pdfcraft_fonts::pdf::Metrics;
@@ -66,12 +66,16 @@ pub(crate) struct Output {
/// dictionaries may name them too (an inherited or shared /Resources, or the caller's when a
/// form has none of its own): [`unlink_doomed`] removes those entries.
pub doomed: Vec<ObjRef>,
/// Original property values retired by this scope or a nested form. Other resource holders
/// may still name them; unlink only entries no surviving content uses.
pub retired_properties: Vec<Object>,
/// Glyphs / inline images still overlapping a region (verify mode).
pub residue: usize,
/// Apply mode met vertical-writing text (which it cannot remove) while a redaction region was
/// in force: the operation must fail closed rather than leave that text under the mark.
pub vertical: bool,
pub tag_changes: Vec<(ObjRef, ObjRef, HashSet<i64>, HashSet<i64>)>,
pub error: Option<CosError>,
}
pub(crate) struct Scope<'a> {
@@ -246,9 +250,10 @@ fn content_draws(doc: &Document, content: &[Vec<u8>], xo: &Dict, name: &[u8], de
/// An entry goes unless a page that draws through that dictionary still draws the name (the same
/// image shown on another page, or again outside a mark). A dictionary that loses an entry is
/// replaced by a private copy on its node, so a dictionary object shared with other nodes is
/// left as it is for them. Fails closed when a page's use of a name cannot be told.
pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef]) -> Result<(), RedactError> {
if doomed.is_empty() {
/// left as it is for them. Retired properties are likewise unlinked only when none of the
/// holder's pages or inheriting forms names them. Fails closed when use cannot be determined.
pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_properties: &[Object]) -> Result<(), RedactError> {
if doomed.is_empty() && retired_properties.is_empty() {
return Ok(());
}
let doomed: HashSet<ObjRef> = doomed.iter().copied().collect();
@@ -278,11 +283,16 @@ pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef]) -> Result<(),
}
}
let index: HashMap<ObjRef, usize> = pdfcraft_model::pages(doc).iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
let holder_nodes: Vec<ObjRef> = holders.iter().map(|(node, _)| *node).collect();
for (node, users) in holders {
let Some(res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue };
let Some(mut res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else {
continue;
};
let mut xo = res_dict(doc, &res, b"XObject");
let names: Vec<Vec<u8>> = xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect();
if names.is_empty() {
let properties = res_dict(doc, &res, b"Properties");
let property_names: Vec<Vec<u8>> = properties.iter().filter(|(_, v)| retired_properties.contains(v)).map(|(n, _)| n.to_vec()).collect();
if names.is_empty() && property_names.is_empty() {
continue;
}
let mut contents = Vec::with_capacity(users.len());
@@ -310,11 +320,84 @@ pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef]) -> Result<(),
}
}
if pruned {
let mut res = res;
res.set(b"XObject".to_vec(), Object::Dict(xo));
}
pruned |= prune_retired_properties(doc, &mut res, &property_names, &contents)?;
if pruned {
doc.update_dict(node, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
}
}
// A surviving form can own the same old resource dictionary as a rewritten page. Its
// unused descriptions remain reachable too, even after every page holder is cleaned.
unlink_form_properties(doc, &holder_nodes, retired_properties)?;
Ok(())
}
/// Retire only properties removed from a rewritten scope, preserving names still used by any
/// content sharing this holder. Different pages' streams must never be concatenated together.
fn prune_retired_properties(doc: &Document, res: &mut Dict, names: &[Vec<u8>], contents: &[Vec<Vec<u8>>]) -> Result<bool, CosError> {
if names.is_empty() {
return Ok(false);
}
let mut used = HashSet::new();
for content in contents {
for op in resource_ops(doc, res, content)? {
if matches!(op.op.as_slice(), b"BDC" | b"DP")
&& let Some(name) = op.name(1)
{
used.insert(name.to_vec());
}
}
}
let mut properties = res_dict(doc, res, b"Properties");
let mut pruned = false;
for name in names {
if !used.contains(name) {
pruned |= properties.remove(name).is_some();
}
}
if pruned {
if properties.is_empty() {
res.remove(b"Properties");
} else {
res.set(b"Properties".to_vec(), Object::Dict(properties));
}
}
Ok(pruned)
}
fn unlink_form_properties(doc: &mut Document, holders: &[ObjRef], retired: &[Object]) -> Result<(), CosError> {
if retired.is_empty() {
return Ok(());
}
let mut pending: Vec<Dict> = holders
.iter()
.filter_map(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()))
.collect();
let mut seen = HashSet::new();
while let Some(resources) = pending.pop() {
for (_, value) in res_dict(doc, &resources, b"XObject").iter() {
let Some(reference) = value.as_ref() else { continue };
if !seen.insert(reference) {
continue;
}
if seen.len() > 1_000_000 {
return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() });
}
let object = doc.get(reference);
let Object::Stream(form) = &*object else { continue };
if form.dict.name(b"Subtype") != Some(b"Form") {
continue;
}
let Some(mut res) = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue };
let names: Vec<Vec<u8>> =
res_dict(doc, &res, b"Properties").iter().filter(|(_, value)| retired.contains(value)).map(|(name, _)| name.to_vec()).collect();
if !names.is_empty() && prune_retired_properties(doc, &mut res, &names, &[vec![form.decoded()?]])? {
doc.update_dict(reference, |d| d.set(b"Resources".to_vec(), Object::Dict(res.clone())))?;
}
pending.push(res);
}
}
Ok(())
}
@@ -341,6 +424,71 @@ fn clip_out(rects: &[[f64; 4]], ctm: &Matrix, around: [f64; 4]) -> Option<Vec<Op
Some(ops)
}
fn resource_ops(doc: &Document, res: &Dict, content: &[Vec<u8>]) -> Result<Vec<Op>, CosError> {
let parsed = Pieces::join(content).parse();
if parsed.skipped > 0 || parsed.ops.len() > 2_000_000 {
return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect property resources".into() });
}
let mut ops = parsed.ops;
let xobjects = res_dict(doc, res, b"XObject");
let mut seen = HashSet::new();
let mut cursor = 0;
while cursor < ops.len() {
let op = &ops[cursor];
if op.is("Do")
&& let Some(r) = op.name(0).and_then(|n| xobjects.get(n)).and_then(Object::as_ref)
&& seen.insert(r)
&& let Object::Stream(s) = &*doc.get(r)
&& s.dict.name(b"Subtype") == Some(b"Form")
&& s.dict.get(b"Resources").is_none_or(|r| doc.resolve(r).as_dict().is_none())
{
let parsed = pdfcraft_content::parse(&s.decoded()?);
if seen.len() > 2_000_000 || ops.len().saturating_add(parsed.ops.len()) > 2_000_000 || parsed.skipped > 0 {
return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect inherited form resources".into() });
}
ops.extend(parsed.ops);
}
cursor += 1;
}
Ok(ops)
}
pub(crate) fn prune_properties(doc: &Document, res: &mut Dict, content: &[Vec<u8>]) -> Result<Vec<Object>, CosError> {
let mut properties = res_dict(doc, res, b"Properties");
if properties.is_empty() {
return Ok(Vec::new());
}
let mut retired = Vec::new();
let ops = resource_ops(doc, res, content)?;
let names: Vec<Vec<u8>> = properties.iter().map(|(n, _)| n.to_vec()).collect();
for name in names {
if !ops.iter().any(|op| matches!(op.op.as_slice(), b"BDC" | b"DP") && op.name(1) == Some(&name))
&& let Some(value) = properties.remove(&name)
{
retired.push(value);
}
}
if properties.is_empty() {
res.remove(b"Properties");
} else {
res.set(b"Properties".to_vec(), Object::Dict(properties));
}
Ok(retired)
}
fn prune_form_resources(doc: &Document, res: &mut Dict, content: &[Vec<u8>]) -> Result<(), CosError> {
let ops = resource_ops(doc, res, content)?;
let mut xo = res_dict(doc, res, b"XObject");
let names: Vec<Vec<u8>> = xo.iter().map(|(n, _)| n.to_vec()).collect();
for name in names {
if !ops.iter().any(|op| op.is("Do") && op.name(0) == Some(&name)) {
xo.remove(&name);
}
}
res.set(b"XObject".to_vec(), Object::Dict(xo));
Ok(())
}
/// One glyph of a shown string.
struct Glyph {
bytes: std::ops::Range<usize>,
@@ -380,6 +528,12 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
// next. Parse them joined, then give each operator back to the piece its keyword is in.
let joined = Pieces::join(streams);
let mut parsed = joined.parse();
// A rewritten piece loses its comments, so keep where they began and ended: a comment that
// ran on into the next piece still hides that piece's first line.
let comment_edges = joined.comment_edges(&parsed);
// Layer removal also changes enclosing replacement spans, so remember the operators before
// any cleanup, not just the survivors of the layer pass.
let original = parsed.ops.clone();
if !scope.hidden_layers.is_empty() {
let props = res_dict(doc, resources, b"Properties");
let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props);
@@ -405,6 +559,7 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
}
}
let mut processed = Vec::new();
for (piece, mut changed) in pieces.into_iter().zip(rewrite) {
let mut ops: Vec<Op> = Vec::with_capacity(piece.len());
for op in piece {
@@ -622,11 +777,68 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
if !path.is_empty() {
ops.append(&mut path);
}
out.streams.push(changed.then(|| serialize_ops(&ops)));
processed.push((ops, changed));
}
if scope.mode == Mode::Apply {
clean_replacements(doc, resources, &original, &mut processed, scope.report);
}
for ((ops, changed), (comment_in, comment_out)) in processed.into_iter().zip(comment_edges) {
out.streams.push(changed.then(|| {
// The piece before still ends inside a comment: end it before the first operator.
let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() };
bytes.extend(serialize_ops(&ops));
// The piece ended inside a comment that hid the start of the next one: keep it open.
if comment_out {
bytes.push(b'%');
}
bytes
}));
}
out
}
fn clean_replacements(doc: &Document, resources: &Dict, original: &[Op], processed: &mut [(Vec<Op>, bool)], report: &mut Report) {
let originals: HashMap<usize, &Op> = original.iter().map(|op| (op.span.start, op)).collect();
let unchanged: HashSet<usize> =
processed.iter().flat_map(|(ops, _)| ops).filter(|op| originals.get(&op.span.start) == Some(op)).map(|op| op.span.start).collect();
let mut stack = Vec::new();
let mut affected = HashSet::new();
for op in original {
match op.op.as_slice() {
b"BDC" | b"BMC" => stack.push(op.span.start),
b"EMC" => {
stack.pop();
}
_ if !unchanged.contains(&op.span.start) => affected.extend(stack.iter().copied()),
_ => {}
}
}
let properties = res_dict(doc, resources, b"Properties");
for (ops, changed) in processed {
for op in ops {
if !op.is("BDC") || !affected.contains(&op.span.start) {
continue;
}
let property = match op.operands.get(1) {
Some(Object::Name(name)) => properties.get(name).map(|p| doc.resolve(p)),
Some(p) => Some(doc.resolve(p)),
None => None,
};
if let Some(mut dict) = property.and_then(|p| p.as_dict().cloned()) {
let mut stripped = false;
for key in [&b"ActualText"[..], b"Alt", b"E", b"T"] {
stripped |= dict.remove(key).is_some();
}
if stripped && let Some(property) = op.operands.get_mut(1) {
*property = Object::Dict(dict);
*changed = true;
report.replacement_text += 1;
}
}
}
}
}
/// Lay out a shown string (or `TJ` array) glyph by glyph, advancing `tm`. Returns the `TJ`
/// items with the glyphs under a region replaced by displacements, and how many were removed.
fn show(scope: &Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (Vec<Object>, usize) {
@@ -798,7 +1010,9 @@ fn xobject(
out.residue += inner.residue;
out.vertical |= inner.vertical;
out.doomed.extend_from_slice(&inner.doomed);
out.retired_properties.extend(inner.retired_properties);
out.tag_changes.extend(inner.tag_changes);
out.error = inner.error.or(out.error.take());
if scope.mode == Mode::Verify {
return None;
}
@@ -808,19 +1022,35 @@ fn xobject(
}
let mut dict = s.dict.clone();
dict.remove(b"Length");
// Rebuild the copy's /Resources whenever inner content gained or lost an XObject, so a
// `Do` the copy no longer makes stops keeping the original image or form reachable.
let mut cleaned_res = res.clone();
// Install rewritten nested forms before finding live property names: a new form may
// still inherit its caller's resources, and its new Do name must resolve here.
if !inner.xobjects.is_empty() || !inner.replaced.is_empty() {
let mut res = res.clone();
let mut xo = res_dict(doc, &res, b"XObject");
if let Some(d) = &new_data {
prune(&mut xo, &inner.replaced, std::slice::from_ref(d));
let mut xo = res_dict(doc, &cleaned_res, b"XObject");
if let Some(data) = &new_data {
prune(&mut xo, &inner.replaced, std::slice::from_ref(data));
}
for (n, r) in &inner.xobjects {
xo.set(n.clone(), Object::Ref(*r));
for (name, reference) in &inner.xobjects {
xo.set(name.clone(), Object::Ref(*reference));
}
res.set(b"XObject".to_vec(), Object::Dict(xo));
dict.set(b"Resources".to_vec(), Object::Dict(res));
cleaned_res.set(b"XObject".to_vec(), Object::Dict(xo));
}
if let Some(data) = &new_data {
let content = std::slice::from_ref(data);
match prune_properties(doc, &mut cleaned_res, content) {
Ok(retired) => out.retired_properties.extend(retired),
Err(error) => {
out.error = Some(error);
return None;
}
}
if let Err(error) = prune_form_resources(doc, &mut cleaned_res, content) {
out.error = Some(error);
return None;
}
}
if cleaned_res != res {
dict.set(b"Resources".to_vec(), Object::Dict(cleaned_res));
}
let (changed, empty) = tags::touched(doc, &res, &data, new_data.as_deref().unwrap_or(&data));
let stream = match new_data {
+45 -2
View File
@@ -119,6 +119,7 @@ pub struct Report {
pub forms_removed: usize,
pub annotations: usize,
pub fields: usize,
pub replacement_text: usize,
/// Structure elements that lost alternate/actual text or emptied marked content.
pub tags: usize,
/// The form was an AcroForm+XFA form and its `/XFA` packets (which still held redacted field
@@ -334,6 +335,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
let mut doomed_fields: Vec<String> = Vec::new();
// The original image and form XObjects the rewrite drops or replaces, on any page.
let mut doomed_xobjects: Vec<ObjRef> = Vec::new();
let mut retired_properties = Vec::new();
let widget_owner: Vec<(ObjRef, String)> =
pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::<Vec<_>>()).collect();
@@ -353,10 +355,14 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
// leave the text in place, refuse the whole operation (the caller keeps the previous
// document). The error does not claim the text was under the mark. Full vertical-text
// removal is a future enhancement.
if let Some(error) = out.error {
return Err(error.into());
}
if out.vertical {
return Err(RedactError::VerticalText(pi + 1));
}
doomed_xobjects.extend_from_slice(&out.doomed);
retired_properties.extend(out.retired_properties);
let mut new_list = list.clone();
let mut changed = false;
for (i, new) in out.streams.into_iter().enumerate() {
@@ -393,6 +399,16 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
}
res.set(b"XObject".to_vec(), Object::Dict(xo));
}
let (_, cleaned_data) = page_streams(
doc,
&{
let mut cleaned_page = page.dict.clone();
cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone()));
cleaned_page
},
pi,
)?;
retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?);
doc.update_dict(page.obj, |d| {
d.set(b"Contents".to_vec(), Object::Array(new_list));
d.set(b"Resources".to_vec(), Object::Dict(res));
@@ -400,7 +416,8 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
// Tags must not keep what the page no longer shows.
let after = doc.get(page.obj).as_dict().cloned().unwrap_or_default();
let (_, new_data) = page_streams(doc, &after, pi)?;
let (changed_ids, empty_ids) = tags::touched(doc, &resources, &data.join(&b'\n'), &new_data.join(&b'\n'));
let (changed_ids, empty_ids) =
tags::touched(doc, &resources, pdfcraft_content::Pieces::join(&data).data(), pdfcraft_content::Pieces::join(&new_data).data());
report.tags += tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?;
}
@@ -438,6 +455,32 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
}
}
}
// Replies (`/IRT`, ISO 32000-2 §12.5.6.2) to a removed annotation go too, and replies to
// those: a reply placed elsewhere would keep its parent — the comment and the QuadPoints
// of the redacted words — in the file through its `/IRT` (#2659). Each pass removes at
// least one more annotation of this page or stops, so it ends.
let replies: Vec<(ObjRef, ObjRef)> = annots
.iter()
.filter_map(Object::as_ref)
.filter_map(|r| {
let obj = doc.get(r);
let d = obj.as_dict()?;
(d.name(b"Subtype") != Some(b"Widget")).then_some(())?;
Some((r, d.get(b"IRT").and_then(Object::as_ref)?))
})
.collect();
loop {
let before = removed.len();
for (r, parent) in &replies {
if !removed.contains(r) && removed.contains(parent) {
removed.push(*r);
report.annotations += 1;
}
}
if removed.len() == before {
break;
}
}
let kept: Vec<Object> = annots
.into_iter()
.filter(|a| {
@@ -487,7 +530,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
// A removed image or form must not stay reachable through another resource dictionary (an
// inherited or shared /Resources): a full save keeps whatever is still referenced.
interp::unlink_doomed(doc, &doomed_xobjects)?;
interp::unlink_doomed(doc, &doomed_xobjects, &retired_properties)?;
// 5. Verify: nothing readable may remain under a region.
let mut residue = 0;
+13 -8
View File
@@ -146,6 +146,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
let mut total = 0;
// The original image and form XObjects the rewrite drops or replaces, on any page.
let mut doomed = Vec::new();
let mut retired_properties = Vec::new();
let n = pdfcraft_model::pages(doc).len();
for pi in 0..n {
let page = pdfcraft_model::pages(doc).swap_remove(pi);
@@ -165,12 +166,16 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
}
scope.hidden_layers = off.clone();
let out = process(doc, &mut scope, &data, &resources, Matrix::IDENTITY);
if let Some(error) = out.error {
return Err(error.into());
}
let blocks = scope.layer_blocks;
total += out.residue + blocks + report.glyphs;
if !write {
continue;
}
doomed.extend_from_slice(&out.doomed);
retired_properties.extend(out.retired_properties);
let mut new_list = list.clone();
let mut changed = false;
for (i, new) in out.streams.into_iter().enumerate() {
@@ -184,24 +189,24 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
changed = true;
}
if changed || !out.xobjects.is_empty() || !out.replaced.is_empty() {
// Pin inherited resources before replacing them. When no page still inherits the
// original holder, retiring it also retires descriptions no longer used anywhere.
crate::localize_resources(doc, &[pi])?;
let mut cleaned_page = page.dict.clone();
cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone()));
let (_, page_content) = page_streams(doc, &cleaned_page, pi)?;
let mut res = resources;
if !out.xobjects.is_empty() || !out.replaced.is_empty() {
let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default();
// A full save keeps whatever is still referenced: unlink the originals this page no
// longer draws so the removed form or image bytes cannot survive in the saved file.
let page_content: Vec<Vec<u8>> = new_list
.iter()
.map(|o| match &*doc.resolve(o) {
Object::Stream(s) => s.decoded().unwrap_or_default(),
_ => Vec::new(),
})
.collect();
crate::interp::prune(&mut xo, &out.replaced, &page_content);
for (n, r) in &out.xobjects {
xo.set(n.clone(), Object::Ref(*r));
}
res.set(b"XObject".to_vec(), Object::Dict(xo));
}
retired_properties.extend(crate::interp::prune_properties(doc, &mut res, &page_content)?);
doc.update_dict(page.obj, |d| {
d.set(b"Contents".to_vec(), Object::Array(new_list));
d.set(b"Resources".to_vec(), Object::Dict(res));
@@ -210,7 +215,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
}
// What a page no longer draws must not stay reachable through an inherited or shared
// /Resources either.
crate::interp::unlink_doomed(doc, &doomed)?;
crate::interp::unlink_doomed(doc, &doomed, &retired_properties)?;
Ok(total)
}
+293
View File
@@ -79,6 +79,30 @@ fn glyphs_under_a_mark_go_and_the_rest_stays_put() {
assert!(!content(&doc, 0).contains("1234"));
}
/// #2130: a font with no /Widths is drawn with its embedded program's advances. Measured with a
/// guess from its name instead (Courier's 600 units for "Mono"), the end of the line seemed to
/// lie past a mark that covers all of it on screen, and was kept.
#[test]
fn a_whole_line_under_a_mark_goes_even_without_widths() {
let text = b"illicit little lilies";
let font = "<< /Type /Font /Subtype /TrueType /BaseFont /HouseMono /Encoding /WinAnsiEncoding /FontDescriptor 6 0 R >>";
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(),
stream("", [b"BT /F1 10 Tf 10 100 Td (".as_slice(), text, b") Tj ET"].concat()),
font.as_bytes().to_vec(),
b"<< /Type /FontDescriptor /FontName /HouseMono /Flags 32 /Ascent 900 /Descent -200 /FontFile2 7 0 R >>".to_vec(),
stream(&format!("/Length1 {}", pdfcraft_fonts::INTER_REGULAR.len()), pdfcraft_fonts::INTER_REGULAR),
]);
// Inter draws these 21 narrow glyphs in about 60 pt; Courier's guess would need 126.
let line = [5.0, 95.0, 90.0, 112.0];
mark(&mut doc, 0, &[line], "");
let r = apply(&mut doc, None).unwrap();
assert_eq!(r.glyphs, text.len(), "every glyph of the line goes");
assert_eq!(under(&mut doc, 0, &[[0.0, 0.0, 300.0, 300.0]]), 0, "{}", content(&doc, 0));
}
#[test]
fn added_text_parameters_do_not_keep_redacted_text() {
// An Edit ▸ Add content item: its stream dictionary keeps the source text under /PCAdded.
@@ -490,6 +514,59 @@ fn redacting_an_xfa_field_drops_the_xfa_packet_so_its_value_cannot_survive() {
assert!(cat.get(b"NeedsRendering").is_none(), "/NeedsRendering is cleared");
}
#[test]
fn replies_to_a_redacted_comment_go_with_it() {
// #2659: a highlight under the mark has a reply placed elsewhere, and that reply a reply of
// its own. The replies' /IRT would keep the highlight (its comment and QuadPoints) in the file.
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>".to_vec(),
stream("", b"BT ET"),
b"<< /Type /Annot /Subtype /Highlight /Rect [20 20 60 30] /QuadPoints [20 30 60 30 20 20 60 20] /Contents (HIGHLIGHT-NOTE) >>".to_vec(),
b"<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 5 0 R /Contents (FIRST-REPLY) >>".to_vec(),
b"<< /Type /Annot /Subtype /Text /Rect [250 200 270 220] /IRT 6 0 R /Contents (SECOND-REPLY) >>".to_vec(),
b"<< /Type /Annot /Subtype /Text /Rect [250 100 270 120] /Contents (UNRELATED) >>".to_vec(),
];
let mut doc = pdf(objs);
mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], "");
let r = apply(&mut doc, None).unwrap();
assert_eq!(r.annotations, 3, "the highlight and both replies: {r:?}");
let p = pdfcraft_model::pages(&doc).swap_remove(0);
assert_eq!(annots_of(&doc, &p.dict).len(), 1, "only the unrelated note stays");
// A classic table, so the objects' strings are plain in the file.
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
for gone in [&b"HIGHLIGHT-NOTE"[..], b"FIRST-REPLY", b"SECOND-REPLY"] {
assert!(!bytes.windows(gone.len()).any(|w| w == gone), "{} survived the save", String::from_utf8_lossy(gone));
}
assert!(bytes.windows(9).any(|w| w == b"UNRELATED"));
}
/// The replies listed before the annotations they answer, deepest first, and a pop-up on a
/// reply: one pass in `/Annots` order would keep them, the repeated passes don't (review of
/// #2697).
#[test]
fn replies_listed_before_their_parent_and_their_pop_ups_go_too() {
let objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [7 0 R 9 0 R 6 0 R 5 0 R 8 0 R] >>".to_vec(),
stream("", b"BT ET"),
b"<< /Type /Annot /Subtype /Highlight /Rect [20 20 60 30] /QuadPoints [20 30 60 30 20 20 60 20] /Contents (HIGHLIGHT-NOTE) >>".to_vec(),
b"<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 5 0 R /Contents (FIRST-REPLY) /Popup 9 0 R >>".to_vec(),
b"<< /Type /Annot /Subtype /Text /Rect [250 200 270 220] /IRT 6 0 R /Contents (SECOND-REPLY) >>".to_vec(),
b"<< /Type /Annot /Subtype /Text /Rect [250 100 270 120] /Contents (UNRELATED) >>".to_vec(),
b"<< /Type /Annot /Subtype /Popup /Rect [200 150 290 190] /Parent 6 0 R >>".to_vec(),
];
let mut doc = pdf(objs);
mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], "");
let r = apply(&mut doc, None).unwrap();
assert_eq!(r.annotations, 3, "the highlight and both replies: {r:?}");
let p = pdfcraft_model::pages(&doc).swap_remove(0);
let left = annots_of(&doc, &p.dict);
assert_eq!(left, vec![Object::Ref(ObjRef::new(8, 0))], "only the unrelated note stays, the reply's pop-up goes too");
}
#[test]
fn comments_links_and_fields_under_a_mark_go() {
let mut doc = one_page(b"", "", vec![]);
@@ -1056,6 +1133,100 @@ fn redacting_every_inheriting_page_retires_the_parent_resources() {
}
}
#[test]
fn replacement_text_and_property_resources_do_not_survive_redaction() {
let mut doc = one_page(
b"/Span /P0 BDC /Span <</ActualText (LEAK_INLINE)>> BDC BT /F1 10 Tf 10 200 Td (SECRET)",
"/Properties << /P0 7 0 R >>",
vec![
b"<< /MCID 0 /ActualText (LEAK_PROPERTY) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) >>".to_vec(),
stream("", b"Tj ET EMC EMC /Span <</ActualText (Public point)>> DP BT /F1 10 Tf 10 100 Td (Public) Tj ET"),
],
);
doc.update_dict(ObjRef::new(3, 0), |d| {
d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(ObjRef::new(8, 0))]));
})
.unwrap();
mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], "");
apply(&mut doc, None).unwrap();
assert_no_secret(&doc, b"LEAK_");
let saved = reopen(&doc);
assert!(content(&saved, 0).contains("Public"));
let p = pdfcraft_model::pages(&saved).swap_remove(0);
assert!(!saved.resolve(p.dict.get(b"Resources").unwrap()).as_dict().unwrap().contains(b"Properties"));
}
#[test]
fn inherited_form_properties_lose_replacement_text() {
let mut doc = one_page(
b"/Fm Do",
"/XObject << /Fm 7 0 R >> /Properties << /P0 8 0 R >>",
vec![
stream(
"/Type /XObject /Subtype /Form /BBox [0 0 300 300]",
b"/Span /P0 BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC BT /F1 10 Tf 10 100 Td (Public) Tj ET",
),
b"<< /ActualText (LEAK_FORM_PROPERTY) >>".to_vec(),
],
);
mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], "");
apply(&mut doc, None).unwrap();
assert_no_secret(&doc, b"LEAK_");
assert_no_secret(&doc, b"SECRET");
let mut saved = reopen(&doc);
assert_eq!(under(&mut saved, 0, &[[0.0, 90.0, 100.0, 110.0]]), 6);
}
#[test]
fn properties_used_by_unmodified_inheriting_forms_stay() {
let mut doc = one_page(
b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET /Fm Do",
"/XObject << /Fm 7 0 R >> /Properties << /P0 8 0 R >>",
vec![stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10]", b"/Span /P0 BDC 0 0 10 10 re f EMC"), b"<< /ActualText (Public) >>".to_vec()],
);
mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], "");
apply(&mut doc, None).unwrap();
let saved = reopen(&doc);
let page = pdfcraft_model::pages(&saved).swap_remove(0);
let resources = saved.resolve(page.dict.get(b"Resources").unwrap());
let properties = saved.resolve(resources.as_dict().unwrap().get(b"Properties").unwrap());
let property = saved.resolve(properties.as_dict().unwrap().get(b"P0").unwrap());
assert_eq!(property.as_dict().unwrap().get(b"ActualText").unwrap().as_string().unwrap().to_text(), "Public");
}
#[test]
fn untouched_replacement_descriptions_survive_redaction_and_hidden_text_cleanup() {
for hidden in [false, true] {
let mode = if hidden { 3 } else { 0 };
let data = format!(
"/Span <</ActualText (LEAK_REMOVED)>> BDC BT /F1 10 Tf {mode} Tr 10 200 Td (SECRET) Tj ET EMC \
/Span /Public BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC"
);
let mut doc = one_page(
data.as_bytes(),
"/Properties << /Public 7 0 R >>",
vec![b"<< /ActualText (PUBLIC_ACTUAL) /Alt (PUBLIC_ALT) /E (PUBLIC_EXPANSION) /T (PUBLIC_TITLE) >>".to_vec()],
);
if hidden {
sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap();
} else {
mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], "");
apply(&mut doc, None).unwrap();
}
assert_no_secret(&doc, b"LEAK_");
assert_no_secret(&doc, b"SECRET");
let saved = reopen(&doc);
let page = pdfcraft_model::pages(&saved).swap_remove(0);
let resources = saved.resolve(page.dict.get(b"Resources").unwrap());
let properties = saved.resolve(resources.as_dict().unwrap().get(b"Properties").unwrap());
let property = saved.resolve(properties.as_dict().unwrap().get(b"Public").unwrap());
let dict = property.as_dict().unwrap();
for key in [&b"ActualText"[..], b"Alt", b"E", b"T"] {
assert!(dict.contains(key), "surviving description removed: {key:?}, hidden={hidden}");
}
}
}
#[test]
fn a_hostile_icc_component_count_is_refused_not_multiplied_into_a_crash() {
// /N comes from the file. 2^60 × 8 bpc × width 2 overflows the row size; a negative /N wrapped
@@ -1117,3 +1288,125 @@ fn masks_and_alternates_outside_redaction_stay() {
assert!(image.dict.contains(b"SMask") && image.dict.contains(b"Alternates"));
assert_eq!(image.decoded().unwrap(), b"PUBLIC_TOPBOTTOM");
}
#[test]
fn stream_boundary_comment_stays_inert_after_unrelated_redaction() {
for tail in [b"BT /F1 10 Tf 10 200 Td (HIDDEN) Tj ET".as_slice(), b"BT /F1 10 Tf 10 200 Td (HIDDEN) Tj ET\nBT /F1 10 Tf 10 250 Td (LIVE) Tj ET"] {
let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (REMOVE) Tj ET % note", "", vec![]);
let hidden = doc.add(Object::Stream(Stream::from_raw(Dict::new(), tail.to_vec())));
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(hidden)]))).unwrap();
mark(&mut doc, 0, &[[9.0, 95.0, 50.0, 110.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6);
let reopened = reopen(&doc);
let page = &pdfcraft_model::pages(&reopened)[0];
let (_, data) = page_streams(&reopened, &page.dict, 0).unwrap();
// Independent of Pieces::join: the renderer concatenates streams with spaces.
let ops = pdfcraft_content::parse(&data.join(&b' ')).ops;
assert!(!ops.iter().any(|o| o.operands.iter().any(|v| v.as_string().is_some_and(|s| s.bytes == b"HIDDEN"))), "hidden text became executable");
let live = ops.iter().any(|o| o.operands.iter().any(|v| v.as_string().is_some_and(|s| s.bytes == b"LIVE")));
assert_eq!(live, tail.contains(&b'\n'));
}
}
#[test]
fn rewriting_a_piece_keeps_its_leading_comment_terminator() {
for eol in [b"\n".as_slice(), b"\r", b"\r\n"] {
let mut doc = one_page(b"q Q % note", "", vec![]);
let mut tail = eol.to_vec();
tail.extend(b"BT /F1 10 Tf 10 100 Td (REMOVE) Tj ET BT /F1 10 Tf 10 200 Td (KEEP) Tj ET");
let second = doc.add(Object::Stream(Stream::from_raw(Dict::new(), tail)));
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(second)]))).unwrap();
mark(&mut doc, 0, &[[9.0, 95.0, 50.0, 110.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6);
let p = &pdfcraft_model::pages(&doc)[0];
let (_, data) = page_streams(&doc, &p.dict, 0).unwrap();
let ops = pdfcraft_content::parse(&data.join(&b' ')).ops;
assert_eq!(ops.iter().filter(|o| o.is("BT")).count(), 2, "leading EOL lost; first live operator swallowed");
assert!(ops.iter().any(|o| o.operands.iter().any(|v| v.as_string().is_some_and(|s| s.bytes == b"KEEP"))));
}
}
#[test]
fn named_hidden_text_descriptions_are_removed_from_saved_bytes() {
let mut doc = one_page(
b"/Span /P0 BDC BT /F1 10 Tf 3 Tr 10 200 Td (SECRET) Tj ET EMC",
"/Properties << /P0 7 0 R >>",
vec![b"<< /ActualText (LEAK_ACTUAL) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) /T (LEAK_TITLE) >>".to_vec()],
);
sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap();
assert_no_secret(&doc, b"SECRET");
assert_no_secret(&doc, b"LEAK_");
let saved = reopen(&doc);
let page = pdfcraft_model::pages(&saved).swap_remove(0);
assert!(!saved.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().contains(b"Properties"));
}
#[test]
fn unused_descriptions_in_shared_or_inherited_resources_are_retired() {
for hidden in [false, true] {
for inherited in [false, true] {
let mode = if hidden { 3 } else { 0 };
let data = format!("/Span /P0 BDC BT /F1 10 Tf {mode} Tr 10 200 Td (SECRET) Tj ET EMC /Fm Do");
let mut doc = one_page(
data.as_bytes(),
"/Properties << /P0 7 0 R /Public 8 0 R >> /XObject << /Fm 9 0 R >>",
vec![
b"<< /ActualText (LEAK_ACTUAL) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) /T (LEAK_TITLE) >>".to_vec(),
b"<< /ActualText (PUBLIC_ACTUAL) /Alt (PUBLIC_ALT) /E (PUBLIC_EXPANSION) /T (PUBLIC_TITLE) >>".to_vec(),
stream("/Type /XObject /Subtype /Form /BBox [0 0 100 120]", b"/Span /Public BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC"),
],
);
let first = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone();
let resources = doc.add(first.get(b"Resources").unwrap().clone());
let second_content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"/Fm Do".to_vec())));
let mut second = first;
second.set(b"Contents".to_vec(), Object::Ref(second_content));
if inherited {
second.remove(b"Resources");
} else {
second.set(b"Resources".to_vec(), Object::Ref(resources));
}
let second = doc.add(Object::Dict(second));
doc.update_dict(ObjRef::new(3, 0), |d| {
if inherited {
d.remove(b"Resources");
} else {
d.set(b"Resources".to_vec(), Object::Ref(resources));
}
})
.unwrap();
doc.update_dict(ObjRef::new(2, 0), |d| {
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(second)]));
d.set(b"Count".to_vec(), Object::Int(2));
if inherited {
d.set(b"Resources".to_vec(), Object::Ref(resources));
}
})
.unwrap();
if hidden {
sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap();
} else {
mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], "");
apply(&mut doc, Some(&[0])).unwrap();
}
assert_no_secret(&doc, b"LEAK_");
assert_no_secret(&doc, b"SECRET");
let mut saved = reopen(&doc);
for page_index in 0..2 {
assert_eq!(under(&mut saved, page_index, &[[0.0, 90.0, 100.0, 110.0]]), 6);
let page = pdfcraft_model::pages(&saved).swap_remove(page_index);
let resources = saved.resolve(page.dict.get(b"Resources").unwrap());
let properties = saved.resolve(resources.as_dict().unwrap().get(b"Properties").unwrap());
assert!(!properties.as_dict().unwrap().contains(b"P0"));
let property = saved.resolve(properties.as_dict().unwrap().get(b"Public").unwrap());
for key in [&b"ActualText"[..], b"Alt", b"E", b"T"] {
assert!(property.as_dict().unwrap().contains(key), "shared form lost {key:?}: hidden={hidden}, inherited={inherited}");
}
}
}
}
}
mod property_retirement;
@@ -0,0 +1,212 @@
use super::*;
const SECRET: &[u8] = b"/Span /P0 BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC";
const DESCRIPTION: &[u8] = b"<< /ActualText (LEAK_ACTUAL) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) /T (LEAK_TITLE) >>";
const PUBLIC: &[u8] = b"<< /ActualText (PUBLIC_ACTUAL) /Alt (PUBLIC_ALT) /E (PUBLIC_EXPANSION) /T (PUBLIC_TITLE) >>";
fn resources(doc: &Document, page: usize) -> Dict {
let page = pdfcraft_model::pages(doc).swap_remove(page);
doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone()
}
fn property(doc: &Document, resources: &Dict, name: &[u8]) -> Option<Dict> {
let properties = doc.resolve(resources.get(b"Properties")?);
doc.resolve(properties.as_dict()?.get(name)?).as_dict().cloned()
}
fn assert_description(dict: &Dict) {
for key in [&b"ActualText"[..], b"Alt", b"E", b"T"] {
assert!(dict.contains(key), "surviving description lost {key:?}");
}
}
fn add_second_page(doc: &mut Document, data: &[u8], resources: Object) -> ObjRef {
let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone();
let contents = doc.add(Object::Stream(Stream::from_raw(Dict::new(), data.to_vec())));
page.set(b"Contents".to_vec(), Object::Ref(contents));
page.set(b"Resources".to_vec(), resources);
let second = doc.add(Object::Dict(page));
doc.update_dict(ObjRef::new(2, 0), |d| {
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(second)]));
d.set(b"Count".to_vec(), Object::Int(2));
})
.unwrap();
second
}
fn redact_first(doc: &mut Document) -> Result<Report, RedactError> {
mark(doc, 0, &[[5.0, 195.0, 60.0, 212.0]], "");
apply(doc, Some(&[0]))
}
#[test]
fn surviving_form_own_resources_do_not_retain_unused_shared_descriptions() {
for hidden in [false, true] {
let mode = if hidden { 3 } else { 0 };
let data = format!("/Span /P0 BDC BT /F1 10 Tf {mode} Tr 10 200 Td (SECRET) Tj ET EMC /Fm Do");
let mut doc = one_page(
data.as_bytes(),
"/Properties << /P0 7 0 R /Public 8 0 R >> /XObject << /Fm 9 0 R >>",
vec![
DESCRIPTION.to_vec(),
PUBLIC.to_vec(),
stream("/Type /XObject /Subtype /Form /BBox [0 0 100 120]", b"/Span /Public BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC"),
],
);
// The page and its unchanged Form both reference the same resource object. The
// dictionary names the Form itself, but the Form's content never invokes that name.
let shared = doc.add(Object::Dict(resources(&doc, 0)));
for owner in [ObjRef::new(3, 0), ObjRef::new(9, 0)] {
doc.update_dict(owner, |d| d.set(b"Resources".to_vec(), Object::Ref(shared))).unwrap();
}
if hidden {
sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap();
} else {
redact_first(&mut doc).unwrap();
}
assert_no_secret(&doc, b"LEAK_");
assert_no_secret(&doc, b"SECRET");
let mut saved = reopen(&doc);
assert_eq!(under(&mut saved, 0, &[[0.0, 90.0, 100.0, 110.0]]), 6);
let page_resources = resources(&saved, 0);
let xobjects = saved.resolve(page_resources.get(b"XObject").unwrap());
let form = saved.resolve(xobjects.as_dict().unwrap().get(b"Fm").unwrap());
let form_resources = saved.resolve(form.as_dict().unwrap().get(b"Resources").unwrap());
let form_resources = form_resources.as_dict().unwrap();
assert!(property(&saved, form_resources, b"P0").is_none());
assert_description(&property(&saved, form_resources, b"Public").unwrap());
}
}
#[test]
fn unused_property_aliases_are_retired_by_value_not_resource_name() {
for direct in [false, true] {
let value = if direct { std::str::from_utf8(DESCRIPTION).unwrap() } else { "7 0 R" };
let mut doc = one_page(SECRET, &format!("/Properties << /P0 {value} >>"), vec![DESCRIPTION.to_vec()]);
let mut second_resources = resources(&doc, 0);
let original_properties = doc.resolve(second_resources.get(b"Properties").unwrap());
let mut alias = Dict::new();
alias.set(b"Alias".to_vec(), original_properties.as_dict().unwrap().get(b"P0").unwrap().clone());
second_resources.set(b"Properties".to_vec(), Object::Dict(alias));
add_second_page(&mut doc, b"q Q", Object::Dict(second_resources));
redact_first(&mut doc).unwrap();
assert_no_secret(&doc, b"LEAK_");
let saved = reopen(&doc);
assert!(property(&saved, &resources(&saved, 1), b"Alias").is_none(), "direct={direct}");
}
}
#[test]
fn unrelated_unused_property_with_the_same_name_is_not_changed() {
let mut doc = one_page(SECRET, "/Properties << /P0 7 0 R >>", vec![DESCRIPTION.to_vec(), PUBLIC.to_vec()]);
let mut second_resources = resources(&doc, 0);
let mut properties = Dict::new();
properties.set(b"P0".to_vec(), Object::Ref(ObjRef::new(8, 0)));
second_resources.set(b"Properties".to_vec(), Object::Dict(properties));
let second = add_second_page(&mut doc, b"q Q", Object::Dict(second_resources.clone()));
let before = doc.get(second);
redact_first(&mut doc).unwrap();
assert_eq!(doc.get(second), before, "an unrelated page was rewritten");
assert_no_secret(&doc, b"LEAK_");
let saved = reopen(&doc);
assert_description(&property(&saved, &resources(&saved, 1), b"P0").unwrap());
}
#[test]
fn null_resources_forms_preserve_a_retired_property_still_used_by_nested_dp() {
let mut doc = one_page(
SECRET,
"/Properties << /P0 7 0 R >> /XObject << /Outer 8 0 R /Inner 9 0 R >>",
vec![
PUBLIC.to_vec(),
stream("/Type /XObject /Subtype /Form /BBox [0 0 100 120] /Resources null", b"/Inner Do"),
stream("/Type /XObject /Subtype /Form /BBox [0 0 100 120]", b"/Span /P0 DP BT /F1 10 Tf 10 100 Td (Public) Tj ET"),
],
);
let shared = doc.add(Object::Dict(resources(&doc, 0)));
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Resources".to_vec(), Object::Ref(shared))).unwrap();
add_second_page(&mut doc, b"/Outer Do", Object::Ref(shared));
redact_first(&mut doc).unwrap();
assert_no_secret(&doc, b"SECRET");
let mut saved = reopen(&doc);
assert!(property(&saved, &resources(&saved, 0), b"P0").is_none());
assert_description(&property(&saved, &resources(&saved, 1), b"P0").unwrap());
assert_eq!(under(&mut saved, 1, &[[0.0, 90.0, 100.0, 110.0]]), 6);
}
#[test]
fn uninspectable_forms_with_candidate_properties_fail_closed() {
for own in [false, true] {
for malformed in [false, true] {
let filter = if malformed { "" } else { "/Filter /UnsupportedPropertyTest" };
let data = if malformed { b")".as_slice() } else { b"opaque" };
if malformed {
assert!(pdfcraft_content::parse(data).skipped > 0, "fixture must need parser recovery");
}
let mut doc = one_page(
SECRET,
"/Properties << /P0 7 0 R >> /XObject << /Fm 8 0 R >>",
vec![DESCRIPTION.to_vec(), stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 100 120] {filter}"), data)],
);
let shared = doc.add(Object::Dict(resources(&doc, 0)));
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Resources".to_vec(), Object::Ref(shared))).unwrap();
if own {
doc.update_dict(ObjRef::new(8, 0), |d| d.set(b"Resources".to_vec(), Object::Ref(shared))).unwrap();
}
add_second_page(&mut doc, b"/Fm Do", Object::Ref(shared));
assert_eq!(under(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]]), 6);
let error = redact_first(&mut doc).unwrap_err();
assert!(matches!(error, RedactError::Cos(_)), "own Resources={own}, malformed={malformed}: {error:?}");
}
}
}
#[test]
fn hidden_text_in_nested_forms_retires_named_descriptions_and_keeps_public_text() {
let mut doc = one_page(
b"/Outer Do",
"/Properties << /P0 7 0 R /Public 8 0 R >> /XObject << /Outer 9 0 R /Inner 10 0 R >>",
vec![
DESCRIPTION.to_vec(),
PUBLIC.to_vec(),
stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"/Inner Do"),
stream(
"/Type /XObject /Subtype /Form /BBox [0 0 300 300]",
b"/Span /P0 BDC BT /F1 10 Tf 3 Tr 10 200 Td (SECRET) Tj ET EMC \
/Span /Public BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC",
),
],
);
sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap();
assert_no_secret(&doc, b"SECRET");
assert_no_secret(&doc, b"LEAK_");
let mut saved = reopen(&doc);
assert_eq!(under(&mut saved, 0, &[[0.0, 90.0, 100.0, 110.0]]), 6);
// The rewritten inner Form owns the surviving /Public description after resources are
// localized; no page-level property lookup is sufficient to check it.
assert!(saved.scan_objects().any(|(_, object)| {
object.as_dict().and_then(|d| d.get(b"ActualText")).and_then(Object::as_string).is_some_and(|s| s.to_text() == "PUBLIC_ACTUAL")
}));
}
#[test]
fn removing_hidden_layer_content_cleans_its_surviving_outer_description() {
let mut doc = one_page(
b"/Span /P0 BDC /OC /Off BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC EMC \
/Span /Public BDC BT /F1 10 Tf 10 100 Td (Public) Tj ET EMC",
"/Properties << /P0 7 0 R /Public 8 0 R /Off 9 0 R >>",
vec![DESCRIPTION.to_vec(), PUBLIC.to_vec(), b"<< /Type /OCG /Name (Hidden test layer) >>".to_vec()],
);
let mut defaults = Dict::new();
defaults.set(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(9, 0))]));
let mut optional_content = Dict::new();
optional_content.set(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(9, 0))]));
optional_content.set(b"D".to_vec(), Object::Dict(defaults));
doc.update_dict(ObjRef::new(1, 0), |d| d.set(b"OCProperties".to_vec(), Object::Dict(optional_content))).unwrap();
sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenLayers]).unwrap();
assert_no_secret(&doc, b"SECRET");
assert_no_secret(&doc, b"LEAK_");
let mut saved = reopen(&doc);
assert_description(&property(&saved, &resources(&saved, 0), b"Public").unwrap());
assert_eq!(under(&mut saved, 0, &[[0.0, 90.0, 100.0, 110.0]]), 6);
}
+248 -6
View File
@@ -4586,7 +4586,11 @@ trailer << /Root 1 0 R >>
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("a self-painting form must not stall the renderer");
// Only a guard against a hang: the shared budget is checked by the warnings below, which
// come out the same on every machine. The render decodes and reads 512 MiB of content,
// a third of a second on a desktop, and a busy FreeBSD VM in CI ran past the minute this
// used to allow.
let page = rx.recv_timeout(std::time::Duration::from_secs(600)).expect("a self-painting form must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "the skipped nested content is observable");
assert_eq!(
@@ -4651,6 +4655,119 @@ trailer << /Root 1 0 R >>
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws");
}
/// A sampled function kept its samples in a hash map keyed by each point of its grid, built
/// again every time a shading using it was painted (vendored hayro-interpret patch 31).
/// InDesign pages paint 4096-sample gradient ramps dozens of times, and `bug1721218` in the
/// pdf.js corpus spent most of its 1.5 s hashing them. The samples are now one flat array
/// indexed by strides: this page paints a 2,097,152-sample ramp (the most a table may hold)
/// 30 times, which took 38 seconds through the hash map.
///
/// The build each paint does is timed against reading the function's samples, the part of it
/// no table can skip, not against a deadline: through the hash map it took about a hundred
/// times as long as the read, through the flat array about as long. A deadline can't tell
/// them apart on every machine: the hash map's 30 paints took 14 s on one desktop, the flat
/// array's over 10 s on a busy FreeBSD VM in CI. No counter does either (both read every
/// sample on every build), so the test compares two times taken on this thread, the fastest
/// of several rounds of each: a stall from other work (other tests, swapping) would have to
/// slow every build and spare a read to fail it.
#[test]
fn shadings_with_large_sampled_functions_paint_quickly() {
use hayro::hayro_interpret::Function;
use hayro_syntax::bit_reader::BitReader;
use hayro_syntax::object::{FromBytes, Object};
use std::time::{Duration, Instant};
const SAMPLES: usize = 1 << 21;
let samples = zlib_zeros(SAMPLES);
let function = format!(
"<< /FunctionType 0 /Domain [0 1] /Range [0 1] /Size [{SAMPLES}] /BitsPerSample 8 /Filter /FlateDecode /Length {} >>",
samples.len()
);
// Black ramp on the left half, 30 times over; a red square after it.
let content = "q 0 0 20 40 re W n /Sh0 sh Q\n".repeat(30) + "1 0 0 rg 36 36 4 4 re f";
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> >> >> endobj\n".to_vec();
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", content.len()).as_bytes());
pdf.extend_from_slice(b"5 0 obj << /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 40 0] /Function 6 0 R >> endobj\n");
pdf.extend_from_slice(format!("6 0 obj {function} stream\n").as_bytes());
pdf.extend_from_slice(&samples);
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
let page = render_first_page(pdf);
assert_eq!(rgb_at(&page.rgba, 40, 5, 20), [0, 0, 0], "the ramp is drawn");
assert_eq!(rgb_at(&page.rgba, 40, 30, 20), [255, 255, 255], "outside the clip");
assert_eq!(rgb_at(&page.rgba, 40, 38, 2), [255, 0, 0], "the rest of the page draws");
// What each paint does: build the function from its stream. Timed against reading the
// stream's samples, the part of the build no table can skip.
let mut bytes = format!("{function} stream\n").into_bytes();
bytes.extend_from_slice(&samples);
bytes.extend_from_slice(b"\nendstream");
let object = Object::from_bytes(&bytes).expect("a stream object");
let Object::Stream(stream) = &object else { panic!("the function is a stream") };
let (mut build, mut read) = (Duration::MAX, Duration::MAX);
for _ in 0..8 {
let started = Instant::now();
let function = Function::new(&object);
assert!(function.is_some(), "a sampled function");
drop(function);
build = build.min(started.elapsed());
let started = Instant::now();
let decoded = stream.decoded().expect("the samples decode");
let mut reader = BitReader::new(&decoded);
let mut values = Vec::new();
while values.len() < SAMPLES
&& let Some(value) = reader.read(8)
{
values.push(value);
}
assert_eq!(std::hint::black_box(&values).len(), SAMPLES);
drop(values);
read = read.min(started.elapsed());
}
assert!(
build < read * 10,
"building a {SAMPLES}-sample function took {build:?}, over ten times the {read:?} reading its samples takes (a hash map took a hundred times)"
);
}
/// Patch 31's flat table gives every sampled function the values the hash map gave: points
/// on the grid, multilinear interpolation between them (first input varying fastest), and no
/// value where a sample the interpolation needs is missing from a short stream.
#[test]
fn sampled_functions_interpolate_their_grid_as_before() {
use hayro::hayro_interpret::Function;
use hayro_syntax::object::{FromBytes, Object};
// Size [3 2], two outputs: out0 = 40 i + 60 j (linear), out1 = 100 i j (bilinear only).
let mut samples = Vec::new();
for j in 0..2u8 {
for i in 0..3u8 {
samples.extend_from_slice(&[40 * i + 60 * j, 100 * i * j]);
}
}
let function = |data: &[u8]| {
let mut bytes =
format!("<< /FunctionType 0 /Domain [0 1 0 1] /Range [0 1 0 1] /Size [3 2] /BitsPerSample 8 /Length {} >> stream\n", data.len())
.into_bytes();
bytes.extend_from_slice(data);
bytes.extend_from_slice(b"\nendstream");
bytes
};
let whole = function(&samples);
let f = Function::new(&Object::from_bytes(&whole).expect("a stream object")).expect("a sampled function");
for (x, y) in [(0.0, 0.0), (1.0, 1.0), (0.5, 0.0), (0.25, 0.5), (0.75, 0.25), (0.1, 0.9), (1.0, 0.3)] {
let (gi, gj) = (2.0 * x, y);
let got = f.eval([x, y].into_iter().collect()).expect("every sample is present");
let want = [(40.0 * gi + 60.0 * gj) / 255.0, 100.0 * gi.min(1.0) * gj / 255.0 + 100.0 * (gi - 1.0).max(0.0) * gj / 255.0];
assert!(got.len() == 2 && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-5), "({x}, {y}): got {got:?}, want {want:?}");
}
// Only the j = 0 row: points on it evaluate, points needing j = 1 have no value.
let short = function(&samples[..6]);
let f = Function::new(&Object::from_bytes(&short).expect("a stream object")).expect("a sampled function");
assert_eq!(f.eval([0.5, 0.0].into_iter().collect()).map(|v| v.to_vec()), Some(vec![40.0 / 255.0, 0.0]));
assert_eq!(f.eval([0.5, 0.5].into_iter().collect()), None);
}
/// From `cargo xtask fuzz`: a mesh shading whose points lie far off the page (a 147-byte
/// Coons patch stream decoded onto ±40000) was sampled pixel by pixel over its whole
/// bounding box, up to 65536 × 65536 entries: a synthetic page passed 6 GB in six seconds.
@@ -4743,6 +4860,54 @@ trailer << /Root 1 0 R >>
assert!(middle != [255, 255, 255, 255] && middle[3] == 255, "a small patch is shaded: {middle:?}");
}
/// Coons and tensor-product patches put each corner's colour at its corner and blend them
/// in between. Patch 33 interpolates each point of a patch's 20 × 20 triangle grid once
/// instead of once per cell touching it, and moves vertices instead of copying them; these
/// colours (taken before that change) pin that every vertex still gets its own point's colour.
#[test]
fn patch_meshes_keep_their_corner_colours() {
// Points on a 4 × 4 grid over the 40 pt page, in stream order: the 12 boundary points
// (both patch types), then a tensor patch's four interior ones.
let grid = |x: u8, y: u8| [x * 85, y * 85];
let boundary = [(0, 0), (0, 1), (0, 2), (0, 3), (1, 3), (2, 3), (3, 3), (3, 2), (3, 1), (3, 0), (2, 0), (1, 0)];
let interior = [(1, 1), (1, 2), (2, 2), (2, 1)];
let colours = [255u8, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0];
let patch = |points: &[(u8, u8)]| {
let mut data = vec![0u8];
data.extend(points.iter().flat_map(|(x, y)| grid(*x, *y)));
data.extend_from_slice(&colours);
data
};
let render = |shading_type: u8, data: Vec<u8>| {
let content = b"/Sh0 sh";
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> >> >> endobj\n".to_vec();
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes());
pdf.extend_from_slice(content);
pdf.extend_from_slice(format!("\nendstream endobj\n5 0 obj << /ShadingType {shading_type} /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 40 0 40 0 1 0 1 0 1] /Length {} >> stream\n", data.len()).as_bytes());
pdf.extend_from_slice(&data);
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
render_first_page(pdf)
};
let coons = render(6, patch(&boundary));
let tensor = render(7, patch(&[&boundary[..], &interior[..]].concat()));
// Near each corner (top-left, bottom-left, bottom-right, top-right in the raster), the
// centre, and a point between them.
let want = [
((5, 5), [32, 199, 28]),
((5, 34), [217, 61, 5]),
((34, 34), [217, 190, 32]),
((34, 5), [32, 61, 190]),
((20, 20), [127, 128, 64]),
((12, 27), [172, 110, 25]),
];
for (what, page) in [("Coons", &coons), ("tensor", &tensor)] {
for ((x, y), colour) in want {
let got = rgb_at(&page.rgba, 40, x, y);
assert!(got.iter().zip(colour).all(|(g, w)| g.abs_diff(w) <= 1), "{what} patch at ({x}, {y}): got {got:?}, want {colour:?}");
}
}
}
/// The other half of the mesh-shading patch: ten thousand triangles that each cover half
/// of this 1000 × 1000 canvas walked their bounding boxes ten thousand times over (ten
/// billion visits). The visit budget stops that work at eight per pixel plus four per
@@ -5979,6 +6144,81 @@ endstream endobj
assert_eq!(rgb_at(&out.rgba, w, 100, 35), [255, 0, 0]);
}
/// The decoded-image cache keyed every decode by the size the image is drawn at, but only
/// a JPX stream decodes differently for a size: a scan was decoded again at every zoom step
/// and kept once per zoom level (vendored hayro-interpret patch 32). An image drawn at five
/// sizes is now decoded once, unless JPX decodes it or its soft mask.
#[test]
fn decoded_images_serve_every_size_they_are_drawn_at() {
use hayro::hayro_interpret::font::Glyph;
use hayro::hayro_interpret::hayro_syntax::Pdf;
use hayro::hayro_interpret::{
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, Paint, PathDrawMode, SoftMask, TransformExt, interpret_page,
};
use kurbo::{Affine, BezPath, Rect};
use std::io::Write;
/// Asks for every raster image at the size it is drawn at, as the renderer does, and
/// counts the decoded images it was handed.
#[derive(Default)]
struct Draws(usize);
impl<'a> Device<'a> for Draws {
fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'a>>, _: BlendMode) {}
fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {}
fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) {
let [a, b, c, d, ..] = transform.as_coeffs();
let target = ((a.hypot(b) * f64::from(image.width())).ceil() as u32, (c.hypot(d) * f64::from(image.height())).ceil() as u32);
if let Image::Raster(r) = image {
r.with_rgba(|_, _| self.0 += 1, Some(target));
}
}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
}
let flate = |data: &[u8]| {
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
z.write_all(data).unwrap();
z.finish().unwrap()
};
let pixels = flate(&(0..64 * 64 * 3).map(|i| (i % 251) as u8).collect::<Vec<_>>());
let image = |extra: &str| {
format!(
"<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode {extra}/Length {} >>",
pixels.len()
)
};
let (plain, jpx_masked) = (image(""), image("/SMask 7 0 R "));
let jpx_mask =
"<< /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /JPXDecode /Length 4 >>";
// `name` drawn at five sizes, from 10 to 40 pt square.
let decodes = |name: &str| {
let content = [(10, 0, 0), (20, 12, 0), (30, 34, 0), (40, 66, 0), (25, 100, 40)]
.map(|(s, x, y)| format!("q {s} 0 0 {s} {x} {y} cm /{name} Do Q"))
.join(" ");
let bytes = image_cache_pdf(
&content,
"<< /XObject << /A 5 0 R /B 6 0 R >> >>",
&[(&plain, &pixels), (&jpx_masked, &pixels), (jpx_mask, b"\0\0\0\0")],
);
let parsed = Pdf::new(Arc::new(bytes)).expect("parses");
let page = &parsed.pages()[0];
let cache = InterpreterCache::new();
let initial = page.initial_transform(true).to_kurbo();
let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 130.0, 70.0), &cache, page.xref(), InterpreterSettings::default());
let mut device = Draws::default();
interpret_page(page, &mut ctx, &mut device);
assert_eq!(device.0, 5, "every draw of /{name} was handed its pixels");
cache.decoded_image_decodes()
};
assert_eq!(decodes("A"), 1, "a Flate image decodes the same at every size");
assert_eq!(decodes("B"), 5, "a JPX soft mask decodes for the size it is drawn at");
}
/// Render `pdf`'s first page at 72 dpi with no time limit beyond the test's own.
fn render_first_page(pdf: Vec<u8>) -> RenderedPage {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
@@ -5993,6 +6233,8 @@ endstream endobj
/// each of a 3304 × 4105 background's pixels, every page took four seconds and 430 MB to
/// draw, again after each field was filled. This image (2048 × 2048, a 9,000-operator
/// program) took about 40 seconds that way; from a 256-entry table it takes milliseconds.
/// The runs are counted (patch 34) rather than timed: a deadline also failed when a slow,
/// busy CI machine took longer than it allowed, and the count is the same on every machine.
#[test]
fn spot_colour_images_run_their_tint_transform_once_per_sample_value() {
const SIDE: usize = 2048;
@@ -6009,11 +6251,11 @@ endstream endobj
let function = format!("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length {} >>", program.len());
let pdf =
image_cache_pdf("q 130 0 0 70 0 0 cm /A Do Q", "<< /XObject << /A 5 0 R >> >>", &[(&image, &data), (&function, program.as_bytes())]);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(render_first_page(pdf));
});
let out = rx.recv_timeout(std::time::Duration::from_secs(10)).expect("a spot-colour image must not run its tint transform for every pixel");
// The count is per thread, and this render runs on this one.
let before = hayro::hayro_interpret::color::tint_transform_runs();
let out = render_first_page(pdf);
let runs = hayro::hayro_interpret::color::tint_transform_runs() - before;
assert_eq!(runs, 256, "the tint transform runs once per possible sample value, not once for each of the {} pixels", SIDE * SIDE);
let w = out.width as usize;
assert_eq!(rgb_at(&out.rgba, w, 10, 35), [255, 0, 0], "tint 0");
assert_eq!(rgb_at(&out.rgba, w, 120, 35), [0, 255, 0], "tint 1");
+142 -14
View File
@@ -1257,26 +1257,134 @@ fn collide(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, h: f32, sc: &mu
dense
}
/// Reverses each right-to-left span of `idx` (visual → logical order). A space between two right-to-left glyphs
/// belongs to the span.
/// Reverse right-to-left runs of `idx` (visual → logical), and the word gaps between their glyphs
/// with them: `spaces[w]` is a gap left of `idx[w]` (#1062).
/// Simplified bidi class of a glyph or word gap (UAX #9: strong L/R, numbers, neutrals).
#[derive(Clone, Copy, PartialEq, Eq)]
enum Bidi {
L,
R,
Num,
N,
}
fn bidi_class(text: &str) -> Bidi {
if text.chars().any(|c| is_rtl(c) && !c.is_numeric()) {
Bidi::R
} else if text.chars().any(char::is_numeric) {
Bidi::Num
} else if text.chars().any(char::is_alphabetic) {
Bidi::L
} else {
Bidi::N
}
}
/// Reorders a line of `idx` from visual to logical order with a simplified bidi level pass (#2667), carrying the
/// word gaps along: `spaces[w]` is a gap left of `idx[w]` (#1062). The paragraph is right-to-left when it has
/// more right-to-left than left-to-right glyphs; numbers keep their left-to-right order, and neutrals
/// (punctuation, gaps) take the direction of their neighbours, or the paragraph's between opposite ones.
fn reverse_rtl(glyphs: &[&TextGlyph], idx: &mut [usize], spaces: &mut [bool]) {
let rtl = |i: usize| glyphs[i].text.chars().any(is_rtl);
// Glyphs and word gaps (None) in visual order.
let mut items: Vec<(Option<usize>, Bidi)> = Vec::with_capacity(idx.len() * 2);
for (w, &i) in idx.iter().enumerate() {
if w > 0 && spaces.get(w).copied().unwrap_or(false) {
items.push((None, Bidi::N));
}
items.push((Some(i), bidi_class(&glyphs[i].text)));
}
let texts: Vec<&str> = items.iter().map(|it| it.0.map_or("", |i| glyphs[i].text.as_str())).collect();
let text = |k: usize| texts.get(k).copied().unwrap_or("");
let count = |b: Bidi| items.iter().filter(|it| it.1 == b).count();
let para = if count(Bidi::R) > count(Bidi::L) { Bidi::R } else { Bidi::L };
let n = items.len();
// W4: a single separator between two digits belongs to the number.
for k in 1..n.saturating_sub(1) {
let sep = matches!(text(k), "." | "," | ":" | "/" | "+" | "-" | "\u{066B}" | "\u{066C}");
if sep && items[k - 1].1 == Bidi::Num && items[k + 1].1 == Bidi::Num {
items[k].1 = Bidi::Num;
}
}
// W5: terminators (percent, currency) next to a number belong to it.
let term =
|s: &str| !s.is_empty() && s.chars().all(|c| matches!(c, '%' | '‰' | '#' | '$' | '¢'..='¥' | '°' | '\u{066A}' | '\u{20A0}'..='\u{20CF}'));
for k in 1..n {
if items[k].1 == Bidi::N && items[k - 1].1 == Bidi::Num && term(text(k)) {
items[k].1 = Bidi::Num;
}
}
for k in (0..n.saturating_sub(1)).rev() {
if items[k].1 == Bidi::N && items[k + 1].1 == Bidi::Num && term(text(k)) {
items[k].1 = Bidi::Num;
}
}
// W7: a number after left-to-right text (or at the start of a left-to-right paragraph) is left-to-right.
let mut strong = para;
for it in &mut items {
match it.1 {
Bidi::L | Bidi::R => strong = it.1,
Bidi::Num if strong == Bidi::L => it.1 = Bidi::L,
_ => {}
}
}
// N1/N2: neutrals between same-direction text take it (numbers count as right-to-left), else the paragraph's.
let dir = |b: Bidi| if b == Bidi::L { Bidi::L } else { Bidi::R };
let mut k = 0;
while k < idx.len() {
if rtl(idx[k]) {
while k < n {
if items[k].1 != Bidi::N {
k += 1;
continue;
}
let mut e = k;
while e < n && items[e].1 == Bidi::N {
e += 1;
}
let before = if k == 0 { para } else { dir(items[k - 1].1) };
let after = if e == n { para } else { dir(items[e].1) };
let resolved = if before == after { before } else { para };
for it in &mut items[k..e] {
it.1 = resolved;
}
k = e;
}
// Levels (I1/I2), then L2: reverse every run at or above each level, highest first.
let rtl_para = para == Bidi::R;
let levels: Vec<u8> = items
.iter()
.map(|it| match it.1 {
Bidi::R => 1,
Bidi::Num => 2,
_ if rtl_para => 2,
_ => 0,
})
.collect();
let mut order: Vec<usize> = (0..n).collect();
for level in (1..=levels.iter().copied().max().unwrap_or(0)).rev() {
let mut k = 0;
while k < n {
if levels[order[k]] < level {
k += 1;
continue;
}
let mut e = k;
while e + 1 < idx.len() && (rtl(idx[e + 1]) || (glyphs[idx[e + 1]].text.trim().is_empty() && e + 2 < idx.len() && rtl(idx[e + 2]))) {
while e < n && levels[order[e]] >= level {
e += 1;
}
idx[k..=e].reverse();
if let Some(gaps) = spaces.get_mut(k + 1..=e) {
gaps.reverse();
order[k..e].reverse();
k = e;
}
}
let mut j = 0;
let mut gap = false;
for &o in &order {
match items[o].0 {
None => gap = true,
Some(i) => {
if let (Some(slot), Some(space)) = (idx.get_mut(j), spaces.get_mut(j)) {
*slot = i;
*space = gap && j > 0;
}
gap = false;
j += 1;
}
k = e + 1;
} else {
k += 1;
}
}
}
@@ -2092,6 +2200,26 @@ mod tests {
assert_eq!(layout(v).plain_text(), "שלום עולם");
}
/// #2667: a number or punctuation on a right-to-left line must not split it into separately reversed words.
#[test]
fn rtl_line_with_number_and_punctuation_reads_in_logical_order() {
let line = |words: &[&str]| {
let mut v = Vec::new();
let mut x = 10.0;
for w in words {
word(&mut v, w, x, 10.0, 6.0);
x += w.chars().count() as f32 * 6.0 + 6.0;
}
layout(v).plain_text()
};
// Each line as drawn, left to right.
assert_eq!(line(&["םילקש", "120", "ריחמ"]), "מחיר 120 שקלים");
assert_eq!(line(&[".הקידב", "ךמסמ", "והז", ":תירבע"]), "עברית: זהו מסמך בדיקה.");
assert_eq!(line(&["باتك", "12.5", "ددع"]), "عدد 12.5 كتاب");
assert_eq!(line(&["םלוע", "םולש"]), "שלום עולם");
assert_eq!(line(&["Total", "םולש", "price"]), "Total שלום price");
}
/// Two lines, "ab cd" and "e fg": words stop at blank glyphs, word gaps and line ends.
#[test]
fn words_and_lines_around_a_glyph() {
+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(),
+1 -1
View File
@@ -71,7 +71,7 @@ rfd = { workspace = true, features = ["file-handle-inner"] }
wasm-bindgen-futures = "0.4"
wasm-bindgen = "0.2"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget", "Navigator"] }
[dev-dependencies]
pdfcraft-xfa = { workspace = true }
+391 -9
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).
@@ -350,6 +423,8 @@ pub struct DocView {
/// This frame's consumers, with visible cells ahead of prefetch.
thumb_demand: HashMap<usize, bool>,
frame_queue: Vec<RenderRequest>,
/// A page-list edit after layout invalidates this frame's scales and page indexes.
frame_geometry_changed: bool,
/// Pages on screen this frame. Their rasters and tiles are always admitted: the viewport
/// bounds them, and the byte and count limits apply to prefetch only.
frame_visible: HashSet<usize>,
@@ -379,6 +454,9 @@ pub struct DocView {
last_press_time: f64,
last_press_pos: Option<Pos2>,
last_queue: Vec<RenderRequest>,
/// Finished renders wait in the pool instead of reaching the view (tests): what the view
/// shows before a raster arrives can be looked at however fast the renderer is.
pub hold_rasters: bool,
viewport_w: f32,
viewport_h: f32,
page_count: usize,
@@ -504,6 +582,8 @@ pub struct DocView {
pub(crate) last_scroll: Vec2,
/// Side-by-side compare: scroll to here this frame, following the other half.
pub(crate) sync_scroll: Option<Vec2>,
/// The page widget's id, by which the Hand tool finds its drag before the pages are laid out.
page_widget: Option<egui::Id>,
pan_hold_until: f64,
/// Pages whose raster proved slow (see `SLOW_MILLIS`): only these get the zoom/pan
/// settle holds. Fast pages render straight through, with no added latency.
@@ -623,6 +703,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(),
@@ -638,6 +720,7 @@ impl DocView {
thumbs: HashMap::new(),
thumb_demand: HashMap::new(),
frame_queue: Vec::new(),
frame_geometry_changed: false,
frame_visible: HashSet::new(),
stale_thumbs: HashSet::new(),
stale_tiles: HashSet::new(),
@@ -654,6 +737,7 @@ impl DocView {
last_press_time: 0.0,
last_press_pos: None,
last_queue: Vec::new(),
hold_rasters: false,
viewport_w: 800.0,
viewport_h: 600.0,
page_count: info.pages.len(),
@@ -695,6 +779,7 @@ impl DocView {
sharp_hold_until: 0.0,
last_scroll: Vec2::ZERO,
sync_scroll: None,
page_widget: None,
pan_hold_until: 0.0,
slow_pages: HashSet::new(),
comments: Default::default(),
@@ -723,6 +808,7 @@ impl DocView {
/// adopt the new page geometry, keeping the reader's place where possible.
pub fn document_changed(&mut self, info: &DocInfo) {
self.invalidate_content();
self.frame_geometry_changed = true;
self.page_count = info.pages.len();
self.page_heights = info.pages.iter().map(|p| p.height).collect();
let last = self.page_count.saturating_sub(1);
@@ -744,10 +830,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 +849,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);
@@ -1435,6 +1524,7 @@ impl DocView {
/// Start collecting demand from every consumer before scheduling or uploading results.
pub(crate) fn begin_render_frame(&mut self) {
self.frame_geometry_changed = false;
self.pages_panel = None;
self.panel_gap = None;
self.thumb_demand.clear();
@@ -1650,12 +1740,23 @@ impl DocView {
}
pub(crate) fn finish_render_frame(&mut self, ctx: &egui::Context, info: &DocInfo, pool: &RenderPool) {
if self.frame_geometry_changed {
// Dialogs and edits can replace pages after the canvas collected demand. Its
// scales and indexes belong to the old pages, not to the new renderer (#3149).
self.begin_render_frame();
self.last_queue.clear();
pool.set_queue(Vec::new());
ctx.request_repaint();
return;
}
let queue = self.prepare_render_queue(info, ctx.pixels_per_point());
if queue != self.last_queue {
pool.set_queue(queue.clone());
self.last_queue = queue;
}
self.receive(ctx, pool);
if !self.hold_rasters {
self.receive(ctx, pool);
}
if !self.last_queue.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(30));
}
@@ -1714,6 +1815,12 @@ impl DocView {
self.errors.insert(r.request.page, e);
return 0;
}
// Even current demand may have been sized with obsolete geometry or a different
// device limit. Check before cache eviction, conversion and every upload path.
let max_side = ctx.input(|i| i.max_texture_side);
if r.width == 0 || r.height == 0 || r.width as usize > max_side || r.height as usize > max_side {
return 0;
}
// Remember heavy pages: only these get the zoom/pan settle holds, so fast pages
// never pay added latency. Stale entries die with the tiles on edits.
if r.millis >= SLOW_MILLIS {
@@ -2204,6 +2311,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 +2353,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),
@@ -2454,6 +2581,25 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if !std::mem::replace(&mut view.shown, true) {
scroll = scroll.scroll_offset(Vec2::ZERO);
}
// The Hand tool pans here, before the pages are laid out, so the page moves with the pointer
// in the frame the pointer moves (#924). Scrolling by the drag from inside the area moved the
// page only after that frame was drawn, a frame behind. The frame egui tells the drag from a
// click, the page catches up with all the pointer has moved since the press. A jump asked for
// below still wins.
if app.quick_tool == QuickTool::Hand
&& !middle_gesture
&& let Some(id) = view.page_widget.filter(|id| ui.ctx().is_being_dragged(*id))
{
let moved = if ui.ctx().drag_started_id() == Some(id) {
ui.input(|i| i.pointer.total_drag_delta()).unwrap_or(Vec2::ZERO)
} else {
ui.input(|i| i.pointer.delta())
};
// Within the content, as egui keeps it: past an end the page would show there for a
// frame and spring back. `max` is never NaN or below zero, so the clamp can't panic.
let max = vec2((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0));
scroll = scroll.scroll_offset((view.last_scroll - moved).clamp(Vec2::ZERO, max));
}
if let Some(v) = view.sync_scroll.take() {
scroll = scroll.scroll_offset(v);
}
@@ -2535,6 +2681,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()
@@ -2598,14 +2745,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none());
}
let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag());
view.page_widget = Some(resp.id);
// A drag lasts only while egui reports it, so the mark can't outlive one.
if !resp.dragged_by(egui::PointerButton::Primary) {
view.selection_drag = false;
}
// The Hand tool pans: the content widget takes every drag, so scroll by its delta.
// The Hand tool pans by this widget's drag, before the pages are laid out (above).
if hand {
if resp.dragged() {
ui.scroll_with_delta(resp.drag_delta());
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
} else if resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
@@ -2636,6 +2783,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 +3440,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
@@ -3696,6 +3870,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if let Some((p, i)) = app.views[index].comments.default_request.take() {
app.make_comment_default(p, i);
}
if let Some((p, i, open)) = app.views[index].comments.attachment_request.take() {
let doc = app.views[index].id;
app.comment_attachment_action(doc, p, i, open);
}
if let Some((name, w)) = field_props {
app.open_field_props(&name, w);
}
@@ -4102,10 +4280,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 +4321,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 +5202,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)]);
@@ -5288,6 +5571,81 @@ trailer << /Root 1 0 R >>
}
}
/// #3149: the replacement dialog commits after the canvas collected this frame's demand.
#[test]
fn replacing_a_page_after_layout_never_uploads_an_oversized_texture() {
let ctx = egui::Context::default();
ctx.input_mut(|i| i.max_texture_side = 2048);
let mut app = PdfCraftApp::new();
let original = app.session.create_blank(200.0, 300.0, 1).unwrap();
let replacement = app.session.create_blank(400.0, 500.0, 1).unwrap();
app.open_bytes("original.pdf", None, original.as_ref().clone()).unwrap();
let request = RenderRequest { scale: 4.7, tag: scale_tag(4.7), ..Default::default() };
app.views[0].frame_queue = vec![request];
app.views[0].frame_visible.insert(0);
assert!(app.apply_edit(Edit::ReplacePages { pages: vec![0], name: "replacement.pdf".into(), bytes: replacement, src_pages: vec![0] }));
let doc = app.session.get(app.views[0].id).unwrap();
assert_eq!((doc.info.pages[0].width, doc.info.pages[0].height), (400.0, 500.0));
let edited_bytes = doc.bytes.clone();
let pool = RenderPool::new_inline(doc.display.clone(), Default::default());
app.views[0].finish_render_frame(&ctx, &doc.info, &pool);
assert!(app.views[0].last_queue.is_empty(), "old geometry must not reach the replacement renderer");
// The next layout can render a bounded backdrop and sharp tiles at the same zoom.
let backdrop = RenderRequest { scale: 2048.0 / 500.0, tag: scale_tag(2048.0 / 500.0), ..request };
let tile = RenderRequest { tile: Some(Tile { x: 0, y: 0, w: 1024, h: 1024 }), ..request };
for _ in 0..2 {
let view = &mut app.views[0];
view.begin_render_frame();
view.frame_visible.insert(0);
view.frame_queue = vec![backdrop, tile];
view.finish_render_frame(&ctx, &doc.info, &pool);
}
let view = &app.views[0];
assert_eq!(view.pages.get(&0).unwrap().tex.size(), [1639, 2048]);
assert_eq!(view.tiles.get(&(0, tile.tag, 0, 0)).unwrap().size(), [1024, 1024]);
assert!(view.errors.is_empty());
assert_eq!(doc.bytes, edited_bytes, "rendering preserves the unsaved edit");
assert!(doc.dirty);
assert_eq!(doc.can_undo(), Some("Replace page"));
}
#[test]
fn oversized_raster_results_do_not_replace_cached_textures() {
let ctx = egui::Context::default();
ctx.input_mut(|i| i.max_texture_side = 64);
for request in [
RenderRequest::default(),
RenderRequest { tag: THUMB_TAG, ..Default::default() },
RenderRequest { tag: GRID_TAG, ..Default::default() },
RenderRequest { tag: PRINT_TAG, ..Default::default() },
RenderRequest { tile: Some(Tile { x: 0, y: 0, w: 64, h: 32 }), ..Default::default() },
] {
let mut view = DocView::new(DocId(1), &thumbnail_info(1), ViewDefaults::default());
view.last_queue = vec![request];
let texture = |v: &DocView| {
v.pages
.values()
.map(|p| &p.tex)
.chain(v.thumbs.values().map(|p| &p.tex))
.chain(v.grid_pages.values().map(|(_, t)| t))
.chain(v.print_pages.values().map(|(_, t)| t))
.chain(v.tiles.values())
.map(|t| (t.id(), t.size()))
.collect::<Vec<_>>()
};
assert_eq!(view.receive_result(&ctx, solid_result(request, 64, 32)), 64 * 32 * 4);
let cached = texture(&view);
assert_eq!(cached.len(), 1);
for (w, h) in [(65, 32), (32, 65), (65, 65), (0, 32), (32, 0)] {
assert_eq!(view.receive_result(&ctx, solid_result(request, w, h)), 0, "{request:?}: {w}x{h}");
assert_eq!(texture(&view), cached, "an invalid result must keep the previous raster");
}
assert_eq!(view.receive_result(&ctx, solid_result(request, 32, 64)), 32 * 64 * 4);
assert_eq!(texture(&view)[0].1, [32, 64], "valid results still make progress");
}
}
#[test]
fn thumbnails_release_on_scroll_panel_close_and_request_again_after_eviction() {
let ctx = egui::Context::default();
@@ -5349,6 +5707,30 @@ trailer << /Root 1 0 R >>
assert_eq!(v.pages.get(&0).unwrap().tag, STALE_TAG);
}
/// #1858: a released drawing stays painted from its points until a raster that has it
/// arrives. A page raster that lands before the drawing's edit is applied (in full screen the
/// edit waits for the next frame, and a render already under way can finish first) is of the
/// document without it, and must not end the preview.
#[test]
fn a_raster_from_before_a_drawing_is_committed_keeps_it_drawn() {
let ctx = egui::Context::default();
let info = thumbnail_info(1);
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
let req = RenderRequest { scale: 1.0, tag: 1000, ..Default::default() };
v.comments.settle(0.0, 0, crate::comments::CommentTool::Ink, &[[10.0, 10.0], [40.0, 30.0]]);
v.last_queue = vec![req];
assert!(v.receive_result(&ctx, solid_result(req, 10, 10)) > 0, "the raster is taken");
assert_eq!(v.comments.settling_len(), 1, "a raster from before the commit doesn't have the stroke");
v.comments.drawings_committed();
v.last_queue = vec![req];
assert!(v.receive_result(&ctx, solid_result(req, 10, 10)) > 0);
assert_eq!(v.comments.settling_len(), 0, "a raster after the commit draws it");
// An edit that fails leaves nothing to wait for.
v.comments.settle(0.0, 0, crate::comments::CommentTool::Ink, &[[10.0, 10.0], [40.0, 30.0]]);
v.comments.drawings_refused();
assert_eq!(v.comments.settling_len(), 0, "a refused drawing isn't left painted");
}
#[test]
fn page_and_tile_admission_accounts_for_bytes_before_count_limits() {
let mut info = thumbnail_info(40);
+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| {
+7 -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)),
@@ -629,6 +629,12 @@ impl PdfCraftApp {
"page.duplicate" => {
self.apply_edit(Edit::DuplicatePages { pages: targets });
}
// Two or more selected pages reverse among themselves; otherwise the whole document.
"page.reverse" => {
let all = active.and_then(|i| self.views.get(i)).map_or(0, |v| v.page_count());
let pages = if targets.len() >= 2 { targets } else { (0..all).collect() };
self.apply_edit(Edit::ReversePages { pages });
}
"ocr.enhance_camera" => {
// The corners start on the document's edges when they can be found, else
// inset on the current page (camera_ui::page_input).
+42 -4
View File
@@ -420,6 +420,10 @@ pub(crate) struct Settling {
pub points: Vec<[f64; 2]>,
/// Frame time (seconds) at release; a raster that never arrives must not leave a ghost.
pub since: f64,
/// Its annotation is in the document, so a raster of the page from now on draws it. Until
/// then a raster is of the document without it (in full screen the edit is applied the frame
/// after the release, and a raster already on its way can arrive in between).
pub committed: bool,
}
/// Longest a released drawing stays painted without its page raster, and how many are kept.
@@ -428,7 +432,7 @@ const SETTLE_MAX: usize = 16;
impl CommentView {
/// Remember a released drag drawing so it stays on screen until its page is re-rendered.
fn settle(&mut self, now: f64, page: usize, tool: CommentTool, points: &[[f64; 2]]) {
pub(crate) fn settle(&mut self, now: f64, page: usize, tool: CommentTool, points: &[[f64; 2]]) {
if !matches!(tool, CommentTool::Ink | CommentTool::Line | CommentTool::Arrow | CommentTool::Rectangle | CommentTool::Oval) {
return;
}
@@ -436,7 +440,7 @@ impl CommentView {
if self.settling.len() >= SETTLE_MAX {
self.settling.remove(0);
}
self.settling.push(Settling { page, tool, points: points.to_vec(), since: now });
self.settling.push(Settling { page, tool, points: points.to_vec(), since: now, committed: false });
}
/// How many released drawings are still painted from their points (waiting for a raster).
@@ -444,9 +448,22 @@ impl CommentView {
self.settling.len()
}
/// A fresh raster of `page` is on screen: it draws the committed annotation itself.
/// A fresh raster of `page` is on screen: it draws the committed annotations itself. One
/// still waiting for its edit isn't in that raster, and stays drawn from its points.
pub(crate) fn page_rendered(&mut self, page: usize) {
self.settling.retain(|s| s.page != page);
self.settling.retain(|s| s.page != page || !s.committed);
}
/// The edit adding the released drawings was applied: rasters from now on draw them.
pub(crate) fn drawings_committed(&mut self) {
for s in &mut self.settling {
s.committed = true;
}
}
/// The edit adding the released drawings failed: nothing will draw them.
pub(crate) fn drawings_refused(&mut self) {
self.settling.retain(|s| s.committed);
}
}
@@ -487,6 +504,9 @@ pub struct CommentView {
pub props_request: Option<(usize, usize)>,
/// Make Current Properties Default was asked for: (page, index).
pub default_request: Option<(usize, usize)>,
/// A file attachment comment's file was asked for: (page, index, open it in a new tab
/// rather than save it).
pub attachment_request: Option<(usize, usize, bool)>,
/// Replace Text: the struck-out text's quads while the replacement is typed.
pub replace: Option<Vec<[f64; 8]>>,
/// Attach file: where the icon goes (page, top-left in user space), for the app to pick a file.
@@ -1578,6 +1598,23 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool
}
}
/// Save attachment… and, for a PDF, Open in a new tab, first in a file attachment comment's
/// menus as in the Attachments panel: the comment is where the reader finds its file. Reading
/// the file changes nothing, so a read-only document offers them too.
pub(crate) fn attachment_items(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, a: &Annotation) {
let source = pdfcraft_render::AttachmentSource::Annotation { page: a.page, index: a.index };
let Some(file) = info.attachments.iter().find(|f| f.source == source) else { return };
if ui.button(tl!("Save attachment…")).clicked() {
view.comments.attachment_request = Some((a.page, a.index, false));
ui.close();
}
if file.name.to_lowercase().ends_with(".pdf") && ui.button(tl!("Open in a new tab")).clicked() {
view.comments.attachment_request = Some((a.page, a.index, true));
ui.close();
}
ui.separator();
}
/// Items of the canvas context menu for the selected comment (or the page).
pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs, allowed: bool) -> Option<CanvasAction> {
let mut action = None;
@@ -1585,6 +1622,7 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo
match selected {
Some(a) => {
let (page, index) = (a.page, a.index);
attachment_items(ui, view, info, a);
if ui.add_enabled(allowed, egui::Button::new(tl!("Edit text…"))).clicked() {
view.comments.composer = Some(Composer {
page,
+3 -1
View File
@@ -255,7 +255,7 @@ pub(crate) fn show(
continue;
}
let thread = replies_of(a);
if let Some(e) = card(ui, t, a, &thread, view, prefs, allowed, nav) {
if let Some(e) = card(ui, t, a, &thread, info, view, prefs, allowed, nav) {
edit = Some(e);
}
}
@@ -268,6 +268,7 @@ fn card(
t: &Tokens,
a: &Annotation,
thread: &[&Annotation],
info: &DocInfo,
view: &mut DocView,
prefs: &CommentPrefs,
allowed: bool,
@@ -447,6 +448,7 @@ fn card(
.layout(Layout::top_down_justified(Align::Min))
.show(|ui| {
ui.set_min_width(160.0);
crate::comments::attachment_items(ui, view, info, a);
if ui.button(tl_ctx!("comment menu", "Edit")).clicked() {
view.comments.editing = Some((a.page, a.index, a.contents.clone().unwrap_or_default()));
ui.close();
+155 -7
View File
@@ -793,14 +793,27 @@ struct Limits {
max_unauthenticated: usize,
/// How long a write may block on a client that never reads its replies.
write_timeout: std::time::Duration,
/// How long a connection refused after a reply is kept half-open, discarding input, so the
/// reply is not lost to a reset (see [`close_after_reply`]).
linger: std::time::Duration,
/// How many refused connections may linger at once. A lingering connection has given back
/// its place among [`Self::max_unauthenticated`], so this is what bounds their threads;
/// beyond it a refused connection is closed at once.
max_lingering: usize,
}
#[cfg(not(target_arch = "wasm32"))]
const LIMITS: Limits =
Limits { auth_deadline: std::time::Duration::from_secs(10), max_unauthenticated: 8, write_timeout: std::time::Duration::from_secs(30) };
const LIMITS: Limits = Limits {
auth_deadline: std::time::Duration::from_secs(10),
max_unauthenticated: 8,
write_timeout: std::time::Duration::from_secs(30),
linger: std::time::Duration::from_secs(1),
max_lingering: 8,
};
/// One of the [`Limits::max_unauthenticated`] places, given back when dropped: when the
/// connection authenticates, and on every way its thread can end.
/// One of a limited number of places, given back when dropped: a place among
/// [`Limits::max_unauthenticated`] (given back when the connection authenticates or is refused,
/// and on every way its thread can end), or among [`Limits::max_lingering`].
#[cfg(not(target_arch = "wasm32"))]
struct Slot(std::sync::Arc<std::sync::atomic::AtomicUsize>);
@@ -850,6 +863,38 @@ impl std::io::Read for Reading {
}
}
/// Close a connection whose last reply has just been written without losing that reply.
///
/// Closing a socket with input still unread makes the kernel send a reset instead of a FIN, and
/// the reset can discard the reply before the client reads it (Linux does): a client still
/// sending when it is refused would never learn why. So send FIN first, then read and discard
/// whatever arrives until the client closes its side or `linger` passes, and only then close.
///
/// Only [`Limits::max_lingering`] connections linger at once: `place` is one of those places,
/// taken by [`refuse`] before the reply was written, and held for the drain only. Without one
/// the connection is closed at once after the FIN, accepting the risk of a reset rather than
/// holding another thread.
#[cfg(not(target_arch = "wasm32"))]
fn close_after_reply(reading: &mut Reading, place: Option<Slot>, linger: std::time::Duration) {
use std::io::Read;
let _ = reading.stream.shutdown(std::net::Shutdown::Write);
let Some(_place) = place else { return };
reading.until = Some(std::time::Instant::now() + linger);
let mut sink = [0u8; 1024];
// Each read is bounded by the time left, and none is made once `linger` has passed.
while matches!(reading.read(&mut sink), Ok(n) if n > 0) {}
}
/// A connection is being refused before `auth`: give back its place among those waiting to
/// authenticate, so it does not keep a real client out while it lingers, and take a place to
/// linger in if one is free. Done before the reply is written, so a client that has read the
/// reply sees the places as they will be.
#[cfg(not(target_arch = "wasm32"))]
fn refuse(slot: &mut Option<Slot>, lingering: &std::sync::Arc<std::sync::atomic::AtomicUsize>, limits: Limits) -> Option<Slot> {
drop(slot.take());
Slot::take(lingering, limits.max_lingering)
}
/// Listen on `127.0.0.1` (random port) and forward authenticated requests to `client`.
///
/// Protocol: newline-delimited JSON-RPC 2.0. The first request on a connection must be
@@ -868,6 +913,7 @@ fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result<Endpoint
let token = random_token()?;
let expected = token.clone();
let waiting = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let lingering = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
std::thread::Builder::new().name("pdfcraft-control".into()).spawn(move || {
for stream in listener.incoming().flatten() {
let _ = stream.set_write_timeout(Some(limits.write_timeout));
@@ -881,6 +927,7 @@ fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result<Endpoint
};
let client = client.clone();
let expected = expected.clone();
let lingering = lingering.clone();
let _ = std::thread::Builder::new().name("pdfcraft-control-conn".into()).spawn(move || {
// Held until `auth` succeeds; given back however this thread ends.
let mut slot = Some(slot);
@@ -899,14 +946,20 @@ fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result<Endpoint
Err(e) if !authed && matches!(e.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock) => {
let secs = limits.auth_deadline.as_secs_f64();
let e = rpc_error(Value::Null, -32004, &format!("not authenticated within {secs} s"));
let _ = writeln!(write, "{e}").and_then(|_| write.flush());
let place = refuse(&mut slot, &lingering, limits);
if writeln!(write, "{e}").and_then(|_| write.flush()).is_ok() {
close_after_reply(reader.get_mut(), place, limits.linger);
}
break;
}
// End of stream or a failed read ends the connection, as before.
Ok(LineRead::End) | Err(_) => break,
Ok(LineRead::TooLong) => {
let e = rpc_error(Value::Null, -32600, &format!("request line longer than {max} bytes before auth"));
let _ = writeln!(write, "{e}").and_then(|_| write.flush());
let place = refuse(&mut slot, &lingering, limits);
if writeln!(write, "{e}").and_then(|_| write.flush()).is_ok() {
close_after_reply(reader.get_mut(), place, limits.linger);
}
break;
}
}
@@ -945,7 +998,13 @@ fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result<Endpoint
}
}
};
if writeln!(write, "{reply}").and_then(|_| write.flush()).is_err() || close {
// `close` is only ever set before `auth`.
let place = if close { refuse(&mut slot, &lingering, limits) } else { None };
if writeln!(write, "{reply}").and_then(|_| write.flush()).is_err() {
break;
}
if close {
close_after_reply(reader.get_mut(), place, limits.linger);
break;
}
}
@@ -1089,6 +1148,8 @@ mod transport_tests {
auth_deadline: std::time::Duration::from_millis(300),
max_unauthenticated: 2,
write_timeout: std::time::Duration::from_secs(5),
linger: std::time::Duration::from_secs(1),
max_lingering: 2,
};
/// The next line the server sends, as JSON, or `None` when it closed the connection instead.
@@ -1146,6 +1207,93 @@ mod transport_tests {
drip.join().unwrap();
}
/// A client refused while the server still has its input unread gets the reason, not a
/// reset: closing on top of unread input sends a reset, which can discard the reply before
/// the client reads it (Linux does). Here a line far over the limit leaves most of itself
/// unread when the server refuses it.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_client_refused_with_input_unread_gets_the_reason() {
use std::io::{BufReader, Write};
let (_control, client) = super::attach(&egui::Context::default());
let ep = super::serve_with(client, TEST_LIMITS).unwrap();
let s = connect(ep.port);
let mut w = s.try_clone().unwrap();
w.write_all(&vec![b'x'; 16 * super::MAX_AUTH_LINE]).unwrap();
// Let the server refuse it (and, before the fix, close) before reading the reply.
std::thread::sleep(std::time::Duration::from_millis(200));
let mut r = BufReader::new(s);
let reply = next_line(&mut r).expect("the server says why before closing");
assert_eq!(reply["error"]["code"], -32600, "{reply}");
}
/// A refused connection gives back its place among those waiting to authenticate before it
/// lingers, so lingering does not keep real clients out.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_lingering_connection_holds_no_place_to_authenticate() {
use std::io::{BufReader, Write};
// A linger far longer than the checks below take, even on a loaded machine.
let limits = super::Limits { linger: std::time::Duration::from_secs(5), ..TEST_LIMITS };
let (_control, client) = super::attach(&egui::Context::default());
let ep = super::serve_with(client, limits).unwrap();
let auth = serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "auth", "params": { "token": ep.token } });
// Refused with most of its line unread, and left open: it lingers for `linger`.
let refused = connect(ep.port);
refused.try_clone().unwrap().write_all(&vec![b'x'; 16 * super::MAX_AUTH_LINE]).unwrap();
let mut r = BufReader::new(refused.try_clone().unwrap());
assert_eq!(next_line(&mut r).expect("refused with a reason")["error"]["code"], -32600);
// Well within the linger, both places are free for new connections.
let started = std::time::Instant::now();
let others: Vec<_> = (0..limits.max_unauthenticated).map(|_| connect(ep.port)).collect();
for s in &others {
// A refused one has already been closed, so its write may fail; its reply says why.
let _ = writeln!(s.try_clone().unwrap(), "{auth}");
let reply = next_line(&mut BufReader::new(s.try_clone().unwrap())).expect("a reply");
assert_eq!(reply["result"]["ok"], true, "admitted while the refused one lingers: {reply}");
}
assert!(started.elapsed() < limits.linger, "checked while it lingered: {:?}", started.elapsed());
drop(refused);
}
/// Only [`super::Limits::max_lingering`] refused connections linger at once: one more still
/// gets its reply, and is then closed at once instead of lingering.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn only_so_many_refused_connections_linger() {
use std::io::{BufReader, Write};
// A linger far longer than the checks below take, even on a loaded machine.
let limits = super::Limits { linger: std::time::Duration::from_secs(5), max_lingering: 1, ..TEST_LIMITS };
let (_control, client) = super::attach(&egui::Context::default());
let ep = super::serve_with(client, limits).unwrap();
let bad = r#"{"jsonrpc": "2.0", "id": 1, "method": "page_count"}"#;
// The first refused connection takes the only lingering place and keeps it.
let first = connect(ep.port);
writeln!(first.try_clone().unwrap(), "{bad}").unwrap();
let mut r1 = BufReader::new(first.try_clone().unwrap());
assert_eq!(next_line(&mut r1).expect("refused with a reason")["error"]["code"], -32001);
// The second still gets its reason (it has nothing unread, so no reset)...
let second = connect(ep.port);
let mut w2 = second.try_clone().unwrap();
writeln!(w2, "{bad}").unwrap();
let mut r2 = BufReader::new(second.try_clone().unwrap());
assert_eq!(next_line(&mut r2).expect("refused with a reason")["error"]["code"], -32001);
// ...and is closed outright, not lingering: writing to it soon fails, where a lingering
// connection would read and discard for the whole of `linger`.
let started = std::time::Instant::now();
let closed = loop {
if w2.write_all(b"x").is_err() {
break true;
}
if started.elapsed() > limits.linger {
break false;
}
std::thread::sleep(std::time::Duration::from_millis(20));
};
assert!(closed && started.elapsed() < limits.linger / 2, "it lingered: {:?}", started.elapsed());
drop(first);
}
/// #1806: only so many connections may wait to authenticate at once. One more is refused
/// on the spot; once a waiting one authenticates or times out, its place is free again.
#[cfg(not(target_arch = "wasm32"))]
+1 -2
View File
@@ -738,8 +738,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
match action {
Some(crate::sign_ui::CertAction::Trust(c)) => app.trust_certificate(*c),
Some(crate::sign_ui::CertAction::Export(c)) => {
let pem = crate::sign_ui::certificate_pem(&c);
app.write_files(&[(format!("{}.cer", c.display_name()), std::sync::Arc::new(pem.into_bytes()))], "Export certificate");
app.export_certificate(&c);
}
None => {}
}
+15
View File
@@ -1475,6 +1475,21 @@ mod tests {
(app, ed)
}
#[test]
fn a_loaded_font_is_applied_to_the_paragraph_being_edited() {
// Loading a font used to only register it on the page: the paragraph kept its source
// face until the font was found again in the document-font menu (#2591).
let (mut app, ed) = preview_app();
app.views[0].line_editor = Some(ed);
let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec();
app.use_files(crate::files::FilePurpose::TextFont { page: 0 }, vec![("Inter-Regular.ttf".into(), inter)]);
let doc = app.session.get(app.views[0].id).unwrap();
let lines = doc.text_lines(0);
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<String>(), "Preview text");
assert!(lines.iter().all(|l| l.base_font == "Inter-Regular"), "{:?}", lines.iter().map(|l| &l.base_font).collect::<Vec<_>>());
assert!(app.views[0].line_editor.is_some(), "the paragraph stays open for editing");
}
#[test]
fn text_background_is_cached_without_editing_and_released_on_cancel() {
let (app, ed) = preview_app();
+28 -10
View File
@@ -24,6 +24,7 @@ pub enum CloseRequest {
pub struct PendingOverwrite {
pub id: pdfcraft_engine::DocId,
pub dest: String,
target: SaveTarget,
/// What was to run once the document is written (close its tab, quit).
after: Box<dyn FnOnce(&mut PdfCraftApp) + Send>,
}
@@ -52,7 +53,10 @@ impl PdfCraftApp {
let Some(doc) = self.session.get(id) else { return true };
let info = &doc.info;
let view = &mut self.views[i];
if !matches!(edit, Edit::AddAnnotation(_)) {
if matches!(edit, Edit::AddAnnotation(_)) {
// A released drawing's raster can come now: it will have the annotation.
view.comments.drawings_committed();
} else {
// Only a new drawing waits on its raster; any other edit may remove or move it.
view.comments.settling.clear();
}
@@ -163,6 +167,10 @@ impl PdfCraftApp {
true
}
Err(e) => {
if let Some(view) = self.views.get_mut(i) {
// A drawing whose annotation wasn't added must not stay painted.
view.comments.drawings_refused();
}
let message = if matches!(edit, Edit::MoveObjects { .. }) { crate::object_ui::move_error(&e) } else { e.to_string() };
self.notify_fmt("{label} failed: {e}", &[("label", &crate::i18n::edit_label(&label)), ("e", &message)]);
false
@@ -614,12 +622,12 @@ impl PdfCraftApp {
let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(name);
// The bytes are taken once the user has chosen, so edits made meanwhile are saved too.
self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, dest| {
app.save_doc_to_then(id, &dest.to_string_lossy(), Box::new(after));
app.save_doc_to_then(id, &dest.to_string_lossy(), target, Box::new(after));
});
return false;
}
};
self.save_doc_to_then(id, &destination, Box::new(after))
self.save_doc_to_then(id, &destination, target, Box::new(after))
}
#[cfg(target_arch = "wasm32")]
{
@@ -674,14 +682,14 @@ impl PdfCraftApp {
/// `pending_overwrite` behind [`Dialog::OverwriteChanged`](crate::Dialog::OverwriteChanged).
/// Returns `true` if written now.
#[cfg(not(target_arch = "wasm32"))]
fn save_doc_to_then(&mut self, id: pdfcraft_engine::DocId, dest: &str, after: Box<dyn FnOnce(&mut Self) + Send>) -> bool {
fn save_doc_to_then(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget, after: Box<dyn FnOnce(&mut Self) + Send>) -> bool {
let own = self.session.get(id).and_then(|d| d.path.as_deref()).is_some_and(|p| crate::same_file(p, dest));
if own && self.watch.disk_differs(id, dest) {
self.pending_overwrite = Some(PendingOverwrite { id, dest: dest.to_string(), after });
self.pending_overwrite = Some(PendingOverwrite { id, dest: dest.to_string(), target, after });
self.dialog = Some(crate::Dialog::OverwriteChanged);
return false;
}
let saved = self.save_doc_to(id, dest);
let saved = self.save_doc_to(id, dest, target);
if saved {
after(self);
}
@@ -692,7 +700,7 @@ impl PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn overwrite_changed_file(&mut self) {
let Some(p) = self.pending_overwrite.take() else { return };
if self.save_doc_to(p.id, &p.dest) {
if self.save_doc_to(p.id, &p.dest, p.target) {
(p.after)(self);
}
}
@@ -708,17 +716,27 @@ impl PdfCraftApp {
/// Write document `id` to `dest` and make it the document's file. Returns `true` if written.
#[cfg(not(target_arch = "wasm32"))]
fn save_doc_to(&mut self, id: pdfcraft_engine::DocId, dest: &str) -> bool {
fn save_doc_to(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget) -> bool {
// Save As answers on a later frame: commit what was typed meanwhile, too (#166), even
// if another tab is active by then.
if !self.commit_typing_in(id) || !self.flatten_fill_sign_for_save(id) {
return false;
}
let Some(name) = self.session.get(id).map(|d| d.name.clone()) else {
let Some((name, full)) = self.session.get(id).map(|d| {
// Save As to another file is a clean copy, without the earlier revisions that still
// hold deleted pages or replaced text (#2165). A signed document stays incremental so
// its signatures keep covering the file.
let full = target == SaveTarget::As
&& !d.path.as_deref().is_some_and(|p| crate::same_file(p, dest))
&& d.read_only_reason.is_none()
&& !d.is_signed();
(d.name.clone(), full)
}) else {
self.notify_tr("The document was closed before it could be saved.");
return false;
};
let bytes = match self.session.save_bytes(id).and_then(|b| self.session.check_save(id, &b).map(|()| b)) {
let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) };
let bytes = match bytes.and_then(|b| self.session.check_save(id, &b).map(|()| b)) {
Ok(b) => b,
Err(e) => {
self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
+68 -10
View File
@@ -195,9 +195,9 @@ pub(crate) fn file_stem_from_user(name: &str) -> String {
if reserved { format!("_{stem}") } else { stem.to_string() }
}
/// A safe native Save suggestion for a PDF-supplied attachment name.
/// This changes only the suggestion, never the stored name or the selected destination.
#[cfg(any(test, not(target_arch = "wasm32")))]
/// A plain file name for a PDF-supplied attachment name: the Save suggestion, and the tab name
/// (which later names files written from that tab). It never changes the stored name or a
/// destination the user selected.
pub(crate) fn attachment_file_name(name: &str) -> String {
let cleaned: String = name
.chars()
@@ -217,6 +217,14 @@ pub(crate) fn attachment_file_name(name: &str) -> String {
if reserved { format!("_{name}") } else { name.to_string() }
}
/// Whether `name` names a file directly inside a folder: not empty, `.` or `..`, with no `/` or
/// `\\`, and no drive (`C:`). On Windows any `:` is refused, which also rules out alternate data
/// streams.
pub(crate) fn is_plain_file_name(name: &str) -> bool {
let drive = matches!(name.as_bytes(), [letter, b':', ..] if letter.is_ascii_alphabetic());
!(name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\']) || drive || (cfg!(windows) && name.contains(':')))
}
/// Pages ▸ Rotate Pages.
#[derive(Clone, Debug, PartialEq)]
pub struct RotateDraft {
@@ -468,13 +476,26 @@ impl PdfCraftApp {
match purpose {
FilePurpose::TextFont { page } => {
let Some((_, bytes)) = files.into_iter().next() else { return };
if self.apply_edit(Edit::RegisterTextFont { page, bytes: Arc::new(bytes) })
&& let Some((index, id)) = self.active_ids()
&& let Some(doc) = self.session.get(id)
&& let Some(ed) = self.views[index].line_editor.as_mut()
&& let Some(ctx) = self.ctx.as_ref()
let bytes = Arc::new(bytes);
if !self.apply_edit(Edit::RegisterTextFont { page, bytes: bytes.clone() }) {
return;
}
let Some((index, id)) = self.active_ids() else { return };
let Some(mut ed) = self.views[index].line_editor.clone().filter(|ed| ed.page == page) else { return };
// The font just loaded is the one the user wants: apply it to the paragraph, as
// choosing it from the document-font menu does, instead of only registering it (#2591).
let resource = self.session.get(id).and_then(|doc| {
doc.text_editor_fonts(page).into_iter().find(|f| f.bytes == *bytes && !f.resource.starts_with("PCApproved")).map(|f| f.resource)
});
let ctx = self.ctx.clone();
if resource.is_some() {
ed.font_resource = resource;
self.apply_line_format(index, ed, ctx.as_ref());
} else if let Some(doc) = self.session.get(id)
&& let Some(ctx) = ctx
{
ed.fonts = crate::edit_text_ui::install_source_fonts(ctx, doc, page);
ed.fonts = crate::edit_text_ui::install_source_fonts(&ctx, doc, page);
self.views[index].line_editor = Some(ed);
}
}
FilePurpose::Combine => self.stage_combine(files),
@@ -667,7 +688,12 @@ impl PdfCraftApp {
/// [`Self::write_files`], then `after` once every file is written: now, or on a later frame
/// when the user chooses the folder. `after` never runs when writing fails or is cancelled.
/// Every name must be a plain file name ([`is_plain_file_name`]); otherwise nothing is written.
pub(crate) fn write_files_then(&mut self, named: Vec<(String, Arc<Vec<u8>>)>, title: &str, after: impl FnOnce(&mut Self) + Send + 'static) {
if let Some((name, _)) = named.iter().find(|(name, _)| !is_plain_file_name(name)) {
self.notify_fmt("Couldn't write {name}: {e}", &[("name", name), ("e", tl!("the name is not a plain file name"))]);
return;
}
#[cfg(not(target_arch = "wasm32"))]
{
let write = move |app: &mut Self, dir: std::path::PathBuf| {
@@ -996,7 +1022,7 @@ impl PdfCraftApp {
#[cfg(test)]
mod stem_tests {
use super::{attachment_file_name, file_stem_from_user};
use super::{attachment_file_name, file_stem_from_user, is_plain_file_name};
#[test]
fn user_file_names_stay_inside_the_folder_and_writable_everywhere() {
@@ -1049,4 +1075,36 @@ mod stem_tests {
assert_eq!(attachment_file_name(&once), once);
}
}
#[test]
fn write_files_refuses_names_that_leave_the_folder() {
let dir = std::env::temp_dir().join(format!("pdfcraft-write-files-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let out = dir.join("a").join("out");
std::fs::create_dir_all(&out).unwrap();
let absolute = dir.join("abs.pdf").to_string_lossy().into_owned();
let mut app = crate::PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.export_dir_override = Some(out.to_string_lossy().into_owned());
for bad in ["../x.pdf", "..", ".", "", "sub/x.pdf", "sub\\x.pdf", "C:x.pdf", absolute.as_str()] {
assert!(!is_plain_file_name(bad), "{bad}");
// A plain name first: nothing is written when any name in the batch is refused.
app.write_files(&[("ok.pdf".into(), std::sync::Arc::new(b"%PDF".to_vec())), (bad.into(), std::sync::Arc::new(b"%PDF".to_vec()))], "t");
assert!(app.toast.as_ref().is_some_and(|t| t.0.contains("not a plain file name")), "{bad}");
}
let names: Vec<_> = walk(&dir);
assert!(names.is_empty(), "{names:?}");
app.write_files(&[("x.pdf".into(), std::sync::Arc::new(b"%PDF".to_vec()))], "t");
assert_eq!(walk(&dir), vec![out.join("x.pdf")]);
let _ = std::fs::remove_dir_all(&dir);
}
fn walk(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() { out.extend(walk(&path)) } else { out.push(path) }
}
out
}
}
+4
View File
@@ -1703,6 +1703,7 @@
Couldn't use {name}: {e} تعذّر استخدام {name}: {e}
Couldn't extract pages: {e} تعذّر استخراج الصفحات: {e}
Couldn't write {name}: {e} تعذّرت كتابة {name}: {e}
the name is not a plain file name الاسم ليس اسم ملف بسيطًا
Wrote 1 file to {dir} كُتب ملف واحد في {dir}
Wrote {n} files to {dir} كُتب {n} ملفات في {dir}
No pages match those choices لا صفحات تطابق هذه الاختيارات
@@ -2402,6 +2403,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.
+5
View File
@@ -1664,6 +1664,7 @@
Couldn't use {name}: {e} {name} не можа да бъде използван: {e}
Couldn't extract pages: {e} Страниците не можаха да бъдат извлечени: {e}
Couldn't write {name}: {e} {name} не можа да бъде записан: {e}
the name is not a plain file name името не е обикновено име на файл
Wrote 1 file to {dir} Записан е 1 файл в {dir}
Wrote {n} files to {dir} Записани са {n} файла в {dir}
No pages match those choices Няма страници, отговарящи на този избор
@@ -2343,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. Разглеждате редакция {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.
+5
View File
@@ -1755,6 +1755,7 @@ key Space Mezerník
Couldn't use {name}: {e} {name} nelze použít: {e}
Couldn't extract pages: {e} Stránky nelze extrahovat: {e}
Couldn't write {name}: {e} {name} nelze zapsat: {e}
the name is not a plain file name název není prostý název souboru
Wrote 1 file to {dir} Zapsán 1 soubor do {dir}
Wrote {n} files to {dir} Zapsáno souborů: {n} (složka {dir})
No pages match those choices Těmto volbám neodpovídají žádné stránky
@@ -2454,6 +2455,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.
+5
View File
@@ -1637,6 +1637,7 @@
Couldn't use {name}: {e} {name} konnte nicht verwendet werden: {e}
Couldn't extract pages: {e} Seiten konnten nicht extrahiert werden: {e}
Couldn't write {name}: {e} {name} konnte nicht geschrieben werden: {e}
the name is not a plain file name der Name ist kein einfacher Dateiname
Wrote 1 file to {dir} 1 Datei nach {dir} geschrieben
Wrote {n} files to {dir} {n} Dateien nach {dir} geschrieben
No pages match those choices Keine Seiten entsprechen dieser Auswahl
@@ -2381,6 +2382,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.
+1
View File
@@ -1215,6 +1215,7 @@
Couldn't use {name}: {e} Δεν ήταν δυνατή η χρήση του {name}: {e}
Couldn't extract pages: {e} Δεν ήταν δυνατή η εξαγωγή σελίδων: {e}
Couldn't write {name}: {e} Δεν ήταν δυνατή η εγγραφή του {name}: {e}
the name is not a plain file name το όνομα δεν είναι απλό όνομα αρχείου
Wrote 1 file to {dir} Γράφτηκε 1 αρχείο στον φάκελο {dir}
Wrote {n} files to {dir} Γράφτηκαν {n} αρχεία στον φάκελο {dir}
No pages match those choices Καμία σελίδα δεν αντιστοιχεί σε αυτές τις επιλογές
Can't render this file because it contains an unexpected character in line 476 and column 9.
+5
View File
@@ -1714,6 +1714,7 @@ action wizard Start Iniciar
Couldn't use {name}: {e} No se pudo usar {name}: {e}
Couldn't extract pages: {e} No se pudieron extraer las páginas: {e}
Couldn't write {name}: {e} No se pudo escribir {name}: {e}
the name is not a plain file name el nombre no es un nombre de archivo simple
Wrote 1 file to {dir} Se ha escrito 1 archivo en {dir}
Wrote {n} files to {dir} Se han escrito {n} archivos en {dir}
No pages match those choices Ninguna página coincide con esas opciones
@@ -2568,3 +2569,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.
+5
View File
@@ -1632,6 +1632,7 @@
Couldn't use {name}: {e} Impossible d'utiliser {name} : {e}
Couldn't extract pages: {e} Impossible d'extraire les pages : {e}
Couldn't write {name}: {e} Impossible d'écrire {name} : {e}
the name is not a plain file name le nom n'est pas un simple nom de fichier
Wrote 1 file to {dir} 1 fichier écrit dans {dir}
Wrote {n} files to {dir} {n} fichiers écrits dans {dir}
No pages match those choices Aucune page ne correspond à ces choix
@@ -2380,6 +2381,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.
+5
View File
@@ -1622,6 +1622,7 @@
Couldn't use {name}: {e} {name} használata nem sikerült: {e}
Couldn't extract pages: {e} Az oldalakat nem sikerült kinyerni: {e}
Couldn't write {name}: {e} {name} írása nem sikerült: {e}
the name is not a plain file name a név nem egyszerű fájlnév
Wrote 1 file to {dir} 1 fájl ide írva: {dir}
Wrote {n} files to {dir} {n} fájl ide írva: {dir}
No pages match those choices Egy oldal sem felel meg ezeknek a választásoknak
@@ -2379,6 +2380,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.
+5
View File
@@ -1709,6 +1709,7 @@
Couldn't use {name}: {e} Impossibile usare {name}: {e}
Couldn't extract pages: {e} Impossibile estrarre le pagine: {e}
Couldn't write {name}: {e} Impossibile scrivere {name}: {e}
the name is not a plain file name il nome non è un semplice nome file
Wrote 1 file to {dir} Scritto 1 file in {dir}
Wrote {n} files to {dir} Scritti {n} file in {dir}
No pages match those choices Nessuna pagina corrisponde a queste scelte
@@ -2389,6 +2390,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.
+5
View File
@@ -1713,6 +1713,7 @@
Couldn't use {name}: {e} {name} を使用できませんでした: {e}
Couldn't extract pages: {e} ページを抽出できませんでした: {e}
Couldn't write {name}: {e} {name} に書き込めませんでした: {e}
the name is not a plain file name 名前が単独のファイル名ではありません
Wrote 1 file to {dir} {dir} に 1 ファイルを書き込みました
Wrote {n} files to {dir} {dir} に {n} ファイルを書き込みました
No pages match those choices 条件に一致するページはありません
@@ -2380,6 +2381,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.
+5
View File
@@ -1634,6 +1634,7 @@
Couldn't use {name}: {e} {name} файлын пайдалану мүмкін болмады: {e}
Couldn't extract pages: {e} Беттерді шығарып алу мүмкін болмады: {e}
Couldn't write {name}: {e} {name} файлына жазу мүмкін болмады: {e}
the name is not a plain file name атау қарапайым файл атауы емес
Wrote 1 file to {dir} 1 файл {dir} ішіне жазылды
Wrote {n} files to {dir} {n} файл {dir} ішіне жазылды
No pages match those choices Ешбір бет бұл таңдауларға сәйкес келмейді
@@ -2378,6 +2379,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.
+5
View File
@@ -1965,6 +1965,7 @@ comment menu Edit Redaguoti
Couldn't use the photo: {e} Nepavyko panaudoti nuotraukos: {e}
Couldn't use {name}: {e} Nepavyko panaudoti {name}: {e}
Couldn't write {name}: {e} Nepavyko įrašyti {name}: {e}
the name is not a plain file name vardas nėra paprastas failo vardas
Couldn't write {path}: {e} Nepavyko įrašyti {path}: {e}
Create link Kurti saitą
Created 1 link Sukurtas 1 saitas
@@ -2624,6 +2625,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.
+76 -2
View File
@@ -390,9 +390,23 @@ fn first_supported(list: &str) -> Option<Lang> {
list.split(['(', ')', ',', '"', '\n']).map(str::trim).filter(|s| !s.is_empty()).find_map(lang_from_tag)
}
/// The first supported language among BCP 47 tags in preference order, as the browser lists them
/// in `navigator.languages` (#2954). Only the first few tags are looked at: the list is short in
/// practice, and a page can't make Auto walk an unbounded one.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
fn first_supported_tag<S: AsRef<str>>(tags: impl IntoIterator<Item = S>) -> Option<Lang> {
tags.into_iter().take(32).find_map(|tag| lang_from_tag(tag.as_ref().trim()))
}
/// The browser's preferred languages, as the desktop builds use the system's: `navigator.languages`
/// in order, then `navigator.language` (kept within the 32 tags `first_supported_tag` reads).
/// English when the browser offers none we have a catalog for.
#[cfg(target_arch = "wasm32")]
fn detect_system_lang() -> Lang {
Lang::EN
let Some(navigator) = web_sys::window().map(|w| w.navigator()) else { return Lang::EN };
let mut tags: Vec<String> = navigator.languages().iter().take(31).filter_map(|v| v.as_string()).collect();
tags.extend(navigator.language());
first_supported_tag(&tags).unwrap_or(Lang::EN)
}
thread_local! {
@@ -411,7 +425,38 @@ pub fn set_current(lang: Lang) {
/// `label` as it reads inside a sentence ("add a text box"): lowercased, except in languages
/// that capitalise nouns (German), where lowercasing would misspell it.
pub fn in_sentence(label: &str) -> std::borrow::Cow<'_, str> {
if current().code() == "de" { std::borrow::Cow::Borrowed(label) } else { std::borrow::Cow::Owned(label.to_lowercase()) }
match current().code() {
"de" => std::borrow::Cow::Borrowed(label),
"tr" => std::borrow::Cow::Owned(label.chars().flat_map(lower_turkish).collect()),
_ => std::borrow::Cow::Owned(label.to_lowercase()),
}
}
/// The translation of `english` in upper case, for section titles. Rust's case mapping knows no
/// language, and Turkish has a dotted and a dotless i (`i` ↔ `İ`, `ı` ↔ `I`): "Gelişmiş" must
/// become "GELİŞMİŞ", not "GELIŞMIŞ". A label without a Turkish row is still English and keeps
/// the English rule.
pub fn upper_label(english: &str) -> String {
let lang = current();
let shown = tr(lang, english);
if lang.code() == "tr" && has(lang, english) { shown.chars().flat_map(upper_turkish).collect() } else { shown.to_uppercase() }
}
fn upper_turkish(c: char) -> std::char::ToUppercase {
match c {
'i' => 'İ'.to_uppercase(),
'ı' => 'I'.to_uppercase(),
c => c.to_uppercase(),
}
}
fn lower_turkish(c: char) -> std::char::ToLowercase {
match c {
// `İ`.to_lowercase() is "i" plus a combining dot above.
'İ' => 'i'.to_lowercase(),
'I' => 'ı'.to_lowercase(),
c => c.to_lowercase(),
}
}
pub fn current() -> Lang {
@@ -619,6 +664,21 @@ mod tests {
assert_eq!(first_supported("("), None);
}
/// #2954: the web build's Auto language follows the browser's preferred languages in order,
/// skipping ones without a catalog, and is English only when none is supported.
#[test]
fn browser_language_list_is_followed_in_order() {
assert_eq!(first_supported_tag(["it-IT", "en-US"]), Lang::from_code("it"));
assert_eq!(first_supported_tag(["xx-YY", "ja", "fr-FR"]), Some(JA()));
assert_eq!(first_supported_tag(["zh-TW"]), Lang::from_code("zh-hant"));
assert_eq!(first_supported_tag(["en-GB", "fr-FR"]), Some(Lang::EN));
assert_eq!(first_supported_tag([" pt-PT "]), Lang::from_code("pt-br"));
assert_eq!(first_supported_tag(["", "xx"]), None);
assert_eq!(first_supported_tag(Vec::<String>::new()), None);
let long = std::iter::repeat_n("xx", 10_000).chain(["fr"]);
assert_eq!(first_supported_tag(long), None, "only the first tags are read");
}
#[test]
fn windows_display_language_resolves_chinese_scripts() {
for tag in ["zh-CN", "zh-SG", "zh-Hans", "zh-Hans-CN"] {
@@ -1961,6 +2021,20 @@ mod tests {
set_current(Lang::EN);
}
#[test]
fn turkish_keeps_its_dotted_and_dotless_i_when_changing_case() {
set_current(Lang::from_code("tr").expect("tr registered"));
assert_eq!(upper_label("Advanced"), "GELİŞMİŞ");
assert_eq!(upper_label("Signatures"), "İMZALAR");
// A label with no Turkish row is still English and keeps the English rule.
assert_eq!(upper_label("Untranslated title"), "UNTRANSLATED TITLE");
assert_eq!(in_sentence("İmza Alanı"), "imza alanı");
assert_eq!(in_sentence("Işık"), "ışık");
set_current(Lang::EN);
assert_eq!(upper_label("Advanced"), "ADVANCED");
assert_eq!(in_sentence("Image"), "image");
}
#[test]
fn german_certificate_subject_has_a_distinct_context() {
let de = Lang::from_code("de").expect("de registered");
+1
View File
@@ -1696,6 +1696,7 @@
Couldn't use {name}: {e} Nie można użyć {name}: {e}
Couldn't extract pages: {e} Nie można wyodrębnić stron: {e}
Couldn't write {name}: {e} Nie można zapisać {name}: {e}
the name is not a plain file name nazwa nie jest zwykłą nazwą pliku
Wrote 1 file to {dir} Zapisano 1 plik w {dir}
Wrote {n} files to {dir} Liczba plików zapisanych w {dir}: {n}
No pages match those choices Żadna strona nie pasuje do wybranych opcji
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -1538,6 +1538,7 @@
Couldn't use {name}: {e} Não foi possível usar {name}: {e}
Couldn't extract pages: {e} Não foi possível extrair páginas: {e}
Couldn't write {name}: {e} Não foi possível gravar {name}: {e}
the name is not a plain file name o nome não é um nome de arquivo simples
Wrote 1 file to {dir} 1 arquivo gravado em {dir}
Wrote {n} files to {dir} {n} arquivos gravados em {dir}
No pages match those choices Nenhuma página corresponde a essas opções
@@ -2394,6 +2395,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.
+5
View File
@@ -1704,6 +1704,7 @@
Couldn't use {name}: {e} Не удалось использовать {name}: {e}
Couldn't extract pages: {e} Не удалось извлечь страницы: {e}
Couldn't write {name}: {e} Не удалось записать {name}: {e}
the name is not a plain file name имя не является простым именем файла
Wrote 1 file to {dir} Записан 1 файл в {dir}
Wrote {n} files to {dir} Записано {n} файлов в {dir}
No pages match those choices Страницы не соответствуют этим выбору
@@ -2293,6 +2294,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.
+5
View File
@@ -479,6 +479,7 @@
Couldn't take the snapshot: {e} Posnetka ni bilo mogoče usvariti: {e}
Couldn't use {name}: {e} Datoteke {name} ni bilo mogoče uporabiti: {e}
Couldn't write {name}: {e} Datoteke {name} ni bilo mogoče zapisati: {e}
the name is not a plain file name ime ni preprosto ime datoteke
Couldn't write {path}: {e} Poti {path} ni bilo mogoče zapisati: {e}
Counterclockwise 90 degrees 90 stopinj v levo
Country/Region Država/regija
@@ -2385,6 +2386,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.
+1
View File
@@ -1623,6 +1623,7 @@ action wizard Start ప్రారంభించు
Couldn't use {name}: {e} {name}ను ఉపయోగించడం సాధ్యం కాలేదు: {e}
Couldn't extract pages: {e} పేజీలను వేరు తీయడం సాధ్యం కాలేదు: {e}
Couldn't write {name}: {e} {name}ను రాయడం సాధ్యం కాలేదు: {e}
the name is not a plain file name పేరు సాధారణ ఫైల్ పేరు కాదు
Wrote 1 file to {dir} {dir}లో 1 ఫైల్ సేవ్ చేయబడింది
Wrote {n} files to {dir} {dir}లో {n} ఫైల్‌లు సేవ్ చేయబడ్డాయి
No pages match those choices ఆ ఎంపికలకు సరిపోయే పేజీలు ఏవీ లేవు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+5
View File
@@ -1603,6 +1603,7 @@
Couldn't use {name}: {e} {name} kullanılamadı: {e}
Couldn't extract pages: {e} Sayfalar ayıklanamadı: {e}
Couldn't write {name}: {e} {name} yazılamadı: {e}
the name is not a plain file name ad, basit bir dosya adı değil
Wrote 1 file to {dir} 1 dosya {dir} konumuna yazıldı
Wrote {n} files to {dir} {n} dosya {dir} konumuna yazıldı
No pages match those choices Bu seçimlerle eşleşen sayfa yok
@@ -2376,6 +2377,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.
+5
View File
@@ -1701,6 +1701,7 @@
Couldn't use {name}: {e} Не вдалося використати {name}: {e}
Couldn't extract pages: {e} Не вдалося видобути сторінки: {e}
Couldn't write {name}: {e} Не вдалося записати {name}: {e}
the name is not a plain file name назва не є простою назвою файлу
Wrote 1 file to {dir} Записано 1 файл у {dir}
Wrote {n} files to {dir} Записано файлів ({n}) у {dir}
No pages match those choices Жодна сторінка не відповідає вибраним параметрам
@@ -2402,6 +2403,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.
+5
View File
@@ -1955,6 +1955,7 @@ comment menu Edit Sửa
Couldn't use the photo: {e} Không thể dùng ảnh: {e}
Couldn't use {name}: {e} Không thể dùng {name}: {e}
Couldn't write {name}: {e} Không thể ghi {name}: {e}
the name is not a plain file name tên không phải là tên tệp đơn thuần
Couldn't write {path}: {e} Không thể ghi {path}: {e}
Create link Tạo liên kết
Created 1 link Đã tạo 1 liên kết
@@ -2681,3 +2682,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.
+5
View File
@@ -1671,6 +1671,7 @@
Couldn't use {name}: {e} 无法使用{name}:{e}
Couldn't extract pages: {e} 无法提取页面:{e}
Couldn't write {name}: {e} 无法写入{name}:{e}
the name is not a plain file name 名称不是单纯的文件名
Wrote 1 file to {dir} 已将 1 个文件写入{dir}
Wrote {n} files to {dir} 已将{n}个文件写入{dir}
No pages match those choices 所选条件没有匹配的页面
@@ -2344,6 +2345,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.
+1
View File
@@ -1615,6 +1615,7 @@
Couldn't use {name}: {e} 無法使用{name}:{e}
Couldn't extract pages: {e} 無法提取頁面:{e}
Couldn't write {name}: {e} 無法寫入{name}:{e}
the name is not a plain file name 名稱不是單純的檔案名稱
Wrote 1 file to {dir} 已將 1 個檔案寫入{dir}
Wrote {n} files to {dir} 已將{n}個檔案寫入{dir}
No pages match those choices 所選條件沒有匹配的頁面
Can't render this file because it contains an unexpected character in line 9 and column 16.
+13 -4
View File
@@ -1520,7 +1520,8 @@ impl PdfCraftApp {
match (data, open) {
(Err(e), _) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]),
(Ok(bytes), true) => {
if let Err(e) = self.open_bytes(&att.name, None, bytes) {
// The tab name later names files written from the tab (Split, Extract pages).
if let Err(e) = self.open_bytes(&crate::files::attachment_file_name(&att.name), None, bytes) {
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]);
}
}
@@ -1540,6 +1541,15 @@ impl PdfCraftApp {
}
}
/// [`Self::attachment_action`] for the file of the attachment comment at (page, index).
pub fn comment_attachment_action(&mut self, doc: DocId, page: usize, index: usize, open: bool) {
let source = pdfcraft_render::AttachmentSource::Annotation { page, index };
// The menus offer the items only for a listed file, so a miss is a comment gone since.
if let Some(i) = self.session.get(doc).and_then(|d| d.info.attachments.iter().position(|a| a.source == source)) {
self.attachment_action(doc, i, open);
}
}
/// Whether the app draws the window's title bar, buttons and resize edges: the desktop leaves
/// the frame to the app and Use system title bar is off.
pub fn draws_window_frame(&self) -> bool {
@@ -2437,7 +2447,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 +3095,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 {
+62
View File
@@ -608,3 +608,65 @@ fn tool_picker_keeps_picked_recipient_when_other_open_is_ready() {
}
}
}
#[test]
#[allow(clippy::expect_used, clippy::unwrap_used)]
fn inline_tool_open_keeps_its_command_for_pdf_conversion_and_password_retry() {
for (name, bytes, encrypted) in
[("plain.pdf", fixture(1), false), ("converted.txt", b"Text to export".to_vec(), false), ("locked.pdf", encrypted_tool_fixture(), true)]
{
let path = std::env::temp_dir().join(format!("pdfcraft-inline-tool-{}-{name}", std::process::id()));
std::fs::write(&path, bytes).expect("write selected file");
let mut app = PdfCraftApp::new();
app.set_option("language", "en").expect("language preference");
assert!(app.ctx.is_none(), "this exercises the no-context inline path");
app.open_path_then(&path.to_string_lossy(), Some("export.text"));
if encrypted {
assert!(app.dialog.is_none());
assert_eq!(app.password_prompt.as_ref().expect("password prompt").then, Some("export.text"));
app.submit_password(Some("wrong".into()));
assert!(app.password_prompt.as_ref().expect("wrong-password prompt").error.is_some());
assert!(app.dialog.is_none());
app.submit_password(Some("tool-password".into()));
}
assert_eq!(app.views.len(), 1);
assert_eq!(app.active, Some(0));
assert!(matches!(app.dialog, Some(crate::Dialog::Export(_))), "the inline {name} retains its export command");
let _ = std::fs::remove_file(path);
}
}
#[test]
#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
fn startup_files_wait_for_absolute_link_consent_and_preparation() {
let (mut h, _) = harness();
let source = h.state().views[0].id;
let path = std::env::temp_dir().join(format!("pdfcraft-open-consented-startup-{}.pdf", std::process::id()));
std::fs::write(&path, fixture(2)).expect("write linked PDF");
h.state_mut().open_remote_link(source, path.to_string_lossy().into_owned(), pdfcraft_render::RemoteDestination::Default);
h.state().startup_inbox.lock().expect("startup queue").push(("startup.pdf".into(), fixture(1)));
h.step();
assert!(h.state().pending_remote_link.is_some());
assert_eq!(h.state().startup_inbox.lock().expect("startup queue").len(), 1);
assert_eq!(h.state().views.len(), 1, "startup cannot replace the consent interaction");
let (gate, started, release, finished) = open_worker::prepare_gate();
h.state_mut().open_prepare_gate = Some(gate);
h.state_mut().resolve_pending_remote_link(true);
started.recv_timeout(Duration::from_secs(5)).expect("worker reached prepare barrier");
h.step();
let queued = h.state().startup_inbox.lock().expect("startup queue").len();
let visible = h.state().views.len();
// Always release before asserting: a failure must not strand the worker at the barrier.
release.send(()).expect("release prepare barrier");
finished.recv_timeout(Duration::from_secs(5)).expect("worker finished preparation");
assert_eq!(queued, 1, "startup bytes wait while the approved linked PDF prepares");
assert_eq!(visible, 1);
h.step();
let app = h.state();
assert_eq!(app.views.len(), 3);
assert_eq!(app.session.get(app.views[1].id).expect("linked document").path.as_deref(), path.to_str());
assert_eq!(app.session.get(app.views[2].id).expect("startup document").name, "startup.pdf");
assert_eq!(app.active, Some(1), "the user's approved navigation keeps focus");
let _ = std::fs::remove_file(path);
}
+25 -14
View File
@@ -418,17 +418,7 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
}
changed |= crate::edit_text_ui::extras_panel(ui, &mut ed.extras);
if changed {
let edit = pdfcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() };
if app.apply_edit(edit) {
ed.applied();
if let Some(doc) = app.session.get(app.views[i].id)
&& let Some(block) = doc.text_blocks(ed.page).get(ed.block)
{
ed.refresh_source(block, doc.text_lines(ed.page));
ed.fonts = crate::edit_text_ui::install_source_fonts(ui.ctx(), doc, ed.page);
}
}
app.views[i].line_editor = Some(ed);
app.apply_line_format(i, ed, Some(ui.ctx()));
}
ui.add_space(6.0);
ui.separator();
@@ -482,6 +472,25 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
ui.separator();
}
impl PdfCraftApp {
/// Apply the open paragraph's formatting as one change; the paragraph stays open.
pub(crate) fn apply_line_format(&mut self, index: usize, mut ed: crate::edit_text_ui::LineEditor, ctx: Option<&egui::Context>) {
let edit = pdfcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() };
if self.apply_edit(edit) {
ed.applied();
if let Some(doc) = self.session.get(self.views[index].id)
&& let Some(block) = doc.text_blocks(ed.page).get(ed.block)
{
ed.refresh_source(block, doc.text_lines(ed.page));
if let Some(ctx) = ctx {
ed.fonts = crate::edit_text_ui::install_source_fonts(ctx, doc, ed.page);
}
}
}
self.views[index].line_editor = Some(ed);
}
}
// ───────────────────────────────────────────────────────────────────────────── right panels
pub(crate) enum Nav {
@@ -778,20 +787,22 @@ 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);
}
Some(crate::sign_ui::PanelAction::ExportCertificate(c)) => {
let pem = crate::sign_ui::certificate_pem(&c);
app.write_files(&[(format!("{}.cer", c.display_name()), std::sync::Arc::new(pem.into_bytes()))], "Export certificate");
app.export_certificate(&c);
}
None => {}
}
if let Some((p, i)) = app.views.get_mut(index).and_then(|v| v.comments.default_request.take()) {
app.make_comment_default(p, i);
}
if let Some((p, i, open)) = app.views.get_mut(index).and_then(|v| v.comments.attachment_request.take()) {
app.comment_attachment_action(id, p, i, open);
}
if let Some((p, i)) = app.views.get_mut(index).and_then(|v| v.comments.props_request.take()) {
app.open_comment_props(p, i);
}
+1 -1
View File
@@ -1103,7 +1103,7 @@ pub(crate) fn body(
);
// Advanced (Acrobat's Advanced Print Setup): print as image. Folded unless it is on.
ui.add_space(6.0);
egui::CollapsingHeader::new(egui::RichText::new(tl!("Advanced").to_uppercase()).font(theme::semibold(10.5)).color(t.text_faint))
egui::CollapsingHeader::new(egui::RichText::new(crate::i18n::upper_label("Advanced")).font(theme::semibold(10.5)).color(t.text_faint))
.id_salt("print-advanced")
.default_open(d.as_image)
.show(ui, |ui| {
+116 -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};
@@ -266,6 +266,12 @@ fn entry_unusable(u: &sign::windows::Unusable) -> DigitalIdEntry {
}
impl PdfCraftApp {
/// Export certificate: write `c` as PEM into a chosen folder, named after its subject.
pub(crate) fn export_certificate(&mut self, c: &Certificate) {
let name = format!("{}.cer", crate::files::attachment_file_name(&c.display_name()));
self.write_files(&[(name, std::sync::Arc::new(certificate_pem(c).into_bytes()))], "Export certificate");
}
/// Start signing: the rectangle (or field) is known; show Sign with a Digital ID.
pub fn start_signing(&mut self, page: usize, rect: Option<[f64; 4]>, field: Option<String>, certify: Option<u8>) {
self.refresh_os_key_store_ids();
@@ -528,36 +534,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 +1154,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 +1173,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 +1309,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 +1502,30 @@ mod tests {
Ok(PathBuf::from(&app.digital_ids[i].path))
}
#[test]
fn an_exported_certificate_is_written_inside_the_chosen_folder() {
let dir = scratch();
let out = dir.join("out");
std::fs::create_dir_all(&out).unwrap();
let key = PrivateKey::generate_p256().unwrap();
let cert = Certificate::self_signed(&Name::build("../x", "", "", "", ""), &key, sign::Time::from_unix(0), 1, &[1]).unwrap();
let mut app = PdfCraftApp::new();
app.export_dir_override = Some(out.to_string_lossy().into_owned());
app.export_certificate(&cert);
let written: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect();
assert_eq!(written, vec![std::ffi::OsString::from("out")]);
let pem = std::fs::read_to_string(out.join(".._x.cer")).unwrap();
assert!(pem.starts_with("-----BEGIN CERTIFICATE-----"), "{pem}");
let _ = std::fs::remove_dir_all(&dir);
}
#[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
@@ -272,7 +272,7 @@ pub fn section_title(ui: &mut egui::Ui, text: &str) {
let t = Tokens::get(ui.ctx());
ui.add_space(10.0);
// Section titles across every panel go through here, so one translation point covers them.
ui.label(egui::RichText::new(tl!(text).to_uppercase()).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6));
ui.label(egui::RichText::new(crate::i18n::upper_label(text)).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6));
ui.add_space(2.0);
}
+95 -2
View File
@@ -7,6 +7,8 @@ use egui_kittest::kittest::Queryable;
use pdfcraft_render::Annotation;
use pdfcraft_ui_egui::{PdfCraftApp, QuickTool};
mod support;
/// Two 300×200 pages of Helvetica text.
const TEXT_FIXTURE: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
@@ -406,8 +408,11 @@ fn shapes_and_ink_are_drawn_by_dragging() {
assert!((r[0] - 40.0).abs() < 3.0 && (r[1] - 40.0).abs() < 3.0 && (r[2] - 140.0).abs() < 3.0 && (r[3] - 100.0).abs() < 3.0, "{r:?}");
}
/// Press, drag and release a stroke, running only one frame after the release.
/// Press, drag and release a stroke, running only one frame after the release. Finished
/// renders are held back from the view (`hold_rasters`), so the raster the commit asks for can't
/// arrive before the test looks, however fast it renders; a test lets it through by clearing it.
fn stroke_without_settling(h: &mut Harness<'static, PdfCraftApp>, a: (f32, f32), b: (f32, f32)) {
h.state_mut().views[0].hold_rasters = true;
let (from, to) = (at(h, a.0, a.1), at(h, b.0, b.1));
h.hover_at(from);
h.run_steps(1);
@@ -429,7 +434,10 @@ fn a_released_pen_stroke_stays_drawn_until_the_page_raster_has_it() {
stroke_without_settling(&mut h, (40.0, 100.0), (140.0, 60.0));
assert_eq!(comments(&h).len(), 1, "the stroke is committed");
assert_eq!(h.state().views[0].comments.settling_len(), 1, "still drawn from its points");
settle(&mut h);
h.run_steps(5);
assert_eq!(h.state().views[0].comments.settling_len(), 1, "drawn from its points for as long as its raster takes");
h.state_mut().views[0].hold_rasters = false;
support::wait_until(&mut h, "the page raster with the stroke", |app| app.views[0].comments.settling_len() == 0);
assert_eq!(h.state().views[0].comments.settling_len(), 0, "the raster draws it now");
}
@@ -1205,6 +1213,91 @@ fn attaching_a_file_as_a_comment() {
assert_eq!(s.quick_tool, QuickTool::Select);
}
/// One 300×200 page with a paperclip FileAttachment comment at (100, 100)–(120, 124) carrying
/// `inner.pdf`, and no /Contents: the card shows only "Attachment" (#2565). The cross-reference
/// table keeps the reader from mistaking the embedded PDF's objects for the outer ones.
fn attachment_comment_fixture() -> Vec<u8> {
let inner = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF";
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R] >>".into(),
"<< /Type /Annot /Subtype /FileAttachment /Rect [100 100 120 124] /Name /Paperclip /T (Reviewer) /M (D:20261014105200) /FS 5 0 R >>".into(),
"<< /Type /Filespec /F (inner.pdf) /UF (inner.pdf) /EF << /F 6 0 R >> >>".into(),
format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n{inner}\nendstream", inner.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn attachment_harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("annexed.pdf", None, attachment_comment_fixture()).expect("opens");
app.set_option("left", "closed").unwrap();
setup(&mut app);
app
});
h.run_steps(4);
settle(&mut h);
h
}
fn open_tab_names(h: &Harness<'static, PdfCraftApp>) -> Vec<String> {
let s = h.state();
s.views.iter().filter_map(|v| s.session.get(v.id)).map(|d| d.name.clone()).collect()
}
#[test]
fn an_attachment_comment_opens_its_file_from_the_page_menu() {
let mut h = attachment_harness(|app| {
app.set_option("panel", "comments").unwrap();
app.set_option("quick", "select").unwrap();
});
assert_eq!(comments(&h)[0].subtype, "FileAttachment");
click_pt(&mut h, (110.0, 112.0));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the attachment comment is selected");
let p = at(&h, 110.0, 112.0);
h.hover_at(p);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(3);
h.get_by_label("Copy text");
// The Attachments panel is closed, so these items can only come from the comment's menu.
h.get_by_label("Save attachment…");
h.get_by_label("Open in a new tab").click();
h.run_steps(4);
assert_eq!(open_tab_names(&h), ["annexed.pdf", "inner.pdf"]);
}
#[test]
fn an_attachment_comment_opens_its_file_from_the_card_menu() {
let mut h = attachment_harness(|app| app.set_option("panel", "comments").unwrap());
h.state_mut().views[0].comments.selected = Some((0, 0));
h.run_steps(2);
h.get_by_label("More").click();
h.run_steps(2);
h.get_by_label("Copy text");
h.get_by_label("Save attachment…");
h.get_by_label("Open in a new tab").click();
h.run_steps(4);
assert_eq!(open_tab_names(&h), ["annexed.pdf", "inner.pdf"]);
}
/// Drag with the middle button (the mouse wheel) in small steps.
fn middle_drag(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) {
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: egui::Modifiers::NONE };
+52
View File
@@ -351,6 +351,21 @@ fn shift_click_selects_a_range_and_moves_it() {
assert_eq!(page_texts(h.state()), ["Page 3", "Page 4", "Page 1", "Page 2"]);
}
#[test]
fn reverse_pages_reverses_the_selection_or_else_the_whole_document() {
let mut h = organize(4);
assert!(h.state_mut().execute("page.reverse"));
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 4", "Page 3", "Page 2", "Page 1"]);
h.get_by_label("Page 1").click();
h.run_steps(1);
h.get_by_label("Page 3").click_modifiers(Modifiers::COMMAND);
h.run_steps(2);
assert!(h.state_mut().execute("page.reverse"));
h.run_steps(3);
assert_eq!(page_texts(h.state()), ["Page 2", "Page 3", "Page 4", "Page 1"]);
}
#[test]
fn command_click_toggles_and_rotation_applies_to_selection() {
let mut h = organize(3);
@@ -520,6 +535,27 @@ fn save_writes_an_incremental_update_and_clears_dirty() {
let _ = std::fs::remove_file(out);
}
#[test]
fn save_as_to_a_new_file_drops_earlier_revisions() {
// #2165: Save As appended a revision, so a deleted page stayed in the new file's first one.
let out = temp_path("save-as-clean.pdf");
let mut h = organize(3);
h.get_by_label("Page 2").hover();
h.run_steps(2);
h.get_by_label("Delete page 2").click();
h.run_steps(3);
h.state_mut().save_override = Some(out.to_string_lossy().into_owned());
assert!(h.state_mut().save_active(pdfcraft_ui_egui::SaveTarget::As));
let saved = std::fs::read(&out).expect("file written");
let count = |needle: &[u8]| saved.windows(needle.len()).filter(|w| *w == needle).count();
assert_eq!(count(b"%%EOF"), 1, "one revision only");
assert_eq!((count(b"(Page 1)"), count(b"(Page 2)")), (1, 0), "the deleted page's content is gone");
assert!(!dirty(&h));
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes.as_slice(), saved.as_slice());
assert_eq!(page_texts(h.state()), ["Page 1", "Page 3"]);
let _ = std::fs::remove_file(out);
}
#[test]
fn closing_a_dirty_tab_asks_and_cancel_keeps_it() {
let mut h = organize(2);
@@ -4225,9 +4261,25 @@ fn image_pixels_follow_the_frame_while_a_corner_is_dragged() {
.filter(|b| b.width() < page.width() - 1.0)
.collect()
};
// The dragged picture is cut from the page raster, which a render thread delivers after
// however many frames it takes (more on a slow runner): wait for it on screen, by state.
let raster_shown = |h: &Harness<'static, PdfCraftApp>| {
h.output().shapes.iter().any(|s| {
matches!(&s.shape, egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() && m.calc_bounds().width() >= page.width() - 1.0)
})
};
let wait_for_raster = |h: &mut Harness<'static, PdfCraftApp>| {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while h.state().render_pending() || !raster_shown(h) {
assert!(std::time::Instant::now() < deadline, "timed out waiting for the page raster");
h.step();
std::thread::sleep(std::time::Duration::from_millis(5));
}
};
let start = frame(&h).expect("the selected image has a frame");
let (grip, fixed) = (start.right_top(), start.left_bottom());
for factor in [0.6, 1.3] {
wait_for_raster(&mut h);
let to = fixed + (grip - fixed) * factor;
h.hover_at(grip);
h.run_steps(1);
+53 -8
View File
@@ -748,20 +748,26 @@ fn a_background_startup_forms_script_notice_waits_until_its_tab_is_selected() {
/// Embedded bytes and the PDF's original /UF name stay independent of the Save suggestion.
fn attachment_fixture() -> Vec<u8> {
let name = "invoice\u{202e}fdp.exe";
attachment_pdf("invoice\u{202e}fdp.exe", b"hello notes")
}
/// A one-page PDF with one embedded file whose /UF name is `name`.
fn attachment_pdf(name: &str, data: &[u8]) -> Vec<u8> {
let uf = std::iter::once(0xfeffu16).chain(name.encode_utf16()).map(|c| format!("{c:04X}")).collect::<String>();
let objs = [
"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(notes.txt) 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] /Resources << >> >>".to_string(),
"<< /Type /EmbeddedFile /Length 11 >>\nstream\nhello notes\nendstream".to_string(),
format!("<< /Type /Filespec /F (notes.txt) /UF <{uf}> /EF << /F 4 0 R >> >>"),
let objs: [Vec<u8>; 5] = [
b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(notes.txt) 5 0 R] >> >> >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> >>".to_vec(),
[format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat(),
format!("<< /Type /Filespec /F (notes.txt) /UF <{uf}> /EF << /F 4 0 R >> >>").into_bytes(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n");
@@ -824,3 +830,42 @@ fn attachment_save_preserves_bytes_metadata_and_selected_destination() {
assert_eq!(doc.info.attachments[index].name, raw_name);
assert_eq!(doc.bytes.as_ref(), &original);
}
/// Every file under `dir`, recursively, sorted.
fn files_under(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() { out.extend(files_under(&path)) } else { out.push(path) }
}
out.sort();
out
}
#[test]
fn files_written_from_an_attachment_tab_stay_inside_the_chosen_folder() {
let dir = AttachmentDir::new();
let absolute = dir.0.join("elsewhere").join("x.pdf").to_string_lossy().into_owned();
for name in ["../../x.pdf", absolute.as_str()] {
let out = dir.0.join("a").join("b").join("out");
std::fs::create_dir_all(&out).unwrap();
let mut h = harness(&[]);
h.state_mut().open_bytes("outer.pdf", None, attachment_pdf(name, &fixture(2))).unwrap();
h.run_steps(2);
let id = h.state().views[0].id;
h.state_mut().attachment_action(id, 0, true);
h.run_steps(2);
assert_eq!(h.state().views.len(), 2, "{name}: the attachment opens as a tab");
h.state_mut().export_dir_override = Some(out.to_string_lossy().into_owned());
h.state_mut().split_active(&pdfcraft_ui_egui::SplitPlan::By(pdfcraft_engine::SplitBy::PageCount(1)));
let written = files_under(&dir.0);
assert_eq!(written.len(), 2, "{name}: two split parts, got {written:?}");
assert!(written.iter().all(|p| p.parent() == Some(out.as_path())), "{name}: {written:?}");
// Extract writes page 1 under the name Split gave it.
h.state_mut().extract_draft = pdfcraft_ui_egui::ExtractDraft { separate: true, ..Default::default() };
h.state_mut().extract_selection();
assert!(h.state().toast.as_ref().is_some_and(|t| t.0.starts_with("Wrote 1 file")), "{name}: {:?}", h.state().toast);
assert_eq!(files_under(&dir.0), written, "{name}");
std::fs::remove_dir_all(dir.0.join("a")).unwrap();
}
}
+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));

Some files were not shown because too many files have changed in this diff Show More