mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 19:48:14 -06:00
Audit: find opaque square annotations over text
This commit is contained in:
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user