Audit: find opaque square annotations over text

This commit is contained in:
midasmatterhorn
2026-10-16 15:18:39 +03:00
parent f0c9901c39
commit 1cb113f110
3 changed files with 71 additions and 14 deletions
+1 -1
View File
@@ -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
+53 -13
View File
@@ -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<Vec<AuditFinding>, 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<Vec<AuditFinding>, 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<Vec<[f64; 4]>, 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<Vec<AnnotationCover>, AuditError> {
let Some(a) = page.get(b"Annots") else { return Ok(Vec::new()) };
let resolved = doc.resolve(a);
let items: Vec<pdfcraft_cos::Object> = match &*resolved {
@@ -198,16 +212,42 @@ fn annots(doc: &Document, page: &Dict) -> Result<Vec<[f64; 4]>, AuditError> {
pdfcraft_cos::Object::Dict(d) => d.clone(),
_ => continue,
};
if dict.name(b"Subtype") != Some(b"Redact") {
continue;
}
let v: Vec<f64> = 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<f64> = dict.get(key).map(|o| doc.resolve(o))?.as_array()?.iter().map(|o| doc.resolve(o).as_f64()).collect::<Option<_>>()?;
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,
}
}
+17
View File
@@ -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.