diff --git a/crates/eps/src/import/ai/mesh.rs b/crates/eps/src/import/ai/mesh.rs index 3937045db..4f667fbe4 100644 --- a/crates/eps/src/import/ai/mesh.rs +++ b/crates/eps/src/import/ai/mesh.rs @@ -133,7 +133,7 @@ impl Space { } /// A named colour or ink's full colour: `[c m y k /DeviceCMYK (name) /kind]` or `[r g b /DeviceRGB …]`. -fn ink(a: &[V]) -> Option { +pub(super) fn ink(a: &[V]) -> Option { let at = a.iter().position(|v| matches!(v, V::Name(_)))?; let nums: Vec = a.get(..at)?.iter().map(|v| v.num().filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0) as f32)).collect::>()?; match (a.get(at).and_then(V::text).as_deref(), nums.as_slice()) { diff --git a/crates/eps/src/import/ai/paint.rs b/crates/eps/src/import/ai/paint.rs index a25f2b82a..5f39c3b97 100644 --- a/crates/eps/src/import/ai/paint.rs +++ b/crates/eps/src/import/ai/paint.rs @@ -72,6 +72,37 @@ pub(super) fn color_op_in(op: &str, vals: &[V], names: &mut Names) -> Option<(Pa } } +/// `n [c m y k /DeviceCMYK (name) /Separation] XC` (or `[r g b /DeviceRGB …]`) before an image: its +/// samples are one ink's tints. The ink at 100 % as a paint, with the named colour that makes its +/// spot swatch (none for Registration). `None` for a device space (`/DeviceGray XC`), the ink +/// `None`, which paints nothing, or another form. +pub(super) fn separation_ink(vals: &[V], names: &mut Names) -> Option<(Paint, Option)> { + let a = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { Some(a) } else { None })?; + let [.., name @ V::Str(_), V::Name(kind)] = a.as_slice() else { return None }; + if kind.trim_start_matches('/') != "Separation" { + return None; + } + let color = super::mesh::ink(a)?; + let name = names.text(name)?; + if name == "None" { + return None; + } + if is_registration(&name) { + let paint = Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint: 1.0 }; + return Some((paint, None)); + } + // A process plate is its CMYK colour, not a spot ink: no swatch of its name. + if let Some(plate) = ["Cyan", "Magenta", "Yellow", "Black"].iter().position(|p| *p == name) { + let mut cmyk = [0.0; 4]; + if let Some(v) = cmyk.get_mut(plate) { + *v = 1.0; + } + return Some((Paint::solid(Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3])), None)); + } + let paint = Paint::Solid { color, swatch: Some(name.clone()), tint: 1.0 }; + Some((paint, Some(Named { name, color, tint: 1.0, spot: true }))) +} + fn is_registration(name: &str) -> bool { matches!(name, vectorcraft_color::swatch::REGISTRATION | "Registration" | "/All" | "All" | "/[Registration]") } diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index 7d2565172..3d859bf74 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -472,10 +472,34 @@ impl<'a> Reader<'a> { return Ok(()); }; let opacity = opacity.filter(|_| alpha_len > 0); + // An image in one ink's Separation space (`XC`): its samples are the ink's tints, the most + // at 100 % (255, or a set bit). It comes in as a grayscale image of them, black at 100 %, + // that the ink colorizes, as a Separation image opens from a PDF; its alpha channel + // becomes its opacity mask. + let ink = if channels == 1 && !image_mask { self.raster.as_mut().and_then(|r| r.ink.take()) } else { None }; + let inverted; + let samples = if ink.is_some() { + inverted = samples.iter().map(|b| !b).collect::>(); + inverted.as_slice() + } else { + samples + }; + let ink = ink.map(|(paint, named)| { + // Its colour counts toward the document's colour model, as a fill's does (Registration + // says nothing of it). + let registration = matches!(&paint, Paint::Solid { swatch: Some(s), .. } if s == vectorcraft_color::swatch::REGISTRATION); + match paint.color().filter(|_| !registration) { + Some(vectorcraft_color::Color::Rgb { .. }) => self.rgb_colors += 1, + Some(vectorcraft_color::Color::Cmyk { .. }) => self.cmyk_colors += 1, + _ => {} + } + self.spots.extend(named); + paint + }); let gray1 = *bits == 1.0 && channels == 1 && opacity.is_none(); if k > 1 || w * h > raster::SMALL || (gray1 && !image_mask) { - // An image mask keeps the color that colorizes it. - let colorize = (image_mask && channels == 1 && *bits == 1.0).then(|| self.mask_paint()); + // An image mask keeps the color that colorizes it, an ink image its ink. + let colorize = if image_mask && channels == 1 && *bits == 1.0 { Some(self.mask_paint()) } else { ink }; return self.large_image(samples, opacity, shape, k, &m, colorize); } // CMYK samples keep their ink amounts as a CMYK TIFF, as the PDF importer keeps a CMYK @@ -487,7 +511,11 @@ impl<'a> Reader<'a> { 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 paint = self.mask_paint(); + return self.colorized_raster(samples, (row, 1), opacity, &m, (w as u32, h as u32), paint); + } + if let Some(paint) = ink { + return self.colorized_raster(samples, (row, *bits as u8), opacity, &m, (w as u32, h as u32), paint); } let mut rgba = Vec::with_capacity((w * h * 4) as usize); for y in 0..h as usize { @@ -526,15 +554,24 @@ impl<'a> Reader<'a> { 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> { + /// A one-channel image (`samples` of `bits` bits, `row` bytes a row) as a grayscale image + /// colorized with `paint`: a 1-bit gray image whose ImageMask is 1, 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), or an ink's tints, inverted, with the ink. + fn colorized_raster( + &mut self, + samples: &[u8], + (row, bits): (usize, u8), + alpha: Option<&[u8]>, + m: &[f64], + (w, h): (u32, u32), + paint: Paint, + ) -> 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); + let v = if bits == 1 { line.get(x / 8).map_or(0, |b| ((b >> (7 - x % 8)) & 1) * 255) } else { line.get(x).copied().unwrap_or(0) }; gray.push(v); } } @@ -544,7 +581,6 @@ impl<'a> Reader<'a> { self.image_left_out("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)) } @@ -625,12 +661,15 @@ impl<'a> Reader<'a> { return self.add_raster(ImageBlob::new("image/png", png), None, m, (ow, oh), colorize); } let cmyk = s.channels == 4; - let (pixels, mask) = raster::reduce(samples, opacity, s, k, !cmyk); + // A colorized image's alpha channel becomes its opacity mask, as at its full size: a + // grayscale image whose pixels have alpha takes only a spot color, black or white. + let inline = !cmyk && colorize.is_none(); + let (pixels, mask) = raster::reduce(samples, opacity, s, k, inline); let blob = if cmyk { Inks::new(ow, oh, pixels).and_then(|i| ImageBlob::cmyk_tiff(&i)) } else { use image::ImageEncoder as _; - let colour = match (s.channels, opacity.is_some()) { + let colour = match (s.channels, inline && opacity.is_some()) { (1, false) => image::ExtendedColorType::L8, (1, true) => image::ExtendedColorType::La8, (_, false) => image::ExtendedColorType::Rgb8, diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index e1fbe0d4d..f7df4f35d 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -181,9 +181,13 @@ impl<'a> Reader<'a> { } else if line.starts_with("AI5_EndRaster") { // A file `XG` named in this raster object is no later one's. self.image_link = None; - if let Some(Raster { operands: Some(vals), mut samples, half, max, .. }) = self.raster.take() { + if let Some(Raster { operands: Some(vals), mut samples, half, max, ink, .. }) = self.raster.take() { end_hex(&mut samples, half, max); - return self.image_from(vals, &samples, true); + // The image's ink (`XC`) stays for `image_from` to read. + self.raster = Some(Raster { ink, ..Raster::default() }); + let read = self.image_from(vals, &samples, true); + self.raster = None; + return read; } } else if line.starts_with("AI5_BeginPlace") { // One that is still open (no `~` ended it) isn't read. diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index f584f26c2..6c84eff23 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -361,6 +361,14 @@ impl<'a> Reader<'a> { r.space = vals.iter().rev().find_map(V::text); } } + // `n [… (name) /Separation] XC`: the image's samples are that ink's tints. A device + // space (`/DeviceGray XC` after a linked image's file name) changes nothing read here. + "XC" => { + let ink = paint::separation_ink(&vals, &mut self.names); + if let Some(r) = self.raster.as_mut() { + r.ink = ink; + } + } // Placed files. // `[a b c d tx ty] llx lly urx ury (path) `` ` ``, three more numbers before the path in // newer files: the version 7 form has no `%AI5_BeginPlace` before it. Before the art, diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index b07270ef8..473a07079 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -279,6 +279,9 @@ impl BrushArtColors { #[derive(Debug, Default)] pub(super) struct Raster { pub(super) space: Option, + /// The ink of an image in a Separation space (`XC`): its paint at 100 % and the named colour + /// for its swatch (`paint::separation_ink`). + pub(super) ink: Option<(Paint, Option)>, /// `XI`'s operands when its samples follow as hex in comment lines (`%FF00…`) up to /// `%AI5_EndRaster`, the form the version 7 specification documents (§8). pub(super) operands: Option>, @@ -370,8 +373,6 @@ pub(super) const IGNORED: &[&str] = &[ "As", "Xd", "Xr", - // `/DeviceGray XC` after a linked image's file name (`XG`): its colour space. - "XC", "Xh", "XH", "Bc", diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 2d6aa9967..19cd6fa27 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -2755,6 +2755,108 @@ fn image_masks_are_colorized_with_the_fill() { assert_eq!(pixels(&doc).0.get_pixel(0, 0).0, [255, 0, 0, 255]); } +/// A gray image `w` × 1 (`bits` bits a pixel, `alpha` channels, data `data`) whose colour space is +/// set by `xc` (the operands of `XC`) instead of `/DeviceGray XN`. +fn ink_image(xc: &str, bits: u32, alpha: u32, w: u32, data: &[u8]) -> Vec { + let mut out = image("[ 1 0 0 1 0 100 ]", (w, 1), ("DeviceGray", 1, alpha), data, data.len() + 3); + // In place, as bytes: the samples aren't text. + let mut swap = |from: &[u8], to: &[u8]| { + let at = out.windows(from.len()).position(|s| s == from).unwrap(); + out.splice(at..at + from.len(), to.iter().copied()); + }; + swap(b"/DeviceGray XN\n", format!("{xc} XC\n").as_bytes()); + swap(format!(" 8 1 {alpha} 0 1 0 ").as_bytes(), format!(" {bits} 1 {alpha} 0 1 0 ").as_bytes()); + out +} + +/// An image in one ink's Separation space (`n [… (name) /Separation] XC`): its samples are the +/// ink's tints (255 at 100 %), so it opens as a grayscale image of them, black at 100 %, colorized +/// by the ink's spot swatch; its alpha channel becomes its opacity mask. +#[test] +fn separation_images_are_colorized_with_their_ink() { + let black = "1 [0 0 0 1 /DeviceCMYK (Black Ink) /Separation]"; + let r = read(&ink_image(black, 8, 0, 3, &[0, 128, 255])).unwrap(); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let a = art(&r.doc); + let Some(Paint::Solid { color, swatch: Some(swatch), tint }) = a[0].extras().colorize.as_deref() else { panic!("{:?}", a[0].extras().colorize) }; + assert_eq!((swatch.as_str(), *tint), ("Black Ink", 1.0)); + assert_eq!(color.to_rgba8(1.0), Color::cmyk(0.0, 0.0, 0.0, 1.0).to_rgba8(1.0)); + assert!(r.doc.swatch("Black Ink").is_some_and(|s| s.spot), "a spot swatch for the ink"); + let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) }; + assert!(r.doc.images[&im.key].gray_source().is_some_and(|g| !g.alpha)); + let img = pixels(&r.doc).0; + assert_eq!((0..3).map(|x| img.get_pixel(x, 0).0[0]).collect::>(), [255, 127, 0], "no ink is white, 100 % black"); + + // An RGB alternate gives the ink's colour; an alpha channel becomes the opacity mask. + let white = "1 [1 1 1 /DeviceRGB (White Ink) /Separation]"; + let r = read(&ink_image(white, 8, 1, 2, &[255, 255, 255, 0])).unwrap(); + let a = art(&r.doc); + let Some(Paint::Solid { color, swatch: Some(swatch), .. }) = a[0].extras().colorize.as_deref() else { panic!("{:?}", a[0].extras().colorize) }; + assert_eq!((swatch.as_str(), color.to_rgba8(1.0)), ("White Ink", Color::rgb(1.0, 1.0, 1.0).to_rgba8(1.0))); + assert!(a[0].extras().mask.is_some(), "the alpha channel as an opacity mask"); + + // 1 bit a pixel: a set bit is the ink at 100 % (101). + let r = read(&ink_image(black, 1, 0, 3, b"\xa0")).unwrap(); + let a = art(&r.doc); + assert!(a[0].extras().colorize.is_some()); + let img = pixels(&r.doc).0; + assert_eq!((0..3).map(|x| img.get_pixel(x, 0).0[0]).collect::>(), [0, 255, 0]); + + // A device space, and the ink None (which paints nothing), leave the image as it was. + for xc in ["/DeviceGray", "1 [0 0 0 1 /DeviceCMYK (None) /Separation]"] { + let doc = read(&ink_image(xc, 8, 0, 2, &[0, 200])).unwrap().doc; + assert!(art(&doc)[0].extras().colorize.is_none(), "{xc}"); + assert_eq!(pixels(&doc).0.get_pixel(1, 0).0, [200, 200, 200, 255], "{xc}"); + } +} + +/// A process plate's Separation colorizes in the plate's CMYK colour, with no swatch of its name; +/// Registration in the Registration colour. +#[test] +fn separation_images_of_process_plates_and_registration() { + let r = read(&ink_image("1 [0 0 0 1 /DeviceCMYK (Black) /Separation]", 8, 0, 2, &[0, 255])).unwrap(); + assert_eq!(art(&r.doc)[0].extras().colorize.as_deref(), Some(&Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 1.0)))); + assert!(r.doc.swatches.iter().all(|s| !s.spot), "no spot swatch for a process plate"); + let r = read(&ink_image("1 [1 1 1 1 /DeviceCMYK (All) /Separation]", 8, 0, 2, &[0, 255])).unwrap(); + let swatch = art(&r.doc)[0].extras().colorize.as_deref().and_then(|p| if let Paint::Solid { swatch, .. } = p { swatch.clone() } else { None }); + assert_eq!(swatch.as_deref(), Some(vectorcraft_color::swatch::REGISTRATION)); +} + +/// The version 7 form, with samples in hex comment lines after `XI`, keeps the image's ink. +#[test] +fn a_separation_image_with_hex_comment_lines_keeps_its_ink() { + let s = "%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n\ + 1 [0 0 0 1 /DeviceCMYK (Black Ink) /Separation] XC\n[ 1 0 0 1 0 100 ] 3 1 0 Xh\n\ + [ 1 0 0 1 0 100 ] 0 0 3 1 3 1 8 1 0 0 0 0 XI\n%0080FF\n%AI5_EndRaster\nN\nLB\n%AI5_EndLayer--\n"; + let mut data = stream("").into_bytes(); + let at = data.windows(14).position(|w| w == b"%%PageTrailer\n").unwrap(); + data.splice(at..at, s.bytes()); + let r = read(&data).unwrap(); + let a = art(&r.doc); + assert!( + matches!(a[0].extras().colorize.as_deref(), Some(Paint::Solid { swatch: Some(s), .. }) if s == "Black Ink"), + "{:?}", + a[0].extras().colorize + ); + let img = pixels(&r.doc).0; + assert_eq!((0..3).map(|x| img.get_pixel(x, 0).0[0]).collect::>(), [255, 127, 0]); +} + +/// A Separation image reduced to fit the limits keeps its ink, and its alpha channel becomes its +/// opacity mask, as at its full size. +#[test] +fn a_reduced_separation_image_keeps_its_ink_and_its_alpha_as_a_mask() { + let samples = [255u8; 16].iter().chain([0u8; 16].iter()).copied().collect::>(); + let data = ink_image("1 [0 0 0 1 /DeviceCMYK (Black Ink) /Separation]", 8, 1, 16, &samples); + let r = super::with_image_limits(4, 1 << 31, 512 << 20, || read(&data).unwrap()); + assert!(r.warnings.iter().any(|w| w.contains("was reduced")), "{:?}", r.warnings); + let a = art(&r.doc); + assert!(a[0].extras().colorize.is_some()); + assert!(a[0].extras().mask.is_some(), "the alpha channel as an opacity mask"); + let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) }; + assert!(r.doc.images[&im.key].gray_source().is_some_and(|g| !g.alpha), "no alpha in the pixels"); +} + #[test] fn images_with_alpha_channels() { // RGB samples, then the alpha channel (not counted in the data's size). diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a04c8926e..1f911f6e5 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (`.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; 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 @@ -223,6 +223,9 @@ private editing data. VectorCraft: 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. + An image whose color space `XC` sets to one ink (`n [… (name) /Separation] XC`) comes in as a grayscale image of + its tints (black at 100 %) colorized by the ink (its spot swatch, or a process plate's CMYK color), its alpha channel + as its opacity mask (#6350). - 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. @@ -324,6 +327,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | Date | Change | Summary | |---|---|---| +| 2026-11-09 | minor | `.ai` import: an image whose color space `XC` sets to one ink (`n [c m y k /DeviceCMYK (name) /Separation] XC`, or an RGB alternate) opened as a gray RGB image: its samples, the ink's tints (255 at 100 %), were read as gray levels, so the tints drew inverted and the ink was lost. It now opens as a grayscale image of the tints, black at 100 %, colorized by the ink at 100 %: a spot swatch, a process plate's CMYK color (no swatch of its name), or Registration; the ink's color counts toward the document's color mode as a fill's does. Its alpha channel becomes its opacity mask, a reduced large image's too: a colorized image reduced to fit the limits (an image mask included) now keeps its alpha as an opacity mask rather than in its pixels, as at its full size. 8-bit and 1-bit samples, binary or in hex comment lines. A device space (`/DeviceGray XC` after a linked image's file name) and the ink `None` change nothing, as before; linked images (`XF`) keep their placeholder. Tests `import::ai::tests::separation_images_are_colorized_with_their_ink`, `separation_images_of_process_plates_and_registration`, `a_separation_image_with_hex_comment_lines_keeps_its_ink`, `a_reduced_separation_image_keeps_its_ink_and_its_alpha_as_a_mask` (#6350) | | 2026-11-09 | minor | PDF import: an image in a Separation color space (one ink, ISO 32000-1 §8.6.6.4) opens as a grayscale image of its tints (white where it lays no ink, black at 100 %, its Decode array applied; 1, 2, 4, 8 and 16 bits a sample, or one gray channel from its JPEG, CCITT, JBIG2 or JPEG 2000 decoder) colorized by its ink at 100 %: a spot ink's swatch, a process plate's CMYK color, or Registration. It used to open as an RGB image, losing the ink, and Fill and Recolor Artwork didn't reach its color. Its soft mask or mask becomes its opacity mask, as a CMYK image's does. The ink is found by the image's color space (kind, colorant name and tint transform), once per image. As with any colorized image, where it lays no ink the art under it shows, where the RGB image was opaque white. The ink `None`, which paints nothing, an ink whose color can't be told, an image inside an alpha soft mask, inline images and DeviceN images open as before; samples that can't be read (data cut short, a decoder's output of another layout) open in RGB with a warning, and the ink gets no swatch. Tests `colorize::a_separation_image_opens_colorized_by_its_ink`, `colorize::a_separation_image_reads_its_samples_and_decode_array`, `colorize::a_separation_image_of_a_process_plate_or_of_no_ink`, `colorize::a_separation_image_keeps_its_soft_mask`, `colorize::a_separation_image_drawn_twice_is_colorized_twice`, `colorize::a_separation_jpeg_opens_colorized_by_its_ink`, `colorize::a_separation_image_that_cant_be_read_stays_rgb` (#6314) | | 2026-11-09 | minor | PDF import: an image mask (`/ImageMask true`, ISO 32000-1 §8.9.6.2) painted in a solid color opens as a grayscale image of what it paints (black, the rest white) colorized by that color, as the `.ai` reader opens a 1-bit image mask (#5790). It used to open as an RGB image with the color in its pixels: Fill and Recolor Artwork didn't reach the color, a spot color lost its ink, and the same mask painted again in another color took the first color, in an uncolored pattern's cell too. Its color now counts toward the document's color mode, as a fill's does. A spot color is found by its color space, as a fill's is when the page's walk names no ink: two spaces of other inks with the same alternate and tint transform can't be told apart, and the color comes in as RGB. Inside an alpha soft mask, or painted with a pattern, a mask opens as before. Tests `colorize::an_image_mask_opens_colorized_by_its_spot_color`, `colorize::an_image_mask_drawn_in_two_colors_keeps_both`, `colorize::an_uncolored_pattern_drawing_an_image_mask_keeps_each_color` (#6313) | | 2026-11-09 | minor | PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged. Synthetic evidence: `crates/pdf/src/tests/actual_text.rs`. |