mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
Merge pull request #2801 from dotCooCoo/fix/issue-2692
Legacy .ai export: images are written in the form the version 7 specification shows
This commit is contained in:
@@ -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##"<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">
|
||||
<defs><clipPath id="c1"><circle cx="100" cy="100" r="70"/></clipPath></defs>
|
||||
<g id="clipped-image">
|
||||
<circle cx="100" cy="100" r="78" fill="none" stroke="#777777" stroke-width="4"/>
|
||||
<g clip-path="url(#c1)"><image x="20" y="20" width="160" height="160" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAGklEQVR42mPMy8tjIAWwcHBwjGoY1TBMNQAAdtYC8NwTJdoAAAAASUVORK5CYII="/></g>
|
||||
</g>
|
||||
<g id="above"><rect x="220" y="20" width="160" height="160" fill="#f7941d"/></g>
|
||||
</svg>"##;
|
||||
const SEE_THROUGH_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">
|
||||
<g id="alpha-image">
|
||||
<image x="20" y="20" width="160" height="160" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAeElEQVR42mNgoBAwoguUlZX9x6ehq6uLEacBdXV1/4mxtampiRHDgLa2tv+kOL2qqooRbkBfX99/cvxfVFTEyMLAwMDAwcFBdiCOGjAYDIAnpHPnzpGUFoyMjBgxkvK1a9eIMkRLS4sRZ2a6d+8eXkOUlJQYGagJAOoJHMzSF7FXAAAAAElFTkSuQmCC"/>
|
||||
</g>
|
||||
<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
|
||||
/// 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<usize> = (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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<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
|
||||
}
|
||||
|
||||
/// 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::<f64>().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);
|
||||
|
||||
Reference in New Issue
Block a user