From f8175fb97e9c3d6bfe15bbe7ddd4d62421ac26cb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 00:24:29 -0400 Subject: [PATCH] M8.5/M8.6/M8.7: Redact a PDF: new content crate (L2: content-stream operators with spans, inline images, serializer, matrices) and redact crate (L4): Redact annotations; apply removes glyphs (TJ displacement keeps the rest in place; simple, composite/CMap and Type 3 fonts), clears covered image pixels (or removes images it can't re-encode), removes or clips vectors and shadings, rewrites form XObjects copy-on-write, removes overlapping comments, links and fields, draws the boxes with overlay text, then verifies and fails closed; redacted documents save as a full rewrite. Search & Redact text and patterns (phone, e-mail, credit card, SSN, dates). UI: Redact tool (text or box), Redact pages, Find text and redact, Set properties, Clear all / Redact all with confirmation; redact_mark/redact_apply/redact_clear tools (57 tools). Fix: deleting a field when /AcroForm is a direct dictionary Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 22 ++ Cargo.toml | 2 + crates/annot/src/appearance.rs | 19 + crates/annot/src/lib.rs | 29 +- crates/automation/src/comments.rs | 2 +- crates/automation/src/lib.rs | 4 + crates/automation/src/redact.rs | 106 +++++ crates/automation/src/tools.rs | 30 ++ crates/automation/tests/automation.rs | 24 ++ crates/content/Cargo.toml | 12 + crates/content/README.md | 14 + crates/content/src/lib.rs | 287 ++++++++++++++ crates/content/src/tests.rs | 65 +++ crates/cos/src/document.rs | 15 + crates/cos/src/writer.rs | 4 +- crates/edit/src/lib.rs | 11 + crates/engine/Cargo.toml | 1 + crates/engine/src/catalog.rs | 9 +- crates/engine/src/commands.rs | 9 + crates/engine/src/lib.rs | 27 +- crates/engine/src/tests.rs | 28 ++ crates/forms/src/author.rs | 12 +- crates/forms/src/tests.rs | 13 + crates/redact/Cargo.toml | 21 + crates/redact/README.md | 37 ++ crates/redact/src/fonts.rs | 281 +++++++++++++ crates/redact/src/image.rs | 118 ++++++ crates/redact/src/interp.rs | 546 ++++++++++++++++++++++++++ crates/redact/src/lib.rs | 369 +++++++++++++++++ crates/redact/src/patterns.rs | 291 ++++++++++++++ crates/redact/src/tests.rs | 266 +++++++++++++ crates/render/src/text.rs | 18 + crates/ui-egui/src/canvas.rs | 28 +- crates/ui-egui/src/commands.rs | 40 ++ crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/dialogs.rs | 48 +++ crates/ui-egui/src/lib.rs | 16 + crates/ui-egui/src/panels.rs | 22 +- crates/ui-egui/src/redact_ui.rs | 310 +++++++++++++++ crates/ui-egui/tests/commands.rs | 11 + crates/ui-egui/tests/redact.rs | 80 ++++ parity/acrobat-features.toml | 120 +++--- 42 files changed, 3295 insertions(+), 73 deletions(-) create mode 100644 crates/automation/src/redact.rs create mode 100644 crates/content/Cargo.toml create mode 100644 crates/content/README.md create mode 100644 crates/content/src/lib.rs create mode 100644 crates/content/src/tests.rs create mode 100644 crates/redact/Cargo.toml create mode 100644 crates/redact/README.md create mode 100644 crates/redact/src/fonts.rs create mode 100644 crates/redact/src/image.rs create mode 100644 crates/redact/src/interp.rs create mode 100644 crates/redact/src/lib.rs create mode 100644 crates/redact/src/patterns.rs create mode 100644 crates/redact/src/tests.rs create mode 100644 crates/ui-egui/src/redact_ui.rs create mode 100644 crates/ui-egui/tests/redact.rs diff --git a/Cargo.lock b/Cargo.lock index 4710c2649..4c58add34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3060,6 +3060,13 @@ dependencies = [ "serde_json", ] +[[package]] +name = "printcraft-content" +version = "0.1.0" +dependencies = [ + "printcraft-cos", +] + [[package]] name = "printcraft-cos" version = "0.1.0" @@ -3120,6 +3127,7 @@ dependencies = [ "printcraft-forms", "printcraft-model", "printcraft-organize", + "printcraft-redact", "printcraft-render", "thiserror 2.0.21", ] @@ -3168,6 +3176,20 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "printcraft-redact" +version = "0.1.0" +dependencies = [ + "printcraft-annot", + "printcraft-content", + "printcraft-cos", + "printcraft-edit", + "printcraft-fonts", + "printcraft-forms", + "printcraft-model", + "thiserror 2.0.21", +] + [[package]] name = "printcraft-render" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index d9b0543d5..88ba6169a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,8 @@ printcraft-organize = { path = "crates/organize", version = "0.1.0" } printcraft-annot = { path = "crates/annot", version = "0.1.0" } printcraft-fonts = { path = "crates/fonts", version = "0.1.0" } printcraft-model = { path = "crates/model", version = "0.1.0" } +printcraft-content = { path = "crates/content", version = "0.1.0" } +printcraft-redact = { path = "crates/redact", version = "0.1.0" } printcraft-edit = { path = "crates/edit", version = "0.1.0" } printcraft-create = { path = "crates/create", version = "0.1.0" } printcraft-forms = { path = "crates/forms", version = "0.1.0" } diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 333127b3b..d4fe78d58 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -201,6 +201,25 @@ pub fn build(d: &Dict) -> Option { } } } + b"Redact" => { + // While marked: the outline of each area (the fill comes when applied). + let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; + let col = stroke.unwrap_or([0.89, 0.13, 0.13]); + c.push_str(&format!("{}1 w\n", rg_stroke(col))); + for quad in q.chunks_exact(8) { + c.push_str(&format!( + "{} {} m {} {} l {} {} l {} {} l h S\n", + n(quad[0]), + n(quad[1]), + n(quad[2]), + n(quad[3]), + n(quad[6]), + n(quad[7]), + n(quad[4]), + n(quad[5]) + )); + } + } b"Square" | b"Circle" => { let fill = color(d, b"IC")?; if stroke.is_none() && fill.is_none() { diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index f52887920..9f81059fe 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -167,6 +167,19 @@ pub enum Shape { Signature { strokes: Vec>, }, + /// A redaction mark (§12.5.6.23) over quadrilaterals (text) or one rectangle as a quad + /// (areas, pages). `overlay` is the text shown on the box once applied. + Redact { + quads: Vec<[f64; 8]>, + overlay: String, + }, +} + +/// A rectangle `[x0 y0 x1 y1]` as a quad in Acrobat's order (top-left, top-right, bottom-left, +/// bottom-right). +pub fn rect_quad(r: [f64; 4]) -> [f64; 8] { + let [x0, y0, x1, y1] = normalize(r); + [x0, y1, x1, y1, x0, y0, x1, y0] } impl Shape { @@ -180,6 +193,7 @@ impl Shape { Shape::Ink { .. } | Shape::Signature { .. } => "Ink", Shape::TextBox { .. } | Shape::Typewriter { .. } => "FreeText", Shape::Mark { .. } => "Stamp", + Shape::Redact { .. } => "Redact", } } } @@ -217,6 +231,8 @@ impl Style { Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0), Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5), Shape::Signature { .. } => ([0.0, 0.0, 0.0], 1.5), + // Red outline while marked; a black box once applied. + Shape::Redact { .. } => return Self { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: Some([0.0, 0.0, 0.0]) }, }; Self { color, opacity: 1.0, width, fill: None } } @@ -393,7 +409,7 @@ fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> { } [at[0], at[1] - NOTE_SIZE, at[0] + NOTE_SIZE, at[1]] } - Shape::TextMarkup { quads, .. } => { + Shape::TextMarkup { quads, .. } | Shape::Redact { quads, .. } => { if quads.is_empty() || !quads.iter().all(|q| finite(q)) { return Err(bad("text area (no quadrilaterals)")); } @@ -465,6 +481,7 @@ fn subject(shape: &Shape) -> &'static str { Shape::Typewriter { .. } => "Typewriter", Shape::Mark { mark, .. } => mark.label(), Shape::Signature { .. } => "Signature", + Shape::Redact { .. } => "Redact", } } @@ -501,6 +518,16 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R d.set(b"C".to_vec(), rgb(style.color)); d.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); } + Shape::Redact { quads, overlay } => { + d.set(b"C".to_vec(), rgb(style.color)); + d.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0]))); + d.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); + if !overlay.is_empty() { + d.set(b"OverlayText".to_vec(), PdfString::text(overlay)); + d.set(b"DA".to_vec(), PdfString::literal(b"1 0 0 rg /Helv 10 Tf".to_vec())); + d.set(b"Q".to_vec(), Object::Int(1)); + } + } Shape::Rectangle { .. } | Shape::Oval { .. } => { d.set(b"C".to_vec(), rgb(style.color)); if let Some(f) = style.fill { diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 7a0745ba2..94f3ea247 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -10,7 +10,7 @@ use serde_json::{Value, json}; use crate::{Args, Automation, Result, ToolError, failed}; /// Author used when a tool call names none. -const DEFAULT_AUTHOR: &str = "PrintCraft"; +pub(crate) const DEFAULT_AUTHOR: &str = "PrintCraft"; pub(crate) fn parse_color(s: &str) -> Result { let named = match s.to_ascii_lowercase().as_str() { diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 9c96216ad..757e1f49a 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -13,6 +13,7 @@ mod comments; mod forms; #[cfg(feature = "mcp")] pub mod mcp; +mod redact; mod tools; use std::collections::HashMap; @@ -256,6 +257,9 @@ impl Automation { "form_add_field" => self.form_add_field(&a)?, "form_set_props" => self.form_set_props(&a)?, "form_delete_field" => self.form_delete_field(&a)?, + "redact_mark" => self.redact_mark(&a)?, + "redact_apply" => self.redact_apply(&a)?, + "redact_clear" => self.redact_clear(&a)?, "fill_sign_add" => self.fill_sign_add(&a)?, "comment_list" => self.comment_list(&a)?, "comment_add" => self.comment_add(&a)?, diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs new file mode 100644 index 000000000..a22c46711 --- /dev/null +++ b/crates/automation/src/redact.rs @@ -0,0 +1,106 @@ +//! Redaction tools: mark areas, text, patterns or whole pages for redaction, apply the marks +//! (removing what they cover for good) or clear them. + +use printcraft_engine::{Edit, 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 { + let doc = self.doc(a)?; + let (id, n) = (doc.id, doc.info.pages.len()); + let pages: Vec = 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() }); + 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 = 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 = marks + .iter() + .map(|(page, quads)| { + let shape = Shape::Redact { quads: quads.clone(), overlay: overlay.clone() }; + Edit::AddAnnotation(NewAnnotation { page: *page, shape, style: style.clone(), contents: String::new(), author: author.clone() }) + }) + .collect(); + let edit = if edits.len() == 1 { edits.into_iter().next().expect("one") } else { 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::>()); + out["marks_pending"] = json!(self.doc(a)?.redaction_marks()); + Ok(out) + } + + pub(crate) fn redact_apply(&mut self, a: &Args) -> Result { + 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 { + if self.doc(a)?.redaction_marks() == 0 { + return Err(failed("there are no redaction marks")); + } + self.apply(a, Edit::ClearRedactions) + } +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index c00292619..bbfc2491d 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -257,6 +257,36 @@ pub fn tools() -> Vec { t("form_delete_field", "Delete a field", "Delete a form field and all its widgets. Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "field": { "type": "string" } }), &["doc", "field"])), + t( + "redact_mark", + "Mark for redaction", + "Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); whole_pages: true. find, pattern and whole_pages work on pages (default all). overlay: text shown on the box once applied; fill: box colour (default black). Undoable.", + ) + .with(schema( + json!({ + "doc": doc(), + "page": { "type": "integer", "minimum": 1 }, + "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, + "find": { "type": "string", "minLength": 1 }, + "pattern": { "type": "string", "enum": ["phone", "email", "credit-card", "ssn", "date"] }, + "whole_pages": { "type": "boolean" }, + "pages": pages("to search or mark (default: all)"), + "overlay": { "type": "string" }, + "fill": { "type": "string", "description": "#RRGGBB or a colour name." }, + "author": { "type": "string" }, + }), + &["doc"], + )), + t( + "redact_apply", + "Apply redactions", + "Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Undoable until saved; the saved file no longer contains the content.", + ) + .destructive() + .with(schema(json!({ "doc": doc(), "pages": pages("whose marks to apply (default: all)") }), &["doc"])), + t("redact_clear", "Remove redaction marks", "Remove every redaction mark without applying it. Undoable.") + .destructive() + .with(schema(json!({ "doc": doc() }), &["doc"])), t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status and replies.") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1, "description": "Only this page." } }), &["doc"])), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 146f39170..05182ff66 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -616,3 +616,27 @@ fn preparing_a_form_through_tools() { )); assert!(matches!(a.call("form_delete_field", &json!({ "doc": doc, "field": "nope" })), Err(ToolError::Failed(_)))); } + +#[test] +fn redacting_through_tools() { + let dir = workdir("redact"); + std::fs::write(dir.join("memo.txt"), "Call 555-123-4567 today\nSSN 123-45-6789 is private\nPublic line").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "memo.txt" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "redact_mark", json!({ "doc": doc, "pattern": "phone" })); + assert_eq!((r["marked"].as_u64(), r["marks_pending"].as_u64()), (Some(1), Some(1))); + ok(&mut a, "redact_mark", json!({ "doc": doc, "pattern": "ssn", "overlay": "SSN" })); + ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "private" })); + assert_eq!(page_text(&mut a, doc)[0].matches("555-123-4567").count(), 1, "marks alone remove nothing"); + let r = ok(&mut a, "redact_apply", json!({ "doc": doc })); + assert_eq!((r["applied"].as_u64(), r["marks_pending"].as_u64()), (Some(3), Some(0))); + let text = page_text(&mut a, doc)[0].clone(); + assert!(!text.contains("555") && !text.contains("6789") && !text.contains("private"), "{text}"); + assert!(text.contains("Call") && text.contains("today") && text.contains("Public line"), "{text}"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "memo.pdf" })); + let bytes = std::fs::read(dir.join("memo.pdf")).unwrap(); + assert!(!bytes.windows(4).any(|w| w == b"4567"), "the saved file has no trace of the number"); + assert!(matches!(a.call("redact_apply", &json!({ "doc": doc })), Err(ToolError::Failed(_)))); + assert!(matches!(a.call("redact_mark", &json!({ "doc": doc, "find": "nowhere to be found" })), Err(ToolError::Failed(_)))); + assert!(matches!(a.call("redact_mark", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_)))); +} diff --git a/crates/content/Cargo.toml b/crates/content/Cargo.toml new file mode 100644 index 000000000..d16a7fee5 --- /dev/null +++ b/crates/content/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "printcraft-content" +description = "Content streams (ISO 32000-2 §7.8, §8–9): operators with source spans, inline images, serializer, matrices" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +printcraft-cos = { workspace = true } diff --git a/crates/content/README.md b/crates/content/README.md new file mode 100644 index 000000000..9286f04a7 --- /dev/null +++ b/crates/content/README.md @@ -0,0 +1,14 @@ +# printcraft-content + +Layer L2: content streams (ISO 32000-2 §7.8.2, §8, §9). + +```rust +let parsed = parse(&stream.decoded()?); // operators with operands and source spans +for op in &parsed.ops { if op.is("Tj") { /* … */ } } +let bytes = serialize_ops(&parsed.ops); // write back (inline images kept whole) +let m = Matrix::from_operands(&op.operands); // cm / Tm arithmetic, invert, bbox +``` + +The parser is tolerant (unreadable bytes are skipped and counted, never fatal) and never +panics. `redact` and (later) `edit` build their interpreters on it; the graphics-state tracker +and the `Patch` API of architecture §6 grow here. diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs new file mode 100644 index 000000000..0f0489b4d --- /dev/null +++ b/crates/content/src/lib.rs @@ -0,0 +1,287 @@ +//! Content streams (ISO 32000-2 §7.8.2, §8, §9): parse a stream into operators that keep their +//! source spans, write operators back, and the matrix arithmetic content interpretation needs. +//! +//! Layer L2. The parser is tolerant: bytes it cannot read are skipped (and counted in +//! [`Parsed::skipped`]) instead of failing the whole stream, and it never panics. Inline images +//! (`BI … ID … EI`) are kept whole, so a parse → serialize round trip preserves them. + +use printcraft_cos::{Dict, Lexer, Object, PdfString, serialize}; + +#[cfg(test)] +mod tests; + +/// One operator with its operands. +#[derive(Clone, Debug, PartialEq)] +pub struct Op { + /// The operator keyword (`Tj`, `re`, `BI` for inline images…). + pub op: Vec, + pub operands: Vec, + /// Inline images: the image dictionary (`operands` is empty) and the data between `ID` and + /// `EI`. + pub inline: Option<(Dict, Vec)>, + /// The bytes of the source stream this operator came from (operands included). + pub span: std::ops::Range, +} + +impl Op { + pub fn new(op: &str, operands: Vec) -> Self { + Op { op: op.as_bytes().to_vec(), operands, inline: None, span: 0..0 } + } + + pub fn is(&self, op: &str) -> bool { + self.op == op.as_bytes() + } + + /// Operand `i` as a number. + pub fn num(&self, i: usize) -> Option { + self.operands.get(i).and_then(Object::as_f64) + } + + /// The last `n` operands as numbers (operators take their operands from the end). + pub fn nums(&self) -> Option<[f64; N]> { + let k = self.operands.len().checked_sub(N)?; + let mut out = [0.0; N]; + for (o, v) in out.iter_mut().zip(&self.operands[k..]) { + *o = v.as_f64()?; + } + Some(out) + } + + pub fn name(&self, i: usize) -> Option<&[u8]> { + self.operands.get(i).and_then(Object::as_name) + } +} + +/// A parsed stream. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Parsed { + pub ops: Vec, + /// Bytes that could not be read as operands or operators. + pub skipped: usize, +} + +fn is_ws(b: u8) -> bool { + matches!(b, b'\0' | b'\t' | b'\n' | b'\x0C' | b'\r' | b' ') +} + +fn is_delim(b: u8) -> bool { + matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%') +} + +/// Parse a (decoded) content stream. +pub fn parse(data: &[u8]) -> Parsed { + let mut out = Parsed::default(); + let mut lx = Lexer::new(data, 0); + let mut operands: Vec = Vec::new(); + let mut start: Option = None; + loop { + lx.skip_ws(); + let Some(&b) = data.get(lx.pos) else { break }; + let at = lx.pos; + let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit(); + if operand_start { + match lx.object() { + Ok(o) => { + start.get_or_insert(at); + operands.push(o); + } + Err(_) if b == b'(' => { + // An unterminated string runs to the end of the stream. + out.skipped += data.len() - at; + lx.pos = data.len(); + } + Err(_) => { + // Skip the offending byte and carry on. + lx.pos = at + 1; + out.skipped += 1; + } + } + continue; + } + if is_delim(b) { + // A stray `)`, `>`, `]`, `{`, `}`. + lx.pos += 1; + out.skipped += 1; + continue; + } + let kw = lx.token(); + if kw.is_empty() { + lx.pos = at + 1; + out.skipped += 1; + continue; + } + match kw { + b"true" => { + start.get_or_insert(at); + operands.push(Object::Bool(true)); + } + b"false" => { + start.get_or_insert(at); + operands.push(Object::Bool(false)); + } + b"null" => { + start.get_or_insert(at); + operands.push(Object::Null); + } + b"BI" => { + let (dict, img, end) = inline_image(data, lx.pos); + lx.pos = end; + out.ops.push(Op { op: b"BI".to_vec(), operands: Vec::new(), inline: Some((dict, img)), span: start.take().unwrap_or(at)..end }); + operands.clear(); + } + _ => { + let s = start.take().unwrap_or(at); + out.ops.push(Op { op: kw.to_vec(), operands: std::mem::take(&mut operands), inline: None, span: s..lx.pos }); + } + } + } + out.skipped += operands.len(); + out +} + +/// Read an inline image starting after `BI`: (parameters, data, position after `EI`). +fn inline_image(data: &[u8], pos: usize) -> (Dict, Vec, usize) { + let mut lx = Lexer::new(data, pos); + let mut dict = Dict::new(); + loop { + lx.skip_ws(); + if lx.eat_keyword(b"ID") { + break; + } + let Ok(Object::Name(k)) = lx.object() else { + return (dict, Vec::new(), data.len()); + }; + let Ok(v) = lx.object() else { + return (dict, Vec::new(), data.len()); + }; + dict.set(k, v); + } + // One whitespace byte separates ID from the data. + let begin = (lx.pos + 1).min(data.len()); + // The data ends at whitespace + `EI` + (whitespace or end). Search for that pattern. + let mut i = begin; + while i + 2 <= data.len() { + if data[i] == b'E' + && data.get(i + 1) == Some(&b'I') + && (i == begin || is_ws(data[i - 1])) + && data.get(i + 2).is_none_or(|b| is_ws(*b) || is_delim(*b)) + { + let end_data = if i > begin && is_ws(data[i - 1]) { i - 1 } else { i }; + return (dict, data[begin..end_data].to_vec(), i + 2); + } + i += 1; + } + (dict, data[begin..].to_vec(), data.len()) +} + +/// Write one operator (with a trailing newline). +pub fn write_op(op: &Op, out: &mut Vec) { + if let Some((dict, img)) = &op.inline { + out.extend_from_slice(b"BI"); + for (k, v) in dict.iter() { + out.push(b' '); + serialize(&Object::Name(k.clone()), out); + out.push(b' '); + serialize(v, out); + } + out.extend_from_slice(b" ID "); + out.extend_from_slice(img); + out.extend_from_slice(b"\nEI\n"); + return; + } + for o in &op.operands { + serialize(o, out); + out.push(b' '); + } + out.extend_from_slice(&op.op); + out.push(b'\n'); +} + +/// Write operators back to a content stream. +pub fn serialize_ops(ops: &[Op]) -> Vec { + let mut out = Vec::new(); + for op in ops { + write_op(op, &mut out); + } + out +} + +/// A number operand, written as an integer when it is one. +pub fn num(v: f64) -> Object { + if v.fract() == 0.0 && v.abs() < 1e15 { Object::Int(v as i64) } else { Object::Real((v * 10_000.0).round() / 10_000.0) } +} + +pub fn string(bytes: Vec) -> Object { + Object::String(PdfString::literal(bytes)) +} + +/// An affine matrix `[a b c d e f]` (PDF order: a point maps to `(a·x + c·y + e, b·x + d·y + f)`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Matrix(pub [f64; 6]); + +impl Default for Matrix { + fn default() -> Self { + Matrix::IDENTITY + } +} + +impl Matrix { + pub const IDENTITY: Matrix = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + + pub fn translate(x: f64, y: f64) -> Self { + Matrix([1.0, 0.0, 0.0, 1.0, x, y]) + } + + pub fn from_operands(v: &[Object]) -> Option { + if v.len() != 6 { + return None; + } + let mut m = [0.0; 6]; + for (o, x) in m.iter_mut().zip(v) { + *o = x.as_f64()?; + } + Some(Matrix(m)) + } + + /// `self` then `then` (`self × then` in PDF's row-vector convention). + pub fn then(&self, then: &Matrix) -> Matrix { + let [a, b, c, d, e, f] = self.0; + let [a2, b2, c2, d2, e2, f2] = then.0; + Matrix([a * a2 + b * c2, a * b2 + b * d2, c * a2 + d * c2, c * b2 + d * d2, e * a2 + f * c2 + e2, e * b2 + f * d2 + f2]) + } + + pub fn apply(&self, x: f64, y: f64) -> (f64, f64) { + let [a, b, c, d, e, f] = self.0; + (a * x + c * y + e, b * x + d * y + f) + } + + pub fn invert(&self) -> Option { + let [a, b, c, d, e, f] = self.0; + let det = a * d - b * c; + if det.abs() < 1e-12 || !det.is_finite() { + return None; + } + let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det); + Some(Matrix([ia, ib, ic, id, -(e * ia + f * ic), -(e * ib + f * id)])) + } + + /// The bounding box of a rectangle `[x0 y0 x1 y1]` after this transform. + pub fn bbox(&self, r: [f64; 4]) -> [f64; 4] { + let pts = [self.apply(r[0], r[1]), self.apply(r[2], r[1]), self.apply(r[0], r[3]), self.apply(r[2], r[3])]; + let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + for (x, y) in pts { + b = [b[0].min(x), b[1].min(y), b[2].max(x), b[3].max(y)]; + } + b + } +} + +/// Do two rectangles overlap by more than `eps` in both directions? +pub fn overlaps(a: [f64; 4], b: [f64; 4], eps: f64) -> bool { + a[0].max(b[0]) + eps < a[2].min(b[2]) && a[1].max(b[1]) + eps < a[3].min(b[3]) +} + +/// Is `inner` inside `outer` (within `eps`)? +pub fn contains(outer: [f64; 4], inner: [f64; 4], eps: f64) -> bool { + inner[0] >= outer[0] - eps && inner[1] >= outer[1] - eps && inner[2] <= outer[2] + eps && inner[3] <= outer[3] + eps +} diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs new file mode 100644 index 000000000..7a4a680f3 --- /dev/null +++ b/crates/content/src/tests.rs @@ -0,0 +1,65 @@ +use super::*; + +#[test] +fn operators_keep_operands_and_spans() { + let src = b"q 1 0 0 1 72 720 cm BT /F1 12 Tf (Hello \\(world\\)) Tj [(A) -120 (B)] TJ ET Q"; + let p = parse(src); + assert_eq!(p.skipped, 0); + let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect(); + assert_eq!(names, [&b"q"[..], b"cm", b"BT", b"Tf", b"Tj", b"TJ", b"ET", b"Q"]); + let cm = &p.ops[1]; + assert_eq!(cm.nums::<6>(), Some([1.0, 0.0, 0.0, 1.0, 72.0, 720.0])); + assert_eq!(&src[cm.span.clone()], b"1 0 0 1 72 720 cm"); + assert_eq!(p.ops[4].operands[0].as_string().unwrap().bytes, b"Hello (world)"); + assert_eq!(p.ops[5].operands[0].as_array().unwrap().len(), 3); + assert_eq!(p.ops[3].name(0), Some(&b"F1"[..])); +} + +#[test] +fn round_trips_through_the_serializer() { + let src = b"0.5 g 10 10 100 50 re f /GS1 gs true false null 3 d0 [3 2] 0 d (x) ' 1 2 (y) \""; + let a = parse(src); + let b = parse(&serialize_ops(&a.ops)); + assert_eq!(a.ops.iter().map(|o| (&o.op, &o.operands)).collect::>(), b.ops.iter().map(|o| (&o.op, &o.operands)).collect::>()); +} + +#[test] +fn inline_images_stay_whole() { + let mut src = b"q 10 0 0 10 0 0 cm BI /W 2 /H 2 /CS /G /BPC 8 ID ".to_vec(); + src.extend_from_slice(&[0, b'E', b'I', 255]); + src.extend_from_slice(b"\nEI Q 1 g"); + let p = parse(&src); + let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect(); + assert_eq!(names, [&b"q"[..], b"cm", b"BI", b"Q", b"g"]); + let (d, data) = p.ops[2].inline.clone().unwrap(); + assert_eq!(d.int(b"W"), Some(2)); + assert_eq!(data, [0, b'E', b'I', 255], "an EI inside the data without whitespace before it is data"); + let again = parse(&serialize_ops(&p.ops)); + assert_eq!(again.ops[2].inline, p.ops[2].inline); +} + +#[test] +fn damage_is_skipped_not_fatal() { + let p = parse(b"1 0 0 RG ) ] 10 20 m 30 40 l S (unterminated"); + let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect(); + assert_eq!(names, [&b"RG"[..], b"m", b"l", b"S"]); + assert!(p.skipped >= 2); + assert!(parse(b"").ops.is_empty()); + assert!(parse(b"BI /W 1").ops.len() == 1); +} + +#[test] +fn matrices_compose_and_invert() { + let a = Matrix([2.0, 0.0, 0.0, 3.0, 10.0, 20.0]); + let b = Matrix([0.0, 1.0, -1.0, 0.0, 5.0, 0.0]); + let ab = a.then(&b); + let (x, y) = ab.apply(1.0, 1.0); + let (x1, y1) = a.apply(1.0, 1.0); + assert_eq!((x, y), b.apply(x1, y1)); + let inv = ab.invert().unwrap(); + let (u, v) = inv.apply(x, y); + assert!((u - 1.0).abs() < 1e-9 && (v - 1.0).abs() < 1e-9); + assert_eq!(Matrix([2.0, 0.0, 0.0, 2.0, 1.0, 1.0]).bbox([0.0, 0.0, 1.0, 1.0]), [1.0, 1.0, 3.0, 3.0]); + assert!(overlaps([0.0, 0.0, 10.0, 10.0], [5.0, 5.0, 20.0, 20.0], 0.0)); + assert!(!overlaps([0.0, 0.0, 10.0, 10.0], [10.0, 0.0, 20.0, 10.0], 0.0)); +} diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index b0c1f149d..b5ff4bb6a 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -89,6 +89,8 @@ pub struct Document { encrypt_num: Option, /// Encryption was added, changed or removed since opening: only a full save can apply it. encryption_changed: bool, + /// Set by edits that must not leave earlier revisions in the file (redaction). + full_save: bool, /// The handler and `/Encrypt` object number that saves use, when encryption changed. out_security: Option>, out_encrypt_num: Option, @@ -119,6 +121,7 @@ impl Document { security: None, encrypt_num: None, encryption_changed: false, + full_save: false, out_security: None, out_encrypt_num: None, }; @@ -171,6 +174,7 @@ impl Document { security: None, encrypt_num: None, encryption_changed: false, + full_save: false, out_security: None, out_encrypt_num: None, }; @@ -259,6 +263,17 @@ impl Document { self.security().map(|s| s.permissions()) } + /// Make the next save a full rewrite: earlier revisions (which still hold what an edit + /// removed) must not stay in the file. Redaction requires this. + pub fn require_full_save(&mut self) { + self.full_save = true; + } + + /// The next save must rewrite the whole file (see [`Document::require_full_save`]). + pub fn full_save_required(&self) -> bool { + self.full_save + } + /// Security was set or removed since the document was opened (the next save applies it). pub fn encryption_changed(&self) -> bool { self.encryption_changed diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index bcf7a27f9..858a30675 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -174,8 +174,8 @@ enum Row { /// which also repairs the file (what users expect after "the file was damaged and repaired"). pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result, CosError> { // Reconstructed files have no chain to append to; added or removed encryption must - // rewrite every object. Both need a full save. - if doc.revisions().is_empty() || doc.encryption_changed() { + // rewrite every object; redaction must not leave the old revision behind. All need a full save. + if doc.revisions().is_empty() || doc.encryption_changed() || doc.full_save_required() { return write_full(doc, opts); } let mut doc = doc.clone(); diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index e9a66319b..7f380c094 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -279,6 +279,17 @@ fn place_tagged(doc: &mut Document, page: &printcraft_model::Page, tag: &str, co Ok(()) } +/// Draw `content` on top of a page (0-based) as permanent page content, with standard Helvetica +/// available as `/PCHelv`. The stream is tagged `tag` (not a mark kind, so Remove never takes +/// it away); the original content is wrapped in q/Q first. Used for applied redaction boxes. +pub fn stamp(doc: &mut Document, page: usize, tag: &str, content: Vec) -> Result<(), EditError> { + let all = page_list(doc); + check(&[page], all.len())?; + add_resources(doc, &all[page], None)?; + let p = page_list(doc).swap_remove(page); + place_tagged(doc, &p, tag, content, false) +} + fn begin(kind: MarkKind, subtype: &str, matrix: [f64; 6]) -> String { format!( "q\n/Artifact <> BDC\n{} {} {} {} {} {} cm\n", diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 72c3b62a2..e1c9e4b77 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -15,6 +15,7 @@ printcraft-organize = { workspace = true } printcraft-annot = { workspace = true } printcraft-forms = { workspace = true } printcraft-edit = { workspace = true } +printcraft-redact = { workspace = true } printcraft-create = { workspace = true } printcraft-model = { workspace = true } thiserror = { workspace = true } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index b2f281eb3..f661bd8ba 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -278,13 +278,14 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ icon: "rectangle-horizontal", hue: RED, badge: None, - availability: Planned("M8"), + availability: Ready, sections: &[ToolSection { title: "Redact", items: &[ - item("Redact text and images", "rectangle-horizontal", "redact.mark", Planned("M8")), - item("Redact pages", "file-x", "redact.pages", Planned("M8")), - item("Find text and redact", "file-search", "redact.search", Planned("M8")), + item("Redact text and images", "rectangle-horizontal", "redact.mark", Ready), + item("Redact pages", "file-x", "redact.pages", Ready), + item("Find text and redact", "file-search", "redact.search", Ready), + item("Set properties", "settings-2", "redact.properties", Ready), item("Sanitize document", "sparkles", "redact.sanitize", Planned("M8")), ], }], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6de6ead98..87e13fa73 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -30,6 +30,8 @@ pub enum Needs { HasComments, /// The document has form fields and allows changes. HasFields, + /// The document has redaction marks and allows changes. + HasRedactions, /// The document has page marks of this kind and allows changes. Marks(crate::MarkKind), /// The document's security may be changed (owner, or nothing restricted). @@ -175,6 +177,12 @@ pub const COMMANDS: &[CommandSpec] = &[ c("comment.flatten", "Flatten comments", None, None, HasComments, "layers"), c("form.flatten", "Flatten form fields", None, None, HasFields, "layers"), c("form.prepare", "Prepare a form", None, None, Modification, "text-cursor-input"), + c("redact.mark", "Redact text and images", None, None, Modification, "rectangle-horizontal"), + c("redact.pages", "Redact pages…", None, None, Modification, "file-x"), + c("redact.search", "Find text and redact…", None, None, Modification, "file-search"), + c("redact.properties", "Redaction properties…", None, None, Document, "settings-2"), + c("redact.apply", "Apply redactions…", None, None, HasRedactions, "check"), + c("redact.clear", "Clear redaction marks", None, None, HasRedactions, "eraser"), c("form.field.properties", "Field properties…", None, None, HasFields, "settings-2"), c("form.add.text", "Add a text field", None, None, Modification, "text-cursor-input"), c("form.add.checkbox", "Add a checkbox", None, None, Modification, "check-circle-2"), @@ -250,6 +258,7 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option) Marks(k) => doc.is_some_and(|d| d.allows_modification() && d.marks.contains(&k)), HasComments => doc.is_some_and(|d| d.allows_modification() && !d.info.annotations.is_empty()), HasFields => doc.is_some_and(|d| d.allows_modification() && !d.form.is_empty()), + HasRedactions => doc.is_some_and(|d| d.allows_modification() && d.redaction_marks() > 0), Security => doc.is_some_and(|d| d.allows_security_change()), ProtectedSecurity => doc.is_some_and(|d| d.allows_security_change() && d.security_summary().is_some()), Undo => doc.is_some_and(|d| d.can_undo().is_some()), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e556020c8..f91fe19de 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -28,7 +28,8 @@ pub use printcraft_forms::{ /// Comment geometry helpers (text-box line breaking) for frontends. pub use printcraft_annot::appearance as annot_text; -pub use printcraft_annot::{FillMark, Markup, NewAnnotation, NoteIcon, Props as CommentProps, ReviewState, Rgb, Shape, Style}; +pub use printcraft_annot::{FillMark, Markup, NewAnnotation, NoteIcon, Props as CommentProps, ReviewState, Rgb, Shape, Style, rect_quad}; +pub use printcraft_redact::patterns::{PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern}; pub type SplitPart = (usize, usize, Arc>); @@ -146,6 +147,11 @@ impl Document { self.editable() && self.permissions().is_none_or(|p| p.assemble()) } + /// Redaction marks waiting to be applied. + pub fn redaction_marks(&self) -> usize { + self.info.annotations.iter().filter(|a| a.subtype == "Redact").count() + } + /// Changes to content and document information are allowed. pub fn allows_modification(&self) -> bool { self.editable() && self.permissions().is_none_or(|p| p.modify()) @@ -537,6 +543,12 @@ pub enum Edit { RemoveMarks { kind: MarkKind, }, + /// Apply redaction marks (all, or those on `pages`): remove what they cover for good. + ApplyRedactions { + pages: Option>, + }, + /// Remove redaction marks without applying them. + ClearRedactions, /// Flatten comments and/or form fields on every page into page content. Flatten { comments: bool, @@ -595,6 +607,8 @@ impl Edit { Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => "Remove header & footer".into(), Edit::RemoveMarks { kind: MarkKind::Watermark } => "Remove watermark".into(), Edit::RemoveMarks { kind: MarkKind::Background } => "Remove background".into(), + Edit::ApplyRedactions { .. } => "Apply redactions".into(), + Edit::ClearRedactions => "Remove redaction marks".into(), Edit::Flatten { comments: true, fields: false } => "Flatten comments".into(), Edit::Flatten { comments: false, fields: true } => "Flatten form fields".into(), Edit::Flatten { .. } => "Flatten".into(), @@ -625,6 +639,7 @@ fn annotation_noun(s: &Shape) -> &'static str { Shape::Mark { mark: FillMark::Dot, .. } => "dot", Shape::Mark { mark: FillMark::Line, .. } => "line", Shape::Signature { .. } => "signature", + Shape::Redact { .. } => "redaction mark", } } @@ -694,6 +709,8 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), | Edit::AddBackground { .. } | Edit::RemoveMarks { .. } | Edit::AddField { .. } + | Edit::ApplyRedactions { .. } + | Edit::ClearRedactions | Edit::SetFieldProps { .. } | Edit::DeleteField { .. } | Edit::Flatten { .. } => { @@ -843,6 +860,12 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) - return Err(EditError::Edit(printcraft_edit::EditError::Invalid("there is nothing to remove".into()))); } } + Edit::ApplyRedactions { pages } => { + printcraft_redact::apply(doc, pages.as_deref())?; + } + Edit::ClearRedactions => { + printcraft_redact::clear_marks(doc, None)?; + } Edit::Flatten { comments, fields } => { let n = printcraft_model::pages(doc).len(); printcraft_edit::flatten(doc, &(0..n).collect::>(), *comments, *fields)?; @@ -960,6 +983,8 @@ pub enum EditError { Protection(String), #[error("{0}")] Form(#[from] printcraft_forms::FormError), + #[error(transparent)] + Redact(#[from] printcraft_redact::RedactError), #[error("{0}")] Edit(#[from] printcraft_edit::EditError), #[error("{0}")] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3bcb39d8a..232846f68 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -690,6 +690,34 @@ fn preparing_a_form_adds_renames_and_deletes_fields() { assert_eq!(names(&s), ["Text1", "Check Box1", "email"]); } +#[test] +fn redaction_marks_apply_for_good_and_undo() { + let (mut s, id) = session_with(2); + // "Page 1" at 24 pt from x 20: the "1" starts near x 82.7 (Helvetica widths). + let shape = Shape::Redact { quads: vec![printcraft_annot::rect_quad([80.0, 140.0, 100.0, 180.0])], overlay: String::new() }; + let mark = + Edit::AddAnnotation(NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Ada".into() }); + s.apply(id, mark).unwrap(); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Add redaction mark")); + assert_eq!(s.get(id).unwrap().redaction_marks(), 1); + assert_eq!(page_texts(&s, id), ["Page 1", "Page 2"], "marking alone changes nothing"); + s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap(); + let d = s.get(id).unwrap(); + assert_eq!((d.can_undo(), d.redaction_marks()), (Some("Apply redactions"), 0)); + // An independent extractor (the renderer's) no longer finds the "1". + assert_eq!(page_texts(&s, id), ["Page", "Page 2"]); + let saved = s.save_bytes(id).unwrap(); + assert!(!saved.windows(8).any(|w| w == b"(Page 1)"), "a full rewrite: the old revision is gone"); + assert!(saved.windows(8).any(|w| w == b"(Page 2)")); + let mut s2 = Session::new(); + let id2 = s2.open("r.pdf", None, saved, None).unwrap(); + assert_eq!(page_texts(&s2, id2), ["Page", "Page 2"]); + s.undo(id).unwrap(); + assert_eq!(page_texts(&s, id), ["Page 1", "Page 2"]); + assert_eq!(s.apply(id, Edit::ClearRedactions).map(|_| s.get(id).unwrap().redaction_marks()), Ok(0)); + assert!(matches!(s.apply(id, Edit::ApplyRedactions { pages: None }), Err(EditError::Redact(_)))); +} + #[test] fn crop_and_duplicate_pages_show_in_the_viewer() { let (mut s, id) = session_with(2); diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index e810e562a..933a0a161 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -518,8 +518,16 @@ pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> { Some(p) => remove_from(doc, p, b"Kids")?, None => { let root = doc.root().ok_or(FormError::NoForm)?; - if let Some(af) = doc.get(root).as_dict().and_then(|d| d.reference(b"AcroForm")) { - remove_from(doc, af, b"Fields")?; + match doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) { + Some(Object::Ref(af)) => remove_from(doc, af, b"Fields")?, + // A form dictionary held directly in the catalog. + Some(Object::Dict(mut af)) => { + let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(f.obj)).collect(); + af.set(b"Fields".to_vec(), Object::Array(kept)); + doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + _ => {} } } } diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 56e128527..b013f7760 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -307,3 +307,16 @@ fn properties_rename_and_delete() { let annots = doc.resolve(doc.get(*p).as_dict().unwrap().get(b"Annots").unwrap()); assert_eq!(annots.as_array().unwrap().len(), 13 - 3, "the deleted widgets left the page"); } + +#[test] +fn deleting_works_with_a_form_dictionary_inside_the_catalog() { + let mut doc = fixture(); + let root = doc.root().unwrap(); + let af = doc.get(printcraft_cos::ObjRef::new(4, 0)).as_dict().cloned().unwrap(); + doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af))).unwrap(); + let before = fields(&doc).len(); + delete_field(&mut doc, "name").unwrap(); + let all = fields(&reopen(&doc)); + assert_eq!(all.len(), before - 1); + assert!(!all.iter().any(|f| f.name == "name")); +} diff --git a/crates/redact/Cargo.toml b/crates/redact/Cargo.toml new file mode 100644 index 000000000..6822106b4 --- /dev/null +++ b/crates/redact/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "printcraft-redact" +description = "Redaction (architecture §11.2): marks, apply (glyphs, images, vectors, XObjects, annotations, fields), verification" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +printcraft-cos = { workspace = true } +printcraft-content = { workspace = true } +printcraft-fonts = { workspace = true } +printcraft-model = { workspace = true } +printcraft-forms = { workspace = true } +printcraft-edit = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +printcraft-annot = { workspace = true } diff --git a/crates/redact/README.md b/crates/redact/README.md new file mode 100644 index 000000000..13803fb4e --- /dev/null +++ b/crates/redact/README.md @@ -0,0 +1,37 @@ +# printcraft-redact + +Layer L4 (may use `edit`): redaction, architecture §11.2. + +```rust +// Marks are Redact annotations (printcraft-annot: Shape::Redact { quads, overlay }). +let marks: Vec = marks(&doc); // page, areas (user space), fill, overlay text +let report = apply(&mut doc, None)?; // everything, or Some(&[pages]) +clear_marks(&mut doc, None)?; // remove marks without applying +``` + +`apply` removes, under every marked area: + +- **text**: glyphs are cut out of `Tj`/`TJ`/`'`/`"` and replaced by a `TJ` displacement of the + same advance, so the rest of the line keeps its exact position. Widths come from `/Widths`, + `/W`/`/DW` (composite fonts, embedded CMaps with codespace and CID ranges), Type 3 font + matrices, and approximations for the standard 14. A glyph goes when it overlaps an area by + more than a fifth of its size (or a point) both ways; zero-size text goes when its origin is + inside; +- **images**: fully covered → removed; partly covered → a copy with the covered pixels cleared + (8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx). Codecs PrintCraft can't + re-encode (DCT, JPX, JBIG2, CCITT) are removed whole (fail-closed); inline images under an + area are removed; +- **vectors**: covered paths are removed; partly covered paths and shadings are clipped so + nothing paints inside the areas; +- **form XObjects**: rewritten recursively into new objects (pages sharing the original keep it); +- **annotations** whose rectangle overlaps (with their pop-ups), and **form fields** with a + widget under a mark; +- the marks themselves, replaced by boxes in their fill colour (and overlay text) drawn into the + page as a `/PCMark /Redaction` stream. + +A verification pass re-reads each redacted page and fails the operation if any glyph or inline +image is still under an area. Content that can't be decoded makes the operation fail rather than +leave it unredacted. + +Not yet: search-and-redact patterns, struct-tree/alt-text cleanup, metadata and hidden-information +sanitising (M8.4), re-encoding of DCT images (they are removed instead). diff --git a/crates/redact/src/fonts.rs b/crates/redact/src/fonts.rs new file mode 100644 index 000000000..16cb034db --- /dev/null +++ b/crates/redact/src/fonts.rs @@ -0,0 +1,281 @@ +//! What redaction needs from a font: how to split a string into character codes, each code's +//! advance width, and the glyph height (ascent, descent). Widths come from the font dictionary +//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); the standard 14 fonts without widths use +//! approximations (a slightly wrong width moves a glyph box by a fraction of a glyph, which the +//! overlap rule tolerates). + +use std::collections::HashMap; + +use printcraft_cos::{Dict, Document, Object}; + +/// How the bytes of a string map to codes. +#[derive(Clone, Debug, PartialEq)] +enum Codes { + One, + Two, + /// Codespace ranges `(length, low, high)` from an embedded CMap. + Ranges(Vec<(usize, Vec, Vec)>), +} + +#[derive(Clone, Debug, PartialEq)] +enum Std14 { + Helvetica, + Times, + Courier, + Symbolic, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Metrics { + codes: Codes, + /// Simple fonts: `/FirstChar` and `/Widths`. + first: u32, + widths: Vec, + /// Composite fonts: CID → width, and CID ranges with one width. + cid_widths: HashMap, + cid_ranges: Vec<(u32, u32, f64)>, + /// Code → CID for embedded non-identity CMaps (`cidrange` / `cidchar`). + cid_map: Vec<(u32, u32, u32)>, + default: f64, + std14: Option, + /// Glyph units → text space (0.001, or `/FontMatrix[0]` for Type 3). + pub scale: f64, + /// Glyph box in text space per unit of font size. + pub ascent: f64, + pub descent: f64, + pub composite: bool, +} + +fn nums(doc: &Document, o: Option<&Object>) -> Vec { + o.map(|o| doc.resolve(o)) + .and_then(|a| a.as_array().map(|a| a.iter().map(|x| doc.resolve(x).as_f64().unwrap_or(0.0)).collect())) + .unwrap_or_default() +} + +fn dict(doc: &Document, o: Option<&Object>) -> Option { + o.and_then(|o| doc.resolve(o).as_dict().cloned()) +} + +fn hex_bytes(tok: &str) -> Option> { + let t = tok.trim_start_matches('<').trim_end_matches('>'); + if !t.len().is_multiple_of(2) || t.is_empty() { + return None; + } + (0..t.len()).step_by(2).map(|i| u8::from_str_radix(&t[i..i + 2], 16).ok()).collect() +} + +fn be(b: &[u8]) -> u32 { + b.iter().fold(0u32, |a, x| (a << 8) | u32::from(*x)) +} + +/// Codespace ranges and CID mappings from an embedded CMap stream. +#[allow(clippy::type_complexity)] +fn parse_cmap(data: &[u8]) -> (Vec<(usize, Vec, Vec)>, Vec<(u32, u32, u32)>) { + let text = String::from_utf8_lossy(data); + let toks: Vec<&str> = text.split_whitespace().collect(); + let (mut spaces, mut cids) = (Vec::new(), Vec::new()); + let mut i = 0; + while i < toks.len() { + match toks[i] { + "begincodespacerange" => { + i += 1; + while i + 1 < toks.len() && toks[i] != "endcodespacerange" { + if let (Some(lo), Some(hi)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1])) + && lo.len() == hi.len() + { + spaces.push((lo.len(), lo, hi)); + } + i += 2; + } + } + "begincidrange" => { + i += 1; + while i + 2 < toks.len() && toks[i] != "endcidrange" { + if let (Some(lo), Some(hi), Ok(c)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1]), toks[i + 2].parse::()) { + cids.push((be(&lo), be(&hi), c)); + } + i += 3; + } + } + "begincidchar" => { + i += 1; + while i + 1 < toks.len() && toks[i] != "endcidchar" { + if let (Some(code), Ok(c)) = (hex_bytes(toks[i]), toks[i + 1].parse::()) { + cids.push((be(&code), be(&code), c)); + } + i += 2; + } + } + _ => {} + } + i += 1; + } + spaces.sort_by_key(|s| s.0); + (spaces, cids) +} + +impl Metrics { + /// Metrics for text without a usable font (Helvetica-like). + pub fn fallback() -> Self { + Metrics { + codes: Codes::One, + first: 0, + widths: Vec::new(), + cid_widths: HashMap::new(), + cid_ranges: Vec::new(), + cid_map: Vec::new(), + default: 500.0, + std14: Some(Std14::Helvetica), + scale: 0.001, + ascent: 0.9, + descent: -0.25, + composite: false, + } + } + + pub fn from_dict(doc: &Document, font: &Dict) -> Self { + let mut m = Metrics::fallback(); + m.std14 = None; + let subtype = font.name(b"Subtype").unwrap_or(b"Type1").to_vec(); + let descriptor; + if subtype == b"Type0" { + m.composite = true; + m.default = 1000.0; + m.codes = Codes::Two; + if let Some(e) = font.get(b"Encoding").map(|e| doc.resolve(e)) + && let Object::Stream(s) = &*e + && let Ok(data) = s.decoded() + { + let (spaces, cids) = parse_cmap(&data); + if !spaces.is_empty() { + m.codes = Codes::Ranges(spaces); + } + m.cid_map = cids; + } + let desc = font + .get(b"DescendantFonts") + .map(|d| doc.resolve(d)) + .and_then(|a| a.as_array().and_then(|a| a.first().cloned())) + .and_then(|d| doc.resolve(&d).as_dict().cloned()) + .unwrap_or_default(); + if let Some(dw) = desc.get(b"DW").and_then(|d| doc.resolve(d).as_f64()) { + m.default = dw; + } + if let Some(w) = desc.get(b"W").map(|w| doc.resolve(w)).and_then(|w| w.as_array().cloned()) { + let mut i = 0; + while i < w.len() { + let Some(c0) = doc.resolve(&w[i]).as_f64().map(|v| v as u32) else { break }; + match w.get(i + 1).map(|o| doc.resolve(o)) { + Some(a) if a.as_array().is_some() => { + for (k, x) in a.as_array().into_iter().flatten().enumerate() { + if let Some(v) = doc.resolve(x).as_f64() { + m.cid_widths.insert(c0 + k as u32, v); + } + } + i += 2; + } + Some(c1) => { + if let (Some(c1), Some(v)) = (c1.as_f64(), w.get(i + 2).and_then(|x| doc.resolve(x).as_f64())) { + m.cid_ranges.push((c0, c1 as u32, v)); + } + i += 3; + } + None => break, + } + } + } + descriptor = dict(doc, desc.get(b"FontDescriptor")); + } else { + m.first = font.get(b"FirstChar").and_then(|f| doc.resolve(f).as_f64()).unwrap_or(0.0).max(0.0) as u32; + m.widths = nums(doc, font.get(b"Widths")); + descriptor = dict(doc, font.get(b"FontDescriptor")); + m.default = descriptor.as_ref().and_then(|d| d.get(b"MissingWidth")).and_then(|w| doc.resolve(w).as_f64()).unwrap_or(0.0); + if subtype == b"Type3" { + let fm = nums(doc, font.get(b"FontMatrix")); + if fm.len() == 6 { + m.scale = fm[0]; + let bbox = nums(doc, font.get(b"FontBBox")); + if bbox.len() == 4 && fm[3] != 0.0 { + m.ascent = (bbox[3] * fm[3]).max(0.5); + m.descent = (bbox[1] * fm[3]).min(-0.1); + } + } + return m; + } + if m.widths.is_empty() { + let base = String::from_utf8_lossy(font.name(b"BaseFont").unwrap_or(b"")).to_ascii_lowercase(); + m.std14 = Some(if base.contains("courier") { + Std14::Courier + } else if base.contains("times") { + Std14::Times + } else if base.contains("symbol") || base.contains("dingbats") { + Std14::Symbolic + } else { + Std14::Helvetica + }); + } + } + if let Some(d) = descriptor { + let a = d.get(b"Ascent").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0) / 1000.0; + let de = d.get(b"Descent").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0) / 1000.0; + // Fonts often claim 0; never shrink the glyph box below a sensible minimum. + m.ascent = if a > 0.3 { a.min(1.5) } else { 0.9 }; + m.descent = if de < -0.05 { de.max(-0.6) } else { -0.25 }; + } + m + } + + /// Split a string into `(code, byte length)`. + pub fn codes(&self, s: &[u8]) -> Vec<(u32, usize)> { + let mut out = Vec::with_capacity(s.len()); + let mut i = 0; + while i < s.len() { + let len = match &self.codes { + Codes::One => 1, + Codes::Two => 2, + Codes::Ranges(r) => r + .iter() + .find(|(len, lo, hi)| s.get(i..i + len).is_some_and(|b| b.iter().zip(lo.iter().zip(hi)).all(|(x, (l, h))| x >= l && x <= h))) + .map_or(r.first().map_or(1, |x| x.0), |x| x.0), + }; + let len = len.min(s.len() - i).max(1); + out.push((be(&s[i..i + len]), len)); + i += len; + } + out + } + + fn cid(&self, code: u32) -> u32 { + if self.cid_map.is_empty() { + return code; + } + self.cid_map.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map_or(0, |(lo, _, c)| c + (code - lo)) + } + + /// The advance of `code` in text space per unit of font size. + pub fn width(&self, code: u32) -> f64 { + if self.composite { + let cid = self.cid(code); + let w = self.cid_widths.get(&cid).copied().or_else(|| self.cid_ranges.iter().find(|(a, b, _)| cid >= *a && cid <= *b).map(|r| r.2)); + return w.unwrap_or(self.default) * self.scale; + } + if let Some(w) = code.checked_sub(self.first).and_then(|i| self.widths.get(i as usize)) { + return w * self.scale; + } + match &self.std14 { + Some(Std14::Courier) => 0.6, + Some(Std14::Symbolic) => 0.75, + Some(f) => { + let c = char::from_u32(code).filter(|c| !c.is_control()).unwrap_or('n'); + let w = printcraft_fonts::helvetica_width(&c.to_string(), 1.0); + if *f == Std14::Times { w * 0.92 } else { w } + } + None => self.default * self.scale, + } + } + + /// Word spacing applies to the single-byte code 32 (§9.3.3). + pub fn is_space(&self, code: u32, len: usize) -> bool { + code == 32 && len == 1 + } +} diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs new file mode 100644 index 000000000..f76dce939 --- /dev/null +++ b/crates/redact/src/image.rs @@ -0,0 +1,118 @@ +//! Clearing the pixels of an image under redaction regions. The image is decoded, every pixel +//! whose centre maps into a region is set to zero (no ink for image masks), and the result is +//! written as a new Flate image; the original object is untouched (other pages may use it). +//! Images whose codec PrintCraft can't decode (DCT, JPX, JBIG2, CCITT) return `None`, and the +//! caller removes the whole image instead; that is fail-closed. + +use printcraft_content::{Matrix, overlaps}; +use printcraft_cos::{Document, Object, Stream}; + +/// Most pixels an image may have to be cleared in place (larger ones are removed). +const MAX_PIXELS: u64 = 64 << 20; + +fn components(doc: &Document, s: &Stream) -> Option { + let cs = doc.resolve(s.dict.get(b"ColorSpace")?); + let cs = &*cs; + let name = match cs { + Object::Name(n) => n.as_slice(), + Object::Array(a) => a.first()?.as_name()?, + _ => return None, + }; + Some(match name { + b"DeviceGray" | b"G" | b"CalGray" | b"Indexed" | b"I" | b"Separation" => 1, + b"DeviceRGB" | b"RGB" | b"CalRGB" | b"Lab" => 3, + b"DeviceCMYK" | b"CMYK" => 4, + b"ICCBased" => { + // /N lives in the ICC profile stream. + match &*doc.resolve(cs.as_array()?.get(1)?) { + Object::Stream(icc) => icc.dict.int(b"N")? as usize, + _ => return None, + } + } + _ => return None, + }) +} + +/// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel centre is covered, +/// `Err(())` when the image can't be cleared (the caller removes it). +#[allow(clippy::result_unit_err)] +pub(crate) fn clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Result, ()> { + decode_and_clear(doc, s, ctm, rects).ok_or(()) +} + +fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Option> { + let supported = |n: &[u8]| { + matches!( + n, + b"FlateDecode" | b"Fl" | b"LZWDecode" | b"LZW" | b"ASCII85Decode" | b"A85" | b"ASCIIHexDecode" | b"AHx" | b"RunLengthDecode" | b"RL" + ) + }; + let ok = match s.dict.get(b"Filter") { + None => true, + Some(Object::Name(n)) => supported(n), + Some(Object::Array(a)) => a.iter().all(|f| f.as_name().is_some_and(supported)), + _ => false, + }; + if !ok { + return None; + } + let (w, h) = (s.dict.int(b"Width")?, s.dict.int(b"Height")?); + if w <= 0 || h <= 0 || (w as u64) * (h as u64) > MAX_PIXELS { + return None; + } + let (w, h) = (w as usize, h as usize); + let mask = matches!(s.dict.get(b"ImageMask"), Some(Object::Bool(true))); + let (ncomp, bpc) = if mask { (1, 1) } else { (components(doc, s)?, s.dict.int(b"BitsPerComponent")? as usize) }; + if !matches!(bpc, 1 | 2 | 4 | 8 | 16) { + return None; + } + let mut data = s.decoded().ok()?; + let row = (w * ncomp * bpc).div_ceil(8); + if data.len() < row * h { + return None; + } + data.truncate(row * h); + // Image masks: a sample of 0 paints unless /Decode is [1 0]; cleared samples must not paint. + let clear_bit = if mask { + let inverted = s.dict.get(b"Decode").and_then(Object::as_array).and_then(|d| d.first()).and_then(Object::as_f64) == Some(1.0); + !inverted + } else { + false + }; + let bits_per_pixel = ncomp * bpc; + let mut cleared = 0usize; + for j in 0..h { + // Image space: (0,0) is the bottom-left of the unit square; row 0 is the top. + let v = 1.0 - (j as f64 + 0.5) / h as f64; + let (ax, ay) = ctm.apply(0.0, v); + let (bx, by) = ctm.apply(1.0, v); + let row_box = [ax.min(bx), ay.min(by), ax.max(bx), ay.max(by)]; + if !rects.iter().any(|r| overlaps(*r, [row_box[0], row_box[1] - 0.01, row_box[2], row_box[3] + 0.01], 0.0)) { + continue; + } + for i in 0..w { + let u = (i as f64 + 0.5) / w as f64; + let (x, y) = ctm.apply(u, v); + if !rects.iter().any(|r| x >= r[0] && x <= r[2] && y >= r[1] && y <= r[3]) { + continue; + } + cleared += 1; + let start = j * row * 8 + i * bits_per_pixel; + for bit in start..start + bits_per_pixel { + let (byte, shift) = (bit / 8, 7 - bit % 8); + if clear_bit { + data[byte] |= 1 << shift; + } else { + data[byte] &= !(1 << shift); + } + } + } + } + if cleared == 0 { + return Some(None); + } + let mut dict = s.dict.clone(); + dict.remove(b"Length"); + dict.remove(b"DecodeParms"); + Some(Some(Stream::flate(dict, &data))) +} diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs new file mode 100644 index 000000000..93c7821b7 --- /dev/null +++ b/crates/redact/src/interp.rs @@ -0,0 +1,546 @@ +//! The content interpreter that applies (or verifies) redaction on one content scope: a page's +//! streams or a form XObject's. +//! +//! It tracks just enough graphics state to place every glyph, image and path in user space: +//! the CTM stack, the text state (font, size, spacing, scaling, rise, leading) and the text +//! matrices. In apply mode it rewrites the operators: +//! - text: glyphs whose boxes overlap a region are cut out of `Tj`/`TJ`/`'`/`"`, replaced by a +//! `TJ` displacement of the same width so the remaining text stays exactly where it was; +//! - images: fully covered → the `Do` is removed; partly covered → the covered pixels are +//! cleared in a copy of the image (or the image is removed when its codec can't be re-encoded); +//! inline images under a region are removed; +//! - vectors: fully covered paths are removed; partly covered ones are clipped so nothing shows +//! inside the regions; shadings are clipped the same way; +//! - form XObjects under a region are rewritten recursively into new objects (copy-on-write, so +//! other pages using the original are untouched). +//! +//! In verify mode nothing changes; glyphs and inline images that still overlap a region are +//! counted. + +use std::collections::HashMap; +use std::rc::Rc; + +use printcraft_content::{Matrix, Op, contains, num, overlaps, parse, serialize_ops, string}; +use printcraft_cos::{Dict, Document, ObjRef, Object, Stream}; + +use crate::fonts::Metrics; +use crate::{Report, image}; + +const MAX_DEPTH: usize = 12; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Mode { + Apply, + Verify, +} + +#[derive(Clone)] +struct Gs { + ctm: Matrix, + font: Rc, + size: f64, + char_spacing: f64, + word_spacing: f64, + scale: f64, + leading: f64, + rise: f64, +} + +/// What processing a scope produced. +#[derive(Default)] +pub(crate) struct Output { + /// The rewritten streams (`None` = unchanged). + pub streams: Vec>>, + /// New XObjects the rewritten content refers to: (resource name, object). + pub xobjects: Vec<(Vec, ObjRef)>, + /// Glyphs / inline images still overlapping a region (verify mode). + pub residue: usize, +} + +pub(crate) struct Scope<'a> { + pub rects: &'a [[f64; 4]], + pub mode: Mode, + pub report: &'a mut Report, + fonts: HashMap, Rc>, + used_names: Vec>, + depth: usize, +} + +impl<'a> Scope<'a> { + pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report) -> Self { + Scope { rects, mode, report, fonts: HashMap::new(), used_names: Vec::new(), depth: 0 } + } + + fn hits(&self, b: [f64; 4]) -> bool { + self.rects.iter().any(|r| overlaps(*r, b, 0.0)) + } + + fn covered(&self, b: [f64; 4]) -> bool { + self.rects.iter().any(|r| contains(*r, b, 0.01)) + } + + /// Does a glyph box count as under a region? Any real overlap does (more than a fifth of + /// the glyph or a point, whichever is less, in both directions), so neighbours that only + /// touch an edge survive while partly covered glyphs go. Degenerate boxes (zero-size text) + /// count when their origin is inside. + fn glyph_hit(&self, b: [f64; 4], origin: (f64, f64)) -> bool { + let (gw, gh) = (b[2] - b[0], b[3] - b[1]); + if gw < 0.01 || gh < 0.01 { + return self.rects.iter().any(|r| origin.0 >= r[0] && origin.0 <= r[2] && origin.1 >= r[1] && origin.1 <= r[3]); + } + let (mx, my) = ((gw * 0.2).min(1.0), (gh * 0.2).min(1.0)); + self.rects.iter().any(|r| { + let ox = b[2].min(r[2]) - b[0].max(r[0]); + let oy = b[3].min(r[3]) - b[1].max(r[1]); + ox > mx && oy > my + }) + } +} + +fn res_dict(doc: &Document, res: &Dict, key: &[u8]) -> Dict { + res.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default() +} + +fn fresh_name(scope: &mut Scope<'_>, xobjects: &Dict) -> Vec { + let mut i = scope.used_names.len() + 1; + loop { + let n = format!("PCRedacted{i}").into_bytes(); + if !xobjects.contains(&n) && !scope.used_names.contains(&n) { + scope.used_names.push(n.clone()); + return n; + } + i += 1; + } +} + +/// The clip that hides everything inside each region: one even-odd clip per region (a big +/// rectangle with the region cut out), in the current user space. Returns `None` when the CTM +/// can't be inverted (nothing is painted then anyway). +fn clip_out(rects: &[[f64; 4]], ctm: &Matrix, around: [f64; 4]) -> Option> { + let inv = ctm.invert()?; + let mut ops = Vec::new(); + let big = [around[0] - 10.0, around[1] - 10.0, around[2] + 10.0, around[3] + 10.0]; + for r in rects.iter().filter(|r| overlaps(**r, around, 0.0)) { + let outer = [(big[0], big[1]), (big[2], big[1]), (big[2], big[3]), (big[0], big[3])]; + let inner = [(r[0], r[1]), (r[0], r[3]), (r[2], r[3]), (r[2], r[1])]; + for poly in [outer, inner] { + for (k, (x, y)) in poly.iter().enumerate() { + let (u, v) = inv.apply(*x, *y); + ops.push(Op::new(if k == 0 { "m" } else { "l" }, vec![num(u), num(v)])); + } + ops.push(Op::new("h", vec![])); + } + ops.push(Op::new("W*", vec![])); + ops.push(Op::new("n", vec![])); + } + Some(ops) +} + +/// One glyph of a shown string. +struct Glyph { + bytes: std::ops::Range, + /// Advance in unscaled text space (before `Th`). + advance: f64, + hit: bool, +} + +/// Process a scope's content streams (state flows from one stream to the next). +pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, ctm: Matrix) -> Output { + let mut out = Output::default(); + let fallback = Rc::new(Metrics::fallback()); + let mut gs = Gs { ctm, font: fallback.clone(), size: 0.0, char_spacing: 0.0, word_spacing: 0.0, scale: 1.0, leading: 0.0, rise: 0.0 }; + let mut stack: Vec = Vec::new(); + let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); + let fonts_res = res_dict(doc, resources, b"Font"); + let mut xobjects = res_dict(doc, resources, b"XObject"); + // The current path: its operators, its bounding box in user space, and whether it clips. + let mut path: Vec = Vec::new(); + let mut path_box: Option<[f64; 4]> = None; + let mut clip = false; + + for data in streams { + let parsed = parse(data); + let mut ops: Vec = Vec::with_capacity(parsed.ops.len()); + let mut changed = false; + for op in parsed.ops { + let o = op.op.as_slice(); + // Path construction. + if matches!(o, b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*") { + let pts: Vec<(f64, f64)> = match o { + b"re" => op.nums::<4>().map(|[x, y, w, h]| vec![(x, y), (x + w, y), (x, y + h), (x + w, y + h)]).unwrap_or_default(), + b"h" | b"W" | b"W*" => Vec::new(), + _ => op.operands.iter().filter_map(Object::as_f64).collect::>().chunks_exact(2).map(|p| (p[0], p[1])).collect(), + }; + for (x, y) in pts { + let (u, v) = gs.ctm.apply(x, y); + path_box = Some(match path_box { + None => [u, v, u, v], + Some(b) => [b[0].min(u), b[1].min(v), b[2].max(u), b[3].max(v)], + }); + } + clip |= matches!(o, b"W" | b"W*"); + path.push(op); + continue; + } + if matches!(o, b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { + let body = std::mem::take(&mut path); + let bbox = path_box.take(); + let clips = std::mem::replace(&mut clip, false); + let hit = scope.mode == Mode::Apply && o != b"n" && bbox.is_some_and(|b| scope.hits(b)); + if !hit { + ops.extend(body); + ops.push(op); + continue; + } + let b = bbox.unwrap_or_default(); + changed = true; + if scope.covered(b) { + scope.report.paths_removed += 1; + if clips { + // Keep the clipping, drop the painting. + ops.extend(body); + ops.push(Op::new("n", vec![])); + } + } else if clips { + // A clip-and-paint path can't be wrapped in q/Q without losing its clip: + // paint it clipped, then set its clip with a no-op path. + scope.report.paths_clipped += 1; + if let Some(c) = clip_out(scope.rects, &gs.ctm, b) { + let plain: Vec = body.iter().filter(|p| !p.is("W") && !p.is("W*")).cloned().collect(); + ops.push(Op::new("q", vec![])); + ops.extend(c); + ops.extend(plain); + ops.push(op); + ops.push(Op::new("Q", vec![])); + } + ops.extend(body); + ops.push(Op::new("n", vec![])); + } else { + scope.report.paths_clipped += 1; + if let Some(c) = clip_out(scope.rects, &gs.ctm, b) { + ops.push(Op::new("q", vec![])); + ops.extend(c); + ops.extend(body); + ops.push(op); + ops.push(Op::new("Q", vec![])); + } + } + continue; + } + if !path.is_empty() { + // A path not ended by a painting operator: pass it through. + ops.append(&mut path); + path_box = None; + clip = false; + } + match o { + b"q" => stack.push(gs.clone()), + b"Q" => { + if let Some(g) = stack.pop() { + gs = g; + } + } + b"cm" => { + if let Some(m) = op.nums::<6>() { + gs.ctm = Matrix(m).then(&gs.ctm); + } + } + b"BT" => { + tm = Matrix::IDENTITY; + tlm = Matrix::IDENTITY; + } + b"Tf" => { + gs.size = op.num(1).unwrap_or(gs.size); + if let Some(name) = op.name(0) { + gs.font = match scope.fonts.get(name) { + Some(f) => f.clone(), + None => { + let m = fonts_res + .get(name) + .and_then(|f| doc.resolve(f).as_dict().cloned()) + .map(|d| Rc::new(Metrics::from_dict(doc, &d))) + .unwrap_or_else(|| fallback.clone()); + scope.fonts.insert(name.to_vec(), m.clone()); + m + } + }; + } + } + b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), + b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), + b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, + b"TL" => gs.leading = op.num(0).unwrap_or(0.0), + b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), + b"Td" | b"TD" => { + if let Some([x, y]) = op.nums::<2>() { + if o == b"TD" { + gs.leading = -y; + } + tlm = Matrix::translate(x, y).then(&tlm); + tm = tlm; + } + } + b"Tm" => { + if let Some(m) = op.nums::<6>() { + tlm = Matrix(m); + tm = tlm; + } + } + b"T*" => { + tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); + tm = tlm; + } + b"Tj" | b"TJ" | b"'" | b"\"" => { + if o == b"\"" { + gs.word_spacing = op.num(0).unwrap_or(gs.word_spacing); + gs.char_spacing = op.num(1).unwrap_or(gs.char_spacing); + } + if matches!(o, b"'" | b"\"") { + tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); + tm = tlm; + } + let items: Vec = match o { + b"TJ" => op.operands.first().and_then(Object::as_array).cloned().unwrap_or_default(), + _ => op.operands.last().cloned().into_iter().collect(), + }; + let (new_items, removed) = show(scope, &gs, &mut tm, &items); + if removed == 0 { + ops.push(op); + continue; + } + if scope.mode == Mode::Verify { + out.residue += removed; + ops.push(op); + continue; + } + changed = true; + scope.report.glyphs += removed; + match o { + b"\"" => { + ops.push(Op::new("Tw", vec![num(gs.word_spacing)])); + ops.push(Op::new("Tc", vec![num(gs.char_spacing)])); + ops.push(Op::new("T*", vec![])); + } + b"'" => ops.push(Op::new("T*", vec![])), + _ => {} + } + ops.push(Op::new("TJ", vec![Object::Array(new_items)])); + continue; + } + b"BI" => { + let b = gs.ctm.bbox([0.0, 0.0, 1.0, 1.0]); + if scope.hits(b) { + if scope.mode == Mode::Verify { + out.residue += 1; + } else { + scope.report.images_removed += 1; + changed = true; + continue; + } + } + } + b"sh" if scope.mode == Mode::Apply => { + // A shading fills the current clip: clip the regions out. + if let Some(c) = clip_out(scope.rects, &gs.ctm, [-1e6, -1e6, 1e6, 1e6]) { + changed = true; + ops.push(Op::new("q", vec![])); + ops.extend(c); + ops.push(op); + ops.push(Op::new("Q", vec![])); + continue; + } + } + b"Do" => { + if let Some(new) = xobject(doc, scope, &op, &gs, resources, &mut xobjects, &mut out) { + changed = true; + if let Some(n) = new { + ops.push(n); + } + continue; + } + } + _ => {} + } + ops.push(op); + } + if !path.is_empty() { + ops.append(&mut path); + } + out.streams.push(changed.then(|| serialize_ops(&ops))); + } + out +} + +/// Lay out a shown string (or `TJ` array) glyph by glyph, advancing `tm`. Returns the `TJ` +/// items with the glyphs under a region replaced by displacements, and how many were removed. +fn show(scope: &Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (Vec, usize) { + let f = &gs.font; + let size = gs.size; + let mut out: Vec = Vec::new(); + let mut removed = 0; + let push_num = |out: &mut Vec, v: f64| { + if v == 0.0 { + return; + } + if let Some(last) = out.last_mut() + && let Some(prev) = last.as_f64() + { + *last = num(prev + v); + return; + } + out.push(num(v)); + }; + for item in items { + match item { + Object::String(s) => { + let bytes = &s.bytes; + let mut glyphs = Vec::new(); + let mut pos = 0; + for (code, len) in f.codes(bytes) { + let w0 = f.width(code); + let spacing = gs.char_spacing + if f.is_space(code, len) { gs.word_spacing } else { 0.0 }; + let trm = Matrix([size * gs.scale, 0.0, 0.0, size, 0.0, gs.rise]).then(tm).then(&gs.ctm); + let b = trm.bbox([0.0, f.descent, w0, f.ascent]); + let origin = trm.apply(0.0, 0.0); + let advance = w0 * size + spacing; + glyphs.push(Glyph { bytes: pos..pos + len, advance, hit: scope.glyph_hit(b, origin) }); + *tm = Matrix::translate(advance * gs.scale, 0.0).then(tm); + pos += len; + } + let mut run: Vec = Vec::new(); + for g in &glyphs { + if g.hit { + removed += 1; + if !run.is_empty() { + out.push(string(std::mem::take(&mut run))); + } + // A TJ number moves by -n/1000 × size (× Th): the removed advance. + if size != 0.0 { + push_num(&mut out, -g.advance * 1000.0 / size); + } + } else { + run.extend_from_slice(&bytes[g.bytes.clone()]); + } + } + if !run.is_empty() { + out.push(string(run)); + } + } + other => { + if let Some(v) = other.as_f64() { + *tm = Matrix::translate(-v / 1000.0 * size * gs.scale, 0.0).then(tm); + push_num(&mut out, v); + } + } + } + } + (out, removed) +} + +/// `Do`: `None` = leave the operator alone; `Some(None)` = drop it; `Some(Some(op))` = replace it. +fn xobject( + doc: &mut Document, + scope: &mut Scope<'_>, + op: &Op, + gs: &Gs, + resources: &Dict, + xobjects: &mut Dict, + out: &mut Output, +) -> Option> { + let name = op.name(0)?.to_vec(); + let r = xobjects.get(&name)?.as_ref()?; + let obj = doc.get(r); + let Object::Stream(s) = &*obj else { return None }; + match s.dict.name(b"Subtype") { + Some(b"Image") => { + let b = gs.ctm.bbox([0.0, 0.0, 1.0, 1.0]); + if !scope.hits(b) || scope.mode == Mode::Verify { + return None; + } + if scope.covered(b) { + scope.report.images_removed += 1; + return Some(None); + } + match image::clear(doc, s, &gs.ctm, scope.rects) { + Ok(None) => None, + Ok(Some(new)) => { + let nr = doc.add(Object::Stream(new)); + let n = fresh_name(scope, xobjects); + xobjects.set(n.clone(), Object::Ref(nr)); + out.xobjects.push((n.clone(), nr)); + scope.report.images_cleared += 1; + Some(Some(Op::new("Do", vec![Object::Name(n)]))) + } + Err(()) => { + scope.report.images_removed += 1; + Some(None) + } + } + } + Some(b"Form") => { + let m = s.dict.get(b"Matrix").and_then(|m| Matrix::from_operands(doc.resolve(m).as_array()?)).unwrap_or_default(); + let fctm = m.then(&gs.ctm); + let bbox = s.dict.get(b"BBox").and_then(|b| { + let v: Vec = doc.resolve(b).as_array()?.iter().filter_map(Object::as_f64).collect(); + (v.len() == 4).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]) + }); + if let Some(bb) = bbox + && !scope.hits(fctm.bbox(bb)) + { + return None; + } + if scope.depth >= MAX_DEPTH { + if scope.mode == Mode::Verify { + return None; + } + scope.report.forms_removed += 1; + return Some(None); + } + let data = match s.decoded() { + Ok(d) => d, + Err(_) if scope.mode == Mode::Apply => { + scope.report.forms_removed += 1; + return Some(None); + } + Err(_) => return None, + }; + let own = s.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()); + let res = own.clone().unwrap_or_else(|| resources.clone()); + let saved_fonts = std::mem::take(&mut scope.fonts); + scope.depth += 1; + let inner = process(doc, scope, &[data], &res, fctm); + scope.depth -= 1; + scope.fonts = saved_fonts; + out.residue += inner.residue; + if scope.mode == Mode::Verify { + return None; + } + let new_data = inner.streams.into_iter().next().flatten(); + if new_data.is_none() && inner.xobjects.is_empty() { + return None; + } + let mut dict = s.dict.clone(); + dict.remove(b"Length"); + if !inner.xobjects.is_empty() { + let mut res = res; + let mut xo = res_dict(doc, &res, b"XObject"); + for (n, r) in &inner.xobjects { + xo.set(n.clone(), Object::Ref(*r)); + } + res.set(b"XObject".to_vec(), Object::Dict(xo)); + dict.set(b"Resources".to_vec(), Object::Dict(res)); + } + let stream = match new_data { + Some(d) => Stream::flate(dict, &d), + None => Stream { dict, raw: s.raw.clone() }, + }; + let nr = doc.add(Object::Stream(stream)); + let n = fresh_name(scope, xobjects); + xobjects.set(n.clone(), Object::Ref(nr)); + out.xobjects.push((n.clone(), nr)); + scope.report.forms_rewritten += 1; + Some(Some(Op::new("Do", vec![Object::Name(n)]))) + } + _ => None, + } +} diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs new file mode 100644 index 000000000..49c7d6cf1 --- /dev/null +++ b/crates/redact/src/lib.rs @@ -0,0 +1,369 @@ +//! Redaction (architecture §11.2, execution plan M8.5–M8.7). Layer L4. +//! +//! Content is marked for redaction with Redact annotations (§12.5.6.23); `printcraft-annot` +//! creates them. [`apply`] then removes everything under the marks, for good: +//! - text glyphs (the rest of each line keeps its position), inline images, and paths that the +//! marks cover; images and vectors partly under a mark lose the covered part; +//! - content inside form XObjects (rewritten as new objects, so pages sharing them keep theirs); +//! - comments, links and form fields whose rectangle overlaps a mark; +//! - the marks themselves, replaced by boxes in their fill colour (with their overlay text) +//! drawn into the page. +//! +//! A verification pass then re-reads every redacted page; if any glyph or inline image is still +//! under a region the whole operation fails (callers keep the previous document: fail-closed). + +use printcraft_cos::{Dict, Document, ObjRef, Object, Stream}; + +mod fonts; +mod image; +mod interp; +pub mod patterns; +#[cfg(test)] +mod tests; + +use interp::{Mode, Scope, process}; + +pub type Rgb = [f64; 3]; + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum RedactError { + #[error("there are no redaction marks to apply")] + NothingToApply, + #[error("page {0} has content that can't be read, so it can't be redacted safely")] + Unreadable(usize), + #[error("redaction could not be verified: {0} item(s) were still found under the marks, so nothing was changed")] + Residue(usize), + #[error(transparent)] + Edit(#[from] printcraft_edit::EditError), + #[error(transparent)] + Form(#[from] printcraft_forms::FormError), + #[error(transparent)] + Cos(#[from] printcraft_cos::CosError), +} + +/// A redaction mark on a page. +#[derive(Clone, Debug, PartialEq)] +pub struct Mark { + /// 0-based page index. + pub page: usize, + pub obj: ObjRef, + /// The marked areas in user space (one per quadrilateral). + pub rects: Vec<[f64; 4]>, + /// The box colour once applied (`/IC`; none = no box). + pub fill: Option, + pub overlay: String, +} + +/// What [`apply`] removed. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Report { + pub marks: usize, + pub pages: usize, + pub glyphs: usize, + pub images_removed: usize, + pub images_cleared: usize, + pub paths_removed: usize, + pub paths_clipped: usize, + pub forms_rewritten: usize, + pub forms_removed: usize, + pub annotations: usize, + pub fields: usize, +} + +fn rect_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { + let v: Vec = doc.resolve(o?).as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); + (v.len() == 4 && v.iter().all(|x| x.is_finite())).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]) +} + +fn color(doc: &Document, d: &Dict, key: &[u8]) -> Option { + let v: Vec = doc.resolve(d.get(key)?).as_array()?.iter().filter_map(Object::as_f64).collect(); + match v.len() { + 1 => Some([v[0]; 3]), + 3 => Some([v[0], v[1], v[2]]), + 4 => Some([(1.0 - v[0]) * (1.0 - v[3]), (1.0 - v[1]) * (1.0 - v[3]), (1.0 - v[2]) * (1.0 - v[3])]), + _ => None, + } +} + +fn annots_of(doc: &Document, page: &Dict) -> Vec { + page.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() +} + +/// Every redaction mark in the document, page by page. +pub fn marks(doc: &Document) -> Vec { + let mut out = Vec::new(); + for (pi, p) in printcraft_model::pages(doc).iter().enumerate() { + for a in annots_of(doc, &p.dict) { + let Some(r) = a.as_ref() else { continue }; + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + if d.name(b"Subtype") != Some(b"Redact") { + continue; + } + let quads: Vec = d + .get(b"QuadPoints") + .and_then(|q| doc.resolve(q).as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())) + .unwrap_or_default(); + let mut rects: Vec<[f64; 4]> = quads + .chunks_exact(8) + .map(|q| { + let xs = [q[0], q[2], q[4], q[6]]; + let ys = [q[1], q[3], q[5], q[7]]; + [ + xs.iter().copied().fold(f64::MAX, f64::min), + ys.iter().copied().fold(f64::MAX, f64::min), + xs.iter().copied().fold(f64::MIN, f64::max), + ys.iter().copied().fold(f64::MIN, f64::max), + ] + }) + .filter(|r| r.iter().all(|v| v.is_finite())) + .collect(); + if rects.is_empty() + && let Some(r) = rect_of(doc, d.get(b"Rect")) + { + rects.push(r); + } + if rects.is_empty() { + continue; + } + let overlay = d.get(b"OverlayText").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(); + out.push(Mark { page: pi, obj: r, rects, fill: color(doc, d, b"IC"), overlay }); + } + } + out +} + +/// A page's content streams: (the references or inline objects as listed, their decoded data). +fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result<(Vec, Vec>), RedactError> { + let list: Vec = match page.get(b"Contents") { + None => Vec::new(), + Some(c) => match &*doc.resolve(c) { + Object::Array(a) => a.clone(), + _ => vec![c.clone()], + }, + }; + let mut data = Vec::new(); + for o in &list { + match &*doc.resolve(o) { + Object::Stream(s) => data.push(s.decoded().map_err(|_| RedactError::Unreadable(index + 1))?), + Object::Null => data.push(Vec::new()), + _ => return Err(RedactError::Unreadable(index + 1)), + } + } + Ok((list, data)) +} + +/// The boxes and overlay text drawn for the applied marks. +fn overlay_content(marks: &[&Mark]) -> Vec { + let n = |v: f64| { + let s = format!("{:.3}", v); + s.trim_end_matches('0').trim_end_matches('.').to_string() + }; + let mut c: Vec = Vec::new(); + for m in marks { + let Some(fill) = m.fill else { continue }; + c.extend(format!("q {} {} {} rg\n", n(fill[0]), n(fill[1]), n(fill[2])).bytes()); + for r in &m.rects { + c.extend(format!("{} {} {} {} re f\n", n(r[0]), n(r[1]), n(r[2] - r[0]), n(r[3] - r[1])).bytes()); + } + c.extend_from_slice(b"Q\n"); + if m.overlay.is_empty() { + continue; + } + // The overlay text, centred in each area and shrunk to fit. + let text = printcraft_fonts::literal(&printcraft_fonts::win_ansi(&m.overlay)); + for r in &m.rects { + let (w, h) = (r[2] - r[0], r[3] - r[1]); + let mut size = (h * 0.7).min(12.0); + let tw = printcraft_fonts::helvetica_width(&m.overlay, size); + if tw > w - 2.0 && tw > 0.0 { + size *= (w - 2.0).max(0.0) / tw; + } + if size < 2.0 { + continue; + } + let tw = printcraft_fonts::helvetica_width(&m.overlay, size); + let (x, y) = (r[0] + (w - tw) / 2.0, r[1] + (h - size * 0.7) / 2.0); + c.extend(format!("q BT 1 0 0 rg /PCHelv {} Tf {} {} Td ", n(size), n(x), n(y)).bytes()); + c.extend_from_slice(&text); + c.extend_from_slice(b" Tj ET Q\n"); + } + } + c +} + +/// Apply every redaction mark (or only those on `pages`, 0-based). Irreversible for the saved +/// file; callers keep the previous document for undo. +pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result { + let all_marks = marks(doc); + let chosen: Vec<&Mark> = all_marks.iter().filter(|m| pages.is_none_or(|p| p.contains(&m.page))).collect(); + if chosen.is_empty() { + return Err(RedactError::NothingToApply); + } + let mut report = Report { marks: chosen.len(), ..Report::default() }; + let mut by_page: Vec = chosen.iter().map(|m| m.page).collect(); + by_page.sort_unstable(); + by_page.dedup(); + report.pages = by_page.len(); + let mut doomed_fields: Vec = Vec::new(); + let widget_owner: Vec<(ObjRef, String)> = + printcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()).collect(); + + for &pi in &by_page { + let page = printcraft_model::pages(doc).swap_remove(pi); + let page_marks: Vec<&Mark> = chosen.iter().copied().filter(|m| m.page == pi).collect(); + let rects: Vec<[f64; 4]> = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); + + // 1. Content. + let (list, data) = page_streams(doc, &page.dict, pi)?; + let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let out = { + let mut scope = Scope::new(&rects, Mode::Apply, &mut report); + process(doc, &mut scope, &data, &resources, printcraft_content::Matrix::IDENTITY) + }; + let mut new_list = list.clone(); + let mut changed = false; + for (i, new) in out.streams.into_iter().enumerate() { + let Some(bytes) = new else { continue }; + let mut dict = match &*doc.resolve(&list[i]) { + Object::Stream(s) => s.dict.clone(), + _ => Dict::new(), + }; + dict.remove(b"Length"); + // A new object: the original may be shared with other pages. + new_list[i] = Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &bytes)))); + changed = true; + } + if changed || !out.xobjects.is_empty() { + let mut res = resources; + if !out.xobjects.is_empty() { + let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); + for (n, r) in &out.xobjects { + xo.set(n.clone(), Object::Ref(*r)); + } + res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + doc.update_dict(page.obj, |d| { + d.set(b"Contents".to_vec(), Object::Array(new_list)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + })?; + } + + // 2. Annotations: the marks, and whatever lies under them (with their pop-ups). + let annots = annots_of(doc, &doc.get(page.obj).as_dict().cloned().unwrap_or_default()); + let mut removed: Vec = Vec::new(); + for a in &annots { + let Some(r) = a.as_ref() else { continue }; + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + let subtype = d.name(b"Subtype").unwrap_or(b""); + if subtype == b"Redact" { + if page_marks.iter().any(|m| m.obj == r) { + removed.push(r); + } + continue; + } + if subtype == b"Popup" { + continue; + } + if rect_of(doc, d.get(b"Rect")).is_some_and(|b| rects.iter().any(|x| printcraft_content::overlaps(*x, b, 0.0))) { + removed.push(r); + if subtype == b"Widget" { + if let Some((_, name)) = widget_owner.iter().find(|(w, _)| *w == r) + && !doomed_fields.contains(name) + { + doomed_fields.push(name.clone()); + } + } else { + report.annotations += 1; + } + } + } + let kept: Vec = annots + .into_iter() + .filter(|a| { + let Some(r) = a.as_ref() else { return true }; + if removed.contains(&r) { + return false; + } + // Pop-ups of removed annotations go too. + let obj = doc.get(r); + !obj.as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) + }) + .collect(); + doc.update_dict(page.obj, |d| { + if kept.is_empty() { + d.remove(b"Annots"); + } else { + d.set(b"Annots".to_vec(), Object::Array(kept)); + } + })?; + } + + // 3. Form fields with a widget under a mark (all their widgets go). + for name in &doomed_fields { + if printcraft_forms::fields(doc).iter().any(|f| &f.name == name) { + printcraft_forms::delete_field(doc, name)?; + report.fields += 1; + } + } + + // 4. The boxes. + for &pi in &by_page { + let page_marks: Vec<&Mark> = chosen.iter().copied().filter(|m| m.page == pi).collect(); + let content = overlay_content(&page_marks); + if !content.is_empty() { + printcraft_edit::stamp(doc, pi, "Redaction", content)?; + } + } + + // 5. Verify: nothing readable may remain under a region. + let mut residue = 0; + for &pi in &by_page { + let page = printcraft_model::pages(doc).swap_remove(pi); + let rects: Vec<[f64; 4]> = chosen.iter().filter(|m| m.page == pi).flat_map(|m| m.rects.iter().copied()).collect(); + let (list, data) = page_streams(doc, &page.dict, pi)?; + // The overlay stream (last) draws text of its own: the overlay label is allowed. + let original: Vec> = list + .iter() + .zip(data) + .filter(|(o, _)| doc.resolve(o).as_dict().and_then(|d| d.name(b"PCMark")) != Some(b"Redaction")) + .map(|(_, d)| d) + .collect(); + let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let mut scratch = Report::default(); + let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch); + residue += process(doc, &mut scope, &original, &resources, printcraft_content::Matrix::IDENTITY).residue; + } + if residue > 0 { + return Err(RedactError::Residue(residue)); + } + // The previous revision still holds the removed content: the next save must rewrite. + doc.require_full_save(); + Ok(report) +} + +/// Remove redaction marks without applying them (`None` = all). +pub fn clear_marks(doc: &mut Document, pages: Option<&[usize]>) -> Result { + let all = marks(doc); + let doomed: Vec = all.iter().filter(|m| pages.is_none_or(|p| p.contains(&m.page))).map(|m| m.obj).collect(); + if doomed.is_empty() { + return Err(RedactError::NothingToApply); + } + for p in printcraft_model::pages(doc) { + let annots = annots_of(doc, &p.dict); + let kept: Vec = annots + .iter() + .filter(|a| { + let Some(r) = a.as_ref() else { return true }; + let parent = doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref); + !doomed.contains(&r) && !parent.is_some_and(|p| doomed.contains(&p)) + }) + .cloned() + .collect(); + if kept.len() != annots.len() { + doc.update_dict(p.obj, |d| d.set(b"Annots".to_vec(), Object::Array(kept)))?; + } + } + Ok(doomed.len()) +} diff --git a/crates/redact/src/patterns.rs b/crates/redact/src/patterns.rs new file mode 100644 index 000000000..c031fbe8d --- /dev/null +++ b/crates/redact/src/patterns.rs @@ -0,0 +1,291 @@ +//! Search & Redact patterns (Acrobat's Find Text ▸ Patterns): phone numbers, email addresses, +//! credit card numbers, US Social Security numbers and dates. Each matcher works on a page's +//! text as characters and returns character ranges; callers map them to glyphs and areas. + +use std::ops::Range; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Pattern { + Phone, + Email, + CreditCard, + Ssn, + Date, +} + +pub const PATTERNS: [Pattern; 5] = [Pattern::Phone, Pattern::Email, Pattern::CreditCard, Pattern::Ssn, Pattern::Date]; + +impl Pattern { + pub fn label(self) -> &'static str { + match self { + Pattern::Phone => "Phone Numbers", + Pattern::Email => "Email Addresses", + Pattern::CreditCard => "Credit Cards", + Pattern::Ssn => "Social Security Numbers", + Pattern::Date => "Dates", + } + } + + pub fn id(self) -> &'static str { + match self { + Pattern::Phone => "phone", + Pattern::Email => "email", + Pattern::CreditCard => "credit-card", + Pattern::Ssn => "ssn", + Pattern::Date => "date", + } + } + + pub fn from_id(id: &str) -> Option { + PATTERNS.into_iter().find(|p| p.id() == id) + } +} + +fn digit(c: Option<&char>) -> bool { + c.is_some_and(char::is_ascii_digit) +} + +fn word(c: Option<&char>) -> bool { + c.is_some_and(|c| c.is_alphanumeric()) +} + +/// Digits and the separators allowed between them, from `start`: (end, digit count). +fn digit_run(s: &[char], start: usize, seps: &[char], max_len: usize) -> (usize, usize) { + let (mut i, mut n) = (start, 0); + while i < s.len() && i - start < max_len { + let c = s[i]; + if c.is_ascii_digit() { + n += 1; + } else if !(seps.contains(&c) && digit(s.get(i + 1)) || (c == '(' || c == ')') && seps.contains(&c)) { + break; + } + i += 1; + } + // Never end on a separator. + while i > start && !s[i - 1].is_ascii_digit() { + i -= 1; + } + (i, n) +} + +fn phone(s: &[char], i: usize) -> Option { + let start_ok = s[i] == '+' && digit(s.get(i + 1)) || s[i] == '(' && digit(s.get(i + 1)) || s[i].is_ascii_digit(); + if !start_ok || word(i.checked_sub(1).and_then(|p| s.get(p))) || s.get(i.wrapping_sub(1)) == Some(&'+') { + return None; + } + let first = if s[i] == '+' { i + 1 } else { i }; + let (end, n) = digit_run(s, first, &[' ', '-', '.', '(', ')'], 20); + let seps = s[first..end].iter().filter(|c| !c.is_ascii_digit()).count(); + // 10 digits (11 with a country code), grouped by at least one separator. + if !(10..=11).contains(&n) || seps == 0 || digit(s.get(end)) { + return None; + } + Some(end - i) +} + +fn email(s: &[char], i: usize) -> Option { + let local = |c: &char| c.is_ascii_alphanumeric() || "._%+-".contains(*c); + if !s[i].is_ascii_alphanumeric() || i.checked_sub(1).and_then(|p| s.get(p)).is_some_and(local) { + return None; + } + let mut j = i; + while j < s.len() && local(&s[j]) { + j += 1; + } + if s.get(j) != Some(&'@') { + return None; + } + let d0 = j + 1; + let mut k = d0; + while k < s.len() && (s[k].is_ascii_alphanumeric() || s[k] == '-' || s[k] == '.' && s.get(k + 1).is_some_and(char::is_ascii_alphanumeric)) { + k += 1; + } + let domain: String = s[d0..k].iter().collect(); + let tld = domain.rsplit('.').next().unwrap_or(""); + (domain.contains('.') && tld.len() >= 2 && tld.chars().all(|c| c.is_ascii_alphabetic())).then_some(k - i) +} + +fn luhn(digits: &[u32]) -> bool { + let sum: u32 = digits + .iter() + .rev() + .enumerate() + .map(|(k, d)| { + if k % 2 == 1 { + let x = d * 2; + if x > 9 { x - 9 } else { x } + } else { + *d + } + }) + .sum(); + sum.is_multiple_of(10) +} + +fn credit_card(s: &[char], i: usize) -> Option { + if !s[i].is_ascii_digit() || digit(i.checked_sub(1).and_then(|p| s.get(p))) { + return None; + } + let (end, n) = digit_run(s, i, &[' ', '-'], 23); + if !(13..=19).contains(&n) || digit(s.get(end)) { + return None; + } + let digits: Vec = s[i..end].iter().filter_map(|c| c.to_digit(10)).collect(); + luhn(&digits).then_some(end - i) +} + +fn ssn(s: &[char], i: usize) -> Option { + if digit(i.checked_sub(1).and_then(|p| s.get(p))) { + return None; + } + let g = |from: usize, n: usize| (from..from + n).all(|k| digit(s.get(k))); + let sep = |k: usize| matches!(s.get(k), Some('-' | ' ')); + if !(g(i, 3) && sep(i + 3) && g(i + 4, 2) && sep(i + 6) && g(i + 7, 4)) || digit(s.get(i + 11)) { + return None; + } + let area: String = s[i..i + 3].iter().collect(); + (area != "000" && area != "666" && !area.starts_with('9')).then_some(11) +} + +const MONTHS: [&str; 12] = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"]; + +/// A month name or its three-letter abbreviation (optionally with a full stop) at `i`. +fn month(s: &[char], i: usize) -> Option { + if word(i.checked_sub(1).and_then(|p| s.get(p))) { + return None; + } + let rest: String = s[i..s.len().min(i + 10)].iter().collect::().to_lowercase(); + for m in MONTHS { + for cand in [m, &m[..3]] { + if rest.starts_with(cand) && !rest[cand.len()..].starts_with(|c: char| c.is_alphabetic()) { + let mut n = cand.chars().count(); + if cand.len() == 3 && s.get(i + n) == Some(&'.') { + n += 1; + } + return Some(n); + } + } + } + None +} + +fn number(s: &[char], i: usize, min: usize, max: usize) -> Option { + let n = s[i.min(s.len())..].iter().take(max + 1).take_while(|c| c.is_ascii_digit()).count(); + (n >= min && n <= max).then_some(n) +} + +fn date(s: &[char], i: usize) -> Option { + if word(i.checked_sub(1).and_then(|p| s.get(p))) { + return None; + } + let spaces = |k: usize| s[k.min(s.len())..].iter().take_while(|c| **c == ' ').count(); + // Numeric: m/d/yy(yy), m-d-yyyy, d.m.yyyy, yyyy-mm-dd. + if let Some(a) = number(s, i, 1, 4) { + let sep = s.get(i + a).copied(); + if matches!(sep, Some('/' | '-' | '.')) + && let Some(b) = number(s, i + a + 1, 1, 2) + && s.get(i + a + 1 + b).copied() == sep + && let Some(c) = number(s, i + a + b + 2, if a == 4 { 1 } else { 2 }, if a == 4 { 2 } else { 4 }) + && (a <= 2 || a == 4) + && !digit(s.get(i + a + b + 2 + c)) + { + return Some(a + b + c + 2); + } + // "5 January 2024". + if a <= 2 + && let k = i + a + spaces(i + a) + && k > i + a + && let Some(m) = month(s, k) + { + let k2 = k + m + spaces(k + m); + if let Some(y) = number(s, k2, 4, 4) { + return Some(k2 + y - i); + } + } + return None; + } + // "January 5, 2024" / "Jan. 5 2024". + let m = month(s, i)?; + let k = i + m + spaces(i + m); + let d = number(s, k, 1, 2)?; + let mut k2 = k + d; + if s.get(k2) == Some(&',') { + k2 += 1; + } + k2 += spaces(k2); + let y = number(s, k2, 4, 4)?; + Some(k2 + y - i) +} + +/// Character ranges of `text` matching `pattern`. +pub fn find(pattern: Pattern, text: &[char]) -> Vec> { + let f: fn(&[char], usize) -> Option = match pattern { + Pattern::Phone => phone, + Pattern::Email => email, + Pattern::CreditCard => credit_card, + Pattern::Ssn => ssn, + Pattern::Date => date, + }; + let mut out = Vec::new(); + let mut i = 0; + while i < text.len() { + match f(text, i) { + Some(n) if n > 0 => { + out.push(i..i + n); + i += n; + } + _ => i += 1, + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn found(p: Pattern, s: &str) -> Vec { + let c: Vec = s.chars().collect(); + find(p, &c).into_iter().map(|r| c[r].iter().collect()).collect() + } + + #[test] + fn phone_numbers() { + assert_eq!( + found(Pattern::Phone, "Call (555) 123-4567 or 555.987.6543, +1 555 222 3333; not 12345 or 5551234567890"), + ["(555) 123-4567", "555.987.6543", "+1 555 222 3333"] + ); + } + + #[test] + fn emails() { + assert_eq!( + found(Pattern::Email, "Write to ada.lovelace+pdf@example.co.uk, or bob@host (no TLD) or x@y.z."), + ["ada.lovelace+pdf@example.co.uk"] + ); + } + + #[test] + fn credit_cards_need_a_valid_checksum() { + assert_eq!( + found(Pattern::CreditCard, "Visa 4111 1111 1111 1111, bad 4111 1111 1111 1112, amex 3782-822463-10005"), + ["4111 1111 1111 1111", "3782-822463-10005"] + ); + } + + #[test] + fn social_security_numbers() { + assert_eq!( + found(Pattern::Ssn, "SSN 123-45-6789 and 123 45 6789; invalid 000-12-3456, 666-12-3456, 912-12-3456, 1234-56-7890"), + ["123-45-6789", "123 45 6789"] + ); + } + + #[test] + fn dates() { + assert_eq!( + found(Pattern::Date, "Due 10/01/2026, 2026-10-01, 1.10.26, March 5, 2024, Jan. 7 2025 and 5 June 2023; not 10/2026 or Mayday 12"), + ["10/01/2026", "2026-10-01", "1.10.26", "March 5, 2024", "Jan. 7 2025", "5 June 2023"] + ); + } +} diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs new file mode 100644 index 000000000..d3773312f --- /dev/null +++ b/crates/redact/src/tests.rs @@ -0,0 +1,266 @@ +use std::sync::Arc; + +use printcraft_annot::{Meta, NewAnnotation, Shape, Style, add_annotation, rect_quad}; +use printcraft_cos::{Document, SaveOptions, write_incremental}; + +use super::*; + +fn stream(dict: &str, data: &[u8]) -> Vec { + let mut v = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes(); + v.extend_from_slice(data); + v.extend_from_slice(b"\nendstream"); + v +} + +fn pdf(objs: Vec>) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +/// A font where every glyph is 500 units wide (so a 10 pt glyph advances 5 pt). +const FONT: &str = "<< /Type /Font /Subtype /TrueType /BaseFont /Arial /FirstChar 32 /LastChar 126 /Widths 95 0 R /FontDescriptor << /Ascent 800 /Descent -200 >> >>"; + +fn widths() -> Vec { + format!("[{}]", vec!["500"; 95].join(" ")).into_bytes() +} + +/// One 300×300 page with `content`, font /F1, and extra resources/objects. +fn one_page(content: &[u8], extra_res: &str, extra: Vec>) -> Document { + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> {extra_res} >> >>") + .into_bytes(), + stream("", content), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + ]; + objs.extend(extra); + pdf(objs) +} + +fn mark(doc: &mut Document, page: usize, rects: &[[f64; 4]], overlay: &str) { + let shape = Shape::Redact { quads: rects.iter().map(|r| rect_quad(*r)).collect(), overlay: overlay.into() }; + let style = Style::default_for(&shape); + add_annotation(doc, &NewAnnotation { page, shape, style, contents: String::new(), author: "Tester".into() }, &Meta::default()).unwrap(); +} + +/// The decoded content streams of a page, joined. +fn content(doc: &Document, page: usize) -> String { + let p = &printcraft_model::pages(doc)[page]; + let (_, data) = page_streams(doc, &p.dict, page).unwrap(); + data.iter().map(|d| String::from_utf8_lossy(d).into_owned()).collect::>().join("\n") +} + +/// Glyphs (and inline images) of page `page` under `rects` (the verifier's count). +fn under(doc: &mut Document, page: usize, rects: &[[f64; 4]]) -> usize { + let p = printcraft_model::pages(doc).swap_remove(page); + let (_, data) = page_streams(doc, &p.dict, page).unwrap(); + let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let mut rep = Report::default(); + let mut scope = Scope::new(rects, Mode::Verify, &mut rep); + process(doc, &mut scope, &data, &res, printcraft_content::Matrix::IDENTITY).residue +} + +fn reopen(doc: &Document) -> Document { + Document::open(Arc::new(write_incremental(doc, &SaveOptions::default()).unwrap())).unwrap() +} + +#[test] +fn glyphs_under_a_mark_go_and_the_rest_stays_put() { + // "AB1234CD" from x = 10 at 10 pt: each glyph 5 pt wide; 1234 spans x 20–40. + let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (AB1234CD) Tj ET", "", vec![]); + assert_eq!(under(&mut doc, 0, &[[40.0, 95.0, 50.0, 110.0]]), 2, "C and D before"); + mark(&mut doc, 0, &[[20.0, 95.0, 40.0, 110.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.marks, r.glyphs), (1, 4)); + let c = content(&doc, 0); + assert!(c.contains("[(AB) -2000 (CD)] TJ"), "{c}"); + assert!(!c.contains("1234")); + assert_eq!(under(&mut doc, 0, &[[40.0, 95.0, 50.0, 110.0]]), 2, "C and D are where they were"); + assert_eq!(under(&mut doc, 0, &[[20.0, 95.0, 40.0, 110.0]]), 0); + // The mark became a black box drawn into the page; the annotation is gone. + assert!(marks(&doc).is_empty()); + assert!(c.contains("0 0 0 rg") && c.contains("20 95 20 15 re f"), "{c}"); + let doc = reopen(&doc); + assert!(!content(&doc, 0).contains("1234")); +} + +#[test] +fn kerning_spacing_scaling_and_line_operators_are_honoured() { + // TJ kerning, character and word spacing, 50% horizontal scaling, ' and ". + let src = b"BT /F1 10 Tf 2 Tc 4 Tw 50 Tz 12 TL 0 200 Td [(AB) -1000 (C D)] TJ (EF) ' 1 0 (GH) \" ET"; + let mut doc = one_page(src, "", vec![]); + // Line 1 (y 200): A at 0, B at 3.5 (advance (5+2)×0.5), kern +5 → C at 12, space at 15.5, + // D at 21 (space advance (5+2+4)×0.5 = 5.5). Remove C only. + mark(&mut doc, 0, &[[12.2, 195.0, 14.8, 210.0]], ""); + // Line 2 (y 188) "EF" and line 3 (y 176) "GH" (Tw 1, Tc 0): remove F and G. + mark(&mut doc, 0, &[[3.8, 183.0, 6.0, 198.0 - 10.0], [0.2, 171.0, 2.3, 180.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.glyphs, 3, "{r:?}"); + let c = content(&doc, 0); + assert!(c.contains("[(AB) -1700 ( D)] TJ"), "{c}"); + assert!(c.contains("T*\n[(E) -700] TJ"), "{c}"); + assert!(c.contains("1 Tw\n0 Tc\nT*\n[-500 (H)] TJ"), "{c}"); + // Everything else stays readable where it was. + assert_eq!(under(&mut doc, 0, &[[20.0, 195.0, 30.0, 210.0]]), 1, "D"); + assert_eq!(under(&mut doc, 0, &[[2.6, 171.0, 6.0, 180.0]]), 1, "H"); +} + +#[test] +fn rotated_text_and_composite_fonts() { + // A Type0 Identity-H font (two-byte codes) with /W widths, and a 90° text matrix. + let t0 = b"<< /Type /Font /Subtype /Type0 /BaseFont /X /Encoding /Identity-H /DescendantFonts [<< /Type /Font /Subtype /CIDFontType2 /BaseFont /X /DW 1000 /W [1 [500 500] 3 4 250] >>] >>".to_vec(); + let src = b"BT /F2 10 Tf 0 1 -1 0 100 50 Tm <0001000200030004> Tj ET"; + let mut doc = one_page(src, "", vec![]); + let font_ref = doc.add(Object::Dict(Dict::new())); + let parsed = { + let mut lx = printcraft_cos::Lexer::new(&t0, 0); + lx.object().unwrap() + }; + doc.set(font_ref, parsed); + let page = printcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| { + let mut res = d.get(b"Resources").and_then(Object::as_dict).cloned().unwrap(); + let mut fonts = res.get(b"Font").and_then(Object::as_dict).cloned().unwrap(); + fonts.set(b"F2".to_vec(), Object::Ref(font_ref)); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + }) + .unwrap(); + // Glyphs run upwards from y 50: CID1 50–55, CID2 55–60, CID3 60–62.5, CID4 62.5–65. + mark(&mut doc, 0, &[[85.0, 55.5, 105.0, 61.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.glyphs, 2, "CIDs 2 and 3"); + let c = content(&doc, 0); + assert!(c.contains("[<0001> -750 <0004>] TJ") || c.contains("[(\\000\\001) -750 (\\000\\004)] TJ"), "{c}"); +} + +#[test] +fn images_are_removed_or_have_their_pixels_cleared() { + // Image 1 is fully covered; image 2 (4×1 gray, all white) is half covered. + let img = stream("/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", &[255, 255, 255, 255]); + let src = b"q 10 0 0 10 10 10 cm /Im1 Do Q q 40 0 0 10 100 100 cm /Im2 Do Q"; + let mut doc = one_page(src, "/XObject << /Im1 7 0 R /Im2 7 0 R >>", vec![img]); + mark(&mut doc, 0, &[[5.0, 5.0, 25.0, 25.0], [95.0, 95.0, 120.0, 115.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.images_removed, r.images_cleared), (1, 1), "{r:?}"); + let c = content(&doc, 0); + assert!(!c.contains("/Im1 Do") && !c.contains("/Im2 Do"), "{c}"); + let p = printcraft_model::pages(&doc).swap_remove(0); + let res = doc.resolve(p.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap(); + let new = xo.iter().find(|(k, _)| k.starts_with(b"PCRedacted")).map(|(_, v)| v.clone()).expect("a cleared copy"); + let Object::Stream(s) = &*doc.resolve(&new) else { panic!() }; + assert_eq!(s.decoded().unwrap(), [0, 0, 255, 255], "pixels 1–2 (x 100–120) cleared"); + let Object::Stream(orig) = &*doc.get(ObjRef::new(7, 0)) else { panic!() }; + assert_eq!(orig.decoded().unwrap(), [255; 4], "the shared original is untouched"); +} + +#[test] +fn vectors_are_removed_or_clipped_and_inline_images_go() { + let src = b"0 g 10 10 20 20 re f 0 0 300 300 re f q 10 0 0 10 50 50 cm BI /W 1 /H 1 /CS /G /BPC 8 ID \x80 EI Q 1 0 0 RG 150 150 m 160 160 l S"; + let mut doc = one_page(src, "", vec![]); + mark(&mut doc, 0, &[[5.0, 5.0, 35.0, 35.0], [45.0, 45.0, 65.0, 65.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.paths_removed, r.paths_clipped, r.images_removed), (1, 1, 1), "{r:?}"); + let c = content(&doc, 0); + assert!(!c.contains("10 10 20 20 re"), "{c}"); + assert!(c.contains("W*\nn\n0 0 300 300 re\nf\nQ"), "the page-size rect is clipped: {c}"); + assert!(!c.contains("BI"), "{c}"); + assert!(c.contains("150 150 m"), "the line elsewhere stays"); +} + +#[test] +fn shared_form_xobjects_are_copied_not_changed() { + let form = + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >>", b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET"); + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 8 0 R] /Count 2 /MediaBox [0 0 300 300] /Resources << /XObject << /Fm 7 0 R >> >> >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".to_vec(), + stream("", b"/Fm Do"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + form, + ]; + objs.push(b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".to_vec()); + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[0.0, 90.0, 300.0, 120.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.glyphs, r.forms_rewritten), (6, 1), "{r:?}"); + assert!(content(&doc, 0).contains("/PCRedacted1 Do")); + assert_eq!(content(&doc, 1), "/Fm Do", "page 2 shares the stream and the form: untouched"); + assert_eq!(under(&mut doc, 1, &[[0.0, 90.0, 300.0, 120.0]]), 6); + assert_eq!(under(&mut doc, 0, &[[0.0, 90.0, 300.0, 120.0]]), 0); +} + +#[test] +fn comments_links_and_fields_under_a_mark_go() { + let mut doc = one_page(b"", "", vec![]); + // A comment under the mark, a link elsewhere, and a text field under the mark. + let square = Shape::Rectangle { rect: [20.0, 20.0, 40.0, 40.0] }; + add_annotation( + &mut doc, + &NewAnnotation { page: 0, style: Style::default_for(&square), shape: square, contents: "x".into(), author: "a".into() }, + &Meta::default(), + ) + .unwrap(); + let link = Shape::Rectangle { rect: [200.0, 200.0, 220.0, 220.0] }; + add_annotation( + &mut doc, + &NewAnnotation { page: 0, style: Style::default_for(&link), shape: link, contents: "keep".into(), author: "a".into() }, + &Meta::default(), + ) + .unwrap(); + printcraft_forms::add_field(&mut doc, 0, [10.0, 50.0, 100.0, 70.0], &printcraft_forms::NewField::Text { multiline: false }, Some("ssn")).unwrap(); + mark(&mut doc, 0, &[[0.0, 0.0, 120.0, 80.0]], "REDACTED"); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.annotations, r.fields), (1, 1), "{r:?}"); + assert!(printcraft_forms::fields(&doc).is_empty()); + let p = printcraft_model::pages(&doc).swap_remove(0); + assert_eq!(annots_of(&doc, &p.dict).len(), 1, "only the far rectangle stays"); + let c = content(&doc, 0); + assert!(c.contains("(REDACTED) Tj"), "overlay text: {c}"); +} + +#[test] +fn nothing_to_apply_and_clearing_marks() { + let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (AB) Tj ET", "", vec![]); + assert_eq!(apply(&mut doc, None), Err(RedactError::NothingToApply)); + mark(&mut doc, 0, &[[0.0, 0.0, 50.0, 50.0]], ""); + assert_eq!(marks(&doc).len(), 1); + assert_eq!(clear_marks(&mut doc, None), Ok(1)); + assert!(marks(&doc).is_empty()); + assert!(content(&doc, 0).contains("(AB) Tj")); +} + +#[test] +fn unreadable_content_fails_closed() { + let mut doc = one_page(b"", "", vec![]); + let page = printcraft_model::pages(&doc)[0].obj; + let bad = doc.add(Object::Stream(Stream::from_raw( + { + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("DCTDecode")); + d + }, + b"garbage".to_vec(), + ))); + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(bad))).unwrap(); + mark(&mut doc, 0, &[[0.0, 0.0, 50.0, 50.0]], ""); + assert_eq!(apply(&mut doc, None), Err(RedactError::Unreadable(1))); +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index c336918c2..ced39153c 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -93,6 +93,24 @@ impl PageText { out } + /// Glyph ranges of the matches a character matcher finds in the page text (words and lines + /// separated by one space, original case). Used for pattern search (Search & Redact). + pub fn find_with(&self, matcher: impl Fn(&[char]) -> Vec>) -> Vec> { + let mut chars: Vec = Vec::new(); + let mut owner: Vec = Vec::new(); + for (i, g) in self.glyphs.iter().enumerate() { + if i > 0 && (self.space_before[i] || self.line_of[i] != self.line_of[i - 1]) && chars.last().is_some_and(|c| *c != ' ') { + chars.push(' '); + owner.push(i); + } + for c in g.text.chars() { + chars.push(if c.is_whitespace() { ' ' } else { c }); + owner.push(i); + } + } + matcher(&chars).into_iter().filter(|r| r.start < r.end && r.end <= chars.len()).map(|r| owner[r.start]..owner[r.end - 1] + 1).collect() + } + /// Index of the glyph nearest to a view-space point (for selection anchors). pub fn nearest(&self, x: f32, y: f32) -> Option { self.glyphs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 33a9ca1c8..ff69a6539 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -132,6 +132,9 @@ pub struct DocView { pub forms: crate::forms_ui::FormView, /// Prepare a form: the selected field and the gesture in progress. pub prepare: crate::prepare::PrepareView, + /// Redact tool: the box being drawn, and a mark to add (page, quads) for the app to style. + pub redact_drag: crate::redact_ui::AreaDrag, + pub pending_redaction: Option<(usize, Vec<[f64; 8]>)>, /// A crop rectangle being dragged (Crop tool). pub crop_drag: crate::crop::CropDrag, /// Fill & Sign text being typed. @@ -206,6 +209,8 @@ impl DocView { comments: Default::default(), forms: Default::default(), prepare: Default::default(), + redact_drag: None, + pending_redaction: None, crop_drag: None, fill_text: None, } @@ -309,6 +314,10 @@ impl DocView { (!quads.is_empty()).then_some((s.page, quads)) } + pub(crate) fn page_text(&self, page: usize) -> Option> { + self.texts.get(&page).cloned() + } + pub(crate) fn clear_selection(&mut self) { self.selection = None; } @@ -786,6 +795,7 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { let selects_text = match tool { QuickTool::Comment(t) => t.markup().is_some(), QuickTool::Select => !preparing, + QuickTool::Redact => true, QuickTool::Hand | QuickTool::Crop | QuickTool::Fill(_) | QuickTool::Field(_) => false, }; let prefs = &app.comment_prefs; @@ -954,7 +964,15 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { } else { tool == QuickTool::Select && crate::forms_ui::page_input(ui, &resp, &xf, i, info, &form, can_fill, view) }; - let consumed = on_field || comments::page_input(ui, &resp, &pcx, view); + let boxing = tool == QuickTool::Redact && can_modify && { + let text = view.page_text(i); + let over_text = |p: Pos2| { + let (vx, vy) = xf.screen_to_view(p); + text.as_ref().is_some_and(|t| t.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3])) + }; + crate::redact_ui::page_input(ui, &resp, &xf, i, info, over_text, view) + }; + let consumed = boxing || on_field || comments::page_input(ui, &resp, &pcx, view); // Text layer: find matches, selection, I-beam and drag-to-select. let to_screen = |g: [f32; 4]| xf.view_rect(g); @@ -1024,6 +1042,10 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { } comments::page_after_text(&resp, &pcx, view); + if tool == QuickTool::Redact && can_modify { + crate::redact_ui::after_text(&resp, i, info, view); + crate::redact_ui::paint(ui, painter, i, view); + } comments::paint_page(ui, painter, &pcx, view); if preparing { crate::prepare::paint_page(ui, painter, &xf, i, info, &form, view); @@ -1200,6 +1222,10 @@ pub fn document_area(app: &mut PrintCraftApp, index: usize, ui: &mut egui::Ui) { if let Some((name, w)) = field_props { app.open_field_props(&name, w); } + if let Some((page, quads)) = app.views[index].pending_redaction.take() { + let author = app.comment_prefs.author.clone(); + app.views[index].pending_edit = Some(app.redact_prefs.mark(page, quads, &author)); + } if open_signature { app.signature_draft.clear(); app.dialog = Some(crate::Dialog::Signature); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index af9245bc4..e0fb609ea 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -25,6 +25,9 @@ impl PrintCraftApp { commands::Needs::FillForms if self.active.is_some() => "This document has no form fields you can fill in".to_string(), commands::Needs::HasComments if self.active.is_some() => "This document has no comments to flatten".to_string(), commands::Needs::HasFields if self.active.is_some() => "This document has no form fields to flatten".to_string(), + commands::Needs::HasRedactions if self.active.is_some() => { + "There are no redaction marks (mark text, areas or pages first)".to_string() + } commands::Needs::Marks(k) if self.active.is_some() => format!( "This document has no {} to change", match k { @@ -207,6 +210,43 @@ impl PrintCraftApp { } "export.image" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Image)), "export.text" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Text)), + "redact.mark" => { + self.quick_tool = crate::QuickTool::Redact; + self.left = crate::LeftPanel::Tool("redact"); + self.left_open = true; + // A text selection made first is marked right away. + if let Some(i) = active { + let info = &self.session.get(self.views[i].id).expect("active").info; + if let Some((page, quads)) = self.views[i].selection_quads(info) { + self.views[i].clear_selection(); + let author = self.comment_prefs.author.clone(); + self.apply_edit(self.redact_prefs.mark(page, quads, &author)); + } + } + } + "redact.pages" => { + if let Some(i) = active { + let n = self.session.get(self.views[i].id).map_or(1, |d| d.info.pages.len()); + self.redact_pages_draft = crate::RedactPagesDraft { current: true, from: self.views[i].current + 1, to: n }; + } + self.dialog = Some(Dialog::RedactPages); + } + "redact.search" => { + self.redact_search.found = None; + self.dialog = Some(Dialog::RedactSearch); + } + "redact.properties" => self.dialog = Some(Dialog::RedactProps), + "redact.apply" => { + let marks = active.and_then(|i| self.session.get(self.views[i].id)).map_or(0, |d| d.redaction_marks()); + if marks == 0 { + self.notify("There are no redaction marks to apply"); + } else { + self.dialog = Some(Dialog::RedactApply); + } + } + "redact.clear" => { + self.apply_edit(Edit::ClearRedactions); + } "form.prepare" => { self.left = crate::LeftPanel::Tool("form"); self.left_open = true; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4c8b6b9f8..13b22a062 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -538,6 +538,7 @@ impl Host for crate::PrintCraftApp { crate::QuickTool::Select => "select".to_string(), crate::QuickTool::Hand => "hand".to_string(), crate::QuickTool::Crop => "crop".to_string(), + crate::QuickTool::Redact => "redact".to_string(), crate::QuickTool::Fill(f) => format!("fill-{}", f.command().trim_start_matches("sign.fill.")), crate::QuickTool::Field(f) => format!("field-{}", f.command().trim_start_matches("form.add.")), crate::QuickTool::Comment(t) => t.command().trim_start_matches("comment.").to_string(), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6e94bf89c..9a5920b5d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -36,6 +36,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { let mut export_now = false; let mut props_now = false; let mut field_props_now = false; + let mut redact_now: Option = None; let mut replace_now = false; let t = Tokens::get(ctx); let mut close = false; @@ -277,6 +278,39 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { }); return; } + Dialog::RedactPages => { + let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); + let (ok, cancel) = crate::redact_ui::pages_body(ui, &mut app.redact_pages_draft, n, &t); + if ok { + redact_now = Some(dialog); + } + close = ok || cancel; + return; + } + Dialog::RedactSearch => { + let (go, cancel) = crate::redact_ui::search_body(ui, &mut app.redact_search, &t); + if go { + redact_now = Some(dialog); + } + close = cancel; + return; + } + Dialog::RedactProps => { + let mut prefs = app.redact_prefs.clone(); + let (ok, cancel) = crate::redact_ui::props_body(ui, &mut prefs, &t); + app.redact_prefs = prefs; + close = ok || cancel; + return; + } + Dialog::RedactApply => { + let marks = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks()); + let (ok, cancel) = crate::redact_ui::apply_body(ui, marks, &t); + if ok { + redact_now = Some(dialog); + } + close = ok || cancel; + return; + } Dialog::FieldProps => { let Some(d) = app.field_props.as_mut() else { close = true; @@ -535,6 +569,20 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) { src_pages: (d.src_from - 1..d.src_from - 1 + n).collect(), }); } + match redact_now { + Some(Dialog::RedactPages) => app.redact_pages(), + Some(Dialog::RedactSearch) => { + let n = app.redact_search(); + app.redact_search.found = Some(n); + } + Some(Dialog::RedactApply) => { + let marks = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks()); + if app.apply_edit(Edit::ApplyRedactions { pages: None }) { + app.notify(format!("Applied {marks} redaction mark{}. Save to remove the content from the file.", if marks == 1 { "" } else { "s" })); + } + } + _ => {} + } if field_props_now && let Some(d) = app.field_props.take() && let Some(props) = d.props() diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 789907f46..2bf77c033 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,8 @@ mod pageboxes; mod palette; mod panels; pub mod prepare; +mod redact_ui; +pub use redact_ui::{PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft}; mod protect; mod recovery; pub mod theme; @@ -86,6 +88,8 @@ pub enum QuickTool { Fill(fill_sign::FillTool), /// A Prepare a form field tool. Field(prepare::FieldTool), + /// Redact text and images (drag across text or draw a box). + Redact, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -112,6 +116,11 @@ pub enum Dialog { ReplacePages, /// Prepare a form ▸ Field Properties. FieldProps, + /// Redact a PDF ▸ Redact pages, Find text and redact, Set properties, apply confirmation. + RedactPages, + RedactSearch, + RedactProps, + RedactApply, /// Documents from a session that ended unexpectedly. Recovery, } @@ -216,6 +225,9 @@ pub struct PrintCraftApp { /// The Comment Properties dialog's state. pub comment_props: Option, pub field_props: Option, + pub redact_prefs: RedactPrefs, + pub redact_pages_draft: RedactPagesDraft, + pub redact_search: RedactSearchDraft, /// The Replace Pages dialog's state. pub replace_draft: Option, /// The last web link the app asked the system to open (tests and automation). @@ -289,6 +301,9 @@ impl PrintCraftApp { signature_draft: Vec::new(), comment_props: None, field_props: None, + redact_prefs: RedactPrefs::default(), + redact_pages_draft: RedactPagesDraft::default(), + redact_search: RedactSearchDraft::default(), replace_draft: None, number_draft: NumberDraft { from: 1, to: 1, style: printcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 }, } @@ -664,6 +679,7 @@ impl PrintCraftApp { "select" => QuickTool::Select, "hand" => QuickTool::Hand, "crop" => QuickTool::Crop, + "redact" => QuickTool::Redact, field if field.starts_with("field-") => QuickTool::Field( prepare::FieldTool::from_command(&format!("form.add.{}", &field[6..])).ok_or_else(|| format!("unknown tool {field}"))?, ), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 6fa2b044d..3cf02ae4a 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -126,7 +126,11 @@ fn tool_detail(app: &mut PrintCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'stat }); } let mut run = None; - egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + // Redact a PDF has Acrobat's footer: Clear all / Redact all. + let footer = g.id == "redact"; + let marks = if footer { app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks()) } else { 0 }; + let list_h = if footer { (ui.available_height() - 52.0).max(80.0) } else { ui.available_height() }; + egui::ScrollArea::vertical().auto_shrink([false, false]).max_height(list_h).show(ui, |ui| { for s in g.sections { widgets::section_title(ui, s.title); for item in s.items { @@ -161,6 +165,22 @@ fn tool_detail(app: &mut PrintCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'stat } } }); + if footer { + ui.add_space(8.0); + ui.horizontal(|ui| { + if marks > 0 { + ui.label(egui::RichText::new(format!("{marks} mark{}", if marks == 1 { "" } else { "s" })).color(t.text_muted)); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.add_enabled_ui(marks > 0, |ui| widgets::pill_button(ui, "Redact all", true)).inner.clicked() { + run = Some("redact.apply"); + } + if ui.add_enabled_ui(marks > 0, |ui| widgets::pill_button(ui, "Clear all", false)).inner.clicked() { + run = Some("redact.clear"); + } + }); + }); + } if let Some(cmd) = run { app.run_command(cmd); } diff --git a/crates/ui-egui/src/redact_ui.rs b/crates/ui-egui/src/redact_ui.rs new file mode 100644 index 000000000..4c02412f7 --- /dev/null +++ b/crates/ui-egui/src/redact_ui.rs @@ -0,0 +1,310 @@ +//! Redact a PDF (execution plan M8.5–M8.6): mark text and areas with the Redact tool (drag across +//! text to mark it, drag elsewhere to mark a box), mark whole pages, find text or patterns and +//! mark every match, set the default box colour and overlay text, and apply all marks (with a +//! confirmation, as Acrobat asks) or clear them. + +use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; +use printcraft_engine::{Edit, NewAnnotation, REDACT_PATTERNS, RedactPattern, Rgb, Shape, Style, rect_quad}; +use printcraft_render::DocInfo; + +use crate::canvas::{DocView, PageXform}; +use crate::theme::Tokens; +use crate::{PrintCraftApp, widgets}; + +const MARK_RED: Color32 = Color32::from_rgb(0xE3, 0x22, 0x22); + +/// Redaction Tool Properties: what new marks look like once applied. +#[derive(Clone, Debug, PartialEq)] +pub struct RedactPrefs { + /// Box colour (`None` = no box, the content just disappears). + pub fill: Option, + pub use_overlay: bool, + pub overlay: String, +} + +impl Default for RedactPrefs { + fn default() -> Self { + Self { fill: Some([0.0, 0.0, 0.0]), use_overlay: false, overlay: String::new() } + } +} + +impl RedactPrefs { + pub fn mark(&self, page: usize, quads: Vec<[f64; 8]>, author: &str) -> Edit { + let shape = Shape::Redact { quads, overlay: if self.use_overlay { self.overlay.clone() } else { String::new() } }; + let mut style = Style::default_for(&shape); + style.fill = self.fill; + Edit::AddAnnotation(NewAnnotation { page, shape, style, contents: String::new(), author: author.to_string() }) + } +} + +/// Redact pages: which pages to mark. +#[derive(Clone, Debug, PartialEq)] +pub struct PagesDraft { + pub current: bool, + pub from: usize, + pub to: usize, +} + +impl Default for PagesDraft { + fn default() -> Self { + Self { current: true, from: 1, to: 1 } + } +} + +/// Find text and redact. +#[derive(Clone, Debug, PartialEq)] +pub struct SearchDraft { + pub patterns: bool, + pub text: String, + pub pattern: RedactPattern, + /// The result of the last search (matches marked), shown in the dialog. + pub found: Option, +} + +impl Default for SearchDraft { + fn default() -> Self { + Self { patterns: false, text: String::new(), pattern: RedactPattern::Phone, found: None } + } +} + +/// A box being drawn with the Redact tool: (page, start). +pub type AreaDrag = Option<(usize, Pos2)>; + +fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { + let (vx, vy) = xf.screen_to_view(p); + let u = info.pages[page].view_to_user(vx, vy); + [u[0] as f64, u[1] as f64] +} + +/// Redact tool input on a page. Presses on text are left to text selection (the selection is +/// marked when it ends, see [`after_text`]); presses elsewhere draw a box. Returns `true` when +/// the gesture is a box. +pub(crate) fn page_input( + ui: &egui::Ui, + resp: &egui::Response, + xf: &PageXform, + page: usize, + info: &DocInfo, + over_text: impl Fn(Pos2) -> bool, + view: &mut DocView, +) -> bool { + let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())); + if let Some((dp, start)) = view.redact_drag + && dp == page + { + if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + view.redact_drag = None; + let end = pointer.unwrap_or(start); + let r = Rect::from_two_pos(start, end).intersect(xf.rect); + if r.width() >= 3.0 && r.height() >= 3.0 { + let (a, b) = (to_user(xf, info, page, r.min), to_user(xf, info, page, r.max)); + view.pending_redaction = Some((page, vec![rect_quad([a[0], a[1], b[0], b[1]])])); + } + } + return true; + } + let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false }; + if !over_text(p) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.drag_started() { + let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); + if !over_text(origin) { + view.redact_drag = Some((page, origin)); + return true; + } + } + false +} + +/// A finished text selection with the Redact tool becomes a mark. +pub(crate) fn after_text(resp: &egui::Response, page: usize, info: &DocInfo, view: &mut DocView) { + if !(resp.drag_stopped() || resp.double_clicked()) || view.redact_drag.is_some() { + return; + } + if let Some((p, quads)) = view.selection_quads(info).filter(|(p, _)| *p == page) { + view.clear_selection(); + view.pending_redaction = Some((p, quads)); + } +} + +/// The box being drawn. +pub(crate) fn paint(ui: &egui::Ui, painter: &egui::Painter, page: usize, view: &DocView) { + if let (Some((dp, start)), Some(p)) = (view.redact_drag, ui.input(|i| i.pointer.hover_pos())) + && dp == page + { + let r = Rect::from_two_pos(start, p); + painter.rect_filled(r, CornerRadius::ZERO, MARK_RED.gamma_multiply(0.12)); + painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.5, MARK_RED), egui::StrokeKind::Inside); + } +} + +impl PrintCraftApp { + /// Mark every match of the search draft on every page; returns how many were marked. + pub fn redact_search(&mut self) -> usize { + let Some((_, id)) = self.active_ids() else { return 0 }; + let Some(doc) = self.session.get(id) else { return 0 }; + let d = self.redact_search.clone(); + if !d.patterns && d.text.trim().is_empty() { + return 0; + } + let config = printcraft_render::RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..Default::default() }; + let mut r = printcraft_render::PageRenderer::new(doc.bytes.clone(), config); + let mut edits = Vec::new(); + let author = self.comment_prefs.author.clone(); + for page in 0..doc.info.pages.len() { + let out = + r.render(printcraft_render::RenderRequest { page, kind: printcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); + let Some(text) = out.text else { continue }; + let hits = if d.patterns { text.find_with(|c| printcraft_engine::find_pattern(d.pattern, c)) } else { text.find(&d.text) }; + for h in hits { + let quads: Vec<[f64; 8]> = text.line_rects(h).into_iter().map(|r| doc.info.pages[page].view_rect_to_quad(r)).collect(); + if !quads.is_empty() { + edits.push(self.redact_prefs.mark(page, quads, &author)); + } + } + } + let n = edits.len(); + if n > 0 { + self.apply_edit(Edit::Batch { label: "Mark for redaction".into(), edits }); + } + n + } + + /// Mark the pages of the Redact pages draft. + pub fn redact_pages(&mut self) { + let Some((i, id)) = self.active_ids() else { return }; + let Some(doc) = self.session.get(id) else { return }; + let n = doc.info.pages.len(); + let d = self.redact_pages_draft.clone(); + let pages: Vec = if d.current { vec![self.views[i].current] } else { (d.from.max(1) - 1..d.to.min(n)).collect() }; + let author = self.comment_prefs.author.clone(); + let edits: Vec = pages + .iter() + .map(|&p| { + let c = doc.info.pages[p].crop; + self.redact_prefs.mark(p, vec![rect_quad([c[0] as f64, c[1] as f64, c[2] as f64, c[3] as f64])], &author) + }) + .collect(); + match edits.len() { + 0 => {} + 1 => { + self.apply_edit(edits.into_iter().next().expect("one")); + } + _ => { + self.apply_edit(Edit::Batch { label: "Mark pages for redaction".into(), edits }); + } + } + } +} + +/// Redact pages ("Mark Page Range"). Returns (apply, cancel). +pub(crate) fn pages_body(ui: &mut egui::Ui, d: &mut PagesDraft, pages: usize, _t: &Tokens) -> (bool, bool) { + ui.label(egui::RichText::new("Mark Page Range").font(crate::theme::semibold(18.0))); + ui.add_space(8.0); + ui.radio_value(&mut d.current, true, "Current page"); + ui.horizontal(|ui| { + ui.radio_value(&mut d.current, false, "Pages from"); + ui.add_enabled(!d.current, egui::DragValue::new(&mut d.from).range(1..=pages)); + ui.label("to"); + ui.add_enabled(!d.current, egui::DragValue::new(&mut d.to).range(1..=pages)); + ui.label(format!("of {pages}")); + }); + d.to = d.to.max(d.from); + ui.add_space(12.0); + buttons(ui, "OK", true) +} + +/// Find text and redact. Returns (search, cancel). +pub(crate) fn search_body(ui: &mut egui::Ui, d: &mut SearchDraft, t: &Tokens) -> (bool, bool) { + ui.set_width(420.0); + ui.label(egui::RichText::new("Find text and redact").font(crate::theme::semibold(18.0))); + ui.add_space(8.0); + ui.radio_value(&mut d.patterns, false, "Single word or phrase"); + let mut enter = false; + ui.add_enabled_ui(!d.patterns, |ui| { + let r = ui.add(egui::TextEdit::singleline(&mut d.text).hint_text("Text to find").desired_width(380.0)); + enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + }); + ui.add_space(6.0); + ui.radio_value(&mut d.patterns, true, "Patterns"); + ui.add_enabled_ui(d.patterns, |ui| { + egui::ComboBox::from_id_salt("redact-pattern").selected_text(d.pattern.label()).width(240.0).show_ui(ui, |ui| { + for p in REDACT_PATTERNS { + ui.selectable_value(&mut d.pattern, p, p.label()); + } + }); + }); + ui.add_space(6.0); + ui.label(egui::RichText::new("Text in images isn't found; recognise text (OCR) first.").small().color(t.text_faint)); + if let Some(n) = d.found { + ui.label( + egui::RichText::new(if n == 0 { "No matches.".to_string() } else { format!("{n} match(es) marked for redaction.") }).color(t.text_muted), + ); + } + ui.add_space(12.0); + let (go, cancel) = buttons(ui, "Mark all", true); + (go || enter, cancel) +} + +/// Redaction Tool Properties. Returns (apply, cancel). +pub(crate) fn props_body(ui: &mut egui::Ui, d: &mut RedactPrefs, _t: &Tokens) -> (bool, bool) { + ui.set_width(380.0); + ui.label(egui::RichText::new("Redaction Tool Properties").font(crate::theme::semibold(18.0))); + ui.add_space(8.0); + egui::Grid::new("redact-props").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { + ui.label("Redacted area fill colour:"); + ui.horizontal(|ui| { + let mut none = d.fill.is_none(); + if ui.checkbox(&mut none, "No colour").changed() { + d.fill = if none { None } else { Some([0.0, 0.0, 0.0]) }; + } + }); + ui.end_row(); + ui.label(""); + if let Some(c) = crate::comments::swatch_grid(ui, d.fill) { + d.fill = Some(c); + } + ui.end_row(); + ui.label(""); + ui.checkbox(&mut d.use_overlay, "Use overlay text"); + ui.end_row(); + let l = ui.label("Custom text:"); + ui.add_enabled(d.use_overlay, egui::TextEdit::singleline(&mut d.overlay).desired_width(220.0)).labelled_by(l.id); + ui.end_row(); + }); + ui.add_space(12.0); + buttons(ui, "OK", true) +} + +/// Apply redactions confirmation. Returns (apply, cancel). +pub(crate) fn apply_body(ui: &mut egui::Ui, marks: usize, t: &Tokens) -> (bool, bool) { + ui.set_width(420.0); + ui.label(egui::RichText::new("Apply redactions").font(crate::theme::semibold(18.0))); + ui.add_space(8.0); + ui.label(format!( + "You are about to apply {marks} redaction mark{}. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently.", + if marks == 1 { "" } else { "s" } + )); + ui.add_space(4.0); + ui.label( + egui::RichText::new("Save the document afterwards: saving rewrites the whole file so no trace of the removed content stays in it.") + .small() + .color(t.text_muted), + ); + ui.add_space(12.0); + buttons(ui, "Apply", true) +} + +fn buttons(ui: &mut egui::Ui, ok: &str, primary: bool) -> (bool, bool) { + let (mut a, mut c) = (false, false); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if widgets::pill_button(ui, ok, primary).clicked() { + a = true; + } + if widgets::pill_button(ui, "Cancel", false).clicked() { + c = true; + } + }); + (a, c) +} diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index b3075bf30..02fce260f 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -81,6 +81,17 @@ fn every_registered_command_is_implemented() { author: String::new(), })); } + if spec.id.starts_with("redact.") { + use printcraft_engine::{Edit, NewAnnotation, Shape, Style}; + let shape = Shape::Redact { quads: vec![printcraft_engine::rect_quad([10.0, 10.0, 50.0, 50.0])], overlay: String::new() }; + app.apply_edit(Edit::AddAnnotation(NewAnnotation { + page: 0, + style: Style::default_for(&shape), + shape, + contents: String::new(), + author: String::new(), + })); + } if spec.id == "protect.remove" { let p = printcraft_engine::Protection { open_password: Some("pw".into()), ..Default::default() }; app.apply_edit(printcraft_engine::Edit::Protect(p)); diff --git a/crates/ui-egui/tests/redact.rs b/crates/ui-egui/tests/redact.rs new file mode 100644 index 000000000..5e75b37fa --- /dev/null +++ b/crates/ui-egui/tests/redact.rs @@ -0,0 +1,80 @@ +//! Redact a PDF in the real shell (egui_kittest): the Redact tool, Redact pages, the apply +//! confirmation and Clear all. + +use egui::Pos2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use printcraft_ui_egui::{Dialog, PrintCraftApp}; + +fn harness() -> Harness<'static, PrintCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PrintCraftApp::new(); + app.open_bytes("form.pdf", None, include_bytes!("data/form.pdf").to_vec()).unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + for _ in 0..60 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h +} + +fn at(h: &Harness<'static, PrintCraftApp>, x: f32, y: f32) -> Pos2 { + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let k = r.width() / 300.0; + r.min + egui::vec2(x * k, y * k) +} + +fn marks(h: &Harness<'static, PrintCraftApp>) -> usize { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().redaction_marks() +} + +#[test] +fn marking_applying_and_clearing() { + let mut h = harness(); + assert!(h.state_mut().execute("redact.mark")); + h.run_steps(2); + h.get_by_label("Redact a PDF"); + // Draw a box in an empty part of the page. + let (a, b) = (at(&h, 40.0, 280.0), at(&h, 200.0, 320.0)); + h.hover_at(a); + h.run_steps(1); + h.drag_at(a); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(a + (b - a) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(b); + h.run_steps(4); + assert_eq!(marks(&h), 1); + // Redact pages ▸ current page. + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("Mark Page Range"); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(marks(&h), 2); + // Clear all, then undo it. + h.get_by_label("Clear all").click(); + h.run_steps(3); + assert_eq!(marks(&h), 0); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert_eq!(marks(&h), 2); + // Redact all asks first; Apply removes the marks (and the page's fields under them). + h.get_by_label("Redact all").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(Dialog::RedactApply)); + h.get_by_label("Apply").click(); + h.run_steps(4); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!((doc.redaction_marks(), doc.can_undo()), (0, Some("Apply redactions"))); + assert!(doc.form.is_empty(), "the whole page was redacted, fields included"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 78093b1f4..234017b5e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5419,11 +5419,11 @@ title = "Mark text for redaction" area = "G" tier = "P0" milestone = "M8" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["redact.mark"] +tools = ["redact_mark"] +evidence = ["crates/ui-egui/tests/redact.rs::marking_applying_and_clearing", "crates/automation/tests/automation.rs::redacting_through_tools", "crates/redact/src/tests.rs::glyphs_under_a_mark_go_and_the_rest_stays_put"] +notes = "drag across text (or select first) with the Redact tool" [[feature]] id = "protect.redact-area" @@ -5431,11 +5431,11 @@ title = "Mark areas and images for redaction" area = "G" tier = "P0" milestone = "M8" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["redact.mark"] +tools = ["redact_mark"] +evidence = ["crates/ui-egui/tests/redact.rs::marking_applying_and_clearing", "crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared"] +notes = "drag a box anywhere else" [[feature]] id = "protect.redact-pages" @@ -5443,11 +5443,11 @@ title = "Mark whole pages for redaction" area = "G" tier = "P0" milestone = "M8" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["redact.pages"] +tools = ["redact_mark"] +evidence = ["crates/ui-egui/tests/redact.rs::marking_applying_and_clearing"] +notes = "Mark Page Range: current page or a range" [[feature]] id = "protect.search-and-redact" @@ -5455,11 +5455,11 @@ title = "Find text and redact" area = "G" tier = "P0" milestone = "M8" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["redact.search"] +tools = ["redact_mark"] +evidence = ["crates/automation/tests/automation.rs::redacting_through_tools"] +notes = "single word or phrase, every match on every page" [[feature]] id = "protect.redact-word-lists" @@ -5479,11 +5479,11 @@ title = "Redact patterns (phone, email, card, SSN, dates)" area = "G" tier = "P0" milestone = "M8" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["redact.search"] +tools = ["redact_mark"] +evidence = ["crates/redact/src/patterns.rs::phone_numbers", "crates/redact/src/patterns.rs::credit_cards_need_a_valid_checksum", "crates/automation/tests/automation.rs::redacting_through_tools"] +notes = "phone, e-mail, credit card (Luhn), SSN, dates (English US forms)" [[feature]] id = "protect.redact-pattern-locales" @@ -5527,11 +5527,11 @@ title = "Redaction fill colour and overlay text" area = "G" tier = "P0" milestone = "M8" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["redact.properties"] +tools = ["redact_mark"] +evidence = ["crates/ui-egui/tests/redact.rs::marking_applying_and_clearing"] +notes = "fill colour or none, custom overlay text; fonts, repeat, codes pending" [[feature]] id = "protect.redaction-codes" @@ -5551,11 +5551,11 @@ title = "Apply redactions (remove underlying content)" area = "G" tier = "P0" milestone = "M8" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["redact.apply"] +tools = ["redact_apply"] +evidence = ["crates/ui-egui/tests/redact.rs::marking_applying_and_clearing", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo", "crates/automation/tests/automation.rs::redacting_through_tools"] +notes = "confirmation; one undo step; next save is a full rewrite" [[feature]] id = "protect.redact-glyph-removal" @@ -5563,11 +5563,11 @@ title = "Remove redacted text glyphs from content streams" area = "G" tier = "P0" milestone = "M8" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["redact.apply"] +tools = ["redact_apply"] +evidence = ["crates/redact/src/tests.rs::glyphs_under_a_mark_go_and_the_rest_stays_put", "crates/redact/src/tests.rs::kerning_spacing_scaling_and_line_operators_are_honoured", "crates/redact/src/tests.rs::rotated_text_and_composite_fonts", "crates/redact/src/tests.rs::shared_form_xobjects_are_copied_not_changed"] +notes = "TJ displacement keeps the rest in place; simple, composite (CMaps), Type 3 fonts" [[feature]] id = "protect.redact-image-pixels" @@ -5575,11 +5575,11 @@ title = "Remove redacted image pixels" area = "G" tier = "P0" milestone = "M8" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["redact.apply"] +tools = ["redact_apply"] +evidence = ["crates/redact/src/tests.rs::images_are_removed_or_have_their_pixels_cleared"] +notes = "covered pixels cleared in a copy; DCT/JPX/JBIG2/CCITT images removed whole" [[feature]] id = "protect.redact-vectors" @@ -5587,11 +5587,11 @@ title = "Remove or cut redacted vector paths" area = "G" tier = "P0" milestone = "M8" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["redact.apply"] +tools = ["redact_apply"] +evidence = ["crates/redact/src/tests.rs::vectors_are_removed_or_clipped_and_inline_images_go"] +notes = "covered paths removed, others clipped; shadings clipped" [[feature]] id = "protect.redact-annots-fields" @@ -5599,11 +5599,11 @@ title = "Remove annotations and fields under redactions" area = "G" tier = "P0" milestone = "M8" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["redact.apply"] +tools = ["redact_apply"] +evidence = ["crates/redact/src/tests.rs::comments_links_and_fields_under_a_mark_go", "crates/ui-egui/tests/redact.rs::marking_applying_and_clearing"] +notes = "with pop-ups; fields lose all widgets" [[feature]] id = "protect.redact-struct-tree" @@ -5623,11 +5623,11 @@ title = "Verify no residue remains after redaction" area = "G" tier = "P0" milestone = "M8" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["redact.apply"] +tools = ["redact_apply"] +evidence = ["crates/redact/src/tests.rs::unreadable_content_fails_closed", "crates/engine/src/tests.rs::redaction_marks_apply_for_good_and_undo"] +notes = "re-reads redacted pages; residue or unreadable content fails the whole operation" [[feature]] id = "protect.redact-filename-suffix"