Fix #155: edit text across content stream pieces (#628)

A page's /Contents array is one content stream in pieces that writers may
split between any two tokens (ISO 32000-2 §7.8.2). Edit text parsed each
piece on its own, so an operator whose operands ended one piece and whose
keyword started the next arrived with no operands: a `[(After) -20 (wards)]`
/ `TJ` split made the line vanish from text_lines, text_paragraphs and
text_edit, and from the Edit text tool.

The joined-parse logic the redaction interpreter used (#153) moves into
pdfcraft-content as `Pieces`: join the pieces, parse them as one stream,
map positions back to pieces, and splice joined byte ranges back into
per-piece bytes (an edit replacing or removing an operator that spans
pieces rewrites every piece it touches; untouched pieces keep their
objects). text_lines now interprets the joined operators, and replace_line
and rewrite_block splice through `Pieces`. Redaction uses the same helper.

Lines and paragraphs are grouped by the piece their first operator starts
in, as before, so pages whose pieces are separate drawings don't merge.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-09 17:57:02 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 275cbcf323
commit 72d640f9f0
7 changed files with 341 additions and 125 deletions
+42
View File
@@ -1288,6 +1288,48 @@ fn editing_existing_text_through_tools() {
assert_eq!(p["lines"].as_array().map(Vec::len), Some(2), "{p}");
}
/// One page whose content is one stream in three pieces (#155): a `TJ` array ends the middle
/// piece and its operator starts the last one.
fn split_streams_pdf() -> Vec<u8> {
let piece = |s: &str| format!("<< /Length {} >>\nstream\n{s}\nendstream", s.len());
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents [4 0 R 5 0 R 6 0 R] /Resources << /Font << /F1 7 0 R >> >> >>".into(),
piece("/P << /MCID 0"),
piece(">> BDC BT /F1 12 Tf 72 700 Td (Target) Tj 0 -20 Td [(After) -20 (wards)]"),
piece("TJ ET EMC"),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
#[test]
fn a_line_split_across_content_streams_is_listed_and_edited() {
let dir = workdir("split-streams");
std::fs::write(dir.join("split.pdf"), split_streams_pdf()).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "split.pdf" }))["doc"].as_u64().unwrap();
let lines = ok(&mut a, "text_lines", json!({ "doc": doc, "page": 1 }));
let texts: Vec<&str> = lines["lines"].as_array().unwrap().iter().filter_map(|l| l["text"].as_str()).collect();
assert_eq!(texts, ["Target", "Afterwards"], "{lines}");
let r = ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "line": 2, "text": "Later" }));
assert_eq!(r["line"]["text"], "Later");
assert_eq!(page_text(&mut a, doc)[0].split_whitespace().collect::<Vec<_>>(), ["Target", "Later"]);
}
#[test]
fn paragraph_bold_without_font_keeps_the_source_family() {
let dir = workdir("paragraph-bold");
+94
View File
@@ -208,6 +208,100 @@ pub fn serialize_ops(ops: &[Op]) -> Vec<u8> {
out
}
/// A page's content streams joined into the one stream they are. Writers may split it between
/// any two tokens (ISO 32000-2 §7.8.2), so an operator's operands can end one piece and its
/// keyword start the next: parse the pieces joined ([`Pieces::parse`]) and map positions back
/// with [`Pieces::piece_of`]. A newline separates the pieces so tokens never run together.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Pieces {
data: Vec<u8>,
/// Where each piece ends in `data`, after its separator.
ends: Vec<usize>,
}
impl Pieces {
pub fn join<D: AsRef<[u8]>>(pieces: &[D]) -> Self {
let mut data = Vec::new();
let mut ends = Vec::with_capacity(pieces.len());
for p in pieces {
data.extend_from_slice(p.as_ref());
data.push(b'\n');
ends.push(data.len());
}
Pieces { data, ends }
}
/// The joined bytes (the spans of [`Pieces::parse`]'s operators index into them).
pub fn data(&self) -> &[u8] {
&self.data
}
/// How many pieces there are.
pub fn len(&self) -> usize {
self.ends.len()
}
pub fn is_empty(&self) -> bool {
self.ends.is_empty()
}
/// Parse the pieces as one stream.
pub fn parse(&self) -> Parsed {
parse(&self.data)
}
/// The piece holding joined position `at` (the last piece for positions past the end).
pub fn piece_of(&self, at: usize) -> usize {
self.ends.iter().position(|&e| at < e).unwrap_or(self.ends.len().saturating_sub(1))
}
/// The pieces an operator spans: (the one its first token is in, the one its keyword is in).
pub fn pieces_of(&self, op: &Op) -> (usize, usize) {
(self.piece_of(op.span.start), self.piece_of(op.span.end.saturating_sub(1)))
}
/// The joined range of piece `i`, without its separator.
fn range(&self, i: usize) -> std::ops::Range<usize> {
let start = match i.checked_sub(1) {
None => 0,
Some(p) => self.ends.get(p).copied().unwrap_or(self.data.len()),
};
let end = self.ends.get(i).map_or(self.data.len(), |e| e.saturating_sub(1)).max(start);
start..end
}
/// Rebuild every piece after replacing joined byte ranges: `edits` are (range, bytes) in
/// ascending, non-overlapping order. A replacement goes into the piece its range starts in,
/// and the rest of a range that runs on into later pieces is removed from them, so an
/// operator split across pieces is replaced or removed whole. Everything else is copied byte
/// for byte. Returns every piece's new bytes (unchanged pieces come back equal).
pub fn splice(&self, edits: impl IntoIterator<Item = (std::ops::Range<usize>, Vec<u8>)>) -> Vec<Vec<u8>> {
let mut out: Vec<Vec<u8>> = (0..self.len()).map(|i| Vec::with_capacity(self.range(i).len())).collect();
let copy = |out: &mut Vec<Vec<u8>>, from: usize, to: usize| {
for (i, piece) in out.iter_mut().enumerate() {
let r = self.range(i);
let (a, b) = (from.max(r.start), to.min(r.end));
if a < b {
piece.extend_from_slice(self.data.get(a..b).unwrap_or_default());
}
}
};
let mut at = 0;
for (range, bytes) in edits {
let start = range.start.clamp(at, self.data.len());
copy(&mut out, at, start);
if !bytes.is_empty()
&& let Some(piece) = out.get_mut(self.piece_of(start))
{
piece.extend_from_slice(&bytes);
}
at = range.end.clamp(start, self.data.len());
}
copy(&mut out, at, self.data.len());
out
}
}
/// A number operand, written as an integer when it is one.
pub fn num(v: f64) -> Object {
if v.fract() == 0.0 && v.abs() < 1e15 { Object::Int(v as i64) } else { Object::Real((v * 10_000.0).round() / 10_000.0) }
+25
View File
@@ -63,3 +63,28 @@ fn matrices_compose_and_invert() {
assert!(overlaps([0.0, 0.0, 10.0, 10.0], [5.0, 5.0, 20.0, 20.0], 0.0));
assert!(!overlaps([0.0, 0.0, 10.0, 10.0], [10.0, 0.0, 20.0, 10.0], 0.0));
}
#[test]
fn pieces_parse_as_one_stream_and_splice_back() {
// An operator's operands end one piece and its keyword starts the next (§7.8.2).
let pieces = Pieces::join(&[&b"BT 72 700 Td [(After) -20 (wards)]"[..], b"TJ ET", b"q Q"]);
assert_eq!(pieces.len(), 3);
let ops = pieces.parse().ops;
let names: Vec<&[u8]> = ops.iter().map(|o| o.op.as_slice()).collect();
assert_eq!(names, [&b"BT"[..], b"Td", b"TJ", b"ET", b"q", b"Q"]);
assert_eq!(pieces.pieces_of(&ops[2]), (0, 1));
assert_eq!(pieces.pieces_of(&ops[3]), (1, 1));
assert_eq!(pieces.piece_of(usize::MAX), 2);
// Nothing changed: the pieces come back byte for byte.
assert_eq!(pieces.splice([]), [&b"BT 72 700 Td [(After) -20 (wards)]"[..], b"TJ ET", b"q Q"]);
// Replacing the split TJ rewrites both pieces it spans; the third is untouched.
let out = pieces.splice([(ops[2].span.clone(), b"(New) Tj".to_vec())]);
assert_eq!(out, [&b"BT 72 700 Td (New) Tj"[..], b" ET", b"q Q"]);
// Removing it leaves no stray operands or keyword.
let out = pieces.splice([(ops[2].span.clone(), Vec::new())]);
assert_eq!(out, [&b"BT 72 700 Td "[..], b" ET", b"q Q"]);
// Out-of-range or reversed edits are clamped, never a panic.
let out = pieces.splice([(std::ops::Range { start: 10, end: 5 }, b"x".to_vec()), (1000..2000, Vec::new())]);
assert_eq!(out.len(), 3);
assert!(Pieces::join::<&[u8]>(&[]).splice([(0..1, b"x".to_vec())]).is_empty());
}
+50 -2
View File
@@ -790,10 +790,58 @@ fn editing_text_keeps_the_tokens_a_stream_shares_with_its_neighbours() {
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Target");
text::replace_line(&mut doc, 0, 0, "Edited").unwrap();
assert_split_tokens_kept(&reopen(&doc), "Edited");
// A paragraph rewrite rebuilds the same piece.
// A paragraph rewrite: "Target" and the line after it (split across pieces) are one
// paragraph, rewritten whole without stray tokens.
let mut doc = split_streams_page();
assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 1);
text::replace_block(&mut doc, 0, 0, "Rewrapped").unwrap();
assert_split_tokens_kept(&reopen(&doc), "Rewrapped");
let (joined, ops) = joined_ops(&reopen(&doc));
assert!(joined.contains("Rewrapped") && !joined.contains("Target") && !joined.contains("wards"), "{joined}");
assert!(ops.iter().find(|o| o.is("BDC")).is_some_and(|o| o.operands.len() == 2), "{joined}");
}
/// The operators of the page's pieces joined back into one stream, each checked for operands.
fn joined_ops(doc: &Document) -> (String, Vec<pdfcraft_content::Op>) {
let joined = streams(doc, 0).join("\n");
let ops = pdfcraft_content::parse(joined.as_bytes());
assert_eq!(ops.skipped, 0, "stray tokens: {joined}");
(joined, ops.ops)
}
#[test]
fn a_line_whose_operator_starts_the_next_stream_can_be_edited() {
// #155: `[(After) -20 (wards)]` ends one piece and its `TJ` starts the next.
let doc = split_streams_page();
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Target", "Afterwards"]);
assert!(lines[1].rect[1] < lines[0].rect[1] && lines[1].rect[2] > lines[1].rect[0] + 40.0, "{:?}", lines[1].rect);
// Retyping it replaces the operator whole, in both pieces it spans.
let mut doc = split_streams_page();
text::replace_line(&mut doc, 0, 1, "Later").unwrap();
let doc = reopen(&doc);
let (joined, ops) = joined_ops(&doc);
assert!(!joined.contains("After") && !joined.contains("wards") && joined.contains("Target"), "{joined}");
assert!(ops.iter().filter(|o| o.is("TJ")).count() == 0, "{joined}");
assert!(ops.iter().find(|o| o.is("BDC")).is_some_and(|o| o.operands.len() == 2), "{joined}");
let lines = text::text_lines(&doc, 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>(), ["Target", "Later"]);
// The pieces stay pieces: the first is untouched, the marked content still closes.
let pieces = streams(&doc, 0);
assert_eq!(pieces.len(), 3);
assert_eq!(pieces[0].trim(), "/P << /MCID 0");
assert!(pieces[2].contains("EMC") && !pieces[2].contains("TJ"), "{pieces:?}");
// As a paragraph with the line before it: both lines are rewritten.
let mut doc = split_streams_page();
let blocks = text::text_blocks(&doc, 0).unwrap();
let last = blocks.iter().position(|b| b.text.contains("Afterwards")).unwrap();
text::replace_block(&mut doc, 0, last, "Rewrapped").unwrap();
let doc = reopen(&doc);
let (joined, ops) = joined_ops(&doc);
assert!(!joined.contains("wards") && joined.contains("Rewrapped"), "{joined}");
assert!(ops.iter().find(|o| o.is("BDC")).is_some_and(|o| o.operands.len() == 2), "{joined}");
assert!(text::text_lines(&doc, 0).unwrap().iter().any(|l| l.text == "Rewrapped"));
}
#[test]
+124 -110
View File
@@ -13,7 +13,7 @@
use std::collections::HashMap;
use std::rc::Rc;
use pdfcraft_content::{Matrix, Op, parse, serialize_ops};
use pdfcraft_content::{Matrix, Op, Pieces, serialize_ops};
use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream};
use pdfcraft_fonts::pdf::Metrics;
use pdfcraft_fonts::{CraftFont, GlyphError, japanese_glyph_from};
@@ -38,6 +38,8 @@ pub struct TextLine {
/// 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,
/// The content stream piece the line starts in, and its operators (indices into the page's
/// streams parsed joined).
stream: usize,
ops: Vec<usize>,
/// Where the line starts: text matrix (text space), the state there, and its `BT`.
@@ -167,32 +169,53 @@ fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
.collect()
}
/// Rewrite one of a page's content streams: `edit(i)` gives the operators to insert before
/// operator `i` of `ops` (parsed from `data`) and whether to keep it. Everything else is copied
/// byte for byte. A page's streams are one stream in pieces, split between any two tokens
/// (ISO 32000-2 §7.8.2), so a piece can end with operands whose operator starts the next one,
/// or start by closing a dictionary the previous one opened; those tokens belong to no operator
/// parsed here and must stay where they are.
fn splice(data: &[u8], ops: &[Op], mut edit: impl FnMut(usize) -> (Vec<Op>, bool)) -> Vec<u8> {
let mut out = Vec::with_capacity(data.len());
let mut at = 0;
/// Rewrite a page's content (its streams joined, see [`Pieces`]): `edit(i)` gives the operators
/// to insert before operator `i` of `ops` (parsed from the joined pieces) and whether to keep it.
/// Everything else is copied byte for byte. A page's streams are one stream in pieces, split
/// between any two tokens (ISO 32000-2 §7.8.2), so an operator's operands can end one piece and
/// its keyword start the next; removing or replacing it rewrites every piece it spans. Returns
/// each piece's new bytes.
fn splice(pieces: &Pieces, ops: &[Op], mut edit: impl FnMut(usize) -> (Vec<Op>, bool)) -> Vec<Vec<u8>> {
let data = pieces.data();
let mut edits = Vec::new();
for (i, op) in ops.iter().enumerate() {
let (insert, keep) = edit(i);
if insert.is_empty() && keep {
continue;
}
let start = op.span.start.clamp(at, data.len());
out.extend_from_slice(data.get(at..start).unwrap_or_default());
let mut bytes = Vec::new();
if !insert.is_empty() {
if out.last().is_some_and(|b| !b.is_ascii_whitespace()) {
out.push(b'\n');
if op.span.start.checked_sub(1).and_then(|p| data.get(p)).is_some_and(|b| !b.is_ascii_whitespace()) {
bytes.push(b'\n');
}
out.extend_from_slice(&serialize_ops(&insert));
bytes.extend_from_slice(&serialize_ops(&insert));
}
at = if keep { start } else { op.span.end.clamp(start, data.len()) };
let end = if keep { op.span.start } else { op.span.end };
edits.push((op.span.start..end, bytes));
}
pieces.splice(edits)
}
/// The page's `/Contents` after a [`splice`]: pieces whose bytes changed become new streams
/// (keeping their dictionaries), the others stay as they were.
fn rewritten_contents(doc: &mut Document, streams: &[(Object, Vec<u8>)], new: Vec<Vec<u8>>) -> Object {
let mut contents: Vec<Object> = Vec::with_capacity(streams.len());
for ((obj, old), data) in streams.iter().zip(new) {
if *old == data {
contents.push(obj.clone());
continue;
}
let mut dict = match &*doc.resolve(obj) {
Object::Stream(s) => s.dict.clone(),
_ => Dict::new(),
};
dict.remove(b"Length");
contents.push(Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &data)))));
}
match <[Object; 1]>::try_from(contents) {
Ok([one]) => one,
Err(many) => Object::Array(many),
}
out.extend_from_slice(data.get(at..).unwrap_or_default());
out
}
fn page_dict(doc: &Document, page: usize) -> Result<pdfcraft_model::Page, EditError> {
@@ -436,59 +459,64 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
let mut cache = HashMap::new();
let mut lines: Vec<TextLine> = Vec::new();
let mut carry = Carry::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, &mut carry);
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
&& lines.last().is_some_and(|l| l.font.as_bytes() == s.font.as_slice())
&& lines.last().is_some_and(|l| {
(s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color
})
&& (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;
l.color = fill_color(&s.state.fill);
} 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,
bold: s.bold,
italic: s.italic,
color: fill_color(&s.state.fill),
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(),
ctm: s.state.ctm.0,
baseline: s.baseline,
x: s.start_x,
state: TextState::of(&s.state),
bt_op: s.bt_op,
bt_state: TextState::of(&s.bt_state),
},
});
// The streams are one stream in pieces (§7.8.2), split between any two tokens: parse them
// joined, so an operator whose operands end one piece still has them.
let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry);
let mut last: Option<(usize, usize, f64, f64, f64)> = None; // (piece, bt, baseline, end_x, size)
for s in shown {
// A line belongs to the piece its first operator starts in (lines in different pieces
// aren't joined or grouped, as before; editing rewrites every piece an edit touches).
let si = ops.get(s.op).map_or(0, |op| pieces.pieces_of(op).0);
let joins = last.is_some_and(|(piece, bt, base, end, size)| {
piece == si
&& bt == s.bt
&& lines.last().is_some_and(|l| l.font.as_bytes() == s.font.as_slice())
&& lines.last().is_some_and(|l| {
(s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color
})
&& (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.3);
if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
l.text.push(' ');
}
last = Some((s.bt, s.baseline, s.end_x, s.size.max(1.0)));
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;
l.color = fill_color(&s.state.fill);
} 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,
bold: s.bold,
italic: s.italic,
color: fill_color(&s.state.fill),
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(),
ctm: s.state.ctm.0,
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((si, 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());
@@ -695,9 +723,9 @@ pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) ->
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 ops = parse(&data).ops;
let first = target.ops[0];
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let first = target.ops.first().copied().filter(|i| *i < ops.len()).ok_or_else(|| EditError::Invalid("the page's content changed".into()))?;
// 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));
@@ -753,22 +781,14 @@ pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) ->
// Rebuild: the line's first operator becomes the replacement, its others go. Copies of the
// line drawn in the same area go too, so the replacement is all that shows.
let drops = coincident_ops(&lines, std::slice::from_ref(&target.rect));
let new_data = splice(&data, &ops, |i| {
if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.get(&target.stream).is_some_and(|d| d.contains(&i))) }
});
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, &new_data)));
let contents: Vec<Object> = streams.iter().enumerate().map(|(i, (o, _))| if i == target.stream { Object::Ref(new) } else { o.clone() }).collect();
let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) });
let contents = rewritten_contents(doc, &streams, new_data);
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) });
d.set(b"Contents".to_vec(), contents);
if substituted.is_some() {
d.set(b"Resources".to_vec(), Object::Dict(res));
}
@@ -781,13 +801,13 @@ 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 operators of every line drawn in the same area as one of `rects`, grouped by content
/// stream. Documents sometimes draw a line more than once (fake bold, an invisible text layer),
/// and a surviving copy would show the old text under the replaced line. Copies overlap a
/// member's rect by more than half of the smaller rect; neighbouring lines share no area.
fn coincident_ops(lines: &[TextLine], rects: &[[f64; 4]]) -> std::collections::HashMap<usize, std::collections::HashSet<usize>> {
/// The operators of every line drawn in the same area as one of `rects`. Documents sometimes
/// draw a line more than once (fake bold, an invisible text layer), and a surviving copy would
/// show the old text under the replaced line. Copies overlap a member's rect by more than half of
/// the smaller rect; neighbouring lines share no area.
fn coincident_ops(lines: &[TextLine], rects: &[[f64; 4]]) -> std::collections::HashSet<usize> {
let area = |r: [f64; 4]| ((r[2] - r[0]) * (r[3] - r[1])).max(0.0);
let mut drop = std::collections::HashMap::new();
let mut drop = std::collections::HashSet::new();
for l in lines {
let covered = rects.iter().any(|m| {
let ix = (m[2].min(l.rect[2]) - m[0].max(l.rect[0])).max(0.0);
@@ -796,7 +816,7 @@ fn coincident_ops(lines: &[TextLine], rects: &[[f64; 4]]) -> std::collections::H
small > 0.0 && ix * iy / small > 0.5
});
if covered {
drop.entry(l.stream).or_insert_with(std::collections::HashSet::new).extend(l.ops.iter().copied());
drop.extend(l.ops.iter().copied());
}
}
drop
@@ -935,8 +955,8 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
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 pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let text = text.unwrap_or(&b.text).split(paragraph_separator).filter(|word| !word.is_empty()).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()))?;
@@ -1117,18 +1137,18 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
block_ops.push(Op::new("Q", vec![]));
// The paragraph's operators go, and so does anything drawn in the same area (a fake-bold
// second copy, an invisible text layer): the new text is all that may show.
let mut drop: std::collections::HashMap<usize, std::collections::HashSet<usize>> =
members.iter().flat_map(|l| [(l.stream, l.ops.iter().copied().collect::<std::collections::HashSet<_>>())]).collect();
let member_rects: Vec<[f64; 4]> = members.iter().map(|l| l.rect).collect();
for (stream, ops) in coincident_ops(&lines, &member_rects) {
drop.entry(stream).or_default().extend(ops);
}
let mut drop = coincident_ops(&lines, &member_rects);
drop.extend(members.iter().flat_map(|l| l.ops.iter().copied()));
// 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.get(&first.stream).is_some_and(|d| d.contains(&(o.bt_op + i)))
});
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()));
@@ -1138,23 +1158,17 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect()));
}
let at = if split { start } else { o.bt_op };
let new_data = splice(&data, &ops, |i| {
let new_data = splice(&pieces, &ops, |i| {
let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() };
(insert, drop.get(&first.stream).is_none_or(|d| !d.contains(&i)))
(insert, !drop.contains(&i))
});
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, &new_data)));
let contents: Vec<Object> = streams.iter().enumerate().map(|(i, (o, _))| if i == first.stream { Object::Ref(new) } else { o.clone() }).collect();
let contents = rewritten_contents(doc, &streams, new_data);
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) });
d.set(b"Contents".to_vec(), contents);
if new_font {
d.set(b"Resources".to_vec(), Object::Dict(res));
}
+4 -11
View File
@@ -20,7 +20,7 @@
use std::collections::HashMap;
use std::rc::Rc;
use pdfcraft_content::{Matrix, Op, contains, num, overlaps, parse, serialize_ops, string};
use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use crate::{Report, image};
@@ -210,15 +210,8 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
// The streams are one content stream in pieces, which may be split between any two tokens
// (ISO 32000-2 §7.8.2): an operator's operands can end one piece and the operator start the
// next. Parse them joined, then give each operator back to the piece its keyword is in.
let mut joined: Vec<u8> = Vec::new();
let mut ends: Vec<usize> = Vec::with_capacity(streams.len());
for data in streams {
joined.extend_from_slice(data);
joined.push(b'\n');
ends.push(joined.len());
}
let piece_of = |at: usize| ends.iter().position(|&e| at < e).unwrap_or(ends.len().saturating_sub(1));
let mut parsed = parse(&joined);
let joined = Pieces::join(streams);
let mut parsed = joined.parse();
if !scope.hidden_layers.is_empty() {
let props = res_dict(doc, resources, b"Properties");
let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props);
@@ -231,7 +224,7 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
let mut pieces: Vec<Vec<Op>> = streams.iter().map(|_| Vec::new()).collect();
let mut rewrite: Vec<bool> = vec![forced_change; streams.len()];
for op in parsed.ops {
let (first, last) = (piece_of(op.span.start), piece_of(op.span.end.saturating_sub(1)));
let (first, last) = joined.pieces_of(&op);
// An operator split across pieces is written whole into its keyword's piece, so every
// piece it spans is rewritten.
if first < last {
+2 -2
View File
@@ -1806,8 +1806,8 @@ milestone = "M7"
status = "partial"
commands = ["edit.edit_text"]
tools = ["text_lines", "text_paragraphs", "text_edit"]
evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"]
notes = "click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next"
evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/editing.rs::editing_existing_text_in_place", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/automation.rs::a_line_split_across_content_streams_is_listed_and_edited"]
notes = "click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next"
[[feature]]
id = "edit.text-reflow"