M2.6: edits stop at a content stream that doesn't decode, as at damage

An edit was refused after a content stream damaged partway, since what the damage hides can
change how the later streams read, the graphics state they draw in among it. A stream that
doesn't decode at all hides all of its operators, and an edit after one went ahead. It now stops
there too: text lines and blocks, and images (arrange, crop, transform, move), as selecting
objects on `main` already does for a page with such a stream. An edit in a stream before it goes
on, and the stream is kept as stored.

A file may give a stream's /Filter by reference, which isn't read here, so the case needs no
damage: `content_after_a_stream_that_does_not_decode_is_not_edited` has such a stream move the
text and image after it with a `cm`. `a_content_stream_that_does_not_decode_is_kept_and_edited_around`
becomes `..._kept_and_edited_before`, its edit now in the stream before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz MartensandClaude Opus 5.5 committed 2026-11-07 18:41:02 +01:00
1 parent 0916bec6ef
commit 86cb375869
2 files changed
+48 -15

No files matched your search

+37 -6
View File
@@ -5191,7 +5191,7 @@ fn an_edit_never_rewrites_a_content_stream_damaged_partway() {
assert!(refused.to_string().contains("is damaged partway"), "{refused}");
assert_eq!(contents(&doc), listed);
let refused = text::replace_line(&mut doc, 0, 1, "Edited").unwrap_err();
assert!(refused.to_string().contains("comes after a content stream that is damaged partway"), "{refused}");
assert!(refused.to_string().contains("comes after a content stream that is damaged or can't be decoded"), "{refused}");
assert_eq!(contents(&doc), listed);
let mut doc = split_page("BT /F1 12 Tf 72 600 Td (Whole) Tj ET", "");
@@ -5244,25 +5244,56 @@ fn a_content_stream_that_does_not_decode_is_never_written_over() {
}
/// A content stream that doesn't decode at all is no damage to write over: it is kept as stored,
/// and the page's other streams are read and edited around it. A stream of a few MB is read for
/// and the page's streams before it are read and edited. A stream of a few MB is read for
/// selecting objects.
#[test]
fn a_content_stream_that_does_not_decode_is_kept_and_edited_around() {
let mut doc = split_page("", "BT /F1 12 Tf 72 600 Td (Whole) Tj ET");
fn a_content_stream_that_does_not_decode_is_kept_and_edited_before() {
let mut doc = split_page("BT /F1 12 Tf 72 600 Td (Whole) Tj ET", "");
let mut dict = pdfcraft_cos::Dict::new();
dict.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
let unknown = pdfcraft_cos::Stream::from_raw(dict, b"BT /F1 12 Tf 72 700 Td (Unread) Tj ET".to_vec());
doc.set(pdfcraft_cos::ObjRef::new(4, 0), unknown.clone());
doc.set(pdfcraft_cos::ObjRef::new(5, 0), unknown.clone());
text::replace_line(&mut doc, 0, 0, "Edited").unwrap();
let doc = reopen(&doc);
assert_eq!(text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect::<Vec<_>>(), ["Edited"]);
let Object::Stream(kept) = &*doc.get(pdfcraft_cos::ObjRef::new(4, 0)) else { panic!("not a stream") };
let Object::Stream(kept) = &*doc.get(pdfcraft_cos::ObjRef::new(5, 0)) else { panic!("not a stream") };
assert_eq!(kept.raw, unknown.raw, "stored as it was");
let large = format!("{}0 0 m 10 10 l S", " ".repeat(2 << 20));
assert!(editable_objects(&split_page(&large, "BT /F1 12 Tf 72 600 Td (Whole) Tj ET"), 0).is_ok());
}
/// A content stream that doesn't decode hides its operators as damage does. Here its `/Filter` is
/// given by reference, which a file may do and which isn't read here, and it holds a `cm` that
/// moves what the page draws after it. Text and images there aren't edited, as the edit would be
/// worked out without the `cm`.
#[test]
fn content_after_a_stream_that_does_not_decode_is_not_edited() {
use pdfcraft_cos::{Dict, ObjRef, Stream};
let hidden_move = |doc: &mut Document| {
let filter = doc.add(Object::name("FlateDecode"));
let mut s = Stream::flate(Dict::new(), b"1 0 0 1 0 -300 cm");
s.dict.set(b"Filter".to_vec(), Object::Ref(filter));
doc.set(ObjRef::new(4, 0), s);
};
let contents = |doc: &Document| pdfcraft_model::pages(doc)[0].dict.get(b"Contents").cloned();
let mut doc = split_page("", "BT /F1 12 Tf 72 600 Td (After) Tj ET");
hidden_move(&mut doc);
let listed = contents(&doc);
let refused = text::replace_line(&mut doc, 0, 0, "Edited").unwrap_err();
assert!(refused.to_string().contains("comes after a content stream that is damaged or can't be decoded"), "{refused}");
assert_eq!(contents(&doc), listed);
let mut doc = two_stream_image_page("", "q 100 0 0 100 200 200 cm /Im1 Do Q");
hidden_move(&mut doc);
let listed = contents(&doc);
let shift = images::ImageChange::Transform([1.0, 0.0, 0.0, 1.0, 10.0, 0.0]);
let refused = images::change_image(&mut doc, 0, 0, &shift).unwrap_err();
assert!(refused.to_string().contains("comes after"), "{refused}");
assert_eq!(contents(&doc), listed);
}
/// A content stream whose `/DecodeParms`, or a parameter in them, is given by reference is read
/// through the reference: its text is listed and edited, not read with the predictor's tag bytes
/// left in it.
+11 -9
View File
@@ -148,29 +148,31 @@ pub(crate) fn splice(pieces: &Pieces, ops: &[Op], mut edit: impl FnMut(usize) ->
}
/// An error when content at `/Contents` index `at` (a stream there, or one inserted there) is drawn
/// after a stream damaged partway: what the damage hides can change how it reads.
/// after a stream not read whole, damaged partway or not decoding at all: what wasn't read can
/// change how it reads, the graphics state it draws in among it.
pub(crate) fn drawn_before_damage(streams: &[ContentStream], at: usize) -> Result<(), EditError> {
if at > first_damaged(streams) {
return Err(EditError::Invalid(
"this change comes after a content stream that is damaged partway, and what the damage hides can change how the rest of the page reads"
"this change comes after a content stream that is damaged or can't be decoded, and what wasn't read can change how the rest of the page reads"
.into(),
));
}
Ok(())
}
/// The index of the first stream damaged partway, or the number of streams.
/// The index of the first stream not read whole, or the number of streams.
fn first_damaged(streams: &[ContentStream]) -> usize {
streams.iter().position(|s| s.read == Read::Damaged).unwrap_or(streams.len())
streams.iter().position(|s| s.read != Read::Whole).unwrap_or(streams.len())
}
/// 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
/// damaged partway. Written from what decoded, a damaged stream would lose everything after the
/// damage, and one that doesn't decode all of it. What follows the damage is unknown, so a later
/// stream may not read as it seems: a string or comment the damage left open runs on into it, and
/// operators the damage hides can change the graphics state it draws in.
/// added, when a piece that changed is a stream that didn't decode whole, or comes after one.
/// Written from what decoded, a damaged stream would lose everything after the damage, and one
/// that doesn't decode all of it. What follows the damage is unknown, so a later stream may not
/// read as it seems: a string or comment the damage left open runs on into it, and operators the
/// damage hides can change the graphics state it draws in. A stream that doesn't decode hides its
/// operators all the same.
pub(crate) fn rewritten_contents(doc: &mut Document, streams: &[ContentStream], new: Vec<Vec<u8>>) -> Result<Object, EditError> {
// The last stream that changed: when it is drawn before the damage, so are the others.
if let Some(i) = streams.iter().zip(&new).rposition(|(s, data)| s.data != *data) {