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 1/3] 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" From cd2b26d492b1b6102c88ea3b34defc2dd7c8e4da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Lu=C3=ADs=20da=20Silva?= Date: Fri, 23 Oct 2026 03:53:31 -0300 Subject: [PATCH 2/3] fix(audit): join covered runs the way the line joins them TextLine::runs also says whether joining put a space before each run, and covered_words uses it: a word shown in two runs with nothing between them (`SE` `CRET`) is reported as one word, not `SE CRET`. Runs with uncovered runs left out between them are still spaced. The regression test writes its content streams on one line each. Review on #4198 (CodeRabbit). --- crates/audit/src/lib.rs | 31 ++++++++++++++++++++----------- crates/audit/src/tests.rs | 28 ++++++++++++---------------- crates/edit/src/text.rs | 37 ++++++++++++++++++++----------------- 3 files changed, 52 insertions(+), 44 deletions(-) diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index c3518ca832..46823a1faa 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -262,20 +262,29 @@ fn covers(cover: &[f64; 4], line: &TextLine) -> bool { /// 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. +/// reported as hidden. Neighbouring runs are joined as the line joins them, so a word split +/// across runs stays one word; runs with others left out between them are spaced. 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(' '); + let mut words = String::new(); + let mut last = None; + for (i, (text, ink, spaced)) in line.runs().enumerate() { + if text.trim().is_empty() || !ink.iter().any(overlaps) { + continue; } - out.push_str(text.trim()); + let apart = last.is_some_and(|l: usize| l.saturating_add(1) != i || spaced); + if apart && !words.ends_with(char::is_whitespace) && !text.starts_with(char::is_whitespace) { + words.push(' '); + } + words.push_str(text); + last = Some(i); + } + if line.runs().nth(1).is_none() || last.is_none() { + out.push_str(line.text.trim()); + } else { + out.push_str(words.trim()); } } diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index f3edb0c0bc..7908980f1f 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -649,30 +649,26 @@ fn later_text_on_the_line_does_not_clear_an_earlier_cover() { /// 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"]); + let covered = |content: &[u8]| { + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + findings.into_iter().map(|f| f.covered_text).collect::>() + }; + assert_eq!(covered(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"), ["SECRET"]); + // A word shown in two runs with nothing between them stays one word (review on #4198). + assert_eq!(covered(b"0 g\nBT /F1 24 Tf 100 700 Td (SE) Tj (CRET) Tj ET\n98 688 94 32 re f\nBT /F1 24 Tf 200 700 Td (tail) Tj ET\n"), ["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")] { + // The same for an unapplied redaction mark over the first run only. A mark over both runs + // reports both, with the space the line has between them (`tail` is set apart here). + for (rect, expected) in [("[98 688 192 720]", "SECRET"), ("[98 688 310 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_stream(b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\nBT /F1 24 Tf 250 700 Td (tail) Tj ET\n"); 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)]); + assert_eq!(findings.iter().map(|f| (f.kind.clone(), f.covered_text.as_str())).collect::>(), [(FindingKind::UnappliedMark, expected)]); } } diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index fe4ff42020..4c9f1b882e 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -80,9 +80,10 @@ 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)>, + /// The runs the line joins, in content order: each one's text, the end of its words in + /// `ink`, and whether joining put a space before it. Empty when the line's text was put + /// into another order (see [`TextLine::runs`]). + runs: Vec<(String, usize, bool)>, } impl TextLine { @@ -542,17 +543,17 @@ 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 { + /// The runs of text the line joins, in content order: each run's text as its glyphs show it, + /// the boxes of its words (a part of `ink`), and whether joining put a space between it and + /// the run before (`text` has one there; runs closer together join with none, so one word + /// can span runs). 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)| { + self.runs.iter().map(move |(text, end, spaced)| { let ink = self.ink.get(start..*end).unwrap_or_default(); start = *end; - (text.as_str(), ink) + (text.as_str(), ink, *spaced) }) } @@ -1377,14 +1378,15 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> }); if joins && let Some(l) = lines.last_mut() { let gap = last.map_or(0.0, |(_, pen)| pen.continues(&s, 0.0).1); - if gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') { + let spaced = gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' '); + if spaced { l.text.push(' '); } 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.frame = union(l.frame, s.frame); l.ink.extend(s.ink.iter().copied()); - l.runs.push((s.text.clone(), l.ink.len())); + l.runs.push((s.text.clone(), l.ink.len(), spaced)); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); l.decodable &= s.decodable; @@ -1417,7 +1419,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())], + runs: vec![(s.text.clone(), s.ink.len(), false)], ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -1487,14 +1489,15 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream }); if joins && let Some(l) = lines.last_mut() { let gap = last.map_or(0.0, |pen| pen.continues(&s, 0.0).1); - if gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') { + let spaced = gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' '); + if spaced { l.text.push(' '); } 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.frame = union(l.frame, s.frame); l.ink.extend(s.ink.iter().copied()); - l.runs.push((s.text.clone(), l.ink.len())); + l.runs.push((s.text.clone(), l.ink.len(), spaced)); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); l.decodable &= s.decodable; @@ -1526,7 +1529,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())], + runs: vec![(s.text.clone(), s.ink.len(), false)], ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, From 52d49c301eab9a78f433b801f49a777b8e828150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Lu=C3=ADs=20da=20Silva?= Date: Fri, 23 Oct 2026 04:15:41 -0300 Subject: [PATCH 3/3] fix(audit): charge a line's runs to the comparison bound The covered text walks every run of a covered line, and a run can draw no words (`( ) Tj`), so a line now costs its word boxes or its runs, whichever are more. A word followed by many empty runs can no longer be walked once per cover outside the 50-million-comparison bound. Review on #4198 (CodeRabbit). --- crates/audit/src/lib.rs | 6 ++++-- crates/audit/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 46823a1faa..f2bcf63358 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -138,10 +138,12 @@ struct Budget { } impl Budget { - /// Resets the comparison count for a page with these `lines`. + /// Resets the comparison count for a page with these `lines`. A line costs its word boxes + /// or its runs, whichever are more (at least one): a cover check walks the boxes, and the + /// covered text walks the runs, which can draw no words at all (`( ) Tj`). fn start_page(&mut self, lines: &[TextLine]) { self.comparisons = 0; - self.page_cost = lines.iter().fold(0, |n, line| n.saturating_add(line.ink.len().max(1))); + self.page_cost = lines.iter().fold(0, |n, line| n.saturating_add(line.ink.len().max(line.runs().count()).max(1))); } /// The text `cover` hides in the `lines` that `hides` ([`covered_words`]), joined with single diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 7908980f1f..e5fb9ab683 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -704,6 +704,28 @@ fn cover_and_line_comparisons_are_bounded() { assert!(crate::audit(&doc, limits).expect("within the budget").is_empty()); } +/// A run that draws no word still costs a comparison: the covered text walks every run of a +/// covered line, so a word followed by many `( ) Tj` must not be checked for free (review on +/// #4198). +#[test] +fn runs_without_words_count_against_the_comparison_bound() { + let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET) Tj".to_vec(); + content.extend_from_slice(&b" ( ) Tj".repeat(50)); + content.extend_from_slice( + b" ET +0 g +98 688 94 32 re f +", + ); + let doc = open(one_page(&content, "")); + let lines = pdfcraft_edit::glyph_text_lines(&doc, &pdfcraft_model::pages(&doc)[0]).expect("text"); + assert_eq!(lines.iter().map(|l| (l.ink.len(), l.runs().count())).collect::>(), [(1, 51)]); + let limits = crate::Limits { comparisons: 50, covered_text: usize::MAX }; + assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons))); + let limits = crate::Limits { comparisons: 51, covered_text: usize::MAX }; + assert_eq!(crate::audit(&doc, limits).expect("within the budget").len(), 1); +} + /// Covered text is document text: a document whose permissions withhold copying reports where /// its covers are but not what they hide, unless it was opened with the owner password. #[test]