mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 21:11:52 +00:00
821 lines
43 KiB
Rust
821 lines
43 KiB
Rust
//! Edit text on a scanned page: rewriting an OCR word (invisible text over the scan) shows the
|
||
//! new text and hides the scanned word. Synthetic image only.
|
||
|
||
use pdfcraft_engine::ocr::PlacedWord;
|
||
use pdfcraft_engine::{Edit, Session, export};
|
||
use pdfcraft_render::{PageRenderer, RenderRequest};
|
||
|
||
const PAPER: [u8; 3] = [230, 225, 200];
|
||
const INK: [u8; 3] = [20, 30, 90];
|
||
/// The scanned "word": a block of ink, in image pixels (72 dpi, so points), y down.
|
||
const WORD: [u32; 4] = [40, 40, 100, 60];
|
||
|
||
/// A 200 × 100 pt "scan" on tinted paper, with OCR text over its word.
|
||
fn scanned() -> (Session, pdfcraft_engine::DocId) {
|
||
scanned_with(vec![PlacedWord {
|
||
text: "John".into(),
|
||
origin: [40.0, 40.0],
|
||
across: [60.0, 0.0],
|
||
up: [0.0, 20.0],
|
||
confidence: None,
|
||
source: "ocrs".into(),
|
||
}])
|
||
}
|
||
|
||
fn scanned_with(words: Vec<PlacedWord>) -> (Session, pdfcraft_engine::DocId) {
|
||
let (w, h) = (200u32, 100u32);
|
||
let mut rgba = Vec::new();
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let ink = (WORD[0]..WORD[2]).contains(&x) && (WORD[1]..WORD[3]).contains(&y);
|
||
rgba.extend(if ink { INK } else { PAPER });
|
||
rgba.push(255);
|
||
}
|
||
}
|
||
let png = export::encode_png(w, h, &rgba).unwrap();
|
||
let mut s = Session::new();
|
||
let bytes = s.create_from_images(&[("scan.png".into(), png)]).unwrap();
|
||
let id = s.open("scan.pdf", None, bytes, None).unwrap();
|
||
s.apply(id, Edit::AddOcrText { page: 0, words }).unwrap();
|
||
(s, id)
|
||
}
|
||
|
||
fn word_pixels(s: &Session, id: pdfcraft_engine::DocId) -> Vec<[u8; 3]> {
|
||
let doc = s.get(id).unwrap();
|
||
let out = PageRenderer::new(doc.bytes.clone(), Default::default()).render(RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||
assert!(out.error.is_none(), "{:?}", out.error);
|
||
let mut px = Vec::new();
|
||
for y in WORD[1] + 2..WORD[3] - 2 {
|
||
for x in WORD[0] + 2..WORD[2] - 2 {
|
||
let i = ((y * out.width + x) * 4) as usize;
|
||
px.push([out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]]);
|
||
}
|
||
}
|
||
px
|
||
}
|
||
|
||
fn near(a: [u8; 3], b: [u8; 3]) -> bool {
|
||
a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 24)
|
||
}
|
||
|
||
#[test]
|
||
fn editing_an_ocr_word_covers_the_scan_and_shows_the_new_text() {
|
||
let (mut s, id) = scanned();
|
||
let blocks = s.get(id).unwrap().text_blocks(0);
|
||
assert_eq!(blocks.len(), 1);
|
||
assert!(blocks[0].invisible, "the OCR layer is invisible text");
|
||
assert!(word_pixels(&s, id).iter().all(|p| near(*p, INK)), "the scanned word fills its box");
|
||
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "Jane".into(), style: Default::default() }).unwrap();
|
||
|
||
let blocks = s.get(id).unwrap().text_blocks(0);
|
||
assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>(), ["Jane"]);
|
||
assert!(!blocks[0].invisible, "the new text is drawn");
|
||
let px = word_pixels(&s, id);
|
||
let paper = px.iter().filter(|p| near(**p, PAPER)).count();
|
||
let ink = px.iter().filter(|p| near(**p, INK)).count();
|
||
// The scanned word is covered in the paper's colour; the new letters are in the ink's.
|
||
assert!(paper * 10 > px.len() * 4, "mostly paper now: {paper} of {}", px.len());
|
||
assert!(ink * 100 > px.len() * 3, "the new text shows in the scan's ink colour: {ink} of {}", px.len());
|
||
}
|
||
|
||
#[test]
|
||
fn an_explicit_colour_is_kept_for_the_new_text() {
|
||
let (mut s, id) = scanned();
|
||
let style = pdfcraft_engine::BlockStyle { color: Some([1.0, 0.0, 0.0]), ..Default::default() };
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "Jane".into(), style }).unwrap();
|
||
let px = word_pixels(&s, id);
|
||
assert!(px.iter().any(|p| p[0] > 200 && p[1] < 60 && p[2] < 60), "red text");
|
||
assert!(!px.iter().any(|p| near(*p, INK)), "no scanned ink is left");
|
||
}
|
||
|
||
/// A word as OCR boxes it on a scan set at `size` on `baseline`: tight around its ink, from the
|
||
/// descender line when it has descenders, up to the cap height.
|
||
fn scanned_word(text: &str, x0: f64, x1: f64, baseline: f64, size: f64) -> PlacedWord {
|
||
let low = if text.chars().any(|c| "gjpqy".contains(c)) { 0.21 * size } else { 0.0 };
|
||
PlacedWord {
|
||
text: text.into(),
|
||
origin: [x0, baseline - low],
|
||
across: [x1 - x0, 0.0],
|
||
up: [0.0, 0.718 * size + low],
|
||
confidence: None,
|
||
source: "ocrs".into(),
|
||
}
|
||
}
|
||
|
||
fn word(text: &str, x0: f64, x1: f64, baseline: f64) -> PlacedWord {
|
||
PlacedWord { text: text.into(), origin: [x0, baseline], across: [x1 - x0, 0.0], up: [0.0, 10.0], confidence: None, source: "ocrs".into() }
|
||
}
|
||
|
||
/// OCR words keep the scan's layout: a phrase is one paragraph, a value in the next table cell and
|
||
/// the line below are others, so rewriting one never moves the rest.
|
||
#[test]
|
||
fn ocr_words_group_into_phrases_that_edit_in_place() {
|
||
let (mut s, id) = scanned_with(vec![
|
||
word("EMPLOYEE", 10.0, 60.0, 70.0),
|
||
word("NAME", 64.0, 90.0, 70.0),
|
||
word("John", 120.0, 145.0, 70.0),
|
||
word("Smith", 120.0, 148.0, 56.0),
|
||
]);
|
||
let texts = |s: &Session| s.get(id).unwrap().text_blocks(0).iter().map(|b| b.text.clone()).collect::<Vec<_>>();
|
||
assert_eq!(texts(&s), ["EMPLOYEE NAME", "John", "Smith"]);
|
||
let john = s.get(id).unwrap().text_blocks(0)[1].rect;
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 1, text: "Jane".into(), style: Default::default() }).unwrap();
|
||
assert_eq!(texts(&s), ["EMPLOYEE NAME", "Jane", "Smith"]);
|
||
let blocks = s.get(id).unwrap().text_blocks(0);
|
||
assert!(blocks[0].invisible && blocks[2].invisible, "the other OCR words are left alone");
|
||
assert!(!blocks[1].invisible);
|
||
assert!((blocks[1].rect[0] - john[0]).abs() < 1.0, "the new word starts where the old one did: {:?} vs {john:?}", blocks[1].rect);
|
||
}
|
||
|
||
/// Words added to an OCR phrase grow rightwards as one line, without wrapping onto the line below.
|
||
#[test]
|
||
fn a_longer_ocr_phrase_grows_rightwards() {
|
||
let (mut s, id) = scanned_with(vec![
|
||
scanned_word("Project", 20.0, 55.0, 70.0, 11.0),
|
||
scanned_word("Lead", 59.0, 80.0, 70.0, 11.0),
|
||
scanned_word("Testing", 20.0, 56.0, 56.0, 11.0),
|
||
]);
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "Project Coordinator Lead".into(), style: Default::default() }).unwrap();
|
||
let blocks = s.get(id).unwrap().text_blocks(0);
|
||
assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>(), ["Project", "Coordinator Lead", "Testing"]);
|
||
assert_eq!(blocks[1].rows.len(), 1, "one line");
|
||
}
|
||
|
||
/// OCR words differ in size and are stretched to fit the scan: a rewritten phrase keeps each
|
||
/// word's width, so the words don't run into one another.
|
||
#[test]
|
||
fn a_rewritten_ocr_phrase_keeps_its_word_widths() {
|
||
let (mut s, id) = scanned_with(vec![
|
||
scanned_word("John", 20.0, 45.0, 70.0, 10.5),
|
||
scanned_word("Smith", 49.0, 78.0, 70.0, 11.0),
|
||
scanned_word("Example", 82.0, 124.0, 70.0, 11.5),
|
||
]);
|
||
// Every word changes, so every word is redrawn. "Jahn" is as wide as "John" in Helvetica.
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "Jahn Smyth Exam".into(), style: Default::default() }).unwrap();
|
||
let lines = s.get(id).unwrap().text_lines(0);
|
||
assert_eq!(lines.iter().map(|l| l.text.trim()).collect::<Vec<_>>(), ["Jahn Smyth Exam"]);
|
||
let ink = &lines[0].ink;
|
||
assert!(ink.len() == 3, "three words: {ink:?}");
|
||
assert!((ink[0][2] - 45.0).abs() < 0.5, "Jahn keeps John's width: {ink:?}");
|
||
for pair in ink.windows(2) {
|
||
assert!(pair[1][0] >= pair[0][2] - 0.5, "{pair:?} run into each other");
|
||
}
|
||
}
|
||
|
||
/// Recognize text sizes each word from its box and a guess at how much of the em its letters fill,
|
||
/// so one phrase's words come out several points apart (#3313). Here the first word is the
|
||
/// largest and the middle one the smallest; the phrase is set in 11 pt.
|
||
fn uneven_phrase() -> (Session, pdfcraft_engine::DocId) {
|
||
scanned_with(vec![
|
||
scanned_word("John", 20.0, 45.0, 70.0, 15.0),
|
||
scanned_word("Smith", 49.0, 78.0, 70.0, 9.0),
|
||
scanned_word("Example", 82.0, 124.0, 70.0, 11.0),
|
||
])
|
||
}
|
||
|
||
/// An edited OCR phrase is redrawn in one size, the median of its words', and each word keeps
|
||
/// the width the scan shows it at (#3313).
|
||
#[test]
|
||
fn an_edited_ocr_phrase_is_drawn_at_one_size() {
|
||
let (mut s, id) = uneven_phrase();
|
||
let block = s.get(id).unwrap().text_blocks(0).remove(0);
|
||
assert!((block.size - 11.0).abs() < 0.05, "the phrase's size is its words' median: {}", block.size);
|
||
|
||
// Every word changes, so every word is redrawn. "Jahn" is as wide as "John" in Helvetica.
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "Jahn Smyth Exbmple".into(), style: Default::default() }).unwrap();
|
||
let lines = s.get(id).unwrap().text_lines(0);
|
||
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<String>().replace(' ', ""), "JahnSmythExbmple");
|
||
for line in &lines {
|
||
assert!((line.size - 11.0).abs() < 0.05, "{:?} is drawn at {} pt, not the phrase's 11", line.text, line.size);
|
||
}
|
||
let ink = &lines[0].ink;
|
||
assert!((ink[0][2] - ink[0][0] - 25.0).abs() < 0.5, "Jahn keeps John's width: {ink:?}");
|
||
}
|
||
|
||
/// Only invisible OCR text is set in one phrase size: visible text keeps each run's own size, and
|
||
/// a phrase whose size is NaN, zero, negative or infinite falls back to its words' sizes (#3313).
|
||
#[test]
|
||
fn only_an_ocr_phrase_with_a_usable_size_is_set_in_one_size() {
|
||
let (s, id) = uneven_phrase();
|
||
let block = s.get(id).unwrap().text_blocks(0).remove(0);
|
||
assert!(block.invisible);
|
||
assert_eq!(block.phrase_size(), Some(block.size));
|
||
let visible = pdfcraft_engine::TextBlock { invisible: false, ..block.clone() };
|
||
assert_eq!(visible.phrase_size(), None, "visible text keeps its runs' sizes");
|
||
for size in [f64::NAN, 0.0, -3.0, f64::INFINITY] {
|
||
assert_eq!(pdfcraft_engine::TextBlock { size, ..block.clone() }.phrase_size(), None, "a {size} pt phrase falls back to its words");
|
||
}
|
||
}
|
||
|
||
/// Choosing a font sends the phrase through the single-style rewrite, which takes its size from
|
||
/// the first run: that is the phrase's size too, not the first word's guess.
|
||
#[test]
|
||
fn a_new_font_for_an_ocr_phrase_keeps_its_one_size() {
|
||
let (mut s, id) = uneven_phrase();
|
||
let style = pdfcraft_engine::BlockStyle { family: Some((pdfcraft_engine::FontFamily::Times, false, false)), ..Default::default() };
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith Example".into(), style }).unwrap();
|
||
let lines = s.get(id).unwrap().text_lines(0);
|
||
assert!(!lines.is_empty());
|
||
for line in &lines {
|
||
assert!((line.size - 11.0).abs() < 0.05, "{:?} is drawn at {} pt, not the phrase's 11", line.text, line.size);
|
||
}
|
||
}
|
||
|
||
/// Two neighbouring words OCR sized alike join into one run; the median still counts each word,
|
||
/// so 15, 9 and 9 pt make a 9 pt phrase, not the 12 pt of its two runs.
|
||
#[test]
|
||
fn words_joined_into_one_run_each_count_toward_the_phrase_size() {
|
||
let (s, id) = scanned_with(vec![
|
||
scanned_word("John", 20.0, 45.0, 70.0, 15.0),
|
||
scanned_word("SMITH", 49.0, 78.0, 70.0, 9.0),
|
||
scanned_word("JONES", 82.0, 112.0, 70.0, 9.0),
|
||
]);
|
||
let doc = s.get(id).unwrap();
|
||
assert_eq!(doc.text_lines(0).len(), 2, "the two 9 pt words share a run");
|
||
let size = doc.text_blocks(0)[0].size;
|
||
assert!((size - 9.0).abs() < 0.05, "the phrase is set in its words' median, 9 pt: {size}");
|
||
}
|
||
|
||
/// A word OCR sized at more than twice the phrase's size still keeps the width it has on the scan.
|
||
#[test]
|
||
fn a_word_far_larger_than_its_phrase_keeps_its_width() {
|
||
let (mut s, id) = scanned_with(vec![
|
||
scanned_word("John", 20.0, 45.0, 70.0, 25.0),
|
||
scanned_word("Smith", 49.0, 78.0, 70.0, 10.0),
|
||
scanned_word("Example", 82.0, 124.0, 70.0, 10.5),
|
||
]);
|
||
// Every word changes, so every word is redrawn. "Jahn" is as wide as "John" in Helvetica.
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "Jahn Smyth Exam".into(), style: Default::default() }).unwrap();
|
||
let lines = s.get(id).unwrap().text_lines(0);
|
||
let ink = &lines[0].ink;
|
||
assert!(ink.len() == 3, "three words: {ink:?}");
|
||
assert!((ink[0][2] - ink[0][0] - 25.0).abs() < 0.5, "Jahn keeps John's width: {ink:?}");
|
||
}
|
||
|
||
/// With a new font the phrase is one run in its first word's text state. How wide it is must not
|
||
/// depend on how far OCR misjudged that word's size: the same boxes give the same line.
|
||
#[test]
|
||
fn a_new_font_for_an_ocr_phrase_is_as_wide_whatever_its_first_word_was_sized() {
|
||
let width = |first: f64| {
|
||
let (mut s, id) = scanned_with(vec![
|
||
scanned_word("John", 20.0, 45.0, 70.0, first),
|
||
scanned_word("Smith", 49.0, 78.0, 70.0, 11.0),
|
||
scanned_word("Example", 82.0, 124.0, 70.0, 11.0),
|
||
]);
|
||
let style = pdfcraft_engine::BlockStyle { family: Some((pdfcraft_engine::FontFamily::Helvetica, false, false)), ..Default::default() };
|
||
// Short enough not to wrap in the phrase's box either way.
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith".into(), style }).unwrap();
|
||
let lines = s.get(id).unwrap().text_lines(0);
|
||
assert_eq!(lines.len(), 1, "one line: {lines:?}");
|
||
lines.iter().map(|l| l.rect[2]).fold(f64::MIN, f64::max) - lines.iter().map(|l| l.rect[0]).fold(f64::MAX, f64::min)
|
||
};
|
||
let (guessed, right) = (width(15.0), width(11.0));
|
||
assert!((guessed - right).abs() < 0.5, "a 15 pt guess for John makes the line {guessed} wide, an 11 pt one {right}");
|
||
}
|
||
|
||
/// The lines drawn invisibly (still the scan's) and visibly (redrawn), by their text.
|
||
fn scan_and_drawn(s: &Session, id: pdfcraft_engine::DocId) -> (Vec<String>, Vec<String>) {
|
||
let blocks = s.get(id).unwrap().text_blocks(0);
|
||
let of = |invisible: bool| blocks.iter().filter(|b| b.invisible == invisible).map(|b| b.text.clone()).collect::<Vec<_>>();
|
||
(of(true), of(false))
|
||
}
|
||
|
||
/// A phrase over the scan's block of ink ([`WORD`]): two words side by side.
|
||
fn two_words_on_the_ink() -> (Session, pdfcraft_engine::DocId) {
|
||
scanned_with(vec![scanned_word("John", 42.0, 66.0, 42.0, 18.0), scanned_word("Smith", 70.0, 98.0, 42.0, 18.0)])
|
||
}
|
||
|
||
/// The share of paper among the page's pixels from x0 to x1 inside the scanned word's height.
|
||
fn paper_between(s: &Session, id: pdfcraft_engine::DocId, x0: u32, x1: u32) -> f64 {
|
||
let doc = s.get(id).unwrap();
|
||
let out = PageRenderer::new(doc.bytes.clone(), Default::default()).render(RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||
assert!(out.error.is_none(), "{:?}", out.error);
|
||
let (mut paper, mut all) = (0, 0);
|
||
for y in WORD[1] + 4..WORD[3] - 4 {
|
||
for x in x0..x1 {
|
||
let i = ((y * out.width + x) * 4) as usize;
|
||
paper += usize::from(near([out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]], PAPER));
|
||
all += 1;
|
||
}
|
||
}
|
||
paper as f64 / all as f64
|
||
}
|
||
|
||
/// Deleting a word covers that word and nothing else: the rest of the line is still the scan, and
|
||
/// the words after it stay where the scan shows them (#3313).
|
||
#[test]
|
||
fn deleting_an_ocr_word_leaves_the_rest_of_the_line_as_the_scan() {
|
||
let (mut s, id) = uneven_phrase();
|
||
let before = s.get(id).unwrap().text_lines(0);
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Example".into(), style: Default::default() }).unwrap();
|
||
assert_eq!(scan_and_drawn(&s, id), (vec!["John".to_owned(), "Example".to_owned()], Vec::new()), "nothing is redrawn");
|
||
let after = s.get(id).unwrap().text_lines(0);
|
||
assert_eq!(after.len(), 2);
|
||
assert_eq!(after[0].rect, before[0].rect, "John hasn't moved");
|
||
assert_eq!(after[1].rect, before[2].rect, "Example hasn't closed up the gap");
|
||
assert_eq!(after[0].size, before[0].size, "and kept words keep the layer's own sizes");
|
||
}
|
||
|
||
/// On the page: the deleted word's pixels are covered, its neighbour's aren't.
|
||
#[test]
|
||
fn a_deleted_ocr_word_is_covered_and_its_neighbour_is_not() {
|
||
let (mut s, id) = two_words_on_the_ink();
|
||
assert!(paper_between(&s, id, 44, 64) < 0.02 && paper_between(&s, id, 72, 96) < 0.02, "both words are ink to begin with");
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John".into(), style: Default::default() }).unwrap();
|
||
assert!(paper_between(&s, id, 44, 64) < 0.02, "John is still the scan");
|
||
assert!(paper_between(&s, id, 72, 96) > 0.98, "Smith is covered");
|
||
assert_eq!(s.get(id).unwrap().text_blocks(0).iter().map(|b| b.text.as_str()).collect::<Vec<_>>(), ["John"], "and gone from the text");
|
||
}
|
||
|
||
/// Replacing a word redraws that word, at the phrase's size and in its place.
|
||
#[test]
|
||
fn replacing_an_ocr_word_redraws_only_that_word() {
|
||
let (mut s, id) = uneven_phrase();
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smyth Example".into(), style: Default::default() }).unwrap();
|
||
assert_eq!(scan_and_drawn(&s, id), (vec!["John".to_owned(), "Example".to_owned()], vec!["Smyth".to_owned()]));
|
||
let lines = s.get(id).unwrap().text_lines(0);
|
||
let smyth = lines.iter().find(|l| l.text == "Smyth").unwrap();
|
||
assert!((smyth.size - 11.0).abs() < 0.05, "at the phrase's size: {}", smyth.size);
|
||
assert!((smyth.rect[0] - 49.0).abs() < 0.5, "where Smith was: {:?}", smyth.rect);
|
||
}
|
||
|
||
/// Changes apart from each other are each their own, with the word between them kept, as one step.
|
||
#[test]
|
||
fn two_changed_ocr_words_keep_the_word_between_them() {
|
||
let (mut s, id) = uneven_phrase();
|
||
let original = s.get(id).unwrap().bytes.clone();
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "Jon Smith Exam".into(), style: Default::default() }).unwrap();
|
||
assert_eq!(scan_and_drawn(&s, id), (vec!["Smith".to_owned()], vec!["Jon".to_owned(), "Exam".to_owned()]));
|
||
s.undo(id).unwrap();
|
||
assert_eq!(s.get(id).unwrap().bytes, original, "one undo brings the phrase back");
|
||
s.redo(id).unwrap();
|
||
assert_eq!(scan_and_drawn(&s, id), (vec!["Smith".to_owned()], vec!["Jon".to_owned(), "Exam".to_owned()]));
|
||
}
|
||
|
||
/// A replacement a little wider than its word is squeezed into its place, and the next word stays.
|
||
#[test]
|
||
fn a_slightly_longer_ocr_word_is_squeezed_into_its_place() {
|
||
let (mut s, id) = uneven_phrase();
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smithy Example".into(), style: Default::default() }).unwrap();
|
||
assert_eq!(scan_and_drawn(&s, id), (vec!["John".to_owned(), "Example".to_owned()], vec!["Smithy".to_owned()]));
|
||
let lines = s.get(id).unwrap().text_lines(0);
|
||
let smithy = lines.iter().find(|l| l.text == "Smithy").unwrap();
|
||
let example = lines.iter().find(|l| l.text == "Example").unwrap();
|
||
assert!(smithy.rect[2] <= 80.5, "it ends before the middle of the gap: {:?}", smithy.rect);
|
||
assert_eq!(example.rect[0].round(), 82.0, "and Example hasn't moved");
|
||
assert!((smithy.size - 11.0).abs() < 0.05, "squeezed across, not smaller: {}", smithy.size);
|
||
}
|
||
|
||
/// A replacement too wide to squeeze takes over the word it would run into, and no more.
|
||
#[test]
|
||
fn a_longer_ocr_word_takes_over_the_word_it_would_run_into() {
|
||
let (mut s, id) = scanned_with(vec![
|
||
scanned_word("John", 20.0, 45.0, 70.0, 11.0),
|
||
scanned_word("Smith", 49.0, 78.0, 70.0, 11.5),
|
||
scanned_word("Jones", 82.0, 112.0, 70.0, 12.0),
|
||
scanned_word("Brown", 118.5, 150.0, 70.0, 12.5),
|
||
]);
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smithsons Jones Brown".into(), style: Default::default() }).unwrap();
|
||
let (scan, drawn) = scan_and_drawn(&s, id);
|
||
assert_eq!(scan, ["John", "Brown"], "the words it doesn't reach stay: {drawn:?}");
|
||
assert_eq!(drawn, ["Smithsons Jones"]);
|
||
let lines = s.get(id).unwrap().text_lines(0);
|
||
let brown = lines.iter().find(|l| l.text == "Brown").unwrap();
|
||
let jones = lines.iter().find(|l| l.text.ends_with("Jones")).unwrap();
|
||
assert!(jones.rect[2] <= brown.rect[0], "{:?} runs into {:?}", jones.rect, brown.rect);
|
||
}
|
||
|
||
/// A word added with nothing to replace redraws a neighbour; the rest stay.
|
||
#[test]
|
||
fn an_added_ocr_word_redraws_only_a_neighbour() {
|
||
let (mut s, id) = uneven_phrase();
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith Example again".into(), style: Default::default() }).unwrap();
|
||
assert_eq!(scan_and_drawn(&s, id), (vec!["John Smith".to_owned()], vec!["Example again".to_owned()]));
|
||
}
|
||
|
||
/// Words OCR sized alike share a run in the text lines; they are still kept one by one.
|
||
#[test]
|
||
fn ocr_words_of_one_size_are_kept_one_by_one() {
|
||
let (mut s, id) = scanned_with(vec![
|
||
scanned_word("JOHN", 20.0, 45.0, 70.0, 11.0),
|
||
scanned_word("SMITH", 49.0, 78.0, 70.0, 11.0),
|
||
scanned_word("JONES", 82.0, 112.0, 70.0, 11.0),
|
||
]);
|
||
assert_eq!(s.get(id).unwrap().text_lines(0).len(), 1, "one run to begin with");
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "JOHN SMYTH JONES".into(), style: Default::default() }).unwrap();
|
||
let (scan, drawn) = scan_and_drawn(&s, id);
|
||
assert_eq!((scan, drawn), (vec!["JOHN".to_owned(), "JONES".to_owned()], vec!["SMYTH".to_owned()]));
|
||
}
|
||
|
||
/// Changing the formatting is about the whole phrase, so all of it is redrawn.
|
||
#[test]
|
||
fn a_formatting_change_redraws_the_whole_ocr_phrase() {
|
||
let (mut s, id) = uneven_phrase();
|
||
let style = pdfcraft_engine::BlockStyle { color: Some([1.0, 0.0, 0.0]), ..Default::default() };
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smyth Example".into(), style }).unwrap();
|
||
let (scan, drawn) = scan_and_drawn(&s, id);
|
||
assert!(scan.is_empty(), "nothing is left as the scan: {scan:?}");
|
||
assert_eq!(drawn.join(" "), "John Smyth Example");
|
||
}
|
||
|
||
/// A layer whose words follow one another with no position of their own: a word that goes leaves
|
||
/// its advance behind (ISO 32000-2 §9.4.4), so the words after it stay where they were and are
|
||
/// kept as the scan, with nothing drawn.
|
||
#[test]
|
||
fn words_that_depend_on_the_one_before_are_kept_in_place() {
|
||
let bytes = under_scan("BT /F1 10 Tf 3 Tr 20 44 Td (John) Tj ( ) Tj (Smith) Tj ( ) Tj (Example) Tj ET");
|
||
let mut s = Session::new();
|
||
let id = s.open("relative.pdf", None, bytes, None).unwrap();
|
||
assert_eq!(texts(&s, id), ["John Smith Example"]);
|
||
let right = |s: &Session| s.get(id).unwrap().text_lines(0).iter().find(|l| l.text.contains("Example")).map(|l| l.rect[2]);
|
||
let before = right(&s).unwrap();
|
||
edit_block(&mut s, id, 0, "John Example");
|
||
let (scan, drawn) = scan_and_drawn(&s, id);
|
||
assert_eq!((scan, drawn), (vec!["John".to_owned(), "Example".to_owned()], Vec::<String>::new()));
|
||
assert_eq!(right(&s), Some(before), "Example stays where it was");
|
||
}
|
||
|
||
/// The edit survives a save and reopen, and each piece can be edited again.
|
||
#[test]
|
||
fn a_kept_ocr_word_can_be_edited_after_a_save() {
|
||
let (mut s, id) = uneven_phrase();
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smyth Example".into(), style: Default::default() }).unwrap();
|
||
let saved = s.get(id).unwrap().bytes.clone();
|
||
let id = s.open("saved.pdf", None, saved, None).unwrap();
|
||
assert_eq!(scan_and_drawn(&s, id), (vec!["John".to_owned(), "Example".to_owned()], vec!["Smyth".to_owned()]));
|
||
let john = s.get(id).unwrap().text_blocks(0).iter().position(|b| b.text == "John").unwrap();
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: john, text: "Jon".into(), style: Default::default() }).unwrap();
|
||
assert_eq!(scan_and_drawn(&s, id), (vec!["Example".to_owned()], vec!["Jon".to_owned(), "Smyth".to_owned()]));
|
||
}
|
||
|
||
/// A draft with the same words, a space typed after the last of them, changes nothing: not the
|
||
/// page, and not the undo history. It used to redraw the whole line.
|
||
#[test]
|
||
fn a_draft_with_the_same_words_changes_nothing() {
|
||
let (mut s, id) = uneven_phrase();
|
||
let (bytes, generation) = (s.get(id).unwrap().bytes.clone(), s.get(id).unwrap().edit_generation());
|
||
for draft in ["John Smith Example ", " John Smith Example"] {
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: draft.into(), style: Default::default() }).unwrap();
|
||
assert_eq!(s.get(id).unwrap().bytes, bytes, "{draft:?} wrote nothing");
|
||
assert_eq!(s.get(id).unwrap().edit_generation(), generation, "{draft:?} is no undo step");
|
||
assert_eq!(scan_and_drawn(&s, id).1, Vec::<String>::new(), "{draft:?} drew nothing");
|
||
}
|
||
}
|
||
|
||
/// Visible text positioned after the end of an OCR word depends on that word. The word that goes
|
||
/// leaves its advance behind, so the visible text stays where it was and the word kept stays the
|
||
/// scan's.
|
||
#[test]
|
||
fn visible_text_that_follows_a_removed_ocr_word_stays_in_place() {
|
||
let bytes = under_scan("BT /F1 10 Tf 3 Tr 20 44 Td (John) Tj ( ) Tj (Smith) Tj 0 Tr /F1 14 Tf ( Label) Tj ET");
|
||
let mut s = Session::new();
|
||
let id = s.open("label.pdf", None, bytes, None).unwrap();
|
||
let label = |s: &Session| s.get(id).unwrap().text_lines(0).iter().find(|l| l.text.contains("Label")).map(|l| l.rect[0]);
|
||
assert_eq!(scan_and_drawn(&s, id), (vec!["John Smith".to_owned()], vec!["Label".to_owned()]));
|
||
let before = label(&s).unwrap();
|
||
edit_block(&mut s, id, 0, "John");
|
||
// Nothing new is drawn: the visible text is the page's own.
|
||
assert_eq!(scan_and_drawn(&s, id), (vec!["John".to_owned()], vec!["Label".to_owned()]));
|
||
assert_eq!(label(&s), Some(before), "Label stays where it was");
|
||
}
|
||
|
||
/// A large word right before a small one, as OCR's sizes can make them: the large word's cover,
|
||
/// padded in proportion to its height, stops at the small word, or that word would lose ink while
|
||
/// staying in the text.
|
||
#[test]
|
||
fn a_cover_stops_at_the_word_beside_it() {
|
||
let (mut s, id) = scanned_with(vec![scanned_word("John", 42.0, 66.0, 42.0, 30.0), scanned_word("Smith", 67.2, 98.0, 42.0, 8.0)]);
|
||
assert_eq!(s.get(id).unwrap().text_blocks(0)[0].text, "John Smith");
|
||
assert!(paper_between(&s, id, 67, 70) < 0.02, "Smith's start is ink to begin with");
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "Smith".into(), style: Default::default() }).unwrap();
|
||
assert_eq!(scan_and_drawn(&s, id), (vec!["Smith".to_owned()], Vec::new()), "only John goes");
|
||
assert!(paper_between(&s, id, 44, 64) > 0.98, "John is covered");
|
||
// John's padding would reach to x = 68, over this column of Smith's ink.
|
||
let smith = paper_between(&s, id, 67, 68);
|
||
assert!(smith < 0.02, "Smith keeps its ink: {smith}");
|
||
}
|
||
|
||
/// Deleting a word with Backspace leaves the spaces either side of it. That isn't spacing anyone
|
||
/// asked for: the words around it stay where the scan has them, with the gap already between them.
|
||
#[test]
|
||
fn the_spaces_a_deleted_word_leaves_keep_the_scan() {
|
||
for (draft, scan, drawn) in [
|
||
("John Example", vec!["John", "Example"], vec![]),
|
||
(" John Example", vec!["John", "Example"], vec![]),
|
||
("John Smyth Example", vec!["John", "Example"], vec!["Smyth"]),
|
||
] {
|
||
let (mut s, id) = uneven_phrase();
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: draft.into(), style: Default::default() }).unwrap();
|
||
let (kept, redrawn) = scan_and_drawn(&s, id);
|
||
assert_eq!(
|
||
(kept.iter().map(String::as_str).collect::<Vec<_>>(), redrawn.iter().map(String::as_str).collect::<Vec<_>>()),
|
||
(scan, drawn),
|
||
"{draft:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Spacing that has to be drawn goes to the rewrite that can draw it: inside new words, or typed
|
||
/// between words nothing else changed. It isn't quietly dropped.
|
||
#[test]
|
||
fn spacing_that_has_to_be_drawn_redraws_the_phrase() {
|
||
for draft in ["Jon Smyth Example", "John Smith Example"] {
|
||
let (mut s, id) = uneven_phrase();
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: draft.into(), style: Default::default() }).unwrap();
|
||
let (scan, _) = scan_and_drawn(&s, id);
|
||
assert!(scan.is_empty(), "{draft:?} keeps nothing as the scan: {scan:?}");
|
||
}
|
||
let (mut s, id) = uneven_phrase();
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "Jon Smyth Example".into(), style: Default::default() }).unwrap();
|
||
let (_, drawn) = scan_and_drawn(&s, id);
|
||
assert!(drawn.join("").contains("Jon Smy"), "the typed spaces are there: {drawn:?}");
|
||
}
|
||
|
||
/// A word drawn twice, its copy earlier in the layer: rewriting the word takes the copy with it, and
|
||
/// the words after the copy move up the list. The next change must not land on the wrong word.
|
||
#[test]
|
||
fn a_change_lands_on_its_own_word_when_a_rewrite_removes_an_earlier_copy() {
|
||
let bytes = under_scan(
|
||
"BT /F1 12 Tf 3 Tr 1 0 0 1 84 44 Tm (Jones) Tj 1 0 0 1 20 44 Tm (John) Tj 1 0 0 1 50 44 Tm (Smith) Tj 1 0 0 1 84 44 Tm (Jones) Tj ET",
|
||
);
|
||
let mut s = Session::new();
|
||
let id = s.open("copy.pdf", None, bytes, None).unwrap();
|
||
let block = texts(&s, id).iter().position(|t| t == "John Smith Jones").expect("the phrase, after its copy");
|
||
// John goes. A deletion draws nothing, so nothing else would notice it landing on Smith.
|
||
edit_block(&mut s, id, block, "Smith Joness");
|
||
let text = s.get(id).unwrap().text_lines(0).iter().map(|l| l.text.as_str()).collect::<Vec<_>>().join(" ");
|
||
let words: Vec<&str> = text.split_whitespace().collect();
|
||
assert!(words.contains(&"Smith"), "Smith wasn't changed: {words:?}");
|
||
assert!(!words.contains(&"John"), "John is the word that goes: {words:?}");
|
||
assert!(words.contains(&"Joness"), "{words:?}");
|
||
}
|
||
|
||
/// A layer whose words are already narrowed (50 Tz): squeezing a replacement narrows it further,
|
||
/// rather than setting a scale wider than the one it had and running it into the next word.
|
||
#[test]
|
||
fn a_squeeze_is_relative_to_the_words_own_scale() {
|
||
let bytes = under_scan("BT /F1 12 Tf 3 Tr 50 Tz 1 0 0 1 20 44 Tm (John) Tj 1 0 0 1 36 44 Tm (Smith) Tj 1 0 0 1 56 44 Tm (Jones) Tj ET");
|
||
let mut s = Session::new();
|
||
let id = s.open("narrow.pdf", None, bytes, None).unwrap();
|
||
assert_eq!(scan_and_drawn(&s, id).0, ["John Smith Jones"]);
|
||
edit_block(&mut s, id, 0, "John Smithy Jones");
|
||
let (scan, drawn) = scan_and_drawn(&s, id);
|
||
assert_eq!(drawn, ["Smithy"], "Jones isn't taken over; the scan has {scan:?}");
|
||
let lines = s.get(id).unwrap().text_lines(0);
|
||
let smithy = lines.iter().find(|l| l.text == "Smithy").unwrap();
|
||
let jones = lines.iter().find(|l| l.text == "Jones").unwrap();
|
||
assert!(smithy.rect[2] <= jones.rect[0], "{:?} runs into {:?}", smithy.rect, jones.rect);
|
||
}
|
||
|
||
/// Visible text set with a horizontally stretched text matrix (3 × 1), as some generators write.
|
||
fn stretched_visible() -> std::sync::Arc<Vec<u8>> {
|
||
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream};
|
||
let mut d = Document::new_empty();
|
||
let pages = d.add(Object::Dict(Dict::new()));
|
||
let contents = d.add(Object::Stream(Stream::flate(Dict::new(), b"BT /F1 12 Tf 3 0 0 1 20 60 Tm (Source) Tj ET")));
|
||
let mut font = Dict::new();
|
||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||
font.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
|
||
let mut fonts = Dict::new();
|
||
fonts.set(b"F1".to_vec(), Object::Dict(font));
|
||
let mut res = Dict::new();
|
||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||
let mut p = Dict::new();
|
||
p.set(b"Type".to_vec(), Object::name("Page"));
|
||
p.set(b"Parent".to_vec(), Object::Ref(pages));
|
||
p.set(b"Contents".to_vec(), Object::Ref(contents));
|
||
p.set(b"Resources".to_vec(), Object::Dict(res));
|
||
p.set(b"MediaBox".to_vec(), Object::Array([0, 0, 400, 120].map(Object::Int).to_vec()));
|
||
let page = d.add(Object::Dict(p));
|
||
d.update_dict(pages, |p| {
|
||
p.set(b"Type".to_vec(), Object::name("Pages"));
|
||
p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
|
||
p.set(b"Count".to_vec(), Object::Int(1));
|
||
})
|
||
.unwrap();
|
||
let mut root = Dict::new();
|
||
root.set(b"Type".to_vec(), Object::name("Catalog"));
|
||
root.set(b"Pages".to_vec(), Object::Ref(pages));
|
||
let root = d.add(Object::Dict(root));
|
||
d.trailer_mut().set(b"Root".to_vec(), Object::Ref(root));
|
||
std::sync::Arc::new(pdfcraft_cos::write_full(&d, &SaveOptions::default()).unwrap())
|
||
}
|
||
|
||
/// Visible text is not OCR: a styled rewrite of stretched text keeps its width, draws no cover
|
||
/// and stays visible.
|
||
#[test]
|
||
fn visible_stretched_text_rewrites_unchanged() {
|
||
let mut s = Session::new();
|
||
let id = s.open("stretched.pdf", None, stretched_visible(), None).unwrap();
|
||
let before = s.get(id).unwrap().text_blocks(0);
|
||
assert_eq!(before.len(), 1);
|
||
assert!(!before[0].invisible);
|
||
let style = pdfcraft_engine::BlockStyle { color: Some([1.0, 0.0, 0.0]), ..Default::default() };
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "Source".into(), style }).unwrap();
|
||
let after = s.get(id).unwrap().text_blocks(0);
|
||
assert_eq!(after.iter().map(|b| b.text.as_str()).collect::<Vec<_>>(), ["Source"]);
|
||
assert!(!after[0].invisible);
|
||
let (w0, w1) = (before[0].rect[2] - before[0].rect[0], after[0].rect[2] - after[0].rect[0]);
|
||
assert!((w0 - w1).abs() < 1.0, "the stretched word keeps its width: {w0} → {w1}");
|
||
assert!((before[0].rect[0] - after[0].rect[0]).abs() < 0.5);
|
||
// No paper-coloured cover: outside the glyphs the page is still white.
|
||
let doc = s.get(id).unwrap();
|
||
let out = PageRenderer::new(doc.bytes.clone(), Default::default()).render(RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||
assert!(out.error.is_none());
|
||
let r = after[0].rect;
|
||
let (x, y) = ((r[0] - 1.0).max(0.0) as u32, (120.0 - (r[1] + r[3]) / 2.0) as u32);
|
||
let i = ((y * out.width + x) * 4) as usize;
|
||
assert_eq!(&out.rgba[i..i + 3], &[255, 255, 255]);
|
||
}
|
||
|
||
/// A scan whose OCR layer is drawn first and the opaque page image over it, as some OCR tools
|
||
/// write it (#3973): invisible Helvetica `text` on the scanned word, then the image.
|
||
fn ocr_under_scan(text: &str) -> std::sync::Arc<Vec<u8>> {
|
||
under_scan(&format!("BT /F1 20 Tf 3 Tr 42 44 Td ({text}) Tj ET"))
|
||
}
|
||
|
||
/// A page whose `text` content is followed by the opaque 200 × 100 scan image.
|
||
fn under_scan(text: &str) -> std::sync::Arc<Vec<u8>> {
|
||
under_scan_with(text, |_| pdfcraft_cos::Dict::new())
|
||
}
|
||
|
||
/// As [`under_scan`], with the page's ExtGState resources made by `ext_gstates`.
|
||
fn under_scan_with(text: &str, ext_gstates: impl FnOnce(&mut pdfcraft_cos::Document) -> pdfcraft_cos::Dict) -> std::sync::Arc<Vec<u8>> {
|
||
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream};
|
||
let (w, h) = (200u32, 100u32);
|
||
let mut rgb = Vec::new();
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let ink = (WORD[0]..WORD[2]).contains(&x) && (WORD[1]..WORD[3]).contains(&y);
|
||
rgb.extend(if ink { INK } else { PAPER });
|
||
}
|
||
}
|
||
let mut d = Document::new_empty();
|
||
let pages = d.add(Object::Dict(Dict::new()));
|
||
let content = format!("{text} q 200 0 0 100 0 0 cm /Im Do Q");
|
||
let contents = d.add(Object::Stream(Stream::flate(Dict::new(), content.as_bytes())));
|
||
let mut image = Dict::new();
|
||
image.set(b"Type".to_vec(), Object::name("XObject"));
|
||
image.set(b"Subtype".to_vec(), Object::name("Image"));
|
||
image.set(b"Width".to_vec(), Object::Int(i64::from(w)));
|
||
image.set(b"Height".to_vec(), Object::Int(i64::from(h)));
|
||
image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
|
||
image.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||
let image = d.add(Object::Stream(Stream::flate(image, &rgb)));
|
||
let mut font = Dict::new();
|
||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||
font.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
|
||
let mut fonts = Dict::new();
|
||
fonts.set(b"F1".to_vec(), Object::Dict(font));
|
||
let mut xobjects = Dict::new();
|
||
xobjects.set(b"Im".to_vec(), Object::Ref(image));
|
||
let mut res = Dict::new();
|
||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||
res.set(b"XObject".to_vec(), Object::Dict(xobjects));
|
||
res.set(b"ExtGState".to_vec(), Object::Dict(ext_gstates(&mut d)));
|
||
let mut p = Dict::new();
|
||
p.set(b"Type".to_vec(), Object::name("Page"));
|
||
p.set(b"Parent".to_vec(), Object::Ref(pages));
|
||
p.set(b"Contents".to_vec(), Object::Ref(contents));
|
||
p.set(b"Resources".to_vec(), Object::Dict(res));
|
||
p.set(b"MediaBox".to_vec(), Object::Array([0, 0, 200, 100].map(Object::Int).to_vec()));
|
||
let page = d.add(Object::Dict(p));
|
||
d.update_dict(pages, |p| {
|
||
p.set(b"Type".to_vec(), Object::name("Pages"));
|
||
p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
|
||
p.set(b"Count".to_vec(), Object::Int(1));
|
||
})
|
||
.unwrap();
|
||
let mut root = Dict::new();
|
||
root.set(b"Type".to_vec(), Object::name("Catalog"));
|
||
root.set(b"Pages".to_vec(), Object::Ref(pages));
|
||
let root = d.add(Object::Dict(root));
|
||
d.trailer_mut().set(b"Root".to_vec(), Object::Ref(root));
|
||
std::sync::Arc::new(pdfcraft_cos::write_full(&d, &SaveOptions::default()).unwrap())
|
||
}
|
||
|
||
/// The scanned word's box shows the cover and new ink, not the scan.
|
||
fn assert_edit_shows(s: &Session, id: pdfcraft_engine::DocId, label: &str) {
|
||
let px = word_pixels(s, id);
|
||
let paper = px.iter().filter(|p| near(**p, PAPER)).count();
|
||
let ink = px.iter().filter(|p| near(**p, INK)).count();
|
||
assert!(paper * 10 > px.len() * 4, "{label}: the cover is drawn over the scan: {paper} of {} paper", px.len());
|
||
assert!(ink * 100 > px.len() * 3, "{label}: the new text shows: {ink} of {} ink", px.len());
|
||
}
|
||
|
||
fn texts(s: &Session, id: pdfcraft_engine::DocId) -> Vec<String> {
|
||
s.get(id).unwrap().text_blocks(0).iter().map(|b| b.text.replace('\n', " ")).collect()
|
||
}
|
||
|
||
fn edit_block(s: &mut Session, id: pdfcraft_engine::DocId, block: usize, text: &str) {
|
||
s.apply(id, Edit::EditTextBlock { page: 0, block, text: text.into(), style: Default::default() }).unwrap();
|
||
}
|
||
|
||
/// The OCR layer is under the scan image: the rewritten paragraph is drawn on top of the image,
|
||
/// not in the OCR text's place where the image would hide it (#3973). One line takes the
|
||
/// single-style rewrite, a typed line break the styled one. Undo restores the hidden layer, and
|
||
/// the edit survives redo, a save and reopen, and being edited again.
|
||
#[test]
|
||
fn an_ocr_layer_under_the_scan_image_shows_its_edit() {
|
||
for edit in ["Jane", "Jane\nDoe"] {
|
||
let mut s = Session::new();
|
||
let id = s.open("ocr-under-scan.pdf", None, ocr_under_scan("John"), None).unwrap();
|
||
let blocks = s.get(id).unwrap().text_blocks(0);
|
||
assert_eq!(blocks.len(), 1);
|
||
assert!(blocks[0].invisible, "the OCR layer is invisible text");
|
||
assert!(word_pixels(&s, id).iter().all(|p| near(*p, INK)), "the scanned word fills its box");
|
||
|
||
edit_block(&mut s, id, 0, edit);
|
||
assert_eq!(texts(&s, id), [edit.replace('\n', " ")], "{edit:?}");
|
||
assert_edit_shows(&s, id, edit);
|
||
|
||
s.undo(id).unwrap();
|
||
assert_eq!(texts(&s, id), ["John"], "{edit:?}: undo");
|
||
assert!(s.get(id).unwrap().text_blocks(0)[0].invisible, "{edit:?}: undo hides the layer again");
|
||
assert!(word_pixels(&s, id).iter().all(|p| near(*p, INK)), "{edit:?}: undo shows the scan again");
|
||
s.redo(id).unwrap();
|
||
assert_edit_shows(&s, id, &format!("{edit:?} redo"));
|
||
|
||
let saved = s.save_bytes(id).unwrap();
|
||
let mut reopened = Session::new();
|
||
let id = reopened.open("saved.pdf", None, saved, None).unwrap();
|
||
assert_eq!(texts(&reopened, id), [edit.replace('\n', " ")], "{edit:?}: reopened");
|
||
assert_edit_shows(&reopened, id, &format!("{edit:?} reopened"));
|
||
|
||
edit_block(&mut reopened, id, 0, "Ann");
|
||
assert_eq!(texts(&reopened, id), ["Ann"], "{edit:?}: edited again");
|
||
assert_edit_shows(&reopened, id, &format!("{edit:?} edited again"));
|
||
}
|
||
}
|
||
|
||
/// Visible text earlier in the same text object makes the rewrite split it; the OCR phrase still
|
||
/// goes on top of the scan, and the visible text stays.
|
||
#[test]
|
||
fn an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit() {
|
||
for edit in ["Jane", "Jane\nDoe"] {
|
||
let mut s = Session::new();
|
||
let page = under_scan("BT /F1 20 Tf 10 80 Td (Head) Tj 3 Tr 32 -36 Td (John) Tj ET");
|
||
let id = s.open("split.pdf", None, page, None).unwrap();
|
||
let blocks = s.get(id).unwrap().text_blocks(0);
|
||
let john = blocks.iter().position(|b| b.text == "John").unwrap();
|
||
assert!(blocks[john].invisible && blocks.iter().any(|b| b.text == "Head" && !b.invisible), "{blocks:?}");
|
||
edit_block(&mut s, id, john, edit);
|
||
let mut after = texts(&s, id);
|
||
after.sort();
|
||
let mut want = vec!["Head".to_string(), edit.replace('\n', " ")];
|
||
want.sort();
|
||
assert_eq!(after, want, "{edit:?}");
|
||
assert_edit_shows(&s, id, &format!("{edit:?} split"));
|
||
}
|
||
}
|
||
|
||
/// `/SM`: a luminosity soft mask that is white over 30..110 × 30..70 in the space it is selected
|
||
/// in, black elsewhere.
|
||
fn soft_mask_state(d: &mut pdfcraft_cos::Document) -> pdfcraft_cos::Dict {
|
||
use pdfcraft_cos::{Dict, Object, Stream};
|
||
let mut group = Dict::new();
|
||
group.set(b"S".to_vec(), Object::name("Transparency"));
|
||
group.set(b"CS".to_vec(), Object::name("DeviceRGB"));
|
||
let mut form = Dict::new();
|
||
form.set(b"Type".to_vec(), Object::name("XObject"));
|
||
form.set(b"Subtype".to_vec(), Object::name("Form"));
|
||
form.set(b"BBox".to_vec(), Object::Array([0, 0, 200, 100].map(Object::Int).to_vec()));
|
||
form.set(b"Group".to_vec(), Object::Dict(group));
|
||
let mut mask = Dict::new();
|
||
mask.set(b"Type".to_vec(), Object::name("Mask"));
|
||
mask.set(b"S".to_vec(), Object::name("Luminosity"));
|
||
mask.set(b"G".to_vec(), Object::Ref(d.add(Object::Stream(Stream::flate(form, b"1 g 30 30 80 40 re f")))));
|
||
let mut state = Dict::new();
|
||
state.set(b"Type".to_vec(), Object::name("ExtGState"));
|
||
state.set(b"SMask".to_vec(), Object::Dict(mask));
|
||
let mut states = Dict::new();
|
||
states.set(b"SM".to_vec(), Object::Dict(state));
|
||
states
|
||
}
|
||
|
||
/// The soft mask is selected before a translation, so it is placed by the CTM before it. The
|
||
/// paragraph lifted over the scan selects it again after its own `cm`: there it must be placed
|
||
/// as it was, not 100 pt to the right where it would hide the new text, so the edit renders
|
||
/// exactly as the same edit on the page without the mask. Both rewrite paths.
|
||
#[test]
|
||
fn a_soft_mask_selected_before_a_cm_stays_put_when_the_edit_is_lifted() {
|
||
for edit in ["Jane", "Jane\nDoe"] {
|
||
let mut plain = Session::new();
|
||
let page = under_scan("q 1 0 0 1 100 0 cm BT /F1 20 Tf 3 Tr -58 44 Td (John) Tj ET Q");
|
||
let plain_id = plain.open("plain.pdf", None, page, None).unwrap();
|
||
edit_block(&mut plain, plain_id, 0, edit);
|
||
let mut s = Session::new();
|
||
let page = under_scan_with("q /SM gs 1 0 0 1 100 0 cm BT /F1 20 Tf 3 Tr -58 44 Td (John) Tj ET Q", soft_mask_state);
|
||
let id = s.open("masked.pdf", None, page, None).unwrap();
|
||
assert_eq!(texts(&s, id), ["John"]);
|
||
edit_block(&mut s, id, 0, edit);
|
||
assert_eq!(texts(&s, id), [edit.replace('\n', " ")], "{edit:?}");
|
||
assert_edit_shows(&s, id, &format!("{edit:?} masked"));
|
||
let ink = |s: &Session, id| word_pixels(s, id).iter().filter(|p| near(**p, INK)).count();
|
||
assert_eq!(ink(&s, id), ink(&plain, plain_id), "{edit:?}: the mask does not hide the new text");
|
||
}
|
||
}
|