Files
pdfcraft/crates/redact/src/lib.rs
T

371 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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;
pub mod sanitize;
#[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,
}
}
pub(crate) 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).
pub(crate) 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())
}