Files
pdfcraft/crates/engine/src/find_replace/tests.rs
T

342 lines
18 KiB
Rust

use std::collections::BTreeMap;
use std::sync::Arc;
use super::*;
use crate::{DocId, Session};
/// A one-page document with `content` and two fonts: `F1` Helvetica, `F2` Helvetica-Bold, and
/// `F3` a subset of a face no computer has, with glyphs for `a` and `b` only.
fn pdf(content: &str) -> Vec<u8> {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R /F3 7 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+NoSuchFace /FirstChar 97 /LastChar 99 /Widths [500 520 0] /Encoding /WinAnsiEncoding >>"
.into(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn open(bytes: Vec<u8>) -> (Session, DocId) {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("replace.pdf", None, Arc::new(bytes), None).expect("opens");
(s, id)
}
fn query(find: &str, with: &str) -> Replace {
Replace { find: find.into(), with: with.into(), ..Default::default() }
}
/// Plan and apply; the plan's counts.
fn replace_all(s: &mut Session, id: DocId, q: &Replace) -> Replacement {
let pages: Vec<usize> = (0..s.get(id).unwrap().info.pages.len()).collect();
let plan = s.get(id).unwrap().plan_replace(&pages, q, Which::All, None).unwrap();
if let Some(edit) = plan.edit.clone() {
s.apply(id, edit).unwrap();
}
plan
}
fn texts(s: &Session, id: DocId, page: usize) -> Vec<String> {
s.get(id).unwrap().text_blocks(page).into_iter().map(|b| b.text).collect()
}
/// The page text as the find bar reads it (the text layer's glyphs).
fn page_text(s: &Session, id: DocId, page: usize) -> pdfcraft_render::PageText {
let doc = s.get(id).unwrap();
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), pdfcraft_render::RenderConfig::default());
let out = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() });
out.text.map(|t| t.as_ref().clone()).unwrap_or_default()
}
#[test]
fn a_replaced_word_keeps_its_paragraphs_font_and_size() {
let (mut s, id) = open(pdf("BT /F1 14 Tf 72 700 Td (The quick brown fox) Tj ET"));
let plan = replace_all(&mut s, id, &query("quick", "slow"));
assert_eq!((plan.replaced, plan.pages.clone(), plan.skipped_total()), (1, vec![0], 0));
assert_eq!(texts(&s, id, 0), ["The slow brown fox"]);
let lines = s.get(id).unwrap().text_lines(0);
assert_eq!(lines.len(), 1);
assert_eq!((lines[0].font.as_str(), lines[0].base_font.as_str(), lines[0].size), ("F1", "Helvetica", 14.0));
assert_eq!(s.get(id).unwrap().can_undo(), Some("Replace all"));
}
/// A red word and a bold name inside a paragraph keep their own style when words around them
/// are replaced; a replacement inside the red word stays red.
#[test]
fn a_mixed_style_paragraph_keeps_the_other_runs_styles() {
let content = "BT /F1 12 Tf 72 700 Td 0 g (Please pay the invoice ) Tj 1 0 0 rg (now) Tj 0 g (, before the end of the month,) Tj ET
BT /F1 12 Tf 72 686 Td 0 g (as agreed with ) Tj /F2 12 Tf (Ada Lovelace) Tj /F1 12 Tf (.) Tj ET";
let (mut s, id) = open(pdf(content));
assert_eq!(texts(&s, id, 0).len(), 1, "one paragraph of five runs");
let q = Replace { whole_words: true, ..query("the", "THE") };
let plan = replace_all(&mut s, id, &q);
assert_eq!(plan.replaced, 3);
let plan = replace_all(&mut s, id, &query("now", "today"));
assert_eq!(plan.replaced, 1);
assert_eq!(texts(&s, id, 0), ["Please pay THE invoice today, before THE end of THE month, as agreed with Ada Lovelace."]);
let lines = s.get(id).unwrap().text_lines(0);
let run = |word: &str| lines.iter().find(|l| l.text.contains(word)).unwrap_or_else(|| panic!("no run with {word:?}: {lines:?}"));
assert_eq!(run("today").color, [1.0, 0.0, 0.0], "the replacement takes the red run it lands in");
assert_eq!(run("today").text.trim(), "today", "and only it is red");
assert_eq!(run("Ada").base_font, "Helvetica-Bold");
assert_eq!(run("Ada").text.trim(), "Ada Lovelace");
assert_eq!(run("invoice").color, [0.0, 0.0, 0.0]);
assert_eq!(run("invoice").base_font, "Helvetica");
}
#[test]
fn replace_all_across_pages_is_one_undo_step() {
let (mut s, id) = open(crate::tests::fixture(3));
let before = s.get(id).unwrap().bytes.clone();
let plan = replace_all(&mut s, id, &query("page", "Sheet"));
assert_eq!((plan.replaced, plan.pages.clone()), (3, vec![0, 1, 2]));
assert!(matches!(&plan.edit, Some(Edit::Batch { edits, .. }) if edits.len() == 3));
for p in 0..3 {
assert_eq!(texts(&s, id, p), [format!("Sheet {}", p + 1)]);
}
s.undo(id).unwrap();
for p in 0..3 {
assert_eq!(texts(&s, id, p), [format!("Page {}", p + 1)]);
}
assert_eq!(s.get(id).unwrap().can_undo(), None, "one step undid all three pages");
assert_eq!(s.get(id).unwrap().bytes, before, "the original bytes are back");
}
#[test]
fn whole_words_and_case_follow_the_find_options() {
let content = "BT /F1 12 Tf 72 700 Td (cat Cats concat CAT) Tj ET";
let count = |q: Replace| {
let (s, id) = open(pdf(content));
s.get(id).unwrap().plan_replace(&[0], &q, Which::All, None).unwrap().replaced
};
assert_eq!(count(query("cat", "dog")), 4);
assert_eq!(count(Replace { whole_words: true, ..query("cat", "dog") }), 2);
assert_eq!(count(Replace { case_sensitive: true, ..query("cat", "dog") }), 2);
assert_eq!(count(Replace { case_sensitive: true, whole_words: true, ..query("CAT", "dog") }), 1);
let (mut s, id) = open(pdf(content));
replace_all(&mut s, id, &Replace { whole_words: true, ..query("cat", "dog") });
assert_eq!(texts(&s, id, 0), ["dog Cats concat dog"]);
// An empty replacement deletes the match.
replace_all(&mut s, id, &query(" concat", ""));
assert_eq!(texts(&s, id, 0), ["dog Cats dog"]);
}
#[test]
fn a_match_spanning_two_paragraphs_is_skipped_and_counted() {
let content = "BT /F1 12 Tf 72 700 Td (The first ends here) Tj ET BT /F1 12 Tf 72 500 Td (second starts there) Tj ET";
let (mut s, id) = open(pdf(content));
assert_eq!(texts(&s, id, 0).len(), 2);
let plan = replace_all(&mut s, id, &query("here second", "x"));
assert_eq!(plan.replaced, 0);
assert_eq!(plan.skipped, BTreeMap::from([(Skip::SpansParagraphs, 1)]));
assert!(plan.edit.is_none());
assert_eq!(texts(&s, id, 0), ["The first ends here", "second starts there"]);
}
/// The subset in `F3` has glyphs for `a` and `b` only, from a face no computer has: a `c` would
/// be drawn in Helvetica instead. That paragraph is left alone and counted, the other replaced.
#[test]
fn a_character_the_font_cannot_draw_is_skipped_and_counted() {
let content = "BT /F3 12 Tf 72 700 Td (ab ba) Tj ET BT /F1 12 Tf 72 500 Td (ab in Helvetica) Tj ET";
let (mut s, id) = open(pdf(content));
let before = texts(&s, id, 0);
assert_eq!(before, ["ab ba", "ab in Helvetica"]);
let plan = replace_all(&mut s, id, &query("ab", "cb"));
assert_eq!(plan.replaced, 1);
assert_eq!(plan.skipped, BTreeMap::from([(Skip::MissingGlyphs, 1)]));
assert_eq!(texts(&s, id, 0), ["ab ba", "cb in Helvetica"]);
let lines = s.get(id).unwrap().text_lines(0);
assert!(lines.iter().filter(|l| l.text.contains("ab ba")).all(|l| l.font == "F3"), "the subset paragraph keeps its font: {lines:?}");
// What the subset can draw still goes in.
let plan = replace_all(&mut s, id, &query("ab ba", "ba ab"));
assert_eq!((plan.replaced, plan.skipped_total()), (1, 0));
assert_eq!(texts(&s, id, 0)[0], "ba ab");
}
/// The styled rewrite (a paragraph of several runs) reports the missing character as the
/// one-style rewrite above does.
#[test]
fn a_character_the_font_cannot_draw_is_skipped_in_a_paragraph_of_several_runs() {
let (mut s, id) = open(pdf("BT /F3 12 Tf 72 700 Td (ab ) Tj /F1 12 Tf (and Helvetica) Tj ET"));
let plan = replace_all(&mut s, id, &query("ab", "cb"));
assert_eq!(plan.replaced, 0);
assert_eq!(plan.skipped, BTreeMap::from([(Skip::MissingGlyphs, 1)]));
assert!(plan.edit.is_none());
}
/// A character Helvetica has no code for is never written as a box or a `?`: whether another font
/// has it or none does, the match is left alone and reported.
#[test]
fn a_replacement_outside_the_fonts_encoding_is_reported_and_changes_nothing() {
for with in ["\u{65E5}\u{672C}", "\u{1F600}"] {
let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (one cat) Tj ET"));
let plan = replace_all(&mut s, id, &query("cat", with));
assert_eq!((plan.replaced, plan.skipped_total()), (0, 1), "{with}: {plan:?}");
assert!(plan.edit.is_none(), "{with}");
assert_eq!(texts(&s, id, 0), ["one cat"]);
}
}
/// The rects of the page's text lines holding `word`.
fn line_rects(s: &Session, id: DocId, word: &str) -> Vec<[f64; 4]> {
s.get(id).unwrap().text_lines(0).into_iter().filter(|l| l.text.contains(word)).map(|l| l.rect).collect()
}
/// A paragraph of several rows keeps its width and gains rows below; what is under it stays where
/// it was, so the new rows run over it.
#[test]
fn a_longer_replacement_adds_rows_and_moves_nothing_else() {
let content =
"BT /F1 12 Tf 14 TL 72 700 Td (alpha beta gamma delta) Tj T* (epsilon zeta eta theta) Tj ET BT /F1 12 Tf 72 650 Td (next paragraph) Tj ET";
let (mut s, id) = open(pdf(content));
assert_eq!(texts(&s, id, 0).len(), 2);
let right = s.get(id).unwrap().text_blocks(0)[0].rect[2];
let next = line_rects(&s, id, "next paragraph");
let with = "a much longer replacement that takes several more rows than the paragraph has";
let plan = replace_all(&mut s, id, &query("beta", with));
assert_eq!((plan.replaced, plan.skipped_total()), (1, 0));
let lines = s.get(id).unwrap().text_lines(0);
let rows: Vec<_> = lines.iter().filter(|l| !l.text.contains("next paragraph")).collect();
assert!(rows.len() >= 5, "{rows:?}");
assert!(rows.iter().all(|l| l.rect[2] <= right + 1.0), "the rows keep the width: {rows:?}");
assert!(rows.iter().any(|l| l.rect[1] < next[0][1]), "the new rows go past what was below: {rows:?}");
assert_eq!(line_rects(&s, id, "next paragraph"), next);
}
/// One row grows to the right up to the margin, then wraps; a word wider than that stays whole.
#[test]
fn a_longer_replacement_grows_one_row_to_the_margin() {
let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (short) Tj ET"));
replace_all(&mut s, id, &query("short", "a line far wider than the word it took the place of"));
assert_eq!(s.get(id).unwrap().text_lines(0).len(), 1);
let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (short) Tj ET"));
replace_all(&mut s, id, &query("short", &"many words in a row ".repeat(8)));
let lines = s.get(id).unwrap().text_lines(0);
assert!(lines.len() > 1 && lines.iter().all(|l| l.rect[2] <= 595.0 - 36.0 + 1.0), "{lines:?}");
let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (short) Tj ET"));
replace_all(&mut s, id, &query("short", &"w".repeat(80)));
let lines = s.get(id).unwrap().text_lines(0);
assert_eq!(lines.len(), 1);
assert!(lines[0].rect[2] > 595.0, "the word runs past the page: {lines:?}");
}
/// A match can run over a row's end hyphen, typed as the find bar reads it (the break is a space);
/// Replace all then folds case, joins the rows and counts what the find bar counts. The word read
/// unhyphenated is also a find-bar match, but no paragraph's text holds it: left alone and counted.
#[test]
fn case_insensitive_replace_all_takes_a_match_across_a_hyphenated_row_end() {
let content = "BT /F1 12 Tf 14 TL 72 700 Td (An EXAM-) Tj T* (PLE and an Exam-) Tj T* (ple to end) Tj ET";
let (mut s, id) = open(pdf(content));
assert_eq!(texts(&s, id, 0), ["An EXAMPLE and an Example to end"]);
let text = page_text(&s, id, 0);
let whole = query("example", "sample");
let found = BTreeMap::from([(0, text.find_opts(&whole.find, false, false).len())]);
assert_eq!(found[&0], 2);
let none = s.get(id).unwrap().plan_replace(&[0], &whole, Which::All, Some(&found)).unwrap();
assert_eq!((none.replaced, none.skipped_total(), none.edit.is_none()), (0, 2, true));
let q = query("exam- ple", "sample");
let found = BTreeMap::from([(0, text.find_opts(&q.find, false, false).len())]);
assert_eq!(found[&0], 2);
let plan = s.get(id).unwrap().plan_replace(&[0], &q, Which::All, Some(&found)).unwrap();
assert_eq!((plan.replaced, plan.skipped_total()), (2, 0));
s.apply(id, plan.edit.unwrap()).unwrap();
assert_eq!(texts(&s, id, 0), ["An sample and an sample to end"]);
}
#[test]
fn invisible_text_over_a_scan_is_skipped() {
let (mut s, id) = open(pdf("BT 3 Tr /F1 12 Tf 72 700 Td (recognized words) Tj ET"));
let plan = replace_all(&mut s, id, &query("words", "text"));
assert_eq!(plan.skipped, BTreeMap::from([(Skip::ScannedText, 1)]));
assert!(plan.edit.is_none());
}
/// What the find bar counts on a page beyond what the paragraphs hold is reported as not
/// editable text (a form field, a comment), so the counts add up to what was highlighted.
#[test]
fn matches_the_paragraphs_dont_hold_are_counted_as_not_editable() {
let (s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (one cat) Tj ET"));
let found = BTreeMap::from([(0, 3)]);
let plan = s.get(id).unwrap().plan_replace(&[0], &query("cat", "dog"), Which::All, Some(&found)).unwrap();
assert_eq!(plan.replaced, 1);
assert_eq!(plan.skipped, BTreeMap::from([(Skip::NotEditable, 2)]));
}
/// Replace (one match): the highlighted one, found by where the find bar's glyphs are, even when
/// the paragraph holds the word twice.
#[test]
fn the_highlighted_match_is_the_one_replaced() {
let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (cat and cat) Tj ET BT /F1 12 Tf 72 500 Td (no match) Tj ET"));
let text = page_text(&s, id, 0);
let found = text.find("cat");
assert_eq!(found.len(), 2);
let centre = |g: usize| {
let r = text.glyphs[g].rect;
[(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]
};
let second = &found[1];
let which = Which::At { page: 0, start: centre(second.start), end: centre(second.end - 1) };
let plan = s.get(id).unwrap().plan_replace(&[0], &query("cat", "dog"), which, None).unwrap();
assert_eq!(plan.replaced, 1);
assert!(matches!(&plan.edit, Some(Edit::EditTextBlock { block: 0, .. })), "one paragraph edit, not a batch: {:?}", plan.edit);
s.apply(id, plan.edit.unwrap()).unwrap();
assert_eq!(texts(&s, id, 0), ["cat and dog", "no match"]);
// A point in no paragraph is not editable text.
let away = Which::At { page: 0, start: [500.0, 20.0], end: [510.0, 20.0] };
let plan = s.get(id).unwrap().plan_replace(&[0], &query("cat", "dog"), away, None).unwrap();
assert_eq!(plan.skipped, BTreeMap::from([(Skip::NotEditable, 1)]));
}
#[test]
fn first_replaces_only_the_first_match_in_document_order() {
let (mut s, id) = open(crate::tests::fixture(2));
let q = query("page", "Sheet");
let texts_of: Vec<_> = (0..2).map(|p| page_text(&s, id, p)).collect();
let which = Which::first_in(texts_of.iter().enumerate(), &q).expect("the find bar has a match");
assert!(matches!(which, Which::At { page: 0, .. }));
let plan = s.get(id).unwrap().plan_replace(&[0, 1], &q, which, None).unwrap();
assert_eq!((plan.replaced, plan.pages.clone()), (1, vec![0]));
s.apply(id, plan.edit.unwrap()).unwrap();
assert_eq!((texts(&s, id, 0), texts(&s, id, 1)), (vec!["Sheet 1".to_string()], vec!["Page 2".to_string()]));
assert!(Which::first_in(texts_of.iter().enumerate(), &query("absent", "x")).is_none());
}
/// The first match is the find bar's, not the first one a paragraph holds: when it is a match no
/// paragraph holds (a word split by a line-end hyphen), it is reported and nothing is replaced,
/// not the later paragraph that has the word whole.
#[test]
fn first_does_not_skip_a_match_no_paragraph_holds_for_a_later_one() {
let content = "BT /F1 12 Tf 14 TL 72 700 Td (An EXAM-) Tj T* (PLE to end) Tj ET BT /F1 12 Tf 72 400 Td (a second example) Tj ET";
let (s, id) = open(pdf(content));
let q = query("example", "sample");
let text = page_text(&s, id, 0);
assert_eq!(text.find_opts("example", false, false).len(), 2, "the find bar has both");
let which = Which::first_in([(0, &text)], &q).unwrap();
let plan = s.get(id).unwrap().plan_replace(&[0], &q, which, None).unwrap();
assert_eq!((plan.replaced, plan.edit.is_none()), (0, true), "{plan:?}");
assert_eq!(plan.skipped, BTreeMap::from([(Skip::NotEditable, 1)]));
assert_eq!(texts(&s, id, 0).last().unwrap(), "a second example");
}
#[test]
fn nothing_to_find_is_refused() {
let (s, id) = open(crate::tests::fixture(1));
assert!(matches!(s.get(id).unwrap().plan_replace(&[0], &query(" ", "x"), Which::All, None), Err(EditError::Invalid(_))));
}