diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index c3518ca83..46823a1fa 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 f3edb0c0b..7908980f1 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 fe4ff4202..4c9f1b882 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,