Legacy .ai export: an F line follows each %AI5_EndRaster

alongtheblue-sketch took 0.12.0's version 7 output of the SVGs in #2692
and #2830, edited it by hand and opened it in the reference app
(https://github.com/storytold/vectorcraft/pull/2801#issuecomment-6244853548).
Writing the image lines as #2801 does, with the bracketed matrix and
the 60-digit data lines, did not change the result there. With one
line, F, added after %AI5_EndRaster, the reference app opened the image
in its clipping group and the art after it in both files.

The writer now writes F after every %AI5_EndRaster: clipped, grouped
and loose images, flattened images and images in pattern tiles. No path
is open at that point, so the line paints nothing. VectorCraft's reader
opens the files with the same objects as before, and the file's
PostScript prolog draws the same page.
This commit is contained in:
dotCooCoo
2026-10-19 00:03:50 +07:00
committed by bflatastic
parent 9fe0f03de2
commit e5c9054324
3 changed files with 34 additions and 20 deletions
+13 -7
View File
@@ -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##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="200" viewBox="0 0 400 200">
@@ -389,14 +389,16 @@ const SEE_THROUGH_IMAGE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" xmln
<g id="above"><rect x="220" y="20" width="160" height="160" fill="#f7941d"/></g>
</svg>"##;
/// #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}");
}
+5 -3
View File
@@ -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");
}
}
+16 -10
View File
@@ -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<Vec<&str>> {
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<Vec<&str>> {
/// 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::<f64>().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);