mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 09:21:17 +00:00
Merge remote-tracking branch 'origin/main' into pr-2753
# Conflicts: # .github/workflows/nix.yml
This commit is contained in:
196 files changed
+14211
-1352
No files matched your search
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -0,0 +1,32 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directories:
|
||||
- "/"
|
||||
- "/crates/filters/fuzz"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
+22
-22
@@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "cefa955aa92790d6773cb1d1e15e19019b3165a00528d57d6617362e1d1988ab"
|
||||
sha256 = "6eee0b34db6529f26235bfc07fa1fdb9030b8e7b0b10ede271ca9242cb491919"
|
||||
|
||||
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
|
||||
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
|
||||
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "dbfebf4e9b2e9ac31ecf72202c2098173e72ebfaf90e9fee8f0563a6734fe31f"
|
||||
sha256 = "9870c0fb9f7198155be16adee7f45d36cb9a254af90b545a3ce5b04cbbbf24d4"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
|
||||
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "0b0dafa3806a6b3508684f64e1f07b435113f8efd0e59201d12250c63c1d9547"
|
||||
sha256 = "fc31e40877ce115cfb7033c57dd6acabcae3725b249364387e3fe23e04d94e5e"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/cs.tsv"
|
||||
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "898a5240fa59cac42ef6b99cb60bcfca0094ac27370e1c8d020bd27936b56517"
|
||||
sha256 = "5de19834f293efaea68cb6b7f51a71d320391f53345f56cfebcd73dab828b665"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pt-br.tsv"
|
||||
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "b4745c94797575529cd0e78c18c71fa2c0dfddfbf19b9c40b41dc2057c6f6543"
|
||||
sha256 = "cef0d22341d0722f029c6543fc846fce164331b7ad8f693a6754b7567b9d9e95"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/es.tsv"
|
||||
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "2acb3f6522d6d1f744343f474fe4c51ebec610a888a7fa83fc9422228e6fa3c1"
|
||||
sha256 = "c0ca7761cbabd8096bc9af54cdf6b88b3ae0cd8210003db8b3356e2706e44fc8"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fr.tsv"
|
||||
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "1b739478a4f186a49900cce84edbe136206a53b46340322df4caf77bb3fb5b12"
|
||||
sha256 = "a8afd51c3296f44b0446d6b06492b1348444b3e3c448a326ad213d18ce7af714"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/de.tsv"
|
||||
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "da3b71608ff7fe6e230b076207cee96ac01f66e068ca1be83af1ad56ac9c60eb"
|
||||
sha256 = "7676a2dfaa7d66e2e9ef608d2d9de7f863557dcecbef6d94bafa981097405e6d"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/te.tsv"
|
||||
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "989d2e5f57cf22c21a85951835ca9fbd05e314da67eb4c17332df1f03679fa00"
|
||||
sha256 = "9a22996c0308ea5b20ec5e328504cd83cbb560147763faaf0e6987f974844c29"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/it.tsv"
|
||||
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "5e84916e3ad8670216360695689fe8fd624365a29886f66a3c655ba126892f5b"
|
||||
sha256 = "eacc972d65a2d951e94e1a8cf944638e8328f73f6c05759de6059a425f159cd7"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pl.tsv"
|
||||
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "988bcf2e0322a46c0464ff7b91ab5d90193b0ea7f454812d12595eb843de6fc7"
|
||||
sha256 = "dfa4aa540cc7d61fa22a27a949e6eec0f78dcc5b883d0e4e004d695e460b95aa"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ru.tsv"
|
||||
@@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "084f4ea06f31c632ad90b0e87f66240ce5b5dc012ac7770987d44918ee66f452"
|
||||
sha256 = "27e02e7bb3d378bceca76988a9dceaa68d974f57cbee962954deca6184cf62aa"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/hu.tsv"
|
||||
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "973cd90ec34c8a973d146a0e2c73c1d632e17357c3d87ccf10c59e64d12c1587"
|
||||
sha256 = "9675edd36c5ae54d9893fd10c4325aab664941bf714c08fbfeef9134fa42056c"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/bg.tsv"
|
||||
@@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "485f85d4053c9661ddda2ed9394a0644c77b489feb598d02f12ef209e11f2ee3"
|
||||
sha256 = "ce18fc96f8516aa917e4a8bcfd290edec13a9800b3d1ee102e4b2a71b8467d2b"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/kk.tsv"
|
||||
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "24524864022ab2abeff68b5979519eee4e92134d0c4053d40c741bc7f01c249c"
|
||||
sha256 = "286a1e2e051748eb4bcee3906918dc78d0704f8a37bde22dc50d3376d5e75017"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ja.tsv"
|
||||
@@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "bc6d123bdb34d5d7c22717fed1a86ff20e7e493acc1e155e61e61e2dc74459aa"
|
||||
sha256 = "77593b7497c67f4a79cc31fa43fdd6eae11601dd87c079a8e666b25cdd9117ba"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/uk.tsv"
|
||||
@@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "82678dbe730f56062037ea78de165b2c016b3fa670fb1cbfc7cd713eca900bac"
|
||||
sha256 = "d7d93a03f22ab380696f69b20b12104bf30e78c4440b683df63594af716894e2"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ar.tsv"
|
||||
@@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "304d661b27cdb5246f6b2974b4b5e78667f0928cda3588440be3d8f35f254679"
|
||||
sha256 = "889a0d0a9ea53ca0a818c2ba672c053c232d2cca140f8d63127353e6f36505fe"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/tr.tsv"
|
||||
@@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "05e2b7d5401166d362ba055120fba9a1645d6ec324997cf670e19fafcb0c54cf"
|
||||
sha256 = "bae8b21651ae2a69c71c91b69ba79727254dbfb078c1be312a13383332934927"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/lt.tsv"
|
||||
@@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "17c8579e0e9d1524ffbeb87e7b30c14562dbaac64400826ee786cecfe31648fa"
|
||||
sha256 = "7fa70f68a9fa24d0b1cac05fe3aaabaeaca9750c57416124bd317a4eda806d5a"
|
||||
|
||||
|
||||
[[asset]]
|
||||
@@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "2d1c4b4276d02512a4e20101be95a1671de5c4f5cdfd661887af5ebab0db06a6"
|
||||
sha256 = "95b5fc6d10f7bc8371688772741870448de716fa2dd9af62fdfe4e009d269b45"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/sl.tsv"
|
||||
@@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "8f8a259264cda06eb6d9a9bf91dc7d873a8561213a9a9e7acbd32623bef40be0"
|
||||
sha256 = "3ac2b113045b7e399dd6d9ddb7773e5b0874c8b43c2ba4cca5854598a2a7ba92"
|
||||
|
||||
[[asset]]
|
||||
|
||||
|
||||
Generated
+46
-36
@@ -1142,6 +1142,11 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
@@ -3933,7 +3938,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"craft-quit",
|
||||
"eframe",
|
||||
@@ -3958,7 +3963,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-a11y"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -3970,7 +3975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-annot"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -3981,7 +3986,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-audit"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -3992,7 +3997,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-automation"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"flate2",
|
||||
@@ -4013,7 +4018,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cli"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"pdfcraft-audit",
|
||||
@@ -4021,6 +4026,7 @@ dependencies = [
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-engine",
|
||||
"pdfcraft-platform",
|
||||
"pdfcraft-preflight",
|
||||
"pdfcraft-render",
|
||||
"rustix 1.1.5",
|
||||
"serde_json",
|
||||
@@ -4029,18 +4035,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-compare"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-content"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"pdfcraft-cos",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cos"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-crypt",
|
||||
@@ -4054,7 +4060,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-create"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"hayro-syntax",
|
||||
@@ -4070,7 +4076,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-crypt"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"md-5",
|
||||
@@ -4082,7 +4088,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-edit"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4097,9 +4103,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-engine"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
"icu_properties",
|
||||
"idna",
|
||||
"image",
|
||||
@@ -4129,20 +4136,21 @@ dependencies = [
|
||||
"pdfcraft-xfdf",
|
||||
"png 0.18.1",
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
"unicode-segmentation",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-export"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-filters"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4152,7 +4160,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-fonts"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"pdfcraft-cos",
|
||||
@@ -4162,7 +4170,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-forms"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4174,18 +4182,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-geom"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-js"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-measure"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4198,14 +4206,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-model"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"pdfcraft-cos",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ocr"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"ocrs",
|
||||
"pdfcraft-fonts",
|
||||
@@ -4215,7 +4223,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-optimize"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"pdfcraft-annot",
|
||||
@@ -4228,11 +4236,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-oracle"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-organize"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4243,7 +4251,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-platform"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
@@ -4253,16 +4261,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-model",
|
||||
"roxmltree",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-print"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"craft-print",
|
||||
"pdfcraft-content",
|
||||
@@ -4273,7 +4283,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-redact"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4289,7 +4299,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-render"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"flate2",
|
||||
@@ -4308,7 +4318,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-sign"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aws-lc-rs",
|
||||
@@ -4342,11 +4352,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-testkit"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ui-egui"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"base64 0.22.1",
|
||||
@@ -4390,7 +4400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-web"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4402,7 +4412,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfa"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"image",
|
||||
@@ -4416,7 +4426,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfdf"
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-cos",
|
||||
|
||||
+36
-34
@@ -5,7 +5,7 @@ exclude = ["vendor"]
|
||||
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.0"
|
||||
version = "0.10.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -18,40 +18,42 @@ craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0",
|
||||
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" }
|
||||
# Shared system spooler; document composition remains in pdfcraft-print.
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" }
|
||||
# Whether a path stored in a document may be opened (GoToR link targets).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
# internal
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.8.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.8.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.8.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.8.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.8.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.8.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.8.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.8.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.8.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.8.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.8.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.8.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.8.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.8.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.8.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.8.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.8.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.8.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.8.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.8.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.8.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.8.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.8.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.8.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.8.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.8.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.8.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.8.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.8.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.8.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.8.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.8.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.8.0", default-features = false }
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.10.1" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.10.1" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.10.1" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.10.1" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.10.1" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.10.1" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.10.1" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.10.1" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.10.1" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.10.1" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.10.1" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.10.1" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.10.1" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.10.1" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.10.1" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.10.1" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.10.1" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.10.1" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.10.1" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.10.1" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.10.1" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.10.1" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.10.1" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.10.1" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.10.1" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.10.1" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.10.1" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.10.1" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.10.1" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.10.1" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.10.1" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.10.1" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.10.1", default-features = false }
|
||||
|
||||
# pdf
|
||||
hayro = "0.7.1"
|
||||
|
||||
@@ -11,16 +11,19 @@
|
||||
<h1 align="center">PdfCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>The PDF workbench; an open-source, clean-room reimplementation of Adobe Acrobat, rebuilt in pure Rust.</b><br>
|
||||
<b>The PDF workbench; an open-source, modern PDF reader and editor, built in pure Rust.</b><br>
|
||||
Read, organize, combine, split and secure PDFs in a fast, native app, written in Rust from the ground up.<br>
|
||||
macOS · Windows · Linux · FreeBSD · the web
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-12a58a">
|
||||
<img alt="Written in Rust" src="https://img.shields.io/badge/written%20in-Rust-0a7563">
|
||||
<img alt="Platforms: macOS, Windows, Linux, FreeBSD, web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20web-12a58a">
|
||||
<img alt="No account, no telemetry" src="https://img.shields.io/badge/no%20account-no%20telemetry-0a7563">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-12a58a?style=flat-square">
|
||||
<img alt="Written in Rust" src="https://img.shields.io/badge/written%20in-Rust-0a7563?style=flat-square">
|
||||
<img alt="Platforms: macOS, Windows, Linux, FreeBSD, web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20web-12a58a?style=flat-square">
|
||||
<img alt="No account, no telemetry" src="https://img.shields.io/badge/no%20account-no%20telemetry-0a7563?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_pdfcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/pdfcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -417,16 +420,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 +584,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.
|
||||
|
||||
<sub>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.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+71
@@ -2,7 +2,20 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged)
|
||||
> **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)
|
||||
@@ -27,6 +40,7 @@
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged)
|
||||
|
||||
PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what
|
||||
comes next. Adobe's cloud services (Document Cloud, Acrobat Sign requests, shared review, the AI
|
||||
@@ -157,9 +171,47 @@ 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-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
|
||||
|
||||
- **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged.
|
||||
|
||||
- **2026-10-18 (M10, #3120):** Export to Word, PowerPoint, HTML and RTF reads text from form XObjects nested as deep as the renderer draws them (50 levels), and up to 50 000 forms a page (the renderer's budget for nested ones); it stopped at 12 levels and 4 096 forms and left the rest out silently. A page past those limits is listed in `doc_export_office`'s `truncated_pages`, with a warning. Estimates unchanged.
|
||||
|
||||
- **2026-10-18 (M5, PR #1971 review):** Nested FDF annotation values that identify a source `/Page` or `/Pages` dictionary become null at every copy depth. A synthetic three-page PDF saved and reopened before export verifies that destination actions cannot pull source page content into the FDF, while unrelated nested metadata and shared references remain intact. FDF import semantics, copied-graph limits and estimates are unchanged.
|
||||
- **2026-10-15 (M5, PR #1971 refresh):** Nested FDF annotation values retain shared or cyclic copied graphs. Discovery borrows direct values and records annotation positions with lazy array owners; retained entries are budgeted before cloning. Synthetic stress fixtures include repeated aliases and saved-PDF cache eviction. Limits cover the copied graph; source parsing and page discovery retain their existing limits, with no total source-memory bound. FDF import semantics and estimates are unchanged.
|
||||
- **2026-10-13 (M5, nested FDF values):** Comment export copies and renumbers nested indirect values, retaining geometry, colours, borders and shared graphs. Graph/data limits return an error before the destination is written; synthetic saved-PDF and automation exchange regressions. Estimates unchanged.
|
||||
|
||||
- **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged.
|
||||
|
||||
- **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved.
|
||||
- **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-19 (M3, #3360):** Switching between Read and the docking workspace retains the current document’s scroll position at fixed zoom, including within-page position; separate tabs retain their own offsets. 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-19 (M7, tracked text):** Editable paragraphs and object labels distinguish repeated glyph tracking from word gaps in TJ arrays and separately positioned glyphs. Synthetic regressions cover character spacing, horizontal scaling, rotated baselines, larger word gaps, and rows of one-glyph words or cells (inferred tracking is capped at half an em, so `a b c` and digit or Y/N columns keep their spaces). 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 `<occur>` 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 a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged.
|
||||
- **2026-10-19 (M12, XFA, #2574):** The Acrobat observations behind the line above were first made on a public real-world form, which the Privacy rule does not allow; they are replaced by ones made on a synthetic form (three `breakBefore pageArea` sections, then a page area with `occur min="1" max="1"`, with and without `relevant="-print"`). Acrobat Pro shows the closing page last in both and prints it in both, blank only with `relevant="-print"`: confirmed. The synthetic form also shows a difference not fixed yet: Acrobat opens it as 5 pages, with an empty first body page before the first section, where PdfCraft lays out 4. Two more synthetic forms narrow it down: with `breakBefore targetType="pageArea"` on the first section, with or without `startNew`, Acrobat opens an empty body page first; without a break on the first section it lays out 4, as PdfCraft does (so `startNew` is not the cause). Not fixed yet: `layout.rs` skips a break while the page is still empty. 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 `<occur>` 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.
|
||||
@@ -168,6 +220,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 (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.
|
||||
@@ -493,9 +548,22 @@ 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-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
|
||||
| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
|
||||
| 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. |
|
||||
@@ -516,7 +584,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.
|
||||
@@ -18,6 +18,7 @@ serde_json = { workspace = true }
|
||||
pdfcraft-engine = { workspace = true }
|
||||
pdfcraft-audit = { workspace = true }
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-preflight = { workspace = true }
|
||||
pdfcraft-automation = { workspace = true }
|
||||
pdfcraft-platform = { workspace = true }
|
||||
base64 = "0.22"
|
||||
|
||||
@@ -57,6 +57,7 @@ macro_rules! mcp_usage {
|
||||
const USAGE: &str = concat!(
|
||||
"\
|
||||
pdfcraft-cli info <file.pdf> [--password PW] document summary as JSON
|
||||
(includes any declared PDF/A or PDF/UA level)
|
||||
pdfcraft-cli render <file.pdf> --page N [--dpi 96] --out x.png (.png, .jpg, .tif or .pam)
|
||||
pdfcraft-cli text <file.pdf> [--page N] extracted text (pages separated by form feeds)
|
||||
pdfcraft-cli edit <in.pdf> --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1]
|
||||
@@ -298,29 +299,45 @@ 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();
|
||||
// What the document declares as a standard, read the way the Standards panel reads it, so
|
||||
// `info` and the app agree. A file that declares nothing leaves these null and empty.
|
||||
let declared =
|
||||
pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default();
|
||||
let json = serde_json::json!({
|
||||
"file": path,
|
||||
"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,
|
||||
"pdfa": declared.pdfa.as_ref().map(|(part, conformance)| format!("PDF/A-{part}{}", conformance.to_lowercase())),
|
||||
"pdfua": declared.pdfua,
|
||||
"output_intents": declared.output_intents,
|
||||
"xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }),
|
||||
"xfa_layout": xfa_layout,
|
||||
"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::<Vec<_>>(),
|
||||
"warnings": info.warnings,
|
||||
});
|
||||
@@ -348,7 +365,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 +388,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<usize> = match selected_page {
|
||||
Some(page) => vec![page],
|
||||
None => (0..r.page_count()).collect(),
|
||||
@@ -588,16 +608,22 @@ 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());
|
||||
}
|
||||
let bytes = match format {
|
||||
Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f)?,
|
||||
// With `reject_oversize` the page is drawn at `--dpi` or not at all, so the file can say so.
|
||||
Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f, f64::from(dpi))?,
|
||||
None => {
|
||||
// PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it.
|
||||
let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes();
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! `info` reports the standard a document declares, as the Standards panel does (#3319). A file
|
||||
//! carrying `pdfaid:part` and `pdfaid:conformance` in its XMP metadata was summarised with no
|
||||
//! mention of PDF/A at all, though the app reads the same declaration and shows it.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
|
||||
|
||||
/// A one-page PDF, with `metadata` as the catalog's XMP stream when given.
|
||||
fn fixture(metadata: Option<&str>) -> Vec<u8> {
|
||||
let content = b"0 0 0 rg 10 10 40 20 re f";
|
||||
let catalog = match metadata {
|
||||
Some(_) => "<< /Type /Catalog /Pages 2 0 R /Metadata 5 0 R >>".to_string(),
|
||||
None => "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
};
|
||||
let mut objs: Vec<Vec<u8>> = vec![
|
||||
catalog.into_bytes(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 60] /Contents 4 0 R >>".to_vec(),
|
||||
format!("<< /Length {} >> stream\n", content.len()).into_bytes(),
|
||||
];
|
||||
objs[3].extend_from_slice(content);
|
||||
objs[3].extend_from_slice(b"\nendstream");
|
||||
if let Some(xmp) = metadata {
|
||||
let mut stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >> stream\n", xmp.len()).into_bytes();
|
||||
stream.extend_from_slice(xmp.as_bytes());
|
||||
stream.extend_from_slice(b"\nendstream");
|
||||
objs.push(stream);
|
||||
}
|
||||
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 off in &offsets {
|
||||
out.extend_from_slice(format!("{off: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
|
||||
}
|
||||
|
||||
fn xmp(part: &str, conformance: &str) -> String {
|
||||
format!(
|
||||
"<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n\
|
||||
<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n <rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n\
|
||||
<rdf:Description rdf:about=\"\" xmlns:pdfaid=\"http://www.aiim.org/pdfa/ns/id/\">\n\
|
||||
<pdfaid:part>{part}</pdfaid:part>\n <pdfaid:conformance>{conformance}</pdfaid:conformance>\n\
|
||||
</rdf:Description>\n </rdf:RDF>\n</x:xmpmeta>\n<?xpacket end=\"w\"?>"
|
||||
)
|
||||
}
|
||||
|
||||
fn info_of(bytes: &[u8], name: &str) -> serde_json::Value {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-info-standards-{}-{name}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("f.pdf");
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let out = Command::new(BIN).args(["info", path.to_str().unwrap()]).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let json = serde_json::from_slice(&out.stdout).expect("info prints JSON");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
json
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_names_the_declared_pdfa_level() {
|
||||
let declared = info_of(&fixture(Some(&xmp("2", "B"))), "pdfa2b");
|
||||
assert_eq!(declared["pdfa"], serde_json::json!("PDF/A-2b"), "{declared}");
|
||||
assert_eq!(declared["pdfua"], serde_json::Value::Null);
|
||||
assert_eq!(declared["output_intents"], serde_json::json!([]));
|
||||
// Lower-case conformance in the file still reads as the level's own spelling.
|
||||
assert_eq!(info_of(&fixture(Some(&xmp("3", "a"))), "pdfa3a")["pdfa"], serde_json::json!("PDF/A-3a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_declares_nothing_for_a_plain_pdf() {
|
||||
let plain = info_of(&fixture(None), "plain");
|
||||
assert_eq!(plain["pdfa"], serde_json::Value::Null, "{plain}");
|
||||
assert_eq!(plain["pdfua"], serde_json::Value::Null);
|
||||
// The rest of the summary is unaffected.
|
||||
assert_eq!(plain["pages"], serde_json::json!(1));
|
||||
assert_eq!(plain["pdf_version"], serde_json::json!("1.7"));
|
||||
}
|
||||
@@ -56,6 +56,17 @@ fn jpeg_and_tiff_names_get_those_formats() {
|
||||
assert!(bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*"), "not a TIFF: {:?}", &bytes[..4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_records_the_dpi_it_was_rendered_at() {
|
||||
// The engine's tests check each format; this checks that `--dpi` reaches the file.
|
||||
let pdf = fixture("dpi");
|
||||
let jpg = tmp("dpi", "p1.jpg");
|
||||
assert!(render(&pdf, &jpg).status.success());
|
||||
let bytes = std::fs::read(&jpg).unwrap();
|
||||
assert_eq!(&bytes[6..11], b"JFIF\0");
|
||||
assert_eq!(bytes[13..18], [1, 0, 36, 0, 36], "JFIF density in dots per inch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pam_name_still_gets_the_netpbm_stream() {
|
||||
let pdf = fixture("pam");
|
||||
|
||||
@@ -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#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
|
||||
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form" layout="tb">
|
||||
<pageSet><pageArea name="page"><occur max="-1"/>
|
||||
<contentArea x="20pt" y="20pt" w="160pt" h="260pt"/>
|
||||
<medium short="200pt" long="300pt"/>
|
||||
</pageArea></pageSet>
|
||||
<draw w="160pt" h="20pt"><value><text>First XFA page</text></value></draw>
|
||||
<field name="name" w="160pt" h="20pt"><ui><textEdit/></ui><value><text>Ada</text></value></field>
|
||||
<subform name="last" layout="tb"><breakBefore targetType="pageArea"/>
|
||||
<draw w="160pt" h="20pt"><value><text>Second XFA page</text></value></draw>
|
||||
</subform>
|
||||
</subform></template></xdp:xdp>"#;
|
||||
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);
|
||||
}
|
||||
@@ -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<Vec<u8>, 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<Self> {
|
||||
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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -54,8 +54,9 @@ winit = { version = "0.30", default-features = false, features = ["wayland"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# Which GPU drives which display (EnumDisplayDevices), so the window is drawn on the GPU that
|
||||
# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it).
|
||||
winsafe = { version = "0.0.29", default-features = false, features = ["user"] }
|
||||
# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it),
|
||||
# and `advapi` for the session id that names the single-instance socket (#3338).
|
||||
winsafe = { version = "0.0.29", default-features = false, features = ["user", "advapi"] }
|
||||
# Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects (src/motion.rs), through WinRT's
|
||||
# `UISettings`, whose bindings are safe to call (winsafe's `SystemParametersInfo` is an unsafe
|
||||
# fn). Already in the tree through wgpu's DX12 backend; this adds one small namespace to it.
|
||||
|
||||
@@ -231,7 +231,10 @@ fn main() -> eframe::Result {
|
||||
// running and exits (#282, #317, #1726). Any option means the caller wants this launch's own
|
||||
// window.
|
||||
let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images;
|
||||
let instance = match single_instance::claim(&single_instance::absolute(&files), may_hand_off) {
|
||||
// Absolute for this instance too: links relative to a document resolve against its folder,
|
||||
// never the working directory.
|
||||
let files = single_instance::absolute(&files);
|
||||
let instance = match single_instance::claim(&files, may_hand_off) {
|
||||
single_instance::Claim::HandedOff => return Ok(()),
|
||||
single_instance::Claim::Primary(server) => Some(server),
|
||||
single_instance::Claim::Alone => None,
|
||||
@@ -377,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();
|
||||
@@ -562,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| {
|
||||
@@ -603,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
|
||||
}
|
||||
@@ -909,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;
|
||||
|
||||
@@ -153,7 +153,7 @@ pub fn claim(files: &[String], may_hand_off: bool) -> Claim {
|
||||
/// `%LOCALAPPDATA%` is unset.
|
||||
#[cfg(windows)]
|
||||
fn socket_path() -> Option<std::path::PathBuf> {
|
||||
let name = socket_name(std::env::var("SESSIONNAME").ok().as_deref());
|
||||
let name = socket_name(session_id(), std::env::var("SESSIONNAME").ok().as_deref());
|
||||
// %LOCALAPPDATA% is private to the user's sign-in, so other accounts can't receive this
|
||||
// user's file paths or send it files to open. A portable copy uses it too (#889), under its
|
||||
// own name so it never hands files to an installed copy (which keeps its settings elsewhere).
|
||||
@@ -184,13 +184,31 @@ fn display_socket_name(display: Option<&str>) -> String {
|
||||
if display.is_empty() { "instance.sock".to_string() } else { format!("instance-{display}.sock") }
|
||||
}
|
||||
|
||||
/// The socket's file name for a Windows session (`SESSIONNAME`: `Console`, `RDP-Tcp#3`…). The
|
||||
/// same user signed in twice (at the console and over Remote Desktop) gets one socket per session,
|
||||
/// so a file never opens in a window on the other screen. Only letters, digits and `-` are kept.
|
||||
/// The Windows session this process runs in, from its access token: the console, each Remote
|
||||
/// Desktop sign-in and session 0 (services) each have their own. `None` if Windows won't say.
|
||||
#[cfg(windows)]
|
||||
fn session_id() -> Option<u32> {
|
||||
use winsafe::{HACCESSTOKEN, TokenInfo, co::TOKEN_INFORMATION_CLASS};
|
||||
match HACCESSTOKEN::GetCurrentProcessToken().GetTokenInformation(TOKEN_INFORMATION_CLASS::SessionId) {
|
||||
Ok(TokenInfo::SessionId(id)) => Some(*id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The socket's file name for a Windows session. The same user signed in twice (at the console
|
||||
/// and over Remote Desktop) gets one socket per session, so a file never opens in a window on the
|
||||
/// other screen. The session is the token's session id (`session`): a launch from Task Scheduler,
|
||||
/// a service or a remote shell has no `SESSIONNAME` but still runs in a session, so it finds the
|
||||
/// window of the session it runs in (#3338), and never one in another session (review on #3340).
|
||||
/// Only if the id can't be read does `SESSIONNAME` (`Console`, `RDP-Tcp#3`…) name it, letters,
|
||||
/// digits and `-` kept.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
fn socket_name(session: Option<&str>) -> String {
|
||||
let session: String = session.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect();
|
||||
if session.is_empty() { "instance.sock".to_string() } else { format!("instance-{session}.sock") }
|
||||
fn socket_name(session: Option<u32>, session_name: Option<&str>) -> String {
|
||||
if let Some(id) = session {
|
||||
return format!("instance-s{id}.sock");
|
||||
}
|
||||
let name: String = session_name.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect();
|
||||
if name.is_empty() { "instance.sock".to_string() } else { format!("instance-{name}.sock") }
|
||||
}
|
||||
|
||||
/// Held while a launch finds out whether it is the first, so launches that start together (several
|
||||
@@ -359,12 +377,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn each_windows_session_gets_its_own_socket_name() {
|
||||
assert_eq!(socket_name(Some("Console")), "instance-Console.sock");
|
||||
assert_eq!(socket_name(Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock");
|
||||
assert_ne!(socket_name(Some("RDP-Tcp#3")), socket_name(Some("RDP-Tcp#4")));
|
||||
assert_eq!(socket_name(Some(r"..\x/y:z")), "instance-xyz.sock", "never a path");
|
||||
assert_eq!(socket_name(None), "instance.sock");
|
||||
assert_eq!(socket_name(Some("")), "instance.sock");
|
||||
// The session id decides: #3338, a launch from Task Scheduler (no SESSIONNAME) in the
|
||||
// console's session finds the window opened from Explorer there...
|
||||
assert_eq!(socket_name(Some(1), None), socket_name(Some(1), Some("Console")));
|
||||
// ...and (review on #3340) one in a Remote Desktop session never reaches the console's.
|
||||
assert_ne!(socket_name(Some(2), None), socket_name(Some(1), Some("Console")));
|
||||
assert_ne!(socket_name(Some(2), Some("RDP-Tcp#3")), socket_name(Some(3), Some("RDP-Tcp#3")));
|
||||
assert_eq!(socket_name(Some(4_000_000_000), None), "instance-s4000000000.sock");
|
||||
// Only without an id does SESSIONNAME name it.
|
||||
assert_eq!(socket_name(None, Some("Console")), "instance-Console.sock");
|
||||
assert_eq!(socket_name(None, Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock");
|
||||
assert_ne!(socket_name(None, Some("RDP-Tcp#3")), socket_name(None, Some("RDP-Tcp#4")));
|
||||
assert_eq!(socket_name(None, Some(r"..\x/y:z")), "instance-xyz.sock", "never a path");
|
||||
assert_eq!(socket_name(None, None), "instance.sock");
|
||||
assert_eq!(socket_name(None, Some("")), "instance.sock");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -394,11 +420,21 @@ mod tests {
|
||||
assert_eq!(display_socket_name(Some("")), "instance.sock");
|
||||
}
|
||||
|
||||
/// The socket is named by the session the process runs in, which Windows always gives.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn this_process_has_a_session_id() {
|
||||
let id = super::session_id();
|
||||
assert!(id.is_some(), "the access token names the session");
|
||||
let name = socket_name(id, None);
|
||||
assert!(socket_path().is_none_or(|p| p.file_name().is_some_and(|f| f.to_string_lossy().ends_with(&name))), "{name}");
|
||||
}
|
||||
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
fn socket(test: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-instance-{}-{test}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir.join(socket_name(None))
|
||||
dir.join(socket_name(None, None))
|
||||
}
|
||||
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
|
||||
@@ -145,3 +145,11 @@ sync_github_name = false
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.MortenHusted]
|
||||
sync_github_name = true
|
||||
|
||||
[people.EricKaden]
|
||||
real_name = "Eric Kaden"
|
||||
display_name = "Eric Kaden"
|
||||
sync_github_name = false
|
||||
@@ -174,7 +174,7 @@ fn interior_color(d: &Dict, fallback: Option<Rgb>) -> Option<Rgb> {
|
||||
color(d, b"IC").flatten().or(fallback)
|
||||
}
|
||||
|
||||
fn border_width(d: &Dict) -> f64 {
|
||||
pub(crate) fn border_width(d: &Dict) -> f64 {
|
||||
if let Some(w) = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()) {
|
||||
return w.max(0.0);
|
||||
}
|
||||
|
||||
+27
-15
@@ -1987,13 +1987,12 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m
|
||||
result
|
||||
}
|
||||
|
||||
/// Whether a comment of this subtype has an interior that can be filled (`/IC`: rectangles,
|
||||
/// ovals, polygons and clouds, §12.5.6.8–9).
|
||||
/// Whether a comment has a fill: `/IC` for shapes, `/C` for FreeText (§12.5.6).
|
||||
pub fn fillable(subtype: &str) -> bool {
|
||||
matches!(subtype, "Square" | "Circle" | "Polygon")
|
||||
matches!(subtype, "Square" | "Circle" | "Polygon" | "FreeText")
|
||||
}
|
||||
|
||||
/// Fill a rectangle, oval or polygon comment with `fill`, or remove its fill (`None`), and
|
||||
/// Fill a shape or FreeText comment with `fill`, or remove its fill (`None`), and
|
||||
/// redraw its appearance (Acrobat: Properties ▸ Fill Color).
|
||||
pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
@@ -2001,7 +2000,7 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>
|
||||
let d = annot_dict(doc, r);
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
|
||||
if !fillable(&subtype) {
|
||||
return Err(AnnotError::Invalid("only rectangles, ovals and polygons can be filled".into()));
|
||||
return Err(AnnotError::Invalid("only rectangles, ovals, polygons and text boxes can be filled".into()));
|
||||
}
|
||||
// Check before changing anything: a stale appearance would contradict the new fill.
|
||||
if appearance::build(&d).is_none() {
|
||||
@@ -2013,10 +2012,11 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
let key: &[u8] = if subtype == "FreeText" { b"C" } else { b"IC" };
|
||||
match fill {
|
||||
Some(c) => d.set(b"IC".to_vec(), rgb(c)),
|
||||
Some(c) => d.set(key.to_vec(), rgb(c)),
|
||||
None => {
|
||||
d.remove(b"IC");
|
||||
d.remove(key);
|
||||
}
|
||||
}
|
||||
touch(d, meta);
|
||||
@@ -2205,7 +2205,7 @@ pub struct Props {
|
||||
pub subject: String,
|
||||
pub color: Option<Rgb>,
|
||||
pub opacity: f64,
|
||||
/// Border / line width, for shapes, lines and drawings.
|
||||
/// Border / line width, for shapes, lines, drawings and text boxes.
|
||||
pub width: Option<f64>,
|
||||
/// Notes only.
|
||||
pub icon: Option<NoteIcon>,
|
||||
@@ -2217,9 +2217,9 @@ pub struct Props {
|
||||
pub locked: bool,
|
||||
/// `/LE`: two names for a line or polyline (`None` when unset), one for a callout.
|
||||
pub endings: Option<Vec<LineEnding>>,
|
||||
/// Rectangles, ovals and polygons: their interior can be filled (see [`fillable`]).
|
||||
/// Whether the comment has a fill control (see [`fillable`]).
|
||||
pub fillable: bool,
|
||||
/// `/IC`, the interior colour (`None`: no fill).
|
||||
/// The interior colour (`None`: no fill).
|
||||
pub fill: Option<Rgb>,
|
||||
/// Text boxes, typewriter text and callouts: the `/DA` font size in points.
|
||||
pub font_size: Option<f64>,
|
||||
@@ -2234,8 +2234,13 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned();
|
||||
let c: Vec<f64> = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default();
|
||||
let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) };
|
||||
let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine")
|
||||
.then(|| d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0));
|
||||
let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| {
|
||||
if subtype == "FreeText" {
|
||||
if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) }
|
||||
} else {
|
||||
d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0)
|
||||
}
|
||||
});
|
||||
Some(Props {
|
||||
author: text_value(doc, d, b"T").unwrap_or_default(),
|
||||
subject: text_value(doc, d, b"Subj").unwrap_or_default(),
|
||||
@@ -2254,9 +2259,10 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `/IC` as an RGB colour. An empty array means no fill; grey and CMYK fills are converted.
|
||||
/// A shape’s `/IC` or FreeText `/C` as RGB. Grey and CMYK fills are converted.
|
||||
fn fill_of(d: &Dict) -> Option<Rgb> {
|
||||
let c: Vec<f64> = d.get(b"IC")?.as_array()?.iter().filter_map(|x| x.as_f64()).collect();
|
||||
let key: &[u8] = if d.name(b"Subtype") == Some(b"FreeText") { b"C" } else { b"IC" };
|
||||
let c: Vec<f64> = d.get(key)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect();
|
||||
let rgb = match c.as_slice() {
|
||||
[g] => [*g; 3],
|
||||
[r, g, b] => [*r, *g, *b],
|
||||
@@ -2282,6 +2288,11 @@ fn line_endings_of(d: &Dict) -> Option<Vec<LineEnding>> {
|
||||
/// 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 +2303,7 @@ pub fn add_text_replacement(
|
||||
strike: &Style,
|
||||
caret: &Style,
|
||||
meta: &Meta,
|
||||
caret_meta: &Meta,
|
||||
) -> Result<usize, AnnotError> {
|
||||
let last = *quads.last().ok_or_else(|| AnnotError::Invalid("select the text to replace first".into()))?;
|
||||
let s = add_annotation(
|
||||
@@ -2317,7 +2329,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| {
|
||||
|
||||
@@ -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<u8> {
|
||||
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(_) => {}
|
||||
|
||||
@@ -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<String> = 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]
|
||||
@@ -1308,3 +1335,50 @@ fn text_box_font_size_changes_the_da_and_grows_the_box() {
|
||||
assert_eq!(props(&doc, 0, i).unwrap().font_size, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_boxes_and_callouts_can_change_fill_and_border() {
|
||||
let mut doc = fixture();
|
||||
let shapes = [
|
||||
Shape::TextBox { rect: [300.0, 600.0, 500.0, 660.0], font_size: 12.0 },
|
||||
Shape::Callout {
|
||||
rect: [300.0, 600.0, 500.0, 660.0],
|
||||
knee: [260.0, 560.0],
|
||||
point: [200.0, 520.0],
|
||||
font_size: 12.0,
|
||||
ending: LineEnding::OpenArrow,
|
||||
},
|
||||
];
|
||||
for shape in shapes {
|
||||
let index = add_annotation(&mut doc, &new(0, shape), &meta("test")).unwrap();
|
||||
assert!(props(&doc, 0, index).unwrap().fillable);
|
||||
set_fill(&mut doc, 0, index, Some([1.0, 1.0, 1.0]), &meta("test")).unwrap();
|
||||
set_style(&mut doc, 0, index, None, None, Some(3.0), None, &meta("test")).unwrap();
|
||||
let saved = reopen(&doc);
|
||||
let properties = props(&saved, 0, index).unwrap();
|
||||
assert_eq!(properties.fill, Some([1.0; 3]));
|
||||
assert_eq!(properties.width, Some(3.0));
|
||||
let ap = ap_content(&saved, &list(&saved, 0)[index]);
|
||||
assert!(ap.contains("1 1 1 rg") && ap.contains(" re f") && ap.contains("3 w"), "{ap}");
|
||||
set_fill(&mut doc, 0, index, None, &meta("test")).unwrap();
|
||||
assert_eq!(props(&doc, 0, index).unwrap().fill, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_text_properties_show_legacy_or_absent_border_width() {
|
||||
let mut doc = fixture();
|
||||
let index = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 100.0, 200.0, 140.0], font_size: 12.0 }), &meta("test")).unwrap();
|
||||
let (_, reference) = annot_ref(&mut doc, 0, index).unwrap();
|
||||
doc.update_dict(reference, |d| {
|
||||
d.remove(b"BS");
|
||||
d.remove(b"Border");
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(props(&doc, 0, index).unwrap().width, Some(0.0));
|
||||
doc.update_dict(reference, |d| {
|
||||
d.set(b"Border".to_vec(), Object::Array([0.0, 0.0, 4.0].into_iter().map(Object::Real).collect()));
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(props(&doc, 0, index).unwrap().width, Some(4.0));
|
||||
}
|
||||
@@ -16,9 +16,9 @@ It reports two kinds of finding:
|
||||
A properly applied redaction removes the text, so it never flags. Page backgrounds, table
|
||||
shading and highlights don't either: they're painted before the text, or light against it.
|
||||
|
||||
A rectangle covers a text line when it hides at least 40% of the area of the words the line
|
||||
draws, or at least 40% of the line's box together with a positive-area intersection with one
|
||||
of its words. The gap an applied redaction leaves (a `TJ` adjustment where the text was) is in the line's box but
|
||||
A rectangle covers a text line when it hides at least half of one of the line's words, at
|
||||
least 40% of the area of the words the line draws, or at least 40% of the line's box together
|
||||
with a positive-area intersection with one of its words. The gap an applied redaction leaves (a `TJ` adjustment where the text was) is in the line's box but
|
||||
holds no word, so a redaction box over it alone does not count.
|
||||
|
||||
Limits, by design and documented rather than hidden:
|
||||
@@ -29,8 +29,8 @@ Limits, by design and documented rather than hidden:
|
||||
- covers inside form XObjects are found, but only text drawn directly in the page content is
|
||||
checked under them;
|
||||
- annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign;
|
||||
- a cover that hides less than 40% of a line's box and less than 40% of its words (a short
|
||||
value in the middle of a long line) is not flagged.
|
||||
- a cover that hides less than half of every word it touches, less than 40% of a line's box and
|
||||
less than 40% of its words (a sliver of a long value) is not flagged.
|
||||
|
||||
It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper
|
||||
than 32 or in a cycle, or a walk past the operator bound is an error, never "no findings".
|
||||
|
||||
+10
-1
@@ -87,6 +87,9 @@ pub enum AuditError {
|
||||
/// Fraction of a text line's ink (the area of its words), or of its box, that must lie under a
|
||||
/// cover to count as hidden (see [`covers`]).
|
||||
const COVER_FRACTION: f64 = 0.4;
|
||||
/// Fraction of one word's area that must lie under a cover to count as hidden on its own, however
|
||||
/// long the line around it (see [`covers`]).
|
||||
const WORD_FRACTION: f64 = 0.5;
|
||||
/// WCAG contrast ratio below which the text is unreadable against the cover.
|
||||
const COVER_CONTRAST: f64 = 3.0;
|
||||
|
||||
@@ -138,7 +141,10 @@ fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool {
|
||||
rect.pos > line.paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST
|
||||
}
|
||||
|
||||
/// Does `cover` hide a substantial part of what `line` draws? Two ways, either is enough:
|
||||
/// Does `cover` hide a substantial part of what `line` draws? Three ways, any is enough:
|
||||
///
|
||||
/// - Half of one word lies under the cover. A box over one number in a long line hides a few
|
||||
/// percent of the line, and the number all the same (#2662).
|
||||
///
|
||||
/// - 40% of the area of the line's words (its `ink`) lies under the cover. Measured on the words,
|
||||
/// not the box: an applied redaction leaves a gap that draws nothing (a `TJ` adjustment where
|
||||
@@ -155,6 +161,9 @@ fn covers(cover: &[f64; 4], line: &TextLine) -> bool {
|
||||
let total: f64 = boxes.iter().map(area).sum();
|
||||
if total > 0.0 { boxes.iter().map(hidden).sum::<f64>() / total } else { 0.0 }
|
||||
};
|
||||
if line.ink.iter().any(|w| area(w) > 0.0 && hidden(w) / area(w) >= WORD_FRACTION) {
|
||||
return true;
|
||||
}
|
||||
let inked = fraction(&line.ink) >= COVER_FRACTION;
|
||||
let boxed = fraction(std::slice::from_ref(&line.rect)) >= COVER_FRACTION;
|
||||
let word_intersects = || line.ink.iter().any(|w| hidden(w) > 0.0);
|
||||
|
||||
@@ -175,6 +175,47 @@ fn unapplied_redact_mark_is_found() {
|
||||
assert!(findings[0].covered_text.contains("DO NOT SHARE"));
|
||||
}
|
||||
|
||||
/// #2662: a box over one number of a long line hides a few percent of the line, and all of the
|
||||
/// number. Found as a cover, and as an unapplied mark over the same word.
|
||||
#[test]
|
||||
fn a_cover_over_one_word_of_a_long_line_is_found() {
|
||||
// Helvetica 11: "The contract value is " ends near x 132, "98765" spans about 132-163.
|
||||
let line = b"BT /F1 11 Tf 30 100 Td (The contract value is 98765 and the rest of this long line continues here.) Tj ET
|
||||
";
|
||||
let mut content = line.to_vec();
|
||||
content.extend_from_slice(
|
||||
b"0 g
|
||||
131 96 33 14 re f
|
||||
",
|
||||
);
|
||||
let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].kind, FindingKind::CoveredText);
|
||||
assert!(findings[0].covered_text.contains("98765"), "{findings:?}");
|
||||
|
||||
let mut b = Builder::new();
|
||||
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
||||
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>");
|
||||
b.add_stream(line);
|
||||
b.add(FONT);
|
||||
b.add("<< /Type /Annot /Subtype /Redact /Rect [131 96 164 110] >>");
|
||||
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].kind, FindingKind::UnappliedMark);
|
||||
}
|
||||
|
||||
/// A box that only grazes the words beside it (less than half of each) is not a cover.
|
||||
#[test]
|
||||
fn a_box_grazing_a_word_is_not_flagged() {
|
||||
let content = b"BT /F1 11 Tf 30 100 Td (The contract value is 98765 and the rest of this long line continues here.) Tj ET
|
||||
0 g
|
||||
131 96 6 14 re f
|
||||
";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_before_text_is_not_flagged() {
|
||||
// An opaque dark rectangle painted *before* the text is a background, not a cover.
|
||||
@@ -303,15 +344,17 @@ fn partial_value_is_found(rotated: bool) {
|
||||
/// Keep the historic detections with several covers, lines and text-spacing states.
|
||||
#[test]
|
||||
fn faux_redactions_with_multiple_covers_lines_and_spacing_are_found() {
|
||||
let cases: [&[u8]; 4] = [
|
||||
b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj ET\n0 g\n72 738 65 16 re f\n140 738 40 16 re f\n",
|
||||
b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj 0 -15 Td (Account: 987654321) Tj ET\n0 g\n115 723 100 30 re f\n",
|
||||
b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n",
|
||||
b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n",
|
||||
// The second box of the first case hides most of the number (less than 40% of the line): a
|
||||
// finding of its own since covers are also measured per word (#2662).
|
||||
let cases: [(&[u8], usize); 4] = [
|
||||
(b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj ET\n0 g\n72 738 65 16 re f\n140 738 40 16 re f\n", 2),
|
||||
(b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj 0 -15 Td (Account: 987654321) Tj ET\n0 g\n115 723 100 30 re f\n", 1),
|
||||
(b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n", 1),
|
||||
(b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n", 1),
|
||||
];
|
||||
for content in cases {
|
||||
for (content, expected) in cases {
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{}: {findings:?}", String::from_utf8_lossy(content));
|
||||
assert_eq!(findings.len(), expected, "{}: {findings:?}", String::from_utf8_lossy(content));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,3 +500,32 @@ fn forms_nested_past_depth_limit_fail_closed() {
|
||||
let result = audit_redactions(&open(nested_forms(33)));
|
||||
assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_across_streams_neither_invents_text_nor_a_cover() {
|
||||
let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Visible text) Tj ET % note", b"0 g 70 690 200 40 re f"));
|
||||
assert!(audit_redactions(&doc).unwrap().is_empty(), "commented cover must not be reported");
|
||||
let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Before comment) Tj ET % note", b"BT /F1 24 Tf 72 650 Td (After comment) Tj ET"));
|
||||
let text: String = pdfcraft_edit::text_lines(&doc, 0).unwrap().into_iter().map(|line| line.text).collect();
|
||||
assert!(!text.contains("After comment"), "editor exposed phantom text: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_blocks_respect_stream_comments_and_split_operands() {
|
||||
let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Before comment) Tj ET % note", b"BT /F1 24 Tf 72 650 Td (After comment) Tj ET"));
|
||||
let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect();
|
||||
assert_eq!(text, "Before comment");
|
||||
let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Split operand)", b"Tj ET"));
|
||||
let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect();
|
||||
assert_eq!(text, "Split operand");
|
||||
// Marked content opened in one stream applies to text in the next: its /ActualText replaces
|
||||
// the glyphs, as when the streams are read joined.
|
||||
for (s1, s2) in [
|
||||
(b"BT /F1 24 Tf 72 700 Td /Span <</ActualText (Replacement text)".as_slice(), b">> BDC (Glyphs) Tj EMC ET".as_slice()),
|
||||
(b"BT /F1 24 Tf 72 700 Td /Span <</ActualText (Replacement text)>> BDC", b"(Glyphs) Tj EMC ET"),
|
||||
] {
|
||||
let doc = open(two_streams(s1, s2));
|
||||
let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect();
|
||||
assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1));
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,20 @@ semantics; they never silently act on partial text.
|
||||
|
||||
## Shared field properties
|
||||
|
||||
For an individual push button, `form_set_props` also edits its visible normal label:
|
||||
|
||||
```json
|
||||
{"doc": 1, "field": "clear_button", "caption": "Clear Form", "widget": 1}
|
||||
```
|
||||
|
||||
`widget` is a 1-based index (default 1), used only with `caption`; an empty caption clears the
|
||||
label. Neither renames the field. `form_fields` returns the first widget's `caption` and all
|
||||
`captions` as `[{"widget": 1, "caption": "Clear Form"}]`. Other widgets and icon-only layouts
|
||||
keep their settings. CLI and MCP use the same edit, including undo/redo and save/reopen.
|
||||
The desktop exposes it as **Button Properties → Options → Label**. Rollover/down labels and
|
||||
icon layout are not edited here; the existing caption font and unsupported-character warning
|
||||
policy apply.
|
||||
|
||||
`form_set_props` accepts either `field` (one name) or `fields` (1–1000 unique names):
|
||||
|
||||
```json
|
||||
@@ -75,7 +89,7 @@ The batch is atomic and creates one undo step. A missing or locked field, invali
|
||||
unknown argument or malformed appearance refuses the whole edit. Set `locked: false` to
|
||||
unlock and change fields together. Only supplied properties change: each field/widget keeps
|
||||
its other colours, fonts, values and unknown PDF dictionary keys. `"none"` removes a border
|
||||
or fill colour. Names, rectangles and rotations require the single-field form. Results keep
|
||||
or fill colour. Names, rectangles, rotations and captions require the single-field form. Results keep
|
||||
`field` for single edits and return `fields` for batches. CLI and MCP use the same tool.
|
||||
|
||||
## MCP server
|
||||
|
||||
@@ -710,9 +710,19 @@ impl Automation {
|
||||
let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html or rtf)")))?;
|
||||
let bytes = self.doc_to_copy(a)?.export_office(format);
|
||||
write_atomic(&path, &bytes)?;
|
||||
Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len(), "format": format.extension() }))
|
||||
let export = self.doc_to_copy(a)?.export_office_checked(format);
|
||||
write_atomic(&path, &export.bytes)?;
|
||||
// Text left out to keep a hostile page bounded is said, not silent (#3120).
|
||||
let truncated: Vec<usize> = export.truncated_pages.iter().map(|p| p.saturating_add(1)).collect();
|
||||
let mut out = json!({ "path": path.to_string_lossy(), "bytes": export.bytes.len(), "format": format.extension(),
|
||||
"truncated": !truncated.is_empty(), "truncated_pages": truncated });
|
||||
if !export.truncated_pages.is_empty() {
|
||||
use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS};
|
||||
out["warnings"] = json!([format!(
|
||||
"forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them"
|
||||
)]);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn pdfa_level(&self, a: &Args) -> Result<pdfcraft_engine::pdfa::Level> {
|
||||
@@ -918,11 +928,16 @@ impl Automation {
|
||||
let list: Vec<Value> = found
|
||||
.iter()
|
||||
.map(|(p, c)| {
|
||||
let kind = match c.kind {
|
||||
pdfcraft_engine::detect::Kind::Text => "text",
|
||||
pdfcraft_engine::detect::Kind::CheckBox => "checkbox",
|
||||
let (kind, comb) = match c.kind {
|
||||
pdfcraft_engine::detect::Kind::Text => ("text", None),
|
||||
pdfcraft_engine::detect::Kind::CheckBox => ("checkbox", None),
|
||||
pdfcraft_engine::detect::Kind::Comb { cells } => ("text", Some(cells)),
|
||||
};
|
||||
json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) })
|
||||
let mut v = json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) });
|
||||
if let Some(cells) = comb {
|
||||
v["comb"] = json!(cells);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -233,6 +233,19 @@ impl Automation {
|
||||
o.insert("unsupported_scripts".into(), json!(f.actions.unsupported));
|
||||
}
|
||||
match f.kind {
|
||||
FormFieldKind::PushButton => {
|
||||
o.insert("caption".into(), json!(w.and_then(|w| w.caption.as_deref()).unwrap_or_default()));
|
||||
o.insert(
|
||||
"captions".into(),
|
||||
json!(
|
||||
f.widgets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, w)| { json!({"widget": i.saturating_add(1), "caption": w.caption.as_deref().unwrap_or_default()}) })
|
||||
.collect::<Vec<_>>()
|
||||
),
|
||||
);
|
||||
}
|
||||
FormFieldKind::Radio => {
|
||||
o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::<Vec<_>>()));
|
||||
}
|
||||
@@ -327,6 +340,9 @@ impl Automation {
|
||||
}
|
||||
|
||||
pub(crate) fn form_set_props(&mut self, a: &Args) -> Result<Value> {
|
||||
if a.get("widget").is_some() && a.get("caption").is_none() {
|
||||
return Err(bad("widget requires caption"));
|
||||
}
|
||||
let single = a.opt_str("field")?;
|
||||
let bulk = a.get("fields").is_some();
|
||||
if single.is_some() == bulk {
|
||||
@@ -344,7 +360,7 @@ impl Automation {
|
||||
return Err(bad(format!("field {name:?} is listed more than once")));
|
||||
}
|
||||
}
|
||||
for key in ["name", "rect", "rotation"] {
|
||||
for key in ["name", "rect", "rotation", "caption", "widget"] {
|
||||
if a.get(key).is_some() {
|
||||
return Err(bad(format!("{key} applies to one field; use field instead of fields")));
|
||||
}
|
||||
@@ -398,6 +414,15 @@ impl Automation {
|
||||
rect,
|
||||
name: a.opt_str("name")?.map(str::to_owned),
|
||||
tooltip: a.opt_str("tooltip")?.map(str::to_owned),
|
||||
caption: a
|
||||
.opt_str("caption")?
|
||||
.map(|caption| -> Result<_> {
|
||||
let wi = a.opt_int("widget")?.unwrap_or(1);
|
||||
let wi =
|
||||
usize::try_from(wi).ok().and_then(|wi| wi.checked_sub(1)).ok_or_else(|| bad("widget must be a positive 1-based index"))?;
|
||||
Ok((wi, caption.to_owned()))
|
||||
})
|
||||
.transpose()?,
|
||||
read_only: a.opt_bool("read_only")?,
|
||||
required: a.opt_bool("required")?,
|
||||
multiline: a.opt_bool("multiline")?,
|
||||
|
||||
@@ -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)?,
|
||||
@@ -1746,16 +1754,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::<String>();
|
||||
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)?;
|
||||
@@ -1781,12 +1794,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<usize> {
|
||||
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<Option<usize>> {
|
||||
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> {
|
||||
@@ -2394,6 +2412,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::<Vec<_>>(),
|
||||
"creator": i.creator, "producer": i.producer,
|
||||
"tagged": i.tagged,
|
||||
"has_javascript": i.has_javascript,
|
||||
@@ -3101,4 +3122,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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,7 +156,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["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()
|
||||
@@ -224,6 +224,9 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "pages": pages("to move"), "to": { "type": "integer", "minimum": 1 } }),
|
||||
&["doc", "pages", "to"],
|
||||
)),
|
||||
t("page_reverse", "Reverse pages", "Reverse the order of pages among the positions they occupy: the listed pages (at least two), or the whole document when pages is omitted. Undoable.")
|
||||
.cmd("page.reverse")
|
||||
.with(schema(json!({ "doc": doc(), "pages": pages("to reverse (default: all)") }), &["doc"])),
|
||||
t("page_insert_blank", "Insert a blank page", "Insert a blank page so it becomes page `at`. Size defaults to the neighbouring page. Undoable.")
|
||||
.cmd("page.insert_blank")
|
||||
.with(schema(
|
||||
@@ -318,7 +321,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc", "from", "to"],
|
||||
)),
|
||||
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags.")
|
||||
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).")
|
||||
.ro()
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t(
|
||||
@@ -371,7 +374,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"form_set_props",
|
||||
"Field properties",
|
||||
"Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position) and rotation require a single field. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
|
||||
"Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position), rotation and caption require a single field. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -380,6 +383,8 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"fields": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 1000, "uniqueItems": true, "description": "Apply the same changes to these fields in one atomic edit. Use either field or fields." },
|
||||
"name": { "type": "string" },
|
||||
"tooltip": { "type": "string" },
|
||||
"caption": { "type": "string", "description": "Normal push-button label; an empty string clears it. Requires a single field." },
|
||||
"widget": { "type": "integer", "minimum": 1, "description": "Widget whose caption changes (1-based, default 1). Requires caption. See form_fields.captions." },
|
||||
"read_only": { "type": "boolean" },
|
||||
"required": { "type": "boolean" },
|
||||
"multiline": { "type": "boolean" },
|
||||
@@ -779,13 +784,13 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }),
|
||||
&["doc"],
|
||||
)),
|
||||
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon; \"none\" removes it), rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema(
|
||||
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema(
|
||||
comment_ref(json!({
|
||||
"contents": { "type": "string" },
|
||||
"color": color(),
|
||||
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"width": { "type": "number", "minimum": 0 },
|
||||
"fill": { "type": "string", "description": "Interior colour of a rectangle, oval or polygon: #RRGGBB, a colour name, or \"none\" for no fill." },
|
||||
"fill": { "type": "string", "description": "Interior colour of a rectangle, oval, polygon, text box or callout: #RRGGBB, a colour name, or \"none\" for no fill." },
|
||||
"font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"move": point(),
|
||||
@@ -822,9 +827,9 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
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",
|
||||
@@ -1060,7 +1065,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
|
||||
&["doc", "field", "event"],
|
||||
)),
|
||||
t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited.")
|
||||
t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit are listed in truncated_pages, with a warning; the text and images of the forms left out are missing.")
|
||||
.cmd("export.docx")
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
|
||||
t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.")
|
||||
@@ -1098,7 +1103,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("doc_compare_mark", "Mark differences as comments", "Add the differences from other (older) to doc (newer) as comments in doc: highlights over replaced (blue) and inserted (green) text, notes where text was deleted (red), authored Compare. Undoable.")
|
||||
.cmd("doc.compare")
|
||||
.with(schema(json!({ "doc": doc(), "other": { "type": "integer" } }), &["doc", "other"])),
|
||||
t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, small squares for check boxes; on scanned pages read from the pixels) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left).")
|
||||
t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, rows of comb boxes, small squares for check boxes; on scanned pages read from the pixels) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left); a row of comb boxes is one text field with comb set to its number of cells, created with the comb flag and that character limit.")
|
||||
.cmd("form.detect")
|
||||
.with(schema(json!({ "doc": doc(), "pages": pages("to look at (default: all)"), "add": { "type": "boolean" } }), &["doc"])),
|
||||
t("form_actions", "Field actions", "Field Properties ▸ Actions: the field's action for each trigger (mouse_up, mouse_down, mouse_enter, mouse_exit, on_focus, on_blur).")
|
||||
|
||||
@@ -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<String> = 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");
|
||||
@@ -5080,6 +5282,74 @@ fn exporting_to_word_html_and_rtf() {
|
||||
assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "a.xyz" })).is_err());
|
||||
}
|
||||
|
||||
/// #3120: a PDF whose pages draw "Head", then a chain of `depths[page]` nested forms whose
|
||||
/// innermost writes "TAIL".
|
||||
fn nested_forms_pdf(depths: &[usize]) -> Vec<u8> {
|
||||
let mut objs: Vec<String> =
|
||||
vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), String::new(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()];
|
||||
let mut kids = Vec::new();
|
||||
for &depth in depths {
|
||||
// The object number of D1, D2 and so on.
|
||||
let first = objs.len() + 1;
|
||||
let xobjects: String = (1..=depth).map(|i| format!("/D{i} {} 0 R ", first + i - 1)).collect();
|
||||
let resources = format!("<< /Font << /F1 3 0 R >> /XObject << {xobjects}>> >>");
|
||||
for i in 1..=depth {
|
||||
let body = if i == depth { "BT /F1 24 Tf 20 150 Td (TAIL) Tj ET".to_string() } else { format!("/D{} Do", i + 1) };
|
||||
objs.push(format!(
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 200 300] /Resources {resources} /Length {} >>\nstream\n{body}\nendstream",
|
||||
body.len()
|
||||
));
|
||||
}
|
||||
let body = "BT /F1 24 Tf 20 250 Td (Head) Tj ET /D1 Do";
|
||||
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()));
|
||||
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources {resources} >>", objs.len()));
|
||||
kids.push(format!("{} 0 R", objs.len()));
|
||||
}
|
||||
objs[1] = format!("<< /Type /Pages /Kids [{}] /Count {} /MediaBox [0 0 200 300] >>", kids.join(" "), kids.len());
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/// #3120: export reads forms as deeply nested as the renderer draws them (13 used to lose their
|
||||
/// text), and its result says which pages draw forms past that, whose text is missing.
|
||||
#[test]
|
||||
fn exporting_office_files_says_which_pages_left_form_text_out() {
|
||||
use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS};
|
||||
let dir = workdir("office-forms");
|
||||
std::fs::write(dir.join("deep.pdf"), nested_forms_pdf(&[13, MAX_FORM_DEPTH + 1, MAX_FORM_DEPTH])).unwrap();
|
||||
std::fs::write(dir.join("fine.pdf"), nested_forms_pdf(&[13])).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "deep.pdf" }))["doc"].as_u64().unwrap();
|
||||
for ext in ["html", "docx"] {
|
||||
let r = ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": format!("deep.{ext}") }));
|
||||
assert_eq!((&r["truncated"], &r["truncated_pages"]), (&json!(true), &json!([2])), "{r}");
|
||||
let warning = r["warnings"][0].as_str().unwrap();
|
||||
assert!(warning.contains(&format!("{MAX_FORM_DEPTH} deep")) && warning.contains(&MAX_FORM_VISITS.to_string()), "{warning}");
|
||||
assert!(warning.contains("truncated_pages"), "{warning}");
|
||||
}
|
||||
let html = std::fs::read_to_string(dir.join("deep.html")).unwrap();
|
||||
let tails: Vec<usize> = html.split("<hr>").map(|page| page.matches("TAIL").count()).collect();
|
||||
assert_eq!(tails, [1, 0, 1], "{html}");
|
||||
assert_eq!(html.matches("Head").count(), 3, "{html}");
|
||||
|
||||
let fine = ok(&mut a, "doc_open", json!({ "path": "fine.pdf" }))["doc"].as_u64().unwrap();
|
||||
let r = ok(&mut a, "doc_export_office", json!({ "doc": fine, "path": "fine.html" }));
|
||||
assert_eq!((&r["truncated"], &r["truncated_pages"]), (&json!(false), &json!([])), "{r}");
|
||||
assert!(r.get("warnings").is_none(), "{r}");
|
||||
assert!(std::fs::read_to_string(dir.join("fine.html")).unwrap().contains("TAIL"));
|
||||
}
|
||||
|
||||
/// #773: a diagonal watermark must not enter the body flow or move invoice fields.
|
||||
#[test]
|
||||
fn exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow() {
|
||||
@@ -6495,3 +6765,93 @@ fn a_root_less_write_output_is_atomic_too() {
|
||||
assert!(matches!(&e, ToolError::Failed(m) if m.contains("is a folder")), "{e:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fdf_nested_comment_values_survive_export_import_and_save_through_tools() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full};
|
||||
use std::sync::Arc;
|
||||
let dir = workdir("fdf-nested-values");
|
||||
let mut source = Document::open(Arc::new(fixture(1))).unwrap();
|
||||
let page = pdfcraft_model::pages(&source)[0].obj;
|
||||
let mut note = Dict::new();
|
||||
note.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
note.set(b"Subtype".to_vec(), Object::name("Square"));
|
||||
note.set(b"NM".to_vec(), PdfString::text("nested-values"));
|
||||
note.set(b"Rect".to_vec(), Object::Array([10, 20, 30, 40].map(|value| Object::Ref(source.add(Object::Int(value)))).into()));
|
||||
note.set(b"C".to_vec(), Object::Array([0.25, 0.5, 0.75].map(|value| Object::Ref(source.add(Object::Real(value)))).into()));
|
||||
let note = source.add(note);
|
||||
source.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap();
|
||||
std::fs::write(dir.join("source.pdf"), write_full(&source, &SaveOptions::default()).unwrap()).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let source = ok(&mut a, "doc_open", json!({ "path": "source.pdf" }))["doc"].as_u64().unwrap();
|
||||
|
||||
ok(&mut a, "doc_export_data", json!({ "doc": source, "path": "notes.fdf", "what": "comments" }));
|
||||
let target = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_import_data", json!({ "doc": target, "path": "notes.fdf" }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": target, "path": "restored.pdf" }));
|
||||
let reopened = ok(&mut a, "doc_open", json!({ "path": "restored.pdf" }))["doc"].as_u64().unwrap();
|
||||
let after = ok(&mut a, "comment_list", json!({ "doc": reopened }));
|
||||
// The saved input has legal indirect scalar numbers. The engine's source inspection
|
||||
// currently reads those as defaults, so compare the restored data to the fixture's
|
||||
// known values (converted from PDF bottom-left to the tool's top-left coordinates).
|
||||
assert_eq!(after["comments"][0]["rect"], json!([10.0, 260.0, 30.0, 280.0]));
|
||||
assert_eq!(after["comments"][0]["color"], "#4080BF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_fdf_export_keeps_the_destination_and_source_through_tools() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, write_full};
|
||||
use std::sync::Arc;
|
||||
let dir = workdir("fdf-export-limit");
|
||||
let mut source = Document::open(Arc::new(fixture(1))).unwrap();
|
||||
let page = pdfcraft_model::pages(&source)[0].obj;
|
||||
let mut note = Dict::new();
|
||||
note.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
note.set(b"Subtype".to_vec(), Object::name("Square"));
|
||||
note.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 20.into(), 30.into(), 40.into()]));
|
||||
note.set(b"PCData".to_vec(), Object::Array(vec![Object::Null; 100_001]));
|
||||
let note = source.add(note);
|
||||
source.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap();
|
||||
let bytes = write_full(&source, &SaveOptions::default()).unwrap();
|
||||
std::fs::write(dir.join("source.pdf"), &bytes).unwrap();
|
||||
std::fs::write(dir.join("notes.fdf"), b"existing destination").unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "source.pdf" }))["doc"].as_u64().unwrap();
|
||||
let before = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||||
let error = a.call("doc_export_data", &json!({ "doc": doc, "path": "notes.fdf", "what": "comments" })).unwrap_err();
|
||||
assert!(error.to_string().contains("100,000 values"), "{error}");
|
||||
assert_eq!(std::fs::read(dir.join("notes.fdf")).unwrap(), b"existing destination");
|
||||
assert_eq!(std::fs::read(dir.join("source.pdf")).unwrap(), bytes);
|
||||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc })), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_box_and_callout_fill_and_border_through_tools() {
|
||||
let dir = workdir("textbox-fill");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
for kind in ["textbox", "callout"] {
|
||||
let mut args = json!({ "doc": doc, "page": 1, "type": kind, "rect": [80, 40, 180, 100], "contents": "Example" });
|
||||
if kind == "callout" {
|
||||
args["to"] = json!([20, 120]);
|
||||
}
|
||||
let added = ok(&mut a, "comment_add", args);
|
||||
let comment = &added["comment"];
|
||||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": comment["index"], "fill": "white", "width": 3 }));
|
||||
}
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "filled.pdf" }));
|
||||
let mut session = pdfcraft_engine::Session::new();
|
||||
let bytes = std::sync::Arc::new(std::fs::read(dir.join("filled.pdf")).unwrap());
|
||||
let saved = session.open("filled.pdf", None, bytes, None).unwrap();
|
||||
for index in 0..2 {
|
||||
let props = session.get(saved).unwrap().comment_props(0, index).unwrap();
|
||||
assert_eq!((props.fill, props.width), (Some([1.0; 3]), Some(3.0)));
|
||||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": index + 1, "fill": "none" }));
|
||||
}
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "transparent.pdf" }));
|
||||
let bytes = std::sync::Arc::new(std::fs::read(dir.join("transparent.pdf")).unwrap());
|
||||
let saved = session.open("transparent.pdf", None, bytes, None).unwrap();
|
||||
for index in 0..2 {
|
||||
assert_eq!(session.get(saved).unwrap().comment_props(0, index).unwrap().fill, None);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,17 @@ fn result(auto: &mut Automation, args: Value) -> Value {
|
||||
|
||||
// Fresh fixtures include the current timestamp; retain their bytes for output comparisons.
|
||||
fn pdf() -> Vec<u8> {
|
||||
Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone()
|
||||
// Reuse one synthetic document so byte comparisons cannot straddle the
|
||||
// writer's modification-time clock. Each caller gets an independent buffer.
|
||||
static PDF: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
|
||||
PDF.get_or_init(|| Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone()).clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_fixture_remains_identical_across_metadata_clock_tick() {
|
||||
let before = pdf();
|
||||
std::thread::sleep(std::time::Duration::from_millis(1100));
|
||||
assert_eq!(before, pdf());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Editing a button's visible label through the CLI/MCP tool table (#2892).
|
||||
|
||||
use pdfcraft_automation::{Automation, Content};
|
||||
use pdfcraft_engine::{DocId, Edit, NewField, Session};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn tool(a: &mut Automation, name: &str, args: Value) -> Value {
|
||||
match a.call(name, &args).unwrap().remove(0) {
|
||||
Content::Json(value) => value,
|
||||
other => panic!("expected JSON: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn button_caption_can_be_changed_after_creation() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-button-caption-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut session = Session::new();
|
||||
let bytes = session.create_blank(300.0, 400.0, 1).unwrap();
|
||||
let id = session.open("buttons.pdf", None, bytes, None).unwrap();
|
||||
session
|
||||
.apply(
|
||||
id,
|
||||
Edit::AddField {
|
||||
page: 0,
|
||||
rect: [40.0, 300.0, 200.0, 330.0],
|
||||
kind: NewField::Button { caption: String::new() },
|
||||
name: Some("clear_button".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("buttons.pdf"), session.save_bytes(id).unwrap().as_slice()).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap().with_clock(|| 1_700_000_000);
|
||||
let id = tool(&mut a, "doc_open", json!({"path": "buttons.pdf"}))["doc"].as_u64().unwrap();
|
||||
let before = a.session().save_bytes(DocId(id)).unwrap();
|
||||
let (_, _, empty_pixels) = a.render_pixels(id, 1, 1.0).unwrap();
|
||||
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "Clear Form"}));
|
||||
let fields = tool(&mut a, "form_fields", json!({"doc": id}));
|
||||
assert_eq!(fields["fields"][0]["caption"], "Clear Form");
|
||||
assert_eq!(fields["fields"][0]["captions"], json!([{"widget": 1, "caption": "Clear Form"}]));
|
||||
assert_eq!(a.session().get(DocId(id)).unwrap().can_undo(), Some("Change field properties"));
|
||||
let (_, _, labelled_pixels) = a.render_pixels(id, 1, 1.0).unwrap();
|
||||
assert_ne!(empty_pixels, labelled_pixels, "the label must reach the actual page appearance");
|
||||
tool(&mut a, "edit_undo", json!({"doc": id}));
|
||||
assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before);
|
||||
tool(&mut a, "edit_redo", json!({"doc": id}));
|
||||
tool(&mut a, "doc_save", json!({"doc": id, "path": "labelled.pdf"}));
|
||||
let saved = std::sync::Arc::new(std::fs::read(dir.join("labelled.pdf")).unwrap());
|
||||
let doc = pdfcraft_cos::Document::open(saved).unwrap();
|
||||
let root = doc.get(doc.root().unwrap());
|
||||
let form = doc.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap());
|
||||
let fields = doc.resolve(form.as_dict().unwrap().get(b"Fields").unwrap());
|
||||
let button = doc.resolve(&fields.as_array().unwrap()[0]);
|
||||
let mk = doc.resolve(button.as_dict().unwrap().get(b"MK").unwrap());
|
||||
assert_eq!(doc.resolve(mk.as_dict().unwrap().get(b"CA").unwrap()).as_string().unwrap().to_text(), "Clear Form");
|
||||
assert_eq!(doc.resolve(button.as_dict().unwrap().get(b"T").unwrap()).as_string().unwrap().to_text(), "clear_button");
|
||||
let reopened = tool(&mut a, "doc_open", json!({"path": "labelled.pdf"}))["doc"].as_u64().unwrap();
|
||||
let fields = tool(&mut a, "form_fields", json!({"doc": reopened}));
|
||||
assert_eq!(fields["fields"][0]["caption"], "Clear Form");
|
||||
let (_, _, pixels) = a.render_pixels(reopened, 1, 1.0).unwrap();
|
||||
assert_eq!(pixels, labelled_pixels);
|
||||
let out = tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "提交"}));
|
||||
assert!(out["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).any(|w| w.contains("U+63D0")));
|
||||
let fields = tool(&mut a, "form_fields", json!({"doc": id}));
|
||||
assert_eq!(fields["fields"][0]["caption"], "提交", "unsupported characters are retained and reported");
|
||||
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "Clear Form"}));
|
||||
for invalid in [
|
||||
json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": 0}),
|
||||
json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": -1}),
|
||||
json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": 2}),
|
||||
json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": i64::MAX}),
|
||||
json!({"doc": id, "field": "clear_button", "caption": 42}),
|
||||
json!({"doc": id, "field": "clear_button", "widget": 1, "tooltip": "bad"}),
|
||||
json!({"doc": id, "fields": ["clear_button"], "caption": "bad"}),
|
||||
] {
|
||||
let before = a.session().save_bytes(DocId(id)).unwrap();
|
||||
assert!(a.call("form_set_props", &invalid).is_err(), "{invalid}");
|
||||
assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before, "{invalid}");
|
||||
}
|
||||
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "locked": true}));
|
||||
let before = a.session().save_bytes(DocId(id)).unwrap();
|
||||
assert!(a.call("form_set_props", &json!({"doc": id, "field": "clear_button", "caption": "bad"})).is_err());
|
||||
assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before);
|
||||
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "locked": false}));
|
||||
tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": ""}));
|
||||
let fields = tool(&mut a, "form_fields", json!({"doc": id}));
|
||||
assert_eq!(fields["fields"][0]["caption"], "");
|
||||
let (_, _, pixels) = a.render_pixels(id, 1, 1.0).unwrap();
|
||||
assert_eq!(pixels, empty_pixels);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -60,6 +60,10 @@ pub struct Parsed {
|
||||
pub ops: Vec<Op>,
|
||||
/// Bytes that could not be read as operands or operators.
|
||||
pub skipped: usize,
|
||||
/// For [`Pieces::parse`]: per piece, whether a comment runs into it from the piece before
|
||||
/// and whether one runs out of it into the next. Only boundaries are recorded, so the size
|
||||
/// depends on the number of pieces, not of comments.
|
||||
comment_edges: Vec<(bool, bool)>,
|
||||
}
|
||||
|
||||
fn is_ws(b: u8) -> bool {
|
||||
@@ -72,12 +76,25 @@ fn is_delim(b: u8) -> bool {
|
||||
|
||||
/// Parse a (decoded) content stream.
|
||||
pub fn parse(data: &[u8]) -> Parsed {
|
||||
let mut out = Parsed::default();
|
||||
parse_pieces(data, &[])
|
||||
}
|
||||
|
||||
/// Parse `data`, recording which of the piece boundaries `ends` (as in [`Pieces`]) a comment
|
||||
/// crosses.
|
||||
fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed {
|
||||
let mut out = Parsed { comment_edges: vec![(false, false); ends.len()], ..Parsed::default() };
|
||||
let mut boundary = 0;
|
||||
let mut lx = Lexer::new(data, 0);
|
||||
let mut operands: Vec<Object> = Vec::new();
|
||||
let mut start: Option<usize> = None;
|
||||
loop {
|
||||
let trivia = lx.pos;
|
||||
lx.skip_ws();
|
||||
// Only the whitespace the lexer skips here holds top-level comments: a `%` inside a
|
||||
// string, a name, an array or dictionary operand or inline image data is not one.
|
||||
if boundary < ends.len() {
|
||||
comment_edges(data, trivia..lx.pos, ends, &mut boundary, &mut out.comment_edges);
|
||||
}
|
||||
let Some(&b) = data.get(lx.pos) else { break };
|
||||
let at = lx.pos;
|
||||
let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit();
|
||||
@@ -148,6 +165,39 @@ pub fn parse(data: &[u8]) -> Parsed {
|
||||
out
|
||||
}
|
||||
|
||||
/// Record the piece boundaries crossed by the comments in `trivia` (skipped whitespace and
|
||||
/// comments). `boundary` is the first boundary not yet passed; boundaries only move forward.
|
||||
fn comment_edges(data: &[u8], trivia: std::ops::Range<usize>, ends: &[usize], boundary: &mut usize, edges: &mut [(bool, bool)]) {
|
||||
let mut at = trivia.start;
|
||||
while at < trivia.end {
|
||||
if data.get(at) != Some(&b'%') {
|
||||
at += 1;
|
||||
continue;
|
||||
}
|
||||
let comment = at;
|
||||
while at < trivia.end && !matches!(data.get(at), Some(b'\r' | b'\n')) {
|
||||
at += 1;
|
||||
}
|
||||
// The separator of piece `boundary` is the last byte before its end.
|
||||
while let Some(&end) = ends.get(*boundary) {
|
||||
let separator = end.saturating_sub(1);
|
||||
if separator >= at {
|
||||
break;
|
||||
}
|
||||
if separator >= comment {
|
||||
if let Some(edge) = edges.get_mut(*boundary) {
|
||||
edge.1 = true;
|
||||
}
|
||||
// Includes a comment whose end of line is the next piece's first byte.
|
||||
if let Some(edge) = edges.get_mut(boundary.saturating_add(1)) {
|
||||
edge.0 = true;
|
||||
}
|
||||
}
|
||||
*boundary += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an inline image starting after `BI`: (parameters, data, position after `EI`). `Err` holds
|
||||
/// the position after the `EI` (or the end of the stream) of an image whose parameters can't be read.
|
||||
fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec<u8>, usize), usize> {
|
||||
@@ -305,7 +355,9 @@ pub fn serialize_ops(ops: &[Op]) -> Vec<u8> {
|
||||
/// A page's content streams joined into the one stream they are. Writers may split it between
|
||||
/// any two tokens (ISO 32000-2 §7.8.2), so an operator's operands can end one piece and its
|
||||
/// keyword start the next: parse the pieces joined ([`Pieces::parse`]) and map positions back
|
||||
/// with [`Pieces::piece_of`]. A newline separates the pieces so tokens never run together.
|
||||
/// with [`Pieces::piece_of`]. A space separates the pieces, as renderers join them (hayro,
|
||||
/// MuPDF and Poppler alike): tokens never run together, and a comment at the end of a piece
|
||||
/// runs on into the next one, up to its first end of line.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Pieces {
|
||||
data: Vec<u8>,
|
||||
@@ -319,7 +371,7 @@ impl Pieces {
|
||||
let mut ends = Vec::with_capacity(pieces.len());
|
||||
for p in pieces {
|
||||
data.extend_from_slice(p.as_ref());
|
||||
data.push(b'\n');
|
||||
data.push(b' ');
|
||||
ends.push(data.len());
|
||||
}
|
||||
Pieces { data, ends }
|
||||
@@ -341,12 +393,22 @@ impl Pieces {
|
||||
|
||||
/// Parse the pieces as one stream.
|
||||
pub fn parse(&self) -> Parsed {
|
||||
parse(&self.data)
|
||||
parse_pieces(&self.data, &self.ends)
|
||||
}
|
||||
|
||||
/// Per piece, from `parsed` (this piece set's [`Pieces::parse`]): whether a comment runs
|
||||
/// into it from the piece before, and whether one runs out of it into the next. A writer
|
||||
/// that rewrites a piece without its comments must keep both: end the incoming comment
|
||||
/// before its first operator, and leave a comment open at its end.
|
||||
pub fn comment_edges(&self, parsed: &Parsed) -> Vec<(bool, bool)> {
|
||||
let mut edges = parsed.comment_edges.clone();
|
||||
edges.resize(self.len(), (false, false));
|
||||
edges
|
||||
}
|
||||
|
||||
/// The piece holding joined position `at` (the last piece for positions past the end).
|
||||
pub fn piece_of(&self, at: usize) -> usize {
|
||||
self.ends.iter().position(|&e| at < e).unwrap_or(self.ends.len().saturating_sub(1))
|
||||
self.ends.partition_point(|&e| e <= at).min(self.ends.len().saturating_sub(1))
|
||||
}
|
||||
|
||||
/// The pieces an operator spans: (the one its first token is in, the one its keyword is in).
|
||||
|
||||
@@ -283,3 +283,108 @@ fn pieces_parse_as_one_stream_and_splice_back() {
|
||||
assert_eq!(out.len(), 3);
|
||||
assert!(Pieces::join::<&[u8]>(&[]).splice([(0..1, b"x".to_vec())]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_boundary_comment_matches_space_concatenation() {
|
||||
for eol in [b"\n".as_slice(), b"\r", b"\r\n"] {
|
||||
let mut tail = b"(HIDDEN) Tj".to_vec();
|
||||
tail.extend(eol);
|
||||
tail.extend(b"(LIVE) Tj");
|
||||
let pieces = Pieces::join(&[b"BT % comment".as_slice(), b"(ALSO HIDDEN) Tj", &tail, b"ET"]);
|
||||
let parsed = pieces.parse();
|
||||
let shown: Vec<_> = parsed.ops.iter().filter(|op| op.is("Tj")).collect();
|
||||
assert_eq!(shown.len(), 1);
|
||||
assert_eq!(shown[0].operands[0].as_string().unwrap().bytes, b"LIVE");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_edges_terminate_at_next_piece_byte_zero() {
|
||||
for eol in [b"\n".as_slice(), b"\r", b"\r\n"] {
|
||||
let mut next = eol.to_vec();
|
||||
next.extend_from_slice(b"q Q");
|
||||
let pieces = Pieces::join(&[b"% outgoing".as_slice(), next.as_slice()]);
|
||||
let parsed = pieces.parse();
|
||||
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false)], "EOL {eol:?}");
|
||||
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), vec![b"q".as_slice(), b"Q"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_edges_span_empty_pieces_and_end_of_content() {
|
||||
let pieces = Pieces::join(&[b"q % outgoing".as_slice(), b"", b"", b"ignored\nQ"]);
|
||||
let parsed = pieces.parse();
|
||||
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, true), (true, true), (true, false)]);
|
||||
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), vec![b"q".as_slice(), b"Q"]);
|
||||
|
||||
let pieces = Pieces::join(&[b"% outgoing".as_slice(), b"", b"still ignored"]);
|
||||
let parsed = pieces.parse();
|
||||
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, true), (true, true)]);
|
||||
assert!(parsed.ops.is_empty());
|
||||
|
||||
let pieces = Pieces::join::<&[u8]>(&[]);
|
||||
assert!(pieces.comment_edges(&pieces.parse()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_edges_ignore_percent_inside_operands_and_image_data() {
|
||||
for first in [
|
||||
b"(literal % not a comment) Tj".as_slice(),
|
||||
b"/Percent#25Name MP",
|
||||
b"[(literal % not a comment)] TJ",
|
||||
b"/P << /ActualText (literal % not a comment) >> BDC EMC",
|
||||
b"BI /W 1 /H 1 /BPC 8 /CS /G ID % EI",
|
||||
] {
|
||||
let pieces = Pieces::join(&[first, b"q Q"]);
|
||||
let parsed = pieces.parse();
|
||||
assert_eq!(pieces.comment_edges(&parsed), vec![(false, false), (false, false)], "{first:?}");
|
||||
assert_eq!(parsed.ops.last().unwrap().op, b"Q");
|
||||
assert_eq!(parsed.skipped, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_operand_comment_split_is_owned_by_spanning_operator() {
|
||||
for parts in [[b"[(A) % ignored".as_slice(), b"still ignored\n(B)] TJ"], [b"/P << /MCID 0 % ignored".as_slice(), b"still ignored\n>> BDC"]] {
|
||||
let pieces = Pieces::join(&parts);
|
||||
let parsed = pieces.parse();
|
||||
// The object lexer consumes this nested comment. Its containing operator spans
|
||||
// both pieces, which forces both to be rewritten together by the redactor.
|
||||
assert_eq!(pieces.comment_edges(&parsed), vec![(false, false), (false, false)]);
|
||||
assert_eq!(parsed.ops.len(), 1);
|
||||
assert_eq!(pieces.pieces_of(&parsed.ops[0]), (0, 1));
|
||||
assert_eq!(parsed.skipped, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_edges_do_not_confuse_local_comments_with_boundary_comments() {
|
||||
let pieces = Pieces::join(&[b"% local\nq % crossing".as_slice(), b"ignored\n% local again\nQ\n% final local\n", b"q Q"]);
|
||||
let parsed = pieces.parse();
|
||||
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false), (false, false)]);
|
||||
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), vec![b"q".as_slice(), b"Q", b"q", b"Q"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_edge_storage_depends_on_piece_count_not_comment_count() {
|
||||
let mut first = b"%\n".repeat(16_384);
|
||||
first.extend_from_slice(b"q % crossing");
|
||||
let pieces = Pieces::join(&[first.as_slice(), b"ignored\nQ"]);
|
||||
let parsed = pieces.parse();
|
||||
assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false)]);
|
||||
assert_eq!(parsed.ops.len(), 2);
|
||||
// Also exercise public parsing without piece-boundary metadata.
|
||||
assert_eq!(parse(&first).ops.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn piece_lookup_preserves_exact_boundary_and_empty_piece_mapping() {
|
||||
// Joined layout: q<space><space>Q<space>; separator belongs to prior piece.
|
||||
let pieces = Pieces::join(&[b"q".as_slice(), b"", b"Q"]);
|
||||
for (at, expected) in [(0, 0), (1, 0), (2, 1), (3, 2), (4, 2), (5, 2), (usize::MAX, 2)] {
|
||||
assert_eq!(pieces.piece_of(at), expected, "position {at}");
|
||||
}
|
||||
let empty = Pieces::join::<&[u8]>(&[]);
|
||||
assert_eq!(empty.piece_of(0), 0);
|
||||
assert_eq!(empty.piece_of(usize::MAX), 0);
|
||||
}
|
||||
+314
-15
@@ -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<u32, Slot>,
|
||||
}
|
||||
|
||||
impl ReadState {
|
||||
/// Whether `doc` reads as this state.
|
||||
pub fn matches(&self, doc: &Document) -> bool {
|
||||
self.source == doc.source_identity()
|
||||
&& self.trailer == doc.trailer
|
||||
&& self.overlay.len() == doc.overlay.len()
|
||||
&& self.overlay.iter().zip(&doc.overlay).all(|((a, x), (b, y))| {
|
||||
a == b
|
||||
&& match (x, y) {
|
||||
(Slot::Set(g, o), Slot::Set(h, p)) => g == h && Arc::ptr_eq(o, p),
|
||||
(Slot::Freed(g), Slot::Freed(h)) => g == h,
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ReadState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ReadState").field("changed_objects", &self.overlay.len()).finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Document {
|
||||
data: Arc<Vec<u8>>,
|
||||
@@ -801,6 +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<u32, XrefEntry>, odd: &mut Vec<u32>) -> Result<(Dict, bool), CosError> {
|
||||
fn read_section(
|
||||
&self,
|
||||
off: usize,
|
||||
entries: &mut BTreeMap<u32, XrefEntry>,
|
||||
odd: &mut Vec<u32>,
|
||||
log: &mut Vec<String>,
|
||||
) -> 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<u8> {
|
||||
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::<u64>().ok());
|
||||
lx.skip_ws();
|
||||
let generation = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::<u32>().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<u8> {
|
||||
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<u8> {
|
||||
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<u8>, num: u32, body: &str| {
|
||||
let off = out.len();
|
||||
out.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes());
|
||||
off as u16
|
||||
};
|
||||
let o1 = at(&mut out, 1, "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>");
|
||||
let o2 = at(&mut out, 2, "<< /Type /Pages /Kids [] /Count 0 >>");
|
||||
let inner = "<< /Type /Outlines /Count 0 >>";
|
||||
at(&mut out, 3, &format!("<< /Type /ObjStm /N 1 /First 4 /Length {} >>\nstream\n4 0 {inner}\nendstream", 4 + inner.len()));
|
||||
// /W [1 2 1]: object 3 is said to live in stream 99 (which does not exist), 4 in stream 3.
|
||||
let rows: Vec<[u8; 4]> = vec![
|
||||
[0, 0, 0, 255],
|
||||
[1, 0, o1 as u8, 0],
|
||||
[1, 0, o2 as u8, 0],
|
||||
[2, 0, 99, 0],
|
||||
[2, 0, 3, 0],
|
||||
[1, (out.len() >> 8) as u8, out.len() as u8, 0],
|
||||
];
|
||||
let rows: Vec<u8> = rows.concat();
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(
|
||||
format!("5 0 obj\n<< /Type /XRef /W [1 2 1] /Index [0 6] /Size 6 /Root 1 0 R /Length {} >>\nstream\n", rows.len()).as_bytes(),
|
||||
);
|
||||
out.extend_from_slice(&rows);
|
||||
out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref}\n%%EOF\n").as_bytes());
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
/// An object stream whose own cross-reference entry says it is compressed inside another
|
||||
/// stream (which the format forbids, and which does not exist here: qpdf's
|
||||
/// `xref-compressed-in-compressed.pdf`, #2367) could not be loaded, so every object in it
|
||||
/// read as null and a full save wrote `/Outlines null`. The stream is now found by its own
|
||||
/// header, as a plain object with a wrong offset is.
|
||||
#[test]
|
||||
fn an_object_stream_marked_as_compressed_is_found_by_its_header() {
|
||||
let doc = compressed_in_compressed_fixture();
|
||||
assert_eq!(doc.revisions().len(), 1, "the chain itself reads: {:?}", doc.repair_log());
|
||||
let outlines = doc.get(ObjRef::new(4, 0));
|
||||
assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "{outlines:?}");
|
||||
let saved = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap();
|
||||
let back = Document::open(Arc::new(saved)).unwrap();
|
||||
let catalog = back.get(back.root().unwrap());
|
||||
let outlines = back.resolve(catalog.as_dict().unwrap().get(b"Outlines").unwrap());
|
||||
assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "the full save keeps it: {outlines:?}");
|
||||
}
|
||||
|
||||
/// The header fallback is for file entries only: once the stream is freed (or replaced) in
|
||||
/// the overlay, its objects must not come back from the file's copy of it.
|
||||
#[test]
|
||||
fn a_freed_object_stream_is_not_found_by_its_header() {
|
||||
let doc = compressed_in_compressed_fixture();
|
||||
assert!(doc.get(ObjRef::new(4, 0)).as_dict().is_some());
|
||||
let mut edited = doc.clone();
|
||||
edited.free(ObjRef::new(3, 0));
|
||||
let fresh = edited.object_reader().document;
|
||||
assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0)));
|
||||
let mut replaced = doc.clone();
|
||||
replaced.set(ObjRef::new(3, 0), Object::Int(0));
|
||||
let fresh = replaced.object_reader().document;
|
||||
assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0)));
|
||||
}
|
||||
|
||||
/// The same once the free is saved: the revision's table says the stream is free, and
|
||||
/// neither reading the file back nor rewriting it in full brings its objects back.
|
||||
#[test]
|
||||
fn a_container_freed_in_a_saved_revision_is_not_found_by_its_header() {
|
||||
let mut edited = compressed_in_compressed_fixture();
|
||||
edited.free(ObjRef::new(3, 0));
|
||||
let incremental = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap();
|
||||
let reopened = Document::open(Arc::new(incremental)).unwrap();
|
||||
assert_eq!(reopened.revisions().len(), 2);
|
||||
assert!(matches!(reopened.xref_entry(3), Some(XrefEntry::Free { .. })));
|
||||
assert!(matches!(*reopened.get(ObjRef::new(4, 0)), Object::Null), "{:?}", reopened.get(ObjRef::new(4, 0)));
|
||||
for object_streams in [true, false] {
|
||||
let opts = crate::SaveOptions { object_streams, ..Default::default() };
|
||||
let full = crate::write_full(&reopened, &opts).unwrap();
|
||||
let back = Document::open(Arc::new(full)).unwrap();
|
||||
let catalog = back.get(back.root().unwrap());
|
||||
let outlines = catalog.as_dict().unwrap().get(b"Outlines").map(|o| back.resolve(o));
|
||||
assert!(outlines.as_ref().is_none_or(|o| matches!(**o, Object::Null)), "the full save keeps it out: {outlines:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_xref_is_reconstructed() {
|
||||
let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -482,6 +482,19 @@ mod tests {
|
||||
assert_eq!(stream.decoded_strict().unwrap(), b"abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_flate_fallback_streams_preserve_the_caller_limit() {
|
||||
// Raw deflate with a stored byte and a final empty block. 78 01 also
|
||||
// passes the zlib header check, so decoding must retry the whole input.
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
|
||||
let stream = Stream::from_raw(dict, vec![0x78, 0x01, 0x00, 0xfe, 0xff, b'A', 0x01, 0x00, 0x00, 0xff, 0xff]);
|
||||
let result = stream.decoded_within(0);
|
||||
assert!(matches!(&result, Err(CosError::Filter(detail)) if detail.contains("exceeds the limit of 0 bytes")), "{result:?}");
|
||||
assert_eq!(stream.decoded_within(1).unwrap(), b"A");
|
||||
assert_eq!(stream.decoded_strict().unwrap(), b"A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_filter_decodes_only_the_outer_wrapper() {
|
||||
let data = "0 0 m 100 100 l S\n".repeat(64).into_bytes();
|
||||
|
||||
@@ -337,11 +337,14 @@ impl<'a> Lexer<'a> {
|
||||
}
|
||||
return Ok(Object::Real(0.0));
|
||||
}
|
||||
match s.trim_start_matches('+').parse::<i64>() {
|
||||
// Tolerate a doubled sign ("--280") like the real branch does.
|
||||
let t = s.trim_start_matches('+');
|
||||
let cleaned = t.strip_prefix('-').filter(|r| r.starts_with('-')).unwrap_or(t);
|
||||
match cleaned.parse::<i64>() {
|
||||
Ok(v) => Ok(Object::Int(v)),
|
||||
Err(_) => {
|
||||
// Out-of-range integers are treated as reals (§7.3.3 allows implementation limits).
|
||||
s.parse::<f64>().map(Object::Real).map_err(|_| CosError::Syntax { offset: start, detail: format!("bad number {s:?}") })
|
||||
cleaned.parse::<f64>().map(Object::Real).map_err(|_| CosError::Syntax { offset: start, detail: format!("bad number {s:?}") })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -401,7 +404,16 @@ fn parse_indirect_in(
|
||||
if let Object::Dict(dict) = &obj
|
||||
&& lx.eat_keyword(b"stream")
|
||||
{
|
||||
// The keyword is followed by CRLF or LF (a lone CR is tolerated).
|
||||
// The keyword is followed by CRLF or LF (a lone CR is tolerated). Microsoft Reporting
|
||||
// Services writes a space first (`stream \r\n`); spaces and tabs count only when a line
|
||||
// break follows, so they are never taken from the data.
|
||||
let mut eol = lx.pos;
|
||||
while matches!(data.get(eol), Some(b' ' | b'\t')) {
|
||||
eol += 1;
|
||||
}
|
||||
if matches!(data.get(eol), Some(b'\r' | b'\n')) {
|
||||
lx.pos = eol;
|
||||
}
|
||||
match (data.get(lx.pos), data.get(lx.pos + 1)) {
|
||||
(Some(b'\r'), Some(b'\n')) => lx.pos += 2,
|
||||
(Some(b'\n'), _) | (Some(b'\r'), _) => lx.pos += 1,
|
||||
@@ -475,6 +487,20 @@ mod tests {
|
||||
Lexer::new(s.as_bytes(), 0).object().unwrap_or_else(|e| panic!("{s}: {e}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doubled_sign_integer_reads_like_the_real_form() {
|
||||
// Some writers emit a TJ kerning as "--280"; the real form "--280.0" is already read.
|
||||
assert_eq!(obj("--280.0"), Object::Real(-280.0));
|
||||
assert_eq!(obj("--280"), Object::Int(-280));
|
||||
match obj("[(Hello) --280 (World)]") {
|
||||
Object::Array(items) => {
|
||||
assert_eq!(items.len(), 3);
|
||||
assert_eq!(items[1], Object::Int(-280));
|
||||
}
|
||||
other => panic!("not an array: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalars() {
|
||||
assert_eq!(obj("true"), Object::Bool(true));
|
||||
@@ -537,6 +563,25 @@ mod tests {
|
||||
assert_eq!(s.dict.int(b"Length"), Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indirect_stream_with_a_space_before_the_line_break() {
|
||||
// `stream \r\n` as Microsoft Reporting Services writes it: the data starts after the
|
||||
// line break, so the /Length is still right and the data keeps no leading space.
|
||||
for eol in [&b" \r\n"[..], b" \n", b"\t \r"] {
|
||||
let mut data = b"7 0 obj\n<< /Length 5 >>\nstream".to_vec();
|
||||
data.extend_from_slice(eol);
|
||||
data.extend_from_slice(b"HELLO\r\nendstream\nendobj\n");
|
||||
let (_, o) = parse_indirect(&data, 0, &|_| None).unwrap();
|
||||
let Object::Stream(s) = o else { panic!("stream expected") };
|
||||
assert_eq!(&s.raw[..], b"HELLO", "{eol:?}");
|
||||
}
|
||||
// Spaces with no line break after them are data, not a keyword terminator.
|
||||
let data = b"7 0 obj\n<< /Length 7 >>\nstream HELLO\nendstream\nendobj\n";
|
||||
let (_, o) = parse_indirect(data, 0, &|_| None).unwrap();
|
||||
let Object::Stream(s) = o else { panic!("stream expected") };
|
||||
assert_eq!(&s.raw[..], b" HELLO");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indirect_length_is_resolved() {
|
||||
let data = b"7 0 obj\n<< /Length 8 0 R >>\nstream\r\nAB\r\nendstream\nendobj\n";
|
||||
|
||||
@@ -183,7 +183,7 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
|
||||
}
|
||||
// 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<Vec<u8>,
|
||||
// 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<u32, (u64, u16, bool)> = BTreeMap::new(); // num → (offset, gen, in use)
|
||||
for (num, generation, obj) in doc.overlay_entries() {
|
||||
|
||||
@@ -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<u8> {
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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::<usize>().ok()) {
|
||||
Some(o) => format!("{:010} 00000 n \n", o + prefix.len()),
|
||||
None if line.parse::<usize>().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.
|
||||
}
|
||||
@@ -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.
|
||||
@@ -126,13 +126,13 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), 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<u8>), 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<u8>), 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<u8>), 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<u8>), 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<Vec<[f64; 2]>> {
|
||||
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()?);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
|
||||
|
||||
mod extract;
|
||||
mod upright_image;
|
||||
pub use extract::{ExtractedImage, ImageExport, extract_images, image_file};
|
||||
use pdfcraft_fonts::{literal, win_ansi, wrap};
|
||||
|
||||
@@ -528,7 +529,7 @@ pub fn source_kind(name: &str, bytes: &[u8]) -> Option<SourceKind> {
|
||||
/// Detect the image format from its bytes; a TIFF may hold several pages.
|
||||
fn embed(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
|
||||
if bytes.starts_with(&[0xFF, 0xD8]) {
|
||||
Ok(vec![jpeg(name, bytes)?])
|
||||
Ok(vec![jpeg(name, &upright_image::upright(bytes))?])
|
||||
} else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
Ok(vec![png_image(name, bytes)?])
|
||||
} else if is_tiff(bytes) {
|
||||
|
||||
@@ -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<u8>) -> (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<u8>, 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<u8> {
|
||||
@@ -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<u8> {
|
||||
use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit};
|
||||
let mut jpeg = Vec::new();
|
||||
let mut encoder = JpegEncoder::new(&mut jpeg);
|
||||
encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches });
|
||||
encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap();
|
||||
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
|
||||
exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]);
|
||||
let mut out = jpeg[..2].to_vec();
|
||||
out.extend_from_slice(&[0xff, 0xe1]);
|
||||
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
|
||||
out.extend_from_slice(&exif);
|
||||
out.extend_from_slice(&jpeg[2..]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jpeg_with_an_exif_orientation_becomes_an_upright_page() {
|
||||
let doc = reopen(&from_images(&[("photo.jpg".into(), phone_jpeg(6, 144)), ("plain.jpg".into(), phone_jpeg(1, 72))]).unwrap());
|
||||
// Stored 60 x 20 px at 144 x 72 dpi; turned it is 20 x 60 px at 72 x 144 dpi: 20 x 30 pt.
|
||||
let m0 = media(&pages(&doc)[0]);
|
||||
assert!((m0[2] - 20.0).abs() < 0.01 && (m0[3] - 30.0).abs() < 0.01, "{m0:?}");
|
||||
let img = image_of(&doc, 0);
|
||||
assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(20), Some(60)));
|
||||
// "As stored" stays as it is.
|
||||
let m1 = media(&pages(&doc)[1]);
|
||||
assert!((m1[2] - 60.0).abs() < 0.01 && (m1[3] - 20.0).abs() < 0.01, "{m1:?}");
|
||||
let img = image_of(&doc, 1);
|
||||
assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(60), Some(20)));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Photos from phones are stored sideways with an EXIF Orientation tag. PDF image XObjects have
|
||||
//! no such tag, so a JPEG embedded as it is would show up turned. Images placed into a page
|
||||
//! (stamps, added images, replacements) go through here first.
|
||||
//! no such tag, so a JPEG embedded as it is would show up turned. Every JPEG embedded by this
|
||||
//! crate (pages made from images, stamps, added images, replacements) goes through here first.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::io::Cursor;
|
||||
@@ -79,24 +79,7 @@ fn jfif_density(bytes: &[u8]) -> Option<PixelDensity> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Edit, MarkFile};
|
||||
|
||||
/// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90
|
||||
/// degrees clockwise).
|
||||
fn phone_jpeg(orientation: u8, dpi: u16) -> Vec<u8> {
|
||||
let mut jpeg = Vec::new();
|
||||
let mut encoder = JpegEncoder::new(&mut jpeg);
|
||||
encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches });
|
||||
encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap();
|
||||
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
|
||||
exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]);
|
||||
let mut out = jpeg[..2].to_vec();
|
||||
out.extend_from_slice(&[0xff, 0xe1]);
|
||||
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
|
||||
out.extend_from_slice(&exif);
|
||||
out.extend_from_slice(&jpeg[2..]);
|
||||
out
|
||||
}
|
||||
use crate::tests::phone_jpeg;
|
||||
|
||||
#[test]
|
||||
fn orientation_is_applied_and_plain_images_are_left_alone() {
|
||||
@@ -111,23 +94,4 @@ mod tests {
|
||||
assert!(matches!(upright(b"\x89PNG\r\n\x1a\n"), Cow::Borrowed(_)));
|
||||
assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() {
|
||||
let mut session = crate::Session::new();
|
||||
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
|
||||
let id = session.open_new("form.pdf", blank).unwrap();
|
||||
// A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright.
|
||||
// Named "Signature" so `signature_image` finds its picture.
|
||||
let file = MarkFile { name: "photo.jpg".into(), bytes: std::sync::Arc::new(phone_jpeg(6, 144)), page: 0 };
|
||||
let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() };
|
||||
session.apply(id, stamp).unwrap();
|
||||
let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos;
|
||||
let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap();
|
||||
let obj = cos.get(picture);
|
||||
let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() };
|
||||
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60)));
|
||||
let rect = pdfcraft_annot::summaries(cos)[0].rect;
|
||||
assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}");
|
||||
}
|
||||
}
|
||||
+15
-1
@@ -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<i32> {
|
||||
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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
+156
-171
@@ -4,8 +4,8 @@
|
||||
//! or delete. Only the stream that draws it is rewritten, as a new object.
|
||||
|
||||
use kurbo::{BezPath, Point, Shape};
|
||||
use pdfcraft_content::{Matrix, Op, parse, serialize_ops};
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
|
||||
use pdfcraft_content::{Matrix, Op, Pieces, parse};
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::EditError;
|
||||
@@ -30,6 +30,8 @@ pub struct PageImage {
|
||||
/// Pixel size; zero for vector/grouped Form artwork.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// The content stream its `Do` (its first token) is in, and the `Do`'s index among the
|
||||
/// operators of the page's streams joined ([`Pieces`]).
|
||||
stream: usize,
|
||||
op: usize,
|
||||
/// CTM before `Do`, excluding a Form's own Matrix.
|
||||
@@ -90,6 +92,11 @@ fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The page's streams joined into the one stream they are: an operator may span two of them.
|
||||
fn joined(streams: &[(Object, Vec<u8>)]) -> Pieces {
|
||||
Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn page_of(doc: &Document, page: usize) -> Result<pdfcraft_model::Page, EditError> {
|
||||
pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))
|
||||
}
|
||||
@@ -214,72 +221,71 @@ pub fn page_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditEr
|
||||
let p = page_of(doc, page)?;
|
||||
let xo = xobjects(doc, &p.dict);
|
||||
let mut out = Vec::new();
|
||||
// The graphics state carries over from one stream to the next.
|
||||
// The streams are one stream in pieces, so the graphics state and operators carry over.
|
||||
let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
|
||||
for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() {
|
||||
for (i, op) in parse(&data).ops.iter().enumerate() {
|
||||
state.apply(op)?;
|
||||
let ctm = state.placement.matrix;
|
||||
if op.is("Do") {
|
||||
let Some(name) = op.name(0) else { continue };
|
||||
let Some(resource) = xo.get(name) else { continue };
|
||||
let obj = doc.resolve(resource);
|
||||
let Object::Stream(s) = &*obj else { continue };
|
||||
let is_form = s.dict.name(b"Subtype") == Some(b"Form");
|
||||
let (matrix, bounds) = if is_form {
|
||||
let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| {
|
||||
v.as_array().and_then(|a| {
|
||||
if a.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
let mut b = [0.0; 4];
|
||||
for (n, v) in b.iter_mut().zip(a) {
|
||||
*n = v.as_f64()?;
|
||||
}
|
||||
Some(b)
|
||||
})
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let matrix = match s.dict.get(b"Matrix") {
|
||||
Some(v) => {
|
||||
let v = doc.resolve(v);
|
||||
let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue };
|
||||
m
|
||||
let pieces = joined(&streams(doc, &p.dict));
|
||||
for (i, op) in pieces.parse().ops.iter().enumerate() {
|
||||
state.apply(op)?;
|
||||
let ctm = state.placement.matrix;
|
||||
if op.is("Do") {
|
||||
let Some(name) = op.name(0) else { continue };
|
||||
let Some(resource) = xo.get(name) else { continue };
|
||||
let obj = doc.resolve(resource);
|
||||
let Object::Stream(s) = &*obj else { continue };
|
||||
let is_form = s.dict.name(b"Subtype") == Some(b"Form");
|
||||
let (matrix, bounds) = if is_form {
|
||||
let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| {
|
||||
v.as_array().and_then(|a| {
|
||||
if a.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
None => Matrix::IDENTITY,
|
||||
};
|
||||
(matrix.then(&ctm), bounds)
|
||||
} else if s.dict.name(b"Subtype") == Some(b"Image") {
|
||||
(ctm, [0.0, 0.0, 1.0, 1.0])
|
||||
} else {
|
||||
let mut b = [0.0; 4];
|
||||
for (n, v) in b.iter_mut().zip(a) {
|
||||
*n = v.as_f64()?;
|
||||
}
|
||||
Some(b)
|
||||
})
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let rect = matrix.bbox(bounds);
|
||||
if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite())
|
||||
|| bounds[2] <= bounds[0]
|
||||
|| bounds[3] <= bounds[1]
|
||||
|| rect[2] <= rect[0]
|
||||
|| rect[3] <= rect[1]
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push(PageImage {
|
||||
rect,
|
||||
visible_rect: intersection(rect, state.placement.clip),
|
||||
clip_rect: Some(state.placement.clip),
|
||||
matrix: matrix.0,
|
||||
name: String::from_utf8_lossy(name).into_owned(),
|
||||
object: resource.as_ref(),
|
||||
is_form,
|
||||
width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
|
||||
height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
|
||||
stream: si,
|
||||
op: i,
|
||||
draw_matrix: ctm.0,
|
||||
clips: state.placement.clips.clone(),
|
||||
});
|
||||
let matrix = match s.dict.get(b"Matrix") {
|
||||
Some(v) => {
|
||||
let v = doc.resolve(v);
|
||||
let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue };
|
||||
m
|
||||
}
|
||||
None => Matrix::IDENTITY,
|
||||
};
|
||||
(matrix.then(&ctm), bounds)
|
||||
} else if s.dict.name(b"Subtype") == Some(b"Image") {
|
||||
(ctm, [0.0, 0.0, 1.0, 1.0])
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let rect = matrix.bbox(bounds);
|
||||
if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite())
|
||||
|| bounds[2] <= bounds[0]
|
||||
|| bounds[3] <= bounds[1]
|
||||
|| rect[2] <= rect[0]
|
||||
|| rect[3] <= rect[1]
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.push(PageImage {
|
||||
rect,
|
||||
visible_rect: intersection(rect, state.placement.clip),
|
||||
clip_rect: Some(state.placement.clip),
|
||||
matrix: matrix.0,
|
||||
name: String::from_utf8_lossy(name).into_owned(),
|
||||
object: resource.as_ref(),
|
||||
is_form,
|
||||
width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
|
||||
height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
|
||||
stream: pieces.piece_of(op.span.start),
|
||||
op: i,
|
||||
draw_matrix: ctm.0,
|
||||
clips: state.placement.clips.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
@@ -294,24 +300,24 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
|
||||
/// The forms being read (cycle guard) and how many were entered (the page's budget).
|
||||
struct Forms {
|
||||
path: Vec<ObjRef>,
|
||||
visits: usize,
|
||||
budget: crate::text::FormBudget,
|
||||
}
|
||||
fn walk(
|
||||
doc: &Document,
|
||||
data: &[u8],
|
||||
ops: &[Op],
|
||||
resources: &Dict,
|
||||
st: &mut ImageState,
|
||||
stream: usize,
|
||||
stream: &dyn Fn(&Op) -> usize,
|
||||
forms: &mut Forms,
|
||||
out: &mut Vec<PageImage>,
|
||||
) -> Result<(), EditError> {
|
||||
let xo = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
for (i, op) in parse(data).ops.iter().enumerate() {
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
st.apply(op)?;
|
||||
let ctm = st.placement.matrix;
|
||||
if op.is("Do") {
|
||||
let Some(name) = op.name(0) else { continue };
|
||||
if let Some(form) = crate::text::form_call(doc, resources, name, ctm, &forms.path, &mut forms.visits) {
|
||||
if let Some(form) = crate::text::form_call(doc, resources, name, ctm, &forms.path, &mut forms.budget) {
|
||||
if let Some(r) = form.obj {
|
||||
forms.path.push(r);
|
||||
}
|
||||
@@ -325,7 +331,8 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
|
||||
);
|
||||
let clip = bounds.map_or(st.placement.clip, |b| intersection(st.placement.clip, b).unwrap_or([0.0; 4]));
|
||||
let mut inner = ImageState::new(form.ctm, clip, st.placement.clips.clone());
|
||||
walk(doc, &form.data, &form.resources, &mut inner, stream, forms, out)?;
|
||||
let piece = stream(op);
|
||||
walk(doc, &parse(&form.data).ops, &form.resources, &mut inner, &|_| piece, forms, out)?;
|
||||
if form.obj.is_some() {
|
||||
forms.path.pop();
|
||||
}
|
||||
@@ -347,7 +354,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
|
||||
is_form: false,
|
||||
width: s.dict.int(b"Width").unwrap_or(0).max(0) as u32,
|
||||
height: s.dict.int(b"Height").unwrap_or(0).max(0) as u32,
|
||||
stream,
|
||||
stream: stream(op),
|
||||
op: i,
|
||||
draw_matrix: ctm.0,
|
||||
clips: st.placement.clips.clone(),
|
||||
@@ -360,10 +367,9 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
|
||||
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let mut out = Vec::new();
|
||||
let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
|
||||
let mut forms = Forms { path: Vec::new(), visits: 0 };
|
||||
for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() {
|
||||
walk(doc, &data, &res, &mut st, si, &mut forms, &mut out)?;
|
||||
}
|
||||
let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::default() };
|
||||
let pieces = joined(&streams(doc, &p.dict));
|
||||
walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -450,9 +456,9 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
|
||||
let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?;
|
||||
let p = page_of(doc, page)?;
|
||||
let all = streams(doc, &p.dict);
|
||||
let (obj, data) = all.get(img.stream).cloned().ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?;
|
||||
let mut ops = parse(&data).ops;
|
||||
if ops.get(img.op).is_none() {
|
||||
let pieces = joined(&all);
|
||||
let ops = pieces.parse().ops;
|
||||
if !ops.get(img.op).is_some_and(|op| op.is("Do")) {
|
||||
return Err(EditError::Invalid("the image operation is missing".into()));
|
||||
}
|
||||
let mut resources = None;
|
||||
@@ -479,16 +485,10 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
|
||||
vec![Op::new("Do", vec![Object::name(&name)])]
|
||||
}
|
||||
};
|
||||
ops.splice(img.op..=img.op, replacement);
|
||||
let mut dict = match &*doc.resolve(&obj) {
|
||||
Object::Stream(s) => s.dict.clone(),
|
||||
_ => Dict::new(),
|
||||
};
|
||||
dict.remove(b"Length");
|
||||
let new = doc.add(Object::Stream(Stream::flate(dict, &serialize_ops(&ops))));
|
||||
let contents: Vec<Object> = all.iter().enumerate().map(|(i, (o, _))| if i == img.stream { Object::Ref(new) } else { o.clone() }).collect();
|
||||
let changed = crate::text::splice(&pieces, &ops, |index| if index == img.op { (replacement.clone(), false) } else { (Vec::new(), true) });
|
||||
let contents = crate::text::rewritten_contents(doc, &all, changed);
|
||||
doc.update_dict(p.obj, |d| {
|
||||
d.set(b"Contents".to_vec(), if let [one] = contents.as_slice() { one.clone() } else { Object::Array(contents) });
|
||||
d.set(b"Contents".to_vec(), contents);
|
||||
if let Some(res) = resources {
|
||||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
}
|
||||
@@ -599,22 +599,12 @@ fn transform_image(
|
||||
if !transform.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
||||
return Err(EditError::Invalid("the image transform must be finite and no larger than 1e12".into()));
|
||||
}
|
||||
let mut offset = 0_usize;
|
||||
for (_, data) in streams.iter().take(image.stream) {
|
||||
offset = offset
|
||||
.checked_add(data.len())
|
||||
.and_then(|n| n.checked_add(1))
|
||||
.ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
|
||||
}
|
||||
let data = streams.get(image.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?;
|
||||
let op = parse(&data.1).ops.get(image.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
|
||||
let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
|
||||
let pieces = pdfcraft_content::Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::<Vec<_>>());
|
||||
let pieces = joined(streams);
|
||||
let ops = pieces.parse().ops;
|
||||
let target = ops
|
||||
.iter()
|
||||
.position(|op| op.span.start == span && op.is("Do"))
|
||||
.ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?;
|
||||
let target = image.op;
|
||||
if !ops.get(target).is_some_and(|op| op.is("Do")) {
|
||||
return Err(EditError::Invalid("the image operation could not be located".into()));
|
||||
}
|
||||
// A private path crop moves with its image regardless of intervening state operators.
|
||||
// Shared crops retain the existing fixed-clip behavior, preserving neighbouring paint.
|
||||
let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix)));
|
||||
@@ -677,22 +667,12 @@ fn crop_image(
|
||||
if x1 - x0 < 1e-6 || y1 - y0 < 1e-6 {
|
||||
return Err(EditError::Invalid("the crop box has no area".into()));
|
||||
}
|
||||
let mut offset = 0_usize;
|
||||
for (_, data) in streams.iter().take(image.stream) {
|
||||
offset = offset
|
||||
.checked_add(data.len())
|
||||
.and_then(|n| n.checked_add(1))
|
||||
.ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
|
||||
}
|
||||
let data = streams.get(image.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?;
|
||||
let op = parse(&data.1).ops.get(image.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
|
||||
let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
|
||||
let pieces = pdfcraft_content::Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::<Vec<_>>());
|
||||
let pieces = joined(streams);
|
||||
let ops = pieces.parse().ops;
|
||||
let target = ops
|
||||
.iter()
|
||||
.position(|op| op.span.start == span && op.is("Do"))
|
||||
.ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?;
|
||||
let target = image.op;
|
||||
if !ops.get(target).is_some_and(|op| op.is("Do")) {
|
||||
return Err(EditError::Invalid("the image operation could not be located".into()));
|
||||
}
|
||||
let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix)));
|
||||
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
|
||||
let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| inverse.apply(x, y));
|
||||
@@ -758,67 +738,72 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
|
||||
if !movement.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
||||
return Err(EditError::Invalid("the image move is too large".into()));
|
||||
}
|
||||
plans.insert((image.stream, image.op), (movement, placement, offset));
|
||||
plans.insert(image.op, (movement, placement, offset));
|
||||
}
|
||||
let mut data = Vec::with_capacity(all.len());
|
||||
let pieces = joined(&all);
|
||||
let ops = pieces.parse().ops;
|
||||
let mut inside_text = false;
|
||||
for (stream, (_, bytes)) in all.iter().enumerate() {
|
||||
let pieces = pdfcraft_content::Pieces::join(&[bytes.as_slice()]);
|
||||
let ops = pieces.parse().ops;
|
||||
for (index, op) in ops.iter().enumerate() {
|
||||
if op.is("BT") {
|
||||
inside_text = true;
|
||||
} else if op.is("ET") {
|
||||
inside_text = false;
|
||||
} else if inside_text && plans.contains_key(&(stream, index)) {
|
||||
return Err(EditError::Invalid("an image inside a text object cannot be moved in a group".into()));
|
||||
}
|
||||
for (index, op) in ops.iter().enumerate() {
|
||||
if op.is("BT") {
|
||||
inside_text = true;
|
||||
} else if op.is("ET") {
|
||||
inside_text = false;
|
||||
} else if inside_text && plans.contains_key(&index) {
|
||||
return Err(EditError::Invalid("an image inside a text object cannot be moved in a group".into()));
|
||||
}
|
||||
// Images alone in their own `q re W n cm Do Q` take the clip with them.
|
||||
let mut clips = std::collections::BTreeMap::new();
|
||||
for index in 0..ops.len() {
|
||||
let Some(&(_, placement, offset)) = plans.get(&(stream, index)) else { continue };
|
||||
let Some((at, cm)) = exclusive_clip(&ops, index) else { continue };
|
||||
let translation = Matrix([1.0, 0.0, 0.0, 1.0, offset[0], offset[1]]);
|
||||
let Some(c) = clip_matrix(placement, cm, translation).filter(|c| c.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12)) else {
|
||||
return Err(EditError::Invalid("the image move is too large".into()));
|
||||
};
|
||||
plans.remove(&(stream, index));
|
||||
clips.insert((stream, at), c);
|
||||
}
|
||||
// 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();
|
||||
for index in 0..ops.len() {
|
||||
let Some(&(movement, _, _)) = plans.get(&(stream, index)) else { continue };
|
||||
let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue };
|
||||
let folded = movement.then(&cm);
|
||||
if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
||||
return Err(EditError::Invalid("the image move is too large".into()));
|
||||
}
|
||||
plans.remove(&(stream, index));
|
||||
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(m) = folds.remove(&index) {
|
||||
return (vec![Op::new("cm", m.0.map(Object::Real).to_vec())], false);
|
||||
}
|
||||
let Some((matrix, _, _)) = plans.remove(&(stream, index)) else { return (Vec::new(), true) };
|
||||
let original = ops.get(index).cloned().into_iter();
|
||||
// Local translations can be tiny under a large CTM. Preserve their precision
|
||||
// rather than rounding them to zero or amplifying an error on the page.
|
||||
let mut out = vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())];
|
||||
out.extend(original);
|
||||
out.push(Op::new("Q", vec![]));
|
||||
(out, false)
|
||||
});
|
||||
data.push(changed.into_iter().next().unwrap_or_default());
|
||||
}
|
||||
if !plans.is_empty() {
|
||||
// Images alone in their own `q re W n cm Do Q` take the clip with them.
|
||||
let mut clips = std::collections::BTreeMap::new();
|
||||
for index in 0..ops.len() {
|
||||
let Some(&(_, placement, offset)) = plans.get(&index) else { continue };
|
||||
let Some((at, cm)) = exclusive_clip(&ops, index) else { continue };
|
||||
let translation = Matrix([1.0, 0.0, 0.0, 1.0, offset[0], offset[1]]);
|
||||
let Some(c) = clip_matrix(placement, cm, translation).filter(|c| c.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12)) else {
|
||||
return Err(EditError::Invalid("the image move is too large".into()));
|
||||
};
|
||||
plans.remove(&index);
|
||||
// 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(i, (m, false)),
|
||||
None => clips.insert(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();
|
||||
for index in 0..ops.len() {
|
||||
let Some(&(movement, _, _)) = plans.get(&index) else { continue };
|
||||
let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue };
|
||||
let folded = movement.then(&cm);
|
||||
if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
||||
return Err(EditError::Invalid("the image move is too large".into()));
|
||||
}
|
||||
plans.remove(&index);
|
||||
folds.insert(at, folded);
|
||||
}
|
||||
if plans.keys().any(|index| !ops.get(*index).is_some_and(|op| op.is("Do"))) {
|
||||
return Err(EditError::Invalid("a selected image operation is missing".into()));
|
||||
}
|
||||
let data = crate::text::splice(&pieces, &ops, |index| {
|
||||
if let Some((c, keep)) = clips.remove(&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);
|
||||
}
|
||||
let Some((matrix, _, _)) = plans.remove(&index) else { return (Vec::new(), true) };
|
||||
let original = ops.get(index).cloned().into_iter();
|
||||
// Local translations can be tiny under a large CTM. Preserve their precision
|
||||
// rather than rounding them to zero or amplifying an error on the page.
|
||||
let mut out = vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())];
|
||||
out.extend(original);
|
||||
out.push(Op::new("Q", vec![]));
|
||||
(out, false)
|
||||
});
|
||||
let contents = crate::text::rewritten_contents(doc, &all, data);
|
||||
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
|
||||
Ok(())
|
||||
|
||||
+225
-20
@@ -8,7 +8,8 @@
|
||||
//! `/PCMark` entry (also in the stream dictionary) names the kind of mark, which is how Update and
|
||||
//! Remove find them again without decompressing page content. When
|
||||
//! anything is drawn on top, the page's original content is wrapped in `q … Q` (two tiny streams)
|
||||
//! so its graphics state can't leak into the mark.
|
||||
//! so its graphics state cannot reach the mark. The closing stream also ends known unfinished
|
||||
//! marked-content sequences before the new mark.
|
||||
//!
|
||||
//! Text uses standard Helvetica (WinAnsi) added to the page resources as `/PCHelv`.
|
||||
|
||||
@@ -327,7 +328,41 @@ fn add_resources(doc: &mut Document, page: &pdfcraft_model::Page, opacity: Optio
|
||||
}
|
||||
|
||||
const WRAP_OPEN: &[u8] = b"q %PdfCraft\n";
|
||||
const WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n";
|
||||
/// Starts with an end of line, so a comment left open at the end of the content before it
|
||||
/// can't swallow the `Q`. Older files have the close without it.
|
||||
const WRAP_CLOSE: &[u8] = b"\nQ %PdfCraft\n";
|
||||
const OLD_WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n";
|
||||
|
||||
/// Whether `bytes` are a wrapper's close: canonical `EMC` closures, then the existing
|
||||
/// graphics closures. Legacy closes without the leading newline remain valid on their own.
|
||||
fn is_wrap_close(bytes: &[u8]) -> bool {
|
||||
let mut rest = bytes;
|
||||
while let Some(shorter) = rest.strip_prefix(b"\nEMC".as_slice()) {
|
||||
rest = shorter;
|
||||
}
|
||||
let legacy = rest.len() == bytes.len();
|
||||
loop {
|
||||
if rest == WRAP_CLOSE || (legacy && rest == OLD_WRAP_CLOSE) {
|
||||
return true;
|
||||
}
|
||||
match rest.strip_prefix(b"\nQ".as_slice()) {
|
||||
Some(shorter) => rest = shorter,
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Wrap`-tagged stream's role from its exact bytes: `Some(true)` for an open, `Some(false)`
|
||||
/// for a close.
|
||||
fn wrapper_role(doc: &Document, o: &Object) -> Option<bool> {
|
||||
if stream_tag(doc, o).as_deref() != Some("Wrap") {
|
||||
return None;
|
||||
}
|
||||
let Object::Stream(stream) = &*doc.resolve(o) else { return None };
|
||||
// Wrappers are a few bytes; one that decodes to more isn't PdfCraft's.
|
||||
let bytes = stream.decoded_within(64 * 1024).ok()?;
|
||||
if bytes == WRAP_OPEN { Some(true) } else { is_wrap_close(&bytes).then_some(false) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn stream_bytes(doc: &Document, o: &Object) -> Option<Vec<u8>> {
|
||||
@@ -350,27 +385,165 @@ fn tagged(tag: &str) -> Dict {
|
||||
d
|
||||
}
|
||||
|
||||
/// What placing a mark on top needs, from the page's actual operators (not the `/PCMark` tags,
|
||||
/// which another writer may keep, copy or forge).
|
||||
enum Isolation {
|
||||
/// The content parses cleanly with balanced `q`/`Q` and closed text objects and paths.
|
||||
/// `suffix`: the stream from which the content leaves graphics state set (or a comment open)
|
||||
/// that a mark on top must not inherit, so it needs a wrapper from there to the end.
|
||||
/// `wrappers`: PdfCraft wrapper pairs (open, close) as stream indexes.
|
||||
/// `marked`: unfinished marked-content sequences to close before a new mark.
|
||||
Clean { suffix: Option<usize>, wrappers: Vec<(usize, usize)>, marked: usize },
|
||||
/// The content could not be read that precisely (it doesn't decode, is too large, or has
|
||||
/// stray bytes, unbalanced `q`/`Q` or an unfinished text object or path). `unclosed` is
|
||||
/// the count of `q` left open at the end, when known. `marked` counts known unfinished
|
||||
/// marked-content sequences independently of graphics malformation, or zero when unknown.
|
||||
Unknown { unclosed: usize, marked: usize },
|
||||
}
|
||||
|
||||
const ISOLATION_MAX_STREAMS: usize = 100_000;
|
||||
const ISOLATION_MAX_BYTES: usize = 128 * 1024 * 1024;
|
||||
const ISOLATION_MAX_OPS: usize = 1_000_000;
|
||||
const ISOLATION_MAX_DEPTH: usize = 10_000;
|
||||
|
||||
fn isolation(doc: &Document, list: &[Object]) -> Isolation {
|
||||
let unknown = Isolation::Unknown { unclosed: 0, marked: 0 };
|
||||
if list.len() > ISOLATION_MAX_STREAMS {
|
||||
return unknown;
|
||||
}
|
||||
let mut remaining = ISOLATION_MAX_BYTES;
|
||||
let mut data = Vec::with_capacity(list.len());
|
||||
for object in list {
|
||||
let resolved = doc.resolve(object);
|
||||
let Object::Stream(stream) = &*resolved else { return unknown };
|
||||
let Ok(bytes) = stream.decoded_within(remaining) else { return unknown };
|
||||
let Some(left) = remaining.checked_sub(bytes.len().saturating_add(1)) else { return unknown };
|
||||
remaining = left;
|
||||
data.push(bytes);
|
||||
}
|
||||
let pieces = pdfcraft_content::Pieces::join(&data);
|
||||
let parsed = pieces.parse();
|
||||
if parsed.skipped != 0 || parsed.ops.len() > ISOLATION_MAX_OPS {
|
||||
return unknown;
|
||||
}
|
||||
let edges = pieces.comment_edges(&parsed);
|
||||
let (mut text_open, mut path_open, mut dirty, mut malformed) = (false, false, false, false);
|
||||
let mut stack: Vec<usize> = Vec::new();
|
||||
let (mut marked, mut marked_known) = (0usize, true);
|
||||
let mut split = 0;
|
||||
let mut previous_last = None;
|
||||
let mut wrappers = Vec::new();
|
||||
for op in &parsed.ops {
|
||||
let (first, last) = pieces.pieces_of(op);
|
||||
// A wrapper can only go in before a whole stream, outside graphics and marked-content
|
||||
// scopes, between operators, where no comment would swallow the wrapper's `q`.
|
||||
if stack.is_empty()
|
||||
&& marked_known
|
||||
&& marked == 0
|
||||
&& !text_open
|
||||
&& !path_open
|
||||
&& !dirty
|
||||
&& previous_last.is_none_or(|previous| first > previous)
|
||||
&& !edges.get(first).is_some_and(|edge| edge.0)
|
||||
{
|
||||
split = first;
|
||||
}
|
||||
previous_last = Some(last);
|
||||
// Match the reader's tolerant operands: BMC uses the last name, BDC the last
|
||||
// name/object pair, and EMC ignores surplus operands. Invalid begins are ignored.
|
||||
let begin_marked = match op.op.as_slice() {
|
||||
b"BMC" => op.operands.last().is_some_and(|o| matches!(o, Object::Name(_))),
|
||||
b"BDC" => op.operands.len().checked_sub(2).and_then(|i| op.operands.get(i)).is_some_and(|o| matches!(o, Object::Name(_))),
|
||||
_ => false,
|
||||
};
|
||||
if marked_known {
|
||||
if begin_marked {
|
||||
if marked < ISOLATION_MAX_DEPTH {
|
||||
marked += 1;
|
||||
} else {
|
||||
// Once the budget is exceeded, later EMCs cannot restore a known depth.
|
||||
marked_known = false;
|
||||
marked = 0;
|
||||
}
|
||||
} else if op.is("EMC") {
|
||||
marked = marked.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
match op.op.as_slice() {
|
||||
b"BT" if !text_open => text_open = true,
|
||||
b"ET" if text_open => text_open = false,
|
||||
b"BT" | b"ET" => malformed = true,
|
||||
b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*" => path_open = true,
|
||||
b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => path_open = false,
|
||||
_ => {}
|
||||
}
|
||||
match op.op.as_slice() {
|
||||
b"q" if op.operands.is_empty() && stack.len() < ISOLATION_MAX_DEPTH => stack.push(first),
|
||||
b"Q" if op.operands.is_empty() => match stack.pop() {
|
||||
Some(open) => {
|
||||
// Only a standalone PdfCraft wrapper pair may later be removed again.
|
||||
let wrap = |i: usize| list.get(i).is_some_and(|o| stream_tag(doc, o).as_deref() == Some("Wrap"));
|
||||
if wrap(open)
|
||||
&& wrap(first)
|
||||
&& data.get(open).is_some_and(|d| d == WRAP_OPEN)
|
||||
&& data.get(first).is_some_and(|d| is_wrap_close(d))
|
||||
{
|
||||
wrappers.push((open, first));
|
||||
}
|
||||
}
|
||||
None => malformed = true,
|
||||
},
|
||||
b"q" | b"Q" => malformed = true,
|
||||
_ if stack.is_empty() => dirty = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if malformed || text_open || path_open || !stack.is_empty() || !marked_known {
|
||||
return Isolation::Unknown { unclosed: if malformed { 0 } else { stack.len() }, marked };
|
||||
}
|
||||
let comment_open = edges.last().is_some_and(|edge| edge.1);
|
||||
Isolation::Clean { suffix: (dirty || comment_open || marked != 0).then_some(split), wrappers, marked }
|
||||
}
|
||||
|
||||
/// Put a mark's content on a page: behind (prepended) or on top (appended, after wrapping the
|
||||
/// original content in q/Q).
|
||||
fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec<u8>, behind: bool) -> Result<(), EditError> {
|
||||
place_tagged(doc, page, kind.tag(), content, behind)
|
||||
}
|
||||
|
||||
/// Put content on a page, tagged `tag` (see [`place`]).
|
||||
/// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the
|
||||
/// content before leaves set (a `cm`, a clip, text state…) must not reach the mark, including
|
||||
/// content appended after an earlier PdfCraft wrapper by another tool (#3084): the stretch of
|
||||
/// content that leaves state set is wrapped in q/Q, once. Any known unfinished marked-content
|
||||
/// sequences are closed at the end too, so hidden optional content cannot hide the new mark.
|
||||
fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec<u8>, behind: bool) -> Result<(), EditError> {
|
||||
let mut list = contents(doc, page)?;
|
||||
let wrap = if behind {
|
||||
None
|
||||
} else {
|
||||
match isolation(doc, &list) {
|
||||
Isolation::Clean { suffix, marked, .. } => suffix.map(|split| (split, 0, marked)),
|
||||
// Content that can't be read precisely is wrapped whole, as before: after the
|
||||
// behind-marks at the start. A `q` it leaves open is closed at its end, where
|
||||
// closing it changes nothing already drawn.
|
||||
Isolation::Unknown { unclosed, marked } => {
|
||||
let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()];
|
||||
let split = list.iter().position(|o| !stream_tag(doc, o).is_some_and(|t| behind_kinds.contains(&t.as_str()))).unwrap_or(list.len());
|
||||
(split < list.len()).then_some((split, unclosed, marked))
|
||||
}
|
||||
}
|
||||
};
|
||||
let mark = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content))));
|
||||
if behind {
|
||||
list.insert(0, mark);
|
||||
} else {
|
||||
let wrapped = list.iter().any(|o| stream_tag(doc, o).as_deref() == Some("Wrap"));
|
||||
let original = list.iter().any(|o| stream_tag(doc, o).is_none());
|
||||
if !wrapped && original {
|
||||
if let Some((split, unclosed, marked)) = wrap {
|
||||
let mut close_bytes = b"\nEMC".repeat(marked);
|
||||
close_bytes.extend_from_slice(&b"\nQ".repeat(unclosed));
|
||||
close_bytes.extend_from_slice(WRAP_CLOSE);
|
||||
let open = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_OPEN.to_vec()))));
|
||||
let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_CLOSE.to_vec()))));
|
||||
// Behind-marks stay outside the wrapper.
|
||||
let split = list.iter().position(|o| stream_tag(doc, o).is_none()).unwrap_or(list.len());
|
||||
list.insert(split, open);
|
||||
let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), close_bytes))));
|
||||
list.insert(split.min(list.len()), open);
|
||||
list.push(close);
|
||||
}
|
||||
list.push(mark);
|
||||
@@ -613,8 +786,9 @@ pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, repl
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove marks of `kind` from `pages`; returns how many were removed. When no mark is left on
|
||||
/// top of a page, the q/Q wrapper goes too, restoring the original content list.
|
||||
/// Remove marks of `kind` from `pages`; returns how many were removed. Wrappers left with
|
||||
/// nothing after them go too, restoring the original content list; a wrapper that content
|
||||
/// after it still relies on (another tool's appended stream) stays.
|
||||
pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Result<usize, EditError> {
|
||||
let all = page_list(doc);
|
||||
check(pages, all.len())?;
|
||||
@@ -625,12 +799,41 @@ pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Resu
|
||||
let before = list.len();
|
||||
let mut kept: Vec<Object> = list.into_iter().filter(|o| stream_tag(doc, o).as_deref() != Some(kind.tag())).collect();
|
||||
removed += before - kept.len();
|
||||
// Marks on top come after the closing wrapper; without any, the wrapper has no purpose.
|
||||
let is_wrap = |o: &Object| stream_tag(doc, o).as_deref() == Some("Wrap");
|
||||
let close = kept.iter().rposition(&is_wrap);
|
||||
let marks_on_top = close.is_some_and(|c| kept[c + 1..].iter().any(|o| stream_tag(doc, o).is_some_and(|t| t != "Wrap")));
|
||||
if !marks_on_top {
|
||||
kept.retain(|o| !is_wrap(o));
|
||||
// Strip only wrapper pairs whose close ends the list (after stripping the pairs after
|
||||
// it): nothing comes after them to need the state they restore. Pairs come from the
|
||||
// operators; for content that can't be read that precisely, from the wrapper streams
|
||||
// alone, so updating a mark there doesn't nest another wrapper each time.
|
||||
if before != kept.len() {
|
||||
let wrappers = match isolation(doc, &kept) {
|
||||
Isolation::Clean { wrappers, .. } => wrappers,
|
||||
Isolation::Unknown { .. } => {
|
||||
let mut opens = Vec::new();
|
||||
let mut pairs = Vec::new();
|
||||
for (i, o) in kept.iter().enumerate() {
|
||||
match wrapper_role(doc, o) {
|
||||
Some(true) => opens.push(i),
|
||||
Some(false) => pairs.extend(opens.pop().map(|open| (open, i))),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
pairs
|
||||
}
|
||||
};
|
||||
let pairs: std::collections::HashMap<usize, usize> = wrappers.into_iter().map(|(open, close)| (close, open)).collect();
|
||||
let mut discarded = std::collections::HashSet::new();
|
||||
let mut end = kept.len();
|
||||
while let Some(close) = end.checked_sub(1) {
|
||||
if discarded.contains(&close) {
|
||||
end = close;
|
||||
} else if let Some(&open) = pairs.get(&close) {
|
||||
discarded.insert(open);
|
||||
discarded.insert(close);
|
||||
end = close;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
kept = kept.into_iter().enumerate().filter(|(i, _)| !discarded.contains(i)).map(|(_, o)| o).collect();
|
||||
}
|
||||
if before != kept.len() {
|
||||
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept)))?;
|
||||
@@ -656,8 +859,8 @@ pub mod indic;
|
||||
mod linebreak;
|
||||
pub mod text;
|
||||
pub use text::{
|
||||
BlockStyle, LineEdit, RangeStyle, TextBlock, TextLine, clear_block, reading_blocks, replace_block, replace_line, rewrite_block, text_blocks,
|
||||
text_lines,
|
||||
BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, clear_block, reading_blocks,
|
||||
reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines,
|
||||
};
|
||||
pub mod added;
|
||||
pub use added::{
|
||||
@@ -665,5 +868,7 @@ pub use added::{
|
||||
update_content,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod marked_isolation_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,122 @@
|
||||
use super::*;
|
||||
|
||||
fn fixture(parts: &[&[u8]]) -> Document {
|
||||
let mut doc = Document::new_empty();
|
||||
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
|
||||
let streams = parts.iter().map(|data| Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), data.to_vec()))))).collect();
|
||||
let mut page = Dict::new();
|
||||
page.set(b"Type".to_vec(), Object::name("Page"));
|
||||
page.set(b"Parent".to_vec(), Object::Ref(pages));
|
||||
page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));
|
||||
page.set(b"Contents".to_vec(), Object::Array(streams));
|
||||
let page = doc.add(page);
|
||||
doc.update_dict(pages, |d| {
|
||||
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
|
||||
d.set(b"Count".to_vec(), Object::Int(1));
|
||||
})
|
||||
.unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
fn placed(parts: &[&[u8]]) -> (Document, Vec<Object>) {
|
||||
let mut doc = fixture(parts);
|
||||
stamp(&mut doc, 0, "Probe", b"q Q\n".to_vec()).unwrap();
|
||||
let page = page_list(&doc).remove(0);
|
||||
let list = contents(&mut doc, &page).unwrap();
|
||||
(doc, list)
|
||||
}
|
||||
|
||||
fn close(parts: &[&[u8]]) -> Vec<u8> {
|
||||
let (doc, list) = placed(parts);
|
||||
let object = &list[list.len() - 2];
|
||||
assert_eq!(stream_tag(&doc, object).as_deref(), Some("Wrap"));
|
||||
let bytes = stream_bytes(&doc, object).unwrap();
|
||||
assert_eq!(wrapper_role(&doc, object), Some(false));
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closes_only_marked_operators_not_strings_names_comments_or_image_samples() {
|
||||
for tail in
|
||||
[b"/Span << /Text (BMC BDC EMC) >> DP".as_slice(), b"/BMC /EMC DP", b"% BMC BDC EMC", b"BI /W 11 /H 1 /BPC 8 /CS /G ID BMC BDC EMC EI\n"]
|
||||
{
|
||||
let mut input = b"/OC /L1 BDC\n".to_vec();
|
||||
input.extend_from_slice(tail);
|
||||
assert_eq!(close(&[&input]), b"\nEMC\nQ %PdfCraft\n", "{tail:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_suffix_stays_outside_marked_content_across_balanced_graphics() {
|
||||
let (doc, list) = placed(&[b"q /OC /L1 BDC Q", b"50 Tz"]);
|
||||
assert_eq!(stream_bytes(&doc, &list[0]).unwrap(), WRAP_OPEN);
|
||||
assert_eq!(stream_bytes(&doc, &list[1]).unwrap(), b"q /OC /L1 BDC Q");
|
||||
assert_eq!(stream_bytes(&doc, &list[list.len() - 2]).unwrap(), b"\nEMC\nQ %PdfCraft\n");
|
||||
assert_eq!(close(&[b"q /OC /L1 BDC Q"]), b"\nEMC\nQ %PdfCraft\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_marked_depth_survives_unknown_graphics_and_tolerant_operands() {
|
||||
for (input, expected) in [
|
||||
(b"/OC /L1 BDC q".as_slice(), b"\nEMC\nQ\nQ %PdfCraft\n".as_slice()),
|
||||
(b"Q /OC /L1 BDC", b"\nEMC\nQ %PdfCraft\n"),
|
||||
(b"/OC /L1 BDC BT", b"\nEMC\nQ %PdfCraft\n"),
|
||||
(b"/OC /L1 BDC 0 0 m", b"\nEMC\nQ %PdfCraft\n"),
|
||||
(b"/OC /L1 BDC 123 BMC /Artifact BDC", b"\nEMC\nQ %PdfCraft\n"),
|
||||
(b"EMC 123 /OC /L1 BDC 123 /Artifact BMC", b"\nEMC\nEMC\nQ %PdfCraft\n"),
|
||||
(b"/OC /L1 BDC 123 EMC", b"\nQ %PdfCraft\n"),
|
||||
(b"/Span 123 BDC", b"\nEMC\nQ %PdfCraft\n"),
|
||||
] {
|
||||
assert_eq!(close(&[input]), expected, "{input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closes_cross_stream_begins_without_closing_balanced_sequences_twice() {
|
||||
assert_eq!(close(&[b"/OC", b"/L1 BDC"]), b"\nEMC\nQ %PdfCraft\n");
|
||||
assert_eq!(close(&[b"/OC /L1 BDC", b"EMC"]), WRAP_CLOSE);
|
||||
assert_eq!(close(&[b"/OC /L1 BDC % EMC", b"EMC\n"]), b"\nEMC\nQ %PdfCraft\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_only_canonical_new_closes_and_retains_legacy_closes() {
|
||||
for bytes in [WRAP_CLOSE, OLD_WRAP_CLOSE, b"\nQ\nQ %PdfCraft\n", b"\nEMC\nQ %PdfCraft\n", b"\nEMC\nEMC\nQ\nQ %PdfCraft\n"] {
|
||||
assert!(is_wrap_close(bytes), "{bytes:?}");
|
||||
}
|
||||
for bytes in [
|
||||
b"\nEMCQ %PdfCraft\n".as_slice(),
|
||||
b"\nEMC\nQQ %PdfCraft\n",
|
||||
b"\nQ\nEMC\nQ %PdfCraft\n",
|
||||
b"EMC\nQ %PdfCraft\n",
|
||||
b"\nEMC 1 w\nQ %PdfCraft\n",
|
||||
b"\nEMC\nQ %PdfCraft\nq Q",
|
||||
] {
|
||||
assert!(!is_wrap_close(bytes), "{bytes:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_budget_bounds_emitted_closures_and_overflow_stays_unknown() {
|
||||
let maximum = b"/Artifact BMC\n".repeat(ISOLATION_MAX_DEPTH);
|
||||
let mut expected = b"\nEMC".repeat(ISOLATION_MAX_DEPTH);
|
||||
expected.extend_from_slice(WRAP_CLOSE);
|
||||
assert_eq!(close(&[&maximum]), expected);
|
||||
let mut combined = maximum.clone();
|
||||
combined.extend_from_slice(&b"q\n".repeat(ISOLATION_MAX_DEPTH));
|
||||
let mut expected = b"\nEMC".repeat(ISOLATION_MAX_DEPTH);
|
||||
expected.extend_from_slice(&b"\nQ".repeat(ISOLATION_MAX_DEPTH));
|
||||
expected.extend_from_slice(WRAP_CLOSE);
|
||||
assert_eq!(close(&[&combined]), expected);
|
||||
assert!(expected.len() < 64 * 1024, "maximum generated close remains recognizable for removal");
|
||||
let mut doc = fixture(&[&combined]);
|
||||
let page = page_list(&doc).remove(0);
|
||||
let original = contents(&mut doc, &page).unwrap();
|
||||
place_tagged(&mut doc, &page, MarkKind::HeaderFooter.tag(), b"q Q\n".to_vec(), false).unwrap();
|
||||
assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1);
|
||||
assert_eq!(contents(&mut doc, &page).unwrap(), original, "maximum combined close can be removed without nesting wrappers");
|
||||
for suffix in [b"/Artifact BMC\n".as_slice(), b"/Artifact BMC\nEMC\n", b"/Artifact BMC\nEMC\nEMC\n"] {
|
||||
let mut over = maximum.clone();
|
||||
over.extend_from_slice(suffix);
|
||||
assert_eq!(close(&[&over]), WRAP_CLOSE, "overflow cannot resume speculative depth tracking after EMC");
|
||||
}
|
||||
}
|
||||
+970
-1
File diff suppressed because it is too large.
Load diff
+289
-62
@@ -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<String>,
|
||||
/// 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,
|
||||
@@ -64,6 +68,10 @@ pub struct TextLine {
|
||||
/// Some of the line's text is `/ActualText`, which is already in logical order: visual-order
|
||||
/// Indic recovery would scramble it.
|
||||
actual_text: bool,
|
||||
/// Whether any of the line's runs draws in a clipping mode (`4 Tr` to `7 Tr`). The join below
|
||||
/// does not require one rendering mode, so a line that starts as ink can continue as a mask;
|
||||
/// [`refuse_clipping_text`] reads this rather than the origin's mode alone.
|
||||
clips: bool,
|
||||
}
|
||||
|
||||
impl TextLine {
|
||||
@@ -470,12 +478,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 +496,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 {
|
||||
@@ -631,6 +639,7 @@ struct Shown {
|
||||
glyph_count: usize,
|
||||
/// Advance in text space, including spacing and TJ adjustments.
|
||||
advance: f64,
|
||||
tracking: f64,
|
||||
/// The text matrix where it starts (text space), and the state there and at its `BT`.
|
||||
tm: Matrix,
|
||||
tlm: Matrix,
|
||||
@@ -639,6 +648,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,
|
||||
@@ -696,6 +707,77 @@ impl Pen {
|
||||
}
|
||||
}
|
||||
|
||||
/// Use the lower third of letter gaps, not word gaps. A pair of isolated labels is not
|
||||
/// evidence of tracking; unadjusted neighbours inside a string contribute zero too.
|
||||
/// The tracking a run of glyphs was set with: the lower third of its gaps, when that is at
|
||||
/// most half an em (`limit`). A wider "tracking" is word or cell spacing between one-glyph
|
||||
/// words (`a b c`, a column of digits or Y/N ticks), which must keep its spaces.
|
||||
fn typical_tracking(gaps: &mut [f64], limit: f64) -> f64 {
|
||||
if gaps.len() < 3 {
|
||||
return 0.0;
|
||||
}
|
||||
let at = gaps.len() / 3;
|
||||
gaps.select_nth_unstable_by(at, f64::total_cmp);
|
||||
gaps.get(at).copied().filter(|n| n.is_finite() && *n <= limit).unwrap_or(0.0).max(0.0)
|
||||
}
|
||||
|
||||
fn tj_tracking(pieces: &[Object], metrics: &Metrics, size: f64, scale: f64) -> f64 {
|
||||
let mut gaps = Vec::new();
|
||||
let mut previous = false;
|
||||
let mut gap = 0.0;
|
||||
for piece in pieces {
|
||||
if let Object::String(s) = piece {
|
||||
for (code, len) in metrics.codes(&s.bytes) {
|
||||
let blank = metrics.is_space(code, len) || metrics.text_of(code).is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace));
|
||||
if previous && !blank {
|
||||
gaps.push(gap);
|
||||
}
|
||||
previous = !blank;
|
||||
gap = 0.0;
|
||||
}
|
||||
} else if let Some(n) = piece.as_f64() {
|
||||
gap += -n / 1000.0 * size * scale;
|
||||
}
|
||||
}
|
||||
typical_tracking(&mut gaps, size * scale.abs() * 0.5)
|
||||
}
|
||||
|
||||
fn positioned_tracking(shown: &mut [Shown]) {
|
||||
let mut start = 0;
|
||||
while start < shown.len() {
|
||||
let mut end = start.saturating_add(1);
|
||||
while let (Some(prev), Some(next)) = (shown.get(end.saturating_sub(1)), shown.get(end)) {
|
||||
if prev.glyph_count != 1
|
||||
|| next.glyph_count != 1
|
||||
|| prev.font != next.font
|
||||
|| prev.scope != next.scope
|
||||
|| (prev.size - next.size).abs() > 0.01
|
||||
|| prev.state.char_spacing != next.state.char_spacing
|
||||
|| prev.state.scale != next.state.scale
|
||||
|| !Pen::after(prev).continues(next, 1.5).0
|
||||
{
|
||||
break;
|
||||
}
|
||||
end = end.saturating_add(1);
|
||||
}
|
||||
if let Some(run) = shown.get_mut(start..end) {
|
||||
let mut gaps: Vec<f64> = run
|
||||
.windows(2)
|
||||
.filter_map(|pair| {
|
||||
let [prev, next] = pair else { return None };
|
||||
(!prev.text.trim().is_empty() && !next.text.trim().is_empty()).then(|| Pen::after(prev).continues(next, 0.0).1)
|
||||
})
|
||||
.collect();
|
||||
let limit = run.first().map_or(0.0, |s| s.size * s.state.scale.abs() * 0.5);
|
||||
let tracking = typical_tracking(&mut gaps, limit);
|
||||
for s in run.iter_mut().skip(1) {
|
||||
s.tracking = tracking;
|
||||
}
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
|
||||
/// The graphics state carried from one of a page's content streams to the next: the streams
|
||||
/// are one stream in pieces (§7.8.2), so a `cm` (AutoCAD scales the whole page in the first
|
||||
/// stream), an unbalanced `q`, the font and the colour still apply in the streams after it.
|
||||
@@ -734,7 +816,8 @@ fn interpret(
|
||||
cache: &mut HashMap<Vec<u8>, (Rc<Metrics>, Option<String>)>,
|
||||
carry: &mut Carry,
|
||||
mut invoked: Option<&mut Vec<Invocation>>,
|
||||
logical_text: bool,
|
||||
// `Some(properties)`: read `/ActualText`, looking named property lists up in `properties`.
|
||||
logical_text: Option<&Dict>,
|
||||
) -> Vec<Shown> {
|
||||
let mut out: Vec<Shown> = Vec::new();
|
||||
let mut ts = carry.ts.clone();
|
||||
@@ -751,11 +834,16 @@ fn interpret(
|
||||
// artifacts and inner spans, which inherit their enclosing region.
|
||||
let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH"));
|
||||
marked.push(if region { Some(i) } else { marked.last().copied().flatten() });
|
||||
let logical = op.operands.get(1).and_then(Object::as_dict).and_then(|d| d.get(b"ActualText")).and_then(|s| match s {
|
||||
// The property list is inline or named in the resources' /Properties (§14.6.2).
|
||||
let list = logical_text.and_then(|properties| match op.operands.get(1) {
|
||||
Some(Object::Name(n)) => properties.get(n).and_then(|o| doc.dict(o)),
|
||||
other => other.and_then(Object::as_dict).cloned(),
|
||||
});
|
||||
let logical = list.as_ref().and_then(|d| d.get(b"ActualText")).and_then(|s| match s {
|
||||
Object::String(s) if s.bytes.len() <= 4_000_000 => Some(s.to_text()),
|
||||
_ => None,
|
||||
});
|
||||
actual.push(if logical_text { logical.map(|s| (out.len(), i, s)) } else { None });
|
||||
actual.push(logical.map(|s| (out.len(), i, s)));
|
||||
}
|
||||
b"EMC" => {
|
||||
marked.pop();
|
||||
@@ -773,6 +861,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]);
|
||||
@@ -870,6 +959,8 @@ fn interpret(
|
||||
b"TJ" => op.operands.first().and_then(Object::as_array).cloned().unwrap_or_default(),
|
||||
_ => op.operands.last().cloned().into_iter().collect(),
|
||||
};
|
||||
// Tc is already included in the pen advance; TJ may encode tracking instead.
|
||||
let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 };
|
||||
let trm0 = tm.then(&ts.ctm);
|
||||
let start = trm0.apply(0.0, ts.rise);
|
||||
let mut text = String::new();
|
||||
@@ -911,12 +1002,12 @@ fn interpret(
|
||||
if let Some(n) = other.as_f64() {
|
||||
let dx = -n / 1000.0 * ts.size * ts.scale;
|
||||
// A large gap inside TJ reads as a space.
|
||||
if dx > ts.size * 0.2 && !text.ends_with(' ') {
|
||||
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') {
|
||||
text.push(' ');
|
||||
}
|
||||
// Kerning keeps a word together; a jump either way of more
|
||||
// than 0.2 em ends it (whatever the sign of the size or `Tz`).
|
||||
if dx.abs() > (ts.size * ts.scale).abs() * 0.2 {
|
||||
// Tracking and kerning keep a word together; a larger jump
|
||||
// ends it (whatever the sign of the size or `Tz`).
|
||||
if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 {
|
||||
in_word = false;
|
||||
}
|
||||
x_text += dx;
|
||||
@@ -936,6 +1027,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)| {
|
||||
@@ -956,6 +1054,7 @@ fn interpret(
|
||||
extra_ops: Vec::new(),
|
||||
glyph_count,
|
||||
advance: x_text,
|
||||
tracking: 0.0,
|
||||
tm: Matrix([tm.0[0], tm.0[1], tm.0[2], tm.0[3], tm.0[4] - x_text * tm.0[0], tm.0[5] - x_text * tm.0[1]]),
|
||||
state: ts.clone(),
|
||||
tlm,
|
||||
@@ -963,6 +1062,7 @@ fn interpret(
|
||||
bt_state: at_bt.1.clone(),
|
||||
text,
|
||||
rect,
|
||||
frame,
|
||||
ink,
|
||||
baseline: start.1,
|
||||
start_x: start.0,
|
||||
@@ -988,6 +1088,7 @@ fn interpret(
|
||||
carry.ts = ts;
|
||||
carry.stack = stack;
|
||||
carry.marked = marked;
|
||||
positioned_tracking(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -1003,7 +1104,8 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
|
||||
// joined, so an operator whose operands end one piece still has them.
|
||||
let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::<Vec<_>>());
|
||||
let ops = pieces.parse().ops;
|
||||
let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, true);
|
||||
let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default();
|
||||
let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, Some(&properties));
|
||||
let mut last: Option<(usize, Pen)> = None; // the stream piece and where its last run ended
|
||||
for mut s in shown {
|
||||
// A single RTL glyph's ActualText (e.g. lam-alef) is an inline ligature, not a
|
||||
@@ -1035,22 +1137,25 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
|
||||
});
|
||||
if joins && let Some(l) = lines.last_mut() {
|
||||
let gap = last.map_or(0.0, |(_, pen)| pen.continues(&s, 0.0).1);
|
||||
if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
|
||||
if gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
|
||||
l.text.push(' ');
|
||||
}
|
||||
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());
|
||||
l.decodable &= s.decodable;
|
||||
l.visual_order |= s.visual_order;
|
||||
l.actual_text |= s.actual_text;
|
||||
l.clips |= s.state.render_mode >= 4;
|
||||
// Spaces may use a separate subset font; they do not change the visible style.
|
||||
} else {
|
||||
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(),
|
||||
@@ -1065,6 +1170,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
|
||||
scope: s.scope,
|
||||
logical_order: s.logical_order,
|
||||
actual_text: s.actual_text,
|
||||
clips: s.state.render_mode >= 4,
|
||||
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
|
||||
origin: Origin {
|
||||
tm: s.tm.0,
|
||||
@@ -1123,21 +1229,24 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
|
||||
});
|
||||
if joins && let Some(l) = lines.last_mut() {
|
||||
let gap = last.map_or(0.0, |pen| pen.continues(&s, 0.0).1);
|
||||
if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
|
||||
if gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
|
||||
l.text.push(' ');
|
||||
}
|
||||
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());
|
||||
l.decodable &= s.decodable;
|
||||
l.visual_order |= s.visual_order;
|
||||
l.actual_text |= s.actual_text;
|
||||
l.clips |= s.state.render_mode >= 4;
|
||||
} else {
|
||||
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(),
|
||||
@@ -1152,6 +1261,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
|
||||
scope: s.scope,
|
||||
logical_order: s.logical_order,
|
||||
actual_text: s.actual_text,
|
||||
clips: s.state.render_mode >= 4,
|
||||
ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(),
|
||||
origin: Origin {
|
||||
tm: s.tm.0,
|
||||
@@ -1169,13 +1279,27 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream
|
||||
*last = Some(Pen::after(&s));
|
||||
}
|
||||
|
||||
/// How deep form XObjects nest before reading stops (they may also refer to themselves).
|
||||
pub(crate) const MAX_FORM_DEPTH: usize = 12;
|
||||
/// How deep form XObjects nest before reading stops (they may also refer to themselves). The
|
||||
/// renderer stops at the same depth, so export reads the text the page shows (#3120).
|
||||
pub const MAX_FORM_DEPTH: usize = 50;
|
||||
|
||||
/// The most form XObjects one page's walk enters, at any depth. The depth cap alone still lets
|
||||
/// a form that draws another form many times fan out exponentially (100 `Do`s on each of 12
|
||||
/// levels), which would hang export on a hostile PDF.
|
||||
pub(crate) const MAX_FORM_VISITS: usize = 4096;
|
||||
/// a form that draws another form many times fan out exponentially (100 `Do`s on each of 50
|
||||
/// levels), which would hang export on a hostile PDF. The renderer paints as many forms nested
|
||||
/// in forms; this budget also counts the forms the page draws itself, so a page that draws one
|
||||
/// a million times stays bounded too. A label or table cell drawn as a form a few thousand
|
||||
/// times is read in full (#3120).
|
||||
pub const MAX_FORM_VISITS: usize = 50_000;
|
||||
|
||||
/// One page's walk through the form XObjects it draws: how many it entered (the page's
|
||||
/// [`MAX_FORM_VISITS`]), and whether a limit left a form unread.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct FormBudget {
|
||||
visits: usize,
|
||||
/// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`] or comes past
|
||||
/// [`MAX_FORM_VISITS`]: what it draws is missing from the walk.
|
||||
pub(crate) truncated: bool,
|
||||
}
|
||||
|
||||
/// A form XObject a page draws: its object, content, resources and its matrix composed with the
|
||||
/// CTM at the `Do`.
|
||||
@@ -1187,17 +1311,19 @@ pub(crate) struct FormCall {
|
||||
}
|
||||
|
||||
/// The form XObject `name` in `resources`, drawn with `ctm`. `None` for images, missing names
|
||||
/// and forms already being read (`path`), nested too deep, or past the page's `visits` budget
|
||||
/// ([`MAX_FORM_VISITS`]); each form entered counts one visit.
|
||||
/// and forms already being read (`path`), and for forms nested too deep or past the page's
|
||||
/// budget, which `budget` records as truncated; each form entered counts one visit.
|
||||
pub(crate) fn form_call(
|
||||
doc: &Document,
|
||||
resources: &Dict,
|
||||
name: &[u8],
|
||||
ctm: Matrix,
|
||||
path: &[pdfcraft_cos::ObjRef],
|
||||
visits: &mut usize,
|
||||
budget: &mut FormBudget,
|
||||
) -> Option<FormCall> {
|
||||
if path.len() >= MAX_FORM_DEPTH || *visits >= MAX_FORM_VISITS {
|
||||
let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS;
|
||||
// Once a limit has cut the page short, refusing the rest costs nothing more.
|
||||
if limited && budget.truncated {
|
||||
return None;
|
||||
}
|
||||
let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())?;
|
||||
@@ -1210,6 +1336,11 @@ pub(crate) fn form_call(
|
||||
if s.dict.name(b"Subtype") != Some(b"Form") {
|
||||
return None;
|
||||
}
|
||||
// Only a form left unread loses content; an image at the limit is still read by its caller.
|
||||
if limited {
|
||||
budget.truncated = true;
|
||||
return None;
|
||||
}
|
||||
let m = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| {
|
||||
let a = m.as_array()?;
|
||||
let v: Vec<f64> = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
|
||||
@@ -1217,7 +1348,7 @@ pub(crate) fn form_call(
|
||||
});
|
||||
// A form without its own resources uses the ones it was drawn with (PDF 1.1 files).
|
||||
let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
|
||||
*visits = visits.saturating_add(1);
|
||||
budget.visits = budget.visits.saturating_add(1);
|
||||
Some(FormCall {
|
||||
obj,
|
||||
data: s.decoded().unwrap_or_default(),
|
||||
@@ -1228,21 +1359,25 @@ pub(crate) fn form_call(
|
||||
|
||||
/// The lines a page shows as it reads, including text drawn by form XObjects (which
|
||||
/// [`text_lines`] leaves out because it can only rewrite the page's own streams). Lines from a
|
||||
/// form carry a stream number past the page's streams, one per form drawn.
|
||||
fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError> {
|
||||
/// form carry a stream number past the page's streams, one per form drawn. The flag is `true`
|
||||
/// when forms past [`MAX_FORM_DEPTH`] or [`MAX_FORM_VISITS`] were left unread.
|
||||
fn reading_lines(doc: &Document, page: usize) -> Result<(Vec<TextLine>, bool), EditError> {
|
||||
struct Walk<'a> {
|
||||
doc: &'a Document,
|
||||
lines: Vec<TextLine>,
|
||||
next_stream: usize,
|
||||
path: Vec<pdfcraft_cos::ObjRef>,
|
||||
visits: usize,
|
||||
budget: FormBudget,
|
||||
}
|
||||
fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: usize) {
|
||||
/// `stream` gives the stream number of the operator at an index of `ops`.
|
||||
fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: &dyn Fn(usize) -> usize) {
|
||||
let fonts_res = resources.get(b"Font").map(|f| w.doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
|
||||
let mut cache = HashMap::new();
|
||||
let mut invoked = Vec::new();
|
||||
let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), true);
|
||||
let properties = resources.get(b"Properties").and_then(|o| w.doc.dict(o)).unwrap_or_default();
|
||||
let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), Some(&properties));
|
||||
let mut last = None;
|
||||
let mut last_stream = None;
|
||||
let mut forms = invoked.into_iter().peekable();
|
||||
for s in shown {
|
||||
while let Some((_, name, ts)) = forms.next_if(|f| f.0 < s.op) {
|
||||
@@ -1250,14 +1385,19 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
|
||||
// Text after a form starts a new line.
|
||||
last = None;
|
||||
}
|
||||
add_shown(&mut w.lines, &mut last, s, stream);
|
||||
let si = stream(s.op);
|
||||
// Text from another stream starts a new line, as when each stream was read alone.
|
||||
if last_stream.replace(si).is_some_and(|prev| prev != si) {
|
||||
last = None;
|
||||
}
|
||||
add_shown(&mut w.lines, &mut last, s, si);
|
||||
}
|
||||
for (_, name, ts) in forms {
|
||||
enter(w, resources, &name, ts);
|
||||
}
|
||||
}
|
||||
fn enter(w: &mut Walk, resources: &Dict, name: &[u8], ts: Ts) {
|
||||
let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.visits) else { return };
|
||||
let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.budget) else { return };
|
||||
let ops = parse(&form.data).ops;
|
||||
// The form starts with the graphics state at its `Do`, its own matrix applied.
|
||||
let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new(), marked: Vec::new() };
|
||||
@@ -1266,7 +1406,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
|
||||
if let Some(r) = form.obj {
|
||||
w.path.push(r);
|
||||
}
|
||||
walk(w, &ops, &form.resources, &mut carry, stream);
|
||||
walk(w, &ops, &form.resources, &mut carry, &|_| stream);
|
||||
if form.obj.is_some() {
|
||||
w.path.pop();
|
||||
}
|
||||
@@ -1274,18 +1414,23 @@ fn reading_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError
|
||||
let p = page_dict(doc, page)?;
|
||||
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let streams = content_streams(doc, &p.dict);
|
||||
let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), visits: 0 };
|
||||
let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::default() };
|
||||
let mut carry = Carry::new();
|
||||
for (si, (_, data)) in streams.into_iter().enumerate() {
|
||||
walk(&mut w, &parse(&data).ops, &res, &mut carry, si);
|
||||
}
|
||||
// Read the page's streams as the one stream they are, in one pass, as rendering and editing
|
||||
// do: a comment, an operator's operands or a marked-content sequence (its /ActualText too)
|
||||
// can run on from one stream into the next. Text still counts as the stream its operator
|
||||
// starts in, which keeps lines from different streams apart.
|
||||
let pieces = Pieces::join(&streams.into_iter().map(|(_, data)| data).collect::<Vec<_>>());
|
||||
let ops = pieces.parse().ops;
|
||||
let stream_of = |i: usize| ops.get(i).map_or(0, |op| pieces.pieces_of(op).0);
|
||||
walk(&mut w, &ops, &res, &mut carry, &stream_of);
|
||||
let mut lines = w.lines;
|
||||
// A visual-order Indic font draws pre-base vowel signs before their consonant.
|
||||
for l in lines.iter_mut().filter(|l| l.visual_order && !l.actual_text) {
|
||||
l.text = crate::indic::to_logical(&l.text);
|
||||
}
|
||||
lines.retain(|l| !l.text.trim().is_empty());
|
||||
Ok(lines)
|
||||
Ok((lines, w.budget.truncated))
|
||||
}
|
||||
|
||||
/// Text → the runs that show it: each run's font resource name and the bytes shown in that font.
|
||||
@@ -1630,7 +1775,15 @@ fn source_encodes(metrics: &Metrics, text: &str) -> bool {
|
||||
text.split(' ').all(|word| metrics.encode(word).is_some())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How often [`source_space`] ran on this thread, for the test that pins it out of per-character loops.
|
||||
pub(crate) static SOURCE_SPACE_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn source_space(doc: &Document, fonts: &Dict, metrics: &Metrics) -> f64 {
|
||||
#[cfg(test)]
|
||||
SOURCE_SPACE_CALLS.with(|c| c.set(c.get().saturating_add(1)));
|
||||
let width = |m: &Metrics| m.encode(" ").map(|b| m.codes(&b).iter().map(|(c, _)| m.width(*c)).sum::<f64>());
|
||||
if let Some(w) = width(metrics) {
|
||||
return w;
|
||||
@@ -1692,11 +1845,33 @@ pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) ->
|
||||
replace_line_with_limit(doc, page, line, text, MAX_TYPE3_GLYPHS)
|
||||
}
|
||||
|
||||
/// Refuses a rewrite that would remove text drawn as a clipping path (`4 Tr` to `7 Tr`). Such
|
||||
/// glyphs are not ink but a mask, and whatever is painted through them takes their shape.
|
||||
/// Removing the glyphs removes the mask, and the fill or image behind floods its own rectangle
|
||||
/// instead -- the artwork is destroyed, in a file that still opens without a warning (#3429).
|
||||
/// `clear_block` already refuses these for the same reason.
|
||||
///
|
||||
/// Every line the rewrite drops is checked, not only the one being replaced: a line's runs need
|
||||
/// not share a rendering mode (see [`TextLine::clips`]), and [`coincident_ops`] drops a whole
|
||||
/// overlapping line without regard to its mode -- so a mask could go with text that is ink.
|
||||
fn refuse_clipping_text(lines: &[TextLine], dropped: &std::collections::HashSet<usize>) -> Result<(), EditError> {
|
||||
if lines.iter().any(|l| l.clips && l.ops.iter().any(|op| dropped.contains(op))) {
|
||||
return Err(EditError::Invalid("text used as a clipping path cannot be edited: its glyphs mask the artwork behind them".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`replace_line`] with the most unique characters one generated font may hold (tests lower it).
|
||||
pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usize, text: &str, max_glyphs: usize) -> Result<LineEdit, EditError> {
|
||||
let text = text.replace(['\n', '\r'], " ");
|
||||
let lines = text_lines(doc, page)?;
|
||||
let target = lines.get(line).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no line {}", page + 1, line + 1)))?;
|
||||
// Copies of the line drawn on the same baseline go with it, so the replacement is all that
|
||||
// shows. Which operators go is settled here, before any font is generated, so that a refusal
|
||||
// leaves the document untouched.
|
||||
let mut drops = coincident_ops(&lines, &[&target]);
|
||||
drops.extend(target.ops.iter().copied());
|
||||
refuse_clipping_text(&lines, &drops)?;
|
||||
let p = page_dict(doc, page)?;
|
||||
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
|
||||
@@ -1770,9 +1945,7 @@ 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]);
|
||||
// Rebuild: the line's first operator becomes the replacement, its others go.
|
||||
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;
|
||||
@@ -1804,7 +1977,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());
|
||||
}
|
||||
@@ -1822,13 +1998,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).
|
||||
@@ -1853,7 +2032,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
|
||||
let streams = content_streams(doc, &p.dict);
|
||||
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
|
||||
let ops = pieces.parse().ops;
|
||||
let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, false);
|
||||
let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, None);
|
||||
let mut patches = HashMap::new();
|
||||
for s in shown.into_iter().filter(|s| drops.contains(&s.op)) {
|
||||
if s.state.render_mode >= 4 {
|
||||
@@ -1886,9 +2065,27 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
|
||||
/// The paragraphs on a page as a reader sees them, including text drawn by form XObjects
|
||||
/// (Export to Word, HTML and RTF). Read-only: indexes into this list are not
|
||||
/// [`text_blocks`] indexes, so they can't be passed to [`replace_block`] or [`rewrite_block`].
|
||||
/// [`reading_blocks_checked`] also says whether text was left out.
|
||||
pub fn reading_blocks(doc: &Document, page: usize) -> Result<Vec<TextBlock>, EditError> {
|
||||
let lines = reading_lines(doc, page)?;
|
||||
Ok(regions::group_reading_blocks(&lines))
|
||||
reading_blocks_checked(doc, page).map(|r| r.blocks)
|
||||
}
|
||||
|
||||
/// A page's paragraphs as a reader sees them ([`reading_blocks_checked`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ReadingBlocks {
|
||||
/// The paragraphs, as [`reading_blocks`] returns them.
|
||||
pub blocks: Vec<TextBlock>,
|
||||
/// The page draws forms nested deeper than [`MAX_FORM_DEPTH`], or more of them than
|
||||
/// [`MAX_FORM_VISITS`]. Reading leaves those out to stay bounded on hostile files, so their
|
||||
/// text is missing from `blocks`; [`crate::reading_images`] walks the same forms and misses
|
||||
/// their images too.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// [`reading_blocks`], saying whether the limits that bound reading left text out.
|
||||
pub fn reading_blocks_checked(doc: &Document, page: usize) -> Result<ReadingBlocks, EditError> {
|
||||
let (lines, truncated) = reading_lines(doc, page)?;
|
||||
Ok(ReadingBlocks { blocks: regions::group_reading_blocks(&lines), truncated })
|
||||
}
|
||||
|
||||
// An ideographic space carries intentional Japanese spacing, including paragraph indentation.
|
||||
@@ -2009,6 +2206,10 @@ fn rewrite_block_from(
|
||||
let blocks = group_blocks(&lines);
|
||||
let b = blocks.get(block).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?;
|
||||
let members: Vec<&TextLine> = b.lines.iter().map(|i| &lines[*i]).collect();
|
||||
// One clipping run is enough: rewriting the paragraph would drop its mask with the rest.
|
||||
let mut dropped = coincident_ops(&lines, &members);
|
||||
dropped.extend(members.iter().flat_map(|l| l.ops.iter().copied()));
|
||||
refuse_clipping_text(&lines, &dropped)?;
|
||||
let first = members.first().copied().ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?;
|
||||
let p = page_dict(doc, page)?;
|
||||
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
@@ -2060,6 +2261,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.
|
||||
@@ -2120,7 +2323,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()))?;
|
||||
@@ -2128,6 +2331,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) {
|
||||
@@ -2149,9 +2358,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
|
||||
};
|
||||
@@ -2205,7 +2432,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)]));
|
||||
@@ -2220,7 +2447,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;
|
||||
@@ -2303,7 +2530,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 {
|
||||
@@ -2318,24 +2545,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))
|
||||
@@ -2484,7 +2711,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize
|
||||
let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
|
||||
let ops = pieces.parse().ops;
|
||||
let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, false);
|
||||
let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, None);
|
||||
let mut patches = HashMap::new();
|
||||
// Keep full precision: a tiny rounded text-space change can be magnified by a CTM,
|
||||
// and restoring rise/spacing must not alter following unselected text.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -493,6 +489,20 @@ pub(super) fn rewrite_styled_block(
|
||||
};
|
||||
// A source run may carry TJ kerning/justification that is absent from its text state.
|
||||
// Keep its measured advance when rebuilding the run instead of making it wrap on entry.
|
||||
// Each font's space width, worked out once: when a font can't show a space, finding one
|
||||
// reads every font on the page, and doing that for every character of a paragraph made each
|
||||
// keystroke in a long paragraph take half a second (#2616).
|
||||
// Only for the fonts the paragraph uses, when first asked: working out every page font's width
|
||||
// up front would scan the page once per font for a short edit on a page of many fonts.
|
||||
let spaces: std::cell::RefCell<HashMap<String, f64>> = std::cell::RefCell::new(HashMap::new());
|
||||
let space_of = |name: &str, m: &Metrics| {
|
||||
if let Some(w) = spaces.borrow().get(name) {
|
||||
return *w;
|
||||
}
|
||||
let w = source_space(doc, &fonts, m);
|
||||
spaces.borrow_mut().insert(name.to_owned(), w);
|
||||
w
|
||||
};
|
||||
let mut calibration = HashMap::new();
|
||||
for (index, source) in lines.iter().enumerate() {
|
||||
if let Some(m) = metrics.get(&source.font) {
|
||||
@@ -500,7 +510,7 @@ pub(super) fn rewrite_styled_block(
|
||||
m,
|
||||
&source.text,
|
||||
source.origin.state.font.as_ref().map_or(1.0, |(_, s)| *s),
|
||||
source_space(doc, &fonts, m),
|
||||
space_of(&source.font, m),
|
||||
source.origin.state.char_spacing,
|
||||
source.origin.state.word_spacing,
|
||||
) * source.origin.state.scale
|
||||
@@ -517,7 +527,7 @@ pub(super) fn rewrite_styled_block(
|
||||
m,
|
||||
&item.0.to_string(),
|
||||
size_of(source, item.1),
|
||||
source_space(doc, &fonts, m),
|
||||
space_of(&item.2, m),
|
||||
overrides
|
||||
.get(&item.1)
|
||||
.and_then(|r| r.char_spacing)
|
||||
@@ -620,19 +630,20 @@ pub(super) fn rewrite_styled_block(
|
||||
let kept_lines: std::collections::HashSet<usize> =
|
||||
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 +679,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 +752,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;
|
||||
@@ -781,7 +792,7 @@ pub(super) fn rewrite_styled_block(
|
||||
m,
|
||||
&string,
|
||||
size_of(source, item.1),
|
||||
source_space(doc, &fonts, m),
|
||||
spaces.borrow().get(&item.2).copied().unwrap_or_else(|| source_space(doc, &fonts, m)),
|
||||
state.char_spacing,
|
||||
state.word_spacing,
|
||||
)?);
|
||||
@@ -790,10 +801,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 +821,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)
|
||||
|
||||
@@ -9,6 +9,7 @@ license.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-docpath = { workspace = true }
|
||||
pdfcraft-measure = { workspace = true }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff"] }
|
||||
pdfcraft-render = { workspace = true }
|
||||
@@ -36,6 +37,9 @@ pdfcraft-model = { workspace = true }
|
||||
pdfcraft-geom = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
png = "0.18"
|
||||
# Already in the graph (through image and pdfcraft-create): image's TiffEncoder can't set the
|
||||
# resolution or the compression of an exported page.
|
||||
tiff = "0.11"
|
||||
# Already in the graph (url and idna through webbrowser, icu_properties through boa): they show
|
||||
# the host of a document's link the way the browser reads it (links::display_host).
|
||||
url = "2.5"
|
||||
|
||||
@@ -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")),
|
||||
],
|
||||
|
||||
@@ -362,6 +362,7 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("page.insert_blank", "Insert blank page", PAGES, None, Assembly, "file-plus"),
|
||||
c("page.rotate_dialog", "Rotate pages…", PAGES, None, Assembly, "rotate-cw"),
|
||||
c("page.duplicate", "Duplicate pages", PAGES, None, Assembly, "copy-plus"),
|
||||
c("page.reverse", "Reverse pages", PAGES, None, Assembly, "arrow-up-down"),
|
||||
c("page.crop", "Crop pages", PAGES, None, Assembly, "crop"),
|
||||
c("page.boxes", "Set page boxes…", PAGES, None, Assembly, "square-dashed-mouse-pointer"),
|
||||
c("page.insert", "Insert pages from file…", PAGES, None, Assembly, "file-input"),
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use pdfcraft_compare::{Change, Comparison, Kind, Side};
|
||||
// How far Office export reads into form XObjects; `OfficeExport::truncated_pages` lists the pages
|
||||
// that draw more.
|
||||
pub use pdfcraft_edit::{MAX_FORM_DEPTH, MAX_FORM_VISITS};
|
||||
|
||||
use crate::{DocId, Edit, EditError, Markup, NewAnnotation, NoteIcon, Session, Shape};
|
||||
|
||||
@@ -151,12 +154,31 @@ impl OfficeFormat {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Word, PowerPoint, HTML or RTF export ([`crate::Document::export_office_checked`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct OfficeExport {
|
||||
/// The exported file.
|
||||
pub bytes: Vec<u8>,
|
||||
/// Pages (0-based) that draw forms nested deeper than [`MAX_FORM_DEPTH`] or more of them
|
||||
/// than [`MAX_FORM_VISITS`]: export stops reading there to stay bounded on hostile files, so
|
||||
/// the text and images of those forms are missing from `bytes`.
|
||||
pub truncated_pages: Vec<usize>,
|
||||
}
|
||||
|
||||
impl crate::Document {
|
||||
/// The pages as paragraphs and images (for Word, HTML and RTF export), including what
|
||||
/// form XObjects draw.
|
||||
pub fn export_pages(&self) -> Vec<pdfcraft_export::Page> {
|
||||
let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return Vec::new() };
|
||||
self.info
|
||||
self.export_pages_checked().0
|
||||
}
|
||||
|
||||
/// [`Self::export_pages`], and the pages whose forms were left partly unread
|
||||
/// ([`OfficeExport::truncated_pages`]).
|
||||
fn export_pages_checked(&self) -> (Vec<pdfcraft_export::Page>, Vec<usize>) {
|
||||
let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return (Vec::new(), Vec::new()) };
|
||||
let mut truncated = Vec::new();
|
||||
let pages = self
|
||||
.info
|
||||
.pages
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -171,8 +193,12 @@ impl crate::Document {
|
||||
let crop_width = f64::from(info.crop[2] - info.crop[0]);
|
||||
let display_width = f64::from(if info.rotation % 180 == 0 { info.width } else { info.height });
|
||||
let unit = if crop_width > 0.0 && crop_width.is_finite() && display_width.is_finite() { display_width / crop_width } else { 1.0 };
|
||||
let blocks = pdfcraft_edit::reading_blocks(cos, i)
|
||||
.unwrap_or_default()
|
||||
let reading = pdfcraft_edit::reading_blocks_checked(cos, i).unwrap_or_default();
|
||||
if reading.truncated {
|
||||
truncated.push(i);
|
||||
}
|
||||
let blocks = reading
|
||||
.blocks
|
||||
.into_iter()
|
||||
.filter(|b| !b.text.trim().is_empty())
|
||||
.map(|b| {
|
||||
@@ -204,18 +230,26 @@ impl crate::Document {
|
||||
.collect();
|
||||
pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images }
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
(pages, truncated)
|
||||
}
|
||||
|
||||
/// The document as a Word, PowerPoint, HTML or RTF file.
|
||||
pub fn export_office(&self, format: OfficeFormat) -> Vec<u8> {
|
||||
let pages = self.export_pages();
|
||||
self.export_office_checked(format).bytes
|
||||
}
|
||||
|
||||
/// [`Self::export_office`], and the pages whose text it had to leave out
|
||||
/// ([`OfficeExport::truncated_pages`]).
|
||||
pub fn export_office_checked(&self, format: OfficeFormat) -> OfficeExport {
|
||||
let (pages, truncated_pages) = self.export_pages_checked();
|
||||
let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string());
|
||||
match format {
|
||||
let bytes = match format {
|
||||
OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title),
|
||||
OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title),
|
||||
OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(),
|
||||
OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(),
|
||||
}
|
||||
};
|
||||
OfficeExport { bytes, truncated_pages }
|
||||
}
|
||||
}
|
||||
+31
-15
@@ -93,14 +93,14 @@ impl Exporter {
|
||||
encode_png(r.width, r.height, &r.rgba)
|
||||
}
|
||||
|
||||
/// Page `page` as an image file of `format`.
|
||||
/// Page `page` as an image file of `format`, which says the resolution it was drawn at.
|
||||
pub fn image(&mut self, page: usize, dpi: f64, format: ImageFormat) -> Result<Vec<u8>, String> {
|
||||
self.check(page)?;
|
||||
let r = self.renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() });
|
||||
if let Some(e) = r.error {
|
||||
return Err(format!("page {}: {e}", page + 1));
|
||||
}
|
||||
encode_image(r.width, r.height, &r.rgba, format)
|
||||
encode_image(r.width, r.height, &r.rgba, format, self.dpi_used(page, dpi))
|
||||
}
|
||||
|
||||
/// The reading-order text of a page.
|
||||
@@ -127,12 +127,14 @@ impl Exporter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA → an image file of `format`.
|
||||
pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: ImageFormat) -> Result<Vec<u8>, String> {
|
||||
/// Premultiplied RGBA → an image file of `format`. A JPEG or TIFF says it is `dpi` dots per inch,
|
||||
/// so other programs show and print it at the page's size. A PNG says nothing: its `pHYs` counts
|
||||
/// pixels per metre, so 72 dpi would read back as 72.009 and a page made from it would shrink.
|
||||
pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: ImageFormat, dpi: f64) -> Result<Vec<u8>, String> {
|
||||
match format {
|
||||
ImageFormat::Png => encode_png(width, height, premultiplied),
|
||||
ImageFormat::Jpeg { quality } => encode_jpeg(width, height, premultiplied, quality),
|
||||
ImageFormat::Tiff => encode_tiff(width, height, premultiplied),
|
||||
ImageFormat::Jpeg { quality } => encode_jpeg(width, height, premultiplied, quality, dpi),
|
||||
ImageFormat::Tiff => encode_tiff(width, height, premultiplied, dpi),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,23 +201,37 @@ fn over_white(premultiplied: &[u8]) -> Vec<u8> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA → baseline JPEG (pages are opaque: transparency becomes white paper).
|
||||
pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8) -> Result<Vec<u8>, String> {
|
||||
/// Premultiplied RGBA → baseline JPEG at `dpi` (pages are opaque: transparency becomes white paper).
|
||||
pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8, dpi: f64) -> Result<Vec<u8>, String> {
|
||||
use image::ImageEncoder;
|
||||
use image::codecs::jpeg::{JpegEncoder, PixelDensity};
|
||||
let rgb = over_white(premultiplied);
|
||||
let mut out = Vec::new();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100))
|
||||
.write_image(&rgb, width, height, image::ExtendedColorType::Rgb8)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut enc = JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100));
|
||||
// The JFIF density is whole dots per inch.
|
||||
enc.set_pixel_density(PixelDensity::dpi(dpi.round() as u16));
|
||||
enc.write_image(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA → TIFF (RGB, LZW-compressed by the encoder's default).
|
||||
pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8]) -> Result<Vec<u8>, String> {
|
||||
use image::ImageEncoder;
|
||||
/// Premultiplied RGBA → TIFF (RGB, LZW-compressed) at `dpi`. Written with `tiff` directly:
|
||||
/// `image`'s `TiffEncoder` always says 1 × 1 with no unit and leaves the strips uncompressed.
|
||||
/// No predictor: LZW alone is the compression TIFF readers support most widely after none.
|
||||
pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8], dpi: f64) -> Result<Vec<u8>, String> {
|
||||
use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::RGB8};
|
||||
use tiff::tags::{ResolutionUnit, Tag};
|
||||
let err = |e: tiff::TiffError| e.to_string();
|
||||
let rgb = over_white(premultiplied);
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
image::codecs::tiff::TiffEncoder::new(&mut out).write_image(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?;
|
||||
let mut tiff = TiffEncoder::new(&mut out).map_err(err)?.with_compression(Compression::Lzw);
|
||||
let mut image = tiff.new_image::<RGB8>(width, height).map_err(err)?;
|
||||
// In hundredths: a page drawn at a lowered resolution (`Exporter::dpi_used`) is rarely a whole number.
|
||||
let resolution = Rational { n: (dpi * 100.0).round() as u32, d: 100 };
|
||||
let tags = image.encoder();
|
||||
tags.write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).map_err(err)?;
|
||||
tags.write_tag(Tag::XResolution, resolution.clone()).map_err(err)?;
|
||||
tags.write_tag(Tag::YResolution, resolution).map_err(err)?;
|
||||
image.write_data(&rgb).map_err(err)?;
|
||||
Ok(out.into_inner())
|
||||
}
|
||||
|
||||
|
||||
+18
-13
@@ -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) });
|
||||
}
|
||||
}
|
||||
|
||||
+83
-25
@@ -36,7 +36,6 @@ pub mod signature_image;
|
||||
pub mod signature_scan;
|
||||
pub mod text_fonts;
|
||||
mod text_preview;
|
||||
mod upright_image;
|
||||
pub mod word_count;
|
||||
pub mod xfa;
|
||||
|
||||
@@ -306,6 +305,10 @@ struct Editor {
|
||||
saved: Arc<Vec<u8>>,
|
||||
/// Passwords that open [`Self::saved`].
|
||||
saved_keys: Keys,
|
||||
/// [`Self::saved`] was never written where the document belongs: a new document (combine,
|
||||
/// extract, create) or one restored from recovery. It stays unsaved until a save, whatever
|
||||
/// undo brings back, or closing it would lose the whole document without asking.
|
||||
unsaved_file: bool,
|
||||
undo: Vec<Snapshot>,
|
||||
redo: Vec<Snapshot>,
|
||||
keys: Keys,
|
||||
@@ -317,15 +320,15 @@ struct Editor {
|
||||
text_blocks: TextBlocksCache,
|
||||
}
|
||||
|
||||
/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the unmodified document state they
|
||||
/// were read from. Opening a paragraph and applying its edit both need them, and grouping a long
|
||||
/// page is not cheap. Only an unmodified document is cached: its identity changes on every edit,
|
||||
/// undo and save (each reopens the working file), so a stale entry never matches.
|
||||
/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the document state they were read
|
||||
/// from. Opening a paragraph and applying its edit both need them, and grouping a long page is not
|
||||
/// cheap. The state ([`pdfcraft_cos::ReadState`]) changes with every edit and save and returns
|
||||
/// with undo, so a stale entry never matches, also after the document has been edited.
|
||||
#[derive(Default)]
|
||||
struct TextBlocksCache(std::sync::Mutex<Option<CachedTextBlocks>>);
|
||||
|
||||
/// The document state, the page (0-based) and its text blocks.
|
||||
type CachedTextBlocks = (pdfcraft_cos::SourceIdentity, usize, Arc<Vec<pdfcraft_edit::TextBlock>>);
|
||||
type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc<Vec<pdfcraft_edit::TextBlock>>);
|
||||
|
||||
impl Clone for TextBlocksCache {
|
||||
fn clone(&self) -> Self {
|
||||
@@ -337,20 +340,16 @@ impl Clone for TextBlocksCache {
|
||||
impl Editor {
|
||||
/// The text blocks on `page` (0-based), from the cache when the document hasn't changed.
|
||||
fn text_blocks(&self, page: usize) -> Result<Arc<Vec<pdfcraft_edit::TextBlock>>, EditError> {
|
||||
let source = (!self.cos.is_modified()).then(|| self.cos.source_identity());
|
||||
if let Some(source) = &source
|
||||
&& let Ok(cache) = self.text_blocks.0.lock()
|
||||
&& let Some((s, p, blocks)) = cache.as_ref()
|
||||
&& s == source
|
||||
if let Ok(cache) = self.text_blocks.0.lock()
|
||||
&& let Some((state, p, blocks)) = cache.as_ref()
|
||||
&& *p == page
|
||||
&& state.matches(&self.cos)
|
||||
{
|
||||
return Ok(blocks.clone());
|
||||
}
|
||||
let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?);
|
||||
if let Some(source) = source
|
||||
&& let Ok(mut cache) = self.text_blocks.0.lock()
|
||||
{
|
||||
*cache = Some((source, page, blocks.clone()));
|
||||
if let Ok(mut cache) = self.text_blocks.0.lock() {
|
||||
*cache = Some((self.cos.read_state(), page, blocks.clone()));
|
||||
}
|
||||
Ok(blocks)
|
||||
}
|
||||
@@ -832,6 +831,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 {
|
||||
@@ -984,6 +988,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();
|
||||
@@ -1247,14 +1272,20 @@ pub enum Edit {
|
||||
pages: Vec<usize>,
|
||||
to: usize,
|
||||
},
|
||||
/// `pages` reversed among the positions they occupy.
|
||||
ReversePages {
|
||||
pages: Vec<usize>,
|
||||
},
|
||||
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<usize>,
|
||||
at: Option<usize>,
|
||||
},
|
||||
/// Replace Pages: the content of `pages` is replaced by `src_pages` of another PDF; the
|
||||
/// pages' links, comments and bookmarks stay.
|
||||
@@ -1777,6 +1808,7 @@ pub const EDIT_LABELS: &[&str] = &[
|
||||
"Delete pages",
|
||||
"Move page",
|
||||
"Move pages",
|
||||
"Reverse pages",
|
||||
"Insert blank page",
|
||||
"Duplicate page",
|
||||
"Duplicate pages",
|
||||
@@ -1916,8 +1948,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"),
|
||||
@@ -2088,6 +2121,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 { .. }
|
||||
@@ -2298,11 +2332,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)?;
|
||||
@@ -2374,7 +2409,10 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?;
|
||||
}
|
||||
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())?;
|
||||
@@ -3526,6 +3564,7 @@ impl Session {
|
||||
cos,
|
||||
saved,
|
||||
saved_keys: keys.clone(),
|
||||
unsaved_file: false,
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
keys,
|
||||
@@ -3823,7 +3862,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).
|
||||
@@ -3873,6 +3912,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);
|
||||
@@ -4049,11 +4089,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<Arc<Vec<u8>>, 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() };
|
||||
@@ -4108,6 +4153,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());
|
||||
@@ -4171,7 +4217,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))
|
||||
}
|
||||
@@ -4296,7 +4342,7 @@ impl Session {
|
||||
}
|
||||
guard(|| match format {
|
||||
DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()),
|
||||
DataFormat::Fdf => Ok(pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name)),
|
||||
DataFormat::Fdf => pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name).map_err(|e| e.to_string()),
|
||||
DataFormat::Xml | DataFormat::Xdp if xfa => {
|
||||
pdfcraft_xfa::export_data(&cos, format == DataFormat::Xdp, &doc.name).map(String::into_bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -4332,12 +4378,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()))
|
||||
}
|
||||
|
||||
@@ -4645,6 +4697,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;
|
||||
}
|
||||
@@ -4697,6 +4752,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
|
||||
}
|
||||
@@ -4906,7 +4964,7 @@ fn embed_image(
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> {
|
||||
pdfcraft_create::image_xobject(doc, name, &upright_image::upright(bytes))
|
||||
pdfcraft_create::image_xobject(doc, name, bytes)
|
||||
}
|
||||
|
||||
/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image.
|
||||
|
||||
+57
-24
@@ -137,7 +137,8 @@ pub enum RemoteDocumentPathError {
|
||||
Empty,
|
||||
TooLong,
|
||||
Hidden,
|
||||
UrlOrNetwork,
|
||||
/// A URL, a network or device path, or a path that climbs above its root.
|
||||
NotLocal(craft_docpath::Refused),
|
||||
MissingSourcePath,
|
||||
/// The target doesn't end in `.pdf`.
|
||||
NotPdf,
|
||||
@@ -155,7 +156,7 @@ impl std::fmt::Display for RemoteDocumentPathError {
|
||||
Self::Empty => f.write_str("the file specification is empty"),
|
||||
Self::TooLong => f.write_str("the file specification is too long"),
|
||||
Self::Hidden => f.write_str("the file specification contains hidden or control characters"),
|
||||
Self::UrlOrNetwork => f.write_str("only local document paths are supported"),
|
||||
Self::NotLocal(r) => write!(f, "only local document paths are supported: {r}"),
|
||||
Self::MissingSourcePath => f.write_str("a relative link needs a source document path"),
|
||||
Self::NotPdf => f.write_str("the linked file is not a .pdf file"),
|
||||
Self::NotAFile => f.write_str("the linked path is not a regular file"),
|
||||
@@ -173,7 +174,9 @@ pub fn remote_document_path_is_absolute(raw: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Resolve a PDF remote-Go-To file specification to a local path without opening it.
|
||||
/// Relative paths are based on the source document; an in-memory source has no fallback to CWD.
|
||||
/// Relative paths are based on the source document, which should be an absolute path; an
|
||||
/// in-memory source has no fallback to CWD. URLs (including `file:`), network and device paths are
|
||||
/// refused ([`craft_docpath::resolve`]).
|
||||
pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result<PathBuf, RemoteDocumentPathError> {
|
||||
if raw.is_empty() || raw.trim().is_empty() {
|
||||
return Err(RemoteDocumentPathError::Empty);
|
||||
@@ -184,25 +187,22 @@ pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result<
|
||||
if raw.chars().any(hides_text) {
|
||||
return Err(RemoteDocumentPathError::Hidden);
|
||||
}
|
||||
if raw.starts_with("//") || raw.starts_with("\\\\") || raw.starts_with("/\\") || raw.starts_with("\\/") {
|
||||
return Err(RemoteDocumentPathError::UrlOrNetwork);
|
||||
}
|
||||
let path = Path::new(raw);
|
||||
let drive_absolute = remote_document_path_is_absolute(raw);
|
||||
// A GoToR file specification is a path, not a URL: any scheme of two or more characters is
|
||||
// refused (one letter is a drive), `file:` and names such as `custom:target.pdf` included.
|
||||
if let Some((scheme, _)) = raw.split_once(':') {
|
||||
let valid_scheme = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
|
||||
let is_scheme = scheme.len() > 1
|
||||
&& scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
|
||||
&& scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
|
||||
let windows_drive = scheme.len() == 1 && drive_absolute;
|
||||
if valid_scheme && !windows_drive {
|
||||
return Err(RemoteDocumentPathError::UrlOrNetwork);
|
||||
if is_scheme {
|
||||
return Err(RemoteDocumentPathError::NotLocal(craft_docpath::Refused::Url));
|
||||
}
|
||||
}
|
||||
if drive_absolute {
|
||||
return Ok(path.to_path_buf());
|
||||
let path = craft_docpath::resolve(raw, source).map_err(RemoteDocumentPathError::NotLocal)?;
|
||||
// A relative result would be read against the working directory.
|
||||
if !path.is_absolute() {
|
||||
return Err(RemoteDocumentPathError::MissingSourcePath);
|
||||
}
|
||||
let source = source.ok_or(RemoteDocumentPathError::MissingSourcePath)?;
|
||||
let parent = source.parent().ok_or(RemoteDocumentPathError::MissingSourcePath)?;
|
||||
Ok(parent.join(path))
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Largest linked PDF a GoToR link may open (1 GiB, the largest size the large-file work measures).
|
||||
@@ -503,23 +503,56 @@ mod tests {
|
||||
use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve};
|
||||
use std::path::Path;
|
||||
|
||||
let source = Path::new("/docs/chapter/source.pdf");
|
||||
assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(Path::new("/docs/chapter/../target.pdf")));
|
||||
let source = std::env::temp_dir().join("chapter").join("source.pdf");
|
||||
let source = source.as_path();
|
||||
let folder = source.parent().unwrap();
|
||||
assert_eq!(resolve(Some(source), "target.pdf"), Ok(folder.join("target.pdf")));
|
||||
assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(folder.parent().unwrap().join("target.pdf").as_path()));
|
||||
#[cfg(unix)]
|
||||
assert_eq!(resolve(Some(source), "/manual/target.pdf").as_deref(), Ok(Path::new("/manual/target.pdf")));
|
||||
assert_eq!(resolve(None, "target.pdf"), Err(E::MissingSourcePath));
|
||||
assert_eq!(resolve(Some(Path::new("source.pdf")), "target.pdf"), Err(E::MissingSourcePath), "a relative source is not used");
|
||||
assert_eq!(resolve(Some(source), ""), Err(E::Empty));
|
||||
assert_eq!(resolve(Some(source), " "), Err(E::Empty));
|
||||
for url in ["file:///tmp/a.pdf", "https://example.org/a.pdf", "smb://host/share/a.pdf", "ftp://host/a.pdf", "custom:target.pdf"] {
|
||||
assert_eq!(resolve(Some(source), url), Err(E::UrlOrNetwork), "{url}");
|
||||
}
|
||||
for network_path in ["//server/share/a.pdf", "\\\\server\\share\\a.pdf", "\\\\?\\C:\\a.pdf", "\\\\.\\device\\a.pdf", "/\\server/share/a.pdf"]
|
||||
for url in
|
||||
["file:///tmp/a.pdf", "file://h/s/a.pdf", "https://example.org/a.pdf", "smb://host/share/a.pdf", "ftp://host/a.pdf", "custom:target.pdf"]
|
||||
{
|
||||
assert_eq!(resolve(Some(source), network_path), Err(E::UrlOrNetwork), "{network_path:?}");
|
||||
assert!(matches!(resolve(Some(source), url), Err(E::NotLocal(_))), "{url}");
|
||||
}
|
||||
assert_eq!(resolve(Some(source), "folder/hidden\u{200B}.pdf"), Err(E::Hidden));
|
||||
assert_eq!(resolve(Some(source), &"a".repeat(super::MAX_REMOTE_DOCUMENT_PATH_BYTES + 1)), Err(E::TooLong));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_document_paths_to_other_computers_and_devices_are_refused() {
|
||||
use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve};
|
||||
|
||||
let source = std::env::temp_dir().join("chapter").join("source.pdf");
|
||||
let source = source.as_path();
|
||||
// Refused before any filesystem access: none of these exist, and a stat of a network path
|
||||
// would contact its host.
|
||||
for not_local in [
|
||||
"//server/share/a.pdf",
|
||||
"\\\\server\\share\\a.pdf",
|
||||
"\\\\h\\s\\a.pdf",
|
||||
"\\\\?\\UNC\\h\\s\\a.pdf",
|
||||
"\\??\\UNC\\h\\s\\a.pdf",
|
||||
"/??/x",
|
||||
"\\??\\C:\\a.pdf",
|
||||
"\\\\.\\device\\a.pdf",
|
||||
"/\\server/share/a.pdf",
|
||||
"NUL.pdf",
|
||||
] {
|
||||
assert!(matches!(resolve(Some(source), not_local), Err(E::NotLocal(_))), "{not_local:?}");
|
||||
}
|
||||
// Linux reads `//tmp/x` as `/tmp/x`, but on Windows it names a share.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let doubled = format!("/{}", source.with_file_name("a.pdf").display());
|
||||
assert!(matches!(resolve(Some(source), &doubled), Err(E::NotLocal(_))), "{doubled}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_follow_the_artcraft_scheme() {
|
||||
assert_eq!(super::APP_PAGE, format!("{}/apps/{}", super::WEBSITE, super::APP));
|
||||
|
||||
+313
-8
@@ -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<u8> {
|
||||
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::<Vec<_>>());
|
||||
@@ -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<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 6 0 R] >> >> >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R /Resources << /Properties << /A 5 0 R /B 6 0 R >> >> >>".to_vec(),
|
||||
[format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content, b"\nendstream"].concat(),
|
||||
b"<< /Type /OCG /Name (Layer A) >>".to_vec(),
|
||||
b"<< /Type /OCG /Name (Layer B) >>".to_vec(),
|
||||
];
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
pdf.extend_from_slice(o);
|
||||
pdf.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
|
||||
// Red and green pixels on the first printed sheet.
|
||||
let printed = |s: &Session, id: DocId| {
|
||||
let bytes = s.print_pdf(id, &print::Settings { pages: vec![0], ..Default::default() }).unwrap();
|
||||
let mut out = Session::new();
|
||||
let printed = out.open("print.pdf", None, Arc::new(bytes), None).unwrap();
|
||||
let page = render_page(&out, printed);
|
||||
let px = page.rgba.as_chunks::<4>().0;
|
||||
let red = px.iter().filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count();
|
||||
let green = px.iter().filter(|p| p[1] > 200 && p[0] < 60 && p[2] < 60).count();
|
||||
(red, green)
|
||||
};
|
||||
let mut s = Session::new();
|
||||
let id = s.open("layers.pdf", None, Arc::new(pdf), None).unwrap();
|
||||
let (red, green) = printed(&s, id);
|
||||
assert!(red > 100 && green > 100, "untouched layers both print: {red} red, {green} green");
|
||||
let a = s.get(id).unwrap().info.layers.iter().position(|l| l.name == "Layer A").unwrap();
|
||||
assert!(s.set_layer_visible(id, a, false));
|
||||
let (red, green) = printed(&s, id);
|
||||
assert_eq!(red, 0, "Layer A was hidden, so it doesn't print");
|
||||
assert!(green > 100, "Layer B still prints: {green} green");
|
||||
assert!(s.set_layer_visible(id, a, true));
|
||||
let (red, _) = printed(&s, id);
|
||||
assert!(red > 100, "shown again, Layer A prints again: {red} red");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printing_lays_out_sheets_that_render() {
|
||||
let (s, id) = session_with(5);
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -2448,6 +2574,48 @@ fn pages_export_as_png_jpeg_and_tiff() {
|
||||
let tif = ex.image(0, 144.0, ImageFormat::Tiff).unwrap();
|
||||
let decoded = image::load_from_memory_with_format(&tif, image::ImageFormat::Tiff).unwrap();
|
||||
assert_eq!((decoded.width(), decoded.height()), (400, 600));
|
||||
let mut tags = tiff::decoder::Decoder::new(std::io::Cursor::new(&tif)).unwrap();
|
||||
let compression = tags.get_tag_u32(tiff::tags::Tag::Compression).unwrap();
|
||||
let predictor = tags.get_tag_u32(tiff::tags::Tag::Predictor).unwrap();
|
||||
assert_eq!((compression, predictor), (5, 1), "LZW, without a predictor");
|
||||
}
|
||||
|
||||
/// A TIFF's resolution unit (2 = inch) and its horizontal and vertical resolution.
|
||||
fn tiff_resolution(bytes: &[u8]) -> (u32, f64, f64) {
|
||||
use tiff::{decoder::ifd::Value, tags::Tag};
|
||||
let mut d = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut res = |tag| match d.get_tag(tag).unwrap() {
|
||||
Value::Rational(n, d) => f64::from(n) / f64::from(d),
|
||||
v => panic!("{tag:?} is {v:?}"),
|
||||
};
|
||||
let (x, y) = (res(Tag::XResolution), res(Tag::YResolution));
|
||||
(d.get_tag_u32(Tag::ResolutionUnit).unwrap(), x, y)
|
||||
}
|
||||
|
||||
/// An exported TIFF or JPEG says the resolution it was drawn at, so other programs show and print it
|
||||
/// at the page's size. A TIFF said 1 × 1 with no unit, and a JPEG said nothing.
|
||||
#[test]
|
||||
fn exported_pages_record_the_resolution_they_were_drawn_at() {
|
||||
use crate::export::{Exporter, ImageFormat};
|
||||
let (s, id) = session_with(1);
|
||||
let mut ex = Exporter::new(s.get(id).unwrap());
|
||||
assert_eq!(tiff_resolution(&ex.image(0, 144.0, ImageFormat::Tiff).unwrap()), (2, 144.0, 144.0));
|
||||
let jpg = ex.image(0, 144.0, ImageFormat::Jpeg { quality: 85 }).unwrap();
|
||||
assert_eq!(&jpg[6..11], b"JFIF\0");
|
||||
assert_eq!(jpg[13..18], [1, 0, 144, 0, 144], "JFIF density in dots per inch");
|
||||
// pHYs counts pixels per metre: 72 dpi would read back as 72.009, and a page made from the PNG
|
||||
// (doc_create from images) would come out 0.01% small.
|
||||
let png = ex.image(0, 72.0, ImageFormat::Png).unwrap();
|
||||
assert!(png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap().info().pixel_dims.is_none(), "a PNG states no resolution");
|
||||
|
||||
// A page too large for the renderer at the asked resolution says the one it was drawn at.
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let bytes = s.create_blank(14_400.0, 792.0, 1).unwrap();
|
||||
let id = s.open("wide.pdf", None, bytes, None).unwrap();
|
||||
let mut ex = Exporter::new(s.get(id).unwrap());
|
||||
let used = ex.dpi_used(0, 600.0);
|
||||
let (unit, x, y) = tiff_resolution(&ex.image(0, 600.0, ImageFormat::Tiff).unwrap());
|
||||
assert!(unit == 2 && (x - used).abs() < 0.01 && (y - used).abs() < 0.01, "{x} × {y}, drawn at {used}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3286,6 +3454,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 +3490,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 +3830,34 @@ fn detecting_fields_on_a_scanned_form() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detecting_comb_boxes_adds_one_comb_field_per_row() {
|
||||
// The rows from #1887: five 18 x 22 cells and ten 16 x 20 cells, each a stroked `re`.
|
||||
let mut body = String::from("BT /F1 10 Tf 40 205 Td (Postcode A) Tj ET BT /F1 10 Tf 40 105 Td (Postcode B) Tj ET\n");
|
||||
for i in 0..5 {
|
||||
body.push_str(&format!("{} 200 18 22 re S\n", 120 + i * 18));
|
||||
}
|
||||
for i in 0..10 {
|
||||
body.push_str(&format!("{} 100 16 20 re S\n", 120 + i * 16));
|
||||
}
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("comb.pdf", None, Arc::new(one_line_page("/Font << /F1 4 0 R >>", &body)), None).unwrap();
|
||||
let found: Vec<(String, pdfcraft_forms::detect::Kind)> = s.detect_fields(id, &[]).into_iter().map(|(_, c)| (c.name, c.kind)).collect();
|
||||
assert_eq!(
|
||||
found,
|
||||
[
|
||||
("Postcode A".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 5 }),
|
||||
("Postcode B".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 10 })
|
||||
]
|
||||
);
|
||||
s.auto_detect_fields(id, &[]).unwrap();
|
||||
let d = s.get(id).unwrap();
|
||||
let combs: Vec<(&str, FormFieldKind, bool, Option<usize>)> =
|
||||
d.form.iter().map(|f| (f.name.as_str(), f.kind, f.has(pdfcraft_forms::flags::COMB), f.max_len)).collect();
|
||||
assert_eq!(combs, [("Postcode A", FormFieldKind::Text, true, Some(5)), ("Postcode B", FormFieldKind::Text, true, Some(10))]);
|
||||
assert_eq!(d.can_undo(), Some("Detect form fields"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comparing_two_versions_of_a_document() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
@@ -5801,3 +6021,88 @@ fn cropping_an_existing_image_clips_it_and_moves_with_it() {
|
||||
// No area, or a box off the image, is refused.
|
||||
assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([10.0, 10.0, 10.0, 50.0]) }).is_err());
|
||||
}
|
||||
|
||||
/// A 60 x 20 JPEG at 144 x 72 dpi, tagged with EXIF Orientation 6 (shown turned 90 degrees clockwise).
|
||||
fn phone_jpeg() -> Vec<u8> {
|
||||
use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit};
|
||||
let mut jpeg = Vec::new();
|
||||
let mut encoder = JpegEncoder::new(&mut jpeg);
|
||||
encoder.set_pixel_density(PixelDensity { density: (144, 72), unit: PixelDensityUnit::Inches });
|
||||
encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap();
|
||||
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
|
||||
exif.extend_from_slice(&[6, 0, 0, 0, 0, 0, 0, 0]);
|
||||
let mut out = jpeg[..2].to_vec();
|
||||
out.extend_from_slice(&[0xff, 0xe1]);
|
||||
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
|
||||
out.extend_from_slice(&exif);
|
||||
out.extend_from_slice(&jpeg[2..]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() {
|
||||
let mut session = Session::new();
|
||||
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
|
||||
let id = session.open_new("form.pdf", blank).unwrap();
|
||||
// A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright.
|
||||
// Named "Signature" so `signature_image` finds its picture.
|
||||
let file = MarkFile { name: "photo.jpg".into(), bytes: Arc::new(phone_jpeg()), page: 0 };
|
||||
let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() };
|
||||
session.apply(id, stamp).unwrap();
|
||||
let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos;
|
||||
let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap();
|
||||
let obj = cos.get(picture);
|
||||
let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() };
|
||||
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60)));
|
||||
let rect = pdfcraft_annot::summaries(cos)[0].rect;
|
||||
assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}");
|
||||
}
|
||||
|
||||
/// Opening a paragraph and applying its edit read the page's text blocks; after an edit they are
|
||||
/// still read once per document state, and undo and redo never get another state's blocks.
|
||||
#[test]
|
||||
fn text_blocks_stay_cached_after_an_edit_and_follow_undo_and_redo() {
|
||||
let (mut session, id) = session_with(1);
|
||||
let blocks = |s: &Session| s.get(id).unwrap().editor.as_ref().unwrap().text_blocks(0).unwrap();
|
||||
let before = blocks(&session);
|
||||
assert_eq!(before[0].editor_text, "Page 1");
|
||||
let edit = Edit::EditTextBlock { page: 0, block: 0, text: "Edited".into(), style: pdfcraft_edit::BlockStyle::default() };
|
||||
session.apply(id, edit).unwrap();
|
||||
let edited = blocks(&session);
|
||||
assert_eq!(edited[0].editor_text, "Edited");
|
||||
assert!(Arc::ptr_eq(&edited, &blocks(&session)), "the edited page is grouped once");
|
||||
session.undo(id).unwrap();
|
||||
assert_eq!(blocks(&session)[0].editor_text, "Page 1");
|
||||
session.redo(id).unwrap();
|
||||
let redone = blocks(&session);
|
||||
assert_eq!(redone[0].editor_text, "Edited");
|
||||
assert!(Arc::ptr_eq(&redone, &blocks(&session)));
|
||||
}
|
||||
|
||||
/// A new document, or one restored from recovery, has never been written where it belongs: undo
|
||||
/// back to the bytes it was opened from must not make it clean, or closing it would discard the
|
||||
/// whole document without asking. Saving makes it clean; Revert of a saved one too.
|
||||
#[test]
|
||||
fn undo_never_makes_an_unsaved_document_clean() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let new = s.open_new("Combined.pdf", Arc::new(fixture(2))).unwrap();
|
||||
s.apply(new, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
|
||||
s.undo(new).unwrap();
|
||||
assert!(s.get(new).unwrap().dirty, "a new document is unsaved until it is saved");
|
||||
assert_eq!(s.autosave_snapshots().len(), 1, "and still needs recovery");
|
||||
s.revert(new).unwrap();
|
||||
assert!(s.get(new).unwrap().dirty, "Revert goes back to the created bytes, which aren't saved anywhere");
|
||||
|
||||
let recovered = s.open("Report.pdf", None, Arc::new(fixture(2)), None).unwrap();
|
||||
s.mark_recovered(recovered, Some("Report.pdf".into()));
|
||||
s.apply(recovered, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
|
||||
s.undo(recovered).unwrap();
|
||||
assert!(s.get(recovered).unwrap().dirty, "the recovered work was never saved");
|
||||
|
||||
let saved = s.save_bytes(new).unwrap();
|
||||
s.mark_saved(new, saved, Some("Combined.pdf".into())).unwrap();
|
||||
assert!(!s.get(new).unwrap().dirty);
|
||||
s.apply(new, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
|
||||
s.undo(new).unwrap();
|
||||
assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean");
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Contributor-original pages. Added marks must not inherit an unfinished hidden layer.
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_incremental};
|
||||
use pdfcraft_engine::{AddedText, Edit, HeaderFooter, Session, Watermark};
|
||||
use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind};
|
||||
|
||||
const VISIBLE: &str = "BT /F1 18 Tf 72 700 Td (Visible text) Tj ET\n";
|
||||
const HIDDEN: &str = "BT /F1 18 Tf 72 600 Td (Hidden note) Tj ET\n";
|
||||
|
||||
fn fixture(parts: &[String]) -> Arc<Vec<u8>> {
|
||||
let mut doc = Document::new_empty();
|
||||
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"F1".to_vec(), Object::Ref(doc.add(font)));
|
||||
let mut group = Dict::new();
|
||||
group.set(b"Type".to_vec(), Object::name("OCG"));
|
||||
group.set(b"Name".to_vec(), Object::String(pdfcraft_cos::PdfString::text("Hidden layer")));
|
||||
let group = doc.add(group);
|
||||
let mut properties = Dict::new();
|
||||
properties.set(b"L1".to_vec(), Object::Ref(group));
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
resources.set(b"Properties".to_vec(), Object::Dict(properties));
|
||||
let mut config = Dict::new();
|
||||
config.set(b"OFF".to_vec(), Object::Array(vec![Object::Ref(group)]));
|
||||
let mut oc = Dict::new();
|
||||
oc.set(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(group)]));
|
||||
oc.set(b"D".to_vec(), Object::Dict(config));
|
||||
doc.update_dict(doc.root().unwrap(), |d| d.set(b"OCProperties".to_vec(), Object::Dict(oc))).unwrap();
|
||||
let streams = parts.iter().map(|s| Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), s.as_bytes().to_vec()))))).collect();
|
||||
let mut page = Dict::new();
|
||||
page.set(b"Type".to_vec(), Object::name("Page"));
|
||||
page.set(b"Parent".to_vec(), Object::Ref(pages));
|
||||
page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()]));
|
||||
page.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
page.set(b"Contents".to_vec(), Object::Array(streams));
|
||||
let page = doc.add(page);
|
||||
doc.update_dict(pages, |d| {
|
||||
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
|
||||
d.set(b"Count".to_vec(), Object::Int(1));
|
||||
})
|
||||
.unwrap();
|
||||
Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())
|
||||
}
|
||||
|
||||
fn render(bytes: Arc<Vec<u8>>) -> (Vec<u8>, String) {
|
||||
let mut renderer = PageRenderer::new(bytes, Default::default());
|
||||
let pixels = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
assert!(pixels.error.is_none(), "{:?}", pixels.error);
|
||||
let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() });
|
||||
assert!(text.error.is_none(), "{:?}", text.error);
|
||||
let text = text.text.unwrap();
|
||||
(pixels.rgba.to_vec(), text.text_of(0..text.glyphs.len()))
|
||||
}
|
||||
|
||||
fn edit(kind: &str) -> Edit {
|
||||
match kind {
|
||||
"HEADER" | "000001" => {
|
||||
let mut settings = HeaderFooter::default();
|
||||
if kind == "HEADER" {
|
||||
settings.text[1] = "HEADER".into();
|
||||
} else {
|
||||
settings.text[5] = "<<Bates Number#6#1##>>".into();
|
||||
}
|
||||
Edit::AddHeaderFooter { pages: vec![0], settings, replace: true }
|
||||
}
|
||||
"WATERMARK" => Edit::AddWatermark {
|
||||
pages: vec![0],
|
||||
settings: Watermark { text: kind.into(), opacity: 1.0, rotation: 0.0, font_size: 18.0, ..Default::default() },
|
||||
replace: true,
|
||||
file: None,
|
||||
},
|
||||
"ADDED" => Edit::AddText { page: 0, text: AddedText { text: kind.into(), rect: [72.0, 300.0, 250.0, 330.0], ..Default::default() } },
|
||||
_ => panic!("unknown mark"),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_and_save(input: Arc<Vec<u8>>, kind: &str) -> Arc<Vec<u8>> {
|
||||
let mut session = Session::new();
|
||||
let id = session.open("fixture.pdf", None, input.clone(), None).unwrap();
|
||||
session.apply(id, edit(kind)).unwrap();
|
||||
let saved = session.save_bytes(id).unwrap();
|
||||
assert!(saved.starts_with(&input), "incremental save preserves the original file bytes");
|
||||
assert_original_streams(&input, &saved);
|
||||
let mut reopened = Session::new();
|
||||
let id = reopened.open("saved.pdf", None, saved.clone(), None).unwrap();
|
||||
assert_eq!(reopened.save_bytes(id).unwrap().as_slice(), saved.as_slice());
|
||||
saved
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_four_mark_paths_remain_visible_after_saved_unfinished_marked_content() {
|
||||
let mut failures = Vec::new();
|
||||
for (case, opening, hidden) in [("hidden", "/OC /L1 BDC\n", true), ("artifact", "/Artifact BMC\n", false), ("balanced", "/OC /L1 BDC\n", true)] {
|
||||
let closed = fixture(&[format!("{VISIBLE}{opening}{HIDDEN}EMC\n")]);
|
||||
let input = if case == "balanced" { closed.clone() } else { fixture(&[format!("{VISIBLE}{opening}{HIDDEN}")]) };
|
||||
let original = render(input.clone());
|
||||
assert!(original.1.contains("Visible text"));
|
||||
assert_eq!(original.1.contains("Hidden note"), !hidden);
|
||||
for kind in ["HEADER", "000001", "WATERMARK", "ADDED"] {
|
||||
let saved = add_and_save(input.clone(), kind);
|
||||
|
||||
let actual = render(saved);
|
||||
let expected_bytes = add_and_save(closed.clone(), kind);
|
||||
|
||||
let expected = render(expected_bytes);
|
||||
let mut wanted = vec!["Visible text", kind];
|
||||
if !hidden {
|
||||
wanted.push("Hidden note");
|
||||
}
|
||||
wanted.sort_unstable();
|
||||
let pass = actual.0 == expected.0 && actual.1 == expected.1 && lines(&actual.1) == wanted;
|
||||
eprintln!("{case}/{kind}: visible={} closed-control-pixels={}", actual.1.contains(kind), actual.0 == expected.0);
|
||||
if !pass {
|
||||
failures.push(format!("{case}/{kind}: {:?}", actual.1));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
fn stream_list(doc: &Document) -> Vec<Object> {
|
||||
let page = pdfcraft_model::pages(doc).remove(0);
|
||||
let contents = doc.resolve(page.dict.get(b"Contents").unwrap());
|
||||
match &*contents {
|
||||
Object::Array(items) => items.clone(),
|
||||
_ => vec![page.dict.get(b"Contents").unwrap().clone()],
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_original_streams(before: &Arc<Vec<u8>>, after: &Arc<Vec<u8>>) {
|
||||
let original = Document::open(before.clone()).unwrap();
|
||||
let saved = Document::open(after.clone()).unwrap();
|
||||
let saved_list = stream_list(&saved);
|
||||
for reference in stream_list(&original) {
|
||||
assert!(saved_list.contains(&reference), "the original stream stays on the page");
|
||||
assert_eq!(original.resolve(&reference), saved.resolve(&reference), "original encoded stream object remains unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
fn lines(text: &str) -> Vec<&str> {
|
||||
let mut lines: Vec<_> = text.lines().filter(|line| !line.is_empty()).collect();
|
||||
lines.sort_unstable();
|
||||
lines
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_stream_edges_and_malformed_graphics_keep_hidden_content_hidden() {
|
||||
let cases: Vec<(&str, Vec<String>, usize)> = vec![
|
||||
("nested", vec![format!("{VISIBLE}/OC /L1 BDC /Artifact BMC {HIDDEN}")], 2),
|
||||
("split operands", vec![format!("{VISIBLE}/OC"), format!("/L1 BDC {HIDDEN}")], 1),
|
||||
("split close", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}"), "EMC\n".into()], 0),
|
||||
("trailing comment", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}% EMC")], 1),
|
||||
("cross-stream comment", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}% EMC"), "EMC BMC\n".into()], 1),
|
||||
("unclosed graphics", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}q")], 1),
|
||||
("balanced graphics open marked", vec![format!("q {VISIBLE}/OC /L1 BDC {HIDDEN}Q")], 1),
|
||||
("malformed graphics", vec![format!("Q {VISIBLE}/OC /L1 BDC {HIDDEN}")], 1),
|
||||
("invalid begins", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}123 BMC /Artifact BDC")], 1),
|
||||
("extra begins", vec![format!("{VISIBLE}123 /OC /L1 BDC {HIDDEN}123 /Artifact BMC")], 2),
|
||||
("extra close", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}123 EMC")], 0),
|
||||
("underflow", vec![format!("EMC {VISIBLE}/OC /L1 BDC {HIDDEN}")], 1),
|
||||
("operator strings", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}/Note <</Text (BMC BDC EMC)>> DP")], 1),
|
||||
("inline samples", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}BI /W 11 /H 1 /BPC 8 /CS /G ID BMC BDC EMC EI\n")], 1),
|
||||
];
|
||||
for (case, parts, close_count) in cases {
|
||||
let input = fixture(&parts);
|
||||
let mut closed = parts.clone();
|
||||
closed.push(format!("\n{}", "EMC\n".repeat(close_count)));
|
||||
let actual = render(add_and_save(input, "HEADER"));
|
||||
let expected = render(add_and_save(fixture(&closed), "HEADER"));
|
||||
assert_eq!(lines(&actual.1), lines("Visible text\nHEADER"), "{case}");
|
||||
assert!(actual.0 == expected.0, "{case}: pixels equal the explicitly closed control");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_and_removing_marks_restores_the_original_stream_list() {
|
||||
let input = fixture(&[format!("{VISIBLE}/OC /L1 BDC {HIDDEN}")]);
|
||||
let original = Document::open(input.clone()).unwrap();
|
||||
for kind in ["HEADER", "000001", "WATERMARK"] {
|
||||
let mut session = Session::new();
|
||||
let mut id = session.open("fixture.pdf", None, input.clone(), None).unwrap();
|
||||
for _ in 0..3 {
|
||||
session.apply(id, edit(kind)).unwrap();
|
||||
let saved = session.save_bytes(id).unwrap();
|
||||
let page = Document::open(saved.clone()).unwrap();
|
||||
assert_eq!(stream_list(&page).len(), 4, "{kind}: updating does not add wrappers");
|
||||
assert_original_streams(&input, &saved);
|
||||
assert_eq!(lines(&render(saved.clone()).1), lines(&format!("Visible text\n{kind}")));
|
||||
session = Session::new();
|
||||
id = session.open("saved.pdf", None, saved, None).unwrap();
|
||||
}
|
||||
let mark_kind = if kind == "WATERMARK" { pdfcraft_edit::MarkKind::Watermark } else { pdfcraft_edit::MarkKind::HeaderFooter };
|
||||
session.apply(id, Edit::RemoveMarks { kind: mark_kind }).unwrap();
|
||||
let saved = session.save_bytes(id).unwrap();
|
||||
let page = Document::open(saved.clone()).unwrap();
|
||||
assert_eq!(stream_list(&page), stream_list(&original), "{kind}: removal restores original references");
|
||||
assert_original_streams(&input, &saved);
|
||||
assert!(render(saved) == render(input.clone()), "{kind}: removal restores original pixels and text");
|
||||
session.undo(id).unwrap();
|
||||
assert_eq!(lines(&render(session.save_bytes(id).unwrap()).1), lines(&format!("Visible text\n{kind}")));
|
||||
session.redo(id).unwrap();
|
||||
assert!(render(session.save_bytes(id).unwrap()) == render(input.clone()), "redo removal restores the original rendering");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn added_update_delete_and_readd_preserve_existing_wrapper_behavior() {
|
||||
let input = fixture(&[format!("{VISIBLE}/OC /L1 BDC {HIDDEN}")]);
|
||||
let mut session = Session::new();
|
||||
let mut id = session.open("fixture.pdf", None, input.clone(), None).unwrap();
|
||||
for _ in 0..3 {
|
||||
session.apply(id, edit("ADDED")).unwrap();
|
||||
assert_eq!(session.get(id).unwrap().added().len(), 1);
|
||||
let text = AddedText { text: "UPDATED".into(), rect: [72.0, 300.0, 250.0, 330.0], ..Default::default() };
|
||||
session.apply(id, Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_edit::Content::Text(text) }).unwrap();
|
||||
let saved = session.save_bytes(id).unwrap();
|
||||
assert_eq!(lines(&render(saved.clone()).1), lines("Visible text\nUPDATED"));
|
||||
assert_eq!(stream_list(&Document::open(saved.clone()).unwrap()).len(), 4);
|
||||
assert_original_streams(&input, &saved);
|
||||
session = Session::new();
|
||||
id = session.open("saved.pdf", None, saved, None).unwrap();
|
||||
session.apply(id, Edit::DeleteContent { page: 0, index: 0 }).unwrap();
|
||||
let saved = session.save_bytes(id).unwrap();
|
||||
assert_eq!(stream_list(&Document::open(saved.clone()).unwrap()).len(), 3, "Added deletion retains the established wrappers");
|
||||
assert!(render(saved.clone()) == render(input.clone()), "deleting Added restores the original rendering");
|
||||
assert_original_streams(&input, &saved);
|
||||
session = Session::new();
|
||||
id = session.open("saved.pdf", None, saved, None).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_existing_behind_watermark_does_not_inherit_or_reveal_hidden_content() {
|
||||
let input = fixture(&[format!("{VISIBLE}/OC /L1 BDC {HIDDEN}")]);
|
||||
let mut session = Session::new();
|
||||
let id = session.open("fixture.pdf", None, input.clone(), None).unwrap();
|
||||
session
|
||||
.apply(
|
||||
id,
|
||||
Edit::AddWatermark {
|
||||
pages: vec![0],
|
||||
settings: Watermark { text: "BEHIND".into(), behind: true, font_size: 18.0, opacity: 1.0, rotation: 0.0, ..Default::default() },
|
||||
replace: false,
|
||||
file: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
session.apply(id, edit("HEADER")).unwrap();
|
||||
let saved = session.save_bytes(id).unwrap();
|
||||
assert_eq!(lines(&render(saved.clone()).1), lines("Visible text\nBEHIND\nHEADER"));
|
||||
assert_original_streams(&input, &saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_mark_closes_hidden_content_appended_after_an_old_wrapper() {
|
||||
let bytes = fixture(&["q %PdfCraft\n".into(), VISIBLE.into(), "Q %PdfCraft\n".into(), format!("/OC /L1 BDC {HIDDEN}")]);
|
||||
let mut doc = Document::open(bytes).unwrap();
|
||||
let list = stream_list(&doc);
|
||||
for index in [0, 2] {
|
||||
let reference = list[index].as_ref().unwrap();
|
||||
let Object::Stream(mut stream) = doc.get(reference).as_ref().clone() else { panic!("stream") };
|
||||
stream.dict.set(b"PCMark".to_vec(), Object::name("Wrap"));
|
||||
doc.set(reference, Object::Stream(stream));
|
||||
}
|
||||
let input = Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap());
|
||||
let saved = add_and_save(input, "HEADER");
|
||||
assert_eq!(lines(&render(saved).1), lines("Visible text\nHEADER"));
|
||||
}
|
||||
+106
-2
@@ -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<bool> {
|
||||
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<Table>, Vec<bool>) {
|
||||
es.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL);
|
||||
es
|
||||
};
|
||||
let cont = continuations(blocks);
|
||||
let qual: Vec<bool> = rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let mut cs: Vec<usize> = 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<usize> = 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("<table>"), "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("<table>"), "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("<table>"), "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 {
|
||||
|
||||
@@ -24,7 +24,7 @@ encode_flate(data) -> Vec<u8> // zlib, level 6
|
||||
|
||||
- `chain` is applied first to last, as in a `/Filter` array.
|
||||
- Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. Decryption belongs to `crypt`.
|
||||
- `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`.
|
||||
- `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`. This also holds when Flate retries raw deflate after a misleading zlib header, in both strict and tolerant decoding.
|
||||
- `decode_tolerant` turns `Corrupt` into "what was decoded so far" plus `partial = true`, then runs the rest of the chain on it (predictors are applied to partial output too). `LimitExceeded` and `Unsupported` are still errors.
|
||||
|
||||
## Behaviour
|
||||
|
||||
@@ -26,7 +26,12 @@ pub(crate) fn decode(data: &[u8], max: usize) -> Step {
|
||||
Some((_, flg)) if flg & 0x20 != 0 => Err(Failure::corrupt(NAME, "zlib preset dictionary is not allowed in PDF", Vec::new())),
|
||||
Some(_) => match inflate_raw(&data[2..], max) {
|
||||
// A header that only looked valid: retry the whole input as raw deflate.
|
||||
Err(f) if f.partial.is_empty() && matches!(f.error, crate::FilterError::Corrupt { .. }) => inflate_raw(data, max).map_err(|_| f),
|
||||
Err(f) if f.partial.is_empty() && matches!(f.error, crate::FilterError::Corrupt { .. }) => match inflate_raw(data, max) {
|
||||
// Keep the original corruption diagnostic only if the retry is corrupt
|
||||
// too. A limit error must remain fatal even for tolerant decoding.
|
||||
Err(retry) if matches!(retry.error, crate::FilterError::Corrupt { .. }) => Err(f),
|
||||
retry => retry,
|
||||
},
|
||||
r => r,
|
||||
},
|
||||
None => inflate_raw(data, max),
|
||||
|
||||
@@ -295,6 +295,26 @@ mod tests {
|
||||
assert_eq!(decode(&chain, &hex, 100).unwrap().len(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_flate_fallback_preserves_output_limits() {
|
||||
// RFC 1951 stored blocks: the first is nonfinal with LEN=257 and
|
||||
// NLEN=!257, followed by a final empty block. Its ignored alignment
|
||||
// bits make the first two bytes also look like a valid zlib header.
|
||||
let data = vec![b'A'; 257];
|
||||
let mut raw = vec![0x78, 0x01, 0x01, 0xfe, 0xfe];
|
||||
raw.extend_from_slice(&data);
|
||||
raw.extend_from_slice(&[0x01, 0x00, 0x00, 0xff, 0xff]);
|
||||
let chain = [(Filter::Flate, Params::default())];
|
||||
for max in [0, 1, 128, 256] {
|
||||
assert!(matches!(decode(&chain, &raw, max), Err(FilterError::LimitExceeded(m)) if m == max));
|
||||
assert!(matches!(decode_tolerant(&chain, &raw, max), Err(FilterError::LimitExceeded(m)) if m == max));
|
||||
}
|
||||
for max in [257, 258] {
|
||||
assert_eq!(decode(&chain, &raw, max).unwrap(), data);
|
||||
assert_eq!(decode_tolerant(&chain, &raw, max).unwrap(), (data.clone(), false));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerant_flate_returns_partial() {
|
||||
let data: Vec<u8> = (0..20_000u32).map(|i| (i % 97) as u8 ^ (i / 300) as u8).collect();
|
||||
|
||||
@@ -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<Item = Written> + '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");
|
||||
}
|
||||
}
|
||||
+24
-3
@@ -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<u8> {
|
||||
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', '?'] {
|
||||
|
||||
+109
-3
@@ -1,8 +1,8 @@
|
||||
//! What content editing and redaction need from a PDF font: how to split a string into character
|
||||
//! codes, each code's advance width, the glyph height (ascent, descent), and what each code means
|
||||
//! (Unicode) — and back, which text the font can show. Widths come from the font dictionary
|
||||
//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); a standard-14 font with no `/Widths` uses the exact
|
||||
//! published metrics ([`crate::Std14`]).
|
||||
//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); a simple font with no `/Widths` uses its embedded
|
||||
//! sfnt program's advances, else the exact published standard-14 metrics ([`crate::Std14`]).
|
||||
//!
|
||||
//! Meanings come from `/ToUnicode`, else the encoding (`/Encoding` base and `/Differences` glyph
|
||||
//! names, ISO 32000-2 Annex D). A code may deliberately have **no** meaning: where the font's own
|
||||
@@ -167,6 +167,44 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec<u64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A simple font with no `/Widths` is drawn (and found by text search) with its embedded
|
||||
/// program's own advances, so measure with them too; a standard-14 guess from the name can be
|
||||
/// wider and leave the end of a line outside a redaction mark (#2130). The glyph is looked up
|
||||
/// the way the renderer does: the encoding's character in the Unicode cmap, else the code itself
|
||||
/// (as in a symbolic cmap), else `.notdef`. Only sfnt programs are read; others keep the guess.
|
||||
fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) {
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::{GlyphId, MetadataProvider as _, raw::TableProvider as _};
|
||||
let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return };
|
||||
for key in [b"FontFile2".as_slice(), b"FontFile3".as_slice()] {
|
||||
let Some(object) = descriptor.get(key) else { continue };
|
||||
let object = doc.resolve(object);
|
||||
let Object::Stream(stream) = object.as_ref() else { continue };
|
||||
let Ok(bytes) = stream.decoded_within(16 << 20) else { continue };
|
||||
let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue };
|
||||
let upem = f64::from(face.metrics(Size::unscaled(), LocationRef::default()).units_per_em);
|
||||
if upem == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let advances = face.glyph_metrics(Size::unscaled(), LocationRef::default());
|
||||
let (charmap, cmap) = (face.charmap(), face.cmap().ok());
|
||||
let found = |g: Option<GlyphId>| g.filter(|g| *g != GlyphId::NOTDEF);
|
||||
m.widths = (0..=255u32)
|
||||
.map(|code| {
|
||||
let glyph = found(m.unicode.encoding.get(&code).and_then(|t| only_char(t)).and_then(|c| charmap.map(c)))
|
||||
.or_else(|| [0, 0xF000, 0xF100, 0xF200].into_iter().find_map(|o| found(cmap.as_ref()?.map_codepoint(code + o))));
|
||||
// A subset holds only the glyphs it draws: 0 keeps `has_glyph` false for the rest.
|
||||
match glyph {
|
||||
None if m.subset => 0.0,
|
||||
_ => advances.advance_width(glyph.unwrap_or(GlyphId::NOTDEF)).map_or(0.0, |w| f64::from(w) * 1000.0 / upem),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
m.first = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace an existing cmap directory entry with a bounded Unicode format-12 table.
|
||||
/// Checked ranges prevent damaged sfnt offsets from indexing outside the source program.
|
||||
fn append_editor_cmap(bytes: &mut Vec<u8>, mappings: &std::collections::BTreeMap<u32, u32>) -> Option<()> {
|
||||
@@ -607,6 +645,9 @@ impl Metrics {
|
||||
Codes::Ranges(r) => r.last().map_or(2, |x| x.0),
|
||||
};
|
||||
m.unicode = unicode_map(doc, font, m.composite);
|
||||
if m.std14.is_some() {
|
||||
embedded_widths(doc, font, &mut m);
|
||||
}
|
||||
let mut reverse = HashMap::new();
|
||||
for (&code, text) in m.unicode.explicit.iter().map(|(c, (_, t))| (c, t)).chain(m.unicode.encoding.iter()) {
|
||||
if let Some(ch) = only_char(text)
|
||||
@@ -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> <FFFF> 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<char, u32> = 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);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,13 @@ delete_field(&mut doc, "email")?;
|
||||
Button1, Date1, Signature1; `/Helv` is added to `/DR`; appearances are drawn at once. Radio
|
||||
buttons joining a group become kid widgets with their own export state. Renaming changes only
|
||||
the last part of a dotted name.
|
||||
- `Widget::caption` reads a push button's normal label (`/MK /CA`, including indirect values).
|
||||
`FieldProps { caption: Some((widget_index, text)), ..Default::default() }` changes or clears
|
||||
that label, independently of the field name. The index is 0-based. A label-only edit redraws
|
||||
just that widget, copying shared/indirect `/MK` and appearance dictionaries before editing.
|
||||
Other widgets, actions, icons, rotation and unknown entries are preserved. Existing icon-only
|
||||
layouts keep hiding the caption; caption rendering retains the existing Helvetica/WinAnsi
|
||||
limits and reports unavailable characters as above.
|
||||
|
||||
## Not yet
|
||||
|
||||
|
||||
+237
-28
@@ -9,6 +9,7 @@
|
||||
//! writes them back when a form is authored. Other scripts are kept untouched and reported as
|
||||
//! unsupported; they need the JavaScript engine (M6.4).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
/// The Format tab.
|
||||
@@ -545,24 +546,55 @@ fn weekday(y: i32, m: u32, d: u32) -> usize {
|
||||
(y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7) as usize
|
||||
}
|
||||
|
||||
/// The order of the year/month/day fields in a format, as letters. A run of literal digits
|
||||
/// (the fixed year in "dd.mm.26") is `'#'` with its value; the others carry 0.
|
||||
fn order(fmt: &str) -> Vec<(char, u32)> {
|
||||
let mut out: Vec<(char, u32)> = Vec::new();
|
||||
/// One field of a date format, in the order the format has them.
|
||||
struct Part {
|
||||
/// `'y'`, `'m'`, `'d'`, `'H'`, `'M'` or `'s'`; a run of literal digits (the fixed year in
|
||||
/// "dd.mm.26") is `'#'`.
|
||||
kind: char,
|
||||
/// The value of a `'#'` run; 0 for the others.
|
||||
literal: u32,
|
||||
/// The digits the field takes when typed run together with the next one: 4 for "yyyy", 2 for
|
||||
/// "yy", "mm", "dd", "HH", "MM" and "ss". 0 when the width varies ("m", "d") or the field is
|
||||
/// a name ("mmm").
|
||||
width: usize,
|
||||
/// The next field follows with nothing between them ("yyyymmdd"), so one run of typed digits
|
||||
/// can hold both.
|
||||
joined: bool,
|
||||
}
|
||||
|
||||
/// The fields of a format (year, month, day, hours, minutes, seconds and literal digits), in
|
||||
/// order.
|
||||
fn order(fmt: &str) -> Vec<Part> {
|
||||
let mut out: Vec<Part> = Vec::new();
|
||||
// Weekday names (ddd, dddd) are shown, not typed: leave them out.
|
||||
let mut cleaned = fmt.replace("dddd", " ");
|
||||
cleaned = cleaned.replace("ddd", " ");
|
||||
let mut prev = ' ';
|
||||
// The letter of the last field's current run and its length so far.
|
||||
let mut run: Option<(char, usize)> = None;
|
||||
let fixed = |k: char, n: usize| match (k, n) {
|
||||
('y', 2 | 4) | ('m' | 'd' | 'H' | 'M' | 's', 2) => n,
|
||||
_ => 0,
|
||||
};
|
||||
for c in cleaned.chars() {
|
||||
let continues = run.is_some_and(|(r, _)| r == c);
|
||||
if let Some((r, n)) = run
|
||||
&& !continues
|
||||
{
|
||||
// The run ended; it was the last field's width.
|
||||
if let Some(last) = out.last_mut().filter(|p| p.kind != '#') {
|
||||
last.width = fixed(if r == 'h' { 'H' } else { r }, n);
|
||||
}
|
||||
run = None;
|
||||
}
|
||||
if let Some(digit) = c.to_digit(10) {
|
||||
match out.last_mut() {
|
||||
Some(('#', n)) if prev.is_ascii_digit() => *n = n.saturating_mul(10).saturating_add(digit),
|
||||
_ => out.push(('#', digit)),
|
||||
Some(p) if p.kind == '#' && prev.is_ascii_digit() => p.literal = p.literal.saturating_mul(10).saturating_add(digit),
|
||||
_ => out.push(Part { kind: '#', literal: digit, width: 0, joined: false }),
|
||||
}
|
||||
prev = c;
|
||||
continue;
|
||||
}
|
||||
prev = c;
|
||||
let k = match c {
|
||||
'y' => 'y',
|
||||
'm' => 'm',
|
||||
@@ -570,11 +602,28 @@ fn order(fmt: &str) -> Vec<(char, u32)> {
|
||||
'H' | 'h' => 'H',
|
||||
'M' => 'M',
|
||||
's' => 's',
|
||||
_ => continue,
|
||||
_ => {
|
||||
prev = c;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if out.last().map(|&(l, _)| l) != Some(k) {
|
||||
out.push((k, 0));
|
||||
if continues {
|
||||
run = run.map(|(r, n)| (r, n.saturating_add(1)));
|
||||
} else {
|
||||
// A field starting right where the last one's letters stopped is joined to it.
|
||||
let adjacent = matches!(prev, 'y' | 'm' | 'd' | 'H' | 'h' | 'M' | 's');
|
||||
if out.last().map(|p| p.kind) != Some(k) {
|
||||
if let Some(last) = out.last_mut().filter(|p| p.kind != '#') {
|
||||
last.joined = adjacent;
|
||||
}
|
||||
out.push(Part { kind: k, literal: 0, width: 0, joined: false });
|
||||
}
|
||||
run = Some((c, 1));
|
||||
}
|
||||
prev = c;
|
||||
}
|
||||
if let (Some((r, n)), Some(last)) = (run, out.last_mut().filter(|p| p.kind != '#')) {
|
||||
last.width = fixed(if r == 'h' { 'H' } else { r }, n);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -587,18 +636,21 @@ pub fn parse_date(text: &str, fmt: &str) -> Option<DateTime> {
|
||||
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<u32> = Vec::new();
|
||||
// Tokens: runs of digits (kept as text, so a run can be split between fields typed without
|
||||
// separators) and month names.
|
||||
let mut nums: VecDeque<String> = VecDeque::new();
|
||||
let mut month_name: Option<u32> = None;
|
||||
let mut cur = String::new();
|
||||
let flush = |cur: &mut String, nums: &mut Vec<u32>, month: &mut Option<u32>| {
|
||||
let flush = |cur: &mut String, nums: &mut VecDeque<String>, month: &mut Option<u32>| {
|
||||
if cur.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Ok(n) = cur.parse::<u32>() {
|
||||
nums.push(n);
|
||||
if cur.starts_with(|c: char| c.is_ascii_digit()) {
|
||||
nums.push_back(std::mem::take(cur));
|
||||
} else if cur.len() >= 3
|
||||
&& let Some(i) = MONTHS.iter().position(|m| m.to_lowercase().starts_with(&cur.to_lowercase()))
|
||||
{
|
||||
@@ -620,34 +672,76 @@ pub fn parse_date(text: &str, fmt: &str) -> Option<DateTime> {
|
||||
}
|
||||
flush(&mut cur, &mut nums, &mut month_name);
|
||||
let mut dt = DateTime { y: 0, m: 1, d: 1, hh: 0, mm: 0, ss: 0 };
|
||||
let mut it = nums.into_iter().peekable();
|
||||
// The next number for a field. A field joined to the next one in the format ("yyyymmdd")
|
||||
// takes only its own width from a longer run of digits and leaves the rest for the next.
|
||||
// A run typed on its own is split only when fewer runs were typed than the fields of its kind
|
||||
// still to fill (`need`, this one included: date fields for a date field, time fields for a
|
||||
// time field, since times may be left out), and only when its rest can fill the next field
|
||||
// (`next_width`): with separators each run is one field's number, so "2026-10-18" keeps its
|
||||
// four-digit year in a yymmdd or yymmddHHMM field, and "2026 10 018" is the 18th. What is left
|
||||
// of a split run (`tail`: the front of `nums` is one) goes on being split along the fields
|
||||
// written side by side, and must fill its last field exactly, so "202610018" leaves "018" for
|
||||
// dd and is refused. `None` when there is no number left, it doesn't fit a u32, or such a
|
||||
// rest has the wrong width.
|
||||
let next_number = |nums: &mut VecDeque<String>, tail: &mut bool, part: &Part, need: usize, next_width: usize| -> Option<u32> {
|
||||
let run = nums.pop_front()?;
|
||||
let was_tail = std::mem::replace(tail, false);
|
||||
let short = nums.len() < need.saturating_sub(1);
|
||||
let digits = match (run.get(..part.width), run.get(part.width..)) {
|
||||
(Some(head), Some(rest)) if part.joined && part.width > 0 && !rest.is_empty() && (was_tail || (short && rest.len() >= next_width)) => {
|
||||
nums.push_front(rest.to_string());
|
||||
*tail = true;
|
||||
head
|
||||
}
|
||||
_ if was_tail && part.width > 0 && run.len() != part.width => return None,
|
||||
_ => run.as_str(),
|
||||
};
|
||||
digits.parse::<u32>().ok()
|
||||
};
|
||||
let parts = order(fmt);
|
||||
let takes_number = |p: &Part| matches!(p.kind, 'y' | 'd' | 'H' | 'M' | 's') || (p.kind == 'm' && month_name.is_none());
|
||||
let is_time = |p: &Part| matches!(p.kind, 'H' | 'M' | 's');
|
||||
// The fields of each kind still to fill: [date, time].
|
||||
let mut wanted = [parts.iter().filter(|p| takes_number(p) && !is_time(p)).count(), parts.iter().filter(|p| is_time(p)).count()];
|
||||
let mut tail = false;
|
||||
let mut have_y = false;
|
||||
for (k, literal) in order(fmt) {
|
||||
match k {
|
||||
for (i, part) in parts.iter().enumerate() {
|
||||
let kind = usize::from(is_time(part));
|
||||
let need = wanted.get(kind).copied().unwrap_or(0);
|
||||
let next_width = parts.get(i + 1).map_or(0, |p| p.width);
|
||||
if takes_number(part)
|
||||
&& let Some(left) = wanted.get_mut(kind)
|
||||
{
|
||||
*left = left.saturating_sub(1);
|
||||
}
|
||||
let take = |nums: &mut VecDeque<String>, tail: &mut bool| next_number(nums, tail, part, need, next_width);
|
||||
match part.kind {
|
||||
// Literal digits in the format may be typed or left out ("15.05.26" or "15.05"
|
||||
// for "dd.mm.26"); a different number there is left over and rejected below.
|
||||
'#' if it.peek() == Some(&literal) => {
|
||||
it.next();
|
||||
'#' if nums.front().and_then(|n| n.parse::<u32>().ok()) == Some(part.literal) => {
|
||||
nums.pop_front();
|
||||
tail = false;
|
||||
}
|
||||
'm' => {
|
||||
dt.m = match month_name {
|
||||
Some(m) => m,
|
||||
None => it.next()?,
|
||||
None => take(&mut nums, &mut tail)?,
|
||||
}
|
||||
}
|
||||
'd' => dt.d = it.next()?,
|
||||
'd' => dt.d = take(&mut nums, &mut tail)?,
|
||||
'y' => {
|
||||
let y = i32::try_from(it.next()?).ok()?;
|
||||
let y = i32::try_from(take(&mut nums, &mut tail)?).ok()?;
|
||||
dt.y = if y < 100 { if y < 50 { 2000 + y } else { 1900 + y } } else { y };
|
||||
have_y = true;
|
||||
}
|
||||
'H' => dt.hh = it.next().unwrap_or(0),
|
||||
'M' => dt.mm = it.next().unwrap_or(0),
|
||||
's' => dt.ss = it.next().unwrap_or(0),
|
||||
// Times may be left out; a number that is there must be readable.
|
||||
'H' => dt.hh = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? },
|
||||
'M' => dt.mm = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? },
|
||||
's' => dt.ss = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? },
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if it.next().is_some() {
|
||||
if !nums.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if !have_y {
|
||||
@@ -1285,3 +1379,118 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod undelimited_tests {
|
||||
use super::*;
|
||||
|
||||
fn ymd(dt: Option<DateTime>) -> Option<(i32, u32, u32)> {
|
||||
dt.map(|d| (d.y, d.m, d.d))
|
||||
}
|
||||
|
||||
/// Fields written side by side in the format ("yyyymmdd") take their fixed widths from one
|
||||
/// run of typed digits. Every such date used to be refused, because the run was read as a
|
||||
/// single number.
|
||||
#[test]
|
||||
fn digits_typed_without_separators_fill_adjacent_fields() {
|
||||
assert_eq!(ymd(parse_date("20261018", "yyyymmdd")), Some((2026, 10, 18)));
|
||||
assert_eq!(ymd(parse_date("261018", "yymmdd")), Some((2026, 10, 18)));
|
||||
assert_eq!(ymd(parse_date("10182026", "mmddyyyy")), Some((2026, 10, 18)));
|
||||
assert_eq!(ymd(parse_date("18102026", "ddmmyyyy")), Some((2026, 10, 18)));
|
||||
let f = Format::Date("yyyymmdd".into());
|
||||
assert_eq!(keystroke(&f, "Date", "20261018"), Ok("20261018".into()));
|
||||
assert_eq!(format_value(&f, "20261018"), "20261018");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separators_are_still_accepted_in_an_undelimited_format() {
|
||||
assert_eq!(ymd(parse_date("2026-10-18", "yyyymmdd")), Some((2026, 10, 18)));
|
||||
assert_eq!(ymd(parse_date("2026 1018", "yyyymmdd")), Some((2026, 10, 18)));
|
||||
}
|
||||
|
||||
/// Separators mean one number per field, as before: a four-digit year typed with them stays
|
||||
/// whole in a two-digit-year format. Only a run covering several fields is split.
|
||||
#[test]
|
||||
fn a_run_is_split_only_when_it_has_to_fill_several_fields() {
|
||||
assert_eq!(ymd(parse_date("2026-10-18", "yymmdd")), Some((2026, 10, 18)));
|
||||
assert_eq!(ymd(parse_date("2026-10", "yymm")), Some((2026, 10, 1)));
|
||||
assert_eq!(ymd(parse_date("2610", "yymm")), Some((2026, 10, 1)));
|
||||
assert_eq!(ymd(parse_date("202610 18", "yyyymmdd")), Some((2026, 10, 18)));
|
||||
assert_eq!(ymd(parse_date("18Oct2026", "ddmmmyyyy")), Some((2026, 10, 18)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn impossible_undelimited_dates_are_refused() {
|
||||
let f = Format::Date("yyyymmdd".into());
|
||||
assert!(keystroke(&f, "Date", "20261340").is_err(), "no month 13");
|
||||
assert!(keystroke(&f, "Date", "20260230").is_err(), "no 30 February");
|
||||
assert!(keystroke(&f, "Date", "2026101899").is_err(), "digits left over make no day");
|
||||
assert!(keystroke(&f, "Date", "2026").is_err(), "a year alone is not a date");
|
||||
}
|
||||
|
||||
/// What is left of a split run fills its field exactly: "202610018" leaves "018" for the
|
||||
/// day, one digit too many, and used to pass as 18 October. Numbers typed on their own
|
||||
/// keep their leeway.
|
||||
#[test]
|
||||
fn the_rest_of_a_split_run_must_fill_its_field_exactly() {
|
||||
assert_eq!(parse_date("202610018", "yyyymmdd"), None, "one digit too many");
|
||||
assert_eq!(parse_date("2026101", "yyyymmdd"), None, "one digit too few");
|
||||
assert_eq!(parse_date("2026101814305", "yyyymmddHHMM"), None, "one digit too many in the time");
|
||||
assert_eq!(ymd(parse_date("2026 10 018", "yyyymmdd")), Some((2026, 10, 18)), "typed on its own, 018 is still a day");
|
||||
assert_eq!(parse_date("202610181430", "yyyymmddHHMM").map(|d| (d.d, d.hh, d.mm)), Some((18, 14, 30)));
|
||||
}
|
||||
|
||||
/// Times may be left out, so a number typed on its own isn't split for them: "018" is the
|
||||
/// day in "2026 10 018" even when the format goes on to HHMM.
|
||||
#[test]
|
||||
fn a_number_typed_on_its_own_is_split_only_when_the_rest_fills_the_next_field() {
|
||||
let dt = parse_date("2026 10 018", "yyyymmddHHMM").expect("a date with the time left out");
|
||||
assert_eq!((dt.y, dt.m, dt.d, dt.hh, dt.mm), (2026, 10, 18, 0, 0));
|
||||
assert_eq!(ymd(parse_date("20261018", "yyyymmddHHMM")), Some((2026, 10, 18)));
|
||||
assert_eq!(parse_date("2026 10 18 1430", "yyyymmdd HHMM").map(|d| (d.hh, d.mm)), Some((14, 30)));
|
||||
}
|
||||
|
||||
/// The date's runs are matched against the date's fields, the time's against the time's:
|
||||
/// optional times left out don't make a separated four-digit year look like it needs
|
||||
/// splitting, and a time typed in one run still fills HHMM.
|
||||
#[test]
|
||||
fn date_and_time_fields_are_counted_apart() {
|
||||
let dt = parse_date("2026-10-18", "yymmddHHMM").expect("a date with the time left out");
|
||||
assert_eq!((dt.y, dt.m, dt.d, dt.hh, dt.mm), (2026, 10, 18, 0, 0));
|
||||
assert_eq!(parse_date("20261018 1430", "yyyymmddHHMM").map(|d| (d.y, d.m, d.d, d.hh, d.mm)), Some((2026, 10, 18, 14, 30)));
|
||||
assert_eq!(parse_date("2026-10-18 1430", "yyyy-mm-dd HHMM").map(|d| (d.hh, d.mm)), Some((14, 30)));
|
||||
assert_eq!(parse_date("20261018143005", "yyyymmddHHMMss").map(|d| (d.d, d.hh, d.mm, d.ss)), Some((18, 14, 30, 5)));
|
||||
}
|
||||
|
||||
/// A run of more digits than fit in a u32 ("yyyymmddHHMMss") is split, not dropped.
|
||||
#[test]
|
||||
fn long_runs_of_digits_split_into_dates_and_times() {
|
||||
let dt = parse_date("20261018143005", "yyyymmddHHMMss").expect("valid date and time");
|
||||
assert_eq!((dt.y, dt.m, dt.d, dt.hh, dt.mm, dt.ss), (2026, 10, 18, 14, 30, 5));
|
||||
assert_eq!(parse_date("1430", "HHMM").map(|d| (d.hh, d.mm)), Some((14, 30)));
|
||||
assert_eq!(parse_date("99999999999999999999", "yyyymmdd"), None);
|
||||
assert_eq!(parse_date("2026 99999999999", "yyyy"), None, "an unreadable number is left over, not ignored");
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,9 @@ impl NewField {
|
||||
pub struct FieldProps {
|
||||
pub name: Option<String>,
|
||||
pub tooltip: Option<String>,
|
||||
/// Normal push-button label (`/MK /CA`) of one widget (0-based index).
|
||||
/// An empty string clears the label; other widgets and icon layouts are preserved.
|
||||
pub caption: Option<(usize, String)>,
|
||||
pub read_only: Option<bool>,
|
||||
pub required: Option<bool>,
|
||||
pub multiline: Option<bool>,
|
||||
@@ -657,6 +660,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
|
||||
locked: false,
|
||||
hidden: false,
|
||||
rotation: 0,
|
||||
caption: None,
|
||||
list: None,
|
||||
};
|
||||
let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export);
|
||||
@@ -677,9 +681,16 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
|
||||
/// (`pdfcraft_fonts::UNDRAWN_MARK`) and other viewers are asked to redraw the form (see
|
||||
/// `ask_viewers_to_redraw`), as when a value is set.
|
||||
pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
|
||||
redraw_widgets(doc, name, None)
|
||||
}
|
||||
|
||||
fn redraw_widgets(doc: &mut Document, name: &str, only: Option<usize>) -> Result<(), FormError> {
|
||||
let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
|
||||
let mut undrawn = false;
|
||||
for w in &f.widgets {
|
||||
for (index, w) in f.widgets.iter().enumerate() {
|
||||
if only.is_some_and(|only| only != index) {
|
||||
continue;
|
||||
}
|
||||
let mut missing = None;
|
||||
let ap = match f.kind {
|
||||
FieldKind::Text | FieldKind::Combo | FieldKind::List => {
|
||||
@@ -861,6 +872,15 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
}
|
||||
let all = fields(doc);
|
||||
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone();
|
||||
// Refuse a wrong type/index before any other requested property is written.
|
||||
if let Some((wi, _)) = &props.caption {
|
||||
if f.kind != FieldKind::PushButton {
|
||||
return invalid("only push buttons have a caption");
|
||||
}
|
||||
if f.widgets.get(*wi).is_none() {
|
||||
return invalid(format!("{name} has no widget {}", wi.saturating_add(1)));
|
||||
}
|
||||
}
|
||||
// Validated before anything is written, so a refused rotation leaves the document unchanged.
|
||||
// `Some(Some(rect))`: the widget's axis changes and it gets the swapped rectangle.
|
||||
let mut rotated_rect: Option<Option<[f64; 4]>> = None;
|
||||
@@ -1178,7 +1198,18 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
}
|
||||
})?;
|
||||
}
|
||||
redraw_field(doc, &new_name)?;
|
||||
if let Some((wi, caption)) = &props.caption
|
||||
&& let Some(w) = f.widgets.get(*wi)
|
||||
{
|
||||
// Copy an indirect/shared MK before patching it; colours, icons, rotation and
|
||||
// unknown entries belong to this widget, and other owners keep their old label.
|
||||
let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default();
|
||||
mk.set(b"CA".to_vec(), PdfString::text(caption));
|
||||
doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?;
|
||||
}
|
||||
// A label-only edit must not replace another widget's custom appearances.
|
||||
let only = props.caption.as_ref().filter(|_| *props == (FieldProps { caption: props.caption.clone(), ..Default::default() })).map(|(wi, _)| *wi);
|
||||
redraw_widgets(doc, &new_name, only)?;
|
||||
if props.calculate.is_some() {
|
||||
crate::recalculate(doc)?;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Prepare a form ▸ automatic field detection: find where a printed form expects answers —
|
||||
//! underscore runs, rules (horizontal lines), empty boxes and small squares — and name each new
|
||||
//! field from the label next to it (Acrobat's auto-naming).
|
||||
//! underscore runs, rules (horizontal lines), empty boxes, rows of comb cells and small squares —
|
||||
//! and name each new field from the label next to it (Acrobat's auto-naming).
|
||||
//!
|
||||
//! [`detect`] is a pure function over the page's words and drawn shapes; [`page_shapes`] reads
|
||||
//! the shapes from a page's content, [`raster::shapes`] from a picture of the page (a scan).
|
||||
@@ -30,6 +30,11 @@ pub struct Shapes {
|
||||
pub enum Kind {
|
||||
Text,
|
||||
CheckBox,
|
||||
/// A row of touching cells, one character each: a text field with the comb flag and
|
||||
/// `cells` as its character limit.
|
||||
Comb {
|
||||
cells: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -40,6 +45,10 @@ pub struct Candidate {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// The most points a subpath may have and still be read as a rectangle: its four corners plus
|
||||
/// points along its sides (#2942: a cell drawn with a midpoint on each vertical side).
|
||||
const MAX_RECT_POINTS: usize = 16;
|
||||
|
||||
/// The rectangles and horizontal segments a page draws (stroked or filled), in user space.
|
||||
#[derive(Default)]
|
||||
struct DetectionPath {
|
||||
@@ -65,12 +74,33 @@ impl DetectionPath {
|
||||
return None;
|
||||
}
|
||||
let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6;
|
||||
let mut points = self.points.as_slice();
|
||||
let mut points = self.points.clone();
|
||||
let repeated_end = points.len() > 1 && near(points[0], *points.last()?);
|
||||
if repeated_end {
|
||||
points = &points[..points.len() - 1];
|
||||
points.pop();
|
||||
}
|
||||
if points.len() != 4 || !(self.closed || implicitly_closed || repeated_end) {
|
||||
if !(self.closed || implicitly_closed || repeated_end) {
|
||||
return None;
|
||||
}
|
||||
// A point on a straight side, between its neighbours, doesn't change the shape: drop it,
|
||||
// so a cell drawn with a midpoint on a side is the rectangle it looks like (#2942). A
|
||||
// point that turns back along the side is not between its neighbours and stays, so a
|
||||
// path that doubles back is still not a rectangle.
|
||||
let between = |a: f64, m: f64, b: f64| a.min(b) - 1e-6 <= m && m <= a.max(b) + 1e-6;
|
||||
let mut i = 0;
|
||||
while points.len() > 4 && i < points.len() {
|
||||
let n = points.len();
|
||||
let (Some(&a), Some(&m), Some(&b)) = (points.get((i + n - 1) % n), points.get(i), points.get((i + 1) % n)) else { break };
|
||||
let on_row = (a[1] - m[1]).abs() < 1e-6 && (m[1] - b[1]).abs() < 1e-6 && between(a[0], m[0], b[0]);
|
||||
let on_column = (a[0] - m[0]).abs() < 1e-6 && (m[0] - b[0]).abs() < 1e-6 && between(a[1], m[1], b[1]);
|
||||
if on_row || on_column {
|
||||
points.remove(i);
|
||||
i = 0;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if points.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
let mut horizontal = [false; 4];
|
||||
@@ -157,7 +187,7 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
|
||||
let (bx, by) = ctm.apply(x, y);
|
||||
let b = [bx, by];
|
||||
if !path.complex {
|
||||
if path.points.len() >= 5 {
|
||||
if path.points.len() > MAX_RECT_POINTS {
|
||||
path.complex = true;
|
||||
} else {
|
||||
if path.points.is_empty() {
|
||||
@@ -284,6 +314,42 @@ fn label_for(words: &[Word], rect: [f64; 4], right_of: bool) -> String {
|
||||
clean(&on.iter().map(|w| w.text.as_str()).collect::<Vec<_>>().join(" "))
|
||||
}
|
||||
|
||||
/// Rows of two or more touching cells of one size, as (the row's rect, cell count). Two is
|
||||
/// enough: dates split into day and month groups of two cells. Cells are square or taller
|
||||
/// than wide; an empty table of wider cells is not a comb.
|
||||
fn combs(boxes: &[[f64; 4]]) -> Vec<([f64; 4], usize)> {
|
||||
let cells: Vec<[f64; 4]> = boxes
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|r| {
|
||||
let (w, h) = (r[2] - r[0], r[3] - r[1]);
|
||||
(5.0..=30.0).contains(&w) && (8.0..=40.0).contains(&h) && w <= h * 1.2
|
||||
})
|
||||
.collect();
|
||||
// `b` comes right after `a` in a row: same size, its left edge on a's right edge.
|
||||
let follows = |a: &[f64; 4], b: &[f64; 4]| {
|
||||
(b[0] - a[2]).abs() <= 1.0 && (b[1] - a[1]).abs() <= 0.5 && (b[3] - a[3]).abs() <= 0.5 && ((b[2] - b[0]) - (a[2] - a[0])).abs() <= 0.5
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for first in &cells {
|
||||
if cells.iter().any(|a| follows(a, first)) {
|
||||
continue;
|
||||
}
|
||||
let (mut last, mut n) = (first, 1usize);
|
||||
// Each step moves at least 4 pt right, so this ends; the bound is for safety.
|
||||
while let Some(b) = cells.iter().find(|b| follows(last, b))
|
||||
&& n < cells.len()
|
||||
{
|
||||
last = b;
|
||||
n += 1;
|
||||
}
|
||||
if n >= 2 {
|
||||
out.push(([first[0], first[1], last[2], first[3]], n));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Propose fields for a page. `existing` are the page's widget rectangles (no candidate
|
||||
/// overlaps them); names are unique against `taken`.
|
||||
pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[String]) -> Vec<Candidate> {
|
||||
@@ -304,6 +370,15 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S
|
||||
let l = label_for(&labels, rect, false);
|
||||
push(Kind::Text, rect, l, &mut out);
|
||||
}
|
||||
// Comb boxes (postcodes, dates, IBANs): one text field per row of cells, found before
|
||||
// check boxes so the cells don't each become one.
|
||||
for (rect, cells) in combs(&shapes.boxes) {
|
||||
if labels.iter().any(|x| overlaps(x.rect, rect)) {
|
||||
continue;
|
||||
}
|
||||
let l = label_for(&labels, rect, false);
|
||||
push(Kind::Comb { cells }, rect, l, &mut out);
|
||||
}
|
||||
// Small squares: check boxes, labelled by the text to their right.
|
||||
for r in &shapes.boxes {
|
||||
let (w, h) = (r[2] - r[0], r[3] - r[1]);
|
||||
@@ -356,7 +431,7 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S
|
||||
for c in &mut out {
|
||||
let base = if c.name.is_empty() {
|
||||
match c.kind {
|
||||
Kind::Text => "Text".to_string(),
|
||||
Kind::Text | Kind::Comb { .. } => "Text".to_string(),
|
||||
Kind::CheckBox => "Check Box".to_string(),
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -103,6 +103,8 @@ pub struct Widget {
|
||||
pub hidden: bool,
|
||||
/// `/MK /R` as 0, 90, 180 or 270 degrees counterclockwise. Anything else is stored as 0.
|
||||
pub rotation: i64,
|
||||
/// Push buttons: the normal label (`/MK /CA`), separate from the field's name.
|
||||
pub caption: Option<String>,
|
||||
/// List boxes: where the options are drawn, so a click can pick the one under the pointer.
|
||||
pub list: Option<appearance::ListRows>,
|
||||
}
|
||||
@@ -802,6 +804,13 @@ fn walk(
|
||||
locked: annot_flags & 128 != 0,
|
||||
hidden: annot_flags & (2 | 32) != 0,
|
||||
rotation: appearance::mk_rotation(doc, wd),
|
||||
caption: (kind == FieldKind::PushButton)
|
||||
.then(|| {
|
||||
let mk = doc.resolve(wd.get(b"MK")?);
|
||||
let value = doc.resolve(mk.as_dict()?.get(b"CA")?);
|
||||
value.as_string().map(|s| s.to_text())
|
||||
})
|
||||
.flatten(),
|
||||
list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -87,6 +87,108 @@ fn ap(doc: &Document, w: &Widget) -> String {
|
||||
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn button_caption_preserves_other_widgets_and_shared_dictionaries() {
|
||||
for indirect_caption in [false, true] {
|
||||
let mut doc = one_page();
|
||||
let a = add_field(&mut doc, 0, [40.0, 600.0, 200.0, 630.0], &NewField::Button { caption: "Original".into() }, Some("first")).unwrap();
|
||||
let b = add_field(&mut doc, 0, [40.0, 500.0, 200.0, 530.0], &NewField::Button { caption: "Original".into() }, Some("second")).unwrap();
|
||||
let all = fields(&doc);
|
||||
let (first, second) = (field(&all, &a).obj, field(&all, &b).obj);
|
||||
let mut parent = Dict::new();
|
||||
parent.set(b"FT".to_vec(), Object::name("Btn"));
|
||||
parent.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64));
|
||||
parent.set(b"T".to_vec(), PdfString::text("clear_button"));
|
||||
parent.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(second)]));
|
||||
let mut action = Dict::new();
|
||||
action.set(b"S".to_vec(), Object::name("ResetForm"));
|
||||
parent.set(b"A".to_vec(), Object::Dict(action.clone()));
|
||||
let parent = doc.add(parent);
|
||||
let mut mk = Dict::new();
|
||||
let ca = Object::String(PdfString::text("Original"));
|
||||
let ca = if indirect_caption { Object::Ref(doc.add(ca)) } else { ca };
|
||||
mk.set(b"CA".to_vec(), ca);
|
||||
mk.set(b"TP".to_vec(), Object::Int(0));
|
||||
mk.set(b"R".to_vec(), Object::Int(90));
|
||||
mk.set(b"VendorNote".to_vec(), PdfString::text("kept"));
|
||||
let mk = doc.add(mk);
|
||||
let mut shared_ap = doc.get(first).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().clone();
|
||||
let normal = shared_ap.get(b"N").unwrap().clone();
|
||||
shared_ap.set(b"R".to_vec(), normal.clone());
|
||||
shared_ap.set(b"D".to_vec(), normal);
|
||||
shared_ap.set(b"VendorAP".to_vec(), PdfString::text("kept"));
|
||||
let shared_ap = doc.add(shared_ap);
|
||||
for widget in [first, second] {
|
||||
doc.update_dict(widget, |d| {
|
||||
for key in [b"FT".as_slice(), b"Ff", b"T"] {
|
||||
d.remove(key);
|
||||
}
|
||||
d.set(b"Parent".to_vec(), Object::Ref(parent));
|
||||
d.set(b"MK".to_vec(), Object::Ref(mk));
|
||||
d.set(b"AP".to_vec(), Object::Ref(shared_ap));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let af = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"AcroForm").unwrap();
|
||||
doc.update_dict(af, |d| d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(parent)]))).unwrap();
|
||||
let original = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
let original = Document::open(Arc::new(original)).unwrap();
|
||||
// Full saves can renumber objects: reacquire references from the reopened model.
|
||||
let f = field(&fields(&original), "clear_button").clone();
|
||||
let first = f.widgets[0].obj;
|
||||
let mk = original.get(first).as_dict().unwrap().reference(b"MK").unwrap();
|
||||
for full in [false, true] {
|
||||
let mut doc = original.clone();
|
||||
let untouched = doc.get(first).clone();
|
||||
let shared_mk = doc.get(mk).clone();
|
||||
assert_eq!(field(&fields(&doc), "clear_button").widgets[1].caption.as_deref(), Some("Original"));
|
||||
set_props(&mut doc, "clear_button", &FieldProps { caption: Some((1, "Clear Form".into())), ..Default::default() }).unwrap();
|
||||
assert_eq!(doc.get(first), untouched, "the other widget and all its appearances are untouched");
|
||||
assert_eq!(doc.get(mk), shared_mk, "shared MK is not patched in place");
|
||||
let bytes = if full {
|
||||
pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap()
|
||||
} else {
|
||||
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
assert!(bytes.starts_with(original.bytes().as_slice()));
|
||||
assert!(bytes.len() > original.bytes().len());
|
||||
bytes
|
||||
};
|
||||
let doc = Document::open(Arc::new(bytes)).unwrap();
|
||||
let f = field(&fields(&doc), "clear_button").clone();
|
||||
assert_eq!(f.widgets.iter().map(|w| w.caption.as_deref()).collect::<Vec<_>>(), [Some("Original"), Some("Clear Form")]);
|
||||
assert_eq!(f.button, Some(af::ButtonAction::Reset { fields: Vec::new(), exclude: false }));
|
||||
assert_eq!(doc.get(f.obj).as_dict().unwrap().get(b"A"), Some(&Object::Dict(action.clone())));
|
||||
let w = doc.get(f.widgets[1].obj);
|
||||
let mk = w.as_dict().unwrap().get(b"MK").unwrap().as_dict().unwrap();
|
||||
assert_eq!(mk.get(b"VendorNote").unwrap().as_string().unwrap().to_text(), "kept");
|
||||
assert_eq!(mk.get(b"R").unwrap().as_int(), Some(90));
|
||||
assert_eq!(mk.get(b"TP").unwrap().as_int(), Some(0));
|
||||
let apd = w.as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap();
|
||||
assert!(apd.contains(b"VendorAP"));
|
||||
assert!(!apd.contains(b"R") && !apd.contains(b"D"), "stale hover/pressed text must not survive");
|
||||
assert!(ap(&doc, &f.widgets[1]).contains("(Clear Form) Tj"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn button_caption_rejects_wrong_type_and_index_before_mutation() {
|
||||
let mut doc = fixture();
|
||||
for (name, wi) in [("name", 0), ("agree", 0), ("go", usize::MAX)] {
|
||||
let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
let props = FieldProps { caption: Some((wi, "Clear Form".into())), name: Some("renamed".into()), ..Default::default() };
|
||||
assert!(set_props(&mut doc, name, &props).is_err());
|
||||
assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before);
|
||||
}
|
||||
set_props(&mut doc, "go", &FieldProps { caption: Some((0, "提交".into())), ..Default::default() }).unwrap();
|
||||
assert_eq!(field(&fields(&doc), "go").widgets[0].caption.as_deref(), Some("提交"));
|
||||
assert_eq!(undrawn_fields(&doc), [("go".to_string(), '提')]);
|
||||
set_props(&mut doc, "go", &FieldProps { caption: Some((0, String::new())), ..Default::default() }).unwrap();
|
||||
assert_eq!(field(&fields(&doc), "go").widgets[0].caption.as_deref(), Some(""));
|
||||
assert!(undrawn_fields(&doc).is_empty());
|
||||
assert!(!ap(&doc, &field(&fields(&doc), "go").widgets[0]).contains(" Tj"));
|
||||
}
|
||||
|
||||
/// Read the drawn strings together with the appearance's actual font after saving and reopening.
|
||||
fn appearance_text(doc: &Document, w: &Widget, font_name: &[u8]) -> (Object, Vec<Vec<u8>>) {
|
||||
let widget = doc.get(w.obj);
|
||||
@@ -1411,6 +1513,54 @@ fn detection_finds_blanks_rules_boxes_and_names_them() {
|
||||
assert!(again.is_empty(), "{again:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detection_makes_a_row_of_touching_cells_one_comb_field() {
|
||||
use crate::detect::*;
|
||||
let w = |t: &str, x0: f64, y0: f64, x1: f64| Word { text: t.into(), rect: [x0, y0, x1, y0 + 10.0] };
|
||||
let words = vec![
|
||||
w("Postcode", 40.0, 205.0, 85.0),
|
||||
w("A", 88.0, 205.0, 95.0),
|
||||
w("Postcode", 40.0, 105.0, 85.0),
|
||||
w("B", 88.0, 105.0, 95.0),
|
||||
w("Yes", 70.0, 41.0, 90.0),
|
||||
];
|
||||
let row = |n: usize, x: f64, y: f64, cw: f64, ch: f64| (0..n).map(move |i| [x + i as f64 * cw, y, x + (i + 1) as f64 * cw, y + ch]);
|
||||
// 18 x 22 cells (too tall for a check box), 16 x 20 cells (each one check-box sized) drawn
|
||||
// right to left, and a lone square.
|
||||
let mut boxes: Vec<[f64; 4]> = row(5, 120.0, 200.0, 18.0, 22.0).collect();
|
||||
boxes.extend(row(10, 120.0, 100.0, 16.0, 20.0).rev());
|
||||
boxes.push([50.0, 40.0, 62.0, 52.0]);
|
||||
let found = detect(&words, &Shapes { boxes, rules: Vec::new() }, &[], &[]);
|
||||
let got: Vec<(&str, &Kind, [f64; 4])> = found.iter().map(|c| (c.name.as_str(), &c.kind, c.rect)).collect();
|
||||
assert_eq!(
|
||||
got,
|
||||
[
|
||||
("Postcode A", &Kind::Comb { cells: 5 }, [120.0, 200.0, 210.0, 222.0]),
|
||||
("Postcode B", &Kind::Comb { cells: 10 }, [120.0, 100.0, 280.0, 120.0]),
|
||||
("Yes", &Kind::CheckBox, [50.0, 40.0, 62.0, 52.0]),
|
||||
],
|
||||
"{found:?}"
|
||||
);
|
||||
// Squares with a gap between them stay check boxes, and so do touching squares of
|
||||
// different sizes.
|
||||
let apart = Shapes {
|
||||
boxes: vec![[50.0, 40.0, 62.0, 52.0], [66.0, 40.0, 78.0, 52.0], [80.0, 40.0, 92.0, 52.0], [92.0, 40.0, 102.0, 52.0]],
|
||||
rules: Vec::new(),
|
||||
};
|
||||
let kinds: Vec<Kind> = detect(&[], &apart, &[], &[]).into_iter().map(|c| c.kind).collect();
|
||||
assert_eq!(kinds, [Kind::CheckBox, Kind::CheckBox, Kind::CheckBox, Kind::CheckBox]);
|
||||
// An empty table of cells wider than tall (an attendance sheet) is not a comb.
|
||||
let grid = |cw: f64, ch: f64| {
|
||||
(0..3).flat_map(move |r| {
|
||||
(0..3).map(move |c| [100.0 + c as f64 * cw, 300.0 + r as f64 * ch, 100.0 + (c + 1) as f64 * cw, 300.0 + (r + 1) as f64 * ch])
|
||||
})
|
||||
};
|
||||
for (cw, ch) in [(24.0, 18.0), (28.0, 20.0)] {
|
||||
let table = detect(&[], &Shapes { boxes: grid(cw, ch).collect(), rules: Vec::new() }, &[], &[]);
|
||||
assert!(table.is_empty(), "{cw} x {ch}: {table:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_shapes_reads_boxes_and_rules() {
|
||||
let mut doc = fixture();
|
||||
@@ -2023,6 +2173,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 [
|
||||
|
||||
@@ -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<f64> {
|
||||
let t = s.trim();
|
||||
@@ -1926,6 +1933,8 @@ fn days_in(y: i64, m: u32) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
fn format_date(y: i64, m: u32, d: u32, picture: &str) -> String {
|
||||
let pic: Vec<char> = picture.chars().collect();
|
||||
let mut out = String::new();
|
||||
@@ -1948,6 +1957,19 @@ fn format_date(y: i64, m: u32, d: u32, picture: &str) -> String {
|
||||
('D', 1) => out.push_str(&d.to_string()),
|
||||
(_, _) => out.push_str(&format!("{d:02}")),
|
||||
}
|
||||
} else if pic[i] == 'E' {
|
||||
let mut run = 1;
|
||||
while i + run < pic.len() && pic[i + run] == 'E' {
|
||||
run += 1;
|
||||
}
|
||||
i += run;
|
||||
// Day 1 (1900-01-01) was a Monday, so the serial modulo 7 indexes from Sunday.
|
||||
let weekday = days_from_civil(y, m, d).rem_euclid(7) as usize;
|
||||
match run {
|
||||
1 | 2 => out.push_str(&(weekday + 1).to_string()),
|
||||
3 => out.push_str(WEEKDAYS.get(weekday).map_or("", |n| &n[..3])),
|
||||
_ => out.push_str(WEEKDAYS.get(weekday).copied().unwrap_or("")),
|
||||
}
|
||||
} else {
|
||||
out.push(pic[i]);
|
||||
i += 1;
|
||||
@@ -2166,7 +2188,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 +2277,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();
|
||||
|
||||
@@ -636,6 +636,34 @@ mod formcalc {
|
||||
assert_eq!(calc("; a comment\n// another\nSum(1,\n 2,\n 3)"), "6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_pictures_render_the_weekday_letters() {
|
||||
assert_eq!(calc(r#"Num2Date(45350, DateFmt(4))"#), "Thursday, February 29, 2024");
|
||||
assert_eq!(calc(r#"Num2Date(45350, "EEE, YYYY-MM-DD")"#), "Thu, 2024-02-29");
|
||||
assert_eq!(calc(r#"Num2Date(45350, "EEEE")"#), "Thursday");
|
||||
assert_eq!(calc(r#"Num2Date(45350, "E")"#), "5");
|
||||
assert_eq!(calc(r#"Num2Date(45351, "EEEE")"#), "Friday");
|
||||
assert_eq!(calc(r#"Num2Date(45352, "EEEE")"#), "Saturday");
|
||||
assert_eq!(calc(r#"Num2Date(45353, "EEEE")"#), "Sunday");
|
||||
assert_eq!(calc(r#"Num2Date(1, "EEEE")"#), "Monday");
|
||||
assert_eq!(calc(r#"Format("EEEE, YYYY-MM-DD", 45350)"#), "Thursday, 2024-02-29");
|
||||
// Other letters are still literal text.
|
||||
assert_eq!(calc(r#"Num2Date(45350, "YYYY-MM-DD x")"#), "2024-02-29 x");
|
||||
}
|
||||
|
||||
#[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");
|
||||
|
||||
+25
-2
@@ -215,7 +215,10 @@ fn num(v: f64) -> String {
|
||||
/// WinAnsiEncoding), each word stretched to its box so selection and search highlight the
|
||||
/// right place. Marked content `/OCR` so it can be told apart from the page's own text.
|
||||
pub fn text_layer(words: &[PlacedWord]) -> Vec<u8> {
|
||||
let mut out = b"/OCR BMC\nBT\n3 Tr\n/PCHelv 1 Tf\n".to_vec();
|
||||
// `3 Tr` and the font are graphics state, which `ET` doesn't reset: without `q … Q` they
|
||||
// would stay set for every later content stream on the page, so text added after OCR (a
|
||||
// header or footer) drew invisibly (#3029).
|
||||
let mut out = b"/OCR BMC\nq\nBT\n3 Tr\n/PCHelv 1 Tf\n".to_vec();
|
||||
for w in words {
|
||||
let width = helvetica_width(&w.text, 1.0);
|
||||
let height = w.up[0].hypot(w.up[1]);
|
||||
@@ -235,7 +238,7 @@ pub fn text_layer(words: &[PlacedWord]) -> Vec<u8> {
|
||||
out.extend(pdfcraft_fonts::literal(&pdfcraft_fonts::win_ansi(&w.text)));
|
||||
out.extend_from_slice(b" Tj\n");
|
||||
}
|
||||
out.extend_from_slice(b"ET\nEMC\n");
|
||||
out.extend_from_slice(b"ET\nQ\nEMC\n");
|
||||
out
|
||||
}
|
||||
|
||||
@@ -272,6 +275,26 @@ mod tests {
|
||||
assert!(s.starts_with("/OCR BMC") && s.ends_with("EMC\n"));
|
||||
}
|
||||
|
||||
/// #3029: the layer's invisible rendering mode and font end with the layer. Every operator
|
||||
/// that sets them sits inside a `q … Q` pair that the layer closes itself, so content drawn
|
||||
/// after it on the page starts from the graphics state it had before.
|
||||
#[test]
|
||||
fn the_layer_restores_the_graphics_state_it_changes() {
|
||||
let w = PlacedWord { text: "Hi".into(), origin: [10.0, 20.0], across: [30.0, 0.0], up: [0.0, 9.3] };
|
||||
let s = String::from_utf8(text_layer(&[w])).unwrap();
|
||||
let mut depth = 0_i32;
|
||||
for token in s.split_ascii_whitespace() {
|
||||
match token {
|
||||
"q" => depth += 1,
|
||||
"Q" => depth -= 1,
|
||||
"Tr" | "Tf" | "Tm" => assert!(depth > 0, "`{token}` outside q … Q in {s:?}"),
|
||||
_ => {}
|
||||
}
|
||||
assert!(depth >= 0, "unbalanced Q in {s:?}");
|
||||
}
|
||||
assert_eq!(depth, 0, "the layer leaves a q open: {s:?}");
|
||||
}
|
||||
|
||||
/// A word box is tight around the ink, so the text's size and baseline come from the letters:
|
||||
/// capitals, lowercase without ascenders, descenders.
|
||||
#[test]
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
//! This crate is test scaffolding (`Class::Testkit` in `xtask/src/layers.rs`): a dev-dependency
|
||||
//! only, never compiled into the product.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod pixels;
|
||||
|
||||
use std::fmt;
|
||||
|
||||
@@ -201,6 +201,10 @@ pub fn decode_pam_gray(bytes: &[u8]) -> Option<(usize, usize, Vec<u8>)> {
|
||||
}
|
||||
|
||||
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
// `windows(0)` panics.
|
||||
if needle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
haystack.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
|
||||
@@ -404,6 +408,14 @@ mod tests {
|
||||
assert_eq!((w, h, px.len()), (4, 2, 8));
|
||||
}
|
||||
|
||||
/// An empty needle is not found, rather than panicking in `windows(0)`.
|
||||
#[test]
|
||||
fn find_with_an_empty_needle_is_none() {
|
||||
assert_eq!(find(b"abc", b""), None);
|
||||
assert_eq!(find(b"", b""), None);
|
||||
assert_eq!(find(b"abcabc", b"ca"), Some(2));
|
||||
}
|
||||
|
||||
/// Malformed or hostile headers return `None` rather than panicking or inventing a size.
|
||||
#[test]
|
||||
fn hostile_pgm_headers_are_rejected() {
|
||||
|
||||
@@ -113,13 +113,17 @@ pub fn pages(doc: &Document) -> Result<Vec<PageRef>, OrganizeError> {
|
||||
pub fn page_rotation(doc: &Document, index: usize) -> Result<i64, OrganizeError> {
|
||||
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<usize, OrganizeError> {
|
||||
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)?;
|
||||
@@ -201,7 +205,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)))?;
|
||||
}
|
||||
@@ -472,9 +479,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<usize>) -> Result<(), OrganizeError> {
|
||||
let n = page_count(doc)?;
|
||||
check(indices, n)?;
|
||||
let Some(&last) = indices.iter().max() else { return Ok(()) };
|
||||
@@ -482,7 +510,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
|
||||
@@ -536,6 +564,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<String> {
|
||||
let info = doc.trailer().get(b"Info").map(|o| doc.resolve(o))?;
|
||||
@@ -548,6 +624,9 @@ pub fn info(doc: &Document, key: &str) -> Option<String> {
|
||||
/// 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)?;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -2657,3 +2789,35 @@ fn a_chain_of_objects_too_deep_to_copy_is_refused_and_leaves_the_destination_alo
|
||||
.join();
|
||||
assert!(run.is_ok(), "the copy must not overflow the stack");
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,20 @@ pub fn create_staging(
|
||||
name: &str,
|
||||
form: StagingName,
|
||||
suffixes: impl IntoIterator<Item = u64>,
|
||||
) -> 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<Item = u64>,
|
||||
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,17 @@ pub fn staging_suffixes() -> impl Iterator<Item = u64> {
|
||||
/// 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. On Windows and macOS a replaced file keeps its creation date, where
|
||||
/// the volume stores one.
|
||||
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,14 +116,30 @@ 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;
|
||||
// A saved file is the same document: it keeps its creation date (#2658).
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
let created = std::fs::metadata(path).ok().filter(|m| m.is_file()).and_then(|m| m.created().ok());
|
||||
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).
|
||||
{
|
||||
let mut file = file;
|
||||
file.write_all(bytes)?;
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
keep_created(&file, created);
|
||||
sync_staged(&file, sync_all)?;
|
||||
}
|
||||
std::fs::rename(&tmp, path)
|
||||
@@ -103,6 +150,20 @@ pub fn write_atomically_with(
|
||||
result
|
||||
}
|
||||
|
||||
/// Give the staging file the creation date of the file it replaces, where the system keeps one
|
||||
/// that a program can set (Windows, macOS). Best effort: a volume that can't store it leaves the
|
||||
/// new file's own date, as before.
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
fn keep_created(file: &std::fs::File, created: Option<std::time::SystemTime>) {
|
||||
if let Some(created) = created {
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::os::macos::fs::FileTimesExt as _;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::fs::FileTimesExt as _;
|
||||
let _ = file.set_times(std::fs::FileTimes::new().set_created(created));
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush the staging file to the disk before it replaces the target. On macOS `sync_all` asks
|
||||
/// for a full flush (`F_FULLFSYNC`), which SMB and FUSE volumes, such as a mounted Cryptomator
|
||||
/// vault, refuse as unsupported: there a plain `fsync` is the strongest flush the volume offers.
|
||||
@@ -162,6 +223,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 +303,62 @@ 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);
|
||||
}
|
||||
|
||||
/// #2658: a save replaces the file, but the document is the same one: it keeps its creation
|
||||
/// date where the system lets a program set it.
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
#[test]
|
||||
fn a_replaced_file_keeps_its_creation_date() {
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::os::macos::fs::FileTimesExt as _;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::fs::FileTimesExt as _;
|
||||
let dir = test_dir("created");
|
||||
let target = dir.join("doc.pdf");
|
||||
std::fs::write(&target, b"OLD").unwrap();
|
||||
// 2020-01-02 03:04:05 UTC.
|
||||
let then = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245);
|
||||
let file = std::fs::OpenOptions::new().write(true).open(&target).unwrap();
|
||||
file.set_times(std::fs::FileTimes::new().set_created(then)).unwrap();
|
||||
drop(file);
|
||||
assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the fixture's date is set");
|
||||
write_atomically(&target, b"NEW", StagingName::SuffixThenTag).unwrap();
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"NEW");
|
||||
assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the saved file keeps its creation date");
|
||||
// A new file gets a date of its own.
|
||||
let fresh = dir.join("new.pdf");
|
||||
write_atomically(&fresh, b"NEW", StagingName::SuffixThenTag).unwrap();
|
||||
assert!(std::fs::metadata(&fresh).unwrap().created().unwrap() > then);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -14,3 +14,7 @@ workspace = true
|
||||
pdfcraft-content = { workspace = true }
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
roxmltree = "0.20"
|
||||
|
||||
[dev-dependencies]
|
||||
flate2 = { workspace = true }
|
||||
@@ -165,7 +165,7 @@ fn metadata(doc: &Document) -> Option<String> {
|
||||
let cat = catalog(doc);
|
||||
let m = doc.resolve(cat.get(b"Metadata")?);
|
||||
match &*m {
|
||||
Object::Stream(s) => s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()),
|
||||
Object::Stream(s) => s.decoded_within(xmp::MAX_XMP_BYTES).ok().map(|b| String::from_utf8_lossy(&b).into_owned()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -174,10 +174,10 @@ fn metadata(doc: &Document) -> Option<String> {
|
||||
pub fn declared(doc: &Document) -> Declared {
|
||||
let mut d = Declared::default();
|
||||
if let Some(x) = metadata(doc) {
|
||||
let part = xmp::value(&x, "pdfaid:part").and_then(|p| p.parse().ok());
|
||||
let conf = xmp::value(&x, "pdfaid:conformance").unwrap_or_default();
|
||||
let part = xmp::identifier(&x, xmp::PDFA_ID, "part").and_then(|p| p.parse().ok());
|
||||
let conf = xmp::identifier(&x, xmp::PDFA_ID, "conformance").unwrap_or_default();
|
||||
d.pdfa = part.map(|p| (p, conf.to_uppercase()));
|
||||
d.pdfua = xmp::value(&x, "pdfuaid:part").and_then(|p| p.parse().ok());
|
||||
d.pdfua = xmp::identifier(&x, xmp::PDFUA_ID, "part").and_then(|p| p.parse().ok());
|
||||
}
|
||||
let cat = catalog(doc);
|
||||
if let Some(list) = cat.get(b"OutputIntents").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) {
|
||||
@@ -423,8 +423,8 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
|
||||
match metadata(doc) {
|
||||
None => issue("6.6.2.1", "The document has no XMP metadata", None, None, true),
|
||||
Some(x) => {
|
||||
let part = xmp::value(&x, "pdfaid:part");
|
||||
let conf = xmp::value(&x, "pdfaid:conformance");
|
||||
let part = xmp::identifier(&x, xmp::PDFA_ID, "part");
|
||||
let conf = xmp::identifier(&x, xmp::PDFA_ID, "conformance");
|
||||
if part.as_deref() != Some(&level.part().to_string()) || conf.as_deref().map(str::to_uppercase).as_deref() != Some("B") {
|
||||
issue("6.6.4", "The metadata doesn't identify the file as {}", Some(level.label().to_owned()), None, true);
|
||||
}
|
||||
|
||||
@@ -371,3 +371,49 @@ fn device_colour_in_forms_patterns_and_glyphs_is_found() {
|
||||
assert!(declared(&doc).output_intents.is_empty(), "{what}: an sRGB output intent was added to a CMYK document: {:?}", r.fixed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standards_identification_uses_namespace_not_prefix() {
|
||||
fn with_xmp(xmp: &str) -> Document {
|
||||
let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len());
|
||||
build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "")
|
||||
}
|
||||
let attribute = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:a="http://www.aiim.org/pdfa/ns/id/" xmlns:u="http://www.aiim.org/pdfua/ns/id/" a:part="2" a:conformance="b" u:part="1"/></rdf:RDF>"#;
|
||||
let element = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description><part xmlns="http://www.aiim.org/pdfa/ns/id/">2</part><x:conformance xmlns:x="http://www.aiim.org/pdfa/ns/id/" >B</x:conformance></rdf:Description></rdf:RDF>"#;
|
||||
for xml in [attribute, element] {
|
||||
let doc = with_xmp(xml);
|
||||
assert_eq!(declared(&doc).pdfa, Some((2, "B".into())));
|
||||
assert!(!verify(&doc, Level::A2b).iter().any(|i| i.clause == "6.6.4"));
|
||||
}
|
||||
assert_eq!(declared(&with_xmp(attribute)).pdfua, Some(1));
|
||||
let wrong = r#"<root xmlns:pdfaid="urn:wrong" pdfaid:part="2" pdfaid:conformance="B"/>"#;
|
||||
let doc = with_xmp(wrong);
|
||||
assert_eq!(declared(&doc).pdfa, None);
|
||||
assert!(verify(&doc, Level::A2b).iter().any(|i| i.clause == "6.6.4"));
|
||||
let nested = format!("{}{}", "<n>".repeat(1100), "</n>".repeat(1100));
|
||||
assert_eq!(declared(&with_xmp(&nested)).pdfa, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_xmp_is_refused_while_decoding() {
|
||||
let xmp = " ".repeat(xmp::MAX_XMP_BYTES + 1);
|
||||
let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len());
|
||||
let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "");
|
||||
assert!(metadata(&doc).is_none());
|
||||
assert_eq!(declared(&doc).pdfa, None);
|
||||
|
||||
use std::io::Write as _;
|
||||
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
z.write_all(xmp.as_bytes()).unwrap();
|
||||
let compressed = z.finish().unwrap();
|
||||
let hex: String = compressed.iter().map(|byte| format!("{byte:02X}")).collect();
|
||||
let data = format!("{hex}>");
|
||||
let stream =
|
||||
format!("<< /Type /Metadata /Subtype /XML /Filter [/ASCIIHexDecode /FlateDecode] /Length {} >>\nstream\n{data}\nendstream", data.len());
|
||||
let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "");
|
||||
let cat = catalog(&doc);
|
||||
let raw = doc.resolve(cat.get(b"Metadata").unwrap());
|
||||
let Object::Stream(stream) = &*raw else { panic!("fixture has no metadata stream") };
|
||||
assert_eq!(stream.decoded().unwrap().len(), xmp::MAX_XMP_BYTES + 1, "fixture must actually decompress");
|
||||
assert!(metadata(&doc).is_none(), "compressed metadata must be limited during decoding");
|
||||
}
|
||||
@@ -3,6 +3,116 @@
|
||||
|
||||
use pdfcraft_cos::Document;
|
||||
|
||||
/// The largest XMP packet read; a larger one is treated as missing.
|
||||
pub(crate) const MAX_XMP_BYTES: usize = 8 * 1024 * 1024;
|
||||
/// Namespace URIs of the PDF/A and PDF/UA identification schemas.
|
||||
pub(crate) const PDFA_ID: &str = "http://www.aiim.org/pdfa/ns/id/";
|
||||
pub(crate) const PDFUA_ID: &str = "http://www.aiim.org/pdfua/ns/id/";
|
||||
|
||||
/// Read an identification property by its expanded XML name, not its arbitrary prefix. A packet
|
||||
/// that is not well-formed XML (an undeclared prefix, an illegal character) or is too deep or wide
|
||||
/// to parse safely is read by the conventional prefix (`pdfaid:`, `pdfuaid:`) instead, as before.
|
||||
pub(crate) fn identifier(xmp: &str, namespace: &str, local: &str) -> Option<String> {
|
||||
if xmp.len() > MAX_XMP_BYTES {
|
||||
return None;
|
||||
}
|
||||
match parsed_identifier(xmp, namespace, local) {
|
||||
Ok(found) => found,
|
||||
Err(()) => {
|
||||
let prefix = match namespace {
|
||||
PDFA_ID => "pdfaid",
|
||||
PDFUA_ID => "pdfuaid",
|
||||
_ => return None,
|
||||
};
|
||||
value(xmp, &format!("{prefix}:{local}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`identifier`] through an XML parser; `Err` when the packet can't be parsed within the limits.
|
||||
fn parsed_identifier(xmp: &str, namespace: &str, local: &str) -> Result<Option<String>, ()> {
|
||||
if !xml_limits_ok(xmp) {
|
||||
return Err(());
|
||||
}
|
||||
let options = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: 100_000 };
|
||||
let doc = roxmltree::Document::parse_with_options(xmp, options).map_err(|_| ())?;
|
||||
for node in doc.descendants().filter(roxmltree::Node::is_element) {
|
||||
if let Some(value) = node.attribute((namespace, local)) {
|
||||
return Ok(Some(value.trim().to_owned()));
|
||||
}
|
||||
if node.tag_name().namespace() == Some(namespace) && node.tag_name().name() == local {
|
||||
return Ok(node.text().map(|value| value.trim().to_owned()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Reject deep XML and start tags with too many attributes before roxmltree checks duplicates.
|
||||
/// Each unquoted `=` in a valid start tag introduces an attribute, including namespace bindings.
|
||||
/// Every `b[i]` and `hay[p..]` below is guarded by `i < b.len()` / `p < hay.len()`.
|
||||
fn xml_limits_ok(text: &str) -> bool {
|
||||
fn find(hay: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
|
||||
(from..hay.len()).find(|&p| hay[p..].starts_with(needle))
|
||||
}
|
||||
let b = text.as_bytes();
|
||||
let (mut i, mut depth, mut total_attributes) = (0usize, 0usize, 0usize);
|
||||
while i < b.len() {
|
||||
if b[i] != b'<' {
|
||||
i += 1;
|
||||
} else if b[i..].starts_with(b"<!--") {
|
||||
i = find(b, i + 4, b"-->").map_or(b.len(), |p| p + 3);
|
||||
} else if b[i..].starts_with(b"<![CDATA[") {
|
||||
i = find(b, i + 9, b"]]>").map_or(b.len(), |p| p + 3);
|
||||
} else if b[i..].starts_with(b"<?") {
|
||||
i = find(b, i + 2, b"?>").map_or(b.len(), |p| p + 2);
|
||||
} else if b[i..].starts_with(b"<!") {
|
||||
i = find(b, i + 2, b">").map_or(b.len(), |p| p + 1);
|
||||
} else if b[i..].starts_with(b"</") {
|
||||
depth = depth.saturating_sub(1);
|
||||
i = find(b, i + 2, b">").map_or(b.len(), |p| p + 1);
|
||||
} else {
|
||||
depth += 1;
|
||||
if depth > 1024 {
|
||||
return false;
|
||||
}
|
||||
// Skip the rest of the start tag, over any quoted attribute value, and remember its last
|
||||
// significant byte: a tag that ends in `/` closes itself, so it is one level deep while it
|
||||
// is read rather than a level left open.
|
||||
i += 1;
|
||||
let mut quote = 0u8;
|
||||
let mut last = 0u8;
|
||||
let mut attributes = 0usize;
|
||||
while i < b.len() {
|
||||
let c = b[i];
|
||||
if quote != 0 {
|
||||
if c == quote {
|
||||
quote = 0;
|
||||
}
|
||||
} else if c == b'"' || c == b'\'' {
|
||||
quote = c;
|
||||
} else if c == b'>' {
|
||||
i += 1;
|
||||
break;
|
||||
} else if c == b'=' {
|
||||
attributes += 1;
|
||||
total_attributes += 1;
|
||||
if attributes > 128 || total_attributes > 4096 {
|
||||
return false;
|
||||
}
|
||||
last = c;
|
||||
} else if !c.is_ascii_whitespace() {
|
||||
last = c;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if last == b'/' {
|
||||
depth -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The value of a simple XMP property, written either as an attribute (`pdfaid:part="2"`) or
|
||||
/// as an element (`<pdfaid:part>2</pdfaid:part>`, or inside an `rdf:Alt`/`rdf:Seq`).
|
||||
pub fn value(xmp: &str, key: &str) -> Option<String> {
|
||||
@@ -133,3 +243,28 @@ mod tests {
|
||||
assert_eq!(iso_date("junk"), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod limit_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn attribute_and_namespace_budgets_bound_parser_work() {
|
||||
let prefix = r#"<root xmlns:a="http://www.aiim.org/pdfa/ns/id/" a:part="2" "#;
|
||||
let attrs = |count| (0..count).map(|i| format!("x{i}=\"v=1\" ")).collect::<String>();
|
||||
let at_limit = format!("{prefix}{}/>", attrs(126));
|
||||
assert_eq!(identifier(&at_limit, PDFA_ID, "part").as_deref(), Some("2"));
|
||||
let over_limit = format!("{prefix}{}/>", attrs(127));
|
||||
assert_eq!(identifier(&over_limit, PDFA_ID, "part"), None);
|
||||
let namespaces = (0..129).map(|i| format!("xmlns:n{i}=\"urn:{i}\" ")).collect::<String>();
|
||||
assert_eq!(identifier(&format!("<root {namespaces}/>"), PDFA_ID, "part"), None);
|
||||
let many_elements = format!("<root>{}</root>", r#"<n a="v"/>"#.repeat(4097));
|
||||
assert!(!xml_limits_ok(&many_elements));
|
||||
// Not well-formed (the prefix is never declared): read by the conventional prefix, as before.
|
||||
let undeclared = r#"<rdf:Description pdfaid:part="3" pdfaid:conformance="u"/>"#;
|
||||
assert_eq!(identifier(undeclared, PDFA_ID, "part").as_deref(), Some("3"));
|
||||
assert_eq!(identifier(undeclared, PDFA_ID, "conformance").as_deref(), Some("u"));
|
||||
let over_bytes = format!("<root>{}</root>", " ".repeat(8 * 1024 * 1024));
|
||||
assert_eq!(identifier(&over_bytes, PDFA_ID, "part"), None);
|
||||
}
|
||||
}
|
||||
@@ -506,7 +506,8 @@ mod cups_options {
|
||||
let Some(record) = std::env::var_os(RECORD) else { return };
|
||||
let args: Vec<String> = std::env::args().skip_while(|a| a != "--").skip(1).collect();
|
||||
std::fs::write(std::path::Path::new(&record).join("args"), args.join("\n")).unwrap();
|
||||
println!("{}", std::env::var(ANSWER).unwrap());
|
||||
// Serial libtest prints its test label without a newline before this helper's stdout.
|
||||
println!("\n{}", std::env::var(ANSWER).unwrap());
|
||||
}
|
||||
|
||||
fn command(record: &std::path::Path, answer: &str) -> std::process::Command {
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-11
@@ -1,5 +1,5 @@
|
||||
//! Clearing the pixels of an image under redaction regions. The image is decoded, every pixel
|
||||
//! whose centre maps into a region is set to zero (no ink for image masks), and the result is
|
||||
//! whose footprint intersects a region is set to zero (no ink for image masks), and the result is
|
||||
//! written as a new Flate image; the original object is untouched (other pages may use it).
|
||||
//! Images whose codec PdfCraft can't decode (DCT, JPX, JBIG2, CCITT) return `None`, and the
|
||||
//! caller removes the whole image instead; that is fail-closed.
|
||||
@@ -33,7 +33,7 @@ fn components(doc: &Document, s: &Stream) -> Option<usize> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel centre is covered,
|
||||
/// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel intersects,
|
||||
/// `Err(())` when the image can't be cleared (the caller removes it).
|
||||
#[allow(clippy::result_unit_err)]
|
||||
pub(crate) fn clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Result<Option<Stream>, ()> {
|
||||
@@ -91,18 +91,20 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
|
||||
};
|
||||
let mut cleared = 0usize;
|
||||
for j in 0..h {
|
||||
// Image space: (0,0) is the bottom-left of the unit square; row 0 is the top.
|
||||
let v = 1.0 - (j as f64 + 0.5) / h as f64;
|
||||
let (ax, ay) = ctm.apply(0.0, v);
|
||||
let (bx, by) = ctm.apply(1.0, v);
|
||||
let row_box = [ax.min(bx), ay.min(by), ax.max(bx), ay.max(by)];
|
||||
if !rects.iter().any(|r| overlaps(*r, [row_box[0], row_box[1] - 0.01, row_box[2], row_box[3] + 0.01], 0.0)) {
|
||||
// Image space has its origin at the bottom left; row 0 is at the top.
|
||||
let v0 = 1.0 - (j + 1) as f64 / h as f64;
|
||||
let v1 = 1.0 - j as f64 / h as f64;
|
||||
let row_box = ctm.bbox([0.0, v0, 1.0, v1]);
|
||||
if !rects.iter().any(|r| overlaps(*r, row_box, 0.0)) {
|
||||
continue;
|
||||
}
|
||||
for i in 0..w {
|
||||
let u = (i as f64 + 0.5) / w as f64;
|
||||
let (x, y) = ctm.apply(u, v);
|
||||
if !rects.iter().any(|r| x >= r[0] && x <= r[2] && y >= r[1] && y <= r[3]) {
|
||||
let u0 = i as f64 / w as f64;
|
||||
let u1 = (i + 1) as f64 / w as f64;
|
||||
// The bounding box may cover extra pixels for a rotated or skewed image; that
|
||||
// conservative over-clear prevents a touched sample surviving in the file.
|
||||
let pixel_box = ctm.bbox([u0, v0, u1, v1]);
|
||||
if !rects.iter().any(|r| overlaps(*r, pixel_box, 0.0)) {
|
||||
continue;
|
||||
}
|
||||
cleared += 1;
|
||||
|
||||
+481
-36
@@ -21,7 +21,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
|
||||
use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string};
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
|
||||
use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object, Stream};
|
||||
|
||||
use crate::{RedactError, Report, image, page_streams, tags};
|
||||
use pdfcraft_fonts::pdf::Metrics;
|
||||
@@ -66,12 +66,16 @@ pub(crate) struct Output {
|
||||
/// dictionaries may name them too (an inherited or shared /Resources, or the caller's when a
|
||||
/// form has none of its own): [`unlink_doomed`] removes those entries.
|
||||
pub doomed: Vec<ObjRef>,
|
||||
/// Original property values retired by this scope or a nested form. Other resource holders
|
||||
/// may still name them; unlink only entries no surviving content uses.
|
||||
pub retired_properties: Vec<Object>,
|
||||
/// Glyphs / inline images still overlapping a region (verify mode).
|
||||
pub residue: usize,
|
||||
/// Apply mode met vertical-writing text (which it cannot remove) while a redaction region was
|
||||
/// in force: the operation must fail closed rather than leave that text under the mark.
|
||||
pub vertical: bool,
|
||||
pub tag_changes: Vec<(ObjRef, ObjRef, HashSet<i64>, HashSet<i64>)>,
|
||||
pub error: Option<CosError>,
|
||||
}
|
||||
|
||||
pub(crate) struct Scope<'a> {
|
||||
@@ -80,9 +84,19 @@ pub(crate) struct Scope<'a> {
|
||||
pub report: &'a mut Report,
|
||||
/// Sanitize: remove hidden text (invisible render modes, or wholly outside this page box).
|
||||
pub hidden_text: Option<[f64; 4]>,
|
||||
/// Sanitize: optional content groups (and membership dictionaries) that are off; content
|
||||
/// marked with them is removed.
|
||||
pub hidden_layers: Vec<ObjRef>,
|
||||
/// Sanitize: optional content groups that are off; content marked with them is removed.
|
||||
pub hidden_layers: HashSet<ObjRef>,
|
||||
/// Whether this pass is evaluating optional-content rules. An empty off set is meaningful:
|
||||
/// policies such as `/AllOff` can still hide content when every group is on.
|
||||
pub layers_enabled: bool,
|
||||
/// Off groups referenced by optional content that remains visible and therefore still needs
|
||||
/// its original default state after sanitization.
|
||||
pub retained_off_layers: HashSet<ObjRef>,
|
||||
/// Cache membership results so repeated blocks cannot repeatedly walk hostile expressions.
|
||||
ocmd_cache: HashMap<ObjRef, bool>,
|
||||
/// Direct `/OC` dictionaries on XObjects have no object reference of their own. Cache them
|
||||
/// by the containing XObject so repeated `Do` calls share the same bounded evaluation.
|
||||
xobject_oc_cache: HashMap<ObjRef, bool>,
|
||||
/// Content removed from hidden layers (blocks and XObjects).
|
||||
pub layer_blocks: usize,
|
||||
fonts: HashMap<Vec<u8>, Rc<Metrics>>,
|
||||
@@ -97,7 +111,11 @@ impl<'a> Scope<'a> {
|
||||
mode,
|
||||
report,
|
||||
hidden_text: None,
|
||||
hidden_layers: Vec::new(),
|
||||
hidden_layers: HashSet::new(),
|
||||
layers_enabled: false,
|
||||
retained_off_layers: HashSet::new(),
|
||||
ocmd_cache: HashMap::new(),
|
||||
xobject_oc_cache: HashMap::new(),
|
||||
layer_blocks: 0,
|
||||
fonts: HashMap::new(),
|
||||
used_names: Vec::new(),
|
||||
@@ -107,20 +125,26 @@ impl<'a> Scope<'a> {
|
||||
|
||||
/// Sanitizing visits every form XObject (not only those under a region).
|
||||
fn everywhere(&self) -> bool {
|
||||
self.hidden_text.is_some() || !self.hidden_layers.is_empty()
|
||||
self.hidden_text.is_some() || self.layers_enabled
|
||||
}
|
||||
|
||||
/// Is this optional-content reference (an OCG or OCMD) hidden?
|
||||
fn layer_hidden(&self, doc: &Document, o: &Object) -> bool {
|
||||
if self.hidden_layers.is_empty() {
|
||||
fn layer_hidden(&mut self, doc: &Document, o: &Object) -> bool {
|
||||
if !self.layers_enabled {
|
||||
return false;
|
||||
}
|
||||
if let Some(r) = o.as_ref()
|
||||
&& self.hidden_layers.contains(&r)
|
||||
{
|
||||
return true;
|
||||
let reference = o.as_ref();
|
||||
if let Some(r) = reference {
|
||||
if let Some(hidden) = self.ocmd_cache.get(&r) {
|
||||
return *hidden;
|
||||
}
|
||||
if self.hidden_layers.contains(&r) {
|
||||
self.ocmd_cache.insert(r, true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// A membership dictionary (default policy AnyOn): hidden when all its groups are.
|
||||
// Evaluate the policy against the same default configuration used to select hidden
|
||||
// groups. A membership dictionary's default policy is AnyOn.
|
||||
let d = doc.resolve(o);
|
||||
let Some(d) = d.as_dict() else { return false };
|
||||
if d.name(b"Type") != Some(b"OCMD") {
|
||||
@@ -131,7 +155,188 @@ impl<'a> Scope<'a> {
|
||||
Some(_) => d.get(b"OCGs").and_then(Object::as_ref).into_iter().collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
!groups.is_empty() && groups.iter().all(|g| self.hidden_layers.contains(g))
|
||||
let fail_closed = || groups.iter().any(|g| self.hidden_layers.contains(g));
|
||||
let hidden = if let Some(expression) = d.get(b"VE") {
|
||||
let visible = self.visibility_expression(doc, expression, 0, &mut HashSet::new(), &mut 4096);
|
||||
match visible {
|
||||
Some(visible) => !visible,
|
||||
None => fail_closed() || self.expression_mentions_hidden(doc, expression, 0, &mut HashSet::new(), &mut 4096),
|
||||
}
|
||||
} else if groups.is_empty() {
|
||||
false
|
||||
} else {
|
||||
let any_on = groups.iter().any(|g| !self.hidden_layers.contains(g));
|
||||
let all_on = groups.iter().all(|g| !self.hidden_layers.contains(g));
|
||||
let policy = d.get(b"P").map(|p| doc.resolve(p)).and_then(|p| p.as_name().map(|name| name.to_vec()));
|
||||
match (d.get(b"P"), policy.as_deref()) {
|
||||
(None, _) | (Some(_), Some(b"AnyOn")) => !any_on,
|
||||
(Some(_), Some(b"AllOn")) => !all_on,
|
||||
(Some(_), Some(b"AnyOff")) => all_on,
|
||||
(Some(_), Some(b"AllOff")) => any_on,
|
||||
// An invalid policy must not make a candidate hidden block survive sanitization.
|
||||
_ => fail_closed(),
|
||||
}
|
||||
};
|
||||
if !hidden {
|
||||
self.retained_off_layers.extend(groups.iter().filter(|g| self.hidden_layers.contains(g)).copied());
|
||||
if let Some(expression) = d.get(b"VE") {
|
||||
let mut referenced = HashSet::new();
|
||||
if Self::collect_hidden_expression_groups(doc, expression, &self.hidden_layers, 0, &mut HashSet::new(), &mut 4096, &mut referenced) {
|
||||
self.retained_off_layers.extend(referenced);
|
||||
} else {
|
||||
// If an expression cannot be fully walked, preserve every off group so that
|
||||
// the content we kept does not change visibility after configuration cleanup.
|
||||
self.retained_off_layers.extend(self.hidden_layers.iter().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(r) = reference {
|
||||
self.ocmd_cache.insert(r, hidden);
|
||||
}
|
||||
hidden
|
||||
}
|
||||
|
||||
fn xobject_layer_hidden(&mut self, doc: &Document, xobject: ObjRef, oc: &Object) -> bool {
|
||||
if oc.as_ref().is_some() {
|
||||
return self.layer_hidden(doc, oc);
|
||||
}
|
||||
if let Some(hidden) = self.xobject_oc_cache.get(&xobject) {
|
||||
return *hidden;
|
||||
}
|
||||
let hidden = self.layer_hidden(doc, oc);
|
||||
self.xobject_oc_cache.insert(xobject, hidden);
|
||||
hidden
|
||||
}
|
||||
|
||||
fn collect_hidden_expression_groups(
|
||||
doc: &Document,
|
||||
expression: &Object,
|
||||
hidden_layers: &HashSet<ObjRef>,
|
||||
depth: usize,
|
||||
path: &mut HashSet<ObjRef>,
|
||||
budget: &mut usize,
|
||||
found: &mut HashSet<ObjRef>,
|
||||
) -> bool {
|
||||
if depth > 64 || *budget == 0 {
|
||||
return false;
|
||||
}
|
||||
*budget -= 1;
|
||||
let reference = expression.as_ref();
|
||||
if let Some(r) = reference
|
||||
&& !path.insert(r)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let resolved = doc.resolve(expression);
|
||||
let complete = match &*resolved {
|
||||
Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => {
|
||||
if let Some(r) = reference
|
||||
&& hidden_layers.contains(&r)
|
||||
{
|
||||
found.insert(r);
|
||||
}
|
||||
reference.is_some()
|
||||
}
|
||||
Object::Array(items) => {
|
||||
let Some(Object::Name(operator)) = items.first() else { return false };
|
||||
let operands = match operator.as_slice() {
|
||||
b"And" | b"Or" if (2..=4097).contains(&items.len()) => items.get(1..),
|
||||
b"Not" if items.len() == 2 => items.get(1..),
|
||||
_ => None,
|
||||
};
|
||||
operands.is_some_and(|operands| {
|
||||
operands.iter().all(|item| Self::collect_hidden_expression_groups(doc, item, hidden_layers, depth + 1, path, budget, found))
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if let Some(r) = reference {
|
||||
path.remove(&r);
|
||||
}
|
||||
complete
|
||||
}
|
||||
|
||||
/// Evaluate an optional-content visibility expression (`/VE`) against the default config.
|
||||
/// Malformed, cyclic, or excessive expressions return `None` so the caller can fail closed.
|
||||
fn visibility_expression(
|
||||
&self,
|
||||
doc: &Document,
|
||||
expression: &Object,
|
||||
depth: usize,
|
||||
path: &mut HashSet<ObjRef>,
|
||||
budget: &mut usize,
|
||||
) -> Option<bool> {
|
||||
if depth > 64 || *budget == 0 {
|
||||
return None;
|
||||
}
|
||||
*budget -= 1;
|
||||
let reference = expression.as_ref();
|
||||
if let Some(r) = reference
|
||||
&& !path.insert(r)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let resolved = doc.resolve(expression);
|
||||
let result = match &*resolved {
|
||||
Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => reference.map(|r| !self.hidden_layers.contains(&r)),
|
||||
Object::Array(items) if (2..=4097).contains(&items.len()) => {
|
||||
let operator = match items.first() {
|
||||
Some(Object::Name(n)) => n.as_slice(),
|
||||
_ => return None,
|
||||
};
|
||||
match operator {
|
||||
b"And" if items.len() >= 2 => {
|
||||
let mut visible = true;
|
||||
for item in items.iter().skip(1) {
|
||||
visible &= self.visibility_expression(doc, item, depth + 1, path, budget)?;
|
||||
}
|
||||
Some(visible)
|
||||
}
|
||||
b"Or" if items.len() >= 2 => {
|
||||
let mut visible = false;
|
||||
for item in items.iter().skip(1) {
|
||||
visible |= self.visibility_expression(doc, item, depth + 1, path, budget)?;
|
||||
}
|
||||
Some(visible)
|
||||
}
|
||||
b"Not" if items.len() == 2 => Some(!self.visibility_expression(doc, &items[1], depth + 1, path, budget)?),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(r) = reference {
|
||||
path.remove(&r);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Conservatively find hidden groups in an expression that could not be evaluated. Unknown
|
||||
/// syntax, cycles, and exhausted budgets count as a possible hidden reference.
|
||||
fn expression_mentions_hidden(&self, doc: &Document, expression: &Object, depth: usize, path: &mut HashSet<ObjRef>, budget: &mut usize) -> bool {
|
||||
if depth > 64 || *budget == 0 {
|
||||
return true;
|
||||
}
|
||||
*budget -= 1;
|
||||
if let Some(r) = expression.as_ref() {
|
||||
if self.hidden_layers.contains(&r) {
|
||||
return true;
|
||||
}
|
||||
if !path.insert(r) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let resolved = doc.resolve(expression);
|
||||
let found = match &*resolved {
|
||||
Object::Array(items) => items.iter().any(|item| self.expression_mentions_hidden(doc, item, depth + 1, path, budget)),
|
||||
Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => expression.as_ref().is_none_or(|r| self.hidden_layers.contains(&r)),
|
||||
Object::Dict(_) => true,
|
||||
_ => false,
|
||||
};
|
||||
if let Some(r) = expression.as_ref() {
|
||||
path.remove(&r);
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn hits(&self, b: [f64; 4]) -> bool {
|
||||
@@ -246,9 +451,10 @@ fn content_draws(doc: &Document, content: &[Vec<u8>], xo: &Dict, name: &[u8], de
|
||||
/// An entry goes unless a page that draws through that dictionary still draws the name (the same
|
||||
/// image shown on another page, or again outside a mark). A dictionary that loses an entry is
|
||||
/// replaced by a private copy on its node, so a dictionary object shared with other nodes is
|
||||
/// left as it is for them. Fails closed when a page's use of a name cannot be told.
|
||||
pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef]) -> Result<(), RedactError> {
|
||||
if doomed.is_empty() {
|
||||
/// left as it is for them. Retired properties are likewise unlinked only when none of the
|
||||
/// holder's pages or inheriting forms names them. Fails closed when use cannot be determined.
|
||||
pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_properties: &[Object]) -> Result<(), RedactError> {
|
||||
if doomed.is_empty() && retired_properties.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let doomed: HashSet<ObjRef> = doomed.iter().copied().collect();
|
||||
@@ -278,11 +484,16 @@ pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef]) -> Result<(),
|
||||
}
|
||||
}
|
||||
let index: HashMap<ObjRef, usize> = pdfcraft_model::pages(doc).iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
|
||||
let holder_nodes: Vec<ObjRef> = holders.iter().map(|(node, _)| *node).collect();
|
||||
for (node, users) in holders {
|
||||
let Some(res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue };
|
||||
let Some(mut res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else {
|
||||
continue;
|
||||
};
|
||||
let mut xo = res_dict(doc, &res, b"XObject");
|
||||
let names: Vec<Vec<u8>> = xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect();
|
||||
if names.is_empty() {
|
||||
let properties = res_dict(doc, &res, b"Properties");
|
||||
let property_names: Vec<Vec<u8>> = properties.iter().filter(|(_, v)| retired_properties.contains(v)).map(|(n, _)| n.to_vec()).collect();
|
||||
if names.is_empty() && property_names.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut contents = Vec::with_capacity(users.len());
|
||||
@@ -310,11 +521,84 @@ pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef]) -> Result<(),
|
||||
}
|
||||
}
|
||||
if pruned {
|
||||
let mut res = res;
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
}
|
||||
pruned |= prune_retired_properties(doc, &mut res, &property_names, &contents)?;
|
||||
if pruned {
|
||||
doc.update_dict(node, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
|
||||
}
|
||||
}
|
||||
// A surviving form can own the same old resource dictionary as a rewritten page. Its
|
||||
// unused descriptions remain reachable too, even after every page holder is cleaned.
|
||||
unlink_form_properties(doc, &holder_nodes, retired_properties)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retire only properties removed from a rewritten scope, preserving names still used by any
|
||||
/// content sharing this holder. Different pages' streams must never be concatenated together.
|
||||
fn prune_retired_properties(doc: &Document, res: &mut Dict, names: &[Vec<u8>], contents: &[Vec<Vec<u8>>]) -> Result<bool, CosError> {
|
||||
if names.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut used = HashSet::new();
|
||||
for content in contents {
|
||||
for op in resource_ops(doc, res, content)? {
|
||||
if matches!(op.op.as_slice(), b"BDC" | b"DP")
|
||||
&& let Some(name) = op.name(1)
|
||||
{
|
||||
used.insert(name.to_vec());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut properties = res_dict(doc, res, b"Properties");
|
||||
let mut pruned = false;
|
||||
for name in names {
|
||||
if !used.contains(name) {
|
||||
pruned |= properties.remove(name).is_some();
|
||||
}
|
||||
}
|
||||
if pruned {
|
||||
if properties.is_empty() {
|
||||
res.remove(b"Properties");
|
||||
} else {
|
||||
res.set(b"Properties".to_vec(), Object::Dict(properties));
|
||||
}
|
||||
}
|
||||
Ok(pruned)
|
||||
}
|
||||
|
||||
fn unlink_form_properties(doc: &mut Document, holders: &[ObjRef], retired: &[Object]) -> Result<(), CosError> {
|
||||
if retired.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut pending: Vec<Dict> = holders
|
||||
.iter()
|
||||
.filter_map(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()))
|
||||
.collect();
|
||||
let mut seen = HashSet::new();
|
||||
while let Some(resources) = pending.pop() {
|
||||
for (_, value) in res_dict(doc, &resources, b"XObject").iter() {
|
||||
let Some(reference) = value.as_ref() else { continue };
|
||||
if !seen.insert(reference) {
|
||||
continue;
|
||||
}
|
||||
if seen.len() > 1_000_000 {
|
||||
return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() });
|
||||
}
|
||||
let object = doc.get(reference);
|
||||
let Object::Stream(form) = &*object else { continue };
|
||||
if form.dict.name(b"Subtype") != Some(b"Form") {
|
||||
continue;
|
||||
}
|
||||
let Some(mut res) = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue };
|
||||
let names: Vec<Vec<u8>> =
|
||||
res_dict(doc, &res, b"Properties").iter().filter(|(_, value)| retired.contains(value)).map(|(name, _)| name.to_vec()).collect();
|
||||
if !names.is_empty() && prune_retired_properties(doc, &mut res, &names, &[vec![form.decoded()?]])? {
|
||||
doc.update_dict(reference, |d| d.set(b"Resources".to_vec(), Object::Dict(res.clone())))?;
|
||||
}
|
||||
pending.push(res);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -341,6 +625,71 @@ fn clip_out(rects: &[[f64; 4]], ctm: &Matrix, around: [f64; 4]) -> Option<Vec<Op
|
||||
Some(ops)
|
||||
}
|
||||
|
||||
fn resource_ops(doc: &Document, res: &Dict, content: &[Vec<u8>]) -> Result<Vec<Op>, CosError> {
|
||||
let parsed = Pieces::join(content).parse();
|
||||
if parsed.skipped > 0 || parsed.ops.len() > 2_000_000 {
|
||||
return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect property resources".into() });
|
||||
}
|
||||
let mut ops = parsed.ops;
|
||||
let xobjects = res_dict(doc, res, b"XObject");
|
||||
let mut seen = HashSet::new();
|
||||
let mut cursor = 0;
|
||||
while cursor < ops.len() {
|
||||
let op = &ops[cursor];
|
||||
if op.is("Do")
|
||||
&& let Some(r) = op.name(0).and_then(|n| xobjects.get(n)).and_then(Object::as_ref)
|
||||
&& seen.insert(r)
|
||||
&& let Object::Stream(s) = &*doc.get(r)
|
||||
&& s.dict.name(b"Subtype") == Some(b"Form")
|
||||
&& s.dict.get(b"Resources").is_none_or(|r| doc.resolve(r).as_dict().is_none())
|
||||
{
|
||||
let parsed = pdfcraft_content::parse(&s.decoded()?);
|
||||
if seen.len() > 2_000_000 || ops.len().saturating_add(parsed.ops.len()) > 2_000_000 || parsed.skipped > 0 {
|
||||
return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect inherited form resources".into() });
|
||||
}
|
||||
ops.extend(parsed.ops);
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
Ok(ops)
|
||||
}
|
||||
|
||||
pub(crate) fn prune_properties(doc: &Document, res: &mut Dict, content: &[Vec<u8>]) -> Result<Vec<Object>, CosError> {
|
||||
let mut properties = res_dict(doc, res, b"Properties");
|
||||
if properties.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut retired = Vec::new();
|
||||
let ops = resource_ops(doc, res, content)?;
|
||||
let names: Vec<Vec<u8>> = properties.iter().map(|(n, _)| n.to_vec()).collect();
|
||||
for name in names {
|
||||
if !ops.iter().any(|op| matches!(op.op.as_slice(), b"BDC" | b"DP") && op.name(1) == Some(&name))
|
||||
&& let Some(value) = properties.remove(&name)
|
||||
{
|
||||
retired.push(value);
|
||||
}
|
||||
}
|
||||
if properties.is_empty() {
|
||||
res.remove(b"Properties");
|
||||
} else {
|
||||
res.set(b"Properties".to_vec(), Object::Dict(properties));
|
||||
}
|
||||
Ok(retired)
|
||||
}
|
||||
|
||||
fn prune_form_resources(doc: &Document, res: &mut Dict, content: &[Vec<u8>]) -> Result<(), CosError> {
|
||||
let ops = resource_ops(doc, res, content)?;
|
||||
let mut xo = res_dict(doc, res, b"XObject");
|
||||
let names: Vec<Vec<u8>> = xo.iter().map(|(n, _)| n.to_vec()).collect();
|
||||
for name in names {
|
||||
if !ops.iter().any(|op| op.is("Do") && op.name(0) == Some(&name)) {
|
||||
xo.remove(&name);
|
||||
}
|
||||
}
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One glyph of a shown string.
|
||||
struct Glyph {
|
||||
bytes: std::ops::Range<usize>,
|
||||
@@ -380,7 +729,13 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
|
||||
// next. Parse them joined, then give each operator back to the piece its keyword is in.
|
||||
let joined = Pieces::join(streams);
|
||||
let mut parsed = joined.parse();
|
||||
if !scope.hidden_layers.is_empty() {
|
||||
// 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.layers_enabled {
|
||||
let props = res_dict(doc, resources, b"Properties");
|
||||
let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props);
|
||||
parsed.ops = kept;
|
||||
@@ -405,6 +760,7 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
|
||||
}
|
||||
}
|
||||
|
||||
let mut processed = Vec::new();
|
||||
for (piece, mut changed) in pieces.into_iter().zip(rewrite) {
|
||||
let mut ops: Vec<Op> = Vec::with_capacity(piece.len());
|
||||
for op in piece {
|
||||
@@ -622,11 +978,68 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
|
||||
if !path.is_empty() {
|
||||
ops.append(&mut path);
|
||||
}
|
||||
out.streams.push(changed.then(|| serialize_ops(&ops)));
|
||||
processed.push((ops, changed));
|
||||
}
|
||||
if scope.mode == Mode::Apply {
|
||||
clean_replacements(doc, resources, &original, &mut processed, scope.report);
|
||||
}
|
||||
for ((ops, changed), (comment_in, comment_out)) in processed.into_iter().zip(comment_edges) {
|
||||
out.streams.push(changed.then(|| {
|
||||
// The piece before still ends inside a comment: end it before the first operator.
|
||||
let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() };
|
||||
bytes.extend(serialize_ops(&ops));
|
||||
// The piece ended inside a comment that hid the start of the next one: keep it open.
|
||||
if comment_out {
|
||||
bytes.push(b'%');
|
||||
}
|
||||
bytes
|
||||
}));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn clean_replacements(doc: &Document, resources: &Dict, original: &[Op], processed: &mut [(Vec<Op>, bool)], report: &mut Report) {
|
||||
let originals: HashMap<usize, &Op> = original.iter().map(|op| (op.span.start, op)).collect();
|
||||
let unchanged: HashSet<usize> =
|
||||
processed.iter().flat_map(|(ops, _)| ops).filter(|op| originals.get(&op.span.start) == Some(op)).map(|op| op.span.start).collect();
|
||||
let mut stack = Vec::new();
|
||||
let mut affected = HashSet::new();
|
||||
for op in original {
|
||||
match op.op.as_slice() {
|
||||
b"BDC" | b"BMC" => stack.push(op.span.start),
|
||||
b"EMC" => {
|
||||
stack.pop();
|
||||
}
|
||||
_ if !unchanged.contains(&op.span.start) => affected.extend(stack.iter().copied()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let properties = res_dict(doc, resources, b"Properties");
|
||||
for (ops, changed) in processed {
|
||||
for op in ops {
|
||||
if !op.is("BDC") || !affected.contains(&op.span.start) {
|
||||
continue;
|
||||
}
|
||||
let property = match op.operands.get(1) {
|
||||
Some(Object::Name(name)) => properties.get(name).map(|p| doc.resolve(p)),
|
||||
Some(p) => Some(doc.resolve(p)),
|
||||
None => None,
|
||||
};
|
||||
if let Some(mut dict) = property.and_then(|p| p.as_dict().cloned()) {
|
||||
let mut stripped = false;
|
||||
for key in [&b"ActualText"[..], b"Alt", b"E", b"T"] {
|
||||
stripped |= dict.remove(key).is_some();
|
||||
}
|
||||
if stripped && let Some(property) = op.operands.get_mut(1) {
|
||||
*property = Object::Dict(dict);
|
||||
*changed = true;
|
||||
report.replacement_text += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out a shown string (or `TJ` array) glyph by glyph, advancing `tm`. Returns the `TJ`
|
||||
/// items with the glyphs under a region replaced by displacements, and how many were removed.
|
||||
fn show(scope: &Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (Vec<Object>, usize) {
|
||||
@@ -716,11 +1129,13 @@ fn xobject(
|
||||
let obj = doc.get(r);
|
||||
let Object::Stream(s) = &*obj else { return None };
|
||||
match s.dict.name(b"Subtype") {
|
||||
_ if s.dict.get(b"OC").is_some_and(|oc| scope.layer_hidden(doc, oc)) => {
|
||||
_ if s.dict.get(b"OC").is_some_and(|oc| scope.xobject_layer_hidden(doc, r, oc)) => {
|
||||
// Verify mode must count hidden XObjects too: scan uses that count to decide
|
||||
// whether the HiddenLayers pass is necessary.
|
||||
scope.layer_blocks += 1;
|
||||
if scope.mode == Mode::Verify {
|
||||
return None;
|
||||
}
|
||||
scope.layer_blocks += 1;
|
||||
out.replaced.push(name);
|
||||
out.doomed.push(r);
|
||||
Some(None)
|
||||
@@ -771,6 +1186,11 @@ fn xobject(
|
||||
}
|
||||
if scope.depth >= MAX_DEPTH {
|
||||
if scope.mode == Mode::Verify {
|
||||
// Sanitization removes a Form it cannot enter, so the verification scan
|
||||
// must count that conservative removal or it may decide to skip the pass.
|
||||
if scope.everywhere() {
|
||||
scope.layer_blocks += 1;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
scope.report.forms_removed += 1;
|
||||
@@ -786,7 +1206,14 @@ fn xobject(
|
||||
out.doomed.push(r);
|
||||
return Some(None);
|
||||
}
|
||||
Err(_) => return None,
|
||||
Err(_) => {
|
||||
// Apply drops an undecodable Form while sanitizing. Count it during Verify
|
||||
// so a zero-count preflight cannot leave its potentially hidden bytes behind.
|
||||
if scope.everywhere() {
|
||||
scope.layer_blocks += 1;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let own = s.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned());
|
||||
let res = own.clone().unwrap_or_else(|| resources.clone());
|
||||
@@ -798,7 +1225,9 @@ fn xobject(
|
||||
out.residue += inner.residue;
|
||||
out.vertical |= inner.vertical;
|
||||
out.doomed.extend_from_slice(&inner.doomed);
|
||||
out.retired_properties.extend(inner.retired_properties);
|
||||
out.tag_changes.extend(inner.tag_changes);
|
||||
out.error = inner.error.or(out.error.take());
|
||||
if scope.mode == Mode::Verify {
|
||||
return None;
|
||||
}
|
||||
@@ -808,19 +1237,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 {
|
||||
@@ -843,7 +1288,7 @@ fn xobject(
|
||||
|
||||
/// Remove marked-content blocks of hidden layers (`/OC /name BDC … EMC`, nested blocks
|
||||
/// included). Returns the remaining operators and how many blocks went.
|
||||
fn strip_hidden_layers(doc: &Document, scope: &Scope<'_>, ops: Vec<Op>, props: &Dict) -> (Vec<Op>, usize) {
|
||||
fn strip_hidden_layers(doc: &Document, scope: &mut Scope<'_>, ops: Vec<Op>, props: &Dict) -> (Vec<Op>, usize) {
|
||||
let mut out = Vec::with_capacity(ops.len());
|
||||
// For each open marked-content block: does it hide its contents?
|
||||
let mut stack: Vec<bool> = Vec::new();
|
||||
|
||||
@@ -51,6 +51,13 @@ pub enum RedactError {
|
||||
"redaction appearance has invalid geometry or exceeds the 8 MiB budget; reduce the marked area, turn off repeated text or apply fewer pages at once (nothing was redacted)"
|
||||
)]
|
||||
OverlayLimit,
|
||||
/// The structure tree couldn't be cleaned completely (#3302): an element written in place that
|
||||
/// would need changing, an element whose inherited page can't be found, or a tree too large to
|
||||
/// walk. Tags that kept the removed text or annotation would undo the redaction.
|
||||
#[error(
|
||||
"the document's tags can't be cleaned completely (an element written in place, or a structure tree too large or too deep to follow); the operation was refused"
|
||||
)]
|
||||
Tags,
|
||||
#[error(transparent)]
|
||||
Edit(#[from] pdfcraft_edit::EditError),
|
||||
#[error(transparent)]
|
||||
@@ -119,6 +126,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
|
||||
@@ -304,6 +312,12 @@ fn localize_resources(doc: &mut Document, selected: &[usize]) -> Result<(), pdfc
|
||||
|
||||
/// Apply every redaction mark (or only those on `pages`, 0-based). Irreversible for the saved
|
||||
/// file; callers keep the previous document for undo.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This operation is not transactional: an error may leave `doc` partially modified, with
|
||||
/// sensitive content still present. Call this on a working clone and replace the original only
|
||||
/// on success. On any error, discard the working clone; do not save or reuse it.
|
||||
pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, RedactError> {
|
||||
let all_marks = marks(doc);
|
||||
let chosen: Vec<&Mark> = all_marks.iter().filter(|m| pages.is_none_or(|p| p.contains(&m.page))).collect();
|
||||
@@ -334,6 +348,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
|
||||
let mut doomed_fields: Vec<String> = Vec::new();
|
||||
// The original image and form XObjects the rewrite drops or replaces, on any page.
|
||||
let mut doomed_xobjects: Vec<ObjRef> = Vec::new();
|
||||
let mut retired_properties = Vec::new();
|
||||
let widget_owner: Vec<(ObjRef, String)> =
|
||||
pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::<Vec<_>>()).collect();
|
||||
|
||||
@@ -353,10 +368,14 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
|
||||
// leave the text in place, refuse the whole operation (the caller keeps the previous
|
||||
// document). The error does not claim the text was under the mark. Full vertical-text
|
||||
// removal is a future enhancement.
|
||||
if let Some(error) = out.error {
|
||||
return Err(error.into());
|
||||
}
|
||||
if out.vertical {
|
||||
return Err(RedactError::VerticalText(pi + 1));
|
||||
}
|
||||
doomed_xobjects.extend_from_slice(&out.doomed);
|
||||
retired_properties.extend(out.retired_properties);
|
||||
let mut new_list = list.clone();
|
||||
let mut changed = false;
|
||||
for (i, new) in out.streams.into_iter().enumerate() {
|
||||
@@ -393,6 +412,16 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
|
||||
}
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
}
|
||||
let (_, cleaned_data) = page_streams(
|
||||
doc,
|
||||
&{
|
||||
let mut cleaned_page = page.dict.clone();
|
||||
cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone()));
|
||||
cleaned_page
|
||||
},
|
||||
pi,
|
||||
)?;
|
||||
retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?);
|
||||
doc.update_dict(page.obj, |d| {
|
||||
d.set(b"Contents".to_vec(), Object::Array(new_list));
|
||||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
@@ -400,7 +429,8 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
|
||||
// Tags must not keep what the page no longer shows.
|
||||
let after = doc.get(page.obj).as_dict().cloned().unwrap_or_default();
|
||||
let (_, new_data) = page_streams(doc, &after, pi)?;
|
||||
let (changed_ids, empty_ids) = tags::touched(doc, &resources, &data.join(&b'\n'), &new_data.join(&b'\n'));
|
||||
let (changed_ids, empty_ids) =
|
||||
tags::touched(doc, &resources, pdfcraft_content::Pieces::join(&data).data(), pdfcraft_content::Pieces::join(&new_data).data());
|
||||
report.tags += tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?;
|
||||
}
|
||||
|
||||
@@ -438,6 +468,32 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
|
||||
}
|
||||
}
|
||||
}
|
||||
// Replies (`/IRT`, ISO 32000-2 §12.5.6.2) to a removed annotation go too, and replies to
|
||||
// those: a reply placed elsewhere would keep its parent — the comment and the QuadPoints
|
||||
// of the redacted words — in the file through its `/IRT` (#2659). Each pass removes at
|
||||
// least one more annotation of this page or stops, so it ends.
|
||||
let replies: Vec<(ObjRef, ObjRef)> = annots
|
||||
.iter()
|
||||
.filter_map(Object::as_ref)
|
||||
.filter_map(|r| {
|
||||
let obj = doc.get(r);
|
||||
let d = obj.as_dict()?;
|
||||
(d.name(b"Subtype") != Some(b"Widget")).then_some(())?;
|
||||
Some((r, d.get(b"IRT").and_then(Object::as_ref)?))
|
||||
})
|
||||
.collect();
|
||||
loop {
|
||||
let before = removed.len();
|
||||
for (r, parent) in &replies {
|
||||
if !removed.contains(r) && removed.contains(parent) {
|
||||
removed.push(*r);
|
||||
report.annotations += 1;
|
||||
}
|
||||
}
|
||||
if removed.len() == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let kept: Vec<Object> = annots
|
||||
.into_iter()
|
||||
.filter(|a| {
|
||||
@@ -487,7 +543,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
|
||||
|
||||
// A removed image or form must not stay reachable through another resource dictionary (an
|
||||
// inherited or shared /Resources): a full save keeps whatever is still referenced.
|
||||
interp::unlink_doomed(doc, &doomed_xobjects)?;
|
||||
interp::unlink_doomed(doc, &doomed_xobjects, &retired_properties)?;
|
||||
|
||||
// 5. Verify: nothing readable may remain under a region.
|
||||
let mut residue = 0;
|
||||
|
||||
@@ -140,14 +140,16 @@ fn address_char(c: char) -> bool {
|
||||
|
||||
fn email(s: &[char], i: usize) -> Option<usize> {
|
||||
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<usize> {
|
||||
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<u32> = 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<u32> = 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<usize> {
|
||||
@@ -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::<String>::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::<String>::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::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn social_security_numbers() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -138,14 +138,16 @@ fn hidden_layers(doc: &Document) -> Vec<ObjRef> {
|
||||
}
|
||||
|
||||
/// Glyphs of hidden text, or content of hidden layers, on every page (`write` = remove them).
|
||||
fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Result<usize, RedactError> {
|
||||
fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Result<(usize, std::collections::HashSet<ObjRef>), RedactError> {
|
||||
let off = if layers { hidden_layers(doc) } else { Vec::new() };
|
||||
if !text && off.is_empty() {
|
||||
return Ok(0);
|
||||
if !text && !layers {
|
||||
return Ok((0, std::collections::HashSet::new()));
|
||||
}
|
||||
let mut total = 0;
|
||||
let mut retained_off_layers = std::collections::HashSet::new();
|
||||
// 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);
|
||||
@@ -163,14 +165,23 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
|
||||
let c = page.crop(doc);
|
||||
scope.hidden_text = Some([c[0].min(c[2]), c[1].min(c[3]), c[0].max(c[2]), c[1].max(c[3])]);
|
||||
}
|
||||
scope.hidden_layers = off.clone();
|
||||
scope.hidden_layers = off.iter().copied().collect();
|
||||
scope.layers_enabled = layers;
|
||||
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;
|
||||
let glyphs = scope.report.glyphs;
|
||||
let retained_for_page = std::mem::take(&mut scope.retained_off_layers);
|
||||
drop(scope);
|
||||
total += out.residue + blocks + glyphs;
|
||||
retained_off_layers.extend(retained_for_page);
|
||||
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 +195,24 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
|
||||
changed = true;
|
||||
}
|
||||
if changed || !out.xobjects.is_empty() || !out.replaced.is_empty() {
|
||||
// Pin inherited resources before replacing them. When no page still inherits the
|
||||
// original holder, retiring it also retires descriptions no longer used anywhere.
|
||||
crate::localize_resources(doc, &[pi])?;
|
||||
let mut cleaned_page = page.dict.clone();
|
||||
cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone()));
|
||||
let (_, page_content) = page_streams(doc, &cleaned_page, pi)?;
|
||||
let mut res = resources;
|
||||
if !out.xobjects.is_empty() || !out.replaced.is_empty() {
|
||||
let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default();
|
||||
// A full save keeps whatever is still referenced: unlink the originals this page no
|
||||
// longer draws so the removed form or image bytes cannot survive in the saved file.
|
||||
let page_content: Vec<Vec<u8>> = new_list
|
||||
.iter()
|
||||
.map(|o| match &*doc.resolve(o) {
|
||||
Object::Stream(s) => s.decoded().unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
crate::interp::prune(&mut xo, &out.replaced, &page_content);
|
||||
for (n, r) in &out.xobjects {
|
||||
xo.set(n.clone(), Object::Ref(*r));
|
||||
}
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
}
|
||||
retired_properties.extend(crate::interp::prune_properties(doc, &mut res, &page_content)?);
|
||||
doc.update_dict(page.obj, |d| {
|
||||
d.set(b"Contents".to_vec(), Object::Array(new_list));
|
||||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
@@ -210,8 +221,8 @@ 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)?;
|
||||
Ok(total)
|
||||
crate::interp::unlink_doomed(doc, &doomed, &retired_properties)?;
|
||||
Ok((total, retained_off_layers))
|
||||
}
|
||||
|
||||
/// How many items each category would remove (categories with nothing are included as 0).
|
||||
@@ -234,10 +245,10 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> {
|
||||
Hidden::Attachments => sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t, 0)) + count_sub(b"FileAttachment"),
|
||||
Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(),
|
||||
Hidden::FormFields => pdfcraft_forms::fields(doc).len(),
|
||||
Hidden::HiddenText => content_pass(&mut doc2, true, false, false).unwrap_or(0),
|
||||
Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0),
|
||||
Hidden::HiddenLayers => {
|
||||
let off = hidden_layers(doc).len();
|
||||
if off == 0 { 0 } else { off + content_pass(&mut doc2, false, true, false).unwrap_or(0) }
|
||||
off + content_pass(&mut doc2, false, true, false).map(|(n, _)| n).unwrap_or(0)
|
||||
}
|
||||
Hidden::Bookmarks => sub(doc, &cat, b"Outlines").map_or(0, |o| outline_len(doc, o.get(b"First"), 0)),
|
||||
Hidden::LinksActionsScripts => {
|
||||
@@ -256,6 +267,12 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> {
|
||||
}
|
||||
|
||||
/// Remove the chosen categories. Returns what was removed per category.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This operation is not transactional: an error may leave `doc` partially modified, with
|
||||
/// sensitive content still present. Call this on a working clone and replace the original only
|
||||
/// on success. On any error, discard the working clone; do not save or reuse it.
|
||||
pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden, usize)>, RedactError> {
|
||||
let counts = scan(doc);
|
||||
let count = |h: Hidden| counts.iter().find(|c| c.0 == h).map_or(0, |c| c.1);
|
||||
@@ -263,9 +280,17 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
|
||||
let mut done = Vec::new();
|
||||
// Content first (it needs the layers and the form still in place).
|
||||
let (text, layers) = (which.contains(&Hidden::HiddenText), which.contains(&Hidden::HiddenLayers));
|
||||
if (text && count(Hidden::HiddenText) > 0) || (layers && count(Hidden::HiddenLayers) > 0) {
|
||||
content_pass(doc, text, layers, true)?;
|
||||
}
|
||||
let retained_off_layers = if (text && count(Hidden::HiddenText) > 0) || (layers && count(Hidden::HiddenLayers) > 0) {
|
||||
content_pass(doc, text, layers, true)?.1
|
||||
} else {
|
||||
std::collections::HashSet::new()
|
||||
};
|
||||
// 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<ObjRef> {
|
||||
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 +311,16 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
|
||||
|| (which.contains(&Hidden::Attachments) && s == b"FileAttachment")
|
||||
|| (which.contains(&Hidden::LinksActionsScripts) && s == b"Link")
|
||||
};
|
||||
// The annotations to remove, on every page first, so a pop-up listed on another page than
|
||||
// its parent goes with it.
|
||||
let removed: std::collections::HashSet<ObjRef> = 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<ObjRef> = 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 +360,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
|
||||
}
|
||||
})?;
|
||||
}
|
||||
// A tagged PDF's structure tree still points at the removed links, comments and flattened
|
||||
// widgets (#2447): drop those references, or the full rewrite keeps the annotations, URLs,
|
||||
// text and field values included.
|
||||
let still = listed(doc);
|
||||
let gone: std::collections::HashSet<ObjRef> = 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");
|
||||
@@ -371,7 +402,7 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
|
||||
Hidden::HiddenLayers if !off.is_empty() => {
|
||||
// The off layers leave the configuration (their content is gone already).
|
||||
if let Some(mut oc) = sub(doc, &cat, b"OCProperties") {
|
||||
let keep = |o: &Object| !o.as_ref().is_some_and(|r| off.contains(&r));
|
||||
let keep = |o: &Object| !o.as_ref().is_some_and(|r| off.contains(&r) && !retained_off_layers.contains(&r));
|
||||
if let Some(Object::Array(a)) = oc.get(b"OCGs").map(|g| (*doc.resolve(g)).clone()) {
|
||||
oc.set(b"OCGs".to_vec(), Object::Array(a.into_iter().filter(keep).collect()));
|
||||
}
|
||||
|
||||
+222
-11
@@ -9,6 +9,8 @@ use std::collections::{HashMap, HashSet};
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
|
||||
use crate::RedactError;
|
||||
|
||||
/// Each MCID's operators (serialized), and whether anything is still drawn inside it.
|
||||
fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap<i64, (Vec<u8>, bool)> {
|
||||
let mut out: HashMap<i64, (Vec<u8>, bool)> = HashMap::new();
|
||||
@@ -103,7 +105,23 @@ pub(crate) fn clean(
|
||||
stream: Option<(ObjRef, ObjRef)>,
|
||||
changed: &HashSet<i64>,
|
||||
empty: &HashSet<i64>,
|
||||
) -> Result<usize, pdfcraft_cos::CosError> {
|
||||
) -> Result<usize, RedactError> {
|
||||
clean_within(doc, page, stream, changed, empty, MAX_STRUCT_REFS)
|
||||
}
|
||||
|
||||
/// [`clean`], visiting at most `limit` structure-tree references. Fail-closed (#3302): when the
|
||||
/// cleanup can't be shown complete, it is [`RedactError::Tags`] and the redaction isn't made. That
|
||||
/// is a tree too large to walk in full (it could hide an element that repeats the redacted text),
|
||||
/// an element whose inherited page can't be found (its ancestry too deep, or looping), and an
|
||||
/// element written in place that would need changing (only the object holding it could be).
|
||||
pub(crate) fn clean_within(
|
||||
doc: &mut Document,
|
||||
page: ObjRef,
|
||||
stream: Option<(ObjRef, ObjRef)>,
|
||||
changed: &HashSet<i64>,
|
||||
empty: &HashSet<i64>,
|
||||
limit: usize,
|
||||
) -> Result<usize, RedactError> {
|
||||
if changed.is_empty() && stream.is_none() {
|
||||
return Ok(0);
|
||||
}
|
||||
@@ -114,17 +132,44 @@ pub(crate) fn clean(
|
||||
else {
|
||||
return Ok(0);
|
||||
};
|
||||
// Indirect elements with the page they inherit.
|
||||
let mut todo: Vec<(Object, Option<ObjRef>)> = root.get(b"K").map(|k| vec![(k.clone(), None)]).unwrap_or_default();
|
||||
// Indirect elements with the page they inherit. Elements are reached from the root's /K, and
|
||||
// also from the parent tree and the ID tree: one listed only there still owns marked content
|
||||
// (#3302). The stack pops /K first, so an element reached through /K gets the page it inherits
|
||||
// from its ancestors before a tree reaches it without one.
|
||||
let mut todo: Vec<(Object, Option<ObjRef>)> =
|
||||
[&b"IDTree"[..], b"ParentTree", b"K"].iter().filter_map(|key| root.get(key).cloned()).map(|k| (k, None)).collect();
|
||||
let mut seen = HashSet::new();
|
||||
let mut pages = HashMap::new();
|
||||
let mut hits: Vec<(ObjRef, Dict, Option<ObjRef>, bool)> = Vec::new();
|
||||
// Whether marked content `x` (a kid on page `pg`) is one this cleanup must change.
|
||||
let touches = |doc: &Document, x: &Object, pg: Option<ObjRef>| {
|
||||
mcid_of(doc, x, pg, page, stream).is_some_and(|m| stream.is_some() || changed.contains(&m) || empty.contains(&m))
|
||||
};
|
||||
while let Some((k, pg)) = todo.pop() {
|
||||
match k {
|
||||
Object::Array(a) => todo.extend(a.into_iter().map(|x| (x, pg))),
|
||||
// A number or name tree node written in place.
|
||||
Object::Dict(d) if !d.contains(b"S") => todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg))),
|
||||
// An element written in place: its kids are followed, but if it owns marked content
|
||||
// this cleanup changes, only rewriting what holds it could change it.
|
||||
Object::Dict(d) => {
|
||||
let pg = match d.get(b"Pg").and_then(Object::as_ref).or(pg) {
|
||||
Some(p) => Some(p),
|
||||
None => inherited_page(doc, &d, &mut pages)?,
|
||||
};
|
||||
let kids = kids_of(doc, &d);
|
||||
if kids.iter().any(|x| touches(doc, x, pg)) {
|
||||
return Err(RedactError::Tags);
|
||||
}
|
||||
todo.extend(kids.into_iter().map(|x| (x, pg)));
|
||||
}
|
||||
Object::Ref(r) => {
|
||||
if !seen.insert(r) || seen.len() > 2_000_000 {
|
||||
if !seen.insert(r) {
|
||||
continue;
|
||||
}
|
||||
if seen.len() > limit {
|
||||
return Err(RedactError::Tags);
|
||||
}
|
||||
let object = doc.get(r);
|
||||
if let Object::Array(a) = &*object {
|
||||
todo.extend(a.iter().cloned().map(|x| (x, pg)));
|
||||
@@ -132,16 +177,15 @@ pub(crate) fn clean(
|
||||
}
|
||||
let Some(d) = object.as_dict().cloned() else { continue };
|
||||
if !d.contains(b"S") {
|
||||
// A parent tree or ID tree node: its values may be elements.
|
||||
todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg)));
|
||||
continue;
|
||||
}
|
||||
let pg = d.get(b"Pg").and_then(Object::as_ref).or(pg);
|
||||
let kids: Vec<Object> = 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(),
|
||||
let pg = match d.get(b"Pg").and_then(Object::as_ref).or(pg) {
|
||||
Some(p) => Some(p),
|
||||
None => inherited_page(doc, &d, &mut pages)?,
|
||||
};
|
||||
let kids = kids_of(doc, &d);
|
||||
let sensitive = kids.iter().any(|x| mcid_of(doc, x, pg, page, stream).is_some_and(|m| changed.contains(&m)));
|
||||
let remap = stream.is_some() && kids.iter().any(|x| mcid_of(doc, x, pg, page, stream).is_some());
|
||||
if sensitive || remap {
|
||||
@@ -202,3 +246,170 @@ pub(crate) fn clean(
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// The page an element inherits (§14.7.4.4): the nearest `/Pg` among its ancestors, following
|
||||
/// `/P`. `Ok(None)` when none has one; [`RedactError::Tags`] when the lookup gives up (more than
|
||||
/// [`MAX_PARENTS`] ancestors, or a loop), which isn't the same as having no page. `pages` keeps
|
||||
/// each ancestor's answer, so siblings don't climb the same chain again.
|
||||
fn inherited_page(doc: &Document, d: &Dict, pages: &mut HashMap<ObjRef, Option<ObjRef>>) -> Result<Option<ObjRef>, RedactError> {
|
||||
let mut chain: Vec<ObjRef> = Vec::new();
|
||||
let mut parent = d.get(b"P").and_then(Object::as_ref);
|
||||
let found = loop {
|
||||
let Some(r) = parent else { break None };
|
||||
if let Some(known) = pages.get(&r) {
|
||||
break *known;
|
||||
}
|
||||
if chain.len() >= MAX_PARENTS || chain.contains(&r) {
|
||||
return Err(RedactError::Tags);
|
||||
}
|
||||
chain.push(r);
|
||||
let object = doc.get(r);
|
||||
let Some(p) = object.as_dict() else { break None };
|
||||
if let Some(pg) = p.get(b"Pg").and_then(Object::as_ref) {
|
||||
break Some(pg);
|
||||
}
|
||||
parent = p.get(b"P").and_then(Object::as_ref);
|
||||
};
|
||||
for r in chain {
|
||||
pages.insert(r, found);
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// An element's kids: its `/K` array, or the one kid it has.
|
||||
fn kids_of(doc: &Document, d: &Dict) -> Vec<Object> {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The most ancestors [`inherited_page`] looks through; deeper trees, and cycles, go unanswered.
|
||||
const MAX_PARENTS: usize = 256;
|
||||
|
||||
/// Whether `o` is an object reference (`/Type /OBJR`, direct or indirect) to one of `gone`.
|
||||
fn refers_to(doc: &Document, o: &Object, gone: &HashSet<ObjRef>) -> 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)))
|
||||
}
|
||||
|
||||
/// What a number or name tree node leads on to: its `/Nums` or `/Names` array (keys and values
|
||||
/// alike; the walk ignores numbers and strings) and its `/Kids`.
|
||||
fn tree_node_entries(d: &Dict) -> Vec<Object> {
|
||||
[&b"Nums"[..], b"Names", b"Kids"].iter().filter_map(|key| d.get(key).cloned()).collect()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Only elements that are objects of their own are edited: changing a structure element written in
|
||||
/// place (a direct dictionary in a `/K` array or a tree) means rewriting the object that holds it,
|
||||
/// so when one of those refers to a removed annotation it is [`RedactError::Tags`] (fail-closed).
|
||||
/// Producers rarely write elements that way.
|
||||
pub(crate) fn drop_object_refs(doc: &mut Document, gone: &HashSet<ObjRef>) -> Result<usize, RedactError> {
|
||||
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. Fail-closed: a tree too large to
|
||||
/// walk in full could keep a reference the walk never reached, and an element written in place
|
||||
/// can only be changed by rewriting what holds it, so either is [`RedactError::Tags`] rather than
|
||||
/// a cleanup that leaves the annotation reachable (removing only the tree would not: an outline
|
||||
/// item's `/SE` or a link's `/SD` still reaches its elements).
|
||||
pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet<ObjRef>, limit: usize) -> Result<usize, RedactError> {
|
||||
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);
|
||||
};
|
||||
// Elements are reached from the root's /K, and also from the parent tree and the ID tree:
|
||||
// one listed only there (left behind by an editor, say) still points at its annotation.
|
||||
let mut todo: Vec<Object> = [&b"K"[..], b"ParentTree", b"IDTree"].iter().filter_map(|key| root.get(key).cloned()).collect();
|
||||
let mut seen = HashSet::new();
|
||||
let mut hits: Vec<ObjRef> = Vec::new();
|
||||
while let Some(k) = todo.pop() {
|
||||
match k {
|
||||
Object::Array(a) => todo.extend(a),
|
||||
// A structure element written in place: its kids are followed; one that tags a
|
||||
// removed annotation can't be changed here (see above).
|
||||
Object::Dict(d) if d.contains(b"S") => {
|
||||
let kids = kids_of(doc, &d);
|
||||
if kids.iter().any(|x| refers_to(doc, x, gone)) {
|
||||
return Err(RedactError::Tags);
|
||||
}
|
||||
todo.extend(kids);
|
||||
}
|
||||
// A number or name tree node written in place (§7.9.6, §7.9.7): its values and kids.
|
||||
Object::Dict(d) => todo.extend(tree_node_entries(&d)),
|
||||
Object::Ref(r) => {
|
||||
if !seen.insert(r) {
|
||||
continue;
|
||||
}
|
||||
if seen.len() > limit {
|
||||
return Err(RedactError::Tags);
|
||||
}
|
||||
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") {
|
||||
// A parent tree or ID tree node: its values may be elements.
|
||||
todo.extend(tree_node_entries(d));
|
||||
continue;
|
||||
}
|
||||
let kids = kids_of(doc, d);
|
||||
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<Object> = 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<Object> = 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)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user