mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-08 20:10:32 +00:00
* 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 refusalc7c018ac6passed 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 scanc7c018ac6added. 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>
239 lines
12 KiB
Rust
239 lines
12 KiB
Rust
//! Redaction tools: mark areas, text, patterns or whole pages for redaction, apply the marks
|
|
//! (removing what they cover for good) or clear them.
|
|
|
|
use pdfcraft_engine::{
|
|
Edit, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACTION_CODE_SETS, RedactPattern, RedactionCodeSet, Shape, Style, find_pattern, rect_quad,
|
|
redact_word_list,
|
|
};
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::comments::{DEFAULT_AUTHOR, parse_color};
|
|
use crate::{Args, Automation, Result, ToolError, failed};
|
|
|
|
impl Automation {
|
|
pub(crate) fn redact_mark(&mut self, a: &Args) -> Result<Value> {
|
|
let doc = self.doc(a)?;
|
|
let (id, n) = (doc.id, doc.info.pages.len());
|
|
if a.get("page").is_some() && a.get("pages").is_some() {
|
|
return Err(ToolError::InvalidArgs("pass page or pages, not both".into()));
|
|
}
|
|
if a.get("rect").is_some() && a.get("pages").is_some() {
|
|
return Err(ToolError::InvalidArgs("rect requires page, not pages".into()));
|
|
}
|
|
let pages: Vec<usize> = if a.get("page").is_some() {
|
|
vec![self.page(a)?]
|
|
} else {
|
|
match a.opt_ints("pages")? {
|
|
Some(_) => self.pages(a, "pages")?,
|
|
None => (0..n).collect(),
|
|
}
|
|
};
|
|
let overlay = match (a.opt_str("overlay")?, a.opt_str("code_set")?) {
|
|
(Some(_), Some(_)) => return Err(ToolError::InvalidArgs("pass overlay or code_set with codes, not both".into())),
|
|
(Some(o), None) => o.to_string(),
|
|
(None, Some(id)) => {
|
|
let set = RedactionCodeSet::from_id(id).ok_or_else(|| {
|
|
let ids: Vec<_> = REDACTION_CODE_SETS.iter().map(|s| s.id).collect();
|
|
ToolError::InvalidArgs(format!("unknown code_set {id:?}; allowed: {}", ids.join(", ")))
|
|
})?;
|
|
let picked = a.strs("codes")?;
|
|
let allowed = || format!("allowed in {}: {}", set.id, set.codes.join(", "));
|
|
match set.overlay(&picked) {
|
|
Ok(o) if !o.is_empty() => o,
|
|
Ok(_) => return Err(ToolError::InvalidArgs(format!("codes is empty; {}", allowed()))),
|
|
Err(bad) => return Err(ToolError::InvalidArgs(format!("unknown code {bad:?}; {}", allowed()))),
|
|
}
|
|
}
|
|
(None, None) if a.get("codes").is_some() => return Err(ToolError::InvalidArgs("codes needs code_set".into())),
|
|
(None, None) => String::new(),
|
|
};
|
|
let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
|
|
let mut style = Style::default_for(&Shape::Redact { quads: Vec::new(), overlay: String::new(), look: Default::default() });
|
|
if let Some(c) = a.opt_str("fill")? {
|
|
style.fill = Some(parse_color(c)?);
|
|
}
|
|
// (page, quads) per mark.
|
|
let mut marks: Vec<(usize, Vec<[f64; 8]>)> = Vec::new();
|
|
let chosen = [
|
|
a.get("rect").is_some(),
|
|
a.get("find").is_some(),
|
|
a.get("words").is_some(),
|
|
a.get("pattern").is_some(),
|
|
a.opt_bool("whole_pages")?.unwrap_or(false),
|
|
];
|
|
if chosen.iter().filter(|c| **c).count() != 1 {
|
|
return Err(ToolError::InvalidArgs("pass exactly one of rect (with page), find, words, pattern or whole_pages: true".into()));
|
|
}
|
|
let case_sensitive = a.opt_bool("case_sensitive")?.unwrap_or(false);
|
|
let whole_words = a.opt_bool("whole_words")?.unwrap_or(false);
|
|
if (case_sensitive || whole_words) && a.get("find").is_none() && a.get("words").is_none() {
|
|
return Err(ToolError::InvalidArgs("case_sensitive and whole_words apply only to find or words".into()));
|
|
}
|
|
// Per searched word or phrase: how many marks it made.
|
|
let mut matched_words: Vec<(String, usize)> = Vec::new();
|
|
if a.get("rect").is_some() {
|
|
let page = self.page(a)?;
|
|
let info = &self.doc(a)?.info.pages[page];
|
|
let r: Vec<f64> = a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
|
let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("rect must be 4 numbers".into()))?;
|
|
let q = info.view_rect_to_quad([r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]);
|
|
marks.push((page, vec![q]));
|
|
} else if a.opt_bool("whole_pages")?.unwrap_or(false) {
|
|
let info = &self.doc(a)?.info;
|
|
for &p in &pages {
|
|
let c = info.pages[p].crop;
|
|
marks.push((p, vec![rect_quad([c[0] as f64, c[1] as f64, c[2] as f64, c[3] as f64])]));
|
|
}
|
|
} else {
|
|
let pattern = match a.opt_str("pattern")? {
|
|
Some(p) => Some(RedactPattern::from_id(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown pattern {p:?}")))?),
|
|
None => None,
|
|
};
|
|
let needles: Vec<String> = match (a.opt_str("find")?, a.get("words")) {
|
|
(Some(f), _) => vec![f.to_owned()],
|
|
(None, Some(_)) => {
|
|
let raw = a.strs("words")?;
|
|
if raw.len() > REDACT_MAX_WORDS {
|
|
return Err(ToolError::InvalidArgs(format!("words has {} entries; at most {REDACT_MAX_WORDS} are allowed", raw.len())));
|
|
}
|
|
let list = redact_word_list(&raw.join("\n"));
|
|
if list.is_empty() {
|
|
return Err(ToolError::InvalidArgs("words must hold at least one non-empty word or phrase".into()));
|
|
}
|
|
list
|
|
}
|
|
(None, None) => Vec::new(),
|
|
};
|
|
matched_words = needles.iter().map(|w| (w.clone(), 0)).collect();
|
|
let texts = self.page_texts(id, &pages)?;
|
|
let info = self.doc(a)?.info.clone();
|
|
for (&p, text) in pages.iter().zip(&texts) {
|
|
let hits: Vec<(Option<usize>, _)> = match pattern {
|
|
Some(pat) => text.find_with(|chars| find_pattern(pat, chars)).into_iter().map(|h| (None, h)).collect(),
|
|
None => needles
|
|
.iter()
|
|
.enumerate()
|
|
.flat_map(|(i, w)| text.find_opts(w, case_sensitive, whole_words).into_iter().map(move |h| (Some(i), h)))
|
|
.collect(),
|
|
};
|
|
for (word, h) in hits {
|
|
let quads: Vec<[f64; 8]> = text.line_rects(h).into_iter().map(|r| info.pages[p].view_rect_to_quad(r)).collect();
|
|
if !quads.is_empty() {
|
|
marks.push((p, quads));
|
|
if let Some(m) = word.and_then(|i| matched_words.get_mut(i)) {
|
|
m.1 += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if marks.is_empty() {
|
|
return Err(failed("nothing matched, so nothing was marked"));
|
|
}
|
|
}
|
|
let count = marks.len();
|
|
let edits: Vec<Edit> = marks
|
|
.iter()
|
|
.map(|(page, quads)| {
|
|
let shape = Shape::Redact { quads: quads.clone(), overlay: overlay.clone(), look: Default::default() };
|
|
Edit::AddAnnotation(NewAnnotation { page: *page, shape, style: style.clone(), contents: String::new(), author: author.clone() })
|
|
})
|
|
.collect();
|
|
let edit = match <[Edit; 1]>::try_from(edits) {
|
|
Ok([one]) => one,
|
|
Err(edits) => Edit::Batch { label: "Mark for redaction".into(), edits },
|
|
};
|
|
let mut out = self.apply(a, edit)?;
|
|
out["marked"] = json!(count);
|
|
out["pages_marked"] = json!(marks.iter().map(|m| m.0 + 1).collect::<std::collections::BTreeSet<_>>());
|
|
out["marks_pending"] = json!(self.doc(a)?.redaction_marks());
|
|
if a.get("words").is_some() {
|
|
out["matched_words"] = json!(matched_words.iter().map(|(w, n)| json!({ "word": w, "marked": n })).collect::<Vec<_>>());
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
pub(crate) fn redact_apply(&mut self, a: &Args) -> Result<Value> {
|
|
let pages = match a.opt_ints("pages")? {
|
|
Some(_) => Some(self.pages(a, "pages")?),
|
|
None => None,
|
|
};
|
|
let before = self.doc(a)?.redaction_marks();
|
|
if before == 0 {
|
|
return Err(failed("there are no redaction marks to apply (see redact_mark)"));
|
|
}
|
|
let mut out = self.apply(a, Edit::ApplyRedactions { pages })?;
|
|
let after = self.doc(a)?.redaction_marks();
|
|
out["applied"] = json!(before - after);
|
|
out["marks_pending"] = json!(after);
|
|
Ok(out)
|
|
}
|
|
|
|
pub(crate) fn redact_clear(&mut self, a: &Args) -> Result<Value> {
|
|
if self.doc(a)?.redaction_marks() == 0 {
|
|
return Err(failed("there are no redaction marks"));
|
|
}
|
|
self.apply(a, Edit::ClearRedactions)
|
|
}
|
|
|
|
pub(crate) fn doc_hidden_info(&self, a: &Args) -> Result<Value> {
|
|
let doc = self.doc(a)?;
|
|
let items: Vec<Value> = doc.hidden_info().into_iter().map(|(h, n)| json!({ "category": h.id(), "label": h.label(), "count": n })).collect();
|
|
let total: usize = doc.hidden_info().iter().map(|c| c.1).sum();
|
|
Ok(json!({ "total": total, "categories": items }))
|
|
}
|
|
|
|
pub(crate) fn doc_audit_redactions(&self, a: &Args) -> Result<Value> {
|
|
let d = self.doc(a)?;
|
|
let findings = d.audit_redactions().map_err(ToolError::Failed)?;
|
|
let items: Vec<Value> = findings
|
|
.into_iter()
|
|
.map(|f| {
|
|
json!({
|
|
"page": f.page + 1,
|
|
"kind": f.kind.id(),
|
|
// Points from the top-left of the displayed page, like `comment_list` and `doc_info`.
|
|
"rect": crate::view_rect(&d.info, f.page, f.rect.map(|v| v as f32)),
|
|
"covered_text": f.covered_text,
|
|
})
|
|
})
|
|
.collect();
|
|
Ok(json!({ "findings": items, "count": items.len() }))
|
|
}
|
|
|
|
pub(crate) fn doc_remove_hidden(&mut self, a: &Args) -> Result<Value> {
|
|
let which = match a.get("categories") {
|
|
None => None,
|
|
Some(_) => {
|
|
let which = a
|
|
.strs("categories")?
|
|
.into_iter()
|
|
.map(|c| Hidden::from_id(c).ok_or_else(|| ToolError::InvalidArgs(format!("unknown category {c:?} (see doc_hidden_info)"))))
|
|
.collect::<Result<Vec<_>>>()?;
|
|
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 = 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));
|
|
Ok(out)
|
|
}
|
|
}
|