mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 02:19:56 -06:00
Merge pull request #5906 from dotCooCoo/fix/legacy-ai-colorized-images
Legacy .ai export: black-and-white colorized images save as image masks
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