From ca59598cd7c29d76b8bd2af9304fb7bb13949905 Mon Sep 17 00:00:00 2001 From: jmverges Date: Mon, 9 Nov 2026 18:39:32 +0000 Subject: [PATCH] PDF import: Separation images keep their ink (#6314) An image in a Separation color space opened as an RGB image: its spot ink was lost (no plate in Separations Preview), and Fill and Recolor Artwork didn't reach its color. It now opens as a grayscale image of its tints (white where it lays no ink, black at 100 %, its Decode array applied; 1 to 16 bits a sample, or one gray channel from its JPEG, CCITT, JBIG2 or JPEG 2000 decoder), colorized by its ink at 100 %: the spot swatch, a process plate's CMYK color, or Registration. Its mask becomes its opacity mask, as a CMYK image's does. The ink is found by the image's color space among those the file defines, once per image. The ink None, an ink whose color can't be told, images inside an alpha soft mask, inline images and DeviceN images open as before; samples that can't be read open in RGB with a warning, and the ink gets no swatch. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/import/builder.rs | 12 +++ crates/pdf/src/import/device.rs | 19 +++++ crates/pdf/src/import/state.rs | 3 + crates/pdf/src/import_color.rs | 12 +++ crates/pdf/src/import_image.rs | 76 ++++++++++++++++- crates/pdf/src/tests/colorize.rs | 135 +++++++++++++++++++++++++++++++ docs/file-format-parity.md | 5 +- 7 files changed, 257 insertions(+), 5 deletions(-) diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index bc2474592..b16b49b03 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -26,6 +26,7 @@ impl<'p> Builder<'p> { images: HashMap::new(), image_keys: HashMap::new(), cmyk_keys: HashSet::new(), + separation_paints: HashMap::new(), opaque: HashSet::new(), warnings: vec![], fonts: HashMap::new(), @@ -582,6 +583,17 @@ impl<'p> Builder<'p> { Some(Paint::Pattern { pattern: name, xf }) } + /// Separation image `st` (`w` × `h` pixels) as the paint of its ink at 100 % (a spot swatch, a + /// process plate or Registration, found by its colour space) and its tints as gray pixels + /// ([`crate::import_image::tint_gray`]). `None` for another image, an ink whose colour can't + /// be told, or tints that can't be read (with a warning): the image opens in RGB. The ink is + /// only marked used once the tints are read. + pub(super) fn separation_image(&mut self, st: &craft_pdf::syntax::object::Stream<'_>, w: u32, h: u32) -> Option<(Paint, Vec)> { + let space = self.colors.array_space(&crate::import_image::separation_space(st)?)?; + let gray = crate::import_image::tint_gray(st, w, h).map_err(|why| self.warn(why)).ok()?; + Some((self.colors.native(space, &[1.0])?.paint(), gray)) + } + pub(super) fn add_image(&mut self, key: u128, make: impl FnOnce() -> Option<(ImageBlob, u32, u32)>, xf: Affine) { self.add_image_masked(key, make, xf, None, None); } diff --git a/crates/pdf/src/import/device.rs b/crates/pdf/src/import/device.rs index 49f7d6921..53da5a521 100644 --- a/crates/pdf/src/import/device.rs +++ b/crates/pdf/src/import/device.rs @@ -250,6 +250,25 @@ impl<'a> Device<'a> for Builder<'_> { let mask = if crate::import_image::has_mask(st) { self.alpha_mask(key, &r, w, h, transform) } else { None }; return self.add_image_masked(key, || cmyk.map(|blob| (blob, w, h)), transform, mask, None); } + // A Separation image keeps its ink: its tints as a grayscale image that the ink at + // 100 % colorizes, as a mask painted in the ink comes in. Its mask becomes an + // opacity mask, as a CMYK image's does. Read once per image: the decision is kept, + // so one that opens in RGB stays RGB, and the gray pixels have their own key. + if !white { + let found = match self.separation_paints.get(&key) { + Some(paint) => paint.clone().map(|p| (p, None)), + None => { + let read = self.separation_image(st, w, h); + self.separation_paints.insert(key, read.as_ref().map(|(p, _)| p.clone())); + read.map(|(p, gray)| (p, Some(gray))) + } + }; + if let Some((paint, gray)) = found { + let mask = if crate::import_image::has_mask(st) { self.alpha_mask(key, &r, w, h, transform) } else { None }; + let make = || gray.and_then(|g| gray_png(g, w, h)).map(|png| (ImageBlob::new("image/png", png), w, h)); + return self.add_image_masked(key ^ 0x5e9_a7e5, make, transform, mask, Some(paint)); + } + } // JPEG passthrough for plain DeviceRGB/DeviceGray DCT images. let dict = st.dict(); let filters = st.filters(); diff --git a/crates/pdf/src/import/state.rs b/crates/pdf/src/import/state.rs index 70ef449ea..0eb100b36 100644 --- a/crates/pdf/src/import/state.rs +++ b/crates/pdf/src/import/state.rs @@ -86,6 +86,9 @@ pub(super) struct Builder<'p> { pub(super) image_keys: HashMap, /// The images kept with their CMYK samples ([`crate::import_image`]). pub(super) cmyk_keys: HashSet, + /// Images in a Separation space, by key: the paint of their ink, or `None` when they open in + /// RGB (an ink that can't be told, tints that can't be read). + pub(super) separation_paints: HashMap>, /// Mask keys of CMYK images whose mask turned out to hide nothing. pub(super) opaque: HashSet, pub(super) warnings: Vec, diff --git a/crates/pdf/src/import_color.rs b/crates/pdf/src/import_color.rs index b7e033b6c..2dfac228d 100644 --- a/crates/pdf/src/import_color.rs +++ b/crates/pdf/src/import_color.rs @@ -239,6 +239,18 @@ impl<'p> Colors<'p> { } } + /// The space of colour space array `a` (`[/Separation name alt tint]` or `[/DeviceN …]`, such + /// as an image's) among the file's: matched by its kind, its colorant names and its tint + /// transform, not by the page's walk. `None` for another array. + pub fn array_space(&mut self, a: &Array<'_>) -> Option { + self.scan(); + let mut it = a.flex_iter(); + let (device_n, names) = ink_names(&mut it)?; + it.next::>()?; + let key = debug_text(&Function::new(&it.next::>()?)?)?; + self.spaces.iter().position(|s| s.device_n == device_n && s.names == names && s.key == key).map(Space::Inks) + } + /// Whether a space of inks `names` has the kind, colorant count and tint transform of a space /// of other inks. [`Colors::space_of`] tells such spaces apart only by the names from the /// page's walk. diff --git a/crates/pdf/src/import_image.rs b/crates/pdf/src/import_image.rs index cfda8a494..b5875e9bd 100644 --- a/crates/pdf/src/import_image.rs +++ b/crates/pdf/src/import_image.rs @@ -3,11 +3,12 @@ //! convention JPEG readers and the PDF writer take a CMYK JPEG to follow) is kept as it is; any //! other is decoded, its Decode array applied, and stored as a CMYK TIFF //! ([`ImageBlob::cmyk_tiff`]). A mask (`/SMask`, `/Mask`) doesn't change that: the importer -//! carries it as the image's opacity mask ([`has_mask`]). Every other image is read as RGB by the -//! interpreter. +//! carries it as the image's opacity mask ([`has_mask`]). A Separation image keeps its ink: its +//! tints are read as a grayscale image ([`tint_gray`]) that the ink colorizes. Every other image is +//! read as RGB by the interpreter. use craft_pdf::syntax::Filter; -use craft_pdf::syntax::object::stream::ImageDecodeParams; +use craft_pdf::syntax::object::stream::{ImageColorSpace, ImageDecodeParams}; use craft_pdf::syntax::object::{Array, Dict, Name, Stream}; use vectorcraft_doc::ImageBlob; use vectorcraft_doc::cmyk::{Inks, MAX_CMYK_PIXELS, jpeg_components}; @@ -90,6 +91,75 @@ pub(crate) fn cmyk(st: &Stream<'_>, width: u32, height: u32) -> Result(st: &Stream<'a>) -> Option> { + let d = st.dict(); + let a = d.get::>(key(d, b"ColorSpace", b"CS"))?; + let one_ink = { + let mut it = a.flex_iter(); + it.next::>()?.as_ref() == b"Separation" && it.next::>()?.as_ref() != b"None" + }; + one_ink.then_some(a) +} + +/// The tints of the one-component image `st` (`width` × `height` pixels, a Separation image's) +/// as a grayscale image: white where it lays no ink, black at 100 %, its Decode array applied. +/// `Err` (a warning) when its samples can't be read. +pub(crate) fn tint_gray(st: &Stream<'_>, width: u32, height: u32) -> Result, &'static str> { + let d = st.dict(); + let bpc = d.get::(key(d, b"BitsPerComponent", b"BPC")).unwrap_or(8); + if !matches!(bpc, 1 | 2 | 4 | 8 | 16) { + return Err(UNREAD_TINTS); + } + // The same cap as CMYK images'. + let n = u64::from(width) * u64::from(height); + if n == 0 || n > MAX_CMYK_PIXELS { + return Err(UNREAD_TINTS); + } + let (lo, hi) = match d.get::>(key(d, b"Decode", b"D")).map(|a| a.iter::().collect::>()).as_deref() { + Some(&[lo, hi]) if lo.is_finite() && hi.is_finite() => (lo, hi), + _ => (0.0, 1.0), + }; + let params = ImageDecodeParams { is_indexed: false, bpc: Some(bpc), num_components: Some(1), target_dimension: None, width, height }; + let decoded = st.decoded_image(¶ms).map_err(|_| UNREAD_TINTS)?; + // An image codec (JPEG, CCITT, JBIG2, JPEG 2000) says what it decoded to: one gray channel of + // the image's size, at its own bit depth. + let bpc = match &decoded.image_data { + None => bpc, + Some(i) + if i.alpha.is_none() + && matches!(i.color_space, Some(ImageColorSpace::Gray)) + && (i.width, i.height) == (width, height) + && matches!(i.bits_per_component, 1 | 2 | 4 | 8 | 16) => + { + i.bits_per_component + } + Some(_) => return Err(UNREAD_TINTS), + }; + let bits = usize::from(bpc); + let row = (width as usize * bits).div_ceil(8); + let data = decoded.data.get(..row * height as usize).ok_or(UNREAD_TINTS)?; + let max = ((1u32 << bits) - 1) as f32; + let mut out = Vec::with_capacity(n as usize); + for line in data.chunks_exact(row) { + for x in 0..width as usize { + let v = match bits { + 16 => line.get(x * 2..x * 2 + 2).map_or(0, |s| s.iter().fold(0u32, |v, b| (v << 8) | u32::from(*b))), + 8 => line.get(x).map_or(0, |b| u32::from(*b)), + // 1, 2 or 4 bits a sample, packed from the high bits of each byte. + _ => line.get(x * bits / 8).map_or(0, |b| (u32::from(*b) >> (8 - bits - (x * bits) % 8)) & ((1 << bits) - 1)), + }; + let tint = (lo + v as f32 / max * (hi - lo)).clamp(0.0, 1.0); + out.push(((1.0 - tint) * 255.0).round() as u8); + } + } + Ok(out) +} + /// Does the image carry its own transparency (a soft mask, a stencil mask or a colour key)? pub(crate) fn has_mask(st: &Stream<'_>) -> bool { let d = st.dict(); diff --git a/crates/pdf/src/tests/colorize.rs b/crates/pdf/src/tests/colorize.rs index 9a2c7c83e..2e4469dbb 100644 --- a/crates/pdf/src/tests/colorize.rs +++ b/crates/pdf/src/tests/colorize.rs @@ -297,3 +297,138 @@ fn an_uncolored_pattern_drawing_an_image_mask_keeps_each_color() { } assert_eq!(colors, [["#ff0000"], ["#0000ff"]]); } + +const SPOT_RED: &str = "[/Separation /SpotRed /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 1 0] /N 1 >>]"; + +/// A page drawing a `w` × 1 image in color space `space` (`bpc` bits a sample, more dictionary +/// entries `more`, samples `hex`), then the `extra` objects after it. +fn separation_page(space: &str, w: u32, bpc: u8, more: &str, hex: &str, extra: &[&str]) -> Document { + separation_report(space, w, bpc, more, hex, extra, "q 80 0 0 10 10 50 cm /Im1 Do Q").document +} + +/// [`separation_page`] drawn with `content`, with the import's report. `more` may name filters +/// after ASCIIHex (`/Filter [/ASCIIHexDecode …]`). +fn separation_report(space: &str, w: u32, bpc: u8, more: &str, hex: &str, extra: &[&str], content: &str) -> ImportReport { + let filter = if more.contains("/Filter") { "" } else { "/Filter /ASCIIHexDecode" }; + let image = format!( + "<< /Type /XObject /Subtype /Image /Width {w} /Height 1 /ColorSpace {space} /BitsPerComponent {bpc} {more} {filter} /Length {} >>\nstream\n{hex}>\nendstream", + hex.len() + 1 + ); + let resources = format!("/XObject << /Im1 {} 0 R >>", first_extra(1)); + let mut objects = vec![image.as_str()]; + objects.extend_from_slice(extra); + let pdf = pdf_with(&[PdfPage { resources, ..PdfPage::new(100.0, 100.0, content) }], &objects, None); + import_with_report(&pdf, &ImportOptions::default()).unwrap() +} + +/// An image in a spot ink's Separation space opens as a grayscale image of its tints, colorized by +/// the ink's spot swatch at 100 %. +#[test] +fn a_separation_image_opens_colorized_by_its_ink() { + let d = separation_page(SPOT_RED, 4, 8, "", "0055AAFF", &[]); + let images = imported_images(&d); + assert_eq!(images.len(), 1); + let (paint, gray) = &images[0]; + assert_eq!(gray, &[255, 170, 85, 0], "the tints 0, 1/3, 2/3 and 1"); + let Some(Paint::Solid { swatch, tint, .. }) = paint else { panic!("colorized: {paint:?}") }; + assert_eq!((swatch.as_deref(), *tint), (Some("SpotRed"), 1.0)); + assert!(d.swatch("SpotRed").is_some_and(|s| s.spot), "a spot swatch"); +} + +/// Samples of 1, 4 and 16 bits are unpacked, and the Decode array applied. +#[test] +fn a_separation_image_reads_its_samples_and_decode_array() { + for (bpc, w, more, hex, want) in [ + (1, 8, "/Decode [1 0]", "C3", vec![255, 255, 0, 0, 0, 0, 255, 255]), + (4, 3, "", "0F80", vec![255, 0, 119]), + (16, 2, "", "0000FFFF", vec![255, 0]), + ] { + let d = separation_page(SPOT_RED, w, bpc, more, hex, &[]); + let images = imported_images(&d); + assert_eq!(images.len(), 1, "{bpc} bits"); + assert_eq!(images[0].1, want, "{bpc} bits"); + assert!(images[0].0.is_some(), "{bpc} bits: colorized"); + } +} + +/// A Separation of a process plate colorizes in that plate's CMYK color; the ink `None`, which +/// paints nothing, is left to the interpreter. +#[test] +fn a_separation_image_of_a_process_plate_or_of_no_ink() { + let black = "[/Separation /Black /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0 0 1] /N 1 >>]"; + let d = separation_page(black, 2, 8, "", "00FF", &[]); + let images = imported_images(&d); + assert_eq!(images.len(), 1); + assert_eq!(images[0].0, Some(Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 1.0)))); + assert!(d.swatches.iter().all(|s| !s.spot), "no spot swatch for a process plate"); + let none = "[/Separation /None /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 1 0] /N 1 >>]"; + let d = separation_page(none, 2, 8, "", "00FF", &[]); + let mut colorized = vec![]; + for l in &d.layers { + l.walk(&mut |n| { + if matches!(n.kind, NodeKind::Image(_)) { + colorized.push(n.extras().colorize.is_some()); + } + }); + } + assert!(colorized.iter().all(|c| !c), "{colorized:?}"); +} + +/// A Separation image's soft mask becomes its opacity mask. +#[test] +fn a_separation_image_keeps_its_soft_mask() { + let smask = "<< /Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 9 >>\nstream\nFF00FF00>\nendstream"; + let d = separation_page(SPOT_RED, 4, 8, &format!("/SMask {} 0 R", first_extra(1) + 1), "0055AAFF", &[smask]); + let mut found = vec![]; + for l in &d.layers { + l.walk(&mut |n| { + if matches!(n.kind, NodeKind::Image(_)) && n.extras().colorize.is_some() { + found.push(n.extras().mask.is_some()); + } + }); + } + assert_eq!(found, [true]); +} + +/// The same Separation image drawn twice is colorized twice, over the same pixels. +#[test] +fn a_separation_image_drawn_twice_is_colorized_twice() { + let d = separation_report(SPOT_RED, 4, 8, "", "0055AAFF", &[], "q 80 0 0 10 10 20 cm /Im1 Do Q q 80 0 0 10 10 60 cm /Im1 Do Q").document; + let images = imported_images(&d); + assert_eq!(images.len(), 2); + assert_eq!(images[0], images[1]); + assert!(images[0].0.is_some(), "colorized"); +} + +/// A one-component JPEG in a Separation space is read through its decoder. +#[test] +fn a_separation_jpeg_opens_colorized_by_its_ink() { + let mut jpeg = vec![]; + jpeg_encoder::Encoder::new(&mut jpeg, 100).encode(&[0, 0, 255, 255, 0, 0, 255, 255], 8, 1, jpeg_encoder::ColorType::Luma).unwrap(); + let hex: String = jpeg.iter().map(|b| format!("{b:02X}")).collect(); + let d = separation_page(SPOT_RED, 8, 8, "/Filter [/ASCIIHexDecode /DCTDecode]", &hex, &[]); + let images = imported_images(&d); + assert_eq!(images.len(), 1); + assert!(images[0].0.is_some(), "colorized"); + // Sample 0 lays no ink (white), 255 lays it all (black); JPEG blurs the edges a little. + let gray = &images[0].1; + assert!(gray[0] > 200 && gray[2] < 55 && gray[4] > 200 && gray[6] < 55, "{gray:?}"); +} + +/// A Separation image whose tints can't be read (its data cut short) opens in RGB each time it is +/// drawn, with one warning and no swatch for its ink. +#[test] +fn a_separation_image_that_cant_be_read_stays_rgb() { + let r = separation_report(SPOT_RED, 4, 8, "", "0055", &[], "q 80 0 0 10 10 20 cm /Im1 Do Q q 80 0 0 10 10 60 cm /Im1 Do Q"); + let mut colorized = vec![]; + for l in &r.document.layers { + l.walk(&mut |n| { + if matches!(n.kind, NodeKind::Image(_)) { + colorized.push(n.extras().colorize.is_some()); + } + }); + } + assert_eq!(colorized, [false, false]); + assert!(r.document.swatches.iter().all(|s| !s.spot), "no swatch for an ink nothing uses"); + assert_eq!(r.warnings.iter().filter(|w| w.contains("spot ink")).count(), 1, "{:?}", r.warnings); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5e55cc6df..9861de2ae 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 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 (PDF import: Separation images keep their ink, #6314; 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 @@ -280,7 +280,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | VectorCraft `.vectorcraft` / `.vctemplate` | — | yes | yes | lossless JSON, versioned, save down to v1; property-tested round trips (`format/tests/prop_format.rs`); documents with a lot of path data are written as compact JSON, about a fifth of the size (#1492); a save falls back to a plain fsync and to writing elsewhere and copying in when the folder refuses the usual steps (#1625, #1649, part of #1620; not confirmed on a real network share); the size limits on embedded data also apply to files read from memory (#1783), and saving over a private file on Unix leaves no readable temporary copy beside it (#1778); a dynamic symbol's type (`dynamic` on the symbol) and an instance's appearance changes to its art (`symbol_overrides`, each path at most 64 deep, counted against the object budget) are written only when set, so documents without them save as before (#4938) | | Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) | | Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure with template layers and ruler guides, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus | -| PDF | open, place, save | yes (layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import still needs ActualText support (#6165); separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | +| PDF | open, place, save | yes (layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import still needs ActualText support (#6165); separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. | | EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined Unicode CMap (`UniJIS-UCS2-H`), set as type in the font of the CIDFont's name; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | | SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`) | @@ -320,6 +320,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | Date | Change | Summary | |---|---|---| +| 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 marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged. Decoded stream and placement regressions; reader ActualText (#6165), styled clusters and external viewer validation remain separate. | | 2026-11-07 | minor | EPS and `.ai` editing data: when the page's type is matched with the text objects, a glyph the page's font encoding names with a private-use character (a ligature such as `uniE028`, which says nothing of the letters it stands for) splits the page's word there, and the pieces are matched as words. A ligature in "Quality" no longer reads "Qualy" against the text object: four users' Illustrator 30 EPS files (used locally, not committed) lose the false "some of the page's type has no text object" note, their renders unchanged |