From cfb71d850e99a41cd47e0c87e2f8449b8abf6a9e Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Thu, 15 Oct 2026 23:57:29 -0400
Subject: [PATCH 1/7] ci: switch to shared nix workflow from
craft-common-actions
---
.github/workflows/nix.yml | 16 +++-------------
1 file changed, 3 insertions(+), 13 deletions(-)
diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml
index 18392587a8..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@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- - 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
From ec0a965074f376a5bf610435948e4e5184bf3299 Mon Sep 17 00:00:00 2001
From: Mex <44814932+MMexicann@users.noreply.github.com>
Date: Mon, 19 Oct 2026 20:21:37 +0200
Subject: [PATCH 2/7] M5.6: preserve nested annotation values in FDF exports
(#1971)
* M5.6: preserve nested annotation values in FDF exports
Copy and renumber retained object graphs without expanding aliases or following
indirect cycles recursively. Refuse excessive work and propagate export errors
before the destination is written. Cover saved geometry, colour and borders,
shared graphs, streams, stale references and refusal through automation tools.
Co-Authored-By: Codex
* Bound FDF annotation copying before allocation
Keep selected aliases shared, budget retained keys and values before copying, and add direct-payload and alias-count regressions. Retain copied-graph limits and document source-memory scope.
* M5.6: bound FDF cache-eviction discovery and field values
Extend the concurrent annotation-budget correction while retaining its direct-payload and alias regressions. Record annotation positions instead of resolved bodies so cache eviction cannot pin repeated parsed copies; borrow direct values and budget retained entries before cloning. Count every emitted multi-select field value before allocating its output array.
Observed pre-fix allocation-cap and field-limit failures, then fixed tests passed. Final validation: 549 scoped XFDF/engine/automation tests passed, three existing ignored; strict all-targets Clippy, WASM, fmt, layers, assets, parity and diff checks passed.
AI assistance: OpenAI Codex for @MMexicann.
* M5.6: record bounded FDF export regression evidence
Record comment cache-eviction discovery and emitted-field-value budget regressions without changing feature scores or status. Describe copied-graph limits and existing source-parser limits precisely. Final 549 scoped tests, strict Clippy, WASM, fmt, layers, assets and parity passed.
AI assistance: OpenAI Codex for @MMexicann.
* M5.6: align FDF progress with budgeted discovery
Document lazy annotation positions, cache-eviction coverage and copied-graph limits while retaining the original nested-value progress entry and all upstream notes. Estimates unchanged.
AI assistance: OpenAI Codex for @MMexicann.
* M5.6: describe FDF discovery and copied-graph limits
Align exchange documentation with the tested lazy-position discovery implementation and its copied-graph limits. Source parsing/page discovery keep existing limits.
AI assistance: OpenAI Codex for @MMexicann.
* M5.6: align milestone notes with FDF discovery limits
Retain upstream milestone history and describe the tested discovery/copy budgets without increasing estimates or claiming a total source-memory bound.
AI assistance: OpenAI Codex for @MMexicann.
* M5.6: keep nested page links out of FDF exports
Preserve graph aliases and existing budgets; exclude direct and indirect Page/Pages type names. Add saved-PDF regression and bounded type-resolution controls. Both pre-fix failures observed; 46 scoped tests, strict Clippy, WASM and formatting passed.
* M5.6: document bounded nested page exclusion
Address maintainer feedback on the existing PR. Preserve upstream work and document the tested scope and remaining validation limits.
* M5.6: record nested page-reference regression evidence
Address maintainer feedback on the existing PR. Preserve upstream work and document the tested scope and remaining validation limits.
* M5.6: retain FDF limits in milestone evidence
Address maintainer feedback on the existing PR. Preserve upstream work and document the tested scope and remaining validation limits.
* M5.6: cite nested page-reference exclusion tests
Address maintainer feedback on the existing PR. Preserve upstream work and document the tested scope and remaining validation limits.
* M5.6: respect generations while identifying page references
Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits.
* XFDF: document stale Type preservation regression
Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits.
* M5.6: record generation-aware page exclusion evidence
Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits.
* M5.6: propagate FDF export errors in usage example
Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits.
* M5.6: consolidate roadmap change and target fields
Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits.
---------
Co-authored-by: Codex
Co-authored-by: skylar taylor-barrick
---
ROADMAP.md | 8 +
crates/automation/tests/automation.rs | 59 ++++
crates/engine/src/lib.rs | 2 +-
crates/xfdf/README.md | 18 +-
crates/xfdf/src/lib.rs | 292 +++++++++++++++++---
crates/xfdf/src/tests.rs | 374 +++++++++++++++++++++++++-
docs/roadmap.md | 5 +-
parity/acrobat-features.toml | 8 +-
8 files changed, 716 insertions(+), 50 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index f4d54ccea5..b1283e7751 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.
@@ -546,6 +552,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/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
-
-
-
-
+
+
+
+
+
+
+
From b77d57000c22c760f731206f6cf14486d149de72 Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Tue, 20 Oct 2026 11:38:32 -0400
Subject: [PATCH 5/7] Release 0.10.1: rebuild with the shared workflow's exact
Linux tarball pick
0.10.0's Arch Linux and Flatpak aarch64 jobs picked up the
pdfcraft-cli tarball (storytold/craft-common-actions#35).
Co-Authored-By: Claude Opus 5.5
---
Cargo.lock | 72 +++++++++++++++++++++++++++---------------------------
Cargo.toml | 68 +++++++++++++++++++++++++--------------------------
2 files changed, 70 insertions(+), 70 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index d56c63a31f..2399531ef4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3938,7 +3938,7 @@ dependencies = [
[[package]]
name = "pdfcraft"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"craft-quit",
"eframe",
@@ -3963,7 +3963,7 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3975,7 +3975,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -3986,7 +3986,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3997,7 +3997,7 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"base64 0.22.1",
"flate2",
@@ -4018,7 +4018,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cli"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"base64 0.22.1",
"pdfcraft-audit",
@@ -4035,18 +4035,18 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
-version = "0.10.0"
+version = "0.10.1"
[[package]]
name = "pdfcraft-content"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-cos"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"hayro-syntax",
"pdfcraft-crypt",
@@ -4060,7 +4060,7 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"encoding_rs",
"hayro-syntax",
@@ -4076,7 +4076,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"aes",
"md-5",
@@ -4088,7 +4088,7 @@ dependencies = [
[[package]]
name = "pdfcraft-edit"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"hayro-syntax",
"kurbo 0.13.1",
@@ -4103,7 +4103,7 @@ dependencies = [
[[package]]
name = "pdfcraft-engine"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"chrono",
"craft-docpath",
@@ -4143,14 +4143,14 @@ dependencies = [
[[package]]
name = "pdfcraft-export"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"flate2",
"proptest",
@@ -4160,7 +4160,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -4170,7 +4170,7 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4182,18 +4182,18 @@ dependencies = [
[[package]]
name = "pdfcraft-geom"
-version = "0.10.0"
+version = "0.10.1"
[[package]]
name = "pdfcraft-js"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"boa_engine",
]
[[package]]
name = "pdfcraft-measure"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4206,14 +4206,14 @@ dependencies = [
[[package]]
name = "pdfcraft-model"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"ocrs",
"pdfcraft-fonts",
@@ -4223,7 +4223,7 @@ dependencies = [
[[package]]
name = "pdfcraft-optimize"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"image",
"pdfcraft-annot",
@@ -4236,11 +4236,11 @@ dependencies = [
[[package]]
name = "pdfcraft-oracle"
-version = "0.10.0"
+version = "0.10.1"
[[package]]
name = "pdfcraft-organize"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4251,7 +4251,7 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"async-channel",
"futures-lite",
@@ -4261,7 +4261,7 @@ dependencies = [
[[package]]
name = "pdfcraft-preflight"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"flate2",
"pdfcraft-content",
@@ -4272,7 +4272,7 @@ dependencies = [
[[package]]
name = "pdfcraft-print"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"craft-print",
"pdfcraft-content",
@@ -4283,7 +4283,7 @@ dependencies = [
[[package]]
name = "pdfcraft-redact"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4299,7 +4299,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"bytemuck",
"flate2",
@@ -4318,7 +4318,7 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"aes",
"aws-lc-rs",
@@ -4352,11 +4352,11 @@ dependencies = [
[[package]]
name = "pdfcraft-testkit"
-version = "0.10.0"
+version = "0.10.1"
[[package]]
name = "pdfcraft-ui-egui"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"arboard",
"base64 0.22.1",
@@ -4400,7 +4400,7 @@ dependencies = [
[[package]]
name = "pdfcraft-web"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"eframe",
"egui",
@@ -4412,7 +4412,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"base64 0.22.1",
"image",
@@ -4426,7 +4426,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfdf"
-version = "0.10.0"
+version = "0.10.1"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
diff --git a/Cargo.toml b/Cargo.toml
index 6a3e2aacd2..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.10.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.10.0" }
-pdfcraft-filters = { path = "crates/filters", version = "0.10.0" }
-pdfcraft-cos = { path = "crates/cos", version = "0.10.0" }
-pdfcraft-crypt = { path = "crates/crypt", version = "0.10.0" }
-pdfcraft-organize = { path = "crates/organize", version = "0.10.0" }
-pdfcraft-measure = { path = "crates/measure", version = "0.10.0" }
-pdfcraft-annot = { path = "crates/annot", version = "0.10.0" }
-pdfcraft-audit = { path = "crates/audit", version = "0.10.0" }
-pdfcraft-fonts = { path = "crates/fonts", version = "0.10.0" }
-pdfcraft-model = { path = "crates/model", version = "0.10.0" }
-pdfcraft-content = { path = "crates/content", version = "0.10.0" }
-pdfcraft-redact = { path = "crates/redact", version = "0.10.0" }
-pdfcraft-print = { path = "crates/print", version = "0.10.0" }
-pdfcraft-xfdf = { path = "crates/xfdf", version = "0.10.0" }
-pdfcraft-sign = { path = "crates/sign", version = "0.10.0" }
-pdfcraft-optimize = { path = "crates/optimize", version = "0.10.0" }
-pdfcraft-a11y = { path = "crates/a11y", version = "0.10.0" }
-pdfcraft-oracle = { path = "crates/oracle", version = "0.10.0" }
-pdfcraft-testkit = { path = "crates/testkit", version = "0.10.0" }
-pdfcraft-ocr = { path = "crates/ocr", version = "0.10.0" }
-pdfcraft-js = { path = "crates/js", version = "0.10.0" }
-pdfcraft-compare = { path = "crates/compare", version = "0.10.0" }
-pdfcraft-preflight = { path = "crates/preflight", version = "0.10.0" }
-pdfcraft-export = { path = "crates/export", version = "0.10.0" }
-pdfcraft-edit = { path = "crates/edit", version = "0.10.0" }
-pdfcraft-create = { path = "crates/create", version = "0.10.0" }
-pdfcraft-forms = { path = "crates/forms", version = "0.10.0" }
-pdfcraft-xfa = { path = "crates/xfa", version = "0.10.0" }
-pdfcraft-render = { path = "crates/render", version = "0.10.0" }
-pdfcraft-engine = { path = "crates/engine", version = "0.10.0" }
-pdfcraft-platform = { path = "crates/platform", version = "0.10.0" }
-pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.10.0" }
-pdfcraft-automation = { path = "crates/automation", version = "0.10.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"
From 22bedbf2af25bd4cdc1c188b5eff6ec3d2d089fb Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Tue, 20 Oct 2026 11:38:59 -0400
Subject: [PATCH 6/7] Skip CodeRabbit reviews on Dependabot PRs (#3692)
Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot].
Co-authored-by: Claude Opus 5.5
---
.coderabbit.yaml | 6 ++++++
1 file changed, 6 insertions(+)
create mode 100644 .coderabbit.yaml
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]"
From d695bc56545944fe9c7181901c61d0bf1c11c887 Mon Sep 17 00:00:00 2001
From: skylar taylor-barrick
Date: Tue, 20 Oct 2026 18:17:34 -0600
Subject: [PATCH 7/7] fix(redact): evaluate OCMD visibility during sanitization
(#3522)
* fix(redact): evaluate OCMD visibility during sanitization
Fixes #3499 by evaluating OCMD policies and bounded visibility expressions against the default optional-content configuration before removing hidden groups. Unknown or malformed visibility data is handled conservatively.
* fix(redact): preserve OCMD visibility during sanitization
* fix(redact): count hidden XObjects during verification
* test(redact): cover hidden form depth limit
---
crates/redact/src/interp.rs | 251 +++++++++++++++++++--
crates/redact/src/sanitize.rs | 32 ++-
crates/redact/src/tests.rs | 414 ++++++++++++++++++++++++++++++++++
3 files changed, 667 insertions(+), 30 deletions(-)
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