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

347 lines
16 KiB
Rust

//! Find and replace in the page text (Edit text ▸ Replace, the `text_replace` tool).
//!
//! Each match is rewritten inside its own paragraph ([`crate::Document::text_blocks`]) through
//! [`Edit::EditTextBlock`], so the paragraph keeps its fonts, sizes and colours and rewraps in its
//! box. The paragraph's `editor_text` is matched with the find bar's own rules
//! ([`pdfcraft_render::text::find_in_text`]) and the replacement takes the style of the run it
//! lands in ([`pdfcraft_edit::TextBlock::replaced`]); every other run keeps its own.
//!
//! A longer replacement changes the paragraph only, measured with its own fonts' widths, and
//! nothing is shrunk or hyphenated to fit. A paragraph of one row grows to the right, up to 36 pt
//! from the crop box (a centred line stays in its own box), then wraps; a paragraph of several rows
//! keeps its width and rewraps at spaces. New rows go below the last one at the paragraph's line
//! spacing and nothing else on the page moves, so they can run over what is below, or off the page,
//! without being reported. A word wider than the width stays whole, past the box.
//!
//! A match is left alone, and counted, when it runs from one paragraph into the next, lies in
//! invisible text over a scan, would need another font (the paragraph's own can't draw the new
//! text, see [`pdfcraft_edit::LineEdit::substituted`]) or is not page text the paragraphs hold
//! (a form field, a comment, text drawn inside a form XObject). Each paragraph is rewritten on a
//! copy first, so the edit returned is exactly what will apply: [`Edit::Batch`] for Replace all,
//! one undo step.
use std::collections::BTreeMap;
use std::ops::Range;
use crate::edit::check_permission;
use crate::{Document, Edit, EditError, guard};
/// What to look for and what to put in its place, with the find bar's options.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Replace {
pub find: String,
/// May be empty: the match is deleted.
pub with: String,
pub case_sensitive: bool,
pub whole_words: bool,
}
/// Which matches to replace.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Which {
/// Every match on the pages.
All,
/// The match the find bar highlights on `page`: the centres of its first and last glyphs, in
/// view space (points from the top-left of the displayed page, y down: the text layer's).
At { page: usize, start: [f32; 2], end: [f32; 2] },
}
impl Which {
/// The first match the find bar shows (page text order: pages in order, then reading order),
/// as an [`Which::At`] on its first and last glyphs, or `None` when no page has one. `texts`
/// are the pages' text, in page order. Replacing "the first match" is replacing this one: if
/// no paragraph holds it, [`Document::plan_replace`] reports it as not editable and replaces
/// nothing, rather than passing it for a later match that is.
pub fn first_in<'a>(texts: impl IntoIterator<Item = (usize, &'a pdfcraft_render::PageText)>, query: &Replace) -> Option<Which> {
texts.into_iter().find_map(|(page, text)| {
let found = text.find_opts_limited(&query.find, query.case_sensitive, query.whole_words, 1).into_iter().next()?;
let centre = |g: usize| text.glyphs.get(g).map(|g| [(g.rect[0] + g.rect[2]) / 2.0, (g.rect[1] + g.rect[3]) / 2.0]);
Some(Which::At { page, start: centre(found.start)?, end: centre(found.end.saturating_sub(1))? })
})
}
}
/// Why a match was left as it is.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Skip {
/// It runs from one paragraph into the next.
SpansParagraphs,
/// It is invisible text over a scanned image (recognized text).
ScannedText,
/// The paragraph's font can't draw the new text; another font would have stepped in.
MissingGlyphs,
/// The paragraph couldn't be rewritten (see [`Replacement::refusals`]).
Refused,
/// It is not in the page's editable text: a form field, a comment, text inside a form XObject.
NotEditable,
}
impl Skip {
/// How the reason is said after a count ("2 skipped: …"); an interface translates it.
pub fn wording(self) -> &'static str {
match self {
Skip::SpansParagraphs => "spans two paragraphs",
Skip::ScannedText => "scanned text",
Skip::MissingGlyphs => "the font can't draw it",
Skip::Refused => "the paragraph can't be rewritten",
Skip::NotEditable => "not editable text",
}
}
/// A short name for tools.
pub fn key(self) -> &'static str {
match self {
Skip::SpansParagraphs => "spans_paragraphs",
Skip::ScannedText => "scanned_text",
Skip::MissingGlyphs => "missing_glyphs",
Skip::Refused => "refused",
Skip::NotEditable => "not_editable",
}
}
}
/// Every [`Skip::wording`], for an interface to translate.
pub const SKIP_WORDINGS: &[&str] =
&["spans two paragraphs", "scanned text", "the font can't draw it", "the paragraph can't be rewritten", "not editable text"];
/// What a replacement would do: the edit (apply it with [`crate::Session::apply`]) and the counts.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Replacement {
/// `None` when nothing can be replaced.
pub edit: Option<Edit>,
/// Matches replaced.
pub replaced: usize,
/// The pages (0-based, ascending) where something was replaced.
pub pages: Vec<usize>,
/// Matches left alone, by reason.
pub skipped: BTreeMap<Skip, usize>,
/// What the engine said about the paragraphs it refused (at most [`MAX_REFUSALS`]).
pub refusals: Vec<String>,
}
/// Refusal messages kept in a [`Replacement`].
pub const MAX_REFUSALS: usize = 10;
impl Replacement {
pub fn skipped_total(&self) -> usize {
self.skipped.values().fold(0, |a, b| a.saturating_add(*b))
}
/// Say that `page`'s text could not be read for the find bar (`why`): its matches can't be
/// counted, so one is counted as [`Skip::Refused`] and the page is named in the refusals.
pub fn unreadable_page(&mut self, page: usize, why: &str) {
refused(self, 1, page, format!("its text could not be read: {why}"));
}
fn skip(&mut self, why: Skip, n: usize) {
if n > 0 {
let count = self.skipped.entry(why).or_insert(0);
*count = count.saturating_add(n);
}
}
}
/// The label of Replace all in the history (one of [`crate::BATCH_LABELS`]).
pub const REPLACE_ALL: &str = "Replace all";
impl Document {
/// The edit that replaces `query`'s matches on `pages` (0-based; others are ignored), and
/// what it replaces and leaves. `found` is how many matches the find bar has on each page:
/// those no paragraph holds are counted as [`Skip::NotEditable`] (`None`: not counted). The
/// document is not changed; the same permission check as Edit text applies.
pub fn plan_replace(
&self,
pages: &[usize],
query: &Replace,
which: Which,
found: Option<&BTreeMap<usize, usize>>,
) -> Result<Replacement, EditError> {
let editor = self.editor.as_ref().ok_or_else(|| EditError::ReadOnly(self.read_only_reason.clone().unwrap_or_default()))?;
// What Edit text checks: the document's permissions.
let probe = Edit::EditTextBlock { page: 0, block: 0, text: String::new(), style: Default::default() };
if let Some(p) = editor.cos.permissions() {
check_permission(&probe, &p)?;
}
if query.find.split_whitespace().next().is_none() {
return Err(EditError::Invalid("there is nothing to find".into()));
}
let n = self.info.pages.len();
let mut pages: Vec<usize> = pages.iter().copied().filter(|p| *p < n).collect();
pages.sort_unstable();
pages.dedup();
if let Which::At { page, .. } = which {
pages.retain(|p| *p == page);
}
let mut out = Replacement::default();
let mut work = editor.cos.clone();
let mut edits = Vec::new();
for page in pages {
// A page whose text can't be read is reported, and the others still replaced.
let blocks = match pdfcraft_edit::text_blocks(&work, page) {
Ok(blocks) => blocks,
Err(e) => {
refused(&mut out, found.and_then(|f| f.get(&page)).copied().unwrap_or(0), page, e.to_string());
continue;
}
};
let matches: Vec<Vec<Range<usize>>> = blocks.iter().map(|b| find(&b.editor_text, query, usize::MAX)).collect();
// Paragraph `k`'s matches to replace now.
let mut chosen: BTreeMap<usize, Vec<Range<usize>>> = BTreeMap::new();
match which {
Which::All => {
let held = matches.iter().fold(0usize, |a, m| a.saturating_add(m.len()));
let spanning = spanning(&blocks, query);
out.skip(Skip::SpansParagraphs, spanning);
if let Some(f) = found.and_then(|f| f.get(&page)) {
out.skip(Skip::NotEditable, f.saturating_sub(held.saturating_add(spanning)));
}
chosen.extend(matches.into_iter().enumerate().filter(|(_, m)| !m.is_empty()));
}
Which::At { start, end, .. } => match at(self, &work, page, &blocks, &matches, start, end) {
Ok((k, m)) => {
chosen.insert(k, vec![m]);
}
Err(why) => out.skip(why, 1),
},
}
// Last paragraph first, which usually leaves those before it where they were. That is
// checked, not assumed: once a paragraph on this page is rewritten, the page is read
// again and a paragraph that is no longer what the match was found in is refused.
let mut rewrote = false;
for (k, ranges) in chosen.into_iter().rev() {
let Some(block) = blocks.get(k) else { continue };
let count = ranges.len();
if rewrote {
let same = pdfcraft_edit::text_blocks(&work, page)
.ok()
.is_some_and(|now| now.get(k).is_some_and(|b| b.editor_text == block.editor_text));
if !same {
refused(&mut out, count, page, "the page's paragraphs moved after an earlier replacement".into());
continue;
}
}
if block.invisible {
out.skip(Skip::ScannedText, count);
continue;
}
let Some((text, sources)) = block.replaced(&ranges, &query.with) else {
out.skip(Skip::Refused, count);
continue;
};
if text == block.editor_text {
// The replacement is what was there.
out.replaced = out.replaced.saturating_add(count);
continue;
}
let style = pdfcraft_edit::BlockStyle { sources, ..Default::default() };
let mut trial = work.clone();
match guard(|| pdfcraft_edit::rewrite_block(&mut trial, page, k, Some(&text), &style)) {
Ok(Ok(pdfcraft_edit::LineEdit { substituted: None })) => {
work = trial;
rewrote = true;
edits.push(Edit::EditTextBlock { page, block: k, text, style });
out.replaced = out.replaced.saturating_add(count);
if !out.pages.contains(&page) {
out.pages.push(page);
}
}
Ok(Ok(pdfcraft_edit::LineEdit { substituted: Some(_) })) => out.skip(Skip::MissingGlyphs, count),
Ok(Err(e)) => refused(&mut out, count, page, e.to_string()),
Err(panic) => refused(&mut out, count, page, panic),
}
}
}
out.pages.sort_unstable();
out.edit = match (which, edits.len()) {
(_, 0) => None,
(Which::All, _) => Some(Edit::Batch { label: REPLACE_ALL.into(), edits }),
(Which::At { .. }, _) => edits.pop(),
};
Ok(out)
}
}
fn find(text: &str, query: &Replace, limit: usize) -> Vec<Range<usize>> {
pdfcraft_render::text::find_in_text(text, &query.find, query.case_sensitive, query.whole_words, limit)
}
fn refused(out: &mut Replacement, count: usize, page: usize, why: String) {
out.skip(Skip::Refused, count);
if out.refusals.len() < MAX_REFUSALS {
out.refusals.push(format!("page {}: {why}", page.saturating_add(1)));
}
}
/// Matches that start in one paragraph and end in a later one: the page's paragraphs read one
/// after another (a line break between two), as the find bar reads the page.
fn spanning(blocks: &[pdfcraft_edit::TextBlock], query: &Replace) -> usize {
let mut joined = String::new();
let mut ends = Vec::with_capacity(blocks.len());
for b in blocks {
if !joined.is_empty() {
joined.push('\n');
}
joined.push_str(&b.editor_text);
ends.push(joined.len());
}
// Which paragraph a byte of `joined` is in (a separator counts with the paragraph after it).
let paragraph = |byte: usize| ends.partition_point(|end| *end <= byte);
find(&joined, query, usize::MAX).into_iter().filter(|r| paragraph(r.start) != paragraph(r.end.saturating_sub(1))).count()
}
/// The paragraph match the find bar's match at `start` … `end` (view space) is: the paragraph
/// whose box holds both, and its match nearest `start`.
fn at(
doc: &Document,
cos: &pdfcraft_cos::Document,
page: usize,
blocks: &[pdfcraft_edit::TextBlock],
matches: &[Vec<Range<usize>>],
start: [f32; 2],
end: [f32; 2],
) -> Result<(usize, Range<usize>), Skip> {
let info = doc.info.pages.get(page).ok_or(Skip::NotEditable)?;
let view = |x: f64, y: f64| info.user_to_view(x as f32, y as f32);
// A paragraph's box in view space, a little larger so a glyph's centre on its edge is inside.
let holds = |b: &pdfcraft_edit::TextBlock, p: [f32; 2]| {
let corners = [view(b.rect[0], b.rect[1]), view(b.rect[2], b.rect[3]), view(b.rect[0], b.rect[3]), view(b.rect[2], b.rect[1])];
let pad = (b.size as f32 * 0.3).clamp(1.0, 12.0);
let (x0, x1) = corners.iter().fold((f32::MAX, f32::MIN), |(a, b), c| (a.min(c[0]), b.max(c[0])));
let (y0, y1) = corners.iter().fold((f32::MAX, f32::MIN), |(a, b), c| (a.min(c[1]), b.max(c[1])));
p[0] >= x0 - pad && p[0] <= x1 + pad && p[1] >= y0 - pad && p[1] <= y1 + pad
};
let lines = pdfcraft_edit::text_lines(cos, page).unwrap_or_default();
// Where a match's first character is drawn, estimated along its run.
let place = |b: &pdfcraft_edit::TextBlock, r: &Range<usize>| -> Option<[f32; 2]> {
let (span, line) = b.spans.iter().find(|(s, _)| s.contains(&r.start)).or_else(|| b.spans.iter().find(|(s, _)| s.start >= r.start))?;
let line = lines.get(*line)?;
let run = b.editor_text.get(span.clone())?;
let before = b.editor_text.get(span.start..r.start.max(span.start))?.chars().count() as f64;
let total = run.chars().count().max(1) as f64;
let along = ((before + 0.5) / total).clamp(0.0, 1.0);
let x = line.rect[0] + along * (line.rect[2] - line.rect[0]);
Some(view(x, (line.rect[1] + line.rect[3]) / 2.0))
};
let distance = |p: [f32; 2]| (p[0] - start[0]).hypot(p[1] - start[1]);
let mut best: Option<(f32, usize, Range<usize>)> = None;
for (k, b) in blocks.iter().enumerate().filter(|(_, b)| holds(b, start)) {
for r in matches.get(k).into_iter().flatten() {
let d = place(b, r).map_or(f32::MAX, distance);
if best.as_ref().is_none_or(|(e, ..)| d < *e) {
best = Some((d, k, r.clone()));
}
}
}
match best {
Some((_, k, r)) if blocks.get(k).is_some_and(|b| holds(b, end)) => Ok((k, r)),
Some(_) => Err(Skip::SpansParagraphs),
None if blocks.iter().any(|b| holds(b, start)) && blocks.iter().any(|b| holds(b, end) && !holds(b, start)) => Err(Skip::SpansParagraphs),
None => Err(Skip::NotEditable),
}
}
#[cfg(test)]
mod rewrite_order_tests;
#[cfg(test)]
mod tests;