M11: Optimize runs when a category it discards holds nothing (#5742)

* M11: Optimize runs when a category it discards holds nothing

PDF Optimizer's Discard Objects ticks pass their categories to
remove_hidden, which refuses a run that removes nothing. Ticking "Discard
bookmarks" on a file without bookmarks, or doc_optimize with such a
discard, ended the whole optimization with "there are no redaction marks
to apply". An empty category is now nothing discarded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M8: doc_remove_hidden says when the listed categories hold nothing

Its error came from remove_hidden's refusal: "there are no redaction marks
to apply". It now checks the scan first, as redact_apply checks for marks,
and says there is nothing to remove. The scan counts what removal takes
(an AcroForm with no fields as 1), so the check refuses only what the
removal would. The tool description says it fails then.

The tool test also runs doc_optimize discarding categories that hold
nothing, end to end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M11: Optimize passes remove_hidden only the ticked categories that hold something

Catching remove_hidden's refusal kept a copy it may have changed: with
bookmarks ticked it drops an empty outline root before counting nothing,
and its own documentation says to drop such a copy. The optimizer now
scans first and passes only the ticked categories the scan finds something
in, and with none doesn't call it, so an empty outline root stays, as
Remove Hidden Information leaves it.

Corrects 278a588bf's message: the ticks are under Discard Objects and
Discard User Data, and hidden text is one of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M8: doc_remove_hidden reads one scan, and says why it can't edit

A document that can't be edited has no scan, so the check read every
category as empty and said the listed ones hold nothing; the reason it
can't be edited comes through again. The check and the count of what was
removed now share the one scan the tool took already.

Corrects ada2a1ccb's message: the removal also takes what its scan counts
as 0 (an empty outline root); what the check matches is that the removal
refuses when its scan counts every listed category as 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M11: the optimizer test discards a category that holds something

Mutants of the category filter survived: no test ticked a category that
holds something, or an empty one beside an unticked one that does. A page
with a comment and no bookmarks, discarding both, discards the comment
and only it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

* M11: Optimize passes every ticked category, and keeps the copy from before a refusal

c7c018ac6 passed remove_hidden only the ticked categories its scan finds
something in. But with form fields ticked, discarding hidden text also
takes the hidden text the flattened appearances bring onto the page,
which no scan before flattening counts, so with Flatten form fields and
Discard hidden text ticked, the hidden text was dropped from the list and
stayed in the file, where main removes it.

Every ticked category goes to remove_hidden again, as on main. When it
refuses (every category counts 0), it may have changed the copy (an
empty outline root goes with bookmarks, and then it refuses), so the copy
from before the call is kept and nothing is discarded. That also drops
the extra scan c7c018ac6 added.

Corrects c7c018ac6's message: remove_hidden counts first, then drops an
empty outline root, then refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
PiohrunandClaude Opus 5.5 authored and GitHub committed 2026-11-03 00:33:04 -05:00
1 parent cb405f207e
commit 8582c84da8
5 files changed
+107 -5

No files matched your search

+17 -3
View File
@@ -201,8 +201,8 @@ impl Automation {
}
pub(crate) fn doc_remove_hidden(&mut self, a: &Args) -> Result<Value> {
let edit = match a.get("categories") {
None => Edit::Sanitize,
let which = match a.get("categories") {
None => None,
Some(_) => {
let which = a
.strs("categories")?
@@ -212,10 +212,24 @@ impl Automation {
if which.is_empty() {
return Err(ToolError::InvalidArgs("categories is empty".into()));
}
Some(which)
}
};
// 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
// 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.
let info = self.doc(a)?.hidden_info();
let edit = match which {
None => Edit::Sanitize,
Some(which) => {
if !info.is_empty() && which.iter().all(|h| info.iter().find(|c| c.0 == *h).is_none_or(|c| c.1 == 0)) {
return Err(failed("nothing to remove: the listed categories hold nothing (see doc_hidden_info)"));
}
Edit::RemoveHidden { which }
}
};
let before: usize = self.doc(a)?.hidden_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 after: usize = self.doc(a)?.hidden_info().iter().map(|c| c.1).sum();
out["removed"] = json!(before.saturating_sub(after));
+1 -1
View File
@@ -562,7 +562,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"doc_remove_hidden",
"Remove hidden information / Sanitize",
"Remove the listed categories (see doc_hidden_info), or every category when none are listed (Sanitize Document). Form fields are flattened so their values stay visible. The next save rewrites the whole file. Undoable until saved.",
"Remove the listed categories (see doc_hidden_info), or every category when none are listed (Sanitize Document). Fails when the listed categories hold nothing. Form fields are flattened so their values stay visible. The next save rewrites the whole file. Undoable until saved.",
)
.destructive()
.with(schema(
+11
View File
@@ -4670,12 +4670,23 @@ fn removing_hidden_information_through_tools() {
assert_eq!(r["undo"], "Remove hidden information");
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 0);
assert!(count(&ok(&mut a, "doc_hidden_info", json!({ "doc": doc })), "metadata") >= 1, "only comments went");
// With none left, removing them again says so, and Optimize discarding them still runs.
let again = a.call("doc_remove_hidden", &json!({ "doc": doc, "categories": ["comments"] }));
assert!(matches!(&again, Err(ToolError::Failed(m)) if m.starts_with("nothing to remove")), "{again:?}");
let optimized = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "optimized.pdf", "discard": ["comments", "bookmarks"] }));
assert_eq!(optimized["discarded"], json!([]), "{optimized}");
let r = ok(&mut a, "doc_remove_hidden", json!({ "doc": doc }));
assert_eq!(r["undo"], "Sanitize document");
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "clean.pdf" }));
let bytes = std::fs::read(dir.join("clean.pdf")).unwrap();
assert!(!bytes.windows(11).any(|w| w == b"Secret plan"), "the old revision is gone");
assert!(matches!(a.call("doc_remove_hidden", &json!({ "doc": doc, "categories": ["nonsense"] })), Err(ToolError::InvalidArgs(_))));
// A document that can't be edited has no scan: the refusal says why, not that nothing is there.
let junk = [vec![b'x'; 5000], std::fs::read(dir.join("a.pdf")).unwrap()].concat();
std::fs::write(dir.join("junk.pdf"), junk).unwrap();
let read_only = ok(&mut a, "doc_open", json!({ "path": "junk.pdf" }))["doc"].as_u64().unwrap();
let refused = a.call("doc_remove_hidden", &json!({ "doc": read_only, "categories": ["comments"] }));
assert!(matches!(&refused, Err(ToolError::Failed(m)) if m.contains("can't be edited")), "{refused:?}");
}
/// A one-page PDF whose text "TOP SECRET" sits under an opaque black rectangle.
+18 -1
View File
@@ -92,7 +92,24 @@ impl OptimizeJob {
pdfcraft_xfa::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?;
let mut step = |stage| if progress(stage) { Ok(()) } else { Err(EditError::Cancelled) };
step(OptimizeStage::Discarding)?;
let discarded = if discard.is_empty() { Vec::new() } else { pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard)? };
let discarded = if discard.is_empty() {
Vec::new()
} else {
// Every ticked category, as `remove_hidden` decides between them: with form fields
// ticked, discarding hidden text also takes the hidden text the flattened appearances
// bring onto the page, which no scan before flattening counts. It refuses when its scan
// counts every one as 0, which would end the whole optimization, and may have changed
// the copy by then (an empty outline root goes with bookmarks), so the copy from before
// is kept instead: nothing is discarded.
let before = cos.clone();
match pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard) {
Err(pdfcraft_redact::RedactError::NothingToApply) => {
cos = before;
Vec::new()
}
r => r?,
}
};
let report = optimize::optimize_with_progress(&mut cos, &settings, &mut |s| {
step(match s {
optimize::Stage::Fragments => OptimizeStage::Fragments,
+60
View File
@@ -3610,6 +3610,66 @@ fn create_and_reduce() {
assert_eq!(page_texts(&s3, r), ["Page 1", "Page 1"]);
}
/// Discarding a category that holds nothing discards nothing; it used to end the whole
/// optimization with an error about redaction marks.
#[test]
fn optimize_discarding_an_empty_category_still_runs() {
let (s, id) = session_with(2);
let (bytes, report) = s.optimized_bytes(id, &optimize::Settings::default(), &[crate::Hidden::Bookmarks, crate::Hidden::Comments]).unwrap();
assert!(bytes.starts_with(b"%PDF-"));
assert!(report.discarded.is_empty(), "{:?}", report.discarded);
// An outline root with no entries is no bookmarks: it stays, as Remove Hidden Information,
// which refuses then, leaves it.
let outline = pdfcraft_testkit::Pdf::new()
.obj("<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R /PageMode /UseOutlines >>")
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>")
.obj("<< /Type /Page /Parent 2 0 R >>")
.obj("<< /Type /Outlines /Count 0 >>")
.build();
let mut s = Session::new();
let id = s.open("outline.pdf", None, Arc::new(outline), None).unwrap();
let (bytes, report) = s.optimized_bytes(id, &optimize::Settings::default(), &[crate::Hidden::Bookmarks]).unwrap();
assert!(report.discarded.is_empty(), "{:?}", report.discarded);
let saved = pdfcraft_cos::Document::open(bytes).unwrap();
let catalog = saved.get(saved.root().unwrap()).as_dict().cloned().unwrap();
assert!(catalog.contains(b"Outlines") && catalog.contains(b"PageMode"), "{catalog:?}");
// Beside an empty tick, one that holds something is discarded, and only that one.
let noted = pdfcraft_testkit::Pdf::new()
.obj("<< /Type /Catalog /Pages 2 0 R >>")
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>")
.obj("<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>")
.obj("<< /Type /Annot /Subtype /Text /Rect [20 20 40 40] /Contents (internal note) >>")
.build();
let mut s = Session::new();
let id = s.open("noted.pdf", None, Arc::new(noted), None).unwrap();
let (_, report) = s.optimized_bytes(id, &optimize::Settings::default(), &[crate::Hidden::Bookmarks, crate::Hidden::Comments]).unwrap();
assert_eq!(report.discarded, vec![(crate::Hidden::Comments, 1)]);
// A field whose appearance draws text invisibly: flattening it brings that text onto the page,
// which no scan before counts as hidden, and discarding hidden text takes it too.
let form = pdfcraft_testkit::Pdf::new()
.obj("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/F1 0 Tf 0 g) >> >>")
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>")
.obj("<< /Type /Page /Parent 2 0 R /Annots [4 0 R] /Resources << /Font << /F1 6 0 R >> >> /Contents 7 0 R >>")
.obj("<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (shown) /Rect [100 100 300 130] /P 3 0 R /F 4 /AP << /N 5 0 R >> >>")
.stream(
"/Type /XObject /Subtype /Form /BBox [0 0 200 30] /Resources << /Font << /F1 6 0 R >> >>",
"/Tx BMC BT /F1 12 Tf 2 10 Td (shown) Tj ET BT /F1 12 Tf 3 Tr 2 20 Td (HIDDENSECRET) Tj ET EMC",
)
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
.stream("", "BT /F1 12 Tf 72 700 Td (Page text) Tj ET")
.build();
let mut s = Session::new();
let id = s.open("form.pdf", None, Arc::new(form), None).unwrap();
let (bytes, _) = s.optimized_bytes(id, &optimize::Settings::default(), &[crate::Hidden::FormFields, crate::Hidden::HiddenText]).unwrap();
let saved = pdfcraft_cos::Document::open(bytes).unwrap();
for (r, object) in saved.scan_objects() {
if let pdfcraft_cos::Object::Stream(st) = &*object {
let data = st.decoded().unwrap_or_default();
assert!(!data.windows(12).any(|w| w == b"HIDDENSECRET"), "the hidden text stays in {r:?}");
}
}
}
#[test]
fn an_optimize_job_reports_its_stages_and_can_be_cancelled() {
use crate::optimizer::OptimizeStage;