From 372887fc6c4ecfade3888aa6c1bfc71d84a59bd6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 15:14:12 -0700 Subject: [PATCH] EPS and .ai import: images with TIFF predictor 2 draw their samples at every bit depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PostScript interpreter that reads EPS and PostScript .ai files accepts a FlateDecode or LZWDecode filter with Predictor 2 at BitsPerComponent 1, 2, 4, 8 and 16, but undid the horizontal differencing only at 8 bits. At the other depths the filter gave back the stored differences, and the image drew them instead of its samples. The predictor is now undone at every depth it accepts, as ISO 32000-1 §7.4.4.4 describes: each component adds the same component of the pixel to its left, within its bit width, and each row starts again. 16-bit components add as big-endian 16-bit numbers. The padding bits at the end of a row stay as they are, and 8-bit data takes the same path as before. The PNG predictors are unchanged. Docs: docs/target-app-parity.md. --- .../engine/src/cmd/fileio/tests_epsimport.rs | 29 ++++++++++ crates/engine/tests/import_fuzz.rs | 24 ++++++++- crates/eps/src/import/data.rs | 44 ++++++++++++--- crates/eps/src/import/tests_images.rs | 54 +++++++++++++++++++ docs/target-app-parity.md | 1 + 5 files changed, 144 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/fileio/tests_epsimport.rs b/crates/engine/src/cmd/fileio/tests_epsimport.rs index 255561f2b..8c4141902 100644 --- a/crates/engine/src/cmd/fileio/tests_epsimport.rs +++ b/crates/engine/src/cmd/fileio/tests_epsimport.rs @@ -1,6 +1,8 @@ //! EPS and PostScript files opened and placed: our own EPS files restore the document they carry, //! others are read by the PostScript interpreter or come in as their preview. +use std::io::Write as _; + use serde_json::{Value, json}; use super::tests_svgedit::{comparable, rich}; @@ -93,6 +95,33 @@ fn cmyk_images_keep_their_inks_when_an_eps_opens_or_is_placed() { assert_eq!(images(&s), [want.clone(), want]); } +/// A 4-bit gray image compressed with Flate and TIFF predictor 2 draws the samples it had before +/// differencing, in an EPS and in a PostScript `.ai`. +#[test] +fn tiff_predicted_4_bit_images_open_from_eps_and_postscript_ai_files() { + // Two rows of four samples, 0 5 15 3 and 9 9 2 0, differenced to 0 5 10 4 and 9 0 9 14. + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + z.write_all(&[0x05, 0xa4, 0x90, 0x9e]).unwrap(); + let hex: String = z.finish().unwrap().iter().map(|b| format!("{b:02x}")).collect(); + let ps = format!( + "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 40 20\n%%EndComments\n40 20 scale 4 2 4 [4 0 0 -2 0 2] currentfile /ASCIIHexDecode filter \ + << /Predictor 2 /Colors 1 /BitsPerComponent 4 /Columns 4 >> /FlateDecode filter image\n{hex}>\nshowpage\n%%EOF\n" + ); + for name in ["predicted.eps", "predicted.ai"] { + let mut s = Session::new(); + let r = open(&mut s, name, ps.as_bytes()).unwrap(); + assert_eq!(r["warnings"], json!([]), "{name}: {r}"); + let d = &s.doc().unwrap().doc; + let mut grays = vec![]; + d.walk(|n| { + if let vectorcraft_doc::NodeKind::Image(im) = &n.kind { + grays = image::load_from_memory(&d.images[&im.key].bytes).unwrap().to_luma8().into_raw(); + } + }); + assert_eq!(grays, [0, 5, 15, 3, 9, 9, 2, 0].map(|v| v * 17), "{name}"); + } +} + #[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 aff82b0db..b420725ed 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1792,6 +1792,27 @@ proptest! { let text = mutate_text(&ccitt_images_eps(), cut, &edits); survive("mutated EPS CCITT images", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?; } + + /// TIFF-predicted Flate data (Predictor 2) at every depth the filter accepts, with any bytes, 1 to 5 + /// colors and 1 to 39 columns, read by images of up to 23 × 7 pixels at 1, 2, 4, 8, 12 or 16 bits: + /// read as an image, or as an error. + #[test] + fn eps_tiff_predicted_images_never_panic( + data in prop::collection::vec(any::(), 0..300), + bpc in prop::sample::select(vec![1u8, 2, 4, 8, 16]), + colors in 1u8..6, + columns in 1u16..40, + image_bpc in prop::sample::select(vec![1u8, 2, 4, 8, 12, 16]), + (width, height) in (1u8..24, 1u8..8), + ) { + let hex: String = deflated(&data).iter().map(|b| format!("{b:02x}")).collect(); + let text = format!( + "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n40 30 scale {width} {height} {image_bpc} [{width} 0 0 -{height} 0 {height}] \ + currentfile /ASCIIHexDecode filter << /Predictor 2 /Colors {colors} /BitsPerComponent {bpc} /Columns {columns} >> /FlateDecode filter image\n\ + {hex}>\nshowpage\n%%EOF\n" + ); + survive("TIFF-predicted EPS images", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?; + } } /// An EPS with two 8 × 3 CCITT fax images that the PostScript interpreter reads, coded by hand from @@ -1902,7 +1923,8 @@ fn arb_ps_token() -> impl Strategy { "status", "token", "bytesavailable", "resetfile", "pdfmark", "execform", "nulldevice", "strokepath", "pathforall", "currenthsbcolor", "currentcolorrendering", "gcheck", "rootfont", "setcachedevice2", "writestring", "(%stdout) (w) file", "//x", "{ //dup }", "/ReusableStreamDecode", "currentfile /ASCII85Decode filter /ReusableStreamDecode filter", - "<< /Predictor 12 /Columns 2 /Colors 1 >> /FlateDecode", "<< /FormType 1 /BBox [0 0 9 9] /PaintProc { pop 0 0 5 5 rectfill } >>", + "<< /Predictor 12 /Columns 2 /Colors 1 >> /FlateDecode", "<< /Predictor 2 /Colors 3 /BitsPerComponent 4 /Columns 3 >> /LZWDecode", + "<< /Predictor 2 /BitsPerComponent 16 /Columns 5 >> /FlateDecode", "<< /FormType 1 /BBox [0 0 9 9] /PaintProc { pop 0 0 5 5 rectfill } >>", // Tiling patterns, uncoloured ones and patterns drawing patterns. "<< /PatternType 1 /PaintType 1 /XStep 5 /YStep 5 /BBox [0 0 5 5] /PaintProc { pop 0 0 3 3 rectfill } >> matrix makepattern", "<< /PatternType 1 /PaintType 2 /XStep 5 /YStep 5 /BBox [0 0 5 5] /PaintProc { pop dup setpattern 0 0 3 3 rectfill } >>", diff --git a/crates/eps/src/import/data.rs b/crates/eps/src/import/data.rs index d09fae4cd..b1724d39c 100644 --- a/crates/eps/src/import/data.rs +++ b/crates/eps/src/import/data.rs @@ -324,16 +324,46 @@ impl Predictor { // Bytes per pixel (at least one) is how far back the left neighbour is. let bpp = (self.colors * self.bpc).div_ceil(8).max(1); if self.kind == 2 { + // Horizontal differencing (ISO 32000-1, §7.4.4.4): each component is stored as its + // difference from the same component of the pixel to its left, wrapping within its bit + // width. Each row starts again, and the padding bits at a row's end stay as they are. let mut out = data.to_vec(); - // Horizontal differencing, for 8-bit components (the common case). - if self.bpc == 8 { - for line in out.chunks_mut(row.max(1)) { - for i in bpp..line.len() { - let left = line.get(i - bpp).copied().unwrap_or(0); - if let Some(b) = line.get_mut(i) { - *b = b.wrapping_add(left); + for line in out.chunks_mut(row.max(1)) { + match self.bpc { + 8 => { + for i in bpp..line.len() { + let left = line.get(i - bpp).copied().unwrap_or(0); + if let Some(b) = line.get_mut(i) { + *b = b.wrapping_add(left); + } } } + // 16-bit components are big-endian and add as 16-bit numbers. + 16 => { + let byte = |line: &[u8], i: usize| line.get(i).copied().unwrap_or(0); + for i in (bpp..line.len().saturating_sub(1)).step_by(2) { + let left = u16::from_be_bytes([byte(line, i - bpp), byte(line, i - bpp + 1)]); + let sum = u16::from_be_bytes([byte(line, i), byte(line, i + 1)]).wrapping_add(left); + if let Some(w) = line.get_mut(i..i + 2) { + w.copy_from_slice(&sum.to_be_bytes()); + } + } + } + // 1, 2 or 4 bits: components are packed from each byte's high bit. + bits @ (1 | 2 | 4) => { + let mask = (1u8 << bits) - 1; + let shift = |k: usize| 8 - bits - k * bits % 8; + let sample = |line: &[u8], k: usize| line.get(k * bits / 8).map_or(0, |b| (b >> shift(k)) & mask); + let samples = (line.len() * 8 / bits).min(self.colors * self.columns); + for k in self.colors..samples { + let sum = sample(line, k).wrapping_add(sample(line, k - self.colors)) & mask; + if let Some(b) = line.get_mut(k * bits / 8) { + *b = (*b & !(mask << shift(k))) | (sum << shift(k)); + } + } + } + // `Predictor::of` accepts no other depth. + _ => {} } } return out; diff --git a/crates/eps/src/import/tests_images.rs b/crates/eps/src/import/tests_images.rs index 15ae1e912..199a6ed7a 100644 --- a/crates/eps/src/import/tests_images.rs +++ b/crates/eps/src/import/tests_images.rs @@ -408,3 +408,57 @@ fn predicted_rows_of_an_odd_width() { let want: Vec<[u8; 4]> = raw.iter().flat_map(|row| (0..3).map(move |x| [row[3 * x], row[3 * x + 1], row[3 * x + 2], 255])).collect(); assert_eq!(pixels(&r, 3, 5), want); } + +/// Rows of `bpc`-bit samples packed from the high bit (16-bit ones big-endian), each row padded to +/// a whole byte. +fn packed(rows: &[Vec], bpc: usize) -> Vec { + let mut out = vec![]; + for row in rows { + let mut bytes = vec![0u8; (row.len() * bpc).div_ceil(8)]; + for (k, v) in row.iter().enumerate() { + if bpc == 16 { + bytes[2 * k..2 * k + 2].copy_from_slice(&(*v as u16).to_be_bytes()); + } else { + let bit = k * bpc; + bytes[bit / 8] |= (*v as u8) << (8 - bpc - bit % 8); + } + } + out.extend(bytes); + } + out +} + +/// A TIFF-predicted Flate stream (Predictor 2, horizontal differencing: ISO 32000-1 §7.4.4.4) at +/// 1, 2, 4, 8 and 16 bits a component, gray and RGB, five pixels a row: each component is the +/// difference from the same component of the pixel to its left, within its bit width, and each +/// row starts again. The filter gives back the samples before differencing, read here as 8-bit +/// gray pixels one byte each. +#[test] +fn tiff_predicted_rows_at_every_depth() { + let columns = 5; + let mut wrong = vec![]; + for bpc in [1, 2, 4, 8, 16] { + for colors in [1, 3] { + let max = (1u32 << bpc) - 1; + let rows: Vec> = + (0..3u32).map(|y| (0..(columns * colors) as u32).map(|i| (i * i * 40_503 + i * 7 + y * 12_345 + 1) & max).collect()).collect(); + let differenced: Vec> = rows + .iter() + .map(|row| (0..row.len()).map(|i| if i < colors { row[i] } else { row[i].wrapping_sub(row[i - colors]) & max }).collect()) + .collect(); + let (want, data) = (packed(&rows, bpc), packed(&differenced, bpc)); + let width = want.len() / 3; + let r = read(&format!( + "30 30 scale << /ImageType 1 /Width {width} /Height 3 /BitsPerComponent 8 /Decode [0 1] /ImageMatrix [{width} 0 0 -3 0 3] \ + /DataSource currentfile /ASCII85Decode filter << /Predictor 2 /Colors {colors} /BitsPerComponent {bpc} /Columns {columns} >> \ + /FlateDecode filter >> image\n{}", + crate::ps::ascii85(&crate::ps::deflate(&data)) + )); + let got: Vec = pixels(&r, width as u32, 3).iter().map(|p| p[0]).collect(); + if got != want { + wrong.push(format!("{bpc} bits, {colors} colors: {got:?}, not {want:?}")); + } + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b5936b020..0b49059b7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -718,6 +718,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - 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. - EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. + - EPS and PostScript `.ai` import undoes the TIFF predictor (`/Predictor 2`) of `FlateDecode` and `LZWDecode` data at 1, 2, 4 and 16 bits a component as well as 8: each component adds the same component of the pixel to its left within its bit width, 16-bit components add as big-endian numbers, and each row starts again (ISO 32000-1 §7.4.4.4; `crates/eps/src/import/tests_images.rs`, `crates/engine/src/cmd/fileio/tests_epsimport.rs`). At those depths the image drew the stored differences instead of its samples. - 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.