From 3b327d9512fb287a5e6ff6d69954febbb9a3a2af Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 15 Oct 2026 04:21:03 -0700 Subject: [PATCH] EPS import: CMYK images keep their ink amounts The PostScript interpreter that reads EPS and PostScript files turned the samples of a CMYK image into display RGB and stored an RGBA PNG, so the image lost its ink amounts. The DCTDecode filter decoded a CMYK JPEG to RGB, so it lost its ink amounts too. The PDF importer keeps the ink amounts of 8- and 16-bit CMYK images (in a CMYK TIFF, or the CMYK JPEG itself). A CMYK image (image or colorimage in DeviceCMYK, CIEBasedDEFG or a four-component ICCBased space) now keeps its samples in a CMYK TIFF (ImageBlob::cmyk_tiff), read through its Decode array, at every bit depth the interpreter reads. A CMYK JPEG gives its samples as stored, and the image's Decode array reads them: [1 0 1 0 1 0 1 0] undoes the inverted samples of Adobe's CMYK JPEGs. The pixels that a MaskColor or an ImageType 3 mask hides become a grayscale opacity mask over the image, as in PDF import. A mask that hides nothing adds no mask. Gray, RGB, indexed and spot color images and image masks stay RGBA PNGs. Docs: docs/file-format-parity.md, docs/target-app-parity.md. --- .../engine/src/cmd/fileio/tests_epsimport.rs | 28 ++++ crates/engine/tests/import_fuzz.rs | 41 ++++++ crates/eps/src/import/data.rs | 81 ++++++++++-- crates/eps/src/import/tests_images.rs | 121 ++++++++++++++++-- docs/file-format-parity.md | 2 +- docs/target-app-parity.md | 1 + 6 files changed, 248 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/cmd/fileio/tests_epsimport.rs b/crates/engine/src/cmd/fileio/tests_epsimport.rs index 1f7488cf8..26b247166 100644 --- a/crates/engine/src/cmd/fileio/tests_epsimport.rs +++ b/crates/engine/src/cmd/fileio/tests_epsimport.rs @@ -65,6 +65,34 @@ fn other_eps_files_are_read_by_the_postscript_interpreter() { assert_eq!(s.doc().unwrap().title(), "plain.eps"); } +/// A CMYK image the PostScript interpreter reads keeps its ink amounts when the EPS opens and when +/// it is placed, with its transparent pixel as its opacity mask. +#[test] +fn cmyk_images_keep_their_inks_when_an_eps_opens_or_is_placed() { + let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 60 20\n%%EndComments\n/DeviceCMYK setcolorspace 60 20 scale \ + << /ImageType 4 /Width 2 /Height 1 /BitsPerComponent 8 /Decode [0 1 0 1 0 1 0 1] /ImageMatrix [2 0 0 -1 0 1] \ + /MaskColor [10 20 30 40] /DataSource <0a141e28 00667f08> >> image\nshowpage\n%%EOF\n"; + // Each image object's ink amounts (`None` when it isn't CMYK) and whether it has a mask. + let images = |s: &Session| { + let d = &s.doc().unwrap().doc; + let mut found = vec![]; + d.walk(|n| { + if let vectorcraft_doc::NodeKind::Image(im) = &n.kind { + found.push((d.images[&im.key].cmyk().map(|i| i.data), n.extras().mask.is_some())); + } + }); + found + }; + let want = (Some(vec![10, 20, 30, 40, 0, 102, 127, 8]), true); + let mut s = Session::new(); + let r = open(&mut s, "cmyk.eps", eps).unwrap(); + assert_eq!(r["format"], "eps", "{r}"); + assert_eq!(images(&s), std::slice::from_ref(&want)); + let r = s.execute("file.place", &json!({"name": "cmyk.eps", "dataBase64": vectorcraft_format::base64_encode(eps)})).unwrap(); + assert_eq!(r["ids"].as_array().unwrap().len(), 1, "{r}"); + assert_eq!(images(&s), [want.clone(), want]); +} + #[test] fn an_unreadable_eps_file_comes_in_as_its_preview() { let mut s = Session::new(); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 6c44473e5..b60942e50 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1503,6 +1503,45 @@ proptest! { }); r.map_err(|msg| TestCaseError::fail(format!("placing hostile PostScript panicked: {msg}")))?; } + + /// Damaged or hostile CMYK images (a CMYK JPEG, an image with a key color): read as images, + /// or as an error. + #[test] + fn eps_cmyk_images_never_panic(cut in 0usize..2_500, edits in prop::collection::vec((0usize..2_500, prop::sample::select(vec!['0', '9', 'f', 'a', ' ', '\n', '<', '>', '/', '[', ']', '(', ')', '-'])), 0..12)) { + let text = mutate_text(&cmyk_images_eps(), cut, &edits); + survive("mutated EPS CMYK images", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?; + } +} + +/// An EPS with CMYK images that the PostScript interpreter reads: a CMYK JPEG through `DCTDecode` +/// (Adobe's inverted samples), and an image whose key color hides a pixel. +fn cmyk_images_eps() -> String { + use vectorcraft_render::encode::jpeg::{ColorModel, JpegOptions, encode}; + let o = JpegOptions { color_model: ColorModel::Cmyk, embed_icc: false, ..JpegOptions::default() }; + let jpeg = encode(&[0, 102, 255, 0].repeat(16), 4, 4, 90, None, &o).unwrap(); + let hex: String = jpeg.iter().map(|b| format!("{b:02x}")).collect(); + format!( + "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n/DeviceCMYK setcolorspace gsave 40 40 scale \ + << /ImageType 1 /Width 4 /Height 4 /BitsPerComponent 8 /Decode [1 0 1 0 1 0 1 0] /ImageMatrix [4 0 0 -4 0 4] \ + /DataSource currentfile /ASCIIHexDecode filter /DCTDecode filter >> image\n{hex}>\ngrestore 50 50 translate 40 40 scale \ + << /ImageType 4 /Width 2 /Height 1 /BitsPerComponent 8 /Decode [0 1 0 1 0 1 0 1] /ImageMatrix [2 0 0 -1 0 1] \ + /MaskColor [10 20 30 40] /DataSource <0a141e28 00667f08> >> image\nshowpage\n%%EOF\n" + ) +} + +/// [`cmyk_images_eps`] as it is: both images keep their ink amounts, the keyed one with its mask. +#[test] +fn the_cmyk_images_fixture_reads_its_inks_and_mask() { + let r = vectorcraft_eps::import(cmyk_images_eps().as_bytes()).unwrap(); + assert!(r.warnings.is_empty() && !r.preview, "{:?}", r.warnings); + let d = &r.document; + let mut found = vec![]; + d.walk(|n| { + if let vectorcraft_doc::NodeKind::Image(im) = &n.kind { + found.push((d.images[&im.key].cmyk().map(|i| i.data.len()), n.extras().mask.is_some())); + } + }); + assert_eq!(found, [(Some(64), false), (Some(8), true)]); } /// [`eps_sample`] as another app would write it: without the document it carries, so its @@ -1561,6 +1600,8 @@ fn arb_ps_token() -> impl Strategy { // Masked images. "<< /ImageType 3 /InterleaveType 1 /DataDict << /ImageType 1 /Width 2 /Height 2 /BitsPerComponent 8 /ImageMatrix [2 0 0 2 0 0] /DataSource (abcdefghijklmnop) >> /MaskDict << /ImageType 1 /Width 2 /Height 2 /BitsPerComponent 1 /ImageMatrix [2 0 0 2 0 0] >> >>", "<< /ImageType 3 /InterleaveType 2 /DataDict << /ImageType 1 /Width 3 /Height 1 /BitsPerComponent 8 /ImageMatrix [3 0 0 1 0 0] /DataSource (abcdefghijklmnop) >> /MaskDict << /ImageType 1 /Width 3 /Height 5 /BitsPerComponent 1 /ImageMatrix [3 0 0 5 0 0] >> >>", + "<< /ImageType 4 /Width 2 /Height 2 /BitsPerComponent 8 /Decode [0 1 0 1 0 1 0 1] /ImageMatrix [2 0 0 2 0 0] /MaskColor [97 98 99 100] /DataSource (abcdefghijklmnop) >>", + "2 2 8 [2 0 0 2 0 0] (abcdefghijklmnop) false 4 colorimage", // Type 3 fonts and the show operators that space their glyphs. "/T3 << /FontType 3 /FontMatrix [0.1 0 0 0.1 0 0] /FontBBox [0 0 9 9] /Encoding [/a /b] /BuildChar { pop pop 9 0 setcharwidth 0 0 5 5 rectfill } >> definefont setfont", "/T4 << /FontType 3 /FontMatrix [1 0 0 1 0 0] /BuildGlyph { pop pop (x) show } /Encoding [/a] >> definefont setfont", diff --git a/crates/eps/src/import/data.rs b/crates/eps/src/import/data.rs index fa05ee13b..bbd82bbeb 100644 --- a/crates/eps/src/import/data.rs +++ b/crates/eps/src/import/data.rs @@ -6,7 +6,8 @@ use std::collections::HashMap; use std::rc::Rc; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeId, NodeKind}; +use vectorcraft_doc::cmyk::{Inks, jpeg_components}; +use vectorcraft_doc::{ImageBlob, ImageObject, Node, NodeId, NodeKind, OpacityMask}; use vectorcraft_geom::Affine; use super::graphics::{Space, process}; @@ -33,7 +34,8 @@ pub(crate) enum Stream { Buf { data: Vec, pos: usize, - /// The samples are 8-bit RGB (or grey) whatever the image's colour space: a decoded JPEG. + /// The samples are 8-bit gray, RGB or CMYK (1, 3 or 4 channels) whatever the image's + /// color space: a decoded JPEG. rgb: Option, }, } @@ -146,12 +148,19 @@ fn lzw(raw: &[u8]) -> Res<(Vec, usize)> { } } -/// A JPEG's pixels (grey or RGB, 8 bits) with its channel count, and how many bytes of `raw` it -/// took. +/// A JPEG's pixels (gray, RGB or CMYK, 8 bits) with its channel count, and how many bytes of +/// `raw` it took. A CMYK JPEG gives its samples as stored, for the image's Decode array to read +/// (Adobe's CMYK JPEGs store them inverted, which a Decode array of `[1 0 1 0 1 0 1 0]` undoes). fn dct(raw: &[u8]) -> Res<(Vec, usize, u8)> { let end = find(raw, &[0xFF, 0xD9]).map_or(raw.len(), |e| e + 2); - let img = image::load_from_memory_with_format(raw.get(..end).unwrap_or_default(), image::ImageFormat::Jpeg) - .map_err(|_| PsError::Ps("ioerror", "DCTDecode".into()))?; + let jpeg = raw.get(..end).unwrap_or_default(); + // `cmyk` gives the ink amounts of inverted samples: inverted back, they are the samples. + if jpeg_components(jpeg) == Some(4) + && let Some(inks) = ImageBlob::new("image/jpeg", jpeg.to_vec()).cmyk() + { + return Ok((inks.data.into_iter().map(|v| 255 - v).collect(), end, 4)); + } + let img = image::load_from_memory_with_format(jpeg, image::ImageFormat::Jpeg).map_err(|_| PsError::Ps("ioerror", "DCTDecode".into()))?; if img.color().channel_count() == 1 { Ok((img.to_luma8().into_raw(), end, 1)) } else { Ok((img.to_rgb8().into_raw(), end, 3)) } } @@ -834,7 +843,8 @@ impl Interp<'_> { Ok(Spec { w, h, bpc, space, decode: vec![], matrix, sources, mask_color: None, stencil }) } - /// The image's pixels, straight RGBA, rows from the first one read. + /// The image's pixels, straight RGBA, rows from the first one read, and a CMYK image's ink + /// amounts. fn decode_image(&mut self, s: Spec) -> Res> { if !matches!(s.bpc, 1 | 2 | 4 | 8 | 12 | 16) { return ps_err("rangecheck", "BitsPerComponent"); @@ -851,7 +861,7 @@ impl Interp<'_> { self.materialize(f)?; } } - // A decoded JPEG gives 8-bit grey or RGB samples whatever the colour space says. + // A decoded JPEG gives 8-bit gray, RGB or CMYK samples whatever the color space says. let jpeg = s.sources.iter().find_map(|src| match src { Source::File(f) => match &*f.borrow() { Stream::Buf { rgb, .. } => *rgb, @@ -861,6 +871,7 @@ impl Interp<'_> { }); let (n, bpc, space) = match jpeg { Some(3) => (3, 8, Space::Rgb), + Some(4) => (4, 8, Space::Cmyk), Some(_) => (1, 8, Space::Gray), None => (n, bpc, s.space.clone()), }; @@ -891,9 +902,18 @@ impl Interp<'_> { Space::Indexed { .. } => vec![0.0, max], _ => (0..n).flat_map(|_| [0.0, 1.0]).collect(), }; - let decode = if s.decode.len() >= 2 * n && jpeg.is_none() { s.decode.clone() } else { default_decode }; + // The Decode array applies to a CMYK JPEG's samples as stored; gray and RGB JPEGs are read + // with the default Decode array. + let decode = if s.decode.len() >= 2 * n && jpeg.is_none_or(|c| c == 4) { s.decode.clone() } else { default_decode }; let paint = self.g.paint.color().map(|c| c.to_rgba8(1.0)); let mut rgba = Vec::with_capacity(s.w * s.h * 4); + // CMYK samples keep their ink amounts, as the Decode array reads them. + let mut inks = if matches!(space, Space::Cmyk) && s.stencil.is_none() { + self.alloc(s.w * s.h * 4)?; + Some(Vec::with_capacity(s.w * s.h * 4)) + } else { + None + }; let mut cache: HashMap, [u8; 3]> = HashMap::new(); let mut raw = vec![0u32; n]; let mut comps = vec![0f64; n]; @@ -916,6 +936,9 @@ impl Interp<'_> { let (d0, d1) = (decode.get(2 * c).copied().unwrap_or(0.0), decode.get(2 * c + 1).copied().unwrap_or(1.0)); *slot = d0 + f64::from(*raw.get(c).unwrap_or(&0)) * (d1 - d0) / max; } + if let Some(inks) = inks.as_mut() { + inks.extend(comps.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)); + } let rgb = match (&space, raw.as_slice()) { (Space::Gray, [v]) if bpc == 8 && decode == [0.0, 1.0] => [*v as u8; 3], (Space::Rgb, [r, g, b]) if bpc == 8 && decode == [0.0, 1.0, 0.0, 1.0, 0.0, 1.0] => [*r as u8, *g as u8, *b as u8], @@ -933,7 +956,7 @@ impl Interp<'_> { rgba.extend([rgb[0], rgb[1], rgb[2], if masked { 0 } else { 255 }]); } } - Ok(Some(Pixels { w: s.w as u32, h: s.h as u32, rgba, matrix: s.matrix })) + Ok(Some(Pixels { w: s.w as u32, h: s.h as u32, rgba, inks, matrix: s.matrix })) } /// One pixel's colour in `space` as display RGB. @@ -962,19 +985,47 @@ impl Interp<'_> { if self.g.null { return Ok(()); } + // CMYK samples keep their ink amounts as a CMYK TIFF, as the PDF importer keeps an 8- or + // 16-bit CMYK image's samples, and transparent pixels (`MaskColor`, an ImageType 3 mask) + // become its opacity mask. + if let Some(blob) = img.inks.and_then(|i| Inks::new(img.w, img.h, i)).and_then(|i| ImageBlob::cmyk_tiff(&i)) { + let alpha: Vec = img.rgba.iter().skip(3).step_by(4).copied().collect(); + let mask = self.alpha_mask(&alpha, img.w, img.h, xf); + return self.add_image(blob, img.w, img.h, xf, mask); + } let Some(rgba) = image::RgbaImage::from_raw(img.w, img.h, img.rgba) else { return Ok(()) }; let mut png = vec![]; if rgba.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() { self.out.warn("an image that couldn't be read was left out"); return Ok(()); } + self.add_image(ImageBlob::new("image/png", png), img.w, img.h, xf, None) + } + + /// Add image `blob` (`w` × `h` pixels, mapped into user space by `xf`) with `mask` as its + /// opacity mask. + fn add_image(&mut self, blob: ImageBlob, w: u32, h: u32, xf: Affine, mask: Option) -> Res { + let key = blob.content_key(); + self.out.doc.images.entry(key.clone()).or_insert(blob); + let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }; + let clips = self.g.clips.clone(); + self.out.push(Node::new(NodeId(0), NodeKind::Image(Box::new(im))).with_mask(mask.map(Box::new)), &clips); + Ok(()) + } + + /// An image's alpha (`w` × `h` bytes) as a grayscale image over the image (`xf`): its opacity + /// mask, as in PDF import. `None` when it hides nothing. + fn alpha_mask(&mut self, alpha: &[u8], w: u32, h: u32, xf: Affine) -> Option { + if alpha.iter().all(|v| *v == 255) { + return None; + } + let mut png = Vec::new(); + image::GrayImage::from_raw(w, h, alpha.to_vec())?.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; let blob = ImageBlob::new("image/png", png); let key = blob.content_key(); self.out.doc.images.entry(key.clone()).or_insert(blob); - let im = ImageObject { key, width: img.w, height: img.h, xf, link: None, placement: Default::default() }; - let clips = self.g.clips.clone(); - self.out.push(Node::new(NodeId(0), NodeKind::Image(Box::new(im))), &clips); - Ok(()) + let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }; + Some(OpacityMask::new(Node::new(self.out.doc.alloc_id(), NodeKind::Image(Box::new(im))), true)) } } @@ -1059,6 +1110,8 @@ struct Pixels { w: u32, h: u32, rgba: Vec, + /// A CMYK image's ink amounts: C, M, Y and K bytes a pixel, 0 is no ink. + inks: Option>, matrix: Affine, } diff --git a/crates/eps/src/import/tests_images.rs b/crates/eps/src/import/tests_images.rs index 641cd1acd..85529a302 100644 --- a/crates/eps/src/import/tests_images.rs +++ b/crates/eps/src/import/tests_images.rs @@ -1,9 +1,15 @@ //! Image data read as the PostScript Language Reference (3rd ed.) defines it: several data sources //! read in turn (§4.10 "Images", `image` and `colorimage` in chapter 8), rows padded to a whole //! byte at every bit depth and odd widths (§4.10), and Flate predictors undone row by row (§3.13, -//! `FlateDecode` parameters) (#498). +//! `FlateDecode` parameters) (#498). CMYK images keep their ink amounts, as the Decode array +//! reads their samples, and their transparent pixels become an opacity mask. -use super::tests::read; +use std::sync::Arc; + +use vectorcraft_doc::cmyk::Inks; +use vectorcraft_doc::{ImageBlob, Node, NodeKind}; + +use super::tests::{all, read}; use super::tests_generators::pixel; /// Each pixel of the first image, row by row. @@ -12,6 +18,25 @@ fn pixels(r: &crate::Imported, w: u32, h: u32) -> Vec<[u8; 4]> { (0..h).flat_map(|y| (0..w).map(move |x| (x, y))).map(|(x, y)| pixel(&r.document, x, y)).collect() } +/// The first image object. +fn first_image(r: &crate::Imported) -> Arc { + assert!(!r.preview && r.warnings.is_empty(), "{:?}", r.warnings); + all(&r.document).into_iter().find(|n| matches!(n.kind, NodeKind::Image(_))).expect("an image") +} + +/// The blob of image object `n`. +fn blob<'d>(r: &'d crate::Imported, n: &Node) -> &'d ImageBlob { + let NodeKind::Image(im) = &n.kind else { panic!("an image: {n:?}") }; + &r.document.images[&im.key] +} + +/// The ink amounts of the first image, a CMYK TIFF. +fn inks(r: &crate::Imported) -> Inks { + let b = blob(r, &first_image(r)); + assert_eq!(b.mime, "image/tiff"); + b.cmyk().expect("CMYK samples") +} + /// A CMYK image whose four data sources are procedures reading one file, a row of one ink each: /// the image operator calls them in turn, so each gets its own rows. Read one after the other, /// the cyan source took the first four rows of all four inks and the image came out in @@ -27,15 +52,9 @@ fn procedures_reading_one_file_are_read_in_turn() { {{f 3 string readstring pop}} {{f 3 string readstring pop}} ] >> image\n{}>", rows.concat() )); - let cyan = [0, 174, 239, 255]; - let magenta = [236, 0, 140, 255]; - let yellow = [255, 242, 0, 255]; - let black = [35, 31, 32, 255]; - let white = [255, 255, 255, 255]; - let px = pixels(&r, 3, 2); - let near = |a: [u8; 4], b: [u8; 4]| a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 40); - let want = [cyan, magenta, white, yellow, yellow, black]; - assert!(px.iter().zip(want).all(|(a, b)| near(*a, b)), "{px:?}"); + let (cyan, magenta, yellow, black, white) = ([255, 0, 0, 0], [0, 255, 0, 0], [0, 0, 255, 0], [0, 0, 0, 255], [0, 0, 0, 0]); + let want = [cyan, magenta, white, yellow, yellow, black].concat(); + assert_eq!(inks(&r).data, want); // The operand form: `{r} {g} {b} true 3 colorimage`, procedures reading one file. let r = read( @@ -53,6 +72,86 @@ fn separate_sources_keep_their_planes() { assert_eq!(pixels(&r, 3, 2), [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [16, 64, 112, 255], [32, 80, 128, 255], [48, 96, 144, 255]]); } +/// CMYK samples keep their ink amounts in a CMYK TIFF: in the operand and dictionary forms, from +/// hex and binary data, from one source or one a component, at 4, 8 and 16 bits, read through +/// the Decode array. Gray and RGB images stay PNGs. +#[test] +fn cmyk_images_keep_their_ink_amounts() { + let want = [10, 20, 30, 40, 0, 102, 127, 8]; + let r = read("30 20 scale 2 1 8 [2 0 0 -1 0 1] <0a141e28 00667f08> false 4 colorimage"); + assert_eq!(inks(&r), Inks::new(2, 1, want.to_vec()).unwrap()); + assert!(first_image(&r).extras().mask.is_none(), "an opaque image has no mask"); + let r = read("30 20 scale 2 1 8 [2 0 0 -1 0 1] <0a00> <1466> <1e7f> <2808> true 4 colorimage"); + assert_eq!(inks(&r).data, want, "one source a component"); + let wide: String = want.iter().map(|v| format!("{:04x}", u16::from(*v) * 257)).collect(); + let r = read(&format!("30 20 scale 2 1 16 [2 0 0 -1 0 1] <{wide}> false 4 colorimage")); + assert_eq!(inks(&r).data, want, "16 bits"); + let r = read("30 20 scale 2 1 4 [2 0 0 -1 0 1] false 4 colorimage"); + assert_eq!(inks(&r).data, [255, 0, 136, 0, 0, 255, 0, 255], "4 bits"); + // Binary data after the operator, with a Decode array that inverts the samples. + let samples: String = want.iter().map(|v| char::from(*v)).collect(); + let r = read(&format!( + "/DeviceCMYK setcolorspace 30 20 scale << /ImageType 1 /Width 2 /Height 1 /BitsPerComponent 8 /Decode [1 0 1 0 1 0 1 0] \ + /ImageMatrix [2 0 0 -1 0 1] /DataSource currentfile >> image\n{samples}" + )); + assert_eq!(inks(&r).data, want.map(|v| 255 - v), "binary, inverted"); + let r = read( + "[/ICCBased << /N 4 >>] setcolorspace 30 20 scale << /ImageType 1 /Width 2 /Height 1 /BitsPerComponent 8 \ + /ImageMatrix [2 0 0 -1 0 1] /DataSource <0a141e28 00667f08> >> image", + ); + assert_eq!(inks(&r).data, want, "ICC-based with four components"); + for program in ["30 20 scale 2 1 8 [2 0 0 -1 0 1] <0a141e 00667f> false 3 colorimage", "30 20 scale 2 1 8 [2 0 0 -1 0 1] <0a14> image"] { + let r = read(program); + assert_eq!(blob(&r, &first_image(&r)).mime, "image/png", "{program}"); + } +} + +/// The transparent pixels of a CMYK image (`MaskColor`, an ImageType 3 mask) become its opacity +/// mask, a grayscale image over it as in PDF import, and the inks stay as they are. A key color +/// that no pixel has adds no mask. +#[test] +fn masked_cmyk_images_keep_their_inks_and_carry_the_mask_as_an_opacity_mask() { + let data = "/Width 2 /Height 1 /BitsPerComponent 8 /Decode [0 1 0 1 0 1 0 1] /ImageMatrix [2 0 0 -1 0 1] /DataSource <0a141e28 00667f08>"; + let keyed = format!("/DeviceCMYK setcolorspace 30 20 scale << /ImageType 4 {data} /MaskColor [10 20 30 40] >> image"); + let stencil = format!( + "/DeviceCMYK setcolorspace 30 20 scale << /ImageType 3 /InterleaveType 3 /DataDict << /ImageType 1 {data} >> \ + /MaskDict << /ImageType 1 /Width 2 /Height 1 /BitsPerComponent 1 /Decode [0 1] /ImageMatrix [2 0 0 -1 0 1] /DataSource <40> >> >> image" + ); + for (program, alpha) in [(&keyed, [0, 255]), (&stencil, [255, 0])] { + let r = read(program); + assert_eq!(inks(&r).data, [10, 20, 30, 40, 0, 102, 127, 8], "the inks as they were: {program}"); + let n = first_image(&r); + let mask = n.extras().mask.as_ref().expect("an opacity mask"); + let gray = image::load_from_memory(&blob(&r, &mask.art).bytes).unwrap().to_luma8(); + assert_eq!(gray.into_raw(), alpha, "the mask's values: {program}"); + assert_eq!(n.geometric_bounds(), mask.art.geometric_bounds(), "the mask covers the image"); + } + let r = read(&keyed.replace("[10 20 30 40]", "[1 2 3 4]")); + assert!(first_image(&r).extras().mask.is_none()); +} + +/// A CMYK JPEG gives its samples as stored, for the Decode array to read: Adobe's inverted +/// samples with `[1 0 1 0 1 0 1 0]`, the ink amounts themselves with `[0 1 0 1 0 1 0 1]`. +#[test] +fn cmyk_jpegs_keep_their_ink_amounts() { + use vectorcraft_render::encode::jpeg::{ColorModel, JpegOptions, encode}; + let want: Vec = [[0, 0, 0, 0], [255, 0, 0, 0], [10, 20, 30, 40], [0, 102, 255, 0]].concat(); + // The encoder stores Adobe's inverted samples of the ink amounts it is given. + let jpeg = |inks: &[u8]| { + let o = JpegOptions { color_model: ColorModel::Cmyk, embed_icc: false, ..JpegOptions::default() }; + encode(inks, 2, 2, 100, None, &o).unwrap().iter().map(|b| format!("{b:02x}")).collect::() + }; + let inverted: Vec = want.iter().map(|v| 255 - v).collect(); + for (decode, data) in [("[1 0 1 0 1 0 1 0]", jpeg(&want)), ("[0 1 0 1 0 1 0 1]", jpeg(&inverted))] { + let r = read(&format!( + "/DeviceCMYK setcolorspace 30 30 scale << /ImageType 1 /Width 2 /Height 2 /BitsPerComponent 8 /Decode {decode} \ + /ImageMatrix [2 0 0 -2 0 2] /DataSource currentfile /ASCIIHexDecode filter /DCTDecode filter >> image\n{data}>" + )); + let got = inks(&r).data; + assert!(got.iter().zip(&want).all(|(a, b)| a.abs_diff(*b) <= 3), "{decode}: {got:?}"); + } +} + /// Rows of an odd width at 1, 2, 4, 12 and 16 bits a sample start at a byte (their last byte /// padded), in one source and in several. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fef93a491..d0a81a470 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -83,7 +83,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus | | Illustrator template `.ait` | open, save | yes | no (save as `.vctemplate`) | | | 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+) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text placement issues in some files (#722); 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) | -| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type and CCITTFax images not read; #505; Type 42 (TrueType) fonts set type at its size (#1553); 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); ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | +| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type and CCITTFax images not read; #505; Type 42 (TrueType) fonts set type at its size (#1553); 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); 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 | no | via Print to PostScript | | | 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) | | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). CIRCLE/ARC export fitting, CNC preset and VCarve QA remain open | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 83678458a..bacd132e1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -578,6 +578,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A PDF page whose `UserUnit` (PDF 1.6) is above 1 opens at its size in points, its numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size, with its layers, and when the layers can't be read its pages still take the artboard layout of the data, scaled the same way (part of #1482). Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests_links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part; CCITT Group 3 and planar files with extra channels are not read); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). + - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs. - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked; CIRCLE/ARC fitting, CNC preset and VCarve QA remain pending. - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it.