Files
pdfcraft/crates/automation/src/redact.rs
T
Brandon ThomasandClaude Opus 5.5 d3db99818f Rename PrintCraft to PdfCraft (#174)
The app, crates, binaries, bundle id, packaging, icons, screenshots and docs are now
PdfCraft / pdfcraft (repo storytold/pdfcraft). On first launch the desktop app moves the
PrintCraft settings and crash-recovery folders to the new name, and both apps read settings
saved under the old storage key, so upgrades keep recent files, preferences and unsaved work.
The signing test fixtures keep their PrintCraft subject names (they are binary).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:03:57 -04:00

139 lines
6.6 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, RedactPattern, Shape, Style, find_pattern, rect_quad};
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());
let pages: Vec<usize> = match a.opt_ints("pages")? {
Some(_) => self.pages(a, "pages")?,
None => (0..n).collect(),
};
let overlay = a.opt_str("overlay")?.unwrap_or("").to_string();
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("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, pattern or whole_pages: true".into()));
}
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 find = a.opt_str("find")?.map(str::to_owned);
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 texts = self.page_texts(id, &pages)?;
let info = self.doc(a)?.info.clone();
for (&p, text) in pages.iter().zip(&texts) {
let hits = match (&find, pattern) {
(Some(f), _) => text.find(f),
(None, Some(pat)) => text.find_with(|chars| find_pattern(pat, chars)),
_ => Vec::new(),
};
for 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 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());
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_remove_hidden(&mut self, a: &Args) -> Result<Value> {
let edit = match a.get("categories") {
None => Edit::Sanitize,
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()));
}
Edit::RemoveHidden { which }
}
};
let before: usize = self.doc(a)?.hidden_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)
}
}