mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-15 21:59:04 +00:00
Merge upstream main 0dc77fe4 into pr/attachments
crates/organize/src/import.rs: upstream's name_tree_entries (seen-node set, each node read once) kept whole; this PR's only change to it, pub(crate) for attachments.rs, applied on top. crates/organize/src/attachments.rs: its name_tree_entries call passes the new seen set. crates/automation/tests/attachment_key.rs, crates/ui-egui/tests/attach_picker_target.rs: moved into tests/it/ and registered in main.rs, following upstream's one-binary layout (organize keeps its single test file, as upstream's rule says). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+62
-37
@@ -3936,7 +3936,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-quit",
|
||||
@@ -3962,7 +3962,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-a11y"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -3974,7 +3974,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-annot"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -3985,7 +3985,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-audit"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -3996,7 +3996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-automation"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"flate2",
|
||||
@@ -4018,7 +4018,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cli"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"pdfcraft-audit",
|
||||
@@ -4036,11 +4036,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-compare"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-content"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -4048,7 +4048,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cos"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hayro-syntax",
|
||||
@@ -4063,7 +4063,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-create"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"hayro-syntax",
|
||||
@@ -4079,7 +4079,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-crypt"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"md-5",
|
||||
@@ -4091,7 +4091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-edit"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4106,7 +4106,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-engine"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
@@ -4147,14 +4147,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-export"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-filters"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4164,7 +4164,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-fonts"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"pdfcraft-cos",
|
||||
@@ -4174,7 +4174,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-forms"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4187,11 +4187,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-geom"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-js"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"chrono",
|
||||
@@ -4199,7 +4199,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-measure"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4212,14 +4212,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-model"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"pdfcraft-cos",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ocr"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"ocrs",
|
||||
"pdfcraft-fonts",
|
||||
@@ -4229,7 +4229,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-optimize"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"pdfcraft-annot",
|
||||
@@ -4242,11 +4242,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-oracle"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-organize"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4257,7 +4257,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-platform"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
@@ -4267,7 +4267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"pdfcraft-content",
|
||||
@@ -4278,7 +4278,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-print"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"craft-print",
|
||||
"pdfcraft-content",
|
||||
@@ -4290,7 +4290,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-redact"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4306,7 +4306,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-render"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"flate2",
|
||||
@@ -4325,7 +4325,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-sign"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aws-lc-rs",
|
||||
@@ -4360,14 +4360,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-testkit"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ui-egui"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"base64 0.22.1",
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
"chrono",
|
||||
"craft-quit",
|
||||
@@ -4391,6 +4392,7 @@ dependencies = [
|
||||
"pdfcraft-platform",
|
||||
"pdfcraft-render",
|
||||
"pdfcraft-xfa",
|
||||
"plist",
|
||||
"png 0.18.1",
|
||||
"pollster 1.0.1",
|
||||
"rfd",
|
||||
@@ -4405,11 +4407,12 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"winsafe",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-web"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4421,7 +4424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfa"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"image",
|
||||
@@ -4435,7 +4438,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfdf"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-cos",
|
||||
@@ -4662,6 +4665,19 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"indexmap",
|
||||
"quick-xml 0.42.0",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.17.16"
|
||||
@@ -4889,6 +4905,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.42.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.12"
|
||||
@@ -7261,7 +7286,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quick-xml 0.41.0",
|
||||
"quote",
|
||||
]
|
||||
|
||||
|
||||
+34
-34
@@ -5,7 +5,7 @@ exclude = ["vendor"]
|
||||
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -21,39 +21,39 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.
|
||||
# Whether a path stored in a document may be opened (GoToR link targets).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
# internal
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.12.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.12.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.12.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.12.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.12.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.12.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.12.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.12.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.12.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.12.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.12.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.12.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.12.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.12.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.12.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.12.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.12.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.12.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.12.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.12.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.12.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.12.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.12.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.12.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.12.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.12.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.12.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.12.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.12.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.12.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.12.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.12.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.12.0", default-features = false }
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.13.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.13.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.13.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.13.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.13.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.13.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.13.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.13.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.13.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.13.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.13.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.13.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.13.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.13.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.13.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.13.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.13.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.13.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.13.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.13.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.13.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.13.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.13.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.13.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.13.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.13.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.13.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.13.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.13.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.13.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.13.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.13.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.13.0", default-features = false }
|
||||
|
||||
# pdf
|
||||
hayro = "0.7.1"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M14, #3558: rebase guarded macOS per-file association recovery onto current main; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.)
|
||||
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged)
|
||||
@@ -189,6 +190,7 @@ 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-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged.
|
||||
- **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged.
|
||||
|
||||
- **2026-10-20 (M4, initial view):** Window and interface options read indirect boolean ViewerPreferences values. Synthetic regressions cover true/false, direct/indirect preference dictionaries and save/reopen; malformed entries retain false defaults. Estimates unchanged.
|
||||
|
||||
+44
-17
@@ -4,7 +4,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use pdfcraft_content::Matrix;
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString, Stream};
|
||||
|
||||
/// A Figure element (after the role map).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -23,6 +23,8 @@ pub enum AltError {
|
||||
Untagged,
|
||||
#[error("object {0} is not a figure")]
|
||||
NotAFigure(u32),
|
||||
#[error("the page has more content than can be edited")]
|
||||
TooLarge,
|
||||
#[error("{0}")]
|
||||
Cos(#[from] pdfcraft_cos::CosError),
|
||||
}
|
||||
@@ -48,6 +50,8 @@ pub enum ArtifactError {
|
||||
AmbiguousParent,
|
||||
#[error("couldn't update the parent tree: {0}")]
|
||||
ParentTree(String),
|
||||
#[error("the page has more content than can be edited")]
|
||||
TooLarge,
|
||||
#[error(transparent)]
|
||||
Cos(#[from] pdfcraft_cos::CosError),
|
||||
}
|
||||
@@ -157,22 +161,19 @@ fn bdc_mcid(doc: &Document, props: &Dict, op: &pdfcraft_content::Op) -> Option<i
|
||||
}
|
||||
|
||||
pub(crate) fn page_content(doc: &Document, page: &Dict) -> Vec<u8> {
|
||||
page_content_within(doc, page, &mut DecodeBudget::page())
|
||||
}
|
||||
|
||||
/// The page's content streams, decoded against `budget` and joined with a space. Streams that
|
||||
/// don't decode, or arrive after the budget is spent, are left out.
|
||||
pub(crate) fn page_content_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Vec<u8> {
|
||||
let Some(c) = page.get(b"Contents") else { return Vec::new() };
|
||||
match &*doc.resolve(c) {
|
||||
Object::Stream(s) => s.decoded().unwrap_or_default(),
|
||||
Object::Array(a) => a
|
||||
.iter()
|
||||
.flat_map(|x| match &*doc.resolve(x) {
|
||||
Object::Stream(s) => {
|
||||
let mut v = s.decoded().unwrap_or_default();
|
||||
v.push(b' ');
|
||||
v
|
||||
}
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
let mut out = Vec::new();
|
||||
for data in budget.decode_contents(doc, c).into_iter().flatten() {
|
||||
out.extend_from_slice(&data);
|
||||
out.push(b' ');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] {
|
||||
@@ -375,9 +376,19 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids:
|
||||
let Some(entries) = content_entries(doc, &page.dict) else { return Ok(false) };
|
||||
let props = page_properties(doc, &page.dict);
|
||||
let mut streams: Vec<Option<(Stream, Vec<u8>)>> = Vec::with_capacity(entries.len());
|
||||
let mut budget = DecodeBudget::page();
|
||||
for entry in &entries {
|
||||
match &*doc.resolve(entry) {
|
||||
Object::Stream(s) => streams.push(Some((s.clone(), s.decoded().unwrap_or_default()))),
|
||||
Object::Stream(s) => {
|
||||
let data = match budget.decode(s) {
|
||||
Ok(data) => data,
|
||||
// Content past the budget may hold more of the figure: editing the rest
|
||||
// would leave that part marked as a figure the tree no longer has.
|
||||
Err(_) if budget.exhausted() => return Err(AltError::TooLarge),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
streams.push(Some((s.clone(), data)));
|
||||
}
|
||||
_ => streams.push(None),
|
||||
}
|
||||
}
|
||||
@@ -474,7 +485,23 @@ pub fn mark_tag_artifact(doc: &mut Document, obj: ObjRef) -> Result<(), Artifact
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
let page_ref = element.page.ok_or(ArtifactError::NoPage)?;
|
||||
let page = pages.iter().find(|page| page.obj == page_ref).ok_or(ArtifactError::NoPage)?;
|
||||
let mut ops = pdfcraft_content::parse(&page_content(doc, &page.dict)).ops;
|
||||
let mut content = Vec::new();
|
||||
if let Some(c) = page.dict.get(b"Contents") {
|
||||
let mut budget = DecodeBudget::page();
|
||||
for data in budget.decode_contents(doc, c) {
|
||||
match data {
|
||||
Ok(data) => {
|
||||
content.extend_from_slice(&data);
|
||||
content.push(b' ');
|
||||
}
|
||||
// The page's /Contents is replaced with what was decoded: content past the
|
||||
// budget would be dropped from the page.
|
||||
Err(_) if budget.exhausted() => return Err(ArtifactError::TooLarge),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ops = pdfcraft_content::parse(&content).ops;
|
||||
let mut found = HashSet::new();
|
||||
for op in &mut ops {
|
||||
let id = (op.op == b"BDC")
|
||||
|
||||
+26
-24
@@ -6,7 +6,7 @@
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashSet};
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object};
|
||||
use pdfcraft_model::Page;
|
||||
|
||||
use crate::{Finding, Rule};
|
||||
@@ -20,6 +20,8 @@ pub(crate) struct Scan {
|
||||
pub untagged: BTreeMap<usize, usize>,
|
||||
/// Page → fonts used.
|
||||
pub fonts: BTreeMap<usize, BTreeSet<ObjRef>>,
|
||||
/// Pages read only in part: content or forms past the page's decode budget are missing.
|
||||
pub incomplete: BTreeSet<usize>,
|
||||
}
|
||||
|
||||
const MAX_FORM_DEPTH: usize = 12;
|
||||
@@ -30,38 +32,25 @@ impl Scan {
|
||||
for &p in list {
|
||||
let Some(page) = pages.get(p) else { continue };
|
||||
let res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let data = page_content(doc, &page.dict);
|
||||
let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new() };
|
||||
let mut budget = DecodeBudget::page();
|
||||
let data = crate::alt::page_content_within(doc, &page.dict, &mut budget);
|
||||
if budget.remaining() == 0 {
|
||||
s.incomplete.insert(p);
|
||||
}
|
||||
let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new(), budget };
|
||||
w.content(&data, &res, false, 0);
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
fn page_content(doc: &Document, page: &Dict) -> Vec<u8> {
|
||||
let Some(c) = page.get(b"Contents") else { return Vec::new() };
|
||||
match &*doc.resolve(c) {
|
||||
Object::Stream(s) => s.decoded().unwrap_or_default(),
|
||||
Object::Array(a) => a
|
||||
.iter()
|
||||
.flat_map(|x| match &*doc.resolve(x) {
|
||||
Object::Stream(s) => {
|
||||
let mut v = s.decoded().unwrap_or_default();
|
||||
v.push(b' ');
|
||||
v
|
||||
}
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
struct Walk<'a> {
|
||||
doc: &'a Document,
|
||||
scan: &'a mut Scan,
|
||||
page: usize,
|
||||
forms: HashSet<ObjRef>,
|
||||
/// Decoded bytes and form entries left for this page.
|
||||
budget: DecodeBudget,
|
||||
}
|
||||
|
||||
impl Walk<'_> {
|
||||
@@ -116,14 +105,27 @@ impl Walk<'_> {
|
||||
self.scan.has_images = true;
|
||||
untagged += usize::from(!inside);
|
||||
}
|
||||
Some(b"Form") if depth < MAX_FORM_DEPTH && self.forms.insert(r) => {
|
||||
Some(b"Form") if depth < MAX_FORM_DEPTH && !self.forms.contains(&r) => {
|
||||
if !self.budget.enter_form() {
|
||||
self.scan.incomplete.insert(self.page);
|
||||
continue;
|
||||
}
|
||||
let data = match self.budget.decode(x) {
|
||||
Ok(data) => data,
|
||||
Err(_) => {
|
||||
if self.budget.exhausted() {
|
||||
self.scan.incomplete.insert(self.page);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
self.forms.insert(r);
|
||||
let inner = x
|
||||
.dict
|
||||
.get(b"Resources")
|
||||
.map(|o| self.doc.resolve(o))
|
||||
.and_then(|o| o.as_dict().cloned())
|
||||
.unwrap_or_else(|| res.clone());
|
||||
let data = x.decoded().unwrap_or_default();
|
||||
self.content(&data, &inner, inside, depth + 1);
|
||||
self.forms.remove(&r);
|
||||
}
|
||||
|
||||
@@ -681,6 +681,18 @@ pub fn check(doc: &Document, options: &Options) -> Report {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A page read only in part may hold what the scan didn't find: text that makes the
|
||||
// document more than images, untagged content, or a font without Unicode mappings.
|
||||
let unsure = !scan.incomplete.is_empty()
|
||||
&& match rule {
|
||||
Rule::ImageOnly => !scan.has_text,
|
||||
Rule::TaggedContent | Rule::CharacterEncoding => findings.is_empty(),
|
||||
_ => false,
|
||||
};
|
||||
if unsure {
|
||||
results.push(RuleResult { rule, status: Status::Manual, findings: Vec::new() });
|
||||
continue;
|
||||
}
|
||||
if total > MAX_FINDINGS {
|
||||
let more = total - MAX_FINDINGS;
|
||||
findings.truncate(MAX_FINDINGS);
|
||||
|
||||
@@ -1252,3 +1252,75 @@ fn horizontally_scaled_wrapped_heading_keeps_a_space_between_lines() {
|
||||
let titles: Vec<String> = headings(&doc).into_iter().map(|h| h.title).collect();
|
||||
assert_eq!(titles, ["I Overview"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_content_references_share_one_decode_budget() {
|
||||
use pdfcraft_cos::{Dict, MAX_PAGE_DECODED, Object, Stream};
|
||||
let mut doc = Document::new_empty();
|
||||
let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
|
||||
let page: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300]))].into_iter().collect();
|
||||
let data = crate::alt::page_content(&doc, &page);
|
||||
assert!(data.len() <= MAX_PAGE_DECODED + 300, "{} bytes", data.len());
|
||||
}
|
||||
|
||||
/// A figure whose marked content goes on in a stream past the page's decode budget is refused,
|
||||
/// not removed with that content still pointing at it.
|
||||
#[test]
|
||||
fn marking_decorative_refuses_content_past_the_decode_budget() {
|
||||
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
|
||||
let mut doc = figures_doc();
|
||||
let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
|
||||
let last = doc.add(Stream::from_raw(Dict::new(), b"/Figure << /MCID 0 >> BDC 0 0 5 5 re f EMC".to_vec()));
|
||||
let mut contents = vec![Object::Ref(ObjRef::new(4, 0))];
|
||||
contents.extend(std::iter::repeat_n(Object::Ref(filler), 256));
|
||||
contents.push(Object::Ref(last));
|
||||
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(contents))).unwrap();
|
||||
assert!(mark_decorative(&mut doc, ObjRef::new(7, 0)).is_err());
|
||||
assert_eq!(figures(&doc).len(), 2, "the figure stays");
|
||||
}
|
||||
|
||||
/// Text past the page's decode budget is unread, so a page that shows images and no text so far
|
||||
/// can't be called image-only, nor its content all tagged.
|
||||
#[test]
|
||||
fn content_past_the_decode_budget_needs_a_manual_check() {
|
||||
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
|
||||
let mut doc = pdf(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> /Font << /F1 6 0 R >> >> >>"
|
||||
.into(),
|
||||
stream("/Artifact BMC q 200 0 0 200 0 0 cm /Im Do Q EMC"),
|
||||
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream"
|
||||
.into(),
|
||||
FONT.into(),
|
||||
],
|
||||
"",
|
||||
);
|
||||
let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
|
||||
let text = doc.add(Stream::from_raw(Dict::new(), b"BT /F1 12 Tf 10 10 Td (Hello) Tj ET".to_vec()));
|
||||
let mut contents = vec![Object::Ref(ObjRef::new(4, 0))];
|
||||
contents.extend(std::iter::repeat_n(Object::Ref(filler), 256));
|
||||
contents.push(Object::Ref(text));
|
||||
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(contents))).unwrap();
|
||||
let report = check(&doc, &Options::default());
|
||||
assert_eq!(status(&report, Rule::ImageOnly), Status::Manual);
|
||||
assert_eq!(status(&report, Rule::TaggedContent), Status::Manual);
|
||||
}
|
||||
|
||||
/// Marking a tag as an artifact replaces the page's content with what it decoded, so content past
|
||||
/// the page's decode budget is refused rather than dropped from the page.
|
||||
#[test]
|
||||
fn marking_a_tag_artifact_refuses_content_past_the_decode_budget() {
|
||||
use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
|
||||
let mut doc = figures_doc();
|
||||
let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
|
||||
let last = doc.add(Stream::from_raw(Dict::new(), b"0 0 5 5 re f".to_vec()));
|
||||
let mut contents = vec![Object::Ref(ObjRef::new(4, 0))];
|
||||
contents.extend(std::iter::repeat_n(Object::Ref(filler), 256));
|
||||
contents.push(Object::Ref(last));
|
||||
let contents = Object::Array(contents);
|
||||
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), contents.clone())).unwrap();
|
||||
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::TooLarge));
|
||||
assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents"), Some(&contents), "the page keeps its content");
|
||||
}
|
||||
|
||||
@@ -120,12 +120,18 @@ fn smooth_curve(pts: &[(f64, f64)]) -> String {
|
||||
smooth_segments(pts).iter().map(|[a, b, e]| format!("{} {} {} {} {} {} c\n", n(a.0), n(a.1), n(b.0), n(b.1), n(e.0), n(e.1))).collect()
|
||||
}
|
||||
|
||||
/// Bumps one cloudy outline draws at most (plus one per edge); past it the bumps grow.
|
||||
pub(crate) const MAX_CLOUD_BUMPS: usize = 20_000;
|
||||
|
||||
/// A closed cloudy outline through `pts` (§12.5.4 `/BE /S /C`): each edge becomes a row of
|
||||
/// half-circle bumps of about `r`, bulging outwards.
|
||||
pub fn cloud_path(pts: &[(f64, f64)], r: f64) -> String {
|
||||
let area: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.0 * b.1 - b.0 * a.1).sum();
|
||||
// Outward normal of an edge: to the right of travel for counter-clockwise outlines.
|
||||
let sign = if area >= 0.0 { 1.0 } else { -1.0 };
|
||||
let perimeter: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| (b.0 - a.0).hypot(b.1 - a.1)).sum();
|
||||
// Each edge rounds to at least one bump, so the total stays within the budget plus one per edge.
|
||||
let r = r.max(perimeter / (2.0 * MAX_CLOUD_BUMPS as f64));
|
||||
let mut c = String::new();
|
||||
for (i, (a, b)) in pts.iter().zip(pts.iter().cycle().skip(1)).enumerate() {
|
||||
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
||||
|
||||
+26
-6
@@ -665,17 +665,20 @@ pub fn page_refs(doc: &Document) -> Result<Vec<ObjRef>, AnnotError> {
|
||||
/// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array,
|
||||
/// never one entry per child, so a child listed many times or shared between nodes is skipped by
|
||||
/// the seen check without being queued first (`pdfcraft_model::pages` walks the same way).
|
||||
/// Cycles end at the seen check and at most a million nodes are visited.
|
||||
/// Cycles end at the seen check, a `/Kids` array object shared by several nodes is read once,
|
||||
/// and at most a million nodes and four million `/Kids` entries are read.
|
||||
struct PageWalk<'a> {
|
||||
doc: &'a Document,
|
||||
seen: std::collections::HashSet<ObjRef>,
|
||||
pending: Vec<(std::sync::Arc<Object>, usize)>,
|
||||
/// `/Kids` entries read so far.
|
||||
examined: usize,
|
||||
}
|
||||
|
||||
impl<'a> PageWalk<'a> {
|
||||
fn new(doc: &'a Document, top: ObjRef) -> Self {
|
||||
let top = std::sync::Arc::new(Object::Array(vec![Object::Ref(top)]));
|
||||
Self { doc, seen: Default::default(), pending: vec![(top, 0)] }
|
||||
Self { doc, seen: Default::default(), pending: vec![(top, 0)], examined: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -690,8 +693,9 @@ impl Iterator for PageWalk<'_> {
|
||||
};
|
||||
let node = kid.as_ref();
|
||||
*next += 1;
|
||||
self.examined += 1;
|
||||
let Some(node) = node else { continue };
|
||||
if self.seen.len() >= 1_000_000 {
|
||||
if self.seen.len() >= 1_000_000 || self.examined > 4_000_000 {
|
||||
self.pending.clear();
|
||||
return None;
|
||||
}
|
||||
@@ -700,8 +704,15 @@ impl Iterator for PageWalk<'_> {
|
||||
}
|
||||
let obj = self.doc.get(node);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
match d.get(b"Kids").map(|k| self.doc.resolve(k)) {
|
||||
Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((kids, 0)),
|
||||
match d.get(b"Kids") {
|
||||
// A `/Kids` array object shared by several nodes is read under the first: under
|
||||
// the others it would only list nodes already seen.
|
||||
Some(Object::Ref(array)) if d.name(b"Type") != Some(b"Page") => {
|
||||
if self.seen.insert(*array) {
|
||||
self.pending.push((self.doc.get(*array), 0));
|
||||
}
|
||||
}
|
||||
Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((self.doc.resolve(kids), 0)),
|
||||
_ => return Some(node),
|
||||
}
|
||||
}
|
||||
@@ -717,7 +728,16 @@ fn page_ref(doc: &Document, page: usize) -> Result<ObjRef, AnnotError> {
|
||||
fn annots(doc: &Document, page: ObjRef) -> Vec<Object> {
|
||||
let obj = doc.get(page);
|
||||
let Some(a) = obj.as_dict().and_then(|d| d.get(b"Annots")) else { return Vec::new() };
|
||||
doc.resolve(a).as_array().cloned().unwrap_or_default()
|
||||
let list = doc.resolve(a).as_array().cloned().unwrap_or_default();
|
||||
#[cfg(test)]
|
||||
ANNOTS_ENTRIES_COPIED.with(|n| n.set(n.get() + list.len()));
|
||||
list
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Entries copied out of pages' `/Annots` arrays.
|
||||
static ANNOTS_ENTRIES_COPIED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Replace a page's `/Annots`, writing into the shared array object when it is indirect.
|
||||
|
||||
+68
-12
@@ -78,11 +78,15 @@ pub struct LinkItem {
|
||||
pub style: LinkStyle,
|
||||
}
|
||||
|
||||
/// Bytes of web-address text one listing keeps, counted after conversion to UTF-8; past it a link
|
||||
/// lists as [`LinkAction::Other`].
|
||||
pub(crate) const MAX_URI_BYTES: usize = 16 << 20;
|
||||
|
||||
fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
|
||||
o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef]) -> LinkAction {
|
||||
fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize) -> LinkAction {
|
||||
let dest_page = |dest: &Object| -> Option<usize> {
|
||||
let a = doc.resolve(dest);
|
||||
let first = a.as_array()?.first()?.clone();
|
||||
@@ -97,9 +101,20 @@ fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef]) -> LinkAction {
|
||||
}
|
||||
let Some(a) = d.get(b"A").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()) else { return LinkAction::Other("no action".into()) };
|
||||
match a.name(b"S") {
|
||||
Some(b"URI") => LinkAction::Uri(
|
||||
a.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| String::from_utf8_lossy(&s.bytes).into_owned())).unwrap_or_default(),
|
||||
),
|
||||
Some(b"URI") => {
|
||||
let uri = a.get(b"URI").map(|u| doc.resolve(u));
|
||||
let bytes = uri.as_ref().and_then(|u| u.as_string()).map_or(&[][..], |s| &s.bytes[..]);
|
||||
// The raw length first, so an address already over the budget is never converted; then the
|
||||
// converted text, which is up to three times longer where the bytes are not UTF-8.
|
||||
let text = (bytes.len() <= *uri_budget).then(|| String::from_utf8_lossy(bytes));
|
||||
match text.and_then(|t| Some((uri_budget.checked_sub(t.len())?, t))) {
|
||||
Some((left, text)) => {
|
||||
*uri_budget = left;
|
||||
LinkAction::Uri(text.into_owned())
|
||||
}
|
||||
None => LinkAction::Other("web address too long".into()),
|
||||
}
|
||||
}
|
||||
Some(b"GoTo") => a.get(b"D").and_then(dest_page).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page),
|
||||
Some(s) => LinkAction::Other(String::from_utf8_lossy(s).into_owned()),
|
||||
None => LinkAction::Other("no action".into()),
|
||||
@@ -133,8 +148,16 @@ fn style_of(doc: &Document, d: &Dict) -> LinkStyle {
|
||||
pub fn list(doc: &Document) -> Vec<LinkItem> {
|
||||
let Ok(pages) = page_refs(doc) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut uri_budget = MAX_URI_BYTES;
|
||||
for (pi, p) in pages.iter().enumerate() {
|
||||
for (k, a) in annots(doc, *p).iter().enumerate() {
|
||||
// One annotation object listed under several entries or pages is one link.
|
||||
if let Some(r) = a.as_ref()
|
||||
&& !seen.insert(r)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(d) = doc.resolve(a).as_dict().cloned() else { continue };
|
||||
if d.name(b"Subtype") != Some(b"Link") {
|
||||
continue;
|
||||
@@ -148,7 +171,7 @@ pub fn list(doc: &Document) -> Vec<LinkItem> {
|
||||
index: k,
|
||||
obj: a.as_ref(),
|
||||
rect: [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])],
|
||||
action: action_of(doc, &d, &pages),
|
||||
action: action_of(doc, &d, &pages, &mut uri_budget),
|
||||
style: style_of(doc, &d),
|
||||
});
|
||||
}
|
||||
@@ -232,6 +255,16 @@ fn checked_rect(rect: [f64; 4]) -> Result<[f64; 4], AnnotError> {
|
||||
/// Add a link over `rect` (user space). Returns its index in the page's `/Annots`.
|
||||
pub fn add(doc: &mut Document, page: usize, rect: [f64; 4], action: &LinkAction, style: &LinkStyle) -> Result<usize, AnnotError> {
|
||||
let p = page_ref(doc, page)?;
|
||||
let obj = new_link(doc, p, rect, action, style)?;
|
||||
let mut list = annots(doc, p);
|
||||
list.push(Object::Ref(obj));
|
||||
let index = list.len() - 1;
|
||||
set_annots(doc, p, list)?;
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// A link annotation object for page `p`, not yet on the page's `/Annots`.
|
||||
fn new_link(doc: &mut Document, p: ObjRef, rect: [f64; 4], action: &LinkAction, style: &LinkStyle) -> Result<ObjRef, AnnotError> {
|
||||
let r = checked_rect(rect)?;
|
||||
if matches!(action, LinkAction::Other(_)) {
|
||||
return Err(AnnotError::Invalid("choose a page or a web address".into()));
|
||||
@@ -244,12 +277,7 @@ pub fn add(doc: &mut Document, page: usize, rect: [f64; 4], action: &LinkAction,
|
||||
d.set(b"F".to_vec(), Object::Int(4));
|
||||
write_style(&mut d, style);
|
||||
write_action(doc, &mut d, action)?;
|
||||
let obj = doc.add(Object::Dict(d));
|
||||
let mut list = annots(doc, p);
|
||||
list.push(Object::Ref(obj));
|
||||
let index = list.len() - 1;
|
||||
set_annots(doc, p, list)?;
|
||||
Ok(index)
|
||||
Ok(doc.add(Object::Dict(d)))
|
||||
}
|
||||
|
||||
fn link_at(doc: &Document, page: usize, index: usize) -> Result<(ObjRef, ObjRef), AnnotError> {
|
||||
@@ -414,12 +442,40 @@ pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range<usize>, String)> {
|
||||
/// Create links for found URLs: `(page, quads, uri)` with quads in user space (one link per
|
||||
/// line rectangle). Returns how many links were added.
|
||||
pub fn add_many(doc: &mut Document, links: &[(usize, Vec<[f64; 4]>, String)], style: &LinkStyle) -> Result<usize, AnnotError> {
|
||||
let pages = page_refs(doc)?;
|
||||
// Check the whole batch first, so a bad entry fails it before any link object is created.
|
||||
for (page, rects, uri) in links {
|
||||
pages.get(*page).ok_or(AnnotError::NoSuchPage(*page))?;
|
||||
if uri.trim().is_empty() {
|
||||
return Err(AnnotError::Invalid("type the web address first".into()));
|
||||
}
|
||||
for r in rects {
|
||||
checked_rect(*r)?;
|
||||
}
|
||||
}
|
||||
// Each page's `/Annots` is extended once, with its new links in the order given.
|
||||
let mut added: Vec<(ObjRef, Vec<Object>)> = Vec::new();
|
||||
let mut slot: std::collections::HashMap<ObjRef, usize> = std::collections::HashMap::new();
|
||||
let mut n = 0;
|
||||
for (page, rects, uri) in links {
|
||||
let p = pages.get(*page).copied().ok_or(AnnotError::NoSuchPage(*page))?;
|
||||
let action = LinkAction::Uri(uri.clone());
|
||||
for r in rects {
|
||||
add(doc, *page, *r, &LinkAction::Uri(uri.clone()), style)?;
|
||||
let obj = new_link(doc, p, *r, &action, style)?;
|
||||
let k = *slot.entry(p).or_insert_with(|| {
|
||||
added.push((p, Vec::new()));
|
||||
added.len() - 1
|
||||
});
|
||||
if let Some((_, list)) = added.get_mut(k) {
|
||||
list.push(Object::Ref(obj));
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for (p, new) in added {
|
||||
let mut list = annots(doc, p);
|
||||
list.extend(new);
|
||||
set_annots(doc, p, list)?;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
@@ -1504,3 +1504,121 @@ fn typed_signature_uses_nonzero_winding_fill() {
|
||||
assert!(ap.ends_with("h\nf\n"), "expected non-zero winding fill (`f`), got: {ap}");
|
||||
assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}");
|
||||
}
|
||||
|
||||
/// Many page tree nodes sharing one `/Kids` array read it once, not once per node.
|
||||
#[test]
|
||||
fn page_walk_reads_a_shared_kids_array_once() {
|
||||
let mut doc = fixture();
|
||||
let root = ObjRef { num: 2, generation: 0 };
|
||||
let (first, second) = (ObjRef { num: 3, generation: 0 }, ObjRef { num: 4, generation: 0 });
|
||||
let shared = doc.add(Object::Array([Object::Ref(first), Object::Ref(second)].into_iter().cycle().take(2_000).collect()));
|
||||
let nodes: Vec<Object> = (0..1_000)
|
||||
.map(|_| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("Pages"));
|
||||
d.set(b"Kids".to_vec(), Object::Ref(shared));
|
||||
Object::Ref(doc.add(d))
|
||||
})
|
||||
.collect();
|
||||
doc.update_dict(root, |d| d.set(b"Kids".to_vec(), Object::Array(nodes))).unwrap();
|
||||
let mut walk = PageWalk::new(&doc, root);
|
||||
let pages: Vec<ObjRef> = walk.by_ref().collect();
|
||||
assert_eq!(pages, [first, second]);
|
||||
assert!(walk.examined <= 10_000, "{} kids entries read", walk.examined);
|
||||
}
|
||||
|
||||
/// Adding many links to a page extends its `/Annots` once, not once per link.
|
||||
#[test]
|
||||
fn many_links_extend_a_page_annotation_list_once() {
|
||||
let mut doc = fixture();
|
||||
let before = annots(&doc, page_refs(&doc).unwrap()[0]).len();
|
||||
let n = 2_000;
|
||||
let rects: Vec<[f64; 4]> = (0..n).map(|i| [10.0, i as f64 * 0.1, 50.0, i as f64 * 0.1 + 5.0]).collect();
|
||||
ANNOTS_ENTRIES_COPIED.with(|c| c.set(0));
|
||||
let added = links::add_many(&mut doc, &[(0, rects, "https://example.com".into())], &links::LinkStyle::default()).unwrap();
|
||||
let copied = ANNOTS_ENTRIES_COPIED.with(std::cell::Cell::get);
|
||||
assert_eq!(added, n);
|
||||
assert_eq!(annots(&doc, page_refs(&doc).unwrap()[0]).len(), before + n);
|
||||
assert!(copied <= 4 * n, "{copied} annotation entries copied for {n} links");
|
||||
}
|
||||
|
||||
/// A batch with one bad entry adds nothing: no link objects are left behind unattached.
|
||||
#[test]
|
||||
fn a_link_batch_with_a_bad_entry_creates_no_objects() {
|
||||
let mut doc = fixture();
|
||||
let page = page_refs(&doc).unwrap()[0];
|
||||
let before = (annots(&doc, page).len(), doc.scan_objects().count());
|
||||
let good = [10.0, 10.0, 50.0, 20.0];
|
||||
let style = links::LinkStyle::default();
|
||||
for bad in [
|
||||
vec![(0, vec![good], "https://example.com".to_string()), (0, vec![good, [f64::NAN, 0.0, 10.0, 10.0]], "https://example.com".into())],
|
||||
vec![(0, vec![good], "https://example.com".to_string()), (0, vec![good], " ".into())],
|
||||
vec![(0, vec![good], "https://example.com".to_string()), (99, vec![good], "https://example.com".into())],
|
||||
] {
|
||||
assert!(links::add_many(&mut doc, &bad, &style).is_err());
|
||||
assert_eq!((annots(&doc, page).len(), doc.scan_objects().count()), before);
|
||||
}
|
||||
}
|
||||
|
||||
fn doc_of(objs: &[String]) -> Document {
|
||||
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());
|
||||
Document::open(Arc::new(out)).expect("opens")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_listing_skips_repeated_refs_and_caps_web_address_text() {
|
||||
// One link referenced 1000 times, and 300 small links sharing one 64 KiB address.
|
||||
let annots = format!("{}{}", " 4 0 R".repeat(1000), "<< /Subtype /Link /Rect [0 0 1 1] /A 5 0 R >>".repeat(300));
|
||||
let doc = doc_of(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"),
|
||||
"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A 5 0 R >>".into(),
|
||||
"<< /S /URI /URI 6 0 R >>".into(),
|
||||
format!("({})", "u".repeat(64 << 10)),
|
||||
]);
|
||||
let all = links::list(&doc);
|
||||
assert_eq!(all.iter().filter(|l| l.obj == Some(ObjRef::new(4, 0))).count(), 1);
|
||||
assert_eq!(all.len(), 301, "every distinct link is still listed");
|
||||
let kept: usize = all.iter().map(|l| if let links::LinkAction::Uri(u) = &l.action { u.len() } else { 0 }).sum();
|
||||
assert!(kept <= links::MAX_URI_BYTES, "{kept} bytes of web addresses kept");
|
||||
assert!(matches!(&all[0].action, links::LinkAction::Uri(u) if u.len() == 64 << 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_address_cap_counts_the_text_kept_after_conversion() {
|
||||
// 200 links sharing a 64 KiB address of invalid UTF-8: 12.5 MiB of bytes, each byte kept as a
|
||||
// three-byte replacement character.
|
||||
let annots = "<< /Subtype /Link /Rect [0 0 1 1] /A 4 0 R >>".repeat(200);
|
||||
let doc = doc_of(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"),
|
||||
"<< /S /URI /URI 5 0 R >>".into(),
|
||||
format!("<{}>", "FF".repeat(64 << 10)),
|
||||
]);
|
||||
let all = links::list(&doc);
|
||||
assert_eq!(all.len(), 200);
|
||||
let kept: usize = all.iter().map(|l| if let links::LinkAction::Uri(u) = &l.action { u.len() } else { 0 }).sum();
|
||||
assert!(kept <= links::MAX_URI_BYTES, "{kept} bytes of web addresses kept");
|
||||
assert!(kept > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloud_outline_has_a_bump_budget() {
|
||||
// 50 long edges would each ask for 10,000 bumps of radius 1.
|
||||
let pts: Vec<(f64, f64)> = (0..50).map(|i| (f64::from(i) * std::f64::consts::TAU / 50.0).sin_cos()).map(|(s, c)| (1e7 * c, 1e7 * s)).collect();
|
||||
let bumps = appearance::cloud_path(&pts, 1.0).lines().filter(|l| l.ends_with(" c")).count();
|
||||
assert!((pts.len()..=appearance::MAX_CLOUD_BUMPS + 2 * pts.len()).contains(&bumps), "{bumps} bumps");
|
||||
}
|
||||
|
||||
@@ -33,7 +33,12 @@ Limits, by design and documented rather than hidden:
|
||||
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".
|
||||
than 32 or in a cycle, a walk past the operator bound, more than 50 million cover-against-word
|
||||
comparisons on a page, or more than 64 MiB of covered text in the report is an error, never "no
|
||||
findings".
|
||||
|
||||
A document whose permissions withhold copying (Table 22, bit 5) still gets every finding, with
|
||||
an empty `covered_text`: the audit hands out no text that copying would refuse.
|
||||
|
||||
Surfaces: the `doc_audit_redactions` automation tool and `pdfcraft-cli audit`. Both report each
|
||||
rectangle in points from the top-left of the page as displayed (y down, after `/Rotate`), like
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//! Positions compare lexicographically in paint order.
|
||||
|
||||
use pdfcraft_content::{Matrix, Op, Pieces, parse};
|
||||
use pdfcraft_cos::{Dict, Document, Object};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
|
||||
|
||||
use crate::AuditError;
|
||||
|
||||
@@ -109,6 +109,9 @@ struct Walker<'a> {
|
||||
pieces: &'a Pieces,
|
||||
out: Vec<FilledRect>,
|
||||
ops_seen: usize,
|
||||
/// Decoded bytes and form entries left for the page, its forms included: spending either
|
||||
/// fails the audit.
|
||||
budget: DecodeBudget,
|
||||
}
|
||||
|
||||
impl<'a> Walker<'a> {
|
||||
@@ -144,10 +147,11 @@ impl<'a> Walker<'a> {
|
||||
match op.op.as_slice() {
|
||||
b"q" => stack.push(gs.clone()),
|
||||
b"Q" => {
|
||||
// The current path is not graphics state (ISO 32000-2 §8.4.1): `re Q f` still
|
||||
// fills the rectangle, whose corners are already in user space.
|
||||
if let Some(g) = stack.pop() {
|
||||
*gs = g;
|
||||
}
|
||||
path = Path::Empty;
|
||||
}
|
||||
b"cm" => {
|
||||
let before = gs.ctm;
|
||||
@@ -258,7 +262,7 @@ impl<'a> Walker<'a> {
|
||||
let resolved = self.doc.resolve(entry);
|
||||
let Object::Stream(stream) = &*resolved else { return Ok(()) };
|
||||
let dict = &stream.dict;
|
||||
if dict.name(b"Subtype") != Some(b"Form") {
|
||||
if crate::subtype(self.doc, dict)?.as_deref() != Some(b"Form") {
|
||||
return Ok(());
|
||||
}
|
||||
let matrix = dict
|
||||
@@ -271,8 +275,11 @@ impl<'a> Walker<'a> {
|
||||
})
|
||||
.unwrap_or(Matrix::IDENTITY);
|
||||
let sub_resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
|
||||
if !self.budget.enter_form() {
|
||||
return Err(AuditError::TooManyForms);
|
||||
}
|
||||
// A form stream that cannot be decoded fails the audit: it might hide a cover.
|
||||
let data = stream.decoded_strict()?;
|
||||
let data = self.budget.decode_strict(stream)?;
|
||||
let ops = parse(&data).ops;
|
||||
// The XObject matrix maps form space into the user space active at `Do`.
|
||||
let mut sub_gs = gs.clone();
|
||||
@@ -304,7 +311,8 @@ fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 {
|
||||
/// Every opaque filled rectangle drawn by a page's content (and its form XObjects), with paint
|
||||
/// positions. A page's content streams are walked once, joined, with one graphics state, so
|
||||
/// state set in an earlier stream is still in force in the later ones. Any content stream that
|
||||
/// cannot be decoded is an audit failure, never an empty result.
|
||||
/// cannot be decoded, or content and forms that decode or enter past one page's [`DecodeBudget`]
|
||||
/// in all, is an audit failure, never an empty result.
|
||||
pub fn filled_rects(doc: &Document, page: &Dict) -> Result<Vec<FilledRect>, AuditError> {
|
||||
let resources = page.get(b"Resources").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
|
||||
let contents: Vec<Object> = match page.get(b"Contents") {
|
||||
@@ -314,15 +322,16 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result<Vec<FilledRect>, Audi
|
||||
_ => vec![c.clone()],
|
||||
},
|
||||
};
|
||||
let mut budget = DecodeBudget::page();
|
||||
let mut streams = Vec::with_capacity(contents.len());
|
||||
for c in &contents {
|
||||
if let Object::Stream(s) = &*doc.resolve(c) {
|
||||
streams.push(s.decoded_strict()?);
|
||||
streams.push(budget.decode_strict(s)?);
|
||||
}
|
||||
}
|
||||
let pieces = Pieces::join(&streams);
|
||||
let parsed = pieces.parse();
|
||||
let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0 };
|
||||
let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0, budget };
|
||||
let mut gs = Gs::default();
|
||||
let mut stack = Vec::new();
|
||||
w.walk_ops(&parsed.ops, &mut gs, &mut stack, &resources, 0, None)?;
|
||||
|
||||
+115
-34
@@ -65,7 +65,7 @@ pub struct AuditFinding {
|
||||
/// `rect[0] <= rect[2]` and `rect[1] <= rect[3]`.
|
||||
pub rect: [f64; 4],
|
||||
/// The extractable text the cover hides, lines joined with single spaces and trimmed.
|
||||
/// Possibly empty when nothing decodable remains under the cover.
|
||||
/// Empty when the document's permissions withhold copying (see [`audit_redactions`]).
|
||||
pub covered_text: String,
|
||||
}
|
||||
|
||||
@@ -77,8 +77,14 @@ pub enum AuditError {
|
||||
TooManyOps,
|
||||
#[error("form XObjects nest deeper than the audit follows")]
|
||||
TooDeep,
|
||||
#[error("content walk entered more form XObjects than the audit follows")]
|
||||
TooManyForms,
|
||||
#[error("page has a malformed /Annots entry")]
|
||||
BadAnnots,
|
||||
#[error("page has more covers and text lines than the audit compares")]
|
||||
TooManyComparisons,
|
||||
#[error("covered text exceeds the audit's report budget")]
|
||||
TooMuchOutput,
|
||||
#[error(transparent)]
|
||||
Cos(#[from] pdfcraft_cos::CosError),
|
||||
#[error(transparent)]
|
||||
@@ -95,47 +101,110 @@ const WORD_FRACTION: f64 = 0.5;
|
||||
const COVER_CONTRAST: f64 = 3.0;
|
||||
|
||||
/// Audit every page for faux redactions and unapplied redaction marks. Fails closed: any
|
||||
/// content that cannot be decoded is returned as an error, never as an empty finding list.
|
||||
/// content that cannot be decoded, or that needs more work or report than the audit's bounds, is
|
||||
/// returned as an error, never as an empty finding list.
|
||||
///
|
||||
/// When the document's permissions withhold copying (ISO 32000-2 Table 22, bit 5), findings are
|
||||
/// still reported but their `covered_text` is empty: the audit hands out no text that copying
|
||||
/// would refuse.
|
||||
pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError> {
|
||||
audit(doc, Limits::DEFAULT)
|
||||
}
|
||||
|
||||
/// Work and output bounds for one audit.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Limits {
|
||||
/// Cover-against-line comparisons per page, counting a line once per word box it has (at
|
||||
/// least once), since [`covers`] walks them.
|
||||
comparisons: usize,
|
||||
/// Bytes of covered text the findings report.
|
||||
covered_text: usize,
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
/// Every cover is compared with every line of its page, and each finding repeats the text
|
||||
/// it covers, so both grow with the square of the content: a few megabytes of covers over
|
||||
/// one long line would otherwise take hours and gigabytes.
|
||||
const DEFAULT: Limits = Limits { comparisons: 50_000_000, covered_text: 64 << 20 };
|
||||
}
|
||||
|
||||
/// The work and report the audit has spent against its [`Limits`].
|
||||
struct Budget {
|
||||
limits: Limits,
|
||||
comparisons: usize,
|
||||
/// What comparing one cover with every line of the current page costs.
|
||||
page_cost: usize,
|
||||
covered_text: usize,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
/// Resets the comparison count for a page with these `lines`.
|
||||
fn start_page(&mut self, lines: &[TextLine]) {
|
||||
self.comparisons = 0;
|
||||
self.page_cost = lines.iter().fold(0, |n, line| n.saturating_add(line.ink.len().max(1)));
|
||||
}
|
||||
|
||||
/// The text of the `lines` that `hides`, joined with single spaces, or `None` when they show
|
||||
/// no text. Without `copying` the text is empty and never assembled: one long line under many
|
||||
/// covers would otherwise be copied once per cover, outside both bounds.
|
||||
fn hidden_text(&mut self, lines: &[TextLine], copying: bool, hides: impl Fn(&TextLine) -> bool) -> Result<Option<String>, AuditError> {
|
||||
self.comparisons = self.comparisons.saturating_add(self.page_cost);
|
||||
if self.comparisons > self.limits.comparisons {
|
||||
return Err(AuditError::TooManyComparisons);
|
||||
}
|
||||
if !copying {
|
||||
return Ok(lines.iter().any(|line| hides(line) && !line.text.trim().is_empty()).then(String::new));
|
||||
}
|
||||
let mut covered = String::new();
|
||||
for line in lines.iter().filter(|l| hides(l)) {
|
||||
if !covered.is_empty() {
|
||||
covered.push(' ');
|
||||
}
|
||||
covered.push_str(line.text.trim());
|
||||
}
|
||||
Ok((!covered.trim().is_empty()).then_some(covered))
|
||||
}
|
||||
|
||||
/// The text a finding reports for `covered`: none when copying is withheld. Charges what it
|
||||
/// reports against the output bound.
|
||||
fn report_text(&mut self, covered: String, copying: bool) -> Result<String, AuditError> {
|
||||
let reported = if copying { covered } else { String::new() };
|
||||
self.covered_text = self.covered_text.saturating_add(reported.len());
|
||||
if self.covered_text > self.limits.covered_text {
|
||||
return Err(AuditError::TooMuchOutput);
|
||||
}
|
||||
Ok(reported)
|
||||
}
|
||||
}
|
||||
|
||||
fn audit(doc: &Document, limits: Limits) -> Result<Vec<AuditFinding>, AuditError> {
|
||||
let copying = doc.permissions().is_none_or(|p| p.copy());
|
||||
let mut budget = Budget { limits, comparisons: 0, page_cost: 0, covered_text: 0 };
|
||||
let mut out = Vec::new();
|
||||
for (n, page) in pdfcraft_model::pages(doc).iter().enumerate() {
|
||||
let lines = pdfcraft_edit::text_lines(doc, n).map_err(|e| AuditError::Text(n, e.to_string()))?;
|
||||
// The glyphs themselves: `/ActualText` is the file's own claim about them, and an empty
|
||||
// or substitute one must not hide text a cover sits on.
|
||||
let lines = pdfcraft_edit::glyph_text_lines(doc, page).map_err(|e| AuditError::Text(n, e.to_string()))?;
|
||||
budget.start_page(&lines);
|
||||
// 1. Opaque filled rectangles over extractable text.
|
||||
for r in cover::filled_rects(doc, &page.dict)? {
|
||||
let mut covered = String::new();
|
||||
for line in &lines {
|
||||
if is_cover(&r, line) {
|
||||
if !covered.is_empty() {
|
||||
covered.push(' ');
|
||||
}
|
||||
covered.push_str(line.text.trim());
|
||||
}
|
||||
}
|
||||
if !covered.trim().is_empty() {
|
||||
out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text: covered });
|
||||
if let Some(covered) = budget.hidden_text(&lines, copying, |line| is_cover(&r, line))? {
|
||||
let covered_text = budget.report_text(covered, copying)?;
|
||||
out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text });
|
||||
}
|
||||
}
|
||||
// 2. Annotation covers over extractable text.
|
||||
for annot in annots(doc, &page.dict)? {
|
||||
let mut covered = String::new();
|
||||
for line in &lines {
|
||||
let hidden = match &annot {
|
||||
AnnotationCover::Redact(rect) => covers(rect, line),
|
||||
AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST,
|
||||
};
|
||||
if hidden {
|
||||
if !covered.is_empty() {
|
||||
covered.push(' ');
|
||||
}
|
||||
covered.push_str(line.text.trim());
|
||||
}
|
||||
}
|
||||
if !covered.trim().is_empty() {
|
||||
if let Some(covered) = budget.hidden_text(&lines, copying, |line| match &annot {
|
||||
AnnotationCover::Redact(rect) => covers(rect, line),
|
||||
AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST,
|
||||
})? {
|
||||
let (kind, rect) = match annot {
|
||||
AnnotationCover::Redact(rect) => (FindingKind::UnappliedMark, rect),
|
||||
AnnotationCover::Square { rect, .. } => (FindingKind::CoveredText, rect),
|
||||
};
|
||||
out.push(AuditFinding { page: n, kind, rect, covered_text: covered });
|
||||
let covered_text = budget.report_text(covered, copying)?;
|
||||
out.push(AuditFinding { page: n, kind, rect, covered_text });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,11 +212,13 @@ pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError>
|
||||
}
|
||||
|
||||
/// A filled rectangle is a faux-redaction cover for a text line iff it was painted strictly
|
||||
/// after the line, overlaps it substantially, and is dark enough against the line's color to
|
||||
/// make the text unreadable. Light covers (table shading, highlights) over dark text fail the
|
||||
/// contrast check and are not flagged.
|
||||
/// after the start of the line, overlaps it substantially, and is dark enough against the line's
|
||||
/// color to make the text unreadable. Light covers (table shading, highlights) over dark text fail
|
||||
/// the contrast check and are not flagged. A line can join runs painted before and after the
|
||||
/// cover; the later ones are drawn on it in the same low-contrast color, so they are unreadable
|
||||
/// too.
|
||||
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
|
||||
rect.pos > line.first_paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST
|
||||
}
|
||||
|
||||
/// Does `cover` hide a substantial part of what `line` draws? Three ways, any is enough:
|
||||
@@ -229,7 +300,7 @@ fn annots(doc: &Document, page: &Dict) -> Result<Vec<AnnotationCover>, AuditErro
|
||||
continue;
|
||||
}
|
||||
let rect = [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])];
|
||||
match dict.name(b"Subtype") {
|
||||
match subtype(doc, &dict)?.as_deref() {
|
||||
Some(b"Redact") => out.push(AnnotationCover::Redact(rect)),
|
||||
Some(b"Square") => {
|
||||
let flags = dict.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
|
||||
@@ -249,6 +320,16 @@ fn annots(doc: &Document, page: &Dict) -> Result<Vec<AnnotationCover>, AuditErro
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The `/Subtype` name of `dict`, following an indirect reference. A reference that cannot be
|
||||
/// loaded fails the audit: the subtype decides whether the object is checked at all.
|
||||
fn subtype(doc: &Document, dict: &Dict) -> Result<Option<Vec<u8>>, AuditError> {
|
||||
let name = match dict.get(b"Subtype") {
|
||||
Some(pdfcraft_cos::Object::Ref(r)) => doc.try_get(r.num)?.as_name().map(<[u8]>::to_vec),
|
||||
other => other.and_then(pdfcraft_cos::Object::as_name).map(<[u8]>::to_vec),
|
||||
};
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
fn annotation_color(doc: &Document, dict: &Dict, key: &[u8]) -> Option<[f64; 3]> {
|
||||
let values: Vec<f64> = dict.get(key).map(|o| doc.resolve(o))?.as_array()?.iter().map(|o| doc.resolve(o).as_f64()).collect::<Option<_>>()?;
|
||||
if !values.iter().all(|v| v.is_finite()) {
|
||||
|
||||
@@ -592,3 +592,181 @@ fn reading_blocks_respect_stream_comments_and_split_operands() {
|
||||
assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1));
|
||||
}
|
||||
}
|
||||
|
||||
/// The current path is not graphics state: a rectangle built before `Q` and filled after it
|
||||
/// paints the box, so it is a cover.
|
||||
#[test]
|
||||
fn rectangle_filled_after_restore_is_a_cover() {
|
||||
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\nq 100 688 220 32 re Q f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}");
|
||||
}
|
||||
|
||||
/// A `/Subtype` may be an indirect reference to the name, like any other value: a `/Redact`
|
||||
/// annotation and a covering form XObject named that way are still checked.
|
||||
#[test]
|
||||
fn indirect_subtype_is_resolved() {
|
||||
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(b"BT /F1 24 Tf 100 700 Td (DO NOT SHARE) Tj ET\n");
|
||||
b.add(FONT);
|
||||
b.add("<< /Type /Annot /Subtype 7 0 R /Rect [95 685 305 725] >>");
|
||||
b.add("/Redact");
|
||||
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].kind, FindingKind::UnappliedMark);
|
||||
|
||||
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 6 0 R >> /XObject << /X 5 0 R >> >> >>");
|
||||
b.add_stream(b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n/X Do\n");
|
||||
b.add_stream_with(b"0 g\n100 688 220 32 re f\n", "/Type /XObject /Subtype 7 0 R /BBox [0 0 612 792]");
|
||||
b.add(FONT);
|
||||
b.add("/Form");
|
||||
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].kind, FindingKind::CoveredText);
|
||||
}
|
||||
|
||||
/// Text drawn after a cover on the same baseline joins the covered text in one line. The cover
|
||||
/// still hides the words painted before it.
|
||||
#[test]
|
||||
fn later_text_on_the_line_does_not_clear_an_earlier_cover() {
|
||||
let content = b"0 g\nBT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n98 688 94 32 re f\nBT /F1 24 Tf 200 700 Td (tail) Tj ET\n";
|
||||
let doc = open(one_page(content, ""));
|
||||
let lines = pdfcraft_edit::text_lines(&doc, 0).expect("text");
|
||||
assert_eq!(lines.len(), 1, "the runs join one line: {:?}", lines.iter().map(|l| &l.text).collect::<Vec<_>>());
|
||||
let findings = audit_redactions(&doc).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}");
|
||||
}
|
||||
|
||||
/// Many covers over the same long line repeat its text in every finding: a small file must not
|
||||
/// expand into an unbounded report. Past the budget the audit fails rather than truncate.
|
||||
#[test]
|
||||
fn covered_text_across_findings_is_bounded() {
|
||||
let text = "X".repeat(60_000);
|
||||
let mut content = format!("BT /F1 1 Tf 0 700 Td ({text}) Tj ET\n0 g\n").into_bytes();
|
||||
for i in 0..1_200 {
|
||||
content.extend_from_slice(format!("-{i} 690 50000 20 re f\n").as_bytes());
|
||||
}
|
||||
let result = audit_redactions(&open(one_page(&content, "")));
|
||||
assert!(matches!(result, Err(crate::AuditError::TooMuchOutput)), "{:?}", result.map(|f| f.len()));
|
||||
}
|
||||
|
||||
/// Covers compared against lines are bounded per page, so content that is quadratic in its
|
||||
/// covers and lines fails the audit instead of running for hours. A line costs one comparison per
|
||||
/// word: 20 covers against 20 two-word lines are 800.
|
||||
#[test]
|
||||
fn cover_and_line_comparisons_are_bounded() {
|
||||
let mut content = Vec::new();
|
||||
for i in 0..20 {
|
||||
content.extend_from_slice(format!("BT /F1 12 Tf 72 {} Td (Line {i}) Tj ET\n", 700 - 14 * i).as_bytes());
|
||||
}
|
||||
for _ in 0..20 {
|
||||
content.extend_from_slice(b"1 g 0 0 10 10 re f\n");
|
||||
}
|
||||
let doc = open(one_page(&content, ""));
|
||||
let limits = crate::Limits { comparisons: 799, covered_text: usize::MAX };
|
||||
assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons)));
|
||||
let limits = crate::Limits { comparisons: 800, covered_text: usize::MAX };
|
||||
assert!(crate::audit(&doc, limits).expect("within the budget").is_empty());
|
||||
}
|
||||
|
||||
/// Covered text is document text: a document whose permissions withhold copying reports where
|
||||
/// its covers are but not what they hide, unless it was opened with the owner password.
|
||||
#[test]
|
||||
fn copy_restricted_documents_withhold_covered_text() {
|
||||
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\n100 688 220 32 re f\n";
|
||||
let mut doc = open(one_page(content, ""));
|
||||
doc.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm: pdfcraft_cos::Algorithm::Aes256,
|
||||
user_password: "",
|
||||
owner_password: "owner",
|
||||
permissions: -1 ^ 0b1_0000,
|
||||
encrypt_metadata: true,
|
||||
seed: [7; 32],
|
||||
})
|
||||
.expect("encrypt");
|
||||
let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).expect("save"));
|
||||
let user = Document::open_with_password(bytes.clone(), None).expect("open");
|
||||
let findings = audit_redactions(&user).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "the cover is still reported: {findings:?}");
|
||||
assert_eq!(findings[0].covered_text, "");
|
||||
// The output bound counts the text reported, so withheld text costs nothing.
|
||||
let nothing = crate::Limits { comparisons: usize::MAX, covered_text: 0 };
|
||||
assert_eq!(crate::audit(&user, nothing).expect("withheld text is not output").len(), 1);
|
||||
let owner = Document::open_with_password(bytes, Some("owner")).expect("open");
|
||||
let findings = audit_redactions(&owner).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].covered_text, "SECRET DATA 123");
|
||||
let exact = crate::Limits { comparisons: usize::MAX, covered_text: "SECRET DATA 123".len() };
|
||||
assert!(crate::audit(&owner, exact).is_ok());
|
||||
let short = crate::Limits { covered_text: exact.covered_text - 1, ..exact };
|
||||
assert!(matches!(crate::audit(&owner, short), Err(crate::AuditError::TooMuchOutput)));
|
||||
}
|
||||
|
||||
/// Withheld text is never assembled: covers over one long line would otherwise copy the line once
|
||||
/// per cover, a volume neither the comparison nor the output bound charges.
|
||||
#[test]
|
||||
fn withheld_covered_text_is_not_assembled() {
|
||||
let doc = open(one_page(b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n", ""));
|
||||
let lines = pdfcraft_edit::text_lines(&doc, 0).expect("text");
|
||||
let mut budget = crate::Budget { limits: crate::Limits::DEFAULT, comparisons: 0, page_cost: 0, covered_text: 0 };
|
||||
budget.start_page(&lines);
|
||||
let withheld = budget.hidden_text(&lines, false, |_| true).expect("within the budget");
|
||||
assert_eq!(withheld.map(|t| t.capacity()), Some(0), "a finding, with no text built for it");
|
||||
assert_eq!(budget.hidden_text(&lines, false, |_| false).expect("within the budget"), None);
|
||||
let copied = budget.hidden_text(&lines, true, |_| true).expect("within the budget");
|
||||
assert_eq!(copied.as_deref(), Some("SECRET DATA 123"));
|
||||
}
|
||||
|
||||
/// `/ActualText` is metadata the file's author controls: an empty or substitute replacement must
|
||||
/// not hide the glyphs a cover sits on.
|
||||
#[test]
|
||||
fn a_cover_over_text_with_empty_or_substitute_actual_text_is_found() {
|
||||
for actual in ["()", "(nothing here)"] {
|
||||
let content =
|
||||
format!("/Span << /ActualText {actual} >> BDC\nBT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\nEMC\n0 g\n100 688 220 32 re f\n");
|
||||
let findings = audit_redactions(&open(one_page(content.as_bytes(), ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{actual}: {findings:?}");
|
||||
assert!(findings[0].covered_text.contains("SECRET"), "{actual}: covered_text was {:?}", findings[0].covered_text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_stream_references_share_one_decode_budget() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, Stream};
|
||||
// One 1 MiB stream of spaces (a kilobyte on disk), named 300 times: by the page's /Contents,
|
||||
// and by 300 draws of a form holding it.
|
||||
let mut doc = Document::new_empty();
|
||||
let spaces = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
|
||||
let contents: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(spaces); 300]))].into_iter().collect();
|
||||
assert!(crate::cover::filled_rects(&doc, &contents).is_err(), "repeated content streams");
|
||||
|
||||
let form_dict: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect();
|
||||
let form = doc.add(Stream::flate(form_dict, &vec![b' '; 1 << 20]));
|
||||
let draws = doc.add(Stream::from_raw(Dict::new(), "/X Do ".repeat(300).into_bytes()));
|
||||
let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect();
|
||||
let resources: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect();
|
||||
let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect();
|
||||
assert!(crate::cover::filled_rects(&doc, &page).is_err(), "repeated form draws");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_entries_past_the_page_allowance_fail_the_audit() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, Stream};
|
||||
// An empty form decodes to no bytes, so only the count of entries bounds drawing it again.
|
||||
let mut doc = Document::new_empty();
|
||||
let form_dict: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect();
|
||||
let form = doc.add(Stream::from_raw(form_dict, Vec::new()));
|
||||
let draws = doc.add(Stream::from_raw(Dict::new(), "/X Do ".repeat(pdfcraft_cos::MAX_PAGE_FORMS + 1).into_bytes()));
|
||||
let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect();
|
||||
let resources: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect();
|
||||
let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect();
|
||||
assert!(matches!(crate::cover::filled_rects(&doc, &page), Err(crate::AuditError::TooManyForms)));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The crate's integration tests, linked into one test binary.
|
||||
|
||||
mod attachment_key;
|
||||
mod automation;
|
||||
mod automation_roots;
|
||||
mod batch;
|
||||
|
||||
+163
-83
@@ -78,103 +78,172 @@ fn is_delim(b: u8) -> bool {
|
||||
matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%')
|
||||
}
|
||||
|
||||
/// Longest unreadable number token retried from its next byte. Real numbers are far shorter.
|
||||
const MAX_RETRIED_TOKEN: usize = 64;
|
||||
|
||||
/// Parse a (decoded) content stream.
|
||||
pub fn parse(data: &[u8]) -> Parsed {
|
||||
parse_pieces(data, &[])
|
||||
}
|
||||
|
||||
/// The operators of a (decoded) content stream, read one at a time, as [`parse`] reads them. A
|
||||
/// caller that stops early or keeps only a summary never holds every operator of the stream.
|
||||
pub fn ops(data: &[u8]) -> Ops<'_> {
|
||||
Ops::new(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 mut reader = Ops::new(data, ends);
|
||||
let ops = reader.by_ref().collect();
|
||||
Parsed { ops, skipped: reader.skipped, ambiguous_images: reader.ambiguous_images, comment_edges: reader.comment_edges }
|
||||
}
|
||||
|
||||
/// The iterator [`ops`] returns.
|
||||
pub struct Ops<'a> {
|
||||
data: &'a [u8],
|
||||
lx: Lexer<'a>,
|
||||
operands: Vec<Object>,
|
||||
start: Option<usize>,
|
||||
skipped: usize,
|
||||
ambiguous_images: usize,
|
||||
ends: &'a [usize],
|
||||
boundary: usize,
|
||||
comment_edges: Vec<(bool, bool)>,
|
||||
}
|
||||
|
||||
impl<'a> Ops<'a> {
|
||||
fn new(data: &'a [u8], ends: &'a [usize]) -> Self {
|
||||
Ops {
|
||||
data,
|
||||
lx: Lexer::new(data, 0),
|
||||
operands: Vec::new(),
|
||||
start: None,
|
||||
skipped: 0,
|
||||
ambiguous_images: 0,
|
||||
ends,
|
||||
boundary: 0,
|
||||
comment_edges: vec![(false, false); ends.len()],
|
||||
}
|
||||
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();
|
||||
if operand_start {
|
||||
match lx.object() {
|
||||
Ok(o) => {
|
||||
start.get_or_insert(at);
|
||||
operands.push(o);
|
||||
}
|
||||
Err(_) if b == b'(' => {
|
||||
// An unterminated string runs to the end of the stream.
|
||||
out.skipped += data.len() - at;
|
||||
lx.pos = data.len();
|
||||
}
|
||||
Err(_) => {
|
||||
// Skip the offending byte and carry on.
|
||||
lx.pos = at + 1;
|
||||
out.skipped += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes read so far that could not be read as operands or operators.
|
||||
pub fn skipped(&self) -> usize {
|
||||
self.skipped
|
||||
}
|
||||
|
||||
/// Inline images read so far whose end is this parser's guess (as in
|
||||
/// [`Parsed::ambiguous_images`]).
|
||||
pub fn ambiguous_images(&self) -> usize {
|
||||
self.ambiguous_images
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Ops<'_> {
|
||||
type Item = Op;
|
||||
|
||||
fn next(&mut self) -> Option<Op> {
|
||||
let Ops { data, lx, operands, start, skipped, ambiguous_images, ends, boundary, comment_edges: edges } = self;
|
||||
let (data, ends): (&[u8], &[usize]) = (data, ends);
|
||||
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, boundary, edges);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_delim(b) {
|
||||
// A stray `)`, `>`, `]`, `{`, `}`.
|
||||
lx.pos += 1;
|
||||
out.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let kw = lx.token();
|
||||
if kw.is_empty() {
|
||||
lx.pos = at + 1;
|
||||
out.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
match kw {
|
||||
b"true" => {
|
||||
start.get_or_insert(at);
|
||||
operands.push(Object::Bool(true));
|
||||
}
|
||||
b"false" => {
|
||||
start.get_or_insert(at);
|
||||
operands.push(Object::Bool(false));
|
||||
}
|
||||
b"null" => {
|
||||
start.get_or_insert(at);
|
||||
operands.push(Object::Null);
|
||||
}
|
||||
b"BI" => {
|
||||
let image = inline_image(data, at, lx.pos);
|
||||
out.ambiguous_images += usize::from(image.ambiguous);
|
||||
let end = image.end;
|
||||
lx.pos = end;
|
||||
match image.image {
|
||||
Some((dict, img)) => {
|
||||
out.ops.push(Op {
|
||||
op: b"BI".to_vec(),
|
||||
operands: Vec::new(),
|
||||
inline: Some((dict, img)),
|
||||
span: start.take().unwrap_or(at)..end,
|
||||
});
|
||||
}
|
||||
None => {
|
||||
out.skipped += end.saturating_sub(at);
|
||||
start = None;
|
||||
}
|
||||
}
|
||||
let Some(&b) = data.get(lx.pos) else {
|
||||
// Operands left without an operator.
|
||||
*skipped += operands.len();
|
||||
operands.clear();
|
||||
return None;
|
||||
};
|
||||
let at = lx.pos;
|
||||
let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit();
|
||||
if operand_start {
|
||||
match lx.object() {
|
||||
Ok(o) => {
|
||||
start.get_or_insert(at);
|
||||
operands.push(o);
|
||||
}
|
||||
Err(_) if b == b'(' => {
|
||||
// An unterminated string runs to the end of the stream.
|
||||
*skipped += data.len() - at;
|
||||
lx.pos = data.len();
|
||||
}
|
||||
Err(_) => {
|
||||
let read = lx.pos.saturating_sub(at);
|
||||
#[cfg(test)]
|
||||
tests::RESCANNED.with(|c| c.set(c.get() + read));
|
||||
let number = matches!(b, b'+' | b'-' | b'.') || b.is_ascii_digit();
|
||||
if number && read > MAX_RETRIED_TOKEN {
|
||||
// A long unreadable number is skipped whole: retrying from each of its
|
||||
// bytes would read the rest of it again every time.
|
||||
*skipped += read;
|
||||
} else {
|
||||
// Skip the offending byte and carry on.
|
||||
lx.pos = at + 1;
|
||||
*skipped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
let s = start.take().unwrap_or(at);
|
||||
out.ops.push(Op { op: kw.to_vec(), operands: std::mem::take(&mut operands), inline: None, span: s..lx.pos });
|
||||
if is_delim(b) {
|
||||
// A stray `)`, `>`, `]`, `{`, `}`.
|
||||
lx.pos += 1;
|
||||
*skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let kw = lx.token();
|
||||
if kw.is_empty() {
|
||||
lx.pos = at + 1;
|
||||
*skipped += 1;
|
||||
continue;
|
||||
}
|
||||
match kw {
|
||||
b"true" => {
|
||||
start.get_or_insert(at);
|
||||
operands.push(Object::Bool(true));
|
||||
}
|
||||
b"false" => {
|
||||
start.get_or_insert(at);
|
||||
operands.push(Object::Bool(false));
|
||||
}
|
||||
b"null" => {
|
||||
start.get_or_insert(at);
|
||||
operands.push(Object::Null);
|
||||
}
|
||||
b"BI" => {
|
||||
let image = inline_image(data, at, lx.pos);
|
||||
*ambiguous_images += usize::from(image.ambiguous);
|
||||
let end = image.end;
|
||||
lx.pos = end;
|
||||
operands.clear();
|
||||
match image.image {
|
||||
Some((dict, img)) => {
|
||||
return Some(Op {
|
||||
op: b"BI".to_vec(),
|
||||
operands: Vec::new(),
|
||||
inline: Some((dict, img)),
|
||||
span: start.take().unwrap_or(at)..end,
|
||||
});
|
||||
}
|
||||
None => {
|
||||
*skipped += end.saturating_sub(at);
|
||||
*start = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let s = start.take().unwrap_or(at);
|
||||
return Some(Op { op: kw.to_vec(), operands: std::mem::take(operands), inline: None, span: s..lx.pos });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.skipped += operands.len();
|
||||
out
|
||||
}
|
||||
|
||||
/// Record the piece boundaries crossed by the comments in `trivia` (skipped whitespace and
|
||||
@@ -567,9 +636,20 @@ impl Pieces {
|
||||
/// for byte. Returns every piece's new bytes (unchanged pieces come back equal).
|
||||
pub fn splice(&self, edits: impl IntoIterator<Item = (std::ops::Range<usize>, Vec<u8>)>) -> Vec<Vec<u8>> {
|
||||
let mut out: Vec<Vec<u8>> = (0..self.len()).map(|i| Vec::with_capacity(self.range(i).len())).collect();
|
||||
// Only the pieces from the one holding `from` up to `to`: one call per edit must not
|
||||
// visit every piece.
|
||||
let copy = |out: &mut Vec<Vec<u8>>, from: usize, to: usize| {
|
||||
for (i, piece) in out.iter_mut().enumerate() {
|
||||
if from >= to {
|
||||
return;
|
||||
}
|
||||
let first = self.piece_of(from);
|
||||
for (i, piece) in out.iter_mut().enumerate().skip(first) {
|
||||
#[cfg(test)]
|
||||
tests::PIECE_VISITS.with(|c| c.set(c.get() + 1));
|
||||
let r = self.range(i);
|
||||
if r.start >= to {
|
||||
break;
|
||||
}
|
||||
let (a, b) = (from.max(r.start), to.min(r.end));
|
||||
if a < b {
|
||||
piece.extend_from_slice(self.data.get(a..b).unwrap_or_default());
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
use super::*;
|
||||
|
||||
thread_local! {
|
||||
/// Bytes the lexer read before an operand failed, on this thread.
|
||||
pub(crate) static RESCANNED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Pieces [`Pieces::splice`] looked at while copying, on this thread.
|
||||
pub(crate) static PIECE_VISITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splicing_visits_each_piece_a_few_times() {
|
||||
let n = 5_000;
|
||||
let pieces: Vec<String> = (0..n).map(|i| format!("({i}) Tj")).collect();
|
||||
let joined = Pieces::join(&pieces);
|
||||
let parsed = joined.parse();
|
||||
let edits: Vec<_> = parsed.ops.iter().map(|op| (op.span.clone(), b"(x) Tj".to_vec())).collect();
|
||||
assert_eq!(edits.len(), n);
|
||||
PIECE_VISITS.with(|c| c.set(0));
|
||||
let out = joined.splice(edits);
|
||||
let visits = PIECE_VISITS.with(|c| c.get());
|
||||
assert!(visits <= 4 * n, "{visits} piece visits for {n} pieces");
|
||||
assert!(out.iter().all(|p| p == b"(x) Tj"));
|
||||
// A replacement spanning two pieces stays whole in the first and leaves the rest of the second.
|
||||
let two = Pieces::join(&["q (a)", "Tj Q"]);
|
||||
let tj = two.parse().ops.into_iter().find(|o| o.is("Tj")).map(|o| o.span).unwrap_or_default();
|
||||
assert_eq!(two.splice([(tj, b"(b) Tj".to_vec())]), [b"q (b) Tj".to_vec(), b" Q".to_vec()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_unreadable_number_is_read_once() {
|
||||
for junk in ["+", "5"] {
|
||||
let n = 20_000;
|
||||
let src = format!("{}{} 1 0 0 1 0 0 cm", junk.repeat(n), if junk == "5" { "x" } else { "" });
|
||||
RESCANNED.with(|c| c.set(0));
|
||||
let p = parse(src.as_bytes());
|
||||
let rescanned = RESCANNED.with(|c| c.get());
|
||||
assert!(rescanned <= 2 * n, "{rescanned} bytes read for a {n}-byte token");
|
||||
assert_eq!(p.ops.last().and_then(|o| o.nums::<6>()), Some([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
|
||||
}
|
||||
// A short damaged number still recovers byte by byte.
|
||||
let p = parse(b"-+5 0 Td");
|
||||
assert_eq!((p.ops.len(), p.skipped), (1, 1));
|
||||
assert_eq!(p.ops[0].nums::<2>(), Some([5.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operators_keep_operands_and_spans() {
|
||||
let src = b"q 1 0 0 1 72 720 cm BT /F1 12 Tf (Hello \\(world\\)) Tj [(A) -120 (B)] TJ ET Q";
|
||||
@@ -157,6 +203,9 @@ fn filtered_unknown_and_damaged_inline_images_end_where_the_renderer_ends_them()
|
||||
let parsed = parse(src);
|
||||
assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b"abc");
|
||||
assert_eq!(parsed.ambiguous_images, ambiguous);
|
||||
let mut reader = ops(src);
|
||||
reader.by_ref().for_each(drop);
|
||||
assert_eq!(reader.ambiguous_images(), ambiguous, "the streaming reader counts it too");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
//! A shared allowance for operations that decode many streams.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use pdfcraft_filters::FilterError;
|
||||
|
||||
use crate::{CosError, Document, ObjRef, Object, Stream};
|
||||
|
||||
/// Decoded bytes one page's content walk may produce in all: its `/Contents` streams and every
|
||||
/// form XObject it enters, each occurrence counted again.
|
||||
pub const MAX_PAGE_DECODED: usize = 256 << 20;
|
||||
/// Form XObjects one page's content walk may enter, each entry counted again.
|
||||
pub const MAX_PAGE_FORMS: usize = 50_000;
|
||||
|
||||
/// A stream decode within a byte bound, with the bytes its filter stages decoded: the data or
|
||||
/// the error, each with that count.
|
||||
type Decoder = fn(&Stream, usize) -> Result<(Vec<u8>, usize), (FilterError, usize)>;
|
||||
|
||||
/// What one operation may still decode and enter across all the streams it reads.
|
||||
///
|
||||
/// [`MAX_DECODED`](crate::MAX_DECODED) bounds one decode. It does not bound a page whose
|
||||
/// `/Contents` names the same compressed stream thousands of times, or a form drawn thousands of
|
||||
/// times from other forms: each occurrence would get a fresh allowance. A `DecodeBudget` is
|
||||
/// shared by every decode of one operation, so the total decoded bytes and form entries stay
|
||||
/// under fixed limits however the file repeats its references. A decode that runs past the
|
||||
/// remaining allowance spends all of it, so a bomb referenced many times is inflated once.
|
||||
#[derive(Debug)]
|
||||
pub struct DecodeBudget {
|
||||
/// The whole allowance, for errors.
|
||||
limit: usize,
|
||||
bytes: usize,
|
||||
forms: usize,
|
||||
seen: HashSet<ObjRef>,
|
||||
}
|
||||
|
||||
impl DecodeBudget {
|
||||
/// An allowance of `bytes` decoded bytes and `forms` form entries.
|
||||
pub fn new(bytes: usize, forms: usize) -> Self {
|
||||
Self { limit: bytes, bytes, forms, seen: HashSet::new() }
|
||||
}
|
||||
|
||||
/// The allowance for one page: [`MAX_PAGE_DECODED`] bytes and [`MAX_PAGE_FORMS`] forms.
|
||||
pub fn page() -> Self {
|
||||
Self::new(MAX_PAGE_DECODED, MAX_PAGE_FORMS)
|
||||
}
|
||||
|
||||
/// Decoded bytes still allowed.
|
||||
pub fn remaining(&self) -> usize {
|
||||
self.bytes
|
||||
}
|
||||
|
||||
/// Whether a decode was refused for lack of allowance or a form entry for lack of visits.
|
||||
pub fn exhausted(&self) -> bool {
|
||||
self.bytes == 0 || self.forms == 0
|
||||
}
|
||||
|
||||
/// `stream` decoded like [`Stream::decoded`], charged to the allowance. Fails, without
|
||||
/// decoding, once the allowance is spent.
|
||||
pub fn decode(&mut self, stream: &Stream) -> Result<Vec<u8>, CosError> {
|
||||
self.charge(stream, usize::MAX, Stream::decoded_within_counted)
|
||||
}
|
||||
|
||||
/// Like [`DecodeBudget::decode`], for a stream with a tighter bound of its own: decoding
|
||||
/// past `max` bytes is an error that charges `max`, the work it took to find out.
|
||||
pub fn decode_within(&mut self, stream: &Stream, max: usize) -> Result<Vec<u8>, CosError> {
|
||||
self.charge(stream, max, Stream::decoded_within_counted)
|
||||
}
|
||||
|
||||
/// `stream` decoded like [`Stream::decoded_strict`] (any corruption is an error), charged to
|
||||
/// the allowance.
|
||||
pub fn decode_strict(&mut self, stream: &Stream) -> Result<Vec<u8>, CosError> {
|
||||
self.charge(stream, usize::MAX, Stream::decoded_strict_within_counted)
|
||||
}
|
||||
|
||||
/// Decodes `stream` within `max` and the allowance, charging what every filter stage
|
||||
/// decoded: a stage's output counts even when a later stage shrinks it, and a corrupt stream
|
||||
/// pays for the bytes decoded before the error, so referencing a stream again and again
|
||||
/// spends the allowance by the work each decode does.
|
||||
fn charge(&mut self, stream: &Stream, max: usize, decode: Decoder) -> Result<Vec<u8>, CosError> {
|
||||
if self.bytes == 0 && !stream.raw.is_empty() {
|
||||
return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()));
|
||||
}
|
||||
if max < self.bytes {
|
||||
let result = decode(stream, max);
|
||||
let spent = match &result {
|
||||
Ok((_, spent)) => *spent,
|
||||
Err((FilterError::LimitExceeded(_), _)) => max,
|
||||
Err((_, spent)) => *spent,
|
||||
};
|
||||
self.bytes = self.bytes.saturating_sub(spent);
|
||||
return result.map(|(data, _)| data).map_err(|(e, _)| CosError::Filter(e.to_string()));
|
||||
}
|
||||
match decode(stream, self.bytes) {
|
||||
Ok((data, spent)) => {
|
||||
self.bytes = self.bytes.saturating_sub(spent);
|
||||
Ok(data)
|
||||
}
|
||||
Err((FilterError::LimitExceeded(_), _)) => {
|
||||
self.bytes = 0;
|
||||
Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()))
|
||||
}
|
||||
Err((e, spent)) => {
|
||||
self.bytes = self.bytes.saturating_sub(spent);
|
||||
Err(CosError::Filter(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts one form entry. `false` once [`DecodeBudget::new`]'s `forms` entries are spent:
|
||||
/// the caller leaves that form out.
|
||||
pub fn enter_form(&mut self) -> bool {
|
||||
match self.forms.checked_sub(1) {
|
||||
Some(left) => {
|
||||
self.forms = left;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` the first time `r` is passed to this budget, for callers that need each
|
||||
/// referenced stream only once.
|
||||
pub fn first_visit(&mut self, r: ObjRef) -> bool {
|
||||
self.seen.insert(r)
|
||||
}
|
||||
|
||||
/// Each stream `contents` names, decoded against the allowance, in order. `contents` is a
|
||||
/// page's `/Contents`: one stream, or an array whose non-stream entries are left out.
|
||||
/// A stream that fails to decode, or arrives after the allowance is spent, is an `Err` in its
|
||||
/// place, so the caller chooses between skipping it and giving up.
|
||||
pub fn decode_contents(&mut self, doc: &Document, contents: &Object) -> Vec<Result<Vec<u8>, CosError>> {
|
||||
match &*doc.resolve(contents) {
|
||||
Object::Stream(s) => vec![self.decode(s)],
|
||||
Object::Array(list) => list
|
||||
.iter()
|
||||
.filter_map(|entry| match &*doc.resolve(entry) {
|
||||
Object::Stream(s) => Some(self.decode(s)),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Dict;
|
||||
|
||||
#[test]
|
||||
fn repeated_references_share_one_allowance() {
|
||||
let mut doc = Document::new_empty();
|
||||
let bomb = doc.add(Object::Stream(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])));
|
||||
let contents = Object::Array(vec![Object::Ref(bomb); 64]);
|
||||
let mut budget = DecodeBudget::new(10 << 20, 1);
|
||||
let decoded = budget.decode_contents(&doc, &contents);
|
||||
assert_eq!(decoded.len(), 64);
|
||||
assert_eq!(decoded.iter().filter(|d| d.is_ok()).count(), 10);
|
||||
let total: usize = decoded.iter().flatten().map(Vec::len).sum();
|
||||
assert_eq!(total, 10 << 20);
|
||||
assert!(budget.exhausted());
|
||||
// A decode past the allowance spends it, so a bomb is inflated at most once more.
|
||||
let mut budget = DecodeBudget::new((1 << 20) - 1, 1);
|
||||
assert!(budget.decode_contents(&doc, &contents).iter().all(Result::is_err));
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
let mut budget = DecodeBudget::new((1 << 20) - 1, 1);
|
||||
let Object::Stream(stream) = &*doc.get(bomb) else { panic!("not a stream") };
|
||||
assert!(budget.decode_strict(stream).is_err());
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
|
||||
let mut budget = DecodeBudget::new(10 << 20, 1);
|
||||
assert!(budget.decode_within(stream, 1000).is_err());
|
||||
assert_eq!(budget.remaining(), (10 << 20) - 1000);
|
||||
assert_eq!(budget.decode_within(stream, 2 << 20).map(|d| d.len()).ok(), Some(1 << 20));
|
||||
assert_eq!(budget.remaining(), (9 << 20) - 1000);
|
||||
|
||||
assert!(budget.enter_form());
|
||||
assert!(!budget.enter_form());
|
||||
assert!(budget.first_visit(bomb));
|
||||
assert!(!budget.first_visit(bomb));
|
||||
}
|
||||
|
||||
/// A corrupt stream fails a strict decode after decoding part of it: each reference pays
|
||||
/// for that part, so references to it can't decode without limit.
|
||||
#[test]
|
||||
fn failed_strict_decodes_charge_what_they_decoded() {
|
||||
let whole = Stream::flate(Dict::new(), &vec![b' '; 1 << 20]);
|
||||
let cut = whole.raw.len() / 2;
|
||||
let corrupt = Stream::from_raw(whole.dict.clone(), whole.raw.get(..cut).unwrap_or_default().to_vec());
|
||||
let partial = corrupt.decoded().map(|d| d.len()).unwrap_or(0);
|
||||
assert!(partial > 0, "a cut flate stream decodes in part");
|
||||
let mut budget = DecodeBudget::new(10 << 20, 1);
|
||||
assert!(budget.decode_strict(&corrupt).is_err());
|
||||
assert_eq!(budget.remaining(), (10 << 20) - partial);
|
||||
let mut budget = DecodeBudget::new(10 << 20, 1);
|
||||
assert!(budget.decode_within(&corrupt, 1 << 30).is_ok());
|
||||
assert_eq!(budget.remaining(), (10 << 20) - partial);
|
||||
}
|
||||
|
||||
/// A chain whose later stage shrinks the data pays for every stage's output: a Flate stage
|
||||
/// that inflates to hex text, which ASCIIHex then halves, charges both.
|
||||
#[test]
|
||||
fn decodes_charge_every_stage_of_a_chain() {
|
||||
let hex: Vec<u8> = std::iter::repeat_n(b"ab".as_slice(), 1 << 19).flatten().copied().collect();
|
||||
let flate = Stream::flate(Dict::new(), &hex);
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")]));
|
||||
let chained = Stream::from_raw(dict, flate.raw.to_vec());
|
||||
assert_eq!(chained.decoded().map(|d| d.len()).ok(), Some(1 << 19));
|
||||
let charged = (1 << 20) + (1 << 19);
|
||||
let mut budget = DecodeBudget::new(10 << 20, 1);
|
||||
assert_eq!(budget.decode(&chained).map(|d| d.len()).ok(), Some(1 << 19));
|
||||
assert_eq!(budget.remaining(), (10 << 20) - charged);
|
||||
assert!(budget.decode_within(&chained, 2 << 20).is_ok());
|
||||
assert_eq!(budget.remaining(), (10 << 20) - 2 * charged);
|
||||
assert!(budget.decode_strict(&chained).is_ok());
|
||||
assert_eq!(budget.remaining(), (10 << 20) - 3 * charged);
|
||||
}
|
||||
}
|
||||
+156
-13
@@ -10,7 +10,7 @@ use std::sync::{Arc, Mutex, Weak};
|
||||
|
||||
use crate::CosError;
|
||||
use crate::object::{Dict, ObjRef, Object};
|
||||
use crate::parser::{Lexer, is_whitespace, parse_indirect, parse_indirect_shared};
|
||||
use crate::parser::{EndstreamIndex, Lexer, is_whitespace, parse_indirect, parse_indirect_shared};
|
||||
|
||||
thread_local! {
|
||||
/// The objects being loaded on this thread, innermost last (see `Document::try_get`).
|
||||
@@ -405,6 +405,8 @@ impl std::fmt::Debug for ReadState {
|
||||
#[derive(Clone)]
|
||||
pub struct Document {
|
||||
data: Arc<Vec<u8>>,
|
||||
/// Where `endstream` occurs in `data`; see [`EndstreamIndex`].
|
||||
endstreams: Arc<EndstreamIndex>,
|
||||
source_identity: Arc<()>,
|
||||
entries: Arc<XrefIndex>,
|
||||
trailer: Dict,
|
||||
@@ -494,12 +496,22 @@ impl ObjectReader {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Cross-reference sections read on this thread.
|
||||
static SECTION_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Nesting [`owned_bytes`] walks: past the parser's own nesting limit, so every parsed object
|
||||
/// is measured in full.
|
||||
const OWNED_BYTES_DEPTH: usize = 256;
|
||||
|
||||
/// Bytes `object` holds of its own: string and stream data that is not a view into `data`
|
||||
/// (the input file), nested a few levels deep (deeper data is rare and still bounded by the
|
||||
/// reader's object count).
|
||||
/// (the input file). An object nested deeper than [`OWNED_BYTES_DEPTH`] cannot be measured
|
||||
/// and counts as too large to keep.
|
||||
fn owned_bytes(object: &Object, data: &Arc<Vec<u8>>, depth: usize) -> usize {
|
||||
if depth > 4 {
|
||||
return 0;
|
||||
if depth > OWNED_BYTES_DEPTH {
|
||||
return usize::MAX;
|
||||
}
|
||||
match object {
|
||||
Object::String(s) => s.bytes.len(),
|
||||
@@ -532,6 +544,7 @@ impl Document {
|
||||
pub fn new_empty() -> Self {
|
||||
let mut doc = Document {
|
||||
data: Arc::new(Vec::new()),
|
||||
endstreams: Arc::default(),
|
||||
source_identity: Arc::new(()),
|
||||
entries: Arc::new(XrefIndex::empty()),
|
||||
trailer: Dict::new(),
|
||||
@@ -614,6 +627,7 @@ impl Document {
|
||||
};
|
||||
let mut doc = Document {
|
||||
data,
|
||||
endstreams: Arc::default(),
|
||||
source_identity: Arc::new(()),
|
||||
entries: Arc::new(XrefIndex::empty()),
|
||||
trailer: Dict::new(),
|
||||
@@ -978,7 +992,7 @@ impl Document {
|
||||
match self.xref_entry(num) {
|
||||
None | Some(XrefEntry::Free { .. }) => true,
|
||||
Some(XrefEntry::InFile { offset, .. }) => {
|
||||
let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None), Ok((id, _)) if id.num == num);
|
||||
let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None, &self.endstreams), Ok((id, _)) if id.num == num);
|
||||
// Offsets may count from a header that does not start the file.
|
||||
usize::try_from(offset).is_ok_and(|off| at(off) || (self.header_offset != 0 && at(off.saturating_add(self.header_offset))))
|
||||
}
|
||||
@@ -1176,7 +1190,7 @@ impl Document {
|
||||
length
|
||||
};
|
||||
let off = offset as usize;
|
||||
let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve) {
|
||||
let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve, &self.endstreams) {
|
||||
Ok((id, o)) if id.num == num => Some(self.decrypted(id, o)),
|
||||
_ => None,
|
||||
};
|
||||
@@ -1242,7 +1256,7 @@ impl Document {
|
||||
if p > 0 && !is_whitespace(data[p - 1]) {
|
||||
continue;
|
||||
}
|
||||
if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None)
|
||||
if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None, &self.endstreams)
|
||||
&& id.num == num
|
||||
{
|
||||
found = Some((id, o)); // keep the last (newest) definition
|
||||
@@ -1740,6 +1754,12 @@ impl Document {
|
||||
let mut revisions = Vec::new();
|
||||
let mut oddities = Vec::new();
|
||||
let mut visited = HashSet::new();
|
||||
// Hybrid streams already read, by offset: their entries, or `None` when unreadable. A newer
|
||||
// section has merged every entry of one into `entries`, so an older section that names it
|
||||
// again only has its own table compared with them, without decoding the stream again.
|
||||
// At most MAX_XREF_OBJECTS entries are kept in all; a stream past that is read again.
|
||||
let mut hybrids: HashMap<i64, Option<BTreeMap<u32, XrefEntry>>> = HashMap::new();
|
||||
let (mut hybrid_rows, mut noted) = (0usize, HashSet::new());
|
||||
let mut next = Some(first);
|
||||
while let Some(off) = next.take() {
|
||||
if !visited.insert(off) || visited.len() > 10_000 {
|
||||
@@ -1770,11 +1790,24 @@ impl Document {
|
||||
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 hybrid = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0);
|
||||
if let Some((x, read)) = hybrid.and_then(|x| hybrids.get(&x).map(|read| (x, read))) {
|
||||
if noted.insert(x) {
|
||||
log.push(format!("hybrid /XRefStm at {x} named again by the section at {off}; read once"));
|
||||
}
|
||||
if let Some(stm) = read {
|
||||
for (num, old) in § {
|
||||
// The table places the object somewhere other than the stream does.
|
||||
if !matches!(old, XrefEntry::Free { .. }) && stm.get(num).is_some_and(|e| e != old) {
|
||||
odd.push(*num);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(x) = hybrid {
|
||||
let mut stm = BTreeMap::new();
|
||||
match self.read_section(x as usize, &mut stm, &mut odd, log) {
|
||||
let read = match self.read_section(x as usize, &mut stm, &mut odd, log) {
|
||||
Ok(_) => {
|
||||
for (num, e) in stm {
|
||||
for (&num, &e) in &stm {
|
||||
match sect.get(&num) {
|
||||
None | Some(XrefEntry::Free { .. }) => {
|
||||
sect.insert(num, e);
|
||||
@@ -1784,8 +1817,17 @@ impl Document {
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
Some(stm)
|
||||
}
|
||||
Err(e) => log.push(format!("hybrid /XRefStm at {x} unreadable: {e}")),
|
||||
Err(e) => {
|
||||
log.push(format!("hybrid /XRefStm at {x} unreadable: {e}"));
|
||||
None
|
||||
}
|
||||
};
|
||||
let rows = read.as_ref().map_or(0, BTreeMap::len);
|
||||
if let Some(total) = hybrid_rows.checked_add(rows).filter(|total| *total <= MAX_XREF_OBJECTS) {
|
||||
hybrid_rows = total;
|
||||
hybrids.insert(x, read);
|
||||
}
|
||||
}
|
||||
oddities.extend(odd.into_iter().map(|num| (off, num)));
|
||||
@@ -1814,6 +1856,8 @@ impl Document {
|
||||
odd: &mut Vec<u32>,
|
||||
log: &mut Vec<String>,
|
||||
) -> Result<(Dict, bool), CosError> {
|
||||
#[cfg(test)]
|
||||
SECTION_READS.with(|c| c.set(c.get() + 1));
|
||||
let data = &self.data;
|
||||
let mut lx = Lexer::new(data, off);
|
||||
if lx.eat_keyword(b"xref") {
|
||||
@@ -2051,7 +2095,7 @@ impl Document {
|
||||
if trailer.as_ref().is_some_and(|((catalog, p), _)| *catalog && *p > at) {
|
||||
continue;
|
||||
}
|
||||
if let Ok((_, Object::Stream(s))) = parse_indirect_shared(&data, at, &|_| None)
|
||||
if let Ok((_, Object::Stream(s))) = parse_indirect_shared(&data, at, &|_| None, &self.endstreams)
|
||||
&& s.dict.name(b"Type") == Some(b"XRef")
|
||||
&& let Some(r) = rank(at, &s.dict)
|
||||
&& trailer.as_ref().is_none_or(|(best, _)| r > *best)
|
||||
@@ -2993,6 +3037,56 @@ endobj
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streams_without_length_share_one_endstream_search() {
|
||||
let n = 2_000;
|
||||
let mut file = b"%PDF-1.7\n".to_vec();
|
||||
for k in 1..=n {
|
||||
file.extend_from_slice(format!("{k} 0 obj\n<< >>\nstream\nxx\n").as_bytes());
|
||||
}
|
||||
file.extend_from_slice(b"endstream\nendobj\n");
|
||||
file.extend_from_slice(
|
||||
format!(
|
||||
"{0} 0 obj\n<< /Type /Catalog /Pages {1} 0 R >>\nendobj\n{1} 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n",
|
||||
n + 1,
|
||||
n + 2
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
file.extend_from_slice(format!("trailer\n<< /Root {} 0 R /Size {} >>\n%%EOF\n", n + 1, n + 3).as_bytes());
|
||||
let len = file.len();
|
||||
crate::parser::ENDSTREAM_SCAN.with(|c| c.set(0));
|
||||
let doc = Document::open(Arc::new(file)).unwrap();
|
||||
for k in [1, n / 2, n] {
|
||||
assert!(matches!(doc.try_get(k).as_deref(), Ok(Object::Stream(_))), "object {k}");
|
||||
}
|
||||
let scanned = crate::parser::ENDSTREAM_SCAN.with(|c| c.get());
|
||||
assert!(scanned <= 4 * len, "{scanned} bytes searched in a {len}-byte file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_cache_counts_deeply_nested_strings() {
|
||||
let mut nested = Object::String(crate::PdfString::literal(vec![b'x'; 1 << 20]));
|
||||
for _ in 0..8 {
|
||||
nested = Object::Array(vec![nested]);
|
||||
}
|
||||
let mut source = Document::new_empty();
|
||||
let objects: Vec<_> = (0..48).map(|_| Object::Ref(source.add(nested.clone()))).collect();
|
||||
source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap();
|
||||
let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap());
|
||||
let doc = Document::open(bytes).unwrap();
|
||||
assert_eq!(owned_bytes(&nested, &doc.data, 0), 1 << 20);
|
||||
let root = doc.get(doc.root().unwrap());
|
||||
for reference in root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap() {
|
||||
let Object::Ref(reference) = reference else { panic!("indirect object") };
|
||||
drop(doc.get(*reference));
|
||||
let cache = doc.cache.lock().unwrap();
|
||||
let owned = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, &doc.data, 0)));
|
||||
let strings = cache.values().filter(|o| o.as_array().is_some()).count();
|
||||
assert!(strings << 20 <= Document::EDITOR_CACHE_BYTES + (1 << 20), "{strings} MiB strings cached, {owned} bytes counted");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_cache_stays_bounded() {
|
||||
let mut source = Document::new_empty();
|
||||
@@ -3141,6 +3235,55 @@ endobj
|
||||
out
|
||||
}
|
||||
|
||||
/// Many revisions naming one hybrid /XRefStm read it once.
|
||||
#[test]
|
||||
fn a_hybrid_stream_named_by_many_revisions_is_read_once() {
|
||||
let mut out = object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0);
|
||||
let stream = find(&out, b"5 0 obj", 0, out.len()).unwrap();
|
||||
let mut prev = rfind(&out, b"\nxref\n", 0).unwrap() + 1;
|
||||
let revisions = 200;
|
||||
for _ in 0..revisions {
|
||||
let at = out.len();
|
||||
out.extend_from_slice(
|
||||
format!("xref\ntrailer << /Size 6 /Root 1 0 R /XRefStm {stream} /Prev {prev} >>\nstartxref\n{at}\n%%EOF\n").as_bytes(),
|
||||
);
|
||||
prev = at;
|
||||
}
|
||||
let doc = Document::open(Arc::new(out)).unwrap();
|
||||
SECTION_READS.with(|c| c.set(0));
|
||||
let (entries, ..) = doc.read_xref_chain_materialized(&mut Vec::new()).unwrap();
|
||||
let reads = SECTION_READS.with(|c| c.get());
|
||||
assert!(reads <= revisions + 2, "{reads} sections read for {} revisions", revisions + 1);
|
||||
assert!(matches!(entries.get(&3), Some(XrefEntry::InStream { stream: 4, .. })), "{:?}", entries.get(&3));
|
||||
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"));
|
||||
assert_compact_matches_materialized(&doc);
|
||||
}
|
||||
|
||||
/// An older section that names an /XRefStm a newer section already read still has its own
|
||||
/// table compared with that stream, so an entry it sets aside is recorded for it.
|
||||
#[test]
|
||||
fn a_repeated_hybrid_stream_records_each_sections_conflicts() {
|
||||
let mut out = object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0);
|
||||
let stream = find(&out, b"5 0 obj", 0, out.len()).unwrap();
|
||||
let catalog = find(&out, b"1 0 obj", 0, out.len()).unwrap();
|
||||
let base = rfind(&out, b"\nxref\n", 0).unwrap() + 1;
|
||||
// This section's table places object 3 at the catalog; the stream puts it in object stream 4.
|
||||
let older = out.len();
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"xref\n3 1\n{catalog:010} 00000 n \ntrailer << /Size 6 /Root 1 0 R /XRefStm {stream} /Prev {base} >>\nstartxref\n{older}\n%%EOF\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
let newer = out.len();
|
||||
out.extend_from_slice(
|
||||
format!("xref\ntrailer << /Size 6 /Root 1 0 R /XRefStm {stream} /Prev {older} >>\nstartxref\n{newer}\n%%EOF\n").as_bytes(),
|
||||
);
|
||||
let doc = Document::open(Arc::new(out)).unwrap();
|
||||
assert_eq!(doc.xref_oddities(), [(older as u64, 3)]);
|
||||
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"));
|
||||
}
|
||||
|
||||
fn text_of(doc: &Document, num: u32, key: &[u8]) -> Option<String> {
|
||||
doc.get(ObjRef::new(num, 0)).as_dict().and_then(|d| d.get(key).and_then(|v| v.as_string().map(|s| s.to_text())))
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod budget;
|
||||
mod bytes;
|
||||
mod document;
|
||||
mod object;
|
||||
@@ -21,6 +22,7 @@ mod parser;
|
||||
mod security;
|
||||
mod writer;
|
||||
|
||||
pub use budget::{DecodeBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS};
|
||||
pub use bytes::Bytes;
|
||||
pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry};
|
||||
pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream};
|
||||
|
||||
+170
-16
@@ -76,10 +76,37 @@ impl PdfString {
|
||||
/// A name object (without the leading `/`, with `#xx` escapes already decoded).
|
||||
pub type Name = Vec<u8>;
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Key comparisons and index lookups made by [`Dict`] on this thread.
|
||||
static KEY_COMPARES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Entries from which a [`Dict`] keeps a key index. Smaller dictionaries (nearly all of them)
|
||||
/// search their entries; a parsed file controls the key count, and a linear search per
|
||||
/// inserted key would make a dictionary of many distinct keys quadratic to build.
|
||||
const INDEX_FROM: usize = 32;
|
||||
|
||||
/// An ordered dictionary.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Dict {
|
||||
entries: Vec<(Name, Object)>,
|
||||
/// Key to position in `entries`, kept once the dictionary reaches [`INDEX_FROM`] entries.
|
||||
/// Boxed so that every `Dict`, and so every `Object`, grows by one pointer, not by a map.
|
||||
#[allow(clippy::box_collection)]
|
||||
index: Option<Box<std::collections::HashMap<Name, usize>>>,
|
||||
}
|
||||
|
||||
impl PartialEq for Dict {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.entries == other.entries
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Dict {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Dict").field("entries", &self.entries).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Dict {
|
||||
@@ -87,26 +114,53 @@ impl Dict {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn position(&self, key: &[u8]) -> Option<usize> {
|
||||
if let Some(index) = &self.index {
|
||||
#[cfg(test)]
|
||||
KEY_COMPARES.with(|c| c.set(c.get() + 1));
|
||||
return index.get(key).copied();
|
||||
}
|
||||
self.entries.iter().position(|(k, _)| {
|
||||
#[cfg(test)]
|
||||
KEY_COMPARES.with(|c| c.set(c.get() + 1));
|
||||
k == key
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &[u8]) -> Option<&Object> {
|
||||
self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v)
|
||||
self.position(key).and_then(|i| self.entries.get(i)).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, key: &[u8]) -> Option<&mut Object> {
|
||||
self.entries.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v)
|
||||
let i = self.position(key)?;
|
||||
self.entries.get_mut(i).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
/// Insert or replace, keeping the position of an existing key.
|
||||
pub fn set(&mut self, key: impl Into<Name>, value: impl Into<Object>) {
|
||||
let key = key.into();
|
||||
let value = value.into();
|
||||
match self.entries.iter_mut().find(|(k, _)| *k == key) {
|
||||
match self.position(&key).and_then(|i| self.entries.get_mut(i)) {
|
||||
Some((_, v)) => *v = value,
|
||||
None => self.entries.push((key, value)),
|
||||
None => {
|
||||
let at = self.entries.len();
|
||||
if let Some(index) = &mut self.index {
|
||||
index.insert(key.clone(), at);
|
||||
} else if at + 1 >= INDEX_FROM {
|
||||
let index = self.entries.iter().enumerate().map(|(i, (k, _))| (k.clone(), i)).chain([(key.clone(), at)]).collect();
|
||||
self.index = Some(Box::new(index));
|
||||
}
|
||||
self.entries.push((key, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, key: &[u8]) -> Option<Object> {
|
||||
let i = self.entries.iter().position(|(k, _)| k == key)?;
|
||||
let i = self.position(key)?;
|
||||
if let Some(index) = &mut self.index {
|
||||
index.remove(key);
|
||||
index.values_mut().filter(|at| **at > i).for_each(|at| *at -= 1);
|
||||
}
|
||||
Some(self.entries.remove(i).1)
|
||||
}
|
||||
|
||||
@@ -204,6 +258,21 @@ impl Stream {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`Stream::filters`], or an error when `/Filter` is present but is not a name or an
|
||||
/// array of names (an indirect reference, say): the data is not unfiltered, so it must not
|
||||
/// read as decoded.
|
||||
fn readable_filters(&self) -> Result<Vec<(Filter, Params)>, pdfcraft_filters::FilterError> {
|
||||
let readable = match self.dict.get(b"Filter") {
|
||||
None | Some(Object::Null | Object::Name(_)) => true,
|
||||
Some(Object::Array(a)) => a.iter().all(|f| f.as_name().is_some()),
|
||||
Some(_) => false,
|
||||
};
|
||||
if !readable {
|
||||
return Err(pdfcraft_filters::FilterError::Unsupported("/Filter is not a name or an array of names".into()));
|
||||
}
|
||||
Ok(self.filters())
|
||||
}
|
||||
|
||||
/// Decoded data, tolerating truncated or corrupt encodings the way viewers do (the data
|
||||
/// decoded before the damage is returned). Streams with image codecs fail with
|
||||
/// `CosError::Filter` (keep them encoded). Use `decoded_strict` to detect damage.
|
||||
@@ -214,26 +283,60 @@ impl Stream {
|
||||
/// Like [`Stream::decoded`], for a stream whose decoded size has a tighter bound than
|
||||
/// [`MAX_DECODED`]: decoding past `max` bytes is an error.
|
||||
pub fn decoded_within(&self, max: usize) -> Result<Vec<u8>, CosError> {
|
||||
let chain = self.filters();
|
||||
let decode = |chain: &[(Filter, Params)], data: &[u8], max| {
|
||||
pdfcraft_filters::decode_tolerant(chain, data, max).map(|(v, _)| (v, 0)).map_err(|e| (e, 0))
|
||||
};
|
||||
self.decode_chain(max, decode).map(|(v, _)| v).map_err(|(e, _)| CosError::Filter(e.to_string()))
|
||||
}
|
||||
|
||||
/// [`Stream::decoded_within`] for a caller charging an allowance: the filter error is kept
|
||||
/// typed, so a caller can tell a spent limit from corrupt data, `max` bounds all filter
|
||||
/// stages together, and the result gives the bytes they decoded (the work done, on success
|
||||
/// and on failure).
|
||||
pub(crate) fn decoded_within_counted(&self, max: usize) -> Result<(Vec<u8>, usize), (pdfcraft_filters::FilterError, usize)> {
|
||||
let decode = |chain: &[(Filter, Params)], data: &[u8], max| {
|
||||
pdfcraft_filters::decode_tolerant_counted(chain, data, max).map(|(v, _, spent)| (v, spent))
|
||||
};
|
||||
self.decode_chain(max, decode)
|
||||
}
|
||||
|
||||
/// The stream decoded through its filters by `decode`, or copied when it has none, within
|
||||
/// `max` and [`MAX_DECODED`].
|
||||
fn decode_chain(
|
||||
&self,
|
||||
max: usize,
|
||||
decode: impl FnOnce(&[(Filter, Params)], &[u8], usize) -> Result<(Vec<u8>, usize), (pdfcraft_filters::FilterError, usize)>,
|
||||
) -> Result<(Vec<u8>, usize), (pdfcraft_filters::FilterError, usize)> {
|
||||
let max = max.min(MAX_DECODED);
|
||||
let chain = self.readable_filters().map_err(|e| (e, 0))?;
|
||||
if chain.is_empty() {
|
||||
return self.raw_within(max);
|
||||
return self
|
||||
.raw_within_typed(max)
|
||||
.map(|v| {
|
||||
let n = v.len();
|
||||
(v, n)
|
||||
})
|
||||
.map_err(|e| (e, 0));
|
||||
}
|
||||
pdfcraft_filters::decode_tolerant(&chain, &self.raw, max.min(MAX_DECODED)).map(|(v, _)| v).map_err(|e| CosError::Filter(e.to_string()))
|
||||
decode(&chain, &self.raw, max)
|
||||
}
|
||||
|
||||
/// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error.
|
||||
pub fn decoded_strict(&self) -> Result<Vec<u8>, CosError> {
|
||||
let chain = self.filters();
|
||||
if chain.is_empty() {
|
||||
return self.raw_within(MAX_DECODED);
|
||||
}
|
||||
pdfcraft_filters::decode(&chain, &self.raw, MAX_DECODED).map_err(|e| CosError::Filter(e.to_string()))
|
||||
let decode = |chain: &[(Filter, Params)], data: &[u8], max| pdfcraft_filters::decode(chain, data, max).map(|v| (v, 0)).map_err(|e| (e, 0));
|
||||
self.decode_chain(MAX_DECODED, decode).map(|(v, _)| v).map_err(|(e, _)| CosError::Filter(e.to_string()))
|
||||
}
|
||||
|
||||
/// [`Stream::decoded_strict`] bounded by `max` and counted like
|
||||
/// [`Stream::decoded_within_counted`].
|
||||
pub(crate) fn decoded_strict_within_counted(&self, max: usize) -> Result<(Vec<u8>, usize), (pdfcraft_filters::FilterError, usize)> {
|
||||
self.decode_chain(max, pdfcraft_filters::decode_counted)
|
||||
}
|
||||
|
||||
/// Data after the first filter alone, bounded by `max`: the rest of the chain stays encoded.
|
||||
/// Any corruption in that first filter is an error.
|
||||
pub fn decoded_first_filter_strict(&self, max: usize) -> Result<Vec<u8>, CosError> {
|
||||
let mut chain = self.filters();
|
||||
let mut chain = self.readable_filters().map_err(|e| CosError::Filter(e.to_string()))?;
|
||||
chain.truncate(1);
|
||||
if chain.is_empty() {
|
||||
return self.raw_within(max);
|
||||
@@ -242,11 +345,15 @@ impl Stream {
|
||||
}
|
||||
|
||||
fn raw_within(&self, max: usize) -> Result<Vec<u8>, CosError> {
|
||||
self.raw_within_typed(max).map_err(|e| CosError::Filter(e.to_string()))
|
||||
}
|
||||
|
||||
fn raw_within_typed(&self, max: usize) -> Result<Vec<u8>, pdfcraft_filters::FilterError> {
|
||||
let max = max.min(MAX_DECODED);
|
||||
// An empty decoder chain still produces an owned output buffer: check its limit
|
||||
// before cloning, including streams whose /Crypt filter was applied on load.
|
||||
if self.raw.len() > max {
|
||||
return Err(CosError::Filter(pdfcraft_filters::FilterError::LimitExceeded(max).to_string()));
|
||||
return Err(pdfcraft_filters::FilterError::LimitExceeded(max));
|
||||
}
|
||||
Ok(self.raw.to_vec())
|
||||
}
|
||||
@@ -382,6 +489,53 @@ impl From<Stream> for Object {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_filter_that_is_not_a_direct_name_is_not_decoded_as_absent() {
|
||||
let stream = |filter: Object| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Filter".to_vec(), filter);
|
||||
Stream::from_raw(d, b"48 69>".to_vec())
|
||||
};
|
||||
let hex = Object::Name(b"ASCIIHexDecode".to_vec());
|
||||
assert_eq!(stream(hex.clone()).decoded_strict().ok(), Some(b"Hi".to_vec()));
|
||||
assert_eq!(stream(Object::Array(vec![hex.clone()])).decoded().ok(), Some(b"Hi".to_vec()));
|
||||
assert_eq!(stream(Object::Null).decoded_strict().ok(), Some(b"48 69>".to_vec()));
|
||||
for filter in [
|
||||
Object::Ref(ObjRef::new(5, 0)),
|
||||
Object::Array(vec![Object::Ref(ObjRef::new(5, 0))]),
|
||||
Object::Int(3),
|
||||
Object::Array(vec![hex, Object::Int(1)]),
|
||||
] {
|
||||
let s = stream(filter.clone());
|
||||
assert!(s.decoded_strict().is_err(), "{filter:?}");
|
||||
assert!(s.decoded().is_err(), "{filter:?}");
|
||||
assert!(s.decoded_first_filter_strict(1 << 20).is_err(), "{filter:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsing_many_distinct_keys_is_not_quadratic() {
|
||||
let n = 20_000;
|
||||
let body: String = (0..n).map(|i| format!("/K{i} {i} ")).collect();
|
||||
let text = format!("<< {body}/K7 true >>");
|
||||
KEY_COMPARES.with(|c| c.set(0));
|
||||
let Ok(Object::Dict(d)) = crate::parser::Lexer::new(text.as_bytes(), 0).object() else { panic!("not a dict") };
|
||||
let compares = KEY_COMPARES.with(|c| c.get());
|
||||
assert!(compares < 8 * n, "{compares} key comparisons for {n} keys");
|
||||
// Order and replace-in-place semantics stay those of a plain ordered dictionary.
|
||||
assert_eq!(d.len(), n);
|
||||
assert_eq!(d.iter().next().map(|(k, _)| k.as_slice()), Some(&b"K0"[..]));
|
||||
assert_eq!(d.get(b"K7"), Some(&Object::Bool(true)));
|
||||
assert_eq!(d.iter().nth(7).map(|(_, v)| v), Some(&Object::Bool(true)));
|
||||
let mut d = d;
|
||||
assert_eq!(d.remove(b"K3"), Some(Object::Int(3)));
|
||||
assert_eq!(d.get(b"K4"), Some(&Object::Int(4)));
|
||||
assert_eq!(d.iter().nth(3).map(|(k, _)| k.as_slice()), Some(&b"K4"[..]));
|
||||
d.set(b"K3".to_vec(), Object::Null);
|
||||
assert_eq!(d.iter().last().map(|(k, _)| k.as_slice()), Some(&b"K3"[..]));
|
||||
assert!(d.get(b"K20000").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_strings_round_trip() {
|
||||
for s in ["Plain ASCII", "Café — naïve", "日本語", "emoji 📄"] {
|
||||
|
||||
+128
-19
@@ -10,6 +10,9 @@ use crate::object::{Dict, ObjRef, Object, PdfString, Stream};
|
||||
use crate::{Bytes, CosError};
|
||||
|
||||
const MAX_DEPTH: usize = 128;
|
||||
/// Objects one top-level [`Lexer::object`] call may build. Real arrays (page trees, parent
|
||||
/// trees, widths) stay far below; a decompressed object stream of `0 0 0 ...` does not.
|
||||
const MAX_NODES: usize = 1 << 22;
|
||||
|
||||
/// One of the six PDF whitespace characters (ISO 32000-2 §7.2.3, Table 1).
|
||||
pub fn is_whitespace(b: u8) -> bool {
|
||||
@@ -28,11 +31,13 @@ fn is_regular(b: u8) -> bool {
|
||||
pub struct Lexer<'a> {
|
||||
pub data: &'a [u8],
|
||||
pub pos: usize,
|
||||
/// Objects built by the current top-level [`Lexer::object`] call.
|
||||
nodes: usize,
|
||||
}
|
||||
|
||||
impl<'a> Lexer<'a> {
|
||||
pub fn new(data: &'a [u8], pos: usize) -> Self {
|
||||
Self { data, pos: pos.min(data.len()) }
|
||||
Self { data, pos: pos.min(data.len()), nodes: 0 }
|
||||
}
|
||||
|
||||
fn peek(&self) -> Option<u8> {
|
||||
@@ -84,13 +89,22 @@ impl<'a> Lexer<'a> {
|
||||
|
||||
/// Parse one direct object. References `N G R` are recognised.
|
||||
pub fn object(&mut self) -> Result<Object, CosError> {
|
||||
self.nodes = 0;
|
||||
self.object_depth(0)
|
||||
}
|
||||
|
||||
fn over_budget(&self) -> bool {
|
||||
self.nodes > MAX_NODES
|
||||
}
|
||||
|
||||
fn object_depth(&mut self, depth: usize) -> Result<Object, CosError> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(self.err("nesting too deep"));
|
||||
}
|
||||
self.nodes = self.nodes.saturating_add(1);
|
||||
if self.over_budget() {
|
||||
return Err(self.err("object too large"));
|
||||
}
|
||||
self.skip_ws();
|
||||
let Some(b) = self.peek() else { return Err(self.err("unexpected end of data")) };
|
||||
match b {
|
||||
@@ -125,6 +139,7 @@ impl<'a> Lexer<'a> {
|
||||
let before = self.pos;
|
||||
match self.object_depth(depth + 1) {
|
||||
Ok(o) => items.push(o),
|
||||
Err(e) if self.over_budget() => return Err(e),
|
||||
// Tolerate junk inside arrays by skipping one byte.
|
||||
Err(_) if self.pos == before => self.pos += 1,
|
||||
Err(e) => return Err(e),
|
||||
@@ -283,6 +298,9 @@ impl<'a> Lexer<'a> {
|
||||
// Tolerate garbage between entries (seen in damaged files): skip a token.
|
||||
let before = self.pos;
|
||||
let _ = self.object_depth(depth + 1);
|
||||
if self.over_budget() {
|
||||
return Err(self.err("object too large"));
|
||||
}
|
||||
if self.pos == before {
|
||||
self.pos += 1;
|
||||
}
|
||||
@@ -324,18 +342,8 @@ impl<'a> Lexer<'a> {
|
||||
if s.contains('.') {
|
||||
// Tolerate forms like "--5", "5-" or "1.2.3" by parsing the longest valid prefix.
|
||||
let cleaned: String = s.trim_start_matches('+').replacen("--", "-", 1);
|
||||
let mut end = cleaned.len();
|
||||
while end > 0 {
|
||||
// Only cut at character boundaries: a token can hold multi-byte UTF-8 (fuzzing
|
||||
// found ".—", which panicked here).
|
||||
if cleaned.is_char_boundary(end)
|
||||
&& let Ok(v) = cleaned[..end].parse::<f64>()
|
||||
{
|
||||
return Ok(Object::Real(v));
|
||||
}
|
||||
end -= 1;
|
||||
}
|
||||
return Ok(Object::Real(0.0));
|
||||
let v = cleaned.get(..real_prefix_len(cleaned.as_bytes())).and_then(|p| parse_counted::<f64>(p).ok());
|
||||
return Ok(Object::Real(v.unwrap_or(0.0)));
|
||||
}
|
||||
// Tolerate a doubled sign ("--280") like the real branch does.
|
||||
let t = s.trim_start_matches('+');
|
||||
@@ -350,6 +358,46 @@ impl<'a> Lexer<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Length of the longest prefix of `s` that reads as a decimal number: a sign, digits with at
|
||||
/// most one `.`, and an exponent with digits. Zero when that prefix has no digit. Found in one
|
||||
/// pass so a long damaged token is converted once, not once per shorter prefix.
|
||||
fn real_prefix_len(s: &[u8]) -> usize {
|
||||
let digits = |from: usize| s.get(from..).map_or(0, |r| r.iter().take_while(|b| b.is_ascii_digit()).count());
|
||||
let mut at = usize::from(matches!(s.first(), Some(b'+' | b'-')));
|
||||
let int = digits(at);
|
||||
at += int;
|
||||
let mut frac = 0;
|
||||
if s.get(at) == Some(&b'.') {
|
||||
frac = digits(at + 1);
|
||||
at += 1 + frac;
|
||||
}
|
||||
if int + frac == 0 {
|
||||
return 0;
|
||||
}
|
||||
if matches!(s.get(at), Some(b'e' | b'E')) {
|
||||
let sign = usize::from(matches!(s.get(at + 1), Some(b'+' | b'-')));
|
||||
let exp = digits(at + 1 + sign);
|
||||
if exp > 0 {
|
||||
at += 1 + sign + exp;
|
||||
}
|
||||
}
|
||||
at
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Bytes searched for `endstream` on this thread.
|
||||
pub(crate) static ENDSTREAM_SCAN: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
/// Bytes handed to number conversion on this thread.
|
||||
static NUMBER_BYTES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn parse_counted<T: std::str::FromStr>(s: &str) -> Result<T, T::Err> {
|
||||
#[cfg(test)]
|
||||
NUMBER_BYTES.with(|c| c.set(c.get() + s.len()));
|
||||
s.parse()
|
||||
}
|
||||
|
||||
fn hexval(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
@@ -362,17 +410,30 @@ fn hexval(b: u8) -> Option<u8> {
|
||||
/// Parse an indirect object at `offset`: `N G obj <object> [stream…endstream] endobj`.
|
||||
/// `resolve_length` resolves an indirect `/Length`. Returns the object and its (num, gen).
|
||||
pub fn parse_indirect(data: &[u8], offset: usize, resolve_length: &dyn Fn(ObjRef) -> Option<i64>) -> Result<(ObjRef, Object), CosError> {
|
||||
parse_indirect_in(data, None, offset, resolve_length)
|
||||
parse_indirect_in(data, None, offset, resolve_length, None)
|
||||
}
|
||||
|
||||
/// [`parse_indirect`] for a document's own bytes: stream data points into `data` instead of
|
||||
/// being copied out of it.
|
||||
/// `endstreams` is the document's [`EndstreamIndex`] for `data`.
|
||||
pub(crate) fn parse_indirect_shared(
|
||||
data: &Arc<Vec<u8>>,
|
||||
offset: usize,
|
||||
resolve_length: &dyn Fn(ObjRef) -> Option<i64>,
|
||||
endstreams: &EndstreamIndex,
|
||||
) -> Result<(ObjRef, Object), CosError> {
|
||||
parse_indirect_in(data, Some(data), offset, resolve_length)
|
||||
parse_indirect_in(data, Some(data), offset, resolve_length, Some(endstreams))
|
||||
}
|
||||
|
||||
/// Where `endstream` occurs in a document's bytes, found once, the first time a stream's
|
||||
/// /Length is wrong or missing. Searching the rest of the file for each such stream instead
|
||||
/// makes many of them ending at one late `endstream` quadratic to read.
|
||||
pub(crate) type EndstreamIndex = std::sync::OnceLock<Vec<usize>>;
|
||||
|
||||
fn endstream_offsets(data: &[u8]) -> Vec<usize> {
|
||||
#[cfg(test)]
|
||||
ENDSTREAM_SCAN.with(|c| c.set(c.get() + data.len()));
|
||||
data.windows(9).enumerate().filter(|(_, w)| *w == b"endstream").map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
fn parse_indirect_in(
|
||||
@@ -380,6 +441,7 @@ fn parse_indirect_in(
|
||||
shared: Option<&Arc<Vec<u8>>>,
|
||||
offset: usize,
|
||||
resolve_length: &dyn Fn(ObjRef) -> Option<i64>,
|
||||
endstreams: Option<&EndstreamIndex>,
|
||||
) -> Result<(ObjRef, Object), CosError> {
|
||||
let mut lx = Lexer::new(data, offset);
|
||||
lx.skip_ws();
|
||||
@@ -439,7 +501,11 @@ fn parse_indirect_in(
|
||||
let len = match declared {
|
||||
Some(l) if valid(l) => l as usize,
|
||||
// Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery).
|
||||
_ => find_endstream(data, start).ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })? - start,
|
||||
_ => {
|
||||
find_endstream(data, start, endstreams)
|
||||
.ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })?
|
||||
- start
|
||||
}
|
||||
};
|
||||
let range = start..start + len;
|
||||
let raw = match shared {
|
||||
@@ -455,9 +521,20 @@ fn parse_indirect_in(
|
||||
}
|
||||
|
||||
/// Offset where stream data ends (before the EOL preceding "endstream").
|
||||
fn find_endstream(data: &[u8], from: usize) -> Option<usize> {
|
||||
let hay = data.get(from..)?;
|
||||
let at = hay.windows(9).position(|w| w == b"endstream")? + from;
|
||||
fn find_endstream(data: &[u8], from: usize, endstreams: Option<&EndstreamIndex>) -> Option<usize> {
|
||||
let at = match endstreams {
|
||||
Some(index) => {
|
||||
let index = index.get_or_init(|| endstream_offsets(data));
|
||||
index.get(index.partition_point(|&p| p < from)).copied()?
|
||||
}
|
||||
None => {
|
||||
let hay = data.get(from..)?;
|
||||
let found = hay.windows(9).position(|w| w == b"endstream");
|
||||
#[cfg(test)]
|
||||
ENDSTREAM_SCAN.with(|c| c.set(c.get() + found.map_or(hay.len(), |p| p + 9)));
|
||||
found? + from
|
||||
}
|
||||
};
|
||||
let mut end = at;
|
||||
if end > from && data[end - 1] == b'\n' {
|
||||
end -= 1;
|
||||
@@ -483,6 +560,22 @@ mod tests {
|
||||
assert_eq!(Lexer::new("1.5\u{2014}".as_bytes(), 0).object().unwrap(), Object::Real(1.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_real_is_converted_once() {
|
||||
let token = format!("1.{}{}", "5".repeat(2_000), "x".repeat(20_000));
|
||||
NUMBER_BYTES.with(|c| c.set(0));
|
||||
let Ok(Object::Real(v)) = Lexer::new(token.as_bytes(), 0).object() else { panic!("not a real") };
|
||||
let converted = NUMBER_BYTES.with(|c| c.get());
|
||||
assert!(converted <= token.len(), "{converted} bytes converted for a {}-byte token", token.len());
|
||||
assert!((v - 1.5555).abs() < 1e-3);
|
||||
// The longest numeric prefix wins, as before.
|
||||
for (t, want) in
|
||||
[("1.2.3", 1.2), ("--5.", -5.0), ("+.5x", 0.5), ("5.-", 5.0), ("1.5e3", 1500.0), ("1.5e+", 1.5), ("-.x", 0.0), (".", 0.0), ("-inf.", 0.0)]
|
||||
{
|
||||
assert_eq!(Lexer::new(t.as_bytes(), 0).object().ok(), Some(Object::Real(want)), "{t}");
|
||||
}
|
||||
}
|
||||
|
||||
fn obj(s: &str) -> Object {
|
||||
Lexer::new(s.as_bytes(), 0).object().unwrap_or_else(|e| panic!("{s}: {e}"))
|
||||
}
|
||||
@@ -598,4 +691,20 @@ mod tests {
|
||||
let deep = "[".repeat(10_000);
|
||||
assert!(Lexer::new(deep.as_bytes(), 0).object().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_object_has_a_node_budget() {
|
||||
// A wide array (from a tiny compressed object stream) must not grow without bound.
|
||||
let wide = |n: usize| format!("[{}]", "0 ".repeat(n));
|
||||
let at_cap = Lexer::new(wide(MAX_NODES - 1).as_bytes(), 0).object();
|
||||
assert!(matches!(at_cap, Ok(Object::Array(ref items)) if items.len() == MAX_NODES - 1));
|
||||
assert!(Lexer::new(wide(MAX_NODES).as_bytes(), 0).object().is_err());
|
||||
// The same inside a dictionary value and behind tolerated junk.
|
||||
assert!(Lexer::new(format!("<< /A {} >>", wide(MAX_NODES)).as_bytes(), 0).object().is_err());
|
||||
assert!(Lexer::new(format!("[[{}] ]", "0 ".repeat(MAX_NODES)).as_bytes(), 0).object().is_err());
|
||||
// The budget is per top-level object: a lexer reused for many operands keeps working.
|
||||
let two = wide(MAX_NODES - 1).repeat(2);
|
||||
let mut lx = Lexer::new(two.as_bytes(), 0);
|
||||
assert!(lx.object().is_ok() && lx.object().is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +359,9 @@ impl Space {
|
||||
}
|
||||
}
|
||||
|
||||
/// A lookup table holds at most 256 entries of 4 bytes; decoding stops well past that.
|
||||
const MAX_LOOKUP: usize = 64 << 10;
|
||||
|
||||
fn space(doc: &Document, cs: Option<&Object>) -> Result<Space, String> {
|
||||
let Some(cs) = cs else { return Err("the image has no colour space".into()) };
|
||||
let cs = doc.resolve(cs);
|
||||
@@ -400,7 +403,9 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result<Space, String> {
|
||||
};
|
||||
let table = match a.get(3).map(|t| doc.resolve(t)).as_deref() {
|
||||
Some(Object::String(s)) => s.bytes.clone(),
|
||||
Some(Object::Stream(t)) => t.decoded().map_err(|e| e.to_string())?,
|
||||
Some(Object::Stream(t)) => {
|
||||
t.decoded_within(MAX_LOOKUP).map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())?
|
||||
}
|
||||
_ => return Err("an indexed colour space without a lookup table".into()),
|
||||
};
|
||||
Ok(Space::Indexed(Box::new(base), hival, table))
|
||||
|
||||
@@ -393,17 +393,38 @@ fn rgba_image(rgba: &[u8], (w, h): (u32, u32), dpi: (f64, f64)) -> Embedded {
|
||||
/// BMP and GIF (the first frame), through the `image` decoders. Their resolution is not read:
|
||||
/// 72 dpi, as for images without one.
|
||||
fn decoded(name: &str, bytes: &[u8], format: image::ImageFormat) -> Result<Embedded, CreateError> {
|
||||
// A GIF's first frame is painted on a canvas the size of its logical screen, however small
|
||||
// the frame: a few bytes can ask for hundreds of MB.
|
||||
if format == image::ImageFormat::Gif
|
||||
&& let Some(&[w0, w1, h0, h1]) = bytes.get(6..10)
|
||||
&& u64::from(u16::from_le_bytes([w0, w1])) * u64::from(u16::from_le_bytes([h0, h1])) > MAX_GIF_PIXELS
|
||||
{
|
||||
return Err(CreateError::Image(name.into(), "the GIF's screen is larger than 4096×4096 pixels".into()));
|
||||
}
|
||||
let img = image::load_from_memory_with_format(bytes, format).map_err(|e| CreateError::Image(name.into(), e.to_string()))?;
|
||||
let rgba = img.to_rgba8();
|
||||
let rgba = img.into_rgba8();
|
||||
Ok(rgba_image(rgba.as_raw(), rgba.dimensions(), (72.0, 72.0)))
|
||||
}
|
||||
|
||||
/// Largest GIF logical screen, in pixels (4096×4096).
|
||||
const MAX_GIF_PIXELS: u64 = 1 << 24;
|
||||
|
||||
/// Most decoded bytes one TIFF page or PNG may take: room for the 768 MiB CMYK poster in #665,
|
||||
/// twice over.
|
||||
const MAX_DECODED_BYTES: usize = 2 << 30;
|
||||
|
||||
/// Every page of a TIFF (multi-page scans become multi-page PDFs).
|
||||
fn tiff_pages(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
|
||||
/// Most decoded bytes all the pages of one TIFF may take together: its pages stay in memory until
|
||||
/// they are embedded, and many small directories can share one compressed strip. Saturates to the
|
||||
/// whole address space on 32-bit targets (wasm32), where 4 GiB doesn't fit in `usize`.
|
||||
const MAX_DECODED_TOTAL: usize = MAX_DECODED_BYTES.saturating_mul(2);
|
||||
|
||||
/// The first `wanted` pages of a TIFF (multi-page scans become multi-page PDFs).
|
||||
fn tiff_pages(name: &str, bytes: &[u8], wanted: usize) -> Result<Vec<Embedded>, CreateError> {
|
||||
tiff_pages_within(name, bytes, wanted, MAX_DECODED_TOTAL)
|
||||
}
|
||||
|
||||
/// [`tiff_pages`], refused once the pages kept take more than `budget` bytes.
|
||||
fn tiff_pages_within(name: &str, bytes: &[u8], wanted: usize, budget: usize) -> Result<Vec<Embedded>, CreateError> {
|
||||
use tiff::ColorType as C;
|
||||
use tiff::decoder::{Decoder, DecodingResult, Limits};
|
||||
use tiff::tags::Tag;
|
||||
@@ -415,6 +436,7 @@ fn tiff_pages(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
|
||||
limits.decoding_buffer_size = MAX_DECODED_BYTES;
|
||||
let mut dec = Decoder::new(std::io::Cursor::new(bytes)).map_err(|e| bad(e.to_string()))?.with_limits(limits);
|
||||
let mut out = Vec::new();
|
||||
let mut kept = 0usize;
|
||||
loop {
|
||||
let (w, h) = dec.dimensions().map_err(|e| bad(e.to_string()))?;
|
||||
let ct = dec.colortype().map_err(|e| bad(e.to_string()))?;
|
||||
@@ -469,8 +491,12 @@ fn tiff_pages(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
|
||||
}
|
||||
other => return Err(bad(format!("{other:?} TIFF images aren't supported yet"))),
|
||||
};
|
||||
kept = kept.saturating_add(page.data.len()).saturating_add(page.smask.as_ref().map_or(0, |(_, alpha)| alpha.len()));
|
||||
if kept > budget {
|
||||
return Err(bad(format!("the TIFF's pages take more than {} MiB together", budget >> 20)));
|
||||
}
|
||||
out.push(page);
|
||||
if !dec.more_images() {
|
||||
if out.len() >= wanted || !dec.more_images() {
|
||||
break;
|
||||
}
|
||||
dec.next_image().map_err(|e| bad(e.to_string()))?;
|
||||
@@ -526,14 +552,15 @@ pub fn source_kind(name: &str, bytes: &[u8]) -> Option<SourceKind> {
|
||||
(lower.ends_with(".txt") || lower.ends_with(".text")).then_some(SourceKind::Text)
|
||||
}
|
||||
|
||||
/// Detect the image format from its bytes; a TIFF may hold several pages.
|
||||
fn embed(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
|
||||
/// Detect the image format from its bytes; a TIFF may hold several pages, of which the first
|
||||
/// `wanted` are read.
|
||||
fn embed(name: &str, bytes: &[u8], wanted: usize) -> Result<Vec<Embedded>, CreateError> {
|
||||
if bytes.starts_with(&[0xFF, 0xD8]) {
|
||||
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) {
|
||||
tiff_pages(name, bytes)
|
||||
tiff_pages(name, bytes, wanted)
|
||||
} else if bytes.starts_with(b"BM") {
|
||||
Ok(vec![decoded(name, bytes, image::ImageFormat::Bmp)?])
|
||||
} else if is_gif(bytes) {
|
||||
@@ -548,7 +575,7 @@ fn embed(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
|
||||
/// Embed an image file (its first page, for TIFFs) as an image XObject in `doc`. Returns the
|
||||
/// object and the image's natural size in points (from its resolution).
|
||||
pub fn image_xobject(doc: &mut Document, name: &str, bytes: &[u8]) -> Result<(ObjRef, (f64, f64)), CreateError> {
|
||||
let img = embed(name, bytes)?.into_iter().next().ok_or_else(|| CreateError::Image(name.into(), "the file has no image".into()))?;
|
||||
let img = embed(name, bytes, 1)?.into_iter().next().ok_or_else(|| CreateError::Image(name.into(), "the file has no image".into()))?;
|
||||
let size = (img.px.0 as f64 * 72.0 / img.dpi.0, img.px.1 as f64 * 72.0 / img.dpi.1);
|
||||
Ok((add_image(doc, img, &mut Vec::new()), size))
|
||||
}
|
||||
@@ -580,7 +607,7 @@ pub fn from_images_with_resolution(images: &[(String, Vec<u8>)], resolution: Ima
|
||||
}
|
||||
let mut doc = Document::new_empty();
|
||||
let mut profiles = Vec::new();
|
||||
for img in images.iter().map(|(name, bytes)| embed(name, bytes)).collect::<Result<Vec<_>, _>>()?.into_iter().flatten() {
|
||||
for img in images.iter().map(|(name, bytes)| embed(name, bytes, usize::MAX)).collect::<Result<Vec<_>, _>>()?.into_iter().flatten() {
|
||||
let dpi = match resolution {
|
||||
ImageResolution::Embedded => img.dpi,
|
||||
ImageResolution::Dpi(dpi) => (dpi, dpi),
|
||||
|
||||
@@ -832,6 +832,15 @@ fn indexed_hival_resolves_indirect_values_and_stream_palettes() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_lookup_stream_has_a_size_cap() {
|
||||
// A lookup table needs 256 × 4 bytes at most; a 1 MiB one is refused, not decoded whole.
|
||||
let mut palette = vec![255, 0, 0, 0, 0, 255];
|
||||
palette.resize(1 << 20, 0);
|
||||
let (doc, image) = indexed_export_fixture(Object::Int(1), palette, true, false, 8, vec![0, 1, 0, 1]);
|
||||
assert!(crate::extract::image_file(&doc, image).unwrap_err().contains("lookup table"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_hival_boundary_values_keep_valid_palette_entries() {
|
||||
let palette: Vec<u8> = (0..=255u8).flat_map(|i| [i, 255 - i, 17]).collect();
|
||||
@@ -1197,3 +1206,47 @@ fn a_cyclic_indexed_colour_space_is_refused() {
|
||||
assert!(out.images.is_empty());
|
||||
assert_eq!(out.skipped.len(), 1, "{:?}", out.skipped);
|
||||
}
|
||||
|
||||
/// A TIFF of `pages` 64×64 gray pages, then (with `unreadable`) one whose samples Create can't read.
|
||||
fn gray_tiff(pages: usize, unreadable: bool) -> Vec<u8> {
|
||||
let mut tif = std::io::Cursor::new(Vec::new());
|
||||
let mut enc = tiff::encoder::TiffEncoder::new(&mut tif).unwrap();
|
||||
for _ in 0..pages {
|
||||
enc.write_image::<tiff::encoder::colortype::Gray8>(64, 64, &[128; 64 * 64]).unwrap();
|
||||
}
|
||||
if unreadable {
|
||||
enc.write_image::<tiff::encoder::colortype::RGB32Float>(1, 1, &[0.5; 3]).unwrap();
|
||||
}
|
||||
tif.into_inner()
|
||||
}
|
||||
|
||||
/// Many small IFDs can each decode to a large page: the pages a TIFF keeps in memory together
|
||||
/// are bounded, and embedding one image reads only its first page.
|
||||
#[test]
|
||||
fn tiff_pages_are_decoded_within_a_total_budget() {
|
||||
let tif = gray_tiff(3, false);
|
||||
assert!(matches!(tiff_pages_within("t.tif", &tif, usize::MAX, 10_000), Err(CreateError::Image(..))), "12 KiB of pages past a 10 KB budget");
|
||||
assert_eq!(tiff_pages_within("t.tif", &tif, usize::MAX, 20_000).unwrap().len(), 3);
|
||||
let tif = gray_tiff(1, true);
|
||||
let mut doc = Document::new_empty();
|
||||
assert!(image_xobject(&mut doc, "t.tif", &tif).is_ok(), "the unreadable last page is never decoded");
|
||||
}
|
||||
|
||||
/// A one-pixel GIF on a `w`×`h` logical screen: the decoder paints it on a canvas that size.
|
||||
fn gif_on_screen(w: u16, h: u16) -> Vec<u8> {
|
||||
let mut gif = b"GIF89a".to_vec();
|
||||
gif.extend_from_slice(&w.to_le_bytes());
|
||||
gif.extend_from_slice(&h.to_le_bytes());
|
||||
gif.extend_from_slice(&[0x80, 0, 0, 0xFF, 0xFF, 0xFF, 0, 0, 0]);
|
||||
gif.extend_from_slice(&[0x2C, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 2, 0x44, 1, 0, 0x3B]);
|
||||
gif
|
||||
}
|
||||
|
||||
/// The decoder's own limit lets a 74-byte GIF ask for a canvas of hundreds of MB, which the
|
||||
/// conversion then copies; a screen past [`MAX_GIF_PIXELS`] is refused before decoding.
|
||||
#[test]
|
||||
fn a_tiny_gif_on_a_huge_screen_is_refused() {
|
||||
assert!(matches!(from_images(&[("big.gif".into(), gif_on_screen(4200, 4100))]), Err(CreateError::Image(..))));
|
||||
let doc = reopen(&from_images(&[("small.gif".into(), gif_on_screen(40, 30))]).unwrap());
|
||||
assert_eq!(media(&pages(&doc)[0]), [0.0, 0.0, 40.0, 30.0]);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ const QUALITY: u8 = 92;
|
||||
/// Larger photos are embedded as they are: turning them would take hundreds of MB.
|
||||
const MAX_PIXELS: u64 = 64 << 20;
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many JPEGs [`turned`] handed to the decoder on this thread.
|
||||
static DECODES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// A JPEG whose EXIF Orientation is not "as stored" has its pixels turned (and re-encoded);
|
||||
/// everything else, and any JPEG this can't read, is returned unchanged.
|
||||
pub(crate) fn upright(bytes: &[u8]) -> Cow<'_, [u8]> {
|
||||
@@ -24,9 +30,11 @@ pub(crate) fn upright(bytes: &[u8]) -> Cow<'_, [u8]> {
|
||||
}
|
||||
|
||||
fn turned(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
if !bytes.starts_with(&[0xFF, 0xD8]) {
|
||||
if !bytes.starts_with(&[0xFF, 0xD8]) || extended_xmp_chunks(bytes) > MAX_EXTENDED_XMP_CHUNKS {
|
||||
return None;
|
||||
}
|
||||
#[cfg(test)]
|
||||
DECODES.with(|c| c.set(c.get().saturating_add(1)));
|
||||
let mut decoder = ImageReader::with_format(Cursor::new(bytes), ImageFormat::Jpeg).into_decoder().ok()?;
|
||||
let orientation = decoder.orientation().ok()?;
|
||||
let (w, h) = decoder.dimensions();
|
||||
@@ -53,6 +61,41 @@ fn turned(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Most Extended XMP chunks a JPEG handed to the decoder may carry (64 MB of metadata). The
|
||||
/// decoder re-sorts and rescans every chunk so far after each marker, quadratic in their number.
|
||||
const MAX_EXTENDED_XMP_CHUNKS: usize = 1024;
|
||||
|
||||
/// How many Extended XMP chunks (APP1 segments) come before the image data.
|
||||
fn extended_xmp_chunks(bytes: &[u8]) -> usize {
|
||||
const PREFIX: &[u8] = b"http://ns.adobe.com/xmp/extension/\0";
|
||||
let (mut i, mut chunks) = (2usize, 0usize);
|
||||
while let Some(&byte) = bytes.get(i) {
|
||||
// Stray bytes between segments, which the decoder skips too.
|
||||
if byte != 0xFF {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let Some(&marker) = bytes.get(i.saturating_add(1)) else { break };
|
||||
match marker {
|
||||
// Fill bytes before a marker.
|
||||
0xFF => i += 1,
|
||||
// Markers without a segment.
|
||||
0x00 | 0x01 | 0xD0..=0xD7 => i += 2,
|
||||
// The image data starts (or ends): no headers after it.
|
||||
0xDA | 0xD9 => break,
|
||||
_ => {
|
||||
let Some(&[hi, lo]) = bytes.get(i.saturating_add(2)..i.saturating_add(4)) else { break };
|
||||
let len = usize::from(u16::from_be_bytes([hi, lo]));
|
||||
if marker == 0xE1 && bytes.get(i.saturating_add(4)..).is_some_and(|seg| seg.starts_with(PREFIX)) {
|
||||
chunks += 1;
|
||||
}
|
||||
i = i.saturating_add(2).saturating_add(len.max(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
chunks
|
||||
}
|
||||
|
||||
/// The resolution in a JFIF header, if the file has one in inches or centimetres.
|
||||
fn jfif_density(bytes: &[u8]) -> Option<PixelDensity> {
|
||||
let mut i = 2usize;
|
||||
@@ -94,4 +137,26 @@ 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_jpeg_with_thousands_of_extended_xmp_chunks_is_not_decoded() {
|
||||
// Empty chunks of one never-complete extended packet: the decoder re-sorts and rescans every
|
||||
// chunk so far after each marker, quadratic in their number.
|
||||
let chunk = |n: usize| {
|
||||
let mut seg = b"http://ns.adobe.com/xmp/extension/\0".to_vec();
|
||||
seg.extend_from_slice(&[b'0'; 32]); // the packet's GUID
|
||||
seg.extend_from_slice(&1000u32.to_be_bytes()); // its full length
|
||||
seg.extend_from_slice(&0u32.to_be_bytes()); // this chunk's offset
|
||||
let mut out = vec![0xFF, 0xE1];
|
||||
out.extend_from_slice(&u16::try_from(seg.len() + 2).unwrap().to_be_bytes());
|
||||
out.extend_from_slice(&seg);
|
||||
out.repeat(n)
|
||||
};
|
||||
let photo = phone_jpeg(6, 144);
|
||||
let with = |n: usize| [&photo[..2], b"\0".as_slice(), &chunk(n), &photo[2..]].concat();
|
||||
DECODES.with(|c| c.set(0));
|
||||
assert!(matches!(upright(&with(4)), Cow::Owned(_)), "a few chunks are an ordinary photo");
|
||||
assert!(matches!(upright(&with(5_000)), Cow::Borrowed(_)));
|
||||
assert_eq!(DECODES.with(std::cell::Cell::get), 1, "only the ordinary photo reached the decoder");
|
||||
}
|
||||
}
|
||||
|
||||
+49
-17
@@ -138,9 +138,51 @@ pub struct SecurityHandler {
|
||||
key: Vec<u8>,
|
||||
auth: Auth,
|
||||
recovered_user: Option<Vec<u8>>,
|
||||
methods: Methods,
|
||||
}
|
||||
|
||||
/// The crypt filter methods of a handler, resolved once: a document controls both the number of
|
||||
/// filters and the number of strings, so a search of `/CF` per string would multiply them.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Methods {
|
||||
string: Method,
|
||||
stream: Method,
|
||||
embedded_file: Method,
|
||||
named: std::collections::HashMap<Vec<u8>, Method>,
|
||||
}
|
||||
|
||||
impl Methods {
|
||||
fn new(dict: &EncryptDict) -> Self {
|
||||
let mut named = std::collections::HashMap::with_capacity(dict.crypt_filters.len());
|
||||
for (name, method) in &dict.crypt_filters {
|
||||
#[cfg(test)]
|
||||
tests::FILTER_COMPARES.with(|c| c.set(c.get() + 1));
|
||||
// The first of two filters with one name wins, as a search would find it.
|
||||
named.entry(name.clone()).or_insert(*method);
|
||||
}
|
||||
let mut methods = Methods { string: Method::Identity, stream: Method::Identity, embedded_file: Method::Identity, named };
|
||||
methods.string = methods.get(&dict.str_f);
|
||||
methods.stream = methods.get(&dict.stm_f);
|
||||
methods.embedded_file = methods.get(if dict.ef_f.is_empty() { &dict.stm_f } else { &dict.ef_f });
|
||||
methods
|
||||
}
|
||||
|
||||
fn get(&self, name: &[u8]) -> Method {
|
||||
if name == b"Identity" || name.is_empty() {
|
||||
return Method::Identity;
|
||||
}
|
||||
#[cfg(test)]
|
||||
tests::FILTER_COMPARES.with(|c| c.set(c.get() + 1));
|
||||
self.named.get(name).copied().unwrap_or(Method::Identity)
|
||||
}
|
||||
}
|
||||
|
||||
impl SecurityHandler {
|
||||
fn new(dict: EncryptDict, key: Vec<u8>, auth: Auth, recovered_user: Option<Vec<u8>>) -> Self {
|
||||
let methods = Methods::new(&dict);
|
||||
Self { dict, key, auth, recovered_user, methods }
|
||||
}
|
||||
|
||||
/// Authenticate with `password` (tried as owner password, then user password). `None`
|
||||
/// tries the empty password, which opens documents that only have an owner password.
|
||||
pub fn open(dict: EncryptDict, id0: &[u8], password: Option<&str>) -> Result<Self, CryptError> {
|
||||
@@ -177,7 +219,7 @@ impl SecurityHandler {
|
||||
}
|
||||
r => return Err(CryptError::Unsupported(format!("revision {r}"))),
|
||||
};
|
||||
Ok(Self { dict, key, auth, recovered_user })
|
||||
Ok(Self::new(dict, key, auth, recovered_user))
|
||||
}
|
||||
|
||||
/// Upgrade to owner access with the owner (permissions) password, as when a restricted
|
||||
@@ -235,26 +277,16 @@ impl SecurityHandler {
|
||||
self.dict.encrypt_metadata
|
||||
}
|
||||
|
||||
fn method_named(&self, name: &[u8]) -> Method {
|
||||
if name == b"Identity" || name.is_empty() {
|
||||
return Method::Identity;
|
||||
}
|
||||
self.dict.crypt_filters.iter().find(|(n, _)| n == name).map(|(_, m)| *m).unwrap_or(Method::Identity)
|
||||
}
|
||||
|
||||
/// The method for strings or a kind of stream.
|
||||
fn method(&self, stream: Option<StreamKind>) -> Method {
|
||||
if self.dict.v < 4 {
|
||||
return Method::Rc4;
|
||||
}
|
||||
match stream {
|
||||
None => self.method_named(&self.dict.str_f),
|
||||
Some(StreamKind::Normal) => self.method_named(&self.dict.stm_f),
|
||||
Some(StreamKind::EmbeddedFile) => {
|
||||
let eff = if self.dict.ef_f.is_empty() { &self.dict.stm_f } else { &self.dict.ef_f };
|
||||
self.method_named(eff)
|
||||
}
|
||||
Some(StreamKind::Named(n)) => self.method_named(n),
|
||||
None => self.methods.string,
|
||||
Some(StreamKind::Normal) => self.methods.stream,
|
||||
Some(StreamKind::EmbeddedFile) => self.methods.embedded_file,
|
||||
Some(StreamKind::Named(n)) => self.methods.get(n),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,7 +674,7 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result<SecurityHandler, Cry
|
||||
d.o = o;
|
||||
let key = file_key_r4(&d, id0, &user);
|
||||
d.u = u_value_r4(&d, id0, &key);
|
||||
Ok(SecurityHandler { dict: d, key, auth: Auth::Owner, recovered_user: Some(user) })
|
||||
Ok(SecurityHandler::new(d, key, Auth::Owner, Some(user)))
|
||||
}
|
||||
Algorithm::Aes256 => {
|
||||
(d.v, d.r, d.length_bits) = (5, 6, 256);
|
||||
@@ -671,7 +703,7 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result<SecurityHandler, Cry
|
||||
perms[9..12].copy_from_slice(b"adb");
|
||||
perms[12..16].copy_from_slice(&expand(¶ms.seed, b"perms", 4));
|
||||
d.perms = aes_cbc_encrypt(&key, &[0; 16], &perms, false);
|
||||
Ok(SecurityHandler { dict: d, key, auth: Auth::Owner, recovered_user: None })
|
||||
Ok(SecurityHandler::new(d, key, Auth::Owner, None))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,32 @@ fn strings_and_streams_round_trip_and_are_scrambled() {
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Crypt filter names compared or looked up, on this thread.
|
||||
pub(crate) static FILTER_COMPARES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crypt_filters_are_looked_up_once_per_handler() {
|
||||
let plain = new(Algorithm::Aes128, "", "o", -1);
|
||||
let mut d = plain.dict().clone();
|
||||
let n = 5_000;
|
||||
let mut filters: Vec<_> = (0..n).map(|i| (format!("F{i}").into_bytes(), Method::Identity)).collect();
|
||||
filters.append(&mut d.crypt_filters);
|
||||
d.crypt_filters = filters;
|
||||
FILTER_COMPARES.with(|c| c.set(0));
|
||||
let h = SecurityHandler::open(d, ID, None).unwrap();
|
||||
let strings = 1_000;
|
||||
let enc = plain.encrypt_string(7, 0, b"secret");
|
||||
for _ in 0..strings {
|
||||
assert_eq!(h.decrypt_string(7, 0, &enc), b"secret");
|
||||
assert_eq!(h.decrypt_stream(7, 0, &plain.encrypt_stream(7, 0, b"s", StreamKind::Normal), StreamKind::Normal), b"s");
|
||||
}
|
||||
assert_eq!(h.decrypt_stream(7, 0, b"x", StreamKind::Named(b"F3")), b"x");
|
||||
let compares = FILTER_COMPARES.with(|c| c.get());
|
||||
assert!(compares <= 2 * n + 3 * strings, "{compares} filter name comparisons");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_crypt_filters_leave_data_alone() {
|
||||
let mut d = new(Algorithm::Aes256, "", "o", -1).dict().clone();
|
||||
|
||||
@@ -235,9 +235,17 @@ pub fn lines(t: &AddedText) -> Vec<String> {
|
||||
wrapped(t).into_iter().map(|(line, _)| line).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How often [`wrapped`] laid a text out on this thread, for the test that keeps it out of per-line loops.
|
||||
pub(crate) static WRAP_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// [`lines`], each with whether its paragraph runs right to left (its first strong character
|
||||
/// does).
|
||||
fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
|
||||
#[cfg(test)]
|
||||
WRAP_CALLS.with(|c| c.set(c.get().saturating_add(1)));
|
||||
let width = (t.rect[2] - t.rect[0]).max(t.size);
|
||||
let mut out = Vec::new();
|
||||
for para in t.text.split('\n') {
|
||||
@@ -780,7 +788,8 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing:
|
||||
let r = text_rect(t);
|
||||
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
|
||||
out.extend(format!("BT /{name} {} Tf {} {} {} rg\n", n(t.size), n(cr), n(cg), n(cb)).bytes());
|
||||
for (i, line) in lines(t).iter().enumerate() {
|
||||
let all = lines(t);
|
||||
for (i, line) in all.iter().enumerate() {
|
||||
let w = t.family.width(line, t.size, t.bold, t.italic);
|
||||
let x = match t.align {
|
||||
Align::Left | Align::Justify => r[0],
|
||||
@@ -790,7 +799,6 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing:
|
||||
// Baseline: 0.8 em below the line top.
|
||||
let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size;
|
||||
// Justified: word spacing makes every line but the last fill the box.
|
||||
let all = lines(t);
|
||||
let spaces = line.matches(' ').count();
|
||||
let tw =
|
||||
if t.align == Align::Justify && i + 1 < all.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
|
||||
@@ -1042,6 +1050,9 @@ fn write(doc: &mut Document, page: usize, c: &Content, obj: Option<ObjRef>) -> R
|
||||
}
|
||||
let taken = pres.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
|
||||
let (content, res) = draw(doc, c, view, &taken, obj.is_some())?;
|
||||
if obj.is_none() {
|
||||
crate::check_on_top(doc, p)?;
|
||||
}
|
||||
for (k, v) in res.iter() {
|
||||
let mut sub = pres.get(k).map(|s| doc.resolve(s)).and_then(|s| s.as_dict().cloned()).unwrap_or_default();
|
||||
for (n2, o) in v.as_dict().into_iter().flat_map(|d| d.iter()) {
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn page_background(doc: &Document, page: usize) -> Result<Option<Rgb>, EditE
|
||||
let crop = p.crop(doc);
|
||||
let resources = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let gstates = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default();
|
||||
let streams = crate::text::content_streams(doc, &p.dict);
|
||||
let streams = crate::text::content_streams(doc, &p.dict)?;
|
||||
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
|
||||
let ops = pieces.parse().ops;
|
||||
|
||||
|
||||
+62
-11
@@ -9,7 +9,7 @@
|
||||
//! form's field tree. An annotation whose appearance cannot be drawn is not flattened: it stays
|
||||
//! as it is, so no comment or field value is lost with nothing in its place.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
|
||||
@@ -129,7 +129,16 @@ fn undrawn_on(doc: &Document, all: &[pdfcraft_model::Page], pages: &[usize], whi
|
||||
None
|
||||
}
|
||||
|
||||
/// Runs [`flatten_in`] on a copy of `doc` (unchanged objects are shared) and keeps it only on
|
||||
/// success, so a page refused part way leaves the pages before it as they were.
|
||||
fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize, EditError> {
|
||||
let mut work = doc.clone();
|
||||
let drawn = flatten_in(&mut work, pages, which)?;
|
||||
*doc = work;
|
||||
Ok(drawn)
|
||||
}
|
||||
|
||||
fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize, EditError> {
|
||||
let all = page_list(doc);
|
||||
check(pages, all.len())?;
|
||||
// `?` in an appearance can still be fixed (a field's value, a comment's text are stored as
|
||||
@@ -295,6 +304,12 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
|
||||
Ok(drawn)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many field-tree nodes `prune_fields` visited on this thread.
|
||||
pub(crate) static KEEP_CALLS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Remove flattened widgets from the AcroForm field tree, and fields left without widgets. With
|
||||
/// `on_page`, also the widgets (fields without kids) that are on no page.
|
||||
fn prune_fields(doc: &mut Document, widgets: &HashSet<ObjRef>, on_page: Option<&HashSet<ObjRef>>) -> Result<(), EditError> {
|
||||
@@ -303,23 +318,59 @@ fn prune_fields(doc: &mut Document, widgets: &HashSet<ObjRef>, on_page: Option<&
|
||||
let af_ref = af.as_ref();
|
||||
let mut af_dict = doc.resolve(&af).as_dict().cloned().unwrap_or_default();
|
||||
let fields = af_dict.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default();
|
||||
// Returns whether the node survives.
|
||||
fn keep(doc: &mut Document, r: ObjRef, widgets: &HashSet<ObjRef>, on_page: Option<&HashSet<ObjRef>>, depth: usize) -> bool {
|
||||
if widgets.contains(&r) || depth > 64 {
|
||||
return depth > 64;
|
||||
// Returns whether the node survives, and whether that answer depends on the depth cutoff.
|
||||
// `known` holds each node's answer once it is visited, so a node listed twice is pruned once,
|
||||
// and one still being visited (a cycle) survives. An answer the cutoff decided records the depth
|
||||
// it was reached at, and a shallower path visits the node again: at most once per depth.
|
||||
fn keep(
|
||||
doc: &mut Document,
|
||||
r: ObjRef,
|
||||
widgets: &HashSet<ObjRef>,
|
||||
on_page: Option<&HashSet<ObjRef>>,
|
||||
known: &mut HashMap<ObjRef, (bool, Option<usize>)>,
|
||||
depth: usize,
|
||||
) -> (bool, bool) {
|
||||
#[cfg(test)]
|
||||
KEEP_CALLS.with(|c| c.set(c.get().saturating_add(1)));
|
||||
if widgets.contains(&r) {
|
||||
return (false, false);
|
||||
}
|
||||
let Some(d) = doc.get(r).as_dict().cloned() else { return true };
|
||||
if depth > 64 {
|
||||
return (true, true);
|
||||
}
|
||||
match known.get(&r) {
|
||||
Some(&(survives, None)) => return (survives, false),
|
||||
Some(&(survives, Some(at))) if depth >= at => return (survives, true),
|
||||
_ => {}
|
||||
}
|
||||
known.insert(r, (true, None));
|
||||
let Some(d) = doc.get(r).as_dict().cloned() else { return (true, false) };
|
||||
let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) else {
|
||||
return on_page.is_none_or(|p| p.contains(&r));
|
||||
let survives = on_page.is_none_or(|p| p.contains(&r));
|
||||
known.insert(r, (survives, None));
|
||||
return (survives, false);
|
||||
};
|
||||
let survivors: Vec<Object> =
|
||||
kids.iter().filter(|k| k.as_ref().is_none_or(|kr| keep(doc, kr, widgets, on_page, depth + 1))).cloned().collect();
|
||||
let mut cut = false;
|
||||
let survivors: Vec<Object> = kids
|
||||
.iter()
|
||||
.filter(|k| {
|
||||
k.as_ref().is_none_or(|kr| {
|
||||
let (survives, kid_cut) = keep(doc, kr, widgets, on_page, known, depth + 1);
|
||||
cut |= kid_cut;
|
||||
survives
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
if survivors.len() != kids.len() {
|
||||
let _ = doc.update_dict(r, |d| d.set(b"Kids".to_vec(), Object::Array(survivors.clone())));
|
||||
}
|
||||
!survivors.is_empty()
|
||||
known.insert(r, (!survivors.is_empty(), cut.then_some(depth)));
|
||||
(!survivors.is_empty(), cut)
|
||||
}
|
||||
let survivors: Vec<Object> = fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, on_page, 0))).cloned().collect();
|
||||
let mut known = HashMap::new();
|
||||
let survivors: Vec<Object> =
|
||||
fields.iter().filter(|f| f.as_ref().is_none_or(|r| keep(doc, r, widgets, on_page, &mut known, 0).0)).cloned().collect();
|
||||
if survivors.len() == fields.len() && widgets.is_empty() {
|
||||
return Ok(()); // only looked for fields on no page, and there were none
|
||||
}
|
||||
|
||||
+20
-23
@@ -9,6 +9,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::EditError;
|
||||
use crate::text::content_streams;
|
||||
|
||||
/// An image drawn on a page.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -73,23 +74,12 @@ struct ClipPath {
|
||||
even_odd: bool,
|
||||
}
|
||||
|
||||
fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
|
||||
let list: Vec<Object> = match page.get(b"Contents") {
|
||||
None => Vec::new(),
|
||||
Some(c) => match &*doc.resolve(c) {
|
||||
Object::Array(a) => a.clone(),
|
||||
_ => vec![c.clone()],
|
||||
},
|
||||
};
|
||||
list.into_iter()
|
||||
.map(|o| {
|
||||
let data = match &*doc.resolve(&o) {
|
||||
Object::Stream(s) => s.decoded().unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
(o, data)
|
||||
})
|
||||
.collect()
|
||||
/// Image draws one page's inventory lists at most: a form drawn many times from other forms
|
||||
/// would otherwise multiply a few hundred bytes into millions of records.
|
||||
pub(crate) const MAX_PAGE_IMAGES: usize = 100_000;
|
||||
|
||||
fn too_many_images() -> EditError {
|
||||
EditError::Invalid(format!("the page draws more than {MAX_PAGE_IMAGES} images"))
|
||||
}
|
||||
|
||||
/// The page's streams joined into the one stream they are: an operator may span two of them.
|
||||
@@ -237,7 +227,7 @@ pub fn page_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditEr
|
||||
let mut out = Vec::new();
|
||||
// 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());
|
||||
let pieces = joined(&streams(doc, &p.dict));
|
||||
let pieces = joined(&content_streams(doc, &p.dict)?);
|
||||
for (i, op) in pieces.parse().ops.iter().enumerate() {
|
||||
state.apply(op)?;
|
||||
let ctm = state.placement.matrix;
|
||||
@@ -285,6 +275,9 @@ pub fn page_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditEr
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if out.len() >= MAX_PAGE_IMAGES {
|
||||
return Err(too_many_images());
|
||||
}
|
||||
out.push(PageImage {
|
||||
rect,
|
||||
visible_rect: intersection(rect, state.placement.clip),
|
||||
@@ -358,6 +351,9 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
|
||||
if s.dict.name(b"Subtype") != Some(b"Image") {
|
||||
continue;
|
||||
}
|
||||
if out.len() >= MAX_PAGE_IMAGES {
|
||||
return Err(too_many_images());
|
||||
}
|
||||
out.push(PageImage {
|
||||
rect: ctm.bbox([0.0, 0.0, 1.0, 1.0]),
|
||||
visible_rect: intersection(ctm.bbox([0.0, 0.0, 1.0, 1.0]), st.placement.clip),
|
||||
@@ -381,8 +377,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(), budget: crate::text::FormBudget::default() };
|
||||
let pieces = joined(&streams(doc, &p.dict));
|
||||
let mut decode = pdfcraft_cos::DecodeBudget::page();
|
||||
let pieces = joined(&crate::text::content_streams_within(doc, &p.dict, &mut decode)?);
|
||||
let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::new(decode) };
|
||||
walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
@@ -483,7 +480,7 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
|
||||
let images = page_images(doc, page)?;
|
||||
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 all = content_streams(doc, &p.dict)?;
|
||||
let pieces = joined(&all);
|
||||
let ops = pieces.parse().ops;
|
||||
if !ops.get(img.op).is_some_and(|op| op.is("Do")) {
|
||||
@@ -531,7 +528,7 @@ pub(crate) fn selectable_images(
|
||||
excluded: &std::collections::BTreeSet<ObjRef>,
|
||||
) -> Result<Vec<(usize, PageImage)>, EditError> {
|
||||
let p = page_of(doc, page)?;
|
||||
let all = streams(doc, &p.dict);
|
||||
let all = content_streams(doc, &p.dict)?;
|
||||
Ok(page_images(doc, page)?
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
@@ -755,7 +752,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
|
||||
}
|
||||
let images = page_images(doc, page)?;
|
||||
let p = page_of(doc, page)?;
|
||||
let all = streams(doc, &p.dict);
|
||||
let all = content_streams(doc, &p.dict)?;
|
||||
let mut plans = std::collections::BTreeMap::new();
|
||||
for index in indexes {
|
||||
let image = images.get(*index).ok_or_else(|| EditError::Invalid("a selected image no longer exists".into()))?;
|
||||
|
||||
+42
-5
@@ -492,6 +492,9 @@ enum Isolation {
|
||||
/// 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 },
|
||||
/// Marked content nests past [`ISOLATION_MAX_DEPTH`]: how many sequences a mark on top would
|
||||
/// have to close (some may be hidden optional content) is unknown.
|
||||
TooDeep,
|
||||
}
|
||||
|
||||
const ISOLATION_MAX_STREAMS: usize = 100_000;
|
||||
@@ -591,7 +594,10 @@ fn isolation(doc: &Document, list: &[Object]) -> Isolation {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if malformed || text_open || path_open || !stack.is_empty() || !marked_known {
|
||||
if !marked_known {
|
||||
return Isolation::TooDeep;
|
||||
}
|
||||
if malformed || text_open || path_open || !stack.is_empty() {
|
||||
return Isolation::Unknown { unclosed: if malformed { 0 } else { stack.len() }, marked };
|
||||
}
|
||||
let comment_open = edges.last().is_some_and(|edge| edge.1);
|
||||
@@ -608,8 +614,8 @@ fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, conten
|
||||
/// 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.
|
||||
/// Returns the new content stream.
|
||||
/// sequences are closed at the end too, so hidden optional content cannot hide the new mark; when
|
||||
/// their number is unknown, nothing is placed on top. Returns the new content stream.
|
||||
fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec<u8>, behind: bool) -> Result<ObjRef, EditError> {
|
||||
let mut list = contents(doc, page)?;
|
||||
// The behind-marks at the start: backgrounds in the order added, then watermarks.
|
||||
@@ -624,6 +630,8 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont
|
||||
// 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 } => (behind_marks < list.len()).then_some((behind_marks, unclosed, marked)),
|
||||
// Content left inside an optional-content sequence it cannot close could be hidden.
|
||||
Isolation::TooDeep => return Err(too_deep_on_top()),
|
||||
}
|
||||
};
|
||||
let mark_ref = doc.add(Object::Stream(Stream::flate(tagged(tag), &content)));
|
||||
@@ -651,6 +659,26 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont
|
||||
Ok(mark_ref)
|
||||
}
|
||||
|
||||
fn too_deep_on_top() -> EditError {
|
||||
EditError::Invalid("the page's marked content is nested too deeply to add anything on top of it".into())
|
||||
}
|
||||
|
||||
/// The refusal [`place_tagged`] gives for a mark on top of `page`, checked before an edit changes
|
||||
/// anything (removing old marks, adding resources) so a refused edit leaves the document as it was.
|
||||
fn check_on_top(doc: &Document, page: &pdfcraft_model::Page) -> Result<(), EditError> {
|
||||
let obj = doc.get(page.obj);
|
||||
let list = match obj.as_dict().and_then(|d| d.get(b"Contents")) {
|
||||
None => return Ok(()),
|
||||
Some(Object::Ref(r)) => doc.get(*r).as_array().cloned().unwrap_or_else(|| vec![Object::Ref(*r)]),
|
||||
Some(Object::Array(a)) => a.clone(),
|
||||
Some(other) => vec![other.clone()],
|
||||
};
|
||||
match isolation(doc, &list) {
|
||||
Isolation::TooDeep => Err(too_deep_on_top()),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Put content tagged `tag` first inside the q/Q wrapper, so it applies to the page's original
|
||||
/// content and nothing else (a header's shrink). A page without a wrapper has no original content
|
||||
/// on it and is left alone.
|
||||
@@ -670,6 +698,7 @@ fn place_inside_wrapper(doc: &mut Document, page: &pdfcraft_model::Page, tag: &s
|
||||
pub fn stamp(doc: &mut Document, page: usize, tag: &str, content: Vec<u8>) -> Result<ObjRef, EditError> {
|
||||
let all = page_list(doc);
|
||||
check(&[page], all.len())?;
|
||||
check_on_top(doc, &all[page])?;
|
||||
add_resources(doc, &all[page], None, Some(&content))?;
|
||||
let p = page_list(doc).swap_remove(page);
|
||||
place_tagged(doc, &p, tag, content, false)
|
||||
@@ -749,6 +778,9 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter,
|
||||
.iter()
|
||||
.map(|&i| hf.shrink.then(|| shrink_matrix(hf, all[i].display_size(doc), all[i].view_matrix(doc))).transpose())
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
for &i in pages {
|
||||
check_on_top(doc, &all[i])?;
|
||||
}
|
||||
if replace {
|
||||
remove_marks(doc, pages, MarkKind::HeaderFooter)?;
|
||||
}
|
||||
@@ -837,6 +869,11 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac
|
||||
{
|
||||
return Err(EditError::Invalid("invalid watermark settings".into()));
|
||||
}
|
||||
if !wm.behind {
|
||||
for &i in pages {
|
||||
check_on_top(doc, &all[i])?;
|
||||
}
|
||||
}
|
||||
if replace {
|
||||
remove_marks(doc, pages, MarkKind::Watermark)?;
|
||||
}
|
||||
@@ -941,7 +978,7 @@ pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Resu
|
||||
if before != kept.len() {
|
||||
let wrappers = match isolation(doc, &kept) {
|
||||
Isolation::Clean { wrappers, .. } => wrappers,
|
||||
Isolation::Unknown { .. } => {
|
||||
Isolation::Unknown { .. } | Isolation::TooDeep => {
|
||||
let mut opens = Vec::new();
|
||||
let mut pairs = Vec::new();
|
||||
for (i, o) in kept.iter().enumerate() {
|
||||
@@ -997,7 +1034,7 @@ mod linebreak;
|
||||
pub mod text;
|
||||
pub use text::{
|
||||
BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping,
|
||||
clear_block, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines,
|
||||
clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines,
|
||||
};
|
||||
pub mod added;
|
||||
pub use added::{
|
||||
|
||||
@@ -96,7 +96,7 @@ fn recognizes_only_canonical_new_closes_and_retains_legacy_closes() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depth_budget_bounds_emitted_closures_and_overflow_stays_unknown() {
|
||||
fn depth_budget_bounds_emitted_closures_and_overflow_is_refused_on_top() {
|
||||
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);
|
||||
@@ -114,9 +114,46 @@ fn depth_budget_bounds_emitted_closures_and_overflow_stays_unknown() {
|
||||
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");
|
||||
// Past the budget the depth is unknown, so nothing can close the page's (possibly hidden)
|
||||
// optional content before a mark on top: placing one there is refused, and the page is left
|
||||
// as it was. Behind the content stays possible.
|
||||
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");
|
||||
let mut doc = fixture(&[&over]);
|
||||
let page = page_list(&doc).remove(0);
|
||||
let original = contents(&mut doc, &page).unwrap();
|
||||
assert!(
|
||||
matches!(stamp(&mut doc, 0, "Probe", b"q Q\n".to_vec()), Err(EditError::Invalid(_))),
|
||||
"overflow cannot resume speculative depth tracking after EMC"
|
||||
);
|
||||
assert_eq!(contents(&mut doc, &page).unwrap(), original);
|
||||
place_tagged(&mut doc, &page, MarkKind::Background.tag(), b"q Q\n".to_vec(), true).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks refused on top of content nested past the depth budget leave the page as it was: old
|
||||
/// marks are not removed and no resources are added before the refusal.
|
||||
#[test]
|
||||
fn a_mark_refused_on_top_changes_nothing() {
|
||||
let mut over = b"/Artifact BMC\n".repeat(ISOLATION_MAX_DEPTH);
|
||||
over.extend_from_slice(b"/Artifact BMC\n");
|
||||
let mut doc = fixture(&[&over]);
|
||||
let behind = Watermark { text: "OLD".into(), behind: true, ..Watermark::default() };
|
||||
add_watermark(&mut doc, &[0], &behind, false).unwrap();
|
||||
let page = page_list(&doc).remove(0).obj;
|
||||
let before = (*doc.get(page)).clone();
|
||||
let on_top = Watermark { text: "NEW".into(), ..Watermark::default() };
|
||||
assert!(matches!(add_watermark(&mut doc, &[0], &on_top, true), Err(EditError::Invalid(_))));
|
||||
assert_eq!((*doc.get(page)).clone(), before, "the old watermark stays");
|
||||
let hf = HeaderFooter {
|
||||
text: [String::new(), "Top".into(), String::new(), String::new(), String::new(), String::new()],
|
||||
font: added::Family::Times,
|
||||
..HeaderFooter::default()
|
||||
};
|
||||
assert!(matches!(add_header_footer(&mut doc, &[0], &hf, true, &Context::default()), Err(EditError::Invalid(_))));
|
||||
assert_eq!((*doc.get(page)).clone(), before, "no fonts are added");
|
||||
let mut stamped = doc.clone();
|
||||
assert!(matches!(stamp(&mut stamped, 0, "Probe", b"/PCTimes 9 Tf\n".to_vec()), Err(EditError::Invalid(_))));
|
||||
assert_eq!((*stamped.get(page)).clone(), before, "no fonts are added");
|
||||
}
|
||||
|
||||
@@ -6355,3 +6355,219 @@ fn reading_keeps_dotted_leader_entries_whole_and_in_order() {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn added_text_is_wrapped_once_per_write() {
|
||||
let mut doc = fixture();
|
||||
let lines: Vec<String> = (0..40).map(|i| format!("line {i}")).collect();
|
||||
let text = AddedText { rect: [72.0, 100.0, 300.0, 700.0], text: lines.join("\n"), size: 10.0, align: Align::Justify, ..AddedText::default() };
|
||||
added::WRAP_CALLS.with(|c| c.set(0));
|
||||
add_content(&mut doc, 0, &Content::Text(text)).unwrap();
|
||||
let calls = added::WRAP_CALLS.with(std::cell::Cell::get);
|
||||
assert!(calls <= 4, "the text was wrapped {calls} times for 40 lines");
|
||||
}
|
||||
|
||||
/// A field tree whose every node lists its child twice is pruned in work linear in its nodes, not
|
||||
/// in its paths, and a node listing itself is visited once.
|
||||
#[test]
|
||||
fn flattening_prunes_a_field_tree_with_repeated_kids_once_per_node() {
|
||||
let levels = 16;
|
||||
let page = stream("", " ");
|
||||
// 5: the flattened widget; 6…: the chain; then the hidden widget at its end, on the page.
|
||||
let leaf = 6 + levels;
|
||||
let fields = format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R {} 0 R] >> >>", leaf + 2);
|
||||
let annots = format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R {leaf} 0 R] >>");
|
||||
let mut objs: Vec<String> = vec![
|
||||
fields,
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
annots,
|
||||
page,
|
||||
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (shown) /Rect [10 10 150 40] /F 4 /AP << /N {} 0 R >> >>", leaf + 1),
|
||||
];
|
||||
for i in 0..levels {
|
||||
let next = 7 + i;
|
||||
objs.push(format!("<< /T (n{i}) /Kids [{next} 0 R {next} 0 R] >>"));
|
||||
}
|
||||
objs.push("<< /Type /Annot /Subtype /Widget /FT /Tx /T (hidden) /Rect [10 90 150 120] /F 2 >>".into());
|
||||
objs.push("<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream".into());
|
||||
objs.push(format!("<< /T (loop) /Kids [{} 0 R] >>", leaf + 2));
|
||||
let objs: Vec<&str> = objs.iter().map(String::as_str).collect();
|
||||
let mut doc = build(&objs);
|
||||
flatten::KEEP_CALLS.with(|c| c.set(0));
|
||||
assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1);
|
||||
let calls = flatten::KEEP_CALLS.with(std::cell::Cell::get);
|
||||
assert!(calls <= 2 * (levels + 4), "{calls} field-tree visits");
|
||||
let root = doc.root().unwrap();
|
||||
let af = doc.resolve(doc.get(root).as_dict().unwrap().get(b"AcroForm").unwrap());
|
||||
let left = doc.resolve(af.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().len();
|
||||
assert_eq!(left, 2, "the chain to the hidden widget and the self-listing node stay");
|
||||
}
|
||||
|
||||
/// A field reached first at the end of a long chain has its kids cut off by the depth limit, so
|
||||
/// they survive there. A shorter path to the same field still looks at its kids: an off-page widget
|
||||
/// under it is taken out when every field is flattened.
|
||||
#[test]
|
||||
fn a_field_reached_again_by_a_shorter_path_is_pruned_there() {
|
||||
let chain = 64;
|
||||
let page = stream("", " ");
|
||||
// 7…: the chain; then the shared field, its off-page widget, and the short path to it.
|
||||
let shared = 7 + chain;
|
||||
let fields = format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 7 0 R {} 0 R] >> >>", shared + 2);
|
||||
let mut objs: Vec<String> = vec![
|
||||
fields,
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>".into(),
|
||||
page,
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (shown) /Rect [10 10 150 40] /F 4 /AP << /N 6 0 R >> >>".into(),
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream".into(),
|
||||
];
|
||||
for i in 0..chain {
|
||||
objs.push(format!("<< /T (c{i}) /Kids [{} 0 R] >>", 8 + i));
|
||||
}
|
||||
objs.push(format!("<< /T (shared) /Kids [{} 0 R] >>", shared + 1));
|
||||
objs.push("<< /Type /Annot /Subtype /Widget /FT /Tx /T (orphan) /Rect [10 50 150 80] /F 4 >>".into());
|
||||
objs.push(format!("<< /T (short) /Kids [{shared} 0 R] >>"));
|
||||
let objs: Vec<&str> = objs.iter().map(String::as_str).collect();
|
||||
let mut doc = build(&objs);
|
||||
assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1);
|
||||
let shared_ref = pdfcraft_cos::ObjRef::new(u32::try_from(shared).unwrap(), 0);
|
||||
let kids = doc.resolve(doc.get(shared_ref).as_dict().unwrap().get(b"Kids").unwrap()).as_array().unwrap().len();
|
||||
assert_eq!(kids, 0, "the off-page widget is pruned along the short path");
|
||||
}
|
||||
|
||||
/// A ToUnicode destination is repeated for every glyph that shows its code: a page whose text
|
||||
/// would run past the extraction budget is refused, not built in memory.
|
||||
#[test]
|
||||
fn page_text_past_the_budget_is_refused() {
|
||||
let cmap = stream("", &format!("1 beginbfchar <41> <{}> endbfchar", "0041".repeat(256)));
|
||||
let page = |shown: &str| {
|
||||
let content = stream("", &format!("BT /F1 12 Tf 10 10 Td ({shown}) Tj ET"));
|
||||
build(&[
|
||||
"<< /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] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
|
||||
&content,
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /ToUnicode 6 0 R >>",
|
||||
&cmap,
|
||||
])
|
||||
};
|
||||
let doc = page(&"A".repeat(100_000));
|
||||
assert!(matches!(text_lines(&doc, 0), Err(EditError::Invalid(_))), "25 MB of text from a 100 kB page");
|
||||
let doc = page("AA");
|
||||
assert_eq!(text_lines(&doc, 0).unwrap()[0].text.len(), 512, "a page within the budget still reads");
|
||||
}
|
||||
|
||||
/// A one-page document from the page's /Contents and /Resources, built in code.
|
||||
fn page_with(build: impl FnOnce(&mut Document) -> (Object, Dict)) -> Document {
|
||||
let mut doc = Document::new_empty();
|
||||
let pages = doc.add(Object::Null);
|
||||
let (contents, resources) = build(&mut doc);
|
||||
let page: Dict = [
|
||||
(b"Type".to_vec(), Object::Name(b"Page".to_vec())),
|
||||
(b"Parent".to_vec(), Object::Ref(pages)),
|
||||
(b"MediaBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::from).to_vec())),
|
||||
(b"Contents".to_vec(), contents),
|
||||
(b"Resources".to_vec(), Object::Dict(resources)),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let page = doc.add(page);
|
||||
let tree: Dict = [
|
||||
(b"Type".to_vec(), Object::Name(b"Pages".to_vec())),
|
||||
(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])),
|
||||
(b"Count".to_vec(), Object::Int(1)),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
doc.set(pages, Object::Dict(tree));
|
||||
let catalog: Dict = [(b"Type".to_vec(), Object::Name(b"Catalog".to_vec())), (b"Pages".to_vec(), Object::Ref(pages))].into_iter().collect();
|
||||
let catalog = doc.add(catalog);
|
||||
doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog));
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_content_references_share_one_decode_budget() {
|
||||
// One 1 MiB stream (a kilobyte on disk) named 300 times by the page's /Contents.
|
||||
let doc = page_with(|doc| {
|
||||
let spaces = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
|
||||
(Object::Array(vec![Object::Ref(spaces); 300]), Dict::new())
|
||||
});
|
||||
match text_lines(&doc, 0) {
|
||||
Ok(lines) => panic!("read {} lines from more content than a page may decode", lines.len()),
|
||||
Err(e) => assert!(matches!(e, EditError::Invalid(_)), "{e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_images_stops_at_the_page_record_limit() {
|
||||
// 20 draws of a form drawing a form 20 times that draws an image 20 times: 8,000 images
|
||||
// per level-two form, 160,000 in all, from a few hundred bytes.
|
||||
let doc = page_with(|doc| {
|
||||
let image: Dict = [
|
||||
(b"Subtype".to_vec(), Object::Name(b"Image".to_vec())),
|
||||
(b"Width".to_vec(), Object::Int(1)),
|
||||
(b"Height".to_vec(), Object::Int(1)),
|
||||
(b"ColorSpace".to_vec(), Object::Name(b"DeviceGray".to_vec())),
|
||||
(b"BitsPerComponent".to_vec(), Object::Int(8)),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let mut inner = doc.add(Stream::from_raw(image, vec![0]));
|
||||
let draws = "/X Do ".repeat(20);
|
||||
let resources = |inner: pdfcraft_cos::ObjRef| -> Dict {
|
||||
let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect();
|
||||
[(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect()
|
||||
};
|
||||
for _ in 0..3 {
|
||||
let mut d = resources(inner);
|
||||
d = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec())), (b"Resources".to_vec(), Object::Dict(d))].into_iter().collect();
|
||||
inner = doc.add(Stream::from_raw(d, draws.clone().into_bytes()));
|
||||
}
|
||||
let page = doc.add(Stream::from_raw(Dict::new(), draws.into_bytes()));
|
||||
(Object::Ref(page), resources(inner))
|
||||
});
|
||||
if let Ok(images) = crate::reading_images(&doc, 0) {
|
||||
assert!(images.len() <= crate::images::MAX_PAGE_IMAGES, "{} images", images.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_spends_one_decode_budget_on_content_and_forms() {
|
||||
// 130 MiB of content and a 130 MiB form (a kilobyte each on disk): each fits a page's budget,
|
||||
// together they don't, so reading leaves the form out and says so.
|
||||
let doc = page_with(|doc| {
|
||||
let mut body = b"%".to_vec();
|
||||
body.resize(130 << 20, b'x');
|
||||
let form: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect();
|
||||
let form = doc.add(Stream::flate(form, &body));
|
||||
let mut content = b"/X Do\n%".to_vec();
|
||||
content.resize(130 << 20, b'x');
|
||||
let content = doc.add(Stream::flate(Dict::new(), &content));
|
||||
let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect();
|
||||
(Object::Ref(content), [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect())
|
||||
});
|
||||
assert!(crate::text::reading_blocks_checked(&doc, 0).unwrap().truncated);
|
||||
}
|
||||
|
||||
/// A page that can't take flattened content on top (marked content nested past the depth budget)
|
||||
/// refuses the whole flatten: the pages before it keep their comments and content.
|
||||
#[test]
|
||||
fn a_flatten_refused_on_a_later_page_changes_nothing() {
|
||||
let deep = stream("", &"/Artifact BMC\n".repeat(10_001));
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>",
|
||||
&stream("", " "),
|
||||
"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /F 4 /AP << /N 9 0 R >> >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 7 0 R /Annots [8 0 R] >>",
|
||||
&deep,
|
||||
"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /F 4 /AP << /N 9 0 R >> >>",
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 0 >>\nstream\n\nendstream",
|
||||
]);
|
||||
let first = pdfcraft_cos::ObjRef::new(3, 0);
|
||||
let before = (*doc.get(first)).clone();
|
||||
assert!(matches!(flatten(&mut doc, &[0, 1], true, false), Err(EditError::Invalid(_))));
|
||||
assert_eq!((*doc.get(first)).clone(), before, "the first page keeps its comment and content");
|
||||
}
|
||||
|
||||
+105
-47
@@ -14,7 +14,7 @@ use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops};
|
||||
use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object, PdfString, Stream};
|
||||
use pdfcraft_fonts::pdf::Metrics;
|
||||
use pdfcraft_fonts::{CraftFont, GlyphError, japanese_glyph_from};
|
||||
|
||||
@@ -113,10 +113,12 @@ pub fn text_editor_fonts(doc: &Document, page: usize) -> Result<Vec<TextEditorFo
|
||||
let resources = p.dict.get(b"Resources").and_then(|o| doc.dict(o)).unwrap_or_default();
|
||||
let fonts = resources.get(b"Font").and_then(|o| doc.dict(o)).unwrap_or_default();
|
||||
let mut budget = 32_usize << 20;
|
||||
// Every program decoded counts, usable or not, so aliases of one program can't repeat the work.
|
||||
let mut decode = DecodeBudget::new(2 * budget, 0);
|
||||
let mut result = Vec::new();
|
||||
for (name, font) in fonts.iter().take(128) {
|
||||
let Some(font) = doc.dict(font) else { continue };
|
||||
let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font) else { continue };
|
||||
let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt_within(doc, &font, &mut decode) else { continue };
|
||||
if bytes.len() > budget {
|
||||
continue;
|
||||
}
|
||||
@@ -528,12 +530,13 @@ impl TextLine {
|
||||
self.origin.baseline
|
||||
}
|
||||
|
||||
/// Paint position of the line's last text-showing operator: `(content-stream index,
|
||||
/// operator index in the page's joined stream)`. Lines paint in order of this tuple.
|
||||
/// Paint position of the line's first operator: `(content-stream index, operator index in
|
||||
/// the page's joined stream)`. A line joins runs painted at different times, so anything
|
||||
/// painted after this position may lie on top of part of it.
|
||||
/// (Lines from `text_lines` never come from form XObjects, so `stream` is always a
|
||||
/// page content-stream index and `ops` are indices into the joined operator list.)
|
||||
pub fn paint_pos(&self) -> (usize, usize) {
|
||||
(self.stream, self.ops.iter().max().copied().unwrap_or(0))
|
||||
pub fn first_paint_pos(&self) -> (usize, usize) {
|
||||
(self.stream, self.ops.iter().min().copied().unwrap_or(0))
|
||||
}
|
||||
|
||||
fn rotation(&self) -> f64 {
|
||||
@@ -615,7 +618,16 @@ struct Ts {
|
||||
unreadable: Option<Rc<str>>,
|
||||
}
|
||||
|
||||
pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
|
||||
/// The page's content streams as listed, each with its decoded data (empty when it doesn't
|
||||
/// decode), within one page's [`DecodeBudget`]. An error when the content runs past it: reading
|
||||
/// or rewriting a page from part of its content would lose the rest.
|
||||
pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Result<Vec<(Object, Vec<u8>)>, EditError> {
|
||||
content_streams_within(doc, page, &mut DecodeBudget::page())
|
||||
}
|
||||
|
||||
/// [`content_streams`], decoded against `budget`, which a walk of the page's forms then goes on
|
||||
/// spending.
|
||||
pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Result<Vec<(Object, Vec<u8>)>, EditError> {
|
||||
let list: Vec<Object> = match page.get(b"Contents") {
|
||||
None => Vec::new(),
|
||||
Some(c) => match &*doc.resolve(c) {
|
||||
@@ -623,15 +635,19 @@ pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u
|
||||
_ => vec![c.clone()],
|
||||
},
|
||||
};
|
||||
list.into_iter()
|
||||
.map(|o| {
|
||||
let data = match &*doc.resolve(&o) {
|
||||
Object::Stream(s) => s.decoded().unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
(o, data)
|
||||
})
|
||||
.collect()
|
||||
let mut out = Vec::with_capacity(list.len());
|
||||
for o in list {
|
||||
let data = match &*doc.resolve(&o) {
|
||||
Object::Stream(s) => match budget.decode(s) {
|
||||
Ok(data) => data,
|
||||
Err(e) if budget.remaining() == 0 => return Err(EditError::Invalid(format!("the page content is too large to read: {e}"))),
|
||||
Err(_) => Vec::new(),
|
||||
},
|
||||
_ => Vec::new(),
|
||||
};
|
||||
out.push((o, data));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Rewrite a page's content (its streams joined, see [`Pieces`]): `edit(i)` gives the operators
|
||||
@@ -887,8 +903,15 @@ struct Carry {
|
||||
marked: Vec<Option<usize>>,
|
||||
/// For each open marked-content sequence, its optional content group when it is `/OC`.
|
||||
layers: Vec<Option<Vec<u8>>>,
|
||||
/// Bytes of glyph text the rest of the page may still produce (see [`MAX_PAGE_TEXT`]).
|
||||
text_left: usize,
|
||||
}
|
||||
|
||||
/// The most glyph text one page's extraction produces, in bytes. A ToUnicode destination is
|
||||
/// copied for every glyph showing its code, so a small page can otherwise expand into gigabytes;
|
||||
/// real pages hold far less (a whole novel is about 1 MB).
|
||||
const MAX_PAGE_TEXT: usize = 16 << 20;
|
||||
|
||||
impl Carry {
|
||||
fn new() -> Self {
|
||||
let ts = Ts {
|
||||
@@ -907,7 +930,7 @@ impl Carry {
|
||||
vertical: None,
|
||||
unreadable: None,
|
||||
};
|
||||
Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new() }
|
||||
Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: MAX_PAGE_TEXT }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1119,7 +1142,13 @@ fn interpret(
|
||||
let blank =
|
||||
m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace));
|
||||
match shown {
|
||||
Some(t) => text.push_str(&t),
|
||||
Some(t) => {
|
||||
carry.text_left = carry
|
||||
.text_left
|
||||
.checked_sub(t.len())
|
||||
.ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?;
|
||||
text.push_str(&t);
|
||||
}
|
||||
None => decodable = false,
|
||||
}
|
||||
let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
|
||||
@@ -1277,17 +1306,29 @@ fn interpret(
|
||||
|
||||
/// The lines of text on a page (0-based), in content order.
|
||||
pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError> {
|
||||
let p = page_dict(doc, page)?;
|
||||
text_lines_in(doc, &page_dict(doc, page)?, true)
|
||||
}
|
||||
|
||||
/// The lines `page`'s glyphs show, in content order, without `/ActualText` replacements: what the
|
||||
/// page draws, not what its marked-content metadata says the glyphs mean. For checks that must
|
||||
/// not trust that metadata (the redaction audit). Takes the page itself, so a caller going
|
||||
/// through every page doesn't walk the page tree again for each.
|
||||
pub fn glyph_text_lines(doc: &Document, page: &pdfcraft_model::Page) -> Result<Vec<TextLine>, EditError> {
|
||||
text_lines_in(doc, page, false)
|
||||
}
|
||||
|
||||
/// [`text_lines`] of `p`; with `actual_text`, `/ActualText` replaces the glyphs it spans.
|
||||
fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> Result<Vec<TextLine>, EditError> {
|
||||
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let mut cache = HashMap::new();
|
||||
let mut lines: Vec<TextLine> = Vec::new();
|
||||
let mut carry = Carry::new();
|
||||
// The streams are one stream in pieces (§7.8.2), split between any two tokens: parse them
|
||||
// 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 pieces = Pieces::join(&content_streams(doc, &p.dict)?.into_iter().map(|(_, d)| d).collect::<Vec<_>>());
|
||||
let ops = pieces.parse().ops;
|
||||
let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default();
|
||||
let shown = interpret(doc, &ops, &res, &mut cache, &mut carry, None, Some(&properties))?;
|
||||
let shown = interpret(doc, &ops, &res, &mut cache, &mut carry, None, actual_text.then_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
|
||||
@@ -1490,15 +1531,23 @@ pub const MAX_FORM_DEPTH: usize = 50;
|
||||
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)]
|
||||
/// [`MAX_FORM_VISITS`]), the bytes the page's content and forms may still decode to, and whether
|
||||
/// a limit left a form unread.
|
||||
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.
|
||||
decode: DecodeBudget,
|
||||
/// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`], comes past
|
||||
/// [`MAX_FORM_VISITS`] or past the decode budget: what it draws is missing from the walk.
|
||||
pub(crate) truncated: bool,
|
||||
}
|
||||
|
||||
impl FormBudget {
|
||||
/// The walk of a page's forms, spending what `decode` has left after the page's content.
|
||||
pub(crate) fn new(decode: DecodeBudget) -> Self {
|
||||
Self { visits: 0, decode, truncated: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// A form XObject a page draws: its object, content, resources and its matrix composed with the
|
||||
/// CTM at the `Do`.
|
||||
pub(crate) struct FormCall {
|
||||
@@ -1519,7 +1568,7 @@ pub(crate) fn form_call(
|
||||
path: &[pdfcraft_cos::ObjRef],
|
||||
budget: &mut FormBudget,
|
||||
) -> Option<FormCall> {
|
||||
let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS;
|
||||
let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS || budget.decode.remaining() == 0;
|
||||
// Once a limit has cut the page short, refusing the rest costs nothing more.
|
||||
if limited && budget.truncated {
|
||||
return None;
|
||||
@@ -1546,13 +1595,16 @@ 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());
|
||||
let data = match budget.decode.decode(s) {
|
||||
Ok(data) => data,
|
||||
Err(_) if budget.decode.remaining() == 0 => {
|
||||
budget.truncated = true;
|
||||
return None;
|
||||
}
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
budget.visits = budget.visits.saturating_add(1);
|
||||
Some(FormCall {
|
||||
obj,
|
||||
data: s.decoded().unwrap_or_default(),
|
||||
resources: own.unwrap_or_else(|| resources.clone()),
|
||||
ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)),
|
||||
})
|
||||
Some(FormCall { obj, data, resources: own.unwrap_or_else(|| resources.clone()), ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)) })
|
||||
}
|
||||
|
||||
/// The lines a page shows as it reads, including text drawn by form XObjects (which
|
||||
@@ -1578,7 +1630,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec<TextLine>, bool), E
|
||||
let mut forms = invoked.into_iter().peekable();
|
||||
for s in shown {
|
||||
while let Some((_, name, ts)) = forms.next_if(|f| f.0 < s.op) {
|
||||
enter(w, resources, &name, ts)?;
|
||||
enter(w, resources, &name, ts, &mut carry.text_left)?;
|
||||
// Text after a form starts a new line.
|
||||
last = None;
|
||||
}
|
||||
@@ -1590,21 +1642,23 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec<TextLine>, bool), E
|
||||
add_shown(&mut w.lines, &mut last, s, si);
|
||||
}
|
||||
for (_, name, ts) in forms {
|
||||
enter(w, resources, &name, ts)?;
|
||||
enter(w, resources, &name, ts, &mut carry.text_left)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn enter(w: &mut Walk, resources: &Dict, name: &[u8], ts: Ts) -> Result<(), EditError> {
|
||||
/// `text_left`: the page's text budget, shared with the forms the page draws.
|
||||
fn enter(w: &mut Walk, resources: &Dict, name: &[u8], ts: Ts, text_left: &mut usize) -> Result<(), EditError> {
|
||||
let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.budget) else { return Ok(()) };
|
||||
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(), layers: Vec::new() };
|
||||
let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: *text_left };
|
||||
let stream = w.next_stream;
|
||||
w.next_stream = w.next_stream.saturating_add(1);
|
||||
if let Some(r) = form.obj {
|
||||
w.path.push(r);
|
||||
}
|
||||
walk(w, &ops, &form.resources, &mut carry, &|_| stream)?;
|
||||
*text_left = carry.text_left;
|
||||
if form.obj.is_some() {
|
||||
w.path.pop();
|
||||
}
|
||||
@@ -1612,8 +1666,9 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec<TextLine>, bool), E
|
||||
}
|
||||
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(), budget: FormBudget::default() };
|
||||
let mut decode = DecodeBudget::page();
|
||||
let streams = content_streams_within(doc, &p.dict, &mut decode)?;
|
||||
let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::new(decode) };
|
||||
let mut carry = Carry::new();
|
||||
// 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)
|
||||
@@ -2235,7 +2290,7 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
|
||||
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();
|
||||
let streams = content_streams(doc, &p.dict);
|
||||
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 first = target.ops.first().copied().filter(|i| *i < ops.len()).ok_or_else(|| EditError::Invalid("the page's content changed".into()))?;
|
||||
@@ -2440,7 +2495,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
|
||||
drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied()));
|
||||
let p = page_dict(doc, page)?;
|
||||
let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
|
||||
let streams = content_streams(doc, &p.dict);
|
||||
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, &res, &mut HashMap::new(), &mut Carry::new(), None, None)?;
|
||||
@@ -2486,14 +2541,17 @@ pub fn reading_blocks(doc: &Document, page: usize) -> Result<Vec<TextBlock>, Edi
|
||||
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
|
||||
/// The page draws forms nested deeper than [`MAX_FORM_DEPTH`], more of them than
|
||||
/// [`MAX_FORM_VISITS`], or forms that run past what the page's content left of its decode
|
||||
/// budget. Reading leaves those forms 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.
|
||||
/// [`reading_blocks`], saying whether the limits that bound reading left text out. A page
|
||||
/// whose `/Contents` alone runs past its decode budget is an [`EditError::Invalid`], not a
|
||||
/// truncated result.
|
||||
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 })
|
||||
@@ -2661,7 +2719,7 @@ fn rewrite_block_from(
|
||||
{
|
||||
return rewrite_styled_block(doc, page, &b, &lines, text, style);
|
||||
}
|
||||
let streams = content_streams(doc, &p.dict);
|
||||
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;
|
||||
// Explicit line breaks stay (#1443); within a line, runs of white space become one space.
|
||||
@@ -3044,7 +3102,7 @@ pub(crate) fn selectable_blocks(
|
||||
excluded: &std::collections::BTreeSet<pdfcraft_cos::ObjRef>,
|
||||
) -> Result<Vec<(usize, TextBlock)>, EditError> {
|
||||
let p = page_dict(doc, page)?;
|
||||
let streams = content_streams(doc, &p.dict);
|
||||
let streams = content_streams(doc, &p.dict)?;
|
||||
let lines = text_lines(doc, page)?;
|
||||
Ok(group_blocks(&lines)
|
||||
.into_iter()
|
||||
@@ -3133,7 +3191,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize
|
||||
return Ok(());
|
||||
}
|
||||
let p = page_dict(doc, page)?;
|
||||
let streams = content_streams(doc, &p.dict);
|
||||
let streams = content_streams(doc, &p.dict)?;
|
||||
let lines = text_lines(doc, page)?;
|
||||
let blocks = group_blocks(&lines);
|
||||
let mut selected = std::collections::BTreeSet::new();
|
||||
|
||||
@@ -772,7 +772,7 @@ pub(super) fn rewrite_styled_block(
|
||||
row_ranges.push(row_start..row_start + row.len());
|
||||
rows.push(row);
|
||||
}
|
||||
let streams = content_streams(doc, &p.dict);
|
||||
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;
|
||||
// A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! Compare files: the text differences between two open documents, a report, and the
|
||||
//! differences marked as comments in the newer one.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::ObjRef;
|
||||
|
||||
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.
|
||||
@@ -10,6 +13,10 @@ pub use pdfcraft_edit::{MAX_FORM_DEPTH, MAX_FORM_VISITS};
|
||||
|
||||
use crate::{DocId, Edit, EditError, Markup, NewAnnotation, NoteIcon, Session, Shape};
|
||||
|
||||
/// Most bytes of encoded pictures one Office export prepares, counting each image object once
|
||||
/// (the Word and PowerPoint writers keep at most 512 MiB of them as well).
|
||||
pub const MAX_EXPORT_IMAGE_BYTES: usize = 512 << 20;
|
||||
|
||||
/// Acrobat's compare colours: replaced blue, inserted green, deleted red.
|
||||
pub fn colour(kind: Kind) -> crate::Rgb {
|
||||
match kind {
|
||||
@@ -163,23 +170,32 @@ 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`.
|
||||
/// than [`MAX_FORM_VISITS`], or pictures past [`MAX_EXPORT_IMAGE_BYTES`]: export stops
|
||||
/// reading there to stay bounded on hostile files, so the text and images of those forms, or
|
||||
/// those pictures, are missing from `bytes`.
|
||||
pub truncated_pages: Vec<usize>,
|
||||
}
|
||||
|
||||
/// An image object prepared for Office export: its file extension and encoded bytes, or why it is
|
||||
/// left out (`Err(true)`: past [`MAX_EXPORT_IMAGE_BYTES`]; `Err(false)`: it can't be encoded).
|
||||
type PreparedImage = Result<(&'static str, Arc<[u8]>), bool>;
|
||||
|
||||
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> {
|
||||
self.export_pages_checked().0
|
||||
self.export_pages_checked(true, MAX_EXPORT_IMAGE_BYTES).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>) {
|
||||
/// [`Self::export_pages`], and the pages whose forms were left partly unread or whose
|
||||
/// pictures went past `image_budget` bytes ([`OfficeExport::truncated_pages`]). Without
|
||||
/// `images` no picture is prepared.
|
||||
pub(crate) fn export_pages_checked(&self, images: bool, image_budget: usize) -> (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();
|
||||
// Each image object is encoded once and shared by every place that draws it.
|
||||
let mut encoded: HashMap<ObjRef, PreparedImage> = HashMap::new();
|
||||
let mut image_bytes = image_budget;
|
||||
let pages = self
|
||||
.info
|
||||
.pages
|
||||
@@ -197,9 +213,7 @@ impl crate::Document {
|
||||
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 reading = pdfcraft_edit::reading_blocks_checked(cos, i).unwrap_or_default();
|
||||
if reading.truncated {
|
||||
truncated.push(i);
|
||||
}
|
||||
let forms_unread = reading.truncated;
|
||||
let blocks = reading
|
||||
.blocks
|
||||
.into_iter()
|
||||
@@ -217,13 +231,21 @@ impl crate::Document {
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let images = pdfcraft_edit::reading_images(cos, i)
|
||||
.unwrap_or_default()
|
||||
let placed = if images { pdfcraft_edit::reading_images(cos, i).unwrap_or_default() } else { Vec::new() };
|
||||
let mut over_budget = false;
|
||||
let images = placed
|
||||
.iter()
|
||||
.filter_map(|im| {
|
||||
let (ext, bytes) = pdfcraft_create::image_file(cos, im.object?).ok()?;
|
||||
let object = im.object?;
|
||||
let prepared = encoded.entry(object).or_insert_with(|| {
|
||||
let (ext, bytes) = pdfcraft_create::image_file(cos, object).map_err(|_| false)?;
|
||||
image_bytes = image_bytes.checked_sub(bytes.len()).ok_or(true)?;
|
||||
Ok((if ext == "jpg" { "jpg" } else { "png" }, Arc::from(bytes)))
|
||||
});
|
||||
over_budget |= matches!(prepared, Err(true));
|
||||
let (ext, bytes) = prepared.clone().ok()?;
|
||||
Some(pdfcraft_export::Image {
|
||||
ext: if ext == "jpg" { "jpg" } else { "png" },
|
||||
ext,
|
||||
bytes,
|
||||
rect: visible_rect(im.rect),
|
||||
size: [im.matrix[0].hypot(im.matrix[1]) * unit, im.matrix[2].hypot(im.matrix[3]) * unit],
|
||||
@@ -231,6 +253,9 @@ impl crate::Document {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if forms_unread || over_budget {
|
||||
truncated.push(i);
|
||||
}
|
||||
// The page colour (Edit a PDF ▸ Background, a producer's page fill) for formats that keep one.
|
||||
let background = pdfcraft_edit::page_background(cos, i).ok().flatten();
|
||||
pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images, background }
|
||||
@@ -247,7 +272,8 @@ impl crate::Document {
|
||||
/// [`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();
|
||||
// RTF leaves pictures out.
|
||||
let (pages, truncated_pages) = self.export_pages_checked(format != OfficeFormat::Rtf, MAX_EXPORT_IMAGE_BYTES);
|
||||
let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string());
|
||||
let bytes = match format {
|
||||
OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title),
|
||||
|
||||
+36
-5
@@ -289,6 +289,12 @@ pub(crate) fn page_words(text: &pdfcraft_render::PageText, info: &pdfcraft_rende
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Characters read to tell underscore runs from other words.
|
||||
static UNDERSCORE_CHARS_READ: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// A page's words in reading order, their boxes mapped from view space by `map` (and ordered
|
||||
/// again, so that [x0, y0, x1, y1] holds whatever way `map` turns them).
|
||||
pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32) -> [f32; 2]) -> Vec<(String, [f32; 4])> {
|
||||
@@ -296,6 +302,8 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32
|
||||
let mut last_line = u32::MAX;
|
||||
// A space glyph ends the word before it.
|
||||
let mut gap = false;
|
||||
// Whether the last word is an underscore run (a form's blank), which other glyphs don't join.
|
||||
let mut last_under = false;
|
||||
for (i, g) in text.glyphs.iter().enumerate() {
|
||||
let line = text.line_of.get(i).copied().unwrap_or(0);
|
||||
if g.text.trim().is_empty() {
|
||||
@@ -303,10 +311,9 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32
|
||||
continue;
|
||||
}
|
||||
let under = g.text.chars().all(|c| c == '_');
|
||||
let breaks = match words.last() {
|
||||
None => true,
|
||||
Some((w, _)) => gap || line != last_line || text.space_before.get(i).copied().unwrap_or(false) || w.chars().all(|c| c == '_') != under,
|
||||
};
|
||||
#[cfg(test)]
|
||||
UNDERSCORE_CHARS_READ.with(|n| n.set(n.get() + g.text.len()));
|
||||
let breaks = words.is_empty() || gap || line != last_line || text.space_before.get(i).copied().unwrap_or(false) || last_under != under;
|
||||
gap = false;
|
||||
last_line = line;
|
||||
match words.last_mut() {
|
||||
@@ -317,7 +324,10 @@ pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32
|
||||
r[2] = r[2].max(g.rect[2]);
|
||||
r[3] = r[3].max(g.rect[3]);
|
||||
}
|
||||
_ => words.push((g.text.clone(), g.rect)),
|
||||
_ => {
|
||||
words.push((g.text.clone(), g.rect));
|
||||
last_under = under;
|
||||
}
|
||||
}
|
||||
}
|
||||
words
|
||||
@@ -409,6 +419,27 @@ impl Session {
|
||||
mod review_tests {
|
||||
use super::*;
|
||||
|
||||
/// A long run of underscores (a form's blank) is read in one pass: each glyph is compared with
|
||||
/// the word so far without reading the word again.
|
||||
#[test]
|
||||
fn a_long_underscore_run_is_read_in_one_pass() {
|
||||
let n = 10_000;
|
||||
let glyph = |i: usize, t: &str| pdfcraft_render::TextGlyph {
|
||||
text: t.into(),
|
||||
rect: [i as f32, 0.0, i as f32 + 1.0, 10.0],
|
||||
quad: [[0.0; 2]; 4],
|
||||
direction: [1.0, 0.0],
|
||||
};
|
||||
let mut glyphs: Vec<_> = (0..n).map(|i| glyph(i, "_")).collect();
|
||||
glyphs.push(glyph(n, "x"));
|
||||
let text = pdfcraft_render::PageText { line_of: vec![0; glyphs.len()], space_before: vec![false; glyphs.len()], glyphs };
|
||||
UNDERSCORE_CHARS_READ.with(|c| c.set(0));
|
||||
let words = words_with(&text, |x, y| [x, y]);
|
||||
assert_eq!(words.iter().map(|(w, _)| w.len()).collect::<Vec<_>>(), [n, 1]);
|
||||
let read = UNDERSCORE_CHARS_READ.with(std::cell::Cell::get);
|
||||
assert!(read <= 4 * n, "{read} characters read for {n} glyphs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_field_names_diff_against_first_original() {
|
||||
let first = pdfcraft_js::FieldState::new("duplicate", pdfcraft_js::FieldType::Text, vec!["first".into()]);
|
||||
|
||||
@@ -6695,7 +6695,7 @@ fn exporting_a_jpeg_logo_to_word_keeps_the_image() {
|
||||
assert!(jpeg.starts_with(&[0xFF, 0xD8]));
|
||||
let docx = d.export_office(compare::OfficeFormat::Docx);
|
||||
assert!(docx.windows(21).any(|w| w == b"word/media/image1.jpg"), "overlap {overlaps}");
|
||||
assert!(docx.windows(jpeg.len()).any(|w| w == jpeg.as_slice()), "the JPEG is stored unchanged (overlap {overlaps})");
|
||||
assert!(docx.windows(jpeg.len()).any(|w| w == &jpeg[..]), "the JPEG is stored unchanged (overlap {overlaps})");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7283,3 +7283,49 @@ fn exporting_office_keeps_dotted_leader_entries_as_paragraphs() {
|
||||
let positions: Vec<_> = entries.iter().map(|entry| html.find(&format!("<p>{entry}</p>")).expect("one paragraph per entry")).collect();
|
||||
assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "reading order: {html}");
|
||||
}
|
||||
|
||||
/// A page whose forms go past the reading limit and whose picture goes past the image budget is
|
||||
/// listed once among the truncated pages.
|
||||
#[test]
|
||||
fn a_page_truncated_for_forms_and_pictures_is_listed_once() {
|
||||
let depth = compare::MAX_FORM_DEPTH + 1;
|
||||
let mut objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(),
|
||||
String::new(),
|
||||
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\0\x20\x40\x7f\nendstream".into(),
|
||||
"<< /Length 34 >>\nstream\nq 10 0 0 10 0 0 cm /Im Do Q /D1 Do\nendstream".into(),
|
||||
];
|
||||
// Forms D1..D{depth}, objects 6 onwards, each drawing the next.
|
||||
let xobjects: String = (1..=depth).map(|i| format!("/D{i} {} 0 R ", 5 + i)).collect();
|
||||
let resources = format!("<< /XObject << /Im 4 0 R {xobjects}>> >>");
|
||||
for i in 1..=depth {
|
||||
let body = if i == depth { "q Q".to_string() } else { format!("/D{} Do", i + 1) };
|
||||
objs.push(format!(
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Resources {resources} /Length {} >>\nstream\n{body}\nendstream",
|
||||
body.len()
|
||||
));
|
||||
}
|
||||
objs[2] = format!("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources {resources} >>");
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
let mut s = Session::new();
|
||||
let id = s.open("both.pdf", None, Arc::new(bytes), None).unwrap();
|
||||
let doc = s.get(id).unwrap();
|
||||
assert_eq!(doc.export_pages_checked(false, 0).1, [0], "the forms alone truncate the page");
|
||||
let (exported, _) = doc.export_pages_checked(true, 1 << 20);
|
||||
assert_eq!(exported[0].images.len(), 1, "the picture exports within a sufficient budget");
|
||||
let (pages, truncated) = doc.export_pages_checked(true, 0);
|
||||
assert!(pages[0].images.is_empty(), "the picture is past the budget");
|
||||
assert_eq!(truncated, [0]);
|
||||
}
|
||||
|
||||
@@ -8,5 +8,6 @@ mod document_fonts;
|
||||
mod form_rotation;
|
||||
mod marked_content_isolation;
|
||||
mod ocr_edit;
|
||||
mod office_export_images;
|
||||
mod private_image_resize;
|
||||
mod private_text_glyphs;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Office export of pages that draw one image many times.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_engine::Session;
|
||||
|
||||
fn pdf(objects: &[String]) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objects.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", objects.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", objects.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// One image drawn many times is prepared once: every placement shares its encoded bytes.
|
||||
#[test]
|
||||
fn an_image_drawn_many_times_is_encoded_once() {
|
||||
let placements = 500;
|
||||
let content = "q 10 0 0 10 0 0 cm /Im Do Q\n".repeat(placements);
|
||||
let bytes = pdf(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> >> >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{content}endstream", content.len()),
|
||||
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\u{0}\u{40}\u{80}\u{ff}\nendstream".into(),
|
||||
]);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("many.pdf", None, Arc::new(bytes), None).unwrap();
|
||||
let pages = s.get(id).unwrap().export_pages();
|
||||
let images = &pages[0].images;
|
||||
assert_eq!(images.len(), placements);
|
||||
let first = images[0].bytes.as_ptr();
|
||||
assert!(!images[0].bytes.is_empty());
|
||||
assert!(images.iter().all(|im| im.bytes.as_ptr() == first), "each placement holds its own copy of the image");
|
||||
}
|
||||
@@ -37,7 +37,8 @@ pub struct Block {
|
||||
pub struct Image {
|
||||
/// "png" or "jpg".
|
||||
pub ext: &'static str,
|
||||
pub bytes: Vec<u8>,
|
||||
/// Shared: a picture drawn in many places is held once.
|
||||
pub bytes: std::sync::Arc<[u8]>,
|
||||
pub rect: [f64; 4],
|
||||
/// Width and height before rotation, in points.
|
||||
pub size: [f64; 2],
|
||||
@@ -997,7 +998,7 @@ mod tests {
|
||||
],
|
||||
images: vec![Image {
|
||||
ext: "png",
|
||||
bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".to_vec(),
|
||||
bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(),
|
||||
rect: [72.0, 450.0, 272.0, 600.0],
|
||||
size: [200.0, 150.0],
|
||||
rotation: 0.0,
|
||||
@@ -1439,7 +1440,7 @@ mod tests {
|
||||
/// file in the package, with a content type; JPEG pictures too, inline or placed.
|
||||
#[test]
|
||||
fn word_pictures_resolve_to_media_in_the_package() {
|
||||
let jpeg = Image { ext: "jpg", bytes: vec![0xFF, 0xD8, 0xFF, 0xD9], ..page().images[0].clone() };
|
||||
let jpeg = Image { ext: "jpg", bytes: [0xFF, 0xD8, 0xFF, 0xD9].as_slice().into(), ..page().images[0].clone() };
|
||||
for placed in [false, true] {
|
||||
let mut p = page();
|
||||
p.images = vec![jpeg.clone()];
|
||||
@@ -1466,7 +1467,7 @@ mod tests {
|
||||
fn word_export_bounds_the_number_of_pictures_and_skips_empty_ones() {
|
||||
let mut p = page();
|
||||
let one = p.images[0].clone();
|
||||
p.images = vec![Image { bytes: Vec::new(), ..one.clone() }];
|
||||
p.images = vec![Image { bytes: [].as_slice().into(), ..one.clone() }];
|
||||
p.images.extend(std::iter::repeat_n(one, MAX_MEDIA + 10));
|
||||
let zip = docx(&[p], "T");
|
||||
let rels = part(&zip, "word/_rels/document.xml.rels");
|
||||
|
||||
@@ -391,7 +391,7 @@ mod tests {
|
||||
],
|
||||
images: vec![Image {
|
||||
ext: "png",
|
||||
bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".to_vec(),
|
||||
bytes: b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x02\0\0\0\x03".as_slice().into(),
|
||||
rect: [72.0, 150.0, 300.0, 300.0],
|
||||
size: [228.0, 150.0],
|
||||
rotation: 0.0,
|
||||
@@ -619,7 +619,13 @@ mod tests {
|
||||
blocks.push(huge("inf", f64::INFINITY, 200.0 - k as f64));
|
||||
}
|
||||
let images = (0..40)
|
||||
.map(|k| Image { ext: "png", bytes: vec![0; 4], rect: [0.0, -1e300, 1e-300, 1e300 - k as f64], size: [1.0, 1.0], rotation: 0.0 })
|
||||
.map(|k| Image {
|
||||
ext: "png",
|
||||
bytes: [0; 4].as_slice().into(),
|
||||
rect: [0.0, -1e300, 1e-300, 1e300 - k as f64],
|
||||
size: [1.0, 1.0],
|
||||
rotation: 0.0,
|
||||
})
|
||||
.collect();
|
||||
let p = Page { width: f64::MAX, height: f64::NAN, blocks, images, background: None };
|
||||
let bytes = pptx(&[p], "Hostile");
|
||||
|
||||
@@ -17,14 +17,17 @@ pub struct Params { predictor, colors, bits_per_component, columns, early_change
|
||||
pub enum FilterError { Corrupt { filter, detail }, LimitExceeded(usize), Unsupported(String) }
|
||||
|
||||
decode(chain, data, max_output) -> Result<Vec<u8>, FilterError>
|
||||
decode_counted(chain, data, max_output) -> Result<(Vec<u8>, usize /*decoded*/), (FilterError, usize /*decoded*/)>
|
||||
decode_tolerant(chain, data, max_output) -> Result<(Vec<u8>, bool /*partial*/), FilterError>
|
||||
decode_tolerant_counted(chain, data, max_output) -> Result<(Vec<u8>, bool /*partial*/, usize /*decoded*/), (FilterError, usize /*decoded*/)>
|
||||
encode(filter, params, data) -> Result<Vec<u8>, FilterError>
|
||||
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`.
|
||||
- Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. So does a chain longer than `MAX_CHAIN` (8) filters, before any stage runs. Decryption belongs to `crypt`.
|
||||
- `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_counted` and `decode_tolerant_counted` are for a caller charging a budget (`pdfcraft_cos::DecodeBudget`). `max_output` bounds all stages together (each stage gets what the earlier ones left), and they give the bytes every stage that ran decoded, on success and on failure. A stage's output counts as its decoder produced it, before a predictor or a later stage shrinks it, and a failed decode pays for what it decoded first.
|
||||
- `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
|
||||
|
||||
+112
-18
@@ -137,52 +137,95 @@ pub(crate) type Step = Result<Vec<u8>, Failure>;
|
||||
/// Decodes `data` through `chain` (first element applied first).
|
||||
///
|
||||
/// Image codecs, `Crypt` and unknown filters anywhere in the chain give
|
||||
/// [`FilterError::Unsupported`] (callers keep such data encoded). `max_output` bounds
|
||||
/// every intermediate buffer.
|
||||
/// [`FilterError::Unsupported`] (callers keep such data encoded), as does a chain longer than
|
||||
/// [`MAX_CHAIN`]. `max_output` bounds every intermediate buffer.
|
||||
pub fn decode(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<Vec<u8>, FilterError> {
|
||||
run(chain, data, max_output, true).map(|(v, _)| v)
|
||||
run(chain, data, max_output, true, false).map(|(v, _, _)| v).map_err(|(e, _)| e)
|
||||
}
|
||||
|
||||
/// Like [`decode`] for a caller charging an allowance: `max_output` bounds the bytes all stages
|
||||
/// decode together, each stage getting what the earlier ones left, and the result gives those
|
||||
/// bytes, on success and on failure. A stage's output counts even when a later stage or its
|
||||
/// predictor shrinks it.
|
||||
pub fn decode_counted(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec<u8>, usize), (FilterError, usize)> {
|
||||
run(chain, data, max_output, true, true).map(|(v, _, spent)| (v, spent))
|
||||
}
|
||||
|
||||
/// Like [`decode`], but on corrupt data returns what was decoded before the error and
|
||||
/// reports `partial = true` (the behaviour viewers expect). The remaining filters of the
|
||||
/// chain still run on the partial data. Limit and unsupported errors are still errors.
|
||||
pub fn decode_tolerant(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec<u8>, bool), FilterError> {
|
||||
run(chain, data, max_output, false)
|
||||
run(chain, data, max_output, false, false).map(|(v, partial, _)| (v, partial)).map_err(|(e, _)| e)
|
||||
}
|
||||
|
||||
fn run(chain: &[(Filter, Params)], data: &[u8], max: usize, strict: bool) -> Result<(Vec<u8>, bool), FilterError> {
|
||||
/// [`decode_tolerant`] bounded and counted as [`decode_counted`] is.
|
||||
pub fn decode_tolerant_counted(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec<u8>, bool, usize), (FilterError, usize)> {
|
||||
run(chain, data, max_output, false, true)
|
||||
}
|
||||
|
||||
/// Longest filter chain decoded. Real streams use one to three filters; every stage may produce
|
||||
/// up to the output limit, so the chain length multiplies the work.
|
||||
pub const MAX_CHAIN: usize = 8;
|
||||
|
||||
/// The decoded data, whether it is partial and the bytes all stages decoded, or the error and
|
||||
/// the bytes decoded before it. `max` bounds each stage, or with `cumulative` all of them
|
||||
/// together.
|
||||
fn run(chain: &[(Filter, Params)], data: &[u8], max: usize, strict: bool, cumulative: bool) -> Result<(Vec<u8>, bool, usize), (FilterError, usize)> {
|
||||
if chain.len() > MAX_CHAIN {
|
||||
return Err((FilterError::Unsupported(format!("a chain of {} filters", chain.len())), 0));
|
||||
}
|
||||
if let Some((f, _)) = chain.iter().find(|(f, _)| !f.is_decodable()) {
|
||||
return Err(FilterError::Unsupported(f.name().to_owned()));
|
||||
return Err((FilterError::Unsupported(f.name().to_owned()), 0));
|
||||
}
|
||||
let mut cur: Cow<[u8]> = Cow::Borrowed(data);
|
||||
let mut partial = false;
|
||||
let mut spent = 0usize;
|
||||
for (filter, params) in chain {
|
||||
cur = Cow::Owned(match decode_one(filter, params, &cur, max) {
|
||||
#[cfg(test)]
|
||||
STAGE_BYTES.with(|c| c.set(c.get() + cur.len()));
|
||||
let limit = if cumulative { max.saturating_sub(spent) } else { max };
|
||||
let (step, work) = decode_one(filter, params, &cur, limit);
|
||||
spent = spent.saturating_add(work);
|
||||
cur = Cow::Owned(match step {
|
||||
Ok(v) => v,
|
||||
Err(Failure { error: FilterError::Corrupt { .. }, partial: out }) if !strict => {
|
||||
partial = true;
|
||||
out
|
||||
}
|
||||
Err(f) => return Err(f.error),
|
||||
Err(Failure { error: FilterError::LimitExceeded(_), .. }) => return Err((FilterError::LimitExceeded(max), spent)),
|
||||
Err(f) => return Err((f.error, spent)),
|
||||
});
|
||||
}
|
||||
Ok((cur.into_owned(), partial))
|
||||
Ok((cur.into_owned(), partial, spent))
|
||||
}
|
||||
|
||||
fn decode_one(filter: &Filter, params: &Params, data: &[u8], max: usize) -> Step {
|
||||
/// One stage's result and the bytes its decoder produced (before any predictor).
|
||||
fn decode_one(filter: &Filter, params: &Params, data: &[u8], max: usize) -> (Step, usize) {
|
||||
match filter {
|
||||
Filter::Flate => with_predictor(flate::decode(data, max), params, "FlateDecode"),
|
||||
Filter::Lzw => with_predictor(lzw::decode(data, params.early_change != 0, max), params, "LZWDecode"),
|
||||
Filter::AsciiHex => asciihex::decode(data, max),
|
||||
Filter::Ascii85 => ascii85::decode(data, max),
|
||||
Filter::RunLength => runlength::decode(data, max),
|
||||
other => Err(Failure { error: FilterError::Unsupported(other.name().to_owned()), partial: Vec::new() }),
|
||||
Filter::AsciiHex => counted(asciihex::decode(data, max)),
|
||||
Filter::Ascii85 => counted(ascii85::decode(data, max)),
|
||||
Filter::RunLength => counted(runlength::decode(data, max)),
|
||||
other => (Err(Failure { error: FilterError::Unsupported(other.name().to_owned()), partial: Vec::new() }), 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the predictor to a decoder's result, including to partial output on failure.
|
||||
fn with_predictor(step: Step, params: &Params, name: &'static str) -> Step {
|
||||
match step {
|
||||
/// A step with the bytes it produced, its output or its partial output.
|
||||
fn counted(step: Step) -> (Step, usize) {
|
||||
let n = match &step {
|
||||
Ok(v) => v.len(),
|
||||
Err(f) => f.partial.len(),
|
||||
};
|
||||
(step, n)
|
||||
}
|
||||
|
||||
/// Applies the predictor to a decoder's result, including to partial output on failure. The
|
||||
/// count is the decoder's output: a PNG predictor drops a tag byte per row, but the decoder
|
||||
/// produced it.
|
||||
fn with_predictor(step: Step, params: &Params, name: &'static str) -> (Step, usize) {
|
||||
let (step, n) = counted(step);
|
||||
let step = match step {
|
||||
Ok(v) => predictor::decode(params, v, name),
|
||||
Err(Failure { error, partial }) => {
|
||||
let partial = match predictor::decode(params, partial, name) {
|
||||
@@ -191,7 +234,8 @@ fn with_predictor(step: Step, params: &Params, name: &'static str) -> Step {
|
||||
};
|
||||
Err(Failure { error, partial })
|
||||
}
|
||||
}
|
||||
};
|
||||
(step, n)
|
||||
}
|
||||
|
||||
/// Encodes `data` with `filter`. Supported: Flate (zlib, level 6), LZW, ASCIIHex,
|
||||
@@ -218,8 +262,27 @@ pub(crate) fn is_pdf_whitespace(b: u8) -> bool {
|
||||
matches!(b, 0 | 9 | 10 | 12 | 13 | 32)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Bytes handed to decoding stages on this thread.
|
||||
static STAGE_BYTES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_long_filter_chain_is_refused_before_decoding() {
|
||||
// `FF FF` run-length decodes to itself, so every stage of the chain repeats the work.
|
||||
let data = vec![0xFF; 4096];
|
||||
let chain = |n: usize| vec![(super::Filter::RunLength, super::Params::default()); n];
|
||||
super::STAGE_BYTES.with(|c| c.set(0));
|
||||
let long = super::decode(&chain(1000), &data, 1 << 20);
|
||||
let read = super::STAGE_BYTES.with(|c| c.get());
|
||||
assert!(read <= 16 * data.len(), "{read} bytes decoded through the chain");
|
||||
assert!(matches!(long, Err(super::FilterError::Unsupported(_))), "{long:?}");
|
||||
assert_eq!(super::decode(&chain(super::MAX_CHAIN), &data, 1 << 20).ok(), Some(data));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -343,4 +406,35 @@ mod tests {
|
||||
assert!(partial);
|
||||
assert_eq!(&data[..out.len()], &out[..]);
|
||||
}
|
||||
|
||||
/// A counted decode bounds what all stages decode together: Flate inflating 1 MiB of hex
|
||||
/// that ASCIIHex halves is 1.5 MiB of work, past a 1.25 MiB allowance. A plain decode keeps
|
||||
/// the limit per stage.
|
||||
#[test]
|
||||
fn a_counted_decode_bounds_all_stages_together() {
|
||||
let hex: Vec<u8> = std::iter::repeat_n(b"ab".as_slice(), 1 << 19).flatten().copied().collect();
|
||||
let data = encode_flate(&hex);
|
||||
let chain = [(Filter::Flate, Params::default()), (Filter::AsciiHex, Params::default())];
|
||||
let max = (1 << 20) + (1 << 18);
|
||||
assert_eq!(decode(&chain, &data, max).map(|d| d.len()).ok(), Some(1 << 19));
|
||||
assert!(matches!(decode_counted(&chain, &data, max), Err((FilterError::LimitExceeded(m), _)) if m == max));
|
||||
assert_eq!(decode_counted(&chain, &data, 2 << 20).map(|(_, spent)| spent).ok(), Some((1 << 20) + (1 << 19)));
|
||||
}
|
||||
|
||||
/// A PNG predictor drops a tag byte per row, but Flate produced it: the count includes it,
|
||||
/// also when the predictor fails on a bad tag.
|
||||
#[test]
|
||||
fn a_counted_decode_counts_output_before_the_predictor() {
|
||||
let params = Params { predictor: 12, ..Params::default() };
|
||||
let rows = vec![7u8; 1 << 16];
|
||||
let data = encode(&Filter::Flate, ¶ms, &rows).unwrap();
|
||||
let chain = [(Filter::Flate, params.clone())];
|
||||
let (out, spent) = decode_counted(&chain, &data, usize::MAX).unwrap();
|
||||
assert!(out == rows);
|
||||
assert_eq!(spent, 2 << 16);
|
||||
let bad: Vec<u8> = std::iter::repeat_n([0u8, 7], 1 << 15).flatten().chain([9, 7]).collect();
|
||||
let (e, spent) = decode_counted(&chain, &encode_flate(&bad), usize::MAX).unwrap_err();
|
||||
assert!(matches!(e, FilterError::Corrupt { .. }), "{e}");
|
||||
assert_eq!(spent, bad.len());
|
||||
}
|
||||
}
|
||||
|
||||
+108
-32
@@ -1,9 +1,10 @@
|
||||
//! Unicode embedding of an explicitly supplied, embeddable TrueType face. Font files are
|
||||
//! document inputs or the optional craft-fonts build input, never system-interface fallbacks.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream};
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
|
||||
use skrifa::{
|
||||
FontRef, MetadataProvider,
|
||||
instance::{LocationRef, Size},
|
||||
@@ -173,7 +174,8 @@ pub fn embed_mapping(
|
||||
return Err("the font family name is invalid".into());
|
||||
}
|
||||
let glyph_map = if cff { cid_encoding.as_bytes() } else { gids.as_slice() };
|
||||
if let Some(resource) = same_resource(doc, fonts, &name, bytes, glyph_map, cmap.as_bytes()) {
|
||||
let mut programs = ProgramMatch::new(bytes);
|
||||
if let Some(resource) = same_resource(doc, fonts, &name, &mut programs, glyph_map, cmap.as_bytes()) {
|
||||
return Ok(resource);
|
||||
}
|
||||
let mut descriptor = Dict::new();
|
||||
@@ -193,12 +195,7 @@ pub fn embed_mapping(
|
||||
);
|
||||
// Reuse the program another resource of this page already has, else one registered on another
|
||||
// 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 is_program(doc, s, bytes) => Some(Object::Ref(r)),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
let existing = existing_program(doc, fonts, &mut programs);
|
||||
let program = existing.unwrap_or_else(|| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Length1".to_vec(), Object::Int(bytes.len() as i64));
|
||||
@@ -246,12 +243,64 @@ pub fn embed_mapping(
|
||||
Ok(resource)
|
||||
}
|
||||
|
||||
/// The decoded data of the stream `object` refers to.
|
||||
fn stream_data(doc: &Document, object: Option<&Object>) -> Option<Vec<u8>> {
|
||||
match doc.resolve(object?).as_ref() {
|
||||
Object::Stream(s) => s.decoded_within(17 << 20).ok(),
|
||||
_ => None,
|
||||
/// Whether the stream `object` refers to decodes to `expected`.
|
||||
fn stream_is(doc: &Document, object: Option<&Object>, expected: &[u8]) -> bool {
|
||||
object.is_some_and(|o| match doc.resolve(o).as_ref() {
|
||||
Object::Stream(s) => s.decoded_within(expected.len()).is_ok_and(|d| d == expected),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Full programs one embedding decodes at most while looking for its own. A real match passes
|
||||
/// the cheap checks and decodes once; many candidates that pass them come from a hostile file.
|
||||
const MAX_PROGRAM_DECODES: usize = 16;
|
||||
|
||||
/// Recognises the full program written for `bytes`: each candidate stream decodes at most once
|
||||
/// and within `bytes.len()`, and at most [`MAX_PROGRAM_DECODES`] decode in all.
|
||||
struct ProgramMatch<'a> {
|
||||
bytes: &'a [u8],
|
||||
known: HashMap<ObjRef, bool>,
|
||||
decodes: usize,
|
||||
}
|
||||
|
||||
impl<'a> ProgramMatch<'a> {
|
||||
fn new(bytes: &'a [u8]) -> Self {
|
||||
Self { bytes, known: HashMap::new(), decodes: 0 }
|
||||
}
|
||||
|
||||
/// Whether `r`, holding `object`, is the program.
|
||||
fn is(&mut self, doc: &Document, r: ObjRef, object: &Object) -> bool {
|
||||
if let Some(&known) = self.known.get(&r) {
|
||||
return known;
|
||||
}
|
||||
let found = match object {
|
||||
Object::Stream(s)
|
||||
if s.dict.get(b"PCFullFont").is_some()
|
||||
&& s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(self.bytes.len()).ok()
|
||||
&& self.decodes < MAX_PROGRAM_DECODES =>
|
||||
{
|
||||
self.decodes = self.decodes.saturating_add(1);
|
||||
s.decoded_within(self.bytes.len()).is_ok_and(|data| data == self.bytes)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
self.known.insert(r, found);
|
||||
found
|
||||
}
|
||||
|
||||
/// Whether the resource's program is this one.
|
||||
fn written(&mut self, doc: &Document, w: &Written) -> bool {
|
||||
w.program.as_ref().is_some_and(|r| self.is(doc, r, &doc.get(r)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The program for `programs.bytes` that this page's `PCFull` resources, or else the first
|
||||
/// objects of the document, already have.
|
||||
fn existing_program(doc: &Document, fonts: &Dict, programs: &mut ProgramMatch) -> Option<Object> {
|
||||
written(doc, fonts)
|
||||
.find(|w| programs.written(doc, w))
|
||||
.map(|w| w.program)
|
||||
.or_else(|| doc.scan_objects().take(8192).find_map(|(r, o)| programs.is(doc, r, &o).then_some(Object::Ref(r))))
|
||||
}
|
||||
|
||||
/// A `PCFull` resource that [`embed_mapping`] wrote.
|
||||
@@ -263,21 +312,6 @@ struct Written {
|
||||
program: Object,
|
||||
}
|
||||
|
||||
impl Written {
|
||||
/// Whether its program is `bytes`.
|
||||
fn has_program(&self, doc: &Document, bytes: &[u8]) -> bool {
|
||||
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)| {
|
||||
@@ -291,14 +325,14 @@ fn written<'a>(doc: &'a Document, fonts: &'a Dict) -> impl Iterator<Item = Writt
|
||||
|
||||
/// A resource in `fonts` that already is this embedding: the same font name, program, glyph map
|
||||
/// and Unicode map. Editing the same text again reuses it instead of adding another copy.
|
||||
fn same_resource(doc: &Document, fonts: &Dict, name: &str, bytes: &[u8], glyph_map: &[u8], unicode: &[u8]) -> Option<String> {
|
||||
fn same_resource(doc: &Document, fonts: &Dict, name: &str, programs: &mut ProgramMatch, glyph_map: &[u8], unicode: &[u8]) -> Option<String> {
|
||||
written(doc, fonts)
|
||||
.find(|w| {
|
||||
let glyphs = w.cid.get(b"CIDToGIDMap").or_else(|| w.top.get(b"Encoding"));
|
||||
w.top.name(b"BaseFont") == Some(name.as_bytes())
|
||||
&& stream_data(doc, w.top.get(b"ToUnicode")).is_some_and(|d| d == unicode)
|
||||
&& stream_data(doc, glyphs).is_some_and(|d| d == glyph_map)
|
||||
&& w.has_program(doc, bytes)
|
||||
&& stream_is(doc, w.top.get(b"ToUnicode"), unicode)
|
||||
&& stream_is(doc, glyphs, glyph_map)
|
||||
&& programs.written(doc, w)
|
||||
})
|
||||
.map(|w| w.resource)
|
||||
}
|
||||
@@ -385,6 +419,48 @@ mod tests {
|
||||
assert_eq!(programs(&doc), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_program_search_decodes_each_candidate_once_and_few_in_all() {
|
||||
let bytes = b"the program".as_slice();
|
||||
let decoy = |doc: &mut Document| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"PCFullFont".to_vec(), Object::Bool(true));
|
||||
d.set(b"Length1".to_vec(), Object::Int(bytes.len() as i64));
|
||||
doc.add(Object::Stream(Stream::flate(d, &[0; 4096])))
|
||||
};
|
||||
let resource = |doc: &mut Document, program: ObjRef| {
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
|
||||
let mut cid = Dict::new();
|
||||
cid.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
|
||||
let mut top = Dict::new();
|
||||
top.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(cid)]));
|
||||
doc.add(Object::Dict(top))
|
||||
};
|
||||
// 200 resources sharing one decoy program decode it once.
|
||||
let mut doc = Document::new_empty();
|
||||
let shared = decoy(&mut doc);
|
||||
let mut fonts = Dict::new();
|
||||
for i in 0..200 {
|
||||
let r = resource(&mut doc, shared);
|
||||
fonts.set(format!("PCFull{i}").into_bytes(), Object::Ref(r));
|
||||
}
|
||||
let mut programs = ProgramMatch::new(bytes);
|
||||
assert_eq!(existing_program(&doc, &fonts, &mut programs), None);
|
||||
assert_eq!(programs.decodes, 1);
|
||||
// 100 distinct decoys decode a bounded number of times.
|
||||
let mut doc = Document::new_empty();
|
||||
let mut fonts = Dict::new();
|
||||
for i in 0..100 {
|
||||
let program = decoy(&mut doc);
|
||||
let r = resource(&mut doc, program);
|
||||
fonts.set(format!("PCFull{i}").into_bytes(), Object::Ref(r));
|
||||
}
|
||||
let mut programs = ProgramMatch::new(bytes);
|
||||
assert_eq!(existing_program(&doc, &fonts, &mut programs), None);
|
||||
assert_eq!(programs.decodes, MAX_PROGRAM_DECODES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_full_program_is_written_compressed_and_another_page_reuses_it() {
|
||||
let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
|
||||
|
||||
+64
-4
@@ -14,7 +14,7 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BinaryHeap, HashMap, HashSet};
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, Object};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
|
||||
|
||||
/// How the bytes of a string map to codes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -92,6 +92,15 @@ fn legacy_indic_font(base_font: &str) -> bool {
|
||||
/// A source PDF's sfnt program for an in-memory editor. Never a bundled font or an export
|
||||
/// fallback. Type 1/Type 3 programs cannot be consumed by an sfnt text renderer.
|
||||
pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option<Vec<u8>> {
|
||||
embedded_sfnt_within(doc, font, &mut DecodeBudget::new(2 * MAX_SFNT, 0))
|
||||
}
|
||||
|
||||
/// The largest embedded sfnt program read for the editor.
|
||||
const MAX_SFNT: usize = 16 << 20;
|
||||
|
||||
/// [`embedded_sfnt`], decoding against `budget`, which an enumeration of a page's fonts shares:
|
||||
/// each program decoded is charged whether or not it turns out to be usable.
|
||||
pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Option<Vec<u8>> {
|
||||
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
|
||||
let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref());
|
||||
let descriptor = dict(doc, descendant.as_ref().unwrap_or(font).get(b"FontDescriptor"))?;
|
||||
@@ -99,7 +108,7 @@ pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option<Vec<u8>> {
|
||||
let Some(object) = descriptor.get(key) else { continue };
|
||||
let object = doc.resolve(object);
|
||||
let Object::Stream(stream) = object.as_ref() else { continue };
|
||||
let Ok(mut bytes) = stream.decoded_within(16 << 20) else { continue };
|
||||
let Ok(mut bytes) = budget.decode_within(stream, MAX_SFNT) else { continue };
|
||||
let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue };
|
||||
if face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()).units_per_em == 0 {
|
||||
continue;
|
||||
@@ -275,6 +284,9 @@ const MAX_CMAP_ENTRIES: usize = 1 << 20;
|
||||
/// Most codespace or CID ranges kept from a CMap; Adobe's largest published CMaps have a few
|
||||
/// thousand.
|
||||
const MAX_CMAP_RANGES: usize = 1 << 16;
|
||||
/// Largest decoded Encoding or ToUnicode CMap stream read; real CMaps stay well under it, and a larger
|
||||
/// one is not decoded at all.
|
||||
const MAX_CMAP_BYTES: usize = 16 << 20;
|
||||
|
||||
/// Code → Unicode of a font. Explicit mappings and sequential ranges are kept as declared, so a range
|
||||
/// costs one record however many codes it covers; where two overlap, the one declared later wins.
|
||||
@@ -679,7 +691,7 @@ impl Metrics {
|
||||
m.codes = Codes::Two;
|
||||
if let Some(e) = font.get(b"Encoding").map(|e| doc.resolve(e))
|
||||
&& let Object::Stream(s) = &*e
|
||||
&& let Ok(data) = s.decoded()
|
||||
&& let Ok(data) = s.decoded_within(MAX_CMAP_BYTES)
|
||||
{
|
||||
let (spaces, cids) = parse_cmap(&data);
|
||||
if !spaces.is_empty() {
|
||||
@@ -1281,7 +1293,7 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode {
|
||||
}
|
||||
match font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() {
|
||||
Some(Object::Stream(s)) => {
|
||||
if let Ok(data) = s.decoded() {
|
||||
if let Ok(data) = s.decoded_within(MAX_CMAP_BYTES) {
|
||||
parse_to_unicode(&data, &mut out);
|
||||
}
|
||||
}
|
||||
@@ -1639,6 +1651,38 @@ mod tests {
|
||||
assert_eq!(m.encode("abc"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmap_streams_decode_within_a_size_cap() {
|
||||
// A valid CMap padded past the cap (a small Flate stream of whitespace) is not decoded whole.
|
||||
let to_unicode = b"1 begincodespacerange <0000> <FFFF> endcodespacerange 1 beginbfrange <0024> <0026> <0041> endbfrange";
|
||||
let encoding = b"1 begincodespacerange <0000> <FFFF> endcodespacerange 1 begincidrange <0000> <FFFF> 0 endcidrange";
|
||||
for padded in [false, true] {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut stream = |cmap: &[u8]| {
|
||||
let mut data = cmap.to_vec();
|
||||
if padded {
|
||||
data.resize(MAX_CMAP_BYTES + 1, b' ');
|
||||
}
|
||||
Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &data))))
|
||||
};
|
||||
let (tu, enc) = (stream(to_unicode), stream(encoding));
|
||||
let mut desc = Dict::new();
|
||||
desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2"));
|
||||
let f = font(
|
||||
&mut doc,
|
||||
vec![
|
||||
("Subtype", Object::name("Type0")),
|
||||
("Encoding", enc),
|
||||
("DescendantFonts", Object::Array(vec![Object::Dict(desc)])),
|
||||
("ToUnicode", tu),
|
||||
],
|
||||
);
|
||||
let m = Metrics::from_dict(&doc, &f);
|
||||
assert_eq!(m.decode(&[0, 0x24]) == "A", !padded, "ToUnicode, padded: {padded}");
|
||||
assert_eq!(matches!(m.codes, Codes::Ranges(_)), !padded, "Encoding, padded: {padded}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_fonts_use_to_unicode() {
|
||||
let mut doc = Document::new_empty();
|
||||
@@ -2257,4 +2301,20 @@ mod encoding_tests {
|
||||
assert_eq!(m.text_of(0x61).as_deref(), Some("a"));
|
||||
assert_eq!(m.decode(b"abc"), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusable_programs_still_spend_the_decode_budget() {
|
||||
// 128 resources naming one 1 MiB program that isn't a font: each decode is charged,
|
||||
// so an enumeration stops decoding once the shared budget is spent.
|
||||
let mut doc = Document::new_empty();
|
||||
let program = doc.add(pdfcraft_cos::Stream::flate(Dict::new(), &vec![0; 1 << 20]));
|
||||
let descriptor: Dict = [(b"FontFile2".to_vec(), Object::Ref(program))].into_iter().collect();
|
||||
let descriptor = doc.add(descriptor);
|
||||
let font: Dict = [(b"FontDescriptor".to_vec(), Object::Ref(descriptor))].into_iter().collect();
|
||||
let mut budget = DecodeBudget::new(64 << 20, 0);
|
||||
for _ in 0..128 {
|
||||
assert!(embedded_sfnt_within(&doc, &font, &mut budget).is_none());
|
||||
}
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,11 +105,11 @@ fn texts(doc: &Document, o: &Object) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef]) -> Option<FieldAction> {
|
||||
fn read(doc: &Document, o: &Object, pages: &[pdfcraft_cos::ObjRef], scripts: &mut pdfcraft_cos::DecodeBudget) -> Option<FieldAction> {
|
||||
let a = doc.resolve(o);
|
||||
let d = a.as_dict()?;
|
||||
Some(match d.name(b"S")? {
|
||||
b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o).unwrap_or_default()),
|
||||
b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o, scripts).unwrap_or_default()),
|
||||
b"URI" => FieldAction::Uri(d.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| s.to_text())).unwrap_or_default()),
|
||||
b"ResetForm" => FieldAction::Reset(d.get(b"Fields").map(|f| texts(doc, f)).unwrap_or_default()),
|
||||
b"Named" => FieldAction::Named(d.name(b"N").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_default()),
|
||||
@@ -145,13 +145,14 @@ pub fn field_actions(doc: &Document, name: &str) -> Result<Vec<(Trigger, FieldAc
|
||||
let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default();
|
||||
let aa = wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default();
|
||||
let mut out = Vec::new();
|
||||
let mut scripts = crate::script_budget();
|
||||
for t in Trigger::ALL {
|
||||
let o = match t.key() {
|
||||
None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()),
|
||||
Some(k) => aa.get(k).cloned(),
|
||||
};
|
||||
// An action chain (/Next) counts as its first action here.
|
||||
if let Some(a) = o.and_then(|o| read(doc, &o, &pages)) {
|
||||
if let Some(a) = o.and_then(|o| read(doc, &o, &pages, &mut scripts)) {
|
||||
out.push((t, a));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,12 @@ pub(crate) fn run_order(doc: &Document, fields: &[Field]) -> (Vec<(usize, bool)>
|
||||
(run, extra)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Read names compared while merging a field's reads.
|
||||
pub(crate) static NAMES_COMPARED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The form's calculation order (`fields` as [`fields`] reads them), with each calculated field's
|
||||
/// reads and issues, and Fix order's order.
|
||||
pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder {
|
||||
@@ -132,8 +138,11 @@ pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder {
|
||||
.map(|(f, _)| {
|
||||
let mut names = f.actions.calculate.reads();
|
||||
if let Some(js) = &f.actions.scripts.calculate {
|
||||
let mut known: HashSet<String> = names.iter().cloned().collect();
|
||||
for name in crate::af::calculation_reads(js) {
|
||||
if !names.contains(&name) {
|
||||
#[cfg(test)]
|
||||
NAMES_COMPARED.with(|n| n.set(n.get() + 1));
|
||||
if known.insert(name.clone()) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
+41
-18
@@ -14,7 +14,7 @@
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString};
|
||||
|
||||
mod actions;
|
||||
pub mod af;
|
||||
@@ -264,7 +264,7 @@ fn name_obj(text: &str) -> Object {
|
||||
const MAX_LAYER_STATE: usize = 1024;
|
||||
|
||||
/// A push button's mouse-up action (`/A`, or `/AA /U`, on the field or its widget).
|
||||
fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option<af::ButtonAction> {
|
||||
fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> Option<af::ButtonAction> {
|
||||
// A button PdfCraft generated for an XFA image field (`/PCXfaImage true`, no PDF action):
|
||||
// clicking it picks a picture, as `buttonImportIcon()` does.
|
||||
if matches!(d.get(b"PCXfaImage").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) {
|
||||
@@ -345,7 +345,7 @@ fn button_action(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> Option<af::But
|
||||
let preserve_rb = !a.get(b"PreserveRB").is_some_and(|p| matches!(&*doc.resolve(p), Object::Bool(false)));
|
||||
af::ButtonAction::SetLayers { changes, preserve_rb }
|
||||
}
|
||||
b"JavaScript" => af::button_script(&script(doc, &action)?),
|
||||
b"JavaScript" => af::button_script(&script(doc, &action, scripts)?),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -371,20 +371,30 @@ fn qualified_name(doc: &Document, r: ObjRef) -> Option<String> {
|
||||
(!parts.is_empty()).then(|| parts.join("."))
|
||||
}
|
||||
|
||||
/// The JavaScript of an action dictionary (`/JS` string or stream).
|
||||
fn script(doc: &Document, action: &Object) -> Option<String> {
|
||||
/// JavaScript streams one reading of the form (its fields, or its document scripts) may decode
|
||||
/// in all, however many actions name the same stream.
|
||||
const MAX_SCRIPT_BYTES: usize = 64 << 20;
|
||||
|
||||
/// A budget for the script streams of one reading of the form.
|
||||
fn script_budget() -> DecodeBudget {
|
||||
DecodeBudget::new(MAX_SCRIPT_BYTES, 0)
|
||||
}
|
||||
|
||||
/// The JavaScript of an action dictionary (`/JS` string or stream). A stream is decoded against
|
||||
/// `budget`, and is `None` once it is spent.
|
||||
fn script(doc: &Document, action: &Object, budget: &mut DecodeBudget) -> Option<String> {
|
||||
let a = doc.resolve(action);
|
||||
let js = a.as_dict()?.get(b"JS")?.clone();
|
||||
match &*doc.resolve(&js) {
|
||||
Object::String(s) => Some(s.to_text()),
|
||||
Object::Stream(s) => s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()),
|
||||
Object::Stream(s) => budget.decode(s).ok().map(|b| String::from_utf8_lossy(&b).into_owned()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A field's format/keystroke, validate and calculate scripts (field dictionary, or its only
|
||||
/// widget's when the field has none).
|
||||
fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions {
|
||||
fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut DecodeBudget) -> af::Actions {
|
||||
let aa = d.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).or_else(|| {
|
||||
(widgets.len() == 1)
|
||||
.then(|| doc.get(widgets[0]))
|
||||
@@ -393,7 +403,7 @@ fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef]) -> af::Actions {
|
||||
});
|
||||
let mut out = af::Actions::default();
|
||||
let Some(aa) = aa else { return out };
|
||||
let js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a));
|
||||
let mut js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a, scripts));
|
||||
if let Some(f) = js(b"F") {
|
||||
match af::parse_format(&f) {
|
||||
Some(fm) => out.format = fm,
|
||||
@@ -580,7 +590,7 @@ fn enumerate(doc: &Document) -> (Vec<Field>, usize) {
|
||||
None => Inherited::default(),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut seen = Seen { fields: std::collections::HashSet::new(), widgets: std::collections::HashSet::new(), scripts: script_budget() };
|
||||
if let Some(af) = &af {
|
||||
for f in af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() {
|
||||
if let Some(r) = f.as_ref() {
|
||||
@@ -604,7 +614,7 @@ fn adopt_page_fields(
|
||||
pages: &[ObjRef],
|
||||
base: &Inherited,
|
||||
page_of: &std::collections::HashMap<ObjRef, usize>,
|
||||
seen: &mut std::collections::HashSet<ObjRef>,
|
||||
seen: &mut Seen,
|
||||
out: &mut Vec<Field>,
|
||||
) {
|
||||
for p in pages {
|
||||
@@ -613,7 +623,7 @@ fn adopt_page_fields(
|
||||
let Some(annots) = annots.as_array() else { continue };
|
||||
for e in annots {
|
||||
let Some(r) = e.as_ref() else { continue };
|
||||
if seen.contains(&r) {
|
||||
if seen.fields.contains(&r) || seen.widgets.contains(&r) {
|
||||
continue;
|
||||
}
|
||||
let obj = doc.get(r);
|
||||
@@ -624,7 +634,7 @@ fn adopt_page_fields(
|
||||
if !d.contains(b"FT") && !d.contains(b"T") && !d.contains(b"Parent") {
|
||||
continue;
|
||||
}
|
||||
if let Some(root) = unlisted_root(doc, r, seen) {
|
||||
if let Some(root) = unlisted_root(doc, r, &seen.fields) {
|
||||
walk(doc, root, base, page_of, seen, out, 0);
|
||||
}
|
||||
}
|
||||
@@ -754,6 +764,15 @@ pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What one reading of the form's fields has used: the field and widget dictionaries read, and
|
||||
/// the script budget their actions share.
|
||||
struct Seen {
|
||||
fields: std::collections::HashSet<ObjRef>,
|
||||
/// A widget belongs to the first field that lists it, however often it is listed.
|
||||
widgets: std::collections::HashSet<ObjRef>,
|
||||
scripts: DecodeBudget,
|
||||
}
|
||||
|
||||
/// Read the field at `r` and the fields under it into `out`, with what they inherit from
|
||||
/// `parent`: kids with a name are fields of their own, kids without are this field's widgets.
|
||||
fn walk(
|
||||
@@ -761,11 +780,11 @@ fn walk(
|
||||
r: ObjRef,
|
||||
parent: &Inherited,
|
||||
page_of: &std::collections::HashMap<ObjRef, usize>,
|
||||
seen: &mut std::collections::HashSet<ObjRef>,
|
||||
seen: &mut Seen,
|
||||
out: &mut Vec<Field>,
|
||||
depth: usize,
|
||||
) {
|
||||
if depth > 64 || !seen.insert(r) {
|
||||
if depth > 64 || !seen.fields.insert(r) {
|
||||
return;
|
||||
}
|
||||
let obj = doc.get(r);
|
||||
@@ -807,10 +826,8 @@ fn walk(
|
||||
}
|
||||
return;
|
||||
}
|
||||
let widget_refs: Vec<ObjRef> = if d.contains(b"Rect") { vec![r] } else { kids };
|
||||
let actions = actions_of(doc, d, &widget_refs);
|
||||
let button =
|
||||
(inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0).then(|| button_action(doc, d, &widget_refs)).flatten();
|
||||
// A field of no kind this reads is skipped before it claims widgets or scripts, which a later
|
||||
// field listing them then reads.
|
||||
let ff = inh.ff.unwrap_or(0);
|
||||
let kind = match inh.ft.as_deref() {
|
||||
Some(b"Tx") => FieldKind::Text,
|
||||
@@ -822,6 +839,12 @@ fn walk(
|
||||
Some(b"Sig") => FieldKind::Signature,
|
||||
_ => return,
|
||||
};
|
||||
let widget_refs: Vec<ObjRef> = if d.contains(b"Rect") { vec![r] } else { kids };
|
||||
let widget_refs: Vec<ObjRef> = widget_refs.into_iter().filter(|w| seen.widgets.insert(*w)).collect();
|
||||
let actions = actions_of(doc, d, &widget_refs, &mut seen.scripts);
|
||||
let button = (inh.ft.as_deref() == Some(b"Btn") && inh.ff.unwrap_or(0) & flags::PUSH_BUTTON != 0)
|
||||
.then(|| button_action(doc, d, &widget_refs, &mut seen.scripts))
|
||||
.flatten();
|
||||
let widgets = widget_refs
|
||||
.into_iter()
|
||||
.filter_map(|w| {
|
||||
|
||||
@@ -176,6 +176,7 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> {
|
||||
let Some(names) = cat.as_dict().and_then(|c| c.get(b"Names")).map(|n| doc.resolve(n)) else { return Vec::new() };
|
||||
let Some(tree) = names.as_dict().and_then(|n| n.get(b"JavaScript")).cloned() else { return Vec::new() };
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
let mut scripts = crate::script_budget();
|
||||
// Kids stay unresolved on the stack, and each node is read once: a node listed many times,
|
||||
// or among its own kids, must not multiply the work.
|
||||
let mut stack = vec![tree];
|
||||
@@ -203,7 +204,7 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> {
|
||||
Object::String(s) => s.to_text(),
|
||||
_ => continue,
|
||||
};
|
||||
if let Some(js) = crate::script(doc, v) {
|
||||
if let Some(js) = crate::script(doc, v, &mut scripts) {
|
||||
out.push((key, js));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2981,3 +2981,95 @@ fn page_walk_skips_repeated_and_cyclic_kids_without_queueing_them() {
|
||||
assert_eq!(walk.next(), None);
|
||||
assert_eq!(page_refs(&doc), [page]);
|
||||
}
|
||||
|
||||
/// A Calculate script naming thousands of fields is merged with the field's other reads in one
|
||||
/// pass, not by searching the names so far for each one.
|
||||
#[test]
|
||||
fn a_script_reading_many_fields_merges_its_reads_in_one_pass() {
|
||||
use crate::calc_order::NAMES_COMPARED;
|
||||
let mut doc = one_page();
|
||||
add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("total")).unwrap();
|
||||
let n = 2_000;
|
||||
let script: String = (0..n).map(|i| format!("getField(\"f{i}\").value + ")).collect::<String>() + "0";
|
||||
set_field_script(&mut doc, "total", "calculate", Some(&format!("event.value = {script};"))).unwrap();
|
||||
NAMES_COMPARED.with(|c| c.set(0));
|
||||
let order = calculation_order(&doc, &fields(&doc));
|
||||
assert_eq!(order.fields[0].reads.len(), n);
|
||||
let compared = NAMES_COMPARED.with(std::cell::Cell::get);
|
||||
assert!(compared <= 4 * n, "{compared} names compared for {n} reads");
|
||||
}
|
||||
|
||||
/// 300 fields and 300 document scripts that all name one 1 MiB JavaScript stream.
|
||||
fn repeated_script_references() -> Document {
|
||||
use pdfcraft_cos::Stream;
|
||||
let dict = |entries: Vec<(&[u8], Object)>| -> Object { Object::Dict(entries.into_iter().map(|(k, v)| (k.to_vec(), v)).collect()) };
|
||||
let mut doc = Document::new_empty();
|
||||
let js = doc.add(Stream::flate(pdfcraft_cos::Dict::new(), &vec![b'x'; 1 << 20]));
|
||||
let action = doc.add(dict(vec![(b"S", Object::Name(b"JavaScript".to_vec())), (b"JS", Object::Ref(js))]));
|
||||
let fields: Vec<Object> = (0..300)
|
||||
.map(|i| {
|
||||
Object::Ref(doc.add(dict(vec![
|
||||
(b"FT", Object::Name(b"Tx".to_vec())),
|
||||
(b"T", Object::String(pdfcraft_cos::PdfString::literal(format!("f{i}")))),
|
||||
(b"Rect", Object::Array([0, 0, 10, 10].map(Object::from).to_vec())),
|
||||
(b"AA", dict(vec![(b"C", Object::Ref(action))])),
|
||||
])))
|
||||
})
|
||||
.collect();
|
||||
let names: Vec<Object> =
|
||||
(0..300).flat_map(|i| [Object::String(pdfcraft_cos::PdfString::literal(format!("s{i:03}"))), Object::Ref(action)]).collect();
|
||||
let catalog = doc.add(dict(vec![
|
||||
(b"Type", Object::Name(b"Catalog".to_vec())),
|
||||
(b"AcroForm", dict(vec![(b"Fields", Object::Array(fields))])),
|
||||
(b"Names", dict(vec![(b"JavaScript", dict(vec![(b"Names", Object::Array(names))]))])),
|
||||
]));
|
||||
doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog));
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_script_references_share_one_decode_budget() {
|
||||
let doc = repeated_script_references();
|
||||
let limit = MAX_SCRIPT_BYTES;
|
||||
let named: usize = document_scripts_named(&doc).iter().map(|(_, js)| js.len()).sum();
|
||||
assert!(named > 0 && named <= limit, "{named} bytes of document scripts");
|
||||
let all = fields(&doc);
|
||||
assert_eq!(all.len(), 300);
|
||||
let calculate: usize = all.iter().filter_map(|f| f.actions.scripts.calculate.as_ref()).map(String::len).sum();
|
||||
assert!(calculate > 0 && calculate <= limit, "{calculate} bytes of field scripts");
|
||||
}
|
||||
|
||||
/// A widget listed many times, by one field's `/Kids` or by a `/Kids` array several fields share,
|
||||
/// is read once: the first field to list it owns it.
|
||||
#[test]
|
||||
fn a_widget_listed_repeatedly_is_read_once() {
|
||||
let kids = "7 0 R ".repeat(500);
|
||||
let doc = document(&[
|
||||
"<< /Type /Catalog /Pages 4 0 R /AcroForm << /Fields [2 0 R 3 0 R] >> >>".into(),
|
||||
"<< /FT /Tx /T (a) /Kids 6 0 R >>".into(),
|
||||
"<< /FT /Tx /T (b) /Kids 6 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [5 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 4 0 R /MediaBox [0 0 100 100] /Annots [7 0 R] >>".into(),
|
||||
format!("[{kids}]"),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 2 0 R /Rect [0 0 10 10] >>".into(),
|
||||
]);
|
||||
let all = fields(&doc);
|
||||
let widgets: Vec<usize> = all.iter().map(|f| f.widgets.len()).collect();
|
||||
assert_eq!(widgets, [1, 0], "{:?}", all.iter().map(|f| &f.name).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// A field of a kind that isn't read doesn't claim the widget it shares with a field that is.
|
||||
#[test]
|
||||
fn a_skipped_field_leaves_its_widget_to_the_next() {
|
||||
let doc = document(&[
|
||||
"<< /Type /Catalog /Pages 4 0 R /AcroForm << /Fields [2 0 R 3 0 R] >> >>".into(),
|
||||
"<< /FT /Zz /T (odd) /Kids [6 0 R] >>".into(),
|
||||
"<< /FT /Tx /T (b) /Kids [6 0 R] >>".into(),
|
||||
"<< /Type /Pages /Kids [5 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 4 0 R /MediaBox [0 0 100 100] /Annots [6 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 3 0 R /Rect [0 0 10 10] >>".into(),
|
||||
]);
|
||||
let all = fields(&doc);
|
||||
let widgets: Vec<(&str, usize)> = all.iter().map(|f| (f.name.as_str(), f.widgets.len())).collect();
|
||||
assert_eq!(widgets, [("b", 1)]);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::rc::Rc;
|
||||
use crate::Limits;
|
||||
use crate::xfa::{
|
||||
HNode, XHost, XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaOutcome, child_named, clip, clip_result, descendants_named, instances,
|
||||
resolve_som, run_within,
|
||||
reset_names, resolve_som, run_within,
|
||||
};
|
||||
|
||||
/// Deepest nesting the parser follows.
|
||||
@@ -1466,8 +1466,7 @@ impl Interp<'_> {
|
||||
}
|
||||
"resetdata" => {
|
||||
let list = self.arg_str(args, 0)?;
|
||||
let names: Vec<String> = list.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect();
|
||||
self.h.push(XfaEffect::ResetData(names));
|
||||
self.h.push(XfaEffect::ResetData(reset_names(&list)));
|
||||
Ok(Val::Null)
|
||||
}
|
||||
"print" => {
|
||||
@@ -1618,19 +1617,13 @@ impl Interp<'_> {
|
||||
"presence" => {
|
||||
let p = self.to_str(&v);
|
||||
if matches!(p.as_str(), "visible" | "invisible" | "hidden" | "inactive") {
|
||||
if let Some(n) = self.h.nodes.get_mut(i) {
|
||||
n.presence = p.clone();
|
||||
}
|
||||
self.h.push(XfaEffect::SetPresence { som, presence: p });
|
||||
self.h.set_state(i, XfaEffect::SetPresence { som, presence: p });
|
||||
}
|
||||
}
|
||||
"access" => {
|
||||
let a = self.to_str(&v);
|
||||
if matches!(a.as_str(), "open" | "readOnly" | "protected" | "nonInteractive") {
|
||||
if let Some(n) = self.h.nodes.get_mut(i) {
|
||||
n.access = a.clone();
|
||||
}
|
||||
self.h.push(XfaEffect::SetAccess { som, access: a });
|
||||
self.h.set_state(i, XfaEffect::SetAccess { som, access: a });
|
||||
}
|
||||
}
|
||||
other => {
|
||||
@@ -1646,6 +1639,8 @@ impl Interp<'_> {
|
||||
return Err(format!("{som} has no {other} to assign to"));
|
||||
}
|
||||
for k in kids {
|
||||
// Every destination copies the value: charge each copy.
|
||||
self.charge(self.cost(&v))?;
|
||||
self.assign_to(Val::Node(k), "rawValue", v.clone())?;
|
||||
}
|
||||
}
|
||||
@@ -1654,6 +1649,7 @@ impl Interp<'_> {
|
||||
}
|
||||
Val::List(items) => {
|
||||
for it in items {
|
||||
self.charge(self.cost(&v))?;
|
||||
self.assign_to(it, prop, v.clone())?;
|
||||
}
|
||||
Ok(())
|
||||
|
||||
+100
-14
@@ -203,6 +203,66 @@ struct Host {
|
||||
console: Vec<String>,
|
||||
requests: Vec<Request>,
|
||||
doc: DocInfo,
|
||||
/// Bytes of text kept in alerts, console lines and requests, against [`xfa::MAX_PAYLOAD`].
|
||||
kept: usize,
|
||||
/// Alerts, console lines and requests the limits left out.
|
||||
dropped: usize,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not.
|
||||
fn room(&mut self, bytes: usize) -> bool {
|
||||
match self.kept.checked_add(bytes).filter(|k| *k <= xfa::MAX_PAYLOAD) {
|
||||
Some(k) => {
|
||||
self.kept = k;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn alert(&mut self, m: String) {
|
||||
if self.alerts.len() >= xfa::MAX_ALERTS {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
} else if self.room(m.len()) {
|
||||
self.alerts.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
fn print(&mut self, m: String) {
|
||||
if self.console.len() >= xfa::MAX_CONSOLE {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
} else if self.room(m.len()) {
|
||||
self.console.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record `r`, its names and URLs cut to a message's length, up to [`xfa::MAX_EFFECTS`]
|
||||
/// requests and the run's byte budget.
|
||||
fn request(&mut self, r: Request) {
|
||||
if self.requests.len() >= xfa::MAX_EFFECTS {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
return;
|
||||
}
|
||||
let r = match r {
|
||||
Request::Reset(names) => Request::Reset(names.into_iter().map(xfa::clip).collect()),
|
||||
Request::LaunchUrl(u) => Request::LaunchUrl(xfa::clip(u)),
|
||||
Request::Submit(u) => Request::Submit(xfa::clip(u)),
|
||||
Request::Focus(n) => Request::Focus(xfa::clip(n)),
|
||||
other => other,
|
||||
};
|
||||
let bytes = match &r {
|
||||
Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::<String>())),
|
||||
Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(),
|
||||
_ => 0,
|
||||
};
|
||||
if self.room(bytes) {
|
||||
self.requests.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Shared = RefCell<Host>;
|
||||
@@ -223,6 +283,19 @@ fn text(v: &JsValue, ctx: &mut Context) -> JsResult<String> {
|
||||
Ok(v.to_string(ctx)?.to_std_string_escaped())
|
||||
}
|
||||
|
||||
/// [`text`] cut to a message's length ([`xfa::clip`]), without first copying all of a longer
|
||||
/// string.
|
||||
fn text_line(v: &JsValue, ctx: &mut Context) -> JsResult<String> {
|
||||
if v.is_undefined() || v.is_null() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let js = v.to_string(ctx)?;
|
||||
// Two UTF-16 units per character at most: the clip still sees more than a line's worth.
|
||||
let keep = xfa::MAX_LINE.saturating_add(1).saturating_mul(2);
|
||||
let js = if js.len() > keep { js.get(..keep).unwrap_or_default() } else { js };
|
||||
Ok(xfa::clip(js.to_std_string_escaped()))
|
||||
}
|
||||
|
||||
fn arg(args: &[JsValue], i: usize) -> JsValue {
|
||||
args.get(i).cloned().unwrap_or_default()
|
||||
}
|
||||
@@ -440,7 +513,7 @@ fn field_object(ctx: &mut Context, name: &str) -> JsObject {
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
fn set_focus(_: &JsValue, _: &[JsValue], name: &JsString, c: &mut Context) -> JsResult<JsValue> {
|
||||
host(c)?.borrow_mut().requests.push(Request::Focus(name.to_std_string_escaped()));
|
||||
host(c)?.borrow_mut().request(Request::Focus(name.to_std_string_escaped()));
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
@@ -494,12 +567,12 @@ fn get_nth_field_name(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsRes
|
||||
|
||||
fn reset_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let names = strings(&arg(args, 0), ctx)?;
|
||||
host(ctx)?.borrow_mut().requests.push(Request::Reset(names));
|
||||
host(ctx)?.borrow_mut().request(Request::Reset(names));
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
host(ctx)?.borrow_mut().requests.push(Request::Print);
|
||||
host(ctx)?.borrow_mut().request(Request::Print);
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
@@ -512,7 +585,7 @@ fn submit_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
|
||||
}
|
||||
_ => text(&a, ctx)?,
|
||||
};
|
||||
host(ctx)?.borrow_mut().requests.push(Request::Submit(url));
|
||||
host(ctx)?.borrow_mut().request(Request::Submit(url));
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
@@ -534,38 +607,38 @@ fn page_num_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<Js
|
||||
let last = h.doc.num_pages.saturating_sub(1);
|
||||
let p = (n.max(0.0) as usize).min(last);
|
||||
h.doc.page = p;
|
||||
h.requests.push(Request::GoToPage(p));
|
||||
h.request(Request::GoToPage(p));
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
// ── app, util, console ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A message argument: a string, or an object with `cMsg`.
|
||||
/// A message argument: a string, or an object with `cMsg`, cut to a message's length.
|
||||
fn message(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult<String> {
|
||||
match v.as_object() {
|
||||
Some(o) if !o.is_array() && !o.is_callable() => {
|
||||
let m = o.get(JsString::from(key), ctx)?;
|
||||
text(&m, ctx)
|
||||
text_line(&m, ctx)
|
||||
}
|
||||
_ => text(v, ctx),
|
||||
_ => text_line(v, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let m = message(&arg(args, 0), "cMsg", ctx)?;
|
||||
host(ctx)?.borrow_mut().alerts.push(m);
|
||||
host(ctx)?.borrow_mut().alert(m);
|
||||
// The OK button.
|
||||
Ok(JsValue::from(1))
|
||||
}
|
||||
|
||||
fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
host(ctx)?.borrow_mut().requests.push(Request::Beep);
|
||||
host(ctx)?.borrow_mut().request(Request::Beep);
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let u = message(&arg(args, 0), "cURL", ctx)?;
|
||||
host(ctx)?.borrow_mut().requests.push(Request::LaunchUrl(u));
|
||||
host(ctx)?.borrow_mut().request(Request::LaunchUrl(u));
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
@@ -575,8 +648,8 @@ fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult<JsValue> {
|
||||
}
|
||||
|
||||
fn println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let m = text(&arg(args, 0), ctx)?;
|
||||
host(ctx)?.borrow_mut().console.push(m);
|
||||
let m = text_line(&arg(args, 0), ctx)?;
|
||||
host(ctx)?.borrow_mut().print(m);
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
@@ -752,7 +825,11 @@ fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue>
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
let c = b[i];
|
||||
let run = b[i..].iter().take_while(|x| **x == c).count();
|
||||
// Only date letters read as runs; a literal is copied one character at a time, so
|
||||
// measuring its run would rescan a long literal stretch at every position.
|
||||
let run = if b"ymdHhMst".contains(&c) { b[i..].iter().take_while(|x| **x == c).count() } else { 1 };
|
||||
#[cfg(test)]
|
||||
PRINTD_SCANNED.fetch_add(run as u64, std::sync::atomic::Ordering::Relaxed);
|
||||
let push_num = |out: &mut String, v: i64, n: usize| {
|
||||
if n >= 2 {
|
||||
out.push_str(&format!("{v:02}"));
|
||||
@@ -1011,6 +1088,10 @@ fn event_object(ctx: &mut Context, e: &Event) -> JsObject {
|
||||
// stack a script can use: brackets and prefix operators by the limits below, and every other
|
||||
// construct (a few KiB per level, several bytes of source each) by the length limit.
|
||||
|
||||
/// Bytes `util.printd` has scanned for runs of a format character, for tests that bound its work.
|
||||
#[cfg(test)]
|
||||
pub(crate) static PRINTD_SCANNED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// Longest script, in bytes.
|
||||
pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024;
|
||||
/// Largest width or precision accepted by `util.printf`.
|
||||
@@ -1179,6 +1260,8 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<FieldSta
|
||||
console: Vec::new(),
|
||||
requests: Vec::new(),
|
||||
doc: doc.clone(),
|
||||
kept: 0,
|
||||
dropped: 0,
|
||||
}));
|
||||
let mut out = Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), ..Default::default() };
|
||||
let result = (|| -> JsResult<()> {
|
||||
@@ -1209,6 +1292,9 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<FieldSta
|
||||
out.alerts = h.alerts;
|
||||
out.console = h.console;
|
||||
out.requests = h.requests;
|
||||
if h.dropped > 0 {
|
||||
out.console.push(format!("the script's messages, console lines and requests passed their limits; {} more were left out", h.dropped));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -244,6 +244,46 @@ fn document_requests_and_console() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Bytes of text a run handed back in its messages, console lines and requests.
|
||||
fn kept_bytes(o: &Outcome) -> usize {
|
||||
let req = |r: &Request| match r {
|
||||
Request::Reset(names) => names.iter().map(String::len).sum(),
|
||||
Request::Submit(u) | Request::LaunchUrl(u) | Request::Focus(u) => u.len(),
|
||||
_ => 0,
|
||||
};
|
||||
o.console.iter().chain(&o.alerts).map(String::len).sum::<usize>() + o.requests.iter().map(req).sum::<usize>()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn console_alerts_and_requests_are_capped_in_count_and_bytes() {
|
||||
let o = go(
|
||||
"var s = 'x'; while (s.length < 8192) s += s; \
|
||||
for (var i = 0; i < 1500; i++) { console.println(s); app.alert(s); } \
|
||||
var a = []; for (var i = 0; i < 2000; i++) a.push(s); \
|
||||
for (var i = 0; i < 5; i++) this.resetForm(a); \
|
||||
for (var i = 0; i < 12000; i++) app.launchURL('u');",
|
||||
&Event::field("Mouse Up", "agree", ""),
|
||||
);
|
||||
assert_eq!(o.error, None);
|
||||
assert!(o.console.len() <= crate::xfa::MAX_CONSOLE + 1, "{} console lines", o.console.len());
|
||||
assert!(o.alerts.len() <= crate::xfa::MAX_ALERTS, "{} alerts", o.alerts.len());
|
||||
assert!(o.requests.len() <= crate::xfa::MAX_EFFECTS, "{} requests", o.requests.len());
|
||||
assert!(o.console.iter().chain(&o.alerts).all(|m| m.chars().count() <= 4_097));
|
||||
assert!(kept_bytes(&o) <= crate::xfa::MAX_PAYLOAD, "{} bytes kept", kept_bytes(&o));
|
||||
assert!(o.console.last().is_some_and(|l| l.contains("left out")), "{:?}", o.console.last());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printd_reads_a_long_literal_run_in_linear_work() {
|
||||
crate::PRINTD_SCANNED.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
let o = go("event.value = util.printd(new Array(20001).join('x'), new Date(2024, 0, 5)).length;", &Event::field("Calculate", "total", ""));
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.value, "20000");
|
||||
// Other tests format short dates at the same time; they add a few dozen bytes at most.
|
||||
let scanned = crate::PRINTD_SCANNED.load(std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(scanned < 40_000, "printd scanned {scanned} bytes for a 20000-byte format");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_level_functions_are_available() {
|
||||
let o = run(
|
||||
@@ -415,6 +455,27 @@ mod xfa_model {
|
||||
assert_eq!(o.effects, vec![XfaEffect::SetValue { som: "form1[0].page1[0].total[0]".into(), value: "5".into() }]);
|
||||
}
|
||||
|
||||
/// A long value written again and again stops at the run's byte budget, and the write that
|
||||
/// does not fit is left out of the field the script reads as well as of the effect.
|
||||
#[test]
|
||||
fn a_value_past_the_byte_budget_is_left_out_of_reads_and_effects_alike() {
|
||||
let o = go(
|
||||
"var s = 'x'; while (s.length < 65536) s += s; \
|
||||
for (var i = 0; i < 300; i++) details.note.rawValue = ('00000' + i).slice(-6) + s.slice(6); \
|
||||
details.note.rawValue.slice(0, 6)",
|
||||
"form1[0].page1[0].total[0]",
|
||||
"calculate",
|
||||
);
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
let set: Vec<&str> = o
|
||||
.effects
|
||||
.iter()
|
||||
.filter_map(|e| if let XfaEffect::SetValue { value, .. } = e { Some(value.get(..6).unwrap_or(value)) } else { None })
|
||||
.collect();
|
||||
assert_eq!(set.len(), 1);
|
||||
assert_eq!(o.result.as_deref(), set.first().copied());
|
||||
}
|
||||
|
||||
/// The script runs through `eval`, so code built from strings at run time is held to the
|
||||
/// script's own size and nesting checks instead of being refused outright.
|
||||
#[test]
|
||||
@@ -625,6 +686,74 @@ mod xfa_model {
|
||||
assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.chars().count() <= 4_097));
|
||||
}
|
||||
|
||||
/// Past the byte budget, adding or removing a row is left out of the snapshot as well as the
|
||||
/// effects, so the script never counts rows the form will not get.
|
||||
#[test]
|
||||
fn rows_changed_past_the_byte_budget_are_left_out_of_the_snapshot_too() {
|
||||
let o = go(
|
||||
"var s = 'x'; while (s.length < 4096) s += s; for (var i = 0; i < 9000; i++) xfa.host.gotoURL(s); \
|
||||
table._row.removeInstance(0); var added = table._row.addInstance(1); (added === null) + ':' + table._row.count",
|
||||
"form1[0].page1[0].go[0]",
|
||||
"click",
|
||||
);
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
assert!(o.notes.iter().any(|n| n.contains("MiB")), "{:?}", o.notes);
|
||||
assert!(!o.effects.iter().any(|e| matches!(e, XfaEffect::AddInstance { .. } | XfaEffect::RemoveInstance { .. })));
|
||||
assert_eq!(o.result.as_deref(), Some("true:2"));
|
||||
}
|
||||
|
||||
/// Past the effect cap, presence and access set on a new object are left out of the snapshot
|
||||
/// as well as the effects; one set again on an object that already has its effect still lands.
|
||||
#[test]
|
||||
fn presence_and_access_past_the_effect_cap_are_left_out_of_the_snapshot_too() {
|
||||
let read = "qty.presence + ':' + qty.access + ':' + price.presence";
|
||||
let before = go(read, "form1[0].page1[0]", "click").result;
|
||||
let o = go(
|
||||
&format!(
|
||||
"price.presence = 'invisible'; for (var i = 0; i < 20000; i++) xfa.host.beep(); qty.presence = 'hidden'; qty.access = 'readOnly'; price.presence = 'hidden'; {read}"
|
||||
),
|
||||
"form1[0].page1[0]",
|
||||
"click",
|
||||
);
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
assert_eq!(o.effects.len(), MAX_EFFECTS);
|
||||
let unchanged = before.as_deref().and_then(|b| b.rsplit_once(':')).map(|(qty, _)| format!("{qty}:hidden"));
|
||||
assert_eq!(o.result, unchanged);
|
||||
assert!(o.effects.contains(&XfaEffect::SetPresence { som: "form1[0].page1[0].price[0]".into(), presence: "hidden".into() }));
|
||||
assert!(!o.effects.iter().any(|e| matches!(e, XfaEffect::SetPresence { som, .. } | XfaEffect::SetAccess { som, .. } if som.contains("qty"))));
|
||||
}
|
||||
|
||||
/// Past the effect cap, a value written to a field with no effect yet is left out of reads as
|
||||
/// well as the effects; a field written again before the cap still takes its new value.
|
||||
#[test]
|
||||
fn a_value_past_the_effect_cap_is_left_out_of_reads_and_effects_alike() {
|
||||
let o = go(
|
||||
"price.rawValue = 1; for (var i = 0; i < 20000; i++) xfa.host.beep(); qty.rawValue = 9; price.rawValue = 3; qty.rawValue + ':' + price.rawValue",
|
||||
"form1[0].page1[0]",
|
||||
"click",
|
||||
);
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
assert_eq!(o.effects.len(), MAX_EFFECTS);
|
||||
assert_eq!(o.result.as_deref(), Some("4:3"));
|
||||
assert!(!o.effects.iter().any(|e| matches!(e, XfaEffect::SetValue { som, .. } if som.contains("qty"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_and_reset_lists_are_clipped_and_deduplicated() {
|
||||
let o = go(
|
||||
"var s = 'x'; while (s.length < 65536) s += s; for (var i = 0; i < 600; i++) xfa.host.gotoURL(s + i); \
|
||||
var r = 'a,'; while (r.length < 262144) r += r; xfa.host.resetData(r);",
|
||||
"form1[0].page1[0]",
|
||||
"initialize",
|
||||
);
|
||||
assert_eq!(o.error, None);
|
||||
let urls: Vec<&String> = o.effects.iter().filter_map(|e| if let XfaEffect::LaunchUrl(u) = e { Some(u) } else { None }).collect();
|
||||
assert_eq!(urls.len(), 600);
|
||||
assert!(urls.iter().all(|u| u.chars().count() <= 4_097));
|
||||
let names: usize = o.effects.iter().map(|e| if let XfaEffect::ResetData(n) = e { n.len() } else { 0 }).sum();
|
||||
assert_eq!(names, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slow_engine_start_does_not_count_against_the_script() {
|
||||
let ms = std::time::Duration::from_millis;
|
||||
@@ -676,6 +805,54 @@ mod formcalc {
|
||||
o.result.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Assigning one long value to many fields at once stops at the run's byte budget and is
|
||||
/// charged per copy, and a reset list names each field once.
|
||||
#[test]
|
||||
fn bulk_assignments_and_reset_lists_are_bounded() {
|
||||
let f = |i: usize| super::xfa_model::field("f", &format!("form1[0].page1[0].f[{i}]"), "", false);
|
||||
let root = XfaNode {
|
||||
name: "form1".into(),
|
||||
som: "form1[0]".into(),
|
||||
kind: Some(XfaKind::Subform),
|
||||
children: vec![XfaNode {
|
||||
name: "page1".into(),
|
||||
som: "form1[0].page1[0]".into(),
|
||||
kind: Some(XfaKind::Subform),
|
||||
children: (0..1300).map(f).collect(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let ev = XfaEvent { activity: "initialize".into(), target: "form1[0]".into(), ..Default::default() };
|
||||
let run = |script: &str| run_formcalc(script, &ev, &XfaDoc::default(), &root, Limits::default());
|
||||
let o = run("var s = \"x\"\nwhile (Len(s) < 65536) do s = Concat(s, s) endwhile\npage1.f = s");
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
let kept: usize = o.effects.iter().map(|e| if let XfaEffect::SetValue { value, .. } = e { value.len() } else { 0 }).sum();
|
||||
assert!(kept <= MAX_PAYLOAD, "{kept} bytes of values kept");
|
||||
assert!(o.notes.iter().any(|n| n.contains("left out")), "{:?}", o.notes);
|
||||
// 1300 copies of a 1 MiB value are more work than a script may do.
|
||||
let o = run("var s = \"x\"\nwhile (Len(s) < 1048576) do s = Concat(s, s) endwhile\npage1.f = s");
|
||||
assert_eq!(o.error.as_deref(), Some("the script ran too long"), "{:?}", o.notes);
|
||||
let o = run("var r = \"a,\"\nwhile (Len(r) < 262144) do r = Concat(r, r) endwhile\n$host.resetData(r)\n$host.resetData(r)");
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
let names: usize = o.effects.iter().map(|e| if let XfaEffect::ResetData(n) = e { n.len() } else { 0 }).sum();
|
||||
assert_eq!(names, 2);
|
||||
}
|
||||
|
||||
/// FormCalc presence and access past the effect cap are left out of the snapshot too.
|
||||
#[test]
|
||||
fn formcalc_presence_and_access_past_the_effect_cap_are_left_out_of_the_snapshot_too() {
|
||||
let o = go(
|
||||
"for i = 1 upto 20000 do $host.beep() endfor\nqty.presence = \"hidden\"\nqty.access = \"readOnly\"\nConcat(qty.presence, \":\", qty.access)",
|
||||
"form1[0].page1[0]",
|
||||
"click",
|
||||
);
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
assert_eq!(o.effects.len(), MAX_EFFECTS);
|
||||
let before = go("Concat(qty.presence, \":\", qty.access)", "form1[0].page1[0]", "click");
|
||||
assert_eq!(o.result, before.result);
|
||||
}
|
||||
|
||||
/// FormCalc reads `formattedValue` as the field's picture shows it too, until the script
|
||||
/// sets the value.
|
||||
#[test]
|
||||
|
||||
+159
-40
@@ -8,7 +8,7 @@
|
||||
//! children, then its siblings, then each ancestor's children.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use boa_engine::object::ObjectInitializer;
|
||||
use boa_engine::object::builtins::{JsArray, JsProxy};
|
||||
@@ -158,7 +158,10 @@ pub const MAX_ALERTS: usize = 100;
|
||||
/// Most console lines one run may print.
|
||||
pub const MAX_CONSOLE: usize = 1_000;
|
||||
/// Longest message or console line kept, in characters.
|
||||
const MAX_LINE: usize = 4_096;
|
||||
pub(crate) const MAX_LINE: usize = 4_096;
|
||||
/// Most bytes of text one run may hand back or write: messages, console lines, URLs, names
|
||||
/// and the values it sets. Past it, more are left out and noted.
|
||||
pub(crate) const MAX_PAYLOAD: usize = 32 << 20;
|
||||
|
||||
/// Longest value a script may put in a field (characters); longer ones are cut and noted.
|
||||
const MAX_VALUE: usize = 65_536;
|
||||
@@ -179,6 +182,47 @@ pub(crate) fn clip_value(v: String) -> (String, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The field names of a `resetData` list (comma-separated), each cut to [`MAX_LINE`] and
|
||||
/// named once, at most as many as a snapshot holds nodes.
|
||||
pub(crate) fn reset_names(list: &str) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
for name in list.split(',').map(str::trim).filter(|s| !s.is_empty()) {
|
||||
if out.len() >= MAX_NODES {
|
||||
break;
|
||||
}
|
||||
let name = clip(name.to_string());
|
||||
if seen.insert(name.clone()) {
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The (property, SOM) an effect setting a property of an object is merged by, see
|
||||
/// [`XHost::set_at`].
|
||||
fn effect_key(e: &XfaEffect) -> Option<(u8, String)> {
|
||||
match e {
|
||||
XfaEffect::SetValue { som, .. } => Some((0, som.clone())),
|
||||
XfaEffect::SetPresence { som, .. } => Some((1, som.clone())),
|
||||
XfaEffect::SetAccess { som, .. } => Some((2, som.clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes of text `e` holds.
|
||||
fn payload(e: &XfaEffect) -> usize {
|
||||
match e {
|
||||
XfaEffect::SetValue { som, value: s } | XfaEffect::SetPresence { som, presence: s } | XfaEffect::SetAccess { som, access: s } => {
|
||||
som.len().saturating_add(s.len())
|
||||
}
|
||||
XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(),
|
||||
XfaEffect::MessageBox(s) | XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(),
|
||||
XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::<String>())),
|
||||
XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A script's completion value (what a calculate script puts in its field) cut to
|
||||
/// [`MAX_VALUE`] characters, as values a script sets are, with a note when it was.
|
||||
pub(crate) fn clip_result(out: &mut XfaOutcome) {
|
||||
@@ -232,6 +276,9 @@ pub(crate) struct XHost {
|
||||
clipped_values: usize,
|
||||
dropped_alerts: usize,
|
||||
dropped_console: usize,
|
||||
/// Bytes of text kept in effects, console lines and values written, against [`MAX_PAYLOAD`].
|
||||
kept: usize,
|
||||
dropped_bytes: usize,
|
||||
}
|
||||
|
||||
impl XHost {
|
||||
@@ -256,15 +303,71 @@ impl XHost {
|
||||
clipped_values: 0,
|
||||
dropped_alerts: 0,
|
||||
dropped_console: 0,
|
||||
kept: 0,
|
||||
dropped_bytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not.
|
||||
fn room(&mut self, bytes: usize) -> bool {
|
||||
match self.kept.checked_add(bytes).filter(|k| *k <= MAX_PAYLOAD) {
|
||||
Some(k) => {
|
||||
self.kept = k;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
self.dropped_bytes = self.dropped_bytes.saturating_add(1);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an effect: values, presence and access set again on the same object replace the
|
||||
/// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes) effects are counted
|
||||
/// and dropped.
|
||||
/// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes, or [`MAX_PAYLOAD`]
|
||||
/// bytes of text) effects are counted and dropped.
|
||||
pub(crate) fn push(&mut self, e: XfaEffect) {
|
||||
let e = match e {
|
||||
let e = self.clip_effect(e);
|
||||
if self.room(payload(&e)) {
|
||||
self.record(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Room for `e`, which holds `bytes` of text, charged now: a change to the snapshot is made
|
||||
/// only when the effect that applies it will be kept, so a script never sees a change its
|
||||
/// effects leave out. An effect that replaces an earlier one needs no new slot. A drop is
|
||||
/// counted when there is no room.
|
||||
fn reserve(&mut self, e: &XfaEffect, bytes: usize) -> bool {
|
||||
let replaces = effect_key(e).and_then(|k| self.set_at.get(&k)).is_some_and(|&at| at >= self.barrier && at < self.effects.len());
|
||||
if !replaces && self.effects.len() >= MAX_EFFECTS {
|
||||
self.dropped_effects += 1;
|
||||
return false;
|
||||
}
|
||||
self.room(bytes)
|
||||
}
|
||||
|
||||
/// Set the presence or access of node `i` to what `e` (a [`XfaEffect::SetPresence`] or
|
||||
/// [`XfaEffect::SetAccess`]) says, in the snapshot only when the effect is kept.
|
||||
pub(crate) fn set_state(&mut self, i: usize, e: XfaEffect) {
|
||||
if !self.reserve(&e, payload(&e)) {
|
||||
return;
|
||||
}
|
||||
if let Some(n) = self.nodes.get_mut(i) {
|
||||
match &e {
|
||||
XfaEffect::SetPresence { presence, .. } => n.presence = presence.clone(),
|
||||
XfaEffect::SetAccess { access, .. } => n.access = access.clone(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.record(e);
|
||||
}
|
||||
|
||||
/// `e` with its text cut to the lengths an effect keeps.
|
||||
fn clip_effect(&mut self, e: XfaEffect) -> XfaEffect {
|
||||
match e {
|
||||
XfaEffect::MessageBox(m) => XfaEffect::MessageBox(clip(m)),
|
||||
XfaEffect::LaunchUrl(u) => XfaEffect::LaunchUrl(clip(u)),
|
||||
XfaEffect::SetFocus(s) => XfaEffect::SetFocus(clip(s)),
|
||||
XfaEffect::ResetData(names) => XfaEffect::ResetData(names.into_iter().map(clip).collect()),
|
||||
XfaEffect::SetValue { som, value } => {
|
||||
let (value, cut) = clip_value(value);
|
||||
if cut {
|
||||
@@ -273,14 +376,12 @@ impl XHost {
|
||||
XfaEffect::SetValue { som, value }
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
let key = match &e {
|
||||
XfaEffect::SetValue { som, .. } => Some((0u8, som.clone())),
|
||||
XfaEffect::SetPresence { som, .. } => Some((1, som.clone())),
|
||||
XfaEffect::SetAccess { som, .. } => Some((2, som.clone())),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(key) = key {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep `e`, whose bytes are already charged, against the count limits.
|
||||
fn record(&mut self, e: XfaEffect) {
|
||||
if let Some(key) = effect_key(&e) {
|
||||
if let Some(&at) = self.set_at.get(&key)
|
||||
&& at >= self.barrier
|
||||
&& let Some(slot) = self.effects.get_mut(at)
|
||||
@@ -316,7 +417,7 @@ impl XHost {
|
||||
pub(crate) fn print(&mut self, line: String) {
|
||||
if self.console.len() >= MAX_CONSOLE {
|
||||
self.dropped_console += 1;
|
||||
} else {
|
||||
} else if self.room(line.len()) {
|
||||
self.console.push(line);
|
||||
}
|
||||
}
|
||||
@@ -333,6 +434,9 @@ impl XHost {
|
||||
if self.dropped_console > 0 {
|
||||
out.push(format!("the script printed more than {MAX_CONSOLE} console lines; {} more were left out", self.dropped_console));
|
||||
}
|
||||
if self.dropped_bytes > 0 {
|
||||
out.push(format!("the script's changes and output passed {} MiB of text; {} more were left out", MAX_PAYLOAD >> 20, self.dropped_bytes));
|
||||
}
|
||||
if self.clipped_values > 0 {
|
||||
out.push(format!("the script set {} value(s) longer than {MAX_VALUE} characters; they were cut", self.clipped_values));
|
||||
}
|
||||
@@ -343,20 +447,28 @@ impl XHost {
|
||||
/// holds a value; writing one into a subform would put text where another viewer expects
|
||||
/// a data group). Returns whether it was.
|
||||
pub(crate) fn set_value(&mut self, i: usize, text: String) -> bool {
|
||||
let Some(n) = self.nodes.get_mut(i) else { return false };
|
||||
let Some(n) = self.nodes.get(i) else { return false };
|
||||
if n.gone || !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) {
|
||||
return false;
|
||||
}
|
||||
let som = n.som.clone();
|
||||
// Cut here, not only in the effect: later reads in the script see what is saved, and
|
||||
// the snapshot stays bounded however often a long value is written.
|
||||
let (text, cut) = clip_value(text);
|
||||
n.value = text.clone();
|
||||
n.formatted = None;
|
||||
let som = n.som.clone();
|
||||
if cut {
|
||||
self.clipped_values += 1;
|
||||
}
|
||||
self.push(XfaEffect::SetValue { som, value: text });
|
||||
// The snapshot's copy and the effect's are charged together, before either is kept:
|
||||
// past the byte budget or the effect cap the write is left out of both.
|
||||
let effect = XfaEffect::SetValue { som, value: text.clone() };
|
||||
if !self.reserve(&effect, text.len().saturating_add(payload(&effect))) {
|
||||
return true;
|
||||
}
|
||||
if let Some(n) = self.nodes.get_mut(i) {
|
||||
n.value = text;
|
||||
n.formatted = None;
|
||||
}
|
||||
self.record(effect);
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -590,13 +702,7 @@ fn renumber(h: &mut XHost, idx: usize, depth: usize) {
|
||||
let parent_som = n.parent.and_then(|p| h.nodes.get(p)).map(|p| p.som.clone()).unwrap_or_default();
|
||||
(parent_som, n.name.clone(), n.index, n.children.clone())
|
||||
};
|
||||
let som = if name.starts_with('#') || name.is_empty() {
|
||||
parent_som
|
||||
} else if parent_som.is_empty() {
|
||||
format!("{name}[{index}]")
|
||||
} else {
|
||||
format!("{parent_som}.{name}[{index}]")
|
||||
};
|
||||
let som = child_som(parent_som, &name, index);
|
||||
if let Some(n) = h.nodes.get_mut(idx) {
|
||||
n.som = som;
|
||||
}
|
||||
@@ -605,6 +711,17 @@ fn renumber(h: &mut XHost, idx: usize, depth: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The SOM of instance `index` of the node `name` under a parent at `parent_som`.
|
||||
fn child_som(parent_som: String, name: &str, index: usize) -> String {
|
||||
if name.starts_with('#') || name.is_empty() {
|
||||
parent_som
|
||||
} else if parent_som.is_empty() {
|
||||
format!("{name}[{index}]")
|
||||
} else {
|
||||
format!("{parent_som}.{name}[{index}]")
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy of `src`'s subtree (values cleared) under `parent`, returning the copy's index.
|
||||
fn clone_subtree(h: &mut XHost, src: usize, parent: Option<usize>, depth: usize) -> Option<usize> {
|
||||
if depth > MAX_DEPTH || h.nodes.len() >= MAX_NODES {
|
||||
@@ -672,7 +789,14 @@ impl XHost {
|
||||
let live = instances(h, parent, name);
|
||||
let &first = live.first()?;
|
||||
let (repeatable, max) = h.nodes.get(first).map(|n| (n.repeatable, n.occur_max)).unwrap_or((false, Some(1)));
|
||||
if !repeatable || max.is_some_and(|m| live.len() >= m) || h.added >= MAX_ADDED {
|
||||
if !repeatable || max.is_some_and(|m| live.len() >= m) || h.added >= MAX_ADDED || h.nodes.len() >= MAX_NODES {
|
||||
return None;
|
||||
}
|
||||
// The copy's SOM, as `renumber` will give it, so its effect is reserved before the copy.
|
||||
let name = h.nodes.get(first).map(|n| n.name.clone()).unwrap_or_default();
|
||||
let parent_som = h.nodes.get(parent).map(|p| p.som.clone()).unwrap_or_default();
|
||||
let som = child_som(parent_som, &name, live.len());
|
||||
if !h.reserve(&XfaEffect::AddInstance { som: String::new() }, som.len()) {
|
||||
return None;
|
||||
}
|
||||
let new = clone_subtree(h, first, Some(parent), 0)?;
|
||||
@@ -687,8 +811,7 @@ impl XHost {
|
||||
}
|
||||
renumber(h, new, 0);
|
||||
h.added += 1;
|
||||
let som = h.nodes.get(new).map(|n| n.som.clone()).unwrap_or_default();
|
||||
h.push(XfaEffect::AddInstance { som });
|
||||
h.record(XfaEffect::AddInstance { som });
|
||||
Some(new)
|
||||
}
|
||||
|
||||
@@ -725,6 +848,9 @@ impl XHost {
|
||||
return;
|
||||
}
|
||||
let som = h.nodes.get(gone).map(|n| n.som.clone()).unwrap_or_default();
|
||||
if !h.reserve(&XfaEffect::RemoveInstance { som: String::new() }, som.len()) {
|
||||
return;
|
||||
}
|
||||
if let Some(n) = h.nodes.get_mut(gone) {
|
||||
n.gone = true;
|
||||
}
|
||||
@@ -735,7 +861,7 @@ impl XHost {
|
||||
}
|
||||
renumber(h, inst, 0);
|
||||
}
|
||||
h.push(XfaEffect::RemoveInstance { som });
|
||||
h.record(XfaEffect::RemoveInstance { som });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -958,20 +1084,14 @@ fn node_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValu
|
||||
"hidden" | "invisible" | "inactive" | "visible" => value.clone(),
|
||||
_ => return Ok(JsValue::from(true)),
|
||||
};
|
||||
if let Some(n) = h.nodes.get_mut(idx) {
|
||||
n.presence = p.clone();
|
||||
}
|
||||
h.push(XfaEffect::SetPresence { som, presence: p });
|
||||
h.set_state(idx, XfaEffect::SetPresence { som, presence: p });
|
||||
}
|
||||
"access" => {
|
||||
let a = match value.as_str() {
|
||||
"open" | "readOnly" | "protected" | "nonInteractive" => value.clone(),
|
||||
_ => return Ok(JsValue::from(true)),
|
||||
};
|
||||
if let Some(n) = h.nodes.get_mut(idx) {
|
||||
n.access = a.clone();
|
||||
}
|
||||
h.push(XfaEffect::SetAccess { som, access: a });
|
||||
h.set_state(idx, XfaEffect::SetAccess { som, access: a });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1024,8 +1144,7 @@ fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
|
||||
|
||||
fn reset_data(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let list = text(&arg(args, 0), ctx)?;
|
||||
let names: Vec<String> = list.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect();
|
||||
effect(ctx, XfaEffect::ResetData(names))?;
|
||||
effect(ctx, XfaEffect::ResetData(reset_names(&list)))?;
|
||||
Ok(JsValue::undefined())
|
||||
}
|
||||
|
||||
|
||||
@@ -448,10 +448,20 @@ pub struct Unsupported {
|
||||
pub struct Listing {
|
||||
pub measurements: Vec<Measurement>,
|
||||
pub unsupported: Vec<Unsupported>,
|
||||
/// The annotation limit stopped enumeration early.
|
||||
/// The annotation or retained-size limit stopped enumeration early.
|
||||
pub truncated: bool,
|
||||
}
|
||||
const MAX_ANNOTATIONS: usize = 100_000;
|
||||
/// Points and text one listing keeps, as counted by [`retained`]: about a thousand
|
||||
/// measurements of [`MAX_POINTS`] points each.
|
||||
const MAX_LISTED_BYTES: usize = 32 << 20;
|
||||
/// The bytes a listed measurement keeps in points and text.
|
||||
fn retained(m: &Measurement) -> usize {
|
||||
let s = &m.scale;
|
||||
[m.id.len(), m.author.len(), m.label.len(), s.unit.len(), s.area_unit.len(), s.ratio.len()]
|
||||
.into_iter()
|
||||
.fold(m.points.len().saturating_mul(std::mem::size_of::<Point>()), usize::saturating_add)
|
||||
}
|
||||
fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: usize) -> Result<Measurement> {
|
||||
let scale = Scale::read(doc, d.get(b"Measure").ok_or_else(|| invalid("measurement has no scale"))?)?;
|
||||
let key = if kind == Kind::Distance { &b"L"[..] } else { b"Vertices" };
|
||||
@@ -471,6 +481,8 @@ fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: us
|
||||
pub fn list(doc: &Document) -> Listing {
|
||||
let mut out = Listing::default();
|
||||
let mut count = 0usize;
|
||||
let mut kept = 0usize;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (page, p) in pdfcraft_model::pages(doc).iter().enumerate() {
|
||||
let Some(o) = p.dict.get(b"Annots") else { continue };
|
||||
let o = doc.resolve(o);
|
||||
@@ -481,6 +493,12 @@ pub fn list(doc: &Document) -> Listing {
|
||||
out.truncated = true;
|
||||
return out;
|
||||
}
|
||||
// One annotation object under several entries or pages is one measurement.
|
||||
if let Some(r) = a.as_ref()
|
||||
&& !seen.insert(r)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let o = doc.resolve(a);
|
||||
let Some(d) = o.as_dict() else { continue };
|
||||
let kind = match d.name(b"IT") {
|
||||
@@ -490,13 +508,29 @@ pub fn list(doc: &Document) -> Listing {
|
||||
_ => continue,
|
||||
};
|
||||
match read_measurement(doc, d, kind, page, index) {
|
||||
Ok(m) => out.measurements.push(m),
|
||||
Ok(m) => {
|
||||
kept = kept.saturating_add(retained(&m));
|
||||
if kept > MAX_LISTED_BYTES {
|
||||
out.truncated = true;
|
||||
return out;
|
||||
}
|
||||
out.measurements.push(m);
|
||||
}
|
||||
Err(e) => out.unsupported.push(Unsupported { page, index, reason: e.to_string() }),
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Most viewports [`set_scale`] gives a page, and most [`scale_at`] reads.
|
||||
const MAX_VIEWPORTS: usize = 1024;
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Numbers read from viewport `/BBox` arrays.
|
||||
pub(crate) static VIEWPORT_NUMBERS_READ: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Last containing viewport wins, using the first point of the measurement (ISO 32000-2 §12.9).
|
||||
/// Outside every viewport the page measures true size in inches.
|
||||
pub fn scale_at(doc: &Document, page_index: usize, at: Point) -> Result<Scale> {
|
||||
@@ -510,12 +544,17 @@ pub fn scale_at_or(doc: &Document, page_index: usize, at: Point, per_point: f64,
|
||||
if let Some(o) = p.dict.get(b"VP") {
|
||||
let o = doc.resolve(o);
|
||||
let arr = o.as_array().ok_or_else(|| invalid("invalid page viewports"))?;
|
||||
for viewport in arr.iter().rev() {
|
||||
// The last viewports win; a page holds at most MAX_VIEWPORTS of its own.
|
||||
for viewport in arr.iter().rev().take(MAX_VIEWPORTS) {
|
||||
let o = doc.resolve(viewport);
|
||||
let Some(d) = o.as_dict() else { continue };
|
||||
if let Some(bbox) = d.get(b"BBox") {
|
||||
let o = doc.resolve(bbox);
|
||||
let b: Option<Vec<f64>> = o.as_array().and_then(|a| a.iter().map(|v| doc.resolve(v).as_f64()).collect());
|
||||
let b: Option<Vec<f64>> = o.as_array().filter(|a| a.len() == 4).and_then(|a| {
|
||||
#[cfg(test)]
|
||||
VIEWPORT_NUMBERS_READ.with(|n| n.set(n.get() + a.len()));
|
||||
a.iter().map(|v| doc.resolve(v).as_f64()).collect()
|
||||
});
|
||||
if let Some(b) = b.filter(|b| b.len() == 4 && b.iter().all(|v| v.is_finite()))
|
||||
&& at[0] >= b[0].min(b[2])
|
||||
&& at[0] <= b[0].max(b[2])
|
||||
@@ -541,7 +580,7 @@ pub fn set_scale(doc: &mut Document, page_index: usize, bbox: [f64; 4], name: &s
|
||||
Some(o) => doc.resolve(o).as_array().cloned().ok_or_else(|| invalid("invalid page viewports"))?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
if list.len() >= 1024 {
|
||||
if list.len() >= MAX_VIEWPORTS {
|
||||
return Err(invalid("page has too many measurement viewports"));
|
||||
}
|
||||
let mut d = Dict::new();
|
||||
|
||||
@@ -214,14 +214,14 @@ impl Walker<'_> {
|
||||
self.geometry.truncated = true;
|
||||
break;
|
||||
}
|
||||
// An undecodable part is skipped; the rest of the page still snaps.
|
||||
// An undecodable part is skipped; the parts before it still snap.
|
||||
match s.decoded_within(remaining) {
|
||||
Ok(data) => {
|
||||
joined.extend(data);
|
||||
// A space, as the renderer joins pieces: a trailing comment runs on.
|
||||
joined.push(b' ');
|
||||
}
|
||||
Err(_) => self.geometry.unreadable = self.geometry.unreadable.saturating_add(1),
|
||||
Err(_) => self.decode_failed(remaining),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,11 +236,17 @@ impl Walker<'_> {
|
||||
match stream.decoded_within(remaining) {
|
||||
Ok(data) => self.ops(&data, resources, initial, depth),
|
||||
Err(_) => {
|
||||
self.geometry.unreadable = self.geometry.unreadable.saturating_add(1);
|
||||
self.decode_failed(remaining);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A tolerant decode fails on an unsupported filter or once it has produced `remaining` bytes,
|
||||
/// so a failure uses up the byte budget: repeating a failing stream cannot repeat that work.
|
||||
fn decode_failed(&mut self, remaining: usize) {
|
||||
self.geometry.unreadable = self.geometry.unreadable.saturating_add(1);
|
||||
self.bytes = self.bytes.saturating_add(remaining);
|
||||
}
|
||||
fn ops(&mut self, data: &[u8], resources: &Dict, initial: Matrix, depth: usize) -> Result<()> {
|
||||
self.bytes = self.bytes.saturating_add(data.len());
|
||||
self.streams = self.streams.saturating_add(1);
|
||||
@@ -250,6 +256,8 @@ impl Walker<'_> {
|
||||
let mut current = None;
|
||||
let mut closures = Vec::new();
|
||||
let mut path = Geometry::default();
|
||||
// Read on the first `Do` and kept: resolving a direct dictionary copies it.
|
||||
let mut xobjects = None;
|
||||
let point = |m: Matrix, x: f64, y: f64| {
|
||||
let (x, y) = m.apply(x, y);
|
||||
[x, y]
|
||||
@@ -384,7 +392,11 @@ impl Walker<'_> {
|
||||
}
|
||||
"Do" => {
|
||||
if let Some(name) = op.name(0) {
|
||||
let xobjects = resources.get(b"XObject").map(|o| self.doc.resolve(o));
|
||||
let xobjects = xobjects.get_or_insert_with(|| {
|
||||
#[cfg(test)]
|
||||
XOBJECT_DICT_READS.with(|n| n.set(n.get() + 1));
|
||||
resources.get(b"XObject").map(|o| self.doc.resolve(o))
|
||||
});
|
||||
if let Some(o) = xobjects.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(name)) {
|
||||
let r = o.as_ref();
|
||||
if r.is_some_and(|r| self.seen.contains(&r)) {
|
||||
@@ -420,6 +432,12 @@ impl Walker<'_> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Times a content stream's `/XObject` resources were read.
|
||||
pub(crate) static XOBJECT_DICT_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
pub fn geometry(doc: &Document, page_index: usize) -> Result<Geometry> {
|
||||
let p = crate::page(doc, page_index)?;
|
||||
let resources = p.dict.get(b"Resources").map(|o| doc.resolve(o));
|
||||
|
||||
@@ -403,3 +403,61 @@ fn a_scale_ratio_converts_its_page_side_to_points() {
|
||||
assert!(Scale::ratio([0.0, 0.0], [1.0, 0.0], page, PageUnit::Mm, real, "mm", 2).is_err(), "{page} {real}");
|
||||
}
|
||||
}
|
||||
/// A content stream reads its `/XObject` resources once, not again (as a copy of a direct
|
||||
/// dictionary) for every `Do`.
|
||||
#[test]
|
||||
fn xobject_resources_are_read_once_per_stream() {
|
||||
let d = fixture(&"/Missing Do\n".repeat(10_000), "", &[]);
|
||||
snap::XOBJECT_DICT_READS.with(|c| c.set(0));
|
||||
let paths = snap::geometry(&d, 0).unwrap();
|
||||
assert!(paths.segments.is_empty());
|
||||
let reads = snap::XOBJECT_DICT_READS.with(std::cell::Cell::get);
|
||||
assert!(reads <= 1, "{reads} reads of the page's XObject resources");
|
||||
}
|
||||
/// Finding the scale under a point reads only well-formed viewport boxes, and only as many
|
||||
/// viewports as a page can be given, however many a file lists or however long their boxes.
|
||||
#[test]
|
||||
fn scale_lookup_reads_viewports_within_bounds() {
|
||||
let vp = vec!["5 0 R"; 5_000].join(" ");
|
||||
let long_box = vec!["0"; 2_000].join(" ");
|
||||
let d = fixture("", &format!("/VP [{vp}]"), &["<< /Type /Viewport /BBox 6 0 R >>", &format!("[{long_box}]")]);
|
||||
VIEWPORT_NUMBERS_READ.with(|c| c.set(0));
|
||||
let scale = scale_at(&d, 0, [10.0, 10.0]).unwrap();
|
||||
assert_eq!(scale.unit, "in");
|
||||
let read = VIEWPORT_NUMBERS_READ.with(std::cell::Cell::get);
|
||||
assert!(read <= 4 * 1024, "{read} viewport numbers read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listing_skips_repeated_refs_and_caps_what_it_keeps() {
|
||||
let mut d = fixture("", "", &[]);
|
||||
let points: Vec<Point> = (0..MAX_POINTS).map(|i| [i as f64 * 0.1, 0.0]).collect();
|
||||
add(&mut d, &new(Kind::Perimeter, points), &Meta::default()).unwrap();
|
||||
let (r, mut annotation) = annot(&d, 0, 0).unwrap();
|
||||
let page = pdfcraft_model::pages(&d)[0].obj;
|
||||
let set_annots = |d: &mut Document, list: Vec<Object>| d.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(list))).unwrap();
|
||||
// One annotation referenced 1000 times is one measurement.
|
||||
set_annots(&mut d, vec![Object::Ref(r); 1000]);
|
||||
assert_eq!(list(&d).measurements.len(), 1);
|
||||
// Many small annotations sharing one 2048-point vertex array stop at the retained budget.
|
||||
let vertices = d.add(annotation.get(b"Vertices").unwrap().clone());
|
||||
annotation.set(b"Vertices".to_vec(), Object::Ref(vertices));
|
||||
set_annots(&mut d, vec![Object::Dict(annotation); 1200]);
|
||||
let listing = list(&d);
|
||||
assert!(listing.truncated);
|
||||
assert!(listing.measurements.iter().map(retained).sum::<usize>() <= MAX_LISTED_BYTES);
|
||||
assert!(!listing.measurements.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn a_stream_over_the_byte_budget_uses_it_up_once() {
|
||||
// 50 references to one Flate stream that inflates past the 16 MiB budget decode once.
|
||||
let big = pdfcraft_cos::Stream::flate(Dict::new(), &vec![b' '; 17 << 20]);
|
||||
let hex: String = big.raw.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let bomb = String::from_utf8(pdfcraft_testkit::stream_obj("/Filter [/ASCIIHexDecode /FlateDecode]", hex)).unwrap();
|
||||
let contents = format!("[4 0 R{}]", " 5 0 R".repeat(50));
|
||||
let d = fixture_with(&contents, "0 0 m 10 0 l S", "", &[bomb.as_str()]);
|
||||
let g = snap::geometry(&d, 0).unwrap();
|
||||
assert_eq!(g.unreadable, 1);
|
||||
assert!(g.truncated);
|
||||
assert_eq!(g.segments.len(), 1, "the parts before it still snap");
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ L3/L4 crates need:
|
||||
The page-tree walk keeps one cursor per ancestor and shares inherited attribute owners.
|
||||
Repeated child references are skipped before copying attributes, with first depth-first
|
||||
occurrence order preserved. Returned page dictionaries still own their effective attributes.
|
||||
A walk copies at most `MAX_INHERITED_COPIES` (1 GiB, approximate heap bytes) of inherited
|
||||
values into the leaves; past it the walk stops, as it does past its leaf cap, and the pages
|
||||
after that point are not listed.
|
||||
|
||||
`organize`, `annot` and `forms` still carry their own small walkers; they move here as the model
|
||||
grows (ADR-0004).
|
||||
|
||||
+66
-7
@@ -82,14 +82,36 @@ pub fn view_matrix_for(rotation: i64, crop: [f64; 4]) -> [f64; 6] {
|
||||
}
|
||||
|
||||
/// Leaf pages in document order, with inherited attributes resolved. Cycles and absurdly deep
|
||||
/// trees are cut off rather than followed.
|
||||
/// trees are cut off rather than followed, as is a tree whose inherited values would take more
|
||||
/// than [`MAX_INHERITED_COPIES`] bytes to copy into its leaves.
|
||||
pub fn pages(doc: &Document) -> Vec<Page> {
|
||||
PageTree::new(doc).collect()
|
||||
}
|
||||
|
||||
/// Approximate bytes of inherited values copied into leaf dictionaries by one page walk.
|
||||
pub const MAX_INHERITED_COPIES: usize = 1 << 30;
|
||||
|
||||
/// Approximate heap bytes of `o`: one [`Object`] per node plus string, name and key bytes.
|
||||
/// Values nested deeper than the parser allows weigh as too large.
|
||||
fn weight(o: &Object, depth: usize) -> usize {
|
||||
if depth > 256 {
|
||||
return usize::MAX;
|
||||
}
|
||||
let own = std::mem::size_of::<Object>();
|
||||
match o {
|
||||
Object::String(s) => own.saturating_add(s.bytes.len()),
|
||||
Object::Name(n) => own.saturating_add(n.len()),
|
||||
Object::Array(a) => a.iter().fold(own, |n, v| n.saturating_add(weight(v, depth + 1))),
|
||||
Object::Dict(d) => d.iter().fold(own, |n, (k, v)| n.saturating_add(k.len()).saturating_add(weight(v, depth + 1))),
|
||||
Object::Stream(s) => s.dict.iter().fold(own, |n, (k, v)| n.saturating_add(k.len()).saturating_add(weight(v, depth + 1))),
|
||||
_ => own,
|
||||
}
|
||||
}
|
||||
|
||||
// Retain the owner of each inherited value rather than deep-cloning direct Resources on
|
||||
// every edge. Only the final Page dictionaries need owned copies of those values.
|
||||
type Inherited = [Option<std::sync::Arc<Object>>; 4];
|
||||
// every edge. Only the final Page dictionaries need owned copies of those values. Each owner
|
||||
// carries the value's weight, measured once where the value is declared.
|
||||
type Inherited = [Option<(std::sync::Arc<Object>, usize)>; 4];
|
||||
|
||||
struct Children {
|
||||
owner: std::sync::Arc<Object>,
|
||||
@@ -112,16 +134,23 @@ struct PageTree<'a> {
|
||||
// One cursor per ancestor, never one entry per child. This also preserves the first
|
||||
// depth-first occurrence's attributes when malformed trees share a child.
|
||||
pending: Vec<Children>,
|
||||
// Weight left for copying inherited values into leaves. A large direct value on an
|
||||
// ancestor of many leaves would otherwise be copied once per leaf.
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
impl<'a> PageTree<'a> {
|
||||
fn new(doc: &'a Document) -> Self {
|
||||
Self::with_budget(doc, MAX_INHERITED_COPIES)
|
||||
}
|
||||
|
||||
fn with_budget(doc: &'a Document, budget: usize) -> Self {
|
||||
let top = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages")));
|
||||
let pending = top
|
||||
.into_iter()
|
||||
.map(|r| Children { owner: std::sync::Arc::new(Object::Array(vec![Object::Ref(r)])), next: 0, inherited: Default::default() })
|
||||
.collect();
|
||||
Self { doc, seen: Default::default(), pending }
|
||||
Self { doc, seen: Default::default(), pending, budget }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,8 +178,8 @@ impl Iterator for PageTree<'_> {
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
let mut inherited = parent.inherited.clone();
|
||||
for (key, owner) in INHERITABLE.iter().zip(&mut inherited) {
|
||||
if d.contains(key) {
|
||||
*owner = Some(obj.clone());
|
||||
if let Some(value) = d.get(key) {
|
||||
*owner = Some((obj.clone(), weight(value, 0)));
|
||||
}
|
||||
}
|
||||
let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page"));
|
||||
@@ -165,8 +194,15 @@ impl Iterator for PageTree<'_> {
|
||||
let mut dict = d.clone();
|
||||
for (key, owner) in INHERITABLE.iter().zip(&inherited) {
|
||||
if !dict.contains(key)
|
||||
&& let Some(value) = owner.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(key))
|
||||
&& let Some((owner, weight)) = owner
|
||||
&& let Some(value) = owner.as_dict().and_then(|d| d.get(key))
|
||||
{
|
||||
// Past the budget the walk is cut off, like a tree too large to follow.
|
||||
let Some(left) = self.budget.checked_sub(*weight) else {
|
||||
self.pending.clear();
|
||||
return None;
|
||||
};
|
||||
self.budget = left;
|
||||
dict.set(key.to_vec(), value.clone());
|
||||
}
|
||||
}
|
||||
@@ -209,6 +245,29 @@ mod tests {
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_copies_stop_at_a_budget() {
|
||||
let mut d = doc(0);
|
||||
let root = ObjRef { num: 2, generation: 0 };
|
||||
let leaf = d.get(ObjRef { num: 3, generation: 0 }).as_dict().unwrap().clone();
|
||||
let kids: Vec<_> = (0..200).map(|_| Object::Ref(d.add(leaf.clone()))).collect();
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"Payload".to_vec(), Object::Array(vec![Object::Int(42); 8192]));
|
||||
let resources = Object::Dict(resources);
|
||||
let each = weight(&resources, 0);
|
||||
d.update_dict(root, |dict| {
|
||||
dict.set(b"Kids".to_vec(), Object::Array(kids));
|
||||
dict.set(b"Resources".to_vec(), resources);
|
||||
})
|
||||
.unwrap();
|
||||
let budget = 1 << 20;
|
||||
let pages: Vec<Page> = PageTree::with_budget(&d, budget).collect();
|
||||
let copied: usize = pages.iter().filter_map(|p| p.dict.get(b"Resources")).map(|r| weight(r, 0)).sum();
|
||||
assert!(copied <= budget, "{copied} bytes of inherited values copied into {} pages", pages.len());
|
||||
assert_eq!(pages.len(), budget / each);
|
||||
assert_eq!(super::pages(&d).len(), 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_children_do_not_queue_resource_copies() {
|
||||
let mut d = doc(0);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
//! drops them.
|
||||
|
||||
use pdfcraft_content::{Matrix, Op, parse, serialize_ops};
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream};
|
||||
|
||||
use crate::images::{components, content_bytes, decodable, page_resources, pixels, resample};
|
||||
use crate::{OptimizeError, Report, Stage};
|
||||
@@ -232,7 +232,9 @@ fn add_xobject(doc: &mut Document, page: ObjRef, name: &[u8], image: ObjRef) ->
|
||||
/// Merge the fragment runs of one page; returns how many fragments were merged.
|
||||
fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool) -> Result<usize, OptimizeError> {
|
||||
let Some(contents) = doc.get(page).as_dict().and_then(|d| d.get(b"Contents").cloned()) else { return Ok(0) };
|
||||
let parsed = parse(&content_bytes(doc, &contents));
|
||||
// A page whose content isn't all known is left alone: rewriting it would drop the rest.
|
||||
let Some(data) = content_bytes(doc, &contents, &mut DecodeBudget::page()) else { return Ok(0) };
|
||||
let parsed = parse(&data);
|
||||
if parsed.skipped > 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use pdfcraft_content::{Matrix, Pieces};
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream};
|
||||
|
||||
use crate::{Compression, ImageSettings, OptimizeError, Report, Settings, Stage};
|
||||
|
||||
@@ -23,21 +23,11 @@ pub(crate) fn page_resources(doc: &Document, page: ObjRef) -> Option<Dict> {
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Vec<u8> {
|
||||
match &*doc.resolve(contents) {
|
||||
Object::Stream(s) => s.decoded().unwrap_or_default(),
|
||||
Object::Array(a) => {
|
||||
let pieces: Vec<_> = a
|
||||
.iter()
|
||||
.filter_map(|c| match &*doc.resolve(c) {
|
||||
Object::Stream(s) => Some(s.decoded().unwrap_or_default()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
Pieces::join(&pieces).data().to_vec()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
/// The page content `contents` names, decoded against `budget` and joined. `None` when a stream
|
||||
/// doesn't decode or the budget runs out: the content is not all known.
|
||||
pub(crate) fn content_bytes(doc: &Document, contents: &Object, budget: &mut DecodeBudget) -> Option<Vec<u8>> {
|
||||
let pieces = budget.decode_contents(doc, contents).into_iter().collect::<Result<Vec<_>, _>>().ok()?;
|
||||
Some(Pieces::join(&pieces).data().to_vec())
|
||||
}
|
||||
|
||||
/// Forms nested deeper than this aren't followed.
|
||||
@@ -57,6 +47,8 @@ struct Walk<'a> {
|
||||
/// Forms walked at least once.
|
||||
seen: HashSet<ObjRef>,
|
||||
rewalks: usize,
|
||||
/// Decoded bytes left for the page being walked.
|
||||
budget: DecodeBudget,
|
||||
/// False once a use may have been missed (depth or work limit).
|
||||
complete: bool,
|
||||
out: HashMap<ObjRef, f64>,
|
||||
@@ -135,23 +127,46 @@ impl Walk<'_> {
|
||||
}
|
||||
self.rewalks += 1;
|
||||
}
|
||||
if !self.budget.enter_form() {
|
||||
self.complete = false;
|
||||
return;
|
||||
}
|
||||
let Ok(data) = self.budget.decode(s) else {
|
||||
self.complete = false;
|
||||
return;
|
||||
};
|
||||
self.path.push(r);
|
||||
self.content(&s.decoded().unwrap_or_default(), own.as_ref().unwrap_or(resources), inner);
|
||||
self.content(&data, own.as_ref().unwrap_or(resources), inner);
|
||||
self.path.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// The effective resolution (ppi) of every image drawn by `pages`: the smallest over its uses.
|
||||
///
|
||||
/// If forms nest too deeply or are drawn too many times to measure every use, every image is
|
||||
/// given `INFINITY` (never resampled), since its largest use may be among those missed.
|
||||
/// If forms nest too deeply, are drawn too many times, or a page's content doesn't decode within
|
||||
/// its budget, every image is given `INFINITY` (never resampled), since its largest use may be
|
||||
/// among those missed.
|
||||
pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap<ObjRef, f64> {
|
||||
let mut walk = Walk { doc, path: Vec::new(), walked: HashSet::new(), seen: HashSet::new(), rewalks: 0, complete: true, out: HashMap::new() };
|
||||
let mut walk = Walk {
|
||||
doc,
|
||||
path: Vec::new(),
|
||||
walked: HashSet::new(),
|
||||
seen: HashSet::new(),
|
||||
rewalks: 0,
|
||||
budget: DecodeBudget::page(),
|
||||
complete: true,
|
||||
out: HashMap::new(),
|
||||
};
|
||||
for p in pages {
|
||||
let Some(d) = doc.get(*p).as_dict().cloned() else { continue };
|
||||
let Some(contents) = d.get(b"Contents") else { continue };
|
||||
let res = page_resources(doc, *p).unwrap_or_default();
|
||||
walk.content(&content_bytes(doc, contents), &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
|
||||
walk.budget = DecodeBudget::page();
|
||||
let Some(data) = content_bytes(doc, contents, &mut walk.budget) else {
|
||||
walk.complete = false;
|
||||
continue;
|
||||
};
|
||||
walk.content(&data, &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
|
||||
}
|
||||
if !walk.complete {
|
||||
walk.out.values_mut().for_each(|v| *v = f64::INFINITY);
|
||||
@@ -471,3 +486,17 @@ mod dimension_tests {
|
||||
assert_eq!(pixels(&stream(1000, 1000), 1).map(|p| p.len()), Some(1000 * 1000));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, MAX_PAGE_DECODED, Object, Stream};
|
||||
|
||||
#[test]
|
||||
fn repeated_content_references_share_one_decode_budget() {
|
||||
let mut doc = Document::new_empty();
|
||||
let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
|
||||
let contents = Object::Array(vec![Object::Ref(stream); 300]);
|
||||
let data = super::content_bytes(&doc, &contents, &mut DecodeBudget::page());
|
||||
assert!(data.is_none_or(|d| d.len() <= MAX_PAGE_DECODED + 300));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1023,6 +1023,19 @@ fn images_are_not_resampled_when_some_uses_could_not_be_measured() {
|
||||
assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY);
|
||||
}
|
||||
|
||||
/// Each form entry counts against the page's form allowance, even for a form that decodes to
|
||||
/// nothing and so costs no decoded bytes.
|
||||
#[test]
|
||||
fn images_are_not_resampled_when_a_page_enters_more_forms_than_allowed() {
|
||||
let mut doc = Document::new_empty();
|
||||
let img = image(&mut doc, 1200, 1200, 3, false, None);
|
||||
let empty = form(&mut doc, None, "");
|
||||
let draws = "/E Do ".repeat(pdfcraft_cos::MAX_PAGE_FORMS + 1);
|
||||
page(&mut doc, &[("E", empty), ("Im", img)], &format!("q 72 0 0 72 36 36 cm /Im Do Q {draws}"));
|
||||
let refs = pdfcraft_annot::page_refs(&doc).unwrap();
|
||||
assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_resolution_keeps_comments_across_content_pieces() {
|
||||
for ending in ["", "\n", "\r", "\r\n"] {
|
||||
|
||||
@@ -93,7 +93,7 @@ fn entries(doc: &Document) -> Vec<(Vec<u8>, Object)> {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
name_tree_entries(doc, &tree, 0, &mut out);
|
||||
name_tree_entries(doc, &tree, 0, &mut std::collections::HashSet::new(), &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -154,8 +154,8 @@ impl<'a> Copier<'a> {
|
||||
}
|
||||
|
||||
/// Rewrite `/P`, `/Dest` and GoTo actions on copied annotations.
|
||||
fn fix_annotations(&self, dst: &mut Document, annot_pages: &HashMap<ObjRef, ObjRef>) -> Result<(), OrganizeError> {
|
||||
for &a in &self.annots {
|
||||
fn fix_annotations(&mut self, dst: &mut Document, annot_pages: &HashMap<ObjRef, ObjRef>) -> Result<(), OrganizeError> {
|
||||
for &a in &std::mem::take(&mut self.annots) {
|
||||
let src_dict = dst.get(a).as_dict().cloned();
|
||||
let Some(d) = src_dict else { continue };
|
||||
let mut d = d;
|
||||
@@ -188,10 +188,13 @@ impl<'a> Copier<'a> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Other actions (URI, Launch, JavaScript…) carry no page references.
|
||||
let mut c = Copier::new(self.src, self.map.clone(), HashMap::new());
|
||||
let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None)?;
|
||||
d.set(b"A".to_vec(), copied);
|
||||
// Other actions (URI, Launch, JavaScript…) carry no page references. The
|
||||
// copier lends its map out and takes it back with the new copies in it,
|
||||
// so actions sharing an object share its one copy.
|
||||
let mut c = Copier::new(self.src, std::mem::take(&mut self.map), HashMap::new());
|
||||
let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None);
|
||||
self.map = c.map;
|
||||
d.set(b"A".to_vec(), copied?);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -541,13 +544,16 @@ fn named_dest(src: &Document, name: &[u8]) -> Option<Object> {
|
||||
}
|
||||
let names = src.resolve(catalog.get(b"Names")?);
|
||||
let tree = names.as_dict()?.get(b"Dests")?.clone();
|
||||
name_tree_lookup(src, &tree, name, 0)
|
||||
name_tree_lookup(src, &tree, name, 0, &mut HashSet::new())
|
||||
}
|
||||
|
||||
fn name_tree_lookup(src: &Document, node: &Object, key: &[u8], depth: u8) -> Option<Object> {
|
||||
if depth > 32 {
|
||||
/// Name trees are read once per node: a node listed twice (or a cycle) is skipped.
|
||||
fn name_tree_lookup(src: &Document, node: &Object, key: &[u8], depth: u8, seen: &mut HashSet<ObjRef>) -> Option<Object> {
|
||||
if depth > 32 || node.as_ref().is_some_and(|r| !seen.insert(r)) {
|
||||
return None;
|
||||
}
|
||||
#[cfg(test)]
|
||||
crate::work(1);
|
||||
let node = src.resolve(node);
|
||||
let d = node.as_dict()?;
|
||||
if let Some(names) = d.get(b"Names").map(|n| src.resolve(n)).and_then(|n| n.as_array().cloned()) {
|
||||
@@ -560,14 +566,16 @@ fn name_tree_lookup(src: &Document, node: &Object, key: &[u8], depth: u8) -> Opt
|
||||
}
|
||||
}
|
||||
let kids = d.get(b"Kids").map(|k| src.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
||||
kids.iter().find_map(|k| name_tree_lookup(src, k, key, depth + 1))
|
||||
kids.iter().find_map(|k| name_tree_lookup(src, k, key, depth + 1, seen))
|
||||
}
|
||||
|
||||
/// All (key, value) pairs of a name tree, in order.
|
||||
pub(crate) fn name_tree_entries(src: &Document, node: &Object, depth: u8, out: &mut Vec<(Vec<u8>, Object)>) {
|
||||
if depth > 32 {
|
||||
/// All (key, value) pairs of a name tree, in order, each node read once.
|
||||
pub(crate) fn name_tree_entries(src: &Document, node: &Object, depth: u8, seen: &mut HashSet<ObjRef>, out: &mut Vec<(Vec<u8>, Object)>) {
|
||||
if depth > 32 || node.as_ref().is_some_and(|r| !seen.insert(r)) {
|
||||
return;
|
||||
}
|
||||
#[cfg(test)]
|
||||
crate::work(1);
|
||||
let node = src.resolve(node);
|
||||
let Some(d) = node.as_dict() else { return };
|
||||
if let Some(names) = d.get(b"Names").map(|n| src.resolve(n)).and_then(|n| n.as_array().cloned()) {
|
||||
@@ -580,7 +588,7 @@ pub(crate) fn name_tree_entries(src: &Document, node: &Object, depth: u8, out: &
|
||||
}
|
||||
}
|
||||
for k in d.get(b"Kids").map(|k| src.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default() {
|
||||
name_tree_entries(src, &k, depth + 1, out);
|
||||
name_tree_entries(src, &k, depth + 1, seen, out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -680,24 +688,32 @@ pub fn combine_field_conflicts(sources: &[&Document]) -> Vec<Vec<(String, String
|
||||
/// Read-only: returns (node, new `/T`, old name, new name). Cycle- and depth-safe.
|
||||
fn plan_renames(doc: &Document, roots: &[ObjRef], taken: &mut std::collections::HashSet<String>) -> Vec<(ObjRef, String, String, String)> {
|
||||
let mut planned = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut walk = RenameWalk::default();
|
||||
for &root in roots {
|
||||
plan_renames_under(doc, root, "", taken, &mut seen, 0, &mut planned);
|
||||
plan_renames_under(doc, root, "", taken, &mut walk, 0, &mut planned);
|
||||
}
|
||||
planned
|
||||
}
|
||||
|
||||
/// What one [`plan_renames`] call remembers: the nodes it visited, and for each colliding name
|
||||
/// the suffix to try next.
|
||||
#[derive(Default)]
|
||||
struct RenameWalk {
|
||||
seen: HashSet<ObjRef>,
|
||||
next: HashMap<String, u32>,
|
||||
}
|
||||
|
||||
/// One node of [`plan_renames`].
|
||||
fn plan_renames_under(
|
||||
doc: &Document,
|
||||
node: ObjRef,
|
||||
prefix: &str,
|
||||
taken: &mut std::collections::HashSet<String>,
|
||||
seen: &mut std::collections::HashSet<ObjRef>,
|
||||
walk: &mut RenameWalk,
|
||||
depth: u8,
|
||||
planned: &mut Vec<(ObjRef, String, String, String)>,
|
||||
) {
|
||||
if depth > 32 || !seen.insert(node) {
|
||||
if depth > 32 || !walk.seen.insert(node) {
|
||||
return;
|
||||
}
|
||||
let Some(d) = doc.get(node).as_dict().cloned() else { return };
|
||||
@@ -705,14 +721,19 @@ fn plan_renames_under(
|
||||
if !name.is_empty() {
|
||||
let fqn = qualified(prefix, &name);
|
||||
if taken.contains(&fqn) {
|
||||
let mut n = 1u32;
|
||||
// `taken` only grows, so every suffix below the last one handed out for this name is
|
||||
// still taken: resume there instead of at 1.
|
||||
let mut n = walk.next.get(&fqn).copied().unwrap_or(1);
|
||||
let mut variant;
|
||||
while {
|
||||
#[cfg(test)]
|
||||
crate::work(1);
|
||||
variant = format!("{name}-{n}");
|
||||
taken.contains(&qualified(prefix, &variant))
|
||||
} {
|
||||
n += 1;
|
||||
n = n.saturating_add(1);
|
||||
}
|
||||
walk.next.insert(fqn.clone(), n.saturating_add(1));
|
||||
let new_fqn = qualified(prefix, &variant);
|
||||
planned.push((node, variant.clone(), fqn, new_fqn.clone()));
|
||||
taken.insert(new_fqn);
|
||||
@@ -724,7 +745,7 @@ fn plan_renames_under(
|
||||
let prefix = qualified_optional(prefix, &name);
|
||||
let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
||||
for kid in kids.iter().filter_map(|k| k.as_ref()) {
|
||||
plan_renames_under(doc, kid, &prefix, taken, seen, depth + 1, planned);
|
||||
plan_renames_under(doc, kid, &prefix, taken, walk, depth + 1, planned);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -826,8 +847,11 @@ fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), Organize
|
||||
_ => (None, Dict::new()),
|
||||
};
|
||||
let mut list = form.get(b"Fields").map(|f| dst.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default();
|
||||
let mut listed: HashSet<ObjRef> = list.iter().filter_map(Object::as_ref).collect();
|
||||
for f in fields {
|
||||
if !list.contains(&Object::Ref(*f)) {
|
||||
#[cfg(test)]
|
||||
crate::work(1);
|
||||
if listed.insert(*f) {
|
||||
list.push(Object::Ref(*f));
|
||||
}
|
||||
}
|
||||
@@ -1144,7 +1168,7 @@ fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec<u8
|
||||
return Ok(());
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
name_tree_entries(src, &tree, 0, &mut entries);
|
||||
name_tree_entries(src, &tree, 0, &mut HashSet::new(), &mut entries);
|
||||
let mut copier = Copier::new(src, HashMap::new(), HashMap::new());
|
||||
for (k, v) in entries {
|
||||
let v = copier.copy_value(dst, &v, None)?;
|
||||
@@ -1368,19 +1392,10 @@ pub fn page_as_form(dst: &mut Document, src: &Document, page: usize) -> Result<(
|
||||
let (w, h) = (bbox[2] - bbox[0], bbox[3] - bbox[1]);
|
||||
let rotate = d.get(b"Rotate").and_then(|r| src.resolve(r).as_int()).unwrap_or(0).rem_euclid(360);
|
||||
// Content: the page's streams, decoded and joined.
|
||||
let mut content = Vec::new();
|
||||
if let Some(c) = own.get(b"Contents") {
|
||||
let list = match &*src.resolve(c) {
|
||||
Object::Array(a) => a.clone(),
|
||||
_ => vec![c.clone()],
|
||||
};
|
||||
for o in list {
|
||||
if let Object::Stream(s) = &*src.resolve(&o) {
|
||||
content.extend(s.decoded().map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?);
|
||||
content.push(b'\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
let content = match own.get(b"Contents") {
|
||||
Some(c) => crate::prune::content_bytes(src, c).map_err(|e| OrganizeError::Invalid(format!("page {} content: {e}", page + 1)))?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
let mut copier = Copier::new(src, HashMap::new(), HashMap::new());
|
||||
let resources = match d.get(b"Resources") {
|
||||
Some(r) => copier.copy_value(dst, r, None)?,
|
||||
|
||||
@@ -741,5 +741,24 @@ pub fn remove_standard_id(doc: &mut Document, standard: Standard) -> Result<(),
|
||||
if removed { Ok(()) } else { Err(OrganizeError::Invalid(format!("the document doesn't declare {}", standard.label()))) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Steps taken on this thread by loops whose cost follows the input's shape, so a test can
|
||||
/// bound the work a hostile document causes without timing it.
|
||||
static WORK: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Count `n` steps of input-shaped work (see [`WORK`]).
|
||||
#[cfg(test)]
|
||||
fn work(n: usize) {
|
||||
WORK.with(|w| w.set(w.get().saturating_add(n)));
|
||||
}
|
||||
|
||||
/// The steps counted on this thread since the last call.
|
||||
#[cfg(test)]
|
||||
fn take_work() -> usize {
|
||||
WORK.with(|w| w.replace(0))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, ObjRef, Object};
|
||||
|
||||
use crate::OrganizeError;
|
||||
|
||||
@@ -29,7 +29,7 @@ enum XKey {
|
||||
}
|
||||
|
||||
/// Who owns the resources currently being walked, for the key drawn names are recorded under.
|
||||
#[derive(Clone, Copy)]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
enum Owner {
|
||||
/// An indirect resources object (a page's, or a form XObject's own).
|
||||
Resources(ObjRef),
|
||||
@@ -50,6 +50,9 @@ struct Pruner<'a> {
|
||||
glyph_seen: HashSet<ObjRef>,
|
||||
/// An indirect font's explicit resources have one owner regardless of which page uses it.
|
||||
glyph_fonts: HashSet<ObjRef>,
|
||||
/// Forms already analysed, with the owner of the resources they drew against: drawing one
|
||||
/// again records nothing new, and re-reading it on every `Do` is exponential in the nesting.
|
||||
forms_seen: HashSet<(ObjRef, Owner)>,
|
||||
}
|
||||
|
||||
impl Pruner<'_> {
|
||||
@@ -211,6 +214,11 @@ impl Pruner<'_> {
|
||||
// A form without its own resources draws against the ones it was invoked with.
|
||||
None => (resources.clone(), owner),
|
||||
};
|
||||
if !self.forms_seen.insert((r, inner_owner)) {
|
||||
continue;
|
||||
}
|
||||
#[cfg(test)]
|
||||
crate::work(1);
|
||||
match s.decoded() {
|
||||
Ok(data) => self.content(&data, &inner, inner_owner, depth + 1),
|
||||
// What the form draws is unknown: keep everything it could reach.
|
||||
@@ -223,23 +231,16 @@ impl Pruner<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's content, concatenated (`/Contents` may be one stream or an array). `None` when a
|
||||
/// stream can't be decoded: the page may draw names that can't be seen.
|
||||
fn content_bytes(doc: &Document, contents: &Object) -> Option<Vec<u8>> {
|
||||
match &*doc.resolve(contents) {
|
||||
Object::Stream(s) => s.decoded().ok(),
|
||||
Object::Array(a) => {
|
||||
let mut out = Vec::new();
|
||||
for c in a {
|
||||
if let Object::Stream(s) = &*doc.resolve(c) {
|
||||
out.extend(s.decoded().ok()?);
|
||||
out.push(b'\n');
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
_ => Some(Vec::new()),
|
||||
/// The page's content, concatenated (`/Contents` may be one stream or an array), decoded
|
||||
/// against one page's [`DecodeBudget`]. An error when a stream can't be decoded or the budget
|
||||
/// runs out: the page may draw names that can't be seen.
|
||||
pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Result<Vec<u8>, CosError> {
|
||||
let mut out = Vec::new();
|
||||
for data in DecodeBudget::page().decode_contents(doc, contents) {
|
||||
out.extend(data?);
|
||||
out.push(b'\n');
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The resources of a page (as `walk` reports them) with their owner.
|
||||
@@ -314,6 +315,7 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result<usize, Organiz
|
||||
glyph_work_left: MAX_GLYPH_RESOURCE_WORK,
|
||||
glyph_seen: HashSet::new(),
|
||||
glyph_fonts: HashSet::new(),
|
||||
forms_seen: HashSet::new(),
|
||||
};
|
||||
for (page, attrs) in &pages {
|
||||
let Some(res) = attrs.get(b"Resources") else { continue };
|
||||
@@ -321,9 +323,9 @@ pub(crate) fn prune_unused_xobjects(doc: &mut Document) -> Result<usize, Organiz
|
||||
let Some(pd) = pruner.doc.get(*page).as_dict().cloned() else { continue };
|
||||
let Some(contents) = pd.get(b"Contents").cloned() else { continue };
|
||||
match content_bytes(pruner.doc, &contents) {
|
||||
Some(data) if data.is_empty() => {}
|
||||
Some(data) => pruner.content(&data, &res_dict, owner, 0),
|
||||
None => {
|
||||
Ok(data) if data.is_empty() => {}
|
||||
Ok(data) => pruner.content(&data, &res_dict, owner, 0),
|
||||
Err(_) => {
|
||||
pruner.protect_type3_resources(&res_dict, owner);
|
||||
pruner.distrust(&res_dict, owner);
|
||||
}
|
||||
|
||||
@@ -3213,3 +3213,157 @@ fn page_walk_skips_repeated_and_cyclic_kids_without_copying_attributes() {
|
||||
assert!(walk.next().is_none());
|
||||
assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 3"]);
|
||||
}
|
||||
|
||||
/// A one-page document whose page carries `annots` (object bodies 5…), with `extra` bodies after them.
|
||||
fn page_with_annots(annots: &[String], extra: &[String], catalog_extra: &str) -> Document {
|
||||
let n = annots.len();
|
||||
let refs: String = (0..n).map(|i| format!("{} 0 R ", i + 5)).collect();
|
||||
let mut b: Vec<String> = vec![
|
||||
format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Annots [{refs}] >>"),
|
||||
body("P1"),
|
||||
];
|
||||
b.extend(annots.iter().cloned());
|
||||
b.extend(extra.iter().cloned());
|
||||
open(build(&b, "/Root 1 0 R"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registering_many_imported_fields_is_linear() {
|
||||
let n = 2_000;
|
||||
let widgets: Vec<String> = (0..n).map(|i| format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{i}) /Rect [0 0 10 10] >>")).collect();
|
||||
let src = page_with_annots(&widgets, &[], "");
|
||||
take_work();
|
||||
let out = extract_pages(&src, &[0]).unwrap();
|
||||
let work = take_work();
|
||||
assert!(work <= 4 * n, "{work} steps registering {n} fields");
|
||||
let form = out.resolve(catalog(&out).get(b"AcroForm").unwrap()).as_dict().cloned().unwrap();
|
||||
assert_eq!(form.get(b"Fields").and_then(|f| f.as_array()).map(Vec::len), Some(n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_many_same_named_fields_resumes_the_suffix_search() {
|
||||
let n = 2_000;
|
||||
let widgets: Vec<String> = (0..n).map(|_| "<< /Type /Annot /Subtype /Widget /FT /Tx /T (x) /Rect [0 0 10 10] >>".to_string()).collect();
|
||||
let src = page_with_annots(&widgets, &[], "");
|
||||
take_work();
|
||||
let renames = combine_field_conflicts(&[&src]).remove(0);
|
||||
let work = take_work();
|
||||
assert!(work <= 4 * n, "{work} suffix attempts for {n} fields");
|
||||
// Still the first free variant, in order.
|
||||
assert_eq!(renames.len(), n - 1);
|
||||
assert_eq!(renames.first(), Some(&("x".to_string(), "x-1".to_string())));
|
||||
assert_eq!(renames.last(), Some(&("x".to_string(), format!("x-{}", n - 1))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotation_actions_share_the_objects_they_copy() {
|
||||
// Fifty links whose URI actions all point at one string object.
|
||||
let n = 50;
|
||||
let links: Vec<String> =
|
||||
(0..n).map(|_| format!("<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI {} 0 R >> >>", n + 5)).collect();
|
||||
let src = page_with_annots(&links, &["(https://example.com/shared)".into()], "");
|
||||
let out = extract_pages(&src, &[0]).unwrap();
|
||||
let copies = out
|
||||
.object_numbers()
|
||||
.into_iter()
|
||||
.filter(|&o| out.get(ObjRef::new(o, out.generation(o))).as_string().is_some_and(|s| s.bytes == b"https://example.com/shared"))
|
||||
.count();
|
||||
assert_eq!(copies, 1, "the shared URI is copied once, not once per action");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_trees_with_shared_kids_are_walked_once_per_node() {
|
||||
// Each level lists the next one twice: 2^depth paths through `depth` nodes.
|
||||
let depth = 10;
|
||||
let first = 6; // after the catalog, pages, page, content and the link
|
||||
let mut extra = Vec::new();
|
||||
for tree in 0..2 {
|
||||
let base = first + tree * (depth + 1);
|
||||
for level in 0..depth {
|
||||
let next = base + level + 1;
|
||||
extra.push(format!("<< /Kids [{next} 0 R {next} 0 R] >>"));
|
||||
}
|
||||
extra.push(if tree == 0 {
|
||||
"<< /Names [(a.txt) << /Type /Filespec /F (a.txt) >>] >>".into()
|
||||
} else {
|
||||
"<< /Names [(other) [3 0 R /Fit]] >>".into()
|
||||
});
|
||||
}
|
||||
let link = "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /Dest (missing) >>".to_string();
|
||||
let catalog_extra = format!("/Names << /EmbeddedFiles {first} 0 R /Dests {} 0 R >>", first + depth + 1);
|
||||
let src = page_with_annots(&[link], &extra, &catalog_extra);
|
||||
take_work();
|
||||
let out = combine(&[("a", &src)]).unwrap();
|
||||
let work = take_work();
|
||||
assert!(work <= 8 * (depth + 1), "{work} name-tree nodes visited");
|
||||
let names = out.resolve(catalog(&out).get(b"Names").unwrap()).as_dict().cloned().unwrap();
|
||||
let tree = out.resolve(names.get(b"EmbeddedFiles").unwrap()).as_dict().cloned().unwrap();
|
||||
assert_eq!(tree.get(b"Names").and_then(|n| n.as_array()).map(Vec::len), Some(2), "one attachment, listed once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_form_drawing_itself_repeatedly_is_analysed_once() {
|
||||
let form = "/F Do /F Do /F Do /F Do";
|
||||
let b: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /F 5 0 R /Unused 6 0 R >> >> >>".into(),
|
||||
"<< /Length 5 >>\nstream\n/F Do\nendstream".into(),
|
||||
format!(
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Resources << /XObject << /F 5 0 R >> >> /Length {} >>\nstream\n{form}\nendstream",
|
||||
form.len()
|
||||
),
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length 0 >>\nstream\n\nendstream".into(),
|
||||
];
|
||||
let src = open(build(&b, "/Root 1 0 R"));
|
||||
take_work();
|
||||
let out = extract_pages(&src, &[0]).unwrap();
|
||||
let work = take_work();
|
||||
assert!(work <= 4, "{work} form analyses");
|
||||
let page = out.get(pages(&out).unwrap()[0].obj).as_dict().cloned().unwrap();
|
||||
let res = out.resolve(page.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
|
||||
let x = out.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
|
||||
assert!(x.contains(b"F") && !x.contains(b"Unused"), "pruning still keeps what is drawn and drops the rest");
|
||||
}
|
||||
|
||||
/// One page whose /Contents names the same 1 MiB stream 300 times (a few kilobytes on disk).
|
||||
fn repeated_contents() -> Document {
|
||||
use pdfcraft_cos::{Dict, Stream};
|
||||
let mut doc = Document::new_empty();
|
||||
let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
|
||||
let pages = doc.add(Object::Null);
|
||||
let page: Dict = [
|
||||
(b"Type".to_vec(), Object::Name(b"Page".to_vec())),
|
||||
(b"Parent".to_vec(), Object::Ref(pages)),
|
||||
(b"MediaBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::from).to_vec())),
|
||||
(b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300])),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let page = doc.add(page);
|
||||
let tree: Dict = [
|
||||
(b"Type".to_vec(), Object::Name(b"Pages".to_vec())),
|
||||
(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])),
|
||||
(b"Count".to_vec(), Object::Int(1)),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
doc.set(pages, Object::Dict(tree));
|
||||
let catalog: Dict = [(b"Type".to_vec(), Object::Name(b"Catalog".to_vec())), (b"Pages".to_vec(), Object::Ref(pages))].into_iter().collect();
|
||||
let catalog = doc.add(catalog);
|
||||
doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog));
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_content_references_share_one_decode_budget() {
|
||||
let src = repeated_contents();
|
||||
let mut dst = Document::new_empty();
|
||||
if let Ok((form, _)) = page_as_form(&mut dst, &src, 0) {
|
||||
let Object::Stream(s) = &*dst.get(form) else { panic!("not a stream") };
|
||||
let len = s.decoded().unwrap().len();
|
||||
assert!(len <= pdfcraft_cos::MAX_PAGE_DECODED + 300, "{len} bytes");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,11 +172,24 @@ fn element_span(xmp: &str, name: &str) -> Option<(usize, usize)> {
|
||||
/// start and end of its value, and its quote character. Only matches inside a start tag.
|
||||
fn attribute_span(xmp: &str, name: &str) -> Option<(usize, usize, usize, char)> {
|
||||
let mut from = 0;
|
||||
// The last `<` and `>` before the match, found by one pass over the packet rather than by
|
||||
// searching back from every match.
|
||||
let (mut scanned, mut last_open, mut last_close) = (0, None, None);
|
||||
while let Some(i) = xmp.get(from..).and_then(|s| s.find(name)).map(|i| i + from) {
|
||||
from = i + name.len();
|
||||
let before = xmp.get(..i)?;
|
||||
let gap = xmp.get(scanned..i)?;
|
||||
#[cfg(test)]
|
||||
crate::work(gap.len());
|
||||
if let Some(at) = gap.rfind('<') {
|
||||
last_open = Some(scanned + at);
|
||||
}
|
||||
if let Some(at) = gap.rfind('>') {
|
||||
last_close = Some(scanned + at);
|
||||
}
|
||||
scanned = i;
|
||||
// Preceded by whitespace, and inside a tag (the last `<` comes after the last `>`).
|
||||
if !before.ends_with(char::is_whitespace) || before.rfind('<') < before.rfind('>') || before.rfind('<').is_none() {
|
||||
if !before.ends_with(char::is_whitespace) || last_open < last_close || last_open.is_none() {
|
||||
continue;
|
||||
}
|
||||
let rest = xmp.get(from..)?;
|
||||
@@ -269,4 +282,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attribute_search_scans_the_packet_once() {
|
||||
// Thousands of attribute-like words in one text node: none is inside a tag.
|
||||
let packet = format!("<x:xmpmeta><rdf:RDF><rdf:Description>{}</rdf:Description></rdf:RDF></x:xmpmeta>", " rdf:about".repeat(5_000));
|
||||
crate::take_work();
|
||||
assert_eq!(attribute_span(&packet, "rdf:about"), None);
|
||||
let work = crate::take_work();
|
||||
assert!(work <= 2 * packet.len(), "{work} bytes scanned in a {}-byte packet", packet.len());
|
||||
// A real attribute after them is still found.
|
||||
let packet = packet.replace("</rdf:Description>", "</rdf:Description><rdf:Description rdf:about='u'/>");
|
||||
let (_, s, e, q) = attribute_span(&packet, "rdf:about").unwrap();
|
||||
assert_eq!((packet.get(s..e), q), (Some("u"), '\''));
|
||||
}
|
||||
}
|
||||
|
||||
+110
-28
@@ -214,6 +214,9 @@ fn has_pdfa_intent(doc: &Document) -> bool {
|
||||
pdfa_intent_profile(doc).is_some()
|
||||
}
|
||||
|
||||
/// Largest output-intent profile decoded to read its header; real ICC profiles stay well under it.
|
||||
const MAX_ICC_PROFILE: usize = 16 << 20;
|
||||
|
||||
/// The PDF/A output intent's destination profile: `Some(n)` with the profile's number of colour
|
||||
/// components (`/N`, else the ICC header's data colour space; `None` when neither says), or
|
||||
/// `None` when the document has no PDF/A output intent.
|
||||
@@ -229,7 +232,7 @@ fn pdfa_intent_profile(doc: &Document) -> Option<Option<i64>> {
|
||||
let Object::Stream(s) = &*profile else { return Some(None) };
|
||||
Some(s.dict.int(b"N").or_else(|| {
|
||||
// ICC.1 header: the data colour space is bytes 16–19.
|
||||
match s.decoded().ok()?.get(16..20)? {
|
||||
match s.decoded_within(MAX_ICC_PROFILE).ok()?.get(16..20)? {
|
||||
b"GRAY" => Some(1),
|
||||
b"RGB " => Some(3),
|
||||
b"CMYK" => Some(4),
|
||||
@@ -339,8 +342,17 @@ fn is_content(d: &Dict) -> bool {
|
||||
/// The device colour a content stream sets. It is read operator by operator, so the bytes of a
|
||||
/// string, a comment or an inline image are never taken for an operator, and an operator right
|
||||
/// before a delimiter (`1 k/GS0 gs`) is still found.
|
||||
fn content_colour(data: &[u8], used: &mut DeviceColour) {
|
||||
for op in pdfcraft_content::parse(data).ops {
|
||||
///
|
||||
/// Operators are read one at a time and dropped, so a stream of millions of tiny operators never
|
||||
/// becomes millions of parsed ones in memory, and reading stops once all three spaces are set.
|
||||
/// Returns how many operators it read.
|
||||
fn content_colour(data: &[u8], used: &mut DeviceColour) -> usize {
|
||||
let mut read = 0;
|
||||
for op in pdfcraft_content::ops(data) {
|
||||
if used.gray && used.rgb && used.cmyk {
|
||||
break;
|
||||
}
|
||||
read += 1;
|
||||
match op.op.as_slice() {
|
||||
b"g" | b"G" => used.gray = true,
|
||||
b"rg" | b"RG" => used.rgb = true,
|
||||
@@ -366,6 +378,7 @@ fn content_colour(data: &[u8], used: &mut DeviceColour) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
read
|
||||
}
|
||||
|
||||
/// Whether any content or object uses a device colour space.
|
||||
@@ -400,12 +413,18 @@ fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc<Object>)])
|
||||
&& (referenced.contains(r) || is_content(&s.dict))
|
||||
&& let Ok(data) = s.decoded()
|
||||
{
|
||||
content_colour(&data, &mut used);
|
||||
let _ = content_colour(&data, &mut used);
|
||||
}
|
||||
}
|
||||
used
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Page `/Annots` entries read while matching annotations to their pages.
|
||||
static ANNOT_ENTRIES_READ: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Check `doc` against `level`.
|
||||
pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
|
||||
let mut out = Vec::new();
|
||||
@@ -476,17 +495,20 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
|
||||
}
|
||||
// Pages: annotations and page actions.
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
let page_of = |r: ObjRef| {
|
||||
pages.iter().position(|p| {
|
||||
p.dict
|
||||
.get(b"Annots")
|
||||
.map(|a| doc.resolve(a))
|
||||
.and_then(|a| a.as_array().cloned())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.any(|x| x.as_ref() == Some(r))
|
||||
})
|
||||
};
|
||||
// Each annotation's page: the first page that lists it.
|
||||
let mut annot_pages: std::collections::HashMap<ObjRef, usize> = std::collections::HashMap::new();
|
||||
for (i, p) in pages.iter().enumerate() {
|
||||
if let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a))
|
||||
&& let Some(a) = a.as_array()
|
||||
{
|
||||
#[cfg(test)]
|
||||
ANNOT_ENTRIES_READ.with(|n| n.set(n.get() + a.len()));
|
||||
for r in a.iter().filter_map(Object::as_ref) {
|
||||
annot_pages.entry(r).or_insert(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let page_of = |r: ObjRef| annot_pages.get(&r).copied();
|
||||
for (i, p) in pages.iter().enumerate() {
|
||||
if p.dict.get(b"AA").is_some() {
|
||||
issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true);
|
||||
@@ -496,12 +518,14 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
|
||||
let Some(d) = dict_of(o) else { continue };
|
||||
let ty = d.name(b"Type");
|
||||
let sub = d.name(b"Subtype");
|
||||
// Actions anywhere (structure elements also have /S: their role).
|
||||
if let Some(s) = d.name(b"S")
|
||||
&& FORBIDDEN_ACTIONS.contains(&s)
|
||||
&& is_action(d)
|
||||
{
|
||||
issue("6.5.1", "A {} action isn't allowed", Some(String::from_utf8_lossy(s).into_owned()), None, true);
|
||||
// Actions anywhere (structure elements also have /S: their role), with the actions they
|
||||
// chain to and those written directly in an /A or /OpenAction.
|
||||
let own = (d.get(b"S").is_some() && is_action(d)).then_some(d);
|
||||
let direct = [b"A".as_slice(), b"OpenAction"].map(|k| d.get(k).and_then(|a| a.as_dict()));
|
||||
for a in [own].into_iter().chain(direct).flatten() {
|
||||
if let Some(s) = chain_forbidden(doc, a) {
|
||||
issue("6.5.1", "A {} action isn't allowed", Some(s), None, true);
|
||||
}
|
||||
}
|
||||
// Annotations.
|
||||
if ty == Some(b"Annot") || (sub.is_some() && d.get(b"Rect").is_some() && d.get(b"P").is_some()) {
|
||||
@@ -633,10 +657,62 @@ fn is_action(d: &Dict) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `o` is (or refers to) a forbidden action, or to nothing.
|
||||
/// How many actions one action and the `/Next` actions written inside it may hold before the
|
||||
/// whole chain counts as forbidden, since it can't be checked.
|
||||
const MAX_ACTION_CHAIN: usize = 1024;
|
||||
|
||||
/// The first forbidden action type in the action `a` or in the actions written directly in its
|
||||
/// `/Next` (ISO 32000-2 12.6.2: a dictionary or an array of them, which may be an object of its
|
||||
/// own). An action `/Next` refers to is an object of its own, checked and removed as one.
|
||||
fn chain_forbidden(doc: &Document, a: &Dict) -> Option<String> {
|
||||
let mut read = 0;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
// Actions written in an indirect `/Next` array, copied out of it.
|
||||
let mut held: Vec<Dict> = Vec::new();
|
||||
if let Some(s) = walk_chain(doc, a, &mut read, &mut seen, &mut held) {
|
||||
return Some(s);
|
||||
}
|
||||
while let Some(d) = held.pop() {
|
||||
if let Some(s) = walk_chain(doc, &d, &mut read, &mut seen, &mut held) {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// [`chain_forbidden`] for the actions reachable from `a` through direct `/Next` entries. The
|
||||
/// actions of each indirect `/Next` array (each read once) go to `held`.
|
||||
fn walk_chain(doc: &Document, a: &Dict, read: &mut usize, seen: &mut std::collections::HashSet<ObjRef>, held: &mut Vec<Dict>) -> Option<String> {
|
||||
let mut stack = vec![a];
|
||||
while let Some(d) = stack.pop() {
|
||||
*read += 1;
|
||||
if *read > MAX_ACTION_CHAIN {
|
||||
return Some("too deeply chained".into());
|
||||
}
|
||||
if let Some(s) = d.name(b"S")
|
||||
&& FORBIDDEN_ACTIONS.contains(&s)
|
||||
{
|
||||
return Some(String::from_utf8_lossy(s).into_owned());
|
||||
}
|
||||
match d.get(b"Next") {
|
||||
Some(Object::Dict(next)) => stack.push(next),
|
||||
Some(Object::Array(list)) => stack.extend(list.iter().filter_map(Object::as_dict)),
|
||||
Some(Object::Ref(r)) if seen.insert(*r) => {
|
||||
if let Object::Array(list) = &*doc.get(*r) {
|
||||
// More than the chain limit can't be read anyway.
|
||||
held.extend(list.iter().filter_map(Object::as_dict).take(MAX_ACTION_CHAIN).cloned());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether `o` is (or refers to) a forbidden action or one chaining to it, or to nothing.
|
||||
fn forbidden(doc: &Document, o: &Object) -> bool {
|
||||
match &*doc.resolve(o) {
|
||||
Object::Dict(a) => a.name(b"S").is_some_and(|s| FORBIDDEN_ACTIONS.contains(&s)),
|
||||
Object::Dict(a) => chain_forbidden(doc, a).is_some(),
|
||||
Object::Null => true,
|
||||
_ => false,
|
||||
}
|
||||
@@ -782,14 +858,20 @@ pub fn convert(doc: &mut Document, level: Level) -> Result<Report, pdfcraft_cos:
|
||||
files += 1;
|
||||
}
|
||||
}
|
||||
// Forbidden actions left as objects of their own (nothing refers to them any more).
|
||||
// Forbidden actions left as objects of their own, or chained to by one, and those written
|
||||
// directly in an /A elsewhere (an outline item's, say).
|
||||
for (r, o) in objects(doc) {
|
||||
if let Object::Dict(d) = &*o
|
||||
&& d.name(b"S").is_some_and(|s| FORBIDDEN_ACTIONS.contains(&s))
|
||||
&& is_action(d)
|
||||
{
|
||||
let Object::Dict(d) = &*o else { continue };
|
||||
if d.get(b"S").is_some() && is_action(d) && chain_forbidden(doc, d).is_some() {
|
||||
doc.set(r, Object::Null);
|
||||
actions += 1;
|
||||
} else if let Some(Object::Dict(a)) = d.get(b"A")
|
||||
&& chain_forbidden(doc, a).is_some()
|
||||
{
|
||||
let mut nd = d.clone();
|
||||
nd.remove(b"A");
|
||||
doc.set(r, Object::Dict(nd));
|
||||
actions += 1;
|
||||
}
|
||||
}
|
||||
for (n, what) in [
|
||||
|
||||
@@ -417,3 +417,74 @@ fn oversized_xmp_is_refused_while_decoding() {
|
||||
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");
|
||||
}
|
||||
|
||||
/// Content is read one operator at a time and no further than it needs: once gray, RGB and CMYK
|
||||
/// are all set, the rest of the stream is not read.
|
||||
#[test]
|
||||
fn content_colour_reads_operators_one_at_a_time_and_stops_when_all_are_found() {
|
||||
let mut data = b"0 g 0 0 0 rg 0 0 0 0 k\n".to_vec();
|
||||
data.extend(b"n\n".repeat(100_000));
|
||||
let mut used = DeviceColour::default();
|
||||
let read = content_colour(&data, &mut used);
|
||||
assert!(used.gray && used.rgb && used.cmyk);
|
||||
assert_eq!(read, 3);
|
||||
}
|
||||
|
||||
/// A forbidden action chained by `/Next` behind an allowed one, written directly inside its
|
||||
/// parent, is reported and removed like any other.
|
||||
#[test]
|
||||
fn forbidden_actions_chained_behind_allowed_ones_are_found_and_removed() {
|
||||
let js = "<< /S /JavaScript /JS (app.alert(1)) >>";
|
||||
let page = "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>";
|
||||
let pages = "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>";
|
||||
let link = |a: &str| format!("<< /Type /Annot /Subtype /Link /Rect [0 0 0 0] /P 3 0 R /F 4 /A {a} >>");
|
||||
let open_action = format!("<< /Type /Catalog /Pages 2 0 R /OpenAction << /S /GoTo /D [3 0 R /Fit] /Next {js} >> >>");
|
||||
let goto = format!("<< /Type /Action /S /GoTo /D [3 0 R /Fit] /Next [{js}] >>");
|
||||
let cases = [
|
||||
build(&[&open_action, pages, page, &link("<< /S /GoTo /D [3 0 R /Fit] >>")], ""),
|
||||
build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, &link("5 0 R"), &goto], ""),
|
||||
build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, &link(&format!("<< /S /GoTo /D [3 0 R /Fit] /Next {js} >>"))], ""),
|
||||
// `/Next` refers to an array that holds the action directly.
|
||||
build(&["<< /Type /Catalog /Pages 2 0 R >>", pages, page, &link("<< /S /GoTo /D [3 0 R /Fit] /Next 5 0 R >>"), &format!("[{js}]")], ""),
|
||||
];
|
||||
for (i, mut doc) in cases.into_iter().enumerate() {
|
||||
let found = messages(&verify(&doc, Level::A2b)).contains(&"A JavaScript action isn't allowed");
|
||||
assert!(found, "case {i}: {:#?}", verify(&doc, Level::A2b));
|
||||
let r = convert(&mut doc, Level::A2b).unwrap();
|
||||
assert!(!r.remaining.iter().any(|x| x.clause == "6.5.1"), "case {i}: {:#?}", r.remaining);
|
||||
let plain = pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() };
|
||||
let bytes = pdfcraft_cos::write_full(&doc, &plain).unwrap();
|
||||
assert!(!bytes.windows(11).any(|w| w == b"/JavaScript"), "case {i}: {}", String::from_utf8_lossy(&bytes));
|
||||
}
|
||||
}
|
||||
|
||||
/// Each annotation's page is found from one pass over the pages' `/Annots`, not by searching
|
||||
/// every page's list again for each annotation.
|
||||
#[test]
|
||||
fn annotation_pages_are_found_in_one_pass() {
|
||||
let n = 2_000;
|
||||
let annots: Vec<String> = (0..n).map(|i| format!("{} 0 R", i + 4)).collect();
|
||||
let page = format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", annots.join(" "));
|
||||
let mut objs =
|
||||
vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".into(), page];
|
||||
objs.extend((0..n).map(|_| "<< /Type /Annot /Subtype /Link /Rect [0 0 0 0] /P 3 0 R >>".to_string()));
|
||||
let refs: Vec<&str> = objs.iter().map(String::as_str).collect();
|
||||
let doc = build(&refs, "");
|
||||
ANNOT_ENTRIES_READ.with(|c| c.set(0));
|
||||
let issues = verify(&doc, Level::A2b);
|
||||
assert!(issues.iter().any(|i| i.clause == "6.3.2" && i.page == Some(0)), "{issues:#?}");
|
||||
let read = ANNOT_ENTRIES_READ.with(std::cell::Cell::get);
|
||||
assert!(read <= 2 * n, "{read} entries read for {n} annotations");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_output_intent_profile_header_is_read_within_a_size_cap() {
|
||||
let intent = "/OutputIntents [<< /S /GTS_PDFA1 /DestOutputProfile 2 0 R >>]";
|
||||
for (size, n) in [(128, Some(4)), (MAX_ICC_PROFILE + 1, None)] {
|
||||
let mut doc = build(&[&format!("<< /Type /Catalog {intent} >>"), "null"], "");
|
||||
let mut profile = vec![0; size];
|
||||
profile[16..20].copy_from_slice(b"CMYK");
|
||||
doc.set(pdfcraft_cos::ObjRef::new(2, 0), Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &profile)));
|
||||
assert_eq!(pdfa_intent_profile(&doc), Some(n), "a {size}-byte profile");
|
||||
}
|
||||
}
|
||||
|
||||
+50
-24
@@ -18,7 +18,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use pdfcraft_content::Matrix;
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full};
|
||||
|
||||
pub mod range;
|
||||
pub mod spool;
|
||||
@@ -331,11 +331,14 @@ pub fn text_reads_up(doc: &Document, index: usize) -> bool {
|
||||
let to_display = Matrix(page.view_matrix(doc)).invert().unwrap_or_default();
|
||||
let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
|
||||
let mut score = 0i64;
|
||||
text_direction(doc, &decoded_contents(doc, &page.dict), &res, to_display, 0, &mut score);
|
||||
let mut budget = DecodeBudget::page();
|
||||
// Content past the budget is left out: the answer is a guess from the text read so far.
|
||||
let data = decoded_contents(doc, &page.dict, &mut budget).unwrap_or_default();
|
||||
text_direction(doc, &data, &res, to_display, 0, &mut score, &mut budget);
|
||||
score > 0
|
||||
}
|
||||
|
||||
fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64) {
|
||||
fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64, budget: &mut DecodeBudget) {
|
||||
let (mut ctm, mut tm, mut stack) = (base, Matrix::IDENTITY, Vec::new());
|
||||
for op in pdfcraft_content::parse(data).ops {
|
||||
match op.op.as_slice() {
|
||||
@@ -358,8 +361,11 @@ fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth:
|
||||
}
|
||||
let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default();
|
||||
let inner = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_else(|| res.clone());
|
||||
if let Ok(body) = form.decoded() {
|
||||
text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score);
|
||||
if !budget.enter_form() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(body) = budget.decode(form) {
|
||||
text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score, budget);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -676,24 +682,22 @@ fn n(v: f64) -> String {
|
||||
if s == "-0" { "0".into() } else { s.into() }
|
||||
}
|
||||
|
||||
fn decoded_contents(doc: &Document, page: &Dict) -> Vec<u8> {
|
||||
let list: Vec<Object> = match page.get(b"Contents") {
|
||||
None => Vec::new(),
|
||||
Some(c) => match &*doc.resolve(c) {
|
||||
Object::Array(a) => a.clone(),
|
||||
_ => vec![c.clone()],
|
||||
},
|
||||
};
|
||||
/// The page's content streams decoded against `budget` and joined; streams that don't decode
|
||||
/// are left out. `None` when the budget ran out, so part of the content is missing.
|
||||
fn decoded_contents(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Option<Vec<u8>> {
|
||||
let Some(contents) = page.get(b"Contents") else { return Some(Vec::new()) };
|
||||
let mut out = Vec::new();
|
||||
for o in list {
|
||||
if let Object::Stream(s) = &*doc.resolve(&o)
|
||||
&& let Ok(d) = s.decoded()
|
||||
{
|
||||
out.extend_from_slice(&d);
|
||||
out.push(b'\n');
|
||||
for data in budget.decode_contents(doc, contents) {
|
||||
match data {
|
||||
Ok(data) => {
|
||||
out.extend_from_slice(&data);
|
||||
out.push(b'\n');
|
||||
}
|
||||
Err(_) if budget.remaining() == 0 => return None,
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Reduce `doc`'s pages to what [`impose`] draws of them under `content`: the annotations that
|
||||
@@ -770,16 +774,24 @@ fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option<Matr
|
||||
}
|
||||
|
||||
/// A Form XObject holding page `index` as it prints (user space; BBox = crop box).
|
||||
/// `pages` are the document's pages, listed once by the caller: listing them for each page
|
||||
/// printed would make printing quadratic in the page count.
|
||||
fn page_form(
|
||||
doc: &mut Document,
|
||||
pages: &[pdfcraft_model::Page],
|
||||
index: usize,
|
||||
content: Content,
|
||||
promoted: &mut std::collections::HashMap<ObjRef, ObjRef>,
|
||||
) -> Result<ObjRef, PrintError> {
|
||||
let page = pdfcraft_model::pages(doc).swap_remove(index);
|
||||
let page = pages.get(index).ok_or_else(|| PrintError::Invalid(format!("there is no page {}", index.saturating_add(1))))?;
|
||||
let crop = page.crop(doc);
|
||||
let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
|
||||
let mut body = if content == Content::FormFieldsOnly { Vec::new() } else { decoded_contents(doc, &page.dict) };
|
||||
let mut body = if content == Content::FormFieldsOnly {
|
||||
Vec::new()
|
||||
} else {
|
||||
decoded_contents(doc, &page.dict, &mut DecodeBudget::page())
|
||||
.ok_or_else(|| PrintError::Invalid(format!("page {} has more content than can be printed", index + 1)))?
|
||||
};
|
||||
// Wrap the page content so a state it leaves behind can't affect the annotations.
|
||||
if !body.is_empty() {
|
||||
let mut wrapped = b"q\n".to_vec();
|
||||
@@ -841,10 +853,24 @@ fn page_form(
|
||||
Ok(doc.add(Object::Stream(Stream::flate(d, &body))))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Pages listed by [`source_pages`] on this thread.
|
||||
static PAGES_LISTED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The source document's pages, for printing.
|
||||
fn source_pages(doc: &Document) -> Vec<pdfcraft_model::Page> {
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
#[cfg(test)]
|
||||
PAGES_LISTED.with(|c| c.set(c.get() + pages.len()));
|
||||
pages
|
||||
}
|
||||
|
||||
/// Build the print-ready PDF for `settings`. Encryption is not carried over (the file goes to a
|
||||
/// printer or is the user's own copy); callers must check the print permission first.
|
||||
pub fn impose(src: &Document, settings: &Settings) -> Result<Vec<u8>, PrintError> {
|
||||
let pages = pdfcraft_model::pages(src);
|
||||
let pages = source_pages(src);
|
||||
let sizes: Vec<(f64, f64)> = pages.iter().map(|p| p.display_size(src)).collect();
|
||||
let sheets = layout(&sizes, settings)?;
|
||||
let mut doc = src.clone();
|
||||
@@ -861,7 +887,7 @@ pub fn impose(src: &Document, settings: &Settings) -> Result<Vec<u8>, PrintError
|
||||
let form = match forms.get(&pl.page) {
|
||||
Some(f) => *f,
|
||||
None => {
|
||||
let f = page_form(&mut doc, pl.page, settings.content, &mut promoted)?;
|
||||
let f = page_form(&mut doc, &pages, pl.page, settings.content, &mut promoted)?;
|
||||
forms.insert(pl.page, f);
|
||||
f
|
||||
}
|
||||
|
||||
@@ -192,6 +192,17 @@ fn custom_pages_per_sheet_never_overfill_the_grid() {
|
||||
assert_eq!(pdfcraft_model::pages(&Document::open(Arc::new(out)).unwrap()).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imposing_lists_the_pages_once() {
|
||||
let n = 150;
|
||||
let doc = fixture(n);
|
||||
PAGES_LISTED.with(|c| c.set(0));
|
||||
let out = impose(&doc, &settings((0..n).collect(), Layout::multiple(2))).unwrap();
|
||||
let listed = PAGES_LISTED.with(|c| c.get());
|
||||
assert!(listed <= 2 * n, "{listed} pages listed to print {n}");
|
||||
assert_eq!(pdfcraft_model::pages(&Document::open(Arc::new(out)).unwrap()).len(), n / 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn booklets_pair_pages_for_folding() {
|
||||
let sizes = vec![(200.0, 300.0); 6];
|
||||
@@ -877,3 +888,55 @@ fn driver_options_take_the_ppds_own_names_in_the_ui_language() {
|
||||
}
|
||||
|
||||
mod appearances;
|
||||
|
||||
#[test]
|
||||
fn repeated_content_references_share_one_decode_budget() {
|
||||
let mut doc = Document::new_empty();
|
||||
let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
|
||||
let page: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300]))].into_iter().collect();
|
||||
let data = decoded_contents(&doc, &page, &mut DecodeBudget::page());
|
||||
assert!(data.is_none_or(|d| d.len() <= pdfcraft_cos::MAX_PAGE_DECODED + 300));
|
||||
}
|
||||
|
||||
/// 20 draws of a form that draws the next form 20 times, four levels down: 168,420 form
|
||||
/// entries from a few hundred bytes.
|
||||
fn form_fanout(doc: &mut Document) -> Dict {
|
||||
let draws = "/X Do ".repeat(20);
|
||||
let mut form = |content: &str, inner: Option<ObjRef>| {
|
||||
let mut d: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect();
|
||||
if let Some(inner) = inner {
|
||||
let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect();
|
||||
let res: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect();
|
||||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
}
|
||||
doc.add(Stream::from_raw(d, content.as_bytes().to_vec()))
|
||||
};
|
||||
let mut inner = form("BT 0 1 -1 0 0 0 Tm (a) Tj ET", None);
|
||||
for _ in 0..3 {
|
||||
inner = form(&draws, Some(inner));
|
||||
}
|
||||
let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect();
|
||||
[(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_direction_stops_at_the_form_budget() {
|
||||
let mut doc = Document::new_empty();
|
||||
let res = form_fanout(&mut doc);
|
||||
let mut score = 0;
|
||||
text_direction(&doc, "/X Do ".repeat(20).as_bytes(), &res, Matrix::IDENTITY, 0, &mut score, &mut DecodeBudget::page());
|
||||
assert!(score > 0 && score <= pdfcraft_cos::MAX_PAGE_FORMS as i64, "score {score}");
|
||||
}
|
||||
|
||||
/// A form refused by the spent budget is left out; the text drawn after it still counts.
|
||||
#[test]
|
||||
fn text_direction_reads_on_past_a_refused_form() {
|
||||
let mut doc = Document::new_empty();
|
||||
let form = doc.add(Stream::from_raw([(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect(), Vec::new()));
|
||||
let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect();
|
||||
let res: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect();
|
||||
let mut score = 0;
|
||||
let content = b"/X Do BT 0 1 -1 0 0 0 Tm (a) Tj ET";
|
||||
text_direction(&doc, content, &res, Matrix::IDENTITY, 0, &mut score, &mut DecodeBudget::new(1 << 20, 0));
|
||||
assert_eq!(score, 1);
|
||||
}
|
||||
|
||||
@@ -147,7 +147,8 @@ fn promotion_preserves_streams_and_explicit_subtypes() {
|
||||
for subtype in [None, Some(Object::name("Form")), Some(Object::name("Image")), Some(Object::Null), Some(Object::Int(7))] {
|
||||
let (mut doc, ap, _) = widget_fixture(subtype.clone(), Some([2.0, 0.0, 0.0, 3.0, 5.0, 7.0]));
|
||||
let original = doc.get(ap);
|
||||
let form = page_form(&mut doc, 0, Content::Document, &mut std::collections::HashMap::new()).unwrap();
|
||||
let pages = pdfcraft_model::pages(&doc);
|
||||
let form = page_form(&mut doc, &pages, 0, Content::Document, &mut std::collections::HashMap::new()).unwrap();
|
||||
assert_eq!(doc.get(ap), original, "original in the print clone");
|
||||
let promoted = xobject(&doc, doc.get(form).as_dict().unwrap(), b"PCPrA0");
|
||||
if subtype.is_some() {
|
||||
|
||||
@@ -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::{CosError, Dict, Document, ObjRef, Object, Stream};
|
||||
use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, MAX_PAGE_FORMS, ObjRef, Object, Stream};
|
||||
|
||||
use crate::{RedactError, Report, image, page_streams, tags};
|
||||
use pdfcraft_fonts::pdf::Metrics;
|
||||
@@ -106,10 +106,16 @@ pub(crate) struct Scope<'a> {
|
||||
fonts: HashMap<Vec<u8>, Rc<Metrics>>,
|
||||
used_names: Vec<Vec<u8>>,
|
||||
depth: usize,
|
||||
/// Form entries and decoded bytes left for this page, its content streams already charged:
|
||||
/// shared forms drawn from other shared forms would otherwise multiply the work with every
|
||||
/// level.
|
||||
budget: DecodeBudget,
|
||||
}
|
||||
|
||||
impl<'a> Scope<'a> {
|
||||
pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report) -> Self {
|
||||
/// A scope whose form walk spends `budget`, what is left of the page's allowance after its
|
||||
/// content streams were decoded.
|
||||
pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report, budget: DecodeBudget) -> Self {
|
||||
Scope {
|
||||
rects,
|
||||
mode,
|
||||
@@ -124,6 +130,7 @@ impl<'a> Scope<'a> {
|
||||
fonts: HashMap::new(),
|
||||
used_names: Vec::new(),
|
||||
depth: 0,
|
||||
budget,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1272,8 +1279,25 @@ fn xobject(
|
||||
return Some(None);
|
||||
}
|
||||
// Strict, like page content: a Form read only in part counts as undecodable.
|
||||
let data = match s.decoded_strict() {
|
||||
let data = if scope.budget.enter_form() {
|
||||
scope.budget.decode_strict(s)
|
||||
} else {
|
||||
Err(CosError::Filter(format!("the page draws more than {MAX_PAGE_FORMS} form XObjects")))
|
||||
};
|
||||
let data = match data {
|
||||
Ok(d) => d,
|
||||
// Past the page's budget nothing more is read: apply refuses the page, and
|
||||
// verification counts the form as content that may remain.
|
||||
Err(e) if scope.budget.exhausted() => {
|
||||
if scope.mode == Mode::Apply {
|
||||
out.error = Some(e);
|
||||
} else if scope.everywhere() {
|
||||
scope.layer_blocks += 1;
|
||||
} else {
|
||||
out.residue += 1;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
Err(_) if scope.mode == Mode::Apply => {
|
||||
scope.report.forms_removed += 1;
|
||||
out.replaced.push(name);
|
||||
|
||||
@@ -227,8 +227,20 @@ pub(crate) fn marks_checked(doc: &Document) -> (Vec<Mark>, Vec<usize>) {
|
||||
(out, unreadable)
|
||||
}
|
||||
|
||||
/// A page's content streams: (the references or inline objects as listed, their decoded data).
|
||||
/// A page's content streams: (the references or inline objects as listed, their decoded data),
|
||||
/// decoded within one page's [`pdfcraft_cos::DecodeBudget`].
|
||||
pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result<(Vec<Object>, Vec<Vec<u8>>), RedactError> {
|
||||
page_streams_within(doc, page, index, &mut pdfcraft_cos::DecodeBudget::page())
|
||||
}
|
||||
|
||||
/// [`page_streams`], decoded against `budget`, which the walk of the page's forms then goes on
|
||||
/// spending.
|
||||
pub(crate) fn page_streams_within(
|
||||
doc: &Document,
|
||||
page: &Dict,
|
||||
index: usize,
|
||||
budget: &mut pdfcraft_cos::DecodeBudget,
|
||||
) -> Result<(Vec<Object>, Vec<Vec<u8>>), RedactError> {
|
||||
let list: Vec<Object> = match page.get(b"Contents") {
|
||||
None => Vec::new(),
|
||||
Some(c) => match &*doc.resolve(c) {
|
||||
@@ -241,7 +253,7 @@ pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result<
|
||||
match &*doc.resolve(o) {
|
||||
// Strict: a tolerant decode keeps what precedes corruption and drops the rest, which
|
||||
// still draws in viewers that read further; anything not read can't be cleaned.
|
||||
Object::Stream(s) => data.push(s.decoded_strict().map_err(|_| RedactError::Unreadable(index + 1))?),
|
||||
Object::Stream(s) => data.push(budget.decode_strict(s).map_err(|_| RedactError::Unreadable(index + 1))?),
|
||||
Object::Null => data.push(Vec::new()),
|
||||
_ => return Err(RedactError::Unreadable(index + 1)),
|
||||
}
|
||||
@@ -345,10 +357,17 @@ fn localize_resources(doc: &mut Document, selected: &[usize]) -> Result<(), pdfc
|
||||
///
|
||||
/// # 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.
|
||||
/// On any error `doc` is left as it was: the redaction runs on a copy (cheap: unchanged objects
|
||||
/// are shared) that replaces `doc` only on success.
|
||||
pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, RedactError> {
|
||||
let mut work = doc.clone();
|
||||
let report = apply_to(&mut work, pages)?;
|
||||
*doc = work;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// [`apply`] without the copy: an error can leave `doc` part redacted.
|
||||
fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, RedactError> {
|
||||
let (all_marks, unreadable) = marks_checked(doc);
|
||||
// A mark with no readable area is not applied, and applying the others would look complete:
|
||||
// refuse before anything changes. Only the pages being applied count.
|
||||
@@ -394,10 +413,11 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
|
||||
let rects: Vec<[f64; 4]> = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect();
|
||||
|
||||
// 1. Content.
|
||||
let (list, data) = page_streams(doc, &page.dict, pi)?;
|
||||
let mut budget = pdfcraft_cos::DecodeBudget::page();
|
||||
let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?;
|
||||
let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
|
||||
let out = {
|
||||
let mut scope = Scope::new(&rects, Mode::Apply, &mut report);
|
||||
let mut scope = Scope::new(&rects, Mode::Apply, &mut report, budget);
|
||||
process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY)
|
||||
};
|
||||
// Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot
|
||||
@@ -599,13 +619,14 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
|
||||
for &(pi, page_marks) in &groups {
|
||||
let page = pdfcraft_model::pages(doc).swap_remove(pi);
|
||||
let rects: Vec<[f64; 4]> = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect();
|
||||
let (list, data) = page_streams(doc, &page.dict, pi)?;
|
||||
let mut budget = pdfcraft_cos::DecodeBudget::page();
|
||||
let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?;
|
||||
// The overlay stream draws text of its own: the overlay label is allowed.
|
||||
let original: Vec<Vec<u8>> =
|
||||
list.iter().zip(data).filter(|(o, _)| !o.as_ref().is_some_and(|r| report.overlays.contains(&r))).map(|(_, d)| d).collect();
|
||||
let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
|
||||
let mut scratch = Report::default();
|
||||
let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch);
|
||||
let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch, budget);
|
||||
residue += process(doc, &mut scope, &original, &resources, pdfcraft_content::Matrix::IDENTITY).residue;
|
||||
}
|
||||
if residue > 0 {
|
||||
|
||||
@@ -8,7 +8,7 @@ use pdfcraft_content::Matrix;
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
|
||||
use crate::interp::{Mode, Scope, process};
|
||||
use crate::{RedactError, Report, annots_of, listed_annotations, page_streams};
|
||||
use crate::{RedactError, Report, annots_of, listed_annotations, page_streams, page_streams_within};
|
||||
|
||||
/// The categories of Acrobat's Remove Hidden Information panel that PdfCraft handles.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
@@ -356,7 +356,8 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
|
||||
let n = pdfcraft_model::pages(doc).len();
|
||||
for pi in 0..n {
|
||||
let page = pdfcraft_model::pages(doc).swap_remove(pi);
|
||||
let Ok((list, data)) = page_streams(doc, &page.dict, pi) else {
|
||||
let mut budget = pdfcraft_cos::DecodeBudget::page();
|
||||
let Ok((list, data)) = page_streams_within(doc, &page.dict, pi, &mut budget) else {
|
||||
if write {
|
||||
return Err(RedactError::Unreadable(pi + 1));
|
||||
}
|
||||
@@ -368,7 +369,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
|
||||
let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
|
||||
let mut report = Report::default();
|
||||
let mode = if write { Mode::Apply } else { Mode::Verify };
|
||||
let mut scope = Scope::new(&[], mode, &mut report);
|
||||
let mut scope = Scope::new(&[], mode, &mut report, budget);
|
||||
if text {
|
||||
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])]);
|
||||
|
||||
+76
-10
@@ -48,10 +48,11 @@ fn content(doc: &Document, page: usize) -> String {
|
||||
/// Glyphs (and inline images) of page `page` under `rects` (the verifier's count).
|
||||
fn under(doc: &mut Document, page: usize, rects: &[[f64; 4]]) -> usize {
|
||||
let p = pdfcraft_model::pages(doc).swap_remove(page);
|
||||
let (_, data) = page_streams(doc, &p.dict, page).unwrap();
|
||||
let mut budget = pdfcraft_cos::DecodeBudget::page();
|
||||
let (_, data) = page_streams_within(doc, &p.dict, page, &mut budget).unwrap();
|
||||
let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
|
||||
let mut rep = Report::default();
|
||||
let mut scope = Scope::new(rects, Mode::Verify, &mut rep);
|
||||
let mut scope = Scope::new(rects, Mode::Verify, &mut rep, budget);
|
||||
process(doc, &mut scope, &data, &res, pdfcraft_content::Matrix::IDENTITY).residue
|
||||
}
|
||||
|
||||
@@ -1689,8 +1690,8 @@ fn bluefin_survives(doc: &Document) -> bool {
|
||||
|
||||
/// #3285 review (the maintainer's probes): an element written in place in the parent tree that
|
||||
/// owns the redacted text can't be changed, so applying is refused; the same element as an object
|
||||
/// of its own is cleaned, and nothing of the word is left in a full rewrite.
|
||||
/// Refusal does not promise rollback: direct callers discard the failed working copy.
|
||||
/// of its own is cleaned, and nothing of the word is left in a full rewrite. A refusal leaves the
|
||||
/// document as it was.
|
||||
#[test]
|
||||
fn an_element_written_in_place_owning_redacted_text_is_refused() {
|
||||
let objs = |element: &str| -> Vec<Vec<u8>> {
|
||||
@@ -1710,17 +1711,13 @@ fn an_element_written_in_place_owning_redacted_text_is_refused() {
|
||||
let mut doc = pdf(objs("<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) >>"));
|
||||
mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], "");
|
||||
let before = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
let mut working = doc.clone();
|
||||
let error = apply(&mut working, None).unwrap_err();
|
||||
let error = apply(&mut doc, None).unwrap_err();
|
||||
assert!(matches!(error, RedactError::Tags));
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"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"
|
||||
);
|
||||
assert!(!content(&working, 0).contains("Bluefin"), "the failed working copy was partially changed");
|
||||
assert!(bluefin_survives(&working), "the failed working copy still holds the word in its tags");
|
||||
drop(working);
|
||||
assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before, "discarding the working copy preserves the original");
|
||||
assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before, "the refused document is unchanged");
|
||||
let mut doc = pdf(objs("7 0 R"));
|
||||
mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], "");
|
||||
let r = apply(&mut doc, None).unwrap();
|
||||
@@ -2478,6 +2475,23 @@ fn a_tj_surplus_arrays_undrawn_glyphs_are_not_seen_under_a_mark() {
|
||||
assert_eq!(under(&mut doc, 0, &[[10.0, 95.0, 45.0, 110.0]]), 7, "LASTARR's 7 glyphs, the one the page draws");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_entries_share_one_budget_per_page() {
|
||||
// 20 draws of a form that draws the next 20 times, four levels down: 160,000 glyphs from a
|
||||
// few hundred bytes.
|
||||
let draws = "/X Do ".repeat(20);
|
||||
let mut objs = vec![stream("/Type /XObject /Subtype /Form /Resources << /Font << /F1 5 0 R >> >>", b"BT /F1 10 Tf 10 100 Td (A) Tj ET")];
|
||||
for inner in 7..10 {
|
||||
objs.push(stream(&format!("/Type /XObject /Subtype /Form /Resources << /XObject << /X {inner} 0 R >> >>"), draws.as_bytes()));
|
||||
}
|
||||
let mut doc = one_page(draws.as_bytes(), "/XObject << /X 10 0 R >>", objs);
|
||||
let residue = under(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]]);
|
||||
assert!(residue > 0 && residue <= pdfcraft_cos::MAX_PAGE_FORMS, "{residue} glyphs counted");
|
||||
// Applying refuses the page rather than leave forms past the budget unread.
|
||||
mark(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]], "");
|
||||
assert!(apply(&mut doc, None).is_err());
|
||||
}
|
||||
|
||||
/// A name tree whose levels each list the next level twice: a walk that follows every path
|
||||
/// visits 2^levels nodes.
|
||||
#[test]
|
||||
@@ -2696,3 +2710,55 @@ fn repeated_name_tree_kids_count_towards_the_node_budget() {
|
||||
let counts: std::collections::HashMap<Hidden, usize> = scan(&doc).into_iter().collect();
|
||||
assert_eq!(counts[&Hidden::Attachments], 1, "the walk went past its node budget to the last kid");
|
||||
}
|
||||
|
||||
/// Content streams and the forms they draw spend one page allowance: 130 MiB of content and a
|
||||
/// 130 MiB form are each under it, together past it.
|
||||
#[test]
|
||||
fn content_and_forms_share_one_budget_per_page() {
|
||||
use pdfcraft_cos::{Dict, Stream};
|
||||
let mut doc = one_page(b"", "", vec![]);
|
||||
let mut content = b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET /X Do\n%".to_vec();
|
||||
content.resize(130 << 20, b'x');
|
||||
let content = doc.add(Object::Stream(Stream::flate(Dict::new(), &content)));
|
||||
let mut form = Dict::new();
|
||||
form.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
form.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
let mut body = b"%".to_vec();
|
||||
body.resize(130 << 20, b'x');
|
||||
let form = doc.add(Object::Stream(Stream::flate(form, &body)));
|
||||
doc.update_dict(ObjRef::new(3, 0), |d| {
|
||||
let mut xobjects = Dict::new();
|
||||
xobjects.set(b"X".to_vec(), Object::Ref(form));
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"F1".to_vec(), Object::Ref(ObjRef::new(5, 0)));
|
||||
let mut res = Dict::new();
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xobjects));
|
||||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
d.set(b"Contents".to_vec(), Object::Ref(content));
|
||||
})
|
||||
.unwrap();
|
||||
mark(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]], "");
|
||||
assert!(apply(&mut doc, None).is_err(), "the form past the page's allowance was read");
|
||||
}
|
||||
|
||||
/// A page that fails to read after another page was already redacted leaves the whole document as
|
||||
/// it was, the shared resources the first page's rewrite localised included.
|
||||
#[test]
|
||||
fn a_failure_on_a_later_page_leaves_the_document_unchanged() {
|
||||
let mut doc = two_pages_one_resource_dictionary(true, false);
|
||||
let all = pdfcraft_model::pages(&doc);
|
||||
let (first, second, parent) = (all[0].obj, all[1].obj, doc.get(all[0].obj).as_dict().unwrap().reference(b"Parent").unwrap());
|
||||
let mut dct = Dict::new();
|
||||
dct.set(b"Filter".to_vec(), Object::name("DCTDecode"));
|
||||
let bad = doc.add(Object::Stream(Stream::from_raw(dct, b"garbage".to_vec())));
|
||||
doc.update_dict(second, |d| d.set(b"Contents".to_vec(), Object::Ref(bad))).unwrap();
|
||||
mark(&mut doc, 0, &[[0.0, 0.0, 300.0, 300.0]], "");
|
||||
mark(&mut doc, 1, &[[0.0, 0.0, 50.0, 50.0]], "");
|
||||
let before: Vec<Object> = [first, second, parent].iter().map(|r| (*doc.get(*r)).clone()).collect();
|
||||
let text = content(&doc, 0);
|
||||
assert_eq!(apply(&mut doc, None), Err(RedactError::Unreadable(2)));
|
||||
let after: Vec<Object> = [first, second, parent].iter().map(|r| (*doc.get(*r)).clone()).collect();
|
||||
assert_eq!(after, before);
|
||||
assert_eq!(content(&doc, 0), text);
|
||||
}
|
||||
|
||||
+406
-27
@@ -38,6 +38,31 @@ const MAX_XFA_CONFIG_BYTES: usize = 16 << 20;
|
||||
const MAX_REMOTE_FILE_SPEC_BYTES: usize = 4096;
|
||||
const MAX_REMOTE_DESTINATION_NAME_BYTES: usize = 1024;
|
||||
|
||||
/// Text and geometry `DocInfo::annotations` keeps, as counted by [`retained`]. Past it the rest
|
||||
/// are not listed: real comments stay far below, many small ones sharing one long string do not.
|
||||
const MAX_ANNOTATION_BYTES: usize = 64 << 20;
|
||||
|
||||
/// Web-address text the link listing, and name, description and embedded-file key text the
|
||||
/// attachment listing, keep together. Many links or attachments can share one long string, which
|
||||
/// each would otherwise copy. Past it, a link to a web address lists as a URI action without its
|
||||
/// address, an attachment under its generic name without a description, and an embedded file whose
|
||||
/// key does not fit is not listed.
|
||||
const MAX_LINK_ATTACHMENT_TEXT_BYTES: usize = 16 << 20;
|
||||
|
||||
/// The bytes a listed annotation keeps.
|
||||
fn retained(a: &Annotation) -> usize {
|
||||
let text = [&a.author, &a.contents, &a.modified, &a.name, &a.in_reply_to, &a.state, &a.intent];
|
||||
text.into_iter()
|
||||
.map(|t| t.as_ref().map_or(0, String::len))
|
||||
.chain([
|
||||
a.subtype.len(),
|
||||
std::mem::size_of_val(a.quads.as_slice()),
|
||||
std::mem::size_of_val(a.points.as_slice()),
|
||||
std::mem::size_of::<Annotation>(),
|
||||
])
|
||||
.fold(0, usize::saturating_add)
|
||||
}
|
||||
|
||||
/// At most this many layers are read from all of `/RBGroups` together.
|
||||
const MAX_LAYER_GROUP_ENTRIES: usize = 4096;
|
||||
|
||||
@@ -676,7 +701,8 @@ impl<'a> Inspector<'a> {
|
||||
if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) {
|
||||
(info.outline, info.outline_more) = self.outline_window(first);
|
||||
}
|
||||
self.annotations(info);
|
||||
let mut text_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES;
|
||||
self.annotations(info, &mut text_budget);
|
||||
self.transitions(info);
|
||||
let mut field_seen = HashSet::new();
|
||||
if let Some(form) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o))
|
||||
@@ -720,8 +746,10 @@ impl<'a> Inspector<'a> {
|
||||
if let Some(names) = catalog.get(b"Names").ok().and_then(|o| self.dict(o)) {
|
||||
if let Some(ef) = names.get(b"EmbeddedFiles").ok().and_then(|o| self.dict(o)) {
|
||||
let mut seen = HashSet::new();
|
||||
for (name, spec) in self.name_tree(ef, &mut seen, 0) {
|
||||
info.attachments.push(self.attachment(name.clone(), spec, AttachmentSource::Document { key: name }));
|
||||
for (name, spec) in self.name_tree(ef, &mut seen, 0, &mut text_budget) {
|
||||
// The key is kept a second time when it is also the name.
|
||||
let fallback = |budget: &mut usize| spend(name.as_bytes(), budget, |_| name.clone());
|
||||
info.attachments.push(self.attachment(spec, AttachmentSource::Document { key: name.clone() }, &mut text_budget, fallback));
|
||||
}
|
||||
}
|
||||
info.has_javascript |= names.get(b"JavaScript").is_ok();
|
||||
@@ -809,6 +837,12 @@ impl<'a> Inspector<'a> {
|
||||
self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o)
|
||||
}
|
||||
|
||||
/// The object a reference ends at after following any chain of references; `None` for a
|
||||
/// direct object or a broken reference.
|
||||
fn resolved_id(&self, o: &Object) -> Option<ObjectId> {
|
||||
self.doc.dereference(o).ok().and_then(|(id, _)| id)
|
||||
}
|
||||
|
||||
fn dict(&self, o: &'a Object) -> Option<&'a Dictionary> {
|
||||
match self.resolve(o) {
|
||||
Object::Dictionary(d) => Some(d),
|
||||
@@ -818,16 +852,19 @@ impl<'a> Inspector<'a> {
|
||||
}
|
||||
|
||||
fn text(&self, d: &Dictionary, key: &[u8]) -> Option<String> {
|
||||
let o = self.resolve(d.get(key).ok()?);
|
||||
let s = match o {
|
||||
Object::String(bytes, _) => text_string(bytes),
|
||||
Object::Name(n) => String::from_utf8_lossy(n).into_owned(),
|
||||
_ => return None,
|
||||
};
|
||||
let s = s.trim_matches('\0').trim().to_string();
|
||||
let s = trimmed_text(self.resolve(d.get(key).ok()?))?;
|
||||
(!s.is_empty()).then_some(s)
|
||||
}
|
||||
|
||||
/// [`Self::text`], converted and spent from `budget` as [`spend`] does, so text that trims
|
||||
/// short or to nothing still spends its raw length.
|
||||
fn budgeted_text(&self, d: &Dictionary, key: &[u8], budget: &mut usize) -> Option<String> {
|
||||
let o = self.resolve(d.get(key).ok()?);
|
||||
let (Object::String(bytes, _) | Object::Name(bytes)) = o else { return None };
|
||||
let text = spend(bytes, budget, |_| trimmed_text(o).unwrap_or_default())?;
|
||||
(!text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
fn name(&self, d: &Dictionary, key: &[u8]) -> Option<String> {
|
||||
self.resolve(d.get(key).ok()?).as_name().ok().map(|n| String::from_utf8_lossy(n).into_owned())
|
||||
}
|
||||
@@ -916,7 +953,15 @@ impl<'a> Inspector<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn name_tree(&self, node: &'a Dictionary, seen: &mut HashSet<*const Dictionary>, depth: u32) -> Vec<(String, &'a Object)> {
|
||||
/// The entries of an embedded-files name tree. Each string key is spent from `text_budget`
|
||||
/// (see [`MAX_LINK_ATTACHMENT_TEXT_BYTES`]), and an entry whose key does not fit is left out.
|
||||
fn name_tree(
|
||||
&self,
|
||||
node: &'a Dictionary,
|
||||
seen: &mut HashSet<*const Dictionary>,
|
||||
depth: u32,
|
||||
text_budget: &mut usize,
|
||||
) -> Vec<(String, &'a Object)> {
|
||||
let mut out = Vec::new();
|
||||
if depth > 32 || !seen.insert(node as *const _) {
|
||||
return out;
|
||||
@@ -925,7 +970,10 @@ impl<'a> Inspector<'a> {
|
||||
for pair in pairs.chunks(2) {
|
||||
if let [k, v] = pair {
|
||||
let key = match self.resolve(k) {
|
||||
Object::String(bytes, _) => text_string(bytes),
|
||||
Object::String(bytes, _) => match spend(bytes, text_budget, text_string) {
|
||||
Some(key) => key,
|
||||
None => continue,
|
||||
},
|
||||
_ => String::new(),
|
||||
};
|
||||
out.push((key, v));
|
||||
@@ -935,13 +983,53 @@ impl<'a> Inspector<'a> {
|
||||
if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) {
|
||||
for k in kids {
|
||||
if let Some(d) = self.dict(k) {
|
||||
out.extend(self.name_tree(d, seen, depth + 1));
|
||||
out.extend(self.name_tree(d, seen, depth + 1, text_budget));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The first entry of an embedded-files name tree whose key is `key`, in the order
|
||||
/// [`Self::name_tree`] lists them, with no key budget. It keeps no key, and converts a key
|
||||
/// object or `/Names` array that several places reference only once (a second visit cannot
|
||||
/// match, since the first would have returned).
|
||||
fn name_tree_find(&self, node: &'a Dictionary, key: &str, seen: &mut NameTreeSeen, depth: u32) -> Option<&'a Object> {
|
||||
if depth > 32 || !seen.nodes.insert(node as *const _) {
|
||||
return None;
|
||||
}
|
||||
let names = node.get(b"Names").ok();
|
||||
if let Some(names) = names
|
||||
&& self.resolved_id(names).is_none_or(|id| seen.objects.insert(id))
|
||||
&& let Object::Array(pairs) = self.resolve(names)
|
||||
{
|
||||
for pair in pairs.chunks(2) {
|
||||
if let [k, v] = pair {
|
||||
if self.resolved_id(k).is_some_and(|id| !seen.objects.insert(id)) {
|
||||
continue;
|
||||
}
|
||||
let found = match self.resolve(k) {
|
||||
Object::String(bytes, _) => text_string(bytes) == key,
|
||||
_ => key.is_empty(),
|
||||
};
|
||||
if found {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) {
|
||||
for k in kids {
|
||||
if let Some(d) = self.dict(k)
|
||||
&& let Some(v) = self.name_tree_find(d, key, seen, depth + 1)
|
||||
{
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ── outline ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The window `DocInfo::outline` keeps: the first `OUTLINE_WINDOW` bookmarks in outline order,
|
||||
@@ -1141,21 +1229,44 @@ impl<'a> Inspector<'a> {
|
||||
|
||||
// ── annotations ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn annotations(&self, info: &mut DocInfo) {
|
||||
for (&id, &page) in &self.page_index {
|
||||
/// `text_budget` is what is left of [`MAX_LINK_ATTACHMENT_TEXT_BYTES`].
|
||||
fn annotations(&self, info: &mut DocInfo, text_budget: &mut usize) {
|
||||
let mut pages: Vec<(ObjectId, usize)> = self.page_index.iter().map(|(&id, &page)| (id, page)).collect();
|
||||
pages.sort_unstable_by_key(|&(_, page)| page);
|
||||
// An annotation, or a whole `/Annots` array, that several entries or pages list is on the
|
||||
// first of them (§12.5.2 allows one page per annotation). Both are told apart by the object
|
||||
// a reference resolves to, since an indirect object may itself hold a reference.
|
||||
let mut seen_arrays = HashSet::new();
|
||||
let mut seen_annotations = HashSet::new();
|
||||
let mut kept = 0usize;
|
||||
// Once the comment budget is spent, comments are no longer built, but links and attachments
|
||||
// are still listed.
|
||||
let mut comments_full = false;
|
||||
for (id, page) in pages {
|
||||
let Ok(page_dict) = self.doc.get_dictionary(id) else { continue };
|
||||
if let Ok(annots) = page_dict.get(b"Annots")
|
||||
&& let Some(r) = self.resolved_id(annots)
|
||||
&& !seen_arrays.insert(r)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue };
|
||||
for (index, a) in annots.iter().enumerate() {
|
||||
if let Some(r) = self.resolved_id(a)
|
||||
&& !seen_annotations.insert(r)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(d) = self.dict(a) else { continue };
|
||||
let Some(subtype) = self.name(d, b"Subtype") else { continue };
|
||||
if subtype == "FileAttachment"
|
||||
&& let Ok(fs) = d.get(b"FS")
|
||||
{
|
||||
let name = self.text(d, b"Contents").unwrap_or_else(|| "attachment".into());
|
||||
info.attachments.push(self.attachment(name, fs, AttachmentSource::Annotation { page, index }));
|
||||
let fallback = |budget: &mut usize| self.budgeted_text(d, b"Contents", budget);
|
||||
info.attachments.push(self.attachment(fs, AttachmentSource::Annotation { page, index }, text_budget, fallback));
|
||||
}
|
||||
if subtype == "Link" {
|
||||
if let Some(link) = self.link(page, index, d) {
|
||||
if let Some(link) = self.link(page, index, d, text_budget) {
|
||||
if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") {
|
||||
info.has_javascript = true;
|
||||
}
|
||||
@@ -1170,6 +1281,9 @@ impl<'a> Inspector<'a> {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if comments_full {
|
||||
continue;
|
||||
}
|
||||
let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null)));
|
||||
let mut color = match d.get(b"C").map(|o| self.resolve(o)) {
|
||||
Ok(Object::Array(c)) if c.len() == 3 => {
|
||||
@@ -1209,7 +1323,7 @@ impl<'a> Inspector<'a> {
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
info.annotations.push(Annotation {
|
||||
let annotation = Annotation {
|
||||
page,
|
||||
subtype,
|
||||
author: self.text(d, b"T"),
|
||||
@@ -1228,13 +1342,19 @@ impl<'a> Inspector<'a> {
|
||||
intent: self.name(d, b"IT"),
|
||||
fill_sign,
|
||||
points,
|
||||
});
|
||||
};
|
||||
kept = kept.saturating_add(retained(&annotation));
|
||||
if kept > MAX_ANNOTATION_BYTES {
|
||||
comments_full = true;
|
||||
continue;
|
||||
}
|
||||
info.annotations.push(annotation);
|
||||
}
|
||||
}
|
||||
info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3])));
|
||||
}
|
||||
|
||||
fn link(&self, page: usize, index: usize, d: &Dictionary) -> Option<Link> {
|
||||
fn link(&self, page: usize, index: usize, d: &Dictionary, text_budget: &mut usize) -> Option<Link> {
|
||||
let rect = rect4(self.resolve(d.get(b"Rect").ok()?));
|
||||
let target = if let Ok(dest) = d.get(b"Dest") {
|
||||
let (page, view) = self.dest(dest, 0)?;
|
||||
@@ -1247,7 +1367,11 @@ impl<'a> Inspector<'a> {
|
||||
LinkTarget::Page(page, view)
|
||||
}
|
||||
Some("URI") => {
|
||||
LinkTarget::Uri(a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok()).map(|b| String::from_utf8_lossy(b).into_owned())?)
|
||||
let uri = a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok())?;
|
||||
match spend(uri, text_budget, |b| String::from_utf8_lossy(b).into_owned()) {
|
||||
Some(uri) => LinkTarget::Uri(uri),
|
||||
None => LinkTarget::Other("URI".into()),
|
||||
}
|
||||
}
|
||||
Some("SetOCGState") => self.layer_state(a),
|
||||
Some("GoToR") => self.remote_go_to(a).unwrap_or_else(|| LinkTarget::Other("GoToR".into())),
|
||||
@@ -1578,7 +1702,14 @@ impl<'a> Inspector<'a> {
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
}
|
||||
|
||||
fn attachment(&self, name: String, spec: &Object, source: AttachmentSource) -> Attachment {
|
||||
/// The attachment's name is the file specification's, else `fallback`'s, else a generic one.
|
||||
fn attachment(
|
||||
&self,
|
||||
spec: &Object,
|
||||
source: AttachmentSource,
|
||||
text_budget: &mut usize,
|
||||
fallback: impl FnOnce(&mut usize) -> Option<String>,
|
||||
) -> Attachment {
|
||||
let d = self.dict(spec);
|
||||
let size =
|
||||
d.and_then(|d| d.get(b"EF").ok()).and_then(|o| self.dict(o)).and_then(|ef| ef.get(b"F").ok().or_else(|| ef.get(b"UF").ok())).and_then(
|
||||
@@ -1595,8 +1726,11 @@ impl<'a> Inspector<'a> {
|
||||
_ => None,
|
||||
},
|
||||
);
|
||||
let display = d.and_then(|d| self.text(d, b"UF").or_else(|| self.text(d, b"F"))).unwrap_or(name);
|
||||
Attachment { name: display, description: d.and_then(|d| self.text(d, b"Desc")), size, source }
|
||||
let display = d
|
||||
.and_then(|d| self.budgeted_text(d, b"UF", text_budget).or_else(|| self.budgeted_text(d, b"F", text_budget)))
|
||||
.or_else(|| fallback(text_budget))
|
||||
.unwrap_or_else(|| "attachment".into());
|
||||
Attachment { name: display, description: d.and_then(|d| self.budgeted_text(d, b"Desc", text_budget)), size, source }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1606,6 +1740,42 @@ fn text_string(bytes: &[u8]) -> String {
|
||||
pdfcraft_cos::PdfString::literal(bytes).to_text()
|
||||
}
|
||||
|
||||
/// What [`Inspector::name_tree_find`] has visited: node dictionaries, and the key and `/Names`
|
||||
/// array objects it reached by reference.
|
||||
#[derive(Default)]
|
||||
struct NameTreeSeen {
|
||||
nodes: HashSet<*const Dictionary>,
|
||||
objects: HashSet<ObjectId>,
|
||||
}
|
||||
|
||||
/// A string or name value as text, without surrounding NULs and white space.
|
||||
fn trimmed_text(o: &Object) -> Option<String> {
|
||||
let s = match o {
|
||||
Object::String(bytes, _) => text_string(bytes),
|
||||
Object::Name(n) => String::from_utf8_lossy(n).into_owned(),
|
||||
_ => return None,
|
||||
};
|
||||
Some(s.trim_matches('\0').trim().to_string())
|
||||
}
|
||||
|
||||
/// `convert(bytes)` when the result fits in `budget`, which it then spends by the longer of the raw
|
||||
/// and converted lengths. Bytes longer than the budget are not converted. A conversion that comes
|
||||
/// out too long spends the raw length, so a string many entries share is converted only until the
|
||||
/// budget runs out.
|
||||
fn spend(bytes: &[u8], budget: &mut usize, convert: impl FnOnce(&[u8]) -> String) -> Option<String> {
|
||||
if bytes.len() > *budget {
|
||||
return None;
|
||||
}
|
||||
let text = convert(bytes);
|
||||
if text.len() > *budget {
|
||||
*budget = budget.saturating_sub(bytes.len());
|
||||
return None;
|
||||
}
|
||||
// The conversion's work follows the raw length, even when the text it keeps is shorter.
|
||||
*budget = budget.saturating_sub(text.len().max(bytes.len()));
|
||||
Some(text)
|
||||
}
|
||||
|
||||
fn limited_pdf_string(bytes: &[u8], max_bytes: usize) -> Option<String> {
|
||||
if bytes.len() > max_bytes {
|
||||
return None;
|
||||
@@ -1678,8 +1848,7 @@ pub fn attachment_data(bytes: &[u8], password: Option<&str>, att: &Attachment) -
|
||||
AttachmentSource::Document { key } => {
|
||||
let names = doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| insp.dict(o)).ok_or("no /Names")?;
|
||||
let ef = names.get(b"EmbeddedFiles").ok().and_then(|o| insp.dict(o)).ok_or("no /EmbeddedFiles")?;
|
||||
let mut seen = HashSet::new();
|
||||
insp.name_tree(ef, &mut seen, 0).into_iter().find(|(k, _)| k == key).map(|(_, v)| v).ok_or("attachment not found")?
|
||||
insp.name_tree_find(ef, key, &mut NameTreeSeen::default(), 0).ok_or("attachment not found")?
|
||||
}
|
||||
AttachmentSource::Annotation { page, index } => {
|
||||
let (&pid, _) = insp.page_index.iter().find(|(_, i)| **i == *page).ok_or("page not found")?;
|
||||
@@ -2583,6 +2752,216 @@ trailer << /Root 1 0 R >>
|
||||
}
|
||||
}
|
||||
|
||||
/// One comment referenced many times, or a page-shared `/Annots` array, is listed once, and many
|
||||
/// small comments sharing one large `/Contents` string stop at the retained-bytes budget.
|
||||
#[test]
|
||||
fn repeated_annotations_are_listed_once_within_a_byte_budget() {
|
||||
let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 7 0 R >> ".repeat(1200);
|
||||
let pdf = format!(
|
||||
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 5 0 R >> endobj\n\
|
||||
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 5 0 R >> endobj\n\
|
||||
5 0 obj [{} {small}] endobj\n\
|
||||
6 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (note) >> endobj\n\
|
||||
7 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF",
|
||||
"6 0 R ".repeat(1000),
|
||||
"x".repeat(64 << 10),
|
||||
);
|
||||
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
|
||||
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
|
||||
let mut info = DocInfo { pages: vec![page.clone(), page], ..Default::default() };
|
||||
Inspector::new(&doc).fill(&mut info);
|
||||
let notes = &info.annotations;
|
||||
assert_eq!(notes.iter().filter(|a| a.contents.as_deref() == Some("note")).count(), 1);
|
||||
assert!(notes.iter().all(|a| a.page == 0), "the shared array is the first page's");
|
||||
assert!(notes.len() > 1);
|
||||
assert!(notes.iter().map(retained).sum::<usize>() <= MAX_ANNOTATION_BYTES);
|
||||
}
|
||||
|
||||
/// A link reached through an indirect object that holds only a reference to it is the same
|
||||
/// annotation as the link itself, and so is a page array reached the same way: each is listed once.
|
||||
#[test]
|
||||
fn an_annotation_reached_through_a_reference_chain_is_listed_once() {
|
||||
let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >> endobj\n\
|
||||
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 8 0 R >> endobj\n\
|
||||
5 0 obj 6 0 R endobj\n\
|
||||
6 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI (https://example.com) >> >> endobj\n\
|
||||
7 0 obj [5 0 R 6 0 R] endobj\n\
|
||||
8 0 obj 7 0 R endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF";
|
||||
let mut doc = Document::load_mem(pdf.as_bytes()).expect("loads");
|
||||
let mut saved = Vec::new();
|
||||
doc.save_to(&mut saved).expect("saves");
|
||||
let lazy = LazyStructure::new(Arc::new(saved), None).expect("opens");
|
||||
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
|
||||
for reader in [&doc as &dyn Structure, &lazy] {
|
||||
let mut info = DocInfo { pages: vec![page.clone(), page.clone()], ..Default::default() };
|
||||
Inspector::new(reader).fill(&mut info);
|
||||
assert_eq!(info.links.len(), 1, "{:?}", info.links);
|
||||
}
|
||||
}
|
||||
|
||||
/// A conversion rejected as too long, or trimmed short or to nothing, still spends the raw
|
||||
/// length, so a string many entries share is converted only a few times.
|
||||
#[test]
|
||||
fn rejected_text_conversions_spend_the_budget() {
|
||||
let mut budget = 10;
|
||||
let mut conversions = 0;
|
||||
for _ in 0..10 {
|
||||
// Each 0xFF becomes the three-byte replacement character.
|
||||
let kept = spend(&[0xFF; 5], &mut budget, |b| {
|
||||
conversions += 1;
|
||||
String::from_utf8_lossy(b).into_owned()
|
||||
});
|
||||
assert_eq!(kept, None);
|
||||
}
|
||||
assert_eq!((conversions, budget), (2, 0));
|
||||
|
||||
let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF";
|
||||
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
|
||||
let d = Dictionary::from_iter([("Contents", Object::string_literal(" "))]);
|
||||
let mut budget = 10;
|
||||
assert_eq!(Inspector::new(&doc).budgeted_text(&d, b"Contents", &mut budget), None);
|
||||
assert_eq!(budget, 7);
|
||||
// Text that trims short spends its raw length.
|
||||
let d = Dictionary::from_iter([("Contents", Object::string_literal(" x"))]);
|
||||
let mut budget = 10;
|
||||
assert_eq!(Inspector::new(&doc).budgeted_text(&d, b"Contents", &mut budget).as_deref(), Some("x"));
|
||||
assert_eq!(budget, 3);
|
||||
}
|
||||
|
||||
/// An annotation attachment's `/Contents` spends the text budget only when it names the
|
||||
/// attachment, so long descriptions of named files leave a later web address listed.
|
||||
#[test]
|
||||
fn a_named_attachment_leaves_its_contents_unspent() {
|
||||
let files = "<< /Subtype /FileAttachment /Rect [0 0 1 1] /Contents 6 0 R /FS << /F (a.txt) >> >> ".repeat(20);
|
||||
// Longer than what 20 MiB of charged `/Contents` would leave of the 16 MiB budget.
|
||||
let uri = format!("https://example.com/{}", "a".repeat(2 << 20));
|
||||
let pdf = format!(
|
||||
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{files} \
|
||||
<< /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI ({uri}) >> >>] >> endobj\n\
|
||||
6 0 obj ({}) endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF",
|
||||
"x".repeat(1 << 20),
|
||||
);
|
||||
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
|
||||
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
|
||||
let mut info = DocInfo { pages: vec![page], ..Default::default() };
|
||||
Inspector::new(&doc).fill(&mut info);
|
||||
assert!(info.attachments.iter().all(|a| a.name == "a.txt"));
|
||||
assert_eq!(info.links.len(), 1);
|
||||
assert!(matches!(&info.links[0].target, LinkTarget::Uri(u) if *u == uri));
|
||||
}
|
||||
|
||||
/// Embedded files whose name-tree keys share one long string keep it, as key and as name, only
|
||||
/// within `MAX_LINK_ATTACHMENT_TEXT_BYTES`.
|
||||
#[test]
|
||||
fn embedded_file_keys_sharing_a_long_string_keep_it_within_a_budget() {
|
||||
let entries = "4 0 R << /Type /Filespec >> ".repeat(20);
|
||||
let pdf = format!(
|
||||
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [{entries}] >> >> >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n\
|
||||
4 0 obj ({}) endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF",
|
||||
"x".repeat(1 << 20),
|
||||
);
|
||||
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
|
||||
let mut info = DocInfo::default();
|
||||
Inspector::new(&doc).fill(&mut info);
|
||||
let kept: usize =
|
||||
info.attachments.iter().map(|a| a.name.len() + if let AttachmentSource::Document { key } = &a.source { key.len() } else { 0 }).sum();
|
||||
assert!(!info.attachments.is_empty());
|
||||
// Past the budget an attachment still has the generic name.
|
||||
let generic = info.attachments.len() * "attachment".len();
|
||||
assert!(kept <= MAX_LINK_ATTACHMENT_TEXT_BYTES + generic, "{kept} bytes kept");
|
||||
}
|
||||
|
||||
/// An embedded file the listing keeps is extracted, though earlier keys the listing could not
|
||||
/// keep would fill a fresh key budget before it.
|
||||
#[test]
|
||||
fn a_listed_embedded_file_is_extracted_past_keys_the_listing_left_out() {
|
||||
let skipped = "6 0 R 7 0 R ".repeat(9);
|
||||
let pdf = format!(
|
||||
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [{skipped}(b.txt) 9 0 R] >> >> >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [\
|
||||
<< /Subtype /Link /Rect [0 0 1 1] /A << /S /URI /URI ({}) >> >>] >> endobj\n\
|
||||
6 0 obj ({}) endobj\n\
|
||||
7 0 obj << /Type /Filespec /F (a.txt) >> endobj\n\
|
||||
8 0 obj << /Type /EmbeddedFile /Length 5 >> stream\nhello\nendstream endobj\n\
|
||||
9 0 obj << /Type /Filespec /F (b.txt) /EF << /F 8 0 R >> >> endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF",
|
||||
"u".repeat(15 << 20),
|
||||
"k".repeat(2 << 20),
|
||||
);
|
||||
let bytes = Arc::new(pdf.into_bytes());
|
||||
let info = inspect(bytes.clone(), None).expect("opens");
|
||||
let listed = info.attachments.iter().find(|a| a.name == "b.txt").expect("b.txt is listed");
|
||||
assert_eq!(attachment_data(&bytes, None, listed).as_deref(), Ok(&b"hello"[..]));
|
||||
}
|
||||
|
||||
/// Links and attachments sharing one long string each keep a copy only within
|
||||
/// `MAX_LINK_ATTACHMENT_TEXT_BYTES`, and every one is still listed.
|
||||
#[test]
|
||||
fn links_and_attachments_sharing_a_long_string_keep_it_within_a_budget() {
|
||||
const EACH: usize = 20;
|
||||
let links = "<< /Subtype /Link /Rect [0 0 1 1] /A 5 0 R >> ".repeat(EACH);
|
||||
let files = "<< /Subtype /FileAttachment /Rect [0 0 1 1] /FS 7 0 R >> ".repeat(EACH);
|
||||
let pdf = format!(
|
||||
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{links} {files}] >> endobj\n\
|
||||
5 0 obj << /S /URI /URI 6 0 R >> endobj\n\
|
||||
6 0 obj ({}) endobj\n\
|
||||
7 0 obj << /Type /Filespec /F (a.txt) /Desc 6 0 R >> endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF",
|
||||
"x".repeat(1 << 20),
|
||||
);
|
||||
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
|
||||
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
|
||||
let mut info = DocInfo { pages: vec![page], ..Default::default() };
|
||||
Inspector::new(&doc).fill(&mut info);
|
||||
assert_eq!(info.links.len(), EACH);
|
||||
assert_eq!(info.attachments.len(), EACH);
|
||||
let uris = info.links.iter().map(|l| if let LinkTarget::Uri(u) = &l.target { u.len() } else { 0 });
|
||||
let descriptions = info.attachments.iter().map(|a| a.description.as_ref().map_or(0, String::len));
|
||||
let kept: usize = uris.chain(descriptions).sum();
|
||||
assert!(kept > 0 && kept <= MAX_LINK_ATTACHMENT_TEXT_BYTES, "{kept} bytes kept");
|
||||
assert!(info.links.iter().any(|l| matches!(&l.target, LinkTarget::Other(s) if s == "URI")));
|
||||
}
|
||||
|
||||
/// Links and attachments after the comment budget fills are still listed, and a JavaScript link
|
||||
/// among them still marks the document.
|
||||
#[test]
|
||||
fn links_and_attachments_past_the_comment_budget_are_listed() {
|
||||
let small = "<< /Subtype /Text /Rect [0 0 1 1] /Contents 6 0 R >> ".repeat(1100);
|
||||
let pdf = format!(
|
||||
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [{small} \
|
||||
<< /Subtype /Link /Rect [0 0 1 1] /A << /S /JavaScript /JS (1) >> >>] >> endobj\n\
|
||||
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [\
|
||||
<< /Subtype /FileAttachment /Rect [0 0 1 1] /FS << /Type /Filespec /F (a.txt) >> >>] >> endobj\n\
|
||||
6 0 obj ({}) endobj\ntrailer << /Root 1 0 R >>\n%%EOF",
|
||||
"x".repeat(64 << 10),
|
||||
);
|
||||
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
|
||||
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
|
||||
let mut info = DocInfo { pages: vec![page.clone(), page], ..Default::default() };
|
||||
Inspector::new(&doc).fill(&mut info);
|
||||
assert!(info.annotations.len() < 1100, "the comment budget filled");
|
||||
assert_eq!(info.links.len(), 1);
|
||||
assert!(info.has_javascript);
|
||||
assert_eq!(info.attachments.len(), 1);
|
||||
}
|
||||
|
||||
/// Widgets without `/P` are placed on the page that lists them with one pass over the pages'
|
||||
/// `/Annots`, not a search of every page per field: that was O(fields × annotations), a
|
||||
/// second to open a form of 8,000 fields. A widget two pages list is on the first of them.
|
||||
|
||||
@@ -114,6 +114,11 @@ impl Structure for lopdf::Document {
|
||||
fn get_object(&self, id: ObjectId) -> Result<&Object> {
|
||||
self.get_object(id)
|
||||
}
|
||||
// lopdf's `get_object` already follows an indirect object that holds a reference, so the
|
||||
// trait's walk would report the first object of a chain. lopdf's own walk reports the last.
|
||||
fn dereference<'a>(&'a self, object: &'a Object) -> Result<(Option<ObjectId>, &'a Object)> {
|
||||
lopdf::Document::dereference(self, object)
|
||||
}
|
||||
fn encrypted(&self) -> bool {
|
||||
self.is_encrypted() || self.was_encrypted() || self.trailer.has(b"Encrypt")
|
||||
}
|
||||
|
||||
@@ -420,6 +420,15 @@ const MAX_CELLS: i64 = 4096;
|
||||
/// their unmarked redraws (the text reads twice, as it would without `/ActualText`).
|
||||
const MAX_REDRAW_WORK: usize = 1 << 22;
|
||||
|
||||
/// Most replacement text bytes the redraw dedupe in `TextDevice::finish` copies on one page.
|
||||
const MAX_REDRAW_TEXT: usize = 1 << 22;
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Replacement text bytes given to redraws on this thread.
|
||||
static REDRAW_TEXT_BYTES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn cell(v: f32) -> i64 {
|
||||
// Glyph boxes are finite (`draw_glyph` and `merge` drop the rest); `as` saturates.
|
||||
(v / CELL).floor() as i64
|
||||
@@ -564,6 +573,7 @@ impl TextDevice {
|
||||
redraws.sort_by_key(|(range, _)| range.start);
|
||||
let glyphs = std::mem::take(&mut self.glyphs);
|
||||
let mut redraws = redraws.into_iter().peekable();
|
||||
let mut text_left = MAX_REDRAW_TEXT;
|
||||
let mut i = 0;
|
||||
while let Some(g) = glyphs.get(i) {
|
||||
let Some((range, k)) = redraws.next_if(|(range, _)| range.start == i) else {
|
||||
@@ -572,7 +582,18 @@ impl TextDevice {
|
||||
continue;
|
||||
};
|
||||
let run = glyphs.get(range.clone()).unwrap_or_default();
|
||||
let text = self.replaced.get(k).map(|r| r.text.clone()).unwrap_or_default();
|
||||
let replacement = self.replaced.get(k).map_or("", |r| r.text.as_str());
|
||||
// Each redraw takes its own copy of the replacement, and one long replacement can
|
||||
// have many redraws: past the budget a redraw keeps its glyphs, as past the work budget.
|
||||
let Some(left) = text_left.checked_sub(replacement.len()) else {
|
||||
self.glyphs.extend_from_slice(run);
|
||||
i = range.end.max(i + 1);
|
||||
continue;
|
||||
};
|
||||
text_left = left;
|
||||
let text = replacement.to_owned();
|
||||
#[cfg(test)]
|
||||
REDRAW_TEXT_BYTES.with(|c| c.set(c.get() + text.len()));
|
||||
if !text.chars().all(|c| c.is_control()) {
|
||||
match merge(run, text) {
|
||||
Some(glyph) => self.glyphs.push(glyph),
|
||||
@@ -1871,6 +1892,27 @@ mod tests {
|
||||
extract_page(pdf, page, &InterpreterSettings::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redraws_of_a_long_replacement_share_a_text_budget() {
|
||||
let long = "0041".repeat(32 * 1024);
|
||||
let redraws = 200;
|
||||
let content = format!(
|
||||
"BT /F1 20 Tf 1 0 0 1 100 150 Tm /Span <</ActualText <FEFF{long}>>> BDC (x) Tj EMC {} ET",
|
||||
"1 0 0 1 100 150 Tm (x) Tj ".repeat(redraws)
|
||||
);
|
||||
let pdf = marked_pages(&[&content]);
|
||||
REDRAW_TEXT_BYTES.with(|c| c.set(0));
|
||||
let text = marked_text(&pdf, 0);
|
||||
let given = REDRAW_TEXT_BYTES.with(|c| c.get());
|
||||
assert!(given <= MAX_REDRAW_TEXT, "{given} bytes of replacement text given to {redraws} redraws");
|
||||
assert!(text.plain_text().starts_with("AAAA"));
|
||||
// A short replacement still covers its redraw.
|
||||
let pdf = marked_pages(&["BT /F1 20 Tf 1 0 0 1 100 150 Tm /Span <</ActualText (fi)>> BDC (x) Tj EMC 1 0 0 1 100 150 Tm (x) Tj ET"]);
|
||||
REDRAW_TEXT_BYTES.with(|c| c.set(0));
|
||||
assert_eq!(marked_text(&pdf, 0).plain_text(), "fi");
|
||||
assert_eq!(REDRAW_TEXT_BYTES.with(|c| c.get()), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei() {
|
||||
// Everyday content both ways: a string with a byte above 0x7F, and a later word holding
|
||||
|
||||
+101
-23
@@ -892,7 +892,8 @@ fn finish_validation(
|
||||
// Revocation evidence embedded in the document security store, if any. A verified
|
||||
// revocation is final: later blocks must not soften the verdict.
|
||||
let signer = info.chain.first().cloned();
|
||||
let revoked = check_dss_revocation(doc, signer.as_ref(), &at, info);
|
||||
let revocation = check_dss_revocation(doc, signer.as_ref(), &at, info);
|
||||
let revoked = revocation == DssRevocation::Revoked;
|
||||
// Changes after signing.
|
||||
info.modification = if covered == bytes.len() || bytes[covered..].iter().all(|b| b.is_ascii_whitespace() || *b == 0) {
|
||||
Modification::None
|
||||
@@ -929,6 +930,12 @@ fn finish_validation(
|
||||
problems = true;
|
||||
}
|
||||
}
|
||||
if revocation == DssRevocation::Incomplete {
|
||||
if !problems {
|
||||
info.status = Status::Unknown;
|
||||
}
|
||||
problems = true;
|
||||
}
|
||||
if let Some(t) = info.timestamp_time.or(info.signing_time)
|
||||
&& !cert.valid_at(t)
|
||||
{
|
||||
@@ -975,27 +982,86 @@ fn finish_validation(
|
||||
}
|
||||
}
|
||||
|
||||
/// Distinct evidence streams read from one `/DSS` array.
|
||||
const MAX_DSS_ENTRIES: usize = 1024;
|
||||
/// Decoded bytes read from one `/DSS` array; real CRLs and OCSP responses are kilobytes each.
|
||||
const MAX_DSS_BYTES: usize = 64 << 20;
|
||||
|
||||
/// The decoded streams of the `/DSS` evidence array `key` (`/CRLs`, `/OCSPs`), one at a time so
|
||||
/// each is checked before the next decodes: each stream once and within the evidence size cap,
|
||||
/// at most [`MAX_DSS_ENTRIES`] distinct ones and [`MAX_DSS_BYTES`] together. A stream past a cap
|
||||
/// sets [`DssEvidence::cut`] and ends the iteration.
|
||||
struct DssEvidence<'a> {
|
||||
doc: &'a Document,
|
||||
items: std::vec::IntoIter<Object>,
|
||||
seen: HashSet<ObjRef>,
|
||||
taken: usize,
|
||||
left: usize,
|
||||
/// A cap stopped the array being read in full.
|
||||
cut: bool,
|
||||
}
|
||||
|
||||
impl<'a> DssEvidence<'a> {
|
||||
fn new(doc: &'a Document, dss: &Dict, key: &[u8]) -> Self {
|
||||
let items = dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default();
|
||||
DssEvidence { doc, items: items.into_iter(), seen: HashSet::new(), taken: 0, left: MAX_DSS_BYTES, cut: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for DssEvidence<'_> {
|
||||
type Item = Vec<u8>;
|
||||
|
||||
fn next(&mut self) -> Option<Vec<u8>> {
|
||||
if self.cut {
|
||||
return None;
|
||||
}
|
||||
// Repeats are skipped before the caps count anything, so repeating one stream cannot use
|
||||
// them up; the scan of the array itself is bounded by the parser's cap on array length.
|
||||
for item in self.items.by_ref() {
|
||||
if item.as_ref().is_some_and(|r| !self.seen.insert(r)) {
|
||||
continue;
|
||||
}
|
||||
let o = self.doc.resolve(&item);
|
||||
let Object::Stream(s) = o.as_ref() else { continue };
|
||||
if self.taken >= MAX_DSS_ENTRIES {
|
||||
self.cut = true;
|
||||
return None;
|
||||
}
|
||||
self.taken += 1;
|
||||
// A stream past the evidence cap would not parse as evidence either.
|
||||
let Ok(bytes) = s.decoded_within(crate::revocation::MAX_EVIDENCE) else { continue };
|
||||
let Some(left) = self.left.checked_sub(bytes.len()) else {
|
||||
self.cut = true;
|
||||
return None;
|
||||
};
|
||||
self.left = left;
|
||||
return Some(bytes);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// What the catalog's `/DSS` says about the signer's certificate.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum DssRevocation {
|
||||
/// Verified evidence revokes it.
|
||||
Revoked,
|
||||
/// A cap stopped the evidence being read in full, and what was read does not revoke it.
|
||||
Incomplete,
|
||||
/// All of the evidence was read, and none of it revokes it.
|
||||
NotRevoked,
|
||||
}
|
||||
|
||||
/// Check the revocation evidence in the catalog's `/DSS` against the signer's chain at `at`.
|
||||
/// Only evidence whose signature verifies against the issuer counts; returns whether the
|
||||
/// certificate is revoked (which invalidates the signature).
|
||||
fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Option<Time>, info: &mut SignatureInfo) -> bool {
|
||||
/// certificate is revoked (which invalidates the signature), or whether a cap kept the check from
|
||||
/// reading all of the evidence.
|
||||
fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Option<Time>, info: &mut SignatureInfo) -> DssRevocation {
|
||||
use crate::revocation::{CertificateList, OcspResponse, RevocationStatus};
|
||||
let (Some(signer), Some(at)) = (signer, *at) else { return false };
|
||||
let Some(root) = doc.root() else { return false };
|
||||
let (Some(signer), Some(at)) = (signer, *at) else { return DssRevocation::NotRevoked };
|
||||
let Some(root) = doc.root() else { return DssRevocation::NotRevoked };
|
||||
let Some(dss) = doc.get(root).as_dict().and_then(|c| c.get(b"DSS").cloned()).map(|d| doc.resolve(&d)).and_then(|d| d.as_dict().cloned()) else {
|
||||
return false;
|
||||
};
|
||||
let pull = |key: &[u8]| -> Vec<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
for item in dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() {
|
||||
let o = doc.resolve(&item);
|
||||
if let pdfcraft_cos::Object::Stream(s) = &*o
|
||||
&& let Ok(bytes) = s.decoded()
|
||||
{
|
||||
out.push(bytes);
|
||||
}
|
||||
}
|
||||
out
|
||||
return DssRevocation::NotRevoked;
|
||||
};
|
||||
let issuer = info.chain.get(1).unwrap_or(signer);
|
||||
// A verified revocation wins; "good" from any covering evidence is reported, but never
|
||||
@@ -1003,7 +1069,8 @@ fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Optio
|
||||
let mut revoked = None;
|
||||
let mut covered = false;
|
||||
let mut sources = Vec::new();
|
||||
for r in pull(b"CRLs") {
|
||||
let mut crls = DssEvidence::new(doc, &dss, b"CRLs");
|
||||
for r in crls.by_ref() {
|
||||
let Ok(list) = CertificateList::parse(&r) else { continue };
|
||||
sources.push("CRL");
|
||||
match list.check(signer, issuer, at) {
|
||||
@@ -1015,8 +1082,10 @@ fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Optio
|
||||
RevocationStatus::Unknown => {}
|
||||
}
|
||||
}
|
||||
let mut cut = crls.cut;
|
||||
if revoked.is_none() {
|
||||
for r in pull(b"OCSPs") {
|
||||
let mut ocsps = DssEvidence::new(doc, &dss, b"OCSPs");
|
||||
for r in ocsps.by_ref() {
|
||||
let Ok(resp) = OcspResponse::parse(&r) else { continue };
|
||||
sources.push("OCSP");
|
||||
match resp.check(signer, issuer, at) {
|
||||
@@ -1028,6 +1097,7 @@ fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Optio
|
||||
RevocationStatus::Unknown => {}
|
||||
}
|
||||
}
|
||||
cut |= ocsps.cut;
|
||||
}
|
||||
match revoked {
|
||||
Some(at) => {
|
||||
@@ -1035,14 +1105,22 @@ fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Optio
|
||||
// The evidence and the date go in together: one hole, so a reader can put the
|
||||
// sentence's parts in whatever order its language wants.
|
||||
info.details.push(Detail::of("The signer's certificate has been revoked ({}).", Fill::Value(format!("{}, {at}", sources.join(", ")))));
|
||||
true
|
||||
DssRevocation::Revoked
|
||||
}
|
||||
// Evidence past a cap may revoke the certificate, so a partial check proves nothing.
|
||||
None if cut => {
|
||||
info.details.push(Detail::of(
|
||||
"The signature could not be checked ({}).",
|
||||
Fill::Reason("the /DSS holds more revocation evidence than PdfCraft reads".into()),
|
||||
));
|
||||
DssRevocation::Incomplete
|
||||
}
|
||||
None if covered => {
|
||||
info.details
|
||||
.push(Detail::plain("The document's embedded revocation information shows that the signer's certificate has not been revoked."));
|
||||
false
|
||||
DssRevocation::NotRevoked
|
||||
}
|
||||
None => false,
|
||||
None => DssRevocation::NotRevoked,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -371,3 +371,29 @@ fn only_grew_compares_huge_arrays_without_quadratic_work() {
|
||||
assert!(!only_grew(&doc, &doc, &ints(20_000), &ints(20_001), 0));
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dss_evidence_decodes_each_stream_once_within_its_caps() {
|
||||
// One small stream listed 1000 times, then streams that inflate past the evidence cap.
|
||||
let mut doc = fixture(&[]);
|
||||
let small = doc.add(Object::Stream(Stream::flate(Dict::new(), b"evidence")));
|
||||
let mut items = vec![Object::Ref(small); 1000];
|
||||
for _ in 0..4 {
|
||||
items.push(Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &vec![0; crate::revocation::MAX_EVIDENCE + 1])))));
|
||||
}
|
||||
let mut dss = Dict::new();
|
||||
dss.set(b"CRLs".to_vec(), Object::Array(items));
|
||||
let mut crls = DssEvidence::new(&doc, &dss, b"CRLs");
|
||||
let read: Vec<Vec<u8>> = crls.by_ref().collect();
|
||||
assert_eq!(read, vec![b"evidence".to_vec()]);
|
||||
assert!(!crls.cut, "repeats and oversized streams are not a cap");
|
||||
// Distinct streams stop at the entry and byte budgets, and say so.
|
||||
let items: Vec<Object> =
|
||||
(0..MAX_DSS_ENTRIES + 10).map(|_| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &[1; 1 << 16]))))).collect();
|
||||
dss.set(b"OCSPs".to_vec(), Object::Array(items));
|
||||
let mut ocsps = DssEvidence::new(&doc, &dss, b"OCSPs");
|
||||
let total: usize = ocsps.by_ref().map(|b| b.len()).sum();
|
||||
assert!(total <= MAX_DSS_BYTES, "{total}");
|
||||
assert!(ocsps.cut);
|
||||
assert!(DssEvidence::new(&doc, &dss, b"OCSPs").count() <= MAX_DSS_ENTRIES);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::x509::Certificate;
|
||||
use crate::{SignError, Time};
|
||||
|
||||
/// A DER size cap: real CRLs and OCSP responses are far smaller.
|
||||
const MAX_EVIDENCE: usize = 2 * 1024 * 1024;
|
||||
pub(crate) const MAX_EVIDENCE: usize = 2 * 1024 * 1024;
|
||||
|
||||
const ID_PKIX_OCSP_BASIC: &str = "1.3.6.1.5.5.7.48.1.1";
|
||||
const EKU_OCSP_SIGNING: &str = "1.3.6.1.5.5.7.3.9";
|
||||
|
||||
@@ -62,6 +62,8 @@ pub struct TimestampToken {
|
||||
pub policy: String,
|
||||
pub serial: Vec<u8>,
|
||||
pub gen_time: Time,
|
||||
/// The TSTInfo nonce, when it has one that fits a `u64` (the size requests use).
|
||||
pub nonce: Option<u64>,
|
||||
}
|
||||
|
||||
impl TimestampToken {
|
||||
@@ -140,6 +142,9 @@ pub fn parse_token(raw: &[u8]) -> Result<TimestampToken, SignError> {
|
||||
.to_vec();
|
||||
let serial = fields.get(3).ok_or_else(|| bad("TSTInfo has no serial number"))?.uint_bytes().to_vec();
|
||||
let gen_time = fields.get(4).ok_or_else(|| bad("TSTInfo has no generation time"))?.time()?;
|
||||
// The nonce is the only INTEGER after genTime (RFC 3161 §2.4.2): accuracy is a SEQUENCE and
|
||||
// ordering a BOOLEAN.
|
||||
let nonce = fields.iter().skip(5).find(|f| f.tag == tag::INTEGER).and_then(|f| f.u64().ok());
|
||||
// The signed messageDigest uses the SignerInfo's digest algorithm, which need not be the
|
||||
// imprint's (RFC 5652 §5.4).
|
||||
let content_digest = token.signer.digest.digest(&[info]);
|
||||
@@ -151,7 +156,7 @@ pub fn parse_token(raw: &[u8]) -> Result<TimestampToken, SignError> {
|
||||
if !token.verify_signature(cert, &content_digest)? {
|
||||
return Err(bad("TSA signature is invalid"));
|
||||
}
|
||||
Ok(TimestampToken { raw: raw.to_vec(), digest, imprint, policy, serial, gen_time })
|
||||
Ok(TimestampToken { raw: raw.to_vec(), digest, imprint, policy, serial, gen_time, nonce })
|
||||
}
|
||||
|
||||
/// Parse and validate a `TimeStampResp`, including the CMS signature and message imprint.
|
||||
@@ -170,6 +175,12 @@ pub fn parse_response(bytes: &[u8], query: &TimestampQuery) -> Result<TimestampT
|
||||
if token.digest != query.digest || token.imprint != query.imprint {
|
||||
return Err(bad("message imprint does not match the requested digest"));
|
||||
}
|
||||
if query.nonce.is_some() && token.nonce != query.nonce {
|
||||
return Err(bad("time-stamp token does not carry the requested nonce"));
|
||||
}
|
||||
if query.policy.as_ref().is_some_and(|p| *p != token.policy) {
|
||||
return Err(bad("time-stamp token was issued under another policy than the one requested"));
|
||||
}
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
|
||||
@@ -1546,6 +1546,37 @@ fn a_revoked_certificate_stays_invalid_when_it_was_also_expired_at_signing() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_revocation_behind_many_dss_entries_is_not_reported_valid() {
|
||||
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
|
||||
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
|
||||
let t = |year| Time { year, month: 1, day: 1, hour: 0, minute: 0, second: 0 };
|
||||
let crl = self_crl(&id, t(2026), t(2027), Time { year: 2026, month: 6, day: 1, hour: 8, minute: 0, second: 0 });
|
||||
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
|
||||
let trust = TrustStore { certs: vec![anchor] };
|
||||
// `/CRLs`: the revoking CRL after `benign` other entries; `repeated` lists one stream for all of them.
|
||||
let status = |benign: usize, repeated: bool| {
|
||||
let ltv = edit_after(&signed, |doc| {
|
||||
let one = doc.add(stream_of(b"benign"));
|
||||
let mut crls: Vec<Object> = (0..benign)
|
||||
.map(|k| if repeated { Object::Ref(one) } else { Object::Ref(doc.add(stream_of(format!("benign {k}").as_bytes()))) })
|
||||
.collect();
|
||||
crls.push(Object::Ref(doc.add(stream_of(&crl))));
|
||||
set_dss(doc, Object::Dict(dict_of(vec![("Type", Object::name("DSS")), ("CRLs", Object::Array(crls))])));
|
||||
});
|
||||
let s = signatures(&open(<v), <v, &trust).into_iter().find(|s| s.signed && !s.doc_timestamp).unwrap();
|
||||
(s.status, s.details)
|
||||
};
|
||||
let (found, details) = status(1, false);
|
||||
assert_eq!(found, Status::Invalid, "{details:?}");
|
||||
// Repeats of one stream don't use up the entry cap, so the revocation is still found.
|
||||
let (found, details) = status(2000, true);
|
||||
assert_eq!(found, Status::Invalid, "{details:?}");
|
||||
// Past the cap of distinct entries the check is incomplete, so the signature is not valid.
|
||||
let (found, details) = status(1100, false);
|
||||
assert_eq!(found, Status::Unknown, "{details:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untrusted_timestamp_does_not_set_the_validation_time() {
|
||||
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
|
||||
@@ -1580,6 +1611,28 @@ fn a_timestamp_token_may_sign_with_a_different_digest_than_its_imprint() {
|
||||
assert_eq!((token.digest, token.imprint, token.gen_time), (pdfcraft_sign::DigestAlg::Sha1, imprint, time));
|
||||
}
|
||||
|
||||
/// A token answers the request that asked for it: a requested nonce or policy must come back.
|
||||
#[test]
|
||||
fn a_timestamp_response_must_carry_the_requested_nonce_and_policy() {
|
||||
use pdfcraft_sign::timestamp::{TimestampQuery, parse_response, respond};
|
||||
let tsa = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
|
||||
let alg = pdfcraft_sign::DigestAlg::Sha256;
|
||||
let time = Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 };
|
||||
let query = |nonce: Option<u64>, policy: Option<&str>| {
|
||||
let mut q = TimestampQuery::new(alg, alg.digest(&[b"document"])).unwrap();
|
||||
(q.nonce, q.policy) = (nonce, policy.map(str::to_string));
|
||||
q
|
||||
};
|
||||
let answer = |q: &TimestampQuery| respond(&tsa.key, &tsa.certificate, &tsa.chain, alg, q, "1.2.3.4", time, 9).unwrap();
|
||||
let with_nonce = answer(&query(Some(42), None));
|
||||
assert!(parse_response(&with_nonce, &query(Some(42), Some("1.2.3.4"))).is_ok());
|
||||
assert!(parse_response(&with_nonce, &query(None, None)).is_ok());
|
||||
assert!(parse_response(&with_nonce, &query(Some(43), None)).is_err(), "another request's nonce");
|
||||
assert!(parse_response(&with_nonce, &query(Some(42), Some("1.2.3.5"))).is_err(), "another policy");
|
||||
let without_nonce = answer(&query(None, None));
|
||||
assert!(parse_response(&without_nonce, &query(Some(42), None)).is_err(), "no nonce in the token");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_store_enumeration_and_missing_identity() {
|
||||
|
||||
@@ -53,11 +53,13 @@ pollster = "1.0"
|
||||
fontconfig-parser = "0.5.8"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Where the pointer is while files are dragged over the window (winit doesn't say): safe AppKit
|
||||
# calls only. All three are already in the tree through arboard and rfd.
|
||||
# Safe AppKit calls for drag coordinates and repairing an existing per-file PDFCraft binding.
|
||||
# The Objective-C wrappers and block support are already in the tree through arboard and rfd.
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSEvent", "NSScreen"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSGeometry"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSEvent", "NSScreen", "NSWorkspace", "block2"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSGeometry", "NSBundle", "NSURL", "NSString", "NSError"] }
|
||||
block2 = "0.6"
|
||||
plist = "1.10"
|
||||
# A plain fsync where a volume refuses the full flush `sync_all` asks for (SMB, FUSE); already in
|
||||
# the tree through pdfcraft-cli.
|
||||
rustix = { version = "1", features = ["fs"] }
|
||||
@@ -86,3 +88,7 @@ skrifa = "0.44"
|
||||
[build-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
toml = "1"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dev-dependencies]
|
||||
# Only synthetic attribute fixtures use xattr; product reads/writes use rustix.
|
||||
xattr = "1.6"
|
||||
|
||||
@@ -709,7 +709,7 @@ impl PdfCraftApp {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn save_doc_to_then(&mut self, id: pdfcraft_engine::DocId, dest: &str, target: SaveTarget, after: Box<dyn FnOnce(&mut Self) + Send>) -> bool {
|
||||
let own = self.session.get(id).and_then(|d| d.path.as_deref()).is_some_and(|p| crate::same_file(p, dest));
|
||||
if own && self.watch.disk_differs(id, dest) {
|
||||
if own && self.watch.disk_differs(id, dest, self.session.get(id).map(|d| d.saved_bytes())) {
|
||||
self.pending_overwrite = Some(PendingOverwrite { id, dest: dest.to_string(), target, after });
|
||||
self.dialog = Some(crate::Dialog::OverwriteChanged);
|
||||
return false;
|
||||
@@ -785,6 +785,8 @@ impl PdfCraftApp {
|
||||
view.disk_changed = false;
|
||||
}
|
||||
self.notify_fmt("Saved {name}", &[("name", &short_name(dest))]);
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::macos_association::repair(std::path::Path::new(dest));
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -1336,6 +1338,26 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn a_save_preserves_opening_settings_changed_while_staging() {
|
||||
let dir = staging_dir("opening-metadata-changed");
|
||||
let target = dir.join("out.pdf");
|
||||
std::fs::write(&target, b"OLD").unwrap();
|
||||
xattr::set(&target, "com.apple.LaunchServices.OpenWith", b"old handler").unwrap();
|
||||
xattr::set(&target, "com.apple.quarantine", b"0082;1234;Preview;").unwrap();
|
||||
let suffixes = std::iter::once_with(|| {
|
||||
xattr::set(&target, "com.apple.LaunchServices.OpenWith", b"new handler").unwrap();
|
||||
xattr::set(&target, "com.apple.quarantine", b"0082;5678;another app;").unwrap();
|
||||
7
|
||||
});
|
||||
write_atomically_with(&target.to_string_lossy(), b"NEW", suffixes).unwrap();
|
||||
assert_eq!(read(&target), "NEW");
|
||||
assert_eq!(xattr::get(&target, "com.apple.LaunchServices.OpenWith").unwrap(), Some(b"new handler".to_vec()));
|
||||
assert_eq!(xattr::get(&target, "com.apple.quarantine").unwrap(), Some(b"0082;5678;another app;".to_vec()));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outlook_attachment_copies_are_recognised_on_either_separator_and_in_any_case() {
|
||||
for p in [
|
||||
|
||||
@@ -55,6 +55,19 @@ impl Stamp {
|
||||
Self { len: meta.len(), modified: meta.modified().ok(), extra }
|
||||
}
|
||||
|
||||
/// Only Unix change-time differs: metadata may have changed without a content edit.
|
||||
fn metadata_only_candidate(self, other: Self) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
self.len == other.len && self.modified == other.modified && self.extra.get(..2) == other.extra.get(..2)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = other;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// The file at `path` now; `None` when it is missing, not a file or can't be read.
|
||||
pub fn at(path: &str) -> Option<Self> {
|
||||
std::fs::metadata(path).ok().filter(Metadata::is_file).map(|m| Self::of(&m))
|
||||
@@ -158,6 +171,24 @@ impl Watcher {
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a metadata-only candidate without allocating a second PDF. The stamp must stay
|
||||
/// fixed throughout; changed bytes, a replacement or any read failure still count as a change.
|
||||
fn contents_match(path: &str, stamp: Stamp, expected: &[u8]) -> bool {
|
||||
let Ok(mut file) = File::open(path) else { return false };
|
||||
if !file.metadata().is_ok_and(|m| Stamp::of(&m) == stamp) {
|
||||
return false;
|
||||
}
|
||||
let mut buffer = [0u8; 8192];
|
||||
for chunk in expected.chunks(buffer.len()) {
|
||||
let Some(out) = buffer.get_mut(..chunk.len()) else { return false };
|
||||
if file.read_exact(out).is_err() || out != chunk {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let mut tail = [0];
|
||||
matches!(file.read(&mut tail), Ok(0)) && file.metadata().is_ok_and(|m| Stamp::of(&m) == stamp) && Stamp::at(path) == Some(stamp)
|
||||
}
|
||||
|
||||
/// What a changed file turned out to be.
|
||||
enum Version {
|
||||
/// A whole PDF, read.
|
||||
@@ -279,9 +310,11 @@ impl FileWatch {
|
||||
/// else wrote it since the document was opened, reloaded or last saved (#2166). A file
|
||||
/// that is missing or can't be looked at, or a document whose version was never recorded,
|
||||
/// is not a difference.
|
||||
pub fn disk_differs(&self, doc: DocId, path: &str) -> bool {
|
||||
pub fn disk_differs(&self, doc: DocId, path: &str, saved: Option<Arc<Vec<u8>>>) -> bool {
|
||||
match (Stamp::at(path), self.basis.get(&doc)) {
|
||||
(Some(now), Some(basis)) => now != *basis,
|
||||
(Some(now), Some(basis)) => {
|
||||
now != *basis && !(now.metadata_only_candidate(*basis) && saved.is_some_and(|bytes| contents_match(path, now, &bytes)))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -378,6 +411,12 @@ impl PdfCraftApp {
|
||||
if self.watch.known.get(&c.doc) == Some(&c.stamp) {
|
||||
return;
|
||||
}
|
||||
// LaunchServices recovery (and Finder metadata edits) changes ctime. The worker
|
||||
// already read the stable file: identical saved bytes are not a document change.
|
||||
if self.session.get(c.doc).is_some_and(|d| d.saved_bytes().as_slice() == c.bytes.as_slice()) {
|
||||
self.watch.known_as(c.doc, Some(c.stamp));
|
||||
return;
|
||||
}
|
||||
self.watch.known.insert(c.doc, c.stamp);
|
||||
if self.has_unsaved_work(i) {
|
||||
if let Some(view) = self.views.get_mut(i) {
|
||||
@@ -517,6 +556,58 @@ mod tests {
|
||||
app.session.get(app.views[0].id).unwrap()
|
||||
}
|
||||
|
||||
/// Association recovery changes ctime without replacing PDF contents. It must neither
|
||||
/// discard undo history nor make the next Save ask to overwrite our own metadata change.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn association_metadata_does_not_reload_or_block_save() {
|
||||
let (_dir, path) = TempDir::new("association-metadata", &fixture(2));
|
||||
let mut h = harness(&path);
|
||||
let app = h.state_mut();
|
||||
app.views[0].select_pages(&[0]);
|
||||
app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 });
|
||||
xattr::set(&path, "com.apple.quarantine", b"2082;1234;Preview;").unwrap();
|
||||
// Exercise Save before the worker has consumed the metadata change.
|
||||
assert!(h.state_mut().save_active(SaveTarget::InPlace), "ctime alone must not cause an overwrite prompt");
|
||||
xattr::set(&path, "com.apple.quarantine", b"2082;5678;Preview;").unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(doc(&h).can_undo().is_some(), "metadata-only events must not reload and clear history");
|
||||
assert!(!h.state().views[0].disk_changed);
|
||||
}
|
||||
|
||||
/// The repair parser's 64 KiB cap must not override the shared writer's larger limit.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn a_large_binding_does_not_block_a_document_save() {
|
||||
let (_dir, path) = TempDir::new("large-binding-save", &fixture(2));
|
||||
xattr::set(&path, "com.apple.LaunchServices.OpenWith", &vec![b'x'; 65537]).unwrap();
|
||||
xattr::set(&path, "com.apple.quarantine", b"0082;1234;Preview;").unwrap();
|
||||
let mut h = harness(&path);
|
||||
let app = h.state_mut();
|
||||
app.views[0].select_pages(&[0]);
|
||||
app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 });
|
||||
assert!(app.save_active(SaveTarget::InPlace), "the document save uses upstream preservation limits");
|
||||
assert_eq!(xattr::get(&path, "com.apple.LaunchServices.OpenWith").unwrap().unwrap().len(), 65537);
|
||||
assert_eq!(xattr::get(&path, "com.apple.quarantine").unwrap(), Some(b"0082;1234;Preview;".to_vec()));
|
||||
}
|
||||
|
||||
/// Retaining mtime and length must not hide an actual external edit behind metadata noise.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn same_length_and_mtime_content_edits_still_require_overwrite_confirmation() {
|
||||
let (_dir, path) = TempDir::new("ctime-content", &fixture(2));
|
||||
let mut h = harness(&path);
|
||||
let old = std::fs::metadata(&path).unwrap().modified().unwrap();
|
||||
let changed = String::from_utf8(fixture(2)).unwrap().replace("200 300", "201 300");
|
||||
std::fs::write(&path, changed).unwrap();
|
||||
std::fs::File::options().write(true).open(&path).unwrap().set_modified(old).unwrap();
|
||||
let app = h.state_mut();
|
||||
app.views[0].select_pages(&[0]);
|
||||
app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 });
|
||||
assert!(!app.save_active(SaveTarget::InPlace));
|
||||
assert!(matches!(app.dialog, Some(crate::Dialog::OverwriteChanged)));
|
||||
}
|
||||
|
||||
/// #1802: the watcher found version B of the file, then the user saved version C over it
|
||||
/// before B was applied. B is older than the document now: it is not applied, the undo history
|
||||
/// stays, and C stays on disk.
|
||||
|
||||
@@ -57,6 +57,8 @@ mod crop;
|
||||
mod drag_pointer;
|
||||
mod export_ui;
|
||||
mod js_ui;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos_association;
|
||||
mod marks_ui;
|
||||
mod measure_ui;
|
||||
mod ocr_ui;
|
||||
@@ -1431,6 +1433,8 @@ impl PdfCraftApp {
|
||||
self.left_open = left_open;
|
||||
}
|
||||
if let Some(p) = path {
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_association::repair(std::path::Path::new(&p));
|
||||
self.recent.retain(|r| r.path != p);
|
||||
self.recent.insert(0, RecentFile { name: name.to_string(), path: p, pages, size });
|
||||
self.recent.truncate(12);
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
//! Best-effort recovery of an existing per-file PDFCraft binding after a successful open/save.
|
||||
//! Quarantined documents with redirected bindings can be refused by LaunchServices before the
|
||||
//! app receives an open event (Apple feedback FB19468486). In-app Open remains reachable.
|
||||
//! Never remove quarantine, set type-wide defaults, or create a new preference on an open.
|
||||
|
||||
use std::{io, path::Path};
|
||||
|
||||
const OPEN_WITH: &str = "com.apple.LaunchServices.OpenWith";
|
||||
const QUARANTINE: &str = "com.apple.quarantine";
|
||||
const BUNDLE_ID: &str = "ai.storyteller.pdfcraft";
|
||||
const MAX_ATTRIBUTE: usize = 64 * 1024;
|
||||
|
||||
/// A bounded snapshot used only to decide whether native association recovery is eligible.
|
||||
#[derive(Default, PartialEq, Eq)]
|
||||
struct OpeningMetadata {
|
||||
association: Option<Vec<u8>>,
|
||||
quarantine: Option<Vec<u8>>,
|
||||
identity: Option<(u64, u64)>,
|
||||
}
|
||||
|
||||
impl OpeningMetadata {
|
||||
/// Capture one regular file identity and only read a binding when quarantine exists.
|
||||
fn read(path: &Path) -> io::Result<Self> {
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
let identity = match path.symlink_metadata() {
|
||||
Ok(meta) if meta.is_file() => (meta.dev(), meta.ino()),
|
||||
Ok(_) => return Ok(Self::default()),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let quarantine = read_attribute(path, QUARANTINE)?;
|
||||
let association = if quarantine.is_none() { None } else { read_attribute(path, OPEN_WITH)? };
|
||||
let snapshot = Self { association, quarantine, identity: Some(identity) };
|
||||
let current = path.symlink_metadata()?;
|
||||
if !current.is_file() || (current.dev(), current.ino()) != identity {
|
||||
return Err(io::Error::other("the file changed while reading its opening settings"));
|
||||
}
|
||||
Ok(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a last-component-symlink-safe attribute with a strict allocation cap.
|
||||
fn read_attribute(path: &Path, name: &str) -> io::Result<Option<Vec<u8>>> {
|
||||
read_attribute_with(|buffer| rustix::fs::lgetxattr(path, name, buffer))
|
||||
}
|
||||
|
||||
/// Probe the length before allocating. A concurrently grown value fails safely; unreadable
|
||||
/// attributes or values beyond the parser cap skip recovery without affecting open/save.
|
||||
fn read_attribute_with(mut get: impl FnMut(&mut [u8]) -> rustix::io::Result<usize>) -> io::Result<Option<Vec<u8>>> {
|
||||
use rustix::io::Errno;
|
||||
let unreadable = |error| match error {
|
||||
Errno::RANGE => Err(io::Error::new(io::ErrorKind::InvalidData, "opening metadata exceeds 64 KiB or changed while reading")),
|
||||
_ => Ok(None),
|
||||
};
|
||||
let len = match get(&mut []) {
|
||||
Ok(len) if len <= MAX_ATTRIBUTE => len,
|
||||
Ok(_) => return unreadable(Errno::RANGE),
|
||||
Err(e) => return unreadable(e),
|
||||
};
|
||||
let mut bytes = vec![0; len];
|
||||
match get(bytes.as_mut_slice()) {
|
||||
Ok(len) if len <= bytes.len() => {
|
||||
bytes.truncate(len);
|
||||
Ok(Some(bytes.into_boxed_slice().into_vec()))
|
||||
}
|
||||
Ok(_) => unreadable(Errno::RANGE),
|
||||
Err(e) => unreadable(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept only the supported native schema identifying this exact running bundle.
|
||||
fn points_to_this_app(data: &[u8], app: &Path) -> bool {
|
||||
if data.len() > MAX_ATTRIBUTE {
|
||||
return false;
|
||||
}
|
||||
// Deserialize just the small native schema rather than retaining an arbitrary plist tree.
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Binding {
|
||||
version: u32,
|
||||
path: String,
|
||||
// Apple's API sometimes writes only version/path (e.g. an unregistered local bundle).
|
||||
// The exact path still has to match the running bundle whose identity we checked.
|
||||
bundleidentifier: Option<String>,
|
||||
}
|
||||
let Ok(binding) = plist::from_bytes::<Binding>(data) else { return false };
|
||||
if binding.version != 0 || binding.bundleidentifier.as_deref().is_some_and(|id| id != BUNDLE_ID) || !Path::new(&binding.path).is_absolute() {
|
||||
return false;
|
||||
}
|
||||
// Exact native spelling is intentionally conservative. Never resolve an untrusted path:
|
||||
// canonicalize could contact a stale network mount and block the main thread.
|
||||
Path::new(&binding.path) == app
|
||||
}
|
||||
|
||||
/// Queue bounded, deduplicated recovery work without filesystem I/O on the UI thread.
|
||||
/// The framework's safe NSWorkspace binding is callable on a worker; its completion remains
|
||||
/// asynchronous. A stalled volume consumes at most one worker and a bounded queue.
|
||||
pub(crate) fn repair(path: &Path) {
|
||||
use objc2_foundation::NSBundle;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex, OnceLock, PoisonError, mpsc};
|
||||
|
||||
if !objc2::available!(macos = 12.0)
|
||||
|| objc2::MainThreadMarker::new().is_none()
|
||||
|| !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("pdf"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let bundle = NSBundle::mainBundle();
|
||||
if bundle.bundleIdentifier().is_none_or(|id| id.to_string() != BUNDLE_ID) {
|
||||
return;
|
||||
}
|
||||
let Some(app) = bundle.bundleURL().to_file_path() else { return };
|
||||
static QUEUE: OnceLock<Option<RepairQueue>> = OnceLock::new();
|
||||
let Some(queue) = QUEUE.get_or_init(|| {
|
||||
let (tx, rx) = mpsc::sync_channel::<RepairJob>(32);
|
||||
let pending = Arc::new(Mutex::new(HashSet::new()));
|
||||
std::thread::Builder::new()
|
||||
.name("pdfcraft-association".into())
|
||||
.spawn(move || {
|
||||
for job in rx {
|
||||
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| objc2::rc::autoreleasepool(|_| repair_job(job)))).is_err() {
|
||||
log::warn!("PDF association recovery skipped after a worker error");
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok()?;
|
||||
Some(RepairQueue { tx, pending })
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let path = path.to_path_buf();
|
||||
{
|
||||
let mut pending = queue.pending.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if pending.len() >= 32 || !pending.insert(path.clone()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let job = RepairJob { app, pending: Pending { path, set: queue.pending.clone() } };
|
||||
let _ = queue.tx.try_send(job); // A refused send drops the job and releases its pending entry.
|
||||
}
|
||||
|
||||
/// One process-wide worker and the paths queued or awaiting native completion.
|
||||
struct RepairQueue {
|
||||
tx: std::sync::mpsc::SyncSender<RepairJob>,
|
||||
pending: std::sync::Arc<std::sync::Mutex<std::collections::HashSet<std::path::PathBuf>>>,
|
||||
}
|
||||
|
||||
/// The exact running bundle captured on the main thread and its bounded pending entry.
|
||||
struct RepairJob {
|
||||
app: std::path::PathBuf,
|
||||
pending: Pending,
|
||||
}
|
||||
|
||||
/// Release deduplication on every exit, including errors and native callback destruction.
|
||||
struct Pending {
|
||||
path: std::path::PathBuf,
|
||||
set: std::sync::Arc<std::sync::Mutex<std::collections::HashSet<std::path::PathBuf>>>,
|
||||
}
|
||||
|
||||
impl Drop for Pending {
|
||||
fn drop(&mut self) {
|
||||
self.set.lock().unwrap_or_else(std::sync::PoisonError::into_inner).remove(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one identity-checked snapshot immediately before dispatch. No second xattr pass:
|
||||
/// the native API has no compare-and-set, so a post-dispatch external change can still race it.
|
||||
fn repair_job(job: RepairJob) {
|
||||
use block2::RcBlock;
|
||||
use objc2_app_kit::NSWorkspace;
|
||||
use objc2_foundation::{NSError, NSURL};
|
||||
let metadata = match OpeningMetadata::read(&job.pending.path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(_) => {
|
||||
log::warn!("PDF association recovery skipped: could not read opening metadata");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if metadata.quarantine.is_none() || !metadata.association.as_deref().is_some_and(|data| points_to_this_app(data, &job.app)) {
|
||||
return;
|
||||
}
|
||||
let (Some(app_url), Some(file_url)) = (NSURL::from_file_path(&job.app), NSURL::from_file_path(&job.pending.path)) else { return };
|
||||
let pending = job.pending;
|
||||
let completion = RcBlock::new(move |error: *mut NSError| {
|
||||
let _ = &pending;
|
||||
if error.is_null() {
|
||||
log::info!("PDF association recovery completed");
|
||||
} else {
|
||||
log::warn!("PDF association recovery failed; document open/save remains successful");
|
||||
}
|
||||
});
|
||||
NSWorkspace::sharedWorkspace().setDefaultApplicationAtURL_toOpenFileAtURL_completionHandler(&app_url, &file_url, Some(&completion));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Create an isolated scratch directory for parallel metadata tests.
|
||||
fn directory() -> std::path::PathBuf {
|
||||
let p = std::env::temp_dir().join(format!("pdfcraft-association-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
/// Build either native plist encoding with controlled identity fields.
|
||||
fn binding(app: &Path, id: &str, version: u32, binary: bool) -> Vec<u8> {
|
||||
let value = plist::Value::Dictionary(
|
||||
[
|
||||
("version".to_string(), plist::Value::Integer(version.into())),
|
||||
("path".to_string(), plist::Value::String(app.to_string_lossy().into_owned())),
|
||||
("bundleidentifier".to_string(), plist::Value::String(id.into())),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
);
|
||||
let mut bytes = Vec::new();
|
||||
if binary {
|
||||
value.to_writer_binary(&mut bytes).unwrap();
|
||||
} else {
|
||||
value.to_writer_xml(&mut bytes).unwrap();
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Reject foreign apps, unsupported schema versions, malformed data and relative paths.
|
||||
#[test]
|
||||
fn requires_matching_identifier_path_and_native_schema() {
|
||||
let app = directory();
|
||||
let other = directory();
|
||||
for binary in [false, true] {
|
||||
assert!(points_to_this_app(&binding(&app, BUNDLE_ID, 0, binary), &app));
|
||||
assert!(!points_to_this_app(&binding(&app, "com.apple.Preview", 0, binary), &app));
|
||||
assert!(!points_to_this_app(&binding(&other, BUNDLE_ID, 0, binary), &app));
|
||||
assert!(!points_to_this_app(&binding(&app, BUNDLE_ID, 1, binary), &app));
|
||||
}
|
||||
let mut native = plist::from_bytes::<plist::Value>(&binding(&app, BUNDLE_ID, 0, true)).unwrap();
|
||||
native.as_dictionary_mut().unwrap().remove("bundleidentifier");
|
||||
let mut bytes = Vec::new();
|
||||
native.to_writer_binary(&mut bytes).unwrap();
|
||||
assert!(points_to_this_app(&bytes, &app), "Apple's path-only binding still identifies this exact bundle");
|
||||
assert!(!points_to_this_app(&bytes, &other));
|
||||
assert!(!points_to_this_app(b"not a plist", &app));
|
||||
assert!(!points_to_this_app(&vec![0; MAX_ATTRIBUTE + 1], &app));
|
||||
assert!(!points_to_this_app(&binding(Path::new("relative.app"), BUNDLE_ID, 0, true), &app));
|
||||
std::fs::remove_dir_all(app).unwrap();
|
||||
std::fs::remove_dir_all(other).unwrap();
|
||||
}
|
||||
|
||||
/// Preserve destination values verbatim without interpreting them as new preferences.
|
||||
#[test]
|
||||
fn atomic_save_preserves_own_other_and_malformed_bindings_and_quarantine() {
|
||||
let dir = directory();
|
||||
let file = dir.join("file.pdf");
|
||||
for data in [binding(&dir, BUNDLE_ID, 0, true), binding(&dir, "com.apple.Preview", 0, true), b"malformed".to_vec()] {
|
||||
std::fs::write(&file, b"before").unwrap();
|
||||
xattr::set(&file, OPEN_WITH, &data).unwrap();
|
||||
xattr::set(&file, QUARANTINE, b"0082;1234;Preview;").unwrap();
|
||||
crate::editing::write_atomically(file.to_str().unwrap(), b"after").unwrap();
|
||||
assert_eq!(std::fs::read(&file).unwrap(), b"after");
|
||||
assert_eq!(xattr::get(&file, OPEN_WITH).unwrap(), Some(data));
|
||||
assert_eq!(xattr::get(&file, QUARANTINE).unwrap(), Some(b"0082;1234;Preview;".to_vec()));
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A new Save As destination must not inherit a source association or quarantine.
|
||||
#[test]
|
||||
fn new_destination_does_not_acquire_an_association() {
|
||||
let dir = directory();
|
||||
let file = dir.join("new.pdf");
|
||||
crate::editing::write_atomically(file.to_str().unwrap(), b"pdf bytes").unwrap();
|
||||
assert!(xattr::get(&file, OPEN_WITH).unwrap().is_none());
|
||||
assert!(xattr::get(&file, QUARANTINE).unwrap().is_none());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// The repair parser cap must not narrow upstream's larger preservation limits.
|
||||
#[test]
|
||||
fn oversized_binding_skips_repair_but_is_preserved_by_the_shared_writer() {
|
||||
let dir = directory();
|
||||
let file = dir.join("out.pdf");
|
||||
std::fs::write(&file, b"original PDF").unwrap();
|
||||
xattr::set(&file, OPEN_WITH, &vec![b'x'; MAX_ATTRIBUTE + 1]).unwrap();
|
||||
xattr::set(&file, QUARANTINE, b"0082;1234;Preview;").unwrap();
|
||||
assert!(OpeningMetadata::read(&file).is_err());
|
||||
crate::editing::write_atomically(file.to_str().unwrap(), b"new PDF").unwrap();
|
||||
assert_eq!(std::fs::read(&file).unwrap(), b"new PDF");
|
||||
assert_eq!(xattr::get(&file, OPEN_WITH).unwrap().unwrap().len(), MAX_ATTRIBUTE + 1);
|
||||
assert_eq!(xattr::get(&file, QUARANTINE).unwrap(), Some(b"0082;1234;Preview;".to_vec()));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// Snapshots distinguish replaced inodes even with identical attribute bytes.
|
||||
#[test]
|
||||
fn preflight_refuses_a_replaced_file_even_with_identical_attributes() {
|
||||
let dir = directory();
|
||||
let file = dir.join("out.pdf");
|
||||
let replacement = dir.join("replacement.pdf");
|
||||
let prepare = |path: &Path| {
|
||||
std::fs::write(path, b"PDF").unwrap();
|
||||
xattr::set(path, OPEN_WITH, &binding(&dir, BUNDLE_ID, 0, true)).unwrap();
|
||||
xattr::set(path, QUARANTINE, b"0082;1234;Preview;").unwrap();
|
||||
};
|
||||
prepare(&file);
|
||||
let metadata = OpeningMetadata::read(&file).unwrap();
|
||||
assert!(OpeningMetadata::read(&file).unwrap() == metadata);
|
||||
prepare(&replacement);
|
||||
std::fs::rename(replacement, &file).unwrap();
|
||||
assert!(OpeningMetadata::read(&file).unwrap() != metadata, "the file successfully opened is no longer at this path");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// Snapshots distinguish changed quarantine and ignore last-component symlinks.
|
||||
#[test]
|
||||
fn preflight_refuses_changed_quarantine_and_symbolic_links() {
|
||||
let dir = directory();
|
||||
let file = dir.join("out.pdf");
|
||||
std::fs::write(&file, b"PDF").unwrap();
|
||||
xattr::set(&file, OPEN_WITH, &binding(&dir, BUNDLE_ID, 0, true)).unwrap();
|
||||
xattr::set(&file, QUARANTINE, b"0082;1234;Preview;").unwrap();
|
||||
let metadata = OpeningMetadata::read(&file).unwrap();
|
||||
xattr::set(&file, QUARANTINE, b"0082;5678;another app;").unwrap();
|
||||
assert!(OpeningMetadata::read(&file).unwrap() != metadata);
|
||||
let link = dir.join("link.pdf");
|
||||
std::os::unix::fs::symlink(&file, &link).unwrap();
|
||||
let linked = OpeningMetadata::read(&link).unwrap();
|
||||
assert!(linked.association.is_none() && linked.quarantine.is_none());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// Do not resolve aliases or mount paths supplied in untrusted binding metadata.
|
||||
#[test]
|
||||
fn untrusted_bundle_aliases_are_skipped_without_resolving_them() {
|
||||
let dir = directory();
|
||||
let app = dir.join("PdfCraft.app");
|
||||
std::fs::create_dir(&app).unwrap();
|
||||
let alias = dir.join("alias.app");
|
||||
std::os::unix::fs::symlink(&app, &alias).unwrap();
|
||||
assert!(!points_to_this_app(&binding(&alias, BUNDLE_ID, 0, true), &app));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// Check a real captured native binding and the packaged application identity.
|
||||
#[test]
|
||||
fn native_binding_and_packaging_identity_match() {
|
||||
// Captured via Apple's API on a synthetic PDF on macOS 27.0.1. Contains only the
|
||||
// public installed bundle path, identifier and schema version; no document data.
|
||||
let hex = "62706c6973743030d30102030405065776657273696f6e54706174685f101062756e646c656964656e74696669657210005f101a2f4170706c69636174696f6e732f50646643726166742e6170705f101761692e73746f727974656c6c65722e7064666372616674080f171c2f314e0000000000000101000000000000000700000000000000000000000000000068";
|
||||
let captured: Vec<u8> =
|
||||
hex.as_bytes().as_chunks::<2>().0.iter().map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()).collect();
|
||||
assert!(points_to_this_app(&captured, Path::new("/Applications/PdfCraft.app")));
|
||||
let package = plist::from_bytes::<plist::Value>(include_bytes!("../../../packaging/macos/Info.plist.in")).unwrap();
|
||||
assert_eq!(package.as_dictionary().unwrap().get("CFBundleIdentifier").unwrap().as_string(), Some(BUNDLE_ID));
|
||||
}
|
||||
|
||||
/// Exercise arbitrary and damaged metadata through the strict native parser.
|
||||
#[test]
|
||||
fn malformed_plists_and_truncated_native_schemas_do_not_panic() {
|
||||
let app = Path::new("/Applications/PdfCraft.app");
|
||||
for binary in [false, true] {
|
||||
let native = binding(app, BUNDLE_ID, 0, binary);
|
||||
for end in 0..native.len() {
|
||||
let _ = points_to_this_app(native.get(..end).unwrap(), app);
|
||||
}
|
||||
for index in 0..native.len() {
|
||||
for byte in [0, 0xff, b'<', b'&'] {
|
||||
let mut mutated = native.clone();
|
||||
*mutated.get_mut(index).unwrap() = byte;
|
||||
let _ = points_to_this_app(&mutated, app);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut state = 0x125a_9430_8c97_ae13u64;
|
||||
for len in 0..1024 {
|
||||
let data: Vec<_> = (0..len)
|
||||
.map(|_| {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state as u8
|
||||
})
|
||||
.collect();
|
||||
let _ = points_to_this_app(&data, app);
|
||||
}
|
||||
}
|
||||
|
||||
/// Length probing allocates only the value, rejects oversize before a second call,
|
||||
/// and skips recovery if the value grows between the probe and read.
|
||||
#[test]
|
||||
fn attribute_probes_bound_allocation_and_detect_growth() {
|
||||
let mut calls = 0;
|
||||
let small = read_attribute_with(|out| {
|
||||
calls += 1;
|
||||
if calls == 1 {
|
||||
assert!(out.is_empty());
|
||||
return Ok(3);
|
||||
}
|
||||
assert_eq!(out.len(), 3);
|
||||
out.copy_from_slice(b"abc");
|
||||
Ok(3)
|
||||
})
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(small, b"abc");
|
||||
assert_eq!(small.capacity(), 3);
|
||||
assert!(read_attribute_with(|_| Ok(MAX_ATTRIBUTE + 1)).is_err());
|
||||
let mut calls = 0;
|
||||
assert!(
|
||||
read_attribute_with(|_| {
|
||||
calls += 1;
|
||||
if calls == 1 { Ok(3) } else { Err(rustix::io::Errno::RANGE) }
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// Volumes may expose no readable attributes with errors beyond ENOTSUP.
|
||||
#[test]
|
||||
fn unreadable_attributes_are_best_effort() {
|
||||
use rustix::io::Errno;
|
||||
for errno in [Errno::NOSYS, Errno::INVAL, Errno::PERM, Errno::ACCESS, Errno::IO] {
|
||||
assert_eq!(read_attribute_with(|_| Err(errno)).unwrap(), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Skip unavailable attributes while retaining the hard limit on known oversized values.
|
||||
#[test]
|
||||
fn unsupported_attribute_volumes_are_distinct_from_read_failures() {
|
||||
use rustix::io::Errno;
|
||||
for errno in [Errno::NOATTR, Errno::NOTSUP, Errno::OPNOTSUPP] {
|
||||
assert_eq!(read_attribute_with(|_| Err(errno)).unwrap(), None, "{errno}");
|
||||
}
|
||||
assert!(read_attribute_with(|_| Err(Errno::RANGE)).is_err());
|
||||
}
|
||||
}
|
||||
@@ -463,6 +463,8 @@ impl PdfCraftApp {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
app.watch.known_now(doc_id, &path.to_string_lossy());
|
||||
app.session.mark_signed(doc_id, signed, Some(path.to_string_lossy().into_owned())).map_err(|e| e.to_string())?;
|
||||
#[cfg(target_os = "macos")]
|
||||
crate::macos_association::repair(&path);
|
||||
app.sign_graphic = choice;
|
||||
if let Some(view) = app.views.iter_mut().find(|v| v.id == doc_id) {
|
||||
view.invalidate_content();
|
||||
|
||||
@@ -20,6 +20,7 @@ fn gpu() -> std::sync::MutexGuard<'static, ()> {
|
||||
mod accessibility;
|
||||
mod actions;
|
||||
mod animate;
|
||||
mod attach_picker_target;
|
||||
mod bookmark_position;
|
||||
mod bookmarks;
|
||||
mod bulk_fields;
|
||||
|
||||
@@ -661,3 +661,28 @@ fn an_imported_digital_id_can_be_removed() {
|
||||
assert_eq!(h.state().sign_draft.as_ref().map(|d| d.step), Some(SignStep::Configure));
|
||||
h.get_by_label("Configure a Digital ID for Signing");
|
||||
}
|
||||
|
||||
/// Sign-and-save must use the same destination metadata policy as Save As.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn signing_preserves_destination_opening_attributes() {
|
||||
let dir = dir();
|
||||
let dest = dir.join("contract_signed.pdf");
|
||||
std::fs::write(&dest, FIXTURE).unwrap();
|
||||
xattr::set(&dest, "com.apple.LaunchServices.OpenWith", b"existing binding").unwrap();
|
||||
xattr::set(&dest, "com.apple.quarantine", b"0082;1234;Preview;").unwrap();
|
||||
let mut h = harness(dir.clone());
|
||||
let p12 = concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12");
|
||||
let cert = pdfcraft_engine::sign::pkcs12::open(&std::fs::read(p12).unwrap(), "test").unwrap().certificate;
|
||||
h.state_mut().start_signing(0, None, None, None);
|
||||
let entry = h.state_mut().add_digital_id(p12, &cert);
|
||||
let d = h.state_mut().sign_draft.as_mut().unwrap();
|
||||
(d.selected, d.step, d.password) = (Some(entry), SignStep::SignAs, "test".into());
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Sign").click();
|
||||
h.run_steps(6);
|
||||
assert!(h.state().session.get(h.state().views[0].id).unwrap().signatures.iter().any(|s| s.signed));
|
||||
assert_eq!(xattr::get(&dest, "com.apple.LaunchServices.OpenWith").unwrap(), Some(b"existing binding".to_vec()));
|
||||
assert_eq!(xattr::get(&dest, "com.apple.quarantine").unwrap(), Some(b"0082;1234;Preview;".to_vec()));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
+99
-34
@@ -519,6 +519,21 @@ fn escape(s: &str) -> (String, bool) {
|
||||
const MAX_NEW_NODES: usize = 20_000;
|
||||
/// Highest same-name sibling index a write follows.
|
||||
const MAX_INDEX: usize = 10_000;
|
||||
/// Most bytes of names the nodes one write creates hold together: every sibling an index
|
||||
/// skips over gets its own copy of the name.
|
||||
const MAX_NEW_NAME_BYTES: usize = 4 << 20;
|
||||
|
||||
/// What one write may still create: nodes and bytes of their names.
|
||||
struct NewNodes {
|
||||
nodes: usize,
|
||||
name_bytes: usize,
|
||||
}
|
||||
|
||||
impl NewNodes {
|
||||
fn new() -> NewNodes {
|
||||
NewNodes { nodes: MAX_NEW_NODES, name_bytes: MAX_NEW_NAME_BYTES }
|
||||
}
|
||||
}
|
||||
|
||||
/// A data tree being built for writing.
|
||||
#[derive(Default)]
|
||||
@@ -533,8 +548,8 @@ struct Build {
|
||||
impl Build {
|
||||
/// The node at `path` below this one, created as needed. `None` when the path is deeper
|
||||
/// than [`MAX_DEPTH`], an index is above [`MAX_INDEX`], or creating it would spend more
|
||||
/// than `budget` new nodes.
|
||||
fn at(&mut self, path: &[(String, usize)], budget: &mut usize) -> Option<&mut Build> {
|
||||
/// new nodes or name bytes than `budget` holds.
|
||||
fn at(&mut self, path: &[(String, usize)], budget: &mut NewNodes) -> Option<&mut Build> {
|
||||
if path.len() > MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
@@ -547,15 +562,15 @@ impl Build {
|
||||
let have = node.by_name.get(&name).map_or(0, Vec::len);
|
||||
if *idx >= have {
|
||||
let need = idx - have + 1;
|
||||
if need > *budget {
|
||||
let bytes = need.saturating_mul(name.len());
|
||||
if need > budget.nodes || bytes > budget.name_bytes {
|
||||
return None;
|
||||
}
|
||||
*budget -= need;
|
||||
for _ in 0..need {
|
||||
let pos = node.children.len();
|
||||
node.by_name.entry(name.clone()).or_default().push(pos);
|
||||
node.children.push(Build { name: name.clone(), ..Default::default() });
|
||||
}
|
||||
budget.nodes -= need;
|
||||
budget.name_bytes -= bytes;
|
||||
let start = node.children.len();
|
||||
node.children.extend((0..need).map(|_| Build { name: name.clone(), ..Default::default() }));
|
||||
node.by_name.entry(name.clone()).or_default().extend(start..start + need);
|
||||
}
|
||||
let pos = *node.by_name.get(&name)?.get(*idx)?;
|
||||
node = node.children.get_mut(pos)?;
|
||||
@@ -675,7 +690,7 @@ const CONTROL: &str = "Characters XML can't hold were left out of the XFA data o
|
||||
/// Fresh data for `wants`: the children of an `xfa:data` element.
|
||||
fn build_children(wants: &[Want], warnings: &mut Vec<String>) -> String {
|
||||
let mut root = Build::default();
|
||||
let mut budget = MAX_NEW_NODES;
|
||||
let mut budget = NewNodes::new();
|
||||
for w in wants {
|
||||
match root.at(&w.path, &mut budget) {
|
||||
Some(node) => node.text = w.text.clone(),
|
||||
@@ -801,19 +816,52 @@ pub struct ImportNode {
|
||||
/// Deepest data an import keeps.
|
||||
const MAX_IMPORT_DEPTH: usize = MAX_DEPTH;
|
||||
|
||||
/// `n` as an [`ImportNode`], counting nodes against `budget`; `None` past the depth or node
|
||||
/// limits.
|
||||
fn import_node(n: roxmltree::Node, depth: usize, budget: &mut usize) -> Option<ImportNode> {
|
||||
if depth > MAX_IMPORT_DEPTH || *budget == 0 {
|
||||
return None;
|
||||
/// Most bytes of names, namespaces, attributes and text an import copies out of its file: a
|
||||
/// namespace declared once is copied into every element and attribute that inherits it.
|
||||
pub(crate) const MAX_IMPORT_BYTES: usize = 4 * MAX_IMPORT;
|
||||
|
||||
/// What an import may still copy: nodes and bytes.
|
||||
struct ImportBudget {
|
||||
nodes: usize,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl ImportBudget {
|
||||
fn new() -> ImportBudget {
|
||||
ImportBudget { nodes: MAX_NODES as usize, bytes: MAX_IMPORT_BYTES }
|
||||
}
|
||||
|
||||
fn take_bytes(&mut self, n: usize) -> Result<(), XfaError> {
|
||||
self.bytes = self
|
||||
.bytes
|
||||
.checked_sub(n)
|
||||
.ok_or_else(|| XfaError::TooLarge(format!("the data file expands to more than {} MB", MAX_IMPORT_BYTES >> 20)))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` as an [`ImportNode`], counting nodes and copied bytes against `budget`; `TooLarge` past
|
||||
/// the depth or either budget.
|
||||
fn import_node(n: roxmltree::Node, depth: usize, budget: &mut ImportBudget) -> Result<ImportNode, XfaError> {
|
||||
if depth > MAX_IMPORT_DEPTH {
|
||||
return Err(XfaError::TooLarge(format!("the data file nests more than {MAX_IMPORT_DEPTH} levels deep")));
|
||||
}
|
||||
budget.nodes = budget.nodes.checked_sub(1).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?;
|
||||
let name = n.tag_name().name();
|
||||
let ns = n.tag_name().namespace();
|
||||
budget.take_bytes(name.len().saturating_add(ns.map_or(0, str::len)))?;
|
||||
let mut attrs = Vec::new();
|
||||
for a in n.attributes().filter(|a| a.name() != "xmlns" && a.namespace() != Some("http://www.w3.org/2000/xmlns/")) {
|
||||
budget.take_bytes(a.namespace().map_or(0, str::len).saturating_add(a.name().len()).saturating_add(a.value().len()))?;
|
||||
attrs.push((a.namespace().map(str::to_string), a.name().to_string(), a.value().to_string()));
|
||||
}
|
||||
*budget -= 1;
|
||||
let mut children = Vec::new();
|
||||
let mut text = String::new();
|
||||
for c in n.children() {
|
||||
if c.is_element() {
|
||||
children.push(import_node(c, depth + 1, budget)?);
|
||||
} else if let Some(t) = c.text() {
|
||||
budget.take_bytes(t.len())?;
|
||||
text.push_str(t);
|
||||
}
|
||||
}
|
||||
@@ -821,17 +869,7 @@ fn import_node(n: roxmltree::Node, depth: usize, budget: &mut usize) -> Option<I
|
||||
if !children.is_empty() && text.trim().is_empty() {
|
||||
text.clear();
|
||||
}
|
||||
Some(ImportNode {
|
||||
name: n.tag_name().name().to_string(),
|
||||
ns: n.tag_name().namespace().map(str::to_string),
|
||||
attrs: n
|
||||
.attributes()
|
||||
.filter(|a| a.name() != "xmlns" && a.namespace() != Some("http://www.w3.org/2000/xmlns/"))
|
||||
.map(|a| (a.namespace().map(str::to_string), a.name().to_string(), a.value().to_string()))
|
||||
.collect(),
|
||||
text,
|
||||
children,
|
||||
})
|
||||
Ok(ImportNode { name: name.to_string(), ns: ns.map(str::to_string), attrs, text, children })
|
||||
}
|
||||
|
||||
/// `nodes` as markup for a packet whose XFA data namespace prefix is `xfa` (declared where
|
||||
@@ -1072,7 +1110,7 @@ fn merge_values(src: &str, data: roxmltree::Node, wants: &[Want], edits: &mut Ve
|
||||
let tree = data.document();
|
||||
// Nodes to add, per existing element they go into.
|
||||
let mut added: Vec<(roxmltree::NodeId, Build)> = Vec::new();
|
||||
let mut budget = MAX_NEW_NODES;
|
||||
let mut budget = NewNodes::new();
|
||||
for w in wants {
|
||||
let (escaped, dropped) = escape(&w.text);
|
||||
let mut node = data;
|
||||
@@ -1379,15 +1417,14 @@ pub fn parse_data_file(text: &str, expect_root: Option<&str>) -> Result<Vec<Impo
|
||||
{
|
||||
return Err(XfaError::Malformed(format!("the data file's root is <{local}>; this form's data root is <{want}>")));
|
||||
}
|
||||
let mut budget = MAX_NODES as usize;
|
||||
let node = import_node(root, 0, &mut budget).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?;
|
||||
let node = import_node(root, 0, &mut ImportBudget::new())?;
|
||||
return Ok(vec![node]);
|
||||
}
|
||||
};
|
||||
let mut budget = MAX_NODES as usize;
|
||||
let mut budget = ImportBudget::new();
|
||||
let mut out = Vec::new();
|
||||
for c in data.children().filter(|c| c.is_element()) {
|
||||
out.push(import_node(c, 1, &mut budget).ok_or_else(|| XfaError::TooLarge("the data file holds too many nodes".into()))?);
|
||||
out.push(import_node(c, 1, &mut budget)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -1516,6 +1553,16 @@ fn element_markup(src: &str, n: roxmltree::Node) -> Option<String> {
|
||||
Some(format!("{}{decls}{}", text.get(..name_end)?, text.get(name_end..)?))
|
||||
}
|
||||
|
||||
/// Most instance nodes one [`write_data_ops`] queues over all its passes (a removal starts a new
|
||||
/// pass, which asks for the instances again).
|
||||
const MAX_ENSURED: usize = 200_000;
|
||||
|
||||
// Instance nodes write_data_ops has queued on this thread, for tests that bound its work.
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
pub(crate) static ENSURED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Apply `ops` to the datasets packet, in order, as one rewrite of it: everything one script
|
||||
/// event changed becomes a single new datasets stream. When the packet is already the stream
|
||||
/// `reuse` (written earlier for the same event), that stream is replaced in place instead of
|
||||
@@ -1525,6 +1572,9 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option<ObjRef>)
|
||||
let mut steps: Vec<Vec<Want>> = Vec::new();
|
||||
let mut removes: Vec<Option<DataPath>> = Vec::new();
|
||||
let mut run: Vec<Want> = Vec::new();
|
||||
// Instances queued in this run, by parent and name: asking for more only queues the rest.
|
||||
let mut queued: HashMap<(&DataPath, &str), usize> = HashMap::new();
|
||||
let (mut ensured, mut capped) = (0usize, false);
|
||||
let flush = |run: &mut Vec<Want>, steps: &mut Vec<Vec<Want>>, removes: &mut Vec<Option<DataPath>>| {
|
||||
if !run.is_empty() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
@@ -1541,7 +1591,17 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option<ObjRef>)
|
||||
run.push(Want { som: path_som(path), path: path.clone(), text: text.clone(), ensure: false });
|
||||
}
|
||||
DataOp::Instances { parent, name, count } => {
|
||||
for k in 0..(*count).min(MAX_INDEX) {
|
||||
let done = queued.entry((parent, name.as_str())).or_insert(0);
|
||||
while *done < (*count).min(MAX_INDEX) {
|
||||
if ensured >= MAX_ENSURED {
|
||||
capped = true;
|
||||
break;
|
||||
}
|
||||
ensured += 1;
|
||||
#[cfg(test)]
|
||||
ENSURED.with(|n| n.set(n.get() + 1));
|
||||
let k = *done;
|
||||
*done += 1;
|
||||
let mut path = parent.clone();
|
||||
path.push((name.clone(), k));
|
||||
run.push(Want { som: format!("{name}[{k}]"), path, text: String::new(), ensure: true });
|
||||
@@ -1549,6 +1609,7 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option<ObjRef>)
|
||||
}
|
||||
DataOp::Remove(path) if !path.is_empty() => {
|
||||
flush(&mut run, &mut steps, &mut removes);
|
||||
queued.clear();
|
||||
steps.push(Vec::new());
|
||||
removes.push(Some(path.clone()));
|
||||
}
|
||||
@@ -1564,7 +1625,11 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option<ObjRef>)
|
||||
None => Op::Values(w),
|
||||
})
|
||||
.collect();
|
||||
rewrite(doc, &plan, reuse)
|
||||
let mut written = rewrite(doc, &plan, reuse)?;
|
||||
if capped {
|
||||
written.warnings.push(format!("XFA data instances not written past {MAX_ENSURED}: the rows were added and removed too often"));
|
||||
}
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// Put `bytes` in the document as a datasets stream: in place of `old` when that is the stream
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user