mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-26 11:08:48 -06:00
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:
+20
-11
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user