Files
pdfcraft/crates/edit/src/text/blocks.rs
T
KenM76andClaude Opus 5.5 e473629b9e Merge origin/main into fix/ocr-edit-under-scan
Conflict in crates/edit/src/text_regions/styled.rs: kept this branch's
`row_step` beside main's `WrappedStyledRows`, and main's marker-aware
`splice_with_marker` splice with the branch's `at` (already computed
before the reach check). main's `wrap` now yields (line, soft, gap), so
the reach's widest line in rewrite.rs reads the line from the tuple.

Gates: fmt, clippy -p pdfcraft-edit -p pdfcraft-engine --all-targets
-D warnings, tests edit 362 / engine 388 / 18, xtask parity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-10-30 16:20:01 -04:00

444 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::interp::{Carry, interpret};
use super::lines::{content_streams, page_dict, reading_lines, rewritten_contents, splice, text_lines};
use super::rewrite::{LineEdit, coincident_ops, rewrite_block};
use super::{Document, EditError, HashMap, Object, Op, Pieces, TextLine, group_blocks, regions};
/// An editable text region: adjacent runs on visual rows, followed by aligned rows.
/// A region may contain several source fonts, sizes, colours and weights.
#[derive(Clone, Debug, PartialEq)]
pub struct TextBlock {
/// The lines' text joined with spaces (lines that ended in a hyphen are joined directly).
pub text: String,
pub rect: [f64; 4],
pub base_font: String,
pub size: f64,
pub bold: bool,
pub italic: bool,
/// The text fill colour as RGB, used by the visual editor and preserved when no colour
/// override is requested.
pub color: [f64; 3],
/// Counterclockwise baseline angle in page user space, in degrees.
pub rotation: f64,
/// Indexes into [`text_lines`].
pub lines: Vec<usize>,
/// Visual rows; each contains indexes into [`text_lines`], including mixed-style runs.
pub rows: Vec<Vec<usize>>,
/// Text for the inline editor, with a newline only between visual rows.
pub editor_text: String,
/// What a width change reflows. A wrap rejoins with a space; a break that was typed, or a
/// short line that still had room for the next word, stays a newline.
pub reflow_text: String,
/// Byte ranges in `editor_text` and their source line indexes.
pub spans: Vec<(std::ops::Range<usize>, usize)>,
/// Every line is invisible text (rendering mode 3): an OCR layer over a scanned image, whose
/// pixels show the words. A rewrite draws the new text visibly, on top of the page when
/// the scan is drawn after it (see [`BlockStyle::cover`]).
pub invisible: bool,
}
impl TextBlock {
/// 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.
/// Byte ranges are always UTF-8 boundaries. Linear time, including large edits.
pub fn edited_spans(&self, draft: &str) -> Vec<(std::ops::Range<usize>, usize)> {
if self.editor_text.len() > 4_000_000 || draft.len() > 4_000_000 {
return vec![(0..draft.len(), self.lines.first().copied().unwrap_or(0))];
}
let old: Vec<_> = self.editor_text.chars().collect();
let new: Vec<_> = draft.char_indices().collect();
let chars: Vec<_> = new.iter().map(|(_, c)| *c).collect();
let mut original = Vec::with_capacity(old.len());
let mut span = 0;
for (byte, _) in self.editor_text.char_indices() {
while self.spans.get(span).is_some_and(|(r, _)| byte >= r.end) {
span += 1;
}
original.push(self.spans.get(span).or_else(|| self.spans.last()).map_or(0, |(_, i)| *i));
}
let mut sources = Vec::with_capacity(new.len());
let slices = |text: &[char]| words(text).into_iter().map(|w| text.get(w).unwrap_or_default().to_vec()).collect::<Vec<_>>();
let (old_words, new_words) = (slices(&old), slices(&chars));
match edit_script(&old_words, &new_words, MAX_CHANGED_WORDS) {
Some(script) => {
// `(o, n)`: where the current change starts in the old and new text; `(x, y)`: where it ends.
let ((mut o, mut n), (mut x, mut y)) = ((0, 0), (0, 0));
let (mut a, mut b) = (old_words.iter(), new_words.iter());
for step in script {
match step {
Step::Keep => {
replaced(&original, o, old.get(o..x).unwrap_or_default(), chars.get(n..y).unwrap_or_default(), &mut sources);
let len = a.next().map_or(0, Vec::len);
b.next();
sources.extend(original.get(x..x.saturating_add(len)).unwrap_or_default());
(x, y) = (x.saturating_add(len), y.saturating_add(len));
(o, n) = (x, y);
}
Step::Delete => x = x.saturating_add(a.next().map_or(0, Vec::len)),
Step::Insert => y = y.saturating_add(b.next().map_or(0, Vec::len)),
}
}
replaced(&original, o, old.get(o..x).unwrap_or_default(), chars.get(n..y).unwrap_or_default(), &mut sources);
}
None => replaced(&original, 0, &old, &chars, &mut sources),
}
let fallback = original.first().copied().unwrap_or(0);
let mut result: Vec<(std::ops::Range<usize>, usize)> = Vec::new();
for (i, (byte, ch)) in new.iter().enumerate() {
let source = sources.get(i).copied().unwrap_or(fallback);
let end = byte.saturating_add(ch.len_utf8());
if let Some((range, _)) = result.last_mut().filter(|(_, prev)| *prev == source) {
range.end = end;
} else {
result.push((*byte..end, source));
}
}
result
}
}
/// Changed words [`TextBlock::edited_spans`] aligns before it treats the draft as one change.
/// Myers' diff costs O((N + M) · D), so bounding D keeps it linear.
const MAX_CHANGED_WORDS: usize = 64;
/// `text` split into words, each with the whitespace after it, as char ranges.
fn words(text: &[char]) -> Vec<std::ops::Range<usize>> {
let mut out = Vec::new();
let mut start = 0;
for (i, pair) in text.windows(2).enumerate() {
if let [a, b] = pair
&& a.is_whitespace()
&& !b.is_whitespace()
{
out.push(start..i + 1);
start = i + 1;
}
}
if start < text.len() {
out.push(start..text.len());
}
out
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Step {
Keep,
Delete,
Insert,
}
/// Myers' shortest edit script from `a` to `b`, or `None` when it takes more than `max` steps.
fn edit_script<T: PartialEq>(a: &[T], b: &[T], max: usize) -> Option<Vec<Step>> {
let (n, m) = (a.len() as isize, b.len() as isize);
let max = max.min(a.len().saturating_add(b.len())) as isize;
// Furthest x reached on each diagonal k = x - y, stored at k + max + 1.
let at = |v: &[isize], k: isize| usize::try_from(k + max + 1).ok().and_then(|i| v.get(i).copied()).unwrap_or(0);
let mut v = vec![0isize; (2 * max + 3) as usize];
let mut trace = Vec::new();
for d in 0..=max {
trace.push(v.clone());
for k in (-d..=d).step_by(2) {
let down = k == -d || (k != d && at(&v, k - 1) < at(&v, k + 1));
let mut x = if down { at(&v, k + 1) } else { at(&v, k - 1) + 1 };
let mut y = x - k;
while x < n && y < m && a.get(x as usize) == b.get(y as usize) {
x += 1;
y += 1;
}
if let Some(slot) = usize::try_from(k + max + 1).ok().and_then(|i| v.get_mut(i)) {
*slot = x;
}
if x >= n && y >= m {
return Some(backtrack(&trace, n, m, max));
}
}
}
None
}
/// 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);
let mut steps = Vec::new();
let (mut x, mut y) = (n, m);
for (d, v) in trace.iter().enumerate().rev() {
let (d, k) = (d as isize, x - y);
let prev_k = if k == -d || (k != d && at(v, k - 1) < at(v, k + 1)) { k + 1 } else { k - 1 };
let prev_x = at(v, prev_k);
let prev_y = prev_x - prev_k;
while x > prev_x && y > prev_y {
steps.push(Step::Keep);
x -= 1;
y -= 1;
}
if d > 0 {
steps.push(if x == prev_x { Step::Insert } else { Step::Delete });
}
(x, y) = (prev_x, prev_y);
}
steps.reverse();
steps
}
/// `old`, at char `at` of the source, became `new`: characters kept at either end keep their
/// runs, and the rest inherit the run just before the change (or the first one it replaces).
fn replaced(original: &[usize], at: usize, old: &[char], new: &[char], out: &mut Vec<usize>) {
let prefix = old.iter().zip(new).take_while(|(a, b)| a == b).count();
let suffix = old.iter().rev().zip(new.iter().rev()).take(old.len().min(new.len()).saturating_sub(prefix)).take_while(|(a, b)| a == b).count();
let start = at.saturating_add(prefix);
let inherited = start.checked_sub(1).and_then(|i| original.get(i)).or_else(|| original.get(start)).copied().unwrap_or(0);
for i in 0..new.len() {
let source = if i < prefix {
original.get(at.saturating_add(i)).copied()
} else if i >= new.len().saturating_sub(suffix) {
original.get(at.saturating_add(old.len()).saturating_sub(new.len() - i)).copied()
} else {
Some(inherited)
};
out.push(source.unwrap_or(inherited));
}
}
/// How a listing of [`text_blocks`] records a paragraph so that it can be found again.
///
/// The index of a paragraph is its position in a list regrouped from geometry on every read, so
/// an edit that merges or splits paragraphs makes the same index name another one (#2516). A key
/// names the paragraph by what it says and where it is; a paragraph that no longer exists as
/// such has no match, instead of a neighbour taking its place.
#[derive(Clone, Debug, PartialEq)]
pub struct BlockKey {
text: String,
rect: [f64; 4],
}
impl BlockKey {
/// Two boxes are the same box within this many points.
const TOLERANCE: f64 = 0.01;
pub fn of(block: &TextBlock) -> Self {
Self { text: block.text.clone(), rect: block.rect }
}
/// The paragraph's box when it was recorded (user space).
pub fn rect(&self) -> [f64; 4] {
self.rect
}
/// The paragraph in `blocks` with this text and box. `hint` is tried first, so two
/// paragraphs drawn on top of one another are told apart by their order.
pub fn find(&self, blocks: &[TextBlock], hint: usize) -> Option<usize> {
let matches = |b: &TextBlock| b.text == self.text && b.rect.iter().zip(&self.rect).all(|(a, b)| (a - b).abs() <= Self::TOLERANCE);
match blocks.get(hint) {
Some(b) if matches(b) => Some(hint),
_ => blocks.iter().position(matches),
}
}
}
/// The paragraph in `blocks` whose box overlaps `rect` the most, if any overlaps it at all. An
/// edit rewrites a paragraph in place, so this is the paragraph it became.
pub fn block_overlapping(blocks: &[TextBlock], rect: [f64; 4]) -> Option<usize> {
// `f64::min` and `max` skip a NaN, which would make a box that isn't a number overlap anything.
let area = |b: &TextBlock| {
if !rect.iter().chain(&b.rect).all(|v| v.is_finite()) {
return 0.0;
}
let w = b.rect[2].min(rect[2]) - b.rect[0].max(rect[0]);
let h = b.rect[3].min(rect[3]) - b.rect[1].max(rect[1]);
if w > 0.0 && h > 0.0 { w * h } else { 0.0 }
};
blocks.iter().enumerate().map(|(i, b)| (i, area(b))).filter(|(_, a)| *a > 0.0).max_by(|(_, a), (_, b)| a.total_cmp(b)).map(|(i, _)| i)
}
/// The paragraphs on a page (0-based).
pub fn text_blocks(doc: &Document, page: usize) -> Result<Vec<TextBlock>, EditError> {
let lines = text_lines(doc, page)?;
Ok(group_blocks(&lines))
}
/// Remove a paragraph's glyphs for a temporary editor background. Numeric TJ adjustments
/// preserve the text matrix, so unselected text positioned relative to it stays in place.
/// Call this on a clone: it is a preview operation, not a document edit.
pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), EditError> {
let lines = text_lines(doc, page)?;
let blocks = group_blocks(&lines);
let target = blocks.get(block).ok_or_else(|| EditError::Invalid("the paragraph no longer exists".into()))?;
let targets: Vec<&TextLine> = target.lines.iter().filter_map(|i| lines.get(*i)).collect();
targets.iter().try_for_each(|line| line.ensure_editable())?;
let mut drops = coincident_ops(&lines, &targets);
drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied()));
let p = page_dict(doc, page)?;
let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let streams = content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, None)?;
let mut patches = HashMap::new();
for s in shown.into_iter().filter(|s| drops.contains(&s.op)) {
if s.state.render_mode >= 4 {
return Err(EditError::Invalid("text used as a clipping path cannot be previewed for editing".into()));
}
let denominator = s.state.size * s.state.scale;
let adjustment = if denominator != 0.0 { -1000.0 * s.advance / denominator } else { 0.0 };
if !adjustment.is_finite() || (denominator == 0.0 && s.advance != 0.0) {
return Err(EditError::Invalid("the paragraph's text advance cannot be previewed".into()));
}
let mut replacement = Vec::new();
if let Some(op) = ops.get(s.op) {
if op.is("\"") {
replacement.push(Op::new("Tw", vec![op.operands.first().cloned().unwrap_or(Object::Int(0))]));
replacement.push(Op::new("Tc", vec![op.operands.get(1).cloned().unwrap_or(Object::Int(0))]));
}
if op.is("'") || op.is("\"") {
replacement.push(Op::new("T*", vec![]));
}
}
replacement.push(Op::new("TJ", vec![Object::Array(vec![pdfcraft_content::num(adjustment)])]));
patches.insert(s.op, replacement);
}
let data = splice(&pieces, &ops, |i| patches.remove(&i).map_or((Vec::new(), true), |v| (v, false)));
let contents = rewritten_contents(doc, &streams, data);
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
Ok(())
}
/// The paragraphs on a page as a reader sees them, including text drawn by form XObjects
/// (Export to Word, HTML and RTF). Read-only: indexes into this list are not
/// [`text_blocks`] indexes, so they can't be passed to [`replace_block`] or [`rewrite_block`].
/// [`reading_blocks_checked`] also says whether text was left out.
pub fn reading_blocks(doc: &Document, page: usize) -> Result<Vec<TextBlock>, EditError> {
reading_blocks_checked(doc, page).map(|r| r.blocks)
}
/// A page's paragraphs as a reader sees them ([`reading_blocks_checked`]).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ReadingBlocks {
/// The paragraphs, as [`reading_blocks`] returns them.
pub blocks: Vec<TextBlock>,
/// The page draws forms nested deeper than [`MAX_FORM_DEPTH`], more of them than
/// [`MAX_FORM_VISITS`], or forms that run past what the page's content left of its decode
/// budget. Reading leaves those forms out to stay bounded on hostile files, so their text
/// is missing from `blocks`; [`crate::reading_images`] walks the same forms and misses
/// their images too.
pub truncated: bool,
}
/// [`reading_blocks`], saying whether the limits that bound reading left text out. A page
/// whose `/Contents` alone runs past its decode budget is an [`EditError::Invalid`], not a
/// truncated result.
pub fn reading_blocks_checked(doc: &Document, page: usize) -> Result<ReadingBlocks, EditError> {
let (lines, truncated) = reading_lines(doc, page)?;
Ok(ReadingBlocks { blocks: regions::group_reading_blocks(&lines), truncated })
}
// An ideographic space carries intentional Japanese spacing, including paragraph indentation.
// Keep other whitespace normalization unchanged.
pub(crate) fn paragraph_separator(c: char) -> bool {
c.is_whitespace() && c != '\u{3000}'
}
/// Greedy word wrapping to `width` with `advance` giving a string's width. Each `\n` starts a
/// new line. A row may also end inside a word of Chinese or Japanese text ([`crate::linebreak`]);
/// those pieces join without a space. Every white-space character is written as one space, so
/// a run of spaces typed between, before or after words stays (#4199); only the spaces where a
/// row wraps are dropped (recorded as the third value, so a later rejoin can restore the exact
/// count instead of guessing one, #2529). The bool is true when the line continues the previous
/// one because the next word did not fit, rather than because of a newline; the number is how
/// many separator characters stood where that wrap fell (0 for a Chinese/Japanese word split,
/// which has no space of its own; meaningless when the bool is false).
pub(super) fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<(String, bool, usize)> {
let mut out = Vec::new();
for row in text.split('\n') {
let mut line = String::new();
let mut soft = false;
// The gap consumed right before `line` started: 0 until a wrap sets it.
let mut wrap_gap = 0usize;
let start = out.len();
// The separators read since the last word: each one boundary between `split` pieces.
let mut gap = 0usize;
for (k, word) in row.split(paragraph_separator).enumerate() {
if k > 0 {
gap = gap.saturating_add(1);
}
if word.is_empty() {
continue;
}
let gap_here = std::mem::take(&mut gap);
let spaces = " ".repeat(gap_here);
for (i, piece) in crate::linebreak::pieces(word).into_iter().enumerate() {
let candidate = if i == 0 { format!("{line}{spaces}{piece}") } else { format!("{line}{piece}") };
// A row holding only the spaces that indent it doesn't wrap before its first word.
if line.chars().any(|c| c != ' ') && advance(&candidate) > width {
out.push((std::mem::take(&mut line), soft, wrap_gap));
soft = true;
// A mid-word piece (i > 0) had no separator of its own; the first piece of a
// word had exactly the separators just taken.
wrap_gap = if i == 0 { gap_here } else { 0 };
line = piece.to_string();
} else {
line = candidate;
}
}
}
line.push_str(&" ".repeat(gap));
if !line.is_empty() || out.len() == start {
out.push((line, soft, wrap_gap));
}
}
out
}
/// Replace paragraph `block` (an index into [`text_blocks`]) with `text`, rewrapped to the
/// paragraph's width with its line spacing. The paragraph keeps its first line's position, font
/// (or Helvetica when the font can't show the text), size and colour.
pub fn replace_block(doc: &mut Document, page: usize, block: usize, text: &str) -> Result<LineEdit, EditError> {
rewrite_block(doc, page, block, Some(text), &BlockStyle::default())
}
/// Formatting for a rewritten paragraph (`None` keeps the paragraph's own).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct BlockStyle {
/// Disjoint UTF-8 ranges in the replacement, each preserving unspecified source style.
pub ranges: Vec<RangeStyle>,
/// Explicit page font resource (from register_text_font or the page font inventory).
pub resource: Option<String>,
/// A standard font family, bold, italic.
pub family: Option<(crate::added::Family, bool, bool)>,
/// Override weight without choosing a different family (`None` keeps the source weight).
pub bold: Option<bool>,
/// Font size in points (user space).
pub size: Option<f64>,
/// Fill colour (RGB 0–1).
pub color: Option<[f64; 3]>,
pub align: Option<crate::added::Align>,
/// Rows (the text's lines between `\n`s) aligned other than the paragraph, by index; `None`
/// and rows past the end follow `align` ([`crate::paragraph_align`]).
pub aligns: Vec<Option<crate::added::Align>>,
/// Draw a line under each line of text.
pub underline: Option<bool>,
/// Line spacing as a multiple of the font size (1.2 is ordinary).
pub line_spacing: Option<f64>,
/// Character spacing (points) and horizontal scaling (percent).
pub char_spacing: Option<f64>,
pub scale: Option<f64>,
/// Move the paragraph by `[dx, dy]` in user space (dragging its box).
pub offset: Option<[f64; 2]>,
/// Rewrap to this width in user space (dragging the box's edge).
pub width: Option<f64>,
/// 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]>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RangeStyle {
pub range: std::ops::Range<usize>,
pub family: Option<(crate::added::Family, bool, bool)>,
pub resource: Option<String>,
pub size: Option<f64>,
pub color: Option<[f64; 3]>,
pub underline: Option<bool>,
pub char_spacing: Option<f64>,
pub scale: Option<f64>,
}