fix(redact): Remove Hidden Information reads only what the chosen categories need (part of #5873) (#6021)

This commit is contained in:
ljkx authored and GitHub committed 2026-11-09 18:01:36 +08:00
1 parent 25025fa271
commit 930b441312
6 files changed
+277 -55

No files matched your search

+10 -4
View File
@@ -2,7 +2,7 @@
//! (removing what they cover for good) or clear them. //! (removing what they cover for good) or clear them.
use pdfcraft_engine::{ use pdfcraft_engine::{
Edit, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACT_VALIDATORS, REDACTION_CODE_SETS, RedactPattern, RedactRegex, RedactValidator, Edit, HIDDEN, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACT_VALIDATORS, REDACTION_CODE_SETS, RedactPattern, RedactRegex, RedactValidator,
RedactionCodeSet, Shape, Style, find_pattern, rect_quad, redact_word_list, RedactionCodeSet, Shape, Style, find_pattern, rect_quad, redact_word_list,
}; };
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -235,6 +235,9 @@ impl Automation {
Ok(json!({ "findings": items, "count": items.len() })) Ok(json!({ "findings": items, "count": items.len() }))
} }
/// `doc_remove_hidden`: remove the listed categories of hidden information, or all of them
/// (Sanitize Document) when none are listed, and report how many items the listed
/// categories lost as `removed`.
pub(crate) fn doc_remove_hidden(&mut self, a: &Args) -> Result<Value> { pub(crate) fn doc_remove_hidden(&mut self, a: &Args) -> Result<Value> {
let which = match a.get("categories") { let which = match a.get("categories") {
None => None, None => None,
@@ -253,8 +256,11 @@ impl Automation {
// One scan, for this check and for what was removed. The removal refuses when it counts // One scan, for this check and for what was removed. The removal refuses when it counts
// every listed category as 0 (an AcroForm with no fields counts 1), with an error about // every listed category as 0 (an AcroForm with no fields counts 1), with an error about
// redaction marks; this reads the same scan, so it refuses exactly then. A document that // redaction marks; this reads the same scan, so it refuses exactly then. A document that
// can't be edited has no scan: `apply` says why. // can't be edited has no scan: `apply` says why. Only the listed categories are counted,
let info = self.doc(a)?.hidden_info(); // here and after: the others would read every page's content and every object for
// nothing (#5873).
let listed = which.clone().unwrap_or_else(|| HIDDEN.to_vec());
let info = self.doc(a)?.hidden_info_of(&listed);
let edit = match which { let edit = match which {
None => Edit::Sanitize, None => Edit::Sanitize,
Some(which) => { Some(which) => {
@@ -266,7 +272,7 @@ impl Automation {
}; };
let before: usize = info.iter().map(|c| c.1).sum(); let before: usize = info.iter().map(|c| c.1).sum();
let mut out = self.apply(a, edit)?; let mut out = self.apply(a, edit)?;
let after: usize = self.doc(a)?.hidden_info().iter().map(|c| c.1).sum(); let after: usize = self.doc(a)?.hidden_info_of(&listed).iter().map(|c| c.1).sum();
out["removed"] = json!(before.saturating_sub(after)); out["removed"] = json!(before.saturating_sub(after));
Ok(out) Ok(out)
} }
+34
View File
@@ -5059,6 +5059,40 @@ fn removing_hidden_information_through_tools() {
assert!(matches!(&refused, Err(ToolError::Failed(m)) if m.contains("can't be edited")), "{refused:?}"); assert!(matches!(&refused, Err(ToolError::Failed(m)) if m.contains("can't be edited")), "{refused:?}");
} }
/// `removed` counts what went from the listed categories. Flattening a field whose appearance
/// draws invisible text puts that text on the page, where hidden text (not listed) counts it;
/// a count over every category took it off what was removed (#5873).
#[test]
fn removed_hidden_information_counts_the_listed_categories() {
let appearance = "BT /Helv 12 Tf 2 4 Td (Ada) Tj 3 Tr (Secret) Tj ET";
let appearance = format!(
"<< /Type /XObject /Subtype /Form /BBox [0 0 140 18] /Resources << /Font << /Helv 7 0 R >> >> /Length {} >>\nstream\n{appearance}\nendstream",
appearance.len()
);
let objs = [
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>",
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] /Resources << /Font << /Helv 7 0 R >> >> >>",
"<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada) /Rect [10 250 150 268] /P 3 0 R /AP << /N 6 0 R >> >>",
&appearance,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
];
let dir = workdir("hidden-listed");
std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
let count = |a: &mut Automation, c: &str| {
let info = ok(a, "doc_hidden_info", json!({ "doc": doc }));
info["categories"].as_array().unwrap().iter().find(|x| x["category"] == c).unwrap()["count"].as_u64().unwrap()
};
assert_eq!((count(&mut a, "form-fields"), count(&mut a, "hidden-text")), (1, 0));
let r = ok(&mut a, "doc_remove_hidden", json!({ "doc": doc, "categories": ["form-fields"] }));
assert_eq!(r["removed"], 1, "{r}");
assert_eq!(count(&mut a, "form-fields"), 0);
assert_eq!(count(&mut a, "hidden-text"), 6, "the flattened appearance's invisible word is page content now");
}
/// A one-page PDF whose text "TOP SECRET" sits under an opaque black rectangle. /// A one-page PDF whose text "TOP SECRET" sits under an opaque black rectangle.
fn faux_redacted_fixture() -> Vec<u8> { fn faux_redacted_fixture() -> Vec<u8> {
helvetica_page("BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f") helvetica_page("BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f")
+6
View File
@@ -606,6 +606,12 @@ impl Document {
self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default() self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default()
} }
/// [`Self::hidden_info`] for the categories in `which` alone, without the reading the others
/// need (see [`pdfcraft_redact::sanitize::scan_only`]).
pub fn hidden_info_of(&self, which: &[Hidden]) -> Vec<(Hidden, usize)> {
self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan_only(&e.cos, which)).unwrap_or_default()
}
/// Verification: faux redactions (opaque covers over extractable text) and redaction /// Verification: faux redactions (opaque covers over extractable text) and redaction
/// marks that were never applied. Fails closed: any read error is returned, never an /// marks that were never applied. Fails closed: any read error is returned, never an
/// empty list. /// empty list.
+115 -51
View File
@@ -79,6 +79,21 @@ impl Hidden {
} }
} }
#[cfg(test)]
thread_local! {
/// Passes over every page's content ([`content_pass`]) made on this thread.
static CONTENT_PASSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// Walks of the whole object graph for owned entries ([`owners`]) made by [`scan_only`] on
/// this thread.
static OWNER_WALKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// How many content passes and owner walks this thread has made, for tests to count the work.
#[cfg(test)]
pub(crate) fn passes() -> (usize, usize) {
(CONTENT_PASSES.with(std::cell::Cell::get), OWNER_WALKS.with(std::cell::Cell::get))
}
fn catalog(doc: &Document) -> Option<(ObjRef, Dict)> { fn catalog(doc: &Document) -> Option<(ObjRef, Dict)> {
let r = doc.root()?; let r = doc.root()?;
Some((r, doc.get(r).as_dict()?.clone())) Some((r, doc.get(r).as_dict()?.clone()))
@@ -565,6 +580,8 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
if !text && !layers { if !text && !layers {
return Ok((0, std::collections::HashSet::new())); return Ok((0, std::collections::HashSet::new()));
} }
#[cfg(test)]
CONTENT_PASSES.with(|n| n.set(n.get() + 1));
let mut total = 0; let mut total = 0;
let mut retained_off_layers = std::collections::HashSet::new(); let mut retained_off_layers = std::collections::HashSet::new();
// The original image and form XObjects the rewrite drops or replaces, on any page. // The original image and form XObjects the rewrite drops or replaces, on any page.
@@ -672,19 +689,40 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
/// How many items each category would remove (categories with nothing are included as 0). /// How many items each category would remove (categories with nothing are included as 0).
pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> {
scan_only(doc, &HIDDEN)
}
/// [`scan`] for the categories in `which` alone, in [`HIDDEN`] order, each once. A category's
/// count costs what it reads: hidden text and hidden layers read every page's content, and
/// metadata, attachments, links and private data walk every object the catalog reaches, so
/// counting bookmarks alone does neither.
pub fn scan_only(doc: &Document, which: &[Hidden]) -> Vec<(Hidden, usize)> {
let cat = catalog(doc).map(|c| c.1).unwrap_or_default(); let cat = catalog(doc).map(|c| c.1).unwrap_or_default();
let pages = pdfcraft_model::pages(doc); let annots_read = std::cell::OnceCell::<Vec<Dict>>::new();
let annots: Vec<Dict> = pages.iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| doc.resolve(&a).as_dict().cloned()).collect(); let annots = || {
let count_sub = |s: &[u8]| annots.iter().filter(|a| a.name(b"Subtype") == Some(s)).count(); annots_read.get_or_init(|| {
let names = sub(doc, &cat, b"Names").unwrap_or_default(); pdfcraft_model::pages(doc).iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| doc.resolve(&a).as_dict().cloned()).collect()
let owned_keys: Vec<&[u8]> = OWNED.iter().map(|o| o.1).collect(); })
// A graph nested too deeply to walk counts as holding everything, so the removal runs and };
// refuses it. let count_sub = |s: &[u8]| annots().iter().filter(|a| a.name(b"Subtype") == Some(s)).count();
let held = owners(doc, &owned_keys).map_or_else(|_| vec![1; OWNED.len()], |o| o.0); let names_read = std::cell::OnceCell::<Dict>::new();
let owned = |h: Hidden| OWNED.iter().zip(&held).filter(|(o, _)| o.0 == h).map(|(_, n)| n).sum::<usize>(); let names = || names_read.get_or_init(|| sub(doc, &cat, b"Names").unwrap_or_default());
let held = std::cell::OnceCell::<Vec<usize>>::new();
let owned = |h: Hidden| {
let held = held.get_or_init(|| {
#[cfg(test)]
OWNER_WALKS.with(|n| n.set(n.get() + 1));
let owned_keys: Vec<&[u8]> = OWNED.iter().map(|o| o.1).collect();
// A graph nested too deeply to walk counts as holding everything, so the removal
// runs and refuses it.
owners(doc, &owned_keys).map_or_else(|_| vec![1; OWNED.len()], |o| o.0)
});
OWNED.iter().zip(held).filter(|(o, _)| o.0 == h).map(|(_, n)| n).sum::<usize>()
};
let mut doc2 = doc.clone(); let mut doc2 = doc.clone();
HIDDEN HIDDEN
.into_iter() .into_iter()
.filter(|h| which.contains(h))
.map(|h| { .map(|h| {
let n = match h { let n = match h {
Hidden::Metadata => { Hidden::Metadata => {
@@ -692,11 +730,11 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> {
info + owned(Hidden::Metadata) info + owned(Hidden::Metadata)
} }
Hidden::Attachments => { Hidden::Attachments => {
sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t)) sub(doc, names(), b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t))
+ count_sub(b"FileAttachment") + count_sub(b"FileAttachment")
+ owned(Hidden::Attachments) + owned(Hidden::Attachments)
} }
Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(), Hidden::Comments => annots().iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(),
Hidden::FormFields => { Hidden::FormFields => {
let n = pdfcraft_forms::fields(doc).len(); let n = pdfcraft_forms::fields(doc).len();
if n > 0 { if n > 0 {
@@ -717,8 +755,8 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> {
count_sub(b"Link") count_sub(b"Link")
+ usize::from(cat.get(b"OpenAction").is_some_and(|a| doc.resolve(a).as_dict().is_some())) + usize::from(cat.get(b"OpenAction").is_some_and(|a| doc.resolve(a).as_dict().is_some()))
+ owned(Hidden::LinksActionsScripts) + owned(Hidden::LinksActionsScripts)
+ annots.iter().filter(|a| !a.contains(b"AA") && a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A")).count() + annots().iter().filter(|a| !a.contains(b"AA") && a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A")).count()
+ sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t)) + sub(doc, names(), b"JavaScript").map_or(0, |t| name_tree_len(doc, &t))
+ outline_action_count(doc, &cat) + outline_action_count(doc, &cat)
} }
Hidden::PrivateData => owned(Hidden::PrivateData), Hidden::PrivateData => owned(Hidden::PrivateData),
@@ -764,36 +802,15 @@ fn strip_outline_actions(doc: &mut Document, cat: &mut Dict) -> Result<(), Redac
/// sensitive content still present. Call this on a working clone and replace the original only /// sensitive content still present. Call this on a working clone and replace the original only
/// on success. On any error, discard the working clone; do not save or reuse it. /// on success. On any error, discard the working clone; do not save or reuse it.
pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden, usize)>, RedactError> { pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden, usize)>, RedactError> {
let counts = scan(doc); // Only what the chosen categories need is counted: a full scan reads every page's content
let count = |h: Hidden| counts.iter().find(|c| c.0 == h).map_or(0, |c| c.1); // twice and walks the whole object graph, which removing bookmarks alone doesn't need.
let (root, _) = catalog(doc).ok_or(RedactError::NothingToApply)?; let counts = scan_only(doc, which);
let mut done = Vec::new(); remove_counted(doc, which, &counts, false)
// Every annotation the pages list now: whatever is no longer listed at the end (flattened }
// widgets included) leaves the structure tree too.
let listed_before = listed_annotations(doc); /// Take off every page the annotations the chosen categories remove (with `fields`, the widgets
let has_acroform = doc.get(root).as_dict().is_some_and(|d| d.contains(b"AcroForm")); /// flattening kept), and the actions of those that stay when links, actions and scripts go.
let fields = which.contains(&Hidden::FormFields) && (count(Hidden::FormFields) > 0 || has_acroform); fn drop_page_annotations(doc: &mut Document, which: &[Hidden], fields: bool) -> Result<(), RedactError> {
if fields {
// Fields without appearances get one first, so their values stay visible.
for f in pdfcraft_forms::fields(doc) {
if f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))) {
pdfcraft_forms::redraw_field(doc, &f.name)?;
}
}
let n = pdfcraft_model::pages(doc).len();
pdfcraft_edit::flatten(doc, &(0..n).collect::<Vec<_>>(), false, true)?;
doc.update_dict(root, |d| {
d.remove(b"AcroForm");
})?;
}
// Content next (it needs the layers still in place). Flattened appearances are page content
// by now, so hidden text or layers inside them are removed with the rest.
let (text, layers) = (which.contains(&Hidden::HiddenText), which.contains(&Hidden::HiddenLayers));
let retained_off_layers = if (text && (fields || count(Hidden::HiddenText) > 0)) || (layers && (fields || count(Hidden::HiddenLayers) > 0)) {
content_pass(doc, text, layers, true)?.1
} else {
std::collections::HashSet::new()
};
// Annotations: comments, attachments, links, actions, and the widgets flattening kept (hidden // Annotations: comments, attachments, links, actions, and the widgets flattening kept (hidden
// ones, and ones with nothing to draw), which still hold their field's value and parent. // ones, and ones with nothing to draw), which still hold their field's value and parent.
let drop_annot = |a: &Dict| -> bool { let drop_annot = |a: &Dict| -> bool {
@@ -805,13 +822,14 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
}; };
// The annotations to remove, on every page first, so a pop-up listed on another page than // The annotations to remove, on every page first, so a pop-up listed on another page than
// its parent goes with it. // its parent goes with it.
let removed: std::collections::HashSet<ObjRef> = pdfcraft_model::pages(doc) let pages = pdfcraft_model::pages(doc);
let removed: std::collections::HashSet<ObjRef> = pages
.iter() .iter()
.flat_map(|p| annots_of(doc, &p.dict)) .flat_map(|p| annots_of(doc, &p.dict))
.filter_map(|a| a.as_ref()) .filter_map(|a| a.as_ref())
.filter(|&r| doc.get(r).as_dict().is_some_and(drop_annot)) .filter(|&r| doc.get(r).as_dict().is_some_and(drop_annot))
.collect(); .collect();
for p in pdfcraft_model::pages(doc) { for p in pages {
let list = annots_of(doc, &p.dict); let list = annots_of(doc, &p.dict);
let mut kept = Vec::new(); let mut kept = Vec::new();
for a in list { for a in list {
@@ -842,12 +860,58 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
} }
})?; })?;
} }
// A tagged PDF's structure tree still points at the removed links, comments and flattened Ok(())
// widgets (#2447): drop those references, or the full rewrite keeps the annotations, URLs, }
// text and field values included.
let still = listed_annotations(doc); /// [`remove_hidden`] with `counts` from a scan of at least the categories in `which`. With
let gone: std::collections::HashSet<ObjRef> = listed_before.difference(&still).copied().collect(); /// `every_page`, every page's annotation list is rewritten even when no chosen category changes
crate::tags::drop_object_refs(doc, &gone)?; /// it, as removal did before it skipped that work; tests compare the two.
pub(crate) fn remove_counted(
doc: &mut Document,
which: &[Hidden],
counts: &[(Hidden, usize)],
every_page: bool,
) -> Result<Vec<(Hidden, usize)>, RedactError> {
let count = |h: Hidden| counts.iter().find(|c| c.0 == h).map_or(0, |c| c.1);
let (root, _) = catalog(doc).ok_or(RedactError::NothingToApply)?;
let mut done = Vec::new();
let has_acroform = doc.get(root).as_dict().is_some_and(|d| d.contains(b"AcroForm"));
let fields = which.contains(&Hidden::FormFields) && (count(Hidden::FormFields) > 0 || has_acroform);
// Pages' annotation lists change only for these; otherwise every page is left as it is.
let annotations = every_page || fields || which.iter().any(|h| matches!(h, Hidden::Comments | Hidden::Attachments | Hidden::LinksActionsScripts));
// Every annotation the pages list now: whatever is no longer listed at the end (flattened
// widgets included) leaves the structure tree too.
let listed_before = if annotations { listed_annotations(doc) } else { std::collections::HashSet::new() };
if fields {
// Fields without appearances get one first, so their values stay visible.
for f in pdfcraft_forms::fields(doc) {
if f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))) {
pdfcraft_forms::redraw_field(doc, &f.name)?;
}
}
let n = pdfcraft_model::pages(doc).len();
pdfcraft_edit::flatten(doc, &(0..n).collect::<Vec<_>>(), false, true)?;
doc.update_dict(root, |d| {
d.remove(b"AcroForm");
})?;
}
// Content next (it needs the layers still in place). Flattened appearances are page content
// by now, so hidden text or layers inside them are removed with the rest.
let (text, layers) = (which.contains(&Hidden::HiddenText), which.contains(&Hidden::HiddenLayers));
let retained_off_layers = if (text && (fields || count(Hidden::HiddenText) > 0)) || (layers && (fields || count(Hidden::HiddenLayers) > 0)) {
content_pass(doc, text, layers, true)?.1
} else {
std::collections::HashSet::new()
};
if annotations {
drop_page_annotations(doc, which, fields)?;
// A tagged PDF's structure tree still points at the removed links, comments and flattened
// widgets (#2447): drop those references, or the full rewrite keeps the annotations, URLs,
// text and field values included.
let still = listed_annotations(doc);
let gone: std::collections::HashSet<ObjRef> = listed_before.difference(&still).copied().collect();
crate::tags::drop_object_refs(doc, &gone)?;
}
let off = hidden_layers(doc); let off = hidden_layers(doc);
let mut cat = doc.get(root).as_dict().cloned().unwrap_or_default(); let mut cat = doc.get(root).as_dict().cloned().unwrap_or_default();
let mut names = sub(doc, &cat, b"Names"); let mut names = sub(doc, &cat, b"Names");
+1
View File
@@ -4113,6 +4113,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() {
} }
mod descriptions; mod descriptions;
mod hidden_scope;
mod inline_images; mod inline_images;
mod limits; mod limits;
mod property_retirement; mod property_retirement;
+111
View File
@@ -0,0 +1,111 @@
//! Remove Hidden Information does the work of the categories it is asked for, and no more: a
//! textbook of 1,412 pages took over 15 minutes to lose its bookmarks while every page's content
//! was read for hidden text and layers it was not removing (#5873).
use super::*;
use crate::sanitize::{HIDDEN, Hidden, passes, remove_hidden, scan, scan_only};
/// Content passes and owner walks `work` makes on this thread.
fn passes_of<T>(work: impl FnOnce() -> T) -> (T, (usize, usize)) {
let before = passes();
let out = work();
let after = passes();
(out, (after.0 - before.0, after.1 - before.1))
}
/// Every category counted alone gives what the full scan gives it; lists come back in scan
/// order, once each.
#[test]
fn each_category_counts_alone_what_the_full_scan_counts() {
let doc = hidden_fixture();
let full = scan(&doc);
assert_eq!(full.iter().map(|c| c.0).collect::<Vec<_>>(), HIDDEN, "the full scan lists every category in order");
assert!(full.iter().all(|c| c.1 > 0), "the fixture holds something in every category: {full:?}");
for &(h, n) in &full {
assert_eq!(scan_only(&doc, &[h]), [(h, n)], "{h:?}");
}
// Listed out of order and twice, the categories come back in scan order, once each.
let mut every: Vec<Hidden> = HIDDEN.into_iter().rev().collect();
every.push(Hidden::Bookmarks);
assert_eq!(scan_only(&doc, &every), full);
assert!(scan_only(&doc, &[]).is_empty());
}
/// What each category costs: content passes for hidden text and layers, one shared object walk
/// for the owned entries, neither for the rest.
#[test]
fn a_category_reads_page_content_or_walks_every_object_only_when_it_needs_to() {
let doc = hidden_fixture();
assert_eq!(passes_of(|| scan(&doc)).1, (2, 1), "hidden text and hidden layers read the pages; four categories share one walk");
assert_eq!(passes_of(|| scan_only(&doc, &[Hidden::Bookmarks, Hidden::Comments, Hidden::FormFields])).1, (0, 0));
assert_eq!(passes_of(|| scan_only(&doc, &[Hidden::HiddenText])).1, (1, 0));
assert_eq!(passes_of(|| scan_only(&doc, &[Hidden::Metadata, Hidden::PrivateData])).1, (0, 1), "one walk counts both");
}
/// Bookmarks alone: no content pass or walk, only the catalog edited, the rest still counted.
#[test]
fn removing_bookmarks_changes_the_catalog_alone() {
let mut doc = hidden_fixture();
let before = scan(&doc);
// The fixture adds its /Info dictionary as an edit.
let edited = doc.modified_objects();
let (done, work) = passes_of(|| remove_hidden(&mut doc, &[Hidden::Bookmarks]));
assert_eq!(done.unwrap(), [(Hidden::Bookmarks, 2)]);
assert_eq!(work, (0, 0), "no page content read, no object walk");
let root = doc.root().unwrap();
let mut expected = [edited, vec![root.num]].concat();
expected.sort_unstable();
assert_eq!(doc.modified_objects(), expected, "the pages, their annotations and everything else stay as they were");
let cat = doc.get(root).as_dict().cloned().unwrap();
assert!(!cat.contains(b"Outlines") && !cat.contains(b"PageMode"));
// Every other category still holds what it held.
let after: Vec<(Hidden, usize)> = scan(&doc).into_iter().filter(|c| c.0 != Hidden::Bookmarks).collect();
assert_eq!(after, before.into_iter().filter(|c| c.0 != Hidden::Bookmarks).collect::<Vec<_>>());
}
/// Hidden text alone: one pass to count it and one to remove it, no object walk.
#[test]
fn removing_hidden_text_reads_the_pages_for_it_alone() {
let mut doc = hidden_fixture();
let (done, work) = passes_of(|| remove_hidden(&mut doc, &[Hidden::HiddenText]));
assert_eq!(done.unwrap(), [(Hidden::HiddenText, 13)]);
assert_eq!(work, (2, 0), "one pass to count the hidden text, one to remove it");
let c = content(&doc, 0);
assert!(c.contains("(Visible)") && !c.contains("Hidden") && c.contains("(Layer)"), "{c}");
}
/// Comments alone still rewrite the pages' annotation lists, without reading their content.
#[test]
fn removing_comments_still_takes_them_off_every_page() {
let mut doc = hidden_fixture();
let (done, work) = passes_of(|| remove_hidden(&mut doc, &[Hidden::Comments]));
assert_eq!(done.unwrap(), [(Hidden::Comments, 1)]);
assert_eq!(work, (0, 0));
let p = pdfcraft_model::pages(&doc).swap_remove(0);
let left: Vec<ObjRef> = annots_of(&doc, &p.dict).iter().filter_map(Object::as_ref).collect();
// The square and its pop-up go; the widget, the link and the attachment stay.
assert_eq!(left, [ObjRef::new(17, 0), ObjRef::new(18, 0), ObjRef::new(19, 0)]);
}
/// Each category alone, every pair and all of them remove what the full scan and a rewrite of
/// every page's annotation list removed: the same report and the same bytes in a full save.
#[test]
fn removal_writes_what_it_wrote_with_the_full_scan() {
let mut cases: Vec<Vec<Hidden>> = HIDDEN.iter().map(|&h| vec![h]).collect();
for (i, &a) in HIDDEN.iter().enumerate() {
cases.extend(HIDDEN[i + 1..].iter().map(|&b| vec![a, b]));
}
cases.push(HIDDEN.to_vec());
assert_eq!(cases.len(), 9 + 36 + 1);
for which in cases {
let mut before = hidden_fixture();
let counts = scan(&before);
let old = crate::sanitize::remove_counted(&mut before, &which, &counts, true);
let mut after = hidden_fixture();
let new = remove_hidden(&mut after, &which);
assert_eq!(new, old, "{which:?}");
// Removal gives the file a new identifier, salted at random unless the caller pins it.
let save = SaveOptions { id_entropy: Some([7; 16]), ..SaveOptions::default() };
assert!(write_full(&after, &save).unwrap() == write_full(&before, &save).unwrap(), "{which:?}: the saved files differ");
}
}