diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 0000000000..2408084cf9
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,6 @@
+# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
+reviews:
+ auto_review:
+ # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
+ ignore_usernames:
+ - "dependabot[bot]"
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
index ce817da129..01cb9cd663 100644
--- a/.github/workflows/nix.yml
+++ b/.github/workflows/nix.yml
@@ -18,16 +18,6 @@ concurrency:
cancel-in-progress: true
jobs:
- eval:
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: cachix/install-nix-action@448cde739abec1eaf0646249e3d25bb8d9bc7702 # v31.11.2
- - name: nix flake check --no-build
- run: |
- if [ -f flake.nix ]; then
- nix flake check --no-build --all-systems
- else
- echo "No flake.nix present in this repository; skipping check."
- fi
+ nix:
+ # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
+ uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
diff --git a/Cargo.lock b/Cargo.lock
index 68a758c2fb..2399531ef4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3938,7 +3938,7 @@ dependencies = [
[[package]]
name = "pdfcraft"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"craft-quit",
"eframe",
@@ -3963,7 +3963,7 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3975,7 +3975,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -3986,7 +3986,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3997,7 +3997,7 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"base64 0.22.1",
"flate2",
@@ -4018,7 +4018,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cli"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"base64 0.22.1",
"pdfcraft-audit",
@@ -4035,18 +4035,18 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
-version = "0.9.0"
+version = "0.10.1"
[[package]]
name = "pdfcraft-content"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-cos"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"hayro-syntax",
"pdfcraft-crypt",
@@ -4060,7 +4060,7 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"encoding_rs",
"hayro-syntax",
@@ -4076,7 +4076,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"aes",
"md-5",
@@ -4088,7 +4088,7 @@ dependencies = [
[[package]]
name = "pdfcraft-edit"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"hayro-syntax",
"kurbo 0.13.1",
@@ -4103,7 +4103,7 @@ dependencies = [
[[package]]
name = "pdfcraft-engine"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"chrono",
"craft-docpath",
@@ -4143,14 +4143,14 @@ dependencies = [
[[package]]
name = "pdfcraft-export"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"flate2",
"proptest",
@@ -4160,7 +4160,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -4170,7 +4170,7 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4182,18 +4182,18 @@ dependencies = [
[[package]]
name = "pdfcraft-geom"
-version = "0.9.0"
+version = "0.10.1"
[[package]]
name = "pdfcraft-js"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"boa_engine",
]
[[package]]
name = "pdfcraft-measure"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4206,14 +4206,14 @@ dependencies = [
[[package]]
name = "pdfcraft-model"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"ocrs",
"pdfcraft-fonts",
@@ -4223,7 +4223,7 @@ dependencies = [
[[package]]
name = "pdfcraft-optimize"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"image",
"pdfcraft-annot",
@@ -4236,11 +4236,11 @@ dependencies = [
[[package]]
name = "pdfcraft-oracle"
-version = "0.9.0"
+version = "0.10.1"
[[package]]
name = "pdfcraft-organize"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4251,7 +4251,7 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"async-channel",
"futures-lite",
@@ -4261,7 +4261,7 @@ dependencies = [
[[package]]
name = "pdfcraft-preflight"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"flate2",
"pdfcraft-content",
@@ -4272,7 +4272,7 @@ dependencies = [
[[package]]
name = "pdfcraft-print"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"craft-print",
"pdfcraft-content",
@@ -4283,7 +4283,7 @@ dependencies = [
[[package]]
name = "pdfcraft-redact"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4299,7 +4299,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"bytemuck",
"flate2",
@@ -4318,7 +4318,7 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"aes",
"aws-lc-rs",
@@ -4352,11 +4352,11 @@ dependencies = [
[[package]]
name = "pdfcraft-testkit"
-version = "0.9.0"
+version = "0.10.1"
[[package]]
name = "pdfcraft-ui-egui"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"arboard",
"base64 0.22.1",
@@ -4400,7 +4400,7 @@ dependencies = [
[[package]]
name = "pdfcraft-web"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"eframe",
"egui",
@@ -4412,7 +4412,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"base64 0.22.1",
"image",
@@ -4426,7 +4426,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfdf"
-version = "0.9.0"
+version = "0.10.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
diff --git a/Cargo.toml b/Cargo.toml
index ec89098021..b00b726dcb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,7 +5,7 @@ exclude = ["vendor"]
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
[workspace.package]
-version = "0.9.0"
+version = "0.10.1"
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.9.0" }
-pdfcraft-filters = { path = "crates/filters", version = "0.9.0" }
-pdfcraft-cos = { path = "crates/cos", version = "0.9.0" }
-pdfcraft-crypt = { path = "crates/crypt", version = "0.9.0" }
-pdfcraft-organize = { path = "crates/organize", version = "0.9.0" }
-pdfcraft-measure = { path = "crates/measure", version = "0.9.0" }
-pdfcraft-annot = { path = "crates/annot", version = "0.9.0" }
-pdfcraft-audit = { path = "crates/audit", version = "0.9.0" }
-pdfcraft-fonts = { path = "crates/fonts", version = "0.9.0" }
-pdfcraft-model = { path = "crates/model", version = "0.9.0" }
-pdfcraft-content = { path = "crates/content", version = "0.9.0" }
-pdfcraft-redact = { path = "crates/redact", version = "0.9.0" }
-pdfcraft-print = { path = "crates/print", version = "0.9.0" }
-pdfcraft-xfdf = { path = "crates/xfdf", version = "0.9.0" }
-pdfcraft-sign = { path = "crates/sign", version = "0.9.0" }
-pdfcraft-optimize = { path = "crates/optimize", version = "0.9.0" }
-pdfcraft-a11y = { path = "crates/a11y", version = "0.9.0" }
-pdfcraft-oracle = { path = "crates/oracle", version = "0.9.0" }
-pdfcraft-testkit = { path = "crates/testkit", version = "0.9.0" }
-pdfcraft-ocr = { path = "crates/ocr", version = "0.9.0" }
-pdfcraft-js = { path = "crates/js", version = "0.9.0" }
-pdfcraft-compare = { path = "crates/compare", version = "0.9.0" }
-pdfcraft-preflight = { path = "crates/preflight", version = "0.9.0" }
-pdfcraft-export = { path = "crates/export", version = "0.9.0" }
-pdfcraft-edit = { path = "crates/edit", version = "0.9.0" }
-pdfcraft-create = { path = "crates/create", version = "0.9.0" }
-pdfcraft-forms = { path = "crates/forms", version = "0.9.0" }
-pdfcraft-xfa = { path = "crates/xfa", version = "0.9.0" }
-pdfcraft-render = { path = "crates/render", version = "0.9.0" }
-pdfcraft-engine = { path = "crates/engine", version = "0.9.0" }
-pdfcraft-platform = { path = "crates/platform", version = "0.9.0" }
-pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.9.0" }
-pdfcraft-automation = { path = "crates/automation", version = "0.9.0", default-features = false }
+pdfcraft-geom = { path = "crates/geom", version = "0.10.1" }
+pdfcraft-filters = { path = "crates/filters", version = "0.10.1" }
+pdfcraft-cos = { path = "crates/cos", version = "0.10.1" }
+pdfcraft-crypt = { path = "crates/crypt", version = "0.10.1" }
+pdfcraft-organize = { path = "crates/organize", version = "0.10.1" }
+pdfcraft-measure = { path = "crates/measure", version = "0.10.1" }
+pdfcraft-annot = { path = "crates/annot", version = "0.10.1" }
+pdfcraft-audit = { path = "crates/audit", version = "0.10.1" }
+pdfcraft-fonts = { path = "crates/fonts", version = "0.10.1" }
+pdfcraft-model = { path = "crates/model", version = "0.10.1" }
+pdfcraft-content = { path = "crates/content", version = "0.10.1" }
+pdfcraft-redact = { path = "crates/redact", version = "0.10.1" }
+pdfcraft-print = { path = "crates/print", version = "0.10.1" }
+pdfcraft-xfdf = { path = "crates/xfdf", version = "0.10.1" }
+pdfcraft-sign = { path = "crates/sign", version = "0.10.1" }
+pdfcraft-optimize = { path = "crates/optimize", version = "0.10.1" }
+pdfcraft-a11y = { path = "crates/a11y", version = "0.10.1" }
+pdfcraft-oracle = { path = "crates/oracle", version = "0.10.1" }
+pdfcraft-testkit = { path = "crates/testkit", version = "0.10.1" }
+pdfcraft-ocr = { path = "crates/ocr", version = "0.10.1" }
+pdfcraft-js = { path = "crates/js", version = "0.10.1" }
+pdfcraft-compare = { path = "crates/compare", version = "0.10.1" }
+pdfcraft-preflight = { path = "crates/preflight", version = "0.10.1" }
+pdfcraft-export = { path = "crates/export", version = "0.10.1" }
+pdfcraft-edit = { path = "crates/edit", version = "0.10.1" }
+pdfcraft-create = { path = "crates/create", version = "0.10.1" }
+pdfcraft-forms = { path = "crates/forms", version = "0.10.1" }
+pdfcraft-xfa = { path = "crates/xfa", version = "0.10.1" }
+pdfcraft-render = { path = "crates/render", version = "0.10.1" }
+pdfcraft-engine = { path = "crates/engine", version = "0.10.1" }
+pdfcraft-platform = { path = "crates/platform", version = "0.10.1" }
+pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.10.1" }
+pdfcraft-automation = { path = "crates/automation", version = "0.10.1", default-features = false }
# pdf
hayro = "0.7.1"
diff --git a/README.md b/README.md
index 2a65b66457..af49129eca 100644
--- a/README.md
+++ b/README.md
@@ -17,10 +17,13 @@
-
-
-
-
+
+
+
+
+
+
+
diff --git a/ROADMAP.md b/ROADMAP.md
index 924f2f35ec..03fbd5ec69 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,6 +2,7 @@
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
+> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
@@ -176,6 +177,11 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged.
- **2026-10-18 (M10, #3120):** Export to Word, PowerPoint, HTML and RTF reads text from form XObjects nested as deep as the renderer draws them (50 levels), and up to 50 000 forms a page (the renderer's budget for nested ones); it stopped at 12 levels and 4 096 forms and left the rest out silently. A page past those limits is listed in `doc_export_office`'s `truncated_pages`, with a warning. Estimates unchanged.
+
+- **2026-10-18 (M5, PR #1971 review):** Nested FDF annotation values that identify a source `/Page` or `/Pages` dictionary become null at every copy depth. A synthetic three-page PDF saved and reopened before export verifies that destination actions cannot pull source page content into the FDF, while unrelated nested metadata and shared references remain intact. FDF import semantics, copied-graph limits and estimates are unchanged.
+- **2026-10-15 (M5, PR #1971 refresh):** Nested FDF annotation values retain shared or cyclic copied graphs. Discovery borrows direct values and records annotation positions with lazy array owners; retained entries are budgeted before cloning. Synthetic stress fixtures include repeated aliases and saved-PDF cache eviction. Limits cover the copied graph; source parsing and page discovery retain their existing limits, with no total source-memory bound. FDF import semantics and estimates are unchanged.
+- **2026-10-13 (M5, nested FDF values):** Comment export copies and renumbers nested indirect values, retaining geometry, colours, borders and shared graphs. Graph/data limits return an error before the destination is written; synthetic saved-PDF and automation exchange regressions. Estimates unchanged.
+
- **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged.
- **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved.
@@ -547,6 +553,8 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
+| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
| 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. |
| 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. |
| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. |
diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs
index c6e1a574f0..cbf40f1962 100644
--- a/crates/automation/tests/automation.rs
+++ b/crates/automation/tests/automation.rs
@@ -6766,6 +6766,65 @@ fn a_root_less_write_output_is_atomic_too() {
let _ = std::fs::remove_dir_all(&dir);
}
+#[test]
+fn fdf_nested_comment_values_survive_export_import_and_save_through_tools() {
+ use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full};
+ use std::sync::Arc;
+ let dir = workdir("fdf-nested-values");
+ let mut source = Document::open(Arc::new(fixture(1))).unwrap();
+ let page = pdfcraft_model::pages(&source)[0].obj;
+ let mut note = Dict::new();
+ note.set(b"Type".to_vec(), Object::name("Annot"));
+ note.set(b"Subtype".to_vec(), Object::name("Square"));
+ note.set(b"NM".to_vec(), PdfString::text("nested-values"));
+ note.set(b"Rect".to_vec(), Object::Array([10, 20, 30, 40].map(|value| Object::Ref(source.add(Object::Int(value)))).into()));
+ note.set(b"C".to_vec(), Object::Array([0.25, 0.5, 0.75].map(|value| Object::Ref(source.add(Object::Real(value)))).into()));
+ let note = source.add(note);
+ source.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap();
+ std::fs::write(dir.join("source.pdf"), write_full(&source, &SaveOptions::default()).unwrap()).unwrap();
+ let mut a = auto(&dir);
+ let source = ok(&mut a, "doc_open", json!({ "path": "source.pdf" }))["doc"].as_u64().unwrap();
+
+ ok(&mut a, "doc_export_data", json!({ "doc": source, "path": "notes.fdf", "what": "comments" }));
+ let target = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap();
+ ok(&mut a, "doc_import_data", json!({ "doc": target, "path": "notes.fdf" }));
+ ok(&mut a, "doc_save", json!({ "doc": target, "path": "restored.pdf" }));
+ let reopened = ok(&mut a, "doc_open", json!({ "path": "restored.pdf" }))["doc"].as_u64().unwrap();
+ let after = ok(&mut a, "comment_list", json!({ "doc": reopened }));
+ // The saved input has legal indirect scalar numbers. The engine's source inspection
+ // currently reads those as defaults, so compare the restored data to the fixture's
+ // known values (converted from PDF bottom-left to the tool's top-left coordinates).
+ assert_eq!(after["comments"][0]["rect"], json!([10.0, 260.0, 30.0, 280.0]));
+ assert_eq!(after["comments"][0]["color"], "#4080BF");
+}
+
+#[test]
+fn refused_fdf_export_keeps_the_destination_and_source_through_tools() {
+ use pdfcraft_cos::{Dict, Document, Object, SaveOptions, write_full};
+ use std::sync::Arc;
+ let dir = workdir("fdf-export-limit");
+ let mut source = Document::open(Arc::new(fixture(1))).unwrap();
+ let page = pdfcraft_model::pages(&source)[0].obj;
+ let mut note = Dict::new();
+ note.set(b"Type".to_vec(), Object::name("Annot"));
+ note.set(b"Subtype".to_vec(), Object::name("Square"));
+ note.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 20.into(), 30.into(), 40.into()]));
+ note.set(b"PCData".to_vec(), Object::Array(vec![Object::Null; 100_001]));
+ let note = source.add(note);
+ source.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap();
+ let bytes = write_full(&source, &SaveOptions::default()).unwrap();
+ std::fs::write(dir.join("source.pdf"), &bytes).unwrap();
+ std::fs::write(dir.join("notes.fdf"), b"existing destination").unwrap();
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "source.pdf" }))["doc"].as_u64().unwrap();
+ let before = ok(&mut a, "comment_list", json!({ "doc": doc }));
+ let error = a.call("doc_export_data", &json!({ "doc": doc, "path": "notes.fdf", "what": "comments" })).unwrap_err();
+ assert!(error.to_string().contains("100,000 values"), "{error}");
+ assert_eq!(std::fs::read(dir.join("notes.fdf")).unwrap(), b"existing destination");
+ assert_eq!(std::fs::read(dir.join("source.pdf")).unwrap(), bytes);
+ assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc })), before);
+}
+
#[test]
fn text_box_and_callout_fill_and_border_through_tools() {
let dir = workdir("textbox-fill");
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 8f39248abd..dfe11b25c6 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -4342,7 +4342,7 @@ impl Session {
}
guard(|| match format {
DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()),
- DataFormat::Fdf => Ok(pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name)),
+ DataFormat::Fdf => pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name).map_err(|e| e.to_string()),
DataFormat::Xml | DataFormat::Xdp if xfa => {
pdfcraft_xfa::export_data(&cos, format == DataFormat::Xdp, &doc.name).map(String::into_bytes).map_err(|e| e.to_string())
}
diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs
index 52ca6c276f..415382b232 100644
--- a/crates/redact/src/interp.rs
+++ b/crates/redact/src/interp.rs
@@ -84,9 +84,19 @@ pub(crate) struct Scope<'a> {
pub report: &'a mut Report,
/// Sanitize: remove hidden text (invisible render modes, or wholly outside this page box).
pub hidden_text: Option<[f64; 4]>,
- /// Sanitize: optional content groups (and membership dictionaries) that are off; content
- /// marked with them is removed.
- pub hidden_layers: Vec,
+ /// Sanitize: optional content groups that are off; content marked with them is removed.
+ pub hidden_layers: HashSet,
+ /// Whether this pass is evaluating optional-content rules. An empty off set is meaningful:
+ /// policies such as `/AllOff` can still hide content when every group is on.
+ pub layers_enabled: bool,
+ /// Off groups referenced by optional content that remains visible and therefore still needs
+ /// its original default state after sanitization.
+ pub retained_off_layers: HashSet,
+ /// Cache membership results so repeated blocks cannot repeatedly walk hostile expressions.
+ ocmd_cache: HashMap,
+ /// Direct `/OC` dictionaries on XObjects have no object reference of their own. Cache them
+ /// by the containing XObject so repeated `Do` calls share the same bounded evaluation.
+ xobject_oc_cache: HashMap,
/// Content removed from hidden layers (blocks and XObjects).
pub layer_blocks: usize,
fonts: HashMap, Rc>,
@@ -101,7 +111,11 @@ impl<'a> Scope<'a> {
mode,
report,
hidden_text: None,
- hidden_layers: Vec::new(),
+ hidden_layers: HashSet::new(),
+ layers_enabled: false,
+ retained_off_layers: HashSet::new(),
+ ocmd_cache: HashMap::new(),
+ xobject_oc_cache: HashMap::new(),
layer_blocks: 0,
fonts: HashMap::new(),
used_names: Vec::new(),
@@ -111,20 +125,26 @@ impl<'a> Scope<'a> {
/// Sanitizing visits every form XObject (not only those under a region).
fn everywhere(&self) -> bool {
- self.hidden_text.is_some() || !self.hidden_layers.is_empty()
+ self.hidden_text.is_some() || self.layers_enabled
}
/// Is this optional-content reference (an OCG or OCMD) hidden?
- fn layer_hidden(&self, doc: &Document, o: &Object) -> bool {
- if self.hidden_layers.is_empty() {
+ fn layer_hidden(&mut self, doc: &Document, o: &Object) -> bool {
+ if !self.layers_enabled {
return false;
}
- if let Some(r) = o.as_ref()
- && self.hidden_layers.contains(&r)
- {
- return true;
+ let reference = o.as_ref();
+ if let Some(r) = reference {
+ if let Some(hidden) = self.ocmd_cache.get(&r) {
+ return *hidden;
+ }
+ if self.hidden_layers.contains(&r) {
+ self.ocmd_cache.insert(r, true);
+ return true;
+ }
}
- // A membership dictionary (default policy AnyOn): hidden when all its groups are.
+ // Evaluate the policy against the same default configuration used to select hidden
+ // groups. A membership dictionary's default policy is AnyOn.
let d = doc.resolve(o);
let Some(d) = d.as_dict() else { return false };
if d.name(b"Type") != Some(b"OCMD") {
@@ -135,7 +155,188 @@ impl<'a> Scope<'a> {
Some(_) => d.get(b"OCGs").and_then(Object::as_ref).into_iter().collect(),
None => Vec::new(),
};
- !groups.is_empty() && groups.iter().all(|g| self.hidden_layers.contains(g))
+ let fail_closed = || groups.iter().any(|g| self.hidden_layers.contains(g));
+ let hidden = if let Some(expression) = d.get(b"VE") {
+ let visible = self.visibility_expression(doc, expression, 0, &mut HashSet::new(), &mut 4096);
+ match visible {
+ Some(visible) => !visible,
+ None => fail_closed() || self.expression_mentions_hidden(doc, expression, 0, &mut HashSet::new(), &mut 4096),
+ }
+ } else if groups.is_empty() {
+ false
+ } else {
+ let any_on = groups.iter().any(|g| !self.hidden_layers.contains(g));
+ let all_on = groups.iter().all(|g| !self.hidden_layers.contains(g));
+ let policy = d.get(b"P").map(|p| doc.resolve(p)).and_then(|p| p.as_name().map(|name| name.to_vec()));
+ match (d.get(b"P"), policy.as_deref()) {
+ (None, _) | (Some(_), Some(b"AnyOn")) => !any_on,
+ (Some(_), Some(b"AllOn")) => !all_on,
+ (Some(_), Some(b"AnyOff")) => all_on,
+ (Some(_), Some(b"AllOff")) => any_on,
+ // An invalid policy must not make a candidate hidden block survive sanitization.
+ _ => fail_closed(),
+ }
+ };
+ if !hidden {
+ self.retained_off_layers.extend(groups.iter().filter(|g| self.hidden_layers.contains(g)).copied());
+ if let Some(expression) = d.get(b"VE") {
+ let mut referenced = HashSet::new();
+ if Self::collect_hidden_expression_groups(doc, expression, &self.hidden_layers, 0, &mut HashSet::new(), &mut 4096, &mut referenced) {
+ self.retained_off_layers.extend(referenced);
+ } else {
+ // If an expression cannot be fully walked, preserve every off group so that
+ // the content we kept does not change visibility after configuration cleanup.
+ self.retained_off_layers.extend(self.hidden_layers.iter().copied());
+ }
+ }
+ }
+ if let Some(r) = reference {
+ self.ocmd_cache.insert(r, hidden);
+ }
+ hidden
+ }
+
+ fn xobject_layer_hidden(&mut self, doc: &Document, xobject: ObjRef, oc: &Object) -> bool {
+ if oc.as_ref().is_some() {
+ return self.layer_hidden(doc, oc);
+ }
+ if let Some(hidden) = self.xobject_oc_cache.get(&xobject) {
+ return *hidden;
+ }
+ let hidden = self.layer_hidden(doc, oc);
+ self.xobject_oc_cache.insert(xobject, hidden);
+ hidden
+ }
+
+ fn collect_hidden_expression_groups(
+ doc: &Document,
+ expression: &Object,
+ hidden_layers: &HashSet,
+ depth: usize,
+ path: &mut HashSet,
+ budget: &mut usize,
+ found: &mut HashSet,
+ ) -> bool {
+ if depth > 64 || *budget == 0 {
+ return false;
+ }
+ *budget -= 1;
+ let reference = expression.as_ref();
+ if let Some(r) = reference
+ && !path.insert(r)
+ {
+ return false;
+ }
+ let resolved = doc.resolve(expression);
+ let complete = match &*resolved {
+ Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => {
+ if let Some(r) = reference
+ && hidden_layers.contains(&r)
+ {
+ found.insert(r);
+ }
+ reference.is_some()
+ }
+ Object::Array(items) => {
+ let Some(Object::Name(operator)) = items.first() else { return false };
+ let operands = match operator.as_slice() {
+ b"And" | b"Or" if (2..=4097).contains(&items.len()) => items.get(1..),
+ b"Not" if items.len() == 2 => items.get(1..),
+ _ => None,
+ };
+ operands.is_some_and(|operands| {
+ operands.iter().all(|item| Self::collect_hidden_expression_groups(doc, item, hidden_layers, depth + 1, path, budget, found))
+ })
+ }
+ _ => false,
+ };
+ if let Some(r) = reference {
+ path.remove(&r);
+ }
+ complete
+ }
+
+ /// Evaluate an optional-content visibility expression (`/VE`) against the default config.
+ /// Malformed, cyclic, or excessive expressions return `None` so the caller can fail closed.
+ fn visibility_expression(
+ &self,
+ doc: &Document,
+ expression: &Object,
+ depth: usize,
+ path: &mut HashSet,
+ budget: &mut usize,
+ ) -> Option {
+ if depth > 64 || *budget == 0 {
+ return None;
+ }
+ *budget -= 1;
+ let reference = expression.as_ref();
+ if let Some(r) = reference
+ && !path.insert(r)
+ {
+ return None;
+ }
+ let resolved = doc.resolve(expression);
+ let result = match &*resolved {
+ Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => reference.map(|r| !self.hidden_layers.contains(&r)),
+ Object::Array(items) if (2..=4097).contains(&items.len()) => {
+ let operator = match items.first() {
+ Some(Object::Name(n)) => n.as_slice(),
+ _ => return None,
+ };
+ match operator {
+ b"And" if items.len() >= 2 => {
+ let mut visible = true;
+ for item in items.iter().skip(1) {
+ visible &= self.visibility_expression(doc, item, depth + 1, path, budget)?;
+ }
+ Some(visible)
+ }
+ b"Or" if items.len() >= 2 => {
+ let mut visible = false;
+ for item in items.iter().skip(1) {
+ visible |= self.visibility_expression(doc, item, depth + 1, path, budget)?;
+ }
+ Some(visible)
+ }
+ b"Not" if items.len() == 2 => Some(!self.visibility_expression(doc, &items[1], depth + 1, path, budget)?),
+ _ => None,
+ }
+ }
+ _ => None,
+ };
+ if let Some(r) = reference {
+ path.remove(&r);
+ }
+ result
+ }
+
+ /// Conservatively find hidden groups in an expression that could not be evaluated. Unknown
+ /// syntax, cycles, and exhausted budgets count as a possible hidden reference.
+ fn expression_mentions_hidden(&self, doc: &Document, expression: &Object, depth: usize, path: &mut HashSet, budget: &mut usize) -> bool {
+ if depth > 64 || *budget == 0 {
+ return true;
+ }
+ *budget -= 1;
+ if let Some(r) = expression.as_ref() {
+ if self.hidden_layers.contains(&r) {
+ return true;
+ }
+ if !path.insert(r) {
+ return true;
+ }
+ }
+ let resolved = doc.resolve(expression);
+ let found = match &*resolved {
+ Object::Array(items) => items.iter().any(|item| self.expression_mentions_hidden(doc, item, depth + 1, path, budget)),
+ Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => expression.as_ref().is_none_or(|r| self.hidden_layers.contains(&r)),
+ Object::Dict(_) => true,
+ _ => false,
+ };
+ if let Some(r) = expression.as_ref() {
+ path.remove(&r);
+ }
+ found
}
fn hits(&self, b: [f64; 4]) -> bool {
@@ -534,7 +735,7 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
// Layer removal also changes enclosing replacement spans, so remember the operators before
// any cleanup, not just the survivors of the layer pass.
let original = parsed.ops.clone();
- if !scope.hidden_layers.is_empty() {
+ if scope.layers_enabled {
let props = res_dict(doc, resources, b"Properties");
let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props);
parsed.ops = kept;
@@ -928,11 +1129,13 @@ fn xobject(
let obj = doc.get(r);
let Object::Stream(s) = &*obj else { return None };
match s.dict.name(b"Subtype") {
- _ if s.dict.get(b"OC").is_some_and(|oc| scope.layer_hidden(doc, oc)) => {
+ _ if s.dict.get(b"OC").is_some_and(|oc| scope.xobject_layer_hidden(doc, r, oc)) => {
+ // Verify mode must count hidden XObjects too: scan uses that count to decide
+ // whether the HiddenLayers pass is necessary.
+ scope.layer_blocks += 1;
if scope.mode == Mode::Verify {
return None;
}
- scope.layer_blocks += 1;
out.replaced.push(name);
out.doomed.push(r);
Some(None)
@@ -983,6 +1186,11 @@ fn xobject(
}
if scope.depth >= MAX_DEPTH {
if scope.mode == Mode::Verify {
+ // Sanitization removes a Form it cannot enter, so the verification scan
+ // must count that conservative removal or it may decide to skip the pass.
+ if scope.everywhere() {
+ scope.layer_blocks += 1;
+ }
return None;
}
scope.report.forms_removed += 1;
@@ -998,7 +1206,14 @@ fn xobject(
out.doomed.push(r);
return Some(None);
}
- Err(_) => return None,
+ Err(_) => {
+ // Apply drops an undecodable Form while sanitizing. Count it during Verify
+ // so a zero-count preflight cannot leave its potentially hidden bytes behind.
+ if scope.everywhere() {
+ scope.layer_blocks += 1;
+ }
+ return None;
+ }
};
let own = s.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned());
let res = own.clone().unwrap_or_else(|| resources.clone());
@@ -1073,7 +1288,7 @@ fn xobject(
/// Remove marked-content blocks of hidden layers (`/OC /name BDC … EMC`, nested blocks
/// included). Returns the remaining operators and how many blocks went.
-fn strip_hidden_layers(doc: &Document, scope: &Scope<'_>, ops: Vec, props: &Dict) -> (Vec, usize) {
+fn strip_hidden_layers(doc: &Document, scope: &mut Scope<'_>, ops: Vec, props: &Dict) -> (Vec, usize) {
let mut out = Vec::with_capacity(ops.len());
// For each open marked-content block: does it hide its contents?
let mut stack: Vec = Vec::new();
diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs
index 1940e376a8..bafa769bd3 100644
--- a/crates/redact/src/sanitize.rs
+++ b/crates/redact/src/sanitize.rs
@@ -138,12 +138,13 @@ fn hidden_layers(doc: &Document) -> Vec {
}
/// Glyphs of hidden text, or content of hidden layers, on every page (`write` = remove them).
-fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Result {
+fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Result<(usize, std::collections::HashSet), RedactError> {
let off = if layers { hidden_layers(doc) } else { Vec::new() };
- if !text && off.is_empty() {
- return Ok(0);
+ if !text && !layers {
+ return Ok((0, std::collections::HashSet::new()));
}
let mut total = 0;
+ let mut retained_off_layers = std::collections::HashSet::new();
// The original image and form XObjects the rewrite drops or replaces, on any page.
let mut doomed = Vec::new();
let mut retired_properties = Vec::new();
@@ -164,13 +165,18 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
let c = page.crop(doc);
scope.hidden_text = Some([c[0].min(c[2]), c[1].min(c[3]), c[0].max(c[2]), c[1].max(c[3])]);
}
- scope.hidden_layers = off.clone();
+ scope.hidden_layers = off.iter().copied().collect();
+ scope.layers_enabled = layers;
let out = process(doc, &mut scope, &data, &resources, Matrix::IDENTITY);
if let Some(error) = out.error {
return Err(error.into());
}
let blocks = scope.layer_blocks;
- total += out.residue + blocks + report.glyphs;
+ let glyphs = scope.report.glyphs;
+ let retained_for_page = std::mem::take(&mut scope.retained_off_layers);
+ drop(scope);
+ total += out.residue + blocks + glyphs;
+ retained_off_layers.extend(retained_for_page);
if !write {
continue;
}
@@ -216,7 +222,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
// What a page no longer draws must not stay reachable through an inherited or shared
// /Resources either.
crate::interp::unlink_doomed(doc, &doomed, &retired_properties)?;
- Ok(total)
+ Ok((total, retained_off_layers))
}
/// How many items each category would remove (categories with nothing are included as 0).
@@ -239,10 +245,10 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> {
Hidden::Attachments => sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t, 0)) + count_sub(b"FileAttachment"),
Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(),
Hidden::FormFields => pdfcraft_forms::fields(doc).len(),
- Hidden::HiddenText => content_pass(&mut doc2, true, false, false).unwrap_or(0),
+ Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0),
Hidden::HiddenLayers => {
let off = hidden_layers(doc).len();
- if off == 0 { 0 } else { off + content_pass(&mut doc2, false, true, false).unwrap_or(0) }
+ off + content_pass(&mut doc2, false, true, false).map(|(n, _)| n).unwrap_or(0)
}
Hidden::Bookmarks => sub(doc, &cat, b"Outlines").map_or(0, |o| outline_len(doc, o.get(b"First"), 0)),
Hidden::LinksActionsScripts => {
@@ -274,9 +280,11 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result 0) || (layers && count(Hidden::HiddenLayers) > 0) {
- content_pass(doc, text, layers, true)?;
- }
+ let retained_off_layers = if (text && count(Hidden::HiddenText) > 0) || (layers && count(Hidden::HiddenLayers) > 0) {
+ content_pass(doc, text, layers, true)?.1
+ } else {
+ std::collections::HashSet::new()
+ };
// Every annotation the pages list now: whatever is no longer listed at the end (flattened
// widgets included) leaves the structure tree too.
let listed = |doc: &Document| -> std::collections::HashSet {
@@ -394,7 +402,7 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result {
// The off layers leave the configuration (their content is gone already).
if let Some(mut oc) = sub(doc, &cat, b"OCProperties") {
- let keep = |o: &Object| !o.as_ref().is_some_and(|r| off.contains(&r));
+ let keep = |o: &Object| !o.as_ref().is_some_and(|r| off.contains(&r) && !retained_off_layers.contains(&r));
if let Some(Object::Array(a)) = oc.get(b"OCGs").map(|g| (*doc.resolve(g)).clone()) {
oc.set(b"OCGs".to_vec(), Object::Array(a.into_iter().filter(keep).collect()));
}
diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs
index da1ecf12ab..8bc5ec1418 100644
--- a/crates/redact/src/tests.rs
+++ b/crates/redact/src/tests.rs
@@ -492,6 +492,420 @@ fn a_hidden_layers_image_does_not_survive_through_inherited_resources() {
assert!(!bytes.windows(16).any(|w| w == b"HIDDENLAYERIMAGE"), "the off layer's image survived the save");
}
+#[test]
+fn hidden_ocmd_all_on_content_is_removed_before_its_off_group_is_unlinked() {
+ use crate::sanitize::{Hidden, remove_hidden};
+
+ let mut doc = one_page(
+ b"BT /F1 10 Tf 10 200 Td (PUBLIC) Tj ET /OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC",
+ "/Properties << /M 9 0 R >>",
+ vec![
+ b"<< /Type /OCG /Name (On) >>".to_vec(),
+ b"<< /Type /OCG /Name (Off) >>".to_vec(),
+ b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(),
+ ],
+ );
+ let root = doc.root().unwrap();
+ doc.update_dict(root, |d| {
+ d.set(
+ b"OCProperties".to_vec(),
+ Object::Dict(pdfcraft_cos::Dict::from_iter([
+ (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
+ (
+ b"D".to_vec(),
+ Object::Dict(pdfcraft_cos::Dict::from_iter([
+ (b"Order".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
+ (b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))])),
+ ])),
+ ),
+ ])),
+ );
+ })
+ .unwrap();
+
+ let before = pdfcraft_edit::text_lines(&doc, 0).unwrap();
+ assert!(before.iter().any(|line| line.text.contains("PUBLIC")));
+ assert!(before.iter().any(|line| line.text.contains("SECRET")), "extractor sees source text before sanitization");
+ remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
+
+ let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ let lines = pdfcraft_edit::text_lines(&saved, 0).unwrap();
+ assert!(lines.iter().any(|line| line.text.contains("PUBLIC")), "visible content remains: {lines:?}");
+ assert!(!lines.iter().any(|line| line.text.contains("SECRET")), "hidden text is extractable: {lines:?}");
+ assert!(!write_full(&saved, &SaveOptions::default()).unwrap().windows(6).any(|w| w == b"SECRET"));
+}
+
+#[test]
+fn hidden_ocmd_content_in_a_form_and_nested_marked_blocks_is_removed() {
+ use crate::sanitize::{Hidden, remove_hidden};
+
+ let mut doc = one_page(
+ b"/Fm Do",
+ "/Properties << /M 9 0 R >> /XObject << /Fm 10 0 R >>",
+ vec![
+ b"<< /Type /OCG /Name (On) >>".to_vec(),
+ b"<< /Type /OCG /Name (Off) >>".to_vec(),
+ b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(),
+ stream(
+ "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> /Properties << /M 9 0 R >> >>",
+ b"/OC /M BDC /OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC EMC",
+ ),
+ ],
+ );
+ let root = doc.root().unwrap();
+ doc.update_dict(root, |d| {
+ d.set(
+ b"OCProperties".to_vec(),
+ Object::Dict(Dict::from_iter([
+ (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
+ (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))),
+ ])),
+ );
+ })
+ .unwrap();
+
+ remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
+ let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET")));
+}
+
+#[test]
+fn hidden_ocmd_alloff_is_sanitized_when_every_group_is_on() {
+ use crate::sanitize::{Hidden, remove_hidden, scan};
+
+ let mut doc = one_page(
+ b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC",
+ "/Properties << /M 9 0 R >>",
+ vec![
+ b"<< /Type /OCG /Name (On) >>".to_vec(),
+ b"<< /Type /OCG /Name (Unused) >>".to_vec(),
+ b"<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>".to_vec(),
+ ],
+ );
+ let counts = scan(&doc);
+ assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}");
+ remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
+
+ let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET")));
+}
+
+#[test]
+fn hidden_ve_not_is_sanitized_when_its_group_is_on() {
+ use crate::sanitize::{Hidden, remove_hidden, scan};
+
+ let mut doc = one_page(
+ b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC",
+ "/Properties << /M 9 0 R >>",
+ vec![b"<< /Type /OCG /Name (On) >>".to_vec(), b"<< /Type /OCG /Name (Unused) >>".to_vec(), b"<< /Type /OCMD /VE [/Not 7 0 R] >>".to_vec()],
+ );
+ let counts = scan(&doc);
+ assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}");
+ remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
+
+ let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET")));
+}
+
+fn all_on_ocmd_xobject(subtype: &str, policy: &str) -> Document {
+ let membership = match policy {
+ "AllOff" => "<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>",
+ "Not" => "<< /Type /OCMD /VE [/Not 7 0 R] >>",
+ _ => "<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>",
+ };
+ let (object_number, oc) = if policy == "DirectAllOff" { (8, membership) } else { (9, "8 0 R") };
+ let xobject = match subtype {
+ "Image" => stream(
+ &format!("/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /OC {oc}"),
+ b"PRIVATE_HIDDEN_XOBJECT_IMAGE",
+ ),
+ _ => stream(
+ &format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >> /OC {oc}"),
+ b"BT /F1 10 Tf 10 100 Td (PRIVATE_HIDDEN_XOBJECT_FORM) Tj ET",
+ ),
+ };
+ let mut extras = vec![b"<< /Type /OCG /Name (On) >>".to_vec()];
+ if object_number == 9 {
+ extras.push(membership.as_bytes().to_vec());
+ }
+ extras.push(xobject);
+ let mut doc = one_page(b"/X Do", &format!("/XObject << /X {object_number} 0 R >>"), extras);
+ let root = doc.root().unwrap();
+ doc.update_dict(root, |d| {
+ d.set(
+ b"OCProperties".to_vec(),
+ Object::Dict(Dict::from_iter([
+ (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0))])),
+ (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"BaseState".to_vec(), Object::name("ON"))]))),
+ ])),
+ );
+ })
+ .unwrap();
+ doc
+}
+
+#[test]
+fn hidden_all_on_ocmd_image_and_form_xobjects_are_counted_and_removed() {
+ use crate::sanitize::{Hidden, remove_hidden, scan};
+
+ let mut failures = Vec::new();
+ for subtype in ["Image", "Form"] {
+ for policy in ["AllOff", "Not"] {
+ let mut doc = all_on_ocmd_xobject(subtype, policy);
+ let before = scan(&doc);
+ let hidden_count = before.iter().find(|(h, _)| *h == Hidden::HiddenLayers).map_or(0, |(_, n)| *n);
+ if hidden_count == 0 {
+ failures.push(format!("{subtype} /{policy} was not counted: {before:?}"));
+ }
+
+ match remove_hidden(&mut doc, &[Hidden::HiddenLayers]) {
+ Ok(removed) if removed.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0) => {}
+ Ok(removed) => failures.push(format!("{subtype} /{policy} remove report is empty: {removed:?}")),
+ Err(error) => failures.push(format!("{subtype} /{policy} remove failed: {error}")),
+ }
+ let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
+ if bytes.windows(b"PRIVATE_HIDDEN_XOBJECT_IMAGE".len()).any(|w| w == b"PRIVATE_HIDDEN_XOBJECT_IMAGE") {
+ failures.push(format!("{subtype} /{policy} image payload remains in full save"));
+ }
+ if bytes.windows(b"PRIVATE_HIDDEN_XOBJECT_FORM".len()).any(|w| w == b"PRIVATE_HIDDEN_XOBJECT_FORM") {
+ failures.push(format!("{subtype} /{policy} Form payload remains in full save"));
+ }
+ let saved = Document::open(Arc::new(bytes)).unwrap();
+ if pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("PRIVATE_HIDDEN_XOBJECT_FORM")) {
+ failures.push(format!("{subtype} /{policy} Form text remains extractable"));
+ }
+ if !pdfcraft_edit::page_images(&saved, 0).unwrap().is_empty() {
+ failures.push(format!("{subtype} /{policy} image remains on the page"));
+ }
+ }
+ }
+ assert!(failures.is_empty(), "{}", failures.join("\n"));
+}
+
+#[test]
+fn repeated_image_with_direct_alloff_membership_is_counted_per_draw() {
+ use crate::sanitize::{Hidden, scan};
+
+ let mut doc = all_on_ocmd_xobject("Image", "DirectAllOff");
+ let page = pdfcraft_model::pages(&doc)[0].obj;
+ let contents = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"/X Do /X Do /X Do")));
+ doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(contents))).unwrap();
+
+ let original = write_full(&doc, &SaveOptions::default()).unwrap();
+ let counts = scan(&doc);
+ assert_eq!(counts.iter().find(|(h, _)| *h == Hidden::HiddenLayers).map(|(_, n)| *n), Some(3), "{counts:?}");
+ assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), original, "Verify scan must not mutate the document");
+}
+
+#[test]
+fn undecodable_form_is_counted_and_removed_by_hidden_layer_sanitization() {
+ use crate::sanitize::{Hidden, remove_hidden, scan};
+
+ let mut doc = one_page(
+ b"/Bad Do",
+ "/XObject << /Bad 7 0 R >>",
+ vec![stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /UnknownFilter", b"PRIVATE_UNDECODABLE_FORM")],
+ );
+ let counts = scan(&doc);
+ assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}");
+ remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
+
+ let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
+ assert!(!bytes.windows(b"PRIVATE_UNDECODABLE_FORM".len()).any(|w| w == b"PRIVATE_UNDECODABLE_FORM"));
+}
+
+#[test]
+fn form_chain_at_max_depth_is_counted_and_removed_by_hidden_layer_sanitization() {
+ use crate::sanitize::{Hidden, remove_hidden, scan};
+
+ const PRIVATE: &[u8] = b"PRIVATE_FORM_BEYOND_MAX_DEPTH";
+ // MAX_DEPTH is 12. The final Form is encountered at that depth and is removed
+ // conservatively without being entered. Verify must count that removal or sanitize
+ // can skip the HiddenLayers pass entirely.
+ let mut forms = Vec::new();
+ for depth in 0..=12 {
+ let content = if depth == 12 {
+ b"BT /F1 10 Tf 10 100 Td (PRIVATE_FORM_BEYOND_MAX_DEPTH) Tj ET".to_vec()
+ } else {
+ format!("/F{} Do", depth + 1).into_bytes()
+ };
+ forms.push(stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", &content));
+ }
+ let xobjects = (0..=12).map(|depth| format!("/F{depth} {} 0 R", 7 + depth)).collect::>().join(" ");
+ let mut doc = one_page(b"/F0 Do", &format!("/XObject << {xobjects} >>"), forms);
+ assert!(
+ write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE),
+ "fixture lacked nested payload before sanitization"
+ );
+
+ let counts = scan(&doc);
+ assert!(counts.iter().any(|(hidden, count)| *hidden == Hidden::HiddenLayers && *count > 0), "depth-limited Form was not counted: {counts:?}");
+ let removed = remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
+ assert!(
+ removed.iter().any(|(hidden, count)| *hidden == Hidden::HiddenLayers && *count > 0),
+ "depth-limited Form was not reported removed: {removed:?}"
+ );
+ let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
+ assert!(!bytes.windows(PRIVATE.len()).any(|w| w == PRIVATE), "depth-limited Form payload remains in full save");
+}
+
+#[test]
+fn hidden_ocmd_resolves_an_indirect_policy_name() {
+ use crate::sanitize::{Hidden, remove_hidden};
+
+ let mut doc = one_page(
+ b"/OC /M BDC BT /F1 10 Tf 10 100 Td (VISIBLE) Tj ET EMC",
+ "/Properties << /M 9 0 R >>",
+ vec![
+ b"<< /Type /OCG /Name (On) >>".to_vec(),
+ b"<< /Type /OCG /Name (Off) >>".to_vec(),
+ b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P 12 0 R >>".to_vec(),
+ ],
+ );
+ doc.set(ObjRef::new(12, 0), Object::name("AnyOn"));
+ let root = doc.root().unwrap();
+ doc.update_dict(root, |d| {
+ d.set(
+ b"OCProperties".to_vec(),
+ Object::Dict(Dict::from_iter([
+ (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
+ (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))),
+ ])),
+ );
+ })
+ .unwrap();
+
+ remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
+ let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ assert!(pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("VISIBLE")));
+}
+
+#[test]
+fn surviving_anyoff_and_not_content_keeps_its_off_group_state() {
+ use crate::sanitize::{Hidden, remove_hidden};
+
+ for use_not in [false, true] {
+ let mut doc = one_page(
+ b"/OC /M BDC BT /F1 10 Tf 10 100 Td (VISIBLE) Tj ET EMC",
+ "/Properties << /M 9 0 R >>",
+ vec![
+ b"<< /Type /OCG /Name (On) >>".to_vec(),
+ b"<< /Type /OCG /Name (Off) >>".to_vec(),
+ if use_not { b"<< /Type /OCMD /VE [/Not 8 0 R] >>".to_vec() } else { b"<< /Type /OCMD /OCGs [8 0 R] /P /AnyOff >>".to_vec() },
+ ],
+ );
+ let root = doc.root().unwrap();
+ doc.update_dict(root, |d| {
+ d.set(
+ b"OCProperties".to_vec(),
+ Object::Dict(Dict::from_iter([
+ (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
+ (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))),
+ ])),
+ );
+ })
+ .unwrap();
+
+ remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
+ let root = doc.root().unwrap();
+ let catalog = doc.get(root);
+ let oc = catalog.as_dict().and_then(|d| d.get(b"OCProperties")).map(|o| doc.resolve(o));
+ let default = oc.as_deref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"D")).map(|o| doc.resolve(o));
+ let off = default.as_deref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"OFF")).map(|o| doc.resolve(o));
+ assert!(
+ off.as_deref().and_then(Object::as_array).is_some_and(|a| a.iter().any(|o| o.as_ref() == Some(ObjRef::new(8, 0)))),
+ "off group lost for /VE Not={use_not}"
+ );
+ let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ assert!(pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("VISIBLE")));
+ }
+}
+
+#[test]
+fn hidden_ocmd_policy_matches_the_default_configuration() {
+ use crate::sanitize::{Hidden, remove_hidden};
+
+ let cases = [
+ (None, false, None, false),
+ (Some("AnyOn"), false, None, false),
+ (Some("AllOn"), false, None, true),
+ (Some("AnyOff"), false, None, false),
+ (Some("AllOff"), false, None, true),
+ (Some("UnknownPolicy"), false, None, true),
+ (None, true, None, true),
+ (None, false, Some("and"), true),
+ (None, false, Some("and_single_not"), true),
+ (None, false, Some("and_single_off"), true),
+ (None, false, Some("or_single_on"), false),
+ (None, false, Some("or_single_off"), true),
+ (Some("AllOn"), false, Some("not_off"), false),
+ (None, false, Some("malformed_not"), true),
+ (None, false, Some("malformed_without_ocgs"), true),
+ ];
+ for (policy, malformed_policy, visibility, should_remove) in cases {
+ let mut doc = one_page(
+ b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC",
+ "/Properties << /M 9 0 R >>",
+ vec![
+ b"<< /Type /OCG /Name (On) >>".to_vec(),
+ b"<< /Type /OCG /Name (Off) >>".to_vec(),
+ b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(),
+ ],
+ );
+ let mut membership = Dict::new();
+ membership.set(b"Type".to_vec(), Object::name("OCMD"));
+ if visibility != Some("malformed_without_ocgs") {
+ membership.set(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))]));
+ }
+ if let Some(policy) = policy {
+ membership.set(b"P".to_vec(), Object::name(policy));
+ } else if malformed_policy {
+ membership.set(b"P".to_vec(), Object::Int(17));
+ }
+ match visibility {
+ Some("and") => membership
+ .set(b"VE".to_vec(), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
+ Some("and_single_not") => membership.set(
+ b"VE".to_vec(),
+ Object::Array(vec![Object::name("Not"), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(7, 0))])]),
+ ),
+ Some("and_single_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(8, 0))])),
+ Some("or_single_on") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Or"), Object::Ref(ObjRef::new(7, 0))])),
+ Some("or_single_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Or"), Object::Ref(ObjRef::new(8, 0))])),
+ Some("not_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(ObjRef::new(8, 0))])),
+ Some("malformed_not") => membership
+ .set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
+ Some("malformed_without_ocgs") => {
+ membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("UnknownOperator"), Object::Ref(ObjRef::new(8, 0))]))
+ }
+ _ => {}
+ }
+ doc.set(ObjRef::new(9, 0), Object::Dict(membership));
+ let root = doc.root().unwrap();
+ doc.update_dict(root, |d| {
+ d.set(
+ b"OCProperties".to_vec(),
+ Object::Dict(Dict::from_iter([
+ (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
+ (
+ b"D".to_vec(),
+ Object::Dict(Dict::from_iter([
+ (b"Order".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
+ (b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))])),
+ ])),
+ ),
+ ])),
+ );
+ })
+ .unwrap();
+
+ remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
+ let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
+ let has_secret = pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET"));
+ assert_eq!(has_secret, !should_remove, "policy {policy:?}, malformed={malformed_policy}, VE={visibility:?}");
+ }
+}
+
#[test]
fn redacting_an_xfa_field_drops_the_xfa_packet_so_its_value_cannot_survive() {
// A static AcroForm+XFA form whose field value lives both in the widget /V and in the XFA
diff --git a/crates/xfdf/README.md b/crates/xfdf/README.md
index f1ca8ac001..c137aabd93 100644
--- a/crates/xfdf/README.md
+++ b/crates/xfdf/README.md
@@ -4,7 +4,7 @@ Layer L4: comment and form data exchange (execution plan M5.6, M6.7).
```rust
let xml = export_xfdf(&doc, true, true, "form.pdf"); // comments and/or field values
-let fdf = export_fdf(&doc, false, true, "form.pdf");
+let fdf = export_fdf(&doc, false, true, "form.pdf")?;
let txt = export_data(&doc, Format::Txt); // or Xml, Csv
let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV or text, detected
```
@@ -14,7 +14,21 @@ let report = import(&mut doc, &bytes)?; // XFDF, FDF, XML, CSV o
name, author, subject, dates, flags, colours, opacity, width, icon, quads, line ends, ink,
default appearance, callouts (`callout`, `fringe`, `intent`, `head`), pop-ups and reply threads
(`inreplyto`); field values nested by name, with text preserved across XML comments and processing instructions.
-- **FDF**: fields (with `/Kids` hierarchies) and comments as direct objects.
+- **FDF**: fields (with `/Kids` hierarchies) and indirect comments. Nested annotation values
+ are copied under FDF object numbers; shared references and cycles remain shared. Document
+ links and appearances are excluded. Source `/Page` and `/Pages` dictionaries become null
+ at every copy depth, so nested destination actions cannot carry the source page tree or content.
+ Type names may be indirect; lookup checks each reference generation, is iterative, stops at
+ cycles and refuses chains beyond
+ 64 references. Discovery borrows direct arrays and dictionaries,
+ retains array owners lazily and records annotation positions instead of resolved bodies.
+ Retained entries are budgeted before their keys or values are cloned, including when
+ saved-PDF cache eviction causes annotation bodies to be parsed again. Export returns
+ `Result` and refuses copied graphs above 100,000 comments/objects/values, 64 direct nesting
+ levels or a conservative 64 MiB serialization budget instead of exporting partial data.
+ These are copied-graph limits: source parsing and page discovery retain their existing
+ COS/parser limits, with no total source-memory bound. Unknown indirect streams retain their
+ encoded bytes. FDF import semantics are unchanged.
- **Form data**: Acrobat's XML (`xfdf:original` keeps names that aren't XML names), CSV and
tab-delimited text (a row of names, a row of values).
diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs
index ef5a30a90f..35bc781541 100644
--- a/crates/xfdf/src/lib.rs
+++ b/crates/xfdf/src/lib.rs
@@ -11,7 +11,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
use std::fmt::Write as _;
use std::io::Write as _;
@@ -29,6 +29,8 @@ pub enum DataError {
Malformed(String),
#[error("nothing in the file matches this document")]
NothingImported,
+ #[error("FDF export exceeds the {0} limit; no data was exported")]
+ ExportLimit(&'static str),
#[error(transparent)]
Cos(#[from] pdfcraft_cos::CosError),
}
@@ -467,19 +469,223 @@ fn pdf_string(s: &str) -> Vec {
v
}
+// Keep aliases as aliases instead of expanding a shared graph once per use. References are
+// queued, not followed recursively, so cycles and long indirect chains cost linear work.
+const MAX_FDF_OBJECTS: usize = 100_000;
+const MAX_FDF_BYTES: usize = 64 * 1024 * 1024;
+
+/// Direct values stay borrowed: `Document::resolve` would deep-clone them. Only
+/// indirect values need an owner, and annotation bodies are never retained in discovery.
+enum FdfValue<'a> {
+ Borrowed(&'a Object),
+ Indirect(std::sync::Arc