mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 02:19:56 -06:00
.ai import: 1-bit image masks open colorized by their fill color
The version 7 file format specification gives `XI` and `XF` an ImageMask operand: 0 is an opaque image, 1 is an image that is transparent and can be colorized (sections 8.1, 8.2 and the 1-bit examples in 8.4, https://13thmonkey.org/documentation/fonts/AI7FileFormat.pdf). The reader ignored the operand, so a 1-bit image mask opened as an opaque black-and-white image. A 1-bit gray image with ImageMask 1 now opens with the fill color set before it as the color that colorizes it. A spot or global color keeps its swatch and tint. When the fill is a pattern, a gradient or none, the image is colorized black. VectorCraft's own rule is that the pixels that open black with ImageMask 0 take the color and the others are transparent. A linked image (`XF`) keeps the same color for when its file is relinked. 8-bit gray, RGB and CMYK images, and images with ImageMask 0, open as before.
This commit is contained in:
@@ -2763,6 +2763,10 @@ fn editing_text() -> String {
|
||||
"%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceCMYK XN\n[ 4 0 0 4 10 30 ] 2 1 0 Xh\n[ 4 0 0 4 10 30 ] 0 0 2 1 2 1 8 4 1 0 0 0 4 4 0 0\n\
|
||||
%%BeginData: 18\rXI\nff000000000000ff80ff\n%%EndData\nXH\n%AI5_EndRaster\nN"
|
||||
.into(),
|
||||
// A 9 × 2 1-bit image mask (ImageMask 1) colorized by the global fill before it, in hex.
|
||||
"0 0 0 1 (Black global) 0.4 0 Xk\n%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceGray XN\n[ 4 0 0 4 10 30 ] 9 2 0 Xh\n\
|
||||
[ 4 0 0 4 10 30 ] 0 0 9 2 9 2 1 1 0 0 0 1 4 4 0 0\n%%BeginData: 8\rXI\n6080ff00\n%%EndData\nXH\n%AI5_EndRaster\nN"
|
||||
.into(),
|
||||
]
|
||||
.join("\n");
|
||||
let layer = |name: &str, visible: u8, body: &str| {
|
||||
@@ -2849,6 +2853,23 @@ fn the_editing_fixture_reads_its_cmyk_image_with_its_mask() {
|
||||
assert!(layers && found.contains(&(Some(vec![255, 0, 0, 0, 0, 0, 0, 255]), true)), "{found:?} {warnings:?}");
|
||||
}
|
||||
|
||||
/// The fuzzed editing data, as it is, reads its 1-bit image mask as an image colorized with the
|
||||
/// global color before it.
|
||||
#[test]
|
||||
fn the_editing_fixture_reads_its_image_mask_colorized() {
|
||||
let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&editing_text())].concat();
|
||||
let (doc, warnings, layers) = vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, true, 1.0);
|
||||
let mut found = vec![];
|
||||
doc.visit_images(|n, im| {
|
||||
if let Some(vectorcraft_color::Paint::Solid { swatch, .. }) = n.extras().colorize.as_deref() {
|
||||
found.push((swatch.clone(), im.width, im.height, doc.images[&im.key].coverage().map(|c| c.2)));
|
||||
}
|
||||
});
|
||||
// Clear bits (black) show the color; set bits (white) are transparent.
|
||||
let cover = vec![255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255];
|
||||
assert!(layers && found == [(Some("Black global".to_string()), 9, 2, Some(cover))], "{found:?} {warnings:?}");
|
||||
}
|
||||
|
||||
/// The text document of a story of centred type with two styles, as an editing copy keeps it.
|
||||
fn text_document_text() -> String {
|
||||
"/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ << /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 3 4 ] >> >> >> ] >> >>\n\
|
||||
|
||||
@@ -424,10 +424,12 @@ impl<'a> Reader<'a> {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
return Ok(());
|
||||
};
|
||||
let [_, _, _, _, w, h, bits, kind, alpha, _, binary, ..] = n.as_slice() else {
|
||||
let [_, _, _, _, w, h, bits, kind, alpha, _, binary, rest @ ..] = n.as_slice() else {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
return Ok(());
|
||||
};
|
||||
// ImageMask 1: the image is transparent and takes a color (version 7 specification §8.1).
|
||||
let image_mask = rest.first() == Some(&1.0);
|
||||
let (w, h) = (*w as u64, *h as u64);
|
||||
if w.saturating_mul(h) > MAX_PIXELS {
|
||||
self.warn(IMAGE_TOO_LARGE);
|
||||
@@ -470,7 +472,10 @@ impl<'a> Reader<'a> {
|
||||
&& *bits == 8.0
|
||||
&& let Some(blob) = Inks::new(w as u32, h as u32, samples.to_vec()).and_then(|i| ImageBlob::cmyk_tiff(&i))
|
||||
{
|
||||
return self.add_raster(blob, opacity, &m, (w as u32, h as u32));
|
||||
return self.add_raster(blob, opacity, &m, (w as u32, h as u32), None);
|
||||
}
|
||||
if image_mask && channels == 1 && *bits == 1.0 {
|
||||
return self.colorized_raster(samples, row, opacity, &m, (w as u32, h as u32));
|
||||
}
|
||||
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
|
||||
for y in 0..h as usize {
|
||||
@@ -506,13 +511,45 @@ impl<'a> Reader<'a> {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
return Ok(());
|
||||
}
|
||||
self.add_raster(ImageBlob::new("image/png", png), None, &m, (w as u32, h as u32))
|
||||
self.add_raster(ImageBlob::new("image/png", png), None, &m, (w as u32, h as u32), None)
|
||||
}
|
||||
|
||||
/// A 1-bit gray image whose ImageMask is 1 (`samples`, `row` bytes a row): a grayscale image
|
||||
/// colorized with the fill color. Its 0 bits, which open black with ImageMask 0, take that color,
|
||||
/// and its 1 bits are transparent. A fill that isn't a solid color colorizes it black.
|
||||
fn colorized_raster(&mut self, samples: &[u8], row: usize, alpha: Option<&[u8]>, m: &[f64], (w, h): (u32, u32)) -> Result<(), String> {
|
||||
let mut gray = Vec::with_capacity(w as usize * h as usize);
|
||||
for y in 0..h as usize {
|
||||
let line = samples.get(y * row..(y + 1) * row).unwrap_or_default();
|
||||
for x in 0..w as usize {
|
||||
let v = line.get(x / 8).map_or(0, |b| ((b >> (7 - x % 8)) & 1) * 255);
|
||||
gray.push(v);
|
||||
}
|
||||
}
|
||||
let mut png = vec![];
|
||||
let written = image::GrayImage::from_raw(w, h, gray).map(|img| img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png));
|
||||
if !matches!(written, Some(Ok(()))) {
|
||||
self.warn("an image VectorCraft can't read was left out");
|
||||
return Ok(());
|
||||
}
|
||||
let paint = self.mask_paint();
|
||||
self.add_raster(ImageBlob::new("image/png", png), alpha, m, (w, h), Some(paint))
|
||||
}
|
||||
|
||||
/// The color that colorizes an image mask: the fill color, or black when the fill isn't a
|
||||
/// solid color.
|
||||
fn mask_paint(&self) -> Paint {
|
||||
match &self.gs.fill {
|
||||
p @ Paint::Solid { .. } => p.clone(),
|
||||
_ => Paint::solid(vectorcraft_color::Color::BLACK),
|
||||
}
|
||||
}
|
||||
|
||||
/// `XF` (§8.2): an image whose pixels are in the file the `XG` before it names becomes a linked
|
||||
/// image of that file, of its `w` × `h` pixels placed as the matrix maps them, as `XI` places an
|
||||
/// image. The data keeps no pixels or preview of it, so a placeholder is spread over those pixels
|
||||
/// until its file is found. One without that file name, or whose operands `XI` couldn't read (no
|
||||
/// until its file is found. A 1-bit image mask is colorized as `XI` colorizes one. One without
|
||||
/// that file name, or whose operands `XI` couldn't read (no
|
||||
/// matrix, too few numbers, no pixels, or samples of other than 1 or 8 bits), is left out with a
|
||||
/// warning. No pixels are read, so its size isn't held to the limit on `XI`'s samples.
|
||||
fn linked_image(&mut self, vals: Vec<V>, hidden: bool) -> Result<(), String> {
|
||||
@@ -552,13 +589,16 @@ impl<'a> Reader<'a> {
|
||||
self.placeholders.push(xf.transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h))));
|
||||
}
|
||||
let im = ImageObject { key, width: w, height: h, xf, link: Some(LinkInfo::new(path)), placement: Default::default(), pixels: None };
|
||||
self.add_image(im, hidden)
|
||||
// A 1-bit image mask (ImageMask 1) keeps the color that colorizes it once its file is relinked.
|
||||
let image_mask = bits == 1.0 && n.get(7) == Some(&1.0) && n.get(11) == Some(&1.0);
|
||||
let colorize = image_mask.then(|| self.mask_paint());
|
||||
self.add_image_colorized(im, None, colorize, hidden)
|
||||
}
|
||||
|
||||
/// Add the image `blob` (`w` × `h` pixels), which the matrix `m` maps into art space, with
|
||||
/// `alpha` (its alpha channel, one byte a pixel) as its opacity mask when it hides part of the
|
||||
/// image.
|
||||
fn add_raster(&mut self, blob: ImageBlob, alpha: Option<&[u8]>, m: &[f64], (w, h): (u32, u32)) -> Result<(), String> {
|
||||
/// image, and `colorize` as the color that colorizes it.
|
||||
fn add_raster(&mut self, blob: ImageBlob, alpha: Option<&[u8]>, m: &[f64], (w, h): (u32, u32), colorize: Option<Paint>) -> Result<(), String> {
|
||||
// The matrix is an art-space matrix (y up) applied to the pixels with their rows running
|
||||
// downwards, (u, -v): pixels → (u, -v) → art space. Flipping the matrix's result instead
|
||||
// gives the same place only when the image isn't rotated (b = c = 0).
|
||||
@@ -575,7 +615,8 @@ impl<'a> Reader<'a> {
|
||||
let mask = alpha.and_then(|a| self.alpha_mask(a, w, h, xf));
|
||||
let key = blob.content_key();
|
||||
self.doc.images.entry(key.clone()).or_insert(blob);
|
||||
self.add_image_masked(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }, mask, false)
|
||||
let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None };
|
||||
self.add_image_colorized(im, mask, colorize, false)
|
||||
}
|
||||
|
||||
/// An image's alpha channel (`w` × `h` bytes) as a grayscale image over the image (`xf`): its
|
||||
@@ -603,11 +644,18 @@ impl<'a> Reader<'a> {
|
||||
|
||||
/// Add an image object with the state's transparency and `mask` as its opacity mask.
|
||||
fn add_image_masked(&mut self, im: ImageObject, mask: Option<OpacityMask>, hidden: bool) -> Result<(), String> {
|
||||
self.add_image_colorized(im, mask, None, hidden)
|
||||
}
|
||||
|
||||
/// Add an image object with the state's transparency, `mask` as its opacity mask and
|
||||
/// `colorize` as the color that colorizes it.
|
||||
fn add_image_colorized(&mut self, im: ImageObject, mask: Option<OpacityMask>, colorize: Option<Paint>, hidden: bool) -> Result<(), String> {
|
||||
let gs = self.gs.clone();
|
||||
let mut node = self.new_node(NodeKind::Image(Box::new(im)), &gs);
|
||||
node.opacity = gs.opacity;
|
||||
node.blend = gs.blend;
|
||||
node.extras_mut().mask = mask.map(Box::new);
|
||||
node.extras_mut().colorize = colorize.map(Box::new);
|
||||
self.add(node, hidden)?;
|
||||
self.image_tail = self.after_object;
|
||||
Ok(())
|
||||
|
||||
@@ -2173,6 +2173,61 @@ fn one_bit_images_read_each_channel() {
|
||||
assert_eq!((0..3).map(|x| img.get_pixel(x, 0).0[0]).collect::<Vec<_>>(), [255, 0, 255]);
|
||||
}
|
||||
|
||||
/// A gray image `w` × 1 in hex with ImageMask `mask` and `bits` bits a pixel, after the fill `fill`.
|
||||
fn masked_gray(fill: &str, bits: u32, mask: u32, w: u32, hex: &[u8]) -> Vec<u8> {
|
||||
let text = String::from_utf8(image("[ 1 0 0 1 0 100 ]", (w, 1), ("DeviceGray", 1, 0), hex, hex.len())).unwrap();
|
||||
text.replacen(" 8 1 0 0 1 0 ", &format!(" {bits} 1 0 0 0 {mask} "), 1).replacen("(L) Ln\n", &format!("(L) Ln\n{fill}"), 1).into_bytes()
|
||||
}
|
||||
|
||||
/// ImageMask 1 on a 1-bit gray image: the image is colorized with the fill color, so its black
|
||||
/// pixels take the color and its white ones are transparent. ImageMask 0 keeps the opaque image.
|
||||
#[test]
|
||||
fn image_masks_are_colorized_with_the_fill() {
|
||||
// 4 × 1 at 1 bit: black, white, white, black (0110).
|
||||
let r = read(&masked_gray("0 0 1 0 k\n", 1, 1, 4, b"60")).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
let a = art(&r.doc);
|
||||
let Some(Paint::Solid { color, .. }) = a[0].extras().colorize.as_deref() else { panic!("{:?}", a[0].extras().colorize) };
|
||||
assert_eq!(color.to_rgba8(1.0), Color::cmyk(0.0, 0.0, 1.0, 0.0).to_rgba8(1.0));
|
||||
let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) };
|
||||
let blob = &r.doc.images[&im.key];
|
||||
assert!(blob.gray_source().is_some_and(|g| !g.alpha), "an image a fill color can colorize");
|
||||
let img = pixels(&r.doc).0;
|
||||
assert_eq!(
|
||||
(0..4).map(|x| img.get_pixel(x, 0).0).collect::<Vec<_>>(),
|
||||
[[0, 0, 0, 255], [255, 255, 255, 255], [255, 255, 255, 255], [0, 0, 0, 255]]
|
||||
);
|
||||
// How much of the color each pixel shows: the black ones only.
|
||||
assert_eq!(blob.coverage().map(|c| c.2), Some(vec![255, 0, 0, 255]));
|
||||
// A spot color keeps its swatch and tint.
|
||||
let spot = "0 0 1 0 (Ink) 0.5 0 Xx\n";
|
||||
let a = art(&read(&masked_gray(spot, 1, 1, 4, b"60")).unwrap().doc);
|
||||
assert!(
|
||||
matches!(a[0].extras().colorize.as_deref(), Some(Paint::Solid { swatch: Some(s), tint, .. }) if s == "Ink" && (*tint - 0.5).abs() < 1e-6),
|
||||
"{:?}",
|
||||
a[0].extras().colorize
|
||||
);
|
||||
// A fill that isn't a solid color (here a pattern) colorizes it black.
|
||||
let patterned = String::from_utf8(masked_gray("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n", 1, 1, 4, b"60")).unwrap();
|
||||
let doc = read(patterned.replacen("%AI5_BeginLayer", &format!("{DOTS}%AI5_BeginLayer"), 1).as_bytes()).unwrap().doc;
|
||||
let a = art(&doc);
|
||||
let Some(Paint::Solid { color, swatch: None, .. }) = a[0].extras().colorize.as_deref() else { panic!("{:?}", a[0].extras().colorize) };
|
||||
assert_eq!(color.to_rgba8(1.0), Color::BLACK.to_rgba8(1.0));
|
||||
// An 8-bit gray image with ImageMask 1 opens as before, with no color.
|
||||
let doc = read(&masked_gray("0 1 0 0 k\n", 8, 1, 2, b"00ff")).unwrap().doc;
|
||||
assert!(art(&doc)[0].extras().colorize.is_none());
|
||||
assert_eq!(pixels(&doc).0.get_pixel(1, 0).0, [255, 255, 255, 255]);
|
||||
// ImageMask 0: the opaque image, with no color.
|
||||
let doc = read(&masked_gray("0 0 1 0 k\n", 1, 0, 4, b"60")).unwrap().doc;
|
||||
assert!(art(&doc)[0].extras().colorize.is_none());
|
||||
assert_eq!(pixels(&doc).0.get_pixel(0, 0).0, [0, 0, 0, 255]);
|
||||
// An RGB image can't take a color: ImageMask 1 leaves it as it is.
|
||||
let text = String::from_utf8(image("[ 1 0 0 1 0 100 ]", (1, 1), ("DeviceRGB", 3, 0), b"ff0000", 6)).unwrap();
|
||||
let doc = read(text.replacen(" 8 3 0 0 1 0 ", " 8 3 0 0 0 1 ", 1).as_bytes()).unwrap().doc;
|
||||
assert!(art(&doc)[0].extras().colorize.is_none());
|
||||
assert_eq!(pixels(&doc).0.get_pixel(0, 0).0, [255, 0, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_with_alpha_channels() {
|
||||
// RGB samples, then the alpha channel (not counted in the data's size).
|
||||
@@ -2262,6 +2317,27 @@ fn raster7(head: &str, matrix: &str, (w, h): (u32, u32), op: &str, tail: &str) -
|
||||
format!("%AI5_File:\n%AI5_BeginRaster\n{head}{matrix} 0 0 {w} {h} {w} {h} 8 3 0 0 0 0 {op}\n{tail}%AI5_EndRaster\n")
|
||||
}
|
||||
|
||||
/// A linked 1-bit gray image with ImageMask 1 keeps the fill color as the color that colorizes it,
|
||||
/// as an embedded one does. Other linked images have no color.
|
||||
#[test]
|
||||
fn linked_image_masks_are_colorized_with_the_fill() {
|
||||
let m = "[ 1 0 0 1 0 0 ]";
|
||||
let linked = |bits_kind_mask: &str| {
|
||||
let xf = raster7("(a.tif) 0 XG\n", m, (2, 2), "XF", "").replace(" 8 3 0 0 0 0 XF", &format!(" {bits_kind_mask} XF"));
|
||||
let r = read(stream7(&layer7("L", &format!("0 0 1 0 (Ink) 0.5 0 Xx\n{xf}"))).as_bytes()).unwrap();
|
||||
let a = art(&r.doc);
|
||||
assert!(matches!(&a[0].kind, NodeKind::Image(im) if im.link.is_some()), "{:?} {:?}", a, r.warnings);
|
||||
a[0].extras().colorize.as_deref().cloned()
|
||||
};
|
||||
assert!(
|
||||
matches!(linked("1 1 0 0 0 1"), Some(Paint::Solid { swatch: Some(s), tint, .. }) if s == "Ink" && (tint - 0.5).abs() < 1e-6),
|
||||
"a 1-bit image mask"
|
||||
);
|
||||
assert_eq!(linked("1 1 0 0 0 0"), None, "ImageMask 0");
|
||||
assert_eq!(linked("8 1 0 0 0 1"), None, "8-bit gray");
|
||||
assert_eq!(linked("8 3 0 0 0 1"), None, "RGB");
|
||||
}
|
||||
|
||||
/// A linked image in the version 7 form (§8.2, §8.3), `(path) modified XG` and then `XF` with no
|
||||
/// samples, is a linked image of that file. The data keeps no pixels of it, so it shows a
|
||||
/// placeholder over the place the matrix maps the image's pixels to, as an embedded image (`XI`)
|
||||
|
||||
@@ -130,6 +130,9 @@ private editing data. VectorCraft:
|
||||
with a fill's offset, scale, rotation, reflection and shear applied before its matrix. Its linked images in
|
||||
the version 7 form (`(path) modified XG`, then `XF` with no samples) come in as linked images of their files,
|
||||
showing a placeholder where the image goes until the file is relinked in the Links panel.
|
||||
A 1-bit gray image whose `XI` or `XF` has ImageMask 1 comes in colorized by the fill color set before it,
|
||||
or by black when that fill is not a solid color. VectorCraft's own rule is that the pixels that open black
|
||||
with ImageMask 0 take the color and the others are transparent.
|
||||
- Estimate: **~65%** for real exchange with Illustrator users (open: ~75%; save: PDF-compatible, or legacy
|
||||
version 7 with layers and groups; not yet tested by users who hand files back).
|
||||
Closing the open-side issues: 10–20 h with users' files.
|
||||
|
||||
Reference in New Issue
Block a user