mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
# Conflicts: # crates/edit/src/text.rs # crates/edit/src/text_regions.rs
This commit is contained in:
commit
cf10038885
10 files changed
+827
-37
No files matched your search
@@ -25,7 +25,7 @@ mod regions;
|
||||
#[path = "text_session.rs"]
|
||||
mod session;
|
||||
pub use fallback::strip_subset_tag;
|
||||
use regions::{alignment_frame, group_blocks, is_centred, rewrite_styled_block};
|
||||
use regions::{alignment_frame, group_blocks, is_centred, rewrite_changed_words, rewrite_styled_block};
|
||||
pub use session::TextSession;
|
||||
|
||||
mod blocks;
|
||||
@@ -42,7 +42,7 @@ pub use blocks::{
|
||||
BlockKey, BlockStyle, RangeStyle, ReadingBlocks, TextBlock, block_overlapping, clear_block, reading_blocks, reading_blocks_checked,
|
||||
replace_block, text_blocks,
|
||||
};
|
||||
use blocks::{paragraph_separator, row_break};
|
||||
use blocks::{kept_words, paragraph_separator, row_break};
|
||||
pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_layout, text_editor_fonts};
|
||||
use editor::{builtin_thai_choice, layout_draft};
|
||||
#[cfg(test)]
|
||||
@@ -55,12 +55,12 @@ use fallback::{
|
||||
use fallback::{add_type3_font, is_type3_fallback};
|
||||
pub(crate) use fallback::{glyph_path, type3_font_dict, unique_font_name};
|
||||
use interp::{Origin, Paint, union};
|
||||
use lines::page_dict;
|
||||
pub(crate) use lines::{
|
||||
ContentStream, FormBudget, Read, content_streams, content_streams_within, dict_in_place, drawn_before_damage, first_op_after_damage, form_call,
|
||||
joined, resource_dict, rewritten_contents, splice,
|
||||
};
|
||||
pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines};
|
||||
use lines::{page_dict, word_lines};
|
||||
pub use rewrite::{LineEdit, replace_line, rewrite_block};
|
||||
use rewrite::{coincident_ops, rewrite_block_from};
|
||||
pub(crate) use rewrite::{paragraph_ops, reveal_ops, selectable_blocks, show_starts, translate_blocks};
|
||||
|
||||
@@ -44,6 +44,12 @@ impl TextBlock {
|
||||
(self.invisible && self.size.is_finite() && self.size > 0.0).then_some(self.size)
|
||||
}
|
||||
|
||||
/// Whether `draft` says what the paragraph says, give or take space around it. Spacing inside
|
||||
/// it has to match: a typed second space is a change.
|
||||
pub fn same_words(&self, draft: &str) -> bool {
|
||||
draft.trim() == self.editor_text.trim()
|
||||
}
|
||||
|
||||
/// Preserve styles outside the changed ranges; inserted characters inherit the surrounding
|
||||
/// source run. The texts are aligned word by word, so words left untouched between two edits
|
||||
/// keep their own runs (#2198); past [`MAX_CHANGED_WORDS`] the draft counts as one change.
|
||||
@@ -163,6 +169,23 @@ fn edit_script<T: PartialEq>(a: &[T], b: &[T], max: usize) -> Option<Vec<Step>>
|
||||
None
|
||||
}
|
||||
|
||||
/// For each of `old`'s words, the index in `new` of the word it stays as in the shortest edit from
|
||||
/// one to the other (`None` when it goes), or `None` overall past [`MAX_CHANGED_WORDS`] changes.
|
||||
pub(crate) fn kept_words<T: PartialEq>(old: &[T], new: &[T]) -> Option<Vec<Option<usize>>> {
|
||||
let mut kept = vec![None; old.len()];
|
||||
let (mut o, mut n) = (0usize, 0usize);
|
||||
for step in edit_script(old, new, MAX_CHANGED_WORDS)? {
|
||||
if step == Step::Keep
|
||||
&& let Some(slot) = kept.get_mut(o)
|
||||
{
|
||||
*slot = Some(n);
|
||||
}
|
||||
o = o.saturating_add(usize::from(step != Step::Insert));
|
||||
n = n.saturating_add(usize::from(step != Step::Delete));
|
||||
}
|
||||
Some(kept)
|
||||
}
|
||||
|
||||
/// Walk Myers' saved frontiers back from the end to the edit script.
|
||||
fn backtrack(trace: &[Vec<isize>], n: isize, m: isize, max: isize) -> Vec<Step> {
|
||||
let at = |v: &[isize], k: isize| usize::try_from(k + max + 1).ok().and_then(|i| v.get(i).copied()).unwrap_or(0);
|
||||
@@ -444,6 +467,13 @@ pub struct BlockStyle {
|
||||
/// For an invisible paragraph ([`TextBlock::invisible`]): fill its old area with this colour
|
||||
/// (RGB 0–1) before the new text, hiding the scanned word beneath it.
|
||||
pub cover: Option<[f64; 3]>,
|
||||
/// For an invisible paragraph whose formatting nobody asked to change: cover and redraw only
|
||||
/// the words the draft changes, and leave the rest as the scan shows them (#3313). The session
|
||||
/// sets this; a caller that sets it should also give `cover`.
|
||||
pub changed_words_only: bool,
|
||||
/// How far across [`BlockStyle::cover`] may reach, whatever its padding: the edges of the words
|
||||
/// on either side that stay (user space).
|
||||
pub cover_between: Option<[f64; 2]>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
||||
@@ -291,7 +291,13 @@ fn fill_color(fill: &[Op]) -> [f64; 3] {
|
||||
|
||||
/// The lines of text on a page (0-based), in content order.
|
||||
pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError> {
|
||||
text_lines_in(doc, &page_dict(doc, page)?, true)
|
||||
text_lines_in(doc, &page_dict(doc, page)?, true, false)
|
||||
}
|
||||
|
||||
/// [`text_lines`] with every show of invisible text a line of its own, however alike its
|
||||
/// neighbours: an OCR layer's words, which an edit can keep or change one by one.
|
||||
pub(crate) fn word_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError> {
|
||||
text_lines_in(doc, &page_dict(doc, page)?, true, true)
|
||||
}
|
||||
|
||||
/// The lines `page`'s glyphs show, in content order, without `/ActualText` replacements: what the
|
||||
@@ -299,11 +305,12 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
|
||||
/// not trust that metadata (the redaction audit). Takes the page itself, so a caller going
|
||||
/// through every page doesn't walk the page tree again for each.
|
||||
pub fn glyph_text_lines(doc: &Document, page: &pdfcraft_model::Page) -> Result<Vec<TextLine>, EditError> {
|
||||
text_lines_in(doc, page, false)
|
||||
text_lines_in(doc, page, false, false)
|
||||
}
|
||||
|
||||
/// [`text_lines`] of `p`; with `actual_text`, `/ActualText` replaces the glyphs it spans.
|
||||
fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) -> Result<Vec<TextLine>, EditError> {
|
||||
/// [`text_lines`] of `p`; with `actual_text`, `/ActualText` replaces the glyphs it spans, and with
|
||||
/// `invisible_apart`, no show of invisible text joins the one before it.
|
||||
fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, invisible_apart: bool) -> Result<Vec<TextLine>, EditError> {
|
||||
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let mut cache = HashMap::new();
|
||||
let mut lines: Vec<TextLine> = Vec::new();
|
||||
@@ -345,7 +352,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool) ->
|
||||
&& near
|
||||
// An OCR layer is one text object over the whole scan: its words continue a line
|
||||
// only across a word space, not across a table cell or a tab stop.
|
||||
&& (s.state.render_mode != 3 || gap < pen.size * 0.6)
|
||||
&& (s.state.render_mode != 3 || (gap < pen.size * 0.6 && !invisible_apart))
|
||||
});
|
||||
if joins && let Some(l) = lines.last_mut() {
|
||||
let gap = last.map_or(0.0, |(_, pen)| pen.continues(&s, 0.0).1);
|
||||
|
||||
@@ -7,7 +7,7 @@ use super::interp::{Carry, Origin, TextState, interpret};
|
||||
use super::lines::{content_streams, joined, page_dict, rewritten_contents, splice, text_lines};
|
||||
use super::{
|
||||
Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, TextLine, TextSession, alignment_frame, group_blocks, is_centred,
|
||||
parse, rewrite_styled_block,
|
||||
parse, rewrite_changed_words, rewrite_styled_block,
|
||||
};
|
||||
|
||||
/// What replacing a line did.
|
||||
@@ -829,6 +829,9 @@ pub(crate) fn rewrite_block_from(
|
||||
if b.invisible {
|
||||
crate::check_on_top(doc, &p)?;
|
||||
}
|
||||
if let Some(edit) = text.and_then(|draft| rewrite_changed_words(doc, page, &b, &lines, draft, style, max_glyphs)) {
|
||||
return Ok(edit);
|
||||
}
|
||||
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
|
||||
let dispatch = choose_rewrite_path(doc, &fonts_res, &b, &members, first, text, style)?;
|
||||
@@ -996,6 +999,14 @@ pub(crate) fn rewrite_block_from(
|
||||
/// rewritten invisible paragraph, drawn in the space whose CTM is `ctm`. Nothing for visible text.
|
||||
/// The paragraph's `q`/`Q` restores the rendering mode for the invisible text after it; one
|
||||
/// painted over where it was is drawn on top of the page instead ([`crate::draw_on_top`]).
|
||||
/// What covering the word in `rect` paints: scanned ink reaches a little past the recognised box,
|
||||
/// but not past `between`, the words on either side that stay. It always covers the word itself.
|
||||
pub(crate) fn cover_box(rect: [f64; 4], between: Option<[f64; 2]>) -> [f64; 4] {
|
||||
let pad = ((rect[3] - rect[1]).abs() * 0.1).clamp(0.5, 2.0);
|
||||
let [left, right] = between.unwrap_or([f64::NEG_INFINITY, f64::INFINITY]);
|
||||
[(rect[0] - pad).max(left.min(rect[0])), rect[1] - pad, (rect[2] + pad).min(right.max(rect[2])), rect[3] + pad]
|
||||
}
|
||||
|
||||
pub(crate) fn reveal_ops(block: &TextBlock, lines: &[TextLine], style: &BlockStyle, ctm: [f64; 6]) -> Result<Vec<Op>, EditError> {
|
||||
if !block.invisible {
|
||||
return Ok(Vec::new());
|
||||
@@ -1009,11 +1020,8 @@ pub(crate) fn reveal_ops(block: &TextBlock, lines: &[TextLine], style: &BlockSty
|
||||
let back = Matrix(ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be covered in".into()))?;
|
||||
ops.push(Op::new("q", vec![]));
|
||||
ops.push(Op::new("rg", colour.map(n).to_vec()));
|
||||
for rect in block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect) {
|
||||
// Scanned ink reaches a little past the recognised box.
|
||||
let pad = ((rect[3] - rect[1]).abs() * 0.1).clamp(0.5, 2.0);
|
||||
let corners =
|
||||
[[rect[0] - pad, rect[1] - pad], [rect[2] + pad, rect[1] - pad], [rect[2] + pad, rect[3] + pad], [rect[0] - pad, rect[3] + pad]];
|
||||
for rect in block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| cover_box(l.rect, style.cover_between)) {
|
||||
let corners = [[rect[0], rect[1]], [rect[2], rect[1]], [rect[2], rect[3]], [rect[0], rect[3]]];
|
||||
for (i, [x, y]) in corners.into_iter().enumerate() {
|
||||
let (x, y) = back.apply(x, y);
|
||||
if !(x.is_finite() && y.is_finite()) {
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
use super::{
|
||||
BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces,
|
||||
RangeStyle, TextBlock, TextLine, Type3Glyph, builtin_thai_choice, coincident_ops, content_streams, fallback_face, japanese_glyph_from,
|
||||
needs_type3, no_fallback_font, no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, row_break, same_base_font,
|
||||
kept_words, needs_type3, no_fallback_font, no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, row_break, same_base_font,
|
||||
same_font, source_encodes, source_family, source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces,
|
||||
type3_glyph, union, write_type3_glyphs,
|
||||
type3_glyph, union, word_lines, write_type3_glyphs,
|
||||
};
|
||||
|
||||
#[path = "text_regions/grouping.rs"]
|
||||
mod grouping;
|
||||
#[path = "text_regions/ocr_words.rs"]
|
||||
mod ocr_words;
|
||||
#[path = "text_regions/styled.rs"]
|
||||
mod styled;
|
||||
|
||||
pub(super) use grouping::{alignment_frame, group_blocks, group_reading_blocks, is_centred};
|
||||
pub(super) use ocr_words::rewrite_changed_words;
|
||||
pub(super) use styled::rewrite_styled_block;
|
||||
@@ -0,0 +1,436 @@
|
||||
//! Edit text on a scanned page, word by word: a text edit of an OCR phrase covers and redraws only
|
||||
//! the words it changes, and the rest stay as the scan shows them (#3313). Each changed stretch is
|
||||
//! rewritten as a phrase of its own, which is how a phrase beside others in one OCR layer already
|
||||
//! rewrites. A deleted word leaves its gap: the words after it are pixels, and don't move.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::ops::Range;
|
||||
|
||||
use super::grouping::row_rect;
|
||||
use super::styled::rewrite_styled_block;
|
||||
use super::{BlockStyle, Document, EditError, LineEdit, TextBlock, TextLine, kept_words, word_lines};
|
||||
|
||||
/// How many times a replacement may be refitted to the room it has before the whole phrase is
|
||||
/// redrawn instead. Each try rewrites a copy of the document.
|
||||
const MAX_TRIES: usize = 16;
|
||||
|
||||
/// The narrowest a replacement is squeezed to stay in its word's place. Most replacements are
|
||||
/// only a little wider than the word they replace, and taking over the next word pushes that one
|
||||
/// along too, and so on down the line. Past this it would be hard to read, so it takes the word over.
|
||||
const MIN_SQUEEZE: f64 = 0.75;
|
||||
|
||||
/// A stretch of the phrase's word runs that changes, the text that replaces it (empty when it is
|
||||
/// deleted), and how much that text is squeezed across to fit.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Change {
|
||||
runs: Range<usize>,
|
||||
/// Which of the draft's words replace them.
|
||||
words: Range<usize>,
|
||||
text: String,
|
||||
squeeze: Option<f64>,
|
||||
}
|
||||
|
||||
/// The stretches `new` changes, given how many words each run holds and which word of `new` each
|
||||
/// old word stays as ([`kept_words`]). A run in `redrawn` counts as changed, and new words with no
|
||||
/// run of their own to replace add the run after them to it (or the last one, at the end).
|
||||
fn changes(runs: &[usize], kept_as: &[Option<usize>], new: &[&str], redrawn: &mut BTreeSet<usize>) -> Vec<Change> {
|
||||
// Each pass either returns or adds a run to `redrawn`, so this ends.
|
||||
'plan: loop {
|
||||
let mut out = Vec::new();
|
||||
let (mut first_run, mut first_new, mut token) = (0usize, 0usize, 0usize);
|
||||
for (run, count) in runs.iter().enumerate() {
|
||||
let words = kept_as.get(token..token.saturating_add(*count)).unwrap_or_default();
|
||||
token = token.saturating_add(*count);
|
||||
let at = words.first().copied().flatten();
|
||||
let kept = !redrawn.contains(&run) && !words.is_empty() && words.iter().enumerate().all(|(i, w)| *w == at.map(|a| a.saturating_add(i)));
|
||||
let (true, Some(at)) = (kept, at) else { continue };
|
||||
if first_run == run && first_new < at {
|
||||
redrawn.insert(run);
|
||||
continue 'plan;
|
||||
}
|
||||
if first_run < run {
|
||||
out.push(Change {
|
||||
runs: first_run..run,
|
||||
words: first_new..at,
|
||||
text: new.get(first_new..at).unwrap_or_default().join(" "),
|
||||
squeeze: None,
|
||||
});
|
||||
}
|
||||
first_run = run.saturating_add(1);
|
||||
first_new = at.saturating_add(*count);
|
||||
}
|
||||
if first_run < runs.len() {
|
||||
out.push(Change {
|
||||
runs: first_run..runs.len(),
|
||||
words: first_new..new.len(),
|
||||
text: new.get(first_new..).unwrap_or_default().join(" "),
|
||||
squeeze: None,
|
||||
});
|
||||
} else if first_new < new.len()
|
||||
&& let Some(last) = runs.len().checked_sub(1)
|
||||
{
|
||||
redrawn.insert(last);
|
||||
continue 'plan;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/// Each word of `text` as a byte range.
|
||||
fn word_ranges(text: &str) -> Vec<Range<usize>> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = None;
|
||||
for (at, c) in text.char_indices() {
|
||||
match (c.is_whitespace(), start) {
|
||||
(true, Some(from)) => {
|
||||
out.push(from..at);
|
||||
start = None;
|
||||
}
|
||||
(false, None) => start = Some(at),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(from) = start {
|
||||
out.push(from..text.len());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `plan` can stand for the spacing in `draft`, whose words are `words`. Spaces beside a
|
||||
/// change are nothing to draw: Backspacing over a word leaves one either side of where it was, and
|
||||
/// the words around it stay where the scan has them. Anything else that isn't one space has to be
|
||||
/// drawn, inside the new words or between two words nothing else changed, which only the whole
|
||||
/// rewrite can do.
|
||||
fn spacing_fits(draft: &str, words: &[Range<usize>], plan: &[Change]) -> bool {
|
||||
words.windows(2).enumerate().all(|(index, pair)| {
|
||||
let [before, after] = pair else { return true };
|
||||
let gap = draft.get(before.end..after.start).unwrap_or(" ");
|
||||
let boundary = index.saturating_add(1);
|
||||
gap == " " || (gap.chars().all(|c| c == ' ') && plan.iter().any(|change| change.words.start == boundary || change.words.end == boundary))
|
||||
})
|
||||
}
|
||||
|
||||
/// The phrase's stretch `indices` of `lines`, as a paragraph of its own. It keeps the whole
|
||||
/// phrase's size, so what is redrawn is set alike wherever in the phrase it is.
|
||||
fn stretch(phrase: &TextBlock, lines: &[TextLine], indices: &[usize]) -> Result<TextBlock, EditError> {
|
||||
let first = indices.first().and_then(|i| lines.get(*i)).ok_or_else(|| EditError::Invalid("the phrase has no such words".into()))?;
|
||||
let rect = row_rect(indices, lines).ok_or_else(|| EditError::Invalid("the phrase has no such words".into()))?;
|
||||
let mut text = String::new();
|
||||
let mut spans = Vec::with_capacity(indices.len());
|
||||
for index in indices {
|
||||
let Some(line) = lines.get(*index) else { continue };
|
||||
if !text.is_empty() && !text.ends_with(char::is_whitespace) && !line.text.starts_with(char::is_whitespace) {
|
||||
text.push(' ');
|
||||
}
|
||||
let start = text.len();
|
||||
text.push_str(&line.text);
|
||||
spans.push((start..text.len(), *index));
|
||||
}
|
||||
Ok(TextBlock {
|
||||
reflow_text: text.clone(),
|
||||
editor_text: text.clone(),
|
||||
text,
|
||||
spans,
|
||||
rect,
|
||||
base_font: first.base_font.clone(),
|
||||
size: phrase.size,
|
||||
bold: first.bold,
|
||||
italic: first.italic,
|
||||
color: first.color,
|
||||
rotation: first.rotation(),
|
||||
lines: indices.to_vec(),
|
||||
rows: vec![indices.to_vec()],
|
||||
invisible: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn key(line: &TextLine) -> (&str, [i64; 4]) {
|
||||
// Saturating casts: a non-finite edge only makes the comparison below fail, safely.
|
||||
(line.text.as_str(), line.rect.map(|v| (v * 100.0).round() as i64))
|
||||
}
|
||||
|
||||
/// How far right a rewrite of the runs `indices` drew (`None` when it drew nothing), or an error
|
||||
/// when it moved or lost any other text on the page: text positioned after the end of the text
|
||||
/// before it, say, depends on the words that went.
|
||||
fn drawn_right(before: &[TextLine], indices: &[usize], after: &[TextLine]) -> Result<Option<f64>, EditError> {
|
||||
let mut unmatched: HashMap<_, usize> = HashMap::new();
|
||||
for line in before {
|
||||
let count = unmatched.entry(key(line)).or_default();
|
||||
*count = count.saturating_add(1);
|
||||
}
|
||||
let mut drawn = Vec::new();
|
||||
for line in after {
|
||||
match unmatched.get_mut(&key(line)).filter(|count| **count > 0) {
|
||||
Some(count) => *count -= 1,
|
||||
None => drawn.push(line),
|
||||
}
|
||||
}
|
||||
// Visible text beside what was drawn reads as one line with it. It is still where it was.
|
||||
let joined = |line: &TextLine| {
|
||||
let [x0, y0, x1, y1] = line.rect;
|
||||
drawn.iter().any(|d| {
|
||||
d.text.contains(line.text.trim()) && d.rect[0] <= x0 + 0.01 && d.rect[1] <= y0 + 0.01 && d.rect[2] >= x1 - 0.01 && d.rect[3] >= y1 - 0.01
|
||||
})
|
||||
};
|
||||
let moved = before
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(index, line)| !indices.contains(&index) && unmatched.get(&key(line)).is_some_and(|c| *c > 0) && !joined(line));
|
||||
if moved {
|
||||
return Err(EditError::Invalid("rewriting these words alone would move the text around them".into()));
|
||||
}
|
||||
Ok(drawn.iter().map(|line| line.rect[2]).reduce(f64::max))
|
||||
}
|
||||
|
||||
/// Rewrite each change of `plan` in `doc`, last first: a rewrite goes where its words were or at
|
||||
/// the end of the page, so the words before it keep their indexes (an error if they don't). Gives
|
||||
/// how far right each drew.
|
||||
fn apply(
|
||||
doc: &mut Document,
|
||||
page: usize,
|
||||
phrase: &TextBlock,
|
||||
members: &[usize],
|
||||
plan: &[Change],
|
||||
style: &BlockStyle,
|
||||
max_glyphs: usize,
|
||||
) -> Result<(Vec<Option<f64>>, Option<String>), EditError> {
|
||||
// The stretch starts where its words did. Its own box would centre a word that happens to
|
||||
// sit in the middle of the page.
|
||||
let mut style = BlockStyle { align: Some(crate::added::Align::Left), changed_words_only: false, ..style.clone() };
|
||||
let mut rights = vec![None; plan.len()];
|
||||
let mut substituted = None;
|
||||
let original = word_lines(doc, page)?;
|
||||
for (at, change) in plan.iter().enumerate().rev() {
|
||||
let indices = members.get(change.runs.clone()).ok_or_else(|| EditError::Invalid("the phrase has no such words".into()))?;
|
||||
let before = word_lines(doc, page)?;
|
||||
// The words up to the next that stays must still be where they were. A rewrite also takes
|
||||
// away copies of its words, and a copy earlier on the page moves these up the list.
|
||||
let up_to = change.runs.end.saturating_add(1).min(members.len());
|
||||
let same = |index: &usize| before.get(*index).map(key) == original.get(*index).map(key);
|
||||
if !members.get(..up_to).unwrap_or_default().iter().all(same) {
|
||||
return Err(EditError::Invalid("rewriting these words alone would lose track of the words before them".into()));
|
||||
}
|
||||
let part = stretch(phrase, &before, indices)?;
|
||||
// Relative to the scale the words have, which a layer may have narrowed already.
|
||||
let own_scale = indices.first().and_then(|i| before.get(*i)).map_or(1.0, |line| line.origin.state.scale);
|
||||
style.scale = change.squeeze.map(|squeeze| squeeze * own_scale * 100.0);
|
||||
// The words on either side stay, with all their ink.
|
||||
let edge = |run: Option<usize>| run.and_then(|run| members.get(run)).and_then(|i| before.get(*i)).map(|line| line.rect);
|
||||
style.cover_between = Some([
|
||||
edge(change.runs.start.checked_sub(1)).map_or(f64::NEG_INFINITY, |rect| rect[2]),
|
||||
edge(Some(change.runs.end)).map_or(f64::INFINITY, |rect| rect[0]),
|
||||
]);
|
||||
let edit = rewrite_styled_block(doc, page, &part, &before, Some(&change.text), &style, max_glyphs)?;
|
||||
substituted = substituted.or(edit.substituted);
|
||||
let right = drawn_right(&before, indices, &word_lines(doc, page)?)?;
|
||||
if right.is_none() && !change.text.is_empty() {
|
||||
return Err(EditError::Invalid("the new words were not drawn".into()));
|
||||
}
|
||||
if let Some(slot) = rights.get_mut(at) {
|
||||
*slot = right;
|
||||
}
|
||||
}
|
||||
Ok((rights, substituted))
|
||||
}
|
||||
|
||||
/// Whether a change that drew as far as `right` needs another try. It should end by the middle of
|
||||
/// the gap before the word after it, so the two don't touch: within [`MIN_SQUEEZE`] it is squeezed
|
||||
/// to, and otherwise it takes that word over.
|
||||
fn make_room(change: &Change, right: f64, boxes: &[[f64; 4]], squeezes: &mut BTreeMap<usize, f64>, redrawn: &mut BTreeSet<usize>) -> bool {
|
||||
let (Some(first), Some(last), Some(next)) =
|
||||
(boxes.get(change.runs.start), change.runs.end.checked_sub(1).and_then(|run| boxes.get(run)), boxes.get(change.runs.end))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let limit = (last[2] + next[0]) / 2.0;
|
||||
// Widths scale in proportion, so one squeeze lands within this; the rest is rounding.
|
||||
if right <= limit + 0.25 {
|
||||
return false;
|
||||
}
|
||||
let squeeze = change.squeeze.unwrap_or(1.0) * (limit - first[0]) / (right - first[0]);
|
||||
if squeeze >= MIN_SQUEEZE {
|
||||
squeezes.insert(change.runs.start, squeeze);
|
||||
} else {
|
||||
squeezes.remove(&change.runs.start);
|
||||
redrawn.insert(change.runs.end);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Rewrite only the words `draft` changes in the invisible phrase `phrase`, or `None` when this
|
||||
/// edit is not one that can (it changes the formatting or the lines, the phrase is turned or
|
||||
/// right-to-left, or its words can't be told apart or kept in place): the caller then rewrites the
|
||||
/// whole phrase. Nothing changes in `doc` unless the whole edit works.
|
||||
pub(crate) fn rewrite_changed_words(
|
||||
doc: &mut Document,
|
||||
page: usize,
|
||||
phrase: &TextBlock,
|
||||
joined: &[TextLine],
|
||||
draft: &str,
|
||||
style: &BlockStyle,
|
||||
max_glyphs: usize,
|
||||
) -> Option<LineEdit> {
|
||||
let right_to_left = |text: &str| text.chars().any(crate::bidi::is_rtl);
|
||||
if !style.changed_words_only
|
||||
|| !phrase.invisible
|
||||
|| phrase.rows.len() != 1
|
||||
|| draft.contains(['\n', '\r'])
|
||||
|| right_to_left(draft)
|
||||
|| right_to_left(&phrase.text)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let lines = word_lines(doc, page).ok()?;
|
||||
let phrase_ops: BTreeSet<usize> = phrase.lines.iter().filter_map(|i| joined.get(*i)).flat_map(|line| line.ops.iter().copied()).collect();
|
||||
let members: Vec<usize> =
|
||||
lines.iter().enumerate().filter(|(_, line)| line.ops.first().is_some_and(|op| phrase_ops.contains(op))).map(|(index, _)| index).collect();
|
||||
if members.iter().filter_map(|i| lines.get(*i)).any(|line| !line.upright() || line.visual_order) {
|
||||
return None;
|
||||
}
|
||||
let runs: Vec<Vec<&str>> = members.iter().filter_map(|i| lines.get(*i)).map(|line| line.text.split_whitespace().collect()).collect();
|
||||
let old: Vec<&str> = runs.iter().flatten().copied().collect();
|
||||
let new: Vec<&str> = draft.split_whitespace().collect();
|
||||
// Words the layer doesn't show apart (a run per letter, say) can't be kept apart.
|
||||
if !phrase.editor_text.split_whitespace().eq(old.iter().copied()) {
|
||||
return None;
|
||||
}
|
||||
// A single word that changes has nothing to keep. With the same words, the plan is empty.
|
||||
if old.len() < 2 && old != new {
|
||||
return None;
|
||||
}
|
||||
let kept_as = kept_words(&old, &new)?;
|
||||
let counts: Vec<usize> = runs.iter().map(Vec::len).collect();
|
||||
let boxes: Vec<[f64; 4]> = members.iter().filter_map(|i| lines.get(*i)).map(|line| line.rect).collect();
|
||||
let mut redrawn = BTreeSet::new();
|
||||
let mut squeezes = BTreeMap::new();
|
||||
for _ in 0..MAX_TRIES {
|
||||
let mut plan = changes(&counts, &kept_as, &new, &mut redrawn);
|
||||
if !spacing_fits(draft, &word_ranges(draft), &plan) {
|
||||
return None;
|
||||
}
|
||||
for change in &mut plan {
|
||||
change.squeeze = squeezes.get(&change.runs.start).copied();
|
||||
}
|
||||
let mut trial = doc.clone();
|
||||
let (rights, substituted) = apply(&mut trial, page, phrase, &members, &plan, style, max_glyphs).ok()?;
|
||||
let mut again = false;
|
||||
for (change, right) in plan.iter().zip(&rights) {
|
||||
if let Some(right) = right {
|
||||
again |= make_room(change, *right, &boxes, &mut squeezes, &mut redrawn);
|
||||
}
|
||||
}
|
||||
if !again {
|
||||
*doc = trial;
|
||||
return Some(LineEdit { substituted });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn plan(old: &[&[&str]], new: &str) -> Vec<(Range<usize>, String)> {
|
||||
let flat: Vec<&str> = old.iter().flat_map(|run| run.iter().copied()).collect();
|
||||
let new: Vec<&str> = new.split_whitespace().collect();
|
||||
let counts: Vec<usize> = old.iter().map(|run| run.len()).collect();
|
||||
changes(&counts, &kept_words(&flat, &new).unwrap(), &new, &mut BTreeSet::new()).into_iter().map(|c| (c.runs, c.text)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_runs_a_draft_changes_are_planned() {
|
||||
let abc: &[&[&str]] = &[&["a"], &["b"], &["c"]];
|
||||
assert_eq!(plan(abc, "a b c"), []);
|
||||
assert_eq!(plan(abc, "a c"), [(1..2, String::new())], "a deletion");
|
||||
assert_eq!(plan(abc, "a x c"), [(1..2, "x".to_owned())], "a replacement");
|
||||
assert_eq!(plan(abc, "x b y"), [(0..1, "x".to_owned()), (2..3, "y".to_owned())], "two apart");
|
||||
assert_eq!(plan(abc, "x y c"), [(0..2, "x y".to_owned())], "two together");
|
||||
assert_eq!(plan(abc, ""), [(0..3, String::new())], "everything");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_words_with_no_run_to_replace_take_a_neighbour() {
|
||||
let abc: &[&[&str]] = &[&["a"], &["b"], &["c"]];
|
||||
assert_eq!(plan(abc, "x a b c"), [(0..1, "x a".to_owned())], "at the start");
|
||||
assert_eq!(plan(abc, "a x b c"), [(1..2, "x b".to_owned())], "in the middle");
|
||||
assert_eq!(plan(abc, "a b c x"), [(2..3, "c x".to_owned())], "at the end");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_of_several_words_is_kept_only_whole() {
|
||||
let runs: &[&[&str]] = &[&["a", "b"], &["c"]];
|
||||
assert_eq!(plan(runs, "a b x"), [(1..2, "x".to_owned())]);
|
||||
assert_eq!(plan(runs, "a x c"), [(0..1, "a x".to_owned())], "one of its words changed");
|
||||
assert_eq!(plan(runs, "a x b c"), [(0..1, "a x b".to_owned())], "a word went in between its words");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drafts_words_are_found_by_their_bytes() {
|
||||
assert!(word_ranges("").is_empty() && word_ranges(" ").is_empty());
|
||||
assert_eq!(word_ranges("ab é c "), [0..2, 4..6, 7..8]);
|
||||
}
|
||||
|
||||
/// The plan for `draft` against the words a, b and c, and whether it stands for its spacing.
|
||||
fn fits(draft: &str) -> bool {
|
||||
let (old, new): (Vec<&str>, Vec<&str>) = (vec!["a", "b", "c"], draft.split_whitespace().collect());
|
||||
let plan = changes(&[1, 1, 1], &kept_words(&old, &new).unwrap(), &new, &mut BTreeSet::new());
|
||||
spacing_fits(draft, &word_ranges(draft), &plan)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_beside_a_change_are_nothing_to_draw() {
|
||||
assert!(fits("a b c"));
|
||||
assert!(fits("a b c "), "after the words");
|
||||
assert!(fits(" a b c"), "before them");
|
||||
assert!(fits("a c"), "either side of a deleted word");
|
||||
assert!(fits(" b c"), "where a first word went");
|
||||
assert!(fits("a x c"), "either side of a replacement");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacing_that_has_to_be_drawn_doesnt_fit() {
|
||||
assert!(!fits("a b c"), "between words nothing else changed");
|
||||
assert!(!fits("x y c"), "inside the new words");
|
||||
assert!(!fits("a\u{3000}x c"), "another kind of space, even beside a change");
|
||||
assert!(!fits("a\u{a0}x c"));
|
||||
}
|
||||
|
||||
/// Word boxes 10 wide, with a gap of 4 between them.
|
||||
const BOXES: [[f64; 4]; 3] = [[0.0, 0.0, 10.0, 10.0], [14.0, 0.0, 24.0, 10.0], [28.0, 0.0, 38.0, 10.0]];
|
||||
|
||||
fn room_for(right: f64) -> (bool, BTreeMap<usize, f64>, BTreeSet<usize>) {
|
||||
let (mut squeezes, mut redrawn) = (BTreeMap::new(), BTreeSet::new());
|
||||
let change = Change { runs: 0..1, words: 0..1, text: "x".to_owned(), squeeze: None };
|
||||
(make_room(&change, right, &BOXES, &mut squeezes, &mut redrawn), squeezes, redrawn)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_word_that_fits_is_left_alone() {
|
||||
assert_eq!(room_for(12.0), (false, BTreeMap::new(), BTreeSet::new()), "it ends in the gap's first half");
|
||||
let last = Change { runs: 2..3, words: 2..3, text: "x".to_owned(), squeeze: None };
|
||||
assert!(!make_room(&last, 900.0, &BOXES, &mut BTreeMap::new(), &mut BTreeSet::new()), "the last word has the page to grow into");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_word_a_little_too_wide_is_squeezed_to_the_middle_of_the_gap() {
|
||||
let (again, squeezes, redrawn) = room_for(15.0);
|
||||
assert!(again && redrawn.is_empty());
|
||||
assert_eq!(squeezes.get(&0).copied(), Some(12.0 / 15.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_word_far_too_wide_takes_over_the_next_one() {
|
||||
let (again, squeezes, redrawn) = room_for(30.0);
|
||||
assert!(again && squeezes.is_empty());
|
||||
assert_eq!(redrawn, BTreeSet::from([1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_redrawn_run_joins_the_change_beside_it() {
|
||||
let (counts, old, new) = ([1, 1, 1], ["a", "b", "c"], ["x", "b", "c"]);
|
||||
let mut redrawn = BTreeSet::from([1]);
|
||||
let planned = changes(&counts, &kept_words(&old, &new).unwrap(), &new, &mut redrawn);
|
||||
assert_eq!(planned, [Change { runs: 0..2, words: 0..2, text: "x b".to_owned(), squeeze: None }]);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ impl Session {
|
||||
// in the paper's colour and draw the new text in the ink's.
|
||||
let edit = match edit {
|
||||
Edit::EditTextBlock { page, block, text, mut style } => {
|
||||
let plain = style == pdfcraft_edit::BlockStyle::default();
|
||||
// Only invisible text is sampled; the blocks are usually cached from opening it.
|
||||
if style.cover.is_none()
|
||||
&& let Some(info) = doc.info.pages.get(page)
|
||||
@@ -67,6 +68,12 @@ impl Session {
|
||||
{
|
||||
style.cover = Some(paper);
|
||||
style.color = style.color.or(Some(ink));
|
||||
// A text edit alone leaves the words it doesn't change as the scan shows them.
|
||||
style.changed_words_only = plain;
|
||||
// Nothing to redraw: leave undo history and the dirty flag alone.
|
||||
if plain && b.same_words(&text) {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Edit::EditTextBlock { page, block, text, style }
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ fn ocr_words_group_into_phrases_that_edit_in_place() {
|
||||
assert!((blocks[1].rect[0] - john[0]).abs() < 1.0, "the new word starts where the old one did: {:?} vs {john:?}", blocks[1].rect);
|
||||
}
|
||||
|
||||
/// Several words of one OCR phrase rewrite as one line, without wrapping onto the line below.
|
||||
/// 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![
|
||||
@@ -138,8 +138,8 @@ fn a_longer_ocr_phrase_grows_rightwards() {
|
||||
]);
|
||||
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[0].rows.len(), 1, "one line");
|
||||
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
|
||||
@@ -151,12 +151,15 @@ fn a_rewritten_ocr_phrase_keeps_its_word_widths() {
|
||||
scanned_word("Smith", 49.0, 78.0, 70.0, 11.0),
|
||||
scanned_word("Example", 82.0, 124.0, 70.0, 11.5),
|
||||
]);
|
||||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith Exam".into(), style: Default::default() }).unwrap();
|
||||
// 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<_>>(), ["John", "Smith", "Exam"]);
|
||||
assert!((lines[0].rect[2] - 45.0).abs() < 0.5, "John keeps its width: {:?}", lines[0].rect);
|
||||
for pair in lines.windows(2) {
|
||||
assert!(pair[1].rect[0] >= pair[0].rect[2] - 0.5, "{:?} runs into {:?}", pair[1].text, pair[0].text);
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,16 +182,15 @@ fn an_edited_ocr_phrase_is_drawn_at_one_size() {
|
||||
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);
|
||||
|
||||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Example".into(), style: Default::default() }).unwrap();
|
||||
// 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.trim()).collect::<Vec<_>>(), ["John", "Example"]);
|
||||
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);
|
||||
}
|
||||
// Each word keeps its width; the rest of the phrase closes up where Smith was.
|
||||
assert!((lines[0].rect[2] - lines[0].rect[0] - 25.0).abs() < 0.5, "John keeps its width: {:?}", lines[0].rect);
|
||||
assert!((lines[1].rect[2] - lines[1].rect[0] - 42.0).abs() < 0.5, "Example keeps its width: {:?}", lines[1].rect);
|
||||
assert!(lines[1].rect[0] >= lines[0].rect[2] - 0.5, "Example runs into John");
|
||||
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
|
||||
@@ -243,10 +245,12 @@ fn a_word_far_larger_than_its_phrase_keeps_its_width() {
|
||||
scanned_word("Smith", 49.0, 78.0, 70.0, 10.0),
|
||||
scanned_word("Example", 82.0, 124.0, 70.0, 10.5),
|
||||
]);
|
||||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith Exam".into(), style: Default::default() }).unwrap();
|
||||
// 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[0].text.trim(), "John");
|
||||
assert!((lines[0].rect[2] - lines[0].rect[0] - 25.0).abs() < 0.5, "John keeps its width: {:?}", lines[0].rect);
|
||||
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
|
||||
@@ -270,6 +274,295 @@ fn a_new_font_for_an_ocr_phrase_is_as_wide_whatever_its_first_word_was_sized() {
|
||||
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 went would
|
||||
/// move those after it, so keeping them isn't possible and the whole phrase is redrawn instead.
|
||||
#[test]
|
||||
fn words_that_depend_on_the_one_before_are_redrawn_rather_than_moved() {
|
||||
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"]);
|
||||
edit_block(&mut s, id, 0, "John Example");
|
||||
let (scan, drawn) = scan_and_drawn(&s, id);
|
||||
assert!(scan.is_empty(), "no word is left to shift under the scan: {scan:?}");
|
||||
assert_eq!(drawn, ["John Example"]);
|
||||
}
|
||||
|
||||
/// 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. An edit may not
|
||||
/// leave the word gone and the visible text somewhere else while calling the rest kept.
|
||||
#[test]
|
||||
fn visible_text_that_follows_an_ocr_word_stops_it_being_kept_alone() {
|
||||
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).0, ["John Smith"]);
|
||||
let before = label(&s).unwrap();
|
||||
edit_block(&mut s, id, 0, "John");
|
||||
let (scan, _) = scan_and_drawn(&s, id);
|
||||
assert!(scan.is_empty(), "John can't stay alone when Label would move with it: {scan:?}, Label at {:?} from {before}", label(&s));
|
||||
}
|
||||
|
||||
/// 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};
|
||||
|
||||
@@ -160,8 +160,14 @@ scanned line kept apart. Applying an edit draws the new text visibly in the scan
|
||||
the one chosen) and first covers the old words with the paper colour sampled around them. Each
|
||||
word keeps its position and width, and a longer phrase grows rightwards rather than wrapping.
|
||||
OCR sizes each word from its own box, so a phrase's words can come out several points apart: the
|
||||
editor and the rewrite set the whole phrase in one size, the median of its words' sizes. The
|
||||
cover is one flat colour, and the text uses the layer's Helvetica, not the scanned typeface.
|
||||
editor and the rewrite set the whole phrase in one size, the median of its words' sizes.
|
||||
A text edit covers and redraws only the words it changes: the rest of the line stays the scan, and
|
||||
a deleted word leaves its gap, because the words after it are pixels. A replacement a little too
|
||||
wide is squeezed across to fit its place (by up to a quarter); wider than that it takes over the
|
||||
word it would run into, and new words with nothing to replace take a neighbour.
|
||||
The whole phrase is redrawn for a change of formatting or a new line, and for a layer whose words
|
||||
would move without the ones before them. While the phrase is open, the editor still shows all of
|
||||
it in the layer's Helvetica. The cover is one flat colour, and the text uses the layer's Helvetica, not the scanned typeface.
|
||||
This applies to any invisible text (rendering mode 3), not just a PdfCraft OCR layer: editing
|
||||
invisible text, such as another OCR tool's layer, makes the rewritten paragraph visible over a
|
||||
painted cover in the sampled paper colour. Edit text also groups invisible text differently: its lines never
|
||||
|
||||
@@ -6767,8 +6767,8 @@ milestone = "M10"
|
||||
status = "partial"
|
||||
commands = ["edit.edit_text", "ocr.correct"]
|
||||
tools = ["text_edit", "ocr_recognize"]
|
||||
evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/engine/tests/it/ocr_edit.rs::an_edited_ocr_phrase_is_drawn_at_one_size", "crates/engine/tests/it/ocr_edit.rs::an_ocr_layer_under_the_scan_image_shows_its_edit", "crates/engine/tests/it/ocr_edit.rs::an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once", "crates/engine/src/ocr.rs::editable_writer_refuses_empty_and_writes_visible_words", "crates/engine/src/ocr.rs::editable_output_round_trips_non_win_ansi_words", "crates/engine/src/ocr.rs::editable_writer_maps_rotated_pages", "crates/automation/tests/it/automation.rs::ocr_recognize_editable_output_writes_visible_text"]
|
||||
notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; when an image or form is drawn after the OCR layer over where the edit lands (its old box, moved, rewrapped or grown to more lines), or an inline image or shading after it (a layer written under its scan, #3973), the cover and new text are drawn on top of the page instead, so they show; the phrase keeps each word's position and width and grows rightwards instead of wrapping, and is set in one size, the median of its words' (OCR sizes each word from its own box, several points apart), both while typing and when applied (#3313). Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), covering on a textured or patterned background (one flat colour is used), a scan painted by a pattern fill or a filled rectangle over a layer written under it, and, for text drawn on top, the clip, optional content and marked content (tags) that enclosed the OCR layer."
|
||||
evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/engine/tests/it/ocr_edit.rs::deleting_an_ocr_word_leaves_the_rest_of_the_line_as_the_scan", "crates/engine/tests/it/ocr_edit.rs::replacing_an_ocr_word_redraws_only_that_word", "crates/engine/tests/it/ocr_edit.rs::an_edited_ocr_phrase_is_drawn_at_one_size", "crates/engine/tests/it/ocr_edit.rs::an_ocr_layer_under_the_scan_image_shows_its_edit", "crates/engine/tests/it/ocr_edit.rs::an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once", "crates/engine/src/ocr.rs::editable_writer_refuses_empty_and_writes_visible_words", "crates/engine/src/ocr.rs::editable_output_round_trips_non_win_ansi_words", "crates/engine/src/ocr.rs::editable_writer_maps_rotated_pages", "crates/automation/tests/it/automation.rs::ocr_recognize_editable_output_writes_visible_text"]
|
||||
notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; a text edit covers and redraws only the words it changes, and the rest of the line stays the scan, with a deleted word leaving its gap (a replacement a little too wide is squeezed across to fit, wider than that it takes over the word it would run into, and a change of formatting redraws the whole phrase, #3313); when an image or form is drawn after the OCR layer over where the edit lands (its old box, moved, rewrapped or grown to more lines), or an inline image or shading after it (a layer written under its scan, #3973), the cover and new text are drawn on top of the page instead, so they show; the phrase keeps each word's position and width and grows rightwards instead of wrapping, and is set in one size, the median of its words' (OCR sizes each word from its own box, several points apart), both while typing and when applied (#3313). Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), covering on a textured or patterned background (one flat colour is used), a scan painted by a pattern fill or a filled rectangle over a layer written under it, and, for text drawn on top, the clip, optional content and marked content (tags) that enclosed the OCR layer."
|
||||
|
||||
[[feature]]
|
||||
id = "ocr.ocr-downsample"
|
||||
|
||||
Reference in new issue
Block a user