//! 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, query: &Replace) -> Option { 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, /// Matches replaced. pub replaced: usize, /// The pages (0-based, ascending) where something was replaced. pub pages: Vec, /// Matches left alone, by reason. pub skipped: BTreeMap, /// What the engine said about the paragraphs it refused (at most [`MAX_REFUSALS`]). pub refusals: Vec, } /// 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>, ) -> Result { 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 = 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>> = blocks.iter().map(|b| find(&b.editor_text, query, usize::MAX)).collect(); // Paragraph `k`'s matches to replace now. let mut chosen: BTreeMap>> = 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> { 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>], start: [f32; 2], end: [f32; 2], ) -> Result<(usize, Range), 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| -> 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)> = 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;