From 3639fed12ff457844ca02fb5b139d755ea63ee87 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 7 Nov 2026 20:18:07 +0100 Subject: [PATCH] M2.6: text a stream not read whole goes on from isn't edited A text object can run on from one content stream into the next. When it is still open at a stream damaged partway, or one that doesn't decode, the text that stream shows starts where the text before it ends: editing that text moved what the hidden stream shows, its advance changed. An edit in the text object before such a stream went ahead, as edits before the damage do. `rewritten_contents` now refuses a change from the start of a text object still open at the first stream not read whole (`changes_text_open_into_damage`); a change before that text object goes on. `text_a_stream_not_read_whole_goes_on_from_is_not_edited` (the hidden stream damaged from its start and one that doesn't decode, between the text and a stream closing it or last on the page; a line in an earlier text object of the same stream is still edited). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/edit/src/tests.rs | 37 +++++++++++++++++++++++++++++++++++ crates/edit/src/text/lines.rs | 29 +++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 337c8c275e..13d14e9b25 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5294,6 +5294,43 @@ fn content_after_a_stream_that_does_not_decode_is_not_edited() { assert_eq!(contents(&doc), listed); } +/// A text object still open at a stream not read whole goes on in it: the text that stream shows +/// starts where the text before it ends, so editing that text would move it. The edit is refused. +/// A change before the text object, in the same stream, goes on. The same holds when the hidden +/// stream is the page's last. +#[test] +fn text_a_stream_not_read_whole_goes_on_from_is_not_edited() { + use pdfcraft_cos::{Dict, ObjRef, Stream}; + let contents = |doc: &Document| pdfcraft_model::pages(doc)[0].dict.get(b"Contents").cloned(); + // The hidden stream between the text and a stream closing it, or the page's last. + for (damaged, last) in [(false, false), (true, false), (false, true), (true, true)] { + let what = format!("damaged: {damaged}, last: {last}"); + let shown: &[u8] = if last { b"(World) Tj ET" } else { b"(World) Tj" }; + let mut doc = split_page("BT /F1 12 Tf 72 700 Td (Before) Tj ET BT /F1 12 Tf 72 600 Td (Hello) Tj", "ET"); + let hidden = if damaged { + damaged_flate(b"", shown) + } else { + let filter = doc.add(Object::name("FlateDecode")); + let mut s = Stream::flate(Dict::new(), shown); + s.dict.set(b"Filter".to_vec(), Object::Ref(filter)); + s + }; + let hidden = Object::Ref(doc.add(hidden)); + let mut listed = vec![Object::Ref(ObjRef::new(4, 0)), hidden]; + if !last { + listed.push(Object::Ref(ObjRef::new(5, 0))); + } + let listed = Object::Array(listed); + doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), listed.clone())).unwrap(); + let lines: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(lines, ["Before", "Hello"], "{what}"); + let refused = text::replace_line(&mut doc, 0, 1, "Hi").unwrap_err(); + assert!(refused.to_string().contains("goes on in a content stream"), "{what}: {refused}"); + assert_eq!(contents(&doc), Some(listed), "{what}"); + text::replace_line(&mut doc, 0, 0, "Earlier").unwrap(); + } +} + /// An image's `q cm … Q` that closes past a stream not read whole, one that doesn't decode or one /// damaged partway, may hold what that stream draws (here a square, in the image's `cm`), also when /// the square's operands end the stream before it and only its keyword is hidden. Moving the image diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 1a606bc0f1..1aac66fde4 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -174,6 +174,29 @@ pub(crate) fn first_op_after_damage(streams: &[ContentStream], pieces: &Pieces, ops.iter().position(|op| pieces.pieces_of(op).1 > damaged).unwrap_or(ops.len()) } +/// Whether `new` changes a text object still open at the first stream not read whole. The text +/// object goes on in it, and the text it shows there starts where this text ends: a change of this +/// text moves it. A change before the text object doesn't. +fn changes_text_open_into_damage(streams: &[ContentStream], new: &[Vec]) -> bool { + let upto = first_damaged(streams).saturating_add(1); + if upto > streams.len() { + return false; + } + let before = Pieces::join(&streams.iter().take(upto).map(|s| s.data.as_slice()).collect::>()); + let mut open = None; + for op in before.parse().ops { + match op.op.as_slice() { + b"BT" => open = Some(op.span.start), + b"ET" => open = None, + _ => {} + } + } + let Some(at) = open else { return false }; + let after = Pieces::join(&new.iter().take(upto).map(Vec::as_slice).collect::>()); + // Unchanged from the text object's start to the stream not read whole: it ends the same. + !before.data().get(at..).is_some_and(|text| after.data().ends_with(text)) +} + /// The page's `/Contents` after a [`splice`]: pieces whose bytes changed become new streams /// (keeping their dictionaries), the others stay as they were. An error, before anything is /// added, when a piece that changed is a stream that didn't decode whole, or comes after one. @@ -187,6 +210,12 @@ pub(crate) fn rewritten_contents(doc: &mut Document, streams: &[ContentStream], if let Some(i) = streams.iter().zip(&new).rposition(|(s, data)| s.data != *data) { drawn_before_damage(streams, i)?; } + if changes_text_open_into_damage(streams, &new) { + return Err(EditError::Invalid( + "this change is in a text object that goes on in a content stream that is damaged or can't be decoded, whose text starts where this text ends" + .into(), + )); + } for (s, data) in streams.iter().zip(&new) { if s.data == *data { continue;