diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index e662bcac19..a146f3f8ff 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -201,8 +201,8 @@ impl Automation { } pub(crate) fn doc_remove_hidden(&mut self, a: &Args) -> Result { - 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)); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 7cafcb4a37..ba5892a451 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -562,7 +562,7 @@ pub fn tools() -> Vec { 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( diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 0b0a7d598e..c9b50755e8 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -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. diff --git a/crates/engine/src/optimizer.rs b/crates/engine/src/optimizer.rs index 42af737bab..30a14c5695 100644 --- a/crates/engine/src/optimizer.rs +++ b/crates/engine/src/optimizer.rs @@ -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, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 25a185e835..d60ed04d66 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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;