mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 04:54:45 +00:00
Legacy .ai export: black-and-white colorized images save as image masks
A colorized image whose pixels are all black or white (or clear) is written as a 1-bit image mask (ImageMask 1): its color as the fill, a 0 bit for each black pixel and a 1 bit for each white or clear one. The legacy reader opens it colorized again with the same clear pixels, so the engine no longer flattens or omits it. Other colorized images are written with their color in their pixels, over white, with the note for transparent images.
This commit is contained in:
@@ -20,7 +20,9 @@
|
||||
//! pixels, or opacity or a blending mode on the image, the fill, the shape or a group it is in) is
|
||||
//! expanded into its tiles first so its images flatten too. With `omit` the images with
|
||||
//! see-through pixels, opacity or a blending mode of their own are left out, and pattern fills stay
|
||||
//! as the writer draws them (their tiles opaque over white). An image that can't be decoded is
|
||||
//! as the writer draws them (their tiles opaque over white). A colorized image is flattened or
|
||||
//! left out the same way, unless its pixels are all black or white: the writer writes that one as
|
||||
//! a 1-bit image mask in its color, which keeps its white pixels clear. An image that can't be decoded is
|
||||
//! missing from the images that flattening and expanding raster effects draw, and the warnings
|
||||
//! then carry the raster exports' note once.
|
||||
//!
|
||||
@@ -179,8 +181,10 @@ fn expand_image_patterns(d: &mut Document) -> Result<()> {
|
||||
}
|
||||
|
||||
/// The images of `d` a legacy `.ai` can't hold, in paint order with their places in it (as
|
||||
/// [`Document::walk`] counts). These have see-through pixels or a colorize paint, or have opacity
|
||||
/// or a blending mode of their own or, with `flatten`, of a group they are in.
|
||||
/// [`Document::walk`] counts). These have see-through pixels or a colorize paint (except the
|
||||
/// colorized images the writer writes as image masks, clear pixels included,
|
||||
/// [`vectorcraft_eps::writes_image_mask`]), or
|
||||
/// have opacity or a blending mode of their own or, with `flatten`, of a group they are in.
|
||||
/// With `flatten` only visible images are returned, because a hidden one (which the hidden objects
|
||||
/// option may write) is written over white. With `omit` all of them are returned.
|
||||
fn see_through_images(d: &Document, mode: SeeThrough) -> Vec<(usize, NodeId)> {
|
||||
@@ -189,8 +193,7 @@ fn see_through_images(d: &Document, mode: SeeThrough) -> Vec<(usize, NodeId)> {
|
||||
let mut at = 0;
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Image(im) = &n.kind
|
||||
&& (keys.contains(&im.key)
|
||||
|| n.extras().colorize.is_some()
|
||||
&& (((keys.contains(&im.key) || n.extras().colorize.is_some()) && !vectorcraft_eps::writes_image_mask(d, n))
|
||||
|| n.opacity < 1.0
|
||||
|| n.blend != BlendMode::Normal
|
||||
|| (mode == SeeThrough::Flatten && transparent(d, n.id)))
|
||||
|
||||
@@ -121,7 +121,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[
|
||||
name: "transparentImages",
|
||||
ty: "string",
|
||||
default: "\"flatten\"",
|
||||
description: "with aiVersion 7, the images version 7 can't hold (see-through pixels, opacity or a blending mode): flatten (the default: each becomes an opaque image of itself and the art under it, clipped to where it changes what shows; that art stays as it is) or omit (left out)",
|
||||
description: "with aiVersion 7, the images version 7 can't hold (see-through pixels, opacity, a blending mode, or a colorize color unless every pixel is black or white): flatten (the default: each becomes an opaque image of itself and the art under it, clipped to where it changes what shows; that art stays as it is) or omit (left out)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "hiddenObjects",
|
||||
|
||||
@@ -370,13 +370,21 @@ fn colorized_gray_image_is_flattened_for_pdf_x_1a() {
|
||||
assert!(!String::from_utf8_lossy(&bytes).contains("/SMask"), "PDF/X-1a has no soft mask");
|
||||
}
|
||||
|
||||
/// A colorized image with a gray pixel is left out; one of black and white pixels is written as an
|
||||
/// image mask and kept.
|
||||
#[test]
|
||||
fn colorized_gray_image_is_omitted_from_legacy_ai_when_requested() {
|
||||
let mut s = session();
|
||||
let mid = png(image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 1, vec![0, 128]).unwrap()));
|
||||
let id = image(&mut s, "mid", mid);
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [id.0]})).unwrap();
|
||||
let out = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "transparentImages": "omit"})).unwrap();
|
||||
assert!(out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("are left out")), "{}", out["warnings"]);
|
||||
let mut s = session();
|
||||
let id = image(&mut s, "gray", gray(false));
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [id.0]})).unwrap();
|
||||
let out = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "transparentImages": "omit"})).unwrap();
|
||||
assert!(out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("are left out")), "{}", out["warnings"]);
|
||||
assert!(!out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("are left out")), "{}", out["warnings"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -447,6 +447,100 @@ fn transparent_images_can_be_left_out_and_opacity_makes_one_transparent() {
|
||||
assert_eq!(v["options"]["transparentImages"]["value"], "flatten", "{v}");
|
||||
}
|
||||
|
||||
/// A red square (10–50 × 10–50) half under a 4 × 2 black-and-white image (30–70 × 10–30) that the
|
||||
/// spot color `Ink` colorizes. Returns the session and the image's pixels.
|
||||
fn colorized_bitmap_over_square() -> (Session, Vec<u8>) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 100, "m": 0, "y": 0, "k": 0}, "spot": true})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let gray = vec![0, 255, 0, 255, 255, 0, 255, 0];
|
||||
let mut bytes = vec![];
|
||||
image::GrayImage::from_raw(4, 2, gray.clone()).unwrap().write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
|
||||
let p = json!({"name": "b.png", "dataBase64": vectorcraft_format::base64_encode(&bytes), "rect": [30, 10, 40, 20]});
|
||||
s.execute("file.place", &p).unwrap();
|
||||
s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap();
|
||||
(s, gray)
|
||||
}
|
||||
|
||||
/// An image's pixels' coverage and its colorize paint.
|
||||
type Colorized = (Option<(u32, u32, Vec<u8>)>, Option<vectorcraft_color::Paint>);
|
||||
|
||||
/// The colorized image of each image node of `d`, with its pixels' coverage.
|
||||
fn colorized(d: &vectorcraft_doc::Document) -> Vec<Colorized> {
|
||||
let mut out = vec![];
|
||||
d.walk(|n| {
|
||||
if let vectorcraft_doc::NodeKind::Image(im) = &n.kind {
|
||||
out.push((d.image_blob(im).and_then(vectorcraft_doc::ImageBlob::coverage), n.extras().colorize.as_deref().cloned()));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// A colorized image of black and white pixels is neither flattened nor left out: it is written as
|
||||
/// a 1-bit image mask in its color, with either setting of `transparentImages`, and opens again
|
||||
/// colorized with that color, its white pixels clear over the square, and the square as it was.
|
||||
#[test]
|
||||
fn a_colorized_black_and_white_image_is_written_as_an_image_mask() {
|
||||
let (mut s, gray) = colorized_bitmap_over_square();
|
||||
let before = colorized(&s.doc().unwrap().doc);
|
||||
let want: Vec<u8> = gray.iter().map(|v| 255 - v).collect();
|
||||
assert!(matches!(before.as_slice(), [(Some((4, 2, c)), Some(_))] if *c == want), "{before:?}");
|
||||
for mode in ["flatten", "omit"] {
|
||||
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "transparentImages": mode})).unwrap();
|
||||
assert!(!warnings(&r).iter().any(|w| w.contains("transparent images") || w.contains("flattened")), "{mode}: {r}");
|
||||
let mut t = Session::new();
|
||||
t.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
|
||||
let d = &t.doc().unwrap().doc;
|
||||
let after = colorized(d);
|
||||
assert_eq!(after.len(), 1, "{mode}: {after:?}");
|
||||
assert_eq!(after[0].0, before[0].0, "{mode}");
|
||||
let (Some(a), Some(b)) = (&after[0].1, &before[0].1) else { panic!("{mode}: {after:?}") };
|
||||
let rgb = |p: &vectorcraft_color::Paint| p.color().map(|c| c.to_rgb()).unwrap();
|
||||
assert!(rgb(a).iter().zip(rgb(b)).all(|(x, y)| (x - y).abs() < 0.01), "{mode}: {a:?} {b:?}");
|
||||
assert_eq!(images(d), (1, 0), "{mode}");
|
||||
// The first row draws as before: black (the color) and white (clear) over the square, then
|
||||
// black and white (clear) over the page.
|
||||
let src = &s.doc().unwrap().doc;
|
||||
for x in [35.0, 45.0, 55.0, 65.0] {
|
||||
let (now, was) = (colour_at(d, x, 15.0), colour_at(src, x, 15.0));
|
||||
assert!(now.iter().zip(was).all(|(a, b)| a.abs_diff(b) <= 3), "{mode} at {x}: {now:?} {was:?}");
|
||||
}
|
||||
assert_ne!(colour_at(src, 35.0, 15.0), colour_at(src, 45.0, 15.0), "{mode}");
|
||||
assert_ne!(colour_at(src, 55.0, 15.0), colour_at(src, 65.0, 15.0), "{mode}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A colorized image of black and clear pixels is written as an image mask too, its clear pixels
|
||||
/// clear, instead of being flattened or left out for its see-through pixels.
|
||||
#[test]
|
||||
fn a_colorized_image_of_black_and_clear_pixels_is_written_as_an_image_mask() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 100, "m": 0, "y": 0, "k": 0}, "spot": true})).unwrap();
|
||||
let mut bytes = vec![];
|
||||
image::GrayAlphaImage::from_raw(2, 1, vec![0, 255, 0, 0])
|
||||
.unwrap()
|
||||
.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let p = json!({"name": "c.png", "dataBase64": vectorcraft_format::base64_encode(&bytes), "rect": [30, 10, 20, 10]});
|
||||
s.execute("file.place", &p).unwrap();
|
||||
s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap();
|
||||
let before = colorized(&s.doc().unwrap().doc);
|
||||
assert!(matches!(before.as_slice(), [(Some((2, 1, c)), Some(_))] if *c == [255, 0]), "{before:?}");
|
||||
for mode in ["flatten", "omit"] {
|
||||
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "transparentImages": mode})).unwrap();
|
||||
assert!(!warnings(&r).iter().any(|w| w.contains("transparent images") || w.contains("flattened")), "{mode}: {r}");
|
||||
let mut t = Session::new();
|
||||
t.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
|
||||
let after = colorized(&t.doc().unwrap().doc);
|
||||
assert_eq!(after.len(), 1, "{mode}: {after:?}");
|
||||
assert_eq!(after[0].0, before[0].0, "{mode}");
|
||||
assert!(after[0].1.is_some(), "{mode}: {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2692's file, an image clipped to a circle inside a circle's stroke, and #2830's, an image with
|
||||
/// see-through pixels (which the save flattens into an image inside a clipping group), each with
|
||||
/// an orange square on the next layer.
|
||||
@@ -639,7 +733,8 @@ fn colorized_grayscale_image_pattern_keeps_its_colors_in_legacy_ai() {
|
||||
for (a, b) in before.iter().zip(&after) {
|
||||
assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(*b) <= 3), "before {before:?} after {after:?}");
|
||||
}
|
||||
assert!(saved_warnings.iter().any(|w| w.contains("flattened with the art under them")), "{saved_warnings:?}");
|
||||
// The tile's pixels are black or white: its images are written as image masks, not flattened.
|
||||
assert!(!saved_warnings.iter().any(|w| w.contains("flattened with the art under them")), "{saved_warnings:?}");
|
||||
}
|
||||
|
||||
/// #3666: a pattern fill whose tile is an image, under opacity, is flattened with the art under it
|
||||
|
||||
@@ -150,6 +150,27 @@ pub struct LegacyAiOutput {
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Whether image node `n` of `doc` is written as a 1-bit image mask in its colorize color
|
||||
/// (ImageMask 1, §8.1) that draws as the document does: it is colorized with a solid color, its
|
||||
/// pixels take the color and are all black or white (or clear), it has no opacity mask turned on,
|
||||
/// and it has no more pixels than the writer writes. Such an image keeps its clear pixels in the file.
|
||||
pub fn writes_image_mask(doc: &Document, n: &Node) -> bool {
|
||||
let NodeKind::Image(im) = &n.kind else { return false };
|
||||
// The size from the file's header, before any pixels are decoded.
|
||||
let fits = |bytes: &[u8]| {
|
||||
image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.ok()
|
||||
.and_then(|r| r.into_dimensions().ok())
|
||||
.is_some_and(|(w, h)| w > 0 && h > 0 && u64::from(w) * u64::from(h) <= MAX_IMAGE_PIXELS)
|
||||
};
|
||||
matches!(n.extras().colorize.as_deref(), Some(Paint::Solid { .. }))
|
||||
&& !n.extras().mask.as_ref().is_some_and(|m| !m.disabled)
|
||||
&& doc
|
||||
.image_blob(im)
|
||||
.is_some_and(|b| b.takes_colorize() && fits(&b.bytes) && b.coverage().is_some_and(|(_, _, c)| c.iter().all(|&v| v == 0 || v == 255)))
|
||||
}
|
||||
|
||||
/// Write `doc`'s first artboard and every layer (all its art, on the artboard or not) as a
|
||||
/// legacy `.ai` file of version 7. Art space is y up, its origin at the artboard's left edge and
|
||||
/// just under its bottom edge: the artboard's top is on a whole point, where readers that place
|
||||
|
||||
@@ -36,7 +36,7 @@ pub(crate) const PROCSET: &str = "VectorCraft_Legacy 1 0";
|
||||
/// application's procedure set): paths and their painting with the fill and stroke colours,
|
||||
/// compound paths painted as one path, clipping groups, guides that don't print, layers that are
|
||||
/// hidden or don't print marking nothing, and images drawn from the samples on the comment lines
|
||||
/// that follow `XI`. Gradients fill with the colour set before them (the writer sets the first
|
||||
/// that follow `XI` (an image mask's 0 bits in the fill color). Gradients fill with the colour set before them (the writer sets the first
|
||||
/// stop's), and type marks nothing: apps that read the format draw both. The page trailer's
|
||||
/// `annotatepage` (§3, Script Setup: `gsave annotatepage grestore showpage`) marks nothing.
|
||||
pub(super) const PROLOG: &str = "%%BeginResource: procset VectorCraft_Legacy 1 0
|
||||
@@ -96,9 +96,11 @@ VCLegacy begin
|
||||
/_l 256 string def /_s 128 string def
|
||||
/_row {{currentfile _l readline not {exit} if dup length 1 gt {exit} if pop} loop
|
||||
dup length 1 gt {dup length 1 sub 1 exch getinterval /ASCIIHexDecode filter _s readstring pop} {pop ()} ifelse} bd
|
||||
/XI {pop pop pop pop /_ic exch def /_ib exch def /_ih exch def /_iw exch def pop pop pop pop
|
||||
% An image mask (ImageMask 1) paints its 0 bits in the fill color.
|
||||
/XI {1 eq /_im exch def pop pop pop /_ic exch def /_ib exch def /_ih exch def /_iw exch def pop pop pop pop
|
||||
[1 0 0 -1 0 0] exch matrix concatmatrix
|
||||
_on {gsave concat _iw _ih _ib [1 0 0 1 0 0] /_row load false _ic 1 eq {1} {_ic} ifelse colorimage grestore} {pop} ifelse} bd
|
||||
_on {gsave concat _im {_fc _iw _ih false [1 0 0 1 0 0] /_row load imagemask}
|
||||
{_iw _ih _ib [1 0 0 1 0 0] /_row load false _ic 1 eq {1} {_ic} ifelse colorimage} ifelse grestore} {pop} ifelse} bd
|
||||
% Type (15) is set by the app that reads the file: its operators take their operands and mark
|
||||
% nothing; encodings (3.3) are skipped.
|
||||
/TE {cleartomark} bd /TZ {cleartomark} bd
|
||||
|
||||
@@ -442,6 +442,94 @@ fn empty_images_get_the_size_note_and_undecodable_ones_the_decode_note() {
|
||||
assert_eq!(notes(b"not an image".to_vec()), [UNDECODED]);
|
||||
}
|
||||
|
||||
/// A gray image of `doc`, `w` × `h` pixels of `gray`, colorized `paint`, placed 10 pt a pixel
|
||||
/// at (`x`, `y`).
|
||||
fn colorized_image(doc: &mut Document, (w, h): (u32, u32), gray: Vec<u8>, paint: &Paint, (x, y): (f64, f64)) -> Node {
|
||||
let mut png = vec![];
|
||||
image::GrayImage::from_raw(w, h, gray).unwrap().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([10.0, 0.0, 0.0, 10.0, x, y]);
|
||||
let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None };
|
||||
let mut n = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im)));
|
||||
n.extras_mut().colorize = Some(Box::new(paint.clone()));
|
||||
n
|
||||
}
|
||||
|
||||
/// A colorized image whose pixels are black or white is written as a 1-bit image mask
|
||||
/// (ImageMask 1) after its color as the fill: its black pixels are 0 bits, which take the color, and its
|
||||
/// white pixels 1 bits, which are clear. It prints in its color over the square behind it and
|
||||
/// reads back colorized with that color and with the same clear pixels. A colorized image with
|
||||
/// gray pixels is written with its color in its pixels, over white, with the note for
|
||||
/// transparent pixels.
|
||||
#[test]
|
||||
fn colorized_images_keep_their_color() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
doc.layers.clear();
|
||||
let layer = doc.add_layer(Some("Art"));
|
||||
let square = shape(&mut doc, shapes::rectangle(Rect::new(5.0, 5.0, 45.0, 30.0)), red(), Paint::None);
|
||||
doc.insert(Some(layer), usize::MAX, square).unwrap();
|
||||
let paint = Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0));
|
||||
// 1 0 0 1 0 1 1 0 / 0 1 1 0 1 0 0 1 in black (1) and white (0).
|
||||
let mask: Vec<u8> = "1001011001101001".bytes().map(|b| if b == b'1' { 0 } else { 255 }).collect();
|
||||
let bitmap = colorized_image(&mut doc, (8, 2), mask.clone(), &paint, (10.0, 10.0));
|
||||
doc.insert(Some(layer), usize::MAX, bitmap).unwrap();
|
||||
let out = write(&doc);
|
||||
let text = String::from_utf8(out.bytes.clone()).unwrap();
|
||||
let images = rasters(&text);
|
||||
assert_eq!(images.len(), 1, "{text}");
|
||||
let [_, _, xi, data @ .., _, _] = images[0].as_slice() else { panic!("{:?}", images[0]) };
|
||||
assert!(xi.ends_with(" ] 0 0 8 2 8 2 1 1 0 0 0 1 XI"), "{xi}");
|
||||
// Each row is a byte: the black pixels are 0 bits.
|
||||
assert_eq!(data, &["%6996"], "{text}");
|
||||
let at = text.find("%AI5_File:\n").unwrap();
|
||||
assert!(text[..at].ends_with("0 0.5 1 0 k\n"), "{text}");
|
||||
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
|
||||
let read = crate::import(&out.bytes).unwrap().document;
|
||||
let mut found = vec![];
|
||||
read.walk(|n| {
|
||||
if let NodeKind::Image(im) = &n.kind {
|
||||
found.push((read.image_blob(im).and_then(ImageBlob::coverage), n.extras().colorize.as_deref().cloned()));
|
||||
}
|
||||
});
|
||||
let want: Vec<u8> = mask.iter().map(|&v| 255 - v).collect();
|
||||
assert_eq!(found, [(Some((8, 2, want)), Some(paint.clone()))]);
|
||||
|
||||
// Gray pixels: the color at each pixel's strength, over white.
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
doc.layers.clear();
|
||||
let layer = doc.add_layer(Some("Art"));
|
||||
let tint = colorized_image(&mut doc, (2, 1), vec![0, 128], &Paint::solid(Color::rgb(1.0, 0.0, 0.0)), (10.0, 10.0));
|
||||
doc.insert(Some(layer), usize::MAX, tint).unwrap();
|
||||
let out = write(&doc);
|
||||
let text = String::from_utf8(out.bytes.clone()).unwrap();
|
||||
let images = rasters(&text);
|
||||
let [_, _, xi, data @ .., _, _] = images[0].as_slice() else { panic!("{:?}", images[0]) };
|
||||
assert!(xi.ends_with(" ] 0 0 2 1 2 1 8 3 0 0 0 0 XI"), "{xi}");
|
||||
assert_eq!(data, &["%FF0000FF8080"], "{text}");
|
||||
assert_eq!(out.warnings, [IMAGE_ALPHA]);
|
||||
}
|
||||
|
||||
/// An image mask is only for a colorized image the writer draws as the document does: not one
|
||||
/// with an opacity mask turned on, and not one with more pixels than the writer writes.
|
||||
#[test]
|
||||
fn image_masks_are_for_images_the_writer_draws_as_they_are() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let paint = Paint::solid(Color::cmyk(0.0, 0.5, 1.0, 0.0));
|
||||
let mut n = colorized_image(&mut doc, (2, 1), vec![0, 255], &paint, (10.0, 10.0));
|
||||
assert!(writes_image_mask(&doc, &n));
|
||||
let art = shape(&mut doc, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Paint::solid(Color::gray(1.0)), Paint::None);
|
||||
n.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, false)));
|
||||
assert!(!writes_image_mask(&doc, &n));
|
||||
if let Some(m) = n.extras_mut().mask.as_mut() {
|
||||
m.disabled = true;
|
||||
}
|
||||
assert!(writes_image_mask(&doc, &n));
|
||||
let big = colorized_image(&mut doc, (8193, 8193), vec![255; 8193 * 8193], &paint, (10.0, 10.0));
|
||||
assert!(!writes_image_mask(&doc, &big));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_fill_and_its_stroke_are_one_object() {
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
|
||||
@@ -272,7 +272,11 @@ impl<'a> Writer<'a> {
|
||||
}
|
||||
NodeKind::Image(im) => {
|
||||
self.lock(locked);
|
||||
self.image(self.doc.image_blob(im), im.width, im.height, im.xf);
|
||||
let blob = self.doc.image_blob(im);
|
||||
// A colorize color the renderer draws the image in.
|
||||
let colorize =
|
||||
n.extras().colorize.as_deref().filter(|p| matches!(p, Paint::Solid { .. }) && blob.is_some_and(ImageBlob::takes_colorize));
|
||||
self.image(blob, im.width, im.height, im.xf, colorize);
|
||||
}
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
self.warn(LIVE);
|
||||
@@ -835,7 +839,7 @@ impl<'a> Writer<'a> {
|
||||
// Colour bitmap glyphs (emoji): their images, which the runs' paint doesn't paint.
|
||||
for g in &layout.glyphs {
|
||||
if let Some((image, to_text)) = g.placed_image() {
|
||||
self.image(Some(&image.blob), image.width, image.height, t.xf * to_text);
|
||||
self.image(Some(&image.blob), image.width, image.height, t.xf * to_text, None);
|
||||
}
|
||||
// COLR glyphs: their layers, as art.
|
||||
if let Some(art) = g.placed_colour(t.xf) {
|
||||
@@ -853,8 +857,12 @@ impl<'a> Writer<'a> {
|
||||
/// `%AI5_EndRaster`, then `F`. §8.4's examples have no `F`; without it, an app reading the file
|
||||
/// can leave out an image in a clipping group and the art after it. No path is open there, so
|
||||
/// the `F` paints nothing. `blob` holds the pixels, placed as a grid of `width` × `height`
|
||||
/// by `xf`.
|
||||
fn image(&mut self, blob: Option<&ImageBlob>, width: u32, height: u32, xf: Affine) {
|
||||
/// by `xf`, and `colorize` is the solid color the image is colorized with. A colorized image
|
||||
/// whose pixels are all black or white is written as an image mask (ImageMask 1, §8.1): its
|
||||
/// color as the fill, then 1-bit samples, a 0 bit for each black pixel, which takes the color,
|
||||
/// and a 1 bit for each white pixel, which is clear. Another colorized image is written with
|
||||
/// its color in its pixels.
|
||||
fn image(&mut self, blob: Option<&ImageBlob>, width: u32, height: u32, xf: Affine, colorize: Option<&Paint>) {
|
||||
let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
|
||||
let m = self.to_art * xf * flip;
|
||||
if !invertible(m) {
|
||||
@@ -876,25 +884,61 @@ impl<'a> Writer<'a> {
|
||||
// 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(width.max(1)) / f64::from(w), f64::from(height.max(1)) / f64::from(h));
|
||||
let m = self.to_art * xf * fit * flip;
|
||||
// Over white.
|
||||
let over = |c: u8, a: u8| ((u32::from(c) * u32::from(a) + 255 * (255 - u32::from(a)) + 127) / 255) as u8;
|
||||
if let Some(paint) = colorize {
|
||||
let Some((_, _, coverage)) = blob.and_then(ImageBlob::coverage).filter(|(cw, ch, _)| (*cw, *ch) == (w, h)) else {
|
||||
self.warn(UNDECODED);
|
||||
return;
|
||||
};
|
||||
if coverage.iter().all(|&v| v == 0 || v == 255) {
|
||||
let stride = (w as usize).div_ceil(8);
|
||||
let mut bits = vec![0xFF; stride.saturating_mul(h as usize)];
|
||||
for (i, _) in coverage.iter().enumerate().filter(|(_, v)| **v == 255) {
|
||||
let (x, y) = (i % w as usize, i / w as usize);
|
||||
if let Some(byte) = bits.get_mut(y * stride + x / 8) {
|
||||
*byte &= !(0x80 >> (x % 8));
|
||||
}
|
||||
}
|
||||
self.color(paint, false);
|
||||
self.raster(m, &format!("0 0 {w} {h} {w} {h} 1 1 0 0 0 1"), &bits);
|
||||
return;
|
||||
}
|
||||
let rgb = paint.color().map_or([0; 3], |c| c.to_rgb().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8));
|
||||
let samples: Vec<u8> = coverage.iter().flat_map(|&a| rgb.map(|c| over(c, a))).collect();
|
||||
self.warn(IMAGE_ALPHA);
|
||||
self.raster(m, &format!("0 0 {w} {h} {w} {h} 8 3 0 0 0 0"), &samples);
|
||||
return;
|
||||
}
|
||||
let rgba = img.to_rgba8();
|
||||
let mut hex = String::with_capacity(rgba.len() / 4 * 7);
|
||||
hex.push('%');
|
||||
let mut on_line = 0;
|
||||
let mut samples = Vec::with_capacity(rgba.len() / 4 * 3);
|
||||
let mut see_through = false;
|
||||
for px in rgba.pixels() {
|
||||
let [r, g, b, a] = px.0;
|
||||
if a < 255 {
|
||||
see_through = true;
|
||||
}
|
||||
// Over white.
|
||||
let over = |c: u8| ((u32::from(c) * u32::from(a) + 255 * (255 - u32::from(a)) + 127) / 255) as u8;
|
||||
for c in [over(r), over(g), over(b)] {
|
||||
let _ = write!(hex, "{c:02X}");
|
||||
on_line += 2;
|
||||
if on_line >= HEX_LINE {
|
||||
hex.push_str("\n%");
|
||||
on_line = 0;
|
||||
}
|
||||
samples.extend([over(r, a), over(g, a), over(b, a)]);
|
||||
}
|
||||
if see_through {
|
||||
self.warn(IMAGE_ALPHA);
|
||||
}
|
||||
self.raster(m, &format!("0 0 {w} {h} {w} {h} 8 3 0 0 0 0"), &samples);
|
||||
}
|
||||
|
||||
/// An image's lines (§8.4): `%AI5_File:`, `%AI5_BeginRaster`, matrix `m` as `[ a b c d tx ty ]`
|
||||
/// and the numbers `operands` with `XI`, `samples` in hex on comment lines of [`HEX_LINE`]
|
||||
/// digits, `%AI5_EndRaster`, then `F`.
|
||||
fn raster(&mut self, m: Affine, operands: &str, samples: &[u8]) {
|
||||
let mut hex = String::with_capacity(samples.len() * 2 + samples.len() / HEX_LINE * 2 + 2);
|
||||
hex.push('%');
|
||||
let mut on_line = 0;
|
||||
for c in samples {
|
||||
let _ = write!(hex, "{c:02X}");
|
||||
on_line += 2;
|
||||
if on_line >= HEX_LINE {
|
||||
hex.push_str("\n%");
|
||||
on_line = 0;
|
||||
}
|
||||
}
|
||||
if hex.ends_with('%') {
|
||||
@@ -902,14 +946,11 @@ impl<'a> Writer<'a> {
|
||||
} else {
|
||||
hex.push('\n');
|
||||
}
|
||||
if see_through {
|
||||
self.warn(IMAGE_ALPHA);
|
||||
}
|
||||
let [a, b, c, d, e, f] = m.as_coeffs();
|
||||
let s = &mut self.body;
|
||||
s.push_str("%AI5_File:\n%AI5_BeginRaster\n[ ");
|
||||
ps::push_nums(s, &[a, b, c, d, e, f]);
|
||||
let _ = writeln!(s, "] 0 0 {w} {h} {w} {h} 8 3 0 0 0 0 XI");
|
||||
let _ = writeln!(s, "] {operands} XI");
|
||||
s.push_str(&hex);
|
||||
s.push_str("%AI5_EndRaster\nF\n");
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ pub use import::{
|
||||
AiLayers, Imported, POSTSCRIPT_ERRORS, ai_alone, ai_alone_on_page, ai_layers_alone, family_style, import, import_trusting, import_with, is_loss,
|
||||
layered_ai, layered_ai_from, page,
|
||||
};
|
||||
pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai};
|
||||
pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai, writes_image_mask};
|
||||
pub use print::{PrintJob, PrintPage, print};
|
||||
|
||||
use vectorcraft_doc::Document;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (SVG import: `color-interpolation="linearRGB"` gradients, #5878) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
|
||||
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
|
||||
@@ -82,7 +82,9 @@ private editing data. VectorCraft:
|
||||
PostScript form, from the published *Adobe Illustrator File Format Specification* version 7.0
|
||||
([PRONOM fmt/423](https://www.nationalarchives.gov.uk/PRONOM/fmt/423)), which apps reading `.ai` open with its
|
||||
structure (#868). It keeps layers (names, order, visibility, lock), groups, compound paths, clipping groups,
|
||||
solid colours, linear and radial gradients and RGB images. The format has one artboard: each artboard chosen in
|
||||
solid colours, linear and radial gradients and RGB images. A colorized image whose pixels are all black or white
|
||||
is written as a 1-bit image mask (ImageMask 1) in its color, with its white pixels clear, and opens colorized
|
||||
again; other colorized images are flattened or omitted as the Transparent Images setting below says. The format has one artboard: each artboard chosen in
|
||||
the options dialog's artboard picker (thumbnails to check, default all) is a file of its own,
|
||||
`{name}-{artboard}.ai` when there are several. It has no transparency: objects are written opaque, and images
|
||||
with see-through pixels, opacity or a blending mode are flattened (an opaque image of themselves and the art
|
||||
|
||||
+3
-1
@@ -879,7 +879,9 @@ document, takes the PDF options and keeps its path when reopened, so Save writes
|
||||
without giving the document a path. `options: {aiVersion: "7"}` writes legacy `.ai` files of version 7 instead (the
|
||||
PostScript form, which keeps layers, groups, compound paths and clipping groups in apps that read it), one per artboard
|
||||
of `range` (default all; `{name}-{artboard}.ai` when several, listed in the result's `files`), with
|
||||
`transparentImages: "flatten"` (default) or `"omit"` for images with see-through pixels, opacity or a blending mode,
|
||||
`transparentImages: "flatten"` (default) or `"omit"` for images with see-through pixels, opacity, a blending mode
|
||||
or a colorize color (except a colorized image of black and white pixels, which is written as a 1-bit image mask in
|
||||
its color with its white pixels clear),
|
||||
`hiddenObjects: "layer"` (default: a hidden layer of each layer's hidden objects), `"omit"` or `"visible"`, and
|
||||
`outlineText: true` to write type as outlines instead of text objects. Type with rotated characters (Character
|
||||
Rotation) is always written as outlines, which keep the rotation; without `outlineText: true` this gives the warning "type with rotated characters is
|
||||
|
||||
Reference in New Issue
Block a user