fix(audit): compare a cover with the start of a merged text line

A text line joins compatible runs on one baseline, and the audit took
the line's last painting operator as its paint position. Text drawn
after a cover on the same baseline joined the covered words and moved
the line past the cover, so the covered words went unreported. The
audit now compares against the line's first painting operator: any
run painted before the cover is checked, and runs painted after it
in the same low-contrast color are unreadable on it too.
This commit is contained in:
aiongg
2026-10-22 16:44:38 +00:00
parent 4c7ba85beb
commit 3e306c22ef
3 changed files with 24 additions and 8 deletions
+6 -4
View File
@@ -143,11 +143,13 @@ pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, 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:
+13
View File
@@ -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::<Vec<_>>());
let findings = audit_redactions(&doc).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}");
}
+5 -4
View File
@@ -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 {