From b6cf45737f10ceb83374bd372260ca3c63d56525 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:22:47 -0500 Subject: [PATCH 1/2] EPS import: sampled functions interpolate between their samples A sampled function (type 0) of two or more inputs took the nearest sample, so a function-based shading over one came in as flat blocks. It is now interpolated multilinearly, and with Order 3 by cubics through the samples, kept in its range (PLRM 3rd ed. section 3.10.1). An axial or radial shading over a linearly interpolated sampled function of one input has a stop at each sample. --- crates/eps/src/import/graphics/paint.rs | 21 ++++++- crates/eps/src/import/shading.rs | 75 +++++++++++++++++++---- crates/eps/src/import/tests_generators.rs | 32 ++++++++++ docs/file-format-parity.md | 5 +- 4 files changed, 118 insertions(+), 15 deletions(-) diff --git a/crates/eps/src/import/graphics/paint.rs b/crates/eps/src/import/graphics/paint.rs index 8b0a729ee..fc2490fb4 100644 --- a/crates/eps/src/import/graphics/paint.rs +++ b/crates/eps/src/import/graphics/paint.rs @@ -292,7 +292,9 @@ impl Interp<'_> { // there are too many). let mut visits = 0; let points = linear_points(&f, 0, &mut visits).filter(|p| p.len() <= MAX_STITCH_STOPS); - let stitched = points.is_some(); + // A sampled function is continuous: no hard colour changes at its samples. + let stitched = + points.is_some() && !matches!(&f, Obj::Dict(d) if d.borrow().get(&Key::name("FunctionType")).and_then(Obj::as_num) == Some(0.0)); let ts: Vec = match points { Some(mut pts) => { pts.retain(|t| (d0.min(d1)..=d0.max(d1)).contains(t)); @@ -452,6 +454,23 @@ fn linear_points(f: &Obj, depth: u32, visits: &mut usize) -> Option> { let num = |k: &str| d.get(&Key::name(k)).and_then(Obj::as_num); match num("FunctionType")? { 2.0 if num("N").unwrap_or(1.0) == 1.0 => Some(vec![]), + // A sampled function of one input, interpolated linearly: straight between its samples. + 0.0 if depth == 0 && num("Order").unwrap_or(1.0) == 1.0 => { + let nums = |k: &str| -> Vec { + d.get(&Key::name(k)).and_then(Obj::items).map(|i| i.borrow().iter().filter_map(Obj::as_num).collect()).unwrap_or_default() + }; + let (domain, size, encode) = (nums("Domain"), nums("Size"), nums("Encode")); + let ([a, b], [s]) = (<[f64; 2]>::try_from(domain).ok()?, <[f64; 1]>::try_from(size).ok()?); + if !(1.0..=MAX_STITCH_STOPS as f64).contains(&s) || !a.is_finite() || !b.is_finite() { + return None; + } + let (e0, e1) = (encode.first().copied().unwrap_or(0.0), encode.get(1).copied().unwrap_or(s - 1.0)); + if e1 == e0 || !e0.is_finite() || !e1.is_finite() { + return None; + } + // Where each sample index `k` falls in the domain, through `Encode`. + Some((0..s as usize).map(|k| a + (k as f64 - e0) * (b - a) / (e1 - e0)).filter(|x| x.is_finite()).collect()) + } 3.0 => { let items = d.get(&Key::name("Functions"))?.items()?; // More pieces than the stop cap allows: sample the function instead (tested before diff --git a/crates/eps/src/import/shading.rs b/crates/eps/src/import/shading.rs index c59881a98..f2ab9611f 100644 --- a/crates/eps/src/import/shading.rs +++ b/crates/eps/src/import/shading.rs @@ -22,6 +22,9 @@ const FUNCTION_GRID: u32 = 16; const MAX_SAMPLES: usize = 1 << 24; /// Most outputs (colour components) a sampled function has. const MAX_OUTPUTS: usize = 32; +/// Most samples one value of a sampled function is interpolated from: inputs past it take the +/// nearest sample. +const MAX_TAPS: usize = 256; /// Work one patch costs against the operation budget. const PATCH_OPS: u64 = 64; @@ -312,8 +315,8 @@ impl Interp<'_> { Ok(Some(out)) } - /// A sampled function (type 0) at `x`: the samples around it, interpolated linearly for - /// one input, the nearest one for more. + /// A sampled function (type 0) at `x`: the samples around it, interpolated (multilinearly, + /// or by cubics for `Order 3`). pub(super) fn sampled(&mut self, f: &DictRef, x: &[f64]) -> Res> { let get = |k: &str| f.borrow().get(&Key::name(k)).cloned(); let nums = @@ -360,17 +363,65 @@ impl Interp<'_> { let e = if b != a { e0 + (xi - a) * (e1 - e0) / (b - a) } else { e0 }; pos.push(clamp_between(e, 0.0, s - 1.0)); } - if let ([p], Some(s)) = (pos.as_slice(), size.first()) { - let (lo, hi) = (p.floor() as usize, (p.ceil() as usize).min(*s as usize - 1)); - let t = p - p.floor(); - return Ok((0..outs).map(|j| sample(lo, j) * (1.0 - t) + sample(hi, j) * t).collect()); - } - let mut index = 0; - let mut stride = 1; + // The samples each input reads and their weights: the two around it (linear), or the + // four around it for `Order 3` (cubic). The result is their tensor product. + let cubic = get("Order").and_then(|o| o.as_num()) == Some(3.0); + let mut taps: Vec> = Vec::with_capacity(m); + let mut combos = 1usize; for (i, p) in pos.iter().enumerate() { - index += p.round() as usize * stride; - stride *= size.get(i).copied().unwrap_or(1.0) as usize; + let last = size.get(i).copied().unwrap_or(1.0) as usize - 1; + let lo = p.floor() as usize; + let t = p - p.floor(); + let at = |k: isize| (lo as isize + k).clamp(0, last as isize) as usize; + let w: Vec<(usize, f64)> = if t == 0.0 || last == 0 { + vec![(lo.min(last), 1.0)] + } else if cubic && combos.saturating_mul(4) <= MAX_TAPS { + catmull_rom(t).into_iter().zip(-1..=2).map(|(w, k)| (at(k), w)).collect() + } else if combos.saturating_mul(2) <= MAX_TAPS { + vec![(at(0), 1.0 - t), (at(1), t)] + } else { + // Too many inputs between samples to interpolate them all: the nearest. + vec![(p.round() as usize, 1.0)] + }; + combos = combos.saturating_mul(w.len()); + taps.push(w); } - Ok((0..outs).map(|j| sample(index, j)).collect()) + let mut out = vec![0.0; outs]; + let mut digits = vec![0usize; m]; + loop { + let (mut index, mut stride, mut weight) = (0usize, 1usize, 1.0); + for (i, (tap, d)) in taps.iter().zip(&digits).enumerate() { + let (k, w) = tap.get(*d).copied().unwrap_or((0, 0.0)); + index += k * stride; + weight *= w; + stride *= size.get(i).copied().unwrap_or(1.0) as usize; + } + if weight != 0.0 { + for (j, o) in out.iter_mut().enumerate() { + *o += weight * sample(index, j); + } + } + // The next combination, the first input varying fastest. + let Some(i) = digits.iter().zip(&taps).position(|(d, t)| d + 1 < t.len()) else { break }; + for d in digits.iter_mut().take(i) { + *d = 0; + } + if let Some(d) = digits.get_mut(i) { + *d += 1; + } + } + // A cubic overshoots between samples of different slopes: kept in the range. + Ok(out + .into_iter() + .enumerate() + .map(|(j, v)| clamp_between(v, range.get(2 * j).copied().unwrap_or(0.0), range.get(2 * j + 1).copied().unwrap_or(1.0))) + .collect()) } } + +/// The weights of the four samples around a point `t` of the way from the second to the third: +/// the Catmull-Rom cubic, which passes through every sample with the slope of its neighbours. +fn catmull_rom(t: f64) -> [f64; 4] { + let (t2, t3) = (t * t, t * t * t); + [(-t3 + 2.0 * t2 - t) / 2.0, (3.0 * t3 - 5.0 * t2 + 2.0) / 2.0, (-3.0 * t3 + 4.0 * t2 + t) / 2.0, (t3 - t2) / 2.0] +} diff --git a/crates/eps/src/import/tests_generators.rs b/crates/eps/src/import/tests_generators.rs index 4337b9474..eb2d756fd 100644 --- a/crates/eps/src/import/tests_generators.rs +++ b/crates/eps/src/import/tests_generators.rs @@ -677,6 +677,10 @@ fn mesh_and_function_shadings_become_gradient_meshes() { let NodeKind::Mesh(g) = &m.kind else { panic!() }; // Its first corner (the domain's origin) is the first sample. assert_eq!(g.points[0].color, Color::rgb(1.0, 0.0, 0.0)); + // Between the samples it is interpolated (PLRM 3rd ed. §3.10.1, type 0 functions), not the + // nearest sample: the middle of the 17 × 17 grid is the average of red, green, blue and black. + let mid = g.points[8 * 17 + 8].color.to_rgb_uncalibrated(); + assert!(mid.iter().all(|c| (c - 0.25).abs() < 1e-3), "{mid:?}"); // Type 4 packed: 8-bit flags, coordinates and components; a triangle and one on its edge. let tri = [[0u8, 0, 0, 255, 0, 0], [0, 255, 0, 0, 255, 0], [0, 0, 255, 0, 0, 255], [1, 255, 255, 255, 255, 0]].concat(); @@ -1141,3 +1145,31 @@ fn filter_output_counts_against_the_memory_budget() { let Err(err) = many else { panic!("the budget ends the program") }; assert!(err.contains("too much memory"), "{err}"); } + +/// A sampled function of one input in an axial shading: interpolated linearly, its gradient has a +/// stop at every sample (exact, not sampled evenly); with `Order 3` it is a cubic through the +/// samples (PLRM 3rd ed. §3.10.1, Table 3.36), kept in its range. +#[test] +fn sampled_functions_interpolate_linearly_or_by_cubics() { + let shading = |order: &str| { + let r = read(&format!( + "<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 100 0] /Function << /FunctionType 0 /Domain [0 1] \ + /Range [0 1] /Size [4] /BitsPerSample 8 {order} /DataSource <00ffff00> >> >> shfill" + )); + let Some(Paint::Gradient(g)) = all(&r.document).into_iter().find_map(|n| fill(&n).cloned()) else { panic!("no gradient") }; + g.gradient.stops.iter().map(|s| (s.offset, s.color.to_rgb_uncalibrated()[0])).collect::>() + }; + let linear = shading(""); + assert_eq!(linear.len(), 4, "{linear:?}"); + for ((t, v), (et, ev)) in linear.iter().zip([(0.0, 0.0), (1.0 / 3.0, 1.0), (2.0 / 3.0, 1.0), (1.0, 0.0)]) { + assert!((t - et).abs() < 1e-4 && (v - ev).abs() < 1e-3, "{linear:?}"); + } + // Sampled evenly; at 3/32 (0.28 of the way from the first sample to the second) the cubic + // through 0, 0, 1 and 1 (the first sample repeated before it) is 0.237, where a straight line + // would be 0.281. Between the two white samples it would overshoot past 1, and is kept at 1. + let cubic = shading("/Order 3"); + let at = |t: f32| cubic.iter().find(|s| (s.0 - t).abs() < 1e-4).map(|s| s.1); + assert!(at(3.0 / 32.0).is_some_and(|v| (v - 0.237).abs() < 0.01), "{cubic:?}"); + assert!(cubic.iter().all(|s| (0.0..=1.0).contains(&s.1)), "{cubic:?}"); + assert!(cubic.iter().any(|s| s.1 == 1.0), "{cubic:?}"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d3e2f0303..12e42ac80 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-06 · **Change:** minor (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-07 · **Change:** minor (EPS import: sampled functions interpolate between their samples; 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 @@ -153,7 +153,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | 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) | -| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type not read; #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); 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) | +| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type not read; #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); 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; 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`) | | DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open | @@ -191,6 +191,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-11-07 | minor | EPS import: a sampled function (type 0) of two or more inputs took the nearest sample, so a function-based shading (type 1) over one came in as flat blocks; it is now interpolated between its samples (multilinearly), and with `Order 3` by cubics through them (Catmull-Rom, kept in the function's range), as the PostScript Language Reference (3rd ed., §3.10.1, type 0 functions) defines. An axial or radial shading whose function is a linearly interpolated sampled function of one input has a stop at each sample, not 33 evenly spaced ones. Tests `sampled_functions_interpolate_linearly_or_by_cubics`, `mesh_and_function_shadings_become_gradient_meshes`; a 2 × 2 function-based shading is 1.2% from Ghostscript's render (it was 45.9%); scores unchanged | | 2026-11-06 | minor | SVG import: filter and mask regions clip (SVG 1.1 §15.5, §14.4). An object whose imported blur, shadow or glow reaches past its filter's region comes in inside a clip group cut to the region (#5876), and a mask's art is cut to the mask's region, in user space or the object's bounding box (#5875). Objects that fit their regions, and our own exports, are unchanged; primitive subregions are still ignored. Tests `import_live::a_blur_is_cut_at_its_filter_region`, `import_live::a_mask_counts_only_inside_its_region` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | SVG import: a Gaussian blur keeps the colour space its filter asks for with `color-interpolation-filters` (initially `linearRGB`, SVG 1.1 §15.7.6): a linear-light blur becomes Gaussian Blur with the new `linear` parameter, which blurs the colours in linear light (black beside white meets at about 188/255 instead of 128), and SVG export writes it back on the `feGaussianBlur` (#5877). Blurs that ask for sRGB, and the blurs our own export writes, are as before. CSS export still writes `blur()`. Tests `pixel::tests::linear_light_blur_mixes_light_not_values` (crate `vectorcraft-effects`), `import_live::a_gaussian_blur_keeps_its_colour_space` (crate `vectorcraft-svg`); scores unchanged | | 2026-11-06 | minor | PDF export and PDF-compatible `.ai` saves write each object's URL (Attributes panel) as a link annotation with a URI action (web and mail URLs only) over the bounds of the object that shows, trimmed by its clipping groups (ISO 32000-1, 12.5.6.5 and 12.6.4.7); hidden objects, layers that don't print and prints have none, and PDF/X files leave them out with a warning (#5785). Own-fixture tests in `tests::links`; PDF import doesn't read links back as URLs yet; scores unchanged | From 9c8eef4111fe53df11eb9cfd3c2b1faa423a08d4 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 7 Nov 2026 05:24:15 -0500 Subject: [PATCH 2/2] EPS import: a sampled function's sample reads count against the operation budget Each evaluation of a sampled function copied its DataSource and read every tap combination without counting that work: a shading evaluates its function once per stop, so a long DataSource or many interpolated inputs did far more work than the budget charged. Both now count. The nearest-sample fallback is kept at the last sample, which a fractional Size could put it past. --- crates/eps/src/import/shading.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/eps/src/import/shading.rs b/crates/eps/src/import/shading.rs index f2ab9611f..54c8bd006 100644 --- a/crates/eps/src/import/shading.rs +++ b/crates/eps/src/import/shading.rs @@ -338,6 +338,8 @@ impl Interp<'_> { Some(Obj::File { stream, .. }) => self.peek_all(&stream)?, _ => return ps_err("typecheck", "DataSource"), }; + // Copying the samples is work too: a shading evaluates its function once for each stop. + self.spend((bytes.len() / 256) as u64)?; let (encode, decode) = (nums("Encode"), nums("Decode")); let max = ((1u64 << bps) - 1) as f64; let sample = |index: usize, j: usize| -> f64 { @@ -380,12 +382,15 @@ impl Interp<'_> { } else if combos.saturating_mul(2) <= MAX_TAPS { vec![(at(0), 1.0 - t), (at(1), t)] } else { - // Too many inputs between samples to interpolate them all: the nearest. - vec![(p.round() as usize, 1.0)] + // Too many inputs between samples to interpolate them all: the nearest (a + // fractional `Size` can put it past the last sample). + vec![((p.round() as usize).min(last), 1.0)] }; combos = combos.saturating_mul(w.len()); taps.push(w); } + // Each combination of taps reads one sample of every output. + self.spend(combos.saturating_mul(outs) as u64)?; let mut out = vec![0.0; outs]; let mut digits = vec![0usize; m]; loop {