diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index b79353e85..6dc329d9a 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -143,11 +143,13 @@ pub fn audit_redactions(doc: &Document) -> Result, AuditError> } /// A filled rectangle is a faux-redaction cover for a text line iff it was painted strictly -/// after the line, overlaps it substantially, and is dark enough against the line's color to -/// make the text unreadable. Light covers (table shading, highlights) over dark text fail the -/// contrast check and are not flagged. +/// after the start of the line, overlaps it substantially, and is dark enough against the line's +/// color to make the text unreadable. Light covers (table shading, highlights) over dark text fail +/// the contrast check and are not flagged. A line can join runs painted before and after the +/// cover; the later ones are drawn on it in the same low-contrast color, so they are unreadable +/// too. fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool { - rect.pos > line.paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST + rect.pos > line.first_paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST } /// Does `cover` hide a substantial part of what `line` draws? Three ways, any is enough: diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 9be067ae0..9d5a1726d 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -631,3 +631,16 @@ fn indirect_subtype_is_resolved() { assert_eq!(findings.len(), 1, "{findings:?}"); assert_eq!(findings[0].kind, FindingKind::CoveredText); } + +/// Text drawn after a cover on the same baseline joins the covered text in one line. The cover +/// still hides the words painted before it. +#[test] +fn later_text_on_the_line_does_not_clear_an_earlier_cover() { + let content = b"0 g\nBT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n98 688 94 32 re f\nBT /F1 24 Tf 200 700 Td (tail) Tj ET\n"; + let doc = open(one_page(content, "")); + let lines = pdfcraft_edit::text_lines(&doc, 0).expect("text"); + assert_eq!(lines.len(), 1, "the runs join one line: {:?}", lines.iter().map(|l| &l.text).collect::>()); + let findings = audit_redactions(&doc).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}"); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 93a5eec99..1861b7816 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -528,12 +528,13 @@ impl TextLine { self.origin.baseline } - /// Paint position of the line's last text-showing operator: `(content-stream index, - /// operator index in the page's joined stream)`. Lines paint in order of this tuple. + /// Paint position of the line's first operator: `(content-stream index, operator index in + /// the page's joined stream)`. A line joins runs painted at different times, so anything + /// painted after this position may lie on top of part of it. /// (Lines from `text_lines` never come from form XObjects, so `stream` is always a /// page content-stream index and `ops` are indices into the joined operator list.) - pub fn paint_pos(&self) -> (usize, usize) { - (self.stream, self.ops.iter().max().copied().unwrap_or(0)) + pub fn first_paint_pos(&self) -> (usize, usize) { + (self.stream, self.ops.iter().min().copied().unwrap_or(0)) } fn rotation(&self) -> f64 {