diff --git a/crates/audit/README.md b/crates/audit/README.md index c8a6a3249..f90468015 100644 --- a/crates/audit/README.md +++ b/crates/audit/README.md @@ -16,6 +16,11 @@ It reports two kinds of finding: A properly applied redaction removes the text, so it never flags. Page backgrounds, table shading and highlights don't either: they're painted before the text, or light against it. +A rectangle covers a text line when it hides at least 40% of the area of the words the line +draws, or at least 40% of the line's box together with a positive-area intersection with one +of its words. The gap an applied redaction leaves (a `TJ` adjustment where the text was) is in the line's box but +holds no word, so a redaction box over it alone does not count. + Limits, by design and documented rather than hidden: - only filled rectangles count as covers, drawn with `re` or as a single `m`/`l` subpath whose @@ -23,7 +28,9 @@ Limits, by design and documented rather than hidden: translucent one (`/ca` below 1) is not flagged; - covers inside form XObjects are found, but only text drawn directly in the page content is checked under them; -- annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign. +- annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign; +- a cover that hides less than 40% of a line's box and less than 40% of its words (a short + value in the middle of a long line) is not flagged. It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper than 32 or in a cycle, or a walk past the operator bound is an error, never "no findings". diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 23965f6c2..204d237b3 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -84,7 +84,8 @@ pub enum AuditError { Edit(#[from] pdfcraft_edit::EditError), } -/// Fraction of a text line's area that must lie under a cover to count as hidden. +/// Fraction of a text line's ink (the area of its words), or of its box, that must lie under a +/// cover to count as hidden (see [`covers`]). const COVER_FRACTION: f64 = 0.4; /// WCAG contrast ratio below which the text is unreadable against the cover. const COVER_CONTRAST: f64 = 3.0; @@ -114,7 +115,7 @@ pub fn audit_redactions(doc: &Document) -> Result, AuditError> for annot in annots(doc, &page.dict)? { let mut covered = String::new(); for line in &lines { - if covers(&annot, &line.rect) { + if covers(&annot, line) { if !covered.is_empty() { covered.push(' '); } @@ -134,23 +135,35 @@ pub fn audit_redactions(doc: &Document) -> Result, AuditError> /// make the text unreadable. Light covers (table shading, highlights) over dark text fail the /// contrast check and are not flagged. fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool { - rect.pos > line.paint_pos() && covers(&rect.rect, &line.rect) && contrast(line.color, rect.rgb) < COVER_CONTRAST + rect.pos > line.paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST } -/// Does `cover` hide a substantial part of `line`? -fn covers(cover: &[f64; 4], line: &[f64; 4]) -> bool { - let ix0 = cover[0].max(line[0]); - let iy0 = cover[1].max(line[1]); - let ix1 = cover[2].min(line[2]); - let iy1 = cover[3].min(line[3]); - if ix1 <= ix0 || iy1 <= iy0 { - return false; - } - let line_area = (line[2] - line[0]).max(0.0) * (line[3] - line[1]).max(0.0); - if line_area <= 0.0 { - return false; - } - (ix1 - ix0) * (iy1 - iy0) / line_area >= COVER_FRACTION +/// Does `cover` hide a substantial part of what `line` draws? Two ways, either is enough: +/// +/// - 40% of the area of the line's words (its `ink`) lies under the cover. Measured on the words, +/// not the box: an applied redaction leaves a gap that draws nothing (a `TJ` adjustment where +/// the text was), and the box still spans it, so a redaction box at the end of a line used to +/// cover most of the box and flag the words left beside it. +/// - 40% of the line's box lies under the cover (the measure used before `ink`) and intersects the ink +/// of a word still drawn. This keeps a cover over a short value after a long label +/// flagged, while a cover over the gap alone, with no word under it, stops counting. +/// +/// A line without word boxes is measured on its box alone. +fn covers(cover: &[f64; 4], line: &TextLine) -> bool { + let hidden = |b: &[f64; 4]| area(&[cover[0].max(b[0]), cover[1].max(b[1]), cover[2].min(b[2]), cover[3].min(b[3])]); + let fraction = |boxes: &[[f64; 4]]| { + let total: f64 = boxes.iter().map(area).sum(); + if total > 0.0 { boxes.iter().map(hidden).sum::() / total } else { 0.0 } + }; + let inked = fraction(&line.ink) >= COVER_FRACTION; + let boxed = fraction(std::slice::from_ref(&line.rect)) >= COVER_FRACTION; + let word_intersects = || line.ink.iter().any(|w| hidden(w) > 0.0); + inked || (boxed && (line.ink.is_empty() || word_intersects())) +} + +/// The area of a rectangle, 0 when it is empty or inverted. +fn area(r: &[f64; 4]) -> f64 { + (r[2] - r[0]).max(0.0) * (r[3] - r[1]).max(0.0) } /// WCAG 2.x relative luminance of an sRGB triple. diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index ff20f5e84..d7d83a7db 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -228,6 +228,93 @@ fn self_drawing_form_fails_the_audit() { assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}"); } +/// An applied redaction leaves the words around it and a `TJ` adjustment over the gap where the +/// text was, then the redaction box. At the end of a line, the box covers most of the line's box +/// (which spans the gap) but none of its words: nothing is hidden, so nothing is flagged. +#[test] +fn applied_redaction_at_the_end_of_a_line_is_not_flagged() { + let content = b"BT /F1 11 Tf 72 742 Td [(Mail the team:) -11354] TJ ET\n0 g\n144.138 739.8 121.814 11 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert!(findings.is_empty(), "{findings:?}"); + // The same gap between two words, wider than the words beside it. + let content = b"BT /F1 11 Tf 72 742 Td [(Card:) -14000 (ok)] TJ ET\n0 g\n101 739.8 150 11 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert!(findings.is_empty(), "{findings:?}"); +} + +/// The control: the same box over text still drawn under it is a faux redaction, at the end of a +/// line as in the middle of a short one. +#[test] +fn faux_redaction_at_the_end_of_a_line_is_found() { + let content = b"BT /F1 11 Tf 72 742 Td (Mail the team: someone@example.com) Tj ET\n0 g\n144.138 739.8 121.814 11 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].covered_text, "Mail the team: someone@example.com"); + let content = b"BT /F1 11 Tf 72 742 Td [(Card:) -300 (4111111111111111) -300 (ok)] TJ ET\n0 g\n101 739.8 100 11 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); +} + +/// A cover over a short value after a long label hides less than 40% of the line's words, but the +/// whole of the words it covers, and most of the line's box: still a faux redaction. The value +/// follows the label in the same `TJ` after a wide gap, or in a second run moved by `Td`. +#[test] +fn faux_redaction_over_a_short_value_after_a_long_label_is_found() { + let content = b"BT /F1 11 Tf 72 742 Td [(Social insurance number:) -8000 (123 456 789)] TJ ET\n0 g\n200 739.8 150 11 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].covered_text, "Social insurance number: 123 456 789"); + let content = b"BT /F1 11 Tf 72 742 Td (Account:) Tj 70 0 Td (4111) Tj ET\n0 g\n115 739.8 55 11 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert!(findings[0].covered_text.contains("4111"), "{findings:?}"); +} + +/// Text set with a negative font size in a mirrored text matrix runs its glyphs backwards: a +/// cover over the address it draws is still found. +#[test] +fn faux_redaction_over_mirrored_text_is_found() { + let content = b"BT /F1 -11 Tf -1 0 0 1 300 742 Tm (Mail the team: someone@example.com) Tj ET\n0 g\n365 733 130 14 re f\n"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].covered_text, "Mail the team: someone@example.com"); +} + +/// A cover over less than half a value must retain the historical line-box detection. +#[test] +fn faux_redaction_over_a_partial_value_after_a_long_label_is_found() { + partial_value_is_found(false); +} + +#[test] +fn faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found() { + partial_value_is_found(true); +} + +fn partial_value_is_found(rotated: bool) { + let bytes = one_page( + b"BT /F1 11 Tf 72 742 Td [(Social insurance number:) -8000 (123456789)] TJ ET\n0 g\n193 738 111 16 re f\n", + if rotated { "/Rotate 90" } else { "" }, + ); + let findings = audit_redactions(&open(bytes)).expect("audit must run"); + assert_eq!(findings.len(), 1, "partial value, rotated={rotated}"); +} + +/// Keep the historic detections with several covers, lines and text-spacing states. +#[test] +fn faux_redactions_with_multiple_covers_lines_and_spacing_are_found() { + let cases: [&[u8]; 4] = [ + b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj ET\n0 g\n72 738 65 16 re f\n140 738 40 16 re f\n", + b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj 0 -15 Td (Account: 987654321) Tj ET\n0 g\n115 723 100 30 re f\n", + b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n", + b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n", + ]; + for content in cases { + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{}: {findings:?}", String::from_utf8_lossy(content)); + } +} + #[test] fn rectangle_drawn_as_a_path_covers_text_like_re() { // The same cover as `re`, built from `m`/`l` (closed with `h`, a repeated start point, or diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 43705c88f..a10c5acdf 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -3137,7 +3137,11 @@ fn removing_hidden_information_through_tools() { /// A one-page PDF whose text "TOP SECRET" sits under an opaque black rectangle. fn faux_redacted_fixture() -> Vec { - let body = "BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f"; + helvetica_page("BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f") +} + +/// A one-page US Letter PDF drawing `body` with Helvetica as /F1. +fn helvetica_page(body: &str) -> Vec { let objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [4 0 R] /Count 1 >>".to_vec(), @@ -3179,6 +3183,25 @@ fn auditing_faux_redactions_through_tools() { assert_eq!(ok(&mut a, "doc_audit_redactions", json!({ "doc": clean }))["count"], 0); } +/// An applied redaction at the end of a line leaves the words before it, then a gap the text +/// matrix jumps over and the redaction box: nothing is under the box any more, so the audit must +/// not report the words beside it as covered. In the middle of a line it never did. +#[test] +fn an_applied_redaction_at_the_end_of_a_line_audits_clean() { + let dir = workdir("audit-applied-end-of-line"); + let body = "BT /F1 11 Tf 72 692 Td (Mail the team: someone@example.com) Tj ET\nBT /F1 11 Tf 72 662 Td (Account 0042-7788 is in the middle of this line.) Tj ET"; + std::fs::write(dir.join("mail.pdf"), helvetica_page(body)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "mail.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "redact_mark", json!({ "doc": doc, "pattern": "email" })); + ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "0042-7788" })); + ok(&mut a, "redact_apply", json!({ "doc": doc })); + let text = ok(&mut a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap().to_string(); + assert!(text.contains("Mail the team:") && !text.contains("someone") && !text.contains("0042"), "{text}"); + let r = ok(&mut a, "doc_audit_redactions", json!({ "doc": doc })); + assert_eq!(r["count"], 0, "{r}"); +} + #[test] fn printing_through_tools() { let dir = workdir("print"); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 839ba00ee..912cb3c90 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1346,6 +1346,28 @@ fn independent_text_objects_with_different_transforms_stay_separate() { assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2); } +/// A negative font size or `Tz` runs the glyphs backwards along the text matrix: each word's +/// `ink` box still spans all its glyphs, not only the first. +#[test] +fn word_boxes_span_whole_words_when_glyphs_run_backwards() { + // "someone@example.com" in Helvetica 11 is 121.814 pt wide. + for content in [ + "BT /F1 -11 Tf -1 0 0 1 300 742 Tm (Mail the team: someone@example.com) Tj ET", + "BT /F1 11 Tf -100 Tz 300 742 Td (Mail the team: someone@example.com) Tj ET", + ] { + let lines = text::text_lines(&text_page(content), 0).unwrap(); + assert_eq!(lines.len(), 1, "{content}"); + let ink = &lines[0].ink; + assert_eq!(ink.len(), 4, "{content}: {ink:?}"); + let width = |r: &[f64; 4]| r[2] - r[0]; + assert!(ink.iter().all(|r| width(r) > 5.0), "{content}: {ink:?}"); + let address = ink.iter().map(width).fold(0.0, f64::max); + assert!((address - 121.814).abs() < 0.01, "{content}: {ink:?}"); + let (x0, x1) = ink.iter().fold((f64::MAX, f64::MIN), |(a, b), r| (a.min(r[0]), b.max(r[2]))); + assert!(x0 >= lines[0].rect[0] - 0.01 && x1 <= lines[0].rect[2] + 0.01, "{content}: {ink:?} {:?}", lines[0].rect); + } +} + #[test] fn text_lines_are_found_and_replaced_in_place() { let mut doc = diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index bf310156d..198246317 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -31,6 +31,10 @@ pub struct TextLine { pub text: String, /// Its box in user space. pub rect: [f64; 4], + /// The boxes of the words it draws, in user space: from each word's first glyph to the end of + /// its last, without the spaces and `TJ` adjustments around it. `rect` also spans gaps that + /// draw nothing, such as the adjustment an applied redaction leaves where text was removed. + pub ink: Vec<[f64; 4]>, /// The font's resource name and `/BaseFont` (or Type 3 descriptor's `/FontName`), and its size in text space. pub font: String, pub base_font: String, @@ -473,6 +477,8 @@ struct Shown { bt_state: Ts, text: String, rect: [f64; 4], + /// The boxes of its words (see [`TextLine::ink`]). + ink: Vec<[f64; 4]>, baseline: f64, start_x: f64, end_x: f64, @@ -567,6 +573,7 @@ fn interpret( first.rect[2].max(shown.rect[2]), first.rect[3].max(shown.rect[3]), ]; + first.ink.extend(shown.ink); } first.extra_ops.extend([open, i]); // Only right-to-left ActualText is already in typing order and must stay @@ -667,16 +674,34 @@ fn interpret( let mut text = String::new(); let mut decodable = true; let mut x_text = 0.0; + // Where each word's glyphs start and end along the run (text space), and whether + // the next glyph continues the last word. + let mut words: Vec<(f64, f64)> = Vec::new(); + let mut in_word = false; let mut glyph_count = 0_usize; for p in &pieces { match p { Object::String(s) => { for (code, len) in m.codes(&s.bytes) { glyph_count = glyph_count.saturating_add(1); - match m.text_of(code) { + let shown = m.text_of(code); + let blank = + m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace)); + match shown { Some(t) => text.push_str(&t), None => decodable = false, } + if !blank { + let ink_end = x_text + m.width(code) * ts.size * ts.scale; + // A negative font size or `Tz` runs the glyphs backwards: + // the word is the union of its glyphs, whichever way they go. + let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end)); + match words.last_mut() { + Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), + _ => words.push((lo, hi)), + } + } + in_word = !blank; let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; x_text += w * ts.scale; } @@ -688,6 +713,11 @@ fn interpret( if dx > ts.size * 0.2 && !text.ends_with(' ') { text.push(' '); } + // Kerning keeps a word together; a jump either way of more + // than 0.2 em ends it (whatever the sign of the size or `Tz`). + if dx.abs() > (ts.size * ts.scale).abs() * 0.2 { + in_word = false; + } x_text += dx; } } @@ -705,6 +735,15 @@ fn interpret( let rect = corners .iter() .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); + let ink = words + .iter() + .map(|&(a, b)| { + let (y0, y1) = (ts.rise + m.descent * ts.size, ts.rise + m.ascent * ts.size); + [trm0.apply(a, y0), trm0.apply(b, y0), trm0.apply(a, y1), trm0.apply(b, y1)] + .iter() + .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)]) + }) + .collect(); let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size; // A numeric-only TJ adjusts the current text matrix but paints no glyphs. // It must not create a phantom editable line or enlarge its neighbours' boxes. @@ -723,6 +762,7 @@ fn interpret( bt_state: at_bt.1.clone(), text, rect, + ink, baseline: start.1, start_x: start.0, end_x: end.0, @@ -798,6 +838,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro } l.text.push_str(&s.text); l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; + l.ink.extend(s.ink.iter().copied()); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); l.decodable &= s.decodable; @@ -808,6 +849,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro lines.push(TextLine { text: s.text.clone(), rect: s.rect, + ink: s.ink.clone(), font: String::from_utf8_lossy(&s.font).into_owned(), base_font: s.base_font.clone(), size: s.size, @@ -885,6 +927,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option<(usize, f64, f64, f64) } l.text.push_str(&s.text); l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; + l.ink.extend(s.ink.iter().copied()); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); l.decodable &= s.decodable; @@ -894,6 +937,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option<(usize, f64, f64, f64) lines.push(TextLine { text: s.text.clone(), rect: s.rect, + ink: s.ink.clone(), font: String::from_utf8_lossy(&s.font).into_owned(), base_font: s.base_font.clone(), size: s.size, diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index bfc05d107..2864e37e0 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5800,8 +5800,8 @@ milestone = "M8" status = "partial" commands = [] tools = ["doc_audit_redactions"] -evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools"] -notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist)." +evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/automation/tests/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover"] +notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the text it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover over less than 40% of both a line's box and its words (a short value in the middle of a long line) is still not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content is an error, never a clean bill. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist)." [[feature]] id = "protect.remove-hidden-on-close"