Edit text: keep the tokens a content stream shares with its neighbours (#154)

A page's /Contents array is one content stream in pieces, and a writer
may split it between any two tokens (ISO 32000-2 §7.8.2): a TJ array
can end one piece with its operator starting the next, and a
marked-content dictionary can close at the start of the next piece.

Replacing a line or rewriting a paragraph re-serialized the parsed
operators of the line's piece, and the parser keeps neither operands
left at the end of a piece nor a dictionary's closing >> at its start.
The edit dropped them: the text after the edited line vanished, and the
page was left with an operator without operands (TJ, BDC), which other
readers reject.

The piece is now rewritten by splicing: only the replaced, inserted and
removed operators change, and every other byte is copied as it was.

Co-authored-by: EthanSuquet <292885762+EthanSuquet@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
EthanSuquet
2026-10-07 21:13:04 -04:00
committed by GitHub
co-authored by EthanSuquet Claude Opus 5.5 Brandon Thomas
parent d63bc7ad98
commit ff1a8b85dc
2 changed files with 94 additions and 21 deletions
+56
View File
@@ -384,6 +384,62 @@ fn text_lines_are_found_and_replaced_in_place() {
assert_eq!(lines[1].rect, second);
}
/// A page whose content is one stream in three pieces, split between tokens: a marked-content
/// dictionary closes at the start of the middle piece, and the `TJ` array that ends it is shown
/// by the operator that starts the last one.
fn split_streams_page() -> Document {
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 offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
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{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
/// The page's pieces joined back into one stream: every operator must still have its operands.
fn assert_split_tokens_kept(doc: &Document, new_text: &str) {
let joined = streams(doc, 0).join("\n");
assert!(joined.contains(new_text) && !joined.contains("Target"), "{joined}");
assert!(joined.contains("[(After) -20 (wards)]"), "the text after the line is gone: {joined}");
for op in pdfcraft_content::parse(joined.as_bytes()).ops {
let want = match op.op.as_slice() {
b"BDC" => 2,
b"TJ" => 1,
_ => continue,
};
assert_eq!(op.operands.len(), want, "{} lost its operands: {joined}", String::from_utf8_lossy(&op.op));
}
}
#[test]
fn editing_text_keeps_the_tokens_a_stream_shares_with_its_neighbours() {
let mut doc = split_streams_page();
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.
let mut doc = split_streams_page();
text::replace_block(&mut doc, 0, 0, "Rewrapped").unwrap();
assert_split_tokens_kept(&reopen(&doc), "Rewrapped");
}
#[test]
fn missing_glyphs_substitute_helvetica_and_impossible_text_is_refused() {
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
+38 -21
View File
@@ -167,6 +167,34 @@ 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;
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());
if !insert.is_empty() {
if out.last().is_some_and(|b| !b.is_ascii_whitespace()) {
out.push(b'\n');
}
out.extend_from_slice(&serialize_ops(&insert));
}
at = if keep { start } else { op.span.end.clamp(start, data.len()) };
}
out.extend_from_slice(data.get(at..).unwrap_or_default());
out
}
fn page_dict(doc: &Document, page: usize) -> Result<pdfcraft_model::Page, EditError> {
pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))
}
@@ -621,7 +649,7 @@ pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) ->
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 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));
@@ -678,20 +706,15 @@ 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 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 !drops.get(&target.stream).is_some_and(|d| d.contains(&i)) {
new_ops.push(op);
}
}
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, &serialize_ops(&new_ops))));
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 page_ref = p.obj;
if substituted.is_some() {
@@ -1059,22 +1082,16 @@ 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 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);
}
let keep = drop.get(&first.stream).is_none_or(|d| !d.contains(&i));
if keep {
new_ops.push(op);
}
}
let new_data = splice(&data, &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)))
});
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 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();
if new_font {
res.set(b"Font".to_vec(), Object::Dict(fonts_res));