.ai import: a rotated image in the editing data lands where its raster matrix puts it (#2269)

The raster matrix [a b c d tx ty] is an art-space matrix (y up) applied to
the pixels with their rows running downwards, (u, -v). The reader flipped
the matrix's result instead, which only agrees when b = c = 0, so a rotated
image came in turned 180 degrees around its corner. The legacy version 7
writer and its prolog used the same convention; they now write and draw
the matrix the reader takes.
This commit is contained in:
Gert Pellin
2026-10-16 14:58:08 +07:00
committed by bflatastic
parent 88088e99bb
commit b9069e761e
5 changed files with 79 additions and 9 deletions
+4 -2
View File
@@ -1875,10 +1875,12 @@ impl<'a> Reader<'a> {
/// `alpha` (its alpha channel, one byte a pixel) as its opacity mask when it hides part of the
/// image.
fn add_raster(&mut self, blob: ImageBlob, alpha: Option<&[u8]>, m: &[f64], (w, h): (u32, u32)) -> Result<(), String> {
// The matrix maps pixels (y down) into art space with its y flipped.
// The matrix is an art-space matrix (y up) applied to the pixels with their rows running
// downwards, (u, -v): pixels → (u, -v) → art space. Flipping the matrix's result instead
// gives the same place only when the image isn't rotated (b = c = 0).
let [a, b, c, d, tx, ty] = m else { return Ok(()) };
let to_doc = self.space();
let xf = to_doc * FLIP * Affine::new([*a, *b, *c, *d, *tx, -*ty]);
let xf = to_doc * Affine::new([*a, *b, *c, *d, *tx, *ty]) * FLIP;
if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 {
return Ok(());
}
+27
View File
@@ -632,6 +632,33 @@ fn images_with_alpha_channels() {
assert!(art(&r.doc).is_empty() && r.warnings.iter().any(|w| w.contains("cut short")));
}
/// The raster matrix `[a b c d tx ty]` is an art-space matrix (y up) applied to the image's pixels
/// with their rows running downwards, `(u, -v)`: art x = a·u − c·v + tx, art y = b·u − d·v + ty.
/// Without rotation (b = c = 0) that is the same as flipping the matrix's result; a rotated image
/// tells the two apart (#2269).
#[test]
fn a_rotated_image_is_placed_as_its_matrix_maps_it() {
// Turned 90° counter-clockwise: in art space each row runs upwards, the next row to its right.
let doc = read(&image("[ 0 10 -10 0 50 50 ]", (2, 1), ("DeviceRGB", 3, 0), &[255, 0, 0, 0, 0, 255], 6)).unwrap().doc;
let xf = pixels(&doc).1;
// Art (x, y) is (x, 100 - y) on the page.
let page = |x: f64, y: f64| Point::new(x, 100.0 - y);
assert_eq!(xf * Point::ZERO, page(50.0, 50.0), "the top left pixel's corner stays at the matrix's origin");
assert_eq!(xf * Point::new(2.0, 0.0), page(50.0, 70.0), "the first row runs up");
assert_eq!(xf * Point::new(0.0, 1.0), page(60.0, 50.0), "the next row is to its right");
// Any rotation and scale: the same formula.
let (s, c) = 30f64.to_radians().sin_cos();
let m = [3.0 * c, 3.0 * s, -2.0 * s, 2.0 * c, 40.0, 60.0];
let matrix = format!("[ {} ]", m.map(|v| v.to_string()).join(" "));
let doc = read(&image(&matrix, (2, 1), ("DeviceRGB", 3, 0), &[255, 0, 0, 0, 0, 255], 6)).unwrap().doc;
let xf = pixels(&doc).1;
for (u, v) in [(0.0, 0.0), (2.0, 0.0), (0.0, 1.0), (2.0, 1.0)] {
let want = page(m[0] * u - m[2] * v + m[4], m[1] * u - m[3] * v + m[5]);
let got = xf * Point::new(u, v);
assert!((got - want).hypot() < 1e-9, "pixel ({u}, {v}): {got:?}, want {want:?}");
}
}
/// CMYK images keep their ink amounts, as the file's PDF part keeps them, and an alpha channel
/// becomes their opacity mask: a grayscale image over the image.
#[test]
+7 -6
View File
@@ -313,7 +313,7 @@ VCLegacy begin
/_row {{currentfile _l readline not {exit} if dup length 1 gt {exit} if pop} loop
dup length 1 gt {dup length 1 sub 1 exch getinterval /ASCIIHexDecode filter _s readstring pop} {pop ()} ifelse} bd
/XI {pop pop pop pop /_ic exch def /_ib exch def /_ih exch def /_iw exch def pop pop pop pop
aload pop neg 6 array astore [1 0 0 -1 0 0] matrix concatmatrix
[1 0 0 -1 0 0] exch matrix concatmatrix
_on {gsave concat _iw _ih _ib [1 0 0 1 0 0] /_row load false _ic 1 eq {1} {_ic} ifelse colorimage grestore} {pop} ifelse} bd
% Type (15) is set by the app that reads the file: its operators take their operands and mark
% nothing; encodings (3.3) are skipped.
@@ -1201,11 +1201,12 @@ impl<'a> Writer<'a> {
// ---------- images ----------
/// An image (§8): `%AI5_BeginRaster`, its matrix (pixels, y down, → art space with y flipped)
/// and operands with `XI`, its RGB samples in hex on comment lines, `%AI5_EndRaster`.
/// An image (§8): `%AI5_BeginRaster`, its matrix (an art-space matrix applied to the pixels
/// with their rows running downwards, `(u, -v)`) and operands with `XI`, its RGB samples in hex
/// on comment lines, `%AI5_EndRaster`.
fn image(&mut self, im: &ImageObject) {
let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
let m = flip * self.to_art * im.xf;
let m = self.to_art * im.xf * flip;
if !invertible(m) {
return;
}
@@ -1220,7 +1221,7 @@ impl<'a> Writer<'a> {
}
// The object places the image's own pixel grid (its width × height) wherever the file's is.
let fit = Affine::scale_non_uniform(f64::from(im.width.max(1)) / f64::from(w), f64::from(im.height.max(1)) / f64::from(h));
let m = flip * self.to_art * im.xf * fit;
let m = self.to_art * im.xf * fit * flip;
let rgba = img.to_rgba8();
let mut hex = String::with_capacity(rgba.len() / 4 * 7);
hex.push('%');
@@ -1253,7 +1254,7 @@ impl<'a> Writer<'a> {
let [a, b, c, d, e, f] = m.as_coeffs();
let s = &mut self.body;
s.push_str("%AI5_File:\n%AI5_BeginRaster\n[");
ps::push_nums(s, &[a, b, c, d, e, -f]);
ps::push_nums(s, &[a, b, c, d, e, f]);
s.pop();
let _ = writeln!(s, "] 0 0 {w} {h} {w} {h} 8 3 0 0 0 0 XI");
s.push_str(&hex);
+39
View File
@@ -228,6 +228,45 @@ fn writes_the_documented_operators() {
assert!(text.contains("] 0 0 2 1 2 1 8 3 0 0 0 0 XI\n%FF00000000FF\n%AI5_EndRaster\n"), "{text}");
}
/// An image's matrix (§8) is an art-space matrix applied to its pixels with their rows running
/// downwards, `(u, -v)`: a rotated image is written with the matrix the reader and other apps
/// place it by, prints where it is, and reads back where it was (#2269).
#[test]
fn a_rotated_image_keeps_its_place() {
let mut doc = Document::new(200.0, 100.0);
let mut png = vec![];
image::RgbaImage::from_raw(2, 1, vec![255, 0, 0, 255, 0, 0, 255, 255])
.unwrap()
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let blob = ImageBlob::new("image/png", png);
let key = blob.content_key();
doc.images.insert(key.clone(), blob);
// Turned 90° clockwise on the page: the pixel rows run down, the next row is to the left.
let xf = Affine::new([0.0, 10.0, -10.0, 0.0, 100.0, 40.0]);
let im =
Node::new(doc.alloc_id(), NodeKind::Image(Box::new(ImageObject { key, width: 2, height: 1, xf, link: None, placement: Default::default() })));
doc.layers.clear();
let layer = doc.add_layer(Some("Art"));
doc.insert(Some(layer), usize::MAX, im).unwrap();
let out = write(&doc);
let text = String::from_utf8(out.bytes.clone()).unwrap();
// In art space (y up) the rows run down, (0, -10) a pixel, and the next row is to the left,
// (-10, 0): [a b] = (0, -10) and [c d] = (10, 0), as the matrix takes (u, -v).
let line = text.lines().find(|l| l.ends_with(" XI")).unwrap();
let m: Vec<f64> = line.trim_start_matches('[').split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect();
assert_eq!(m[..4], [0.0, -10.0, 10.0, 0.0], "{line}");
let read = crate::import(&out.bytes).unwrap().document;
let mut found = None;
read.walk(|n| {
if let NodeKind::Image(im) = &n.kind {
found = Some(im.xf);
}
});
let back = found.unwrap();
assert!(back.as_coeffs().iter().zip(xf.as_coeffs()).all(|(a, b)| (a - b).abs() < 1e-6), "{back:?}");
}
#[test]
fn a_gradient_fill_and_its_stroke_are_one_object() {
let mut doc = Document::new(100.0, 100.0);
File diff suppressed because one or more lines are too long