mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 20:18:06 -06:00
refactor(edit): split text_regions.rs into text_regions/
New modules: styled (styled-block shaping and rewrite), grouping (row/block grouping).
This commit is contained in:
+6
-1101
File diff suppressed because it is too large
Load Diff
@@ -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<f64> {
|
||||
let mut edges: HashMap<i64, usize> = 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<TextBlock> {
|
||||
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<TextBlock> {
|
||||
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::<usize>();
|
||||
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<TextBlock> {
|
||||
let mut rows: Vec<Vec<usize>> = 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<TextBlock> = 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
|
||||
}
|
||||
@@ -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<char>,
|
||||
glyphs: Vec<Type3Glyph>,
|
||||
positions: Vec<usize>,
|
||||
}
|
||||
|
||||
/// 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<Option<(Vec<u8>, 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<usize>]) -> Vec<bool> {
|
||||
let old: Vec<char> = block.editor_text.chars().collect();
|
||||
let new: Vec<char> = 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<LineEdit, EditError> {
|
||||
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<FallbackGroup> = 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<String, String> = HashMap::new();
|
||||
let mut programs: HashMap<String, Option<std::sync::Arc<Vec<u8>>>> = 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<HashMap<String, f64>> = 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<f64, EditError> {
|
||||
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::<f64>();
|
||||
}
|
||||
at = end;
|
||||
}
|
||||
Ok(width)
|
||||
};
|
||||
let mut rows: Vec<Vec<(char, usize, String)>> = 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::<Vec<_>>());
|
||||
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<usize> =
|
||||
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::<Result<_, _>>()?;
|
||||
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::<f64>());
|
||||
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<Object> = 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 })
|
||||
}
|
||||
Reference in New Issue
Block a user