From 1cb113f110dc18906cc826dc75c87989b606abe7 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:18:39 +0300 Subject: [PATCH] Audit: find opaque square annotations over text --- crates/audit/README.md | 2 +- crates/audit/src/lib.rs | 66 +++++++++++++++++++++++++++++++-------- crates/audit/src/tests.rs | 17 ++++++++++ 3 files changed, 71 insertions(+), 14 deletions(-) diff --git a/crates/audit/README.md b/crates/audit/README.md index 5dd15de3a..263f7edcc 100644 --- a/crates/audit/README.md +++ b/crates/audit/README.md @@ -10,7 +10,7 @@ It reports two kinds of finding: - **faux redaction**: an opaque filled rectangle painted *after* text it overlaps, dark enough against the text's colour (WCAG contrast below 3) to hide it, while the text is still - extractable underneath; + extractable underneath; opaque filled Square annotations count as covers too; - **unapplied mark**: a Redact annotation that was marked but never applied. A properly applied redaction removes the text, so it never flags. Page backgrounds, table diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 204d237b3..894a78000 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -12,8 +12,9 @@ //! Detection limits (documented, not hidden): `re` rectangles and single closed `m`/`l` //! subpaths whose four corners form an axis-aligned rectangle in user space (#1510) are //! considered when closed by a fill operator — other filled paths, shadings and patterns are -//! not; a rectangle drawn with a translucent ExtGState (`/ca < 1`) is not flagged; annotation -//! rectangles are compared in unrotated user space, so pages with `/Rotate` may misalign; +//! not; a rectangle drawn with a translucent ExtGState (`/ca < 1`) is not flagged; opaque +//! filled `/Square` annotations count as covers, but annotation rectangles are compared in +//! unrotated user space, so pages with `/Rotate` may misalign; //! covers inside form XObjects are found, but only text drawn directly in the page content //! is checked under them, so text inside a form XObject is not audited. //! @@ -111,11 +112,15 @@ pub fn audit_redactions(doc: &Document) -> Result, AuditError> out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text: covered }); } } - // 2. Redact annotations that were never applied. + // 2. Annotation covers over extractable text. for annot in annots(doc, &page.dict)? { let mut covered = String::new(); for line in &lines { - if covers(&annot, line) { + let hidden = match &annot { + AnnotationCover::Redact(rect) => covers(rect, line), + AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST, + }; + if hidden { if !covered.is_empty() { covered.push(' '); } @@ -123,7 +128,11 @@ pub fn audit_redactions(doc: &Document) -> Result, AuditError> } } if !covered.trim().is_empty() { - out.push(AuditFinding { page: n, kind: FindingKind::UnappliedMark, rect: annot, covered_text: covered }); + let (kind, rect) = match annot { + AnnotationCover::Redact(rect) => (FindingKind::UnappliedMark, rect), + AnnotationCover::Square { rect, .. } => (FindingKind::CoveredText, rect), + }; + out.push(AuditFinding { page: n, kind, rect, covered_text: covered }); } } } @@ -182,9 +191,14 @@ fn contrast(a: [f64; 3], b: [f64; 3]) -> f64 { (hi + 0.05) / (lo + 0.05) } -/// Rectangles of `/Redact` annotations on a page (unrotated user space). A missing `/Annots` -/// means no marks; a malformed one fails the audit rather than silently passing. -fn annots(doc: &Document, page: &Dict) -> Result, AuditError> { +enum AnnotationCover { + Redact([f64; 4]), + Square { rect: [f64; 4], rgb: [f64; 3] }, +} + +/// Opaque annotation covers on a page (unrotated user space). A missing `/Annots` means no +/// covers; a malformed one fails the audit rather than silently passing. +fn annots(doc: &Document, page: &Dict) -> Result, AuditError> { let Some(a) = page.get(b"Annots") else { return Ok(Vec::new()) }; let resolved = doc.resolve(a); let items: Vec = match &*resolved { @@ -198,16 +212,42 @@ fn annots(doc: &Document, page: &Dict) -> Result, AuditError> { pdfcraft_cos::Object::Dict(d) => d.clone(), _ => continue, }; - if dict.name(b"Subtype") != Some(b"Redact") { - continue; - } let v: Vec = match dict.get(b"Rect") { None => continue, Some(r) => doc.resolve(r).as_array().map(|arr| arr.iter().filter_map(|o| doc.resolve(o).as_f64()).collect()).unwrap_or_default(), }; - if v.len() == 4 && v.iter().all(|x| x.is_finite()) { - out.push([v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]); + if v.len() != 4 || !v.iter().all(|x| x.is_finite()) { + continue; + } + let rect = [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]; + match dict.name(b"Subtype") { + Some(b"Redact") => out.push(AnnotationCover::Redact(rect)), + Some(b"Square") => { + let alpha = dict.get(b"CA").map(|o| doc.resolve(o)).and_then(|o| o.as_f64()).unwrap_or(1.0); + if alpha >= 1.0 + && let Some(rgb) = annotation_color(doc, &dict, b"IC") + { + out.push(AnnotationCover::Square { rect, rgb }); + } + } + _ => {} } } Ok(out) } + +fn annotation_color(doc: &Document, dict: &Dict, key: &[u8]) -> Option<[f64; 3]> { + let values: Vec = dict.get(key).map(|o| doc.resolve(o))?.as_array()?.iter().map(|o| doc.resolve(o).as_f64()).collect::>()?; + if !values.iter().all(|v| v.is_finite()) { + return None; + } + match values.as_slice() { + [g] => Some([g.clamp(0.0, 1.0); 3]), + [r, g, b] => Some([r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)]), + [c, m, y, k] => { + let (c, m, y, k) = (c.clamp(0.0, 1.0), m.clamp(0.0, 1.0), y.clamp(0.0, 1.0), k.clamp(0.0, 1.0)); + Some([(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]) + } + _ => None, + } +} diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index d7d83a7db..dcc479404 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -175,6 +175,23 @@ fn unapplied_redact_mark_is_found() { assert!(findings[0].covered_text.contains("DO NOT SHARE")); } +#[test] +fn opaque_filled_square_annotation_over_text_is_found() { + let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n"; + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>"); + b.add_stream(content); + b.add(FONT); + b.add("<< /Type /Annot /Subtype /Square /Rect [95 685 325 725] /C [0 0 0] /IC [0 0 0] >>"); + let findings = audit_redactions(&open(b.build())).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].kind, FindingKind::CoveredText); + assert_eq!(findings[0].rect, [95.0, 685.0, 325.0, 725.0]); + assert_eq!(findings[0].covered_text, "SECRET DATA 123"); +} + #[test] fn background_before_text_is_not_flagged() { // An opaque dark rectangle painted *before* the text is a background, not a cover.