From 569f631f2b074784bf479020fce8fb862347fda9 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 7 Nov 2026 20:18:07 +0100 Subject: [PATCH] M2.6: an image's move changes no state a stream not read whole draws in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving or resizing an image in a stream before one damaged partway, or one that doesn't decode, goes on, as the edit changes no stream past it. But the image's `q cm … Q` can close past that stream, and what the stream draws inside the scope is unknown. When the image looked alone there, the move changed the scope's `cm` in place, and anything the stream draws in it moved too: a square drawn there in the image's `cm` moved with the image, as on `main`. A scope still open after such a stream now counts as one that paints something else (`first_op_after_damage`, read by `image_scopes` and `enclosing_transform`). An operator counts by its keyword there: a page may split its content between an operator's operands and its keyword, so a `Q` whose operands come before the hidden stream still closes after it. The move gives the `Do` a `cm` of its own and leaves the scope's as it was; with a clip on the image, the clip stays, as one the stream may share. A crop goes around the `Do` alone rather than at the scope's start, where it would clip what the stream draws. A crop PdfCraft put in such a scope isn't removed, and the refusal says why rather than that there is no crop. Arrange is unchanged: it takes out the scope it finds, which changes the stream past it and is refused. `an_image_whose_scope_closes_past_a_stream_not_read_whole_moves_alone`: a stream that doesn't decode and one damaged, each also with the square's operands before what is hidden (ending the stream before, or in the damaged stream before its damage); the image with no clip, a clip, and a PdfCraft crop; moved, cropped, and its crop removed. A scope that closes in a damaged stream before its damage still takes the move in its `cm`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/edit/src/images.rs | 57 ++++++++++++++++++-------- crates/edit/src/tests.rs | 77 +++++++++++++++++++++++++++++++++++ crates/edit/src/text.rs | 4 +- crates/edit/src/text/lines.rs | 9 ++++ 4 files changed, 128 insertions(+), 19 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 5e5c44fab7..fbb37ec1f0 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -527,12 +527,17 @@ fn exclusive_clip(ops: &[Op], op: usize) -> Option<(usize, Matrix)> { /// The `q cm … Q` whose only content after its `cm` is `start..=end` (a `Do`, or a balanced /// `q … Q` scope), with the index and value of that `cm`. Another transform of the same /// placement is folded into it, so repeated moves and resizes don't nest one more `q` each. -fn enclosing_transform(ops: &[Op], start: usize, end: usize) -> Option<(usize, Matrix)> { +/// None when its `Q` is at or past `hidden` ([`crate::text::first_op_after_damage`]): a stream +/// between that wasn't read may draw in that `cm` too. +fn enclosing_transform(ops: &[Op], start: usize, end: usize, hidden: usize) -> Option<(usize, Matrix)> { let at = start.checked_sub(1)?; let cm = ops.get(at)?; if !ops.get(at.checked_sub(1)?)?.is("q") || !ops.get(end.checked_add(1)?)?.is("Q") || !cm.is("cm") || cm.operands.len() != 6 { return None; } + if end.checked_add(1)? >= hidden { + return None; + } let m = Matrix(cm.nums::<6>()?); m.0.iter().all(|v| v.is_finite()).then_some((at, m)) } @@ -1052,7 +1057,9 @@ fn determine_arrange_spans( has_other_paint: bool, marked_unit: bool, ) -> Result<(std::ops::Range, usize, usize, usize), EditError> { - let scope = image_scopes(ops, target, "arranged") + // Nothing is hidden for Arrange: it takes out the scope it finds, so a scope that closes past a + // stream not read whole changes a stream past it, which `rewritten_contents` refuses. + let scope = image_scopes(ops, target, "arranged", ops.len()) .ok() .and_then(|s| s.isolated) .filter(|&(start, end, _)| start >= target_unit.start && end < target_unit.end); @@ -1491,8 +1498,10 @@ impl ImageScopes { } } -/// [`ImageScopes`] of the `Do` at `target`. An image inside a text object cannot be `done`. -fn image_scopes(ops: &[Op], target: usize, done: &str) -> Result { +/// [`ImageScopes`] of the `Do` at `target`. An image inside a text object cannot be `done`. A scope +/// still open at op `hidden` ([`crate::text::first_op_after_damage`]) holds a stream that wasn't +/// read, which may paint there: it counts as painting something else. +fn image_scopes(ops: &[Op], target: usize, done: &str, hidden: usize) -> Result { struct Scope { start: usize, matrix: Matrix, @@ -1511,6 +1520,11 @@ fn image_scopes(ops: &[Op], target: usize, done: &str) -> Result