Merge pull request #5784 from dotCooCoo/feat/ai-editing-data-s4

.ai save: the editing copy keeps images and linked files
This commit is contained in:
BFlat
2026-11-05 17:52:44 +07:00
committed by GitHub
15 changed files with 1237 additions and 101 deletions
+50
View File
@@ -195,6 +195,24 @@ impl ImageBlob {
jpeg_components(&self.bytes) == Some(4) || tiff(&self.bytes).is_some_and(|(t, _)| matches!(t, tiff::ColorType::CMYK(8 | 16)))
}
/// Does the image hold CMYK samples of 16 bits (a CMYK TIFF), which [`Self::cmyk`] gives at 8
/// bits? Reads only its header.
pub fn is_cmyk16(&self) -> bool {
tiff(&self.bytes).is_some_and(|(t, _)| matches!(t, tiff::ColorType::CMYK(16)))
}
/// The width and height a JPEG or TIFF says it has, for any color type. Reads only its header.
pub fn header_size(&self) -> Option<(u32, u32)> {
let bytes = self.bytes.as_slice();
if bytes.starts_with(&[0xFF, 0xD8]) {
let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), DecoderOptions::default());
d.decode_headers().ok()?;
let (w, h) = d.dimensions()?;
return Some((u32::try_from(w).ok()?, u32::try_from(h).ok()?));
}
tiff(bytes).and_then(|(_, mut d)| d.dimensions().ok())
}
/// The ink amounts of a CMYK image; `None` for other images, and ones that can't be decoded
/// or are larger than [`MAX_CMYK_PIXELS`].
pub fn cmyk(&self) -> Option<Inks> {
@@ -633,6 +651,38 @@ mod tests {
assert_eq!(rgb.get_pixel(1, 0).0, [0, 255, 255]);
}
#[test]
fn a_header_gives_the_size_without_the_samples() {
assert_eq!(ImageBlob::cmyk_tiff(&inks()).unwrap().header_size(), Some((3, 2)));
// A CMYK TIFF of 6000 × 6000 pixels whose samples aren't there.
let mut t = b"II*\0\x08\0\0\0\x07\0".to_vec();
// Bits per sample: four values, after the entries (8 + 2 + 7 × 12 + 4 = 98).
let entries = [
(256u16, 4u16, 1u32, 6000u32),
(257, 4, 1, 6000),
(258, 3, 4, 98),
(262, 3, 1, 5),
(273, 4, 1, 106),
(277, 3, 1, 4),
(279, 4, 1, 144_000_000),
];
for (tag, kind, count, value) in entries {
t.extend_from_slice(&tag.to_le_bytes());
t.extend_from_slice(&kind.to_le_bytes());
t.extend_from_slice(&count.to_le_bytes());
t.extend_from_slice(&value.to_le_bytes());
}
t.extend_from_slice(&0u32.to_le_bytes());
t.extend_from_slice(&[8, 0, 8, 0, 8, 0, 8, 0]);
let big = ImageBlob::new("image/tiff", t);
assert!(big.is_cmyk());
assert_eq!(big.header_size(), Some((6000, 6000)));
assert_eq!(big.cmyk(), None);
let mut png = Vec::new();
image::RgbaImage::new(2, 2).write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
assert_eq!(ImageBlob::png(png).header_size(), None, "not a JPEG or TIFF");
}
#[test]
fn a_cmyk_image_samples_its_ink_amounts() {
let b = ImageBlob::cmyk_tiff(&inks()).unwrap();
+1 -1
View File
@@ -109,7 +109,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[
name: "editingData",
ty: "boolean",
default: "true",
description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, compound paths, paths, fills and strokes, colors, linear and radial gradients, patterns on a grid of tiles, swatches, artboards, the transparency of objects and layers, objects' opacity masks, and clipping groups and layers clipped by a path, with type, other patterns, images, symbols, other clipping objects and live objects as plain filled and stroked paths, freeform gradients, gradients along or across a stroke and gradients of more than 256 stops as their average color, gradients that dither or mix perceptually as classic ones, and gradient stops linked to global process colors as their colors (written only with every artboard, as Preserve Editing is)",
description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, compound paths, paths, fills and strokes, colors, linear and radial gradients, patterns on a grid of tiles, swatches, artboards, the transparency of objects and layers, objects' opacity masks, clipping groups and layers clipped by a path, embedded images with their pixels, and linked images as placed files with a preview, with type, other patterns, symbols, other clipping objects and live objects as plain filled and stroked paths, freeform gradients, gradients along or across a stroke and gradients of more than 256 stops as their average color, gradients that dither or mix perceptually as classic ones, and gradient stops linked to global process colors as their colors (written only with every artboard, as Preserve Editing is)",
},
FormatOption {
name: "aiVersion",
@@ -739,6 +739,70 @@ fn paint_outline(doc: &vectorcraft_doc::Document) -> Vec<String> {
out
}
/// The images of `doc`'s art, one line each: its pixel size, where its corners land (rounded to
/// hundredths), its link's path and relative path, and its pixels (RGBA).
fn image_outline(doc: &vectorcraft_doc::Document) -> Vec<String> {
use vectorcraft_doc::NodeKind;
let mut out = vec![];
doc.walk(|n| {
if let NodeKind::Image(im) = &n.kind {
let r = vectorcraft_geom::Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height));
let corners = [(r.x0, r.y0), (r.x1, r.y0), (r.x0, r.y1)].map(|(x, y)| {
let p = im.xf * vectorcraft_geom::Point::new(x, y);
((p.x * 100.0).round() / 100.0, (p.y * 100.0).round() / 100.0)
});
let link = im.link.as_ref().map(|l| (l.path.clone(), l.relative.clone()));
let pixels = doc.image_blob(im).and_then(|b| image::load_from_memory(&b.bytes).ok()).map(|i| i.to_rgba8().into_raw());
out.push(format!("{}x{} {corners:?} {link:?} {pixels:?}", im.width, im.height));
}
});
out
}
/// An embedded image (RGBA, part of it see-through, turned) and a linked one come back from the
/// editing copy for other apps alone, pixel for pixel, with every save option that writes the copy;
/// the linked image keeps its path and its path relative to the document's folder.
#[test]
fn images_come_back_from_the_editing_copy() {
let dir = tmp("ai-images");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(format!("{dir}/art")).unwrap();
let png = |w: u32, h: u32, f: &dyn Fn(u32, u32) -> [u8; 4]| {
let mut out = vec![];
image::RgbaImage::from_fn(w, h, |x, y| image::Rgba(f(x, y))).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
};
let linked = format!("{dir}/art/linked.png");
std::fs::write(&linked, png(5, 4, &|x, y| [(x * 50) as u8, (y * 60) as u8, 90, 255])).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
let data = vectorcraft_format::base64_encode(&png(6, 3, &|x, y| [200, (x * 40) as u8, (y * 80) as u8, if x < 2 { 100 } else { 255 }]));
s.execute("file.place", &json!({"name": "embedded.png", "dataBase64": data})).unwrap();
// The placed image is selected.
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
s.execute("file.place", &json!({"path": linked, "link": true})).unwrap();
let path = format!("{dir}/images.ai");
s.execute("document.save", &json!({"path": path})).unwrap();
let before = image_outline(&s.doc().unwrap().doc);
assert_eq!(before.len(), 2, "{before:#?}");
assert!(before[1].contains("art/linked.png"), "{before:#?}");
for opts in [
json!({"format": "ai"}),
json!({"format": "ai", "compress": false}),
json!({"format": "ai", "embedProfiles": false}),
json!({"format": "ai", "includeLinked": true}),
json!({"format": "ai", "range": "all"}),
json!({"format": "ai", "pdfCompatible": false}),
] {
let r = s.execute("document.export", &opts).unwrap();
assert!(!r["warnings"].to_string().contains("image"), "{r}");
let copy = load::load_with(&path, &b64(&r), &LoadOptions { native: false, ..Default::default() }).unwrap();
assert!(copy.editing_data && !copy.restored, "{opts} {:?}", copy.warnings);
assert!(!copy.warnings.iter().any(|w| w.contains("editing data")), "{opts} {:?}", copy.warnings);
assert_eq!(image_outline(&copy.doc), before, "{opts}");
}
}
/// A document of two artboards (the art on the first): a rectangle filled with a linear gradient of
/// three stops placed across part of it, an ellipse filled with an elliptical radial gradient whose
/// focal point is off its center, a rectangle stroked with a gradient, rectangles filled and
+36
View File
@@ -3325,6 +3325,42 @@ proptest! {
survive("hostile .ai patterns", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
}
/// A `.ai` whose images (binary samples with an alpha channel, the operators that end a raster,
/// then a name and an opacity mask) and placed file (an empty relative folder, a preview) are
/// damaged or hostile, bytes of the samples included.
#[test]
fn ai_images_and_placed_files_never_panic(
cut in 0usize..3_000,
edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec![b'0', b'1', b'9', b' ', b'\r', b'\n', b'(', b')', b'[', b']', b'%', b'_', b'N', b'X', b'~', 0u8, 255u8])), 0..12),
) {
use vectorcraft_testkit::ai;
let raster = "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceRGB XN\n[10 0 0 10 20 -30] 2 1 0 Xh\n[10 0 0 10 20 -30] 0 0 2 1 2 1 8 3 1 17 1 0 4 4 0 0\n%%BeginData: 10\nXI\n@@%%EndData\nXH\n\
%AI17_Begin_Content_if_version_gt:24 17\n72 72 Xr\n%AI17_End_Versioned_Content\n%AI5_EndRaster\nN\n\
%_/ArtDictionary :\n%_/Mask :\n%_X=\n%_0 g\n%_0 0 m\n%_5 0 l\n%_5 -5 l\n%_f\n%_X+\n%_ (Art) ,\n%_; (AI9_Mask) ,\n%_/XMLUID : (Photo) ; (AI10_ArtUID) ,\n%_;\n%_\n";
let place = "%AI5_BeginPlace\n%%FileType: 1\n[1 0 0 -1 -8000 8000] 8000 8000 8002 8001 4 4 0\n(/tmp/p.png)`\n%%IncludeFile:/tmp/p.png\n\
%AI17_Begin_Content_if_version_gt:24 12\n%AI28_BeginPlacedRelativePath\n()\n%AI28_EndPlacedRelativePath\n%AI17_Alternate_Content\n%AI17_End_Versioned_Content\n\
%AI17_Begin_Content_if_version_gt:24 5\n%AI26_BeginPlacedObjectPreview\n2 1 8 32 17 @@\n%AI26_EndPlacedObjectPreview\n%AI17_End_Versioned_Content\n~\n%AI5_EndPlace\n";
let text = ai::editing_data(100.0, 100.0, &ai::layer("L", &format!("{raster}{place}")));
let samples: &[u8] = &[255, 0, 0, 0, 0, 255, 128, 0];
let preview: &[u8] = &[255, 255, 0, 0, 0, 0, 0, 0];
let mut data = Vec::new();
for (i, part) in text.split("@@").enumerate() {
if i > 0 {
data.extend_from_slice(if i == 1 { samples } else { preview });
}
data.extend_from_slice(part.as_bytes());
}
for &(i, b) in &edits {
let n = data.len();
if n > 0 {
data[i % n] = b;
}
}
data.truncate(cut.min(data.len()));
let private = [b"%AI24_ZStandard_Data".as_slice(), &ruzstd::encoding::compress_to_vec(data.as_slice(), ruzstd::encoding::CompressionLevel::Fastest)].concat();
survive("hostile .ai images and placed files", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?;
}
/// A `.ai` whose variable library is damaged or hostile: the library is counted for the warning
/// whether the layers are read or not (an inserted `Z` can make an operator the reader doesn't
/// handle, and then the layers aren't read).
+46 -4
View File
@@ -62,6 +62,22 @@
//! - Opacity masks: an `AI9_Mask` entry in the object's `/ArtDictionary`, a `/Mask` dictionary
//! holding the mask's art between `X=` and `X+` on `%_` lines, `(Art) ,`, and `Clipping`,
//! `Inverted`, `Disabled` and `Linked` as `/Bool` entries.
//! - Embedded images (§8): `%AI5_File:`, `%AI5_BeginRaster`, `() 1 XG`, the color space
//! (`/DeviceGray`, `/DeviceRGB` or `/DeviceCMYK`) with `XN`, `[matrix] w h 0 Xh`, `[matrix] 0 0
//! w h w h 8 type alpha flags 1 0 4 4 0 0` (type 1 gray, 3 RGB, 4 CMYK; alpha 1 with an alpha
//! channel; flags 0 gray, 1 RGB, 2 CMYK, 16 more with an alpha channel), `%%BeginData:` counting
//! `XI`, its line end and the color samples, `XI`, the samples in binary (8 bits, rows from the
//! top, CMYK images with their ink amounts) followed by the alpha channel, `%%EndData`, `XH`,
//! `%AI5_EndRaster`, `N`. The matrix (to ten decimals) is an art-space matrix applied to the
//! pixels with their rows running downwards. In a part on `%_` lines, `XI` and the samples are
//! not, and `%_%%EndData` ends them.
//! - Linked images (version 7 specification §16, with the newer sections): `%AI5_BeginPlace`,
//! `%%FileType: 1`, `[matrix] llx lly urx ury 4 4 0` and `(path)` with `` ` ``, `%%IncludeFile:`,
//! the folder relative to the document's between `%AI28_BeginPlacedRelativePath` and its end,
//! a preview of at most 256 pixels a side between `%AI26_BeginPlacedObjectPreview` and its end
//! (`w h rowBytes 32 17 `, then rows of premultiplied ARGB), each in versioned content, `~`,
//! `%AI5_EndPlace`. The box is the file's pixels at art space's origin on the canvas; the matrix
//! takes it into art space. In opacity masks and pattern tiles, linked images are embedded.
//!
//! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at
//! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the
@@ -74,8 +90,10 @@
//! to global process colors as their colors), pattern fills whose tiles aren't a grid, whose art reaches past
//! the tile or is painted with patterns as their tiles cut to the shape, clipping groups and clipping layers whose clipping object isn't a path,
//! and the clipping groups inside them, as their art cut to that object (its fill cut too, its
//! stroke not), images as their outlines filled with
//! their average color, symbols and live objects as their art (symbol art past
//! stroke not; an image in a clipping group of the region it is cut to), images that can't be
//! decoded or have more than 33,554,432 pixels as their outlines filled mid gray, images of 16 bits
//! a channel at 8 bits, linked images in opacity masks and pattern tiles as embedded images,
//! symbols and live objects (placed documents included) as their art (symbol art past
//! [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects in all left out), strokes a plain line can't draw
//! as their filled outlines, several fills or strokes as a group of a path for each, freeform
//! gradient swatches, gradient swatches of more than 256 stops, swatches of patterns written as
@@ -211,7 +229,8 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result<AiEditi
s.push_str(&w.body);
s.push_str("%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n");
// Lines end in CR LF, as in the files users sent; line breaks inside strings are escaped.
Ok(AiEditingOutput { program: crlf(&s), header: crlf(&header), warnings: w.warnings })
let program = splice(&crlf(&s), &w.payloads);
Ok(AiEditingOutput { program, header: crlf(&header), warnings: w.warnings })
}
/// `s` with each line ending in CR LF.
@@ -219,6 +238,29 @@ fn crlf(s: &str) -> Vec<u8> {
s.replace('\n', "\r\n").into_bytes()
}
/// `text` with each payload's mark (`\u{1}`, its index in `payloads`, `\u{1}`) replaced by its
/// bytes. Strings are written with that byte escaped, so a mark is never part of one.
fn splice(text: &[u8], payloads: &[Vec<u8>]) -> Vec<u8> {
let mark = writer::PAYLOAD as u8;
if payloads.is_empty() {
return text.to_vec();
}
let mut out = Vec::with_capacity(text.len() + payloads.iter().map(Vec::len).sum::<usize>());
let mut parts = text.split(|b| *b == mark);
if let Some(first) = parts.next() {
out.extend_from_slice(first);
}
// The parts alternate: an index, then the text up to the next mark.
while let (Some(index), Some(rest)) = (parts.next(), parts.next()) {
let payload = std::str::from_utf8(index).ok().and_then(|i| i.parse::<usize>().ok()).and_then(|i| payloads.get(i));
if let Some(p) = payload {
out.extend_from_slice(p);
}
out.extend_from_slice(rest);
}
out
}
/// `doc` with its geometry effects and group paint baked ([`vectorcraft_effects::bake_document`]),
/// in its symbols' art and its patterns' tile art too (which instances and pattern fills are
/// written as); `None` when nothing changes.
@@ -331,7 +373,7 @@ fn header(doc: &Document, o: &AiEditingOptions, w: &Writer, art: Rect, first: Re
s.push('\n');
s.push_str("%%DocumentProcessColors: Cyan Magenta Yellow Black\n");
s.push_str("%AI5_FileFormat 14.0\n%AI3_ColorUsage: Color\n");
// Linked images aren't embedded (the data holds no images).
// Linked files aren't included (their previews are).
s.push_str("%AI7_ImageSettings: 0\n");
let spots = w.spot_swatches();
for (i, (name, _)) in spots.iter().enumerate() {
+542 -28
View File
@@ -602,8 +602,8 @@ fn hidden_layers_open_hidden() {
assert!(text.contains("%AI5_NumLayers: 3\n%AI5_OpenViewLayers: 776\n"), "{text}");
}
/// A 200 × 100 document of a gradient fill and a compound path, and what the data holds as plain
/// paths: an image, type and a symbol instance.
/// A 200 × 100 document of a gradient fill, a compound path and an image, and what the data holds
/// as plain paths: type and a symbol instance.
fn unsupported() -> Document {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
@@ -665,7 +665,7 @@ fn paths(nodes: &[Arc<Node>], out: &mut Vec<Node>) {
fn art_the_slice_does_not_cover_is_written_as_plain_paths() {
let doc = unsupported();
let out = export_ai_editing(&doc, &opts()).unwrap();
for w in [writer::IMAGES, writer::TYPE, writer::LIVE] {
for w in [writer::TYPE, writer::LIVE] {
assert!(out.warnings.iter().any(|x| x == w), "{w}: {:?}", out.warnings);
}
let back = read_back(&doc);
@@ -676,19 +676,19 @@ fn art_the_slice_does_not_cover_is_written_as_plain_paths() {
NodeKind::Path { .. } => "path",
NodeKind::Group { .. } => "group",
NodeKind::Compound { .. } => "compound",
NodeKind::Image(_) => "image",
_ => "other",
})
.collect();
assert_eq!(kinds, ["path", "compound", "path", "group", "group"]);
assert_eq!(kinds, ["path", "compound", "image", "group", "group"]);
let mut all = vec![];
paths(layer, &mut all);
paths(&layer[3..], &mut all);
assert!(all.iter().all(|n| matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })));
// The gradient as a gradient, drawing as it did.
let bounds = Rect::new(10.0, 10.0, 30.0, 30.0);
let original = doc.layers[0].children().unwrap()[0].appearance.items[0].paint();
assert_eq!(drawn(&back, layer[0].appearance.items[0].paint(), bounds), drawn(&doc, original, bounds));
// The image's outline filled with its pixels' average.
assert_eq!(paint(layer[2].appearance.items[0].paint()), paint(&solid(Color::rgb(0.5, 0.0, 0.5))));
// The image as an image.
assert_eq!(layer[2].visual_bounds().map(|b| (r3(b.x0), r3(b.y0), r3(b.x1), r3(b.y1))), Some((r3(100.0), r3(10.0), r3(120.0), r3(20.0))));
}
@@ -916,11 +916,10 @@ fn an_adjusted_image_in_a_symbol_is_written_in_its_adjusted_color() {
let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Picture".into(), xf: Affine::translate((50.0, 20.0)) });
doc.insert(Some(layer), usize::MAX, instance).unwrap();
let back = read_back(&doc);
let mut all = vec![];
paths(back.layers[0].children().unwrap(), &mut all);
let fill = paint(all[0].appearance.items[0].paint());
assert_ne!(fill, paint(&solid(Color::gray(0.5))), "the adjusted image is found");
assert_ne!(fill, paint(&solid(Color::rgb(0.0, 0.0, 0.0))), "the image is adjusted");
let mut found = vec![];
pictures(&back, &back.layers, "", &mut found);
assert_eq!(found.len(), 1, "the adjusted image is found");
assert!(!found[0].ends_with("rgba [0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255]"), "the image is adjusted: {found:?}");
}
#[test]
@@ -1017,26 +1016,15 @@ fn effects_on_a_fill_are_reported_left_out() {
}
#[test]
fn images_too_large_to_average_are_mid_gray() {
fn images_of_more_pixels_than_the_reader_reads_are_mid_gray() {
let mut png = vec![];
image::GrayImage::new(5000, 4000).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
assert_eq!(image_average_of(&png), paint(&solid(Color::gray(0.5))));
let mut small = vec![];
image::GrayImage::new(50, 40).write_to(&mut std::io::Cursor::new(&mut small), image::ImageFormat::Png).unwrap();
assert_eq!(image_average_of(&small), paint(&solid(Color::rgb(0.0, 0.0, 0.0))));
}
/// The fill an image of `png` is written with, as the reader reads it.
fn image_average_of(png: &[u8]) -> String {
image::GrayImage::new(6000, 6000).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let blob = ImageBlob::new("image/png", png.to_vec());
let key = blob.content_key();
doc.images.insert(key.clone(), blob);
let im = ImageObject { key, width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
let im = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im)));
let im = image_node(&mut doc, ImageBlob::png(png), (10, 10), Affine::IDENTITY, None);
doc.insert(Some(layer), usize::MAX, im).unwrap();
paint(read_back(&doc).layers[0].children().unwrap()[0].appearance.items[0].paint())
assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::IMAGES]);
assert_eq!(paint(read_back(&doc).layers[0].children().unwrap()[0].appearance.items[0].paint()), paint(&solid(Color::gray(0.5))));
}
#[test]
@@ -2478,3 +2466,529 @@ fn a_pattern_swatchs_own_placement_is_reported_left_out() {
assert_eq!(out.warnings, [writer::SWATCH_PLACEMENT]);
assert!(program(&doc).contains("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n(Big Dots) Pc\n"));
}
// ---------- images and placed files ----------
/// `img` as PNG bytes.
fn png_of(img: image::DynamicImage) -> Vec<u8> {
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
png
}
/// An image object of `blob`'s pixels (`w` × `h`) placed by `xf`, its blob kept in `doc`.
fn image_node(doc: &mut Document, blob: ImageBlob, (w, h): (u32, u32), xf: Affine, link: Option<vectorcraft_doc::LinkInfo>) -> Node {
let key = blob.content_key();
doc.images.insert(key.clone(), blob);
let im = ImageObject { key, width: w, height: h, xf, link, placement: Default::default(), pixels: None };
Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im)))
}
/// A 3 × 2 RGB image, every pixel a different color.
fn rgb_png() -> Vec<u8> {
let px = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [0, 255, 255], [12, 34, 56]];
png_of(image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(3, 2, |x, y| image::Rgb(px[(y * 3 + x) as usize]))))
}
/// A 200 × 100 document of images: on a layer `Pictures`, an RGB image `Photo` turned, scaled and
/// moved, at half opacity in Multiply; a locked RGBA image whose pixels are see-through in part; a
/// hidden gray image; a CMYK image; a clipping group of the RGB image cut by a rectangle; a square
/// whose opacity mask is a gray image. On a layer `Links`, a linked image in a folder next to the
/// document's.
fn images_doc() -> Document {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let turned = Affine::translate((40.0, 30.0)) * Affine::rotate(0.5) * Affine::scale_non_uniform(8.0, 6.0);
let mut photo = image_node(&mut doc, ImageBlob::png(rgb_png()), (3, 2), turned, None);
photo.name = Some("Photo".into());
photo.opacity = 0.5;
photo.blend = BlendMode::Multiply;
doc.insert(Some(layer), usize::MAX, photo).unwrap();
let alpha = [0, 128, 255, 200];
let rgba = image::RgbaImage::from_fn(2, 2, |x, y| image::Rgba([200, 100, (x * 50) as u8, alpha[(y * 2 + x) as usize]]));
let mut see = image_node(&mut doc, ImageBlob::png(png_of(rgba.into())), (2, 2), Affine::new([10.0, 0.0, 0.0, 10.0, 60.0, 10.0]), None);
see.locked = true;
doc.insert(Some(layer), usize::MAX, see).unwrap();
let gray = image::GrayImage::from_fn(4, 1, |x, _| image::Luma([(x * 60) as u8]));
let mut gray = image_node(&mut doc, ImageBlob::png(png_of(gray.into())), (4, 1), Affine::new([5.0, 0.0, 0.0, 5.0, 90.0, 60.0]), None);
gray.visible = false;
doc.insert(Some(layer), usize::MAX, gray).unwrap();
let inks = vectorcraft_doc::cmyk::Inks::new(2, 1, vec![255, 0, 0, 0, 10, 20, 30, 40]).unwrap();
let cmyk = image_node(&mut doc, ImageBlob::cmyk_tiff(&inks).unwrap(), (2, 1), Affine::new([10.0, 0.0, 0.0, 10.0, 120.0, 10.0]), None);
doc.insert(Some(layer), usize::MAX, cmyk).unwrap();
let clip = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(150.0, 10.0, 170.0, 25.0)), Appearance::default());
let inside = image_node(&mut doc, ImageBlob::png(rgb_png()), (3, 2), Affine::new([10.0, 0.0, 0.0, 10.0, 145.0, 5.0]), None);
let group = Node::new(doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(inside)], clip: true });
doc.insert(Some(layer), usize::MAX, group).unwrap();
let ramp = image::GrayImage::from_fn(3, 3, |x, y| image::Luma([(x * 100 + y * 20) as u8]));
let mask_art = image_node(&mut doc, ImageBlob::png(png_of(ramp.into())), (3, 3), Affine::new([10.0, 0.0, 0.0, 10.0, 10.0, 60.0]), None);
let mut square = Node::path(
doc.alloc_id(),
shapes::rectangle(Rect::new(10.0, 60.0, 40.0, 90.0)),
Appearance::basic(solid(Color::rgb(0.0, 0.5, 1.0)), Paint::None, 0.0),
);
// A mask's art is read into a group.
let mask_art = Node::group(doc.alloc_id(), vec![Arc::new(mask_art)]);
square.extras_mut().mask = Some(Box::new(OpacityMask::new(mask_art, false)));
doc.insert(Some(layer), usize::MAX, square).unwrap();
let links = Node::layer(doc.alloc_id(), "Links", LayerColor::Preset(1));
let links_id = links.id;
doc.layers.push(Arc::new(links));
let pic = image::RgbaImage::from_fn(3, 3, |x, y| image::Rgba([(x * 80) as u8, (y * 80) as u8, 30, 255]));
let mut link = vectorcraft_doc::LinkInfo::new("/Users/someone/Job (1)/art/pic.png");
link.relative = Some("art/pic.png".into());
let placed = image_node(&mut doc, ImageBlob::png(png_of(pic.into())), (3, 3), Affine::new([4.0, 1.0, -1.0, 4.0, 60.0, 50.0]), Some(link));
doc.insert(Some(links_id), usize::MAX, placed).unwrap();
doc
}
/// Each image in `nodes` (opacity masks' art included), in order: where it is, its state, size,
/// placement, link and pixels (RGBA, or a CMYK image's ink amounts).
fn pictures(doc: &Document, nodes: &[Arc<Node>], path: &str, out: &mut Vec<String>) {
for (i, n) in nodes.iter().enumerate() {
let at = format!("{path}/{i}");
if let NodeKind::Image(im) = &n.kind {
let blob = doc.image_blob(im).unwrap();
let pixels = match blob.cmyk() {
Some(inks) if blob.is_cmyk() => format!("cmyk {:?}", inks.data),
_ => format!("rgba {:?}", image::load_from_memory(&blob.bytes).unwrap().to_rgba8().into_raw()),
};
let xf: Vec<String> = im.xf.as_coeffs().iter().map(|v| r3(*v)).collect();
let link = im.link.as_ref().map(|l| (l.path.clone(), l.relative.clone()));
out.push(format!(
"{at} {:?} shown={} locked={} opacity={} {:?} {}x{} [{}] {link:?} {pixels}",
n.name,
n.visible,
n.locked,
r3(f64::from(n.opacity)),
n.blend,
im.width,
im.height,
xf.join(" ")
));
}
if let Some(m) = &n.extras().mask {
pictures(doc, std::slice::from_ref(&m.art), &format!("{at}/mask"), out);
}
if let Some(c) = n.children() {
pictures(doc, c, &at, out);
}
}
}
fn images_of(doc: &Document) -> Vec<String> {
let mut out = vec![];
pictures(doc, &doc.layers, "", &mut out);
out
}
#[test]
fn images_come_back_with_their_pixels_and_places() {
let doc = images_doc();
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
let back = read_back(&doc);
let want = images_of(&doc);
let got = images_of(&back);
assert_eq!(want.len(), 7);
for (g, w) in got.iter().zip(&want) {
assert_eq!(g, w);
}
assert_eq!(got.len(), want.len(), "{got:?}");
// The clipping group and the masked square are there as themselves.
assert_eq!(lines(&back).len(), lines(&doc).len(), "{:?}", lines(&back));
}
#[test]
fn images_are_written_again_as_the_same_bytes() {
let mut doc = images_doc();
// The default swatches' RGB colors come back a ten-thousandth off in their CMYK equivalents.
doc.swatches.retain(|s| matches!(s.paint, Paint::None));
doc.swatches.push(Swatch { name: "Gold".into(), paint: solid(GOLD), global: false, spot: false });
doc.swatch_groups = Default::default();
let first = export_ai_editing(&doc, &opts()).unwrap();
assert_eq!(first.program, export_ai_editing(&doc, &opts()).unwrap().program);
let again = export_ai_editing(&read_back(&doc), &opts()).unwrap().program;
for (a, b) in shown(&again).lines().zip(shown(&first.program).lines()) {
assert_eq!(a, b);
}
assert_eq!(again, first.program);
}
/// The program of `doc` with LF line ends, and each image's samples (from after `XI` and its line
/// end to `%%EndData`, a `%_` before it kept) and each preview's rows (from after `32 17 ` to the
/// line end before `%AI26_EndPlacedObjectPreview`) shown as `<n bytes>`.
fn program_shown(doc: &Document) -> String {
shown(&export_ai_editing(doc, &opts()).unwrap().program)
}
/// Program `p` as [`program_shown`] shows it.
fn shown(p: &[u8]) -> String {
let find = |from: usize, what: &[u8]| p.get(from..).and_then(|r| r.windows(what.len()).position(|w| w == what)).map(|i| i + from);
let mut out = Vec::new();
let mut at = 0;
loop {
let xi = find(at, b"\r\nXI\r\n").map(|i| (i + 6, b"%%EndData".as_slice()));
let pv = find(at, b" 32 17 ").map(|i| (i + 7, b"\r\n%AI26_EndPlacedObjectPreview".as_slice()));
let Some((start, end)) = [xi, pv].into_iter().flatten().min_by_key(|(s, _)| *s) else { break };
let mut stop = find(start, end).unwrap();
if end.starts_with(b"%%") && p.get(stop - 2..stop) == Some(b"%_") {
stop -= 2;
}
out.extend_from_slice(&p[at..start]);
out.extend_from_slice(format!("<{} bytes>", stop - start).as_bytes());
at = stop;
}
out.extend_from_slice(&p[at..]);
String::from_utf8(out).unwrap().replace("\r\n", "\n")
}
#[test]
fn an_image_is_a_raster_with_binary_samples() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let im = image_node(&mut doc, ImageBlob::png(rgb_png()), (3, 2), Affine::new([10.0, 0.0, 0.0, 10.0, 20.0, 30.0]), None);
doc.insert(Some(layer), usize::MAX, im).unwrap();
let p = export_ai_editing(&doc, &opts()).unwrap().program;
// `XI`, CR LF and the 18 samples, which are counted, follow the count's line.
let at = p.windows(17).position(|w| w == b"%%BeginData: 22\r\n").unwrap();
let samples: Vec<u8> = vec![255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0, 0, 255, 255, 12, 34, 56];
let mut want = b"XI\r\n".to_vec();
want.extend_from_slice(&samples);
want.extend_from_slice(b"%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN\r\n");
assert_eq!(p.get(at + 17..at + 17 + want.len()), Some(want.as_slice()));
let text = program_shown(&doc);
// Art space is y up from the artboard's top-left corner, the pixels' rows run downwards.
assert!(
text.contains(
"%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceRGB XN\n[10 0 0 10 20 -30] 3 2 0 Xh\n[10 0 0 10 20 -30] 0 0 3 2 3 2 8 3 0 1 1 0 4 4 0 0\n"
),
"{text}"
);
}
#[test]
fn see_through_gray_and_cmyk_images_have_their_flags() {
let doc = images_doc();
let text = program_shown(&doc);
// RGBA: the alpha channel follows the color samples, which the count doesn't take in.
assert!(text.contains(" 0 0 2 2 2 2 8 3 1 17 1 0 4 4 0 0\n%%BeginData: 16\nXI\n<16 bytes>%%EndData\n"), "{text}");
assert!(text.contains("/DeviceGray XN\n"), "{text}");
assert!(text.contains(" 0 0 4 1 4 1 8 1 0 0 1 0 4 4 0 0\n"), "{text}");
assert!(text.contains("/DeviceCMYK XN\n"), "{text}");
assert!(text.contains(" 0 0 2 1 2 1 8 4 0 2 1 0 4 4 0 0\n%%BeginData: 12\nXI\n<8 bytes>%%EndData\n"), "{text}");
// In an opacity mask: on `%_` lines but for `XI` and its samples.
assert!(text.contains("%_%%BeginData: 13\nXI\n<9 bytes>%_%%EndData\n%_XH\n%_%AI5_EndRaster\n%_N\n"), "{text}");
}
#[test]
fn a_linked_image_is_a_placed_file_with_a_preview() {
let doc = images_doc();
let text = program_shown(&doc);
let begin = text.find("%AI5_BeginPlace\n").unwrap();
let place = &text[begin..text[begin..].find("%AI5_EndPlace\n").unwrap() + begin];
let lines: Vec<&str> = place.lines().collect();
assert_eq!(lines[1], "%%FileType: 1");
assert!(lines[2].ends_with("] 8091 8141 8094 8144 4 4 0"), "{place}");
assert_eq!(lines[3], "(/Users/someone/Job \\(1\\)/art/pic.png)`");
assert_eq!(lines[4], "%%IncludeFile:/Users/someone/Job (1)/art/pic.png");
assert_eq!(
&lines[5..10],
[
"%AI17_Begin_Content_if_version_gt:24 12",
"%AI28_BeginPlacedRelativePath",
"(art/)",
"%AI28_EndPlacedRelativePath",
"%AI17_Alternate_Content"
]
);
assert_eq!(&lines[11..13], ["%AI17_Begin_Content_if_version_gt:24 5", "%AI26_BeginPlacedObjectPreview"]);
// 3 × 3 pixels of premultiplied ARGB.
assert_eq!(lines[13], "3 3 12 32 17 <36 bytes>");
assert_eq!(&lines[14..], ["%AI26_EndPlacedObjectPreview", "%AI17_End_Versioned_Content", "~"]);
}
/// The relative folder goes with the path's file name, so a relative path to a file of another
/// name isn't written.
#[test]
fn a_relative_path_to_a_file_of_another_name_is_left_out() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let mut link = vectorcraft_doc::LinkInfo::new("/old/original.png");
link.relative = Some("assets/current.png".into());
let im = image_node(&mut doc, ImageBlob::png(rgb_png()), (3, 2), Affine::scale(10.0), Some(link));
doc.insert(Some(layer), usize::MAX, im).unwrap();
assert!(!program_shown(&doc).contains("%AI28_BeginPlacedRelativePath"));
let back = read_back(&doc);
let NodeKind::Image(im) = &back.layers[0].children().unwrap()[0].kind else { panic!("not an image") };
let link = im.link.as_ref().unwrap();
assert_eq!((link.path.as_str(), link.relative.as_deref()), ("/old/original.png", None));
// Names that differ only in letter case keep the folder where file names ignore case.
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let mut link = vectorcraft_doc::LinkInfo::new("C:\\Job\\art\\PHOTO.PNG");
link.relative = Some("art/photo.png".into());
let im = image_node(&mut doc, ImageBlob::png(rgb_png()), (3, 2), Affine::scale(10.0), Some(link));
doc.insert(Some(layer), usize::MAX, im).unwrap();
let kept = program_shown(&doc).contains("%AI28_BeginPlacedRelativePath\n(art/)\n");
assert_eq!(kept, cfg!(any(windows, target_os = "macos")));
// A relative path with `\` separators keeps its folder.
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let mut link = vectorcraft_doc::LinkInfo::new("C:\\Job\\art\\photo.png");
link.relative = Some("art\\photo.png".into());
let im = image_node(&mut doc, ImageBlob::png(rgb_png()), (3, 2), Affine::scale(10.0), Some(link));
doc.insert(Some(layer), usize::MAX, im).unwrap();
assert!(program_shown(&doc).contains("%AI28_BeginPlacedRelativePath\n(art/)\n"));
}
#[test]
fn a_large_linked_image_has_a_smaller_preview_over_its_pixels() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let big = image::RgbaImage::from_fn(1024, 512, |x, y| image::Rgba([(x / 4) as u8, (y / 2) as u8, 0, 255]));
let xf = Affine::new([0.1, 0.0, 0.0, 0.1, 20.0, 30.0]);
let im = image_node(&mut doc, ImageBlob::png(png_of(big.into())), (1024, 512), xf, Some(vectorcraft_doc::LinkInfo::new("/tmp/big.png")));
doc.insert(Some(layer), usize::MAX, im).unwrap();
assert!(program_shown(&doc).contains("\n256 128 1024 32 17 <131072 bytes>\n"));
let back = read_back(&doc);
let NodeKind::Image(im) = &back.layers[0].children().unwrap()[0].kind else { panic!("not an image") };
assert_eq!((im.width, im.height), (256, 128));
let bounds = im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, 256.0, 128.0));
assert_eq!((r3(bounds.x0), r3(bounds.y0), r3(bounds.x1), r3(bounds.y1)), (r3(20.0), r3(30.0), r3(122.4), r3(81.2)));
assert_eq!(im.link.as_ref().map(|l| l.path.as_str()), Some("/tmp/big.png"));
}
/// Samples that hold the bytes of `%%EndData` without a line end after them are read whole.
#[test]
fn samples_holding_the_end_marker_are_read_whole() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
// The color samples end `%%En` and the alpha channel starts `dData`.
let rgb = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, b'%'], *b"%En"];
let alpha = *b"dData";
let img = image::RgbaImage::from_fn(5, 1, |x, _| {
let [r, g, b] = rgb[x as usize];
image::Rgba([r, g, b, alpha[x as usize]])
});
let im = image_node(&mut doc, ImageBlob::png(png_of(img.into())), (5, 1), Affine::scale(10.0), None);
doc.insert(Some(layer), usize::MAX, im).unwrap();
// The alpha channel holds the marker, a line end and the operator that follows the samples.
let alpha = *b"%%EndData\r\nXH\r\nZZ";
let img = image::RgbaImage::from_fn(17, 1, |x, _| image::Rgba([x as u8, 9, 9, alpha[x as usize]]));
let marked = ImageBlob::png(png_of(img.into()));
let im = image_node(&mut doc, marked.clone(), (17, 1), Affine::translate((0.0, 20.0)), None);
doc.insert(Some(layer), usize::MAX, im).unwrap();
// The same image as an opacity mask's art, on `%_` lines.
let art = image_node(&mut doc, marked, (17, 1), Affine::translate((0.0, 40.0)), None);
let mut square =
Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 17.0, 41.0)), Appearance::basic(solid(Color::BLACK), Paint::None, 0.0));
square.extras_mut().mask = Some(Box::new(OpacityMask::new(Node::group(doc.alloc_id(), vec![Arc::new(art)]), false)));
doc.insert(Some(layer), usize::MAX, square).unwrap();
let back = read_back(&doc);
assert_eq!(images_of(&back), images_of(&doc));
}
/// A preview scaled down mixes no color from pixels that don't show.
#[test]
fn a_smaller_preview_takes_no_color_from_see_through_pixels() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let half = image::RgbaImage::from_fn(511, 3, |x, _| if x % 2 == 0 { image::Rgba([255, 0, 0, 0]) } else { image::Rgba([0, 0, 255, 255]) });
let im = image_node(
&mut doc,
ImageBlob::png(png_of(half.into())),
(511, 3),
Affine::scale(0.2),
Some(vectorcraft_doc::LinkInfo::new("/tmp/half.png")),
);
doc.insert(Some(layer), usize::MAX, im).unwrap();
let back = read_back(&doc);
let NodeKind::Image(im) = &back.layers[0].children().unwrap()[0].kind else { panic!("not an image") };
assert_eq!(im.width, 256);
let px = image::load_from_memory(&back.image_blob(im).unwrap().bytes).unwrap().to_rgba8();
assert!(px.pixels().all(|p| p.0[0] == 0), "{:?}", px.pixels().take(4).collect::<Vec<_>>());
assert!(px.pixels().filter(|p| p.0[3] > 0).count() > 400);
}
#[test]
fn linked_images_in_masks_and_tiles_are_embedded() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let link = Some(vectorcraft_doc::LinkInfo::new("/tmp/ramp.png"));
let art = image_node(&mut doc, ImageBlob::png(rgb_png()), (3, 2), Affine::scale(10.0), link);
let mut square =
Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 20.0)), Appearance::basic(solid(Color::BLACK), Paint::None, 0.0));
square.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
doc.insert(Some(layer), usize::MAX, square).unwrap();
let out = export_ai_editing(&doc, &opts()).unwrap();
assert_eq!(out.warnings, [writer::LINKS_EMBEDDED]);
let text = program_shown(&doc);
assert!(!text.contains("BeginPlace") && text.contains("%_%AI5_BeginRaster"), "{text}");
}
/// An image in an opacity mask's art inside a pattern's tile is hidden once: its samples end
/// `%_%%EndData`.
#[test]
fn an_image_in_a_mask_in_a_pattern_tile_ends_its_samples_once() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let art = image_node(&mut doc, ImageBlob::png(rgb_png()), (3, 2), Affine::scale(10.0), None);
let mut square =
Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 20.0)), Appearance::basic(solid(Color::BLACK), Paint::None, 0.0));
square.extras_mut().mask = Some(Box::new(OpacityMask::new(Node::group(doc.alloc_id(), vec![Arc::new(art)]), false)));
doc.patterns.push(vectorcraft_doc::PatternDef::new("Masked", vec![Arc::new(square)]));
let filled = Node::path(
doc.alloc_id(),
shapes::rectangle(Rect::new(40.0, 40.0, 100.0, 80.0)),
Appearance::basic(Paint::Pattern { pattern: "Masked".into(), xf: Affine::IDENTITY }, Paint::None, 0.0),
);
doc.insert(Some(layer), usize::MAX, filled).unwrap();
let text = program_shown(&doc);
assert!(text.contains("XI\n<18 bytes>%_%%EndData\n%_XH\n"), "{text}");
assert!(!text.contains("%_%_%%EndData"), "{text}");
}
#[test]
fn an_image_in_art_cut_to_a_clipping_object_is_clipped_by_that_region() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
// Type clips: its art is cut to the glyphs' region, which can't cut an image.
let ring = Node::new(
doc.alloc_id(),
NodeKind::Compound {
children: vec![
Arc::new(Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Appearance::default())),
Arc::new(Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), Appearance::default())),
],
rule: FillRule::EvenOdd,
},
);
let ring = Node::new(doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(ring)], clip: false });
let im = image_node(&mut doc, ImageBlob::png(rgb_png()), (3, 2), Affine::scale(20.0), None);
let group = Node::new(doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(ring), Arc::new(im)], clip: true });
doc.insert(Some(layer), usize::MAX, group).unwrap();
let back = read_back(&doc);
let mut found = vec![];
pictures(&back, &back.layers, "", &mut found);
assert_eq!(found.len(), 1, "{:?}", lines(&back));
let text = program_shown(&doc);
let at = text.find("%AI5_EndRaster\nN\n").unwrap();
assert!(text[at..].contains("h\nW\nn\n") && text[..at].contains("q\n"), "{text}");
// The ring's two subpaths clip as one compound path, not as one cut by the other.
let tail = &text[at..];
assert!(tail.contains("0 XR\n*u\n") && tail.contains("h\nW\nn\n*U\n"), "{text}");
fn clipping(nodes: &[Arc<Node>], out: &mut Vec<usize>) {
for n in nodes {
if let NodeKind::Group { children, clip: true } = &n.kind {
out.extend(children.first().map(|c| match &c.kind {
NodeKind::Path { path, .. } => path.subpaths.len(),
NodeKind::Compound { children, .. } => children.len(),
_ => 0,
}));
}
clipping(n.children().map(Vec::as_slice).unwrap_or_default(), out);
}
}
let mut subpaths = vec![];
clipping(&back.layers, &mut subpaths);
assert_eq!(subpaths, [2], "{:?}", lines(&back));
}
/// Art cut to a region with nothing left in it (two clipping objects that don't overlap) leaves the
/// image out.
#[test]
fn an_image_cut_to_nothing_is_left_out() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let boxed = |doc: &mut Document, r: Rect| {
let p = Node::path(doc.alloc_id(), shapes::rectangle(r), Appearance::default());
Arc::new(Node::new(doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(p)], clip: false }))
};
let im = image_node(&mut doc, ImageBlob::png(rgb_png()), (3, 2), Affine::scale(20.0), None);
let far = boxed(&mut doc, Rect::new(50.0, 0.0, 60.0, 10.0));
let inner = Node::new(doc.alloc_id(), NodeKind::Group { children: vec![far, Arc::new(im)], clip: true });
let near = boxed(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0));
let outer = Node::new(doc.alloc_id(), NodeKind::Group { children: vec![near, Arc::new(inner)], clip: true });
doc.insert(Some(layer), usize::MAX, outer).unwrap();
assert!(!program_shown(&doc).contains("%AI5_BeginRaster"));
}
#[test]
fn images_that_cant_be_decoded_are_mid_gray_boxes() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let im = image_node(&mut doc, ImageBlob::png(b"not an image".to_vec()), (2, 1), Affine::scale(10.0), None);
doc.insert(Some(layer), usize::MAX, im).unwrap();
let out = export_ai_editing(&doc, &opts()).unwrap();
assert_eq!(out.warnings, [writer::IMAGES]);
let back = read_back(&doc);
let n = &back.layers[0].children().unwrap()[0];
assert_eq!(paint(n.appearance.items[0].paint()), paint(&solid(Color::gray(0.5))));
}
#[test]
fn sixteen_bit_images_are_written_at_eight_bits() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let deep = image::ImageBuffer::<image::Rgb<u16>, Vec<u16>>::from_fn(2, 1, |x, _| image::Rgb([65535 * x as u16, 257 * 10, 0]));
let im = image_node(&mut doc, ImageBlob::png(png_of(image::DynamicImage::ImageRgb16(deep))), (2, 1), Affine::scale(10.0), None);
doc.insert(Some(layer), usize::MAX, im).unwrap();
let out = export_ai_editing(&doc, &opts()).unwrap();
assert_eq!(out.warnings, [writer::IMAGE_DEPTH]);
let back = read_back(&doc);
let mut found = vec![];
pictures(&back, &back.layers, "", &mut found);
assert!(found[0].ends_with("rgba [0, 10, 0, 255, 255, 10, 0, 255]"), "{found:?}");
}
/// A little-endian, uncompressed CMYK TIFF of 16 bits a sample, `w` × 1 pixels, each pixel's inks
/// `inks[x]`.
fn cmyk16_tiff(inks: &[[u16; 4]]) -> Vec<u8> {
let w = inks.len() as u32;
let data: Vec<u8> = inks.iter().flatten().flat_map(|v| v.to_le_bytes()).collect();
// Tags: width, height, bits (an offset), compression, photometric, strip offset, samples, rows,
// strip bytes, planar configuration.
let bits_at = 8 + 2 + 12 * 10 + 4;
let data_at = bits_at + 8;
let entries: [(u16, u16, u32, u32); 10] = [
(256, 4, 1, w),
(257, 4, 1, 1),
(258, 3, 4, bits_at),
(259, 3, 1, 1),
(262, 3, 1, 5),
(273, 4, 1, data_at),
(277, 3, 1, 4),
(278, 4, 1, 1),
(279, 4, 1, data.len() as u32),
(284, 3, 1, 1),
];
let mut out = b"II*\0".to_vec();
out.extend_from_slice(&8u32.to_le_bytes());
out.extend_from_slice(&10u16.to_le_bytes());
for (tag, kind, count, value) in entries {
out.extend_from_slice(&tag.to_le_bytes());
out.extend_from_slice(&kind.to_le_bytes());
out.extend_from_slice(&count.to_le_bytes());
out.extend_from_slice(&value.to_le_bytes());
}
out.extend_from_slice(&0u32.to_le_bytes());
for _ in 0..4 {
out.extend_from_slice(&16u16.to_le_bytes());
}
out.extend_from_slice(&data);
out
}
#[test]
fn sixteen_bit_cmyk_images_are_written_at_eight_bits() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let blob = ImageBlob::new("image/tiff", cmyk16_tiff(&[[65535, 0, 0, 0], [0, 0x8000, 0, 0x1234]]));
assert!(blob.is_cmyk() && blob.is_cmyk16());
let im = image_node(&mut doc, blob, (2, 1), Affine::scale(10.0), None);
doc.insert(Some(layer), usize::MAX, im).unwrap();
let out = export_ai_editing(&doc, &opts()).unwrap();
assert_eq!(out.warnings, [writer::IMAGE_DEPTH]);
assert!(program_shown(&doc).contains("/DeviceCMYK XN\n"));
}
+49 -47
View File
@@ -9,14 +9,18 @@ use vectorcraft_color::swatch::{REGISTRATION, Swatch};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LAYER_COLORS, LayerColor, Node, NodeKind, OpacityMask, StrokeLayer};
use vectorcraft_effects::stroke;
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Shape, SubPath};
use vectorcraft_geom::{Affine, FillRule, PathData, Shape, SubPath};
use vectorcraft_pathops::BoolOp;
use crate::ai_ops::{self, unit};
use crate::ps;
mod images;
mod paints;
pub(super) use images::PAYLOAD;
#[cfg(test)]
pub(super) use images::{IMAGE_DEPTH, IMAGES, LINKS_EMBEDDED};
#[cfg(test)]
pub(super) use paints::{GRADIENT_MIX, GRADIENT_SHAPES, GRADIENTS, STOP_LINKS, SWATCH_PLACEMENT};
@@ -24,10 +28,6 @@ pub(super) use paints::{GRADIENT_MIX, GRADIENT_SHAPES, GRADIENTS, STOP_LINKS, SW
/// out). Groups and layers need no limit of their own: a document is never deeper than
/// [`vectorcraft_doc::MAX_DEPTH`].
const MAX_NEST: u32 = 8;
/// The side of the image whose pixels are averaged (larger ones are scaled down to it first).
const AVERAGE_SIDE: u32 = 64;
/// Most pixels an image may have to be decoded and averaged (more: mid gray).
const MAX_AVERAGED_PIXELS: u64 = 1 << 24;
pub(super) const TYPE: &str = "type is written as its outlines in the editing data for other apps";
pub(super) const PATTERNS: &str = "pattern fills whose tiles aren't a grid or whose art reaches past the tile, pattern fills in another pattern's tile and pattern fills nested more than 8 deep are written as their tiles cut to the shape in the editing data for other apps";
@@ -35,7 +35,6 @@ pub(super) const PATTERN_TILES: &str = "pattern fills with more tiles than a fil
pub(super) const CLIPPING: &str = "clipping groups and clipping layers whose clipping object isn't a path, and the clipping groups inside them, are written as their art cut to that object in the editing data for other apps";
pub(super) const CLIP_PAINT: &str = "the fills and strokes of clipping paths that a plain fill and line can't draw are written as paths of their own inside the clipping (fills under the clipped art, strokes over it) in the editing data for other apps";
pub(super) const CLIP_PAINT_MASKS: &str = "the opacity masks of clipping paths whose fills and strokes are written as paths of their own stay on the clipping path, not on those paths, in the editing data for other apps";
pub(super) const IMAGES: &str = "images are written as their outlines filled with their average color in the editing data for other apps";
pub(super) const LIVE: &str =
"symbols, blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps";
const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps";
@@ -148,6 +147,14 @@ pub(super) struct Writer<'a> {
defining: Vec<String>,
/// Writing the palette: the definitions it adds are for its swatches only.
in_palette: bool,
/// Art space's origin on the canvas (y down), where a placed file's box starts.
canvas_origin: (f64, f64),
/// The binary parts of the program (images' samples, placed files' previews), each in the
/// text as a mark ([`PAYLOAD`], its index, [`PAYLOAD`]).
pub(super) payloads: Vec<Vec<u8>>,
/// How many parts of the data written on `%_` lines (opacity masks' art, patterns' tiles) the
/// art being written is in.
hidden: u32,
}
impl<'a> Writer<'a> {
@@ -163,6 +170,9 @@ impl<'a> Writer<'a> {
patterns: vec![],
defining: vec![],
in_palette: false,
canvas_origin: origin,
payloads: vec![],
hidden: 0,
cms: vectorcraft_color::cms::active(),
body: String::new(),
warnings: vec![],
@@ -387,12 +397,7 @@ impl<'a> Writer<'a> {
}
NodeKind::Compound { rule, .. } => self.shape(n, &compound_path(n), *rule, xy),
NodeKind::Text(_) => self.text(n, xy),
NodeKind::Image(im) => {
let bounds = PathData::from_bezpath(&(im.xf * Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1)));
let color = image_average(self.doc.image_blob(im).map(|b| b.bytes.as_slice()));
self.warn(IMAGES);
self.fill_object(&bounds, FillRule::NonZero, &FillLayer::new(Paint::solid(color)), xy);
}
NodeKind::Image(im) => self.image(im, xy),
NodeKind::SymbolInstance { symbol, xf } => {
self.warn(LIVE);
self.line("u");
@@ -527,19 +532,15 @@ impl<'a> Writer<'a> {
_ => std::slice::from_ref(art),
};
self.nest += 1;
self.hidden += 1;
for c in members {
self.node(c);
}
self.hidden -= 1;
self.nest -= 1;
let lines = std::mem::replace(&mut self.body, outer);
self.body.push_str("%_/Mask :\n%_X=\n");
for l in lines.lines() {
if !l.starts_with("%_") {
self.body.push_str("%_");
}
self.body.push_str(l);
self.body.push('\n');
}
hide(&lines, &mut self.body);
self.body.push_str("%_X+\n%_(Art) ,\n");
for (key, on) in [("Clipping", m.clip), ("Inverted", m.invert), ("Disabled", m.disabled), ("Linked", m.linked)] {
let _ = writeln!(self.body, "%_{} /Bool ({key}) ,", u8::from(on));
@@ -1158,42 +1159,43 @@ fn clip_path(n: &Node) -> Option<(PathData, FillRule)> {
path.subpaths.iter().any(|sp| !sp.anchors.is_empty()).then_some((path, rule))
}
/// The image `bytes` decoded, when its header gives at most [`MAX_AVERAGED_PIXELS`] pixels; the
/// decoder may allocate at most 8 bytes a pixel.
fn decode_bounded(bytes: &[u8]) -> Option<image::DynamicImage> {
/// `text`'s lines on `%_` lines, as a part of the data that readers of the printed form skip (an
/// opacity mask's art, a pattern's tile) has them. An image's samples stay as they are after an
/// `XI` without `%_`, and `%_%%EndData` ends them, as in the files users sent. Samples hidden
/// already (an opacity mask's art in a pattern's tile) keep their one `%_`.
pub(super) fn hide(text: &str, out: &mut String) {
for l in text.lines() {
if l.starts_with(images::SAMPLES_LINE) {
match l.strip_suffix("%%EndData") {
Some(samples) if !samples.ends_with("%_") => {
out.push_str(samples);
out.push_str("%_%%EndData");
}
_ => out.push_str(l),
}
} else {
if !l.starts_with("%_") {
out.push_str("%_");
}
out.push_str(l);
}
out.push('\n');
}
}
/// The image `bytes` decoded, when its header gives at most `max` pixels; the decoder may allocate
/// at most 8 bytes a pixel.
fn decode_bounded(bytes: &[u8], max: u64) -> Option<image::DynamicImage> {
let reader = || -> image::ImageResult<image::ImageReader<std::io::Cursor<&[u8]>>> {
let mut r = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format()?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(MAX_AVERAGED_PIXELS * 8);
limits.max_alloc = Some(max * 8);
r.limits(limits);
Ok(r)
};
let (w, h) = reader().and_then(|r| r.into_dimensions()).ok()?;
if u64::from(w) * u64::from(h) > MAX_AVERAGED_PIXELS {
if u64::from(w) * u64::from(h) > max {
return None;
}
reader().and_then(|r| r.decode()).ok()
}
/// The average color of the image `bytes` (scaled down first), transparent pixels over white;
/// mid gray when it can't be decoded or has more than [`MAX_AVERAGED_PIXELS`] pixels.
fn image_average(bytes: Option<&[u8]>) -> Color {
let Some(img) = bytes.and_then(decode_bounded) else { return Color::gray(0.5) };
let img = if img.width() > AVERAGE_SIDE || img.height() > AVERAGE_SIDE { img.thumbnail(AVERAGE_SIDE, AVERAGE_SIDE) } else { img };
let rgba = img.to_rgba8();
let mut sum = [0u64; 3];
let mut n = 0u64;
for px in rgba.pixels() {
let [r, g, b, a] = px.0;
let over = |c: u8| (u64::from(c) * u64::from(a) + 255 * (255 - u64::from(a))) / 255;
sum[0] += over(r);
sum[1] += over(g);
sum[2] += over(b);
n += 1;
}
if n == 0 {
return Color::gray(0.5);
}
let c = |v: u64| (v as f32 / n as f32 / 255.0).clamp(0.0, 1.0);
Color::rgb(c(sum[0]), c(sum[1]), c(sum[2]))
}
+326
View File
@@ -0,0 +1,326 @@
//! Images: embedded ones as a raster with its samples, linked ones as a placed file with a preview
//! (see [`crate::ai_editing`] for the form).
use std::fmt::Write;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{FillLayer, ImageBlob, ImageObject, LinkInfo};
use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Shape};
use super::{Writer, Xy};
use crate::ps;
pub(in crate::ai_editing) const IMAGES: &str = "images that can't be decoded or have more than 33,554,432 pixels are written as their outlines filled mid gray in the editing data for other apps";
pub(in crate::ai_editing) const IMAGE_DEPTH: &str = "images of 16 bits a channel are written at 8 bits a channel in the editing data for other apps";
pub(in crate::ai_editing) const LINKS_EMBEDDED: &str =
"linked images in opacity masks and pattern tiles are written as embedded images in the editing data for other apps";
/// The most pixels an image is written with: as many as the reader reads.
pub(super) const MAX_PIXELS: u64 = 1 << 25;
/// The longer side of a placed file's preview at most (a smaller image's preview is its own
/// pixels): the files users sent have previews 256 pixels wide.
const PREVIEW_SIDE: u32 = 256;
/// What marks a binary payload in the program text: `\u{1}`, its index, `\u{1}`
/// ([`super::super::splice`] puts the bytes in its place). Strings never hold the byte unescaped.
pub(in crate::ai_editing) const PAYLOAD: char = '\u{1}';
/// What starts the line of an image's samples: `XI` and its payload, then `%%EndData`.
pub(super) const SAMPLES_LINE: &str = "XI\u{1}";
/// An image's pixels as the data holds them.
struct Samples {
/// `/DeviceGray`, `/DeviceRGB` or `/DeviceCMYK`.
space: &'static str,
/// The image type of `XI` (§8.1): 1 gray, 3 RGB, 4 CMYK.
kind: u8,
width: u32,
height: u32,
/// The color samples, 8 bits each, rows from the top.
color: Vec<u8>,
/// The alpha channel, a byte a pixel, when a pixel isn't opaque.
alpha: Option<Vec<u8>>,
}
impl Samples {
/// The pixels of `blob`: a CMYK image's ink amounts, else gray samples when every pixel is gray
/// or RGB samples, with an alpha channel when they need one. `None` when they can't be decoded or are more than
/// [`MAX_PIXELS`]; the flag is set when the image had 16 bits a channel.
fn of(blob: &ImageBlob) -> Option<(Samples, bool)> {
// The header's size is checked before the inks are decoded.
if blob.is_cmyk()
&& blob.header_size().is_some_and(|(w, h)| u64::from(w) * u64::from(h) <= MAX_PIXELS)
&& let Some(inks) = blob.cmyk()
&& u64::from(inks.width) * u64::from(inks.height) <= MAX_PIXELS
{
let s = Samples { space: "/DeviceCMYK", kind: 4, width: inks.width, height: inks.height, color: inks.data, alpha: None };
return Some((s, blob.is_cmyk16()));
}
let img = super::decode_bounded(&blob.bytes, MAX_PIXELS)?;
let deep = matches!(
img.color(),
image::ColorType::L16
| image::ColorType::La16
| image::ColorType::Rgb16
| image::ColorType::Rgba16
| image::ColorType::Rgb32F
| image::ColorType::Rgba32F
);
let rgba = img.to_rgba8();
// Gray when every pixel is, whatever the file's color type (an image read back from the data
// is RGBA): the same pixels are written the same way.
let gray = rgba.pixels().all(|p| p.0[0] == p.0[1] && p.0[1] == p.0[2]);
let (width, height) = rgba.dimensions();
if width == 0 || height == 0 {
return None;
}
let opaque = rgba.pixels().all(|p| p.0[3] == 255);
let alpha = (!opaque).then(|| rgba.pixels().map(|p| p.0[3]).collect());
let color = if gray { rgba.pixels().map(|p| p.0[0]).collect() } else { rgba.pixels().flat_map(|p| [p.0[0], p.0[1], p.0[2]]).collect() };
let (space, kind) = if gray { ("/DeviceGray", 1) } else { ("/DeviceRGB", 3) };
Some((Samples { space, kind, width, height, color, alpha }, deep))
}
/// `XI`'s fourth flag as the files users sent have it: 0 gray, 1 RGB, 2 CMYK, 16 more with an
/// alpha channel.
fn reserved(&self) -> u8 {
let space = match self.kind {
1 => 0,
4 => 2,
_ => 1,
};
space + if self.alpha.is_some() { 16 } else { 0 }
}
}
impl Writer<'_> {
/// Image object `im` of transparency `xy`: embedded, a raster ([`Self::raster`]); linked, a
/// placed file with a preview ([`Self::placed_file`]), except in a part of the data written on
/// `%_` lines. One whose pixels can't be read is its outline filled mid gray. Where the art is
/// cut to a clipping path, the image is in a clipping group of that path.
pub(super) fn image(&mut self, im: &ImageObject, xy: Xy) {
let doc = self.doc;
let Some((samples, deep)) = doc.image_blob(im).and_then(Samples::of) else {
let bounds = PathData::from_bezpath(&(im.xf * Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1)));
self.warn(IMAGES);
return self.fill_object(&bounds, FillRule::NonZero, &FillLayer::new(Paint::solid(Color::gray(0.5))), xy);
};
// A region that has nothing left hides the image.
if self.clip.as_ref().is_some_and(|c| c.subpaths.iter().all(|sp| sp.anchors.is_empty())) {
return;
}
if deep {
self.warn(IMAGE_DEPTH);
}
let cut = self.clip.take();
let at_q = (self.state, self.gs);
if cut.is_some() {
self.line("q");
}
self.set_xy(xy.object());
// 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(samples.width), f64::from(im.height.max(1)) / f64::from(samples.height));
match &im.link {
Some(link) if self.hidden == 0 => self.placed_file(link, &samples, (im.width.max(1), im.height.max(1)), im.xf),
other => {
if other.is_some() {
self.warn(LINKS_EMBEDDED);
}
self.raster(&samples, im.xf * fit);
}
}
if let Some(region) = &cut {
self.line("0 XR");
// Several subpaths clip as one compound path (`*u` … `*U`), as a clipping group's do.
let subpaths: Vec<_> = region.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).collect();
let several = subpaths.len() > 1;
if several {
self.line("*u");
}
for sp in subpaths {
crate::ai_ops::subpath(&mut self.body, self.to_art, sp);
self.line("h\nW\nn");
}
if several {
self.line("*U");
}
(self.state, self.gs) = at_q;
self.end_group("Q", Xy::PLAIN);
}
self.clip = cut;
}
/// An embedded image whose pixels `xf` places in the document: `%AI5_File:`,
/// `%AI5_BeginRaster`, `() 1 XG` (no file), the color space `XN`, `[matrix] w h 0 Xh`,
/// `[matrix] 0 0 w h w h 8 type alpha reserved 1 0 4 4 0 0`, `%%BeginData:` counting `XI`, its
/// line end and the color samples, `XI`, the color samples then the alpha channel in binary,
/// `%%EndData`, `XH`, `%AI5_EndRaster`, `N`. The matrix is an art-space matrix applied to the
/// pixels with their rows running downwards, `(u, -v)`, as §8.1's image matrix.
fn raster(&mut self, s: &Samples, xf: Affine) {
let m = matrix(self.to_art * xf * Affine::FLIP_Y);
let (w, h) = (s.width, s.height);
let alpha = u8::from(s.alpha.is_some());
let mut payload = Vec::with_capacity(2 + s.color.len() + s.alpha.as_ref().map_or(0, Vec::len));
payload.extend_from_slice(b"\r\n");
payload.extend_from_slice(&s.color);
if let Some(a) = &s.alpha {
payload.extend_from_slice(a);
}
let mark = self.payload(payload);
let _ = writeln!(
self.body,
"%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n{} XN\n{m} {w} {h} 0 Xh\n{m} 0 0 {w} {h} {w} {h} 8 {} {alpha} {} 1 0 4 4 0 0\n%%BeginData: {}\nXI{mark}%%EndData\nXH\n%AI5_EndRaster\nN",
s.space,
s.kind,
s.reserved(),
// `XI`, CR LF.
4 + s.color.len(),
);
}
/// A linked image whose pixels `xf` places in the document: `%AI5_BeginPlace`,
/// `%%FileType: 1`, `[matrix] llx lly urx ury 4 4 0` and the file's path with `` ` ``,
/// `%%IncludeFile:` and the path, the folder relative to the document's between
/// `%AI28_BeginPlacedRelativePath` and its end (`()` for the document's own folder), the preview between
/// `%AI26_BeginPlacedObjectPreview` and its end (`w h rowBytes 32 17 ` and rows of
/// premultiplied ARGB, the top one first, of the pixels `s`), `~`, `%AI5_EndPlace`. The box is
/// as large as the file has pixels (`w` × `h`, the object's pixel grid, which `xf` places in the
/// document), at art space's origin on the canvas (y down); the matrix takes it from there into
/// art space. Each marked part newer releases read is in versioned content, as in the files
/// users sent.
fn placed_file(&mut self, link: &LinkInfo, s: &Samples, (w, h): (u32, u32), xf: Affine) {
let (x, y) = self.canvas_origin;
let m = matrix(self.to_art * xf * Affine::translate((-x, -y)));
let (w, h) = (f64::from(w), f64::from(h));
let path = ps::bytes(link.path.as_bytes());
let _ = write!(self.body, "%AI5_BeginPlace\n%%FileType: 1\n{m} ");
ps::push_nums(&mut self.body, &[x, y, x + w, y + h, 4.0, 4.0, 0.0]);
self.body.pop();
let _ = writeln!(self.body, "\n{path}`");
if !link.path.chars().any(char::is_control) {
let _ = writeln!(self.body, "%%IncludeFile:{}", link.path);
}
// The section holds a folder only, the file's name comes from the path: a relative path to a
// file of another name isn't written. On Windows and macOS, whose file names ignore letter
// case, names that differ only in case are the same file.
let same_name = |r: &&str| r.rsplit(['/', '\\']).next().is_some_and(|n| same_file_name(n, link.name()));
if let Some(folder) = link.relative.as_deref().filter(same_name).map(relative_folder) {
let _ = writeln!(
self.body,
"%AI17_Begin_Content_if_version_gt:24 12\n%AI28_BeginPlacedRelativePath\n{}\n%AI28_EndPlacedRelativePath\n%AI17_Alternate_Content\n%AI17_End_Versioned_Content",
ps::bytes(folder.as_bytes())
);
}
let (pw, ph, rows) = preview(s);
let mark = self.payload(rows);
let _ = writeln!(
self.body,
"%AI17_Begin_Content_if_version_gt:24 5\n%AI26_BeginPlacedObjectPreview\n{pw} {ph} {} 32 17 {mark}\n%AI26_EndPlacedObjectPreview\n%AI17_End_Versioned_Content\n~\n%AI5_EndPlace",
pw * 4
);
}
/// Keep `bytes` to go in the program where the mark this returns is.
fn payload(&mut self, bytes: Vec<u8>) -> String {
self.payloads.push(bytes);
format!("{PAYLOAD}{}{PAYLOAD}", self.payloads.len() - 1)
}
}
/// `[a b c d tx ty]`, each number to ten decimals ([`ps::num`] writes four).
fn matrix(m: Affine) -> String {
let n: Vec<String> = m.as_coeffs().iter().map(|v| precise(*v)).collect();
format!("[{}]", n.join(" "))
}
/// `v` rounded to ten decimals, without trailing zeros (0 when not finite).
fn precise(v: f64) -> String {
let r = (v * 1e10).round() / 1e10;
if !r.is_finite() {
return if v.is_finite() { format!("{}", v + 0.0) } else { "0".into() };
}
format!("{}", r + 0.0)
}
/// The folder of a path relative to the document's (`src/checker.png` → `src/`); empty when the file
/// is in the document's own folder. `\` separators are read as `/`.
fn relative_folder(relative: &str) -> String {
match relative.replace('\\', "/").rsplit_once('/') {
Some((folder, _)) if !folder.is_empty() => format!("{folder}/"),
_ => String::new(),
}
}
/// Do file names `a` and `b` name the same file in one folder: equal, or on Windows and macOS equal
/// but for letter case?
fn same_file_name(a: &str, b: &str) -> bool {
a == b || (cfg!(any(windows, target_os = "macos")) && a.to_lowercase() == b.to_lowercase())
}
/// A placed file's preview of `s`: its width, height and rows of premultiplied ARGB, the top one
/// first; the image's own pixels when neither side is longer than [`PREVIEW_SIDE`], else scaled
/// down to it, each preview pixel the average of the premultiplied pixels it covers.
fn preview(s: &Samples) -> (u32, u32, Vec<u8>) {
let (w, h) = (u64::from(s.width), u64::from(s.height));
let longer = s.width.max(s.height);
let (pw, ph) = if longer > PREVIEW_SIDE {
let k = f64::from(PREVIEW_SIDE) / f64::from(longer);
let side = |v: u32| ((f64::from(v) * k).round() as u32).clamp(1, PREVIEW_SIDE);
(side(s.width), side(s.height))
} else {
(s.width, s.height)
};
let n = usize::from(channels(s.kind));
let cmyk_memo = std::cell::RefCell::new(std::collections::HashMap::new());
// Pixel `i` premultiplied: r, g, b, a.
let pixel = |i: usize| -> [u64; 4] {
let c = |k: usize| s.color.get(i * n + k).copied().unwrap_or(0);
let rgb = match s.kind {
1 => [c(0); 3],
4 => {
let mut memo = cmyk_memo.borrow_mut();
if memo.len() > 1 << 16 {
memo.clear();
}
*memo.entry([c(0), c(1), c(2), c(3)]).or_insert_with(|| {
let f = |v: u8| f32::from(v) / 255.0;
let [r, g, b, _] = Color::cmyk(f(c(0)), f(c(1)), f(c(2)), f(c(3))).to_rgba8(1.0);
[r, g, b]
})
}
_ => [c(0), c(1), c(2)],
};
let a = u64::from(s.alpha.as_ref().and_then(|a| a.get(i)).copied().unwrap_or(255));
let pre = |v: u8| (u64::from(v) * a + 127) / 255;
[pre(rgb[0]), pre(rgb[1]), pre(rgb[2]), a]
};
let (pw64, ph64) = (u64::from(pw), u64::from(ph));
let mut rows = Vec::with_capacity((pw64 * ph64 * 4) as usize);
for py in 0..ph64 {
let (y0, y1) = (py * h / ph64, ((py + 1) * h / ph64).max(py * h / ph64 + 1));
for px in 0..pw64 {
let (x0, x1) = (px * w / pw64, ((px + 1) * w / pw64).max(px * w / pw64 + 1));
let mut sum = [0u64; 4];
for y in y0..y1.min(h) {
for x in x0..x1.min(w) {
let p = pixel((y * w + x) as usize);
for (t, v) in sum.iter_mut().zip(p) {
*t += v;
}
}
}
let count = ((y1.min(h) - y0) * (x1.min(w) - x0)).max(1);
let [r, g, b, a] = sum.map(|t| ((t + count / 2) / count).min(255) as u8);
rows.extend_from_slice(&[a, r, g, b]);
}
}
(pw, ph, rows)
}
/// The samples a pixel of image type `kind` has.
fn channels(kind: u8) -> u8 {
match kind {
1 => 1,
4 => 4,
_ => 3,
}
}
+3 -7
View File
@@ -381,9 +381,11 @@ impl Writer<'_> {
}
self.line("n\n%_/ArtDictionary :\n%_1 /Bool (AIPattern_Editor_Backing_Tile_Rect) ,\n%_;\n%_");
self.nest += 1;
self.hidden += 1;
for a in &def.art {
self.node(a);
}
self.hidden -= 1;
self.nest -= 1;
let art;
(art, (self.to_art, self.grad_space, self.canvas), self.state, self.gs, self.clip, self.block_end) = saved;
@@ -395,13 +397,7 @@ impl Writer<'_> {
ps::push_nums(&mut text, &[tile.x0, tile.y0, tile.x1, tile.y1]);
text.pop();
text.push('\n');
for l in art.lines() {
if !l.starts_with("%_") {
text.push_str("%_");
}
text.push_str(l);
text.push('\n');
}
super::hide(&art, &mut text);
text.push_str("E\n%AI3_EndPattern\n");
self.patterns.push(PatternOut { name: name.to_string(), text, used });
true
+61 -5
View File
@@ -55,6 +55,56 @@ const RELATIVE: [&[u8]; 2] = [b"%AI28_BeginPlacedRelativePath", b"%AI28_EndPlace
/// A document included whole: (begin, end) markers.
const DOCUMENT: (&[u8], &[u8]) = (b"%%BeginDocument", b"%%EndDocument");
/// The line of `src` before the one that starts at `at`, without its line end, when it ends within
/// [`OPERAND_LINE`] bytes of `at` and is no longer than that.
fn previous_line(src: &[u8], at: usize) -> Option<&[u8]> {
let before = src.get(at.saturating_sub(OPERAND_LINE)..at)?;
let end = before.iter().rposition(|b| !is_eol(*b))? + 1;
let start = before.get(..end)?.iter().rposition(|b| is_eol(*b))? + 1;
before.get(start..end)
}
/// The longest line of an image's `XI` operands looked at.
const OPERAND_LINE: usize = 1024;
/// How many bytes the alpha channel of an image whose `XI` operands are on `line` takes (`[matrix]
/// llx lly urx ury w h bits type alpha …`, `%_` before them in a hidden object): a byte a pixel when
/// the alpha count isn't 0, else none (or when the operands aren't of that shape).
fn alpha_bytes(line: &[u8]) -> usize {
let line = line.strip_prefix(b"%_").unwrap_or(line);
let Some(after) = line.iter().rposition(|b| *b == b']').and_then(|i| line.get(i + 1..)) else { return 0 };
let nums: Vec<f64> = std::str::from_utf8(after).unwrap_or_default().split_whitespace().take(9).map_while(|w| w.parse().ok()).collect();
match (nums.get(4), nums.get(5), nums.get(8)) {
// At most the pixels the reader reads an image with.
(Some(&w), Some(&h), Some(&a)) if a > 0.0 && w >= 1.0 && h >= 1.0 && w * h <= (1u64 << 25) as f64 => (w * h) as usize,
_ => 0,
}
}
/// Where in `w` the `%%EndData` that ends an image's samples starts: the first one on a line whose
/// next line is `XH` (`%_XH` in a hidden object), as an image's is; else the first one that a line
/// end, or the end of `w`, follows.
fn end_data(w: &[u8]) -> Option<usize> {
const MARK: &[u8] = b"%%EndData";
let mut first = None;
let mut at = 0;
while let Some(i) = w.get(at..).and_then(|r| find(r, MARK)) {
let i = at + i;
let rest = w.get(i + MARK.len()..).unwrap_or_default();
if rest.first().is_none_or(|b| matches!(b, b'\r' | b'\n')) {
let next = rest.strip_prefix(b"\r").unwrap_or(rest);
let next = next.strip_prefix(b"\n").unwrap_or(next);
let next = next.strip_prefix(b"%_").unwrap_or(next);
if next.starts_with(b"XH") {
return Some(i);
}
first = first.or(Some(i));
}
at = i + 1;
}
first
}
fn is_space(b: u8) -> bool {
matches!(b, b' ' | b'\t' | b'\r' | b'\n' | b'\x0c' | b'\0')
}
@@ -232,12 +282,18 @@ impl<'a> Lexer<'a> {
(Some(b'\r' | b'\n'), _) => data += 1,
_ => {}
}
// The count leaves out an alpha channel: the data ends where `%%EndData` starts.
// The count leaves out an alpha channel: the data ends where `%%EndData` starts, the first one
// that a line end or the end of the data follows (samples can hold the marker's bytes).
let declared = from.saturating_add(n).min(self.src.len()).max(data);
let window =
self.src.get(declared.saturating_sub(4).max(data)..declared.saturating_add(n.saturating_mul(2)).saturating_add(64).min(self.src.len()));
let end = match window.and_then(|w| find(w, b"%%EndData")) {
Some(i) => declared.saturating_sub(4).max(data) + i,
// The alpha channel the line before gives the image (its `XI` operands) follows the counted
// samples: the marker is looked for after it.
let line_start = self.pos.saturating_sub(line.len());
let line_start = if line_start >= 2 && self.src.get(line_start - 2..line_start) == Some(b"%_") { line_start - 2 } else { line_start };
let alpha = previous_line(self.src, line_start).map_or(0, alpha_bytes);
let start = if alpha > 0 { declared.saturating_add(alpha).saturating_sub(2) } else { declared.saturating_sub(4) }.max(data);
let stop = declared.saturating_add(n.saturating_mul(2)).saturating_add(64).max(start.saturating_add(64)).min(self.src.len());
let end = match self.src.get(start..stop).and_then(end_data) {
Some(i) => start + i,
None => declared,
};
self.pos = end;
+4
View File
@@ -274,6 +274,9 @@ struct Reader<'a> {
place: Option<Place>,
/// The last object read is the last child of the innermost frame, and nothing started since.
after_object: bool,
/// The last object read is an image, and only the operators that end its raster (`XH`, `Xr`,
/// `N`) came since: its name and opacity mask still follow.
image_tail: bool,
nodes: usize,
/// What layers that show have that this reader doesn't read (errors).
unsupported: BTreeSet<String>,
@@ -460,6 +463,7 @@ impl<'a> Reader<'a> {
place: None,
foreign_data: None,
after_object: false,
image_tail: false,
nodes: 0,
unsupported: BTreeSet::new(),
unknown: BTreeSet::new(),
+10 -4
View File
@@ -608,7 +608,9 @@ impl<'a> Reader<'a> {
node.opacity = gs.opacity;
node.blend = gs.blend;
node.extras_mut().mask = mask.map(Box::new);
self.add(node, hidden)
self.add(node, hidden)?;
self.image_tail = self.after_object;
Ok(())
}
/// `~`: the placed file read since `%AI5_BeginPlace` becomes a linked image in its place that
@@ -791,14 +793,18 @@ pub(super) fn preview_png(data: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
}
/// A placed file's path relative to the document's folder (`/` separators): its relative `folder`
/// (`src/`, `..\art`) followed by its file `name`. `None` when the folder is empty, absolute, or
/// holds control characters, or when there is no name.
/// (`src/`, `..\art`) followed by its file `name`; the name alone when the folder is empty (the
/// document's own folder). `None` when the folder is absolute or holds control characters, or
/// when there is no name.
pub(super) fn relative_path(folder: &str, name: &str) -> Option<String> {
let folder = folder.replace('\\', "/");
let absolute = folder.starts_with('/') || folder.as_bytes().get(1) == Some(&b':') || folder.contains("://");
if folder.is_empty() || absolute || name.is_empty() || folder.chars().chain(name.chars()).any(char::is_control) {
if absolute || name.is_empty() || folder.chars().chain(name.chars()).any(char::is_control) {
return None;
}
if folder.is_empty() {
return Some(name.to_string());
}
Some(format!("{}/{name}", folder.trim_end_matches('/')))
}
+6
View File
@@ -538,7 +538,13 @@ impl<'a> Reader<'a> {
/// The state block of a layer whose art ended (`Xy`, `Xd`, `XW`, then `LB`) is read at the
/// `LB`, and can have the stroke style between its `Xy` and `Xd`: that keeps the layer's state,
/// and what a mesh or blend before it had (`mesh_undo`).
/// After an image, the operators that end its raster (`XH`, `Xr` and the `N` after
/// `%AI5_EndRaster`) keep it the object its name and opacity mask go to.
fn state_block_continues(&mut self, w: &str) {
if self.image_tail && matches!(w, "XH" | "XT" | "Xr" | "N") {
return;
}
self.image_tail = false;
// An object's tags (`XT`) follow it one after another, and an image's follow the `XH` that
// ends its data.
if matches!(w, "Xy" | "Xd" | "XW" | "BB" | "LB" | "XT" | "XH") {
+33 -3
View File
@@ -1996,6 +1996,35 @@ fn a_drawn_look_that_is_an_image_stands_for_its_object() {
assert!(r.warnings.iter().any(|w| w.contains("drawn look (1)")), "{:?}", r.warnings);
}
/// The name and opacity mask after an image follow the operators that end its raster: `XH`, the
/// versioned `Xr` and the `N` after `%AI5_EndRaster`.
#[test]
fn an_images_name_and_mask_follow_the_end_of_its_raster() {
let mask = "%_/ArtDictionary :\r\n%_/Mask :\r\n%_X=\r\n%_0 g\r\n%_0 0 m\r\n%_5 0 l\r\n%_5 5 l\r\n%_f\r\n%_X+\r\n%_ (Art) ,\r\n\
%_0 /Bool (Clipping) ,\r\n%_0 /Bool (Inverted) ,\r\n%_0 /Bool (Disabled) ,\r\n%_1 /Bool (Linked) ,\r\n%_; (AI9_Mask) ,\r\n\
%_/XMLUID : (Photo) ; (AI10_ArtUID) ,\r\n%_;\r\n%_\r\n";
for tail in [
"XH\r\n%AI5_EndRaster\r\nN\r\n",
"XH\r\n%AI17_Begin_Content_if_version_gt:24 17\r\n72 72 Xr\r\n%AI17_End_Versioned_Content\r\n%AI5_EndRaster\r\nN\r\n",
] {
let mut data = image("[ 10 0 0 10 50 90 ]", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 7);
let at = data.windows(6).position(|w| w == b"XH\r\n%A").unwrap();
let end = data.windows(6).position(|w| w == b"N\r\nLB\r").unwrap() + 3;
data.splice(at..end, [tail, mask].concat().into_bytes());
let r = read(&data).unwrap();
let a = art(&r.doc);
assert_eq!(a.len(), 1, "{a:#?}");
assert!(matches!(a[0].kind, NodeKind::Image(_)), "{:?}", a[0].kind);
assert_eq!(a[0].name.as_deref(), Some("Photo"));
assert!(a[0].extras().mask.is_some());
}
// An operator of another kind in between ends the image: the name has no object.
let mut data = image("[ 10 0 0 10 50 90 ]", (1, 1), ("DeviceRGB", 3, 0), &[255, 0, 0], 7);
let end = data.windows(6).position(|w| w == b"N\r\nLB\r").unwrap() + 3;
data.splice(end..end, b"0 A\r\n%_/ArtDictionary :\r\n%_/XMLUID : (Photo) ; (AI10_ArtUID) ,\r\n%_;\r\n%_\r\n".to_vec());
assert_eq!(art(&read(&data).unwrap().doc)[0].name, None);
}
fn pixels(doc: &Document) -> (image::RgbaImage, kurbo::Affine) {
let a = art(doc);
let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) };
@@ -2320,10 +2349,11 @@ fn a_placed_file_keeps_its_folder_relative_to_the_document() {
assert_eq!(relative_of(&placed_in("( art /)", path, &preview)).as_deref(), Some(" art /photo.png"), "spaces are part of names");
// On `%_` lines too.
assert_eq!(relative_of(&hide_placed(&placed_in("(src/)", path, &preview))).as_deref(), Some("src/photo.png"));
// No folder, an absolute one, one too long or not a string: the path alone, and the file still
// opens.
// An empty folder is the document's own: the file's name.
assert_eq!(relative_of(&placed_in("()", path, &preview)).as_deref(), Some("photo.png"));
// An absolute folder, one too long or not a string: the path alone, and the file still opens.
let long = format!("({})", "a/".repeat(3000));
for folder in ["()", "(/elsewhere/)", "(C:\\\\art\\\\)", long.as_str(), "12", "/src"] {
for folder in ["(/elsewhere/)", "(C:\\\\art\\\\)", long.as_str(), "12", "/src"] {
assert_eq!(relative_of(&placed_in(folder, path, &preview)), None, "{folder}");
}
// A section that doesn't end leaves the folder unread.
+6 -2
View File
@@ -67,8 +67,12 @@ private editing data. VectorCraft:
clipping layers clipped by a path or compound path, linear and radial gradients on fills and strokes (with their
stops' opacity, midpoints and spot colors, elliptical radials and focal points) and gradient swatches, and
patterns whose tiles make a grid and hold their art within the tile, with no pattern fill in that art, as
pattern definitions with their fills, strokes and swatches. Type, other patterns, images, symbols, other clipping
objects and live objects are written as plain filled and stroked paths, and freeform gradients, gradients along
pattern definitions with their fills, strokes and swatches. Embedded images are written with their pixels (gray,
RGB or CMYK ink amounts, and their alpha channel) and linked images as placed files with their path, their folder
relative to the document's and a preview; images that can't be decoded or have more than 33,554,432 pixels are
written as their outlines filled mid gray, images of 16 bits a channel at 8 bits, and linked images in opacity
masks and pattern tiles as embedded images, each with a warning. Type, other patterns, symbols, other clipping
objects and live objects (placed documents included) are written as plain filled and stroked paths, and freeform gradients, gradients along
or across a stroke and gradients without stops, with more than 256 stops or without extent as their average
color, each with a warning. Gradients that dither or mix their colors perceptually are written as classic
gradients, and gradient stops linked to global process colors as their colors, each with a warning. The opacity