fix(edit): refuse arrange across crossed scope boundaries

Require both graphics and marked-content boundaries to balance inside source, destination and removed spans. Regression fails before the guard; all 408 edit tests, scoped clippy, fmt, layers and assets pass on GGEO-AI02.

Co-Authored-By: Codex GPT-6.1 <noreply@openai.com>
This commit is contained in:
FredApps
2026-11-01 12:33:51 +01:00
co-authored by Codex GPT-6.1
parent eb0d5e64c7
commit 1a2f7b2a34
2 changed files with 44 additions and 0 deletions
+30
View File
@@ -976,6 +976,30 @@ fn target_destination_unit<'a>(
dest.map(|u| (u, forward)).ok_or_else(refused)
}
/// A moved unit must not borrow a graphics or marked-content boundary from its neighbours.
fn balanced_arrange_span(ops: &[Op], range: std::ops::Range<usize>) -> bool {
let Some(slice) = ops.get(range) else { return false };
let (mut graphics, mut marked) = (0usize, 0usize);
for op in slice {
let depth = match op.op.as_slice() {
b"q" => {
graphics = graphics.saturating_add(1);
continue;
}
b"BDC" | b"BMC" => {
marked = marked.saturating_add(1);
continue;
}
b"Q" => &mut graphics,
b"EMC" => &mut marked,
_ => continue,
};
let Some(next) = depth.checked_sub(1) else { return false };
*depth = next;
}
graphics == 0 && marked == 0
}
/// What Arrange takes out: the byte span, the op where the image's own scope state starts, and the
/// ops `start..end` removed. That is the image's own `q … Q` scope inside its unit, else the `Do`
/// alone when the unit paints something else too, else the whole unit.
@@ -999,6 +1023,9 @@ fn determine_arrange_spans(
_ if marked_unit => (target_unit.start, target_unit.start.saturating_add(1).min(target), unit_last),
_ => (target_unit.start, target_unit.start, unit_last),
};
if !balanced_arrange_span(ops, first..last.saturating_add(1)) {
return Err(EditError::Invalid("cannot arrange an image across graphics or marked-content boundaries".into()));
}
let missing = || EditError::Invalid("the image content unit could not be located".into());
let start = ops.get(first).ok_or_else(missing)?.span.start;
let end = ops.get(last).ok_or_else(missing)?.span.end;
@@ -1149,6 +1176,9 @@ fn arrange_image(
}
let (dest_unit, insert_after) = target_destination_unit(&units, target_idx, target_unit, order, has_other_paint)?;
if !balanced_arrange_span(&ops, target_unit.clone()) || !balanced_arrange_span(&ops, dest_unit.clone()) {
return Err(EditError::Invalid("cannot arrange an image across graphics or marked-content boundaries".into()));
}
// A marked-content sequence that holds other content cannot be split without generating
// a new structure element and MCID, so we refuse to rearrange it to avoid leaving the image untagged.
+14
View File
@@ -9938,3 +9938,17 @@ fn styled_existing_justification_is_refused_instead_of_silently_left_aligning()
assert_eq!(page_content_bytes(&doc, 0), before);
assert_eq!(page_content_bytes(&reopen(&doc), 0), before);
}
#[test]
fn image_arrange_refuses_crossed_scope_boundaries_without_changes() {
for content in [
"/P <</MCID 0>> BDC q 10 0 0 10 0 0 cm /Im0 Do EMC Q /Im0 Do",
"q /P <</MCID 0>> BDC /Im0 Do Q EMC /Im0 Do",
] {
let mut doc = image_page_with(content);
let before = page_content_bytes(&doc, 0);
assert!(arrange(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).is_err(), "{content}");
assert_eq!(page_content_bytes(&doc, 0), before);
}
}