mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 06:01:36 +00:00
342 lines
18 KiB
Rust
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(_))));
|
|
}
|