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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 00:24:29 -04:00
co-authored by Claude Opus 5.5
parent d8209fcbd2
commit f8175fb97e
42 changed files with 3295 additions and 73 deletions
Generated
+22
View File
@@ -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"
+2
View File
@@ -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" }
+19
View File
@@ -201,6 +201,25 @@ pub fn build(d: &Dict) -> Option<Stream> {
}
}
}
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() {
+28 -1
View File
@@ -167,6 +167,19 @@ pub enum Shape {
Signature {
strokes: Vec<Vec<[f64; 2]>>,
},
/// 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 {
+1 -1
View File
@@ -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<Rgb> {
let named = match s.to_ascii_lowercase().as_str() {
+4
View File
@@ -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)?,
+106
View File
@@ -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<Value> {
let doc = self.doc(a)?;
let (id, n) = (doc.id, doc.info.pages.len());
let pages: Vec<usize> = match a.opt_ints("pages")? {
Some(_) => self.pages(a, "pages")?,
None => (0..n).collect(),
};
let overlay = a.opt_str("overlay")?.unwrap_or("").to_string();
let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
let mut style = Style::default_for(&Shape::Redact { quads: Vec::new(), overlay: String::new() });
if let Some(c) = a.opt_str("fill")? {
style.fill = Some(parse_color(c)?);
}
// (page, quads) per mark.
let mut marks: Vec<(usize, Vec<[f64; 8]>)> = Vec::new();
let chosen = [a.get("rect").is_some(), a.get("find").is_some(), a.get("pattern").is_some(), a.opt_bool("whole_pages")?.unwrap_or(false)];
if chosen.iter().filter(|c| **c).count() != 1 {
return Err(ToolError::InvalidArgs("pass exactly one of rect (with page), find, pattern or whole_pages: true".into()));
}
if a.get("rect").is_some() {
let page = self.page(a)?;
let info = &self.doc(a)?.info.pages[page];
let r: Vec<f64> = a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("rect must be 4 numbers".into()))?;
let q = info.view_rect_to_quad([r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]);
marks.push((page, vec![q]));
} else if a.opt_bool("whole_pages")?.unwrap_or(false) {
let info = &self.doc(a)?.info;
for &p in &pages {
let c = info.pages[p].crop;
marks.push((p, vec![rect_quad([c[0] as f64, c[1] as f64, c[2] as f64, c[3] as f64])]));
}
} else {
let find = a.opt_str("find")?.map(str::to_owned);
let pattern = match a.opt_str("pattern")? {
Some(p) => Some(RedactPattern::from_id(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown pattern {p:?}")))?),
None => None,
};
let texts = self.page_texts(id, &pages)?;
let info = self.doc(a)?.info.clone();
for (&p, text) in pages.iter().zip(&texts) {
let hits = match (&find, pattern) {
(Some(f), _) => text.find(f),
(None, Some(pat)) => text.find_with(|chars| find_pattern(pat, chars)),
_ => Vec::new(),
};
for h in hits {
let quads: Vec<[f64; 8]> = text.line_rects(h).into_iter().map(|r| info.pages[p].view_rect_to_quad(r)).collect();
if !quads.is_empty() {
marks.push((p, quads));
}
}
}
if marks.is_empty() {
return Err(failed("nothing matched, so nothing was marked"));
}
}
let count = marks.len();
let edits: Vec<Edit> = marks
.iter()
.map(|(page, quads)| {
let shape = Shape::Redact { quads: quads.clone(), overlay: overlay.clone() };
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::<std::collections::BTreeSet<_>>());
out["marks_pending"] = json!(self.doc(a)?.redaction_marks());
Ok(out)
}
pub(crate) fn redact_apply(&mut self, a: &Args) -> Result<Value> {
let pages = match a.opt_ints("pages")? {
Some(_) => Some(self.pages(a, "pages")?),
None => None,
};
let before = self.doc(a)?.redaction_marks();
if before == 0 {
return Err(failed("there are no redaction marks to apply (see redact_mark)"));
}
let mut out = self.apply(a, Edit::ApplyRedactions { pages })?;
let after = self.doc(a)?.redaction_marks();
out["applied"] = json!(before - after);
out["marks_pending"] = json!(after);
Ok(out)
}
pub(crate) fn redact_clear(&mut self, a: &Args) -> Result<Value> {
if self.doc(a)?.redaction_marks() == 0 {
return Err(failed("there are no redaction marks"));
}
self.apply(a, Edit::ClearRedactions)
}
}
+30
View File
@@ -257,6 +257,36 @@ pub fn tools() -> Vec<ToolDef> {
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"])),
+24
View File
@@ -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(_))));
}
+12
View File
@@ -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 }
+14
View File
@@ -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.
+287
View File
@@ -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<u8>,
pub operands: Vec<Object>,
/// Inline images: the image dictionary (`operands` is empty) and the data between `ID` and
/// `EI`.
pub inline: Option<(Dict, Vec<u8>)>,
/// The bytes of the source stream this operator came from (operands included).
pub span: std::ops::Range<usize>,
}
impl Op {
pub fn new(op: &str, operands: Vec<Object>) -> 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<f64> {
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<const N: usize>(&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<Op>,
/// 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<Object> = Vec::new();
let mut start: Option<usize> = 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<u8>, 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<u8>) {
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<u8> {
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<u8>) -> 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<Self> {
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<Matrix> {
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
}
+65
View File
@@ -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::<Vec<_>>(), b.ops.iter().map(|o| (&o.op, &o.operands)).collect::<Vec<_>>());
}
#[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));
}
+15
View File
@@ -89,6 +89,8 @@ pub struct Document {
encrypt_num: Option<u32>,
/// 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<Arc<printcraft_crypt::SecurityHandler>>,
out_encrypt_num: Option<u32>,
@@ -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
+2 -2
View File
@@ -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<Vec<u8>, 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();
+11
View File
@@ -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<u8>) -> 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 <</Type /Pagination /Subtype /{subtype} /PCMark /{}>> BDC\n{} {} {} {} {} {} cm\n",
+1
View File
@@ -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 }
+5 -4
View File
@@ -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")),
],
}],
+9
View File
@@ -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<DocId>)
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()),
+26 -1
View File
@@ -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<Vec<u8>>);
@@ -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<Vec<usize>>,
},
/// 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::<Vec<_>>(), *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}")]
+28
View File
@@ -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);
+10 -2
View File
@@ -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<Object> = 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)))?;
}
_ => {}
}
}
}
+13
View File
@@ -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"));
}
+21
View File
@@ -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 }
+37
View File
@@ -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<Mark> = 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).
+281
View File
@@ -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<u8>, Vec<u8>)>),
}
#[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<f64>,
/// Composite fonts: CID → width, and CID ranges with one width.
cid_widths: HashMap<u32, f64>,
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<Std14>,
/// 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<f64> {
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<Dict> {
o.and_then(|o| doc.resolve(o).as_dict().cloned())
}
fn hex_bytes(tok: &str) -> Option<Vec<u8>> {
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<u8>, Vec<u8>)>, 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::<u32>()) {
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::<u32>()) {
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
}
}
+118
View File
@@ -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<usize> {
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<Option<Stream>, ()> {
decode_and_clear(doc, s, ctm, rects).ok_or(())
}
fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Option<Option<Stream>> {
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)))
}
+546
View File
@@ -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<Metrics>,
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<Option<Vec<u8>>>,
/// New XObjects the rewritten content refers to: (resource name, object).
pub xobjects: Vec<(Vec<u8>, 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<Vec<u8>, Rc<Metrics>>,
used_names: Vec<Vec<u8>>,
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<u8> {
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<Vec<Op>> {
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<usize>,
/// 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<u8>], 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<Gs> = 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<Op> = 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<Op> = 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::<Vec<_>>().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<Op> = 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<Object> = 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<Object>, usize) {
let f = &gs.font;
let size = gs.size;
let mut out: Vec<Object> = Vec::new();
let mut removed = 0;
let push_num = |out: &mut Vec<Object>, 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<u8> = 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<Option<Op>> {
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<f64> = 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,
}
}
+369
View File
@@ -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<Rgb>,
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<f64> = 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<Rgb> {
let v: Vec<f64> = 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<Object> {
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<Mark> {
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<f64> = 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<Object>, Vec<Vec<u8>>), RedactError> {
let list: Vec<Object> = 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<u8> {
let n = |v: f64| {
let s = format!("{:.3}", v);
s.trim_end_matches('0').trim_end_matches('.').to_string()
};
let mut c: Vec<u8> = 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<Report, RedactError> {
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<usize> = chosen.iter().map(|m| m.page).collect();
by_page.sort_unstable();
by_page.dedup();
report.pages = by_page.len();
let mut doomed_fields: Vec<String> = 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::<Vec<_>>()).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<ObjRef> = 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<Object> = 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<Vec<u8>> = 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<usize, RedactError> {
let all = marks(doc);
let doomed: Vec<ObjRef> = 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<Object> = 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())
}
+291
View File
@@ -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<Self> {
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<usize> {
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<usize> {
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<usize> {
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<u32> = 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<usize> {
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<usize> {
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::<String>().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<usize> {
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<usize> {
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<Range<usize>> {
let f: fn(&[char], usize) -> Option<usize> = 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<String> {
let c: Vec<char> = 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"]
);
}
}
+266
View File
@@ -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<u8> {
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<Vec<u8>>) -> 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<u8> {
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<Vec<u8>>) -> Document {
let mut objs: Vec<Vec<u8>> = 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::<Vec<_>>().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<u8>> = 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)));
}
+18
View File
@@ -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<std::ops::Range<usize>>) -> Vec<std::ops::Range<usize>> {
let mut chars: Vec<char> = Vec::new();
let mut owner: Vec<usize> = 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<usize> {
self.glyphs
+27 -1
View File
@@ -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<Arc<PageText>> {
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);
+40
View File
@@ -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;
+1
View File
@@ -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(),
+48
View File
@@ -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<Dialog> = 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()
+16
View File
@@ -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<comment_props::PropsDraft>,
pub field_props: Option<prepare::FieldDraft>,
pub redact_prefs: RedactPrefs,
pub redact_pages_draft: RedactPagesDraft,
pub redact_search: RedactSearchDraft,
/// The Replace Pages dialog's state.
pub replace_draft: Option<files::ReplaceDraft>,
/// 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}"))?,
),
+21 -1
View File
@@ -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);
}
+310
View File
@@ -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<Rgb>,
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<usize>,
}
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<usize> = 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<Edit> = 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)
}
+11
View File
@@ -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));
+80
View File
@@ -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");
}
+60 -60
View File
@@ -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"