mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
M8: the redaction audit finds a cover over one word of a long line (#2662)
A cover counted only when it hid 40% of the line's words or box, so a black box over one number in a long line, and an unapplied redaction mark over it, audited clean while the number was still extractable. A cover that hides half of any one word now counts as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
dc41044ba7
commit
9dfc91e0e8
@@ -16,9 +16,9 @@ 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
|
||||
A rectangle covers a text line when it hides at least half of one of the line's words, 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:
|
||||
@@ -29,8 +29,8 @@ Limits, by design and documented rather than hidden:
|
||||
- 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;
|
||||
- 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.
|
||||
- a cover that hides less than half of every word it touches, less than 40% of a line's box and
|
||||
less than 40% of its words (a sliver of a long value) 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".
|
||||
|
||||
+10
-1
@@ -87,6 +87,9 @@ pub enum AuditError {
|
||||
/// 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;
|
||||
/// Fraction of one word's area that must lie under a cover to count as hidden on its own, however
|
||||
/// long the line around it (see [`covers`]).
|
||||
const WORD_FRACTION: f64 = 0.5;
|
||||
/// WCAG contrast ratio below which the text is unreadable against the cover.
|
||||
const COVER_CONTRAST: f64 = 3.0;
|
||||
|
||||
@@ -138,7 +141,10 @@ 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
|
||||
}
|
||||
|
||||
/// Does `cover` hide a substantial part of what `line` draws? Two ways, either is enough:
|
||||
/// Does `cover` hide a substantial part of what `line` draws? Three ways, any is enough:
|
||||
///
|
||||
/// - Half of one word lies under the cover. A box over one number in a long line hides a few
|
||||
/// percent of the line, and the number all the same (#2662).
|
||||
///
|
||||
/// - 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
|
||||
@@ -155,6 +161,9 @@ fn covers(cover: &[f64; 4], line: &TextLine) -> bool {
|
||||
let total: f64 = boxes.iter().map(area).sum();
|
||||
if total > 0.0 { boxes.iter().map(hidden).sum::<f64>() / total } else { 0.0 }
|
||||
};
|
||||
if line.ink.iter().any(|w| area(w) > 0.0 && hidden(w) / area(w) >= WORD_FRACTION) {
|
||||
return true;
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -175,6 +175,47 @@ fn unapplied_redact_mark_is_found() {
|
||||
assert!(findings[0].covered_text.contains("DO NOT SHARE"));
|
||||
}
|
||||
|
||||
/// #2662: a box over one number of a long line hides a few percent of the line, and all of the
|
||||
/// number. Found as a cover, and as an unapplied mark over the same word.
|
||||
#[test]
|
||||
fn a_cover_over_one_word_of_a_long_line_is_found() {
|
||||
// Helvetica 11: "The contract value is " ends near x 132, "98765" spans about 132-163.
|
||||
let line = b"BT /F1 11 Tf 30 100 Td (The contract value is 98765 and the rest of this long line continues here.) Tj ET
|
||||
";
|
||||
let mut content = line.to_vec();
|
||||
content.extend_from_slice(
|
||||
b"0 g
|
||||
131 96 33 14 re f
|
||||
",
|
||||
);
|
||||
let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].kind, FindingKind::CoveredText);
|
||||
assert!(findings[0].covered_text.contains("98765"), "{findings:?}");
|
||||
|
||||
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(line);
|
||||
b.add(FONT);
|
||||
b.add("<< /Type /Annot /Subtype /Redact /Rect [131 96 164 110] >>");
|
||||
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].kind, FindingKind::UnappliedMark);
|
||||
}
|
||||
|
||||
/// A box that only grazes the words beside it (less than half of each) is not a cover.
|
||||
#[test]
|
||||
fn a_box_grazing_a_word_is_not_flagged() {
|
||||
let content = b"BT /F1 11 Tf 30 100 Td (The contract value is 98765 and the rest of this long line continues here.) Tj ET
|
||||
0 g
|
||||
131 96 6 14 re f
|
||||
";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_before_text_is_not_flagged() {
|
||||
// An opaque dark rectangle painted *before* the text is a background, not a cover.
|
||||
@@ -303,15 +344,17 @@ fn partial_value_is_found(rotated: bool) {
|
||||
/// 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",
|
||||
// The second box of the first case hides most of the number (less than 40% of the line): a
|
||||
// finding of its own since covers are also measured per word (#2662).
|
||||
let cases: [(&[u8], usize); 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", 2),
|
||||
(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", 1),
|
||||
(b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n", 1),
|
||||
(b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n", 1),
|
||||
];
|
||||
for content in cases {
|
||||
for (content, expected) 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));
|
||||
assert_eq!(findings.len(), expected, "{}: {findings:?}", String::from_utf8_lossy(content));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5812,8 +5812,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::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", "apps/pdfcraft-cli/tests/audit.rs::audit_rectangles_match_the_tool_in_top_left_points"]
|
||||
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). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools."
|
||||
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/audit/src/tests.rs::a_cover_over_one_word_of_a_long_line_is_found", "crates/audit/src/tests.rs::a_box_grazing_a_word_is_not_flagged", "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", "apps/pdfcraft-cli/tests/audit.rs::audit_rectangles_match_the_tool_in_top_left_points"]
|
||||
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 hiding half of any one word counts too (#2662), so a box over one number in a long line, or an unapplied mark over it, is found; a sliver under half of every word it touches and under 40% of the line is 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). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools."
|
||||
|
||||
[[feature]]
|
||||
id = "protect.remove-hidden-on-close"
|
||||
|
||||
Reference in New Issue
Block a user