From 030729d6f3b00101930c97adee5e186d17e02948 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 12:29:12 -0700 Subject: [PATCH] Legacy .ai export: images are written in the form the version 7 specification shows A version 7 .ai wrote each image's matrix as [a b c d tx ty] and its samples on comment lines of 64 hex digits. The Adobe Illustrator File Format Specification version 7.0, which the writer follows, writes the matrix with a space inside each bracket in the XI syntax (section 8.1) and in all five examples (section 8.4), and every example data line holds 60 hex digits. #2692 reports that the reference app leaves out an image inside a clipping group, and all the art after it, in the files written from the reporter's two SVGs. The writer now writes [ a b c d tx ty ] and data lines of 60 hex digits (the last holds the rest), for every image it writes. VectorCraft's reader and the file's PostScript prolog read both forms. The result was not checked in the reference app. --- crates/engine/src/tests_legacy_ai.rs | 53 +++++++++++++ crates/eps/src/legacy_ai.rs | 19 ++--- crates/eps/src/tests_legacy_ai.rs | 107 +++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/tests_legacy_ai.rs b/crates/engine/src/tests_legacy_ai.rs index 2ee07857e..11b9f9680 100644 --- a/crates/engine/src/tests_legacy_ai.rs +++ b/crates/engine/src/tests_legacy_ai.rs @@ -371,6 +371,59 @@ fn transparent_images_can_be_left_out_and_opacity_makes_one_transparent() { assert_eq!(v["options"]["transparentImages"]["value"], "flatten", "{v}"); } +/// #2692's files: an image clipped to a circle inside a circle's stroke, and an image with +/// see-through pixels (which the save flattens into an image inside a clipping group), each with +/// an orange square on the next layer. +const CLIPPED_IMAGE: &str = r##" + + + + + + +"##; +const SEE_THROUGH_IMAGE: &str = r##" + + + + +"##; + +/// #2692: the image inside a clipping group, clipped in the file or flattened by the save, is +/// written as the version 7 specification shows one (§8.1, §8.4): the matrix with a space inside +/// each bracket and the samples on comment lines of 60 hex digits, between `W n` and `Q`. The next +/// layer follows with the square's exact lines, and the file opens again with both layers, the +/// clipping group, the image and the square. +#[test] +fn a_clipped_or_flattened_image_is_written_as_the_specification_shows_it() { + for (svg, layer) in [(CLIPPED_IMAGE, "clipped-image"), (SEE_THROUGH_IMAGE, "alpha-image")] { + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "t.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap(); + let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap(); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + let text = String::from_utf8(bytes).unwrap(); + let lines: Vec<&str> = text.lines().collect(); + let xi: Vec = (0..lines.len()).filter(|i| lines[*i].ends_with(" XI")).collect(); + assert_eq!(xi.len(), 1, "{text}"); + let at = xi[0]; + assert_eq!(lines[at - 2..at], ["%AI5_File:", "%AI5_BeginRaster"], "{layer}"); + assert!(lines[at].starts_with("[ ") && lines[at].contains(" ] 0 0 "), "{}", lines[at]); + let end = at + lines[at..].iter().position(|l| *l == "%AI5_EndRaster").unwrap(); + let (last, full) = lines[at + 1..end].split_last().unwrap(); + assert!(full.iter().all(|l| l.len() == 61) && (2..=61).contains(&last.len()), "{layer}"); + assert!(text.contains("\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nQ\nLB\n%AI5_EndLayer--\n"), "{text}"); + let square = "%AI5_BeginLayer\n1 1 1 1 0 0 1 255 79 79 Lb\n(above) Ln\n0 A\n0.9686 0.5804 0.1137 Xa\n0 O\n0 XR\n\ + 220 180 m\n380 180 L\n380 20 L\n220 20 L\n220 180 L\nf\nLB\n%AI5_EndLayer--\n%%PageTrailer\n"; + assert!(text.contains(square), "{text}"); + s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(layer_names(d), [layer, "above"]); + assert_eq!(images(d), (1, 1), "{layer}"); + assert_eq!(colour_at(d, 300.0, 100.0), [247, 148, 29], "{layer}"); + assert_ne!(colour_at(d, 100.0, 100.0), [255, 255, 255], "{layer}"); + } +} + #[test] fn a_range_saves_those_artboards_and_save_writes_them_again() { let dir = Folder::new("ai7-range"); diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 9ef02b3c3..6c99174a0 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -27,8 +27,9 @@ //! at a tint). Gradient fills (§3.5.3): the path, `Bb`, a radial's highlight `x y angle fraction //! Bh` (`0 0 0 0 Bh` at the center, §3.5.5, §3.5.6), `1 (name) x y angle length 1 0 0 1 0 0 Bg`, //! `f` (or `F`), and `0 BB`, or `2 BB` (`1 BB` open) to stroke it too. -//! - Images (§8): `%AI5_BeginRaster`, `[matrix] 0 0 w h w h 8 3 0 0 0 0 XI`, the RGB samples in -//! hex on comment lines, `%AI5_EndRaster`. +//! - Images (§8.1, §8.4): `%AI5_File:`, `%AI5_BeginRaster`, +//! `[ a b c d tx ty ] 0 0 w h w h 8 3 0 0 0 0 XI`, the RGB samples in hex on comment lines of 60 +//! digits, `%AI5_EndRaster`. //! - Type (§15): point type, area type and type on a path as text objects, `type To`, `a b c d tx //! ty startPt Tp` (the text matrix; the frame or path in art space between it and `TP`), each //! run's paint style, `Tr` (fill, stroke, both or neither), `/_font size ascent descent Tf`, @@ -75,8 +76,8 @@ use crate::{latin, ps}; const MAX_NEST: u32 = 8; /// Largest image written (pixels). const MAX_IMAGE_PIXELS: u64 = 1 << 26; -/// Hex digits on a line of image data. -const HEX_LINE: usize = 64; +/// Hex digits on a line of image data (§8.4). +const HEX_LINE: usize = 60; pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level layer (legacy .ai files have no sublayers)"; pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, inline graphics, gradient or pattern fills) is written as outlines"; @@ -1223,9 +1224,10 @@ impl<'a> Writer<'a> { // ---------- images ---------- - /// 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`. + /// An image (§8): `%AI5_File:`, `%AI5_BeginRaster`, its matrix (an art-space matrix applied to + /// the pixels with their rows running downwards, `(u, -v)`) as `[ a b c d tx ty ]` and its + /// operands with `XI`, its RGB samples in hex on comment lines of [`HEX_LINE`] digits, + /// `%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 = self.to_art * im.xf * flip; @@ -1275,9 +1277,8 @@ 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["); + s.push_str("%AI5_File:\n%AI5_BeginRaster\n[ "); 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); s.push_str("%AI5_EndRaster\n"); diff --git a/crates/eps/src/tests_legacy_ai.rs b/crates/eps/src/tests_legacy_ai.rs index fc9db957f..bd9badbcd 100644 --- a/crates/eps/src/tests_legacy_ai.rs +++ b/crates/eps/src/tests_legacy_ai.rs @@ -269,6 +269,113 @@ fn a_rotated_image_keeps_its_place() { assert!(back.as_coeffs().iter().zip(xf.as_coeffs()).all(|(a, b)| (a - b).abs() < 1e-6), "{back:?}"); } +/// A 16 × 16 RGB image of `doc` filling `r`. +fn picture(doc: &mut Document, r: Rect) -> Node { + let mut png = vec![]; + image::RgbaImage::from_fn(16, 16, |u, v| image::Rgba([(u * 16) as u8, (v * 16) as u8, 128, 255])) + .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); + let xf = Affine::new([r.width() / 16.0, 0.0, 0.0, r.height() / 16.0, r.x0, r.y0]); + Node::new(doc.alloc_id(), NodeKind::Image(Box::new(ImageObject { key, width: 16, height: 16, xf, link: None, placement: Default::default() }))) +} + +/// A layer `Below` holding a clipping group (a circle clipping an image, as in #2692), a group +/// holding an image and an image; a layer `Above` holding an orange square. Without `images`, +/// `Below` is empty. +fn images_below_a_square(images: bool) -> Document { + let mut doc = Document::new(400.0, 200.0); + doc.layers.clear(); + let below = doc.add_layer(Some("Below")); + let above = doc.add_layer(Some("Above")); + if images { + let clip = Node::new( + doc.alloc_id(), + NodeKind::Path { + path: shapes::ellipse(Rect::new(30.0, 30.0, 170.0, 170.0)), + rule: FillRule::NonZero, + live: None, + clipping: true, + guide: false, + }, + ); + let clipped = picture(&mut doc, Rect::new(20.0, 20.0, 180.0, 180.0)); + let window = Node::new(doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(clipped)], clip: true }); + doc.insert(Some(below), usize::MAX, window).unwrap(); + let grouped = picture(&mut doc, Rect::new(200.0, 20.0, 240.0, 60.0)); + let group = Node::group(doc.alloc_id(), vec![Arc::new(grouped)]); + doc.insert(Some(below), usize::MAX, group).unwrap(); + let loose = picture(&mut doc, Rect::new(250.0, 20.0, 290.0, 60.0)); + doc.insert(Some(below), usize::MAX, loose).unwrap(); + } + let orange = Paint::solid(Color::rgb(247.0 / 255.0, 148.0 / 255.0, 29.0 / 255.0)); + let square = shape(&mut doc, shapes::rectangle(Rect::new(220.0, 100.0, 380.0, 180.0)), orange, Paint::None); + doc.insert(Some(above), usize::MAX, square).unwrap(); + doc +} + +/// Each image the file holds (§8.4): the lines from `%AI5_File:` to `%AI5_EndRaster`. +fn rasters(text: &str) -> Vec> { + let lines: Vec<&str> = text.lines().collect(); + let mut out = vec![]; + for (i, l) in lines.iter().enumerate() { + if *l == "%AI5_File:" { + let end = lines[i..].iter().position(|l| *l == "%AI5_EndRaster").map(|n| i + n).unwrap(); + out.push(lines[i..=end].to_vec()); + } + } + out +} + +/// Every image, in a clipping group, a group or neither, is written as §8.1's syntax and §8.4's +/// examples write one: `%AI5_File:`, `%AI5_BeginRaster`, the matrix with a space inside each +/// bracket and the operands on the `XI` line, the samples in hex on comment lines of 60 digits, +/// `%AI5_EndRaster`. The art after the images is written as it is without them, and the file +/// reads back with its layers, clipping group, images and square (#2692). +#[test] +fn images_are_written_in_the_form_the_specification_shows() { + let out = write(&images_below_a_square(true)); + let text = String::from_utf8(out.bytes.clone()).unwrap(); + let images = rasters(&text); + assert_eq!(images.len(), 3, "{text}"); + for lines in &images { + let [file, begin, xi, data @ .., end] = lines.as_slice() else { panic!("{lines:?}") }; + assert_eq!((*file, *begin, *end), ("%AI5_File:", "%AI5_BeginRaster", "%AI5_EndRaster")); + let matrix = xi.strip_prefix("[ ").and_then(|l| l.strip_suffix(" ] 0 0 16 16 16 16 8 3 0 0 0 0 XI")).unwrap_or_else(|| panic!("{xi}")); + assert_eq!(matrix.split(' ').filter(|v| v.parse::().is_ok()).count(), 6, "{xi}"); + let digits: Vec<&str> = data.iter().map(|l| l.strip_prefix('%').unwrap_or_else(|| panic!("{l}"))).collect(); + let (last, full) = digits.split_last().unwrap(); + assert!(full.iter().all(|d| d.len() == 60) && (1..=60).contains(&last.len()), "{digits:?}"); + assert!(digits.iter().all(|d| d.bytes().all(|b| b.is_ascii_hexdigit())), "{digits:?}"); + assert_eq!(digits.concat().len(), 16 * 16 * 3 * 2); + } + // The clipping group: `q`, the clipping path painted `h W n` (§5.6.2), the image, `Q`; the + // group: `u`, the image, `U`. + assert!(text.contains("\nh\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nQ\n"), "{text}"); + assert!(text.contains("\nu\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nU\n"), "{text}"); + let layer = |t: &str| -> String { + let at = t.find("(Above) Ln\n").unwrap(); + t[at..].split("%AI5_EndLayer--").next().unwrap().to_string() + }; + let alone = String::from_utf8(write(&images_below_a_square(false)).bytes).unwrap(); + assert_eq!(layer(&text), layer(&alone)); + let doc = crate::import(&out.bytes).unwrap().document; + let names: Vec<_> = doc.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert_eq!(names, ["Below", "Above"]); + let below = children(&doc.layers[0]); + assert_eq!(below.len(), 3, "{below:#?}"); + let NodeKind::Group { children: window, clip: true } = &below[0].kind else { panic!("{:?}", below[0].kind) }; + assert!(matches!(window[0].kind, NodeKind::Path { clipping: true, .. }) && matches!(window[1].kind, NodeKind::Image(_)), "{window:#?}"); + assert!(matches!(&below[1].kind, NodeKind::Group { children, clip: false } if matches!(children[0].kind, NodeKind::Image(_)))); + assert!(matches!(below[2].kind, NodeKind::Image(_))); + let above = children(&doc.layers[1]); + assert_eq!(above.len(), 1); + let rgb = above[0].appearance.fill_paint().color().map(|c| c.to_rgb()).unwrap(); + assert!(rgb.iter().zip([247.0, 148.0, 29.0]).all(|(c, want)| (c * 255.0 - want).abs() < 0.1), "{rgb:?}"); +} + #[test] fn a_gradient_fill_and_its_stroke_are_one_object() { let mut doc = Document::new(100.0, 100.0);