Files
pdfcraft/crates/edit/src/text.rs
T
Brandon ThomasandClaude Opus 5.5 ac222e818b Never crash: edit and xfdf have no expect calls
- edit: the substitute font name is a `&str` constant, so there's no `from_utf8(..).expect`.
  Paragraph grouping uses `let … else` / `if let` chains, and the reuse-font path takes the
  metrics with `filter` instead of `expect("checked")`.
- xfdf: a comment key that vanished between listing and lookup is skipped.

Both crates now deny the panic lints. Adds the root clippy.toml (identical to #11) so tests may
still unwrap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:23:06 -04:00

796 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Edit a PDF ▸ Edit text: the text lines already on a page, and replacing a line's text in place.
//!
//! A *line* is a run of text-showing operators inside one `BT … ET` on the same baseline, read
//! from one content stream. Replacing it rewrites only that stream (as a new object, since content
//! streams may be shared): the first operator of the line shows the new text, the others are
//! dropped, and every positioning operator stays, so the lines after it keep their places
//! (`Td`, `T*` and friends move from the line matrix, which showing text doesn't change).
//!
//! The new text uses the line's own font when every character has a code and a glyph in it;
//! otherwise it is set in Helvetica (WinAnsi), and the result says the font was substituted.
//! Reflowing a paragraph to a new width is not done here (see `edit.text-reflow`).
use std::collections::HashMap;
use std::rc::Rc;
use printcraft_content::{Matrix, Op, parse, serialize_ops};
use printcraft_cos::{Dict, Document, Object, PdfString, Stream};
use printcraft_fonts::pdf::Metrics;
use crate::EditError;
/// One line of existing text.
#[derive(Clone, Debug, PartialEq)]
pub struct TextLine {
/// The line's text as shown.
pub text: String,
/// Its box in user space.
pub rect: [f64; 4],
/// The font's resource name and `/BaseFont`, and its size in text space.
pub font: String,
pub base_font: String,
pub size: f64,
/// Whether new text in this font can only be shown by substituting another font (no
/// Unicode mapping for the line, so nothing could be reused).
pub decodable: bool,
stream: usize,
ops: Vec<usize>,
/// Where the line starts: text matrix (text space), the state there, and its `BT`.
origin: Origin,
}
/// Text and graphics state captured for rewriting.
#[derive(Clone, Debug, PartialEq)]
struct TextState {
font: Option<(String, f64)>,
char_spacing: f64,
word_spacing: f64,
scale: f64,
leading: f64,
rise: f64,
fill: Vec<Op>,
}
impl TextState {
fn of(ts: &Ts) -> Self {
TextState {
font: ts.font.as_ref().map(|(n, _)| (String::from_utf8_lossy(n).into_owned(), ts.size)),
char_spacing: ts.char_spacing,
word_spacing: ts.word_spacing,
scale: ts.scale,
leading: ts.leading,
rise: ts.rise,
fill: ts.fill.clone(),
}
}
/// Operators that set this state (inside a text object).
fn ops(&self) -> Vec<Op> {
let n = printcraft_content::num;
let mut v = Vec::new();
if let Some((f, size)) = &self.font {
v.push(Op::new("Tf", vec![Object::name(f), n(*size)]));
}
v.push(Op::new("Tc", vec![n(self.char_spacing)]));
v.push(Op::new("Tw", vec![n(self.word_spacing)]));
v.push(Op::new("Tz", vec![n(self.scale * 100.0)]));
v.push(Op::new("TL", vec![n(self.leading)]));
v.push(Op::new("Ts", vec![n(self.rise)]));
v.extend(self.fill.iter().cloned());
v
}
}
#[derive(Clone, Debug, PartialEq)]
struct Origin {
tm: [f64; 6],
/// The text line matrix there (where the next line's Td/T* starts from).
tlm: [f64; 6],
/// Text-space units → user space (the scale of the text rendering matrix's y axis).
k: f64,
baseline: f64,
x: f64,
state: TextState,
bt_op: usize,
bt_state: TextState,
}
/// A paragraph: consecutive lines in one stream with the same font and size, aligned on the left
/// and regularly spaced.
#[derive(Clone, Debug, PartialEq)]
pub struct TextBlock {
/// The lines' text joined with spaces (lines that ended in a hyphen are joined directly).
pub text: String,
pub rect: [f64; 4],
pub base_font: String,
pub size: f64,
/// Indexes into [`text_lines`].
pub lines: Vec<usize>,
}
impl TextLine {
/// The baseline's height in user space.
pub fn origin_baseline(&self) -> f64 {
self.origin.baseline
}
}
/// What replacing a line did.
#[derive(Clone, Debug, PartialEq)]
pub struct LineEdit {
/// `Some(font)` when the line's font couldn't show the new text and Helvetica was used.
pub substituted: Option<String>,
}
#[derive(Clone)]
struct Ts {
ctm: Matrix,
/// The operators that set the current fill colour (`g`, `rg`, `k`, or `cs` + `sc`/`scn`).
fill: Vec<Op>,
font: Option<(Vec<u8>, Rc<Metrics>)>,
size: f64,
char_spacing: f64,
word_spacing: f64,
scale: f64,
leading: f64,
rise: f64,
}
fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
let list: Vec<Object> = match page.get(b"Contents") {
None => Vec::new(),
Some(c) => match &*doc.resolve(c) {
Object::Array(a) => a.clone(),
_ => vec![c.clone()],
},
};
list.into_iter()
.map(|o| {
let data = match &*doc.resolve(&o) {
Object::Stream(s) => s.decoded().unwrap_or_default(),
_ => Vec::new(),
};
(o, data)
})
.collect()
}
fn page_dict(doc: &Document, page: usize) -> Result<printcraft_model::Page, EditError> {
printcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))
}
/// The text-showing operators of one stream with their text, font, box and baseline.
struct Shown {
op: usize,
/// The text matrix where it starts (text space), and the state there and at its `BT`.
tm: Matrix,
tlm: Matrix,
state: Ts,
bt_op: usize,
bt_state: Ts,
text: String,
rect: [f64; 4],
baseline: f64,
start_x: f64,
end_x: f64,
bt: usize,
font: Vec<u8>,
base_font: String,
size: f64,
decodable: bool,
}
fn interpret(doc: &Document, ops: &[Op], fonts_res: &Dict, cache: &mut HashMap<Vec<u8>, Rc<Metrics>>) -> Vec<Shown> {
let mut out = Vec::new();
let mut ts = Ts {
ctm: Matrix::IDENTITY,
fill: Vec::new(),
font: None,
size: 0.0,
char_spacing: 0.0,
word_spacing: 0.0,
scale: 1.0,
leading: 0.0,
rise: 0.0,
};
let mut at_bt = (0usize, ts.clone());
let mut stack: Vec<Ts> = Vec::new();
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
let mut bt = 0usize;
for (i, op) in ops.iter().enumerate() {
match op.op.as_slice() {
b"q" => stack.push(ts.clone()),
b"Q" => {
if let Some(s) = stack.pop() {
ts = s;
}
}
b"cm" => {
if let Some(m) = op.nums::<6>() {
ts.ctm = Matrix(m).then(&ts.ctm);
}
}
b"BT" => {
tm = Matrix::IDENTITY;
tlm = Matrix::IDENTITY;
bt += 1;
at_bt = (i, ts.clone());
}
b"g" | b"rg" | b"k" | b"cs" => ts.fill = vec![op.clone()],
b"sc" | b"scn" => {
ts.fill.retain(|o| o.is("cs"));
ts.fill.push(op.clone());
}
b"Tf" => {
ts.size = op.num(1).unwrap_or(ts.size);
if let Some(name) = op.name(0) {
let m = cache.entry(name.to_vec()).or_insert_with(|| {
Rc::new(
fonts_res
.get(name)
.and_then(|f| doc.resolve(f).as_dict().cloned())
.map(|d| Metrics::from_dict(doc, &d))
.unwrap_or_else(Metrics::fallback),
)
});
ts.font = Some((name.to_vec(), m.clone()));
}
}
b"Tc" => ts.char_spacing = op.num(0).unwrap_or(0.0),
b"Tw" => ts.word_spacing = op.num(0).unwrap_or(0.0),
b"Tz" => ts.scale = op.num(0).unwrap_or(100.0) / 100.0,
b"TL" => ts.leading = op.num(0).unwrap_or(0.0),
b"Ts" => ts.rise = op.num(0).unwrap_or(0.0),
b"Td" | b"TD" => {
if let Some([x, y]) = op.nums::<2>() {
if op.is("TD") {
ts.leading = -y;
}
tlm = Matrix([1.0, 0.0, 0.0, 1.0, x, y]).then(&tlm);
tm = tlm;
}
}
b"Tm" => {
if let Some(m) = op.nums::<6>() {
tlm = Matrix(m);
tm = tlm;
}
}
b"T*" => {
tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(&tlm);
tm = tlm;
}
b"Tj" | b"TJ" | b"'" | b"\"" => {
if matches!(op.op.as_slice(), b"'" | b"\"") {
if op.is("\"") {
ts.word_spacing = op.num(0).unwrap_or(ts.word_spacing);
ts.char_spacing = op.num(1).unwrap_or(ts.char_spacing);
}
tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(&tlm);
tm = tlm;
}
let Some((name, m)) = ts.font.clone() else { continue };
// Pieces: strings, and TJ number adjustments.
let pieces: Vec<Object> = match op.op.as_slice() {
b"TJ" => op.operands.first().and_then(Object::as_array).cloned().unwrap_or_default(),
_ => op.operands.last().cloned().into_iter().collect(),
};
let trm0 = tm.then(&ts.ctm);
let start = trm0.apply(0.0, ts.rise);
let mut text = String::new();
let mut decodable = true;
let mut x_text = 0.0;
for p in &pieces {
match p {
Object::String(s) => {
for (code, len) in m.codes(&s.bytes) {
match m.text_of(code) {
Some(t) => text.push_str(t),
None => decodable = false,
}
let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
x_text += w * ts.scale;
}
}
other => {
if let Some(n) = other.as_f64() {
let dx = -n / 1000.0 * ts.size * ts.scale;
// A large gap inside TJ reads as a space.
if dx > ts.size * 0.2 && !text.ends_with(' ') {
text.push(' ');
}
x_text += dx;
}
}
}
}
tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, 0.0]).then(&tm);
let end = tm.then(&ts.ctm).apply(0.0, ts.rise);
// The box: from descent to ascent along the run.
let corners = [
trm0.apply(0.0, ts.rise + m.descent * ts.size),
trm0.apply(x_text, ts.rise + m.descent * ts.size),
trm0.apply(0.0, ts.rise + m.ascent * ts.size),
trm0.apply(x_text, ts.rise + m.ascent * ts.size),
];
let rect = corners
.iter()
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size;
out.push(Shown {
op: i,
tm: Matrix([tm.0[0], tm.0[1], tm.0[2], tm.0[3], tm.0[4] - x_text * tm.0[0], tm.0[5] - x_text * tm.0[1]]),
state: ts.clone(),
tlm,
bt_op: at_bt.0,
bt_state: at_bt.1.clone(),
text,
rect,
baseline: start.1,
start_x: start.0,
end_x: end.0,
bt,
font: name,
base_font: m.base_font.clone(),
size: if size_user > 0.0 { size_user } else { ts.size },
decodable,
});
}
_ => {}
}
}
out
}
/// The lines of text on a page (0-based), in content order.
pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditError> {
let p = page_dict(doc, page)?;
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let mut cache = HashMap::new();
let mut lines: Vec<TextLine> = Vec::new();
for (si, (_, data)) in content_streams(doc, &p.dict).into_iter().enumerate() {
let ops = parse(&data).ops;
let shown = interpret(doc, &ops, &fonts_res, &mut cache);
let mut last: Option<(usize, f64, f64, f64)> = None; // (bt, baseline, end_x, size)
for s in shown {
let joins = last.is_some_and(|(bt, base, end, size)| {
bt == s.bt && (s.baseline - base).abs() < size * 0.3 && s.start_x > end - size && s.start_x - end < size * 3.0
});
if joins && let Some(l) = lines.last_mut() {
let gap = s.start_x - last.map_or(s.start_x, |x| x.2);
if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
l.text.push(' ');
}
l.text.push_str(&s.text);
l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])];
l.ops.push(s.op);
l.decodable &= s.decodable;
} else {
lines.push(TextLine {
text: s.text.clone(),
rect: s.rect,
font: String::from_utf8_lossy(&s.font).into_owned(),
base_font: s.base_font.clone(),
size: s.size,
decodable: s.decodable,
stream: si,
ops: vec![s.op],
origin: Origin {
tm: s.tm.0,
tlm: s.tlm.0,
k: (s.tm.then(&s.state.ctm).0[2].powi(2) + s.tm.then(&s.state.ctm).0[3].powi(2)).sqrt(),
baseline: s.baseline,
x: s.start_x,
state: TextState::of(&s.state),
bt_op: s.bt_op,
bt_state: TextState::of(&s.bt_state),
},
});
}
last = Some((s.bt, s.baseline, s.end_x, s.size.max(1.0)));
}
}
// Lines of only spaces aren't editable text.
lines.retain(|l| !l.text.trim().is_empty());
Ok(lines)
}
/// Text → the bytes that show it in the chosen font.
type Encoder = Box<dyn Fn(&str) -> Option<Vec<u8>>>;
/// The substitute font's resource name.
const SUBSTITUTE_NAME: &str = "PCEdHelv";
const SUBSTITUTE: &[u8] = SUBSTITUTE_NAME.as_bytes();
/// Replace the text of line `line` (an index into [`text_lines`]) on `page` with `text`.
pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) -> Result<LineEdit, EditError> {
let text = text.replace(['\n', '\r'], " ");
let lines = text_lines(doc, page)?;
let target = lines.get(line).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no line {}", page + 1, line + 1)))?;
let p = page_dict(doc, page)?;
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let streams = content_streams(doc, &p.dict);
let (stream_obj, data) = streams.get(target.stream).cloned().ok_or_else(|| EditError::Invalid("the page's content changed".into()))?;
let mut ops = parse(&data).ops;
let first = target.ops[0];
// The line's own font, when it can show every character.
let font = fonts_res.get(target.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d));
let reused = font.as_ref().and_then(|m| m.encode(&text));
let mut substituted = None;
let mut replacement: Vec<Op> = Vec::new();
// ' and " also move to the next line; keep that.
match ops[first].op.as_slice() {
b"'" => replacement.push(Op::new("T*", vec![])),
b"\"" => {
replacement.push(Op::new("Tw", vec![ops[first].operands.first().cloned().unwrap_or(Object::Int(0))]));
replacement.push(Op::new("Tc", vec![ops[first].operands.get(1).cloned().unwrap_or(Object::Int(0))]));
replacement.push(Op::new("T*", vec![]));
}
_ => {}
}
match reused {
Some(bytes) => replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])),
None => {
let win = printcraft_fonts::win_ansi(&text);
// WinAnsi turns what it can't show into '?'; refuse rather than print the wrong thing.
let back: String = win.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect();
if text.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') {
return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor Helvetica can show", target.base_font)));
}
// The size in text space: the current Tf's size.
let size = font_size_before(&ops, first).unwrap_or(target.size);
replacement.push(Op::new("Tf", vec![Object::name(SUBSTITUTE_NAME), printcraft_content::num(size)]));
replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))]));
replacement.push(Op::new("Tf", vec![Object::name(&target.font), printcraft_content::num(size)]));
substituted = Some("Helvetica".to_string());
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("Helvetica"));
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
fonts_res.set(SUBSTITUTE.to_vec(), Object::Dict(f));
}
}
// Rebuild: the line's first operator becomes the replacement, its others go.
let drop: std::collections::HashSet<usize> = target.ops[1..].iter().copied().collect();
let mut new_ops = Vec::with_capacity(ops.len() + replacement.len());
for (i, op) in ops.drain(..).enumerate() {
if i == first {
new_ops.append(&mut replacement);
} else if !drop.contains(&i) {
new_ops.push(op);
}
}
let mut dict = match &*doc.resolve(&stream_obj) {
Object::Stream(s) => s.dict.clone(),
_ => Dict::new(),
};
dict.remove(b"Length");
let new = doc.add(Object::Stream(Stream::flate(dict, &serialize_ops(&new_ops))));
let contents: Vec<Object> = streams.iter().enumerate().map(|(i, (o, _))| if i == target.stream { Object::Ref(new) } else { o.clone() }).collect();
let page_ref = p.obj;
if substituted.is_some() {
res.set(b"Font".to_vec(), Object::Dict(fonts_res));
}
doc.update_dict(page_ref, |d| {
d.set(b"Contents".to_vec(), if contents.len() == 1 { contents[0].clone() } else { Object::Array(contents) });
if substituted.is_some() {
d.set(b"Resources".to_vec(), Object::Dict(res));
}
})?;
Ok(LineEdit { substituted })
}
/// The size of the last `Tf` before operator `at`.
fn font_size_before(ops: &[Op], at: usize) -> Option<f64> {
ops[..at].iter().rev().find(|o| o.is("Tf")).and_then(|o| o.num(1))
}
/// The paragraphs on a page (0-based).
pub fn text_blocks(doc: &Document, page: usize) -> Result<Vec<TextBlock>, EditError> {
let lines = text_lines(doc, page)?;
Ok(group_blocks(&lines))
}
/// Lines that share a left edge, a centre or a right edge (left, centred or right-aligned text).
fn aligned(a: &TextLine, b: &TextLine) -> bool {
let tol = b.size.max(1.0);
let centre = |r: [f64; 4]| (r[0] + r[2]) / 2.0;
(a.origin.x - b.origin.x).abs() < tol || (centre(a.rect) - centre(b.rect)).abs() < tol || (a.rect[2] - b.rect[2]).abs() < tol
}
fn group_blocks(lines: &[TextLine]) -> Vec<TextBlock> {
let mut blocks: Vec<TextBlock> = Vec::new();
let mut gap: Option<f64> = None;
for (i, l) in lines.iter().enumerate() {
let joins = i > 0
&& blocks.last().is_some_and(|b| {
let Some(&p) = b.lines.last() else { return false };
let prev = &lines[p];
let g = prev.origin.baseline - l.origin.baseline;
prev.stream == l.stream
&& prev.font == l.font
&& (prev.size - l.size).abs() < 0.01
&& aligned(prev, l)
&& g > l.size * 0.8
&& g < l.size * 2.5
&& gap.is_none_or(|first| (g - first).abs() < first * 0.2)
});
if joins
&& let Some(b) = blocks.last_mut()
&& let Some(&p) = b.lines.last()
{
let prev = &lines[p];
gap.get_or_insert(prev.origin.baseline - l.origin.baseline);
if b.text.ends_with('-') {
b.text.pop();
} else {
b.text.push(' ');
}
b.text.push_str(l.text.trim());
b.rect = [b.rect[0].min(l.rect[0]), b.rect[1].min(l.rect[1]), b.rect[2].max(l.rect[2]), b.rect[3].max(l.rect[3])];
b.lines.push(i);
} else {
gap = None;
blocks.push(TextBlock { text: l.text.trim().to_string(), rect: l.rect, base_font: l.base_font.clone(), size: l.size, lines: vec![i] });
}
}
blocks
}
/// Greedy word wrapping to `width` with `advance` giving a string's width.
fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<String> {
let mut out = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
if !line.is_empty() && advance(&candidate) > width {
out.push(std::mem::take(&mut line));
line = word.to_string();
} else {
line = candidate;
}
}
if !line.is_empty() || out.is_empty() {
out.push(line);
}
out
}
/// Replace paragraph `block` (an index into [`text_blocks`]) with `text`, rewrapped to the
/// paragraph's width with its line spacing. The paragraph keeps its first line's position, font
/// (or Helvetica when the font can't show the text), size and colour.
pub fn replace_block(doc: &mut Document, page: usize, block: usize, text: &str) -> Result<LineEdit, EditError> {
rewrite_block(doc, page, block, Some(text), &BlockStyle::default())
}
/// Formatting for a rewritten paragraph (`None` keeps the paragraph's own).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct BlockStyle {
/// A standard font family, bold, italic.
pub family: Option<(crate::added::Family, bool, bool)>,
/// Font size in points (user space).
pub size: Option<f64>,
/// Fill colour (RGB 0–1).
pub color: Option<[f64; 3]>,
pub align: Option<crate::added::Align>,
/// Draw a line under each line of text.
pub underline: Option<bool>,
/// Line spacing as a multiple of the font size (1.2 is ordinary).
pub line_spacing: Option<f64>,
/// Character spacing (points) and horizontal scaling (percent).
pub char_spacing: Option<f64>,
pub scale: Option<f64>,
}
/// Rewrite paragraph `block` with new text (or its own) and formatting, rewrapped to its width.
pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option<&str>, style: &BlockStyle) -> Result<LineEdit, EditError> {
let lines = text_lines(doc, page)?;
let blocks = group_blocks(&lines);
let b = blocks.get(block).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?;
let members: Vec<&TextLine> = b.lines.iter().map(|i| &lines[*i]).collect();
let first = members[0];
let p = page_dict(doc, page)?;
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let streams = content_streams(doc, &p.dict);
let (stream_obj, data) = streams.get(first.stream).cloned().ok_or_else(|| EditError::Invalid("the page's content changed".into()))?;
let ops = parse(&data).ops;
let text = text.unwrap_or(&b.text).split_whitespace().collect::<Vec<_>>().join(" ");
let o = &first.origin;
let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?;
let k = o.k.max(1e-6);
// The size in text space: points ÷ the text-to-user scale.
let size = style.size.map_or(old_size, |pt| (pt / k).max(0.1));
// Spacing and scaling (text state), with the chosen values.
let mut ts_state = o.state.clone();
if let Some(c) = style.char_spacing {
ts_state.char_spacing = c / k;
}
if let Some(sc) = style.scale {
ts_state.scale = (sc / 100.0).clamp(0.1, 10.0);
}
let o_state = ts_state.clone();
let metrics = fonts_res.get(font_name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d));
let reuse = style.family.is_none() && metrics.as_ref().is_some_and(|m| m.encode(&text).is_some());
// The standard font used when the paragraph's own can't be (chosen, or substituted).
let (family, bold, italic) = style.family.unwrap_or((crate::added::Family::Helvetica, false, false));
let std_width = move |s: &str, size: f64| -> f64 {
match family {
crate::added::Family::Courier => s.chars().count() as f64 * 0.6 * size,
crate::added::Family::Times => printcraft_fonts::helvetica_width(s, size) * 0.92,
crate::added::Family::Helvetica => printcraft_fonts::helvetica_width(s, size) * if bold { 1.05 } else { 1.0 },
}
};
// A paragraph keeps its width; a single line grows to the right, up to the page's margin.
let mut width = (b.rect[2] - b.rect[0]).max(size * k);
if members.len() == 1 {
let crop = p.crop(doc);
width = width.max(crop[2] - 36.0 - b.rect[0]);
}
let advance = |s: &str| -> f64 {
let t = match (&metrics, reuse) {
(Some(m), true) => m
.encode(s)
.map(|bytes| {
m.codes(&bytes)
.iter()
.map(|(c, l)| m.width(*c) * size + o_state.char_spacing + if m.is_space(*c, *l) { o_state.word_spacing } else { 0.0 })
.sum::<f64>()
})
.unwrap_or(0.0),
_ => std_width(s, size) + s.chars().count() as f64 * o_state.char_spacing,
};
t * o_state.scale * k
};
let wrapped = wrap(&text, width + 0.5, advance);
let mut substituted = None;
let new_font = !reuse;
let (show_font, encode): (String, Encoder) = if let Some(m) = metrics.clone().filter(|_| reuse) {
(font_name.clone(), Box::new(move |s: &str| m.encode(s)))
} else {
let win = printcraft_fonts::win_ansi(&text);
let back: String = win.iter().map(|c| char::from_u32(u32::from(*c)).unwrap_or('?')).collect();
let base = family.base_font(bold, italic);
if text.chars().zip(back.chars()).any(|(a, c)| c == '?' && a != '?') {
return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor {base} can show", b.base_font)));
}
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"));
let name = if style.family.is_none() { String::from_utf8_lossy(SUBSTITUTE).into_owned() } else { format!("PCEd{}", base.replace('-', "")) };
fonts_res.set(name.clone().into_bytes(), Object::Dict(f));
if style.family.is_none() {
substituted = Some(base.to_string());
}
(name, Box::new(|s: &str| Some(printcraft_fonts::win_ansi(s))))
};
// Line spacing in text space: the paragraph's own (scaled with the size), or 1.2 × the size.
let lead = match style.line_spacing {
Some(m) => size * m.clamp(0.5, 5.0),
None if members.len() > 1 => (members[0].origin.baseline - members[1].origin.baseline) / k * size / old_size,
None => size * 1.2,
};
let n = printcraft_content::num;
// In its own graphics state, so a new colour (or anything else) stops at the paragraph.
let mut block_ops = vec![Op::new("q", vec![]), Op::new("BT", vec![])];
let mut state = o_state.clone();
state.word_spacing = 0.0;
state.font = Some((show_font, size));
if let Some([r, g, bl]) = style.color {
state.fill = vec![Op::new("rg", vec![n(r), n(g), n(bl)])];
}
block_ops.extend(state.ops());
block_ops.push(Op::new("Tm", o.tm.iter().map(|v| n(*v)).collect()));
// Alignment: each line's offset from the left edge, in text space.
let offset = |line: &str| -> f64 {
let free = (width - advance(line)).max(0.0) / k;
match style.align {
Some(crate::added::Align::Center) => free / 2.0,
Some(crate::added::Align::Right) => free,
_ => 0.0,
}
};
// Justify: word spacing (single-byte code 32 only) so each line but the last fills the width.
let single_byte = !reuse || metrics.as_ref().is_some_and(|m| !m.composite);
let justify = style.align == Some(crate::added::Align::Justify) && single_byte;
let mut x = 0.0;
let mut tw_set = 0.0;
let mut underlines: Vec<(f64, f64, f64)> = Vec::new(); // (x0, x1, y) in text space
for (i, line) in wrapped.iter().enumerate() {
let dx = offset(line);
if i > 0 || dx != 0.0 {
block_ops.push(Op::new("Td", vec![n(dx - x), n(if i > 0 { -lead } else { 0.0 })]));
}
x = dx;
let spaces = line.matches(' ').count();
let tw =
if justify && i + 1 < wrapped.len() && spaces > 0 { (width - advance(line)).max(0.0) / k / o_state.scale / spaces as f64 } else { 0.0 };
if tw != tw_set {
block_ops.push(Op::new("Tw", vec![n(tw)]));
tw_set = tw;
}
let bytes = encode(line).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))?;
block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))]));
let w = (advance(line) + tw * spaces as f64 * o_state.scale * k) / k;
underlines.push((dx, dx + w, -(i as f64) * lead - size * 0.12));
}
block_ops.push(Op::new("ET", vec![]));
if style.underline == Some(true) {
// In text space (the paragraph's text matrix), in its fill colour.
let colour: Vec<Op> = state
.fill
.iter()
.map(|f| match f.op.as_slice() {
b"g" => Op::new("G", f.operands.clone()),
b"rg" => Op::new("RG", f.operands.clone()),
b"k" => Op::new("K", f.operands.clone()),
b"cs" => Op::new("CS", f.operands.clone()),
b"sc" => Op::new("SC", f.operands.clone()),
_ => Op::new("SCN", f.operands.clone()),
})
.collect();
block_ops.push(Op::new("q", vec![]));
block_ops.push(Op::new("cm", o.tm.iter().map(|v| n(*v)).collect()));
block_ops.extend(colour);
block_ops.push(Op::new("w", vec![n(size * 0.06)]));
for (x0, x1, y) in underlines {
block_ops.push(Op::new("m", vec![n(x0), n(y)]));
block_ops.push(Op::new("l", vec![n(x1), n(y)]));
}
block_ops.push(Op::new("S", vec![]));
block_ops.push(Op::new("Q", vec![]));
}
block_ops.push(Op::new("Q", vec![]));
let drop: std::collections::HashSet<usize> = members.iter().flat_map(|l| l.ops.iter().copied()).collect();
// Text shown earlier in the same text object stays first: the paragraph then goes where it
// was, between two halves of the text object, so it keeps its place in reading order.
let start = first.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 the text after the paragraph expects: the state at its BT (or where it was split).
let after = if split { &o.state } else { &o.bt_state };
block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some()));
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 mut new_ops = Vec::with_capacity(ops.len() + block_ops.len());
for (i, op) in ops.into_iter().enumerate() {
if i == at {
new_ops.append(&mut block_ops);
}
if !drop.contains(&i) {
new_ops.push(op);
}
}
let mut dict = match &*doc.resolve(&stream_obj) {
Object::Stream(s) => s.dict.clone(),
_ => Dict::new(),
};
dict.remove(b"Length");
let new = doc.add(Object::Stream(Stream::flate(dict, &serialize_ops(&new_ops))));
let contents: Vec<Object> = streams.iter().enumerate().map(|(i, (o, _))| if i == first.stream { Object::Ref(new) } else { o.clone() }).collect();
if new_font {
res.set(b"Font".to_vec(), Object::Dict(fonts_res));
}
let page_ref = p.obj;
doc.update_dict(page_ref, |d| {
d.set(b"Contents".to_vec(), if contents.len() == 1 { contents[0].clone() } else { Object::Array(contents) });
if new_font {
d.set(b"Resources".to_vec(), Object::Dict(res));
}
})?;
Ok(LineEdit { substituted })
}