diff --git a/crates/engine/src/tests_legacy_ai.rs b/crates/engine/src/tests_legacy_ai.rs index 11b9f9680..cdd0837bd 100644 --- a/crates/engine/src/tests_legacy_ai.rs +++ b/crates/engine/src/tests_legacy_ai.rs @@ -371,7 +371,7 @@ 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 +/// #2692's file, an image clipped to a circle inside a circle's stroke, and #2830's, 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##" @@ -389,14 +389,16 @@ 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 +/// #2692 and #2830: 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`. +/// A line `F`, which the specification's examples do not show, follows `%AI5_EndRaster`. 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. +/// clipping group, the image, the square and, besides the clipping path, only the paths the SVG +/// holds. #[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")] { + for (svg, layer, paths) in [(CLIPPED_IMAGE, "clipped-image", 2), (SEE_THROUGH_IMAGE, "alpha-image", 1)] { 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(); @@ -411,7 +413,8 @@ fn a_clipped_or_flattened_image_is_written_as_the_specification_shows_it() { 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}"); + assert_eq!(lines[end + 1], "F", "{layer}"); + assert!(text.contains("\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nF\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}"); @@ -419,6 +422,9 @@ fn a_clipped_or_flattened_image_is_written_as_the_specification_shows_it() { let d = &s.doc().unwrap().doc; assert_eq!(layer_names(d), [layer, "above"]); assert_eq!(images(d), (1, 1), "{layer}"); + let mut drawn = 0; + d.walk(|n| drawn += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { clipping: false, .. }))); + assert_eq!(drawn, paths, "{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}"); } diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 6c99174a0..741fc2d6d 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -29,7 +29,7 @@ //! `f` (or `F`), and `0 BB`, or `2 BB` (`1 BB` open) to stroke it too. //! - 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`. +//! digits, `%AI5_EndRaster`, then a line `F` that §8.4's examples do not show. //! - 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`, @@ -1227,7 +1227,9 @@ impl<'a> Writer<'a> { /// 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`. + /// `%AI5_EndRaster`, then `F`. §8.4's examples have no `F`; without it, an app reading the file + /// can leave out an image in a clipping group and the art after it. No path is open there, so + /// the `F` paints nothing. 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; @@ -1281,7 +1283,7 @@ impl<'a> Writer<'a> { ps::push_nums(s, &[a, b, c, d, e, f]); 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"); + s.push_str("%AI5_EndRaster\nF\n"); } } diff --git a/crates/eps/src/tests_legacy_ai.rs b/crates/eps/src/tests_legacy_ai.rs index bd9badbcd..1e5b2c2c5 100644 --- a/crates/eps/src/tests_legacy_ai.rs +++ b/crates/eps/src/tests_legacy_ai.rs @@ -316,14 +316,15 @@ fn images_below_a_square(images: bool) -> Document { doc } -/// Each image the file holds (§8.4): the lines from `%AI5_File:` to `%AI5_EndRaster`. +/// Each image the file holds (§8.4): the lines from `%AI5_File:` to the line after +/// `%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.push(lines[i..=(end + 1).min(lines.len() - 1)].to_vec()); } } out @@ -332,8 +333,9 @@ fn rasters(text: &str) -> Vec> { /// 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). +/// `%AI5_EndRaster`. A line `F`, which the examples do not show, follows each image. 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, #2830). #[test] fn images_are_written_in_the_form_the_specification_shows() { let out = write(&images_below_a_square(true)); @@ -341,8 +343,8 @@ fn images_are_written_in_the_form_the_specification_shows() { 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 [file, begin, xi, data @ .., end, paint] = lines.as_slice() else { panic!("{lines:?}") }; + assert_eq!((*file, *begin, *end, *paint), ("%AI5_File:", "%AI5_BeginRaster", "%AI5_EndRaster", "F")); 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(); @@ -352,9 +354,10 @@ fn images_are_written_in_the_form_the_specification_shows() { 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}"); + // group: `u`, the image, `U`; the loose image, then the layer's end. + assert!(text.contains("\nh\nW\nn\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nF\nQ\n"), "{text}"); + assert!(text.contains("\nu\n%AI5_File:\n") && text.contains("\n%AI5_EndRaster\nF\nU\n"), "{text}"); + assert!(text.contains("\n%AI5_EndRaster\nF\nLB\n"), "{text}"); let layer = |t: &str| -> String { let at = t.find("(Above) Ln\n").unwrap(); t[at..].split("%AI5_EndLayer--").next().unwrap().to_string() @@ -367,8 +370,11 @@ fn images_are_written_in_the_form_the_specification_shows() { 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_eq!(window.len(), 2, "{window:#?}"); 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[1].kind, NodeKind::Group { children, clip: false } if children.len() == 1 && matches!(children[0].kind, NodeKind::Image(_))) + ); assert!(matches!(below[2].kind, NodeKind::Image(_))); let above = children(&doc.layers[1]); assert_eq!(above.len(), 1);