mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 12:08:46 -06:00
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:
co-authored by
Claude Opus 5.5
parent
d8209fcbd2
commit
f8175fb97e
Generated
+22
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"])),
|
||||
|
||||
@@ -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(_))));
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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")),
|
||||
],
|
||||
}],
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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}")]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)))?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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).
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}"))?,
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user