From 59ba781e99e2e437ae42f9744b556c41c5b5cc59 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:31:43 +0000 Subject: [PATCH] refactor(edit): split text_regions.rs into text_regions/ New modules: styled (styled-block shaping and rewrite), grouping (row/block grouping). --- crates/edit/src/text_regions.rs | 1107 +--------------------- crates/edit/src/text_regions/grouping.rs | 319 +++++++ crates/edit/src/text_regions/styled.rs | 787 +++++++++++++++ 3 files changed, 1112 insertions(+), 1101 deletions(-) create mode 100644 crates/edit/src/text_regions/grouping.rs create mode 100644 crates/edit/src/text_regions/styled.rs diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index d4ebe4c44..7516cb793 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -2,1105 +2,10 @@ use super::*; -// The draft is capped at 4 MiB; also bound the outline data prepared across all fallback faces. -// A glyph budget worth 64 full batches (15,360 glyphs with the production batch size). -// Lowering the internal batch size for tests lowers the budget by the same ratio. -const MAX_STYLED_FALLBACK_BATCHES: usize = 64; +#[path = "text_regions/grouping.rs"] +mod grouping; +#[path = "text_regions/styled.rs"] +mod styled; -struct FallbackGroup { - face: &'static CraftFont, - seen: std::collections::HashSet, - glyphs: Vec, - positions: Vec, -} - -fn same_plane(a: &TextLine, b: &TextLine) -> bool { - let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm)); - let bt = Matrix(b.origin.tm).then(&Matrix(b.origin.ctm)); - a.scope == b.scope - && a.paint == b.paint - && [0, 2].iter().all(|axis| { - let ax = at.0.get(*axis).copied().unwrap_or(0.0); - let ay = at.0.get(axis + 1).copied().unwrap_or(0.0); - let bx = bt.0.get(*axis).copied().unwrap_or(0.0); - let by = bt.0.get(axis + 1).copied().unwrap_or(0.0); - let al = ax.hypot(ay); - let bl = bx.hypot(by); - al > 0.0 && bl > 0.0 && (ax / al - bx / bl).abs() < 0.001 && (ay / al - by / bl).abs() < 0.001 - }) -} - -fn row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> { - row.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).reduce(union) -} - -/// [`row_rect`] in its text's own frame ([`TextLine::frame_rect`]), so rows of turned text -/// compare along and across their baselines. -fn frame_row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> { - row.iter().filter_map(|i| lines.get(*i)).map(TextLine::frame_rect).reduce(union) -} - -/// The left edge the rest of the page's text sits on, ignoring the rows of `block`. A single row -/// that does not share it is not merely an indented or full-width left-aligned line, so its own -/// centre is the only remaining explanation for where it sits. -fn neighbour_left(lines: &[TextLine], block: &TextBlock) -> Option { - let mut edges: HashMap = HashMap::new(); - for (i, line) in lines.iter().enumerate() { - if block.lines.contains(&i) { - continue; - } - // Quarter-point buckets: equal edges found through different runs still agree. - *edges.entry((line.rect[0] * 4.0).round() as i64).or_insert(0) += 1; - } - let (best, _) = edges.into_iter().max_by_key(|(_, count)| *count)?; - Some(best as f64 / 4.0) -} - -/// Whether a block reads as centred, and so must keep its centre when it grows or wraps. -/// -/// Several ragged rows whose left edges disagree while their centres agree are centred. A one row -/// block cannot say that: all of its left edges are the same, so the ragged test never fires and a -/// centred title would be taken for left-aligned and grow to the right (#2581). Fall back to the -/// page instead — a lone row sitting on the page's own centre, and not on the edge the rest of the -/// text uses, is a centred title. A full-width left-aligned line still lines up with that edge, so -/// it is not mistaken for one. -pub(super) fn is_centred(block: &TextBlock, lines: &[TextLine], page_centre: f64, size: f64) -> bool { - let centre = (block.rect[0] + block.rect[2]) / 2.0; - let lefts: Vec<_> = block.rows.iter().filter_map(|r| row_rect(r, lines).map(|r| r[0])).collect(); - if block.rows.len() > 1 { - let left_span = lefts.iter().copied().fold(f64::NEG_INFINITY, f64::max) - lefts.iter().copied().fold(f64::INFINITY, f64::min); - return left_span > size * 0.5 - && block.rows.iter().all(|row| row_rect(row, lines).is_some_and(|r| ((r[0] + r[2]) / 2.0 - centre).abs() < size)); - } - lefts - .first() - .is_some_and(|left| (centre - page_centre).abs() < size && neighbour_left(lines, block).is_none_or(|edge| (*left - edge).abs() > size * 0.5)) -} - -pub(super) fn group_blocks(lines: &[TextLine]) -> Vec { - group_layout(lines, true) -} - -/// Exporters currently represent a block with one style. Keep their style boundaries until -/// they support rich runs, while edit selection may encompass the complete visual region. -/// Dotted-leader entries stay on one row and use the title run's style. -pub(super) fn group_reading_blocks(lines: &[TextLine]) -> Vec { - let mut blocks = Vec::new(); - let mut start = 0; - let mut offset = 0; - for row in lines.chunk_by(|a, b| same_plane(a, b) && (a.origin.baseline - b.origin.baseline).abs() < a.size.min(b.size) * 0.2) { - let mut text = String::new(); - for (i, line) in row.iter().enumerate() { - if !text.ends_with(char::is_whitespace) - && !line.text.starts_with(char::is_whitespace) - && i.checked_sub(1).and_then(|i| row.get(i)).is_some_and(|prev| line.rect[0] - prev.rect[2] > line.size * 0.12) - { - text.push(' '); - } - text.push_str(&line.text); - } - if leader_entry(&text) - && let Some(first) = row.first() - { - // Tab leaders are a single reading row, even when the title, dots and page - // number are separate text objects far apart. Do not merge neighbouring entries. - blocks.extend(group_layout(lines.get(start..offset).unwrap_or_default(), false)); - let mut line = first.clone(); - line.text = text; - line.rect = row.iter().map(|line| line.rect).reduce(union).unwrap_or(first.rect); - blocks.extend(group_layout(std::slice::from_ref(&line), false)); - start = offset + row.len(); - } - offset += row.len(); - } - blocks.extend(group_layout(lines.get(start..).unwrap_or_default(), false)); - blocks -} - -/// Dotted leaders end in a page number, with a title before the dots. A dot in an article -/// number, or an ellipsis in ordinary prose, is not enough to make a contents entry. -fn leader_entry(text: &str) -> bool { - let text = text.trim_end(); - let prefix = text.trim_end_matches(|c: char| { - c.is_ascii_digit() || matches!(c, 'i' | 'v' | 'x' | 'l' | 'c' | 'd' | 'm' | 'I' | 'V' | 'X' | 'L' | 'C' | 'D' | 'M') - }); - if prefix.len() == text.len() { - return false; - } - let title = prefix.trim_end_matches(|c: char| c.is_whitespace() || matches!(c, '.' | '…')); - let dots = prefix - .get(title.len()..) - .unwrap_or_default() - .chars() - .map(|c| match c { - '.' => 1, - '…' => 3, - _ => 0, - }) - .sum::(); - dots >= 3 && title.chars().any(char::is_alphabetic) -} - -/// Text-space units → user space along the baseline. It is the vertical scale ([`Origin::k`]) -/// unless the text matrix stretches the glyphs, as an OCR layer fitting each word to the scan does. -fn across(line: &TextLine) -> f64 { - let m = Matrix(line.origin.tm).then(&Matrix(line.origin.ctm)).0; - let h = m[0].hypot(m[1]); - if h.is_finite() && h > 1e-9 { h } else { line.origin.k } -} - -/// Invisible text (rendering mode 3), as in an OCR layer over a scanned image. -fn invisible(line: &TextLine) -> bool { - line.origin.state.render_mode == 3 -} - -fn same_style(a: &TextLine, b: &TextLine) -> bool { - same_font(a, b.font.as_bytes(), &b.base_font, b.face.as_deref()) - // With no `/BaseFont` (or only a subset tag) there is no name to compare: the resource or - // fallback face `same_font` matched is the whole identity, so keep the lines together. - && (same_base_font(&a.base_font, &b.base_font) - || (strip_subset_tag(&a.base_font).is_empty() && strip_subset_tag(&b.base_font).is_empty())) - && a.bold == b.bold - && a.italic == b.italic - && a.color == b.color - && (a.rotation() - b.rotation()).abs() < 0.01 - && (a.size - b.size).abs() < 0.01 -} - -/// Does a row open with a list marker? A bullet or a dash, or an ordered marker — digits (or a -/// single letter) followed by `.` or `)` and then a space or the row's end, so prose such as -/// `1.5` or `Dr. Smith` is left alone. -fn opens_with_marker(text: &str) -> bool { - let Some(first) = text.chars().next() else { return false }; - if matches!(first, '•' | '◦' | '▪' | '‣' | '⁃' | '*' | '-' | '–' | '—') { - return true; - } - if !first.is_ascii_digit() && !first.is_ascii_alphabetic() { - return false; - } - let digits = text.chars().take_while(char::is_ascii_digit).count(); - let letters = text.chars().take_while(char::is_ascii_alphabetic).count(); - // Both runs are ASCII characters, so their length in characters is their length in bytes: - // the slice below starts on a character boundary. - let rest = if digits > 0 { - &text[digits..] - } else if letters == 1 { - &text[1..] - } else { - return false; - }; - let mut after = rest.chars(); - matches!(after.next(), Some('.' | ')')) && after.next().is_none_or(char::is_whitespace) -} - -/// A row that opens with a list marker opens its own item: joining it to the row above is what -/// let an edit of one paragraph delete every item after it (#2564). The other error, splitting -/// an ordinary paragraph, costs one more paragraph to click and never any text — so err this way. -fn starts_list_item(row: &[usize], lines: &[TextLine]) -> bool { - row.iter().filter_map(|i| lines.get(*i)).map(|l| l.text.trim_start()).find(|t| !t.is_empty()).is_some_and(opens_with_marker) -} - -fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { - let mut rows: Vec> = Vec::new(); - for (i, line) in lines.iter().enumerate() { - let joins = rows.last().and_then(|r| r.last()).and_then(|i| lines.get(*i)).is_some_and(|prev| { - let gap = line.frame_rect()[0] - prev.frame_rect()[2]; - // OCR words (invisible text over a scan) join only across a word space: a wider gap - // is a table cell or a tab stop, which a rewrite would close up. - let scanned = !mixed || (invisible(prev) == invisible(line) && (!invisible(line) || gap < prev.size.max(line.size) * 0.6)); - scanned - && (mixed || same_style(prev, line)) - && same_plane(prev, line) - // A superscript or subscript run (a footnote marker, say) sits on the same visual - // row as the text around it, raised or lowered by `Ts` from the body baseline - // (#3252). That shift is typically up to half the body size, not the marker's own - // smaller size — but the wider tolerance only applies between a run and a - // distinctly smaller or larger one, with no horizontal overlap (`gap >= 0.0`): - // two equal-size runs can land within it too, at a baseline shift tight columns - // or compressed text (`Tz`) can produce between genuinely separate rows, and the - // original 20% tolerance is what keeps those apart. - && ((prev.frame_origin().1 - line.frame_origin().1).abs() < prev.size.min(line.size) * 0.2 - || (gap >= 0.0 - && prev.size.min(line.size) < prev.size.max(line.size) * 0.9 - && (prev.frame_origin().1 - line.frame_origin().1).abs() < prev.size.max(line.size) * 0.5)) - && gap >= -prev.size.min(line.size) * 0.2 - && gap < prev.size.max(line.size) * 1.5 - // Separate same-style text objects that merely touch or overlap, with no space - // between them, are separate labels (for example a paragraph dragged against - // another, #594). Style runs and words separated by a space or gap still join. - && (prev.origin.bt_op == line.origin.bt_op - || gap > prev.size.min(line.size) * 0.01 - || !same_style(prev, line) - || prev.text.ends_with(char::is_whitespace) - || line.text.starts_with(char::is_whitespace)) - }); - if joins && let Some(row) = rows.last_mut() { - row.push(i); - } else { - rows.push(vec![i]); - } - } - let mut blocks: Vec = Vec::new(); - for row in rows { - let Some(first) = row.first().and_then(|i| lines.get(*i)) else { continue }; - let (Some(rect), Some(frame)) = (row_rect(&row, lines), frame_row_rect(&row, lines)) else { continue }; - let joins = blocks.last().is_some_and(|block| { - // A bullet of its own opens a list item (#2564): such a row is a new paragraph, and - // letting it join is what made an edit of the row above delete this item and the ones - // after it. - if starts_list_item(&row, lines) { - return false; - } - let Some(prev_row) = block.rows.last() else { return false }; - let Some(prev) = prev_row.first().and_then(|i| lines.get(*i)) else { return false }; - let Some(pr) = frame_row_rect(prev_row, lines) else { return false }; - let gap = prev.frame_origin().1 - first.frame_origin().1; - let tol = prev.size.max(first.size).max(1.0); - // The block opens with a list marker and this row sits further right: the item's text - // is at a hanging indent, so the row it wrapped onto continues it (#2564). A row at - // the block's own left edge is already covered by the match on `pr[0]`. Both boxes are - // in the frame, as the rest of this comparison is, so turned text matches too. - let hanging = frame[0] > pr[0] - && frame[0] - pr[0] <= tol * 3.0 - && block.lines.first().and_then(|&i| lines.get(i)).is_some_and(|l| opens_with_marker(l.text.trim_start())); - let aligned = (pr[0] - frame[0]).abs() < tol - || ((pr[0] + pr[2] - frame[0] - frame[2]) / 2.0).abs() < tol - || (pr[2] - frame[2]).abs() < tol - || hanging; - // OCR lines stay apart: the scan fixes where each one is, so they never rewrap together. - (!mixed || !(invisible(prev) || invisible(first))) - && (mixed || same_style(prev, first)) - && same_plane(prev, first) - // The rewrite sets every row in the first one's horizontal scale. - && (prev.origin.state.scale - first.origin.state.scale).abs() < 1e-6 - && aligned - && first.size.min(prev.size) >= first.size.max(prev.size) * 0.4 - && gap > first.size.min(prev.size) * 0.8 - && gap < first.size.max(prev.size) * 2.5 - }); - if !joins { - blocks.push(TextBlock { - text: String::new(), - editor_text: String::new(), - spans: Vec::new(), - rect, - base_font: first.base_font.clone(), - size: first.size, - bold: first.bold, - italic: first.italic, - color: first.color, - rotation: first.rotation(), - lines: Vec::new(), - rows: Vec::new(), - invisible: true, - }); - } - let Some(block) = blocks.last_mut() else { continue }; - let mut row_text = String::new(); - if !block.rows.is_empty() { - block.editor_text.push('\n'); - } - for &index in &row { - let Some(line) = lines.get(index) else { continue }; - // Some producers position separate words without encoding a space. - if let Some(prev) = block.lines.last().filter(|i| row.contains(i)).and_then(|i| lines.get(*i)) - && !row_text.ends_with(char::is_whitespace) - && !line.text.starts_with(char::is_whitespace) - && line.frame_rect()[0] - prev.frame_rect()[2] > line.size * 0.12 - { - row_text.push(' '); - block.editor_text.push(' '); - } - let start = block.editor_text.len(); - block.editor_text.push_str(&line.text); - block.spans.push((start..block.editor_text.len(), index)); - row_text.push_str(&line.text); - block.invisible &= line.origin.state.render_mode == 3; - block.lines.push(index); - } - if !block.text.is_empty() { - if block.text.ends_with('-') { - block.text.pop(); - } else { - block.text.push(' '); - } - } - block.text.push_str(row_text.trim_matches(paragraph_separator)); - block.rect = union(block.rect, rect); - block.rows.push(row); - } - blocks -} - -/// Shape in the source or an explicitly registered matching full face. Never use interface fonts. -fn shape_source( - doc: &Document, - fonts: &Dict, - name: &str, - text: &str, -) -> Result, pdfcraft_fonts::source_shaping::ShapedRun, String)>, EditError> { - if !pdfcraft_fonts::source_shaping::required(text) { - return Ok(None); - } - let source = - fonts.get(name.as_bytes()).and_then(|f| doc.dict(f)).ok_or_else(|| EditError::Invalid("the shaped text has no font resource".into()))?; - let family = Metrics::from_dict(doc, &source).base_font; - let mut candidates = vec![source]; - candidates.extend( - fonts - .iter() - .filter_map(|(_, f)| doc.dict(f)) - .filter(|f| pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, f).base_font) == pdfcraft_fonts::embedding::face_key(&family)), - ); - for font in candidates { - if let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font) - && pdfcraft_fonts::embedding::covers(&bytes, text) - && let Ok(shaped) = pdfcraft_fonts::source_shaping::shape(&bytes, text) - { - return Ok(Some((bytes, shaped, family))); - } - } - Err(EditError::Invalid("complex text needs the complete source TrueType/OpenType face; load that font before editing".into())) -} - -/// For each output row (a range of the draft's characters), whether it is exactly the source row -/// at the same position: the same characters, none of them inserted or changed by the edit. -fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range]) -> Vec { - let old: Vec = block.editor_text.chars().collect(); - let new: Vec = draft.chars().collect(); - 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(); - // The source character a draft character is, if the edit left it alone. - let origin = |i: usize| { - if i < prefix { - Some(i) - } else if i >= new.len().saturating_sub(suffix) { - (old.len() + i).checked_sub(new.len()) - } else { - None - } - }; - let mut source_rows = Vec::new(); - let mut start = 0; - for (i, ch) in old.iter().enumerate() { - if *ch == '\n' { - source_rows.push(start..i); - start = i + 1; - } - } - source_rows.push(start..old.len()); - // A line break inside a source run would pair editor rows with the wrong visual rows. - if source_rows.len() != block.rows.len() { - return vec![false; rows.len()]; - } - rows.iter() - .enumerate() - .map(|(r, range)| { - source_rows.get(r).is_some_and(|source| { - !range.is_empty() && range.len() == source.len() && range.clone().zip(source.clone()).all(|(i, j)| origin(i) == Some(j)) - }) - }) - .collect() -} - -/// Rewrite visual rows while keeping the source style on each surviving character. Resource -/// names may differ between Unicode subsets of the same face; encoding searches those subsets. -pub(super) fn rewrite_styled_block( - doc: &mut Document, - page: usize, - block: &TextBlock, - lines: &[TextLine], - text: Option<&str>, - style: &BlockStyle, - max_glyphs: usize, -) -> Result { - let first = block.lines.first().and_then(|i| lines.get(*i)).ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?; - let draft = match text { - Some(t) if t != block.text => t, - _ => &block.editor_text, - }; - if draft.len() > 4_000_000 { - return Err(EditError::Invalid("the replacement text is too large".into())); - } - if draft == block.editor_text && *style == BlockStyle::default() { - return Ok(LineEdit { substituted: None }); - } - let mut ranges: Vec<_> = style.ranges.iter().collect(); - ranges.sort_by_key(|r| r.range.start); - if ranges.len() > 1024 - || ranges.iter().any(|r| { - r.range.start >= r.range.end - || draft.get(r.range.clone()).is_none() - || [r.size, r.char_spacing, r.scale].iter().flatten().any(|n| !n.is_finite()) - || r.size.is_some_and(|n| n <= 0.0 || n > 14400.0) - || r.color.is_some_and(|c| c.iter().any(|n| !n.is_finite() || !(0.0..=1.0).contains(n))) - }) - || ranges.windows(2).any(|r| r[0].range.end > r[1].range.start) - { - return Err(EditError::Invalid("text style ranges must be disjoint UTF-8 boundaries with finite formatting (at most 1024 ranges)".into())); - } - let source_count = lines.len(); - let mut styled_lines = lines.to_vec(); - let mut styled_spans = Vec::new(); - let mut underlines = HashMap::new(); - let mut overrides = HashMap::new(); - for (span, source) in block.edited_spans(draft) { - let mut edges = vec![span.start, span.end]; - for r in &ranges { - for edge in [r.range.start, r.range.end] { - if span.contains(&edge) { - edges.push(edge); - } - } - } - edges.sort_unstable(); - edges.dedup(); - for bounds in edges.windows(2) { - let (Some(start), Some(end)) = (bounds.first().copied(), bounds.get(1).copied()) else { continue }; - let mut index = source; - let selected = ranges.iter().find(|r| r.range.contains(&start)).copied(); - if selected.is_some() || style.family.is_some() || style.bold.is_some() { - let default_range = RangeStyle { - range: start..end, - family: None, - resource: None, - size: None, - color: None, - underline: None, - char_spacing: None, - scale: None, - }; - let r = selected.unwrap_or(&default_range); - let mut line = lines.get(source).unwrap_or(first).clone(); - if let Some((family, bold, italic)) = - r.family.or(style.family).or_else(|| style.bold.map(|b| (source_family(&line.base_font), b, line.italic))) - { - let base = family.base_font(bold, italic); - line.font = format!("PCRange{}", base.replace('-', "")); - line.base_font = base.to_owned(); - line.bold = bold; - line.italic = italic; - } - if let Some(resource) = &r.resource { - line.font = resource.clone(); - } - if let Some(size) = r.size { - line.size = size; - } - if let Some(color) = r.color { - line.color = color; - line.origin.state.fill = vec![Op::new("rg", color.map(Object::Real).to_vec())]; - } - if let Some(spacing) = r.char_spacing { - line.origin.state.char_spacing = spacing / line.origin.k.max(1e-6); - } - if let Some(scale) = r.scale { - line.origin.state.scale = (scale / 100.0).clamp(0.1, 10.0); - } - index = styled_lines.len(); - styled_lines.push(line); - if let Some(r) = selected { - overrides.insert(index, r); - } - if let Some(underline) = r.underline { - underlines.insert(index, underline); - } - } - styled_spans.push((start..end, index)); - } - } - let lines = styled_lines.as_slice(); - let p = page_dict(doc, page)?; - let mut res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let mut fonts = res.get(b"Font").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - for line in lines.iter().filter(|line| line.font.starts_with("PCRange")) { - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(&line.base_font)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - fonts.set(line.font.as_bytes().to_vec(), Object::Dict(f)); - } - let mut metrics = std::collections::BTreeMap::new(); - for (name, object) in fonts.iter().take(256) { - if let Some(font) = doc.resolve(object).as_dict() { - metrics.insert(String::from_utf8_lossy(name).into_owned(), Metrics::from_dict(doc, font)); - } - } - for index in &block.lines { - if let Some(line) = lines.get(*index) - && !metrics.contains_key(&line.font) - && let Some(font) = fonts.get(line.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()) - { - metrics.insert(line.font.clone(), Metrics::from_dict(doc, &font)); - } - } - if [style.size, style.line_spacing, style.char_spacing, style.scale, style.width].iter().flatten().any(|v| !v.is_finite()) - || style.color.is_some_and(|c| c.iter().any(|v| !v.is_finite())) - { - return Err(EditError::Invalid("the paragraph's formatting must use finite numbers".into())); - } - let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); - if !dx.is_finite() || !dy.is_finite() || style.width.is_some_and(|w| !w.is_finite()) { - return Err(EditError::Invalid("the paragraph's position and width must be finite".into())); - } - // A visual row grows even when it contains several source/font runs. Multi-row - // paragraphs retain their container width; an explicit width always wins. - let natural = if block.invisible || block.rows.len() == 1 { - (p.crop(doc)[2] - 36.0 - (block.rect[0] + dx)).max(block.rect[2] - block.rect[0]) - } else { - block.rect[2] - block.rect[0] - }; - let width = style.width.unwrap_or(natural).clamp(1.0, 14_400.0); - // Each tuple is a character, its source style and an encodable resource name. - let mut chars = Vec::new(); - let mut substituted = None; - let mut fallback_groups: Vec = Vec::new(); - let mut fallback_names: std::collections::BTreeMap<(String, bool, char), usize> = std::collections::BTreeMap::new(); - let mut fallback_glyphs = 0_usize; - let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS); - let fallback_budget = per_font.saturating_mul(MAX_STYLED_FALLBACK_BATCHES); - let mut full_faces: HashMap = HashMap::new(); - let mut programs: HashMap>>> = HashMap::new(); - for (range, index) in styled_spans { - let source = lines.get(index).unwrap_or(first); - let start = range.start; - for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() { - if ch == '\n' { - chars.push((ch, index, source.font.clone())); - continue; - } - // A carriage return breaks the row too. Before `\n` the pair is one break, so the - // CR stays a `\r` the rows skip. Either way it keeps its place among the draft's - // characters, which rows are counted in. - if ch == '\r' { - let crlf = start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n')); - chars.push((if crlf { '\r' } else { '\n' }, index, source.font.clone())); - continue; - } - let s = ch.to_string(); - let name = if let Some(resource) = overrides.get(&index).and_then(|r| r.resource.as_ref()).or(style.resource.as_ref()) { - if !metrics.get(resource).is_some_and(|m| source_encodes(m, &s)) { - return Err(EditError::Invalid(format!("the selected font resource {resource} cannot show U+{:04X}", u32::from(ch)))); - } - resource.clone() - } else if metrics.get(&source.font).is_some_and(|m| source_encodes(m, &s)) { - source.font.clone() - } else if let Some((name, _)) = metrics.iter().find(|(_, m)| { - pdfcraft_fonts::embedding::face_key(&m.base_font) == pdfcraft_fonts::embedding::face_key(&source.base_font) && source_encodes(m, &s) - }) { - name.clone() - } else if let Some(face) = - pdfcraft_fonts::embedding::matching_faces(&source.base_font).into_iter().find(|f| pdfcraft_fonts::embedding::covers(f.bytes, &s)) - { - let key = face.name(); - if let Some(name) = full_faces.get(&key) { - name.clone() - } else { - let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, face.bytes, face.family).map_err(EditError::Invalid)?; - let font = fonts - .get(name.as_bytes()) - .and_then(|f| doc.dict(f)) - .ok_or_else(|| EditError::Invalid("the full source font could not be loaded".into()))?; - metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); - full_faces.insert(key, name.clone()); - name - } - } else if let Some(bytes) = programs - .entry(source.font.clone()) - .or_insert_with(|| source_program(doc, &fonts, &source.font).map(std::sync::Arc::new)) - .clone() - .filter(|bytes| pdfcraft_fonts::embedding::covers(bytes, &s)) - { - // The document's own complete program of this font has the glyph: draw it with - // that face rather than another family's fallback. - let key = format!("source {}", source.font); - if let Some(name) = full_faces.get(&key) { - name.clone() - } else { - let family = pdfcraft_fonts::embedding::names(&bytes).map_or_else(|_| source.base_font.clone(), |(family, _)| family); - let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, &bytes, &family).map_err(EditError::Invalid)?; - let font = fonts - .get(name.as_bytes()) - .and_then(|f| doc.dict(f)) - .ok_or_else(|| EditError::Invalid("the source font could not be loaded".into()))?; - metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); - full_faces.insert(key, name.clone()); - name - } - } else { - let family = source_family(&source.base_font); - if needs_type3(&s) { - let key = (source.base_font.clone(), source.bold, ch); - let group_index = if let Some(index) = fallback_names.get(&key) { - *index - } else { - // Keep the old per-character face choice: a serif Japanese character - // and a Cyrillic character may need different faces even in one style. - // Hebrew uses the separate RTL path before reaching this writer. - let (faces, _) = type3_faces(&s, family, source.bold)?; - let face = - fallback_face(&faces, &[ch], |face, ch| japanese_glyph_from(face, ch).is_ok()).ok_or_else(|| no_fallback_font(&s))?; - let index = fallback_groups.iter().position(|group| std::ptr::eq(group.face, face)).unwrap_or_else(|| { - let index = fallback_groups.len(); - fallback_groups.push(FallbackGroup { face, seen: Default::default(), glyphs: Vec::new(), positions: Vec::new() }); - index - }); - fallback_names.insert(key, index); - index - }; - let group = fallback_groups.get_mut(group_index).ok_or_else(|| EditError::Invalid("the fallback face is missing".into()))?; - if group.seen.insert(ch) { - if fallback_glyphs >= fallback_budget { - return Err(EditError::Invalid(format!( - "the replacement needs more than {fallback_budget} fallback glyphs; edit a smaller paragraph or load a complete font" - ))); - } - let (path, width) = type3_glyph(group.face, ch)?.ok_or_else(|| no_glyph_for(group.face, ch))?; - let code = u8::try_from((group.glyphs.len() % per_font).saturating_add(1)) - .map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?; - group.glyphs.push(Type3Glyph { code, ch, path, width }); - fallback_glyphs = fallback_glyphs.saturating_add(1); - } - group.positions.push(chars.len()); - // Filled from the prepared font/code map before measuring or drawing. - String::new() - } else { - let base = family.base_font(source.bold, source.italic); - let name = format!("PCEd{}", base.replace('-', "")); - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(base)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - metrics.entry(name.clone()).or_insert_with(|| Metrics::from_dict(doc, &f)); - fonts.set(name.clone().into_bytes(), Object::Dict(f)); - substituted = Some(base.to_string()); - name - } - }; - chars.push((ch, index, name)); - } - } - // All fallback outlines have now succeeded. Batch by actual face, preserving source - // style indices; the existing writer then coalesces adjacent equal-font/style characters. - for group in fallback_groups { - let fallback = write_type3_glyphs(doc, &mut fonts, group.face, false, group.glyphs, per_font); - for name in &fallback.names { - let font = fonts - .get(name.as_bytes()) - .and_then(|f| doc.dict(f)) - .ok_or_else(|| EditError::Invalid("the fallback font could not be loaded".into()))?; - metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); - } - for position in group.positions { - let item = chars.get_mut(position).ok_or_else(|| EditError::Invalid("the fallback character is missing".into()))?; - let (font, _) = fallback.codes.get(&item.0).ok_or_else(|| EditError::Invalid("the fallback glyph is missing".into()))?; - item.2 = fallback.names.get(*font).cloned().ok_or_else(|| EditError::Invalid("the fallback font is missing".into()))?; - } - substituted = Some(fallback.family.to_owned()); - } - let size_of = |source: &TextLine, index: usize| { - overrides.get(&index).and_then(|r| r.size).or(style.size).unwrap_or(source.size).max(0.1) / source.origin.k.max(1e-6) - }; - // A source run may carry TJ kerning/justification that is absent from its text state. - // Keep its measured advance when rebuilding the run instead of making it wrap on entry. - // Each font's space width, worked out once: when a font can't show a space, finding one - // reads every font on the page, and doing that for every character of a paragraph made each - // keystroke in a long paragraph take half a second (#2616). - // Only for the fonts the paragraph uses, when first asked: working out every page font's width - // up front would scan the page once per font for a short edit on a page of many fonts. - let spaces: std::cell::RefCell> = std::cell::RefCell::new(HashMap::new()); - let space_of = |name: &str, m: &Metrics| { - if let Some(w) = spaces.borrow().get(name) { - return *w; - } - let w = source_space(doc, &fonts, m); - spaces.borrow_mut().insert(name.to_owned(), w); - w - }; - let mut calibration = HashMap::new(); - for (index, source) in lines.iter().enumerate() { - if let Some(m) = metrics.get(&source.font) { - let nominal = source_width( - m, - &source.text, - source.origin.state.font.as_ref().map_or(1.0, |(_, s)| *s), - space_of(&source.font, m), - source.origin.state.char_spacing, - source.origin.state.word_spacing, - ) * source.origin.state.scale - * across(source); - let changed_face = style.resource.is_some() || overrides.get(&index).is_some_and(|r| r.family.is_some() || r.resource.is_some()); - let factor = if nominal > 0.0 && !changed_face { ((source.rect[2] - source.rect[0]) / nominal).clamp(0.5, 2.0) } else { 1.0 }; - calibration.insert(index, factor); - } - } - let advance = |item: &(char, usize, String)| -> f64 { - let source = lines.get(item.1).unwrap_or(first); - let Some(m) = metrics.get(&item.2) else { return 0.0 }; - source_width( - m, - &item.0.to_string(), - size_of(source, item.1), - space_of(&item.2, m), - overrides - .get(&item.1) - .and_then(|r| r.char_spacing) - .or(style.char_spacing) - .map_or(source.origin.state.char_spacing, |c| c / source.origin.k.max(1e-6)), - source.origin.state.word_spacing, - ) * overrides.get(&item.1).and_then(|r| r.scale).or(style.scale).map_or(source.origin.state.scale, |s| (s / 100.0).clamp(0.1, 10.0)) - * across(source) - * calibration.get(&item.1).copied().unwrap_or(1.0) - }; - let measure = |items: &[(char, usize, String)]| -> Result { - let mut width = 0.0; - let mut at = 0; - while let Some(item) = items.get(at) { - let mut end = at + 1; - while items.get(end).is_some_and(|v| v.1 == item.1 && v.2 == item.2) { - end += 1; - } - let segment = items.get(at..end).unwrap_or_default(); - let string: String = segment.iter().map(|v| v.0).collect(); - if let Some((_, shaped, _)) = shape_source(doc, &fonts, &item.2, &string)? { - let source = lines.get(item.1).unwrap_or(first); - let scale = - overrides.get(&item.1).and_then(|r| r.scale).or(style.scale).map_or(source.origin.state.scale, |s| (s / 100.0).clamp(0.1, 10.0)); - let spacing = overrides - .get(&item.1) - .and_then(|r| r.char_spacing) - .or(style.char_spacing) - .map_or(source.origin.state.char_spacing, |s| s / source.origin.k.max(1e-6)); - width += (shaped.advance * size_of(source, item.1) - + spacing * string.chars().count() as f64 - + source.origin.state.word_spacing * string.matches(' ').count() as f64) - * scale - * source.origin.k; - } else { - width += segment.iter().map(&advance).sum::(); - } - at = end; - } - Ok(width) - }; - let mut rows: Vec> = Vec::new(); - // Each row's characters, as indexes into the draft's characters (`chars` holds all of them). - let mut row_ranges = Vec::new(); - let mut row_start = 0; - let mut row = Vec::new(); - let mut row_width = 0.0; - // Wrap at word boundaries, retaining explicit source row breaks and whitespace. - let mut at = 0; - while at < chars.len() { - let Some(item) = chars.get(at) else { break }; - // The CR of a CRLF: the `\n` after it breaks the row. - if item.0 == '\r' { - at += 1; - continue; - } - if item.0 == '\n' { - row_ranges.push(row_start..row_start + row.len()); - rows.push(std::mem::take(&mut row)); - row_width = 0.0; - at += 1; - row_start = at; - continue; - } - let mut end = at + 1; - if !item.0.is_whitespace() { - // A word ends at a space, or where Chinese or Japanese text may break between characters. - while let (Some(before), Some(c)) = (chars.get(end - 1), chars.get(end)) - && !c.0.is_whitespace() - && !crate::linebreak::between(before.0, c.0) - { - end += 1; - } - } - let word = chars.get(at..end).unwrap_or_default(); - let w = measure(word)?; - if !row.is_empty() && !item.0.is_whitespace() && row_width + w > width + 0.5 { - while row.last().is_some_and(|c: &(char, usize, String)| c.0.is_whitespace()) { - row.pop(); - } - row_ranges.push(row_start..row_start + row.len()); - rows.push(std::mem::take(&mut row)); - row_width = 0.0; - row_start = at; - } - row.extend_from_slice(word); - row_width += w; - at = end; - } - if !row.is_empty() { - row_ranges.push(row_start..row_start + row.len()); - rows.push(row); - } - let streams = content_streams(doc, &p.dict)?; - let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); - let ops = pieces.parse().ops; - // A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and - // glyph widths stay exactly as they were instead of being rebuilt from the text state. - let plain = BlockStyle { ranges: Vec::new(), ..style.clone() } == BlockStyle::default(); - let mut kept = if plain { - untouched_rows(block, draft, &row_ranges).into_iter().zip(&rows).map(|(same, row)| same && row.iter().all(|c| c.1 < source_count)).collect() - } else { - vec![false; rows.len()] - }; - let kept_lines: std::collections::HashSet = - kept.iter().enumerate().filter(|(_, k)| **k).filter_map(|(r, _)| block.rows.get(r)).flatten().copied().collect(); - let rewritten: Vec<&TextLine> = block.lines.iter().filter(|i| !kept_lines.contains(i)).filter_map(|i| lines.get(*i)).collect(); - // The rewrite goes where the last rewritten row was drawn, after any backgrounds painted - // between the rows. A dropped `'` or `"` also moved to the next line, which a kept row relies on. - let lead = - rewritten.last().copied().filter(|_| !rewritten.iter().flat_map(|l| &l.ops).any(|i| ops.get(*i).is_some_and(|op| op.is("'") || op.is("\"")))); - let (members, lead) = match lead { - Some(lead) if !kept_lines.is_empty() => (rewritten, lead), - _ => { - kept.iter_mut().for_each(|k| *k = false); - let all: Vec<&TextLine> = block.lines.iter().filter_map(|i| lines.get(*i)).collect(); - let last = all.last().copied().unwrap_or(first); - (all, last) - } - }; - let o = &lead.origin; - let mut drop = coincident_ops(lines, &members); - drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); - let start = lead.ops.first().copied().unwrap_or(o.bt_op); - let split = ops - .get(o.bt_op..start) - .unwrap_or_default() - .iter() - .enumerate() - .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i))); - // What is in force where the rewrite is drawn, and what the text after it expects. - let after = if split { &o.state } else { &o.bt_state }; - for (row, _) in rows.iter().zip(&kept).filter(|(_, k)| !**k) { - let text: String = row.iter().map(|v| v.0).collect(); - if pdfcraft_fonts::source_shaping::required(&text) && row.first().is_some_and(|first| row.iter().any(|v| v.1 != first.1 || v.2 != first.2)) { - return Err(EditError::Invalid( - "contextual/bidi text across font or style boundaries needs a uniform full-face row; mixed contextual runs are not yet supported" - .into(), - )); - } - } - let measured: Vec<_> = rows.iter().map(|r| measure(r)).collect::>()?; - let mut shaped_runs = HashMap::new(); - let mut segment_advances = HashMap::new(); - for (row_index, row) in rows.iter().enumerate().filter(|(r, _)| !kept.get(*r).copied().unwrap_or(false)) { - let mut at = 0; - while let Some(item) = row.get(at) { - let mut end = at + 1; - while row.get(end).is_some_and(|v| v.1 == item.1 && v.2 == item.2) { - end += 1; - } - segment_advances.insert((row_index, at), row.get(at..end).unwrap_or_default().iter().map(&advance).sum::()); - let string: String = row.get(at..end).unwrap_or_default().iter().map(|v| v.0).collect(); - if let Some((bytes, shaped, family)) = shape_source(doc, &fonts, &item.2, &string)? { - shaped_runs.insert((row_index, at), (bytes, shaped, family)); - } - at = end; - } - } - let mut glyph_resources = HashMap::new(); - for (key, (bytes, shaped, family)) in &shaped_runs { - let mappings: Vec<_> = shaped.glyphs.iter().map(|g| (g.text.clone(), g.id)).collect(); - let resource = pdfcraft_fonts::embedding::embed_mapping(doc, &mut fonts, bytes, family, &mappings).map_err(EditError::Invalid)?; - glyph_resources.insert(*key, resource); - } - let n = pdfcraft_content::num; - let mut block_ops = vec![Op::new("q", vec![])]; - block_ops.extend(reveal_ops(block, lines, style, o.ctm)?); - let back = Matrix([o.ctm[0], o.ctm[1], o.ctm[2], o.ctm[3], 0.0, 0.0]) - .invert() - .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?; - let (tx, ty) = back.apply(dx, dy); - block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(tx), n(ty)])); - let crop = p.crop(doc); - let centered = is_centred(block, lines, (crop[0] + crop[2]) / 2.0, first.size); - let source_leading = block - .rows - .windows(2) - .filter_map(|rows| { - let a = rows[0].first().and_then(|i| lines.get(*i))?; - let b = rows[1].first().and_then(|i| lines.get(*i))?; - Some((a.origin.baseline - b.origin.baseline).abs()) - }) - .rfind(|v| *v > 0.0) - .unwrap_or(first.size * 1.5); - let mut baseline = first.origin.baseline; - for (row_index, row) in rows.iter().enumerate() { - let anchor = block.rows.get(row_index).and_then(|r| r.first()).and_then(|i| lines.get(*i)); - if style.line_spacing.is_none() - && style.size.is_none() - && let Some(anchor) = anchor - { - baseline = anchor.origin.baseline; - } - let anchor = anchor.unwrap_or(first); - if kept.get(row_index).copied().unwrap_or(false) { - // Kept rows only occur without spacing or size changes, so this is the step below. - baseline -= source_leading; - continue; - } - let w = measured.get(row_index).copied().unwrap_or(0.0); - let align = style.align.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left }); - let original_rect = block.rows.get(row_index).and_then(|row| row_rect(row, lines)).unwrap_or(block.rect); - let left = if style.width.is_none() && style.align.is_none() { original_rect[0] } else { block.rect[0] }; - let available = if style.width.is_none() && style.align.is_none() { original_rect[2] - original_rect[0] } else { width }; - let mut x = left - + match align { - crate::added::Align::Center => (available - w) / 2.0, - crate::added::Align::Right => available - w, - _ => 0.0, - }; - let mut pos = 0; - while pos < row.len() { - let Some(item) = row.get(pos) else { break }; - let source = lines.get(item.1).unwrap_or(first); - let mut end = pos + 1; - while row.get(end).is_some_and(|other| other.1 == item.1 && other.2 == item.2) { - end += 1; - } - let segment = row.get(pos..end).unwrap_or_default(); - let string: String = segment.iter().map(|c| c.0).collect(); - let m = metrics.get(&item.2).ok_or_else(|| EditError::Invalid("the paragraph font is missing".into()))?; - let mut state = source.origin.state.clone(); - state.font = Some((item.2.clone(), size_of(source, item.1))); - if let Some(c) = overrides.get(&item.1).and_then(|r| r.char_spacing).or(style.char_spacing) { - state.char_spacing = c / source.origin.k.max(1e-6); - } - if let Some(s) = overrides.get(&item.1).and_then(|r| r.scale).or(style.scale) { - state.scale = (s / 100.0).clamp(0.1, 10.0); - } - if !shaped_runs.contains_key(&(row_index, pos)) { - state.scale *= calibration.get(&item.1).copied().unwrap_or(1.0); - } - if let Some([r, g, b]) = overrides.get(&item.1).and_then(|r| r.color).or(style.color) { - state.fill = vec![Op::new("rg", vec![n(r), n(g), n(b)])]; - } - let inverse = Matrix(o.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; - let mut tm = Matrix(source.origin.tm).then(&Matrix(source.origin.ctm)).then(&inverse).0; - let (ax, ay) = back.apply(x - source.origin.x, baseline - source.origin.baseline); - tm[4] += ax; - tm[5] += ay; - let mut segment_width = segment_advances.get(&(row_index, pos)).copied().unwrap_or(0.0); - // Each run in its own graphics state, restating what its source set inside its text - // object (`gs`, stroke, line width, `Tr`; #3508/#3509) from the state the rewrite is - // drawn in: a `gs` has no inverse, so one run's ExtGState would stay for the next. - block_ops.push(Op::new("q", vec![])); - block_ops.extend(state.paint_ops(after)); - if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) { - let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; - let mut properties = Dict::new(); - properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(&string))); - block_ops.push(Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)])); - state.font = Some((resource.clone(), size_of(source, item.1))); - // One text object for the run: each glyph is placed where shaping put it by a TJ - // adjustment from where the glyph before it ends (its advance in the embedded - // font's widths), and raised by the text rise for a mark set above or below - // (ISO 32000-1 §9.4.3, §9.3.7). Font-program codes are separate from Unicode; - // ToUnicode and ActualText keep the logical text. - let size = size_of(source, item.1); - let per_unit = 1000.0 / size; - if !per_unit.is_finite() { - return Err(EditError::Invalid("the shaped text has no size".into())); - } - let shown = fonts - .get(resource.as_bytes()) - .and_then(|f| doc.resolve(f).as_dict().cloned()) - .map(|d| Metrics::from_dict(doc, &d)) - .ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; - // Spacing is part of each glyph's position, so the run itself has none. - let (char_spacing, word_spacing, rise) = (state.char_spacing, state.word_spacing, state.rise); - state.char_spacing = 0.0; - state.word_spacing = 0.0; - block_ops.push(Op::new("BT", vec![])); - block_ops.extend(state.ops()); - block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); - // `pen`: where the next glyph would start, in thousandths of the font size. - let (mut pen, mut extra, mut raised) = (0.0, 0.0, rise); - let mut shown_glyphs: Vec = Vec::new(); - for (i, glyph) in shaped.glyphs.iter().enumerate() { - let target = (glyph.position[0] * size + extra) * per_unit; - let glyph_rise = rise + glyph.position[1] * size; - if (glyph_rise - raised).abs() > 1e-9 || shown_glyphs.len() >= 4096 { - if !shown_glyphs.is_empty() { - block_ops.push(Op::new("TJ", vec![Object::Array(std::mem::take(&mut shown_glyphs))])); - } - if (glyph_rise - raised).abs() > 1e-9 { - block_ops.push(Op::new("Ts", vec![n(glyph_rise)])); - raised = glyph_rise; - } - } - if (target - pen).abs() > 1e-6 { - shown_glyphs.push(n(pen - target)); - } - let code = u16::try_from(i + 1).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?; - shown_glyphs.push(Object::String(PdfString::literal(code.to_be_bytes().to_vec()))); - pen = target + shown.width(u32::from(code)) * 1000.0; - extra += char_spacing * glyph.text.chars().count() as f64 + word_spacing * glyph.text.matches(' ').count() as f64; - } - if !shown_glyphs.is_empty() { - block_ops.push(Op::new("TJ", vec![Object::Array(shown_glyphs)])); - } - if (raised - rise).abs() > 1e-9 { - block_ops.push(Op::new("Ts", vec![n(rise)])); - } - block_ops.push(Op::new("ET", vec![])); - (state.char_spacing, state.word_spacing) = (char_spacing, word_spacing); - block_ops.push(Op::new("EMC", vec![])); - segment_width = (shaped.advance * size + extra) * state.scale * source.origin.k; - } else { - block_ops.push(Op::new("BT", vec![])); - block_ops.extend(state.ops()); - block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); - block_ops.extend(source_show( - m, - &string, - size_of(source, item.1), - spaces.borrow().get(&item.2).copied().unwrap_or_else(|| source_space(doc, &fonts, m)), - state.char_spacing, - state.word_spacing, - )?); - block_ops.push(Op::new("ET", vec![])); - } - if underlines.get(&item.1).copied().or(style.underline) == Some(true) { - let [r, g, b] = overrides.get(&item.1).and_then(|r| r.color).or(style.color).unwrap_or(source.color); - let underline_size = size_of(source, item.1) * source.origin.k; - let (x0, y0) = back.apply(x - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); - let (x1, y1) = back.apply(x + segment_width - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); - block_ops.push(Op::new("RG", vec![n(r), n(g), n(b)])); - block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / o.ctm[2].hypot(o.ctm[3]).max(1e-6))])); - block_ops.push(Op::new("m", vec![n(x0), n(y0)])); - block_ops.push(Op::new("l", vec![n(x1), n(y1)])); - block_ops.push(Op::new("S", vec![])); - } - block_ops.push(Op::new("Q", vec![])); - x += segment_width; - pos = end; - } - baseline -= if style.line_spacing.is_none() && style.size.is_none() { - source_leading - } else { - style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(anchor.size) - }; - } - block_ops.push(Op::new("Q", vec![])); - block_ops.extend(after.ops()); - if split { - block_ops.insert(0, Op::new("ET", vec![])); - block_ops.push(Op::new("BT", vec![])); - block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect())); - } - let at = if split { start } else { o.bt_op }; - let data = splice(&pieces, &ops, |i| (if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }, !drop.contains(&i))); - let contents = rewritten_contents(doc, &streams, data); - res.set(b"Font".to_vec(), Object::Dict(fonts)); - doc.update_dict(p.obj, |d| { - d.set(b"Contents".to_vec(), contents); - d.set(b"Resources".to_vec(), Object::Dict(res)); - })?; - Ok(LineEdit { substituted }) -} +pub(super) use grouping::{group_blocks, group_reading_blocks, is_centred}; +pub(super) use styled::rewrite_styled_block; diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs new file mode 100644 index 000000000..29004b8e3 --- /dev/null +++ b/crates/edit/src/text_regions/grouping.rs @@ -0,0 +1,319 @@ +use super::*; + +fn same_plane(a: &TextLine, b: &TextLine) -> bool { + let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm)); + let bt = Matrix(b.origin.tm).then(&Matrix(b.origin.ctm)); + a.scope == b.scope + && a.paint == b.paint + && [0, 2].iter().all(|axis| { + let ax = at.0.get(*axis).copied().unwrap_or(0.0); + let ay = at.0.get(axis + 1).copied().unwrap_or(0.0); + let bx = bt.0.get(*axis).copied().unwrap_or(0.0); + let by = bt.0.get(axis + 1).copied().unwrap_or(0.0); + let al = ax.hypot(ay); + let bl = bx.hypot(by); + al > 0.0 && bl > 0.0 && (ax / al - bx / bl).abs() < 0.001 && (ay / al - by / bl).abs() < 0.001 + }) +} + +pub(super) fn row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> { + row.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).reduce(union) +} + +/// [`row_rect`] in its text's own frame ([`TextLine::frame_rect`]), so rows of turned text +/// compare along and across their baselines. +fn frame_row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> { + row.iter().filter_map(|i| lines.get(*i)).map(TextLine::frame_rect).reduce(union) +} + +/// The left edge the rest of the page's text sits on, ignoring the rows of `block`. A single row +/// that does not share it is not merely an indented or full-width left-aligned line, so its own +/// centre is the only remaining explanation for where it sits. +fn neighbour_left(lines: &[TextLine], block: &TextBlock) -> Option { + let mut edges: HashMap = HashMap::new(); + for (i, line) in lines.iter().enumerate() { + if block.lines.contains(&i) { + continue; + } + // Quarter-point buckets: equal edges found through different runs still agree. + *edges.entry((line.rect[0] * 4.0).round() as i64).or_insert(0) += 1; + } + let (best, _) = edges.into_iter().max_by_key(|(_, count)| *count)?; + Some(best as f64 / 4.0) +} + +/// Whether a block reads as centred, and so must keep its centre when it grows or wraps. +/// +/// Several ragged rows whose left edges disagree while their centres agree are centred. A one row +/// block cannot say that: all of its left edges are the same, so the ragged test never fires and a +/// centred title would be taken for left-aligned and grow to the right (#2581). Fall back to the +/// page instead — a lone row sitting on the page's own centre, and not on the edge the rest of the +/// text uses, is a centred title. A full-width left-aligned line still lines up with that edge, so +/// it is not mistaken for one. +pub(crate) fn is_centred(block: &TextBlock, lines: &[TextLine], page_centre: f64, size: f64) -> bool { + let centre = (block.rect[0] + block.rect[2]) / 2.0; + let lefts: Vec<_> = block.rows.iter().filter_map(|r| row_rect(r, lines).map(|r| r[0])).collect(); + if block.rows.len() > 1 { + let left_span = lefts.iter().copied().fold(f64::NEG_INFINITY, f64::max) - lefts.iter().copied().fold(f64::INFINITY, f64::min); + return left_span > size * 0.5 + && block.rows.iter().all(|row| row_rect(row, lines).is_some_and(|r| ((r[0] + r[2]) / 2.0 - centre).abs() < size)); + } + lefts + .first() + .is_some_and(|left| (centre - page_centre).abs() < size && neighbour_left(lines, block).is_none_or(|edge| (*left - edge).abs() > size * 0.5)) +} + +pub(crate) fn group_blocks(lines: &[TextLine]) -> Vec { + group_layout(lines, true) +} + +/// Exporters currently represent a block with one style. Keep their style boundaries until +/// they support rich runs, while edit selection may encompass the complete visual region. +/// Dotted-leader entries stay on one row and use the title run's style. +pub(crate) fn group_reading_blocks(lines: &[TextLine]) -> Vec { + let mut blocks = Vec::new(); + let mut start = 0; + let mut offset = 0; + for row in lines.chunk_by(|a, b| same_plane(a, b) && (a.origin.baseline - b.origin.baseline).abs() < a.size.min(b.size) * 0.2) { + let mut text = String::new(); + for (i, line) in row.iter().enumerate() { + if !text.ends_with(char::is_whitespace) + && !line.text.starts_with(char::is_whitespace) + && i.checked_sub(1).and_then(|i| row.get(i)).is_some_and(|prev| line.rect[0] - prev.rect[2] > line.size * 0.12) + { + text.push(' '); + } + text.push_str(&line.text); + } + if leader_entry(&text) + && let Some(first) = row.first() + { + // Tab leaders are a single reading row, even when the title, dots and page + // number are separate text objects far apart. Do not merge neighbouring entries. + blocks.extend(group_layout(lines.get(start..offset).unwrap_or_default(), false)); + let mut line = first.clone(); + line.text = text; + line.rect = row.iter().map(|line| line.rect).reduce(union).unwrap_or(first.rect); + blocks.extend(group_layout(std::slice::from_ref(&line), false)); + start = offset + row.len(); + } + offset += row.len(); + } + blocks.extend(group_layout(lines.get(start..).unwrap_or_default(), false)); + blocks +} + +/// Dotted leaders end in a page number, with a title before the dots. A dot in an article +/// number, or an ellipsis in ordinary prose, is not enough to make a contents entry. +fn leader_entry(text: &str) -> bool { + let text = text.trim_end(); + let prefix = text.trim_end_matches(|c: char| { + c.is_ascii_digit() || matches!(c, 'i' | 'v' | 'x' | 'l' | 'c' | 'd' | 'm' | 'I' | 'V' | 'X' | 'L' | 'C' | 'D' | 'M') + }); + if prefix.len() == text.len() { + return false; + } + let title = prefix.trim_end_matches(|c: char| c.is_whitespace() || matches!(c, '.' | '…')); + let dots = prefix + .get(title.len()..) + .unwrap_or_default() + .chars() + .map(|c| match c { + '.' => 1, + '…' => 3, + _ => 0, + }) + .sum::(); + dots >= 3 && title.chars().any(char::is_alphabetic) +} + +/// Text-space units → user space along the baseline. It is the vertical scale ([`Origin::k`]) +/// unless the text matrix stretches the glyphs, as an OCR layer fitting each word to the scan does. +pub(super) fn across(line: &TextLine) -> f64 { + let m = Matrix(line.origin.tm).then(&Matrix(line.origin.ctm)).0; + let h = m[0].hypot(m[1]); + if h.is_finite() && h > 1e-9 { h } else { line.origin.k } +} + +/// Invisible text (rendering mode 3), as in an OCR layer over a scanned image. +fn invisible(line: &TextLine) -> bool { + line.origin.state.render_mode == 3 +} + +fn same_style(a: &TextLine, b: &TextLine) -> bool { + same_font(a, b.font.as_bytes(), &b.base_font, b.face.as_deref()) + // With no `/BaseFont` (or only a subset tag) there is no name to compare: the resource or + // fallback face `same_font` matched is the whole identity, so keep the lines together. + && (same_base_font(&a.base_font, &b.base_font) + || (strip_subset_tag(&a.base_font).is_empty() && strip_subset_tag(&b.base_font).is_empty())) + && a.bold == b.bold + && a.italic == b.italic + && a.color == b.color + && (a.rotation() - b.rotation()).abs() < 0.01 + && (a.size - b.size).abs() < 0.01 +} + +/// Does a row open with a list marker? A bullet or a dash, or an ordered marker — digits (or a +/// single letter) followed by `.` or `)` and then a space or the row's end, so prose such as +/// `1.5` or `Dr. Smith` is left alone. +fn opens_with_marker(text: &str) -> bool { + let Some(first) = text.chars().next() else { return false }; + if matches!(first, '•' | '◦' | '▪' | '‣' | '⁃' | '*' | '-' | '–' | '—') { + return true; + } + if !first.is_ascii_digit() && !first.is_ascii_alphabetic() { + return false; + } + let digits = text.chars().take_while(char::is_ascii_digit).count(); + let letters = text.chars().take_while(char::is_ascii_alphabetic).count(); + // Both runs are ASCII characters, so their length in characters is their length in bytes: + // the slice below starts on a character boundary. + let rest = if digits > 0 { + &text[digits..] + } else if letters == 1 { + &text[1..] + } else { + return false; + }; + let mut after = rest.chars(); + matches!(after.next(), Some('.' | ')')) && after.next().is_none_or(char::is_whitespace) +} + +/// A row that opens with a list marker opens its own item: joining it to the row above is what +/// let an edit of one paragraph delete every item after it (#2564). The other error, splitting +/// an ordinary paragraph, costs one more paragraph to click and never any text — so err this way. +fn starts_list_item(row: &[usize], lines: &[TextLine]) -> bool { + row.iter().filter_map(|i| lines.get(*i)).map(|l| l.text.trim_start()).find(|t| !t.is_empty()).is_some_and(opens_with_marker) +} + +fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { + let mut rows: Vec> = Vec::new(); + for (i, line) in lines.iter().enumerate() { + let joins = rows.last().and_then(|r| r.last()).and_then(|i| lines.get(*i)).is_some_and(|prev| { + let gap = line.frame_rect()[0] - prev.frame_rect()[2]; + // OCR words (invisible text over a scan) join only across a word space: a wider gap + // is a table cell or a tab stop, which a rewrite would close up. + let scanned = !mixed || (invisible(prev) == invisible(line) && (!invisible(line) || gap < prev.size.max(line.size) * 0.6)); + scanned + && (mixed || same_style(prev, line)) + && same_plane(prev, line) + // A superscript or subscript run (a footnote marker, say) sits on the same visual + // row as the text around it, raised or lowered by `Ts` from the body baseline + // (#3252). That shift is typically up to half the body size, not the marker's own + // smaller size — but the wider tolerance only applies between a run and a + // distinctly smaller or larger one, with no horizontal overlap (`gap >= 0.0`): + // two equal-size runs can land within it too, at a baseline shift tight columns + // or compressed text (`Tz`) can produce between genuinely separate rows, and the + // original 20% tolerance is what keeps those apart. + && ((prev.frame_origin().1 - line.frame_origin().1).abs() < prev.size.min(line.size) * 0.2 + || (gap >= 0.0 + && prev.size.min(line.size) < prev.size.max(line.size) * 0.9 + && (prev.frame_origin().1 - line.frame_origin().1).abs() < prev.size.max(line.size) * 0.5)) + && gap >= -prev.size.min(line.size) * 0.2 + && gap < prev.size.max(line.size) * 1.5 + // Separate same-style text objects that merely touch or overlap, with no space + // between them, are separate labels (for example a paragraph dragged against + // another, #594). Style runs and words separated by a space or gap still join. + && (prev.origin.bt_op == line.origin.bt_op + || gap > prev.size.min(line.size) * 0.01 + || !same_style(prev, line) + || prev.text.ends_with(char::is_whitespace) + || line.text.starts_with(char::is_whitespace)) + }); + if joins && let Some(row) = rows.last_mut() { + row.push(i); + } else { + rows.push(vec![i]); + } + } + let mut blocks: Vec = Vec::new(); + for row in rows { + let Some(first) = row.first().and_then(|i| lines.get(*i)) else { continue }; + let (Some(rect), Some(frame)) = (row_rect(&row, lines), frame_row_rect(&row, lines)) else { continue }; + let joins = blocks.last().is_some_and(|block| { + // A bullet of its own opens a list item (#2564): such a row is a new paragraph, and + // letting it join is what made an edit of the row above delete this item and the ones + // after it. + if starts_list_item(&row, lines) { + return false; + } + let Some(prev_row) = block.rows.last() else { return false }; + let Some(prev) = prev_row.first().and_then(|i| lines.get(*i)) else { return false }; + let Some(pr) = frame_row_rect(prev_row, lines) else { return false }; + let gap = prev.frame_origin().1 - first.frame_origin().1; + let tol = prev.size.max(first.size).max(1.0); + // The block opens with a list marker and this row sits further right: the item's text + // is at a hanging indent, so the row it wrapped onto continues it (#2564). A row at + // the block's own left edge is already covered by the match on `pr[0]`. Both boxes are + // in the frame, as the rest of this comparison is, so turned text matches too. + let hanging = frame[0] > pr[0] + && frame[0] - pr[0] <= tol * 3.0 + && block.lines.first().and_then(|&i| lines.get(i)).is_some_and(|l| opens_with_marker(l.text.trim_start())); + let aligned = (pr[0] - frame[0]).abs() < tol + || ((pr[0] + pr[2] - frame[0] - frame[2]) / 2.0).abs() < tol + || (pr[2] - frame[2]).abs() < tol + || hanging; + // OCR lines stay apart: the scan fixes where each one is, so they never rewrap together. + (!mixed || !(invisible(prev) || invisible(first))) + && (mixed || same_style(prev, first)) + && same_plane(prev, first) + // The rewrite sets every row in the first one's horizontal scale. + && (prev.origin.state.scale - first.origin.state.scale).abs() < 1e-6 + && aligned + && first.size.min(prev.size) >= first.size.max(prev.size) * 0.4 + && gap > first.size.min(prev.size) * 0.8 + && gap < first.size.max(prev.size) * 2.5 + }); + if !joins { + blocks.push(TextBlock { + text: String::new(), + editor_text: String::new(), + spans: Vec::new(), + rect, + base_font: first.base_font.clone(), + size: first.size, + bold: first.bold, + italic: first.italic, + color: first.color, + rotation: first.rotation(), + lines: Vec::new(), + rows: Vec::new(), + invisible: true, + }); + } + let Some(block) = blocks.last_mut() else { continue }; + let mut row_text = String::new(); + if !block.rows.is_empty() { + block.editor_text.push('\n'); + } + for &index in &row { + let Some(line) = lines.get(index) else { continue }; + // Some producers position separate words without encoding a space. + if let Some(prev) = block.lines.last().filter(|i| row.contains(i)).and_then(|i| lines.get(*i)) + && !row_text.ends_with(char::is_whitespace) + && !line.text.starts_with(char::is_whitespace) + && line.frame_rect()[0] - prev.frame_rect()[2] > line.size * 0.12 + { + row_text.push(' '); + block.editor_text.push(' '); + } + let start = block.editor_text.len(); + block.editor_text.push_str(&line.text); + block.spans.push((start..block.editor_text.len(), index)); + row_text.push_str(&line.text); + block.invisible &= line.origin.state.render_mode == 3; + block.lines.push(index); + } + if !block.text.is_empty() { + if block.text.ends_with('-') { + block.text.pop(); + } else { + block.text.push(' '); + } + } + block.text.push_str(row_text.trim_matches(paragraph_separator)); + block.rect = union(block.rect, rect); + block.rows.push(row); + } + blocks +} diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs new file mode 100644 index 000000000..09baf1ac7 --- /dev/null +++ b/crates/edit/src/text_regions/styled.rs @@ -0,0 +1,787 @@ +use super::grouping::{across, is_centred, row_rect}; +use super::*; + +// The draft is capped at 4 MiB; also bound the outline data prepared across all fallback faces. +// A glyph budget worth 64 full batches (15,360 glyphs with the production batch size). +// Lowering the internal batch size for tests lowers the budget by the same ratio. +const MAX_STYLED_FALLBACK_BATCHES: usize = 64; + +struct FallbackGroup { + face: &'static CraftFont, + seen: std::collections::HashSet, + glyphs: Vec, + positions: Vec, +} + +/// Shape in the source or an explicitly registered matching full face. Never use interface fonts. +fn shape_source( + doc: &Document, + fonts: &Dict, + name: &str, + text: &str, +) -> Result, pdfcraft_fonts::source_shaping::ShapedRun, String)>, EditError> { + if !pdfcraft_fonts::source_shaping::required(text) { + return Ok(None); + } + let source = + fonts.get(name.as_bytes()).and_then(|f| doc.dict(f)).ok_or_else(|| EditError::Invalid("the shaped text has no font resource".into()))?; + let family = Metrics::from_dict(doc, &source).base_font; + let mut candidates = vec![source]; + candidates.extend( + fonts + .iter() + .filter_map(|(_, f)| doc.dict(f)) + .filter(|f| pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, f).base_font) == pdfcraft_fonts::embedding::face_key(&family)), + ); + for font in candidates { + if let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font) + && pdfcraft_fonts::embedding::covers(&bytes, text) + && let Ok(shaped) = pdfcraft_fonts::source_shaping::shape(&bytes, text) + { + return Ok(Some((bytes, shaped, family))); + } + } + Err(EditError::Invalid("complex text needs the complete source TrueType/OpenType face; load that font before editing".into())) +} + +/// For each output row (a range of the draft's characters), whether it is exactly the source row +/// at the same position: the same characters, none of them inserted or changed by the edit. +fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range]) -> Vec { + let old: Vec = block.editor_text.chars().collect(); + let new: Vec = draft.chars().collect(); + 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(); + // The source character a draft character is, if the edit left it alone. + let origin = |i: usize| { + if i < prefix { + Some(i) + } else if i >= new.len().saturating_sub(suffix) { + (old.len() + i).checked_sub(new.len()) + } else { + None + } + }; + let mut source_rows = Vec::new(); + let mut start = 0; + for (i, ch) in old.iter().enumerate() { + if *ch == '\n' { + source_rows.push(start..i); + start = i + 1; + } + } + source_rows.push(start..old.len()); + // A line break inside a source run would pair editor rows with the wrong visual rows. + if source_rows.len() != block.rows.len() { + return vec![false; rows.len()]; + } + rows.iter() + .enumerate() + .map(|(r, range)| { + source_rows.get(r).is_some_and(|source| { + !range.is_empty() && range.len() == source.len() && range.clone().zip(source.clone()).all(|(i, j)| origin(i) == Some(j)) + }) + }) + .collect() +} + +/// Rewrite visual rows while keeping the source style on each surviving character. Resource +/// names may differ between Unicode subsets of the same face; encoding searches those subsets. +pub(crate) fn rewrite_styled_block( + doc: &mut Document, + page: usize, + block: &TextBlock, + lines: &[TextLine], + text: Option<&str>, + style: &BlockStyle, + max_glyphs: usize, +) -> Result { + let first = block.lines.first().and_then(|i| lines.get(*i)).ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?; + let draft = match text { + Some(t) if t != block.text => t, + _ => &block.editor_text, + }; + if draft.len() > 4_000_000 { + return Err(EditError::Invalid("the replacement text is too large".into())); + } + if draft == block.editor_text && *style == BlockStyle::default() { + return Ok(LineEdit { substituted: None }); + } + let mut ranges: Vec<_> = style.ranges.iter().collect(); + ranges.sort_by_key(|r| r.range.start); + if ranges.len() > 1024 + || ranges.iter().any(|r| { + r.range.start >= r.range.end + || draft.get(r.range.clone()).is_none() + || [r.size, r.char_spacing, r.scale].iter().flatten().any(|n| !n.is_finite()) + || r.size.is_some_and(|n| n <= 0.0 || n > 14400.0) + || r.color.is_some_and(|c| c.iter().any(|n| !n.is_finite() || !(0.0..=1.0).contains(n))) + }) + || ranges.windows(2).any(|r| r[0].range.end > r[1].range.start) + { + return Err(EditError::Invalid("text style ranges must be disjoint UTF-8 boundaries with finite formatting (at most 1024 ranges)".into())); + } + let source_count = lines.len(); + let mut styled_lines = lines.to_vec(); + let mut styled_spans = Vec::new(); + let mut underlines = HashMap::new(); + let mut overrides = HashMap::new(); + for (span, source) in block.edited_spans(draft) { + let mut edges = vec![span.start, span.end]; + for r in &ranges { + for edge in [r.range.start, r.range.end] { + if span.contains(&edge) { + edges.push(edge); + } + } + } + edges.sort_unstable(); + edges.dedup(); + for bounds in edges.windows(2) { + let (Some(start), Some(end)) = (bounds.first().copied(), bounds.get(1).copied()) else { continue }; + let mut index = source; + let selected = ranges.iter().find(|r| r.range.contains(&start)).copied(); + if selected.is_some() || style.family.is_some() || style.bold.is_some() { + let default_range = RangeStyle { + range: start..end, + family: None, + resource: None, + size: None, + color: None, + underline: None, + char_spacing: None, + scale: None, + }; + let r = selected.unwrap_or(&default_range); + let mut line = lines.get(source).unwrap_or(first).clone(); + if let Some((family, bold, italic)) = + r.family.or(style.family).or_else(|| style.bold.map(|b| (source_family(&line.base_font), b, line.italic))) + { + let base = family.base_font(bold, italic); + line.font = format!("PCRange{}", base.replace('-', "")); + line.base_font = base.to_owned(); + line.bold = bold; + line.italic = italic; + } + if let Some(resource) = &r.resource { + line.font = resource.clone(); + } + if let Some(size) = r.size { + line.size = size; + } + if let Some(color) = r.color { + line.color = color; + line.origin.state.fill = vec![Op::new("rg", color.map(Object::Real).to_vec())]; + } + if let Some(spacing) = r.char_spacing { + line.origin.state.char_spacing = spacing / line.origin.k.max(1e-6); + } + if let Some(scale) = r.scale { + line.origin.state.scale = (scale / 100.0).clamp(0.1, 10.0); + } + index = styled_lines.len(); + styled_lines.push(line); + if let Some(r) = selected { + overrides.insert(index, r); + } + if let Some(underline) = r.underline { + underlines.insert(index, underline); + } + } + styled_spans.push((start..end, index)); + } + } + let lines = styled_lines.as_slice(); + let p = page_dict(doc, page)?; + let mut res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let mut fonts = res.get(b"Font").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + for line in lines.iter().filter(|line| line.font.starts_with("PCRange")) { + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name(&line.base_font)); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + fonts.set(line.font.as_bytes().to_vec(), Object::Dict(f)); + } + let mut metrics = std::collections::BTreeMap::new(); + for (name, object) in fonts.iter().take(256) { + if let Some(font) = doc.resolve(object).as_dict() { + metrics.insert(String::from_utf8_lossy(name).into_owned(), Metrics::from_dict(doc, font)); + } + } + for index in &block.lines { + if let Some(line) = lines.get(*index) + && !metrics.contains_key(&line.font) + && let Some(font) = fonts.get(line.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()) + { + metrics.insert(line.font.clone(), Metrics::from_dict(doc, &font)); + } + } + if [style.size, style.line_spacing, style.char_spacing, style.scale, style.width].iter().flatten().any(|v| !v.is_finite()) + || style.color.is_some_and(|c| c.iter().any(|v| !v.is_finite())) + { + return Err(EditError::Invalid("the paragraph's formatting must use finite numbers".into())); + } + let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); + if !dx.is_finite() || !dy.is_finite() || style.width.is_some_and(|w| !w.is_finite()) { + return Err(EditError::Invalid("the paragraph's position and width must be finite".into())); + } + // A visual row grows even when it contains several source/font runs. Multi-row + // paragraphs retain their container width; an explicit width always wins. + let natural = if block.invisible || block.rows.len() == 1 { + (p.crop(doc)[2] - 36.0 - (block.rect[0] + dx)).max(block.rect[2] - block.rect[0]) + } else { + block.rect[2] - block.rect[0] + }; + let width = style.width.unwrap_or(natural).clamp(1.0, 14_400.0); + // Each tuple is a character, its source style and an encodable resource name. + let mut chars = Vec::new(); + let mut substituted = None; + let mut fallback_groups: Vec = Vec::new(); + let mut fallback_names: std::collections::BTreeMap<(String, bool, char), usize> = std::collections::BTreeMap::new(); + let mut fallback_glyphs = 0_usize; + let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS); + let fallback_budget = per_font.saturating_mul(MAX_STYLED_FALLBACK_BATCHES); + let mut full_faces: HashMap = HashMap::new(); + let mut programs: HashMap>>> = HashMap::new(); + for (range, index) in styled_spans { + let source = lines.get(index).unwrap_or(first); + let start = range.start; + for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() { + if ch == '\n' { + chars.push((ch, index, source.font.clone())); + continue; + } + // A carriage return breaks the row too. Before `\n` the pair is one break, so the + // CR stays a `\r` the rows skip. Either way it keeps its place among the draft's + // characters, which rows are counted in. + if ch == '\r' { + let crlf = start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n')); + chars.push((if crlf { '\r' } else { '\n' }, index, source.font.clone())); + continue; + } + let s = ch.to_string(); + let name = if let Some(resource) = overrides.get(&index).and_then(|r| r.resource.as_ref()).or(style.resource.as_ref()) { + if !metrics.get(resource).is_some_and(|m| source_encodes(m, &s)) { + return Err(EditError::Invalid(format!("the selected font resource {resource} cannot show U+{:04X}", u32::from(ch)))); + } + resource.clone() + } else if metrics.get(&source.font).is_some_and(|m| source_encodes(m, &s)) { + source.font.clone() + } else if let Some((name, _)) = metrics.iter().find(|(_, m)| { + pdfcraft_fonts::embedding::face_key(&m.base_font) == pdfcraft_fonts::embedding::face_key(&source.base_font) && source_encodes(m, &s) + }) { + name.clone() + } else if let Some(face) = + pdfcraft_fonts::embedding::matching_faces(&source.base_font).into_iter().find(|f| pdfcraft_fonts::embedding::covers(f.bytes, &s)) + { + let key = face.name(); + if let Some(name) = full_faces.get(&key) { + name.clone() + } else { + let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, face.bytes, face.family).map_err(EditError::Invalid)?; + let font = fonts + .get(name.as_bytes()) + .and_then(|f| doc.dict(f)) + .ok_or_else(|| EditError::Invalid("the full source font could not be loaded".into()))?; + metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); + full_faces.insert(key, name.clone()); + name + } + } else if let Some(bytes) = programs + .entry(source.font.clone()) + .or_insert_with(|| source_program(doc, &fonts, &source.font).map(std::sync::Arc::new)) + .clone() + .filter(|bytes| pdfcraft_fonts::embedding::covers(bytes, &s)) + { + // The document's own complete program of this font has the glyph: draw it with + // that face rather than another family's fallback. + let key = format!("source {}", source.font); + if let Some(name) = full_faces.get(&key) { + name.clone() + } else { + let family = pdfcraft_fonts::embedding::names(&bytes).map_or_else(|_| source.base_font.clone(), |(family, _)| family); + let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, &bytes, &family).map_err(EditError::Invalid)?; + let font = fonts + .get(name.as_bytes()) + .and_then(|f| doc.dict(f)) + .ok_or_else(|| EditError::Invalid("the source font could not be loaded".into()))?; + metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); + full_faces.insert(key, name.clone()); + name + } + } else { + let family = source_family(&source.base_font); + if needs_type3(&s) { + let key = (source.base_font.clone(), source.bold, ch); + let group_index = if let Some(index) = fallback_names.get(&key) { + *index + } else { + // Keep the old per-character face choice: a serif Japanese character + // and a Cyrillic character may need different faces even in one style. + // Hebrew uses the separate RTL path before reaching this writer. + let (faces, _) = type3_faces(&s, family, source.bold)?; + let face = + fallback_face(&faces, &[ch], |face, ch| japanese_glyph_from(face, ch).is_ok()).ok_or_else(|| no_fallback_font(&s))?; + let index = fallback_groups.iter().position(|group| std::ptr::eq(group.face, face)).unwrap_or_else(|| { + let index = fallback_groups.len(); + fallback_groups.push(FallbackGroup { face, seen: Default::default(), glyphs: Vec::new(), positions: Vec::new() }); + index + }); + fallback_names.insert(key, index); + index + }; + let group = fallback_groups.get_mut(group_index).ok_or_else(|| EditError::Invalid("the fallback face is missing".into()))?; + if group.seen.insert(ch) { + if fallback_glyphs >= fallback_budget { + return Err(EditError::Invalid(format!( + "the replacement needs more than {fallback_budget} fallback glyphs; edit a smaller paragraph or load a complete font" + ))); + } + let (path, width) = type3_glyph(group.face, ch)?.ok_or_else(|| no_glyph_for(group.face, ch))?; + let code = u8::try_from((group.glyphs.len() % per_font).saturating_add(1)) + .map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?; + group.glyphs.push(Type3Glyph { code, ch, path, width }); + fallback_glyphs = fallback_glyphs.saturating_add(1); + } + group.positions.push(chars.len()); + // Filled from the prepared font/code map before measuring or drawing. + String::new() + } else { + let base = family.base_font(source.bold, source.italic); + let name = format!("PCEd{}", base.replace('-', "")); + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name(base)); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + metrics.entry(name.clone()).or_insert_with(|| Metrics::from_dict(doc, &f)); + fonts.set(name.clone().into_bytes(), Object::Dict(f)); + substituted = Some(base.to_string()); + name + } + }; + chars.push((ch, index, name)); + } + } + // All fallback outlines have now succeeded. Batch by actual face, preserving source + // style indices; the existing writer then coalesces adjacent equal-font/style characters. + for group in fallback_groups { + let fallback = write_type3_glyphs(doc, &mut fonts, group.face, false, group.glyphs, per_font); + for name in &fallback.names { + let font = fonts + .get(name.as_bytes()) + .and_then(|f| doc.dict(f)) + .ok_or_else(|| EditError::Invalid("the fallback font could not be loaded".into()))?; + metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); + } + for position in group.positions { + let item = chars.get_mut(position).ok_or_else(|| EditError::Invalid("the fallback character is missing".into()))?; + let (font, _) = fallback.codes.get(&item.0).ok_or_else(|| EditError::Invalid("the fallback glyph is missing".into()))?; + item.2 = fallback.names.get(*font).cloned().ok_or_else(|| EditError::Invalid("the fallback font is missing".into()))?; + } + substituted = Some(fallback.family.to_owned()); + } + let size_of = |source: &TextLine, index: usize| { + overrides.get(&index).and_then(|r| r.size).or(style.size).unwrap_or(source.size).max(0.1) / source.origin.k.max(1e-6) + }; + // A source run may carry TJ kerning/justification that is absent from its text state. + // Keep its measured advance when rebuilding the run instead of making it wrap on entry. + // Each font's space width, worked out once: when a font can't show a space, finding one + // reads every font on the page, and doing that for every character of a paragraph made each + // keystroke in a long paragraph take half a second (#2616). + // Only for the fonts the paragraph uses, when first asked: working out every page font's width + // up front would scan the page once per font for a short edit on a page of many fonts. + let spaces: std::cell::RefCell> = std::cell::RefCell::new(HashMap::new()); + let space_of = |name: &str, m: &Metrics| { + if let Some(w) = spaces.borrow().get(name) { + return *w; + } + let w = source_space(doc, &fonts, m); + spaces.borrow_mut().insert(name.to_owned(), w); + w + }; + let mut calibration = HashMap::new(); + for (index, source) in lines.iter().enumerate() { + if let Some(m) = metrics.get(&source.font) { + let nominal = source_width( + m, + &source.text, + source.origin.state.font.as_ref().map_or(1.0, |(_, s)| *s), + space_of(&source.font, m), + source.origin.state.char_spacing, + source.origin.state.word_spacing, + ) * source.origin.state.scale + * across(source); + let changed_face = style.resource.is_some() || overrides.get(&index).is_some_and(|r| r.family.is_some() || r.resource.is_some()); + let factor = if nominal > 0.0 && !changed_face { ((source.rect[2] - source.rect[0]) / nominal).clamp(0.5, 2.0) } else { 1.0 }; + calibration.insert(index, factor); + } + } + let advance = |item: &(char, usize, String)| -> f64 { + let source = lines.get(item.1).unwrap_or(first); + let Some(m) = metrics.get(&item.2) else { return 0.0 }; + source_width( + m, + &item.0.to_string(), + size_of(source, item.1), + space_of(&item.2, m), + overrides + .get(&item.1) + .and_then(|r| r.char_spacing) + .or(style.char_spacing) + .map_or(source.origin.state.char_spacing, |c| c / source.origin.k.max(1e-6)), + source.origin.state.word_spacing, + ) * overrides.get(&item.1).and_then(|r| r.scale).or(style.scale).map_or(source.origin.state.scale, |s| (s / 100.0).clamp(0.1, 10.0)) + * across(source) + * calibration.get(&item.1).copied().unwrap_or(1.0) + }; + let measure = |items: &[(char, usize, String)]| -> Result { + let mut width = 0.0; + let mut at = 0; + while let Some(item) = items.get(at) { + let mut end = at + 1; + while items.get(end).is_some_and(|v| v.1 == item.1 && v.2 == item.2) { + end += 1; + } + let segment = items.get(at..end).unwrap_or_default(); + let string: String = segment.iter().map(|v| v.0).collect(); + if let Some((_, shaped, _)) = shape_source(doc, &fonts, &item.2, &string)? { + let source = lines.get(item.1).unwrap_or(first); + let scale = + overrides.get(&item.1).and_then(|r| r.scale).or(style.scale).map_or(source.origin.state.scale, |s| (s / 100.0).clamp(0.1, 10.0)); + let spacing = overrides + .get(&item.1) + .and_then(|r| r.char_spacing) + .or(style.char_spacing) + .map_or(source.origin.state.char_spacing, |s| s / source.origin.k.max(1e-6)); + width += (shaped.advance * size_of(source, item.1) + + spacing * string.chars().count() as f64 + + source.origin.state.word_spacing * string.matches(' ').count() as f64) + * scale + * source.origin.k; + } else { + width += segment.iter().map(&advance).sum::(); + } + at = end; + } + Ok(width) + }; + let mut rows: Vec> = Vec::new(); + // Each row's characters, as indexes into the draft's characters (`chars` holds all of them). + let mut row_ranges = Vec::new(); + let mut row_start = 0; + let mut row = Vec::new(); + let mut row_width = 0.0; + // Wrap at word boundaries, retaining explicit source row breaks and whitespace. + let mut at = 0; + while at < chars.len() { + let Some(item) = chars.get(at) else { break }; + // The CR of a CRLF: the `\n` after it breaks the row. + if item.0 == '\r' { + at += 1; + continue; + } + if item.0 == '\n' { + row_ranges.push(row_start..row_start + row.len()); + rows.push(std::mem::take(&mut row)); + row_width = 0.0; + at += 1; + row_start = at; + continue; + } + let mut end = at + 1; + if !item.0.is_whitespace() { + // A word ends at a space, or where Chinese or Japanese text may break between characters. + while let (Some(before), Some(c)) = (chars.get(end - 1), chars.get(end)) + && !c.0.is_whitespace() + && !crate::linebreak::between(before.0, c.0) + { + end += 1; + } + } + let word = chars.get(at..end).unwrap_or_default(); + let w = measure(word)?; + if !row.is_empty() && !item.0.is_whitespace() && row_width + w > width + 0.5 { + while row.last().is_some_and(|c: &(char, usize, String)| c.0.is_whitespace()) { + row.pop(); + } + row_ranges.push(row_start..row_start + row.len()); + rows.push(std::mem::take(&mut row)); + row_width = 0.0; + row_start = at; + } + row.extend_from_slice(word); + row_width += w; + at = end; + } + if !row.is_empty() { + row_ranges.push(row_start..row_start + row.len()); + rows.push(row); + } + let streams = content_streams(doc, &p.dict)?; + let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let ops = pieces.parse().ops; + // A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and + // glyph widths stay exactly as they were instead of being rebuilt from the text state. + let plain = BlockStyle { ranges: Vec::new(), ..style.clone() } == BlockStyle::default(); + let mut kept = if plain { + untouched_rows(block, draft, &row_ranges).into_iter().zip(&rows).map(|(same, row)| same && row.iter().all(|c| c.1 < source_count)).collect() + } else { + vec![false; rows.len()] + }; + let kept_lines: std::collections::HashSet = + kept.iter().enumerate().filter(|(_, k)| **k).filter_map(|(r, _)| block.rows.get(r)).flatten().copied().collect(); + let rewritten: Vec<&TextLine> = block.lines.iter().filter(|i| !kept_lines.contains(i)).filter_map(|i| lines.get(*i)).collect(); + // The rewrite goes where the last rewritten row was drawn, after any backgrounds painted + // between the rows. A dropped `'` or `"` also moved to the next line, which a kept row relies on. + let lead = + rewritten.last().copied().filter(|_| !rewritten.iter().flat_map(|l| &l.ops).any(|i| ops.get(*i).is_some_and(|op| op.is("'") || op.is("\"")))); + let (members, lead) = match lead { + Some(lead) if !kept_lines.is_empty() => (rewritten, lead), + _ => { + kept.iter_mut().for_each(|k| *k = false); + let all: Vec<&TextLine> = block.lines.iter().filter_map(|i| lines.get(*i)).collect(); + let last = all.last().copied().unwrap_or(first); + (all, last) + } + }; + let o = &lead.origin; + let mut drop = coincident_ops(lines, &members); + drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); + let start = lead.ops.first().copied().unwrap_or(o.bt_op); + let split = ops + .get(o.bt_op..start) + .unwrap_or_default() + .iter() + .enumerate() + .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i))); + // What is in force where the rewrite is drawn, and what the text after it expects. + let after = if split { &o.state } else { &o.bt_state }; + for (row, _) in rows.iter().zip(&kept).filter(|(_, k)| !**k) { + let text: String = row.iter().map(|v| v.0).collect(); + if pdfcraft_fonts::source_shaping::required(&text) && row.first().is_some_and(|first| row.iter().any(|v| v.1 != first.1 || v.2 != first.2)) { + return Err(EditError::Invalid( + "contextual/bidi text across font or style boundaries needs a uniform full-face row; mixed contextual runs are not yet supported" + .into(), + )); + } + } + let measured: Vec<_> = rows.iter().map(|r| measure(r)).collect::>()?; + let mut shaped_runs = HashMap::new(); + let mut segment_advances = HashMap::new(); + for (row_index, row) in rows.iter().enumerate().filter(|(r, _)| !kept.get(*r).copied().unwrap_or(false)) { + let mut at = 0; + while let Some(item) = row.get(at) { + let mut end = at + 1; + while row.get(end).is_some_and(|v| v.1 == item.1 && v.2 == item.2) { + end += 1; + } + segment_advances.insert((row_index, at), row.get(at..end).unwrap_or_default().iter().map(&advance).sum::()); + let string: String = row.get(at..end).unwrap_or_default().iter().map(|v| v.0).collect(); + if let Some((bytes, shaped, family)) = shape_source(doc, &fonts, &item.2, &string)? { + shaped_runs.insert((row_index, at), (bytes, shaped, family)); + } + at = end; + } + } + let mut glyph_resources = HashMap::new(); + for (key, (bytes, shaped, family)) in &shaped_runs { + let mappings: Vec<_> = shaped.glyphs.iter().map(|g| (g.text.clone(), g.id)).collect(); + let resource = pdfcraft_fonts::embedding::embed_mapping(doc, &mut fonts, bytes, family, &mappings).map_err(EditError::Invalid)?; + glyph_resources.insert(*key, resource); + } + let n = pdfcraft_content::num; + let mut block_ops = vec![Op::new("q", vec![])]; + block_ops.extend(reveal_ops(block, lines, style, o.ctm)?); + let back = Matrix([o.ctm[0], o.ctm[1], o.ctm[2], o.ctm[3], 0.0, 0.0]) + .invert() + .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?; + let (tx, ty) = back.apply(dx, dy); + block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(tx), n(ty)])); + let crop = p.crop(doc); + let centered = is_centred(block, lines, (crop[0] + crop[2]) / 2.0, first.size); + let source_leading = block + .rows + .windows(2) + .filter_map(|rows| { + let a = rows[0].first().and_then(|i| lines.get(*i))?; + let b = rows[1].first().and_then(|i| lines.get(*i))?; + Some((a.origin.baseline - b.origin.baseline).abs()) + }) + .rfind(|v| *v > 0.0) + .unwrap_or(first.size * 1.5); + let mut baseline = first.origin.baseline; + for (row_index, row) in rows.iter().enumerate() { + let anchor = block.rows.get(row_index).and_then(|r| r.first()).and_then(|i| lines.get(*i)); + if style.line_spacing.is_none() + && style.size.is_none() + && let Some(anchor) = anchor + { + baseline = anchor.origin.baseline; + } + let anchor = anchor.unwrap_or(first); + if kept.get(row_index).copied().unwrap_or(false) { + // Kept rows only occur without spacing or size changes, so this is the step below. + baseline -= source_leading; + continue; + } + let w = measured.get(row_index).copied().unwrap_or(0.0); + let align = style.align.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left }); + let original_rect = block.rows.get(row_index).and_then(|row| row_rect(row, lines)).unwrap_or(block.rect); + let left = if style.width.is_none() && style.align.is_none() { original_rect[0] } else { block.rect[0] }; + let available = if style.width.is_none() && style.align.is_none() { original_rect[2] - original_rect[0] } else { width }; + let mut x = left + + match align { + crate::added::Align::Center => (available - w) / 2.0, + crate::added::Align::Right => available - w, + _ => 0.0, + }; + let mut pos = 0; + while pos < row.len() { + let Some(item) = row.get(pos) else { break }; + let source = lines.get(item.1).unwrap_or(first); + let mut end = pos + 1; + while row.get(end).is_some_and(|other| other.1 == item.1 && other.2 == item.2) { + end += 1; + } + let segment = row.get(pos..end).unwrap_or_default(); + let string: String = segment.iter().map(|c| c.0).collect(); + let m = metrics.get(&item.2).ok_or_else(|| EditError::Invalid("the paragraph font is missing".into()))?; + let mut state = source.origin.state.clone(); + state.font = Some((item.2.clone(), size_of(source, item.1))); + if let Some(c) = overrides.get(&item.1).and_then(|r| r.char_spacing).or(style.char_spacing) { + state.char_spacing = c / source.origin.k.max(1e-6); + } + if let Some(s) = overrides.get(&item.1).and_then(|r| r.scale).or(style.scale) { + state.scale = (s / 100.0).clamp(0.1, 10.0); + } + if !shaped_runs.contains_key(&(row_index, pos)) { + state.scale *= calibration.get(&item.1).copied().unwrap_or(1.0); + } + if let Some([r, g, b]) = overrides.get(&item.1).and_then(|r| r.color).or(style.color) { + state.fill = vec![Op::new("rg", vec![n(r), n(g), n(b)])]; + } + let inverse = Matrix(o.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; + let mut tm = Matrix(source.origin.tm).then(&Matrix(source.origin.ctm)).then(&inverse).0; + let (ax, ay) = back.apply(x - source.origin.x, baseline - source.origin.baseline); + tm[4] += ax; + tm[5] += ay; + let mut segment_width = segment_advances.get(&(row_index, pos)).copied().unwrap_or(0.0); + // Each run in its own graphics state, restating what its source set inside its text + // object (`gs`, stroke, line width, `Tr`; #3508/#3509) from the state the rewrite is + // drawn in: a `gs` has no inverse, so one run's ExtGState would stay for the next. + block_ops.push(Op::new("q", vec![])); + block_ops.extend(state.paint_ops(after)); + if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) { + let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; + let mut properties = Dict::new(); + properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(&string))); + block_ops.push(Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)])); + state.font = Some((resource.clone(), size_of(source, item.1))); + // One text object for the run: each glyph is placed where shaping put it by a TJ + // adjustment from where the glyph before it ends (its advance in the embedded + // font's widths), and raised by the text rise for a mark set above or below + // (ISO 32000-1 §9.4.3, §9.3.7). Font-program codes are separate from Unicode; + // ToUnicode and ActualText keep the logical text. + let size = size_of(source, item.1); + let per_unit = 1000.0 / size; + if !per_unit.is_finite() { + return Err(EditError::Invalid("the shaped text has no size".into())); + } + let shown = fonts + .get(resource.as_bytes()) + .and_then(|f| doc.resolve(f).as_dict().cloned()) + .map(|d| Metrics::from_dict(doc, &d)) + .ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; + // Spacing is part of each glyph's position, so the run itself has none. + let (char_spacing, word_spacing, rise) = (state.char_spacing, state.word_spacing, state.rise); + state.char_spacing = 0.0; + state.word_spacing = 0.0; + block_ops.push(Op::new("BT", vec![])); + block_ops.extend(state.ops()); + block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); + // `pen`: where the next glyph would start, in thousandths of the font size. + let (mut pen, mut extra, mut raised) = (0.0, 0.0, rise); + let mut shown_glyphs: Vec = Vec::new(); + for (i, glyph) in shaped.glyphs.iter().enumerate() { + let target = (glyph.position[0] * size + extra) * per_unit; + let glyph_rise = rise + glyph.position[1] * size; + if (glyph_rise - raised).abs() > 1e-9 || shown_glyphs.len() >= 4096 { + if !shown_glyphs.is_empty() { + block_ops.push(Op::new("TJ", vec![Object::Array(std::mem::take(&mut shown_glyphs))])); + } + if (glyph_rise - raised).abs() > 1e-9 { + block_ops.push(Op::new("Ts", vec![n(glyph_rise)])); + raised = glyph_rise; + } + } + if (target - pen).abs() > 1e-6 { + shown_glyphs.push(n(pen - target)); + } + let code = u16::try_from(i + 1).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?; + shown_glyphs.push(Object::String(PdfString::literal(code.to_be_bytes().to_vec()))); + pen = target + shown.width(u32::from(code)) * 1000.0; + extra += char_spacing * glyph.text.chars().count() as f64 + word_spacing * glyph.text.matches(' ').count() as f64; + } + if !shown_glyphs.is_empty() { + block_ops.push(Op::new("TJ", vec![Object::Array(shown_glyphs)])); + } + if (raised - rise).abs() > 1e-9 { + block_ops.push(Op::new("Ts", vec![n(rise)])); + } + block_ops.push(Op::new("ET", vec![])); + (state.char_spacing, state.word_spacing) = (char_spacing, word_spacing); + block_ops.push(Op::new("EMC", vec![])); + segment_width = (shaped.advance * size + extra) * state.scale * source.origin.k; + } else { + block_ops.push(Op::new("BT", vec![])); + block_ops.extend(state.ops()); + block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); + block_ops.extend(source_show( + m, + &string, + size_of(source, item.1), + spaces.borrow().get(&item.2).copied().unwrap_or_else(|| source_space(doc, &fonts, m)), + state.char_spacing, + state.word_spacing, + )?); + block_ops.push(Op::new("ET", vec![])); + } + if underlines.get(&item.1).copied().or(style.underline) == Some(true) { + let [r, g, b] = overrides.get(&item.1).and_then(|r| r.color).or(style.color).unwrap_or(source.color); + let underline_size = size_of(source, item.1) * source.origin.k; + let (x0, y0) = back.apply(x - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); + let (x1, y1) = back.apply(x + segment_width - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); + block_ops.push(Op::new("RG", vec![n(r), n(g), n(b)])); + block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / o.ctm[2].hypot(o.ctm[3]).max(1e-6))])); + block_ops.push(Op::new("m", vec![n(x0), n(y0)])); + block_ops.push(Op::new("l", vec![n(x1), n(y1)])); + block_ops.push(Op::new("S", vec![])); + } + block_ops.push(Op::new("Q", vec![])); + x += segment_width; + pos = end; + } + baseline -= if style.line_spacing.is_none() && style.size.is_none() { + source_leading + } else { + style.line_spacing.unwrap_or(1.5).clamp(0.5, 5.0) * style.size.unwrap_or(anchor.size) + }; + } + block_ops.push(Op::new("Q", vec![])); + block_ops.extend(after.ops()); + if split { + block_ops.insert(0, Op::new("ET", vec![])); + block_ops.push(Op::new("BT", vec![])); + block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect())); + } + let at = if split { start } else { o.bt_op }; + let data = splice(&pieces, &ops, |i| (if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }, !drop.contains(&i))); + let contents = rewritten_contents(doc, &streams, data); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + doc.update_dict(p.obj, |d| { + d.set(b"Contents".to_vec(), contents); + d.set(b"Resources".to_vec(), Object::Dict(res)); + })?; + Ok(LineEdit { substituted }) +}