chore(pr-2624): integrate main d8b27058

This commit is contained in:
PerchAnton
2026-10-18 08:20:38 +03:00
72 changed files with 2215 additions and 247 deletions
+22 -22
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 = "dbfebf4e9b2e9ac31ecf72202c2098173e72ebfaf90e9fee8f0563a6734fe31f"
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 = "898a5240fa59cac42ef6b99cb60bcfca0094ac27370e1c8d020bd27936b56517"
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 = "b4745c94797575529cd0e78c18c71fa2c0dfddfbf19b9c40b41dc2057c6f6543"
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 = "2acb3f6522d6d1f744343f474fe4c51ebec610a888a7fa83fc9422228e6fa3c1"
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 = "1b739478a4f186a49900cce84edbe136206a53b46340322df4caf77bb3fb5b12"
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 = "da3b71608ff7fe6e230b076207cee96ac01f66e068ca1be83af1ad56ac9c60eb"
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 = "5e84916e3ad8670216360695689fe8fd624365a29886f66a3c655ba126892f5b"
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 = "084f4ea06f31c632ad90b0e87f66240ce5b5dc012ac7770987d44918ee66f452"
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 = "973cd90ec34c8a973d146a0e2c73c1d632e17357c3d87ccf10c59e64d12c1587"
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 = "485f85d4053c9661ddda2ed9394a0644c77b489feb598d02f12ef209e11f2ee3"
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 = "24524864022ab2abeff68b5979519eee4e92134d0c4053d40c741bc7f01c249c"
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 = "bc6d123bdb34d5d7c22717fed1a86ff20e7e493acc1e155e61e61e2dc74459aa"
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 = "82678dbe730f56062037ea78de165b2c016b3fa670fb1cbfc7cd713eca900bac"
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 = "304d661b27cdb5246f6b2974b4b5e78667f0928cda3588440be3d8f35f254679"
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 = "05e2b7d5401166d362ba055120fba9a1645d6ec324997cf670e19fafcb0c54cf"
sha256 = "884ab18d60db52f65cd0e30530e196c73c0e627f450bc948517e2f4b7deeb04b"
[[asset]]
path = "crates/ui-egui/src/i18n/lt.tsv"
@@ -228,7 +228,7 @@ 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 = "17c8579e0e9d1524ffbeb87e7b30c14562dbaac64400826ee786cecfe31648fa"
sha256 = "c805a2c6da08e77a9a7455856d82c168cf6f353d96e1dc140fcc8898f12ba538"
[[asset]]
@@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "2d1c4b4276d02512a4e20101be95a1671de5c4f5cdfd661887af5ebab0db06a6"
sha256 = "11619064ae9edaeee5d601b7008e40095a40375a3e1a298155d59cb758fe7d34"
[[asset]]
path = "crates/ui-egui/src/i18n/sl.tsv"
@@ -3363,7 +3363,7 @@ 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 = "8f8a259264cda06eb6d9a9bf91dc7d873a8561213a9a9e7acbd32623bef40be0"
sha256 = "89f2c7495cfb0fc9d36d35b95c056dbeda18ccae521250babac719ff5df62d89"
[[asset]]
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"
+21
View File
@@ -1,9 +1,15 @@
# PdfCraft roadmap
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (PR #2624: integrated upstream d8b27058 while retaining this fix and upstream progress; estimates unchanged)
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
> **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 (M12 #2569: growable multiline XFA text reflows after edits; datasets, undo/redo, save/reopen and print covered; oversized fields remain read-only page 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,12 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
## Progress log
- **2026-10-18 (PR #2624, upstream refresh):** Integrated main `d8b27058` while retaining this PR's fix, regression coverage and upstream progress. 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 (M12, #2569):** Editing growable multiline XFA text now lays out pages and widgets from the final datasets, also on forms without scripts. Undo/redo restores the layout; synthetic engine/tool tests cover 10,500 characters, save/reopen and print-ready PDF text. Text longer than a page uses the existing read-only page text fallback, with a warning. 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.
@@ -170,6 +182,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.
@@ -497,6 +512,9 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. |
| 2026-10-13 | minor | 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-15 | minor | M12 #2569: growable multiline XFA text reflows after edits; datasets, undo/redo, save/reopen and print covered; oversized fields remain read-only page text; 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. |
@@ -519,7 +537,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));
}
}
+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);
}
+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();
+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)))?;
+225 -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");
@@ -4249,3 +4250,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 }
+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));
+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();
+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 {
+5
View File
@@ -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 {
+19 -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)?;
}
@@ -487,7 +504,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)
}
+216
View File
@@ -1056,6 +1056,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 +1211,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);
}
+65 -16
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!(
@@ -4657,30 +4661,73 @@ trailer << /Root 1 0 R >>
/// 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 << /FunctionType 0 /Domain [0 1] /Range [0 1] /Size [{SAMPLES}] /BitsPerSample 8 /Filter /FlateDecode /Length {} >> stream\n", samples.len())
.as_bytes(),
);
pdf.extend_from_slice(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 (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(render_first_page(pdf));
});
let page =
rx.recv_timeout(std::time::Duration::from_secs(10)).expect("painting a large sampled function must not rebuild a hash map each time");
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
@@ -6186,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;
@@ -6202,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");
+31 -1
View File
@@ -452,6 +452,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,
@@ -729,6 +732,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(),
@@ -1733,7 +1737,9 @@ impl DocView {
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));
}
@@ -5580,6 +5586,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);
+21 -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);
}
}
+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 => {}
}
+8 -1
View File
@@ -52,7 +52,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 +166,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
+49 -4
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 {
@@ -667,7 +675,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 +1009,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 +1062,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
}
}
+1
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 لا صفحات تطابق هذه الاختيارات
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
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 Няма страници, отговарящи на този избор
Can't render this file because it contains an unexpected character in line 8 and column 16.
+1
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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
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
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.
+1
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
Can't render this file because it contains an unexpected character in line 6 and column 60.
+1
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
Can't render this file because it contains an unexpected character in line 6 and column 39.
+1
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
Can't render this file because it contains an unexpected character in line 7 and column 44.
+1
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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
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 条件に一致するページはありません
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
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 Ешбір бет бұл таңдауларға сәйкес келмейді
Can't render this file because it contains an unexpected character in line 825 and column 9.
+1
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
Can't render this file because it contains an unexpected character in line 404 and column 9.
+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.
+1
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
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
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 Страницы не соответствуют этим выбору
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
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
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.
+1
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
Can't render this file because it contains an unexpected character in line 824 and column 9.
+1
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 Жодна сторінка не відповідає вибраним параметрам
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
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
Can't render this file because it contains an unexpected character in line 490 and column 12.
+1
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 所选条件没有匹配的页面
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.
+2 -1
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())]);
}
}
+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);
}
+1 -2
View File
@@ -784,8 +784,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
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 => {}
}
+23
View File
@@ -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();
@@ -1496,6 +1502,23 @@ 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");
+10 -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");
}
+16
View File
@@ -4225,9 +4225,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();
}
}
+3
View File
@@ -839,6 +839,9 @@ fn files_and_quit_from_the_operating_system() {
let queue = std::rc::Rc::new(std::cell::RefCell::new(vec![OsEvent::Open(vec![path.to_string_lossy().into_owned()])]));
let q = queue.clone();
let mut h = harness(move |app| app.os_events = Some(Box::new(move || q.borrow_mut().drain(..).collect())));
// The file is read on a worker thread, after however many frames that takes: it must still
// be there until the tab has opened.
support::wait_until(&mut h, "the file the operating system opened", |app| !app.views.is_empty());
std::fs::remove_file(&path).ok();
assert_eq!(h.state().views.len(), 1, "the file opens in a tab");
h.get_by_label_contains(&name);
+5
View File
@@ -18,6 +18,11 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o
- **Form data**: Acrobat's XML (`xfdf:original` keeps names that aren't XML names), CSV and
tab-delimited text (a row of names, a row of values).
Plain AcroForm XML imports retain all direct text around comments and processing instructions,
including CDATA and character references. Nested-element and XFA interpretation are unchanged.
XML and XFDF exports preserve tabs and line breaks in field names and comment metadata with
character references, so XML attribute normalization cannot change their identities.
Import replaces comments with the same `/NM` (and drops the replaced comment's pop-up), draws
appearances PdfCraft can draw, and sets values through the form's own checks (formats,
validation, recalculation); read-only fields take imported values, as in Acrobat. XML is read
+5 -1
View File
@@ -197,6 +197,9 @@ fn esc(s: &str) -> String {
'<' => o.push_str("&lt;"),
'>' => o.push_str("&gt;"),
'"' => o.push_str("&quot;"),
// XML 1.0 §3.3.3 normalizes literal attribute whitespace, but not references.
'\t' => o.push_str("&#9;"),
'\n' => o.push_str("&#10;"),
'\r' => o.push_str("&#13;"),
c if (c as u32) < 0x20 && c != '\n' && c != '\t' => {}
c => o.push(c),
@@ -1114,7 +1117,8 @@ fn import_table(doc: &mut Document, text: &str) -> Result<Report, DataError> {
.map(|c| {
let name =
c.attributes().find(|a| a.name() == "original").map(|a| a.value().to_string()).unwrap_or_else(|| c.tag_name().name().to_string());
(name, vec![c.text().unwrap_or("").to_string()])
// Comments and processing instructions do not end a field's character data.
(name, vec![c.children().filter(|node| node.is_text()).filter_map(|node| node.text()).collect()])
})
.collect()
} else if t.lines().next().is_some_and(|l| l.contains('\t')) {
+133
View File
@@ -142,6 +142,108 @@ fn values(doc: &Document) -> Vec<(String, Vec<String>)> {
fields(doc).into_iter().map(|f| (f.name, f.value)).collect()
}
#[test]
fn xml_exports_preserve_whitespace_in_field_names() {
let names = ["Line\nName", "Line\tName", "Line\rName", "Line\r\nName", "\t Leading\nTrailing \t", "Line Name"];
let value = "value\twith\nlines\r\nand <XML> & \"quotes\"";
let mut src = one_field();
pdfcraft_forms::delete_field(&mut src, "Name").unwrap();
for (index, name) in names.iter().enumerate() {
let placeholder = format!("field{index}");
add_field(&mut src, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: true }, Some(&placeholder)).unwrap();
// Existing PDFs can have leading/trailing whitespace even though add_field trims it.
let field = fields(&src).into_iter().find(|f| f.name == placeholder).unwrap();
src.update_dict(field.obj, |d| d.set(b"T".to_vec(), PdfString::text(name))).unwrap();
set_value(&mut src, name, &FieldValue::Text(format!("{index}: {value}"))).unwrap();
}
let source_bytes = write_full(&src, &SaveOptions::default()).unwrap();
let src = Document::open(Arc::new(source_bytes.clone())).unwrap();
for format in [Format::Xml, Format::Xfdf] {
let exported = if format == Format::Xfdf { export_xfdf(&src, false, true, "form.pdf") } else { export_data(&src, format) };
let xml = roxmltree::Document::parse(&exported).unwrap();
let exported_names: Vec<_> = if format == Format::Xfdf {
xml.descendants().filter(|n| n.has_tag_name("field")).map(|n| n.attribute("name").unwrap()).collect()
} else {
xml.root_element()
.children()
.filter(|n| n.is_element())
.map(|n| n.attribute(("http://ns.adobe.com/xfdf-transition/", "original")).unwrap())
.collect()
};
assert_eq!(exported_names, names, "{format:?}: {exported}");
let mut dst = Document::open(Arc::new(source_bytes.clone())).unwrap();
for name in names {
set_value(&mut dst, name, &FieldValue::Text("replace me".into())).unwrap();
}
let report = import(&mut dst, exported.as_bytes()).unwrap();
assert_eq!(report.fields, names.len(), "{format:?}: {report:?}");
assert!(report.rejected.is_empty(), "{report:?}");
let saved = write_full(&dst, &SaveOptions::default()).unwrap();
let reopened = Document::open(Arc::new(saved)).unwrap();
assert_eq!(values(&reopened), values(&src), "{format:?}");
}
assert_eq!(write_full(&src, &SaveOptions::default()).unwrap(), source_bytes, "export must not change the source PDF");
}
#[test]
fn xfdf_exports_preserve_whitespace_in_comment_attributes() {
let mut src = one_field();
let shape = Shape::Note { at: [100.0, 500.0], icon: pdfcraft_annot::NoteIcon::Comment };
let names = ["note\tline\nidentity", "note line identity"];
let author = "Ada\tLovelace\nReview";
let subject = "Subject\twith\nlines\r\nand & quotes\"";
for name in names {
add_annotation(
&mut src,
&NewAnnotation { page: 0, style: Style::default_for(&shape), shape: shape.clone(), contents: name.into(), author: author.into() },
&Meta { id: name.into(), date: None },
)
.unwrap();
}
let annotation_refs = |doc: &Document| {
let page = pdfcraft_model::pages(doc)[0].obj;
doc.get(page)
.as_dict()
.unwrap()
.get(b"Annots")
.map(|a| doc.resolve(a))
.unwrap()
.as_array()
.unwrap()
.iter()
.filter_map(Object::as_ref)
.filter(|r| doc.get(*r).as_dict().unwrap().name(b"Subtype") == Some(b"Text"))
.collect::<Vec<_>>()
};
for annotation in annotation_refs(&src) {
src.update_dict(annotation, |d| d.set(b"Subj".to_vec(), PdfString::text(subject))).unwrap();
}
let source_bytes = write_full(&src, &SaveOptions::default()).unwrap();
let mut dst = Document::open(Arc::new(source_bytes)).unwrap();
let file = "form\twith\nlines.pdf";
let xfdf = export_xfdf(&dst, true, false, file);
let xml = roxmltree::Document::parse(&xfdf).unwrap();
let notes: Vec<_> = xml.descendants().filter(|n| n.has_tag_name("text")).collect();
assert_eq!(notes.iter().map(|n| n.attribute("name").unwrap()).collect::<Vec<_>>(), names);
for note in notes {
assert_eq!(note.attribute("title"), Some(author));
assert_eq!(note.attribute("subject"), Some(subject));
}
assert_eq!(xml.descendants().find(|n| n.has_tag_name("f")).unwrap().attribute("href"), Some(file));
let before = annotation_refs(&dst);
let report = import(&mut dst, xfdf.as_bytes()).unwrap();
assert_eq!(report.comments, names.len());
assert_eq!(annotation_refs(&dst), before, "reimport must replace the original comments");
let saved = write_full(&dst, &SaveOptions::default()).unwrap();
let reopened = Document::open(Arc::new(saved)).unwrap();
for (annotation, name) in annotation_refs(&reopened).iter().zip(names) {
let dict = reopened.get(*annotation);
assert_eq!(text_of(&reopened, dict.as_dict().unwrap(), b"NM").as_deref(), Some(name));
assert_eq!(text_of(&reopened, dict.as_dict().unwrap(), b"T").as_deref(), Some(author));
assert_eq!(text_of(&reopened, dict.as_dict().unwrap(), b"Subj").as_deref(), Some(subject));
}
}
#[test]
fn xfdf_round_trips_comments_and_values() {
let src = filled();
@@ -1011,3 +1113,34 @@ fn xfdf_import_keeps_text_around_xml_comments_and_instructions() {
assert_eq!(values(&reopened)[0].1, want, "{xml_text}");
}
}
#[test]
fn xml_form_import_preserves_text_around_comments_and_instructions() {
for (body, expected) in [
("left<!-- ignored -->right", "leftright"),
("<!-- first -->left<?note ignored?>middle<!-- last -->right", "leftmiddleright"),
("<![CDATA[<left>]]><!-- ignored -->&amp;right", "<left>&right"),
(" left <!-- ignored --> right ", " left right "),
("<!-- empty --><?note ignored?>", ""),
] {
let mut original = one_field();
let root = original.root().unwrap();
original.update_dict(root, |dict| dict.set(b"PCUnknown".to_vec(), PdfString::text("retained"))).unwrap();
set_value(&mut original, "Name", &FieldValue::Text("replace me".into())).unwrap();
let source = write_full(&original, &SaveOptions::default()).unwrap();
let mut doc = Document::open(Arc::new(source.clone())).unwrap();
let xml = format!("<?xml version=\"1.0\"?><fields><Name>{body}</Name></fields>");
let report = import(&mut doc, xml.as_bytes()).unwrap();
assert_eq!(report.fields, 1);
assert!(report.rejected.is_empty());
for saved in [write_full(&doc, &SaveOptions::default()).unwrap(), write_incremental(&doc, &SaveOptions::default()).unwrap()] {
let reopened = Document::open(Arc::new(saved)).unwrap();
let name = fields(&reopened).into_iter().find(|f| f.name == "Name").unwrap();
let want = if expected.is_empty() { Vec::new() } else { vec![expected.to_string()] };
assert_eq!(name.value, want, "XML body: {body}");
let root = reopened.get(reopened.root().unwrap());
assert_eq!(root.as_dict().unwrap().get(b"PCUnknown").unwrap().as_string().unwrap().to_text(), "retained");
}
assert_eq!(fields(&original).into_iter().find(|f| f.name == "Name").unwrap().value, vec!["replace me"]);
}
}
+7
View File
@@ -48,6 +48,13 @@ ignore = [
# winprint's fmt-derive (craft-print's Windows spooler), so it runs at compile time on Windows
# and is not in the binary.
{ id = "RUSTSEC-2024-0370", reason = "compile-time proc macro of winprint's fmt-derive (craft-print Windows printing, #1107)" },
# zvariant, zbus_names and zvariant_derive are unmaintained (no vulnerability, and no fixed
# release to move to). They reach the tree as the D-Bus stack behind `accesskit_unix` ->
# `atspi`, which is Linux accessibility only, and as a dev-dependency of pdfcraft-platform.
# Revisit when the accesskit/zbus chain moves to a maintained successor.
{ id = "RUSTSEC-2026-0370", reason = "zvariant unmaintained; Linux a11y stack via accesskit_unix" },
{ id = "RUSTSEC-2026-0371", reason = "zbus_names unmaintained; Linux a11y stack via accesskit_unix" },
{ id = "RUSTSEC-2026-0372", reason = "zvariant_derive unmaintained; Linux a11y stack via accesskit_unix" },
]
# egui's default UI fonts (Ubuntu-Light, Hack, emoji-icon-font): font licences on the AGENTS.md
+5
View File
@@ -1,6 +1,7 @@
# Where PdfCraft falls short of Acrobat Pro
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (gap 12: reading order is scored, not just word F1) · **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-12 · **Last updated:** 2026-10-13 · **Change:** minor (gap 14: Vietnamese catalog shipped; Hindi/Indonesian/Korean and Arabic RTL remain) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (gap 6: #1910 Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: #1398 stamps read upright on rotated pages; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -140,6 +141,9 @@ update the numbers.
(#1398). Another batch keeps a spaced XFA radio value in the datasets (#1319), replaces only the
selected pages when they are not adjacent (#1305), and leaves unrelated page streams in place
when a figure is marked decorative (#1324). The rest of the backlog is untriaged.
- **Fix verified (2026-10-14):** raw-deflate fallback returns a decoding-limit error instead of
accepting an empty partial stream when a misleading zlib header triggers a retry. Synthetic
filter and COS stream regressions cover refusal at the limit and decoding within it.
- **Impact:** every one is a user who lost trust. Crash reports outrank feature work
(`AGENTS.md` §4).
- **Estimate:** 45–80 h (≈ 0.2–0.4 h per issue). **Doc:** this file.
@@ -379,6 +383,7 @@ update the numbers.
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | Gap 12: the text oracle scores reading order as well as word F1, which is a bag of words and cannot see order; the first measurement separated 18 improvements from 6 regressions in a change F1 called unchanged on 26 of 29 files. 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-15 | patch | Gap 24: record localization and installers as partial (unusable Chinese, Telugu and Arabic; unsigned Linux packages) |
| 2026-10-13 | minor | Gap 6: Organize Pages selects and reveals the current page on open, including after an earlier grid selection elsewhere (#1910). Estimates unchanged. |
| 2026-10-12 | minor | Gap 6: rubber stamps, explicit image rectangles and PDF-page stamps read upright on rotated pages (#1398). Estimates unchanged. |
+14 -3
View File
@@ -1,5 +1,11 @@
# PdfCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (PR #2624: integrated upstream d8b27058 while retaining this fix and upstream progress; 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 (M6: preserve XML/XFDF attribute whitespace; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M12 #2569: growable multiline XFA text reflows after edits; datasets, undo/redo, save/reopen and print covered; oversized fields remain read-only page text; estimates unchanged)
> **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, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -98,7 +104,7 @@ total matches the parity estimate.
| M | Milestone | Plan size (h) | Done | Remaining (h, 2026-10-10) | Notes |
|---|---|---|---|---|---|
| M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates |
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs |
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Refreshed against upstream `99941daa` (2026-10-15). Missing: ≥ 250 tests, own image codecs |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
@@ -118,14 +124,15 @@ total matches the parity estimate.
| M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.<br>Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.<br>Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.<br>Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
@@ -180,6 +187,9 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. |
| 2026-10-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-15 | minor | M12 #2569: growable multiline XFA text reflows after edits; datasets, undo/redo, save/reopen and print covered; oversized fields remain read-only page text; estimates unchanged. |
| 2026-10-15 | minor | M9 #1895: generic appearance rewriting deferred; separate validation overlays preserve original artwork, exports and printing. Baked captions remain unchanged (#79 open). Estimates unchanged. |
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
@@ -235,5 +245,6 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| 2026-10-10 | minor | M3 records the System appearance completion after #881; estimates unchanged |
| 2026-10-10 | minor | Re-judged convert and share with the cross-app gate rule (only a workflow that can't be completed at all blocks): partial, not blocking → stage alpha |
| 2026-10-10 | minor | Added the alpha gate (six core workflows; convert and share fails → pre-alpha, 40–70 h to alpha) and rank 0 in the upcoming milestones |
| 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-10 | major | Created from ROADMAP.md §Milestones, §Critical path, §Risks and §Where we're lacking. Merged the duplicated M1, M2, M3 and M14 rows, re-measured Done and Remaining, added Current focus and the ranked upcoming milestones |
+10 -10
View File
@@ -118,8 +118,8 @@ milestone = "M1"
status = "shipped"
commands = []
tools = []
evidence = ["crates/filters/src/lib.rs::chain_three_filters_with_predictor", "crates/filters/src/lzw.rs::spec_example", "crates/filters/src/predictor.rs::png_hand_vectors"]
notes = ""
evidence = ["crates/filters/src/lib.rs::chain_three_filters_with_predictor", "crates/filters/src/lzw.rs::spec_example", "crates/filters/src/predictor.rs::png_hand_vectors", "crates/filters/src/lib.rs::raw_flate_fallback_preserves_output_limits", "crates/cos/src/object.rs::raw_flate_fallback_streams_preserve_the_caller_limit"]
notes = "Raw-deflate fallback preserves output limits in strict and tolerant decoding, including misleading zlib headers. Image codecs remain outside this general-purpose filter implementation."
[[feature]]
id = "core.image-filters"
@@ -4160,8 +4160,8 @@ milestone = "M5"
status = "shipped"
commands = ["comment.export"]
tools = ["doc_export_data"]
evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
notes = ""
evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/xfdf/src/tests.rs::fdf_round_trips_values_and_comments", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xfdf_exports_preserve_whitespace_in_comment_attributes"]
notes = "XFDF exports retain tabs and line breaks in comment identities and metadata."
[[feature]]
id = "comment.export-selected"
@@ -4692,8 +4692,8 @@ milestone = "M6"
status = "shipped"
commands = ["form.export_data"]
tools = ["doc_export_data"]
evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
notes = "XFDF, FDF; XFA forms also export their data as XML or XDP (form.xfa-data)"
evidence = ["crates/xfdf/src/tests.rs::xfdf_round_trips_comments_and_values", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xml_exports_preserve_whitespace_in_field_names"]
notes = "XFDF, FDF; XFA forms also export their data as XML or XDP (form.xfa-data); XML/XFDF exports retain tabs and line breaks in field identities through character references."
[[feature]]
id = "form.data-xml-csv-txt"
@@ -4704,8 +4704,8 @@ milestone = "M6"
status = "shipped"
commands = ["form.export_data", "form.import_data"]
tools = ["doc_export_data", "doc_import_data"]
evidence = ["crates/xfdf/src/tests.rs::form_data_as_xml_csv_and_text", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text"]
notes = "Acrobat's XML (xfdf:original), CSV, tab-delimited"
evidence = ["crates/xfdf/src/tests.rs::form_data_as_xml_csv_and_text", "crates/automation/tests/automation.rs::comments_and_form_data_travel_as_xfdf_fdf_and_text", "crates/xfdf/src/tests.rs::xml_form_import_preserves_text_around_comments_and_instructions", "crates/xfdf/src/tests.rs::xml_exports_preserve_whitespace_in_field_names"]
notes = "Acrobat's XML (xfdf:original), CSV, tab-delimited; Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Nested-element and XFA interpretation remain outside this flat exchange format. XML/XFDF exports retain tabs and line breaks in field identities through character references."
[[feature]]
id = "form.merge-to-spreadsheet"
@@ -5812,8 +5812,8 @@ milestone = "M8"
status = "partial"
commands = []
tools = ["doc_audit_redactions"]
evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "apps/pdfcraft-cli/tests/audit.rs::audit_rectangles_match_the_tool_in_top_left_points"]
notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover over less than 40% of both a line's box and its words (a short value in the middle of a long line) is still not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools."
evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/audit/src/tests.rs::a_cover_over_one_word_of_a_long_line_is_found", "crates/audit/src/tests.rs::a_box_grazing_a_word_is_not_flagged", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "apps/pdfcraft-cli/tests/audit.rs::audit_rectangles_match_the_tool_in_top_left_points"]
notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover hiding half of any one word counts too (#2662), so a box over one number in a long line, or an unapplied mark over it, is found; a sliver under half of every word it touches and under 40% of the line is not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools."
[[feature]]
id = "protect.remove-hidden-on-close"
+1 -1
View File
File diff suppressed because one or more lines are too long
+22
View File
@@ -22,6 +22,25 @@ use std::sync::{Arc, LazyLock, OnceLock};
/// A storage for the components of colors.
pub type ColorComponents = SmallVec<[f32; 4]>;
std::thread_local! {
// PdfCraft patch (34): see `tint_transform_runs`.
static TINT_TRANSFORM_RUNS: core::cell::Cell<u64> = const { core::cell::Cell::new(0) };
}
/// PdfCraft patch (34): how many colours this thread has run through a Separation or DeviceN
/// tint transform, for diagnostics and tests. An image converted through patch (28)'s lookup
/// table runs it once per possible sample value, not once per pixel. Per thread, so a caller
/// counting its own render is not disturbed by renders on other threads.
pub fn tint_transform_runs() -> u64 {
TINT_TRANSFORM_RUNS.with(core::cell::Cell::get)
}
/// PdfCraft patch (34): count `colours` runs of a tint transform (called once per batch).
fn count_tint_transform_runs(colours: usize) {
let colours = u64::try_from(colours).unwrap_or(u64::MAX);
TINT_TRANSFORM_RUNS.with(|runs| runs.set(runs.get().saturating_add(colours)));
}
/// An RGB color with an alpha channel.
#[derive(Debug, Copy, Clone)]
pub struct AlphaColor {
@@ -857,6 +876,7 @@ impl Separation {
impl ToRgb for Separation {
fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> {
count_tint_transform_runs(input.len());
let evaluated = input
.iter()
.flat_map(|n| {
@@ -911,6 +931,8 @@ impl DeviceN {
impl ToRgb for DeviceN {
fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> {
let colours = input.len().checked_div(usize::from(self.num_components));
count_tint_transform_runs(colours.unwrap_or(0));
let evaluated = input
.chunks_exact(self.num_components as usize)
.flat_map(|n| {