From e7dd758d332acee3b38788b735a223b80a34c5ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Lu=C3=ADs=20da=20Silva?= Date: Fri, 23 Oct 2026 03:14:49 -0300 Subject: [PATCH] fix(audit): covered_text reports the words under the cover, not the whole line A text line joins runs painted on one baseline, and the audit reported the line's whole text for a cover over part of it, including words painted beside the cover that stay visible (`SECRETtail` for a cover over `SECRET`). TextLine now keeps each joined run's text with the end of its word boxes (TextLine::runs), and the audit reports the runs whose words the cover overlaps. A single run, a line reordered for reading (right-to-left or visual-order Indic), or a cover that overlaps the line's box but none of its words still reports the whole line. Fixes #4164 --- crates/audit/src/lib.rs | 43 ++++++++++++++++++++++++++++++------ crates/audit/src/tests.rs | 38 ++++++++++++++++++++++++++++--- crates/edit/src/text.rs | 30 +++++++++++++++++++++++++ parity/acrobat-features.toml | 2 +- 4 files changed, 102 insertions(+), 11 deletions(-) diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index f1f1350987..c3518ca832 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -144,10 +144,17 @@ impl Budget { self.page_cost = lines.iter().fold(0, |n, line| n.saturating_add(line.ink.len().max(1))); } - /// The text of the `lines` that `hides`, joined with single spaces, or `None` when they show - /// no text. Without `copying` the text is empty and never assembled: one long line under many - /// covers would otherwise be copied once per cover, outside both bounds. - fn hidden_text(&mut self, lines: &[TextLine], copying: bool, hides: impl Fn(&TextLine) -> bool) -> Result, AuditError> { + /// The text `cover` hides in the `lines` that `hides` ([`covered_words`]), joined with single + /// spaces, or `None` when they show no text. Without `copying` the text is empty and never + /// assembled: one long line under many covers would otherwise be copied once per cover, + /// outside both bounds. + fn hidden_text( + &mut self, + lines: &[TextLine], + copying: bool, + cover: &[f64; 4], + hides: impl Fn(&TextLine) -> bool, + ) -> Result, AuditError> { self.comparisons = self.comparisons.saturating_add(self.page_cost); if self.comparisons > self.limits.comparisons { return Err(AuditError::TooManyComparisons); @@ -160,7 +167,7 @@ impl Budget { if !covered.is_empty() { covered.push(' '); } - covered.push_str(line.text.trim()); + covered_words(cover, line, &mut covered); } Ok((!covered.trim().is_empty()).then_some(covered)) } @@ -188,14 +195,17 @@ fn audit(doc: &Document, limits: Limits) -> Result, AuditError budget.start_page(&lines); // 1. Opaque filled rectangles over extractable text. for r in cover::filled_rects(doc, &page.dict)? { - if let Some(covered) = budget.hidden_text(&lines, copying, |line| is_cover(&r, line))? { + if let Some(covered) = budget.hidden_text(&lines, copying, &r.rect, |line| is_cover(&r, line))? { let covered_text = budget.report_text(covered, copying)?; out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text }); } } // 2. Annotation covers over extractable text. for annot in annots(doc, &page.dict)? { - if let Some(covered) = budget.hidden_text(&lines, copying, |line| match &annot { + let cover = match &annot { + AnnotationCover::Redact(rect) | AnnotationCover::Square { rect, .. } => *rect, + }; + if let Some(covered) = budget.hidden_text(&lines, copying, &cover, |line| match &annot { AnnotationCover::Redact(rect) => covers(rect, line), AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST, })? { @@ -250,6 +260,25 @@ fn covers(cover: &[f64; 4], line: &TextLine) -> bool { inked || (boxed && (line.ink.is_empty() || word_intersects())) } +/// Appends the text of `line` that `cover` lies on to `out`: the runs whose words it overlaps +/// (#4164), so the words of the line painted beside the cover, which stay visible, are not +/// reported as hidden. The whole line when its runs can't say: a single run, a line whose text +/// was reordered for reading, or a cover that overlaps the line's box but none of its words. +fn covered_words(cover: &[f64; 4], line: &TextLine, out: &mut String) { + let overlaps = |w: &[f64; 4]| area(&[cover[0].max(w[0]), cover[1].max(w[1]), cover[2].min(w[2]), cover[3].min(w[3])]) > 0.0; + let mut runs = line.runs().filter(|(text, ink)| !text.trim().is_empty() && ink.iter().any(overlaps)).peekable(); + if line.runs().nth(1).is_none() || runs.peek().is_none() { + out.push_str(line.text.trim()); + return; + } + for (i, (text, _)) in runs.enumerate() { + if i > 0 { + out.push(' '); + } + out.push_str(text.trim()); + } +} + /// 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) diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 3aca406e7c..f3edb0c0bc 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -645,6 +645,37 @@ fn later_text_on_the_line_does_not_clear_an_earlier_cover() { assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}"); } +/// #4164: a cover over part of a line reports the words under it, not the words of the same +/// line painted beside it, which stay visible. +#[test] +fn covered_text_leaves_out_the_words_beside_the_cover() { + let content = b"0 g +BT /F1 24 Tf 100 700 Td (SECRET) Tj ET +98 688 94 32 re f +BT /F1 24 Tf 200 700 Td (tail) Tj ET +"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + assert_eq!(findings.iter().map(|f| f.covered_text.as_str()).collect::>(), ["SECRET"]); + + // The same for an unapplied redaction mark over the first run only; a mark over both runs + // reports both. + for (rect, covered) in [("[98 688 192 720]", "SECRET"), ("[98 688 260 720]", "SECRET tail")] { + 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( + b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET +BT /F1 24 Tf 200 700 Td (tail) Tj ET +", + ); + b.add(FONT); + b.add(&format!("<< /Type /Annot /Subtype /Redact /Rect {rect} >>")); + let findings = audit_redactions(&open(b.build())).expect("audit must run"); + assert_eq!(findings.iter().map(|f| (f.kind.clone(), f.covered_text.as_str())).collect::>(), [(FindingKind::UnappliedMark, covered)]); + } +} + /// Many covers over the same long line repeat its text in every finding: a small file must not /// expand into an unbounded report. Past the budget the audit fails rather than truncate. #[test] @@ -714,14 +745,15 @@ fn copy_restricted_documents_withhold_covered_text() { /// per cover, a volume neither the comparison nor the output bound charges. #[test] fn withheld_covered_text_is_not_assembled() { + const PAGE: [f64; 4] = [0.0, 0.0, 612.0, 792.0]; let doc = open(one_page(b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n", "")); let lines = pdfcraft_edit::text_lines(&doc, 0).expect("text"); let mut budget = crate::Budget { limits: crate::Limits::DEFAULT, comparisons: 0, page_cost: 0, covered_text: 0 }; budget.start_page(&lines); - let withheld = budget.hidden_text(&lines, false, |_| true).expect("within the budget"); + let withheld = budget.hidden_text(&lines, false, &PAGE, |_| true).expect("within the budget"); assert_eq!(withheld.map(|t| t.capacity()), Some(0), "a finding, with no text built for it"); - assert_eq!(budget.hidden_text(&lines, false, |_| false).expect("within the budget"), None); - let copied = budget.hidden_text(&lines, true, |_| true).expect("within the budget"); + assert_eq!(budget.hidden_text(&lines, false, &PAGE, |_| false).expect("within the budget"), None); + let copied = budget.hidden_text(&lines, true, &PAGE, |_| true).expect("within the budget"); assert_eq!(copied.as_deref(), Some("SECRET DATA 123")); } diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 29ac90abcc..fe4ff42020 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -80,6 +80,9 @@ pub struct TextLine { /// Why the line can't be edited: its font's writing mode (its CMap's `WMode`) couldn't be read, /// so which way its glyphs advance is unknown. The line is listed as horizontal text. unreadable_mode: Option, + /// The runs the line joins, in content order: each one's text and the end of its words in + /// `ink`. Empty when the line's text was put into another order (see [`TextLine::runs`]). + runs: Vec<(String, usize)>, } impl TextLine { @@ -539,6 +542,20 @@ impl TextLine { (self.stream, self.ops.iter().min().copied().unwrap_or(0)) } + /// The runs of text the line joins, in content order: each run's text as its glyphs show it + /// and the boxes of its words (a part of `ink`). Joining adds no space between runs, so a + /// run's text is not a word of `text` that can be found by position. None when the line's + /// text was reordered for reading (right-to-left or visual-order Indic text): then only the + /// line's text as a whole says what it reads. + pub fn runs(&self) -> impl Iterator { + let mut start = 0; + self.runs.iter().map(move |(text, end)| { + let ink = self.ink.get(start..*end).unwrap_or_default(); + start = *end; + (text.as_str(), ink) + }) + } + fn rotation(&self) -> f64 { let m = Matrix(self.origin.tm).then(&Matrix(self.origin.ctm)).0; m[1].atan2(m[0]).to_degrees() @@ -1367,6 +1384,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> 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.frame = union(l.frame, s.frame); l.ink.extend(s.ink.iter().copied()); + l.runs.push((s.text.clone(), l.ink.len())); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); l.decodable &= s.decodable; @@ -1399,6 +1417,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> actual_text: s.actual_text, clips: s.state.render_mode >= 4, unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned), + runs: vec![(s.text.clone(), s.ink.len())], ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -1418,10 +1437,15 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> // Content streams draw right-to-left lines in visual order; the editor works in typing order. for l in lines.iter_mut().filter(|l| !l.logical_order) { // A visual-order Indic font draws pre-base vowel signs before their consonant. + let shown = l.text.clone(); if l.visual_order && !l.actual_text { l.text = crate::indic::to_logical(&l.text); } l.text = crate::bidi::to_logical(&l.text); + // The runs hold the text in drawing order, which no longer reads as the line does. + if l.text != shown { + l.runs.clear(); + } } // Lines of only spaces aren't editable text. lines.retain(|l| !l.text.trim().is_empty()); @@ -1470,6 +1494,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream 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.frame = union(l.frame, s.frame); l.ink.extend(s.ink.iter().copied()); + l.runs.push((s.text.clone(), l.ink.len())); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); l.decodable &= s.decodable; @@ -1501,6 +1526,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream actual_text: s.actual_text, clips: s.state.render_mode >= 4, unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned), + runs: vec![(s.text.clone(), s.ink.len())], ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -1681,7 +1707,11 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), E let mut lines = w.lines; // A visual-order Indic font draws pre-base vowel signs before their consonant. for l in lines.iter_mut().filter(|l| l.visual_order && !l.actual_text) { + let shown = l.text.clone(); l.text = crate::indic::to_logical(&l.text); + if l.text != shown { + l.runs.clear(); + } } lines.retain(|l| !l.text.trim().is_empty()); Ok((lines, w.budget.truncated)) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6a2d0ccf98..53bfe6a14d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5842,7 +5842,7 @@ 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/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/it/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/it/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/it/audit.rs::audit_rectangles_match_the_tool_in_top_left_points", "crates/audit/src/tests.rs::rectangle_filled_after_restore_is_a_cover", "crates/audit/src/tests.rs::later_text_on_the_line_does_not_clear_an_earlier_cover", "crates/audit/src/tests.rs::indirect_subtype_is_resolved", "crates/audit/src/tests.rs::copy_restricted_documents_withhold_covered_text", "crates/audit/src/tests.rs::cover_and_line_comparisons_are_bounded", "crates/audit/src/tests.rs::covered_text_across_findings_is_bounded"] -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 start of the text line 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, or more than 50 million cover-against-word comparisons on a page or 64 MiB of covered text in the report, is an error, never a clean bill. A document that withholds copying gets every finding with the covered text left out. 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." +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 start of the text line 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, or more than 50 million cover-against-word comparisons on a page or 64 MiB of covered text in the report, is an error, never a clean bill. A finding's covered text is the runs of the line whose words the cover overlaps, not the words of the line painted beside it (#4164); a line of one run, a right-to-left or visual-order line, or a cover over none of its words reports the whole line. A document that withholds copying gets every finding with the covered text left out. 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"