From def928c0dc37ac61da43f68e7806d0beaf8f27b1 Mon Sep 17 00:00:00 2001 From: jmverges Date: Mon, 9 Nov 2026 14:43:08 +0000 Subject: [PATCH] PDF import: image masks open colorized by the color they are painted in (#6313) A PDF image mask (`/ImageMask true`) painted in a solid color opened 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 one, because the painted image was cached by the mask alone, and so did an uncolored pattern whose cell draws a mask. It now opens as a grayscale image of what it paints, colorized by that color, as the .ai reader opens a 1-bit image mask (#5790). A spot color keeps its swatch and tint. The page's walk counts paths, not images, so the ink is found from the color space. Readings of a pattern cell are told apart by colorized images' colors too. Inside an alpha soft mask, or painted with a pattern, a mask opens as before. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/import/builder.rs | 6 ++- crates/pdf/src/import/device.rs | 43 +++++++++++++----- crates/pdf/src/import/state.rs | 15 +++++- crates/pdf/src/tests/colorize.rs | 78 ++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 5 +- 5 files changed, 131 insertions(+), 16 deletions(-) diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index 86a4a89a3..bc2474592 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -583,7 +583,7 @@ impl<'p> Builder<'p> { } 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); + self.add_image_masked(key, make, xf, None, None); } /// An image's alpha (its soft mask, stencil mask or colour key, at its own resolution) as a @@ -626,12 +626,15 @@ impl<'p> Builder<'p> { Some(OpacityMask::new(art, true)) } + /// An image of key `key` (its blob made by `make` the first time), placed by `xf`, with opacity + /// mask `mask` and the solid paint that colorizes it (`colorize`, for a grayscale image). pub(super) fn add_image_masked( &mut self, key: u128, make: impl FnOnce() -> Option<(ImageBlob, u32, u32)>, xf: Affine, mask: Option, + colorize: Option, ) { let (k, w, h) = if let Some(v) = self.image_keys.get(&key).cloned() { v @@ -652,6 +655,7 @@ impl<'p> Builder<'p> { NodeKind::Image(Box::new(ImageObject { key: k, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None })), ); n.extras_mut().mask = mask.map(Box::new); + n.extras_mut().colorize = colorize.map(Box::new); self.push_node(n); } diff --git a/crates/pdf/src/import/device.rs b/crates/pdf/src/import/device.rs index 84d8885a0..49f7d6921 100644 --- a/crates/pdf/src/import/device.rs +++ b/crates/pdf/src/import/device.rs @@ -1,7 +1,7 @@ use super::entry::{INVISIBLE_TEXT_NOTE, MAX_NESTED, OUTLINED_TEXT_NOTE, UNSORTED, WHITE_KEY}; use super::state::{ - Builder, Decoded, Frame, FrameKind, GlyphRun, blend, fill_rule, line_width, mean_scale, resize_alpha, rgba_png, same_outline, stroke_layer, - stroke_outline, + Builder, Decoded, Frame, FrameKind, GlyphRun, blend, fill_rule, gray_png, line_width, mean_scale, resize_alpha, rgba_png, same_outline, + stroke_layer, stroke_outline, }; use super::{ Affine, Appearance, AppearanceItem, Arc, BezPath, BlendMode, ClipPath, Device, FillLayer, FillRule, Glyph, GlyphDrawMode, Image, ImageBlob, @@ -248,7 +248,7 @@ impl<'a> Device<'a> for Builder<'_> { // Its mask (read by the interpreter, whatever its kind) becomes an opacity // mask, so the inks stay as they are. 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); + return self.add_image_masked(key, || cmyk.map(|blob| (blob, w, h)), transform, mask, None); } // JPEG passthrough for plain DeviceRGB/DeviceGray DCT images. let dict = st.dict(); @@ -290,22 +290,43 @@ impl<'a> Device<'a> for Builder<'_> { Image::Stencil(s) => { let white = self.white_images; let key = craft_pdf::interpret::CacheKey::cache_key(&s) ^ if white { WHITE_KEY } else { 0 }; + // A mask painted in a solid color comes in as a grayscale image of what it paints + // (black, the rest white) colorized by that color, as the `.ai` reader opens an + // image mask: Fill and Recolor Artwork reach the color, a spot color keeps its ink, + // and the mask drawn again in another color keeps that color. Inside an alpha soft + // mask, or painted with a pattern (which isn't drawn), its pixels are white or black. let mut decoded: Option = None; + let mut colorize = None; s.with_stencil( |luma, paint| { - let rgb = match paint { - _ if white => [255, 255, 255, 255], - craft_pdf::interpret::Paint::Color(c) => c.to_rgba().to_rgba8(), - _ => [0, 0, 0, 255], + let px = match paint { + craft_pdf::interpret::Paint::Color(_) if !white => { + // The walk doesn't count images: the ink is found by its color space. + colorize = Some(self.paint(paint, false, None).0); + luma.data.iter().map(|a| u8::MAX - a).collect() + } + _ => { + let v = if white { u8::MAX } else { 0 }; + luma.data.iter().flat_map(|a| [v, v, v, *a]).collect() + } }; - let rgba = luma.data.iter().flat_map(|a| [rgb[0], rgb[1], rgb[2], *a]).collect(); - decoded = Some((rgba, luma.width, luma.height, luma.scale_factors)); + decoded = Some((px, luma.width, luma.height, luma.scale_factors)); }, None, ); - let Some((rgba, w, h, sf)) = decoded else { return }; + let Some((px, w, h, sf)) = decoded else { return }; let xf = transform * Affine::scale_non_uniform(sf.0 as f64, sf.1 as f64); - self.add_image(key ^ 0x5_7e9c, || rgba_png(rgba, w, h).map(|png| (ImageBlob::new("image/png", png), w, h)), xf); + match colorize { + // Its own key: its pixels are the gray ones, whatever color paints it. + Some(paint) => self.add_image_masked( + key ^ 0x6a7_57e9c, + || gray_png(px, w, h).map(|png| (ImageBlob::new("image/png", png), w, h)), + xf, + None, + Some(paint), + ), + None => self.add_image(key ^ 0x5_7e9c, || rgba_png(px, w, h).map(|png| (ImageBlob::new("image/png", png), w, h)), xf), + } } } } diff --git a/crates/pdf/src/import/state.rs b/crates/pdf/src/import/state.rs index abf7499d0..70ef449ea 100644 --- a/crates/pdf/src/import/state.rs +++ b/crates/pdf/src/import/state.rs @@ -273,11 +273,15 @@ pub(crate) fn round3(v: f32) -> f32 { (v * 1000.0).round() / 1000.0 } -/// The paints of `nodes`' leaves, in order: what tells two readings of a pattern cell apart. +/// The paints of `nodes`' leaves, in order, colorized images' colors included: what tells two +/// readings of a pattern cell apart. pub(super) fn paints(nodes: &[Arc]) -> Vec { let mut out = vec![]; for n in nodes { - n.walk(&mut |c| out.extend(c.appearance.items.iter().map(|i| i.paint().clone()))); + n.walk(&mut |c| { + out.extend(c.appearance.items.iter().map(|i| i.paint().clone())); + out.extend(c.extras().colorize.as_deref().cloned()); + }); } out } @@ -294,6 +298,13 @@ pub(super) fn rgba_png(rgba: Vec, w: u32, h: u32) -> Option> { Some(out) } +pub(super) fn gray_png(gray: Vec, w: u32, h: u32) -> Option> { + let img = image::GrayImage::from_raw(w, h, gray)?; + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; + Some(out) +} + pub(super) fn resize_alpha(a: &LumaData, w: u32, h: u32) -> Vec { if a.width == w && a.height == h { return a.data.clone(); diff --git a/crates/pdf/src/tests/colorize.rs b/crates/pdf/src/tests/colorize.rs index 5ec2e813a..9a2c7c83e 100644 --- a/crates/pdf/src/tests/colorize.rs +++ b/crates/pdf/src/tests/colorize.rs @@ -1,5 +1,6 @@ //! Grayscale and 1-bit images colorized by a fill color are written in that color, a spot color //! as its ink, with the image's coverage as the soft mask. +//! An image mask in a PDF opens as a grayscale image colorized by the color it is painted with. use std::sync::Arc; @@ -8,6 +9,7 @@ use craft_pdf::syntax::object::{Array, Name, Object, Stream}; use vectorcraft_color::{Color, Paint, Swatch}; use vectorcraft_doc::{ColorMode, Document, ImageBlob, ImageObject, Node, NodeKind}; use vectorcraft_geom::Affine; +use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with}; use crate::*; @@ -219,3 +221,79 @@ fn ink_options_naming_no_ink_give_one_short_warning() { assert_eq!(warnings.len(), 1); assert!(warnings[0].contains("`Missing 0`") && warnings[0].contains("and 9990 more") && warnings[0].len() < 500, "{}", warnings[0]); } + +/// An 8 × 1 image mask whose 0 bits (columns 2 to 5) are painted. +const MASK: &str = "<< /Type /XObject /Subtype /Image /Width 8 /Height 1 /ImageMask true /BitsPerComponent 1 /Filter /ASCIIHexDecode /Length 3 >>\nstream\nC3>\nendstream"; + +/// A page drawing [`MASK`] (`/Im1`) with `content`, with a spot ink `SpotRed` as `/CS0`. +fn mask_page(content: &str) -> Vec { + let spot = "[/Separation /SpotRed /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 1 0] /N 1 >>]"; + let resources = format!("/ColorSpace << /CS0 {spot} >> /XObject << /Im1 {} 0 R >>", first_extra(1)); + pdf_with(&[PdfPage { resources, ..PdfPage::new(200.0, 100.0, content) }], &[MASK], None) +} + +/// The imported images, in paint order: their colorize paint and gray pixels. +fn imported_images(d: &Document) -> Vec<(Option, Vec)> { + let mut out = vec![]; + for l in &d.layers { + l.walk(&mut |n| { + if let NodeKind::Image(im) = &n.kind { + let blob = d.images.get(&im.key).unwrap(); + let gray = image::load_from_memory(&blob.bytes).unwrap(); + assert_eq!(gray.color(), image::ColorType::L8, "a grayscale image"); + out.push((n.extras().colorize.as_deref().cloned(), gray.into_luma8().into_raw())); + } + }); + } + out +} + +/// What the mask paints is black, the rest white; a spot color keeps its swatch and tint. +#[test] +fn an_image_mask_opens_colorized_by_its_spot_color() { + let d = crate::import(&mask_page("/CS0 cs 0.5 scn q 80 0 0 10 10 50 cm /Im1 Do Q")).unwrap(); + let images = imported_images(&d); + assert_eq!(images.len(), 1); + let (paint, gray) = &images[0]; + assert_eq!(gray, &[255, 255, 0, 0, 0, 0, 255, 255]); + let Some(Paint::Solid { swatch, tint, .. }) = paint else { panic!("colorized: {paint:?}") }; + assert_eq!((swatch.as_deref(), *tint), (Some("SpotRed"), 0.5)); + assert!(d.swatch("SpotRed").is_some_and(|s| s.spot), "a spot swatch"); +} + +/// A mask drawn twice keeps each color it is painted with, over the same pixels. +#[test] +fn an_image_mask_drawn_in_two_colors_keeps_both() { + let d = crate::import(&mask_page("1 0 0 rg q 80 0 0 10 10 20 cm /Im1 Do Q 0 0 1 rg q 80 0 0 10 10 60 cm /Im1 Do Q")).unwrap(); + let images = imported_images(&d); + let hex: Vec<_> = images.iter().map(|(p, _)| p.as_ref().and_then(Paint::color).map(|c| c.to_hex())).collect(); + assert_eq!(hex, [Some("#ff0000".to_string()), Some("#0000ff".to_string())]); + assert_eq!(images[0].1, images[1].1); +} + +/// An uncolored tiling pattern whose cell draws a mask, filled in red and then in blue, gives a +/// pattern for each color: the readings of its cell differ by the mask's color. +#[test] +fn an_uncolored_pattern_drawing_an_image_mask_keeps_each_color() { + let (mask, pattern) = (first_extra(1), first_extra(1) + 1); + let resources = format!("/ColorSpace << /CS1 [/Pattern /DeviceRGB] >> /Pattern << /P0 {pattern} 0 R >>"); + let cell = format!( + "<< /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << /XObject << /Im1 {mask} 0 R >> >> /Length 28 >>\nstream\nq 10 0 0 10 0 0 cm /Im1 Do Q\nendstream" + ); + let content = "/CS1 cs 1 0 0 /P0 scn 0 0 100 40 re f 0 0 1 /P0 scn 0 50 100 40 re f"; + let d = crate::import(&pdf_with(&[PdfPage { resources, ..PdfPage::new(100.0, 100.0, content) }], &[MASK, &cell], None)).unwrap(); + let mut colors = vec![]; + for l in &d.layers { + l.walk(&mut |n| { + for item in &n.appearance.items { + let Paint::Pattern { pattern, .. } = item.paint() else { continue }; + let mut hex = vec![]; + for a in &d.pattern(pattern).unwrap().art { + a.walk(&mut |c| hex.extend(c.extras().colorize.as_deref().and_then(Paint::color).map(|c| c.to_hex()))); + } + colors.push(hex); + } + }); + } + assert_eq!(colors, [["#ff0000"], ["#0000ff"]]); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9b86156bf..d91a63a0b 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-07 · **Change:** minor (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: 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) | +| 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) | | 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 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-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 | | 2026-11-07 | minor | EPS import: a CID-keyed font over a predefined Unicode CMap the file doesn't define (Adobe's public CMap resources named `Uni…-UCS2`, `-UTF8`, `-UTF16` or `-UTF32`, `-H` or `-V`: [cmap-resources](https://github.com/adobe-type-tools/cmap-resources)), found by its `CIDFont-CMap` name (`/Ryumin-Light-UniJIS-UCS2-H findfont`, the usual name of a CID-keyed font: its CIDFont's and CMap's names joined by a hyphen) or made with `composefont`, sets its codes' characters as live type in the font of its CIDFont's name, and `stringwidth` measures them. Such type was read as Latin-1 codes, or stopped the program at `composefont`. Vertical CMaps are set horizontally, and the show operators' extra spacing isn't kept; CIDFonts over other CMaps the file doesn't carry still draw nothing, with a warning. | | 2026-11-07 | minor | EPS import: CMap mappings to base fonts (`beginbfchar` … `endbfchar`, `beginbfrange` … `endbfrange` after `usefont`: [Adobe Technical Note #5099](https://adobe-type-tools.github.io/font-tech-notes/pdfs/5099.CMapResources.pdf); PLRM 3rd ed., §5.11.4) are read instead of dropped: a code mapped to a character code (a range's codes counting up from its last byte) or to a glyph name (one, or an array of them) draws that glyph of the base font it names, a Type 3, Type 42 or embedded Type 1 font's own glyph or, in a font found by name, its character as type. Such codes took font 0's CID 0 and drew nothing. |