mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
M2.6: an image's move changes no state a stream not read whole draws in
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
1 parent
ae677644e0
commit
569f631f2b
4 files changed
+128
-19
No files matched your search
+40
-17
@@ -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, 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, EditError> {
|
||||
/// [`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<ImageScopes, EditError> {
|
||||
struct Scope {
|
||||
start: usize,
|
||||
matrix: Matrix,
|
||||
@@ -1511,6 +1520,11 @@ fn image_scopes(ops: &[Op], target: usize, done: &str) -> Result<ImageScopes, Ed
|
||||
let mut isolated = None;
|
||||
let mut outermost = None;
|
||||
for (index, op) in ops.iter().enumerate() {
|
||||
if index == hidden {
|
||||
for scope in &mut stack {
|
||||
scope.other_paint = true;
|
||||
}
|
||||
}
|
||||
match op.op.as_slice() {
|
||||
b"q" => {
|
||||
if stack.len() >= 256 {
|
||||
@@ -1600,14 +1614,20 @@ fn remove_image_crop(doc: &mut Document, p: &pdfcraft_model::Page, all: &[Conten
|
||||
let pieces = joined(all);
|
||||
let ops = pieces.parse().ops;
|
||||
let target = img.op;
|
||||
let scope = image_scopes(&ops, target, "cropped")?;
|
||||
let Some((start, _, _)) = scope.transform_scope(target) else {
|
||||
return Err(EditError::Invalid("the image has no PdfCraft crop to remove".into()));
|
||||
let crops = |hidden| -> Result<Vec<std::ops::Range<usize>>, EditError> {
|
||||
let scope = image_scopes(&ops, target, "cropped", hidden)?;
|
||||
Ok(scope.transform_scope(target).map(|(start, _, _)| app_crop_ranges(&ops, start, target)).unwrap_or_default())
|
||||
};
|
||||
let crops = app_crop_ranges(&ops, start, target);
|
||||
if crops.is_empty() {
|
||||
return Err(EditError::Invalid("the image has no PdfCraft crop to remove".into()));
|
||||
let crops_there = crops(crate::text::first_op_after_damage(all, &pieces, &ops))?;
|
||||
if crops_there.is_empty() {
|
||||
// A crop in a scope that closes past a stream not read whole may clip what that stream draws.
|
||||
return Err(EditError::Invalid(if crops(ops.len())?.is_empty() {
|
||||
"the image has no PdfCraft crop to remove".into()
|
||||
} else {
|
||||
"the image's crop may also clip what a content stream that is damaged or can't be decoded draws, so it isn't removed".into()
|
||||
}));
|
||||
}
|
||||
let crops = crops_there;
|
||||
let changed = crate::text::splice(&pieces, &ops, |index| (Vec::new(), !crops.iter().any(|r| r.contains(&index))));
|
||||
let contents = crate::text::rewritten_contents(doc, all, changed)?;
|
||||
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
|
||||
@@ -1705,8 +1725,9 @@ fn transform_image(
|
||||
}
|
||||
// A private path crop moves with its image regardless of intervening state operators.
|
||||
// Shared crops retain the existing fixed-clip behavior, preserving neighbouring paint.
|
||||
let hidden = crate::text::first_op_after_damage(streams, &pieces, &ops);
|
||||
let (start, end, placement) =
|
||||
image_scopes(&ops, target, "transformed")?.transform_scope(target).unwrap_or((target, target, Matrix(image.draw_matrix)));
|
||||
image_scopes(&ops, target, "transformed", hidden)?.transform_scope(target).unwrap_or((target, target, Matrix(image.draw_matrix)));
|
||||
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
|
||||
let local = placement.then(&Matrix(transform)).then(&inverse);
|
||||
if !local.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
||||
@@ -1715,7 +1736,7 @@ fn transform_image(
|
||||
|
||||
// Already alone in a `q cm … Q` (its own placement, or the wrapper an earlier edit added):
|
||||
// change that `cm` in place. The CTM inside is `local · cm · outer` either way.
|
||||
if let Some((at, cm)) = enclosing_transform(&ops, start, end) {
|
||||
if let Some((at, cm)) = enclosing_transform(&ops, start, end, hidden) {
|
||||
let folded = local.then(&cm);
|
||||
if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
||||
return Err(EditError::Invalid("the image transform is too large".into()));
|
||||
@@ -1780,8 +1801,9 @@ fn crop_image_impl(
|
||||
if !ops.get(target).is_some_and(|op| op.is("Do")) {
|
||||
return Err(EditError::Invalid("the image operation could not be located".into()));
|
||||
}
|
||||
let (start, end, placement) =
|
||||
image_scopes(&ops, target, "cropped")?.transform_scope(target).unwrap_or((target, target, Matrix(image.draw_matrix)));
|
||||
let (start, end, placement) = image_scopes(&ops, target, "cropped", crate::text::first_op_after_damage(streams, &pieces, &ops))?
|
||||
.transform_scope(target)
|
||||
.unwrap_or((target, target, Matrix(image.draw_matrix)));
|
||||
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
|
||||
let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| inverse.apply(x, y));
|
||||
if !corners.iter().all(|(x, y)| x.is_finite() && y.is_finite() && x.abs() <= 1e12 && y.abs() <= 1e12) {
|
||||
@@ -1862,6 +1884,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
|
||||
}
|
||||
let pieces = joined(&all);
|
||||
let ops = pieces.parse().ops;
|
||||
let hidden = crate::text::first_op_after_damage(&all, &pieces, &ops);
|
||||
let mut inside_text = false;
|
||||
for (index, op) in ops.iter().enumerate() {
|
||||
if op.is("BT") {
|
||||
@@ -1879,7 +1902,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
|
||||
let mut folds = std::collections::BTreeMap::new();
|
||||
for image in images.iter().filter(|image| image.has_editor_crop()) {
|
||||
let Some(&(_, _, offset)) = plans.get(&image.op) else { continue };
|
||||
let Some((start, end, placement)) = image_scopes(&ops, image.op, "moved")?.transform_scope(image.op) else {
|
||||
let Some((start, end, placement)) = image_scopes(&ops, image.op, "moved", hidden)?.transform_scope(image.op) else {
|
||||
return Err(EditError::Invalid("the image crop cannot be moved independently".into()));
|
||||
};
|
||||
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("a selected image has no area".into()))?;
|
||||
@@ -1896,7 +1919,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
|
||||
};
|
||||
// An enclosing cm is before the captured placement; an inside-scope cm is after it.
|
||||
// Their multiplication orders differ when that cm scales or shears a translation.
|
||||
let folded = enclosing_transform(&ops, start, end)
|
||||
let folded = enclosing_transform(&ops, start, end, hidden)
|
||||
.map(|(at, cm)| (at, local.then(&cm)))
|
||||
.or_else(|| leading_transform().map(|(at, cm)| (at, cm.then(&local))));
|
||||
if let Some((at, folded)) = folded {
|
||||
@@ -1932,7 +1955,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
|
||||
// An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another.
|
||||
for index in 0..ops.len() {
|
||||
let Some(&(movement, _, _)) = plans.get(&index) else { continue };
|
||||
let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue };
|
||||
let Some((at, cm)) = enclosing_transform(&ops, index, index, hidden) else { continue };
|
||||
let folded = movement.then(&cm);
|
||||
if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
||||
return Err(EditError::Invalid("the image move is too large".into()));
|
||||
|
||||
@@ -5294,6 +5294,83 @@ fn content_after_a_stream_that_does_not_decode_is_not_edited() {
|
||||
assert_eq!(contents(&doc), listed);
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// leaves that `cm` as it is and gives the `Do` a `cm` of its own, so the square stays, with a clip
|
||||
/// on the image too, which then stays where it is as one the square may share. A crop goes around
|
||||
/// the `Do` alone, and a crop PdfCraft put in that scope isn't removed. A scope that closes in a
|
||||
/// damaged stream before its damage holds none of it: its `cm` takes the move.
|
||||
#[test]
|
||||
fn an_image_whose_scope_closes_past_a_stream_not_read_whole_moves_alone() {
|
||||
use pdfcraft_cos::{Dict, ObjRef, Stream};
|
||||
let contents = |doc: &Document| pdfcraft_model::pages(doc)[0].dict.get(b"Contents").cloned();
|
||||
let first_stream = |doc: &Document| {
|
||||
let Some(Object::Array(list)) = contents(doc) else { panic!("contents {:?}", contents(doc)) };
|
||||
let Some(Object::Stream(first)) = list.first().map(|f| (*doc.resolve(f)).clone()) else { panic!("no first stream") };
|
||||
String::from_utf8(first.decoded().unwrap()).unwrap()
|
||||
};
|
||||
let later_streams = |doc: &Document| match contents(doc) {
|
||||
Some(Object::Array(list)) => list.get(1..).map(<[Object]>::to_vec),
|
||||
other => panic!("contents {other:?}"),
|
||||
};
|
||||
// The image in `q 100 0 0 100 50 50 cm`, a stream not read whole drawing a square in that
|
||||
// `cm`, then the scope's `Q`. Split, the square's operands come before the hidden keyword.
|
||||
let page = |clip: &str, damaged: bool, split: bool| {
|
||||
let operands = if split && !damaged { " 1.5 0 0.5 0.5" } else { "" };
|
||||
let mut doc = two_stream_image_page(&format!("q 100 0 0 100 50 50 cm {clip}/Im1 Do{operands}"), "Q");
|
||||
let hidden = match (damaged, split) {
|
||||
(true, false) => damaged_flate(b"", b"0 0 1 rg 1.5 0 0.5 0.5 re f"),
|
||||
(true, true) => damaged_flate(b"0 0 1 rg 1.5 0", b" 0.5 0.5 re f"),
|
||||
(false, _) => {
|
||||
let filter = doc.add(Object::name("FlateDecode"));
|
||||
let mut s = Stream::flate(Dict::new(), if split { b"re f".as_slice() } else { b"0 0 1 rg 1.5 0 0.5 0.5 re f" });
|
||||
s.dict.set(b"Filter".to_vec(), Object::Ref(filter));
|
||||
s
|
||||
}
|
||||
};
|
||||
let hidden = doc.add(hidden);
|
||||
let listed = vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(hidden), Object::Ref(ObjRef::new(5, 0))];
|
||||
doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(listed))).unwrap();
|
||||
doc
|
||||
};
|
||||
let shift = images::ImageChange::Transform([1.0, 0.0, 0.0, 1.0, 10.0, 0.0]);
|
||||
for damaged in [false, true] {
|
||||
for split in [false, true] {
|
||||
for clip in ["", "0 0 1 1 re W n ", "/PdfCraftImageCrop MP 0 0 1 1 re W n "] {
|
||||
let what = format!("damaged: {damaged}, split: {split}, clip: {clip:?}");
|
||||
let mut doc = page(clip, damaged, split);
|
||||
let later = later_streams(&doc);
|
||||
images::change_image(&mut doc, 0, 0, &shift).unwrap();
|
||||
assert_eq!(later_streams(&doc), later, "{what}: the streams after it as they were");
|
||||
let first = first_stream(&doc);
|
||||
assert!(first.contains("100 0 0 100 50 50 cm"), "{what}: the scope's cm stays: {first}");
|
||||
assert!(close(images::page_images(&doc, 0).unwrap()[0].rect, [60.0, 50.0, 160.0, 150.0]), "{what}: the image moved");
|
||||
|
||||
let mut doc = page(clip, damaged, split);
|
||||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Crop([60.0, 60.0, 140.0, 140.0])).unwrap();
|
||||
assert_eq!(later_streams(&doc), later, "{what}: cropped, the streams after it as they were");
|
||||
let first = first_stream(&doc);
|
||||
assert!(first.starts_with("q 100 0 0 100 50 50 cm"), "{what}: nothing put at the scope's start: {first}");
|
||||
|
||||
if clip.contains("PdfCraftImageCrop") {
|
||||
let mut doc = page(clip, damaged, split);
|
||||
let listed = contents(&doc);
|
||||
let refused = images::change_image(&mut doc, 0, 0, &images::ImageChange::RemoveCrop).unwrap_err();
|
||||
assert!(refused.to_string().contains("may also clip"), "{what}: {refused}");
|
||||
assert_eq!(contents(&doc), listed, "{what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut doc = two_stream_image_page("q 100 0 0 100 50 50 cm /Im1 Do", "");
|
||||
doc.set(ObjRef::new(5, 0), damaged_flate(b"Q\n", b"0 0 1 rg 1.5 0 0.5 0.5 re f"));
|
||||
images::change_image(&mut doc, 0, 0, &shift).unwrap();
|
||||
let first = first_stream(&doc);
|
||||
assert!(first.contains("100 0 0 100 60 50 cm") && !first.contains("50 50 cm"), "closed before the damage: {first}");
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -56,8 +56,8 @@ pub(crate) use fallback::{glyph_path, type3_font_dict, unique_font_name};
|
||||
use interp::{Origin, Paint, union};
|
||||
use lines::page_dict;
|
||||
pub(crate) use lines::{
|
||||
ContentStream, FormBudget, Read, content_streams, content_streams_within, dict_in_place, drawn_before_damage, form_call, joined, resource_dict,
|
||||
rewritten_contents, splice,
|
||||
ContentStream, FormBudget, Read, content_streams, content_streams_within, dict_in_place, drawn_before_damage, first_op_after_damage, form_call,
|
||||
joined, resource_dict, rewritten_contents, splice,
|
||||
};
|
||||
pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines};
|
||||
pub use rewrite::{LineEdit, replace_line, rewrite_block};
|
||||
|
||||
@@ -165,6 +165,15 @@ fn first_damaged(streams: &[ContentStream]) -> usize {
|
||||
streams.iter().position(|s| s.read != Read::Whole).unwrap_or(streams.len())
|
||||
}
|
||||
|
||||
/// The index of the first of `ops`, parsed from `pieces` (the [`joined`] `streams`), drawn after a
|
||||
/// stream not read whole, or `ops.len()`. A `q … Q` still open there may hold what wasn't read,
|
||||
/// painting in the state it sets. An operator counts by its keyword: one whose operands start
|
||||
/// before such a stream takes them from around what wasn't read.
|
||||
pub(crate) fn first_op_after_damage(streams: &[ContentStream], pieces: &Pieces, ops: &[Op]) -> usize {
|
||||
let damaged = first_damaged(streams);
|
||||
ops.iter().position(|op| pieces.pieces_of(op).1 > damaged).unwrap_or(ops.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.
|
||||
|
||||
Reference in new issue
Block a user