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.
This commit is contained in:
Don Omondi
2026-11-06 20:22:47 -05:00
parent bc57bdaee3
commit b6cf45737f
4 changed files with 118 additions and 15 deletions
+20 -1
View File
@@ -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<f64> = 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<Vec<f64>> {
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<f64> {
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
+63 -12
View File
@@ -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<Vec<f64>> {
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<(usize, f64)>> = 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]
}
+32
View File
@@ -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::<Vec<_>>()
};
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:?}");
}
+3 -2
View File
@@ -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 `<title>`, 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 |