From ff1a8b85dc6ec3830081ad3e581b7de25d9adbbd Mon Sep 17 00:00:00 2001 From: EthanSuquet Date: Wed, 7 Oct 2026 21:13:04 -0400 Subject: [PATCH] Edit text: keep the tokens a content stream shares with its neighbours (#154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Brandon Thomas --- crates/edit/src/tests.rs | 56 ++++++++++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 59 ++++++++++++++++++++++++++-------------- 2 files changed, 94 insertions(+), 21 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 013b8d69a..a33b1d8f5 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -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 = 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"); diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 75960b8c9..b9063351a 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -167,6 +167,34 @@ fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { .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, bool)) -> Vec { + 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::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 = 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 = 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));