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).
This commit is contained in:
Gabriel Luís da Silva
2026-10-23 03:53:31 -03:00
parent e7dd758d33
commit cd2b26d492
3 changed files with 52 additions and 44 deletions
+20 -11
View File
@@ -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());
}
}
+12 -16
View File
@@ -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::<Vec<_>>(), ["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::<Vec<_>>()
};
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::<Vec<_>>(), [(FindingKind::UnappliedMark, covered)]);
assert_eq!(findings.iter().map(|f| (f.kind.clone(), f.covered_text.as_str())).collect::<Vec<_>>(), [(FindingKind::UnappliedMark, expected)]);
}
}
+20 -17
View File
@@ -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<String>,
/// 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<Item = (&str, &[[f64; 4]])> {
/// 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<Item = (&str, &[[f64; 4]], bool)> {
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<TextLine>, last: &mut Option<Pen>, 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<TextLine>, last: &mut Option<Pen>, 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,