diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index 90b821c1f8..14609784ac 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -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 = "25fc94c915d23bf12495ad3ce97fce9065e9a7349c6b7f455b16d2a9524b8754"
+sha256 = "da86ae2d0c8ed2c976f9faa6a6e4f21b18fc76e5ab3638fc7bc6f69d1893b404"
# 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 = "e24d49b04ff0248489cf7345b2c13bf6b07a1dd5b5dddbcde5584511ad5165de"
+sha256 = "45f2c18b50a2f7879a3aa81112107642f4b396340503d61cad703747d6189a0d"
[[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 = "09bcc5e86c217f4354354db41c8e0a8d631ae874571edeabbcc366b61f404b57"
+sha256 = "be39c1b72ee171eef693759d882cf711929fa3dcddb87bd15830e19dc575b841"
[[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 = "4a5c035e920ce52df20f1bcdee57b5d66fa3ffed0dae6d368d9f6f3b90cae790"
+sha256 = "3dd24819053808bd1ccfeeceef2ae70aedfe3bbb571c0d936928aa81f5955202"
[[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 = "201342519f0e715a88779e342026f695dd7e3960621ab8cab3689966b4221684"
+sha256 = "13d767888304b286163a2b7672584ce92b4119fbb22955258e6467485b29c7b5"
[[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 = "4124a45304f320c0a693d8fb8730177f033156b87bf9763ddcbdd010fd4ee0dd"
+sha256 = "f173c035f6e2a723d69b6bfa9b2b00cbfc653bed830f93d28b309e0dfebd7bb9"
[[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 = "75c1975894c457f159380754dfdf2fde9a0a411d0faa766322ad62f3ad5dd6b3"
+sha256 = "7aa0cb381b2d382b012c6fbc661a9f5cd0e2a253b4399296a39132c9ab767370"
[[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 = "092508b34e6f87f48968aca6d158c0ba809c5250e43868e0ae1701c43351cf47"
+sha256 = "4da28646b2e1136951d1a8dd16a09b76ac29175245942a01937666ee7e42f7cc"
[[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 = "513627b2fb47af26350bb060b4c95d1072a0a841fc1aa3a386294a059a7790f7"
+sha256 = "52ca6321dc2161791ee0ce4e0abfaaf43423704671c282b8aad57d3dc44c6fa9"
[[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 = "20269acc8ad2ab87954f7f9a07e2d8efbb45d9f88d9698143bb2c7f6dd4d0071"
+sha256 = "2b30e6b8b5458a37d7cbb639c6821b65ea1a1fadc745766736104aba835af8e0"
[[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 = "7f6e40c06f1a73ca66dab2999a6130be769a5a6f5e4428bbb7ae90a51cd0846d"
+sha256 = "afefb3776c225e8a74d593cfaac8310c6828f429253e10464afc1230de809b4a"
[[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 = "f6cd9b7571a91a64dad5a1505c6f92996f15325ecb8c2660842adbdf2d682f65"
+sha256 = "d165d80b38ac764d9c836684c93debac742ab9044e546b1a516e35a7c86cab67"
[[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 = "61c6ee348f9e677197c4088e7969ef395a1a136c37beb5461bee979a9da3cfbe"
+sha256 = "8e0aef0dc46f436576494f57c9a6bdc36d342644eb8e494f0f6856a9b5d6f73b"
[[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 = "bf923e2d70670f3a64bf22ee682686aec7bf369a464568fdc7b2c987a8c86098"
+sha256 = "9418b5b093eb7e6a9b40a911a88ea81b3505bd9c889d9cf9f62efa5ee7d5b593"
[[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 = "0735d4c9c28e717bd63b6a360dd371eec74ffe03384a1319c93770059c1b89cd"
+sha256 = "b81f5326237bcad2d0fce4059510f965b1f79e48d06e0ca876190db9af8dd6e5"
[[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 = "422b5ebf083a818b9be6bcad42d9c6c5a3c9a86fa18cdcdda84affe422b2b27a"
+sha256 = "aa9be42c9f774c8e3f05ef348bb99835d0759c55c691c9dca70b9429608a87b8"
[[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 = "bb01672cf2f673a75a75d33e3ad67dca837d8f1ba072993504737e3446cc4e43"
+sha256 = "a500169e8b601a652908c18cec34edf1bbc6c963a9aff8b426483881a08b54d5"
[[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 = "648e4b9534157ae0f84906ed169fe390d52cfdd3cf276643e0bf6db783937732"
+sha256 = "bd0f0638e85a3469eebf7063301457742b1b2ff525bec1562c5e79b3bc92d06e"
[[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 = "b698bf8de8264764a0205c879cc8273aa2878e89b00c52664f2d500ab8d32a93"
+sha256 = "cd44a7e7420c06ed41100a5eee6c9fbb21f77c29d51b928875bc02975aa061a1"
[[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 = "ae80faf0682dcdf64d6f2b5b4411fbd47475a0a969d9eaebcc5fd2c8bdb3a876"
+sha256 = "f94f7287af0811c05c83c45e0b7871eff4fe9dd72cf045eb976a5ba0a23458a9"
[[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 = "43af5371ca4b32341221296cb338d78754993b59f57648b59a29da190655c316"
+sha256 = "a22c1ed30e5302294c68cfefc0722a1d100bd394019f68b4cae878e28217be3d"
[[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 = "fa2ba35b15455d97b771d4ce7a61d9dd8eb6e6ffed7aa5ada6bdc2a026fd3512"
+sha256 = "0075d7d9435776e4b51179615757f5cbb604f51489ed49db922c54e9249007c1"
[[asset]]
diff --git a/Cargo.lock b/Cargo.lock
index 4e661341dc..c6f4f49d69 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3938,7 +3938,7 @@ dependencies = [
[[package]]
name = "pdfcraft"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"craft-quit",
"eframe",
@@ -3963,7 +3963,7 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3975,7 +3975,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -3986,7 +3986,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3997,7 +3997,7 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"base64 0.22.1",
"flate2",
@@ -4018,7 +4018,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cli"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"base64 0.22.1",
"pdfcraft-audit",
@@ -4034,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",
@@ -4059,7 +4059,7 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"encoding_rs",
"hayro-syntax",
@@ -4075,7 +4075,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"aes",
"md-5",
@@ -4087,7 +4087,7 @@ dependencies = [
[[package]]
name = "pdfcraft-edit"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"hayro-syntax",
"kurbo 0.13.1",
@@ -4102,7 +4102,7 @@ dependencies = [
[[package]]
name = "pdfcraft-engine"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"chrono",
"craft-docpath",
@@ -4141,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",
@@ -4158,7 +4158,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -4168,7 +4168,7 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4180,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",
@@ -4204,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",
@@ -4221,7 +4221,7 @@ dependencies = [
[[package]]
name = "pdfcraft-optimize"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"image",
"pdfcraft-annot",
@@ -4234,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",
@@ -4249,7 +4249,7 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"async-channel",
"futures-lite",
@@ -4259,7 +4259,7 @@ dependencies = [
[[package]]
name = "pdfcraft-preflight"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -4268,7 +4268,7 @@ dependencies = [
[[package]]
name = "pdfcraft-print"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"craft-print",
"pdfcraft-content",
@@ -4279,7 +4279,7 @@ dependencies = [
[[package]]
name = "pdfcraft-redact"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4295,7 +4295,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"bytemuck",
"flate2",
@@ -4314,7 +4314,7 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"aes",
"aws-lc-rs",
@@ -4348,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",
@@ -4396,7 +4396,7 @@ dependencies = [
[[package]]
name = "pdfcraft-web"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"eframe",
"egui",
@@ -4408,7 +4408,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"base64 0.22.1",
"image",
@@ -4422,7 +4422,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfdf"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
diff --git a/Cargo.toml b/Cargo.toml
index 27fd003e89..ec89098021 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"
@@ -21,39 +21,39 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.
# 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"
diff --git a/README.md b/README.md
index bd33ad23be..2a65b66457 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
PdfCraft
- The PDF workbench; an open-source, clean-room reimplementation of Adobe Acrobat, rebuilt in pure Rust.
+ The PDF workbench; an open-source, modern PDF reader and editor, built in pure Rust.
Read, organize, combine, split and secure PDFs in a fast, native app, written in Rust from the ground up.
macOS · Windows · Linux · FreeBSD · the web
@@ -417,16 +417,16 @@ Logs, environment variables and other development notes are in [docs/development
## What's next
-PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, at parity with Acrobat Pro.
+PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, for professional and everyday workflows.
-**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most of Acrobat Pro's offline features exist in some form (57% of all tracked features, 94% of the must-haves). For everyday work (viewing, commenting, filling and signing forms, organizing pages) it is about 55% of the way to replacing Acrobat; against all of Acrobat Pro, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: no Office-to-PDF, and Word export that loses layout.
+**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most essential offline PDF features exist in some form (57% of the features tracked in [docs/target-app-parity.md](docs/target-app-parity.md), 94% of core essentials). For everyday work (viewing, commenting, filling and signing forms, organizing pages) readiness is about 55%; across all professional workflows, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: no office-document-to-PDF, and .docx export that loses layout.
- **Good today:** viewing and search; organizing, combining and splitting; most kinds of comment; filling and authoring forms (with sandboxed JavaScript); passwords, redaction and sanitizing; basic digital signatures; printing; the Accessibility Checker; agent control through the CLI and MCP.
- **Still borrowed:** pages are drawn by the `hayro` crate while our own renderer is built.
-- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, Office import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms.
-- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been compared with Acrobat side by side.
+- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, office document import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms.
+- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been benchmarked side by side.
-**Next, in order:** Office-to-PDF and a Word export that keeps layout, the reported bugs and the Chinese interface font, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish.
+**Next, in order:** office-document-to-PDF and a .docx export that keeps layout, the reported bugs and the Chinese interface font, a fidelity harness against a reference renderer, editing existing content, then professional workflows (OCR, office formats, Preflight, signatures, accessibility tagging) and 1.0 polish.
The summary is in **[ROADMAP.md](ROADMAP.md)**; the full assessment by dimension and area is in [docs/target-app-parity.md](docs/target-app-parity.md), the ranked gaps in [docs/gaps.md](docs/gaps.md), and the milestones in [docs/roadmap.md](docs/roadmap.md).
@@ -581,8 +581,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and PdfCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
-Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. PdfCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.
-
Made by the ArtCraft team and community.
diff --git a/ROADMAP.md b/ROADMAP.md
index c895ed2c3d..907beb98a4 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,10 +2,19 @@
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; 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 (M6: CSV tab-containing headers; estimates unchanged)
+> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
+> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M4, #1919: repeated page indices accumulate rotations; M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
+> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged)
+> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the pages into the drop gap; `page_duplicate` takes `at`; 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 (M6: AcroForm XML text; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
+> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; 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 (M4: indirect page rotations; M13: CLI check visits each directory once through aliases and cycles; 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-16 | **Last updated:** 2026-10-16 | **Change:** minor (M10 #1201 partial: lossless image Decode; 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)
@@ -160,12 +169,30 @@ 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-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged.
+- **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged.
+- **2026-10-16 (M12, #1645):** CLI info, text and render use the engine's dynamic XFA layout rather than placeholder pages. Synthetic two-page and encrypted-form regressions cover counts, text, pixels, copy permissions and unchanged input bytes. Estimates unchanged.
+
+- **2026-10-18 (M4, PR #1919 review):** repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged.
+- **2026-10-15 (M4, #1919 refresh):** Integrated upstream `99941daa` while retaining the indirect page rotation 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-18 (M3, #3149):** Page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged.
+- **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged.
+- **2026-10-17 (M4, #2997):** Dragging page thumbnails in the Organize grid with Ctrl held (Option on macOS) drops copies at the gap instead of moving the pages: a "+" badge sits by the pointer, the copies land at the gap selected, and one undo step ("Duplicate pages") takes them out again. `page_duplicate` takes `at` (1-based, where the first copy goes; left out, after the last of them) so agents can drive the same thing, and the UI control channel's `ui.drag` modifiers now reach gestures that read the input state. 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-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged.
+- **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged.
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
+- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of the public ISCIII form, with its button, is page 7 of 7). Acrobat Pro shows the form as 7 pages with that page last, as here; when printing, Acrobat keeps that page but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet. Synthetic regression tests. Estimates unchanged.
+- **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged.
+- **2026-10-15 (M6, CSV tab-containing headers):** CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Synthetic export/import and incremental/full save regressions cover header tabs, quotes and line breaks, plus ordinary TSV controls. Formula-cell policy is unchanged. Estimates unchanged.
+- **2026-10-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-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-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. Synthetic regression test; the page area's `occur min` (a once-only page of its own) is still not laid out, recorded in `form.xfa-dynamic`. Estimates unchanged.
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
+- **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are unchanged.
- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged.
- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor).
- **2026-10-12 (M2, inline-image boundaries):** Unfiltered inline images with known sample layouts keep pixel bytes that look like the `EI` terminator when content is rewritten. Regression tests cover row padding, colour-space and parameter aliases, masks and malformed sizes; classic and packed saved PDFs retain the pixels in independent readers and renderers. Filtered and resource-defined layouts retain the existing tolerant heuristic. Estimates unchanged.
@@ -174,9 +201,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-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values.
+- **2026-10-13 (M4, indirect page rotation):** Rotation queries and edits resolve indirect integer values on pages and their page-tree ancestors. Turning a page already at 270° by 90° now saves it at 0°, including when its rotation is inherited. Synthetic full/incremental save-and-reopen tests cover nested inheritance, sibling preservation, negative/large angles and invalid-value fallback. 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.
@@ -502,12 +529,20 @@ Newest first: the date, what moved, and the new overall estimate.
## Revision history
+**2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged.
+
| Date | Change | Summary |
|---|---|---|
+| 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. |
+| 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. |
+| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. |
| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. |
| 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged |
| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. |
| 2026-10-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged |
+| 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. |
+| 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. |
+| 2026-10-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. Estimates unchanged. |
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
| 2026-10-13 | minor | M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. Estimates unchanged. |
@@ -528,7 +563,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.
diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs
index 0e24800d39..be2df77355 100644
--- a/apps/pdfcraft-cli/src/main.rs
+++ b/apps/pdfcraft-cli/src/main.rs
@@ -298,14 +298,21 @@ fn info(args: &[String]) -> Result<(), CliError> {
let path = *positional(args).first().ok_or("info: missing file")?;
let bytes = read(path)?;
let password = flag(args, "--password");
- let info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?;
+ let mut info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?;
+ let pdf_pages = info.pages.len();
+ let acroform_fields = info.fields.len();
// Dynamic XFA forms: say what laying the template out gives (what the app shows).
let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic))
.then(|| {
let mut s = pdfcraft_engine::Session::new();
let id = s.open("info.pdf", None, bytes.clone(), password).ok()?;
let d = s.get(id)?;
- d.xfa.as_ref().map(|x| serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }))
+ d.xfa.as_ref().map(|x| {
+ info = d.info.clone();
+ // The in-memory layout is an appended revision, not the file being inspected.
+ info.file_size = bytes.len();
+ serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })
+ })
})
.flatten();
let json = serde_json::json!({
@@ -313,6 +320,7 @@ fn info(args: &[String]) -> Result<(), CliError> {
"pdf_version": info.pdf_version,
"file_size": info.file_size,
"pages": info.pages.len(),
+ "pdf_pages": pdf_pages, "acroform_fields": acroform_fields,
"first_page_pt": info.pages.first().map(|p| [p.width, p.height]),
"title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator,
"encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript,
@@ -321,6 +329,7 @@ fn info(args: &[String]) -> Result<(), CliError> {
"bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(),
"links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(),
"transitions": info.transitions.len(),
+ "custom_properties": info.custom.len(),
"page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(),
"warnings": info.warnings,
});
@@ -348,7 +357,19 @@ fn text(args: &[String]) -> Result<(), CliError> {
}
}
let password = flag(args, "--password");
- let bytes = read(path)?;
+ let mut bytes = read(path)?;
+ // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the
+ // permissions password, a document that doesn't allow copying gives no text.
+ let mut session = pdfcraft_engine::Session::new();
+ if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) {
+ // The engine's hint is about editing in the app; on the command line the way in is
+ // `--password`.
+ doc.check_copying()
+ .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?;
+ if doc.xfa.is_some() {
+ bytes = doc.display.clone();
+ }
+ }
let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: password.map(Arc::from), ..Default::default() });
// A document the renderer could not open (wrong or missing password, unparseable file) has
// no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask
@@ -359,15 +380,6 @@ fn text(args: &[String]) -> Result<(), CliError> {
return Err("text: the document could not be parsed".into());
}
}
- // Printing the text is copying it, so ask what `text_extract` asks (#1645): without the
- // permissions password, a document that doesn't allow copying gives no text.
- let mut session = pdfcraft_engine::Session::new();
- if let Some(doc) = session.open(path, None, bytes, password).ok().and_then(|id| session.get(id)) {
- // The engine's hint is about editing in the app; on the command line the way in is
- // `--password`.
- doc.check_copying()
- .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?;
- }
let pages: Vec = match selected_page {
Some(page) => vec![page],
None => (0..r.page_count()).collect(),
@@ -588,10 +600,15 @@ fn render(args: &[String]) -> Result<(), CliError> {
// The app renders a display copy that draws comments missing their /AP streams; the CLI
// must agree with it (and with independent viewers), while keeping the file it reads intact.
let bytes = read(path)?;
- let mut r = PageRenderer::new(
- pdfcraft_engine::display_bytes_for(bytes.clone(), flag(args, "--password")),
- RenderConfig { password: flag(args, "--password").map(Arc::from), reject_oversize: true, ..Default::default() },
- );
+ let password = flag(args, "--password");
+ let display = if inspect(bytes.clone(), password).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) {
+ let mut session = pdfcraft_engine::Session::new();
+ let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?;
+ session.get(id).ok_or("render: the document could not be opened")?.display.clone()
+ } else {
+ pdfcraft_engine::display_bytes_for(bytes, password)
+ };
+ let mut r = PageRenderer::new(display, RenderConfig { password: password.map(Arc::from), reject_oversize: true, ..Default::default() });
let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
if let Some(e) = p.error {
return Err(e.into());
diff --git a/apps/pdfcraft-cli/tests/xfa.rs b/apps/pdfcraft-cli/tests/xfa.rs
new file mode 100644
index 0000000000..1b5ff7bc9d
--- /dev/null
+++ b/apps/pdfcraft-cli/tests/xfa.rs
@@ -0,0 +1,136 @@
+//! CLI page commands must use the same dynamic XFA layout as the app and tools (#1645).
+
+use std::path::PathBuf;
+use std::process::{Command, Output};
+use std::sync::Arc;
+
+const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
+
+fn fixture(test: &str) -> PathBuf {
+ let xdp = r#"
+
+
+
+
+
+
+ First XFA page
+ Ada
+
+ Second XFA page
+
+ "#;
+ let objects = [
+ "<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> >>".to_string(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".into(),
+ "<< /Length 0 >>\nstream\n\nendstream".into(),
+ format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()),
+ ];
+ let mut bytes = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, object) in objects.iter().enumerate() {
+ offsets.push(bytes.len());
+ bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
+ }
+ let xref = bytes.len();
+ bytes.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n");
+ for offset in offsets {
+ bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
+ }
+ bytes.extend_from_slice(format!("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
+ let dir = std::env::temp_dir().join(format!("pdfcraft-cli-xfa-{test}-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("form.pdf");
+ std::fs::write(&path, bytes).unwrap();
+ path
+}
+
+fn success(output: &Output) {
+ assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
+}
+
+#[test]
+fn info_reports_the_laid_out_pages_and_fields() {
+ let path = fixture("info");
+ let original = std::fs::read(&path).unwrap();
+ let output = Command::new(BIN).arg("info").arg(&path).output().unwrap();
+ success(&output);
+ let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
+ assert_eq!(info["xfa_layout"]["pages"], 2, "the synthetic form lays out into two pages");
+ assert_eq!(info["pages"], 2, "top-level page count must agree with the app");
+ assert_eq!(info["fields"], 1);
+ assert_eq!(info["pdf_pages"], 1);
+ assert_eq!(info["acroform_fields"], 0);
+ assert_eq!(info["first_page_pt"], serde_json::json!([200.0, 300.0]));
+ assert_eq!(info["file_size"], original.len());
+ assert_eq!(std::fs::read(path).unwrap(), original, "inspection must not write the layout to the input");
+}
+
+#[test]
+fn text_reads_the_layout_including_a_page_absent_from_the_placeholder() {
+ let path = fixture("text");
+ let output = Command::new(BIN).arg("text").arg(&path).output().unwrap();
+ success(&output);
+ let text = String::from_utf8(output.stdout).unwrap();
+ assert!(text.contains("First XFA page") && text.contains("Second XFA page"), "{text:?}");
+ let output = Command::new(BIN).arg("text").arg(&path).args(["--page", "2"]).output().unwrap();
+ success(&output);
+ let text = String::from_utf8(output.stdout).unwrap();
+ assert!(text.contains("Second XFA page") && !text.contains("First XFA page"), "{text:?}");
+}
+
+#[test]
+fn render_matches_the_engine_layout_on_the_second_page() {
+ use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
+ let path = fixture("render");
+ let original = std::fs::read(&path).unwrap();
+ let mut session = pdfcraft_engine::Session::new();
+ let id = session.open("form.pdf", None, Arc::new(original.clone()), None).unwrap();
+ let doc = session.get(id).unwrap();
+ assert_eq!(doc.info.pages.len(), 2);
+ assert!(doc.text_lines(1).iter().any(|line| line.text.contains("Second XFA page")));
+ let mut renderer = PageRenderer::new(doc.display.clone(), RenderConfig::default());
+ let expected = renderer.render(RenderRequest { page: 1, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
+ assert!(expected.error.is_none(), "{:?}", expected.error);
+ assert!(expected.rgba.as_chunks::<4>().0.iter().filter(|[r, _, _, a]| *r < 128 && *a > 0).count() > 10, "the layout has visible ink");
+ let out = path.with_extension("pam");
+ let output = Command::new(BIN).arg("render").arg(&path).args(["--page", "2", "--dpi", "72", "--out"]).arg(&out).output().unwrap();
+ success(&output);
+ let actual = std::fs::read(out).unwrap();
+ let header = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", expected.width, expected.height);
+ assert!(actual.starts_with(header.as_bytes()));
+ assert_eq!(&actual[header.len()..], &expected.rgba[..]);
+ assert_eq!(std::fs::read(path).unwrap(), original);
+}
+
+#[test]
+fn a_laid_out_form_keeps_the_original_copy_permissions() {
+ let path = fixture("permissions");
+ let mut doc = pdfcraft_cos::Document::open(Arc::new(std::fs::read(&path).unwrap())).unwrap();
+ doc.set_encryption(&pdfcraft_cos::NewEncryption {
+ algorithm: pdfcraft_cos::Algorithm::Aes256,
+ user_password: "openme",
+ owner_password: "boss",
+ permissions: -17,
+ encrypt_metadata: true,
+ seed: [17; 32],
+ })
+ .unwrap();
+ let original = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap();
+ std::fs::write(&path, &original).unwrap();
+ let output = Command::new(BIN).arg("text").arg(&path).args(["--password", "openme"]).output().unwrap();
+ assert!(!output.status.success());
+ assert!(output.stdout.is_empty());
+ assert!(String::from_utf8_lossy(&output.stderr).contains("don't allow copying"));
+ let output = Command::new(BIN).arg("text").arg(&path).args(["--password", "boss", "--page", "2"]).output().unwrap();
+ success(&output);
+ assert!(String::from_utf8_lossy(&output.stdout).contains("Second XFA page"));
+ let output = Command::new(BIN).arg("info").arg(&path).args(["--password", "openme"]).output().unwrap();
+ success(&output);
+ let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
+ assert_eq!(info["pages"], 2);
+ assert_eq!(info["encrypted"], true);
+ assert_eq!(info["file_size"], original.len());
+ assert_eq!(std::fs::read(path).unwrap(), original);
+}
diff --git a/apps/pdfcraft-web/src/main.rs b/apps/pdfcraft-web/src/main.rs
index a46a4674b9..73d14f4a6c 100644
--- a/apps/pdfcraft-web/src/main.rs
+++ b/apps/pdfcraft-web/src/main.rs
@@ -70,6 +70,10 @@ fn main() {
app.host_save = Some(bridge.save_fn());
app.host_dirty = Some(bridge.dirty_fn());
bridge.post_ready();
+ } else if query_param("host").as_deref() == Some("parent") {
+ eframe::web_sys::console::warn_1(
+ &"PdfCraft: ?host=parent needs a framing page on the same origin; saves download instead".into(),
+ );
}
Ok(Box::new(app))
}),
@@ -130,7 +134,8 @@ async fn fetch_bytes(url: &str) -> Result, String> {
/// - `pdfcraft:dirty` (to the parent): `{ dirty }` whenever unsaved work appears or goes away.
/// - `pdfcraft:failed` (to the parent): `{ error }` when the app couldn't start.
///
-/// Only the parent window on the app's own origin is heard or answered.
+/// Only the parent window on the app's own origin is heard or answered. With a parent on
+/// another origin there is no bridge: the app runs as if unframed and saves download.
#[cfg(target_arch = "wasm32")]
mod host {
use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure};
@@ -143,7 +148,9 @@ mod host {
}
impl Bridge {
- /// `None` when the page isn't framed (there is no parent to talk to).
+ /// `None` when the page isn't framed (there is no parent to talk to), or when the parent
+ /// is on another origin: messages only go to this origin, so the browser would drop
+ /// every save without an error while the document showed as saved (#2167).
pub fn new() -> Option {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
@@ -151,6 +158,10 @@ mod host {
return None;
}
let origin = window.location().origin().ok()?;
+ // Reading another origin's location throws, which is `None` too.
+ if parent.location().origin().ok()? != origin {
+ return None;
+ }
Some(Self { window, parent, origin })
}
diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs
index 4d9857ce19..721b7da1b3 100644
--- a/apps/pdfcraft/src/main.rs
+++ b/apps/pdfcraft/src/main.rs
@@ -380,6 +380,7 @@ fn app_creator<'a>(
if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string(LEGACY_STORAGE_KEY))) {
app.restore(&json);
}
+ app.window_state = pdfcraft_ui_egui::window_state::WindowState::started_with(cc.storage);
app.integrated_titlebar = integrated;
app.window_controls = window_controls;
app.system_text_scale = text_scale::system();
@@ -565,7 +566,7 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
// egui-wgpu requests only 8192 pixels even when the adapter can render larger surfaces.
// A restored 3440-point window at 250% DPI requests 8600 pixels and otherwise panics in
- // Surface::configure before the app starts. Keep the renderer's other device requirements,
+ // Surface::configure before the app starts. Keep the renderer's other compatible requirements,
// but enable the adapter's actual 2D texture extent (also for smaller/downlevel adapters).
let device_descriptor = std::sync::Arc::clone(&setup.device_descriptor);
setup.device_descriptor = std::sync::Arc::new(move |adapter| {
@@ -606,8 +607,9 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
}
/// Surface textures must fit the device's enabled limits, not just the physical GPU's limits.
-/// Changing only the 2D extent preserves egui-wgpu's backend-specific downlevel requirements.
+/// Clamp egui-wgpu's backend-specific requirements before enabling the adapter's full 2D extent.
fn surface_texture_limits(mut required: eframe::wgpu::Limits, supported: &eframe::wgpu::Limits) -> eframe::wgpu::Limits {
+ required = required.or_worse_values_from(supported);
required.max_texture_dimension_2d = supported.max_texture_dimension_2d;
required
}
@@ -912,16 +914,51 @@ mod tests {
fn surface_limits_preserve_other_requirements_and_never_overrequest() {
for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] {
for extent in [4096, 8192, 16384] {
- let supported = eframe::wgpu::Limits { max_texture_dimension_2d: extent, ..required.clone() };
+ let supported = eframe::wgpu::Limits {
+ max_texture_dimension_2d: extent,
+ max_bind_groups: 8,
+ min_uniform_buffer_offset_alignment: 128,
+ min_storage_buffer_offset_alignment: 128,
+ ..required.clone()
+ };
let enabled = super::surface_texture_limits(required.clone(), &supported);
let mut expected = required.clone();
expected.max_texture_dimension_2d = extent;
assert_eq!(enabled, expected);
assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d);
+ assert!(enabled.check_limits(&supported));
}
}
}
+ #[test]
+ fn surface_limits_fit_v3d_shader_variable_limit() {
+ // Issue #2383: V3D supports 15 variables, below the default request of 16.
+ let required = eframe::wgpu::Limits::default();
+ let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required.clone() };
+ let enabled = super::surface_texture_limits(required.clone(), &supported);
+ assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}");
+ let expected = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required };
+ assert_eq!(enabled, expected);
+ }
+
+ #[test]
+ fn surface_limits_fit_downlevel_maxima_and_minimum_alignments() {
+ let required = eframe::wgpu::Limits::downlevel_webgl2_defaults();
+ let supported = eframe::wgpu::Limits {
+ max_texture_dimension_2d: 2048,
+ max_bind_groups: 2,
+ max_vertex_attributes: 8,
+ min_uniform_buffer_offset_alignment: 512,
+ min_storage_buffer_offset_alignment: 512,
+ ..required.clone()
+ };
+ let enabled = super::surface_texture_limits(required, &supported);
+ assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}");
+ // Larger minimum alignments are weaker capabilities, unlike maximum limits.
+ assert_eq!(enabled, supported);
+ }
+
#[test]
fn a_window_larger_than_the_gpu_limit_gets_a_surface_within_it() {
use eframe::egui_wgpu::winit::surface_fit;
diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs
index f4973aa021..b7f9d62697 100644
--- a/crates/annot/src/lib.rs
+++ b/crates/annot/src/lib.rs
@@ -2282,6 +2282,11 @@ fn line_endings_of(d: &Dict) -> Option> {
/// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the
/// struck text holding `replacement`, grouped with the strikeout (`/IRT` + `/RT /Group`) so they
/// move, list and delete as one. Returns the strikeout's index.
+///
+/// The two annotations need their own `/NM`: XFDF names a reply by the `/NM` of what it replies
+/// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that
+/// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id
+/// from the caller, so the edit stays deterministic.
#[allow(clippy::too_many_arguments)]
pub fn add_text_replacement(
doc: &mut Document,
@@ -2292,6 +2297,7 @@ pub fn add_text_replacement(
strike: &Style,
caret: &Style,
meta: &Meta,
+ caret_meta: &Meta,
) -> Result {
let last = *quads.last().ok_or_else(|| AnnotError::Invalid("select the text to replace first".into()))?;
let s = add_annotation(
@@ -2317,7 +2323,7 @@ pub fn add_text_replacement(
let c = add_annotation(
doc,
&NewAnnotation { page, shape: Shape::Caret { rect }, style: caret.clone(), contents: replacement.into(), author: author.into() },
- meta,
+ caret_meta,
)?;
let (_, cr) = annot_ref(doc, page, c)?;
doc.update_dict(cr, |d| {
diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs
index 2e45b4e4d4..4d3eef632b 100644
--- a/crates/annot/src/links.rs
+++ b/crates/annot/src/links.rs
@@ -178,6 +178,19 @@ fn write_style(d: &mut Dict, s: &LinkStyle) {
d.set(b"H".to_vec(), Object::name(s.highlight.code()));
}
+/// Bytes of `u` as a 7-bit ASCII URI string (§12.6.4.8).
+fn uri_bytes(u: &str) -> Vec {
+ let mut out = Vec::with_capacity(u.len());
+ for &b in u.as_bytes() {
+ if (0x21..=0x7e).contains(&b) {
+ out.push(b);
+ } else {
+ out.extend_from_slice(format!("%{b:02X}").as_bytes());
+ }
+ }
+ out
+}
+
fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), AnnotError> {
match a {
LinkAction::Page(p) => {
@@ -194,7 +207,7 @@ fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), Anno
let mut act = Dict::new();
act.set(b"S".to_vec(), Object::name("URI"));
// URIs are 7-bit ASCII strings (§12.6.4.8).
- act.set(b"URI".to_vec(), PdfString::literal(u.as_bytes().to_vec()));
+ act.set(b"URI".to_vec(), PdfString::literal(uri_bytes(u)));
d.set(b"A".to_vec(), Object::Dict(act));
}
LinkAction::Other(_) => {}
diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs
index faed409abb..d3d448e520 100644
--- a/crates/annot/src/tests.rs
+++ b/crates/annot/src/tests.rs
@@ -588,6 +588,25 @@ fn links_are_added_edited_listed_and_removed() {
assert!(links::add(&mut doc, 0, [0.0, 0.0, 1.0, 1.0], &LinkAction::Page(0), &LinkStyle::default()).is_err(), "too small");
}
+#[test]
+fn link_uri_is_percent_encoded_to_7_bit_ascii() {
+ use crate::links::{self, LinkAction, LinkStyle};
+ let mut doc = fixture();
+ for url in ["https://example.com/ação", "https://example.com/a%20b"] {
+ links::add(&mut doc, 0, [10.0, 10.0, 110.0, 30.0], &LinkAction::Uri(url.into()), &LinkStyle::default()).unwrap();
+ }
+ let doc = reopen(&doc);
+ let urls: Vec = links::list(&doc)
+ .into_iter()
+ .filter_map(|link| match link.action {
+ LinkAction::Uri(url) => Some(url),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(urls, ["https://example.com/a%C3%A7%C3%A3o", "https://example.com/a%20b"]);
+ assert!(urls.iter().all(|url| url.is_ascii()));
+}
+
#[test]
fn link_edit_rejects_non_finite_and_overflowed_rectangles_without_mutating_the_pdf() {
use crate::links::{self, LinkAction, LinkStyle};
@@ -784,7 +803,7 @@ fn replace_text_groups_a_strikeout_and_a_caret() {
let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]];
let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None };
let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None };
- let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r")).unwrap();
+ let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).unwrap();
let doc = reopen(&doc);
let a = list(&doc, 0);
let strike = &a[i];
@@ -792,13 +811,21 @@ fn replace_text_groups_a_strikeout_and_a_caret() {
let caret = a.iter().find(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap();
assert_eq!((text(caret, b"Contents").as_str(), caret.name(b"RT")), ("new words", Some(&b"Group"[..])));
assert!(caret.reference(b"IRT").is_some());
+ // #2666: the pair needs its own `/NM` each. XFDF names a reply by the `/NM` of what it
+ // replies to, so sharing one id writes `inreplyto` equal to the caret's own `name` and the
+ // pair does not survive an export and import.
+ assert_eq!(text(caret, b"NM").as_str(), "r-caret");
+ assert_eq!(text(strike, b"NM").as_str(), "r");
+ assert_ne!(text(caret, b"NM"), text(strike, b"NM"), "the caret must not carry the strikeout's name");
+ let irt = caret.reference(b"IRT").unwrap();
+ assert_eq!(text(doc.get(irt).as_dict().unwrap(), b"NM").as_str(), "r", "the caret replies to the strikeout");
let r = rect(caret);
assert!(r[0] < 200.0 && r[2] > 200.0 && r[1] < 706.0, "at the end of the line, below its baseline: {r:?}");
// Deleting the strikeout takes the caret with it (the group).
let mut doc = doc;
delete_annotation(&mut doc, 0, i).unwrap();
assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret")));
- assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r")).is_err());
+ assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).is_err());
}
#[test]
diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs
index 1e8d71bd1c..8130ed084d 100644
--- a/crates/automation/src/a11y.rs
+++ b/crates/automation/src/a11y.rs
@@ -918,11 +918,16 @@ impl Automation {
let list: Vec = found
.iter()
.map(|(p, c)| {
- let kind = match c.kind {
- pdfcraft_engine::detect::Kind::Text => "text",
- pdfcraft_engine::detect::Kind::CheckBox => "checkbox",
+ let (kind, comb) = match c.kind {
+ pdfcraft_engine::detect::Kind::Text => ("text", None),
+ pdfcraft_engine::detect::Kind::CheckBox => ("checkbox", None),
+ pdfcraft_engine::detect::Kind::Comb { cells } => ("text", Some(cells)),
};
- json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) })
+ let mut v = json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) });
+ if let Some(cells) = comb {
+ v["comb"] = json!(cells);
+ }
+ v
})
.collect();
if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() {
diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs
index 710952d7c2..faefc5fa7c 100644
--- a/crates/automation/src/content.rs
+++ b/crates/automation/src/content.rs
@@ -138,7 +138,7 @@ impl Automation {
.added()
.iter()
.filter(|x| x.page == page)
- .nth((index - 1).max(0) as usize)
+ .nth(usize::try_from(index.saturating_sub(1)).unwrap_or(usize::MAX))
.filter(|_| index >= 1)
.ok_or_else(|| failed(format!("page {} has no added item {index} (see content_list)", page + 1)))?;
Ok((page, index as usize - 1, it.content.clone(), ph))
diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs
index d71a813d29..8e4acdf891 100644
--- a/crates/automation/src/lib.rs
+++ b/crates/automation/src/lib.rs
@@ -283,6 +283,13 @@ impl Automation {
let to = self.position(&a, "to")?;
self.apply(&a, Edit::MovePages { pages, to })?
}
+ "page_reverse" => {
+ let pages = match a.opt_ints("pages")? {
+ Some(_) => self.pages(&a, "pages")?,
+ None => (0..self.doc(&a)?.info.pages.len()).collect(),
+ };
+ self.apply(&a, Edit::ReversePages { pages })?
+ }
"page_insert_blank" => self.insert_blank(&a)?,
"page_insert_file" => self.insert_file(&a)?,
"page_extract" => self.page_extract(&a)?,
@@ -373,7 +380,8 @@ impl Automation {
}
"page_duplicate" => {
let pages = self.pages(&a, "pages")?;
- self.apply(&a, Edit::DuplicatePages { pages })?
+ let at = self.opt_position(&a, "at")?;
+ self.apply(&a, Edit::DuplicatePages { pages, at })?
}
"page_set_box" => self.page_set_box(&a)?,
"page_transition_set" => self.page_transition_set(&a)?,
@@ -1764,16 +1772,21 @@ impl Automation {
let mut used = std::collections::HashSet::new();
for (i, (first, last, bytes)) in parts.iter().enumerate() {
let safe = |t: &str| t.chars().map(|c| if c.is_alphanumeric() || " -_.,()".contains(c) { c } else { '_' }).collect::();
- let file = match titles.iter().find(|t| t.0 + 1 == *first) {
- Some((_, t)) => format!("{stem}-{}.pdf", safe(t)),
- None => format!("{stem}-part{}.pdf", i + 1),
+ let mut base = match titles.iter().find(|t| t.0 + 1 == *first) {
+ Some((_, t)) => format!("{stem}-{}", safe(t)),
+ None => format!("{stem}-part{}", i + 1),
};
+ // A file name is at most 255 bytes: keep the base to 200, cut between characters, which leaves room for ".pdf" and a -N suffix.
+ if let Some((end, _)) = base.char_indices().find(|(at, c)| at + c.len_utf8() > 200) {
+ base.truncate(end);
+ }
+ let file = format!("{base}.pdf");
// Equal titles (or titles that sanitize alike) get -2, -3, ... so no part overwrites another; compared case-insensitively.
- let mut unique = file.clone();
+ let mut unique = file;
let mut n = 1;
while !used.insert(unique.to_lowercase()) {
n += 1;
- unique = format!("{}-{n}.pdf", file.trim_end_matches(".pdf"));
+ unique = format!("{base}-{n}.pdf");
}
let path = child(&dir, &unique);
write_atomic(&path, bytes)?;
@@ -1799,12 +1812,17 @@ impl Automation {
/// A 1-based insertion position (1 = before the first page, n+1 = after the last) → 0-based.
fn position(&self, a: &Args, key: &str) -> Result {
+ self.opt_position(a, key)?.ok_or_else(|| Args::missing(key))
+ }
+
+ /// `position`, but `None` when the key is absent.
+ fn opt_position(&self, a: &Args, key: &str) -> Result> {
+ let Some(at) = a.opt_int(key)? else { return Ok(None) };
let n = self.doc(a)?.info.pages.len();
- let at = a.int(key)?;
if at < 1 || at as usize > n + 1 {
return Err(ToolError::InvalidArgs(format!("{key} must be between 1 and {} (the document has {n} pages)", n + 1)));
}
- Ok(at as usize - 1)
+ Ok(Some(at as usize - 1))
}
fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> {
@@ -2412,6 +2430,9 @@ fn info(d: &Document) -> Value {
"pdf_version": i.pdf_version,
"file_size": i.file_size,
"title": i.title, "author": i.author, "subject": i.subject, "keywords": i.keywords,
+ // Custom document properties (Document Properties ▸ Custom): /Info entries beyond the
+ // ones the spec names, set and cleared with doc_set_info.
+ "custom": i.custom.iter().map(|(name, value)| json!({ "name": name, "value": value })).collect::>(),
"creator": i.creator, "producer": i.producer,
"tagged": i.tagged,
"has_javascript": i.has_javascript,
@@ -3119,4 +3140,23 @@ mod tests {
std::fs::set_permissions(&target, perms).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
+
+ /// A recovered document, or a new one, rotated and then undone still has never been saved:
+ /// `doc_close` refuses it unless told to discard it (#2346 review).
+ #[test]
+ fn closing_a_recovered_or_new_document_after_undo_is_refused() {
+ let mut a = Automation::new();
+ let recovered = a.session.open("Report.pdf", None, Arc::new(numbered_pages(2)), None).unwrap();
+ a.session.mark_recovered(recovered, Some("Report.pdf".into()));
+ let new = a.session.open_new("Combined.pdf", Arc::new(numbered_pages(2))).unwrap();
+ for id in [recovered, new] {
+ json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90}));
+ json(&mut a, "edit_undo", json!({"doc": id.0}));
+ let refused = a.call("doc_close", &json!({"doc": id.0}));
+ assert!(matches!(&refused, Err(e) if e.to_string().contains("unsaved changes")), "{id:?}: {refused:?}");
+ assert!(a.session.get(id).is_some(), "still open");
+ json(&mut a, "doc_close", json!({"doc": id.0, "discard_changes": true}));
+ assert!(a.session.get(id).is_none());
+ }
+ }
}
diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs
index 0425fae2bf..908c8d08a4 100644
--- a/crates/automation/src/tools.rs
+++ b/crates/automation/src/tools.rs
@@ -162,7 +162,7 @@ pub fn tools() -> Vec {
}),
&["doc"],
)),
- t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords; an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.")
+ t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.")
.with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])),
t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).")
.ro()
@@ -230,6 +230,9 @@ pub fn tools() -> Vec {
json!({ "doc": doc(), "pages": pages("to move"), "to": { "type": "integer", "minimum": 1 } }),
&["doc", "pages", "to"],
)),
+ t("page_reverse", "Reverse pages", "Reverse the order of pages among the positions they occupy: the listed pages (at least two), or the whole document when pages is omitted. Undoable.")
+ .cmd("page.reverse")
+ .with(schema(json!({ "doc": doc(), "pages": pages("to reverse (default: all)") }), &["doc"])),
t("page_insert_blank", "Insert a blank page", "Insert a blank page so it becomes page `at`. Size defaults to the neighbouring page. Undoable.")
.cmd("page.insert_blank")
.with(schema(
@@ -828,9 +831,9 @@ pub fn tools() -> Vec {
t("page_replace", "Replace pages", "Replace the content of pages with pages of another PDF (same count). Links, comments, form fields and bookmarks on the original pages stay, as in Acrobat. Undoable.")
.cmd("page.replace")
.with(schema(json!({ "doc": doc(), "pages": pages("to replace"), "path": path_arg(), "from_pages": pages("of the other file, in order (default: its first pages)") }), &["doc", "pages", "path"])),
- t("page_duplicate", "Duplicate pages", "Insert copies of pages after the last of them (fonts and images are shared, not copied). Undoable.")
+ t("page_duplicate", "Duplicate pages", "Insert copies of pages so the first copy becomes page `at`, or after the last of them when `at` is left out (fonts and images are shared, not copied). Undoable.")
.cmd("page.duplicate")
- .with(schema(json!({ "doc": doc(), "pages": pages("to duplicate") }), &["doc", "pages"])),
+ .with(schema(json!({ "doc": doc(), "pages": pages("to duplicate"), "at": { "type": "integer", "minimum": 1 } }), &["doc", "pages"])),
t(
"page_transition_set",
"Set page transitions",
@@ -1104,7 +1107,7 @@ pub fn tools() -> Vec {
t("doc_compare_mark", "Mark differences as comments", "Add the differences from other (older) to doc (newer) as comments in doc: highlights over replaced (blue) and inserted (green) text, notes where text was deleted (red), authored Compare. Undoable.")
.cmd("doc.compare")
.with(schema(json!({ "doc": doc(), "other": { "type": "integer" } }), &["doc", "other"])),
- t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, small squares for check boxes; on scanned pages read from the pixels) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left).")
+ t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, rows of comb boxes, small squares for check boxes; on scanned pages read from the pixels) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left); a row of comb boxes is one text field with comb set to its number of cells, created with the comb flag and that character limit.")
.cmd("form.detect")
.with(schema(json!({ "doc": doc(), "pages": pages("to look at (default: all)"), "add": { "type": "boolean" } }), &["doc"])),
t("form_actions", "Field actions", "Field Properties ▸ Actions: the field's action for each trigger (mouse_up, mouse_down, mouse_enter, mouse_exit, on_focus, on_blur).")
diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs
index f4ed91ccb6..2e0ee7422e 100644
--- a/crates/automation/tests/automation.rs
+++ b/crates/automation/tests/automation.rs
@@ -214,6 +214,98 @@ fn page_transitions_are_set_and_removed_through_the_tools() {
);
}
+/// #1937 through the tools: a damaged file is repaired on open (`doc_info` lists the repair) and
+/// `doc_save` over it writes the rebuilt file, so opening it again needs no repair.
+#[test]
+fn saving_a_repaired_file_puts_the_repair_on_disk() {
+ let dir = workdir("repaired-save");
+ let good = fixture(2);
+ let mut broken = b"%PDF-1.7\n%shifted\n".to_vec();
+ broken.extend_from_slice(&good[b"%PDF-1.7\n".len()..]);
+ std::fs::write(dir.join("broken.pdf"), &broken).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "broken.pdf" }))["doc"].as_u64().unwrap();
+ let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
+ assert!(info["repairs"].as_array().unwrap().iter().any(|r| r.as_str().unwrap_or("").contains("rebuilt")), "{}", info["repairs"]);
+ let saved = ok(&mut a, "doc_save", json!({ "doc": doc }));
+ assert_eq!(saved["incremental"], false, "{saved}");
+ let bytes = std::fs::read(dir.join("broken.pdf")).unwrap();
+ assert_ne!(bytes, broken, "the damaged bytes were written back");
+ let reopened = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap();
+ assert!(reopened.repair_log().is_empty(), "{:?}", reopened.repair_log());
+ let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
+ assert_eq!(info["repairs"].as_array().unwrap().len(), 0, "{}", info["repairs"]);
+ assert_eq!(info["pages"].as_array().unwrap().len(), 2);
+}
+
+/// Custom document properties through the tools: set and cleared with doc_set_info under any
+/// name the spec does not use, listed by doc_info apart from the standard entries, bad names
+/// refused without a change.
+#[test]
+fn custom_document_properties_are_set_listed_and_cleared_through_the_tools() {
+ let dir = workdir("custom-info");
+ std::fs::write(dir.join("a.pdf"), fixture(1)).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "Report" }));
+ ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Project", "value": "Apollo" }));
+ ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Revision", "value": "7" }));
+ let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
+ assert_eq!(info["title"], "Report");
+ let mut custom: Vec<(String, String)> =
+ info["custom"].as_array().unwrap().iter().map(|c| (c["name"].as_str().unwrap().into(), c["value"].as_str().unwrap().into())).collect();
+ custom.sort();
+ assert_eq!(custom, [("Project".to_string(), "Apollo".to_string()), ("Revision".to_string(), "7".to_string())], "{}", info["custom"]);
+ assert!(a.call("doc_set_info", &json!({ "doc": doc, "key": "Bad Name", "value": "x" })).is_err(), "a name with a space is refused");
+ assert!(a.call("doc_set_info", &json!({ "doc": doc, "key": "", "value": "x" })).is_err());
+ ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Project", "value": "" }));
+ let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
+ assert_eq!(info["custom"].as_array().unwrap().len(), 1, "{}", info["custom"]);
+ assert_eq!(info["custom"][0]["name"], "Revision");
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out/custom.pdf" }));
+ let mut b = auto(&dir);
+ let re = ok(&mut b, "doc_open", json!({ "path": "out/custom.pdf" }))["doc"].as_u64().unwrap();
+ let info = ok(&mut b, "doc_info", json!({ "doc": re }));
+ assert_eq!(info["custom"][0]["value"], "7", "{}", info["custom"]);
+}
+
+/// The custom list `doc_info` shows when a file opens is the one it shows after an unrelated
+/// edit: a name value, a blank or NUL-padded string and a number are judged the same way by the
+/// open-time reader and by the engine's refresh.
+#[test]
+fn custom_document_properties_do_not_change_after_an_unrelated_edit() {
+ let dir = workdir("custom-info-stable");
+ let objs = [
+ "<< /Type /Catalog /Pages 2 0 R >>",
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>",
+ "<< /Producer (Test) /Project (Apollo) /Dept /RnD /Blank ( ) /Padded (Lab\\000\\000) /Revision 7 /Empty () /Mixed ( \\000Lab\\000 ) /Pad ( \\000 ) >>",
+ ];
+ let mut pdf = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offsets.push(pdf.len());
+ pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
+ }
+ let xref = pdf.len();
+ pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offsets {
+ pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R /Info 4 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+ std::fs::write(dir.join("custom.pdf"), pdf).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "custom.pdf" }))["doc"].as_u64().unwrap();
+ let opened = ok(&mut a, "doc_info", json!({ "doc": doc }))["custom"].clone();
+ let names: Vec<&str> = opened.as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect();
+ assert_eq!(names, ["Project", "Padded", "Mixed"], "{opened}");
+ assert_eq!(opened[1]["value"], "Lab", "{opened}");
+ assert_eq!(opened[2]["value"], "Lab", "{opened}");
+ ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "Report" }));
+ let edited = ok(&mut a, "doc_info", json!({ "doc": doc }))["custom"].clone();
+ assert_eq!(edited, opened);
+}
+
#[test]
fn autotag_preview_proposes_text_without_changing_the_pdf() {
let dir = workdir("autotag-preview");
@@ -2082,6 +2174,34 @@ fn orphan_form_fields_through_tools() {
assert_ne!(v, "Filled by an agent");
}
+#[test]
+fn reversing_pages_through_tools() {
+ let dir = workdir("reverse");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ ok(&mut a, "page_reverse", json!({ "doc": doc }));
+ assert_eq!(page_text(&mut a, doc), ["Page 3", "Page 2", "Page 1"], "no pages reverses the whole document");
+ ok(&mut a, "page_reverse", json!({ "doc": doc, "pages": [1, 2] }));
+ assert_eq!(page_text(&mut a, doc), ["Page 2", "Page 3", "Page 1"], "only the listed pages swap");
+ ok(&mut a, "edit_undo", json!({ "doc": doc }));
+ assert_eq!(page_text(&mut a, doc), ["Page 3", "Page 2", "Page 1"]);
+ assert!(matches!(a.call("page_reverse", &json!({ "doc": doc, "pages": [4] })), Err(ToolError::InvalidArgs(_))));
+ assert!(matches!(a.call("page_reverse", &json!({ "doc": doc, "pages": [2] })), Err(ToolError::Failed(_))), "one page has nothing to reverse");
+}
+
+#[test]
+fn page_duplicate_at_inserts_the_copies_there() {
+ let dir = workdir("duplicate-at");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ let r = ok(&mut a, "page_duplicate", json!({ "doc": doc, "pages": [2], "at": 1 }));
+ assert_eq!(r["pages"], 4);
+ assert_eq!(page_text(&mut a, doc), ["Page 2", "Page 1", "Page 2", "Page 3"]);
+ // `at` is 1-based and within 1..=pages+1, as for the other page inserts.
+ assert!(matches!(a.call("page_duplicate", &json!({ "doc": doc, "pages": [1], "at": 0 })), Err(ToolError::InvalidArgs(_))), "1-based");
+ assert!(matches!(a.call("page_duplicate", &json!({ "doc": doc, "pages": [1], "at": 6 })), Err(ToolError::InvalidArgs(_))), "past the end");
+}
+
#[test]
fn duplicating_and_cropping_through_tools() {
let dir = workdir("boxes");
@@ -3582,6 +3702,24 @@ fn adding_content_through_tools() {
assert!(matches!(a.call("content_delete", &json!({ "doc": doc, "page": 1, "index": 5 })), Err(ToolError::Failed(_))));
}
+/// An out-of-range `index` (including `i64::MIN`, which overflowed `index - 1`) is a readable
+/// failure for `content_update` and `content_delete`, not a panic.
+#[test]
+fn content_index_out_of_range_is_an_error_not_a_panic() {
+ let dir = workdir("content-index-range");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "hi", "at": [10, 10] }));
+ for index in [i64::MIN, i64::MIN + 1, -1, 0, i64::MAX] {
+ for tool in ["content_delete", "content_update"] {
+ let args = json!({ "doc": doc, "page": 1, "index": index, "text": "x" });
+ let args = if tool == "content_delete" { json!({ "doc": doc, "page": 1, "index": index }) } else { args };
+ assert!(matches!(a.call(tool, &args), Err(ToolError::Failed(_))), "{tool} index {index}");
+ }
+ }
+ assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 1);
+}
+
/// Text styling on an added image is refused (#1331), as image transforms are on text, and the
/// refusal leaves the content, the dirty flag and the undo and redo steps as they were.
#[test]
@@ -3806,6 +3944,35 @@ fn bookmark_split_with_equal_titles_keeps_every_part() {
}
}
+#[test]
+fn bookmark_split_with_long_titles_shortens_the_file_names() {
+ let dir = workdir("organize_long_titles");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ // Two equal titles far over the 255-byte name limit, then a multibyte one (3 bytes a character).
+ let long = format!("Chapter {}", "x".repeat(300));
+ ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": long, "page": 1 }));
+ ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": long, "page": 2 }));
+ ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "章".repeat(100), "page": 3 }));
+ let s = ok(&mut a, "doc_split", json!({ "doc": doc, "bookmarks": true, "out_dir": "parts" }));
+ let files: Vec = s["files"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|f| std::path::Path::new(f["path"].as_str().unwrap()).file_name().unwrap().to_string_lossy().into_owned())
+ .collect();
+ assert_eq!(files.len(), 3, "{files:?}");
+ let distinct: std::collections::HashSet<_> = files.iter().map(|f| f.to_lowercase()).collect();
+ assert_eq!(distinct.len(), 3, "{files:?}");
+ for f in &files {
+ assert!(f.len() <= 255, "{f} is {} bytes", f.len());
+ assert!(f.ends_with(".pdf"), "{f}");
+ assert!(dir.join("parts").join(f).exists(), "{f} missing");
+ }
+ assert!(files[0].starts_with("a-Chapter x"), "{files:?}");
+ assert!(files[2].starts_with("a-章"), "{files:?}");
+}
+
#[test]
fn organizing_with_filters_bookmark_splits_and_extract_options() {
let dir = workdir("organize2");
@@ -4959,6 +5126,41 @@ fn detecting_form_fields_through_tools() {
assert_eq!(f["fields"][1]["value"], "Lisbon", "{f}");
}
+#[test]
+fn detecting_comb_boxes_through_tools() {
+ let mut body = String::from("BT /F1 10 Tf 40 105 Td (Postcode) Tj ET\n");
+ for i in 0..5 {
+ body.push_str(&format!("{} 100 16 20 re S\n", 120 + i * 16));
+ }
+ let objs = [
+ "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>".to_string(),
+ "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(),
+ format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(),
+ ];
+ let mut pdf = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offsets.push(pdf.len());
+ pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
+ }
+ let xref = pdf.len();
+ pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offsets {
+ pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+ let dir = workdir("detect-comb");
+ std::fs::write(dir.join("comb.pdf"), pdf).unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "comb.pdf" }))["doc"].as_u64().unwrap();
+ let r = ok(&mut a, "form_detect_fields", json!({ "doc": doc }));
+ assert_eq!(r["fields"], json!([{ "page": 1, "kind": "text", "comb": 5, "name": "Postcode", "rect": [120.0, 100.0, 200.0, 120.0] }]), "{r}");
+ ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "Postcode": "75001" } }));
+ assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "75001");
+}
+
#[test]
fn comparing_documents_through_tools() {
let dir = workdir("compare");
diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs
index a0b1f56eeb..639edf582c 100644
--- a/crates/cos/src/document.rs
+++ b/crates/cos/src/document.rs
@@ -82,6 +82,8 @@ struct XrefSection {
row_count: usize,
max_num: u32,
has_entries: bool,
+ /// A table subsection was read one number lower than it says ([`subsection_start`]).
+ renumbered: bool,
}
#[derive(Clone, Debug)]
@@ -307,6 +309,10 @@ pub struct Revision {
pub xref_offset: u64,
/// `true` if the section is a cross-reference stream.
pub is_stream: bool,
+ /// `true` if `xref_offset` counts from the `%PDF-` header rather than from the start of the
+ /// file. The two differ only in a file with bytes before its header, and an update appended
+ /// to such a file must count the same way as the section it points back to.
+ pub header_relative: bool,
}
#[derive(Clone, Debug)]
@@ -337,6 +343,40 @@ impl PartialEq for SourceIdentity {
impl Eq for SourceIdentity {}
+/// What a document reads as: its source ([`SourceIdentity`]), its trailer and every object changed
+/// since it was opened. A clone, or a snapshot that undo restores, has the same state, and every
+/// change makes a different one. Changed objects are compared as allocations, which the state
+/// keeps alive, so a later object can't take an address one of them had.
+#[derive(Clone)]
+pub struct ReadState {
+ source: SourceIdentity,
+ trailer: Dict,
+ overlay: BTreeMap,
+}
+
+impl ReadState {
+ /// Whether `doc` reads as this state.
+ pub fn matches(&self, doc: &Document) -> bool {
+ self.source == doc.source_identity()
+ && self.trailer == doc.trailer
+ && self.overlay.len() == doc.overlay.len()
+ && self.overlay.iter().zip(&doc.overlay).all(|((a, x), (b, y))| {
+ a == b
+ && match (x, y) {
+ (Slot::Set(g, o), Slot::Set(h, p)) => g == h && Arc::ptr_eq(o, p),
+ (Slot::Freed(g), Slot::Freed(h)) => g == h,
+ _ => false,
+ }
+ })
+ }
+}
+
+impl std::fmt::Debug for ReadState {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("ReadState").field("changed_objects", &self.overlay.len()).finish_non_exhaustive()
+ }
+}
+
#[derive(Clone)]
pub struct Document {
data: Arc>,
@@ -801,6 +841,12 @@ impl Document {
SourceIdentity(Arc::downgrade(&self.source_identity))
}
+ /// What the document reads as now (see [`ReadState`]): unlike [`Self::source_identity`], it
+ /// changes with every edit, so a value derived from the document can be cached against it.
+ pub fn read_state(&self) -> ReadState {
+ ReadState { source: self.source_identity(), trailer: self.trailer.clone(), overlay: self.overlay.clone() }
+ }
+
pub fn version(&self) -> &str {
&self.version
}
@@ -817,6 +863,19 @@ impl Document {
&self.revisions
}
+ /// Position of the `%PDF-` header in the file: 0 unless bytes come before it.
+ pub fn header_offset(&self) -> usize {
+ self.header_offset
+ }
+
+ /// There is no cross-reference chain to append to: the index was rebuilt from object
+ /// headers because the file's own was unusable (see [`Self::repair_log`]), or the document
+ /// was built from scratch ([`Self::new_empty`]). A save writes such a document in full, even
+ /// when nothing was edited: for a repaired file that is what puts the repair on disk.
+ pub fn reconstructed(&self) -> bool {
+ self.revisions.is_empty()
+ }
+
/// Where each revision of the file ends (oldest first): the byte after the `%%EOF` (and its
/// end-of-line) that closes its cross-reference section. `data[..end]` is that revision as it
/// was saved. A linearized file's first-page section belongs to the revision it precedes, so
@@ -851,9 +910,10 @@ impl Document {
self.repair_log = Arc::new(log);
}
- /// `true` when there are unsaved edits.
+ /// `true` when there are unsaved edits. Removing an `/Encrypt` dictionary written inline in
+ /// the trailer (as MuPDF writes it) changes no object, and is an edit all the same.
pub fn is_modified(&self) -> bool {
- !self.overlay.is_empty()
+ !self.overlay.is_empty() || self.encryption_changed
}
/// Object numbers changed since the document was opened (or last saved).
@@ -1113,7 +1173,19 @@ impl Document {
if let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) {
return Ok(s.clone());
}
- let Object::Stream(s) = &*self.try_get(num)? else { return Err(CosError::MissingObject(num)) };
+ // A file entry that cannot place the stream (one that says it is itself compressed
+ // inside another stream, as in #2367, or points nowhere) is tried by the stream's own
+ // header, as a plain object with a wrong offset is. An edited or freed stream, in the
+ // overlay or in a saved revision's table, is what that says it is: the file's copy
+ // must not come back.
+ let loaded = match self.try_get(num) {
+ Ok(o) if matches!(*o, Object::Stream(_)) => o,
+ _ if self.overlay.contains_key(&num) || matches!(self.xref_entry(num), Some(XrefEntry::Free { .. })) => {
+ return Err(CosError::MissingObject(num));
+ }
+ _ => Arc::new(self.scan_for(num).map(|(id, o)| self.decrypted(id, o)).ok_or(CosError::MissingObject(num))?),
+ };
+ let Object::Stream(s) = &*loaded else { return Err(CosError::MissingObject(num)) };
let n = s.dict.int(b"N").unwrap_or(0).clamp(0, 1_000_000) as usize;
let first = s.dict.int(b"First").unwrap_or(0).max(0) as usize;
let data = s.decoded_within(self.stream_limit)?;
@@ -1345,13 +1417,13 @@ impl Document {
log.push(format!("cross-reference chain loops at offset {off}; stopped"));
break;
}
- let (primary, sect_trailer, is_stream) = match self.read_compact_section(off as usize) {
- Ok(Some(v)) => v,
+ let ((primary, sect_trailer, is_stream), header_relative) = match self.read_compact_section(off as usize) {
+ Ok(Some(v)) => (v, false),
Ok(None) => return Ok(None),
Err(e) if self.header_offset > 0 => {
log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header"));
match self.read_compact_section(off as usize + self.header_offset)? {
- Some(v) => v,
+ Some(v) => (v, true),
None => return Ok(None),
}
}
@@ -1359,7 +1431,10 @@ impl Document {
// materialized reader logs it and the file is rebuilt from object headers (#1656).
Err(e) => return Err(e),
};
- revisions.push(Revision { xref_offset: off, is_stream });
+ revisions.push(Revision { xref_offset: off, is_stream, header_relative });
+ if primary.renumbered {
+ log.push(format!("xref subsection at {off} is numbered from 1 but begins with object 0's entry; read from 0"));
+ }
let Some(rows) = retained_rows.checked_add(primary.row_count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { return Ok(None) };
let Some(bytes) = retained_bytes.checked_add(primary.retained_bytes()).filter(|bytes| *bytes <= MAX_COMPACT_XREF_BYTES) else {
return Ok(None);
@@ -1434,6 +1509,7 @@ impl Document {
let mut has_entries = false;
let mut previous_end = None;
let mut row_count = 0usize;
+ let mut renumbered = false;
loop {
lx.skip_ws();
if lx.eat_keyword(b"trailer") {
@@ -1442,7 +1518,7 @@ impl Document {
if !layout_ok {
return Ok(None);
}
- return Ok(Some((XrefSection { data: XrefSectionData::Table, ranges, row_count, max_num, has_entries }, d, false)));
+ return Ok(Some((XrefSection { data: XrefSectionData::Table, ranges, row_count, max_num, has_entries, renumbered }, d, false)));
}
let (Ok(Object::Int(start)), Ok(Object::Int(count))) = (lx.object(), lx.object()) else {
return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref subsection header".into() });
@@ -1450,6 +1526,9 @@ impl Document {
if !valid_xref_range(start, count) || count > 10_000_000 {
return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() });
}
+ let shifted = subsection_start(data, lx.pos, start, count);
+ renumbered |= shifted != start;
+ let start = shifted;
let Some(next_rows) = usize::try_from(count).ok().and_then(|count| row_count.checked_add(count)) else { return Ok(None) };
if next_rows > MAX_COMPACT_XREF_ROWS {
return Ok(None);
@@ -1602,7 +1681,14 @@ impl Document {
}
let max_num = ranges.iter().filter_map(|range| range.start.checked_add(range.count.saturating_sub(1))).max().unwrap_or(0);
let row_count = row_start;
- let section = XrefSection { data: XrefSectionData::Stream { bytes: Arc::new(raw), widths }, ranges, row_count, max_num, has_entries: false };
+ let section = XrefSection {
+ data: XrefSectionData::Stream { bytes: Arc::new(raw), widths },
+ ranges,
+ row_count,
+ max_num,
+ has_entries: false,
+ renumbered: false,
+ };
let has_entries = section.has_any_entry();
let section = XrefSection { has_entries, ..section };
Ok(Some((section, stream.dict.clone(), true)))
@@ -1636,13 +1722,13 @@ impl Document {
let mut sect = BTreeMap::new();
// Object numbers whose entry in this section was set aside ([`Self::xref_oddities`]).
let mut odd = Vec::new();
- let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut sect, &mut odd) {
- Ok(v) => v,
+ let ((sect_trailer, is_stream), header_relative) = match self.read_section(off as usize, &mut sect, &mut odd, log) {
+ Ok(v) => (v, false),
Err(e) if self.header_offset > 0 => {
log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header"));
sect.clear();
odd.clear();
- self.read_section(off as usize + self.header_offset, &mut sect, &mut odd)?
+ (self.read_section(off as usize + self.header_offset, &mut sect, &mut odd, log)?, true)
}
Err(e) => {
// An unreadable older section loses every object only it locates (the page
@@ -1653,12 +1739,12 @@ impl Document {
return Err(e);
}
};
- revisions.push(Revision { xref_offset: off, is_stream });
+ revisions.push(Revision { xref_offset: off, is_stream, header_relative });
// Hybrid files (§7.5.8.4): the /XRefStm entries supplement the table and replace the
// free entries it gives objects only a cross-reference stream can locate.
if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) {
let mut stm = BTreeMap::new();
- match self.read_section(x as usize, &mut stm, &mut odd) {
+ match self.read_section(x as usize, &mut stm, &mut odd, log) {
Ok(_) => {
for (num, e) in stm {
match sect.get(&num) {
@@ -1693,7 +1779,13 @@ impl Document {
/// Read one section at `off`, adding entries not already known (newer sections win). `odd`
/// collects the object numbers whose entry here is set aside ([`Self::xref_oddities`]).
- fn read_section(&self, off: usize, entries: &mut BTreeMap, odd: &mut Vec) -> Result<(Dict, bool), CosError> {
+ fn read_section(
+ &self,
+ off: usize,
+ entries: &mut BTreeMap,
+ odd: &mut Vec,
+ log: &mut Vec,
+ ) -> Result<(Dict, bool), CosError> {
let data = &self.data;
let mut lx = Lexer::new(data, off);
if lx.eat_keyword(b"xref") {
@@ -1710,6 +1802,11 @@ impl Document {
if !valid_xref_range(start, count) || count > 10_000_000 {
return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() });
}
+ let shifted = subsection_start(data, lx.pos, start, count);
+ if shifted != start {
+ log.push(format!("xref subsection at {off} is numbered from 1 but begins with object 0's entry; read from 0"));
+ }
+ let start = shifted;
for i in 0..count {
lx.skip_ws();
let o = lx.token();
@@ -1920,6 +2017,34 @@ fn generated_id(seed: &[u8; 32]) -> Vec {
out
}
+/// The object number a classic xref subsection really starts at. A common producer bug heads the
+/// table `1 n` and then writes object 0's free entry (`0000000000 65535 f`) as its first row, so
+/// every row would be read one number too high and object 1 as free. Such a subsection is read
+/// from 0 when its second row, read that way, locates an object that is indeed numbered 1. `rows`
+/// is the position just after the subsection header.
+fn subsection_start(data: &[u8], rows: usize, start: i64, count: i64) -> i64 {
+ if start != 1 || count < 2 {
+ return start;
+ }
+ let mut lx = Lexer::new(data, rows);
+ let mut row = || {
+ lx.skip_ws();
+ let offset = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::().ok());
+ lx.skip_ws();
+ let generation = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::().ok());
+ lx.skip_ws();
+ (offset, generation, lx.token() == b"f")
+ };
+ let (Some(0), Some(65535), true) = row() else { return start };
+ let (Some(second), _, false) = row() else { return start };
+ let Some(at) = usize::try_from(second).ok().filter(|at| *at < data.len()) else { return start };
+ let mut obj = Lexer::new(data, at);
+ match (obj.object(), obj.object()) {
+ (Ok(Object::Int(1)), Ok(Object::Int(_))) if obj.eat_keyword(b"obj") => 0,
+ _ => start,
+ }
+}
+
/// Object numbers must fit the representation used by references and the xref map.
/// Validate the whole subsection before adding entries, so neither arithmetic overflow nor
/// truncation can turn a damaged range into entries for unrelated objects.
@@ -1935,6 +2060,28 @@ const MAX_XREF_OBJECTS: usize = 8_388_607;
mod tests {
use super::*;
+ #[test]
+ fn a_read_state_matches_a_clone_and_no_changed_document() {
+ let mut doc = Document::new_empty();
+ let a = doc.add(Object::Int(1));
+ let state = doc.read_state();
+ let snapshot = doc.clone();
+ assert!(state.matches(&doc) && state.matches(&snapshot));
+ // The same value set again is still a change: another allocation.
+ doc.set(a, Object::Int(1));
+ assert!(!state.matches(&doc) && state.matches(&snapshot));
+ let edited = doc.read_state();
+ doc.free(a);
+ assert!(!edited.matches(&doc));
+ let mut trailer_changed = snapshot.clone();
+ trailer_changed.trailer_mut().set(b"Info".to_vec(), Object::Null);
+ assert!(!state.matches(&trailer_changed));
+ // Another document's state never matches, even with the same changes.
+ let mut other = Document::new_empty();
+ other.add(Object::Int(1));
+ assert!(!other.read_state().matches(&snapshot));
+ }
+
/// Build a classic-xref file from object bodies (object i+1 = bodies[i]).
pub(crate) fn build(bodies: &[&str], trailer: &str) -> Vec {
let mut out = b"%PDF-1.7\n".to_vec();
@@ -2085,6 +2232,71 @@ mod tests {
assert!(doc.xref_entry_is_exact(1) && doc.xref_entry_is_exact(2));
}
+ /// A classic table with `/Size` rows and object 0's free entry first, its subsection numbered
+ /// `1 n` instead of `0 n` (#2676): every row is one number off.
+ fn misnumbered_table(start: u32) -> Vec {
+ let bodies = ["<< /Marker (first object) >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Type /Catalog /Pages 2 0 R >>"];
+ let mut out = b"%PDF-1.7
+"
+ .to_vec();
+ let mut offsets = Vec::new();
+ for (i, body) in bodies.iter().enumerate() {
+ offsets.push(out.len());
+ out.extend_from_slice(
+ format!(
+ "{} 0 obj
+{body}
+endobj
+",
+ i + 1
+ )
+ .as_bytes(),
+ );
+ }
+ let xref = out.len();
+ out.extend_from_slice(format!("xref\n{start} 4\n0000000000 65535 f \n").as_bytes());
+ for o in offsets {
+ out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size 4 /Root 3 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
+ out
+ }
+
+ #[test]
+ fn a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0() {
+ let doc = Document::open(Arc::new(misnumbered_table(1))).unwrap();
+ let first = doc.get(ObjRef::new(1, 0)).as_dict().cloned().expect("object 1 is not free");
+ assert!(first.contains(b"Marker"));
+ assert_eq!(doc.root(), Some(ObjRef::new(3, 0)));
+ assert!(doc.repair_log().iter().any(|l| l.contains("numbered from 1")), "{:?}", doc.repair_log());
+ // A correct table reads the same and logs nothing.
+ let fine = Document::open(Arc::new(misnumbered_table(0))).unwrap();
+ assert!(fine.get(ObjRef::new(1, 0)).as_dict().is_some_and(|d| d.contains(b"Marker")));
+ assert!(!fine.repair_log().iter().any(|l| l.contains("numbered from 1")));
+ }
+
+ #[test]
+ fn a_table_that_really_starts_at_1_keeps_its_numbers() {
+ // Object 1 deleted for good (generation 65535) in a subsection that does start at 1: its
+ // second row locates object 2, so nothing is shifted.
+ let mut bytes = b"%PDF-1.7\n".to_vec();
+ let pages = bytes.len();
+ bytes.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n");
+ let catalog = bytes.len();
+ bytes.extend_from_slice(b"3 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
+ let xref = bytes.len();
+ bytes.extend_from_slice(
+ format!(
+ "xref\n0 1\n0000000000 65535 f \n1 3\n0000000000 65535 f \n{pages:010} 00000 n \n{catalog:010} 00000 n \ntrailer\n<< /Size 4 /Root 3 0 R >>\nstartxref\n{xref}\n%%EOF\n"
+ )
+ .as_bytes(),
+ );
+ let doc = Document::open(Arc::new(bytes)).unwrap();
+ assert!(doc.get(ObjRef::new(2, 0)).as_dict().is_some_and(|d| d.name(b"Type") == Some(b"Pages")));
+ assert!(doc.get(ObjRef::new(1, 0)).as_dict().is_none(), "object 1 stays free");
+ assert!(!doc.repair_log().iter().any(|l| l.contains("numbered from 1")));
+ }
+
#[test]
fn opening_an_exhausted_classic_xref_repairs_and_allocates() {
// The extra in-use row need not be loaded to exhaust the next object number.
@@ -2535,6 +2747,93 @@ mod tests {
assert!(old.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"Lang").is_none());
}
+ /// qpdf's `xref-compressed-in-compressed.pdf` in miniature: object 3 is an object stream
+ /// holding object 4, and the cross-reference stream says object 3 itself lives in stream 99.
+ fn compressed_in_compressed_fixture() -> Document {
+ let mut out = b"%PDF-1.5\n".to_vec();
+ let at = |out: &mut Vec, num: u32, body: &str| {
+ let off = out.len();
+ out.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes());
+ off as u16
+ };
+ let o1 = at(&mut out, 1, "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>");
+ let o2 = at(&mut out, 2, "<< /Type /Pages /Kids [] /Count 0 >>");
+ let inner = "<< /Type /Outlines /Count 0 >>";
+ at(&mut out, 3, &format!("<< /Type /ObjStm /N 1 /First 4 /Length {} >>\nstream\n4 0 {inner}\nendstream", 4 + inner.len()));
+ // /W [1 2 1]: object 3 is said to live in stream 99 (which does not exist), 4 in stream 3.
+ let rows: Vec<[u8; 4]> = vec![
+ [0, 0, 0, 255],
+ [1, 0, o1 as u8, 0],
+ [1, 0, o2 as u8, 0],
+ [2, 0, 99, 0],
+ [2, 0, 3, 0],
+ [1, (out.len() >> 8) as u8, out.len() as u8, 0],
+ ];
+ let rows: Vec = rows.concat();
+ let xref = out.len();
+ out.extend_from_slice(
+ format!("5 0 obj\n<< /Type /XRef /W [1 2 1] /Index [0 6] /Size 6 /Root 1 0 R /Length {} >>\nstream\n", rows.len()).as_bytes(),
+ );
+ out.extend_from_slice(&rows);
+ out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref}\n%%EOF\n").as_bytes());
+ Document::open(Arc::new(out)).unwrap()
+ }
+
+ /// An object stream whose own cross-reference entry says it is compressed inside another
+ /// stream (which the format forbids, and which does not exist here: qpdf's
+ /// `xref-compressed-in-compressed.pdf`, #2367) could not be loaded, so every object in it
+ /// read as null and a full save wrote `/Outlines null`. The stream is now found by its own
+ /// header, as a plain object with a wrong offset is.
+ #[test]
+ fn an_object_stream_marked_as_compressed_is_found_by_its_header() {
+ let doc = compressed_in_compressed_fixture();
+ assert_eq!(doc.revisions().len(), 1, "the chain itself reads: {:?}", doc.repair_log());
+ let outlines = doc.get(ObjRef::new(4, 0));
+ assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "{outlines:?}");
+ let saved = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap();
+ let back = Document::open(Arc::new(saved)).unwrap();
+ let catalog = back.get(back.root().unwrap());
+ let outlines = back.resolve(catalog.as_dict().unwrap().get(b"Outlines").unwrap());
+ assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "the full save keeps it: {outlines:?}");
+ }
+
+ /// The header fallback is for file entries only: once the stream is freed (or replaced) in
+ /// the overlay, its objects must not come back from the file's copy of it.
+ #[test]
+ fn a_freed_object_stream_is_not_found_by_its_header() {
+ let doc = compressed_in_compressed_fixture();
+ assert!(doc.get(ObjRef::new(4, 0)).as_dict().is_some());
+ let mut edited = doc.clone();
+ edited.free(ObjRef::new(3, 0));
+ let fresh = edited.object_reader().document;
+ assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0)));
+ let mut replaced = doc.clone();
+ replaced.set(ObjRef::new(3, 0), Object::Int(0));
+ let fresh = replaced.object_reader().document;
+ assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0)));
+ }
+
+ /// The same once the free is saved: the revision's table says the stream is free, and
+ /// neither reading the file back nor rewriting it in full brings its objects back.
+ #[test]
+ fn a_container_freed_in_a_saved_revision_is_not_found_by_its_header() {
+ let mut edited = compressed_in_compressed_fixture();
+ edited.free(ObjRef::new(3, 0));
+ let incremental = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap();
+ let reopened = Document::open(Arc::new(incremental)).unwrap();
+ assert_eq!(reopened.revisions().len(), 2);
+ assert!(matches!(reopened.xref_entry(3), Some(XrefEntry::Free { .. })));
+ assert!(matches!(*reopened.get(ObjRef::new(4, 0)), Object::Null), "{:?}", reopened.get(ObjRef::new(4, 0)));
+ for object_streams in [true, false] {
+ let opts = crate::SaveOptions { object_streams, ..Default::default() };
+ let full = crate::write_full(&reopened, &opts).unwrap();
+ let back = Document::open(Arc::new(full)).unwrap();
+ let catalog = back.get(back.root().unwrap());
+ let outlines = catalog.as_dict().unwrap().get(b"Outlines").map(|o| back.resolve(o));
+ assert!(outlines.as_ref().is_none_or(|o| matches!(**o, Object::Null)), "the full save keeps it out: {outlines:?}");
+ }
+ }
+
#[test]
fn damaged_xref_is_reconstructed() {
let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs
index 213ff341be..152daed2de 100644
--- a/crates/cos/src/lib.rs
+++ b/crates/cos/src/lib.rs
@@ -22,7 +22,7 @@ mod security;
mod writer;
pub use bytes::Bytes;
-pub use document::{Document, Revision, SourceIdentity, XrefEntry};
+pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry};
pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream};
pub use parser::{Lexer, is_whitespace, parse_indirect};
pub use pdfcraft_crypt::{
diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs
index f8d8fd3d80..331f97d443 100644
--- a/crates/cos/src/writer.rs
+++ b/crates/cos/src/writer.rs
@@ -183,7 +183,7 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result,
}
// Reconstructed files have no chain to append to; added or removed encryption must
// rewrite every object; redaction must not leave the old revision behind. All need a full save.
- if doc.revisions().is_empty() || doc.encryption_changed() || doc.full_save_required() {
+ if doc.reconstructed() || doc.encryption_changed() || doc.full_save_required() {
return write_full(doc, opts);
}
let mut doc = doc.clone();
@@ -193,7 +193,11 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result,
// allocated once at its exact size: growing a buffer that holds a large original past a
// guessed capacity would copy the whole file again and keep up to as much again unused.
let newline = !matches!(original.last(), Some(b'\n' | b'\r'));
- let base = (original.len() + usize::from(newline)) as u64;
+ // A file with bytes before its `%PDF-` header may count its offsets from the header. The new
+ // section then has to as well: readers take `/Prev` and `startxref` in one convention, and a
+ // mix sends them into reconstruction (#2364).
+ let shift = if doc.revisions().last().is_some_and(|r| r.header_relative) { doc.header_offset() } else { 0 };
+ let base = (original.len() + usize::from(newline)).saturating_sub(shift) as u64;
let mut out = Vec::new();
let mut offsets: BTreeMap = BTreeMap::new(); // num → (offset, gen, in use)
for (num, generation, obj) in doc.overlay_entries() {
diff --git a/crates/cos/tests/encryption.rs b/crates/cos/tests/encryption.rs
index af92cf67c7..e05161558d 100644
--- a/crates/cos/tests/encryption.rs
+++ b/crates/cos/tests/encryption.rs
@@ -336,3 +336,41 @@ fn an_earlier_revision_opens_with_the_documents_own_credentials() {
assert_eq!(content(¤t.open_with_same_credentials(Arc::new(fixture())).unwrap()), SECRET, "{alg:?}");
}
}
+
+/// `protect`, with the `/Encrypt` dictionary written inline in the trailer, as MuPDF writes it,
+/// instead of as an indirect object.
+fn protect_inline(alg: Algorithm, user: &str, owner: &str) -> Vec {
+ let mut doc = Document::open(Arc::new(fixture())).unwrap();
+ doc.set_encryption(&NewEncryption {
+ algorithm: alg,
+ user_password: user,
+ owner_password: owner,
+ permissions: -1,
+ encrypt_metadata: true,
+ seed: [9; 32],
+ })
+ .unwrap();
+ let dict = doc.get(doc.trailer().reference(b"Encrypt").unwrap()).as_dict().cloned().unwrap();
+ doc.trailer_mut().set(b"Encrypt".to_vec(), Object::Dict(dict));
+ write_full(&doc, &SaveOptions::default()).unwrap()
+}
+
+/// MuPDF writes `/Encrypt` inline in the trailer, so removing it changes no object. It is still
+/// an unsaved change, and saving must write a file that opens without a password (#2661).
+#[test]
+fn removing_an_inline_encrypt_dictionary_leaves_an_unencrypted_file() {
+ for alg in ALL {
+ let bytes = Arc::new(protect_inline(alg, "user", "owner"));
+ assert!(matches!(Document::open(bytes.clone()), Err(CosError::NeedsPassword)), "{alg:?}");
+ let mut doc = Document::open_with_password(bytes, Some("owner")).unwrap();
+ assert!(matches!(doc.trailer().get(b"Encrypt"), Some(Object::Dict(_))), "{alg:?}: inline");
+ assert!(!doc.is_modified(), "{alg:?}");
+ doc.remove_encryption();
+ assert!(doc.is_modified(), "{alg:?}: removing security is an unsaved change");
+ let plain = write_incremental(&doc, &SaveOptions::default()).unwrap();
+ let again = Document::open(Arc::new(plain.clone())).unwrap_or_else(|e| panic!("{alg:?}: {e:?}"));
+ assert!(again.security().is_none() && !again.trailer().contains(b"Encrypt"), "{alg:?}");
+ assert_eq!(content(&again), SECRET, "{alg:?}");
+ assert!(hayro_syntax::Pdf::new(plain).is_ok(), "{alg:?}");
+ }
+}
diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/writer.rs
index ec18b13f36..ac04be2e94 100644
--- a/crates/cos/tests/writer.rs
+++ b/crates/cos/tests/writer.rs
@@ -392,3 +392,67 @@ fn full_save_preserves_unknown_trailer_data() {
assert_eq!(trailer_note(&back), trailer_note(&doc));
assert_eq!(back.trailer().get(b"Version"), doc.trailer().get(b"Version"));
}
+
+/// The `startxref` value at the end of `bytes`.
+fn last_startxref(bytes: &[u8]) -> usize {
+ let at = bytes.windows(9).rposition(|w| w == b"startxref").unwrap();
+ String::from_utf8_lossy(&bytes[at + 9..]).split_whitespace().next().unwrap().parse().unwrap()
+}
+
+#[test]
+fn incremental_saves_of_a_file_with_bytes_before_its_header_count_from_the_header() {
+ // Offsets written before the prefix was added count from `%PDF-`, as in the KillerPDF file
+ // of #2364; the appended section must count the same way as the `/Prev` it points back to.
+ let prefix = vec![b'#'; 128];
+ let as_stream = write_full(&Document::open(Arc::new(fixture())).unwrap(), &SaveOptions::default()).unwrap();
+ for (name, body, marker) in [("table", classic_table(), &b"xref"[..]), ("stream", as_stream, &b" 0 obj"[..])] {
+ let input = [prefix.as_slice(), &body].concat();
+ let mut doc = Document::open(Arc::new(input.clone())).unwrap();
+ assert!(doc.revisions().last().unwrap().header_relative, "{name}");
+ doc.update_dict(ObjRef::new(1, 0), |d| d.set(b"Lang".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"en".to_vec())))).unwrap();
+ let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
+ assert!(saved.starts_with(&input), "{name}");
+
+ let xref = last_startxref(&saved) + prefix.len();
+ let section = saved.get(xref..).unwrap_or_default();
+ assert!(section.windows(marker.len()).position(|w| w == marker).is_some_and(|p| p < 16), "{name}: startxref misses its section");
+ let back = Document::open(Arc::new(saved.clone())).unwrap();
+ assert_eq!(back.revisions().len(), doc.revisions().len() + 1, "{name}");
+ assert!(back.revisions().iter().all(|r| r.header_relative), "{name}: one convention across the chain");
+ let edited = back.get(ObjRef::new(1, 0));
+ assert_eq!(edited.as_dict().unwrap().get(b"Lang").and_then(|o| o.as_string()).map(|s| s.bytes.as_slice()), Some(b"en".as_slice()));
+ assert!(back.xref_entry_is_exact(1), "{name}");
+ assert_qpdf_clean(&saved);
+ }
+}
+
+#[test]
+fn incremental_saves_of_a_file_whose_offsets_count_from_its_first_byte_keep_counting_so() {
+ // The other convention: the same file, but its offsets include the bytes before the header.
+ let prefix = vec![b'#'; 128];
+ let mut input = [prefix.as_slice(), &classic_table()].concat();
+ let xref = input.windows(6).rposition(|w| w == b"\nxref\n").unwrap() + 1;
+ let table: String = String::from_utf8_lossy(&input[xref..])
+ .lines()
+ .map(|line| match line.strip_suffix(" 00000 n ").and_then(|o| o.parse::().ok()) {
+ Some(o) => format!("{:010} 00000 n \n", o + prefix.len()),
+ None if line.parse::().is_ok() => format!("{xref}\n"),
+ None => format!("{line}\n"),
+ })
+ .collect();
+ input.truncate(xref);
+ input.extend_from_slice(table.as_bytes());
+
+ let mut doc = Document::open(Arc::new(input.clone())).unwrap();
+ assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log());
+ assert!(!doc.revisions().last().unwrap().header_relative);
+ doc.update_dict(ObjRef::new(1, 0), |d| d.set(b"Lang".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"en".to_vec())))).unwrap();
+ let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
+ assert!(saved.get(last_startxref(&saved)..).unwrap_or_default().starts_with(b"xref"));
+ let back = Document::open(Arc::new(saved.clone())).unwrap();
+ assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
+ assert_eq!(back.revisions().len(), 2);
+ assert!(back.xref_entry_is_exact(1));
+ // No qpdf check here: qpdf counts every offset from the header in a file with bytes before
+ // it, so it already reconstructs this input before anything is appended.
+}
diff --git a/crates/create/README.md b/crates/create/README.md
index 88c6da2ea5..499081059a 100644
--- a/crates/create/README.md
+++ b/crates/create/README.md
@@ -23,3 +23,9 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2:
line says so (`ESCAPED_NOTE`).
Every function returns a `pdfcraft_cos::Document`; the caller writes it.
+
+Image export and Save Image As apply each lossless image's Decode pair before converting its color components.
+Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples.
+Missing, null or malformed Decode arrays keep the default mapping.
+The existing 16-bit path still uses each sample's high byte. JPEG and mask Decode handling remain limited.
+ICC transforms and calibrated color conversion remain outside this export path.
diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs
index 1034489458..768da4d1f1 100644
--- a/crates/create/src/extract.rs
+++ b/crates/create/src/extract.rs
@@ -126,13 +126,13 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String>
// A stencil mask (`ImageMask true`) has no /SMask or /Mask of its own (8.9.6.2).
let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) };
let key = if mask { None } else { colour_key(doc, s) };
- // Decode arrays: only inversion ([1 0] per component) is honoured, the common case.
- let inverted = s
- .dict
- .get(b"Decode")
- .map(|d| doc.resolve(d))
- .and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64)))
- .unwrap_or(false);
+ // Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images.
+ let inverted = (jpeg || mask)
+ && s.dict
+ .get(b"Decode")
+ .map(|d| doc.resolve(d))
+ .and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64)))
+ .unwrap_or(false);
// In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image
// viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as
// a negative.
@@ -161,6 +161,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String>
(unpack(&data, w, h, space.components(), bpc).ok_or("the image data is shorter than its size says")?, bpc)
};
let n = space.components();
+ let decode = if jpeg || mask { None } else { decode(doc, s, n) };
if alpha.is_none() {
alpha = key.and_then(|k| keyed_alpha(&samples, n, bpc, &k));
}
@@ -168,10 +169,21 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String>
return as_is();
}
let invert = inverted ^ mask; // a stencil mask paints its 0 samples
+ // `unpack` keeps only the high byte at 16 bits, so Decode uses an effective maximum of 255 too.
let max = (1u32 << bpc.min(8)) - 1;
let mut rgb = Vec::with_capacity(w * h * 3);
for px in samples.chunks_exact(n) {
+ let decoded = |i: usize| {
+ decode.as_ref().and_then(|pairs| pairs.get(i)).map(|&[lo, hi]| {
+ let t = f64::from(px.get(i).copied().unwrap_or(0)) / f64::from(max);
+ // Avoid overflowing hi - lo for finite endpoints with opposite signs.
+ (1.0 - t) * lo + t * hi
+ })
+ };
let v = |i: usize| {
+ if let Some(value) = decoded(i) {
+ return (value.clamp(0.0, 1.0) * 255.0).round() as u8;
+ }
let x = (px.get(i).copied().unwrap_or(0) as u32 * 255 / max) as u8;
if invert { 255 - x } else { x }
};
@@ -180,8 +192,12 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String>
Space::Rgb => rgb.extend_from_slice(&[v(0), v(1), v(2)]),
Space::Cmyk => rgb.extend_from_slice(&cmyk(v(0), v(1), v(2), v(3))),
Space::Indexed(base, hival, table) => {
- // Indexed samples outside the declared range use the nearest endpoint (ISO 32000-1, 8.6.6.3).
- let i = px.first().copied().unwrap_or(0).min(*hival) as usize;
+ // Explicit Decode maps into index units, rounded then clipped (8.6.6.3).
+ // Without Decode, retain raw indices rather than normalizing to hival.
+ let i = match decoded(0) {
+ Some(value) => value.round().clamp(0.0, f64::from(*hival)) as usize,
+ None => px.first().copied().unwrap_or(0).min(*hival) as usize,
+ };
let k = base.components();
let e = table.get(i * k..i * k + k).unwrap_or(&[0, 0, 0, 0][..k]);
rgb.extend_from_slice(&match **base {
@@ -198,6 +214,24 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String>
png(w as u32, h as u32, &rgb, alpha.as_deref()).map(|p| ("png", p))
}
+/// The explicit Decode intervals for a lossless colour image (8.9.5.2, Table 90).
+///
+/// A malformed array (not an array, the wrong length, a non-number or non-finite entry) gives
+/// the default mapping, so the image still exports.
+fn decode(doc: &Document, s: &Stream, n: usize) -> Option> {
+ let value = doc.resolve(s.dict.get(b"Decode")?);
+ let values = value.as_array()?;
+ if Some(values.len()) != n.checked_mul(2) {
+ return None;
+ }
+ let endpoint = |value: &Object| doc.resolve(value).as_f64().filter(|v| v.is_finite());
+ let mut pairs = Vec::with_capacity(n);
+ for [lo, hi] in values.as_chunks::<2>().0 {
+ pairs.push([endpoint(lo)?, endpoint(hi)?]);
+ }
+ Some(pairs)
+}
+
/// Width and height as sizes, when both are positive and the image is at most `MAX_PIXELS`.
fn size(w: i64, h: i64) -> Option<(usize, usize)> {
let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?);
diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs
index a42ec84fa8..5717977b1e 100644
--- a/crates/create/src/lib.rs
+++ b/crates/create/src/lib.rs
@@ -5,6 +5,7 @@
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
mod extract;
+mod upright_image;
pub use extract::{ExtractedImage, ImageExport, extract_images, image_file};
use pdfcraft_fonts::{literal, win_ansi, wrap};
@@ -528,7 +529,7 @@ pub fn source_kind(name: &str, bytes: &[u8]) -> Option {
/// Detect the image format from its bytes; a TIFF may hold several pages.
fn embed(name: &str, bytes: &[u8]) -> Result, CreateError> {
if bytes.starts_with(&[0xFF, 0xD8]) {
- Ok(vec![jpeg(name, bytes)?])
+ Ok(vec![jpeg(name, &upright_image::upright(bytes))?])
} else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Ok(vec![png_image(name, bytes)?])
} else if is_tiff(bytes) {
diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs
index 993c38d9f3..6356105699 100644
--- a/crates/create/src/tests.rs
+++ b/crates/create/src/tests.rs
@@ -124,6 +124,18 @@ fn text_is_wrapped_and_paginated() {
assert!(content(p.last().unwrap()).contains("(After a form feed) Tj"), "a form feed starts a page");
}
+#[test]
+fn text_keeps_leading_spaces_and_tabs() {
+ let doc = reopen(&from_text("t", " indented\n\ttabbed\nplain", LETTER, 11.0).unwrap());
+ let p = pages(&doc);
+ let c = doc.resolve(p[0].get(b"Contents").unwrap());
+ let Object::Stream(s) = &*c else { panic!() };
+ let content = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned();
+ assert!(content.contains("( indented) Tj"), "{content}");
+ assert!(content.contains("( tabbed) Tj"), "{content}");
+ assert!(content.contains("(plain) Tj"), "{content}");
+}
+
/// Text Helvetica can't draw is refused with its line and character, never set as `?`; text
/// PdfCraft generates writes such characters as [U+XXXX] and says so.
#[test]
@@ -875,6 +887,193 @@ fn indexed_hival_keeps_existing_short_palette_fallback() {
assert_eq!(exported_png_pixels(&bytes), vec![255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
}
+/// Save and reopen a synthetic image before checking both export entry points.
+fn decode_fixture(dict: Dict, samples: Vec) -> (Document, pdfcraft_cos::ObjRef) {
+ let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
+ with_image(&mut doc, |_| Stream::from_raw(dict, samples));
+ let doc = reopen(&doc);
+ let page = pdfcraft_model::pages(&doc)[0].clone();
+ let res = doc.resolve(page.dict.get(b"Resources").unwrap());
+ let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap());
+ let image = xo.as_dict().unwrap().iter().next().unwrap().1.as_ref().unwrap();
+ (doc, image)
+}
+
+fn assert_decode_pixels(dict: Dict, samples: Vec, expected: &[u8]) {
+ let (doc, image) = decode_fixture(dict, samples);
+ let out = extract_images(&doc, &[0], 0);
+ assert!(out.skipped.is_empty(), "{:?}", out.skipped);
+ assert_eq!(out.images.len(), 1);
+ assert_eq!(exported_png_pixels(&out.images[0].data), expected);
+ let (ext, bytes) = image_file(&doc, image).unwrap();
+ assert_eq!(ext, "png");
+ assert_eq!(exported_png_pixels(&bytes), expected);
+}
+
+fn decode_array(values: &[f64]) -> Object {
+ Object::Array(values.iter().copied().map(Object::Real).collect())
+}
+
+#[test]
+fn lossless_decode_maps_each_component_before_colour_conversion() {
+ for (space, samples, decode, expected) in [
+ ("DeviceRGB", vec![0, 64, 255], vec![1., 0., 0., 1., 0., 1.], [255, 64, 255]),
+ ("DeviceRGB", vec![0, 64, 255], vec![0., 1., 1., 0., 0., 1.], [0, 191, 255]),
+ ("DeviceGray", vec![0], vec![0.2, 0.8], [51, 51, 51]),
+ ("DeviceGray", vec![128], vec![-1., 2.], [129, 129, 129]),
+ ("DeviceCMYK", vec![0, 0, 0, 0], vec![0., 1., 1., 0., 0., 1., 0., 1.], [255, 0, 255]),
+ ] {
+ let mut dict = image_dict(1, 1, space, 8);
+ dict.set(b"Decode".to_vec(), decode_array(&decode));
+ assert_decode_pixels(dict, samples, &expected);
+ }
+}
+
+#[test]
+fn lossless_decode_scales_packed_samples_by_their_bit_depth() {
+ let mut dict = image_dict(4, 1, "DeviceGray", 2);
+ dict.set(b"Decode".to_vec(), decode_array(&[0.2, 0.8]));
+ assert_decode_pixels(dict, vec![0x1b], &[51, 51, 51, 102, 102, 102, 153, 153, 153, 204, 204, 204]);
+}
+
+#[test]
+fn lossless_decode_identity_and_defaults_keep_depth_and_row_padding() {
+ for (bpc, samples, expected) in [
+ (1, vec![0x7f, 0xff], vec![0, 0, 0, 255, 255, 255]),
+ (2, vec![0x7f, 0xbf], vec![85, 85, 85, 170, 170, 170]),
+ (4, vec![0x5f, 0xaf], vec![85, 85, 85, 170, 170, 170]),
+ (8, vec![85, 170], vec![85, 85, 85, 170, 170, 170]),
+ (16, vec![85, 255, 170, 0], vec![85, 85, 85, 170, 170, 170]),
+ ] {
+ for decode in [None, Some(Object::Null), Some(decode_array(&[0., 1.]))] {
+ let mut dict = image_dict(1, 2, "DeviceGray", bpc);
+ if let Some(decode) = decode {
+ dict.set(b"Decode".to_vec(), decode);
+ }
+ assert_decode_pixels(dict, samples.clone(), &expected);
+ }
+ }
+}
+
+#[test]
+fn lossless_indexed_decode_rounds_in_index_units_then_clamps() {
+ let palette = vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120];
+ for (bpc, samples, decode, expected) in [
+ (2, vec![0x1b], Some(vec![3., 0.]), vec![100, 110, 120, 70, 80, 90, 40, 50, 60, 10, 20, 30]),
+ (2, vec![0x1b], Some(vec![-1., 5.]), vec![10, 20, 30, 40, 50, 60, 100, 110, 120, 100, 110, 120]),
+ (2, vec![0x1b], Some(vec![0.5, 0.5]), vec![40, 50, 60, 40, 50, 60, 40, 50, 60, 40, 50, 60]),
+ (8, vec![0, 1, 2, 255], None, vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]),
+ // Existing 16-bit policy retains the high byte, 42 / 255 * 3 < 0.5.
+ (16, vec![0x2a, 0xab, 0, 0, 255, 255, 128, 0], Some(vec![0., 3.]), vec![10, 20, 30, 10, 20, 30, 100, 110, 120, 70, 80, 90]),
+ ] {
+ let (mut doc, image) = indexed_export_fixture(Object::Int(3), palette.clone(), false, false, bpc, samples);
+ if let Some(decode) = decode {
+ let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!() };
+ stream.dict.set(b"Decode".to_vec(), decode_array(&decode));
+ doc.set(image, Object::Stream(stream));
+ }
+ let doc = reopen(&doc);
+ let out = extract_images(&doc, &[0], 0);
+ assert!(out.skipped.is_empty(), "{:?}", out.skipped);
+ assert_eq!(exported_png_pixels(&out.images[0].data), expected);
+ let (_, bytes) = image_file(&doc, out.images[0].object).unwrap();
+ assert_eq!(exported_png_pixels(&bytes), expected);
+ }
+}
+
+#[test]
+fn lossless_decode_keeps_colour_key_comparisons_in_raw_samples() {
+ let mut dict = image_dict(2, 1, "DeviceGray", 8);
+ dict.set(b"Decode".to_vec(), decode_array(&[0.2, 0.8]));
+ dict.set(b"Mask".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0)]));
+ assert_decode_pixels(dict, vec![0, 255], &[51, 51, 51, 0, 204, 204, 204, 255]);
+
+ let (mut doc, image) = indexed_export_fixture(Object::Int(1), vec![255, 0, 0, 0, 0, 255], false, false, 2, vec![0x1b]);
+ let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!() };
+ stream.dict.set(b"Decode".to_vec(), decode_array(&[3., 0.]));
+ stream.dict.set(b"Mask".to_vec(), Object::Array(vec![Object::Int(2), Object::Int(2)]));
+ doc.set(image, Object::Stream(stream));
+ let doc = reopen(&doc);
+ let out = extract_images(&doc, &[0], 0);
+ let expected = [0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 0, 255, 0, 0, 255];
+ assert_eq!(exported_png_pixels(&out.images[0].data), expected);
+ let (_, bytes) = image_file(&doc, out.images[0].object).unwrap();
+ assert_eq!(exported_png_pixels(&bytes), expected);
+}
+
+#[test]
+fn lossless_decode_resolves_indirect_arrays_and_endpoints() {
+ let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
+ with_image(&mut doc, |doc| {
+ let lo = doc.add(Object::Real(0.2));
+ let hi = doc.add(Object::Real(0.8));
+ let decode = doc.add(Object::Array(vec![Object::Ref(lo), Object::Ref(hi)]));
+ let mut dict = image_dict(1, 1, "DeviceGray", 8);
+ dict.set(b"Decode".to_vec(), Object::Ref(decode));
+ Stream::from_raw(dict, vec![0])
+ });
+ let doc = reopen(&doc);
+ let out = extract_images(&doc, &[0], 0);
+ assert_eq!(exported_png_pixels(&out.images[0].data), [51, 51, 51]);
+ let (_, bytes) = image_file(&doc, out.images[0].object).unwrap();
+ assert_eq!(exported_png_pixels(&bytes), [51, 51, 51]);
+
+ let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
+ with_image(&mut doc, |doc| {
+ let decode = doc.add(Object::Null);
+ let mut dict = image_dict(1, 1, "DeviceGray", 8);
+ dict.set(b"Decode".to_vec(), Object::Ref(decode));
+ Stream::from_raw(dict, vec![64])
+ });
+ let doc = reopen(&doc);
+ let out = extract_images(&doc, &[0], 0);
+ assert_eq!(exported_png_pixels(&out.images[0].data), [64, 64, 64]);
+ let (_, bytes) = image_file(&doc, out.images[0].object).unwrap();
+ assert_eq!(exported_png_pixels(&bytes), [64, 64, 64]);
+}
+
+#[test]
+fn lossless_decode_interpolation_avoids_endpoint_difference_overflow() {
+ let (mut doc, image) = decode_fixture(image_dict(3, 1, "DeviceGray", 8), vec![0, 64, 255]);
+ let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!() };
+ stream.dict.set(b"Decode".to_vec(), decode_array(&[-f64::MAX, f64::MAX]));
+ doc.set(image, Object::Stream(stream));
+ let expected = [0, 0, 0, 0, 0, 0, 255, 255, 255];
+ let out = extract_images(&doc, &[0], 0);
+ assert_eq!(exported_png_pixels(&out.images[0].data), expected);
+ let (_, bytes) = image_file(&doc, image).unwrap();
+ assert_eq!(exported_png_pixels(&bytes), expected);
+}
+
+#[test]
+fn lossless_decode_falls_back_to_the_default_mapping_when_malformed() {
+ for (space, samples, decode, expected) in [
+ ("DeviceRGB", vec![0, 64, 255], decode_array(&[1., 0.]), [0, 64, 255]),
+ ("DeviceGray", vec![64], Object::name("bad"), [64, 64, 64]),
+ ("DeviceGray", vec![64], decode_array(&[]), [64, 64, 64]),
+ ("DeviceGray", vec![64], decode_array(&[0.]), [64, 64, 64]),
+ ("DeviceGray", vec![64], decode_array(&[1., 0., 0., 1.]), [64, 64, 64]),
+ ("DeviceGray", vec![64], Object::Array(vec![Object::Int(1), Object::name("bad")]), [64, 64, 64]),
+ ("DeviceGray", vec![64], Object::Array(vec![Object::Int(1), Object::Null]), [64, 64, 64]),
+ ] {
+ let mut dict = image_dict(1, 1, space, 8);
+ dict.set(b"Decode".to_vec(), decode);
+ assert_decode_pixels(dict, samples, &expected);
+ }
+ // Non-finite reals cannot be serialized as PDF numbers. Exercise the in-memory input.
+ for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
+ let mut dict = image_dict(1, 1, "DeviceGray", 8);
+ dict.set(b"Decode".to_vec(), decode_array(&[1., value]));
+ let (mut doc, image) = decode_fixture(image_dict(1, 1, "DeviceGray", 8), vec![64]);
+ doc.set(image, Object::Stream(Stream::from_raw(dict, vec![64])));
+ let out = extract_images(&doc, &[0], 0);
+ assert!(out.skipped.is_empty(), "{:?}", out.skipped);
+ assert_eq!(exported_png_pixels(&out.images[0].data), [64, 64, 64]);
+ let (_, bytes) = image_file(&doc, image).unwrap();
+ assert_eq!(exported_png_pixels(&bytes), [64, 64, 64]);
+ }
+}
+
/// An 8×8 baseline JPEG of one flat colour with four components, stored as `samples`, and an
/// Adobe APP14 segment with `transform` (0: CMYK, 2: YCCK).
fn flat_four_component_jpeg(samples: [u8; 4], transform: u8) -> Vec {
@@ -949,3 +1148,36 @@ fn cmyk_and_ycck_jpegs_export_in_the_colours_the_page_shows() {
}
}
}
+
+/// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90 degrees
+/// clockwise).
+pub(crate) fn phone_jpeg(orientation: u8, dpi: u16) -> Vec {
+ use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit};
+ let mut jpeg = Vec::new();
+ let mut encoder = JpegEncoder::new(&mut jpeg);
+ encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches });
+ encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap();
+ let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
+ exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]);
+ let mut out = jpeg[..2].to_vec();
+ out.extend_from_slice(&[0xff, 0xe1]);
+ out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
+ out.extend_from_slice(&exif);
+ out.extend_from_slice(&jpeg[2..]);
+ out
+}
+
+#[test]
+fn a_jpeg_with_an_exif_orientation_becomes_an_upright_page() {
+ let doc = reopen(&from_images(&[("photo.jpg".into(), phone_jpeg(6, 144)), ("plain.jpg".into(), phone_jpeg(1, 72))]).unwrap());
+ // Stored 60 x 20 px at 144 x 72 dpi; turned it is 20 x 60 px at 72 x 144 dpi: 20 x 30 pt.
+ let m0 = media(&pages(&doc)[0]);
+ assert!((m0[2] - 20.0).abs() < 0.01 && (m0[3] - 30.0).abs() < 0.01, "{m0:?}");
+ let img = image_of(&doc, 0);
+ assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(20), Some(60)));
+ // "As stored" stays as it is.
+ let m1 = media(&pages(&doc)[1]);
+ assert!((m1[2] - 60.0).abs() < 0.01 && (m1[3] - 20.0).abs() < 0.01, "{m1:?}");
+ let img = image_of(&doc, 1);
+ assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(60), Some(20)));
+}
diff --git a/crates/engine/src/upright_image.rs b/crates/create/src/upright_image.rs
similarity index 62%
rename from crates/engine/src/upright_image.rs
rename to crates/create/src/upright_image.rs
index dc2b9cdbfc..9816dfc4b2 100644
--- a/crates/engine/src/upright_image.rs
+++ b/crates/create/src/upright_image.rs
@@ -1,6 +1,6 @@
//! Photos from phones are stored sideways with an EXIF Orientation tag. PDF image XObjects have
-//! no such tag, so a JPEG embedded as it is would show up turned. Images placed into a page
-//! (stamps, added images, replacements) go through here first.
+//! no such tag, so a JPEG embedded as it is would show up turned. Every JPEG embedded by this
+//! crate (pages made from images, stamps, added images, replacements) goes through here first.
use std::borrow::Cow;
use std::io::Cursor;
@@ -79,24 +79,7 @@ fn jfif_density(bytes: &[u8]) -> Option {
#[cfg(test)]
mod tests {
use super::*;
- use crate::{Edit, MarkFile};
-
- /// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90
- /// degrees clockwise).
- fn phone_jpeg(orientation: u8, dpi: u16) -> Vec {
- let mut jpeg = Vec::new();
- let mut encoder = JpegEncoder::new(&mut jpeg);
- encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches });
- encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap();
- let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
- exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]);
- let mut out = jpeg[..2].to_vec();
- out.extend_from_slice(&[0xff, 0xe1]);
- out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
- out.extend_from_slice(&exif);
- out.extend_from_slice(&jpeg[2..]);
- out
- }
+ use crate::tests::phone_jpeg;
#[test]
fn orientation_is_applied_and_plain_images_are_left_alone() {
@@ -111,23 +94,4 @@ mod tests {
assert!(matches!(upright(b"\x89PNG\r\n\x1a\n"), Cow::Borrowed(_)));
assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_)));
}
-
- #[test]
- fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() {
- let mut session = crate::Session::new();
- let blank = session.create_blank(300.0, 400.0, 1).unwrap();
- let id = session.open_new("form.pdf", blank).unwrap();
- // A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright.
- // Named "Signature" so `signature_image` finds its picture.
- let file = MarkFile { name: "photo.jpg".into(), bytes: std::sync::Arc::new(phone_jpeg(6, 144)), page: 0 };
- let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() };
- session.apply(id, stamp).unwrap();
- let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos;
- let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap();
- let obj = cos.get(picture);
- let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() };
- assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60)));
- let rect = pdfcraft_annot::summaries(cos)[0].rect;
- assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}");
- }
}
diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs
index fd600cb9af..82b598688a 100644
--- a/crates/crypt/src/lib.rs
+++ b/crates/crypt/src/lib.rs
@@ -203,8 +203,22 @@ impl SecurityHandler {
self.recovered_user.clone()
}
+ /// For R5/R6, `/P` is stored in clear and again in `/Perms`, encrypted with the file key
+ /// (Algorithm 13). Where the two differ, a permission counts only when both grant it, so an
+ /// edited `/P` grants nothing extra. Without a readable `/Perms`, `/P` applies as before.
pub fn permissions(&self) -> Permissions {
- Permissions { bits: self.dict.p, owner: self.auth == Auth::Owner }
+ let bits = self.perms_p().map_or(self.dict.p, |p| self.dict.p & p);
+ Permissions { bits, owner: self.auth == Auth::Owner }
+ }
+
+ /// `/P` as `/Perms` holds it: AES-256 ECB of one block (= CBC with a zero IV), marked `adb`.
+ fn perms_p(&self) -> Option {
+ if !matches!(self.dict.r, 5 | 6) {
+ return None;
+ }
+ let plain = aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false);
+ let p = i32::from_le_bytes(*plain.first_chunk::<4>()?);
+ (plain.get(9..12) == Some(&b"adb"[..])).then_some(p)
}
pub fn dict(&self) -> &EncryptDict {
diff --git a/crates/crypt/src/tests.rs b/crates/crypt/src/tests.rs
index 7272af9ea1..5110e79998 100644
--- a/crates/crypt/src/tests.rs
+++ b/crates/crypt/src/tests.rs
@@ -212,6 +212,28 @@ fn permission_bits_follow_table_22() {
assert!(p.modify(), "owners ignore /P");
}
+#[test]
+fn perms_limits_what_an_edited_p_grants() {
+ let made = new(Algorithm::Aes256, "user pw", "owner pw", 0b0100); // print only
+ let open = |d: &EncryptDict, pw| SecurityHandler::open(d.clone(), ID, Some(pw)).unwrap().permissions();
+ let mut d = made.dict().clone();
+ assert_eq!(open(&d, "user pw").bits, d.p, "/P and /Perms agree");
+ d.p = -4; // /P edited to allow everything, /Perms as written
+ let p = open(&d, "user pw");
+ assert!(p.print() && !p.modify() && !p.copy() && !p.annotate() && !p.fill_forms() && !p.assemble());
+ assert!(open(&d, "owner pw").modify(), "owners ignore /P");
+ d.p = -3904; // a stricter /P still applies
+ assert!(!open(&d, "user pw").print());
+ // No /Perms, or one that does not decrypt to an `adb` block: /P as before.
+ for perms in [Vec::new(), vec![0; 16], made.dict().perms[..15].to_vec()] {
+ let d = EncryptDict { p: -4, perms, ..made.dict().clone() };
+ assert!(open(&d, "user pw").modify());
+ }
+ // R2 to R4 have no /Perms: /P is part of their file key, so an edited /P does not open.
+ let d = EncryptDict { p: -4, ..new(Algorithm::Aes128, "user pw", "owner pw", 0b0100).dict().clone() };
+ assert_eq!(SecurityHandler::open(d, ID, Some("user pw")).unwrap_err(), CryptError::WrongPassword);
+}
+
proptest::proptest! {
#[test]
fn malformed_dictionaries_never_panic(o in proptest::collection::vec(proptest::num::u8::ANY, 0..60),
diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs
index 8202c18d83..3ac5468057 100644
--- a/crates/edit/src/flatten.rs
+++ b/crates/edit/src/flatten.rs
@@ -63,9 +63,15 @@ enum Which {
fn selected(doc: &Document, d: &Dict, which: Which) -> bool {
let subtype = d.name(b"Subtype").unwrap_or_default();
match which {
+ // Comments are what the comment list calls comments: links, pop-ups and the non-markup
+ // multimedia and print-production annotations (3D, Screen, Movie, RichMedia,
+ // PrinterMark, TrapNet, Watermark) are neither comments nor fields, and stay (#3087).
Which::Comments { comments, fields } => {
- let widget = subtype == b"Widget";
- !matches!(subtype, b"Link" | b"Popup") && (!widget || fields) && (widget || comments)
+ if subtype == b"Widget" {
+ fields
+ } else {
+ comments && pdfcraft_annot::is_comment_subtype(&String::from_utf8_lossy(subtype))
+ }
}
Which::FillSign => !matches!(subtype, b"Link" | b"Popup" | b"Widget") && pdfcraft_annot::is_fill_sign(doc, d),
}
diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs
index fb62125880..62bef8bc5e 100644
--- a/crates/edit/src/images.rs
+++ b/crates/edit/src/images.rs
@@ -784,7 +784,16 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
return Err(EditError::Invalid("the image move is too large".into()));
};
plans.remove(&(stream, index));
- clips.insert((stream, at), c);
+ // A `cm` an earlier move put before the clip takes this move too: `c · prev` is the
+ // same CTM as `prev` followed by another `cm c`, without one more operator per move.
+ let folded = at
+ .checked_sub(1)
+ .and_then(|i| Some((i, c.then(&Matrix(ops.get(i).filter(|op| op.is("cm"))?.nums::<6>()?)))))
+ .filter(|(_, m)| m.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12));
+ match folded {
+ Some((i, m)) => clips.insert((stream, i), (m, false)),
+ None => clips.insert((stream, at), (c, true)),
+ };
}
// An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another.
let mut folds = std::collections::BTreeMap::new();
@@ -799,8 +808,8 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
folds.insert(at, folded);
}
let changed = crate::text::splice(&pieces, &ops, |index| {
- if let Some(c) = clips.remove(&(stream, index)) {
- return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], true);
+ if let Some((c, keep)) = clips.remove(&(stream, index)) {
+ return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], keep);
}
if let Some(m) = folds.remove(&index) {
return (vec![Op::new("cm", m.0.map(Object::Real).to_vec())], false);
diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs
index 88e3d78065..99fae205eb 100644
--- a/crates/edit/src/tests.rs
+++ b/crates/edit/src/tests.rs
@@ -1921,6 +1921,31 @@ fn repeated_image_edits_change_one_transform_instead_of_nesting() {
}
}
+#[test]
+fn repeated_group_moves_of_a_cropped_image_change_one_clip_transform() {
+ // The crop is smaller than the image, so a clip left behind would shrink `visible_rect`.
+ let mut doc = image_page_with("q 150 120 100 50 re W n 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET");
+ let operators = |doc: &Document| pdfcraft_content::parse(&page_content_bytes(doc, 0)).ops.len();
+ let start = images::page_images(&doc, 0).unwrap()[0].clone();
+ let mut first = 0;
+ for i in 0..20 {
+ images::translate_images(&mut doc, 0, &[0], [1.0, -2.0]).unwrap();
+ if i == 0 {
+ first = operators(&doc);
+ }
+ }
+ let moved = |r: [f64; 4]| [r[0] + 20.0, r[1] - 40.0, r[2] + 20.0, r[3] - 40.0];
+ let now = images::page_images(&doc, 0).unwrap()[0].clone();
+ assert!(close(now.rect, moved(start.rect)), "{:?}", now.rect);
+ assert!(close(now.visible_rect.unwrap(), moved(start.visible_rect.unwrap())), "the crop moves with the image: {:?}", now.visible_rect);
+ assert_eq!(operators(&doc), first, "twenty group moves add no more operators than one");
+ assert_eq!(q_depth(&doc), 1);
+ let doc = reopen(&doc);
+ assert!(close(images::page_images(&doc, 0).unwrap()[0].visible_rect.unwrap(), moved(start.visible_rect.unwrap())));
+ let caption: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
+ assert_eq!(caption, ["Caption"]);
+}
+
#[test]
fn page_images_move_turn_replace_and_delete() {
let mut doc = image_page();
@@ -2221,6 +2246,72 @@ fn replacing_a_line_keeps_a_distinct_nearby_line() {
}
}
+#[test]
+fn editing_text_keeps_an_overlapping_watermark_and_covered_paragraphs() {
+ let listed = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::>();
+ // #1756: a rotated watermark's bounding box covers the middle of the page, and a body line
+ // whose baseline happens to sit near the watermark's starting y is still unrelated text.
+ for rotation in [35.0, 45.0, 0.0] {
+ let mut base = text_page("BT /F1 14 Tf 60 700 Td (Heading) Tj ET");
+ add_watermark(
+ &mut base,
+ &[0],
+ &Watermark { text: "TEST WATERMARK".into(), font_size: 48.0, rotation, opacity: 0.25, ..Watermark::default() },
+ false,
+ )
+ .unwrap();
+ let wm_y = text::text_lines(&base, 0).unwrap().into_iter().find(|l| l.text == "TEST WATERMARK").unwrap().origin_baseline();
+ let mut doc = text_page(&format!("BT /F1 14 Tf 1 0 0 1 60 {wm_y:.2} Tm (Body paragraph one with several words here) Tj ET"));
+ add_watermark(
+ &mut doc,
+ &[0],
+ &Watermark { text: "TEST WATERMARK".into(), font_size: 48.0, rotation, opacity: 0.25, ..Watermark::default() },
+ false,
+ )
+ .unwrap();
+ let line_idx = text::text_lines(&doc, 0).unwrap().iter().position(|l| l.text.starts_with("Body")).unwrap();
+ let mut line_edited = doc.clone();
+ text::replace_line(&mut line_edited, 0, line_idx, "Edited body line").unwrap();
+ assert_eq!(listed(&reopen(&line_edited)), ["Edited body line", "TEST WATERMARK"], "rotation {rotation}");
+
+ let block_idx = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text.starts_with("Body")).unwrap();
+ let mut preview = doc.clone();
+ text::clear_block(&mut preview, 0, block_idx).unwrap();
+ assert_eq!(listed(&preview), ["TEST WATERMARK"], "clear_block keeps the watermark at rotation {rotation}");
+
+ text::rewrite_block(&mut doc, 0, block_idx, Some("Edited body paragraph"), &text::BlockStyle::default()).unwrap();
+ assert_eq!(listed(&reopen(&doc)), ["Edited body paragraph", "TEST WATERMARK"], "rotation {rotation}");
+ }
+
+ // #1368: dragging one paragraph onto another paragraph's baseline and moving it on again
+ // leaves the covered paragraph intact.
+ let mut doc = text_page("BT /F1 12 Tf 72 700 Td (First paragraph) Tj ET BT /F1 12 Tf 72 600 Td (Second paragraph) Tj ET");
+ text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { offset: Some([0.0, -100.0]), ..Default::default() }).unwrap();
+ let second = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text == "Second paragraph");
+ assert!(second.is_some(), "the covered paragraph stays on the page: {:?}", listed(&doc));
+ let first = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text == "First paragraph").unwrap();
+ text::rewrite_block(&mut doc, 0, first, None, &text::BlockStyle { offset: Some([0.0, -100.0]), ..Default::default() }).unwrap();
+ assert_eq!(listed(&reopen(&doc)), ["First paragraph", "Second paragraph"]);
+
+ // Even when a large horizontal watermark shows the same word as a body line at the same y,
+ // or a rotated 12 pt line crosses a horizontal 12 pt line of the same word at the same origin,
+ // editing one keeps the other.
+ let mut same_word = text_page("BT /F1 12 Tf 1 0 0 1 200 400 Tm (DRAFT) Tj ET BT /F1 48 Tf 1 0 0 1 150 400 Tm (DRAFT) Tj ET");
+ text::replace_line(&mut same_word, 0, 0, "FINAL").unwrap();
+ let after = text::text_lines(&reopen(&same_word), 0).unwrap();
+ assert_eq!(after.iter().map(|l| (&l.text[..], l.size as i32)).collect::>(), [("FINAL", 12), ("DRAFT", 48)]);
+
+ let mut crossed = text_page("BT /F1 12 Tf 1 0 0 1 100 500 Tm (Crossed) Tj ET BT /F1 12 Tf 0.707 0.707 -0.707 0.707 100 500 Tm (Crossed) Tj ET");
+ text::replace_line(&mut crossed, 0, 0, "Horizontal").unwrap();
+ assert_eq!(listed(&reopen(&crossed)), ["Horizontal", "Crossed"]);
+
+ // An invisible OCR text layer (`3 Tr`) on the same baseline and size as a visible line is
+ // still removed when the visible line is edited, even if OCR misread a letter.
+ let mut ocr = text_page("BT /F1 12 Tf 0 Tr 1 0 0 1 72 700 Tm (Hello world) Tj ET BT /F1 12 Tf 3 Tr 1 0 0 1 72 700 Tm (Rello world) Tj ET");
+ text::replace_line(&mut ocr, 0, 0, "Goodbye world").unwrap();
+ assert_eq!(listed(&reopen(&ocr)), ["Goodbye world"]);
+}
+
#[test]
fn paragraphs_move_and_rewrap_to_a_new_width() {
let para = "BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET";
@@ -3907,6 +3998,31 @@ fn turned_paragraph_keeps_its_line_breaks_when_a_word_is_added() {
assert!(text::rewrite_block(&mut doc, 0, 0, Some("Only line"), &style).is_err());
}
+/// A paragraph drawn at a slant rewraps at the width its rows have along their baseline, as the
+/// same paragraph upright does. Its upright bounding box turned into the baseline's frame is wider
+/// by the rows' height times |sin 2θ|, which let a word that doesn't fit stay on its row.
+#[test]
+fn a_paragraph_at_a_slant_rewraps_at_the_width_it_has_upright() {
+ let rows = ["Rotated paragraph, first row of text", "that continues on a second row and", "ends on the third row here."];
+ let draft = "Rotated paragraph, first row of text that continues on a second row and now carries a few extra words \
+ before it ends on the third row here.";
+ let rewrapped = |degrees: f64| {
+ let (sin, cos) = degrees.to_radians().sin_cos();
+ let shown = rows.iter().map(|r| format!("({r}) Tj")).collect::>().join(" T* ");
+ let mut doc = text_page(&format!("BT /F1 12 Tf 14.4 TL {cos:.6} {sin:.6} {:.6} {cos:.6} 300 400 Tm {shown} ET", -sin));
+ let block = text::text_blocks(&doc, 0).unwrap().remove(0);
+ assert_eq!(block.editor_text, rows.join("\n"), "{degrees}°");
+ text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap();
+ text::text_lines(&reopen(&doc), 0).unwrap().into_iter().map(|l| l.text).collect::>()
+ };
+ let upright = rewrapped(0.0);
+ // The widest source row is 189.44 pt; "now carries a few extra words before" is 196.07 pt.
+ assert_eq!(upright[2], "now carries a few extra words", "{upright:?}");
+ for degrees in [30.0, 45.0, 60.0, 90.0, 135.0, 210.0, 330.0] {
+ assert_eq!(rewrapped(degrees), upright, "{degrees}°");
+ }
+}
+
/// A paragraph in a Chrome-style subset font: `/W` leaves out the glyphs of the `/DW` width.
/// Adding a word keeps every row as it was and shows the word in the page's own font (#1443).
#[test]
@@ -3961,6 +4077,76 @@ fn default_width_subset_paragraph_keeps_its_rows_when_a_word_is_added() {
}
}
+/// #2578: a single turned row grows along its baseline only up to the page's 36 pt margin and wraps
+/// onto further rows from there, instead of running off the page.
+#[test]
+fn turned_single_row_wraps_at_the_page_margin() {
+ let draft = "A single turned row that keeps growing with many more words added until it is far longer than the page is tall, so it has to stop or wrap somewhere";
+ // Reading upwards (90° CCW) from y = 600 on the 600 × 800 test page: top margin is at y = 764.
+ let before = text_page("BT /F1 12 Tf 0 1 -1 0 300 600 Tm (A single turned row) Tj ET");
+ let layout = text::text_edit_layout(&before, 0, 0, draft, &text::BlockStyle::default()).unwrap();
+ let mut doc = before;
+ text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap();
+ let reopened = reopen(&doc);
+ let lines = text::text_lines(&reopened, 0).unwrap();
+ assert!(lines.len() > 1, "expected the turned row to wrap at the top margin: {lines:?}");
+ for line in &lines {
+ assert!((line.rect[1] - 600.0).abs() < 0.5, "row starts at y = 600: {:?}", line.rect);
+ assert!(line.rect[3] <= 800.0 - 36.0 + 0.5, "row stays within the top margin: {:?}", line.rect);
+ }
+ let blocks = text::text_blocks(&reopened, 0).unwrap();
+ assert_eq!(blocks.len(), 1, "wrapped turned rows stay one paragraph: {blocks:?}");
+ assert_eq!(blocks[0].text, draft);
+ assert_eq!(layout.draft_rect, Some(blocks[0].rect));
+
+ // Reading downwards (90° CW) from y = 200: bottom margin is at y = 36.
+ let mut doc = text_page("BT /F1 12 Tf 0 -1 1 0 300 200 Tm (A single turned row) Tj ET");
+ text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap();
+ let lines = text::text_lines(&reopen(&doc), 0).unwrap();
+ assert!(lines.len() > 1, "expected 90° CW row to wrap at the bottom margin: {lines:?}");
+ for line in &lines {
+ assert!((line.rect[3] - 200.0).abs() < 0.5, "row starts at y = 200: {:?}", line.rect);
+ assert!(line.rect[1] >= 36.0 - 0.5, "row stays within the bottom margin: {:?}", line.rect);
+ }
+
+ // Reading upside down (180°) from x = 200: left margin is at x = 36.
+ let mut doc = text_page("BT /F1 12 Tf -1 0 0 -1 200 600 Tm (A single turned row) Tj ET");
+ text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap();
+ let lines = text::text_lines(&reopen(&doc), 0).unwrap();
+ assert!(lines.len() > 1, "expected 180° row to wrap at the left margin: {lines:?}");
+ for line in &lines {
+ assert!((line.rect[2] - 200.0).abs() < 0.5, "row starts at x = 200: {:?}", line.rect);
+ assert!(line.rect[0] >= 36.0 - 0.5, "row stays within the left margin: {:?}", line.rect);
+ }
+
+ // Slanted (45°) from (400, 600): baseline stops where it first reaches a 36 pt margin.
+ let (sin, cos) = 45.0_f64.to_radians().sin_cos();
+ let mut doc = text_page(&format!("BT /F1 12 Tf {cos:.6} {sin:.6} {:.6} {cos:.6} 400 600 Tm (A single turned row) Tj ET", -sin));
+ text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap();
+ let lines = text::text_lines(&reopen(&doc), 0).unwrap();
+ assert!(lines.len() > 1, "expected 45° slanted row to wrap at the margin: {lines:?}");
+ for line in &lines {
+ assert!(line.rect[2] <= 600.0 && line.rect[3] <= 800.0, "slanted row stays on the page: {:?}", line.rect);
+ }
+
+ // Moving a turned single row with `offset` reduces the room left before the margin.
+ let mut doc = text_page("BT /F1 12 Tf 0 1 -1 0 300 500 Tm (A single turned row) Tj ET");
+ let moved = text::BlockStyle { offset: Some([0.0, 150.0]), ..Default::default() };
+ text::rewrite_block(&mut doc, 0, 0, Some(draft), &moved).unwrap();
+ let lines = text::text_lines(&reopen(&doc), 0).unwrap();
+ assert!(lines.len() > 1, "{lines:?}");
+ for line in &lines {
+ assert!((line.rect[1] - 650.0).abs() < 0.5, "moved row starts at y = 650: {:?}", line.rect);
+ assert!(line.rect[3] <= 800.0 - 36.0 + 0.5, "moved row stops at the top margin: {:?}", line.rect);
+ }
+
+ // A turned row that already starts inside the 36 pt margin keeps its own width instead of collapsing.
+ let mut doc = text_page("BT /F1 12 Tf 0 1 -1 0 300 770 Tm (Edge label) Tj ET");
+ text::rewrite_block(&mut doc, 0, 0, Some("Edge label updated"), &text::BlockStyle::default()).unwrap();
+ let lines = text::text_lines(&reopen(&doc), 0).unwrap();
+ assert!(lines.len() >= 2, "{lines:?}");
+}
+
/// An added item is written out as a specific standard-14 face (`/Times-Italic`, `/Helvetica-Bold`
/// …), so it has to be *measured* with that same face. Measuring the upright face while writing
/// the italic one mis-sizes every box: Times-Italic `A` is 611 against Times-Roman's 722.
@@ -4251,6 +4437,106 @@ fn japanese_paragraphs_wrap_between_characters_without_closing_marks_at_a_row_st
}
}
+#[test]
+fn rewritten_paragraph_is_drawn_after_backgrounds_between_its_rows() {
+ // Each row has its own background box drawn immediately before its BT..ET, as cards and
+ // highlighted headings exported from LibreOffice or browsers do (#2377).
+ let card = "0 0.45 0.25 rg 60 696 240 16 re f BT /F1 10 Tf 1 1 1 rg 64 700 Td (SEMINARIO ONLINE) Tj ET \
+ 0 0.45 0.25 rg 60 668 240 24 re f BT /F1 16 Tf 1 1 1 rg 64 674 Td (Dalla teoria alla pratica:) Tj ET \
+ 0 0.45 0.25 rg 60 632 240 32 re f BT /F1 24 Tf 1 1 1 rg 64 640 Td (gestire con metodo) Tj ET \
+ 0 0.45 0.25 rg 60 596 240 32 re f BT /F1 24 Tf 1 1 1 rg 64 604 Td (un progetto complesso) Tj ET";
+ let shown_at = |ops: &[pdfcraft_content::Op], needle: &str| {
+ ops.iter()
+ .position(|op| {
+ matches!(op.op.as_slice(), b"Tj" | b"TJ")
+ && op.operands.iter().any(|o| matches!(o, Object::String(s) if String::from_utf8_lossy(&s.bytes).contains(needle)))
+ })
+ .unwrap()
+ };
+
+ // 1. Editing the title rows emits all rewritten rows after all four background fills.
+ let mut doc = text_page(card);
+ assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 1);
+ let edited = "SEMINARIO ONLINE\nDalla teoria alla pratica:\ngestire con criterio\nun progetto nuovo";
+ text::rewrite_block(&mut doc, 0, 0, Some(edited), &text::BlockStyle::default()).unwrap();
+ let doc = reopen(&doc);
+ assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, edited);
+ let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
+ let fills: Vec = ops.iter().enumerate().filter_map(|(i, op)| op.is("f").then_some(i)).collect();
+ assert_eq!(fills.len(), 4);
+ assert!(
+ shown_at(&ops, "criterio") > fills[3] && shown_at(&ops, "nuovo") > fills[3],
+ "rewritten title rows must be drawn after their row background fills (fills at {fills:?}): {ops:?}"
+ );
+
+ // 2. Partial edit with a kept suffix row: rewritten rows 0..=2 are drawn after fills[0..=2]
+ // and still before kept row 3, preserving reading order.
+ let mut doc = text_page(card);
+ let partial = "WORKSHOP ONLINE\nDalla teoria alla pratica:\ngestire con criterio\nun progetto complesso";
+ text::rewrite_block(&mut doc, 0, 0, Some(partial), &text::BlockStyle::default()).unwrap();
+ let doc = reopen(&doc);
+ assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, partial);
+ let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
+ let fills: Vec = ops.iter().enumerate().filter_map(|(i, op)| op.is("f").then_some(i)).collect();
+ assert!(
+ shown_at(&ops, "WORKSHOP") > fills[2]
+ && shown_at(&ops, "pratica:") > fills[2]
+ && shown_at(&ops, "criterio") > fills[2]
+ && shown_at(&ops, "criterio") < shown_at(&ops, "complesso")
+ && shown_at(&ops, "complesso") > fills[3],
+ "rewritten prefix rows must follow their fills and precede the kept final row"
+ );
+
+ // 3. Editing row 2 so that it wraps pushes row 3 down; all rewritten rows must follow fills[3].
+ let mut doc = text_page(card);
+ let wrapped_edit = "SEMINARIO ONLINE\nDalla teoria alla pratica:\ngestire con criterio e metodo approfondito\nun progetto complesso";
+ text::rewrite_block(&mut doc, 0, 0, Some(wrapped_edit), &text::BlockStyle::default()).unwrap();
+ let doc = reopen(&doc);
+ let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
+ let last_fill = ops.iter().rposition(|op| op.is("f")).unwrap();
+ assert!(
+ shown_at(&ops, "approfondito") > last_fill && shown_at(&ops, "complesso") > last_fill,
+ "wrapped rows and pushed-down suffix row must follow all row background fills"
+ );
+
+ // 4. Single-style paragraph path across a CTM change between rows, with movement, underline,
+ // and a trailing unrelated line sharing the second row's BT..ET.
+ let cross_ctm = "0.9 0.9 0.2 rg 60 696 240 18 re f BT /F1 12 Tf 0 0 0 rg 64 700 Td (First highlighted row) Tj ET \
+ q 1 0 0 1 10 12 cm 0.9 0.9 0.2 rg 50 668 240 18 re f \
+ BT /F1 12 Tf 0 0 0 rg 54 672 Td (Second highlighted row) Tj 246 -40 Td (Side footer) Tj ET Q";
+ let mut doc = text_page(cross_ctm);
+ let para = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.text.starts_with("First highlighted")).unwrap();
+ assert_eq!(text::text_blocks(&doc, 0).unwrap()[para].rows.len(), 2);
+ text::rewrite_block(
+ &mut doc,
+ 0,
+ para,
+ Some("Updated first highlighted row and updated second highlighted row"),
+ &text::BlockStyle { offset: Some([6.0, -4.0]), underline: Some(true), ..Default::default() },
+ )
+ .unwrap();
+ let doc = reopen(&doc);
+ let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
+ let last_fill = ops.iter().rposition(|op| op.is("f")).unwrap();
+ assert!(
+ shown_at(&ops, "Updated first") > last_fill && shown_at(&ops, "Side footer") > shown_at(&ops, "Updated first"),
+ "cross-CTM single-style rewrite must follow both background fills and precede the shared BT's trailing line"
+ );
+ let lines = text::text_lines(&doc, 0).unwrap();
+ let updated_first = lines.iter().find(|l| l.text.starts_with("Updated first")).unwrap();
+ let side_footer = lines.iter().find(|l| l.text == "Side footer").unwrap();
+ assert!(
+ (updated_first.baseline()[0] - 70.0).abs() < 0.01 && (updated_first.baseline()[1] - 696.0).abs() < 0.01,
+ "offset baseline in user space across CTM: {:?}",
+ updated_first.baseline()
+ );
+ assert!(
+ (side_footer.baseline()[0] - 310.0).abs() < 0.01 && (side_footer.baseline()[1] - 644.0).abs() < 0.01,
+ "trailing line in shared BT keeps its tlm across CTM: {:?}",
+ side_footer.baseline()
+ );
+}
+
/// 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, Vec>> {
@@ -4473,3 +4759,43 @@ fn removing_marks_strips_wrappers_that_closed_open_state() {
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Watermark).unwrap(), 1);
assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]);
}
+
+/// #3087: flattening comments leaves alone what isn't a comment: links, and the non-markup
+/// multimedia and print-production annotations (3D, Screen, Movie, RichMedia, PrinterMark,
+/// TrapNet, Watermark), which it used to bake into the page and delete with their data.
+/// Flattening fields only leaves them alone too; real comments are still flattened.
+#[test]
+fn flattening_comments_keeps_annotations_that_are_not_comments() {
+ let kept = ["Widget", "Link", "3D", "Screen", "Movie", "RichMedia", "PrinterMark", "TrapNet", "Watermark"];
+ let flattened = ["Text", "Square", "FileAttachment"];
+ let all: Vec<&str> = flattened.iter().chain(kept.iter()).copied().collect();
+ let form = "<< /Type /XObject /Subtype /Form /BBox [0 0 40 20] /Length 26 >>\nstream\n0 0 1 rg 0 0 40 20 re f\nendstream";
+ let annots: Vec = all
+ .iter()
+ .enumerate()
+ .map(|(i, s)| {
+ format!("<< /Type /Annot /Subtype /{s} /Rect [{x} 700 {r} 720] /AP << /N 4 0 R >> /Contents ({s}) >>", x = 20 + 50 * i, r = 60 + 50 * i)
+ })
+ .collect();
+ let refs: Vec = (0..all.len()).map(|i| format!("{} 0 R", 6 + i)).collect();
+ let page = format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 5 0 R /Annots [{}] >>", refs.join(" "));
+ let content = stream("", " ");
+ let mut objs: Vec<&str> = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", &page, form, &content];
+ objs.extend(annots.iter().map(String::as_str));
+ let left = |doc: &Document| -> Vec {
+ let p = &pdfcraft_model::pages(doc)[0];
+ let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) else { return Vec::new() };
+ a.as_array()
+ .unwrap()
+ .iter()
+ .filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.name(b"Subtype")).map(|s| String::from_utf8_lossy(s).into_owned()))
+ .collect()
+ };
+ let mut doc = build(&objs);
+ assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), flattened.len());
+ assert_eq!(left(&reopen(&doc)), kept, "only the comments were flattened");
+ let mut doc = build(&objs);
+ assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "the widget");
+ let without_widget: Vec<&str> = all.iter().copied().filter(|s| *s != "Widget").collect();
+ assert_eq!(left(&reopen(&doc)), without_widget, "flattening fields touches no annotation that isn't a widget");
+}
diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs
index 916aaeadd9..e4eaaa641e 100644
--- a/crates/edit/src/text.rs
+++ b/crates/edit/src/text.rs
@@ -50,6 +50,10 @@ pub struct TextLine {
pub visual_order: bool,
/// The Japanese or Hebrew fallback face the font subsets, when the line is in one (see [`same_font`]).
face: Option,
+ /// Its box along and across its baseline, `[along0, across0, along1, across1]` (see
+ /// [`TextLine::frame_rect`]). Upright text has `rect`; turned text has its own box, which is
+ /// narrower than `rect` turned into the same frame.
+ frame: [f64; 4],
/// The content stream piece the line starts in, and its operators (indices into the page's
/// streams parsed joined).
stream: usize,
@@ -470,12 +474,7 @@ impl TextLine {
/// Its box in the same frame, `[along0, across0, along1, across1]` (for upright text, `rect`).
fn frame_rect(&self) -> [f64; 4] {
- let dir = self.direction();
- let r = self.rect;
- [(r[0], r[1]), (r[2], r[1]), (r[0], r[3]), (r[2], r[3])]
- .into_iter()
- .map(|p| to_frame(dir, p))
- .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)])
+ self.frame
}
}
@@ -493,6 +492,11 @@ fn to_frame(dir: (f64, f64), p: (f64, f64)) -> (f64, f64) {
(p.0 * dir.0 + p.1 * dir.1, p.1 * dir.0 - p.0 * dir.1)
}
+/// The box covering both `[x0, y0, x1, y1]` boxes.
+fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] {
+ [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]
+}
+
/// What replacing a line did.
#[derive(Clone, Debug, PartialEq)]
pub struct LineEdit {
@@ -639,6 +643,8 @@ struct Shown {
bt_state: Ts,
text: String,
rect: [f64; 4],
+ /// Its box along and across its baseline (see [`TextLine::frame`]).
+ frame: [f64; 4],
/// The boxes of its words (see [`TextLine::ink`]).
ink: Vec<[f64; 4]>,
baseline: f64,
@@ -773,6 +779,7 @@ fn interpret(
first.rect[2].max(shown.rect[2]),
first.rect[3].max(shown.rect[3]),
];
+ first.frame = union(first.frame, shown.frame);
first.ink.extend(shown.ink);
}
first.extra_ops.extend([open, i]);
@@ -936,6 +943,13 @@ fn interpret(
let rect = corners
.iter()
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
+ // The same corners along and across the baseline: the run's own box, not its
+ // upright bounding box turned (which is wider by the box's height on a slant).
+ let dir = direction(&trm0);
+ let frame = corners
+ .iter()
+ .map(|&p| to_frame(dir, p))
+ .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
let ink = words
.iter()
.map(|&(a, b)| {
@@ -963,6 +977,7 @@ fn interpret(
bt_state: at_bt.1.clone(),
text,
rect,
+ frame,
ink,
baseline: start.1,
start_x: start.0,
@@ -1040,6 +1055,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro
}
l.text.push_str(&s.text);
l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])];
+ l.frame = union(l.frame, s.frame);
l.ink.extend(s.ink.iter().copied());
l.ops.push(s.op);
l.ops.extend(s.extra_ops.iter().copied());
@@ -1051,6 +1067,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro
lines.push(TextLine {
text: s.text.clone(),
rect: s.rect,
+ frame: s.frame,
ink: s.ink.clone(),
font: String::from_utf8_lossy(&s.font).into_owned(),
base_font: s.base_font.clone(),
@@ -1128,6 +1145,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream
}
l.text.push_str(&s.text);
l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])];
+ l.frame = union(l.frame, s.frame);
l.ink.extend(s.ink.iter().copied());
l.ops.push(s.op);
l.ops.extend(s.extra_ops.iter().copied());
@@ -1138,6 +1156,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream
lines.push(TextLine {
text: s.text.clone(),
rect: s.rect,
+ frame: s.frame,
ink: s.ink.clone(),
font: String::from_utf8_lossy(&s.font).into_owned(),
base_font: s.base_font.clone(),
@@ -1784,7 +1803,8 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
}
// Rebuild: the line's first operator becomes the replacement, its others go. Copies of the
// line drawn on the same baseline go too, so the replacement is all that shows.
- let drops = coincident_ops(&lines, &[&target]);
+ let mut drops = coincident_ops(&lines, &[&target]);
+ drops.extend(target.ops.iter().copied());
let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) });
let contents = rewritten_contents(doc, &streams, new_data);
let page_ref = p.obj;
@@ -1816,7 +1836,10 @@ fn coincident_ops(lines: &[TextLine], targets: &[&TextLine]) -> std::collections
let area = |r: [f64; 4]| ((r[2] - r[0]) * (r[3] - r[1])).max(0.0);
let mut drop = std::collections::HashSet::new();
for line in lines {
- let copy = targets.iter().any(|target| overlaps_half(line.rect, target.rect, area) && on_same_baseline(line, target));
+ let copy = targets.iter().any(|target| {
+ let same_text = line.text.trim() == target.text.trim() || (line.origin.state.render_mode == 3) != (target.origin.state.render_mode == 3);
+ same_text && overlaps_half(line.rect, target.rect, area) && on_same_baseline(line, target)
+ });
if copy {
drop.extend(line.ops.iter().copied());
}
@@ -1834,13 +1857,16 @@ fn overlaps_half(a: [f64; 4], b: [f64; 4], area: impl Fn([f64; 4]) -> f64) -> bo
/// Perpendicular distance between baselines, in user space. Horizontal text compares height;
/// turned text compares the distance across the writing direction.
fn on_same_baseline(line: &TextLine, target: &TextLine) -> bool {
+ let (dir, target_dir) = (line.direction(), target.direction());
+ let same_dir = (dir.0 - target_dir.0).abs() < 1e-3 && (dir.1 - target_dir.1).abs() < 1e-3;
+ let same_size = line.size.min(target.size) >= line.size.max(target.size) * 0.8;
let angle = target.rotation().to_radians();
let (sin, cos) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) };
let dx = line.origin.x - target.origin.x;
let dy = line.origin.baseline - target.origin.baseline;
let offset = dx.mul_add(-sin, dy * cos).abs();
let size = line.size.min(target.size).max(1.0);
- offset.is_finite() && offset < size * 0.3
+ same_dir && same_size && offset.is_finite() && offset < size * 0.3
}
/// The paragraphs on a page (0-based).
@@ -2072,6 +2098,8 @@ fn rewrite_block_from(
// The characters to show, for choosing fonts: the breaks are moves, not glyphs.
let flat = text.replace('\n', " ");
let o = &first.origin;
+ let last = members.last().copied().unwrap_or(first);
+ let emit = &last.origin;
let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?;
let k = o.k.max(1e-6);
// The size in text space: points ÷ the text-to-user scale.
@@ -2132,7 +2160,7 @@ fn rewrite_block_from(
// paragraph leaves the document as it was. The move is in page space, so it is taken back through
// the CTM's linear part into the space the paragraph is drawn in.
let movement = if dx != 0.0 || dy != 0.0 {
- let c = o.ctm;
+ let c = emit.ctm;
let back = Matrix([c[0], c[1], c[2], c[3], 0.0, 0.0])
.invert()
.ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be moved in".into()))?;
@@ -2140,6 +2168,12 @@ fn rewrite_block_from(
} else {
None
};
+ let tm = if o.ctm == emit.ctm {
+ o.tm
+ } else {
+ let emit_inv = Matrix(emit.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?;
+ Matrix(o.tm).then(&Matrix(o.ctm)).then(&emit_inv).0
+ };
let type3 = if !reuse && needs_type3(&flat) { Some(type3_font(doc, &mut fonts_res, &flat, family, bold, max_glyphs)?) } else { None };
let advance = |s: &str| -> f64 {
let t = match (&metrics, reuse) {
@@ -2161,9 +2195,27 @@ fn rewrite_block_from(
};
t * o_state.scale * k
};
- // A turned single line grows to fit, as far as the largest page PDF allows.
+ // A turned single line grows to fit, up to where its baseline reaches the page's margin.
let width = if !upright && style.width.is_none() && members.len() == 1 {
- text.split('\n').map(&advance).fold(width, f64::max).min(14_400.0)
+ let crop = p.crop(doc);
+ let (x0, y0) = (first.origin.x + dx, first.origin.baseline + dy);
+ let (ux, uy) = first.direction();
+ let tx = if ux > 1e-6 {
+ (crop[2] - 36.0 - x0) / ux
+ } else if ux < -1e-6 {
+ (crop[0] + 36.0 - x0) / ux
+ } else {
+ f64::INFINITY
+ };
+ let ty = if uy > 1e-6 {
+ (crop[3] - 36.0 - y0) / uy
+ } else if uy < -1e-6 {
+ (crop[1] + 36.0 - y0) / uy
+ } else {
+ f64::INFINITY
+ };
+ let cap = width.max(tx.min(ty)).min(14_400.0);
+ text.split('\n').map(&advance).fold(width, f64::max).min(cap)
} else {
width
};
@@ -2217,7 +2269,7 @@ fn rewrite_block_from(
let n = pdfcraft_content::num;
// In its own graphics state, so a new colour (or anything else) stops at the paragraph.
let mut block_ops = vec![Op::new("q", vec![])];
- block_ops.extend(reveal_ops(&b, &lines, style, o.ctm)?);
+ block_ops.extend(reveal_ops(&b, &lines, style, emit.ctm)?);
// Moved: a translation inside that state.
if let Some((ax, ay)) = movement {
block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)]));
@@ -2232,7 +2284,7 @@ fn rewrite_block_from(
state.fill = vec![Op::new("rg", vec![n(r), n(g), n(bl)])];
}
block_ops.extend(state.ops());
- block_ops.push(Op::new("Tm", o.tm.iter().map(|v| n(*v)).collect()));
+ block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect()));
// Alignment: each line's offset from the left edge, in text space.
let offset = |line: &str| -> f64 {
let free = (width - advance(line)).max(0.0) / k;
@@ -2315,7 +2367,7 @@ fn rewrite_block_from(
})
.collect();
block_ops.push(Op::new("q", vec![]));
- block_ops.push(Op::new("cm", o.tm.iter().map(|v| n(*v)).collect()));
+ block_ops.push(Op::new("cm", tm.iter().map(|v| n(*v)).collect()));
block_ops.extend(colour);
block_ops.push(Op::new("w", vec![n(size * 0.06)]));
for (x0, x1, y) in underlines {
@@ -2330,24 +2382,24 @@ fn rewrite_block_from(
// second copy, an invisible text layer): the new text is all that may show.
let mut drop = coincident_ops(&lines, &members);
drop.extend(members.iter().flat_map(|l| l.ops.iter().copied()));
- // Text shown earlier in the same text object stays first: the paragraph then goes where it
- // was, between two halves of the text object, so it keeps its place in reading order.
- let start = first.ops.first().copied().unwrap_or(o.bt_op);
+ // Text shown earlier in the same text object stays first: the paragraph then goes where its
+ // last row was, after any backgrounds between the rows, or between two halves of the text object.
+ let start = last.ops.first().copied().unwrap_or(emit.bt_op);
let split = ops
- .get(o.bt_op..start)
+ .get(emit.bt_op..start)
.unwrap_or_default()
.iter()
.enumerate()
- .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i)));
+ .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(emit.bt_op + i)));
// What the text after the paragraph expects: the state at its BT (or where it was split).
- let after = if split { &o.state } else { &o.bt_state };
+ let after = if split { &emit.state } else { &emit.bt_state };
block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some()));
if split {
block_ops.insert(0, Op::new("ET", vec![]));
block_ops.push(Op::new("BT", vec![]));
- block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect()));
+ block_ops.push(Op::new("Tm", emit.tlm.iter().map(|v| n(*v)).collect()));
}
- let at = if split { start } else { o.bt_op };
+ let at = if split { start } else { emit.bt_op };
let new_data = splice(&pieces, &ops, |i| {
let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() };
(insert, !drop.contains(&i))
diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs
index 2d7baf519f..8e34e0bdf4 100644
--- a/crates/edit/src/text_regions.rs
+++ b/crates/edit/src/text_regions.rs
@@ -17,10 +17,6 @@ fn same_plane(a: &TextLine, b: &TextLine) -> bool {
})
}
-fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] {
- [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]
-}
-
fn row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> {
row.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).reduce(union)
}
@@ -620,19 +616,20 @@ pub(super) fn rewrite_styled_block(
let kept_lines: std::collections::HashSet =
kept.iter().enumerate().filter(|(_, k)| **k).filter_map(|(r, _)| block.rows.get(r)).flatten().copied().collect();
let rewritten: Vec<&TextLine> = block.lines.iter().filter(|i| !kept_lines.contains(i)).filter_map(|i| lines.get(*i)).collect();
- // The rewrite goes where the first rewritten row was drawn, so it must share the paragraph's
- // space. A dropped `'` or `"` also moved to the next line, which a kept row relies on.
- let lead = rewritten.first().copied().filter(|lead| {
- lead.origin.ctm.iter().zip(first.origin.ctm).all(|(a, b)| (a - b).abs() < 1e-9)
- && !rewritten.iter().flat_map(|l| &l.ops).any(|i| ops.get(*i).is_some_and(|op| op.is("'") || op.is("\"")))
- });
+ // The rewrite goes where the last rewritten row was drawn, after any backgrounds painted
+ // between the rows. A dropped `'` or `"` also moved to the next line, which a kept row relies on.
+ let lead =
+ rewritten.last().copied().filter(|_| !rewritten.iter().flat_map(|l| &l.ops).any(|i| ops.get(*i).is_some_and(|op| op.is("'") || op.is("\""))));
let (members, lead) = match lead {
Some(lead) if !kept_lines.is_empty() => (rewritten, lead),
_ => {
kept.iter_mut().for_each(|k| *k = false);
- (block.lines.iter().filter_map(|i| lines.get(*i)).collect(), first)
+ let all: Vec<&TextLine> = block.lines.iter().filter_map(|i| lines.get(*i)).collect();
+ let last = all.last().copied().unwrap_or(first);
+ (all, last)
}
};
+ let o = &lead.origin;
for (row, _) in rows.iter().zip(&kept).filter(|(_, k)| !**k) {
let text: String = row.iter().map(|v| v.0).collect();
if pdfcraft_fonts::source_shaping::required(&text) && row.first().is_some_and(|first| row.iter().any(|v| v.1 != first.1 || v.2 != first.2)) {
@@ -668,8 +665,8 @@ pub(super) fn rewrite_styled_block(
}
let n = pdfcraft_content::num;
let mut block_ops = vec![Op::new("q", vec![])];
- block_ops.extend(reveal_ops(block, lines, style, first.origin.ctm)?);
- let back = Matrix([first.origin.ctm[0], first.origin.ctm[1], first.origin.ctm[2], first.origin.ctm[3], 0.0, 0.0])
+ block_ops.extend(reveal_ops(block, lines, style, o.ctm)?);
+ let back = Matrix([o.ctm[0], o.ctm[1], o.ctm[2], o.ctm[3], 0.0, 0.0])
.invert()
.ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?;
let (tx, ty) = back.apply(dx, dy);
@@ -741,7 +738,7 @@ pub(super) fn rewrite_styled_block(
if let Some([r, g, b]) = overrides.get(&item.1).and_then(|r| r.color).or(style.color) {
state.fill = vec![Op::new("rg", vec![n(r), n(g), n(b)])];
}
- let inverse = Matrix(first.origin.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?;
+ let inverse = Matrix(o.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?;
let mut tm = Matrix(source.origin.tm).then(&Matrix(source.origin.ctm)).then(&inverse).0;
let (ax, ay) = back.apply(x - source.origin.x, baseline - source.origin.baseline);
tm[4] += ax;
@@ -790,10 +787,10 @@ pub(super) fn rewrite_styled_block(
if underlines.get(&item.1).copied().or(style.underline) == Some(true) {
let [r, g, b] = overrides.get(&item.1).and_then(|r| r.color).or(style.color).unwrap_or(source.color);
let underline_size = size_of(source, item.1) * source.origin.k;
- let (x0, y0) = back.apply(x - first.origin.ctm[4], baseline - first.origin.ctm[5] - underline_size * 0.12);
- let (x1, y1) = back.apply(x + segment_width - first.origin.ctm[4], baseline - first.origin.ctm[5] - underline_size * 0.12);
+ let (x0, y0) = back.apply(x - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12);
+ let (x1, y1) = back.apply(x + segment_width - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12);
block_ops.push(Op::new("RG", vec![n(r), n(g), n(b)]));
- block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / first.origin.ctm[2].hypot(first.origin.ctm[3]).max(1e-6))]));
+ block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / o.ctm[2].hypot(o.ctm[3]).max(1e-6))]));
block_ops.push(Op::new("m", vec![n(x0), n(y0)]));
block_ops.push(Op::new("l", vec![n(x1), n(y1)]));
block_ops.push(Op::new("S", vec![]));
@@ -810,7 +807,6 @@ pub(super) fn rewrite_styled_block(
block_ops.push(Op::new("Q", vec![]));
let mut drop = coincident_ops(lines, &members);
drop.extend(members.iter().flat_map(|l| l.ops.iter().copied()));
- let o = &lead.origin;
let start = lead.ops.first().copied().unwrap_or(o.bt_op);
let split = ops
.get(o.bt_op..start)
diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs
index 87f9094364..5c3071a3f9 100644
--- a/crates/engine/src/catalog.rs
+++ b/crates/engine/src/catalog.rs
@@ -206,6 +206,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready),
item("Crop pages", "crop", "page.crop", Ready),
item("Duplicate pages", "copy-plus", "page.duplicate", Ready),
+ item("Reverse pages", "arrow-up-down", "page.reverse", Ready),
item("Page labels", "tag", "page.number", Ready),
item("Page transitions", "presentation", "page.transitions", Planned("M4")),
],
diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs
index e9079e092c..3865c465f4 100644
--- a/crates/engine/src/commands.rs
+++ b/crates/engine/src/commands.rs
@@ -363,6 +363,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("page.insert_blank", "Insert blank page", PAGES, None, Assembly, "file-plus"),
c("page.rotate_dialog", "Rotate pages…", PAGES, None, Assembly, "rotate-cw"),
c("page.duplicate", "Duplicate pages", PAGES, None, Assembly, "copy-plus"),
+ c("page.reverse", "Reverse pages", PAGES, None, Assembly, "arrow-up-down"),
c("page.crop", "Crop pages", PAGES, None, Assembly, "crop"),
c("page.boxes", "Set page boxes…", PAGES, None, Assembly, "square-dashed-mouse-pointer"),
c("page.insert", "Insert pages from file…", PAGES, None, Assembly, "file-input"),
diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs
index 8958688d2d..b797bc8d83 100644
--- a/crates/engine/src/js.rs
+++ b/crates/engine/src/js.rs
@@ -6,7 +6,7 @@
//! Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript is [`Session::set_javascript`]; with it
//! off, scripts don't run and AF calls keep working natively.
-use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, ScriptResult, Scripts};
+use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts};
pub use pdfcraft_js::{Limits, Outcome, Request};
use crate::{DocId, Edit, EditError, Session};
@@ -343,19 +343,24 @@ impl Session {
let names = found.iter().map(|(_, c, _)| c.name.clone()).collect();
let mut edits = Vec::with_capacity(found.len());
for (page, c, turn) in found {
- // A turned field keeps its place: the rectangle is already the one on the page.
- let props = (turn != 0).then(|| pdfcraft_forms::FieldProps { rotation: Some((0, turn)), rect: Some((0, c.rect)), ..Default::default() });
let tall = if turn % 180 == 0 { c.rect[3] - c.rect[1] } else { c.rect[2] - c.rect[0] };
- edits.push(Edit::AddField {
- page,
- rect: c.rect,
- kind: match c.kind {
- pdfcraft_forms::detect::Kind::Text => pdfcraft_forms::NewField::Text { multiline: tall > 30.0 },
- pdfcraft_forms::detect::Kind::CheckBox => pdfcraft_forms::NewField::CheckBox,
- },
- name: Some(c.name.clone()),
- });
- if let Some(props) = props {
+ let (kind, comb) = match c.kind {
+ pdfcraft_forms::detect::Kind::Text => (pdfcraft_forms::NewField::Text { multiline: tall > 30.0 }, None),
+ pdfcraft_forms::detect::Kind::CheckBox => (pdfcraft_forms::NewField::CheckBox, None),
+ pdfcraft_forms::detect::Kind::Comb { cells } => (pdfcraft_forms::NewField::Text { multiline: false }, Some(cells)),
+ };
+ let mut props = FieldProps::default();
+ if turn != 0 {
+ // A turned field keeps its place: the rectangle is already the one on the page.
+ props.rotation = Some((0, turn));
+ props.rect = Some((0, c.rect));
+ }
+ if let Some(cells) = comb {
+ props.max_len = Some(Some(cells));
+ props.flags = vec![(pdfcraft_forms::flags::COMB, true)];
+ }
+ edits.push(Edit::AddField { page, rect: c.rect, kind, name: Some(c.name.clone()) });
+ if turn != 0 || comb.is_some() {
edits.push(Edit::SetFieldProps { name: c.name, props: Box::new(props) });
}
}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 87f288dd9a..255077ce80 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -36,7 +36,6 @@ pub mod signature_image;
pub mod signature_scan;
pub mod text_fonts;
mod text_preview;
-mod upright_image;
pub mod word_count;
pub mod xfa;
@@ -308,6 +307,10 @@ struct Editor {
saved: Arc>,
/// Passwords that open [`Self::saved`].
saved_keys: Keys,
+ /// [`Self::saved`] was never written where the document belongs: a new document (combine,
+ /// extract, create) or one restored from recovery. It stays unsaved until a save, whatever
+ /// undo brings back, or closing it would lose the whole document without asking.
+ unsaved_file: bool,
undo: Vec,
redo: Vec,
keys: Keys,
@@ -319,15 +322,15 @@ struct Editor {
text_blocks: TextBlocksCache,
}
-/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the unmodified document state they
-/// were read from. Opening a paragraph and applying its edit both need them, and grouping a long
-/// page is not cheap. Only an unmodified document is cached: its identity changes on every edit,
-/// undo and save (each reopens the working file), so a stale entry never matches.
+/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the document state they were read
+/// from. Opening a paragraph and applying its edit both need them, and grouping a long page is not
+/// cheap. The state ([`pdfcraft_cos::ReadState`]) changes with every edit and save and returns
+/// with undo, so a stale entry never matches, also after the document has been edited.
#[derive(Default)]
struct TextBlocksCache(std::sync::Mutex>);
/// The document state, the page (0-based) and its text blocks.
-type CachedTextBlocks = (pdfcraft_cos::SourceIdentity, usize, Arc>);
+type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc>);
impl Clone for TextBlocksCache {
fn clone(&self) -> Self {
@@ -339,20 +342,16 @@ impl Clone for TextBlocksCache {
impl Editor {
/// The text blocks on `page` (0-based), from the cache when the document hasn't changed.
fn text_blocks(&self, page: usize) -> Result>, EditError> {
- let source = (!self.cos.is_modified()).then(|| self.cos.source_identity());
- if let Some(source) = &source
- && let Ok(cache) = self.text_blocks.0.lock()
- && let Some((s, p, blocks)) = cache.as_ref()
- && s == source
+ if let Ok(cache) = self.text_blocks.0.lock()
+ && let Some((state, p, blocks)) = cache.as_ref()
&& *p == page
+ && state.matches(&self.cos)
{
return Ok(blocks.clone());
}
let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?);
- if let Some(source) = source
- && let Ok(mut cache) = self.text_blocks.0.lock()
- {
- *cache = Some((source, page, blocks.clone()));
+ if let Ok(mut cache) = self.text_blocks.0.lock() {
+ *cache = Some((self.cos.read_state(), page, blocks.clone()));
}
Ok(blocks)
}
@@ -834,6 +833,11 @@ impl Document {
self.editor.as_ref().and_then(|e| pdfcraft_organize::info(&e.cos, key))
}
+ /// The custom document properties as they are now (name, text), Document Properties ▸ Custom.
+ pub fn custom_info(&self) -> Vec<(String, String)> {
+ self.editor.as_ref().map(|e| pdfcraft_organize::custom_info(&e.cos)).unwrap_or_default()
+ }
+
/// The name shown on the tab and window: the document title when the document asks for it
/// (Initial View ▸ Show: Document Title) and has a real one, else the file name.
pub fn display_name(&self) -> String {
@@ -986,6 +990,27 @@ fn render_threads() -> usize {
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1
}
+/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy
+/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its
+/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties`
+/// is left alone.
+fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) {
+ use pdfcraft_cos::{Dict, ObjRef, Object};
+ let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return };
+ let Some(mut root) = cos.get(root_ref).as_dict().cloned() else { return };
+ let Some(properties) = root.get(b"OCProperties").cloned() else { return };
+ let Some(mut properties) = cos.resolve(&properties).as_dict().cloned() else { return };
+ let mut config = properties.get(b"D").and_then(|d| cos.resolve(d).as_dict().cloned()).unwrap_or_else(Dict::new);
+ let refs =
+ |visible: bool| Object::Array(layers.iter().filter(|l| l.visible == visible).map(|l| Object::Ref(ObjRef::new(l.id.0, l.id.1))).collect());
+ config.set(b"BaseState".to_vec(), Object::name("ON"));
+ config.set(b"ON".to_vec(), refs(true));
+ config.set(b"OFF".to_vec(), refs(false));
+ properties.set(b"D".to_vec(), Object::Dict(config));
+ root.set(b"OCProperties".to_vec(), Object::Dict(properties));
+ cos.set(root_ref, Object::Dict(root));
+}
+
/// Render `doc` with the layer visibility in its `info.layers`.
fn use_layer_choices(doc: &mut Document) {
let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect();
@@ -1249,14 +1274,20 @@ pub enum Edit {
pages: Vec,
to: usize,
},
+ /// `pages` reversed among the positions they occupy.
+ ReversePages {
+ pages: Vec,
+ },
InsertBlankPage {
at: usize,
width: f64,
height: f64,
},
- /// Copies of `pages` inserted after the last of them.
+ /// Copies of `pages` inserted at `at` (an index in the page order as it is now), or after
+ /// the last of them when `at` is `None`.
DuplicatePages {
pages: Vec,
+ at: Option,
},
/// Replace Pages: the content of `pages` is replaced by `src_pages` of another PDF; the
/// pages' links, comments and bookmarks stay.
@@ -1791,6 +1822,7 @@ pub const EDIT_LABELS: &[&str] = &[
"Delete pages",
"Move page",
"Move pages",
+ "Reverse pages",
"Insert blank page",
"Duplicate page",
"Duplicate pages",
@@ -1930,8 +1962,9 @@ impl Edit {
Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()),
Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()),
Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()),
+ Edit::ReversePages { .. } => Label::of("Reverse pages"),
Edit::InsertBlankPage { .. } => Label::of("Insert blank page"),
- Edit::DuplicatePages { pages } => plural("Duplicate page", "Duplicate pages", pages.len()),
+ Edit::DuplicatePages { pages, .. } => plural("Duplicate page", "Duplicate pages", pages.len()),
Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()),
Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", "Crop pages", pages.len()),
Edit::SetPageBox { .. } => Label::of("Set page boxes"),
@@ -2104,6 +2137,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
Edit::RotatePages { .. }
| Edit::DeletePages { .. }
| Edit::MovePages { .. }
+ | Edit::ReversePages { .. }
| Edit::InsertBlankPage { .. }
| Edit::DuplicatePages { .. }
| Edit::ReplacePages { .. }
@@ -2316,11 +2350,12 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?,
Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?,
Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?,
+ Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?,
Edit::InsertBlankPage { at, width, height } => {
pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?;
}
Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?,
- Edit::DuplicatePages { pages } => pdfcraft_organize::duplicate_pages(doc, pages)?,
+ Edit::DuplicatePages { pages, at } => pdfcraft_organize::duplicate_pages(doc, pages, *at)?,
Edit::ReplacePages { pages, name, bytes, src_pages } => {
let src = open_source(name, bytes)?;
pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?;
@@ -2396,7 +2431,10 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
}
Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?,
Edit::ReplaceText { page, quads, text, author, strike, caret } => {
- pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &cx.meta())?;
+ // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by
+ // the strikeout's `/NM` (#2666).
+ let (strike_meta, caret_meta) = (cx.meta(), cx.meta());
+ pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?;
}
Edit::MarkAnnotation { page, index, marked, author } => {
pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?;
@@ -3548,6 +3586,7 @@ impl Session {
cos,
saved,
saved_keys: keys.clone(),
+ unsaved_file: false,
undo: Vec::new(),
redo: Vec::new(),
keys,
@@ -3845,7 +3884,7 @@ impl Session {
/// file is clean; undo past a later save is not, because the restored snapshot's own base
/// is an earlier revision.
fn sync_dirty(doc: &mut Document) {
- doc.dirty = doc.editor.as_ref().is_some_and(|e| e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved));
+ doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved));
}
/// Use the current state's password for viewing (protection edits change it).
@@ -3895,6 +3934,7 @@ impl Session {
doc.info.keywords = info("Keywords");
doc.info.creator = info("Creator");
doc.info.producer = info("Producer");
+ doc.info.custom = pdfcraft_organize::custom_info(&editor.cos);
doc.info.file_size = bytes.len();
if !doc.signatures.is_empty() {
let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache);
@@ -4071,11 +4111,16 @@ impl Session {
/// The bytes to write for Save: an incremental update of the file as opened/last saved,
/// with `/ModDate` stamped. Call `mark_saved` after writing them successfully.
+ ///
+ /// A file whose cross-reference data had to be rebuilt is written in full even when nothing
+ /// was edited: its bytes as opened are the damaged file, and this save is how the repair the
+ /// user was told about reaches the disk (#1937). Not a signed one: a full rewrite would
+ /// break its signatures, so with no edits it keeps its bytes, damage and all.
pub fn save_bytes(&self, id: DocId) -> Result>, EditError> {
let doc = self.get(id).ok_or(EditError::NoDocument)?;
let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) };
let on_saved = Arc::ptr_eq(editor.cos.bytes(), &editor.saved);
- if !editor.cos.is_modified() && on_saved {
+ if !editor.cos.is_modified() && on_saved && (!editor.cos.reconstructed() || doc.is_signed()) {
return Ok(editor.saved.clone());
}
let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() };
@@ -4130,6 +4175,7 @@ impl Session {
pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?;
editor.saved = bytes;
editor.saved_keys = keys;
+ editor.unsaved_file = false;
}
if let Some(p) = path {
doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone());
@@ -4193,7 +4239,7 @@ impl Session {
editor.undo.clear();
editor.redo.clear();
Self::adopt_keys(doc);
- doc.dirty = false;
+ Self::sync_dirty(doc);
doc.generation += 1;
Self::refresh(doc).inspect_err(|_| Self::show_released(doc))
}
@@ -4354,12 +4400,18 @@ impl Session {
};
return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string()));
}
- let cos = match doc.editor.as_ref() {
+ let mut cos = match doc.editor.as_ref() {
Some(e) => e.cos.clone(),
None => {
pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))?
}
};
+ // #3046: the layers print as they are shown. Once the Layers panel (or a set-layer
+ // action) has changed one, the print copy's default configuration says which are on, so a
+ // hidden layer stays off on paper too; untouched, the document's own configuration prints.
+ if !doc.config.layers.is_empty() {
+ set_default_layer_state(&mut cos, &doc.info.layers);
+ }
pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string()))
}
@@ -4667,6 +4719,9 @@ impl Session {
let id = self.open(name, None, bytes, None)?;
if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) {
d.dirty = true;
+ if let Some(e) = d.editor.as_mut() {
+ e.unsaved_file = true;
+ }
// Unsaved work with no edit yet still needs its first recovery snapshot.
d.generation += 1;
}
@@ -4719,6 +4774,9 @@ impl Session {
if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) {
d.path = path;
d.dirty = true;
+ if let Some(e) = d.editor.as_mut() {
+ e.unsaved_file = true;
+ }
d.generation += 1;
d.snapshot_generation = d.generation; // its bytes are already in the recovery store
}
@@ -4928,7 +4986,7 @@ fn embed_image(
name: &str,
bytes: &[u8],
) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> {
- pdfcraft_create::image_xobject(doc, name, &upright_image::upright(bytes))
+ pdfcraft_create::image_xobject(doc, name, bytes)
}
/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image.
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index ec305b1ca5..0f012d9e09 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -519,6 +519,63 @@ fn save_is_incremental_stamps_mod_date_and_rebases() {
assert_eq!(reopened.revisions().len(), 3);
}
+/// #1937: a file whose cross-reference data had to be rebuilt saved as its original bytes when
+/// nothing was edited, so the damage Document Properties reported as repaired was still in the
+/// file. Save writes the rebuilt file in full; reopened, it needs no repair.
+#[test]
+fn a_repaired_file_is_rewritten_by_save_even_without_edits() {
+ let good = fixture(2);
+ // Every offset in the table is off by the inserted line: the chain reads but leads nowhere.
+ let mut shifted = b"%PDF-1.7\n%shifted\n".to_vec();
+ shifted.extend_from_slice(&good[b"%PDF-1.7\n".len()..]);
+ // No cross-reference table at all.
+ let mut lost = good.clone();
+ lost.truncate(good.windows(9).rposition(|w| w == b"startxref").unwrap());
+ lost.extend_from_slice(b"startxref\n999999\n%%EOF\n");
+ for (name, damaged) in [("shifted", shifted), ("lost", lost)] {
+ let mut s = Session::new().with_clock(|| 1_700_000_000);
+ let id = s.open("broken.pdf", Some("/tmp/broken.pdf".into()), Arc::new(damaged.clone()), None).unwrap();
+ let doc = s.get(id).unwrap();
+ assert!(doc.repair_log().iter().any(|l| l.contains("rebuilt cross-reference index")), "{name}: {:?}", doc.repair_log());
+ assert_eq!(doc.info.pages.len(), 2, "{name}");
+ assert!(!doc.dirty, "{name}: a repair is not an edit");
+ let saved = s.save_bytes(id).unwrap();
+ assert_ne!(&saved[..], &damaged[..], "{name}: the damaged bytes are not what Save writes");
+ let reopened = pdfcraft_cos::Document::open(saved.clone()).unwrap();
+ assert!(reopened.repair_log().is_empty(), "{name}: {:?}", reopened.repair_log());
+ assert_eq!(reopened.revisions().len(), 1, "{name}");
+ s.mark_saved(id, saved.clone(), None).unwrap();
+ let doc = s.get(id).unwrap();
+ assert!(doc.repair_log().is_empty(), "{name}: {:?}", doc.repair_log());
+ assert_eq!(doc.info.pages.len(), 2, "{name}");
+ // Sound now: saving again without edits writes the same bytes.
+ assert_eq!(s.save_bytes(id).unwrap(), saved, "{name}");
+ }
+}
+
+/// A damaged file that is signed keeps its bytes on a Save without edits: rewriting it in full
+/// to put the repair on disk would invalidate its signatures without a word.
+#[test]
+fn a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits() {
+ let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
+ let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
+ // After the test certificate's start date (2026-10-02).
+ let mut s = Session::new().with_clock(|| 1_800_000_000);
+ let id = s.open("fixture.pdf", None, Arc::new(fixture(1)), None).unwrap();
+ let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() };
+ let signed = s.sign(id, &digital_id, opts).unwrap();
+ // A trailing `startxref` that points nowhere: the signed revision's bytes are untouched, but
+ // the file's cross-reference data has to be rebuilt.
+ let mut damaged = signed.to_vec();
+ damaged.extend_from_slice(b"\nstartxref\n999999\n%%EOF\n");
+ let mut t = Session::new().with_clock(|| 1_800_000_000);
+ let tid = t.open("damaged.pdf", Some("/tmp/damaged.pdf".into()), Arc::new(damaged.clone()), None).unwrap();
+ let doc = t.get(tid).unwrap();
+ assert!(doc.repair_log().iter().any(|l| l.contains("rebuilt cross-reference index")), "{:?}", doc.repair_log());
+ assert!(doc.is_signed());
+ assert_eq!(&t.save_bytes(tid).unwrap()[..], &damaged[..], "a signed file is not rewritten without edits");
+}
+
#[test]
fn undo_still_works_after_save() {
let (mut s, id) = session_with(2);
@@ -813,7 +870,7 @@ fn form_with_field(name: &str) -> Vec {
fn duplicating_a_page_with_a_field_keeps_the_field_name() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("a.pdf", None, Arc::new(form_with_field("Text1")), None).unwrap();
- s.apply(id, Edit::DuplicatePages { pages: vec![0] }).unwrap();
+ s.apply(id, Edit::DuplicatePages { pages: vec![0], at: None }).unwrap();
let doc = s.get(id).unwrap();
assert_eq!(doc.info.pages.len(), 2);
assert!(doc.form.iter().all(|f| f.name == "Text1"), "{:?}", doc.form.iter().map(|f| &f.name).collect::>());
@@ -1186,6 +1243,22 @@ fn the_permissions_password_unlocks_an_open_document() {
assert!(pdfcraft_cos::Document::open(plain).unwrap().security().is_none(), "security removed");
}
+#[test]
+fn remove_security_from_a_file_with_its_encrypt_dictionary_in_the_trailer() {
+ // MuPDF writes /Encrypt inline in the trailer rather than as an object (#2661).
+ let mut doc = pdfcraft_cos::Document::open_with_password(protected("user", "owner", -1), Some("owner")).unwrap();
+ let r = doc.trailer().reference(b"Encrypt").unwrap();
+ let dict = doc.get(r).as_dict().cloned().unwrap();
+ doc.trailer_mut().set(b"Encrypt".to_vec(), pdfcraft_cos::Object::Dict(dict));
+ let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap());
+ let mut s = Session::new();
+ let id = s.open("m.pdf", None, bytes, Some("owner")).unwrap();
+ s.apply(id, Edit::RemoveProtection).unwrap();
+ assert!(!s.get(id).unwrap().info.encrypted);
+ let plain = s.save_bytes(id).unwrap();
+ assert!(pdfcraft_cos::Document::open(plain).unwrap().security().is_none(), "security removed");
+}
+
#[test]
fn unlocking_needs_the_permissions_password_not_the_open_password() {
let mut s = Session::new();
@@ -1987,6 +2060,59 @@ fn oversized_redaction_overlay_preserves_the_document_and_undo() {
assert_eq!(page_texts(&s, id), ["Page 1"]);
}
+/// #3046: a layer hidden in the Layers panel stays off on paper, as it is on screen; with the
+/// layers untouched, the document's own configuration prints.
+#[test]
+fn printing_keeps_hidden_layers_hidden() {
+ let content = b"/OC /A BDC 1 0 0 rg 10 10 80 80 re f EMC /OC /B BDC 0 1 0 rg 110 10 80 80 re f EMC";
+ let objs: Vec> = vec![
+ b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 6 0 R] >> >> >>".to_vec(),
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R /Resources << /Properties << /A 5 0 R /B 6 0 R >> >> >>".to_vec(),
+ [format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content, b"\nendstream"].concat(),
+ b"<< /Type /OCG /Name (Layer A) >>".to_vec(),
+ b"<< /Type /OCG /Name (Layer B) >>".to_vec(),
+ ];
+ let mut pdf = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offsets.push(pdf.len());
+ pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
+ pdf.extend_from_slice(o);
+ pdf.extend_from_slice(b"\nendobj\n");
+ }
+ let xref = pdf.len();
+ pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offsets {
+ pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+
+ // Red and green pixels on the first printed sheet.
+ let printed = |s: &Session, id: DocId| {
+ let bytes = s.print_pdf(id, &print::Settings { pages: vec![0], ..Default::default() }).unwrap();
+ let mut out = Session::new();
+ let printed = out.open("print.pdf", None, Arc::new(bytes), None).unwrap();
+ let page = render_page(&out, printed);
+ let px = page.rgba.as_chunks::<4>().0;
+ let red = px.iter().filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count();
+ let green = px.iter().filter(|p| p[1] > 200 && p[0] < 60 && p[2] < 60).count();
+ (red, green)
+ };
+ let mut s = Session::new();
+ let id = s.open("layers.pdf", None, Arc::new(pdf), None).unwrap();
+ let (red, green) = printed(&s, id);
+ assert!(red > 100 && green > 100, "untouched layers both print: {red} red, {green} green");
+ let a = s.get(id).unwrap().info.layers.iter().position(|l| l.name == "Layer A").unwrap();
+ assert!(s.set_layer_visible(id, a, false));
+ let (red, green) = printed(&s, id);
+ assert_eq!(red, 0, "Layer A was hidden, so it doesn't print");
+ assert!(green > 100, "Layer B still prints: {green} green");
+ assert!(s.set_layer_visible(id, a, true));
+ let (red, _) = printed(&s, id);
+ assert!(red > 100, "shown again, Layer A prints again: {red} red");
+}
+
#[test]
fn printing_lays_out_sheets_that_render() {
let (s, id) = session_with(5);
@@ -2057,7 +2183,7 @@ fn crop_and_duplicate_pages_show_in_the_viewer() {
assert_eq!(d.info.pages[0].crop, [10.0, 20.0, 170.0, 260.0]);
assert_eq!((d.info.pages[0].width, d.info.pages[0].height), (160.0, 240.0), "the viewer shows the cropped size");
assert_eq!(d.page_boxes()[1][1], [0.0, 0.0, 200.0, 300.0]);
- s.apply(id, Edit::DuplicatePages { pages: vec![1] }).unwrap();
+ s.apply(id, Edit::DuplicatePages { pages: vec![1], at: None }).unwrap();
assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 2"]);
assert_eq!(s.get(id).unwrap().can_undo(), Some("Duplicate page"));
}
@@ -3286,6 +3412,35 @@ fn recognize_text_reads_the_euro_sign() {
assert!(text.contains("€250"), "{text}");
}
+const SYNTHETIC_SCAN_DPI: f64 = 300.0;
+
+/// A scan of the OCR sentence at the source page's physical size, without a text layer.
+fn synthetic_ocr_scan(s: &mut Session) -> (DocId, [f32; 2]) {
+ let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap();
+ let id = s.open("text.pdf", None, text, None).unwrap();
+ let original = &s.get(id).unwrap().info.pages[0];
+ let size = [original.width, original.height];
+ // A typical 300 DPI scan keeps the small leading glyph in "quick" legible to OCR.
+ let png = export::Exporter::new(s.get(id).unwrap()).png(0, SYNTHETIC_SCAN_DPI).unwrap();
+ // Exported PNGs have no resolution metadata. The 72 DPI import default would enlarge
+ // the page and change the PDF-point coordinates the placement assertions exercise.
+ let scan = s.create_from_images_with_resolution(&[("scan.png".into(), png)], ImageResolution::Dpi(SYNTHETIC_SCAN_DPI)).unwrap();
+ (s.open("scan.pdf", None, scan, None).unwrap(), size)
+}
+
+#[test]
+fn synthetic_ocr_scan_keeps_the_original_page_size_without_models() {
+ let mut s = Session::new().with_clock(|| 1_700_000_000);
+ let (id, expected) = synthetic_ocr_scan(&mut s);
+ let page = &s.get(id).unwrap().info.pages[0];
+ // Rounding to whole raster pixels may change a page edge by at most one pixel.
+ let tolerance = 72.0 / SYNTHETIC_SCAN_DPI;
+ for (actual, expected) in [page.width, page.height].into_iter().zip(expected) {
+ assert!(f64::from((actual - expected).abs()) <= tolerance, "page edge {actual} pt, expected {expected} pt");
+ }
+ assert_eq!(page_texts(&s, id), [""], "the scan has no text until OCR runs");
+}
+
#[test]
fn recognize_text_makes_a_scanned_page_searchable() {
if !ocr::available() {
@@ -3293,12 +3448,7 @@ fn recognize_text_makes_a_scanned_page_searchable() {
return;
}
let mut s = Session::new().with_clock(|| 1_700_000_000);
- let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap();
- let id = s.open("text.pdf", None, text, None).unwrap();
- // A typical 300 DPI scan keeps the small leading glyph in "quick" legible to OCR.
- let png = export::Exporter::new(s.get(id).unwrap()).png(0, 300.0).unwrap();
- let scan = s.create_from_images(&[("scan.png".into(), png)]).unwrap();
- let id = s.open("scan.pdf", None, scan, None).unwrap();
+ let (id, _) = synthetic_ocr_scan(&mut s);
assert_eq!(page_texts(&s, id), [""], "a picture has no text");
let found = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap();
@@ -3638,6 +3788,34 @@ fn detecting_fields_on_a_scanned_form() {
}
}
+#[test]
+fn detecting_comb_boxes_adds_one_comb_field_per_row() {
+ // The rows from #1887: five 18 x 22 cells and ten 16 x 20 cells, each a stroked `re`.
+ let mut body = String::from("BT /F1 10 Tf 40 205 Td (Postcode A) Tj ET BT /F1 10 Tf 40 105 Td (Postcode B) Tj ET\n");
+ for i in 0..5 {
+ body.push_str(&format!("{} 200 18 22 re S\n", 120 + i * 18));
+ }
+ for i in 0..10 {
+ body.push_str(&format!("{} 100 16 20 re S\n", 120 + i * 16));
+ }
+ let mut s = Session::new().with_clock(|| 1_700_000_000);
+ let id = s.open("comb.pdf", None, Arc::new(one_line_page("/Font << /F1 4 0 R >>", &body)), None).unwrap();
+ let found: Vec<(String, pdfcraft_forms::detect::Kind)> = s.detect_fields(id, &[]).into_iter().map(|(_, c)| (c.name, c.kind)).collect();
+ assert_eq!(
+ found,
+ [
+ ("Postcode A".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 5 }),
+ ("Postcode B".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 10 })
+ ]
+ );
+ s.auto_detect_fields(id, &[]).unwrap();
+ let d = s.get(id).unwrap();
+ let combs: Vec<(&str, FormFieldKind, bool, Option)> =
+ d.form.iter().map(|f| (f.name.as_str(), f.kind, f.has(pdfcraft_forms::flags::COMB), f.max_len)).collect();
+ assert_eq!(combs, [("Postcode A", FormFieldKind::Text, true, Some(5)), ("Postcode B", FormFieldKind::Text, true, Some(10))]);
+ assert_eq!(d.can_undo(), Some("Detect form fields"));
+}
+
#[test]
fn comparing_two_versions_of_a_document() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
@@ -5801,3 +5979,88 @@ fn cropping_an_existing_image_clips_it_and_moves_with_it() {
// No area, or a box off the image, is refused.
assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([10.0, 10.0, 10.0, 50.0]) }).is_err());
}
+
+/// A 60 x 20 JPEG at 144 x 72 dpi, tagged with EXIF Orientation 6 (shown turned 90 degrees clockwise).
+fn phone_jpeg() -> Vec {
+ use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit};
+ let mut jpeg = Vec::new();
+ let mut encoder = JpegEncoder::new(&mut jpeg);
+ encoder.set_pixel_density(PixelDensity { density: (144, 72), unit: PixelDensityUnit::Inches });
+ encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap();
+ let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
+ exif.extend_from_slice(&[6, 0, 0, 0, 0, 0, 0, 0]);
+ let mut out = jpeg[..2].to_vec();
+ out.extend_from_slice(&[0xff, 0xe1]);
+ out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
+ out.extend_from_slice(&exif);
+ out.extend_from_slice(&jpeg[2..]);
+ out
+}
+
+#[test]
+fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() {
+ let mut session = Session::new();
+ let blank = session.create_blank(300.0, 400.0, 1).unwrap();
+ let id = session.open_new("form.pdf", blank).unwrap();
+ // A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright.
+ // Named "Signature" so `signature_image` finds its picture.
+ let file = MarkFile { name: "photo.jpg".into(), bytes: Arc::new(phone_jpeg()), page: 0 };
+ let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() };
+ session.apply(id, stamp).unwrap();
+ let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos;
+ let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap();
+ let obj = cos.get(picture);
+ let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() };
+ assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60)));
+ let rect = pdfcraft_annot::summaries(cos)[0].rect;
+ assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}");
+}
+
+/// Opening a paragraph and applying its edit read the page's text blocks; after an edit they are
+/// still read once per document state, and undo and redo never get another state's blocks.
+#[test]
+fn text_blocks_stay_cached_after_an_edit_and_follow_undo_and_redo() {
+ let (mut session, id) = session_with(1);
+ let blocks = |s: &Session| s.get(id).unwrap().editor.as_ref().unwrap().text_blocks(0).unwrap();
+ let before = blocks(&session);
+ assert_eq!(before[0].editor_text, "Page 1");
+ let edit = Edit::EditTextBlock { page: 0, block: 0, text: "Edited".into(), style: pdfcraft_edit::BlockStyle::default() };
+ session.apply(id, edit).unwrap();
+ let edited = blocks(&session);
+ assert_eq!(edited[0].editor_text, "Edited");
+ assert!(Arc::ptr_eq(&edited, &blocks(&session)), "the edited page is grouped once");
+ session.undo(id).unwrap();
+ assert_eq!(blocks(&session)[0].editor_text, "Page 1");
+ session.redo(id).unwrap();
+ let redone = blocks(&session);
+ assert_eq!(redone[0].editor_text, "Edited");
+ assert!(Arc::ptr_eq(&redone, &blocks(&session)));
+}
+
+/// A new document, or one restored from recovery, has never been written where it belongs: undo
+/// back to the bytes it was opened from must not make it clean, or closing it would discard the
+/// whole document without asking. Saving makes it clean; Revert of a saved one too.
+#[test]
+fn undo_never_makes_an_unsaved_document_clean() {
+ let mut s = Session::new().with_clock(|| 1_700_000_000);
+ let new = s.open_new("Combined.pdf", Arc::new(fixture(2))).unwrap();
+ s.apply(new, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
+ s.undo(new).unwrap();
+ assert!(s.get(new).unwrap().dirty, "a new document is unsaved until it is saved");
+ assert_eq!(s.autosave_snapshots().len(), 1, "and still needs recovery");
+ s.revert(new).unwrap();
+ assert!(s.get(new).unwrap().dirty, "Revert goes back to the created bytes, which aren't saved anywhere");
+
+ let recovered = s.open("Report.pdf", None, Arc::new(fixture(2)), None).unwrap();
+ s.mark_recovered(recovered, Some("Report.pdf".into()));
+ s.apply(recovered, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
+ s.undo(recovered).unwrap();
+ assert!(s.get(recovered).unwrap().dirty, "the recovered work was never saved");
+
+ let saved = s.save_bytes(new).unwrap();
+ s.mark_saved(new, saved, Some("Combined.pdf".into())).unwrap();
+ assert!(!s.get(new).unwrap().dirty);
+ s.apply(new, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
+ s.undo(new).unwrap();
+ assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean");
+}
diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs
index 16dd2b06d2..30299b2d76 100644
--- a/crates/export/src/lib.rs
+++ b/crates/export/src/lib.rs
@@ -114,6 +114,34 @@ const COL_TOL: f64 = 4.0;
/// its columns, so a hostile page laid out as a staircase of blocks would otherwise make
/// (blocks / 2)² cells.
const MAX_COLS: usize = 63;
+/// Two blocks in a row this close (as a fraction of their font size) are one line of running
+/// text that changed font or colour, such as a link inside a sentence, not two table cells.
+const INLINE_GAP: f64 = 0.5;
+/// How many blocks ending near a block's start are looked at to find the one it continues; a
+/// page laid out so that many end at the same place stays quick.
+const CONTINUATION_PROBE: usize = 64;
+/// Per block, whether it continues another on the same line: it starts where that one ends (up to
+/// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running
+/// on (a link inside a sentence, a superscript), not a cell of its own.
+fn continuations(blocks: &[Block]) -> Vec {
+ let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect();
+ by_end.sort_by(|a, b| a.0.total_cmp(&b.0));
+ blocks
+ .iter()
+ .enumerate()
+ .map(|(bi, b)| {
+ let k = by_end.partition_point(|(end, _)| *end < b.rect[0] - INLINE_GAP * b.size);
+ by_end
+ .get(k..)
+ .unwrap_or_default()
+ .iter()
+ .take_while(|(end, _)| *end <= b.rect[0] + 1.0)
+ .take(CONTINUATION_PROBE)
+ .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi))
+ .any(|a| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]))
+ })
+ .collect()
+}
/// A cell is short: taller blocks are body text (or a multi-column layout), not table cells.
fn is_cell_like(b: &Block) -> bool {
(b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty()
@@ -264,13 +292,20 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec, Vec) {
es.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL);
es
};
+ let cont = continuations(blocks);
let qual: Vec = rows
.iter()
.map(|row| {
- let mut cs: Vec = row.iter().map(|(c, _)| *c).collect();
+ // A block that continues another adds no column of its own, and the cells of a table row
+ // sit side by side: blocks that overlap are a paragraph's lines, not cells (#2806).
+ let cells: Vec<(usize, usize)> = row.iter().copied().filter(|(_, bi)| !cont.get(*bi).copied().unwrap_or(false)).collect();
+ let mut spans: Vec<[f64; 2]> = cells.iter().filter_map(|(_, bi)| blocks.get(*bi)).map(|b| [b.rect[0], b.rect[2]]).collect();
+ spans.sort_by(|a, b| a[0].total_cmp(&b[0]));
+ let apart = spans.windows(2).all(|w| w[1][0] >= w[0][1] - COL_TOL);
+ let mut cs: Vec = cells.iter().map(|(c, _)| *c).collect();
cs.sort_unstable();
cs.dedup();
- cs.len() >= 2
+ apart && cs.len() >= 2
})
.collect();
let mut i = 0;
@@ -1186,6 +1221,75 @@ mod tests {
assert_eq!(grid_cols(&grid(64)), 0);
}
+ fn block(t: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> Block {
+ Block { text: t.into(), rect: [x0, y0, x1, y1], size: 12.0, bold: false, italic: false, color: [0.0; 3], rotation: 0.0 }
+ }
+
+ /// #2806: a sentence whose link is set in another colour or font runs on as separate blocks
+ /// on one line. They are not table columns, even when several such lines share left edges.
+ #[test]
+ fn a_link_inside_a_sentence_is_not_a_table_column() {
+ let blue = |t: &str, x0: f64, y0: f64, x1: f64, y1: f64| Block { color: [0.02, 0.388, 0.757], ..block(t, x0, y0, x1, y1) };
+ let p = Page {
+ width: 612.0,
+ height: 792.0,
+ blocks: vec![
+ block("6.3. 2. All content shown at", 121.7, 710.3, 272.0, 724.9),
+ blue("http", 273.5, 710.3, 294.1, 724.9),
+ blue("s", 294.1, 709.8, 298.8, 725.8),
+ blue("://women", 298.8, 710.3, 348.0, 724.9),
+ blue("g", 348.1, 709.8, 353.7, 725.8),
+ blue("randprix.fide.com", 353.7, 710.3, 443.2, 724.9),
+ block("shall be reviewed and approved by the Press Officer.", 164.3, 622.5, 533.1, 724.9),
+ block("6.3. 3. The Organiser shall warrant that", 121.7, 601.8, 345.1, 616.5),
+ blue("https://womengrandprix.fide.com", 350.2, 601.3, 519.9, 617.4),
+ block("is managed in a professional way and furthermore that it is capable of", 164.3, 557.9, 533.0, 616.5),
+ block("6. 3. 4. The Press Officer shall report to the FIDE Head of PR.", 121.7, 516.6, 530.3, 551.9),
+ block("conducted during a WGP tournament.", 164.3, 502.0, 352.5, 516.6),
+ ],
+ images: Vec::new(),
+ };
+ let h = html(std::slice::from_ref(&p), "Regulations");
+ assert!(!h.contains(""), "running text became a table: {h}");
+ for b in &p.blocks {
+ assert!(h.contains(&b.text), "{} was lost: {h}", b.text);
+ }
+ }
+
+ /// #2806: lines that each run on into a link starting at the same place are sentences, not a
+ /// two-column table, even three in a row.
+ #[test]
+ fn sentences_that_run_on_into_links_are_not_a_table() {
+ let blue = |t: &str, x0: f64, y0: f64, x1: f64, y1: f64| Block { color: [0.02, 0.388, 0.757], ..block(t, x0, y0, x1, y1) };
+ let mut blocks = Vec::new();
+ for (n, lead) in ["Rules are published at", "Pairings are posted at", "Results are listed at"].iter().enumerate() {
+ let y = 700.0 - 16.0 * n as f64;
+ blocks.push(block(lead, 72.0, y, 211.0, y + 12.0));
+ blocks.push(blue("https://example.org/event", 212.5, y, 360.0, y + 12.0));
+ }
+ let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new() };
+ let h = html(std::slice::from_ref(&p), "Event");
+ assert!(!h.contains(""), "sentences became a table: {h}");
+ assert_eq!(h.matches("https://example.org/event").count(), 3, "{h}");
+ }
+
+ /// #2806: a superscript right after a number ("1" "st") continues its cell; the prize table
+ /// around it stays a table.
+ #[test]
+ fn a_superscript_beside_a_number_keeps_its_table() {
+ let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)];
+ for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd"), ("4", "th")].iter().enumerate() {
+ let y = 680.0 - 20.0 * n as f64;
+ blocks.push(block(row, 100.0, y, 106.0, y + 12.0));
+ blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) });
+ blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0));
+ }
+ let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new() };
+ let h = html(std::slice::from_ref(&p), "Prizes");
+ assert!(h.contains(""), "the prize table was lost: {h}");
+ assert!(h.contains("30,000") && h.contains("15,000"), "{h}");
+ }
+
#[test]
fn two_column_body_text_is_not_mistaken_for_a_table() {
let para = |t: &str, x: f64, y: f64| Block {
diff --git a/crates/fonts/src/embedding.rs b/crates/fonts/src/embedding.rs
index 84801d5fd2..a5bb011e00 100644
--- a/crates/fonts/src/embedding.rs
+++ b/crates/fonts/src/embedding.rs
@@ -195,7 +195,7 @@ pub fn embed_mapping(
// page; source subset programs stay untouched.
let existing = written(doc, fonts).find(|w| w.has_program(doc, bytes)).map(|w| w.program).or_else(|| {
doc.scan_objects().take(8192).find_map(|(r, o)| match o.as_ref() {
- Object::Stream(s) if s.dict.get(b"PCFullFont").is_some() && s.raw.as_ref() == bytes => Some(Object::Ref(r)),
+ Object::Stream(s) if is_program(doc, s, bytes) => Some(Object::Ref(r)),
_ => None,
})
});
@@ -206,7 +206,7 @@ pub fn embed_mapping(
d.set(b"Subtype".to_vec(), Object::name("OpenType"));
}
d.set(b"PCFullFont".to_vec(), Object::Bool(true));
- Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, bytes.to_vec()))))
+ Object::Ref(doc.add(Object::Stream(Stream::flate(d, bytes))))
});
descriptor.set(if cff { b"FontFile3".to_vec() } else { b"FontFile2".to_vec() }, program);
let desc = Object::Ref(doc.add(Object::Dict(descriptor)));
@@ -264,15 +264,20 @@ struct Written {
}
impl Written {
- /// Whether its program is `bytes`. The length it was written with is compared before decoding.
+ /// Whether its program is `bytes`.
fn has_program(&self, doc: &Document, bytes: &[u8]) -> bool {
- let Object::Stream(s) = doc.resolve(&self.program).as_ref().clone() else { return false };
- s.dict.get(b"PCFullFont").is_some()
- && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(bytes.len()).ok()
- && s.decoded_within(17 << 20).is_ok_and(|data| data == bytes)
+ matches!(doc.resolve(&self.program).as_ref(), Object::Stream(s) if is_program(doc, s, bytes))
}
}
+/// Whether `s` is a full program written for `bytes`, compressed or not. The length it was
+/// written with is compared before decoding.
+fn is_program(doc: &Document, s: &Stream, bytes: &[u8]) -> bool {
+ s.dict.get(b"PCFullFont").is_some()
+ && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(bytes.len()).ok()
+ && s.decoded_within(17 << 20).is_ok_and(|data| data == bytes)
+}
+
/// The `PCFull` resources in `fonts`, in order.
fn written<'a>(doc: &'a Document, fonts: &'a Dict) -> impl Iterator- + 'a {
fonts.iter().filter(|(key, _)| key.starts_with(b"PCFull")).take(256).filter_map(move |(key, value)| {
@@ -379,4 +384,31 @@ mod tests {
assert!(!fonts.contains(medium.as_bytes()), "{medium} is new");
assert_eq!(programs(&doc), 2);
}
+
+ #[test]
+ fn the_full_program_is_written_compressed_and_another_page_reuses_it() {
+ let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
+ let mut doc = Document::new_empty();
+ let mut first = Dict::new();
+ embed(&mut doc, &mut first, bytes, "Inter").unwrap();
+ let program = || {
+ doc.scan_objects()
+ .find_map(|(_, o)| match o.as_ref() {
+ Object::Stream(s) if s.dict.get(b"PCFullFont").is_some() => Some(s.clone()),
+ _ => None,
+ })
+ .unwrap()
+ };
+ let stream = program();
+ assert_eq!(stream.dict.name(b"Filter"), Some(b"FlateDecode".as_slice()));
+ assert!(stream.raw.len() < bytes.len(), "{} of {} bytes", stream.raw.len(), bytes.len());
+ assert_eq!(stream.decoded().unwrap(), bytes);
+ // Another page's resources don't have it, so it is found among the document's objects.
+ let objects = doc.scan_objects().count();
+ let mut second = Dict::new();
+ embed(&mut doc, &mut second, bytes, "Inter").unwrap();
+ let programs = doc.scan_objects().filter(|(_, o)| matches!(o.as_ref(), Object::Stream(s) if s.dict.get(b"PCFullFont").is_some())).count();
+ assert_eq!(programs, 1, "one program for both pages");
+ assert!(doc.scan_objects().count() > objects, "the second page gets its own resource");
+ }
}
diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs
index cd0d8358e5..a1f2ff2779 100644
--- a/crates/fonts/src/lib.rs
+++ b/crates/fonts/src/lib.rs
@@ -80,13 +80,19 @@ pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64)
let mut lines = Vec::new();
for para in text.split(['\n', '\r']) {
let mut line = String::new();
+ // No word is on the line yet; an empty word (a leading space) still starts it.
+ let mut fresh = true;
for word in para.split(' ') {
- let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
- if measure(&candidate, size) <= width || line.is_empty() && measure(word, size) <= width {
+ let candidate = if fresh { word.to_string() } else { format!("{line} {word}") };
+ if measure(&candidate, size) <= width || fresh && measure(word, size) <= width {
line = candidate;
+ fresh = false;
continue;
}
- if !line.is_empty() {
+ // Indentation alone is not a line: drop it before an over-long word.
+ if line.trim().is_empty() {
+ line.clear();
+ } else {
lines.push(std::mem::take(&mut line));
}
for ch in word.chars() {
@@ -95,6 +101,7 @@ pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64)
}
line.push(ch);
}
+ fresh = line.is_empty();
}
lines.push(line);
}
@@ -256,6 +263,20 @@ pub fn literal(bytes: &[u8]) -> Vec
{
mod tests {
use super::*;
+ #[test]
+ fn wrap_keeps_leading_spaces() {
+ assert_eq!(wrap(" indented", 11.0, 500.0), [" indented"]);
+ assert_eq!(wrap(" a b\n c", 11.0, 500.0), [" a b", " c"]);
+ assert_eq!(wrap("", 11.0, 500.0), [""]);
+ // A single leading space before a word that must be broken leaves no blank first line.
+ let broken = wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0);
+ assert!(broken.len() > 1 && broken.iter().all(|l| !l.is_empty()), "{broken:?}");
+ // So does indentation of two or more spaces: no whitespace-only line, same as one space.
+ let broken = wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0);
+ assert!(broken.len() > 1 && broken.iter().all(|l| !l.trim().is_empty()), "{broken:?}");
+ assert_eq!(broken, wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0));
+ }
+
#[test]
fn win_ansi_encodability_matches_the_encoder() {
for c in ['a', 'é', 'ÿ', '€', '—', '™', '\t', '?'] {
diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs
index 0cc456cba6..26685866b3 100644
--- a/crates/fonts/src/pdf.rs
+++ b/crates/fonts/src/pdf.rs
@@ -1,8 +1,8 @@
//! What content editing and redaction need from a PDF font: how to split a string into character
//! codes, each code's advance width, the glyph height (ascent, descent), and what each code means
//! (Unicode) — and back, which text the font can show. Widths come from the font dictionary
-//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); a standard-14 font with no `/Widths` uses the exact
-//! published metrics ([`crate::Std14`]).
+//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); a simple font with no `/Widths` uses its embedded
+//! sfnt program's advances, else the exact published standard-14 metrics ([`crate::Std14`]).
//!
//! Meanings come from `/ToUnicode`, else the encoding (`/Encoding` base and `/Differences` glyph
//! names, ISO 32000-2 Annex D). A code may deliberately have **no** meaning: where the font's own
@@ -167,6 +167,44 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec {
}
}
+/// A simple font with no `/Widths` is drawn (and found by text search) with its embedded
+/// program's own advances, so measure with them too; a standard-14 guess from the name can be
+/// wider and leave the end of a line outside a redaction mark (#2130). The glyph is looked up
+/// the way the renderer does: the encoding's character in the Unicode cmap, else the code itself
+/// (as in a symbolic cmap), else `.notdef`. Only sfnt programs are read; others keep the guess.
+fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) {
+ use skrifa::instance::{LocationRef, Size};
+ use skrifa::{GlyphId, MetadataProvider as _, raw::TableProvider as _};
+ let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return };
+ for key in [b"FontFile2".as_slice(), b"FontFile3".as_slice()] {
+ let Some(object) = descriptor.get(key) else { continue };
+ let object = doc.resolve(object);
+ let Object::Stream(stream) = object.as_ref() else { continue };
+ let Ok(bytes) = stream.decoded_within(16 << 20) else { continue };
+ let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue };
+ let upem = f64::from(face.metrics(Size::unscaled(), LocationRef::default()).units_per_em);
+ if upem == 0.0 {
+ continue;
+ }
+ let advances = face.glyph_metrics(Size::unscaled(), LocationRef::default());
+ let (charmap, cmap) = (face.charmap(), face.cmap().ok());
+ let found = |g: Option| g.filter(|g| *g != GlyphId::NOTDEF);
+ m.widths = (0..=255u32)
+ .map(|code| {
+ let glyph = found(m.unicode.encoding.get(&code).and_then(|t| only_char(t)).and_then(|c| charmap.map(c)))
+ .or_else(|| [0, 0xF000, 0xF100, 0xF200].into_iter().find_map(|o| found(cmap.as_ref()?.map_codepoint(code + o))));
+ // A subset holds only the glyphs it draws: 0 keeps `has_glyph` false for the rest.
+ match glyph {
+ None if m.subset => 0.0,
+ _ => advances.advance_width(glyph.unwrap_or(GlyphId::NOTDEF)).map_or(0.0, |w| f64::from(w) * 1000.0 / upem),
+ }
+ })
+ .collect();
+ m.first = 0;
+ return;
+ }
+}
+
/// Replace an existing cmap directory entry with a bounded Unicode format-12 table.
/// Checked ranges prevent damaged sfnt offsets from indexing outside the source program.
fn append_editor_cmap(bytes: &mut Vec, mappings: &std::collections::BTreeMap) -> Option<()> {
@@ -607,6 +645,9 @@ impl Metrics {
Codes::Ranges(r) => r.last().map_or(2, |x| x.0),
};
m.unicode = unicode_map(doc, font, m.composite);
+ if m.std14.is_some() {
+ embedded_widths(doc, font, &mut m);
+ }
let mut reverse = HashMap::new();
for (&code, text) in m.unicode.explicit.iter().map(|(c, (_, t))| (c, t)).chain(m.unicode.encoding.iter()) {
if let Some(ch) = only_char(text)
@@ -798,6 +839,11 @@ impl Metrics {
fn has_glyph(&self, code: u32) -> bool {
if self.composite {
let cid = self.cid(code);
+ // CID 0 is `.notdef` (an unmapped code lands there too), whatever ToUnicode says: Word
+ // maps it to a space and gives it a width, and typing with it draws boxes (#3223).
+ if cid == 0 {
+ return false;
+ }
// A CID /W leaves out takes /DW, and Skia (Chrome) leaves out every CID of that width: for
// those, only the subset's program says whether the glyph is there.
return self.cid_widths.contains_key(&cid)
@@ -1218,6 +1264,37 @@ mod tests {
assert!(embedded_sfnt(&doc, &Dict::new()).is_none());
}
+ /// #2130: with no `/Widths`, an embedded program's advances win over a guess from the name
+ /// ("Mono" would mean Courier, 600 units for every glyph).
+ #[test]
+ fn a_font_without_widths_measures_with_its_embedded_program() {
+ use pdfcraft_cos::Stream;
+ use skrifa::MetadataProvider as _;
+ let mut doc = Document::new_empty();
+ let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
+ let face = skrifa::FontRef::new(bytes).unwrap();
+ let (size, location) = (skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default());
+ let upem = f64::from(face.metrics(size, location).units_per_em);
+ let advance = |c: char| f64::from(face.glyph_metrics(size, location).advance_width(face.charmap().map(c).unwrap()).unwrap()) / upem;
+ let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes)));
+ let mut descriptor = Dict::new();
+ descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
+ let f = font(
+ &mut doc,
+ vec![
+ ("Subtype", Object::name("TrueType")),
+ ("BaseFont", Object::name("HouseMono")),
+ ("Encoding", Object::name("WinAnsiEncoding")),
+ ("FontDescriptor", Object::Dict(descriptor)),
+ ],
+ );
+ let m = Metrics::from_dict(&doc, &f);
+ for c in ['i', 'l', 'W', ' '] {
+ assert!((m.width(u32::from(c)) - advance(c)).abs() < 1e-9, "{c}: {} vs {}", m.width(u32::from(c)), advance(c));
+ }
+ assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6");
+ }
+
#[test]
fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() {
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
@@ -1384,6 +1461,34 @@ mod tests {
assert_eq!(m.encode("Z"), None);
}
+ /// Word's Type 0 subsets map CID 0 as well as the real space glyph to U+0020 and give CID 0 a
+ /// width; CID 0 is `.notdef`, so a typed space takes the real one (#3223).
+ #[test]
+ fn composite_fonts_never_encode_with_cid_0() {
+ let mut doc = Document::new_empty();
+ let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \
+ 1 beginbfrange <0000> <0002> [<0020> <004D> <00E1>] endbfrange \
+ 1 beginbfrange <0005> <0007> [<0065> <0072> <0020>] endbfrange end";
+ let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec())));
+ let mut desc = Dict::new();
+ desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2"));
+ let widths = [889, 444, 389, 278, 444, 333, 250].map(Object::Int).to_vec();
+ desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Int(778)]), Object::Int(1), Object::Array(widths)]));
+ let f = font(
+ &mut doc,
+ vec![
+ ("Subtype", Object::name("Type0")),
+ ("BaseFont", Object::name("BCDEEE+TimesNewRomanPSMT")),
+ ("Encoding", Object::name("Identity-H")),
+ ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])),
+ ("ToUnicode", Object::Ref(tu)),
+ ],
+ );
+ let m = Metrics::from_dict(&doc, &f);
+ assert_eq!(m.encode("Me er").as_deref(), Some(&[0, 1, 0, 5, 0, 7, 0, 5, 0, 6][..]));
+ assert_eq!(m.decode(&[0, 1, 0, 7, 0, 2]), "M á");
+ }
+
/// Chrome's subsets leave the glyphs of `/DW` width out of `/W` (#1443): "s" (CID 0x56) is
/// still in the font, but nothing is when the font gives no default width to point at.
#[test]
@@ -1682,7 +1787,8 @@ mod tests {
assert_eq!(got, expected.get(&code).cloned(), "code {code:#06x} in {cmap}");
}
let mut reverse: HashMap = HashMap::new();
- for (&code, text) in &expected {
+ // Code 0 is CID 0, `.notdef`, which no character is encoded with.
+ for (&code, text) in expected.iter().filter(|&(&code, _)| code != 0) {
if let Some(c) = only_char(text) {
keep(&mut reverse, c, code);
}
diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs
index b50ed4c1ed..129f7fa751 100644
--- a/crates/forms/src/af.rs
+++ b/crates/forms/src/af.rs
@@ -587,7 +587,9 @@ pub fn parse_date(text: &str, fmt: &str) -> Option {
return None;
}
let lower = t.to_lowercase();
- let pm = lower.contains("pm") || lower.ends_with('p');
+ // A bare trailing "p" ("5:30p") only counts when it follows a digit, not a word like "Sep".
+ let bare_p = lower.strip_suffix('p').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit()));
+ let pm = lower.contains("pm") || bare_p;
let am = lower.contains("am");
// Tokens: numbers and month names.
let mut nums: Vec = Vec::new();
@@ -1285,3 +1287,25 @@ mod never_crash_tests {
let _ = format_date(dt, "dddd ddd");
}
}
+
+#[cfg(test)]
+mod pm_marker_tests {
+ use super::*;
+
+ /// A month name ending in "p" ("Sep") is not a PM marker; only a "p" attached to a time is.
+ #[test]
+ fn month_name_ending_in_p_is_not_pm() {
+ let dt = parse_date("2026 05 Sep", "yyyy dd mmm HH:MM").expect("valid date");
+ assert_eq!((dt.m, dt.d, dt.hh, dt.mm), (9, 5, 0, 0));
+ let f = Format::Date("yyyy dd mmm HH:MM".into());
+ assert_eq!(format_value(&f, "2026 05 Sep"), "2026 05 Sep 00:00");
+ assert!(keystroke(&f, "date", "2026 05 Sep").is_ok());
+ }
+
+ #[test]
+ fn bare_p_after_a_time_is_still_pm() {
+ assert_eq!(parse_date("05/09/2026 5:30p", "mm/dd/yyyy h:MM").map(|d| d.hh), Some(17));
+ assert_eq!(parse_date("05/09/2026 5:30 p", "mm/dd/yyyy h:MM").map(|d| d.hh), Some(17));
+ assert_eq!(parse_date("05/09/2026 5:30 pm", "mm/dd/yyyy h:MM").map(|d| d.hh), Some(17));
+ }
+}
diff --git a/crates/forms/src/detect.rs b/crates/forms/src/detect.rs
index dbee788e3b..d5bf073b1e 100644
--- a/crates/forms/src/detect.rs
+++ b/crates/forms/src/detect.rs
@@ -1,6 +1,6 @@
//! Prepare a form ▸ automatic field detection: find where a printed form expects answers —
-//! underscore runs, rules (horizontal lines), empty boxes and small squares — and name each new
-//! field from the label next to it (Acrobat's auto-naming).
+//! underscore runs, rules (horizontal lines), empty boxes, rows of comb cells and small squares —
+//! and name each new field from the label next to it (Acrobat's auto-naming).
//!
//! [`detect`] is a pure function over the page's words and drawn shapes; [`page_shapes`] reads
//! the shapes from a page's content, [`raster::shapes`] from a picture of the page (a scan).
@@ -30,6 +30,11 @@ pub struct Shapes {
pub enum Kind {
Text,
CheckBox,
+ /// A row of touching cells, one character each: a text field with the comb flag and
+ /// `cells` as its character limit.
+ Comb {
+ cells: usize,
+ },
}
#[derive(Clone, Debug, PartialEq)]
@@ -40,6 +45,10 @@ pub struct Candidate {
pub name: String,
}
+/// The most points a subpath may have and still be read as a rectangle: its four corners plus
+/// points along its sides (#2942: a cell drawn with a midpoint on each vertical side).
+const MAX_RECT_POINTS: usize = 16;
+
/// The rectangles and horizontal segments a page draws (stroked or filled), in user space.
#[derive(Default)]
struct DetectionPath {
@@ -65,12 +74,33 @@ impl DetectionPath {
return None;
}
let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6;
- let mut points = self.points.as_slice();
+ let mut points = self.points.clone();
let repeated_end = points.len() > 1 && near(points[0], *points.last()?);
if repeated_end {
- points = &points[..points.len() - 1];
+ points.pop();
}
- if points.len() != 4 || !(self.closed || implicitly_closed || repeated_end) {
+ if !(self.closed || implicitly_closed || repeated_end) {
+ return None;
+ }
+ // A point on a straight side, between its neighbours, doesn't change the shape: drop it,
+ // so a cell drawn with a midpoint on a side is the rectangle it looks like (#2942). A
+ // point that turns back along the side is not between its neighbours and stays, so a
+ // path that doubles back is still not a rectangle.
+ let between = |a: f64, m: f64, b: f64| a.min(b) - 1e-6 <= m && m <= a.max(b) + 1e-6;
+ let mut i = 0;
+ while points.len() > 4 && i < points.len() {
+ let n = points.len();
+ let (Some(&a), Some(&m), Some(&b)) = (points.get((i + n - 1) % n), points.get(i), points.get((i + 1) % n)) else { break };
+ let on_row = (a[1] - m[1]).abs() < 1e-6 && (m[1] - b[1]).abs() < 1e-6 && between(a[0], m[0], b[0]);
+ let on_column = (a[0] - m[0]).abs() < 1e-6 && (m[0] - b[0]).abs() < 1e-6 && between(a[1], m[1], b[1]);
+ if on_row || on_column {
+ points.remove(i);
+ i = 0;
+ } else {
+ i += 1;
+ }
+ }
+ if points.len() != 4 {
return None;
}
let mut horizontal = [false; 4];
@@ -157,7 +187,7 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
let (bx, by) = ctm.apply(x, y);
let b = [bx, by];
if !path.complex {
- if path.points.len() >= 5 {
+ if path.points.len() > MAX_RECT_POINTS {
path.complex = true;
} else {
if path.points.is_empty() {
@@ -284,6 +314,42 @@ fn label_for(words: &[Word], rect: [f64; 4], right_of: bool) -> String {
clean(&on.iter().map(|w| w.text.as_str()).collect::>().join(" "))
}
+/// Rows of two or more touching cells of one size, as (the row's rect, cell count). Two is
+/// enough: dates split into day and month groups of two cells. Cells are square or taller
+/// than wide; an empty table of wider cells is not a comb.
+fn combs(boxes: &[[f64; 4]]) -> Vec<([f64; 4], usize)> {
+ let cells: Vec<[f64; 4]> = boxes
+ .iter()
+ .copied()
+ .filter(|r| {
+ let (w, h) = (r[2] - r[0], r[3] - r[1]);
+ (5.0..=30.0).contains(&w) && (8.0..=40.0).contains(&h) && w <= h * 1.2
+ })
+ .collect();
+ // `b` comes right after `a` in a row: same size, its left edge on a's right edge.
+ let follows = |a: &[f64; 4], b: &[f64; 4]| {
+ (b[0] - a[2]).abs() <= 1.0 && (b[1] - a[1]).abs() <= 0.5 && (b[3] - a[3]).abs() <= 0.5 && ((b[2] - b[0]) - (a[2] - a[0])).abs() <= 0.5
+ };
+ let mut out = Vec::new();
+ for first in &cells {
+ if cells.iter().any(|a| follows(a, first)) {
+ continue;
+ }
+ let (mut last, mut n) = (first, 1usize);
+ // Each step moves at least 4 pt right, so this ends; the bound is for safety.
+ while let Some(b) = cells.iter().find(|b| follows(last, b))
+ && n < cells.len()
+ {
+ last = b;
+ n += 1;
+ }
+ if n >= 2 {
+ out.push(([first[0], first[1], last[2], first[3]], n));
+ }
+ }
+ out
+}
+
/// Propose fields for a page. `existing` are the page's widget rectangles (no candidate
/// overlaps them); names are unique against `taken`.
pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[String]) -> Vec {
@@ -304,6 +370,15 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S
let l = label_for(&labels, rect, false);
push(Kind::Text, rect, l, &mut out);
}
+ // Comb boxes (postcodes, dates, IBANs): one text field per row of cells, found before
+ // check boxes so the cells don't each become one.
+ for (rect, cells) in combs(&shapes.boxes) {
+ if labels.iter().any(|x| overlaps(x.rect, rect)) {
+ continue;
+ }
+ let l = label_for(&labels, rect, false);
+ push(Kind::Comb { cells }, rect, l, &mut out);
+ }
// Small squares: check boxes, labelled by the text to their right.
for r in &shapes.boxes {
let (w, h) = (r[2] - r[0], r[3] - r[1]);
@@ -356,7 +431,7 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S
for c in &mut out {
let base = if c.name.is_empty() {
match c.kind {
- Kind::Text => "Text".to_string(),
+ Kind::Text | Kind::Comb { .. } => "Text".to_string(),
Kind::CheckBox => "Check Box".to_string(),
}
} else {
diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs
index ef545cd433..60066e4739 100644
--- a/crates/forms/src/tests.rs
+++ b/crates/forms/src/tests.rs
@@ -1411,6 +1411,54 @@ fn detection_finds_blanks_rules_boxes_and_names_them() {
assert!(again.is_empty(), "{again:?}");
}
+#[test]
+fn detection_makes_a_row_of_touching_cells_one_comb_field() {
+ use crate::detect::*;
+ let w = |t: &str, x0: f64, y0: f64, x1: f64| Word { text: t.into(), rect: [x0, y0, x1, y0 + 10.0] };
+ let words = vec![
+ w("Postcode", 40.0, 205.0, 85.0),
+ w("A", 88.0, 205.0, 95.0),
+ w("Postcode", 40.0, 105.0, 85.0),
+ w("B", 88.0, 105.0, 95.0),
+ w("Yes", 70.0, 41.0, 90.0),
+ ];
+ let row = |n: usize, x: f64, y: f64, cw: f64, ch: f64| (0..n).map(move |i| [x + i as f64 * cw, y, x + (i + 1) as f64 * cw, y + ch]);
+ // 18 x 22 cells (too tall for a check box), 16 x 20 cells (each one check-box sized) drawn
+ // right to left, and a lone square.
+ let mut boxes: Vec<[f64; 4]> = row(5, 120.0, 200.0, 18.0, 22.0).collect();
+ boxes.extend(row(10, 120.0, 100.0, 16.0, 20.0).rev());
+ boxes.push([50.0, 40.0, 62.0, 52.0]);
+ let found = detect(&words, &Shapes { boxes, rules: Vec::new() }, &[], &[]);
+ let got: Vec<(&str, &Kind, [f64; 4])> = found.iter().map(|c| (c.name.as_str(), &c.kind, c.rect)).collect();
+ assert_eq!(
+ got,
+ [
+ ("Postcode A", &Kind::Comb { cells: 5 }, [120.0, 200.0, 210.0, 222.0]),
+ ("Postcode B", &Kind::Comb { cells: 10 }, [120.0, 100.0, 280.0, 120.0]),
+ ("Yes", &Kind::CheckBox, [50.0, 40.0, 62.0, 52.0]),
+ ],
+ "{found:?}"
+ );
+ // Squares with a gap between them stay check boxes, and so do touching squares of
+ // different sizes.
+ let apart = Shapes {
+ boxes: vec![[50.0, 40.0, 62.0, 52.0], [66.0, 40.0, 78.0, 52.0], [80.0, 40.0, 92.0, 52.0], [92.0, 40.0, 102.0, 52.0]],
+ rules: Vec::new(),
+ };
+ let kinds: Vec = detect(&[], &apart, &[], &[]).into_iter().map(|c| c.kind).collect();
+ assert_eq!(kinds, [Kind::CheckBox, Kind::CheckBox, Kind::CheckBox, Kind::CheckBox]);
+ // An empty table of cells wider than tall (an attendance sheet) is not a comb.
+ let grid = |cw: f64, ch: f64| {
+ (0..3).flat_map(move |r| {
+ (0..3).map(move |c| [100.0 + c as f64 * cw, 300.0 + r as f64 * ch, 100.0 + (c + 1) as f64 * cw, 300.0 + (r + 1) as f64 * ch])
+ })
+ };
+ for (cw, ch) in [(24.0, 18.0), (28.0, 20.0)] {
+ let table = detect(&[], &Shapes { boxes: grid(cw, ch).collect(), rules: Vec::new() }, &[], &[]);
+ assert!(table.is_empty(), "{cw} x {ch}: {table:?}");
+ }
+}
+
#[test]
fn page_shapes_reads_boxes_and_rules() {
let mut doc = fixture();
@@ -2023,6 +2071,29 @@ fn page_shapes_accepts_rectangle_closure_and_paint_variants() {
}
}
+/// #2942: a cell drawn with a point in the middle of each vertical side (the French 2042 form's
+/// letter cells), or on every side, is the rectangle it looks like. A point that turns back
+/// along a side is not on the way between its neighbours, so such a path is still not a box.
+#[test]
+fn page_shapes_reads_rectangles_with_points_along_their_sides() {
+ for content in [
+ "100 100 m 111.1 100 l 111.1 107.35 l 111.1 114.7 l 100 114.7 l 100 107.35 l h f",
+ "100 100 m 105 100 l 111.1 100 l 111.1 107.35 l 111.1 114.7 l 105 114.7 l 100 114.7 l 100 107.35 l h S",
+ "100 107.35 m 100 100 l 111.1 100 l 111.1 114.7 l 100 114.7 l h f",
+ ] {
+ let shapes = detection_test_shapes(content.as_bytes());
+ assert_eq!(shapes.boxes, [[100.0, 100.0, 111.1, 114.7]], "{content}");
+ }
+ for content in [
+ // Out past the corner and back: a spike, not a side.
+ "100 100 m 130 100 l 111.1 100 l 111.1 114.7 l 100 114.7 l h S",
+ // A sixth point off the sides.
+ "100 100 m 111.1 100 l 115 107 l 111.1 114.7 l 100 114.7 l 100 107.35 l h S",
+ ] {
+ assert!(detection_test_shapes(content.as_bytes()).boxes.is_empty(), "{content}");
+ }
+}
+
#[test]
fn page_shapes_rejects_open_diagonal_curved_and_complex_rectangles() {
for content in [
diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs
index 059276e936..cf82eff466 100644
--- a/crates/js/src/formcalc.rs
+++ b/crates/js/src/formcalc.rs
@@ -712,6 +712,13 @@ fn num_text(n: f64) -> String {
if s.is_empty() || s == "-0" { "0".into() } else { s.to_string() }
}
+/// `v` with `decimals` places, rounding exact halves away from zero (Rust's formatter rounds them to even).
+fn fixed(v: f64, decimals: usize) -> String {
+ let k = 10f64.powi(decimals.min(20) as i32);
+ let scaled = (v * k).round() / k;
+ format!("{:.decimals$}", if scaled.is_finite() { scaled } else { v })
+}
+
/// The number a string holds: its leading numeric part (`12abc` → 12), `None` for none.
fn parse_num(s: &str) -> Option {
let t = s.trim();
@@ -2166,7 +2173,7 @@ impl Interp<'_> {
let v = self.arg_num(args, 0)?;
let width = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 1000.0) as usize } else { 10 };
let prec = if args.len() > 2 { self.arg_num(args, 2)?.clamp(0.0, 20.0) as usize } else { 0 };
- let text = format!("{v:.prec$}");
+ let text = fixed(v, prec);
Val::Str(if text.len() > width { "*".repeat(width) } else { format!("{text:>width$}") })
}
"substr" => {
@@ -2255,7 +2262,7 @@ impl Interp<'_> {
let num = self.to_num(&v);
let decimals = pic.split_once('.').map_or(0, |(_, f)| f.chars().filter(|c| matches!(c, '9' | 'z' | 'Z')).count()).min(20);
let grouped = pic.contains(',');
- let mut text = format!("{:.decimals$}", num.abs());
+ let mut text = fixed(num.abs(), decimals);
if grouped {
let (int, frac) = text.split_once('.').map_or((text.as_str(), ""), |(a, b)| (a, b));
let mut g = String::new();
diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs
index 55fa45760d..4fe5fd2906 100644
--- a/crates/js/src/tests.rs
+++ b/crates/js/src/tests.rs
@@ -636,6 +636,19 @@ mod formcalc {
assert_eq!(calc("; a comment\n// another\nSum(1,\n 2,\n 3)"), "6");
}
+ #[test]
+ fn str_and_format_round_exact_ties_away_from_zero() {
+ assert_eq!(calc("Str(2.5, 3, 0)"), " 3");
+ assert_eq!(calc("Str(0.5, 3, 0)"), " 1");
+ assert_eq!(calc("Str(-2.5, 4, 0)"), " -3");
+ assert_eq!(calc("Str(1.125, 6, 2)"), " 1.13");
+ assert_eq!(calc(r#"Format("9", 2.5)"#), "3");
+ assert_eq!(calc(r#"Format("9.99", 0.125)"#), "0.13");
+ assert_eq!(calc(r#"Format("9.9", 0.25)"#), "0.3");
+ assert_eq!(calc(r#"Format("9.9", -0.25)"#), "-0.3");
+ assert_eq!(calc(r#"Format("9.99", 2.344)"#), "2.34");
+ }
+
#[test]
fn built_in_functions_cover_the_common_set() {
assert_eq!(calc("Abs(-2.5)"), "2.5");
diff --git a/crates/ocr/src/lib.rs b/crates/ocr/src/lib.rs
index 8d56fa4aa4..affa4ab163 100644
--- a/crates/ocr/src/lib.rs
+++ b/crates/ocr/src/lib.rs
@@ -215,7 +215,10 @@ fn num(v: f64) -> String {
/// WinAnsiEncoding), each word stretched to its box so selection and search highlight the
/// right place. Marked content `/OCR` so it can be told apart from the page's own text.
pub fn text_layer(words: &[PlacedWord]) -> Vec {
- let mut out = b"/OCR BMC\nBT\n3 Tr\n/PCHelv 1 Tf\n".to_vec();
+ // `3 Tr` and the font are graphics state, which `ET` doesn't reset: without `q … Q` they
+ // would stay set for every later content stream on the page, so text added after OCR (a
+ // header or footer) drew invisibly (#3029).
+ let mut out = b"/OCR BMC\nq\nBT\n3 Tr\n/PCHelv 1 Tf\n".to_vec();
for w in words {
let width = helvetica_width(&w.text, 1.0);
let height = w.up[0].hypot(w.up[1]);
@@ -235,7 +238,7 @@ pub fn text_layer(words: &[PlacedWord]) -> Vec {
out.extend(pdfcraft_fonts::literal(&pdfcraft_fonts::win_ansi(&w.text)));
out.extend_from_slice(b" Tj\n");
}
- out.extend_from_slice(b"ET\nEMC\n");
+ out.extend_from_slice(b"ET\nQ\nEMC\n");
out
}
@@ -272,6 +275,26 @@ mod tests {
assert!(s.starts_with("/OCR BMC") && s.ends_with("EMC\n"));
}
+ /// #3029: the layer's invisible rendering mode and font end with the layer. Every operator
+ /// that sets them sits inside a `q … Q` pair that the layer closes itself, so content drawn
+ /// after it on the page starts from the graphics state it had before.
+ #[test]
+ fn the_layer_restores_the_graphics_state_it_changes() {
+ let w = PlacedWord { text: "Hi".into(), origin: [10.0, 20.0], across: [30.0, 0.0], up: [0.0, 9.3] };
+ let s = String::from_utf8(text_layer(&[w])).unwrap();
+ let mut depth = 0_i32;
+ for token in s.split_ascii_whitespace() {
+ match token {
+ "q" => depth += 1,
+ "Q" => depth -= 1,
+ "Tr" | "Tf" | "Tm" => assert!(depth > 0, "`{token}` outside q … Q in {s:?}"),
+ _ => {}
+ }
+ assert!(depth >= 0, "unbalanced Q in {s:?}");
+ }
+ assert_eq!(depth, 0, "the layer leaves a q open: {s:?}");
+ }
+
/// A word box is tight around the ink, so the text's size and baseline come from the letters:
/// capitals, lowercase without ascenders, descenders.
#[test]
diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs
index 2242563fd2..9b95286056 100644
--- a/crates/organize/src/lib.rs
+++ b/crates/organize/src/lib.rs
@@ -115,13 +115,17 @@ pub fn pages(doc: &Document) -> Result, OrganizeError> {
pub fn page_rotation(doc: &Document, index: usize) -> Result {
let pages = walk(doc)?;
let (_, attrs) = pages.get(index).ok_or(OrganizeError::NoSuchPage(index))?;
- Ok(attrs.int(b"Rotate").unwrap_or(0).rem_euclid(360))
+ Ok(resolved_rotation(doc, attrs).rem_euclid(360))
}
pub fn page_count(doc: &Document) -> Result {
Ok(walk(doc)?.len())
}
+fn resolved_rotation(doc: &Document, attrs: &Dict) -> i64 {
+ attrs.get(b"Rotate").and_then(|value| doc.resolve(value).as_int()).unwrap_or(0)
+}
+
/// Rebuild the page tree as one flat node with `order` as its kids (attributes pushed down).
fn rebuild(doc: &mut Document, order: &[(ObjRef, Dict)]) -> Result<(), OrganizeError> {
let root = pages_root(doc)?;
@@ -203,7 +207,10 @@ pub fn rotate_pages(doc: &mut Document, indices: &[usize], degrees: i64) -> Resu
let delta = degrees.rem_euclid(360);
for &i in indices {
let (page, inherited) = &all[i];
- let current = doc.get(*page).as_dict().and_then(|d| d.int(b"Rotate")).or_else(|| inherited.int(b"Rotate")).unwrap_or(0);
+ // Repeated indices must see earlier edits instead of the page walk's snapshot.
+ let current_page = doc.get(*page);
+ let current_attrs = current_page.as_dict().filter(|attrs| attrs.get(b"Rotate").is_some()).unwrap_or(inherited);
+ let current = resolved_rotation(doc, current_attrs);
let new = (current.rem_euclid(360) + delta).rem_euclid(360);
doc.update_dict(*page, |d| d.set(b"Rotate".to_vec(), Object::Int(new)))?;
}
@@ -474,9 +481,30 @@ pub fn move_pages(doc: &mut Document, indices: &[usize], to: usize) -> Result<()
rebuild(doc, &rest)
}
-/// Duplicate pages: copies of `indices` (in order) are inserted after the last of them, sharing
+/// Reverse the order of the pages at `indices` among the positions they occupy; every other
+/// page stays where it is. Fewer than two distinct pages is refused, since nothing would change.
+pub fn reverse_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> {
+ let mut all = walk(doc)?;
+ check(indices, all.len())?;
+ let mut slots = indices.to_vec();
+ slots.sort_unstable();
+ slots.dedup();
+ if slots.len() < 2 {
+ return Err(OrganizeError::Invalid("reversing needs at least two pages".into()));
+ }
+ let pages: Vec<(ObjRef, Dict)> = slots.iter().filter_map(|i| all.get(*i).cloned()).collect();
+ for (slot, page) in slots.iter().zip(pages.into_iter().rev()) {
+ if let Some(entry) = all.get_mut(*slot) {
+ *entry = page;
+ }
+ }
+ rebuild(doc, &all)
+}
+
+/// Duplicate pages: copies of `indices` (in order) are inserted at `at` in the page order
+/// (clamped to the document's end), or after the last of them when `at` is `None`, sharing
/// fonts and images with the originals.
-pub fn duplicate_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> {
+pub fn duplicate_pages(doc: &mut Document, indices: &[usize], at: Option) -> Result<(), OrganizeError> {
let n = page_count(doc)?;
check(indices, n)?;
let Some(&last) = indices.iter().max() else { return Ok(()) };
@@ -484,7 +512,7 @@ pub fn duplicate_pages(doc: &mut Document, indices: &[usize]) -> Result<(), Orga
order.sort_unstable();
order.dedup();
let src = doc.clone();
- import_pages(doc, &src, &order, last + 1).map(|_| ())
+ import_pages(doc, &src, &order, at.unwrap_or(last + 1)).map(|_| ())
}
/// Replace Pages: the content of `targets` (in order) is replaced by that of `src_pages` of
@@ -538,6 +566,54 @@ pub fn insert_blank_page(doc: &mut Document, at: usize, width: f64, height: f64)
/// Document information keys editable in Document Properties ▸ Description.
pub const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"];
+/// The document-information entries the spec names (ISO 32000-2 table 349). Any other key in
+/// `/Info` is a custom property (Document Properties ▸ Custom).
+pub const STANDARD_INFO_KEYS: [&str; 9] = ["Title", "Author", "Subject", "Keywords", "Creator", "Producer", "CreationDate", "ModDate", "Trapped"];
+
+/// Longest custom property name accepted, in bytes: the name length every reader is asked to
+/// accept (§7.3.5, Annex C).
+pub const MAX_CUSTOM_KEY_LEN: usize = 127;
+
+/// The custom document properties: every `/Info` entry whose key the spec does not name and whose
+/// value is text, in the dictionary's order, as (name, text), the text without NUL or whitespace
+/// padding and entries that are only padding left out. At most 256 are listed.
+pub fn custom_info(doc: &Document) -> Vec<(String, String)> {
+ let Some(info) = doc.trailer().get(b"Info").map(|o| doc.resolve(o)) else { return Vec::new() };
+ let Some(d) = info.as_dict() else { return Vec::new() };
+ d.iter()
+ .filter(|(k, _)| !STANDARD_INFO_KEYS.iter().any(|s| s.as_bytes() == k.as_slice()))
+ .filter_map(|(k, v)| {
+ // Padding (\0s and whitespace, in any mix) is not part of the value, and a value that
+ // is only padding is no property. Filtered before the cap, so blank entries can't
+ // crowd real ones out.
+ let text = doc.resolve(v).as_string()?.to_text();
+ let text = text.trim_matches(|c: char| c == '\0' || c.is_whitespace());
+ (!text.is_empty()).then(|| (String::from_utf8_lossy(k).into_owned(), text.to_owned()))
+ })
+ .take(256)
+ .collect()
+}
+
+/// Whether `name` may be a custom property's key: not one the spec names (those are the
+/// Description fields), not empty, at most [`MAX_CUSTOM_KEY_LEN`] bytes, and without the
+/// characters a name cannot carry unescaped (whitespace, the delimiters `()<>[]{}/%` and `#`),
+/// so every reader shows the same name. Acrobat refuses the standard and empty names too.
+pub fn check_custom_key(name: &str) -> Result<(), OrganizeError> {
+ if name.is_empty() {
+ return Err(OrganizeError::Invalid("a custom property needs a name".into()));
+ }
+ if STANDARD_INFO_KEYS.contains(&name) {
+ return Err(OrganizeError::Invalid(format!("{name} is a standard document property, not a custom one")));
+ }
+ if name.len() > MAX_CUSTOM_KEY_LEN {
+ return Err(OrganizeError::Invalid(format!("a custom property name is at most {MAX_CUSTOM_KEY_LEN} bytes")));
+ }
+ if let Some(c) = name.chars().find(|c| c.is_whitespace() || c.is_control() || "()<>[]{}/%#".contains(*c)) {
+ return Err(OrganizeError::Invalid(format!("a custom property name can't contain {c:?}")));
+ }
+ Ok(())
+}
+
/// Read a document-information entry as text.
pub fn info(doc: &Document, key: &str) -> Option {
let info = doc.trailer().get(b"Info").map(|o| doc.resolve(o))?;
@@ -550,6 +626,9 @@ pub fn info(doc: &Document, key: &str) -> Option {
/// in the XMP packet, and viewers that read XMP first show its value: the packet's property is
/// changed (or removed) to match (see `xmp`).
pub fn set_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> {
+ if !STANDARD_INFO_KEYS.contains(&key) {
+ check_custom_key(key)?;
+ }
let value = value.trim();
let entry = (!value.is_empty()).then(|| Object::String(PdfString::text(value)));
set_info_entry(doc, key.as_bytes(), entry)?;
diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs
index 9b36f229d9..fa3a78af40 100644
--- a/crates/organize/src/tests.rs
+++ b/crates/organize/src/tests.rs
@@ -16,6 +16,53 @@ fn page_rotation_resolves_inheritance_overrides_and_missing_pages() {
assert_eq!(page_rotation(&doc, 3), Err(OrganizeError::NoSuchPage(3)));
}
+#[test]
+fn indirect_page_rotation_is_read_and_updated_from_its_effective_value() {
+ let large = i64::MAX - i64::MAX % 90;
+ for (current, degrees, expected) in [(270, 90, 0), (-90, 90, 0), (large, 90, 90), (90, -180, 270)] {
+ for level in 0..3 {
+ let mut doc = open(fixture());
+ let rotation = doc.add(Object::Int(current));
+ let page = pages(&doc).unwrap()[0].obj;
+ let target = match level {
+ 0 => page,
+ 1 => doc.get(page).as_dict().unwrap().reference(b"Parent").unwrap(),
+ _ => pages_root(&doc).unwrap(),
+ };
+ doc.update_dict(target, |d| d.set(b"Rotate".to_vec(), Object::Ref(rotation))).unwrap();
+ let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
+ let mut doc = open(bytes);
+ let rotation = walk(&doc).unwrap()[0].1.reference(b"Rotate").unwrap();
+ let initial = page_rotation(&doc, 0).unwrap();
+ rotate_pages(&mut doc, &[0], degrees).unwrap();
+ assert_eq!(*doc.get(rotation), Object::Int(current), "shared rotation object unchanged");
+ for saved in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] {
+ let reopened = open(saved.clone());
+ let pdf = hayro_syntax::Pdf::new(saved).unwrap();
+ let dimensions = if expected % 180 == 0 { (300.0, 400.0) } else { (400.0, 300.0) };
+ assert_eq!(pdf.pages()[0].render_dimensions(), dimensions, "written PDF has the correct orientation");
+ assert_eq!(page_rotation(&reopened, 0).unwrap(), expected);
+ assert_eq!(page_rotation(&reopened, 1).unwrap(), if level == 0 { 90 } else { current.rem_euclid(360) }, "sibling unchanged");
+ assert_eq!(page_rotation(&reopened, 2).unwrap(), if level == 2 { current.rem_euclid(360) } else { 90 }, "other subtree unchanged");
+ }
+ assert_eq!(initial, current.rem_euclid(360), "effective indirect rotation at level {level}");
+ }
+ }
+}
+
+#[test]
+fn malformed_indirect_rotations_keep_the_zero_fallback() {
+ for value in [Object::Null, Object::name("invalid"), Object::Ref(ObjRef::new(99_999, 0))] {
+ let mut doc = open(fixture());
+ let rotation = doc.add(value);
+ let page = pages(&doc).unwrap()[0].obj;
+ doc.update_dict(page, |d| d.set(b"Rotate".to_vec(), Object::Ref(rotation))).unwrap();
+ assert_eq!(page_rotation(&doc, 0).unwrap(), 0);
+ rotate_pages(&mut doc, &[0], 90).unwrap();
+ assert_eq!(page_rotation(&doc, 0).unwrap(), 90);
+ }
+}
+
#[test]
fn rotate_rejects_angles_that_are_not_multiples_of_90() {
// The page tree only turns in quarter steps, so anything else used to be silently truncated
@@ -244,6 +291,25 @@ fn move_pages_reorders() {
assert_eq!(labels(&doc), ["Page 2", "Page 3", "Page 1"]);
}
+#[test]
+fn reverse_pages_reorders() {
+ let mut doc = open(fixture());
+ reverse_pages(&mut doc, &[0, 1, 2]).unwrap();
+ assert_eq!(labels(&doc), ["Page 3", "Page 2", "Page 1"]);
+ // Unsorted, repeated indices name the same slots; page 1's slot stays put.
+ reverse_pages(&mut doc, &[2, 1, 2]).unwrap();
+ let doc = save_and_reopen(&doc);
+ assert_eq!(labels(&doc), ["Page 3", "Page 1", "Page 2"]);
+}
+
+#[test]
+fn cannot_reverse_missing_or_fewer_than_two_pages() {
+ let mut doc = open(fixture());
+ assert_eq!(reverse_pages(&mut doc, &[0, 3]), Err(OrganizeError::NoSuchPage(3)));
+ assert!(matches!(reverse_pages(&mut doc, &[1, 1]), Err(OrganizeError::Invalid(_))));
+ assert!(!doc.is_modified(), "failed operations leave the document untouched");
+}
+
#[test]
fn inserts_blank_page() {
let mut doc = open(fixture());
@@ -317,6 +383,59 @@ fn page_transitions_are_set_removed_and_checked() {
assert_eq!(page_dict(&doc, 2).get(b"Dur").and_then(Object::as_f64), Some(5.0));
}
+/// Custom document properties (Document Properties ▸ Custom): any `/Info` key the spec does not
+/// name, set and cleared like the standard ones, listed apart from them, and with the names
+/// Acrobat refuses refused here too.
+#[test]
+fn custom_document_properties_are_listed_set_removed_and_their_names_checked() {
+ let mut doc = open(fixture());
+ assert!(custom_info(&doc).is_empty());
+ set_info(&mut doc, "Title", "Report").unwrap();
+ set_info(&mut doc, "Project", "Apollo").unwrap();
+ set_info(&mut doc, "Revision", "7").unwrap();
+ let mut listed = custom_info(&doc);
+ listed.sort();
+ assert_eq!(listed, [("Project".to_string(), "Apollo".to_string()), ("Revision".to_string(), "7".to_string())], "standard keys are not custom");
+ assert_eq!(info(&doc, "Project").as_deref(), Some("Apollo"));
+ set_info(&mut doc, "Project", "").unwrap();
+ let doc = save_and_reopen(&doc);
+ assert_eq!(custom_info(&doc), [("Revision".to_string(), "7".to_string())], "cleared, and the rest survives a save");
+ assert_eq!(info(&doc, "Title").as_deref(), Some("Report"));
+
+ for bad in ["", "Title", "ModDate", "Bad Name", "a/b", "tab\tbed", "hash#tag", "(x)"] {
+ assert!(check_custom_key(bad).is_err(), "{bad:?} must be refused");
+ }
+ let long = "x".repeat(MAX_CUSTOM_KEY_LEN + 1);
+ assert!(check_custom_key(&long).is_err());
+ for good in ["Project", "Révision", "部門", "key-2.0", &"y".repeat(MAX_CUSTOM_KEY_LEN)] {
+ assert!(check_custom_key(good).is_ok(), "{good:?} is a usable name");
+ }
+ let mut doc = open(fixture());
+ assert!(set_info(&mut doc, "Bad Name", "x").is_err(), "set_info checks custom names");
+ assert!(custom_info(&doc).is_empty());
+}
+
+/// Padding is not part of a custom value, in any mix of NULs and whitespace, a value that is only
+/// padding is not listed, and such entries don't count towards the 256 listed: a real property
+/// after 300 blank ones is still there.
+#[test]
+fn custom_document_properties_drop_padding_before_the_cap() {
+ let mut doc = open(fixture());
+ set_info(&mut doc, "Title", "Report").unwrap();
+ let Some(Object::Ref(info_ref)) = doc.trailer().get(b"Info").cloned() else { panic!("an /Info reference") };
+ let text = |s: &str| Object::String(pdfcraft_cos::PdfString { bytes: s.as_bytes().to_vec(), hex: false });
+ doc.update_dict(info_ref, |d| {
+ d.set(b"Mixed".to_vec(), text(" \0Lab\0 "));
+ d.set(b"Pad".to_vec(), text(" \0 \0"));
+ for i in 0..300 {
+ d.set(format!("Blank{i}").into_bytes(), text(" "));
+ }
+ d.set(b"Keep".to_vec(), text("yes"));
+ })
+ .unwrap();
+ assert_eq!(custom_info(&doc), [("Mixed".to_string(), "Lab".to_string()), ("Keep".to_string(), "yes".to_string())]);
+}
+
/// A one-page document whose catalog has `xmp` as its (Flate-compressed) XMP packet, and the
/// same title and author in `/Info`, as LibreOffice writes them.
fn doc_with_xmp(xmp: &[u8]) -> Document {
@@ -1852,11 +1971,24 @@ fn page_boxes_default_inherit_and_set() {
#[test]
fn duplicate_pages_inserts_copies_after_the_last() {
let mut doc = open(fixture());
- duplicate_pages(&mut doc, &[0, 1]).unwrap();
+ duplicate_pages(&mut doc, &[0, 1], None).unwrap();
let doc = open(write_full(&doc, &SaveOptions::default()).unwrap());
assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 1", "Page 2", "Page 3"]);
}
+#[test]
+fn duplicate_pages_at_inserts_the_copies_there() {
+ let mut doc = open(fixture());
+ duplicate_pages(&mut doc, &[2], Some(0)).unwrap();
+ let doc = open(write_full(&doc, &SaveOptions::default()).unwrap());
+ assert_eq!(labels(&doc), ["Page 3", "Page 1", "Page 2", "Page 3"]);
+ // An index past the end lands at the end.
+ let mut doc = open(fixture());
+ duplicate_pages(&mut doc, &[0], Some(99)).unwrap();
+ let doc = open(write_full(&doc, &SaveOptions::default()).unwrap());
+ assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 3", "Page 1"]);
+}
+
#[test]
fn replace_pages_swaps_content_but_keeps_annotations() {
// Page 2 of the target gets an annotation; replacing its content keeps it.
@@ -2463,7 +2595,7 @@ fn import_can_merge_into_same_named_fields_instead() {
#[test]
fn duplicating_a_page_with_a_field_keeps_the_field_name() {
let mut doc = doc_with_field("Text1");
- crate::duplicate_pages(&mut doc, &[0]).unwrap();
+ crate::duplicate_pages(&mut doc, &[0], None).unwrap();
assert_eq!(crate::page_count(&doc).unwrap(), 2);
assert_eq!(form_field_names(&doc), ["Text1", "Text1"], "nothing renamed");
}
@@ -2738,3 +2870,35 @@ fn attachments_are_added_listed_and_deleted() {
assert!(attachment_keys(&doc).is_empty());
assert!(doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Names").is_none());
}
+
+#[test]
+fn rotate_repeated_indices_accumulate_from_the_current_page_value() {
+ for level in 0..3 {
+ for indices in [vec![0, 0], vec![0, 1, 0]] {
+ let mut doc = open(fixture());
+ let rotation = doc.add(Object::Int(90));
+ let page = pages(&doc).unwrap()[0].obj;
+ let target = match level {
+ 0 => page,
+ 1 => doc.get(page).as_dict().unwrap().reference(b"Parent").unwrap(),
+ _ => pages_root(&doc).unwrap(),
+ };
+ doc.update_dict(target, |d| d.set(b"Rotate".to_vec(), Object::Ref(rotation))).unwrap();
+ let mut doc = open(write_full(&doc, &SaveOptions::default()).unwrap());
+ let rotation = walk(&doc).unwrap()[0].1.reference(b"Rotate").unwrap();
+ let original_labels = labels(&doc);
+ rotate_pages(&mut doc, &indices, 90).unwrap();
+ assert_eq!(page_rotation(&doc, 0).unwrap(), 270, "level {level}, indices {indices:?}");
+ assert_eq!(*doc.get(rotation), Object::Int(90), "shared source angle is unchanged");
+ for saved in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] {
+ let reopened = open(saved.clone());
+ assert_eq!(page_rotation(&reopened, 0).unwrap(), 270);
+ assert_eq!(page_rotation(&reopened, 1).unwrap(), if indices.len() == 3 { 180 } else { 90 });
+ assert_eq!(page_rotation(&reopened, 2).unwrap(), 90);
+ assert_eq!(labels(&reopened), original_labels);
+ let independent = hayro_syntax::Pdf::new(saved).unwrap();
+ assert_eq!(independent.pages()[0].render_dimensions(), (400.0, 300.0));
+ }
+ }
+ }
+}
diff --git a/crates/platform/src/staging.rs b/crates/platform/src/staging.rs
index f7200efb9e..2cee86c5e9 100644
--- a/crates/platform/src/staging.rs
+++ b/crates/platform/src/staging.rs
@@ -27,6 +27,20 @@ pub fn create_staging(
name: &str,
form: StagingName,
suffixes: impl IntoIterator- ,
+) -> std::io::Result<(PathBuf, std::fs::File)> {
+ create_staging_as(dir, name, form, suffixes, None)
+}
+
+/// [`create_staging`], giving the new file the permissions `keep` on Unix. It is created readable
+/// and writable by its owner only and then given `keep`, so its mode is never more open than that
+/// of the file it will replace, not even while it is still empty (a handle opened then would keep
+/// reading what is written later). Only the mode is copied: the owner and group are the saver's.
+fn create_staging_as(
+ dir: &Path,
+ name: &str,
+ form: StagingName,
+ suffixes: impl IntoIterator
- ,
+ keep: Option<&std::fs::Permissions>,
) -> std::io::Result<(PathBuf, std::fs::File)> {
// At most 128 bytes of the target's name, cut between characters, so the staging name fits
// the 255-byte (Linux, macOS) and 255-unit (Windows) limits however long that name is.
@@ -42,8 +56,22 @@ pub fn create_staging(
StagingName::SuffixThenTag => format!(".{stem}.{suffix:016x}.pdfcraft-tmp"),
StagingName::TagThenSuffix => format!(".{stem}.pdfcraft-{suffix:016x}.tmp"),
});
- match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
- Ok(file) => return Ok((tmp, file)),
+ let mut options = std::fs::OpenOptions::new();
+ options.write(true).create_new(true);
+ #[cfg(unix)]
+ if keep.is_some() {
+ use std::os::unix::fs::OpenOptionsExt as _;
+ options.mode(0o600);
+ }
+ match options.open(&tmp) {
+ Ok(file) => {
+ if let Some(keep) = keep {
+ // A volume that can't change modes (FAT, some network shares) leaves the file
+ // owner-only: stricter than the file it replaces, never looser.
+ let _ = file.set_permissions(keep.clone());
+ }
+ return Ok((tmp, file));
+ }
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
// Windows reports a folder at the name as "access denied"; it is taken all the same.
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied && tmp.symlink_metadata().is_ok() => {}
@@ -69,14 +97,16 @@ pub fn staging_suffixes() -> impl Iterator
- {
/// crash, a full disk or a refused flush never leaves a half-written file where a good one was,
/// and the target is untouched until the new file is complete. The target's folder must exist;
/// a folder at the target is refused by the rename. Flushing is the staging file's own
-/// (`sync_all`); parent-directory durability after a power loss is not promised.
+/// (`sync_all`); parent-directory durability after a power loss is not promised. On Unix a file
+/// that is replaced keeps its permissions, so a private file stays private; a new file gets the
+/// usual mode for a new file.
pub fn write_atomically(path: &Path, bytes: &[u8], form: StagingName) -> std::io::Result<()> {
write_atomically_with(path, bytes, form, staging_suffixes(), std::fs::File::sync_all)
}
/// [`write_atomically`] with the staging-name suffixes and the flush given, so tests can plant
/// names and refuse flushes. A failure at any step removes the staging file and leaves the
-/// target as it was.
+/// target as it was. A read-only target is refused before anything is written.
pub fn write_atomically_with(
path: &Path,
bytes: &[u8],
@@ -85,9 +115,20 @@ pub fn write_atomically_with(
sync_all: impl FnOnce(&std::fs::File) -> std::io::Result<()>,
) -> std::io::Result<()> {
use std::io::Write as _;
+ // A read-only file is not replaced. Windows refuses the rename anyway, but on Unix a rename
+ // ignores the target's permissions: the file would be overwritten without a word, and come
+ // out writable (#2658).
+ if std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().readonly()) {
+ return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "the file is read-only; save a copy with Save As"));
+ }
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."));
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("save");
- let (tmp, file) = create_staging(dir, name, form, suffixes)?;
+ // A file that is replaced keeps its permissions (#1790). Unix modes only: Windows is as before.
+ #[cfg(unix)]
+ let keep = std::fs::metadata(path).ok().map(|m| m.permissions());
+ #[cfg(not(unix))]
+ let keep = None;
+ let (tmp, file) = create_staging_as(dir, name, form, suffixes, keep.as_ref())?;
let result = (|| {
// Closed at the end of the block, before the rename (required on Windows).
{
@@ -162,6 +203,33 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
+ /// #2658: a read-only file is refused before anything is staged, on Unix (where a rename would
+ /// replace it) as on Windows (where the rename fails).
+ #[test]
+ fn a_read_only_target_is_refused_and_kept() {
+ let dir = test_dir("readonly");
+ let target = dir.join("out.pdf");
+ std::fs::write(&target, "OLD").unwrap();
+ let mut perms = std::fs::metadata(&target).unwrap().permissions();
+ perms.set_readonly(true);
+ std::fs::set_permissions(&target, perms.clone()).unwrap();
+ let staged = std::cell::Cell::new(false);
+ let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [7], |_: &std::fs::File| {
+ staged.set(true);
+ Ok(())
+ })
+ .unwrap_err();
+ assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "{e}");
+ assert!(!staged.get(), "nothing was staged");
+ assert_eq!(std::fs::read(&target).unwrap(), b"OLD");
+ assert!(std::fs::metadata(&target).unwrap().permissions().readonly(), "still read-only");
+ assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "no staging file is left");
+ #[allow(clippy::permissions_set_readonly_false)]
+ perms.set_readonly(false);
+ std::fs::set_permissions(&target, perms).unwrap();
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
#[test]
fn running_out_of_suffixes_refuses_and_creates_nothing() {
let dir = test_dir("empty");
@@ -215,4 +283,34 @@ mod tests {
assert_eq!(names(&dir), ["out.pdf"]);
let _ = std::fs::remove_dir_all(&dir);
}
+
+ /// #1790: a replaced file keeps its permissions, and its staging file already has them while
+ /// it is written; a new file gets the same mode as any other new file.
+ #[cfg(unix)]
+ #[test]
+ fn a_replaced_file_keeps_its_permissions() {
+ use std::os::unix::fs::PermissionsExt as _;
+ let dir = test_dir("modes");
+ let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
+ let target = dir.join("out.pdf");
+ for keep in [0o600, 0o640, 0o664] {
+ std::fs::write(&target, b"OLD").unwrap();
+ std::fs::set_permissions(&target, std::fs::Permissions::from_mode(keep)).unwrap();
+ let mut staged = 0;
+ write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [keep as u64], |f| {
+ staged = f.metadata()?.permissions().mode() & 0o777;
+ Ok(())
+ })
+ .unwrap();
+ assert_eq!(staged, keep, "the staging file has the target's mode before it is flushed");
+ assert_eq!(mode(&target), keep, "the saved file keeps mode {keep:o}");
+ assert_eq!(std::fs::read(&target).unwrap(), b"NEW");
+ }
+ let fresh = dir.join("new.pdf");
+ write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap();
+ let plain = dir.join("plain");
+ std::fs::write(&plain, b"").unwrap();
+ assert_eq!(mode(&fresh), mode(&plain), "a new file is not made private");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
}
diff --git a/crates/redact/README.md b/crates/redact/README.md
index 3b2d54e814..7cd5ded1e4 100644
--- a/crates/redact/README.md
+++ b/crates/redact/README.md
@@ -25,7 +25,7 @@ clear_marks(&mut doc, None)?; // remove marks without app
- **vectors**: covered paths are removed; partly covered paths and shadings are clipped so
nothing paints inside the areas;
- **form XObjects**: rewritten recursively into new objects (pages sharing the original keep it);
-- **annotations** whose rectangle overlaps (with their pop-ups), and **form fields** with a
+- **annotations** whose rectangle overlaps (with their pop-ups and their replies, `/IRT`), and **form fields** with a
widget under a mark;
- the marks themselves, replaced by boxes in their fill colour (and overlay text) drawn into the
page as a `/PCMark /Redaction` stream.
@@ -44,6 +44,11 @@ This limits appearance generation; it is not a limit on the input PDF or the who
Structure cleanup resolves named MCIDs and indirect content references. Form content
references are updated to the cleaned copy; unchanged sibling descriptions remain.
+Replacement descriptions are removed only from marked-content spans changed by cleanup.
+Unchanged spans retain their descriptions; unused property entries are retired during redaction
+and hidden-content cleanup, including aliases in shared/inherited resources. Descriptions still
+used by surviving pages or forms are kept.
+
A redacted page that inherits `/Resources` from the page tree gets them pinned (by reference;
a direct inherited dictionary is made one shared object first) before it is rewritten. The parent's
`/Resources` is then retired unless another page still inherits it, so it cannot keep a removed
diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs
index 50802eff39..52ca6c276f 100644
--- a/crates/redact/src/interp.rs
+++ b/crates/redact/src/interp.rs
@@ -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
,
+ /// 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,
/// 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, HashSet)>,
+ pub error: Option,
}
pub(crate) struct Scope<'a> {
@@ -246,9 +250,10 @@ fn content_draws(doc: &Document, content: &[Vec], 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 = doomed.iter().copied().collect();
@@ -278,11 +283,16 @@ pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef]) -> Result<(),
}
}
let index: HashMap = pdfcraft_model::pages(doc).iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
+ let holder_nodes: Vec = 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> = 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> = 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], contents: &[Vec>]) -> Result {
+ 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 = 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> =
+ 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]) -> Result, 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]) -> Result, 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> = 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]) -> Result<(), CosError> {
+ let ops = resource_ops(doc, res, content)?;
+ let mut xo = res_dict(doc, res, b"XObject");
+ let names: Vec> = 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,
@@ -383,6 +531,9 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
// 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);
@@ -408,7 +559,8 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
}
}
- for ((piece, mut changed), (comment_in, comment_out)) in pieces.into_iter().zip(rewrite).zip(comment_edges) {
+ let mut processed = Vec::new();
+ for (piece, mut changed) in pieces.into_iter().zip(rewrite) {
let mut ops: Vec = Vec::with_capacity(piece.len());
for op in piece {
let o = op.op.as_slice();
@@ -625,6 +777,12 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
if !path.is_empty() {
ops.append(&mut path);
}
+ 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() };
@@ -639,6 +797,48 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
out
}
+fn clean_replacements(doc: &Document, resources: &Dict, original: &[Op], processed: &mut [(Vec, bool)], report: &mut Report) {
+ let originals: HashMap = original.iter().map(|op| (op.span.start, op)).collect();
+ let unchanged: HashSet =
+ 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, usize) {
@@ -810,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;
}
@@ -820,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 {
diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs
index 21af3691c7..a8dbf64d9b 100644
--- a/crates/redact/src/lib.rs
+++ b/crates/redact/src/lib.rs
@@ -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 = Vec::new();
// The original image and form XObjects the rewrite drops or replaces, on any page.
let mut doomed_xobjects: Vec = 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::>()).collect();
@@ -353,10 +355,14 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result) -> Result = annots
+ .iter()
+ .filter_map(Object::as_ref)
+ .filter_map(|r| {
+ let obj = doc.get(r);
+ let d = obj.as_dict()?;
+ (d.name(b"Subtype") != Some(b"Widget")).then_some(())?;
+ Some((r, d.get(b"IRT").and_then(Object::as_ref)?))
+ })
+ .collect();
+ loop {
+ let before = removed.len();
+ for (r, parent) in &replies {
+ if !removed.contains(r) && removed.contains(parent) {
+ removed.push(*r);
+ report.annotations += 1;
+ }
+ }
+ if removed.len() == before {
+ break;
+ }
+ }
let kept: Vec = annots
.into_iter()
.filter(|a| {
@@ -488,7 +530,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result bool {
fn email(s: &[char], i: usize) -> Option {
let local = |c: &char| address_char(*c) || "._%+-".contains(*c);
- if !address_char(s[i]) || i.checked_sub(1).and_then(|p| s.get(p)).is_some_and(local) {
+ // A match starts at the first character of a run of local characters, which may be
+ // punctuation ("_admin@host.com"), but the run needs a letter or digit.
+ if !local(&s[i]) || i.checked_sub(1).and_then(|p| s.get(p)).is_some_and(local) {
return None;
}
let mut j = i;
while j < s.len() && local(&s[j]) {
j += 1;
}
- if s.get(j) != Some(&'@') {
+ if s.get(j) != Some(&'@') || !s[i..j].iter().any(|c| address_char(*c)) {
return None;
}
let d0 = j + 1;
@@ -181,12 +183,33 @@ fn credit_card(s: &[char], i: usize) -> Option {
if !s[i].is_ascii_digit() || digit(i.checked_sub(1).and_then(|p| s.get(p))) {
return None;
}
- let (end, n) = digit_run(s, i, &[' ', '-'], 23);
- if !(13..=19).contains(&n) || digit(s.get(end)) {
+ // Room for the longest card (19 digits in groups, 23 characters) and a tail of up to four digits.
+ let (end, n) = digit_run(s, i, &[' ', '-'], 29);
+ // A run longer than that is some other, longer number.
+ if end == i || run_continues(s, end - 1, 1, &[' ', '-']) {
return None;
}
- let digits: Vec = s[i..end].iter().filter_map(|c| c.to_digit(10)).collect();
- luhn(&digits).then_some(end - i)
+ // The longest run may carry a trailing number (an expiry, a year, a CVV), so try it and then
+ // each shorter run ending at a group boundary, longest first. The dropped tail is at most four
+ // digits, so a longer number (an IBAN's digits) doesn't match on a prefix.
+ let mut count = n;
+ for e in (i + 1..=end).rev() {
+ if n - count > 4 {
+ break;
+ }
+ if !s.get(e - 1).is_some_and(char::is_ascii_digit) {
+ continue;
+ }
+ let boundary = if e == end { !digit(s.get(end)) } else { matches!(s.get(e), Some(' ' | '-')) };
+ if boundary && (13..=19).contains(&count) {
+ let digits: Vec = s[i..e].iter().filter_map(|c| c.to_digit(10)).collect();
+ if luhn(&digits) {
+ return Some(e - i);
+ }
+ }
+ count = count.saturating_sub(1);
+ }
+ None
}
fn ssn(s: &[char], i: usize) -> Option {
@@ -401,6 +424,18 @@ mod tests {
);
}
+ #[test]
+ fn emails_with_a_leading_punctuation_character() {
+ for lead in ["_", "-", ".", "%", "+"] {
+ let address = format!("{lead}john@example.com");
+ assert_eq!(found(Pattern::Email, &format!("Write to {address} now")), [address.as_str()], "lead {lead:?}");
+ }
+ // A run of local characters is still matched whole, never from its middle.
+ assert_eq!(found(Pattern::Email, "a.b@x.com, foo_john@example.com"), ["a.b@x.com", "foo_john@example.com"]);
+ // Punctuation alone is no local part.
+ assert_eq!(found(Pattern::Email, "Write to ..@example.com or -@example.com"), Vec::::new());
+ }
+
#[test]
fn internationalized_emails() {
assert_eq!(
@@ -421,6 +456,18 @@ mod tests {
);
}
+ #[test]
+ fn credit_card_followed_by_another_number() {
+ for tail in ["12/26", "2026", "123", "12 26"] {
+ assert_eq!(found(Pattern::CreditCard, &format!("Card 4111 1111 1111 1111 {tail}")), ["4111 1111 1111 1111"], "tail {tail}");
+ }
+ assert_eq!(found(Pattern::CreditCard, "Card 3782-822463-10005 123"), ["3782-822463-10005"]);
+ // A run that is not a card at any group boundary still matches nothing.
+ assert_eq!(found(Pattern::CreditCard, "Card 4111 1111 1111 1112 12/26"), Vec::::new());
+ // A longer grouped number doesn't match on a card-shaped prefix.
+ assert_eq!(found(Pattern::CreditCard, "IBAN 4111 1111 1111 1111 0000 0000"), Vec::::new());
+ }
+
#[test]
fn social_security_numbers() {
assert_eq!(
diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs
index 64d6ceefce..0c9228ac02 100644
--- a/crates/redact/src/sanitize.rs
+++ b/crates/redact/src/sanitize.rs
@@ -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> = 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)
}
@@ -266,6 +271,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result 0) || (layers && count(Hidden::HiddenLayers) > 0) {
content_pass(doc, text, layers, true)?;
}
+ // Every annotation the pages list now: whatever is no longer listed at the end (flattened
+ // widgets included) leaves the structure tree too.
+ let listed = |doc: &Document| -> std::collections::HashSet {
+ pdfcraft_model::pages(doc).iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| a.as_ref()).collect()
+ };
+ let listed_before = listed(doc);
if which.contains(&Hidden::FormFields) && count(Hidden::FormFields) > 0 {
// Fields without appearances get one first, so their values stay visible.
for f in pdfcraft_forms::fields(doc) {
@@ -286,16 +297,16 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = pdfcraft_model::pages(doc)
+ .iter()
+ .flat_map(|p| annots_of(doc, &p.dict))
+ .filter_map(|a| a.as_ref())
+ .filter(|&r| doc.get(r).as_dict().is_some_and(drop_annot))
+ .collect();
for p in pdfcraft_model::pages(doc) {
let list = annots_of(doc, &p.dict);
- let mut removed: Vec = Vec::new();
- for a in &list {
- if let Some(r) = a.as_ref()
- && doc.get(r).as_dict().is_some_and(drop_annot)
- {
- removed.push(r);
- }
- }
let mut kept = Vec::new();
for a in list {
let Some(r) = a.as_ref() else {
@@ -335,6 +346,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = listed_before.difference(&still).copied().collect();
+ crate::tags::drop_object_refs(doc, &gone)?;
let off = hidden_layers(doc);
let mut cat = doc.get(root).as_dict().cloned().unwrap_or_default();
let mut names = sub(doc, &cat, b"Names");
diff --git a/crates/redact/src/tags.rs b/crates/redact/src/tags.rs
index 02aae12aa3..b70cf23ab9 100644
--- a/crates/redact/src/tags.rs
+++ b/crates/redact/src/tags.rs
@@ -202,3 +202,111 @@ pub(crate) fn clean(
}
Ok(n)
}
+
+/// Whether `o` is an object reference (`/Type /OBJR`, direct or indirect) to one of `gone`.
+fn refers_to(doc: &Document, o: &Object, gone: &HashSet) -> bool {
+ doc.resolve(o)
+ .as_dict()
+ .is_some_and(|d| d.name(b"Type") == Some(b"OBJR") && d.get(b"Obj").and_then(Object::as_ref).is_some_and(|r| gone.contains(&r)))
+}
+
+/// Drop the structure tree's object references (OBJR) to `gone`, annotations removed from their
+/// pages (#2447). A tagged PDF's tree points at each link and comment it tags; left there, the
+/// reference keeps the annotation, its URL or text included, in a full rewrite after the page
+/// no longer lists it. Returns how many references were dropped.
+pub(crate) fn drop_object_refs(doc: &mut Document, gone: &HashSet) -> Result {
+ drop_object_refs_within(doc, gone, MAX_STRUCT_REFS)
+}
+
+/// The most structure-tree references [`drop_object_refs`] visits.
+const MAX_STRUCT_REFS: usize = 2_000_000;
+
+/// [`drop_object_refs`], visiting at most `limit` references. A tree too large to walk in full
+/// could keep a reference the walk never reached, so then the whole structure tree goes instead
+/// (with `/MarkInfo`, which would claim tags there no longer are): fail-closed, at the cost of
+/// the document's tags.
+pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet, limit: usize) -> Result {
+ if gone.is_empty() {
+ return Ok(0);
+ }
+ let Some(root) = doc
+ .root()
+ .and_then(|r| doc.get(r).as_dict().cloned())
+ .and_then(|c| c.get(b"StructTreeRoot").map(|s| doc.resolve(s)).and_then(|s| s.as_dict().cloned()))
+ else {
+ return Ok(0);
+ };
+ let mut todo: Vec = root.get(b"K").cloned().into_iter().collect();
+ let mut seen = HashSet::new();
+ let mut hits: Vec = Vec::new();
+ while let Some(k) = todo.pop() {
+ match k {
+ Object::Array(a) => todo.extend(a),
+ Object::Ref(r) => {
+ if !seen.insert(r) {
+ continue;
+ }
+ if seen.len() > limit {
+ let Some(catalog) = doc.root() else { return Ok(0) };
+ doc.update_dict(catalog, |c| {
+ c.remove(b"StructTreeRoot");
+ c.remove(b"MarkInfo");
+ })?;
+ return Ok(0);
+ }
+ let object = doc.get(r);
+ if let Object::Array(a) = &*object {
+ todo.extend(a.iter().cloned());
+ continue;
+ }
+ let Some(d) = object.as_dict() else { continue };
+ if !d.contains(b"S") {
+ continue;
+ }
+ let kids: Vec = match d.get(b"K").map(|k| doc.resolve(k)) {
+ Some(o) => match &*o {
+ Object::Array(a) => a.clone(),
+ _ => d.get(b"K").cloned().into_iter().collect(),
+ },
+ None => Vec::new(),
+ };
+ if kids.iter().any(|x| refers_to(doc, x, gone)) {
+ hits.push(r);
+ }
+ todo.extend(kids);
+ }
+ _ => {}
+ }
+ }
+ let mut n = 0;
+ for r in hits {
+ let Some(d) = doc.get(r).as_dict().cloned() else { continue };
+ let kids = d.get(b"K").map(|k| doc.resolve(k));
+ let array = kids.as_ref().is_some_and(|k| matches!(&**k, Object::Array(_)));
+ let list: Vec = match kids.as_deref() {
+ Some(Object::Array(a)) => a.clone(),
+ Some(_) => d.get(b"K").cloned().into_iter().collect(),
+ None => Vec::new(),
+ };
+ let before = list.len();
+ let keep: Vec = list.into_iter().filter(|x| !refers_to(doc, x, gone)).collect();
+ n += before - keep.len();
+ doc.update_dict(r, |e| {
+ if array {
+ e.set(b"K".to_vec(), Object::Array(keep.clone()));
+ } else if let Some(k) = keep.first().cloned() {
+ e.set(b"K".to_vec(), k);
+ } else {
+ e.remove(b"K");
+ }
+ // An element left with nothing to tag keeps no text describing what it tagged:
+ // a link's alternate text often repeats the URL, an annotation's its comment.
+ if keep.is_empty() {
+ for key in [&b"Alt"[..], b"ActualText", b"T", b"E"] {
+ e.remove(key);
+ }
+ }
+ })?;
+ }
+ Ok(n)
+}
diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs
index 89feab42a9..a7e11bcaf1 100644
--- a/crates/redact/src/tests.rs
+++ b/crates/redact/src/tests.rs
@@ -79,6 +79,30 @@ fn glyphs_under_a_mark_go_and_the_rest_stays_put() {
assert!(!content(&doc, 0).contains("1234"));
}
+/// #2130: a font with no /Widths is drawn with its embedded program's advances. Measured with a
+/// guess from its name instead (Courier's 600 units for "Mono"), the end of the line seemed to
+/// lie past a mark that covers all of it on screen, and was kept.
+#[test]
+fn a_whole_line_under_a_mark_goes_even_without_widths() {
+ let text = b"illicit little lilies";
+ let font = "<< /Type /Font /Subtype /TrueType /BaseFont /HouseMono /Encoding /WinAnsiEncoding /FontDescriptor 6 0 R >>";
+ let mut doc = pdf(vec![
+ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(),
+ stream("", [b"BT /F1 10 Tf 10 100 Td (".as_slice(), text, b") Tj ET"].concat()),
+ font.as_bytes().to_vec(),
+ b"<< /Type /FontDescriptor /FontName /HouseMono /Flags 32 /Ascent 900 /Descent -200 /FontFile2 7 0 R >>".to_vec(),
+ stream(&format!("/Length1 {}", pdfcraft_fonts::INTER_REGULAR.len()), pdfcraft_fonts::INTER_REGULAR),
+ ]);
+ // Inter draws these 21 narrow glyphs in about 60 pt; Courier's guess would need 126.
+ let line = [5.0, 95.0, 90.0, 112.0];
+ mark(&mut doc, 0, &[line], "");
+ let r = apply(&mut doc, None).unwrap();
+ assert_eq!(r.glyphs, text.len(), "every glyph of the line goes");
+ assert_eq!(under(&mut doc, 0, &[[0.0, 0.0, 300.0, 300.0]]), 0, "{}", content(&doc, 0));
+}
+
#[test]
fn added_text_parameters_do_not_keep_redacted_text() {
// An Edit ▸ Add content item: its stream dictionary keeps the source text under /PCAdded.
@@ -490,6 +514,59 @@ fn redacting_an_xfa_field_drops_the_xfa_packet_so_its_value_cannot_survive() {
assert!(cat.get(b"NeedsRendering").is_none(), "/NeedsRendering is cleared");
}
+#[test]
+fn replies_to_a_redacted_comment_go_with_it() {
+ // #2659: a highlight under the mark has a reply placed elsewhere, and that reply a reply of
+ // its own. The replies' /IRT would keep the highlight (its comment and QuadPoints) in the file.
+ let objs: Vec> = vec![
+ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>".to_vec(),
+ stream("", b"BT ET"),
+ b"<< /Type /Annot /Subtype /Highlight /Rect [20 20 60 30] /QuadPoints [20 30 60 30 20 20 60 20] /Contents (HIGHLIGHT-NOTE) >>".to_vec(),
+ b"<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 5 0 R /Contents (FIRST-REPLY) >>".to_vec(),
+ b"<< /Type /Annot /Subtype /Text /Rect [250 200 270 220] /IRT 6 0 R /Contents (SECOND-REPLY) >>".to_vec(),
+ b"<< /Type /Annot /Subtype /Text /Rect [250 100 270 120] /Contents (UNRELATED) >>".to_vec(),
+ ];
+ let mut doc = pdf(objs);
+ mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], "");
+ let r = apply(&mut doc, None).unwrap();
+ assert_eq!(r.annotations, 3, "the highlight and both replies: {r:?}");
+ let p = pdfcraft_model::pages(&doc).swap_remove(0);
+ assert_eq!(annots_of(&doc, &p.dict).len(), 1, "only the unrelated note stays");
+ // A classic table, so the objects' strings are plain in the file.
+ let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
+ for gone in [&b"HIGHLIGHT-NOTE"[..], b"FIRST-REPLY", b"SECOND-REPLY"] {
+ assert!(!bytes.windows(gone.len()).any(|w| w == gone), "{} survived the save", String::from_utf8_lossy(gone));
+ }
+ assert!(bytes.windows(9).any(|w| w == b"UNRELATED"));
+}
+
+/// The replies listed before the annotations they answer, deepest first, and a pop-up on a
+/// reply: one pass in `/Annots` order would keep them, the repeated passes don't (review of
+/// #2697).
+#[test]
+fn replies_listed_before_their_parent_and_their_pop_ups_go_too() {
+ let objs: Vec> = vec![
+ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Annots [7 0 R 9 0 R 6 0 R 5 0 R 8 0 R] >>".to_vec(),
+ stream("", b"BT ET"),
+ b"<< /Type /Annot /Subtype /Highlight /Rect [20 20 60 30] /QuadPoints [20 30 60 30 20 20 60 20] /Contents (HIGHLIGHT-NOTE) >>".to_vec(),
+ b"<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 5 0 R /Contents (FIRST-REPLY) /Popup 9 0 R >>".to_vec(),
+ b"<< /Type /Annot /Subtype /Text /Rect [250 200 270 220] /IRT 6 0 R /Contents (SECOND-REPLY) >>".to_vec(),
+ b"<< /Type /Annot /Subtype /Text /Rect [250 100 270 120] /Contents (UNRELATED) >>".to_vec(),
+ b"<< /Type /Annot /Subtype /Popup /Rect [200 150 290 190] /Parent 6 0 R >>".to_vec(),
+ ];
+ let mut doc = pdf(objs);
+ mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], "");
+ let r = apply(&mut doc, None).unwrap();
+ assert_eq!(r.annotations, 3, "the highlight and both replies: {r:?}");
+ let p = pdfcraft_model::pages(&doc).swap_remove(0);
+ let left = annots_of(&doc, &p.dict);
+ assert_eq!(left, vec![Object::Ref(ObjRef::new(8, 0))], "only the unrelated note stays, the reply's pop-up goes too");
+}
+
#[test]
fn comments_links_and_fields_under_a_mark_go() {
let mut doc = one_page(b"", "", vec![]);
@@ -631,6 +708,140 @@ fn hidden_information_is_counted_and_removed() {
assert!(shown, "the field's value stays visible as page content");
}
+/// Issue #2447: in a tagged PDF the structure tree points at the link and the comment it tags
+/// (`/OBJR`). Removing them must drop those references too, or the full rewrite keeps the
+/// annotations, the URL and the comment text included, while the page no longer lists them.
+#[test]
+fn removed_links_and_comments_leave_the_structure_tree_and_the_saved_file() {
+ use crate::sanitize::{Hidden, remove_hidden};
+ let objs: Vec> = vec![
+ // 1 catalog, with a structure tree
+ b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 8 0 R >>".to_vec(),
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
+ // 3 page: a tagged paragraph (MCID 0), a link and a comment, both tagged
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R 7 0 R] /StructParents 0 >>".to_vec(),
+ stream("", b"/P << /MCID 0 >> BDC BT /F1 10 Tf 10 230 Td (See the site) Tj ET EMC"),
+ FONT.replace("95 0 R", "13 0 R").into_bytes(),
+ // 6 link, 7 comment
+ b"<< /Type /Annot /Subtype /Link /Rect [10 228 80 240] /StructParent 1 /A << /S /URI /URI (https://secret.example/token=ZX81) >> >>".to_vec(),
+ b"<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /StructParent 2 /Contents (COMMENTSECRETNOTE) >>".to_vec(),
+ // 8 struct tree root, 9 document element, 10 paragraph, 11 link element (direct OBJR),
+ // 12 annotation element (OBJR by reference, as K's only kid)
+ b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [0 [10 0 R] 1 11 0 R 2 12 0 R] >> /ParentTreeNextKey 3 >>".to_vec(),
+ b"<< /Type /StructElem /S /Document /P 8 0 R /K [10 0 R 11 0 R 12 0 R] >>".to_vec(),
+ b"<< /Type /StructElem /S /P /P 9 0 R /Pg 3 0 R /K 0 >>".to_vec(),
+ // the link element's alternate text repeats the URL
+ b"<< /Type /StructElem /S /Link /P 9 0 R /Pg 3 0 R /Alt (secret.example link) /K [<< /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(),
+ b"<< /Type /StructElem /S /Annot /P 9 0 R /Pg 3 0 R /K 14 0 R >>".to_vec(),
+ widths(),
+ b"<< /Type /OBJR /Obj 7 0 R /Pg 3 0 R >>".to_vec(),
+ ];
+ let mut doc = pdf(objs);
+ let done = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts, Hidden::Comments]).unwrap();
+ assert!(done.contains(&(Hidden::LinksActionsScripts, 1)) && done.contains(&(Hidden::Comments, 1)), "{done:?}");
+ // Without object streams, whose compression would hide the strings from the search.
+ let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
+ let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle);
+ assert!(has(b"(See the site)"), "the search sees the saved objects");
+ assert!(!has(b"secret.example"), "the removed link's URL survived the save");
+ assert!(!has(b"COMMENTSECRETNOTE"), "the removed comment's text survived the save");
+ // The tagged paragraph and its content stay; the elements lose only the references.
+ let saved = Document::open(Arc::new(bytes)).unwrap();
+ assert!(content(&saved, 0).contains("(See the site)"));
+ let objrs = saved.object_numbers().into_iter().filter(|&n| {
+ let o = saved.get(ObjRef::new(n, saved.generation(n)));
+ let direct = o.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OBJR"));
+ let nested = o.as_dict().and_then(|d| d.get(b"K")).is_some_and(|k| match &*saved.resolve(k) {
+ Object::Array(a) => a.iter().any(|x| saved.resolve(x).as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OBJR"))),
+ _ => false,
+ });
+ direct || nested
+ });
+ assert_eq!(objrs.count(), 0, "no object reference to a removed annotation is left");
+ let root = saved.get(saved.root().unwrap()).as_dict().cloned().unwrap();
+ assert!(root.contains(b"StructTreeRoot"), "the structure tree itself stays");
+}
+
+/// #2532 review: a tagged form's widgets, flattened when form fields are removed, leave the
+/// structure tree too (their field value with them), and a pop-up listed on another page than
+/// its removed comment goes with it.
+#[test]
+fn flattened_widgets_and_far_pop_ups_leave_the_structure_tree() {
+ use crate::sanitize::{Hidden, remove_hidden};
+ let objs: Vec> = vec![
+ // 1 catalog with a form and a structure tree
+ b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> /StructTreeRoot 8 0 R >>".to_vec(),
+ b"<< /Type /Pages /Kids [3 0 R 13 0 R] /Count 2 >>".to_vec(),
+ // 3 page 1: a text field's widget and a comment
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R 7 0 R] /StructParents 0 >>".to_vec(),
+ stream("", b"BT /F1 10 Tf 10 230 Td (Name:) Tj ET"),
+ FONT.replace("95 0 R", "12 0 R").into_bytes(),
+ // 6 the field and its widget, with an appearance
+ b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (FIELDSECRETVALUE) /Rect [50 225 200 240] /StructParent 1 /AP << /N 11 0 R >> >>".to_vec(),
+ // 7 comment, its pop-up (15) listed on page 2
+ b"<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /Contents (note) /Popup 15 0 R >>".to_vec(),
+ // 8 struct tree root, 9 document, 10 form element tagging the widget
+ b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [1 10 0 R] >> /ParentTreeNextKey 2 >>".to_vec(),
+ b"<< /Type /StructElem /S /Document /P 8 0 R /K [10 0 R] >>".to_vec(),
+ b"<< /Type /StructElem /S /Form /P 9 0 R /Pg 3 0 R /K [<< /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(),
+ stream("/Type /XObject /Subtype /Form /BBox [0 0 150 15] /Resources << /Font << /F1 5 0 R >> >>", b"BT /F1 10 Tf 2 3 Td (FIELDSECRETVALUE) Tj ET"),
+ widths(),
+ // 13 page 2, listing the pop-up of page 1's comment
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [15 0 R 16 0 R] >>".to_vec(),
+ b"<< >>".to_vec(),
+ b"<< /Type /Annot /Subtype /Popup /Rect [0 0 100 50] /Parent 7 0 R >>".to_vec(),
+ // 16 an unrelated link on page 2 stays
+ b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] >>".to_vec(),
+ ];
+ let mut doc = pdf(objs);
+ remove_hidden(&mut doc, &[Hidden::FormFields, Hidden::Comments]).unwrap();
+ let page2 = pdfcraft_model::pages(&doc)[1].dict.clone();
+ let left: Vec = page2.get(b"Annots").map(|a| doc.resolve(a).as_array().map(|a| a.to_vec()).unwrap_or_default()).unwrap_or_default();
+ assert_eq!(left, [Object::Ref(ObjRef::new(16, 0))], "the far pop-up went with its comment");
+ let form = doc.get(ObjRef::new(10, 0)).as_dict().cloned().unwrap();
+ let kids = form.get(b"K").map(|k| doc.resolve(k).as_array().map(Vec::len).unwrap_or(1));
+ assert!(kids.unwrap_or(0) == 0, "the form element no longer references the flattened widget");
+ let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
+ let saved = Document::open(Arc::new(bytes)).unwrap();
+ let field_left = saved.object_numbers().into_iter().any(|n| {
+ saved.get(ObjRef::new(n, saved.generation(n))).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") || d.contains(b"FT"))
+ });
+ assert!(!field_left, "the flattened field didn't survive the save through the structure tree");
+}
+
+/// #2532 review: a structure tree larger than the walk's limit can't be searched in full, so a
+/// reference the walk never reached could keep a removed annotation. Then the whole tree goes
+/// (with `/MarkInfo`), rather than reporting success with references left behind; under the
+/// limit only the reference goes.
+#[test]
+fn a_structure_tree_too_large_to_walk_is_removed_whole() {
+ let objs: Vec> = vec![
+ b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 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 300 300] >>".to_vec(),
+ // 4: the removed link
+ b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://secret.example) >> >>".to_vec(),
+ // 5 root, 6 document, 7 section, 8 link element with the OBJR
+ b"<< /Type /StructTreeRoot /K 6 0 R >>".to_vec(),
+ b"<< /Type /StructElem /S /Document /P 5 0 R /K [7 0 R] >>".to_vec(),
+ b"<< /Type /StructElem /S /Sect /P 6 0 R /K [8 0 R] >>".to_vec(),
+ b"<< /Type /StructElem /S /Link /P 7 0 R /K [<< /Type /OBJR /Obj 4 0 R >>] >>".to_vec(),
+ ];
+ let gone: std::collections::HashSet = [ObjRef::new(4, 0)].into();
+ let catalog = |doc: &Document| doc.get(doc.root().unwrap()).as_dict().cloned().unwrap();
+
+ let mut doc = pdf(objs.clone());
+ assert_eq!(crate::tags::drop_object_refs_within(&mut doc, &gone, 100).unwrap(), 1);
+ assert!(catalog(&doc).contains(b"StructTreeRoot") && catalog(&doc).contains(b"MarkInfo"), "under the limit the tree stays");
+
+ let mut doc = pdf(objs);
+ crate::tags::drop_object_refs_within(&mut doc, &gone, 2).unwrap();
+ assert!(!catalog(&doc).contains(b"StructTreeRoot"), "a tree the walk couldn't finish goes whole");
+ assert!(!catalog(&doc).contains(b"MarkInfo"));
+ let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
+ assert!(!bytes.windows(14).any(|w| w == b"secret.example"), "the removed link can't be reached any more");
+}
+
/// Issue #1325: removing comments keeps the annotations that are not comments: the multimedia
/// and print-production ones (Watermark, PrinterMark, TrapNet, Screen, Movie, RichMedia, 3D).
#[test]
@@ -1056,6 +1267,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 <> 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 <> 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 <> 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
@@ -1156,3 +1461,86 @@ fn rewriting_a_piece_keeps_its_leading_comment_terminator() {
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;
diff --git a/crates/redact/src/tests/property_retirement.rs b/crates/redact/src/tests/property_retirement.rs
new file mode 100644
index 0000000000..ad5197637a
--- /dev/null
+++ b/crates/redact/src/tests/property_retirement.rs
@@ -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 {
+ 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 {
+ 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);
+}
diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs
index 3c3988b7b5..0979f3d38a 100644
--- a/crates/render/src/inspect.rs
+++ b/crates/render/src/inspect.rs
@@ -56,6 +56,9 @@ pub struct DocInfo {
pub keywords: Option,
pub creator: Option,
pub producer: Option,
+ /// Custom document properties: `/Info` entries with a key the spec does not name and a text
+ /// value, as (name, text), in the dictionary's order (at most 256).
+ pub custom: Vec<(String, String)>,
pub pdf_version: String,
pub file_size: usize,
pub encrypted: bool,
@@ -616,6 +619,23 @@ impl<'a> Inspector<'a> {
info.keywords = self.text(d, b"Keywords");
info.creator = self.text(d, b"Creator");
info.producer = self.text(d, b"Producer");
+ const STANDARD: [&[u8]; 9] =
+ [b"Title", b"Author", b"Subject", b"Keywords", b"Creator", b"Producer", b"CreationDate", b"ModDate", b"Trapped"];
+ // The same rule as the engine's list after an edit (`pdfcraft_organize::custom_info`,
+ // trimmed and without empty values): text strings only, so a name value is not a
+ // custom property, and the cap counts listed entries.
+ info.custom = d
+ .iter()
+ .filter(|(k, _)| !STANDARD.contains(&k.as_slice()))
+ .filter_map(|(k, v)| match self.resolve(v) {
+ Object::String(bytes, _) => {
+ let v = text_string(bytes).trim_matches(|c: char| c == '\0' || c.is_whitespace()).to_string();
+ (!v.is_empty()).then(|| (String::from_utf8_lossy(k).into_owned(), v))
+ }
+ _ => None,
+ })
+ .take(256)
+ .collect();
}
let Ok(catalog) = self.doc.catalog() else { return };
info.tagged = catalog
@@ -678,6 +698,12 @@ impl<'a> Inspector<'a> {
}
info.has_javascript |= names.get(b"JavaScript").is_ok();
}
+ if let Ok(action) = catalog.get(b"OpenAction")
+ && let Some(dict) = self.dict(action)
+ && dict.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) == Some(&b"JavaScript"[..])
+ {
+ info.has_javascript = true;
+ }
}
// ── object helpers ──────────────────────────────────────────────────────────────────────
@@ -2302,6 +2328,27 @@ trailer << /Root 1 0 R >>
);
}
+ const CUSTOM_INFO: &[u8] = b"%PDF-1.7
+1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
+2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >> endobj
+3 0 obj << /Type /Page /Parent 2 0 R >> endobj
+4 0 obj << /Title (Report) /Producer (Test) /Project (Apollo) /Revision 7 /Dept (R&D) /Trapped /False /Note >> endobj
+trailer << /Root 1 0 R /Info 4 0 R >>
+%%EOF";
+
+ #[test]
+ fn custom_document_properties_are_listed_apart_from_the_standard_ones() {
+ let info = inspect(Arc::new(CUSTOM_INFO.to_vec()), None).expect("opens");
+ assert_eq!((info.title.as_deref(), info.producer.as_deref()), (Some("Report"), Some("Test")));
+ let mut custom = info.custom.clone();
+ custom.sort();
+ // Revision is a number, not text, so it is not listed; Trapped is a standard entry.
+ assert_eq!(
+ custom,
+ [("Dept".to_string(), "R&D".to_string()), ("Note".to_string(), "部".to_string()), ("Project".to_string(), "Apollo".to_string())]
+ );
+ }
+
#[test]
fn radio_button_layer_groups_are_read() {
let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens");
@@ -2982,3 +3029,49 @@ trailer << /Size 7 /Root 1 0 R >>
assert_eq!((f.substitute.as_deref(), f.substitution.as_deref()), (None, None), "the document's own glyphs are shown");
}
}
+
+#[cfg(test)]
+mod open_action_javascript_tests {
+ use std::sync::Arc;
+
+ use super::inspect;
+
+ /// One-page PDF whose catalog points its /OpenAction at object 4 (written by the caller).
+ fn pdf_with_open_action(action: &[u8]) -> Vec {
+ let objs: Vec<&[u8]> = vec![
+ b"<< /Type /Catalog /Pages 2 0 R /OpenAction 4 0 R >>",
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>",
+ action,
+ ];
+ let mut out = b"%PDF-1.7\n".to_vec();
+ let mut offsets = Vec::new();
+ for (i, o) in objs.iter().enumerate() {
+ offsets.push(out.len());
+ out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
+ out.extend_from_slice(o);
+ out.extend_from_slice(b"\nendobj\n");
+ }
+ let xref = out.len();
+ out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
+ for o in offsets {
+ out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
+ }
+ out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
+ out
+ }
+
+ #[test]
+ fn open_action_javascript_sets_has_javascript() {
+ let bytes = pdf_with_open_action(b"<< /S /JavaScript /JS (app.alert('hi')) >>");
+ let info = inspect(Arc::new(bytes), None).expect("inspects");
+ assert!(info.has_javascript, "an /OpenAction /JavaScript is JavaScript");
+ }
+
+ #[test]
+ fn open_action_goto_does_not_set_has_javascript() {
+ let bytes = pdf_with_open_action(b"<< /S /GoTo /D [3 0 R /Fit] >>");
+ let info = inspect(Arc::new(bytes), None).expect("inspects");
+ assert!(!info.has_javascript, "a /GoTo /OpenAction is not JavaScript");
+ }
+}
diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs
index 8d5686dd8c..b2127894fd 100644
--- a/crates/render/src/raster.rs
+++ b/crates/render/src/raster.rs
@@ -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");
diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs
index b767e28314..6784f14eb0 100644
--- a/crates/render/src/text.rs
+++ b/crates/render/src/text.rs
@@ -1257,26 +1257,134 @@ fn collide(glyphs: &[&TextGlyph], height: &dyn Fn(usize) -> f32, h: f32, sc: &mu
dense
}
-/// Reverses each right-to-left span of `idx` (visual → logical order). A space between two right-to-left glyphs
-/// belongs to the span.
-/// Reverse right-to-left runs of `idx` (visual → logical), and the word gaps between their glyphs
-/// with them: `spaces[w]` is a gap left of `idx[w]` (#1062).
+/// Simplified bidi class of a glyph or word gap (UAX #9: strong L/R, numbers, neutrals).
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum Bidi {
+ L,
+ R,
+ Num,
+ N,
+}
+
+fn bidi_class(text: &str) -> Bidi {
+ if text.chars().any(|c| is_rtl(c) && !c.is_numeric()) {
+ Bidi::R
+ } else if text.chars().any(char::is_numeric) {
+ Bidi::Num
+ } else if text.chars().any(char::is_alphabetic) {
+ Bidi::L
+ } else {
+ Bidi::N
+ }
+}
+
+/// Reorders a line of `idx` from visual to logical order with a simplified bidi level pass (#2667), carrying the
+/// word gaps along: `spaces[w]` is a gap left of `idx[w]` (#1062). The paragraph is right-to-left when it has
+/// more right-to-left than left-to-right glyphs; numbers keep their left-to-right order, and neutrals
+/// (punctuation, gaps) take the direction of their neighbours, or the paragraph's between opposite ones.
fn reverse_rtl(glyphs: &[&TextGlyph], idx: &mut [usize], spaces: &mut [bool]) {
- let rtl = |i: usize| glyphs[i].text.chars().any(is_rtl);
+ // Glyphs and word gaps (None) in visual order.
+ let mut items: Vec<(Option, Bidi)> = Vec::with_capacity(idx.len() * 2);
+ for (w, &i) in idx.iter().enumerate() {
+ if w > 0 && spaces.get(w).copied().unwrap_or(false) {
+ items.push((None, Bidi::N));
+ }
+ items.push((Some(i), bidi_class(&glyphs[i].text)));
+ }
+ let texts: Vec<&str> = items.iter().map(|it| it.0.map_or("", |i| glyphs[i].text.as_str())).collect();
+ let text = |k: usize| texts.get(k).copied().unwrap_or("");
+ let count = |b: Bidi| items.iter().filter(|it| it.1 == b).count();
+ let para = if count(Bidi::R) > count(Bidi::L) { Bidi::R } else { Bidi::L };
+ let n = items.len();
+ // W4: a single separator between two digits belongs to the number.
+ for k in 1..n.saturating_sub(1) {
+ let sep = matches!(text(k), "." | "," | ":" | "/" | "+" | "-" | "\u{066B}" | "\u{066C}");
+ if sep && items[k - 1].1 == Bidi::Num && items[k + 1].1 == Bidi::Num {
+ items[k].1 = Bidi::Num;
+ }
+ }
+ // W5: terminators (percent, currency) next to a number belong to it.
+ let term =
+ |s: &str| !s.is_empty() && s.chars().all(|c| matches!(c, '%' | '‰' | '#' | '$' | '¢'..='¥' | '°' | '\u{066A}' | '\u{20A0}'..='\u{20CF}'));
+ for k in 1..n {
+ if items[k].1 == Bidi::N && items[k - 1].1 == Bidi::Num && term(text(k)) {
+ items[k].1 = Bidi::Num;
+ }
+ }
+ for k in (0..n.saturating_sub(1)).rev() {
+ if items[k].1 == Bidi::N && items[k + 1].1 == Bidi::Num && term(text(k)) {
+ items[k].1 = Bidi::Num;
+ }
+ }
+ // W7: a number after left-to-right text (or at the start of a left-to-right paragraph) is left-to-right.
+ let mut strong = para;
+ for it in &mut items {
+ match it.1 {
+ Bidi::L | Bidi::R => strong = it.1,
+ Bidi::Num if strong == Bidi::L => it.1 = Bidi::L,
+ _ => {}
+ }
+ }
+ // N1/N2: neutrals between same-direction text take it (numbers count as right-to-left), else the paragraph's.
+ let dir = |b: Bidi| if b == Bidi::L { Bidi::L } else { Bidi::R };
let mut k = 0;
- while k < idx.len() {
- if rtl(idx[k]) {
+ while k < n {
+ if items[k].1 != Bidi::N {
+ k += 1;
+ continue;
+ }
+ let mut e = k;
+ while e < n && items[e].1 == Bidi::N {
+ e += 1;
+ }
+ let before = if k == 0 { para } else { dir(items[k - 1].1) };
+ let after = if e == n { para } else { dir(items[e].1) };
+ let resolved = if before == after { before } else { para };
+ for it in &mut items[k..e] {
+ it.1 = resolved;
+ }
+ k = e;
+ }
+ // Levels (I1/I2), then L2: reverse every run at or above each level, highest first.
+ let rtl_para = para == Bidi::R;
+ let levels: Vec = items
+ .iter()
+ .map(|it| match it.1 {
+ Bidi::R => 1,
+ Bidi::Num => 2,
+ _ if rtl_para => 2,
+ _ => 0,
+ })
+ .collect();
+ let mut order: Vec = (0..n).collect();
+ for level in (1..=levels.iter().copied().max().unwrap_or(0)).rev() {
+ let mut k = 0;
+ while k < n {
+ if levels[order[k]] < level {
+ k += 1;
+ continue;
+ }
let mut e = k;
- while e + 1 < idx.len() && (rtl(idx[e + 1]) || (glyphs[idx[e + 1]].text.trim().is_empty() && e + 2 < idx.len() && rtl(idx[e + 2]))) {
+ while e < n && levels[order[e]] >= level {
e += 1;
}
- idx[k..=e].reverse();
- if let Some(gaps) = spaces.get_mut(k + 1..=e) {
- gaps.reverse();
+ order[k..e].reverse();
+ k = e;
+ }
+ }
+ let mut j = 0;
+ let mut gap = false;
+ for &o in &order {
+ match items[o].0 {
+ None => gap = true,
+ Some(i) => {
+ if let (Some(slot), Some(space)) = (idx.get_mut(j), spaces.get_mut(j)) {
+ *slot = i;
+ *space = gap && j > 0;
+ }
+ gap = false;
+ j += 1;
}
- k = e + 1;
- } else {
- k += 1;
}
}
}
@@ -1293,6 +1401,7 @@ fn union(a: &mut [f32; 4], b: &[f32; 4]) {
///
/// Heuristics (documented so they can be tuned and tested; plan/architecture.md §8):
/// 1. *Segments:* consecutive glyphs (content order) on one baseline with no column-sized gap.
+/// A smaller glyph right next to the previous one (a sub- or superscript) stays in the segment.
/// Segments on the same baseline that nearly touch are merged (text drawn out of order).
/// 2. *Blocks:* segments stacked with line-sized gaps and overlapping horizontally.
/// 3. *Block order:* repeatedly take the top-most remaining block, preferring a block to its left
@@ -1345,7 +1454,10 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) {
let Some(p) = s.first.end.checked_sub(1) else { return false };
let ph = height(p).min(height(i));
let gap = g[0] - glyphs[p].rect[2];
- (cy(i) - cy(p)).abs() < ph * 0.5 && gap < ph * 3.0 && g[0] > glyphs[p].rect[0] - ph * 2.0
+ // A sub- or superscript is a smaller glyph on a shifted baseline: its em box centre sits off the line by
+ // more than half its own height, but by less than half the height of the full-size glyph beside it.
+ let centre_limit = if gap.abs() < ph { height(p).max(height(i)) * 0.5 } else { ph * 0.5 };
+ (cy(i) - cy(p)).abs() < centre_limit && gap < ph * 3.0 && g[0] > glyphs[p].rect[0] - ph * 2.0
});
let reset = cont && segs.last().is_some_and(|s| restarts_run(&glyphs, s.first.start, i, furthest_progress));
if cont
@@ -1528,6 +1640,23 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) {
mod tests {
use super::*;
+ fn em_box(text: &str, x0: f32, y0: f32, x1: f32, y1: f32) -> TextGlyph {
+ TextGlyph { text: text.into(), rect: [x0, y0, x1, y1], quad: [[x0, y0], [x1, y0], [x1, y1], [x0, y1]], direction: [1.0, 0.0] }
+ }
+
+ #[test]
+ fn subscript_stays_in_its_word() {
+ // "CO2-E": an 8.1 pt "CO" and "-E", with a 4.7 pt "2" drawn 2.7 pt lower (#3174).
+ let glyphs = vec![
+ em_box("C", 0.0, 92.5, 5.0, 100.6),
+ em_box("O", 5.0, 92.5, 11.0, 100.6),
+ em_box("2", 11.0, 97.9, 13.5, 102.6),
+ em_box("-", 13.5, 92.5, 16.0, 100.6),
+ em_box("E", 16.0, 92.5, 21.0, 100.6),
+ ];
+ assert_eq!(layout(glyphs).plain_text(), "CO2-E");
+ }
+
#[test]
fn identity_v_columns_are_searchable_in_reading_order() {
use lopdf::{Document, Object, Stream, dictionary};
@@ -2092,6 +2221,26 @@ mod tests {
assert_eq!(layout(v).plain_text(), "שלום עולם");
}
+ /// #2667: a number or punctuation on a right-to-left line must not split it into separately reversed words.
+ #[test]
+ fn rtl_line_with_number_and_punctuation_reads_in_logical_order() {
+ let line = |words: &[&str]| {
+ let mut v = Vec::new();
+ let mut x = 10.0;
+ for w in words {
+ word(&mut v, w, x, 10.0, 6.0);
+ x += w.chars().count() as f32 * 6.0 + 6.0;
+ }
+ layout(v).plain_text()
+ };
+ // Each line as drawn, left to right.
+ assert_eq!(line(&["םילקש", "120", "ריחמ"]), "מחיר 120 שקלים");
+ assert_eq!(line(&[".הקידב", "ךמסמ", "והז", ":תירבע"]), "עברית: זהו מסמך בדיקה.");
+ assert_eq!(line(&["باتك", "12.5", "ددع"]), "عدد 12.5 كتاب");
+ assert_eq!(line(&["םלוע", "םולש"]), "שלום עולם");
+ assert_eq!(line(&["Total", "םולש", "price"]), "Total שלום price");
+ }
+
/// Two lines, "ab cd" and "e fg": words stop at blank glyphs, word gaps and line ends.
#[test]
fn words_and_lines_around_a_glyph() {
diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml
index aa9fe54f66..42e9474f07 100644
--- a/crates/ui-egui/Cargo.toml
+++ b/crates/ui-egui/Cargo.toml
@@ -71,7 +71,7 @@ rfd = { workspace = true, features = ["file-handle-inner"] }
wasm-bindgen-futures = "0.4"
wasm-bindgen = "0.2"
js-sys = "0.3"
-web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget"] }
+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget", "Navigator"] }
[dev-dependencies]
pdfcraft-xfa = { workspace = true }
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 0acab64a08..b5ca6abab8 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -423,6 +423,8 @@ pub struct DocView {
/// This frame's consumers, with visible cells ahead of prefetch.
thumb_demand: HashMap,
frame_queue: Vec,
+ /// A page-list edit after layout invalidates this frame's scales and page indexes.
+ frame_geometry_changed: bool,
/// Pages on screen this frame. Their rasters and tiles are always admitted: the viewport
/// bounds them, and the byte and count limits apply to prefetch only.
frame_visible: HashSet,
@@ -444,6 +446,9 @@ pub struct DocView {
pub(crate) text_failed: HashSet,
pub find: Option,
selection: Option,
+ /// The selection whose action bar was closed by its Copy button: the bar stays hidden
+ /// until the selection changes, as it does once a markup button consumes the selection.
+ actions_dismissed: Option,
/// The drag in progress started a text selection: the wheel scrolls the page during it
/// (#721), which egui's scroll area refuses while anything is dragged.
selection_drag: bool,
@@ -452,6 +457,9 @@ pub struct DocView {
last_press_time: f64,
last_press_pos: Option,
last_queue: Vec,
+ /// 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,
@@ -549,6 +557,9 @@ pub struct DocView {
pub pages_panel: Option,
/// The Pages-panel gap under the pointer (where dropped files go), when it can take pages.
pub panel_gap: Option,
+ /// The current page the Pages panel last followed: it scrolls to the current page's
+ /// thumbnail only when the page changes.
+ pub(crate) panel_followed: Option,
/// How large the page grid draws its pages (1.0 = the usual thumbnails).
grid_zoom: f32,
/// A zoom asked for this frame (toolbar, pinch, keys); the grid applies it once it has laid
@@ -577,6 +588,8 @@ pub struct DocView {
pub(crate) last_scroll: Vec2,
/// Side-by-side compare: scroll to here this frame, following the other half.
pub(crate) sync_scroll: Option,
+ /// The page widget's id, by which the Hand tool finds its drag before the pages are laid out.
+ page_widget: Option,
pan_hold_until: f64,
/// Pages whose raster proved slow (see `SLOW_MILLIS`): only these get the zoom/pan
/// settle holds. Fast pages render straight through, with no added latency.
@@ -713,6 +726,7 @@ impl DocView {
thumbs: HashMap::new(),
thumb_demand: HashMap::new(),
frame_queue: Vec::new(),
+ frame_geometry_changed: false,
frame_visible: HashSet::new(),
stale_thumbs: HashSet::new(),
stale_tiles: HashSet::new(),
@@ -724,11 +738,13 @@ impl DocView {
text_failed: HashSet::new(),
find: None,
selection: None,
+ actions_dismissed: None,
selection_drag: false,
clicks: 0,
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(),
@@ -761,6 +777,7 @@ impl DocView {
grid_gap: None,
pages_panel: None,
panel_gap: None,
+ panel_followed: None,
grid_zoom: 1.0,
grid_zoom_request: None,
grid_anchor: None,
@@ -770,6 +787,7 @@ impl DocView {
sharp_hold_until: 0.0,
last_scroll: Vec2::ZERO,
sync_scroll: None,
+ page_widget: None,
pan_hold_until: 0.0,
slow_pages: HashSet::new(),
comments: Default::default(),
@@ -798,6 +816,7 @@ impl DocView {
/// adopt the new page geometry, keeping the reader's place where possible.
pub fn document_changed(&mut self, info: &DocInfo) {
self.invalidate_content();
+ self.frame_geometry_changed = true;
self.page_count = info.pages.len();
self.page_heights = info.pages.iter().map(|p| p.height).collect();
let last = self.page_count.saturating_sub(1);
@@ -1033,6 +1052,7 @@ impl DocView {
pub(crate) fn clear_selection(&mut self) {
self.selection = None;
+ self.actions_dismissed = None;
}
/// Select text on `page` from glyph `from` to glyph `to` (tests and automation).
@@ -1501,6 +1521,13 @@ impl DocView {
}
}
+ /// A zoom typed as a percentage ("150", "150%", " 62.5 % ") as a zoom factor; `None` unless it is a
+ /// finite, positive number. `set_zoom` clamps it to the supported range.
+ pub fn parse_zoom_percent(typed: &str) -> Option {
+ let percent = typed.trim().trim_end_matches('%').trim_end().parse::().ok()?;
+ (percent.is_finite() && percent > 0.0).then_some(percent / 100.0)
+ }
+
/// Standard zoom steps (as in Acrobat's zoom menu).
pub fn zoom_step(&mut self, up: bool) {
const STEPS: [f32; 17] = [0.1, 0.25, 0.33, 0.5, 0.66, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0, 12.0, 16.0, 32.0];
@@ -1513,6 +1540,7 @@ impl DocView {
/// Start collecting demand from every consumer before scheduling or uploading results.
pub(crate) fn begin_render_frame(&mut self) {
+ self.frame_geometry_changed = false;
self.pages_panel = None;
self.panel_gap = None;
self.thumb_demand.clear();
@@ -1728,12 +1756,23 @@ impl DocView {
}
pub(crate) fn finish_render_frame(&mut self, ctx: &egui::Context, info: &DocInfo, pool: &RenderPool) {
+ if self.frame_geometry_changed {
+ // Dialogs and edits can replace pages after the canvas collected demand. Its
+ // scales and indexes belong to the old pages, not to the new renderer (#3149).
+ self.begin_render_frame();
+ self.last_queue.clear();
+ pool.set_queue(Vec::new());
+ ctx.request_repaint();
+ return;
+ }
let queue = self.prepare_render_queue(info, ctx.pixels_per_point());
if queue != self.last_queue {
pool.set_queue(queue.clone());
self.last_queue = queue;
}
- self.receive(ctx, pool);
+ if !self.hold_rasters {
+ self.receive(ctx, pool);
+ }
if !self.last_queue.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(30));
}
@@ -1792,6 +1831,12 @@ impl DocView {
self.errors.insert(r.request.page, e);
return 0;
}
+ // Even current demand may have been sized with obsolete geometry or a different
+ // device limit. Check before cache eviction, conversion and every upload path.
+ let max_side = ctx.input(|i| i.max_texture_side);
+ if r.width == 0 || r.height == 0 || r.width as usize > max_side || r.height as usize > max_side {
+ return 0;
+ }
// Remember heavy pages: only these get the zoom/pan settle holds, so fast pages
// never pay added latency. Stale entries die with the tiles on edits.
if r.millis >= SLOW_MILLIS {
@@ -2150,11 +2195,20 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s
if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::F))) {
view.open_find();
}
+ let cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND, k);
+ let pressed = |s: KeyboardShortcut| ctx.input_mut(|i| i.consume_shortcut(&s));
+ // ⌘G / ⇧⌘G step through the matches from the find field too, as in Acrobat and Preview
+ // (#2669): ahead of the text-input check, but only while there is a search to step.
+ if view.find.is_some() {
+ if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::G)) {
+ view.find_step(false);
+ } else if pressed(cmd(Key::G)) {
+ view.find_step(true);
+ }
+ }
if ctx.egui_wants_keyboard_input() {
return;
}
- let cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND, k);
- let pressed = |s: KeyboardShortcut| ctx.input_mut(|i| i.consume_shortcut(&s));
// Rotation first: egui matches ⌘+ loosely with respect to Shift.
if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Plus))
|| pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Equals))
@@ -2195,11 +2249,6 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s
view.fit = Fit::Width;
view.goto = Some((view.current, 0.0));
}
- if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::G)) {
- view.find_step(false);
- } else if pressed(cmd(Key::G)) {
- view.find_step(true);
- }
if pressed(cmd(Key::OpenBracket)) {
view.view_history(false);
}
@@ -2406,9 +2455,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
// When not scrolling continuously, only one row is shown at a time: the current page, or the
// current two-page spread (with the cover rule). These indices drive paging, which pages are
// drawn, and the content height (#752). Empty for a document with no pages.
- let row: Vec = if rects.is_empty() {
- Vec::new()
- } else {
+ let shown_row = |view: &DocView| -> Vec {
+ if rects.is_empty() {
+ return Vec::new();
+ }
let last = rects.len() - 1;
let cur = view.current.min(last);
match view.layout {
@@ -2420,6 +2470,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
}
}
};
+ let row = shown_row(view);
let middle_gesture = view.auto_scroll.blocks_input();
// Not scrolling: when the whole page or spread fits, the wheel would do nothing, so it turns
// pages instead; zoomed in far enough to pan, it pans, and at the edge it turns the page on
@@ -2520,6 +2571,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if view.fling.gliding() {
ui.ctx().request_repaint();
}
+ // The wheel may have turned the page above: draw the row it turned to. Measured from the old
+ // row, the jump to the new page scrolled both out of view, a blank frame on every turn forward.
+ let row = shown_row(view);
let visible_pages: Vec = if view.continuous { (0..rects.len()).collect() } else { row.clone() };
let (y_shift, content_h) = if view.continuous {
(0.0, rects.iter().map(|r| r.bottom()).reduce(f32::max).map(|bottom| bottom + MARGIN).unwrap_or(0.0))
@@ -2552,6 +2606,25 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if !std::mem::replace(&mut view.shown, true) {
scroll = scroll.scroll_offset(Vec2::ZERO);
}
+ // The Hand tool pans here, before the pages are laid out, so the page moves with the pointer
+ // in the frame the pointer moves (#924). Scrolling by the drag from inside the area moved the
+ // page only after that frame was drawn, a frame behind. The frame egui tells the drag from a
+ // click, the page catches up with all the pointer has moved since the press. A jump asked for
+ // below still wins.
+ if app.quick_tool == QuickTool::Hand
+ && !middle_gesture
+ && let Some(id) = view.page_widget.filter(|id| ui.ctx().is_being_dragged(*id))
+ {
+ let moved = if ui.ctx().drag_started_id() == Some(id) {
+ ui.input(|i| i.pointer.total_drag_delta()).unwrap_or(Vec2::ZERO)
+ } else {
+ ui.input(|i| i.pointer.delta())
+ };
+ // Within the content, as egui keeps it: past an end the page would show there for a
+ // frame and spring back. `max` is never NaN or below zero, so the clamp can't panic.
+ let max = vec2((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0));
+ scroll = scroll.scroll_offset((view.last_scroll - moved).clamp(Vec2::ZERO, max));
+ }
if let Some(v) = view.sync_scroll.take() {
scroll = scroll.scroll_offset(v);
}
@@ -2559,13 +2632,26 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let r = rects[page.min(rects.len() - 1)];
let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height());
scroll = scroll.scroll_offset(vec2((point.x - rel.x).max(0.0), (point.y - rel.y).max(0.0)));
- } else if edge_turned {
- // The row above was laid out for the page before the turn: place the new one next frame.
- ui.ctx().request_repaint();
} else if let Some((page, frac)) = view.goto.take() {
let page = page.min(rects.len() - 1);
let r = rects[page];
- scroll = scroll.vertical_scroll_offset((r.top() - y_shift - GAP + frac * r.height()).max(0.0));
+ // Clamped here: egui clamps only after drawing, so a page that fits was drawn MARGIN - GAP
+ // too high for a frame and then hopped into place. (`avail` is the viewport, as the
+ // scroll bars float.)
+ let max_y = (content_h - avail.height()).max(0.0);
+ scroll = scroll.vertical_scroll_offset((r.top() - y_shift - GAP + frac * r.height()).max(0.0).min(max_y));
+ // Horizontally too, as Acrobat does (#2665): beside a much wider page the others sit off
+ // to one side. A page already shown whole, or one filling the window's width, keeps the
+ // scroll, so paging through a zoomed-in document stays on the same column. Otherwise a
+ // page that fits is centred, and a wider one scrolled just far enough to fill the window.
+ // The page counts with its side gutters, which keep its edge clear of the floating
+ // quick-action bar as the layout does.
+ let (x, w) = (view.last_scroll.x, avail.width());
+ let (left, right) = (r.left() - SIDE, r.right() + SIDE);
+ let target = if right - left <= w { if left >= x && right <= x + w { x } else { r.center().x - w / 2.0 } } else { x.clamp(left, right - w) };
+ if (target - x).abs() > 0.5 {
+ scroll = scroll.horizontal_scroll_offset(target.clamp(0.0, (content_w - w).max(0.0)));
+ }
}
let ppp = ui.ctx().pixels_per_point();
let scale = view.render_scale(ppp);
@@ -2697,14 +2783,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none());
}
let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag());
+ view.page_widget = Some(resp.id);
// A drag lasts only while egui reports it, so the mark can't outlive one.
if !resp.dragged_by(egui::PointerButton::Primary) {
view.selection_drag = false;
}
- // The Hand tool pans: the content widget takes every drag, so scroll by its delta.
+ // The Hand tool pans by this widget's drag, before the pages are laid out (above).
if hand {
if resp.dragged() {
- ui.scroll_with_delta(resp.drag_delta());
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
} else if resp.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
@@ -3191,6 +3277,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
}
}
if resp.drag_started() && press_here && !over_link {
+ // A drag replaces the selection without passing through none, so a
+ // re-drag over text whose bar Copy closed would keep the bar hidden.
+ view.actions_dismissed = None;
view.selection_drag = true;
let origin = ui.input(|inp| inp.pointer.press_origin()).unwrap_or(p);
let (ox, oy) = xf.screen_to_view(origin);
@@ -3275,6 +3364,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if tool == QuickTool::Select && !consumed {
crate::image_pick::page_input(&resp, &xf, i, info, doc, pointer, view);
}
+ crate::image_pick::drop_stale(doc, view);
crate::image_pick::paint(painter, &xf, i, info, view);
comments::page_after_text(&resp, &pcx, view);
if tool == QuickTool::Redact && can_modify {
@@ -3607,6 +3697,26 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
view.last_scroll = scroll_offset;
let pan_held = now_settle < view.pan_hold_until;
let (mut wanted, visible_now) = out.inner;
+ // Single-page and two-page views draw only the current row, so the next one was first asked
+ // for on the turn and showed blank paper until it rendered. Prefetch the rows either side,
+ // as continuous scrolling does for the pages just off screen.
+ if !view.continuous
+ && let (Some(&first), Some(&last)) = (visible_pages.first(), visible_pages.last())
+ {
+ let n = if view.layout == PageLayout::Single { 1 } else { 2 };
+ let max_side = ui.ctx().input(|inp| inp.max_texture_side) as f32;
+ for i in (last.saturating_add(1)..=last.saturating_add(n)).chain(first.saturating_sub(n)..first) {
+ let Some(p) = info.pages.get(i).filter(|_| !view.errors.contains_key(&i)) else { continue };
+ // As drawn: the whole page at this scale, or the backdrop of a tiled page.
+ let side = p.width.max(p.height).max(1.0);
+ wanted.push(if side * scale > TILE_THRESHOLD.min(max_side) {
+ let bs = BASE_SIDE.min(max_side) / side;
+ (i, bs, scale_tag(bs), None)
+ } else {
+ (i, scale, tag, None)
+ });
+ }
+ }
let cur = view.current;
wanted.sort_by_key(|w| (!visible_now.contains(&w.0), w.0.abs_diff(cur), w.3.is_some()));
// Tiles of far-away pages are useless: free them. Tiles of an earlier zoom stay for a page
@@ -3822,6 +3932,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if let Some((p, i)) = app.views[index].comments.default_request.take() {
app.make_comment_default(p, i);
}
+ if let Some((p, i, open)) = app.views[index].comments.attachment_request.take() {
+ let doc = app.views[index].id;
+ app.comment_attachment_action(doc, p, i, open);
+ }
if let Some((name, w)) = field_props {
app.open_field_props(&name, w);
}
@@ -3893,7 +4007,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if copy_selected {
app.execute("edit.copy");
}
- quick_bar(app, avail, ui);
+ // Read mode hides the tools (#2646).
+ if app.mode != crate::Mode::Read {
+ quick_bar(app, avail, ui);
+ }
if let Some((source, file, destination)) = remote_open {
app.open_remote_link(source, file, destination);
}
@@ -3920,7 +4037,13 @@ fn selection_actions(
if ui.input(|input| input.pointer.is_decidedly_dragging()) {
return false;
}
- let Some(selection) = view.selection else { return false };
+ let Some(selection) = view.selection else {
+ view.actions_dismissed = None;
+ return false;
+ };
+ if view.actions_dismissed == Some(selection) {
+ return false;
+ }
let Some(text) = view.texts.get(&selection.page) else { return false };
let Some(xf) = view.page_xform(selection.page) else { return false };
let Some(last) = selection.rects(text).last().copied() else { return false };
@@ -3957,6 +4080,8 @@ fn selection_actions(
});
let Some(action) = chosen else { return false };
if let SelectionAction::Copy = action {
+ // Copying closes the bar but keeps the words selected, for ⌘C or a second action.
+ view.actions_dismissed = Some(selection);
return true;
}
let Some((page, quads)) = view.selection_quads(info) else { return false };
@@ -4737,6 +4862,15 @@ pub(crate) fn drop_gap(cells: &[(usize, Rect)], p: Pos2) -> Option {
Some(if p.x < r.center().x { *i } else { i + 1 })
}
+/// Ctrl (Option on macOS) held when a page drag drops copies the dragged pages into the gap
+/// instead of moving them, as Acrobat's Organize Pages does.
+///
+/// The OS comes from the context, not `cfg!`: the wasm build runs on macOS too, where
+/// `cfg!(target_os = "macos")` is false, so Option has to copy there as well (#3092 review).
+fn copy_modifier(ctx: &egui::Context, m: &egui::Modifiers) -> bool {
+ if crate::commands::mac_shortcuts(ctx) { m.alt } else { m.ctrl }
+}
+
/// A "+" on a gap of the page grid: insert files there. Returns whether it was clicked.
fn gap_button(ui: &mut egui::Ui, gap: usize, at: Pos2, height: f32, label: String, t: &Tokens) -> bool {
let resp = ui.interact(Rect::from_center_size(at, Vec2::splat(22.0)), ui.id().with(("org-gap", gap)), Sense::click());
@@ -4968,6 +5102,8 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool
}
// While dragging: the gap the pages would go to, drawn as a bar.
if let (Some(pages), Some(p)) = (view.org_drag.clone(), ui.input(|i| i.pointer.hover_pos())) {
+ // Ctrl (Option on macOS) held over the drop copies the pages; see #2997.
+ let copying = copy_modifier(ui.ctx(), &ui.input(|i| i.modifiers));
if let Some(gap) = drop_gap(&cells, p) {
let x = match cells.iter().find(|(i, _)| *i == gap) {
Some((_, r)) => r.left() + 3.0,
@@ -4975,8 +5111,18 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool
};
let row = cells.iter().find(|(i, _)| *i == gap).or(cells.last()).map(|(_, r)| r.y_range()).unwrap_or(egui::Rangef::new(0.0, 0.0));
ui.painter().line_segment([pos2(x, row.min + 10.0), pos2(x, row.max - 10.0)], Stroke::new(3.0, t.accent));
+ let mut text_at = p + vec2(14.0, 14.0);
+ if copying {
+ // A "+" by the pointer: this drop copies the pages, it doesn't move them.
+ let badge = p + vec2(12.0, 12.0);
+ ui.painter().circle(badge, 9.0, t.accent, Stroke::new(1.0, t.accent));
+ let ink = Stroke::new(1.5, Color32::WHITE);
+ ui.painter().line_segment([badge - vec2(4.0, 0.0), badge + vec2(4.0, 0.0)], ink);
+ ui.painter().line_segment([badge - vec2(0.0, 4.0), badge + vec2(0.0, 4.0)], ink);
+ text_at.x += 18.0;
+ }
ui.painter().text(
- p + vec2(14.0, 14.0),
+ text_at,
Align2::LEFT_TOP,
if pages.len() == 1 { tl!("1 page").to_string() } else { crate::i18n::fmt(tl!("{n} pages"), &[("n", &pages.len().to_string())]) },
theme::medium(12.0),
@@ -4986,13 +5132,19 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool
if drop {
view.org_drag = None;
if let Some(gap) = drop_gap(&cells, p) {
- // `to` counts positions without the moving pages.
- let to = gap - pages.iter().filter(|x| **x < gap).count();
- let first = pages[0];
- let contiguous = pages.windows(2).all(|w| w[1] == w[0] + 1);
- if !(contiguous && to == first) {
- view.pending_edit = Some(Edit::MovePages { pages: pages.clone(), to });
- view.selected = (to..to + pages.len()).collect();
+ if copying {
+ // The copies land at the gap as it is: nothing leaves its place.
+ view.pending_edit = Some(Edit::DuplicatePages { pages: pages.clone(), at: Some(gap) });
+ view.selected = (gap..gap + pages.len()).collect();
+ } else {
+ // `to` counts positions without the moving pages.
+ let to = gap - pages.iter().filter(|x| **x < gap).count();
+ let first = pages[0];
+ let contiguous = pages.windows(2).all(|w| w[1] == w[0] + 1);
+ if !(contiguous && to == first) {
+ view.pending_edit = Some(Edit::MovePages { pages: pages.clone(), to });
+ view.selected = (to..to + pages.len()).collect();
+ }
}
}
}
@@ -5100,6 +5252,16 @@ mod tests {
use super::*;
+ #[test]
+ fn typed_zoom_percentages_parse() {
+ for (typed, zoom) in [("150", 1.5), ("150%", 1.5), (" 62.5 % ", 0.625), ("6400%", 64.0)] {
+ assert_eq!(DocView::parse_zoom_percent(typed), Some(zoom), "{typed}");
+ }
+ for typed in ["", "%", "abc", "0", "-50", "NaN", "inf", "1e40", "150%%x", "%150"] {
+ assert_eq!(DocView::parse_zoom_percent(typed), None, "{typed}");
+ }
+ }
+
/// A rotated (landscape) page is drawn as wide as the cell allows and centred in the picture
/// area, as Acrobat draws it, not as a small strip pinned to the top; a portrait page still
/// fills the area's height; neither covers the checkbox corner or the label below.
@@ -5519,6 +5681,81 @@ trailer << /Root 1 0 R >>
}
}
+ /// #3149: the replacement dialog commits after the canvas collected this frame's demand.
+ #[test]
+ fn replacing_a_page_after_layout_never_uploads_an_oversized_texture() {
+ let ctx = egui::Context::default();
+ ctx.input_mut(|i| i.max_texture_side = 2048);
+ let mut app = PdfCraftApp::new();
+ let original = app.session.create_blank(200.0, 300.0, 1).unwrap();
+ let replacement = app.session.create_blank(400.0, 500.0, 1).unwrap();
+ app.open_bytes("original.pdf", None, original.as_ref().clone()).unwrap();
+ let request = RenderRequest { scale: 4.7, tag: scale_tag(4.7), ..Default::default() };
+ app.views[0].frame_queue = vec![request];
+ app.views[0].frame_visible.insert(0);
+ assert!(app.apply_edit(Edit::ReplacePages { pages: vec![0], name: "replacement.pdf".into(), bytes: replacement, src_pages: vec![0] }));
+ let doc = app.session.get(app.views[0].id).unwrap();
+ assert_eq!((doc.info.pages[0].width, doc.info.pages[0].height), (400.0, 500.0));
+ let edited_bytes = doc.bytes.clone();
+ let pool = RenderPool::new_inline(doc.display.clone(), Default::default());
+ app.views[0].finish_render_frame(&ctx, &doc.info, &pool);
+ assert!(app.views[0].last_queue.is_empty(), "old geometry must not reach the replacement renderer");
+
+ // The next layout can render a bounded backdrop and sharp tiles at the same zoom.
+ let backdrop = RenderRequest { scale: 2048.0 / 500.0, tag: scale_tag(2048.0 / 500.0), ..request };
+ let tile = RenderRequest { tile: Some(Tile { x: 0, y: 0, w: 1024, h: 1024 }), ..request };
+ for _ in 0..2 {
+ let view = &mut app.views[0];
+ view.begin_render_frame();
+ view.frame_visible.insert(0);
+ view.frame_queue = vec![backdrop, tile];
+ view.finish_render_frame(&ctx, &doc.info, &pool);
+ }
+ let view = &app.views[0];
+ assert_eq!(view.pages.get(&0).unwrap().tex.size(), [1639, 2048]);
+ assert_eq!(view.tiles.get(&(0, tile.tag, 0, 0)).unwrap().size(), [1024, 1024]);
+ assert!(view.errors.is_empty());
+ assert_eq!(doc.bytes, edited_bytes, "rendering preserves the unsaved edit");
+ assert!(doc.dirty);
+ assert_eq!(doc.can_undo(), Some("Replace page"));
+ }
+
+ #[test]
+ fn oversized_raster_results_do_not_replace_cached_textures() {
+ let ctx = egui::Context::default();
+ ctx.input_mut(|i| i.max_texture_side = 64);
+ for request in [
+ RenderRequest::default(),
+ RenderRequest { tag: THUMB_TAG, ..Default::default() },
+ RenderRequest { tag: GRID_TAG, ..Default::default() },
+ RenderRequest { tag: PRINT_TAG, ..Default::default() },
+ RenderRequest { tile: Some(Tile { x: 0, y: 0, w: 64, h: 32 }), ..Default::default() },
+ ] {
+ let mut view = DocView::new(DocId(1), &thumbnail_info(1), ViewDefaults::default());
+ view.last_queue = vec![request];
+ let texture = |v: &DocView| {
+ v.pages
+ .values()
+ .map(|p| &p.tex)
+ .chain(v.thumbs.values().map(|p| &p.tex))
+ .chain(v.grid_pages.values().map(|(_, t)| t))
+ .chain(v.print_pages.values().map(|(_, t)| t))
+ .chain(v.tiles.values())
+ .map(|t| (t.id(), t.size()))
+ .collect::>()
+ };
+ assert_eq!(view.receive_result(&ctx, solid_result(request, 64, 32)), 64 * 32 * 4);
+ let cached = texture(&view);
+ assert_eq!(cached.len(), 1);
+ for (w, h) in [(65, 32), (32, 65), (65, 65), (0, 32), (32, 0)] {
+ assert_eq!(view.receive_result(&ctx, solid_result(request, w, h)), 0, "{request:?}: {w}x{h}");
+ assert_eq!(texture(&view), cached, "an invalid result must keep the previous raster");
+ }
+ assert_eq!(view.receive_result(&ctx, solid_result(request, 32, 64)), 32 * 64 * 4);
+ assert_eq!(texture(&view)[0].1, [32, 64], "valid results still make progress");
+ }
+ }
+
#[test]
fn thumbnails_release_on_scroll_panel_close_and_request_again_after_eviction() {
let ctx = egui::Context::default();
@@ -5580,6 +5817,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);
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
index 84e62622fb..fb9e0a7c0d 100644
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -694,10 +694,36 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width };
view.goto = Some((view.current, 0.0));
}
- let zoom = format!("{:.0}%", view.zoom * 100.0);
- ui.allocate_ui_with_layout(vec2(40.0, 16.0), Layout::top_down(Align::Center), |ui| {
- ui.label(egui::RichText::new(zoom).font(theme::medium(11.0)).color(t.text_muted));
- });
+ let id = egui::Id::new("zoom-input");
+ let draft = id.with("draft");
+ let mut typed = ui.data(|d| d.get_temp::(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0));
+ let edit = egui::TextEdit::singleline(&mut typed)
+ .id(id)
+ .desired_width(40.0)
+ .horizontal_align(Align::Center)
+ .font(theme::medium(11.0))
+ .text_color(t.text_muted)
+ .frame(egui::Frame::NONE)
+ .margin(vec2(2.0, 2.0));
+ let r = ui.add(edit).on_hover_text(tl!("Zoom — type a percentage and press Enter"));
+ ui.ctx().accesskit_node_builder(id, |node| node.set_label(tl!("Zoom")));
+ if r.gained_focus() {
+ let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
+ let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(typed.chars().count()));
+ state.cursor.set_char_range(Some(all));
+ state.store(ui.ctx(), id);
+ }
+ if r.has_focus() {
+ ui.data_mut(|d| d.insert_temp(draft, typed.clone()));
+ } else {
+ ui.data_mut(|d| d.remove::(draft));
+ if r.lost_focus()
+ && ui.input(|i| i.key_pressed(egui::Key::Enter))
+ && let Some(zoom) = DocView::parse_zoom_percent(&typed)
+ {
+ view.set_zoom(zoom);
+ }
+ }
ui.add_space(6.0);
if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() {
view.step_page(true);
diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs
index 1da66e73b5..dd4cda9a55 100644
--- a/crates/ui-egui/src/commands.rs
+++ b/crates/ui-egui/src/commands.rs
@@ -628,7 +628,13 @@ impl PdfCraftApp {
"create.clipboard" => self.create_from_clipboard(),
"optimize.reduce" => self.reduce_file_size(),
"page.duplicate" => {
- self.apply_edit(Edit::DuplicatePages { pages: targets });
+ self.apply_edit(Edit::DuplicatePages { pages: targets, at: None });
+ }
+ // Two or more selected pages reverse among themselves; otherwise the whole document.
+ "page.reverse" => {
+ let all = active.and_then(|i| self.views.get(i)).map_or(0, |v| v.page_count());
+ let pages = if targets.len() >= 2 { targets } else { (0..all).collect() };
+ self.apply_edit(Edit::ReversePages { pages });
}
"ocr.enhance_camera" => {
// The corners start on the document's edges when they can be found, else
diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs
index 5a708c14f6..68b1a72a0c 100644
--- a/crates/ui-egui/src/comments.rs
+++ b/crates/ui-egui/src/comments.rs
@@ -420,6 +420,10 @@ pub(crate) struct Settling {
pub points: Vec<[f64; 2]>,
/// Frame time (seconds) at release; a raster that never arrives must not leave a ghost.
pub since: f64,
+ /// Its annotation is in the document, so a raster of the page from now on draws it. Until
+ /// then a raster is of the document without it (in full screen the edit is applied the frame
+ /// after the release, and a raster already on its way can arrive in between).
+ pub committed: bool,
}
/// Longest a released drawing stays painted without its page raster, and how many are kept.
@@ -428,7 +432,7 @@ const SETTLE_MAX: usize = 16;
impl CommentView {
/// Remember a released drag drawing so it stays on screen until its page is re-rendered.
- fn settle(&mut self, now: f64, page: usize, tool: CommentTool, points: &[[f64; 2]]) {
+ pub(crate) fn settle(&mut self, now: f64, page: usize, tool: CommentTool, points: &[[f64; 2]]) {
if !matches!(tool, CommentTool::Ink | CommentTool::Line | CommentTool::Arrow | CommentTool::Rectangle | CommentTool::Oval) {
return;
}
@@ -436,7 +440,7 @@ impl CommentView {
if self.settling.len() >= SETTLE_MAX {
self.settling.remove(0);
}
- self.settling.push(Settling { page, tool, points: points.to_vec(), since: now });
+ self.settling.push(Settling { page, tool, points: points.to_vec(), since: now, committed: false });
}
/// How many released drawings are still painted from their points (waiting for a raster).
@@ -444,9 +448,22 @@ impl CommentView {
self.settling.len()
}
- /// A fresh raster of `page` is on screen: it draws the committed annotation itself.
+ /// A fresh raster of `page` is on screen: it draws the committed annotations itself. One
+ /// still waiting for its edit isn't in that raster, and stays drawn from its points.
pub(crate) fn page_rendered(&mut self, page: usize) {
- self.settling.retain(|s| s.page != page);
+ self.settling.retain(|s| s.page != page || !s.committed);
+ }
+
+ /// The edit adding the released drawings was applied: rasters from now on draw them.
+ pub(crate) fn drawings_committed(&mut self) {
+ for s in &mut self.settling {
+ s.committed = true;
+ }
+ }
+
+ /// The edit adding the released drawings failed: nothing will draw them.
+ pub(crate) fn drawings_refused(&mut self) {
+ self.settling.retain(|s| s.committed);
}
}
@@ -487,6 +504,9 @@ pub struct CommentView {
pub props_request: Option<(usize, usize)>,
/// Make Current Properties Default was asked for: (page, index).
pub default_request: Option<(usize, usize)>,
+ /// A file attachment comment's file was asked for: (page, index, open it in a new tab
+ /// rather than save it).
+ pub attachment_request: Option<(usize, usize, bool)>,
/// Replace Text: the struck-out text's quads while the replacement is typed.
pub replace: Option>,
/// Attach file: where the icon goes (page, top-left in user space), for the app to pick a file.
@@ -1578,6 +1598,23 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool
}
}
+/// Save attachment… and, for a PDF, Open in a new tab, first in a file attachment comment's
+/// menus as in the Attachments panel: the comment is where the reader finds its file. Reading
+/// the file changes nothing, so a read-only document offers them too.
+pub(crate) fn attachment_items(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, a: &Annotation) {
+ let source = pdfcraft_render::AttachmentSource::Annotation { page: a.page, index: a.index };
+ let Some(file) = info.attachments.iter().find(|f| f.source == source) else { return };
+ if ui.button(tl!("Save attachment…")).clicked() {
+ view.comments.attachment_request = Some((a.page, a.index, false));
+ ui.close();
+ }
+ if file.name.to_lowercase().ends_with(".pdf") && ui.button(tl!("Open in a new tab")).clicked() {
+ view.comments.attachment_request = Some((a.page, a.index, true));
+ ui.close();
+ }
+ ui.separator();
+}
+
/// Items of the canvas context menu for the selected comment (or the page).
pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs, allowed: bool) -> Option {
let mut action = None;
@@ -1585,6 +1622,7 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo
match selected {
Some(a) => {
let (page, index) = (a.page, a.index);
+ attachment_items(ui, view, info, a);
if ui.add_enabled(allowed, egui::Button::new(tl!("Edit text…"))).clicked() {
view.comments.composer = Some(Composer {
page,
diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs
index c5fbbd0ce3..2ac1853f4e 100644
--- a/crates/ui-egui/src/comments_panel.rs
+++ b/crates/ui-egui/src/comments_panel.rs
@@ -255,7 +255,7 @@ pub(crate) fn show(
continue;
}
let thread = replies_of(a);
- if let Some(e) = card(ui, t, a, &thread, view, prefs, allowed, nav) {
+ if let Some(e) = card(ui, t, a, &thread, info, view, prefs, allowed, nav) {
edit = Some(e);
}
}
@@ -268,6 +268,7 @@ fn card(
t: &Tokens,
a: &Annotation,
thread: &[&Annotation],
+ info: &DocInfo,
view: &mut DocView,
prefs: &CommentPrefs,
allowed: bool,
@@ -447,6 +448,7 @@ fn card(
.layout(Layout::top_down_justified(Align::Min))
.show(|ui| {
ui.set_min_width(160.0);
+ crate::comments::attachment_items(ui, view, info, a);
if ui.button(tl_ctx!("comment menu", "Edit")).clicked() {
view.comments.editing = Some((a.page, a.index, a.contents.clone().unwrap_or_default()));
ui.close();
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
index 742641330c..a4ab35d969 100644
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -410,12 +410,23 @@ impl Control {
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200) as usize;
let modifiers = modifiers(p.get("modifiers"))?;
let which = pointer_button(p, "ui.drag")?;
+ // The events carry the modifiers and so does the input state: gestures that read
+ // `ui.input(|i| i.modifiers)` (a Ctrl-drag copy, an Alt-drag column select) see them.
+ let held = |mut events: Vec| {
+ if modifiers != egui::Modifiers::NONE {
+ events.insert(0, egui::Event::ModifiersChanged(modifiers));
+ }
+ events
+ };
let button = |pos, pressed| egui::Event::PointerButton { pos, button: which, pressed, modifiers };
- let mut frames = vec![vec![egui::Event::PointerMoved(from)], vec![button(from, true)]];
+ let mut frames = vec![held(vec![egui::Event::PointerMoved(from)]), held(vec![button(from, true)])];
for k in 1..=steps {
- frames.push(vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32))]);
+ frames.push(held(vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32))]));
+ }
+ frames.push(held(vec![button(to, false)]));
+ if modifiers != egui::Modifiers::NONE {
+ frames.push(vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]);
}
- frames.push(vec![button(to, false)]);
let n = self.inject(frames);
Ok(Handled::AfterFrames(n, json!({ "dragged": [[from.x, from.y], [to.x, to.y]] })))
}
@@ -793,14 +804,27 @@ struct Limits {
max_unauthenticated: usize,
/// How long a write may block on a client that never reads its replies.
write_timeout: std::time::Duration,
+ /// How long a connection refused after a reply is kept half-open, discarding input, so the
+ /// reply is not lost to a reset (see [`close_after_reply`]).
+ linger: std::time::Duration,
+ /// How many refused connections may linger at once. A lingering connection has given back
+ /// its place among [`Self::max_unauthenticated`], so this is what bounds their threads;
+ /// beyond it a refused connection is closed at once.
+ max_lingering: usize,
}
#[cfg(not(target_arch = "wasm32"))]
-const LIMITS: Limits =
- Limits { auth_deadline: std::time::Duration::from_secs(10), max_unauthenticated: 8, write_timeout: std::time::Duration::from_secs(30) };
+const LIMITS: Limits = Limits {
+ auth_deadline: std::time::Duration::from_secs(10),
+ max_unauthenticated: 8,
+ write_timeout: std::time::Duration::from_secs(30),
+ linger: std::time::Duration::from_secs(1),
+ max_lingering: 8,
+};
-/// One of the [`Limits::max_unauthenticated`] places, given back when dropped: when the
-/// connection authenticates, and on every way its thread can end.
+/// One of a limited number of places, given back when dropped: a place among
+/// [`Limits::max_unauthenticated`] (given back when the connection authenticates or is refused,
+/// and on every way its thread can end), or among [`Limits::max_lingering`].
#[cfg(not(target_arch = "wasm32"))]
struct Slot(std::sync::Arc);
@@ -850,6 +874,38 @@ impl std::io::Read for Reading {
}
}
+/// Close a connection whose last reply has just been written without losing that reply.
+///
+/// Closing a socket with input still unread makes the kernel send a reset instead of a FIN, and
+/// the reset can discard the reply before the client reads it (Linux does): a client still
+/// sending when it is refused would never learn why. So send FIN first, then read and discard
+/// whatever arrives until the client closes its side or `linger` passes, and only then close.
+///
+/// Only [`Limits::max_lingering`] connections linger at once: `place` is one of those places,
+/// taken by [`refuse`] before the reply was written, and held for the drain only. Without one
+/// the connection is closed at once after the FIN, accepting the risk of a reset rather than
+/// holding another thread.
+#[cfg(not(target_arch = "wasm32"))]
+fn close_after_reply(reading: &mut Reading, place: Option, linger: std::time::Duration) {
+ use std::io::Read;
+ let _ = reading.stream.shutdown(std::net::Shutdown::Write);
+ let Some(_place) = place else { return };
+ reading.until = Some(std::time::Instant::now() + linger);
+ let mut sink = [0u8; 1024];
+ // Each read is bounded by the time left, and none is made once `linger` has passed.
+ while matches!(reading.read(&mut sink), Ok(n) if n > 0) {}
+}
+
+/// A connection is being refused before `auth`: give back its place among those waiting to
+/// authenticate, so it does not keep a real client out while it lingers, and take a place to
+/// linger in if one is free. Done before the reply is written, so a client that has read the
+/// reply sees the places as they will be.
+#[cfg(not(target_arch = "wasm32"))]
+fn refuse(slot: &mut Option, lingering: &std::sync::Arc, limits: Limits) -> Option {
+ drop(slot.take());
+ Slot::take(lingering, limits.max_lingering)
+}
+
/// Listen on `127.0.0.1` (random port) and forward authenticated requests to `client`.
///
/// Protocol: newline-delimited JSON-RPC 2.0. The first request on a connection must be
@@ -868,6 +924,7 @@ fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result std::io::Result std::io::Result {
let secs = limits.auth_deadline.as_secs_f64();
let e = rpc_error(Value::Null, -32004, &format!("not authenticated within {secs} s"));
- let _ = writeln!(write, "{e}").and_then(|_| write.flush());
+ let place = refuse(&mut slot, &lingering, limits);
+ if writeln!(write, "{e}").and_then(|_| write.flush()).is_ok() {
+ close_after_reply(reader.get_mut(), place, limits.linger);
+ }
break;
}
// End of stream or a failed read ends the connection, as before.
Ok(LineRead::End) | Err(_) => break,
Ok(LineRead::TooLong) => {
let e = rpc_error(Value::Null, -32600, &format!("request line longer than {max} bytes before auth"));
- let _ = writeln!(write, "{e}").and_then(|_| write.flush());
+ let place = refuse(&mut slot, &lingering, limits);
+ if writeln!(write, "{e}").and_then(|_| write.flush()).is_ok() {
+ close_after_reply(reader.get_mut(), place, limits.linger);
+ }
break;
}
}
@@ -945,7 +1009,13 @@ fn serve_with(client: ControlClient, limits: Limits) -> std::io::Result = (0..limits.max_unauthenticated).map(|_| connect(ep.port)).collect();
+ for s in &others {
+ // A refused one has already been closed, so its write may fail; its reply says why.
+ let _ = writeln!(s.try_clone().unwrap(), "{auth}");
+ let reply = next_line(&mut BufReader::new(s.try_clone().unwrap())).expect("a reply");
+ assert_eq!(reply["result"]["ok"], true, "admitted while the refused one lingers: {reply}");
+ }
+ assert!(started.elapsed() < limits.linger, "checked while it lingered: {:?}", started.elapsed());
+ drop(refused);
+ }
+
+ /// Only [`super::Limits::max_lingering`] refused connections linger at once: one more still
+ /// gets its reply, and is then closed at once instead of lingering.
+ #[cfg(not(target_arch = "wasm32"))]
+ #[test]
+ fn only_so_many_refused_connections_linger() {
+ use std::io::{BufReader, Write};
+ // A linger far longer than the checks below take, even on a loaded machine.
+ let limits = super::Limits { linger: std::time::Duration::from_secs(5), max_lingering: 1, ..TEST_LIMITS };
+ let (_control, client) = super::attach(&egui::Context::default());
+ let ep = super::serve_with(client, limits).unwrap();
+ let bad = r#"{"jsonrpc": "2.0", "id": 1, "method": "page_count"}"#;
+ // The first refused connection takes the only lingering place and keeps it.
+ let first = connect(ep.port);
+ writeln!(first.try_clone().unwrap(), "{bad}").unwrap();
+ let mut r1 = BufReader::new(first.try_clone().unwrap());
+ assert_eq!(next_line(&mut r1).expect("refused with a reason")["error"]["code"], -32001);
+ // The second still gets its reason (it has nothing unread, so no reset)...
+ let second = connect(ep.port);
+ let mut w2 = second.try_clone().unwrap();
+ writeln!(w2, "{bad}").unwrap();
+ let mut r2 = BufReader::new(second.try_clone().unwrap());
+ assert_eq!(next_line(&mut r2).expect("refused with a reason")["error"]["code"], -32001);
+ // ...and is closed outright, not lingering: writing to it soon fails, where a lingering
+ // connection would read and discard for the whole of `linger`.
+ let started = std::time::Instant::now();
+ let closed = loop {
+ if w2.write_all(b"x").is_err() {
+ break true;
+ }
+ if started.elapsed() > limits.linger {
+ break false;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(20));
+ };
+ assert!(closed && started.elapsed() < limits.linger / 2, "it lingered: {:?}", started.elapsed());
+ drop(first);
+ }
+
/// #1806: only so many connections may wait to authenticate at once. One more is refused
/// on the spot; once a waiting one authenticates or times out, its place is free again.
#[cfg(not(target_arch = "wasm32"))]
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 0de1c6a7ea..981db214b2 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -354,17 +354,20 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
let ends = doc.revision_ends();
if ends.len() > 1 {
ui.label(egui::RichText::new(tl!("Revisions")).color(t.text_muted));
- ui.horizontal_wrapped(|ui| {
- ui.label(ends.len().to_string());
- for n in (1..ends.len()).rev().take(12) {
- if ui
- .small_button(crate::i18n::fmt(tl!("View revision {n}"), &[("n", &n.to_string())]))
- .on_hover_text(tl!("Open the file as it was saved then"))
- .clicked()
- {
- open_revision = Some(n);
+ // Reserve the full wrapped height before the grid starts the next row.
+ ui.vertical(|ui| {
+ ui.horizontal_wrapped(|ui| {
+ ui.label(ends.len().to_string());
+ for n in (1..ends.len()).rev().take(12) {
+ if ui
+ .small_button(crate::i18n::fmt(tl!("View revision {n}"), &[("n", &n.to_string())]))
+ .on_hover_text(tl!("Open the file as it was saved then"))
+ .clicked()
+ {
+ open_revision = Some(n);
+ }
}
- }
+ });
});
ui.end_row();
}
diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs
index d8881ba47d..66ad98ffe6 100644
--- a/crates/ui-egui/src/edit_text_ui.rs
+++ b/crates/ui-egui/src/edit_text_ui.rs
@@ -1329,7 +1329,12 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
ed.max_width = ed.rect.width();
let scale = (ed.rect.width() / (ed.source_rect[2] - ed.source_rect[0]).abs().max(0.01)).max(0.01);
ed.size = (ed.look.size as f32 * scale).clamp(1.0, 300.0);
- let font = editor_font(&ed.look, ed.size);
+ // egui draws the caret at least one row of this font tall. Each row already has its source
+ // row's height, so the font stays below every row (half the region's smallest size): on a
+ // 12 pt row under a 24 pt title the caret is as tall as the row, not the title.
+ let smallest =
+ ed.source.as_ref().and_then(|(block, lines)| block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.size as f32).reduce(f32::min));
+ let font = editor_font(&ed.look, smallest.map_or(ed.size, |s| (s * scale * 0.5).clamp(1.0, 300.0).min(ed.size)));
let text_color = color32(ed.look.color);
let mut done = None;
egui::Area::new(egui::Id::new("edit-text-line")).order(egui::Order::Foreground).fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())).show(
@@ -1475,6 +1480,21 @@ mod tests {
(app, ed)
}
+ #[test]
+ fn a_loaded_font_is_applied_to_the_paragraph_being_edited() {
+ // Loading a font used to only register it on the page: the paragraph kept its source
+ // face until the font was found again in the document-font menu (#2591).
+ let (mut app, ed) = preview_app();
+ app.views[0].line_editor = Some(ed);
+ let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec();
+ app.use_files(crate::files::FilePurpose::TextFont { page: 0 }, vec![("Inter-Regular.ttf".into(), inter)]);
+ let doc = app.session.get(app.views[0].id).unwrap();
+ let lines = doc.text_lines(0);
+ assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::(), "Preview text");
+ assert!(lines.iter().all(|l| l.base_font == "Inter-Regular"), "{:?}", lines.iter().map(|l| &l.base_font).collect::>());
+ assert!(app.views[0].line_editor.is_some(), "the paragraph stays open for editing");
+ }
+
#[test]
fn text_background_is_cached_without_editing_and_released_on_cancel() {
let (app, ed) = preview_app();
diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs
index be1347d68d..e554d7a1b0 100644
--- a/crates/ui-egui/src/editing.rs
+++ b/crates/ui-egui/src/editing.rs
@@ -24,6 +24,7 @@ pub enum CloseRequest {
pub struct PendingOverwrite {
pub id: pdfcraft_engine::DocId,
pub dest: String,
+ target: SaveTarget,
/// What was to run once the document is written (close its tab, quit).
after: Box,
}
@@ -52,7 +53,16 @@ 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(_)) {
+ // A picture picked with the Select tool may be the one this edit removed or moved:
+ // drop its highlight now, not on the next frame (which only comes with the next input).
+ view.picked_image = None;
+ if let Some(ctx) = &self.ctx {
+ ctx.request_repaint();
+ }
+ 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();
}
@@ -123,6 +133,11 @@ impl PdfCraftApp {
let to = to.min(info.pages.len() - n);
view.select_pages(&(to..to + n).collect::>());
}
+ Edit::DuplicatePages { pages, at: Some(at) } => {
+ let n = pages.iter().collect::>().len();
+ let at = at.min(info.pages.len().saturating_sub(n));
+ view.select_pages(&(at..at + n).collect::>());
+ }
Edit::InsertBlankPage { at, .. } => view.select_pages(&[at.min(info.pages.len() - 1)]),
Edit::DeletePages { .. } => view.select_pages(&[]),
// A stroke drawn with the pen stays unselected, so the selection box and
@@ -163,6 +178,10 @@ impl PdfCraftApp {
true
}
Err(e) => {
+ if let Some(view) = self.views.get_mut(i) {
+ // A drawing whose annotation wasn't added must not stay painted.
+ view.comments.drawings_refused();
+ }
let message = if matches!(edit, Edit::MoveObjects { .. }) { crate::object_ui::move_error(&e) } else { e.to_string() };
self.notify_fmt("{label} failed: {e}", &[("label", &crate::i18n::edit_label(&label)), ("e", &message)]);
false
@@ -614,12 +633,12 @@ impl PdfCraftApp {
let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(name);
// The bytes are taken once the user has chosen, so edits made meanwhile are saved too.
self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, dest| {
- app.save_doc_to_then(id, &dest.to_string_lossy(), Box::new(after));
+ app.save_doc_to_then(id, &dest.to_string_lossy(), target, Box::new(after));
});
return false;
}
};
- self.save_doc_to_then(id, &destination, Box::new(after))
+ self.save_doc_to_then(id, &destination, target, Box::new(after))
}
#[cfg(target_arch = "wasm32")]
{
@@ -674,14 +693,14 @@ impl PdfCraftApp {
/// `pending_overwrite` behind [`Dialog::OverwriteChanged`](crate::Dialog::OverwriteChanged).
/// Returns `true` if written now.
#[cfg(not(target_arch = "wasm32"))]
- fn save_doc_to_then(&mut self, id: pdfcraft_engine::DocId, dest: &str, after: Box) -> bool {
+ fn save_doc_to_then(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget, after: Box) -> bool {
let own = self.session.get(id).and_then(|d| d.path.as_deref()).is_some_and(|p| crate::same_file(p, dest));
if own && self.watch.disk_differs(id, dest) {
- self.pending_overwrite = Some(PendingOverwrite { id, dest: dest.to_string(), after });
+ self.pending_overwrite = Some(PendingOverwrite { id, dest: dest.to_string(), target, after });
self.dialog = Some(crate::Dialog::OverwriteChanged);
return false;
}
- let saved = self.save_doc_to(id, dest);
+ let saved = self.save_doc_to(id, dest, target);
if saved {
after(self);
}
@@ -692,7 +711,7 @@ impl PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn overwrite_changed_file(&mut self) {
let Some(p) = self.pending_overwrite.take() else { return };
- if self.save_doc_to(p.id, &p.dest) {
+ if self.save_doc_to(p.id, &p.dest, p.target) {
(p.after)(self);
}
}
@@ -708,17 +727,27 @@ impl PdfCraftApp {
/// Write document `id` to `dest` and make it the document's file. Returns `true` if written.
#[cfg(not(target_arch = "wasm32"))]
- fn save_doc_to(&mut self, id: pdfcraft_engine::DocId, dest: &str) -> bool {
+ fn save_doc_to(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget) -> bool {
// Save As answers on a later frame: commit what was typed meanwhile, too (#166), even
// if another tab is active by then.
if !self.commit_typing_in(id) || !self.flatten_fill_sign_for_save(id) {
return false;
}
- let Some(name) = self.session.get(id).map(|d| d.name.clone()) else {
+ let Some((name, full)) = self.session.get(id).map(|d| {
+ // Save As to another file is a clean copy, without the earlier revisions that still
+ // hold deleted pages or replaced text (#2165). A signed document stays incremental so
+ // its signatures keep covering the file.
+ let full = target == SaveTarget::As
+ && !d.path.as_deref().is_some_and(|p| crate::same_file(p, dest))
+ && d.read_only_reason.is_none()
+ && !d.is_signed();
+ (d.name.clone(), full)
+ }) else {
self.notify_tr("The document was closed before it could be saved.");
return false;
};
- let bytes = match self.session.save_bytes(id).and_then(|b| self.session.check_save(id, &b).map(|()| b)) {
+ let bytes = if full { self.session.save_full_bytes(id) } else { self.session.save_bytes(id) };
+ let bytes = match bytes.and_then(|b| self.session.check_save(id, &b).map(|()| b)) {
Ok(b) => b,
Err(e) => {
self.notify_fmt("Couldn't save {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
@@ -972,13 +1001,15 @@ fn is_form_edit(edit: &Edit) -> bool {
/// classic Outlook (`Temporary Internet Files\Content.Outlook\` on older Windows), and the
/// `Microsoft.OutlookForWindows` package folder for the new Outlook. Saving there either fails,
/// because Outlook can still hold the file, or appears to work while the edits stay out of the
-/// email and are deleted with the cache. Matched on either separator and in any case, so it can
-/// be tested on every platform.
+/// email and are deleted with the cache. Apple Mail opens one from `Mail Downloads/