From 4c791f0f8140c05186ee31f0cbd094c11c7b2c04 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:58:45 -0500 Subject: [PATCH 1/3] EPS import: gradients drawn as abutting strips come in as one gradient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A page that paints a gradient as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imported as hundreds of separate fills. Each anti-aliased on its own, they left seams where the art below showed through every shared edge (PDF 32000-1 §10.6.4, conflation): thin stripes, and a pale tint over strips a pixel or two wide. An Illustrator EPS whose page drew its gradients that way then differed from its layers and opened as its page with empty layers. A run of at least six such strips under the same clips, side by side in one direction with no gap, each a small colour step from the last, now becomes one object filled with a linear gradient through the strips' colours (stops on a straight colour ramp dropped), and the clip path itself when it was the clip's only art. Abutting blocks of distinct colours stay apart. --- crates/eps/src/import.rs | 3 +- crates/eps/src/import/graphics.rs | 2 + crates/eps/src/import/graphics/strips.rs | 301 +++++++++++++++++++++++ crates/eps/src/import/tests.rs | 69 ++++++ docs/file-format-parity.md | 5 +- docs/gaps.md | 4 +- 6 files changed, 380 insertions(+), 4 deletions(-) create mode 100644 crates/eps/src/import/graphics/strips.rs diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 1b0ad86049..bf5ad45831 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -302,7 +302,8 @@ pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result< /// The document of what `out` drew: one layer, clipped art in clipping groups (grouped art in /// groups), the spot inks as spot swatches, in CMYK when most process colours were. fn finish(mut out: Out) -> Imported { - let drawn = std::mem::take(&mut out.drawn); + let mut drawn = std::mem::take(&mut out.drawn); + graphics::merge_strips(&mut drawn, &mut out.shadings); let mut children = vectorcraft_doc::clipnest::nest(&mut out.doc, drawn); if !out.shadings.is_empty() { graphics::collapse(&mut children, &out.shadings); diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs index a21d6c9f66..ea7a43ee82 100644 --- a/crates/eps/src/import/graphics.rs +++ b/crates/eps/src/import/graphics.rs @@ -21,9 +21,11 @@ mod ops; mod paint; mod path; mod state; +mod strips; pub(crate) use paint::collapse; pub(crate) use state::{GState, Out, Space, process}; +pub(crate) use strips::merge_strips; /// Most objects one file makes. const MAX_NODES: usize = 1 << 20; diff --git a/crates/eps/src/import/graphics/strips.rs b/crates/eps/src/import/graphics/strips.rs new file mode 100644 index 0000000000..84146c3383 --- /dev/null +++ b/crates/eps/src/import/graphics/strips.rs @@ -0,0 +1,301 @@ +//! Gradients a program paints as a row of abutting strips, each in one colour (the way many +//! PostScript writers draw a smooth shading at Level 2, which has no `shfill`), read as one +//! linear gradient. +//! +//! Drawn one by one, abutting strips leave seams: a renderer that anti-aliases each fill lets the +//! art below show through along every shared edge (PDF 32000-1:2008 §10.6.4 notes the effect, +//! "conflation", for adjacent areas painted separately), which reads as thin stripes across the +//! gradient, and as a tint of the colour below over the whole of it where the strips are only a +//! pixel or two wide. A run of them under the same clips becomes one object filled with a linear +//! gradient through the strips' colours, which has no seams and stays editable. +//! +//! Only strips that clearly are a gradient merge: at least [`MIN_STRIPS`] parallelograms of one +//! length, side by side in one direction with no gap or overlap, each a small step in colour from +//! the one before ([`MAX_STEP`]). Abutting blocks of distinct colours (a bar chart, a row of +//! swatches) stay as they are. + +use super::{ + Affine, Appearance, AppearanceItem, BezPath, Color, Drawn, FillLayer, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, HashSet, + MAX_STITCH_STOPS, Node, NodeId, NodeKind, Paint, PathData, Point, Vec2, +}; + +/// Fewest strips (that paint something) a gradient is read from. +const MIN_STRIPS: usize = 6; +/// Largest change of any colour component (0 to 1) from one strip to the next. +const MAX_STEP: f32 = 0.08; +/// How far apart corners may be and still meet (points). +const EPS: f64 = 0.01; +/// Largest change of any colour component a stop left out may make to the gradient. +const TOLERANCE: f32 = 0.5 / 255.0; + +/// A filled parallelogram in one process colour. +struct Strip { + corners: [Point; 4], + model: u8, + comps: [f32; 4], + color: Color, + overprint: bool, +} + +/// The parallelogram `n` fills, in one process colour: `None` for anything else. +fn strip_of(n: &Node) -> Option { + let NodeKind::Path { path, live: None, clipping: false, guide: false, .. } = &n.kind else { return None }; + if n.appearance.items.len() != 1 { + return None; + } + let Some(AppearanceItem::Fill(f)) = n.appearance.items.first() else { return None }; + if f.opacity != 1.0 || !f.visible || !f.effects.is_empty() || f.blend != vectorcraft_color::BlendMode::Normal { + return None; + } + let Paint::Solid { color, swatch: None, .. } = &f.paint else { return None }; + let (model, comps) = match *color { + Color::Rgb { r, g, b } => (0, [r, g, b, 0.0]), + Color::Cmyk { c, m, y, k } => (1, [c, m, y, k]), + Color::Gray { k } => (2, [k, 0.0, 0.0, 0.0]), + Color::Lab { .. } => return None, + }; + let [sp] = path.subpaths.as_slice() else { return None }; + let mut pts: Vec = Vec::with_capacity(5); + for a in &sp.anchors { + if a.h_in != a.p || a.h_out != a.p { + return None; + } + pts.push(a.p); + } + if pts.len() == 5 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.distance(*b) <= EPS) { + pts.pop(); + } + let [a, b, c, d] = pts.as_slice() else { return None }; + // Opposite corners share a midpoint. + if (a.to_vec2() + c.to_vec2() - b.to_vec2() - d.to_vec2()).hypot() > EPS { + return None; + } + Some(Strip { corners: [*a, *b, *c, *d], model, comps, color: *color, overprint: f.overprint }) +} + +/// The edge two strips share, as it runs along the first: `(k, from, to)`, from its corner `k`. +fn shared_edge(a: &Strip, b: &Strip) -> Option<(usize, Point, Point)> { + let near = |p: Point, q: Point| p.distance(q) <= EPS; + for k in 0..4 { + let (p, q) = (a.corners[k], a.corners[(k + 1) % 4]); + if p.distance(q) <= EPS { + continue; + } + for j in 0..4 { + let (r, s) = (b.corners[j], b.corners[(j + 1) % 4]); + if (near(p, r) && near(q, s)) || (near(p, s) && near(q, r)) { + return Some((k, p, q)); + } + } + } + None +} + +/// Coordinates along a run of strips: a point is `o + s·w + t·v`, `v` along the strips (their +/// shared edges run from `t` = 0 to 1), `w` across them. +struct Axes { + o: Point, + w: Vec2, + v: Vec2, + inv: Affine, +} + +impl Axes { + fn of(a: &Strip, b: &Strip) -> Option { + let (k, from, to) = shared_edge(a, b)?; + let w = a.corners[(k + 2) % 4] - a.corners[(k + 1) % 4]; + let v = to - from; + let m = Affine::new([w.x, w.y, v.x, v.y, from.x, from.y]); + if w.hypot() <= EPS || m.determinant().abs() < 1e-9 * w.hypot() * v.hypot() { + return None; + } + Some(Self { o: from, w, v, inv: m.inverse() }) + } + + fn at(&self, s: f64, t: f64) -> Point { + self.o + self.w * s + self.v * t + } + + /// The strip's extent across the run (`s` from, to), when it spans the run's length exactly. + fn band(&self, st: &Strip) -> Option<(f64, f64)> { + let (ts, ss) = (EPS / self.v.hypot(), EPS / self.w.hypot()); + let local: Vec = st.corners.iter().map(|p| self.inv * *p).collect(); + let lo = local.iter().map(|p| p.x).fold(f64::INFINITY, f64::min); + let hi = local.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max); + let fits = local + .iter() + .all(|p| ((p.y).abs() <= ts || (p.y - 1.0).abs() <= ts) && ((p.x - lo).abs() <= ss || (p.x - hi).abs() <= ss) && p.x.is_finite()); + // Each end of the strip touches both edges of the run. + let ends = [0.0, 1.0].iter().all(|t| local.iter().filter(|p| (p.y - t).abs() <= ts).count() == 2); + (fits && ends).then_some((lo, hi)) + } +} + +/// A run of strips found at one place in what was drawn: where it ends (exclusive) and the +/// object it becomes. +struct Run { + start: usize, + end: usize, + node: Node, +} + +/// Is `s` (from, to) too thin to paint anything? +fn thin(b: (f64, f64), axes: &Axes) -> bool { + (b.1 - b.0) * axes.w.hypot() <= EPS +} + +/// The run of strips starting at `i`, if one does. +fn run_at(drawn: &Drawn, strips: &[Option], i: usize) -> Option { + let chain = |k: usize| drawn.get(k).map(|(c, _)| c.iter().map(|c| c.id).collect::>()); + let here = chain(i)?; + let strip = |k: usize| strips.get(k).and_then(Option::as_ref).filter(|_| chain(k).as_ref() == Some(&here)); + let first = strip(i)?; + // The run's axes come from its first two strips that paint something; a line before them + // (a strip of no width, as some writers draw first) joins the run where it lies on its edge. + let (lead, a, b) = match (strip(i + 1), strip(i + 2)) { + (Some(b), _) if Axes::of(first, b).is_some_and(|ax| ax.band(first).is_some_and(|f| !thin(f, &ax))) => (None, first, b), + (Some(a), Some(b)) => (Some(first), a, b), + _ => return None, + }; + let axes = Axes::of(a, b)?; + let (ba, bb) = (axes.band(a)?, axes.band(b)?); + if thin(ba, &axes) || thin(bb, &axes) { + return None; + } + let ss = EPS / axes.w.hypot(); + let forward = if (bb.0 - ba.1).abs() <= ss { + true + } else if (bb.1 - ba.0).abs() <= ss { + false + } else { + return None; + }; + let mut edge = if forward { ba.0 } else { ba.1 }; + if let Some(l) = lead { + let lb = axes.band(l)?; + if !thin(lb, &axes) || (lb.0 - edge).abs() > ss { + return None; + } + } + let at = i + usize::from(lead.is_some()); + let mut bands: Vec<(f64, f64, &Strip)> = vec![]; + let mut end = at; + while bands.len() < MAX_STITCH_STOPS { + let Some(st) = strip(end) else { break }; + let Some(band) = axes.band(st) else { break }; + let meets = if forward { (band.0 - edge).abs() <= ss } else { (band.1 - edge).abs() <= ss }; + if !meets || st.overprint != a.overprint { + break; + } + if !thin(band, &axes) { + if let Some((_, _, prev)) = bands.last() + && (prev.model != st.model || prev.comps.iter().zip(&st.comps).any(|(x, y)| (x - y).abs() > MAX_STEP)) + { + break; + } + bands.push((band.0, band.1, st)); + } + edge = if forward { band.1 } else { band.0 }; + end += 1; + } + if bands.len() < MIN_STRIPS { + return None; + } + let lo = bands.iter().map(|b| b.0).fold(f64::INFINITY, f64::min); + let hi = bands.iter().map(|b| b.1).fold(f64::NEG_INFINITY, f64::max); + if !(hi - lo).is_finite() || hi - lo <= 0.0 { + return None; + } + bands.sort_by(|x, y| x.0.total_cmp(&y.0)); + let stops: Vec<(f32, &Strip)> = bands.iter().map(|(s0, s1, st)| ((((s0 + s1) / 2.0 - lo) / (hi - lo)) as f32, *st)).collect(); + let stops: Vec = simplify(&stops).into_iter().map(|(t, st)| GradientStop::new(t, st.color)).collect(); + // The gradient runs across the strips, square to them, from the run's edge at `lo`. + let len = axes.v.hypot(); + let mut n = Vec2::new(-axes.v.y / len, axes.v.x / len); + let across = axes.w.dot(n) * (hi - lo); + if across < 0.0 { + n = -n; + } + let start = axes.at(lo, 0.0); + let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); + g.geom = Some(GradientGeom { start, end: start + n * across.abs(), aspect: 1.0, focal: None }); + let mut bp = BezPath::new(); + bp.move_to(axes.at(lo, 0.0)); + bp.line_to(axes.at(hi, 0.0)); + bp.line_to(axes.at(hi, 1.0)); + bp.line_to(axes.at(lo, 1.0)); + bp.close_path(); + let item = AppearanceItem::Fill(FillLayer { overprint: a.overprint, ..FillLayer::new(Paint::Gradient(Box::new(g))) }); + let id = drawn.get(i).map(|(_, n)| n.id)?; + let node = Node::path(id, PathData::from_bezpath(&bp), Appearance { items: vec![item], ..Appearance::default() }); + Some(Run { start: i, end, node }) +} + +/// The stops `stops` (offset, strip) needs: those the gradient between the stops kept on either +/// side wouldn't come within [`TOLERANCE`] of are kept. +fn simplify<'a>(stops: &[(f32, &'a Strip)]) -> Vec<(f32, &'a Strip)> { + let mut kept: Vec<(f32, &Strip)> = vec![]; + let Some(&first) = stops.first() else { return kept }; + kept.push(first); + let mut from = 0; + while from + 1 < stops.len() { + // The furthest stop reachable from `from` with every stop between on the line. + let mut to = from + 1; + while to + 1 < stops.len() && on_line(stops, from, to + 1) { + to += 1; + } + if let Some(&s) = stops.get(to) { + kept.push(s); + } + from = to; + } + kept +} + +/// Do the stops between `a` and `b` lie on the blend between those two? +fn on_line(stops: &[(f32, &Strip)], a: usize, b: usize) -> bool { + let (Some((ta, sa)), Some((tb, sb))) = (stops.get(a), stops.get(b)) else { return false }; + let span = tb - ta; + stops.get(a + 1..b).unwrap_or_default().iter().all(|(t, s)| { + let f = if span > 0.0 { (t - ta) / span } else { 0.0 }; + sa.comps.iter().zip(&sb.comps).zip(&s.comps).all(|((x, y), z)| (x + (y - x) * f - z).abs() <= TOLERANCE) + }) +} + +/// Runs of strips in `drawn` become one gradient-filled object each (marked in `shadings`, so a +/// clipping group of nothing else is its clip path filled with the gradient). +pub(crate) fn merge_strips(drawn: &mut Drawn, shadings: &mut HashSet) { + let strips: Vec> = drawn.iter().map(|(_, n)| strip_of(n)).collect(); + if strips.iter().filter(|s| s.is_some()).count() < MIN_STRIPS { + return; + } + let mut runs: Vec = vec![]; + let mut i = 0; + while i < drawn.len() { + match run_at(drawn, &strips, i) { + Some(r) => { + i = r.end.max(i + 1); + runs.push(r); + } + None => i += 1, + } + } + if runs.is_empty() { + return; + } + let old = std::mem::take(drawn); + let mut runs = runs.into_iter().peekable(); + let mut skip_to = 0; + for (k, item) in old.into_iter().enumerate() { + if k < skip_to { + continue; + } + if let Some(r) = runs.next_if(|r| r.start == k) { + skip_to = r.end; + shadings.insert(r.node.id); + drawn.push((item.0, r.node)); + continue; + } + drawn.push(item); + } +} diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 86fa8e35dd..6bdaa8819c 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -1407,3 +1407,72 @@ fn bitshift_by_the_most_negative_integer_does_not_panic() { let r = read("0 0 10 10 rectfill 1 -9223372036854775808 bitshift pop -1 -9223372036854775808 bitshift pop"); assert_eq!(objects(&r.document).len(), 1, "{:?}", r.warnings); } + +/// The stops (offset, grey's black ink or red) and the start and end of the linear gradient `n` is +/// filled with. +fn linear_stops(n: &Node) -> (Vec<(f32, f32)>, Point, Point) { + let Some(Paint::Gradient(g)) = fill(n) else { panic!("not a gradient: {:?}", fill(n)) }; + assert_eq!(g.gradient.kind, GradientKind::Linear); + let level = |c: &Color| match *c { + Color::Gray { k } => k, + Color::Rgb { r, .. } => r, + other => panic!("{other:?}"), + }; + let geom = g.geom.unwrap(); + (g.gradient.stops.iter().map(|s| (s.offset, level(&s.color))).collect(), geom.start, geom.end) +} + +#[test] +fn abutting_strips_of_a_gradient_become_one_gradient() { + // A grey ramp drawn right to left as 40 strips 2 pt wide under a clip, after a strip of no + // width on its right edge: the clip path filled with one linear gradient, without seams. + let r = read( + "gsave 10 10 80 80 rectclip 90 10 0 80 rectfill + 0 1 39 { /i exch def i 39 div setgray 88 i 2 mul sub 10 2 80 rectfill } for grestore", + ); + let o = objects(&r.document); + assert_eq!(o.len(), 1, "{o:#?}"); + let n = &o[0]; + assert!(matches!(n.kind, NodeKind::Path { .. })); + assert!(near(bounds(n), Rect::new(10.0, 10.0, 90.0, 90.0)), "{:?}", bounds(n)); + let (stops, start, end) = linear_stops(n); + // A straight ramp needs only its two ends, at the middles of the end strips: black on the + // right, white on the left. + assert_eq!(stops.len(), 2, "{stops:?}"); + let x = |t: f32| start.x + (end.x - start.x) * f64::from(t); + assert!((start.y - end.y).abs() < 1e-6); + let ((t0, k0), (t1, k1)) = (stops[0], stops[1]); + // A grey's level is its black ink: 1 for black. + let (dark, light) = if k0 > k1 { ((x(t0), k0), (x(t1), k1)) } else { ((x(t1), k1), (x(t0), k0)) }; + assert!((dark.0 - 89.0).abs() < 0.01 && (dark.1 - 1.0).abs() < 1e-6, "{dark:?}"); + assert!((light.0 - 11.0).abs() < 0.01 && light.1.abs() < 1e-6, "{light:?}"); +} + +#[test] +fn turned_strips_become_a_gradient_across_them() { + // Strips drawn in a turned space: the gradient runs square to them. + let r = read("50 50 translate 30 rotate 0 1 19 { /i exch def i 19 div 0 0 setrgbcolor i 2 mul 20 sub -20 2 40 rectfill } for"); + let o = objects(&r.document); + assert_eq!(o.len(), 1, "{o:#?}"); + let (stops, start, end) = linear_stops(&o[0]); + assert_eq!(stops.len(), 2, "{stops:?}"); + let d = end - start; + // 30° in PostScript's y-up space is −30° in the document's y-down one. + let angle = d.y.atan2(d.x).to_degrees(); + assert!((angle + 30.0).abs() < 0.01 || (angle - 150.0).abs() < 0.01, "{angle}"); + assert!((d.hypot() - 40.0).abs() < 0.01, "{}", d.hypot()); +} + +#[test] +fn abutting_blocks_of_distinct_colours_stay_apart() { + // Side by side but each a different colour (a row of swatches): not a gradient. + let blocks: String = + (0..8).map(|i| format!("{} {} {} setrgbcolor {} 10 10 80 rectfill ", i % 2, (i / 2) % 2, (i / 4) % 2, 10 + i * 10)).collect(); + assert_eq!(objects(&read(&blocks).document).len(), 8); + // Too few strips to be sure of. + let few: String = (0..5).map(|i| format!("{} setgray {} 10 2 80 rectfill ", f64::from(i) / 40.0, 10 + i * 2)).collect(); + assert_eq!(objects(&read(&few).document).len(), 5); + // Strips with gaps between them. + let gaps: String = (0..10).map(|i| format!("{} setgray {} 10 2 80 rectfill ", f64::from(i) / 40.0, 10 + i * 3)).collect(); + assert_eq!(objects(&read(&gaps).document).len(), 10); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d3e2f03031..af857ac0b4 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-06 · **Change:** minor (EPS import: gradients drawn as abutting strips come in as one gradient, without seams; 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); a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | | SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`) | | 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-06 | minor | EPS import: a gradient the page paints as a row of abutting one-colour strips imports as one object filled with a linear gradient through the strips' colours (fewer stops where the colours change evenly), its clip path filled with it when it was the clip's only art. Drawn one by one, the strips left seams where the art below showed through each shared edge (PDF 32000-1 §10.6.4, conflation), a pale tint across strips a pixel or two wide; an Illustrator EPS whose page drew its gradients that way differed from its layers and opened as its page. At least 6 strips side by side, each a small colour step from the last; abutting blocks of distinct colours stay apart. Tests `import::tests::abutting_strips_of_a_gradient_become_one_gradient`, `turned_strips_become_a_gradient_across_them`, `abutting_blocks_of_distinct_colours_stay_apart`; 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 | diff --git a/docs/gaps.md b/docs/gaps.md index adb50d60fe..dbddf1277c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -36,6 +36,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | G9: EPS import reads gradients a page paints as abutting strips as one linear gradient each (no seams); an Illustrator EPS whose page drew them that way keeps its layers. Estimate unchanged. | | 2026-11-05 | minor | G15: long open strokes with an undashed width profile are outlined near the view while keeping profile positions on the full path; a 200,000-segment outline took 59.95 ms before and 8.44 ms after in local median runs. Closed and dashed profile strokes remain whole; estimate unchanged. | | 2026-11-05 | minor | G15: Direct Selection reuses one document hit result when choosing the cursor over a path; a 200,000-anchor path's segment cursor took 58.15 ms before and 29.33 ms after in local median runs. Cursor behavior is unchanged; estimate unchanged. | | 2026-11-05 | minor | G15: Selection skips building corner and pie widgets when the pointer is well outside a large selection; empty-canvas hover with 20,000 selected paths took 28.72 ms before and 16.41 ms after in local median runs. Other cursor targets remain active; estimate unchanged. | @@ -182,6 +183,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G9 Illustrator `.ai` and EPS files from real users +- **Done (2026-11-06):** an EPS page that paints its gradients as rows of abutting one-colour strips imports them as linear gradients, so the seams between strips no longer show and an Illustrator EPS drawn that way keeps its layers instead of opening as its page. This narrows G9; scores and estimates unchanged. - **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out; EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged. - **Save-back update (2026-10-26):** ordinary EPS Save retains page margins and the whole editable document; source-loss confirmation remains. Import fidelity gaps and estimates below are unchanged. From fc7e1bf0bdd9ace6ba42c604bb4b2b9c92ebc4c1 Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 7 Nov 2026 00:11:56 -0500 Subject: [PATCH 2/3] Docs: keep this branch's entries that the merge with main dropped --- docs/gaps.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/gaps.md b/docs/gaps.md index 4dac9431ee..bda46c5610 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -37,6 +37,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | G9: EPS import reads gradients a page paints as abutting strips as one linear gradient each (no seams); an Illustrator EPS whose page drew them that way keeps its layers. Estimate unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | | 2026-11-05 | minor | G15: long open strokes with an undashed width profile are outlined near the view while keeping profile positions on the full path; a 200,000-segment outline took 59.95 ms before and 8.44 ms after in local median runs. Closed and dashed profile strokes remain whole; estimate unchanged. | | 2026-11-05 | minor | G15: Direct Selection reuses one document hit result when choosing the cursor over a path; a 200,000-anchor path's segment cursor took 58.15 ms before and 29.33 ms after in local median runs. Cursor behavior is unchanged; estimate unchanged. | @@ -184,6 +185,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G9 Illustrator `.ai` and EPS files from real users +- **Done (2026-11-06):** an EPS page that paints its gradients as rows of abutting one-colour strips imports them as linear gradients, so the seams between strips no longer show and an Illustrator EPS drawn that way keeps its layers instead of opening as its page. This narrows G9; scores and estimates unchanged. - **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out; EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged. - **Save-back update (2026-10-26):** ordinary EPS Save retains page margins and the whole editable document; source-loss confirmation remains. Import fidelity gaps and estimates below are unchanged. From 28f8f84fd5a64b71e1195a920968ddbbf8e7096b Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 7 Nov 2026 05:01:25 -0500 Subject: [PATCH 3/3] EPS import: strips under a larger clip keep the clip A run of gradient strips under a clip larger than the strips became the clip path filled with the gradient, painting where the strips did not. The clip path now takes the gradient only when every point of the clip lies inside the run; otherwise the gradient stays clipped to the strips' area. --- crates/eps/src/import/graphics/strips.rs | 40 ++++++++++++++++++------ crates/eps/src/import/tests.rs | 17 ++++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/crates/eps/src/import/graphics/strips.rs b/crates/eps/src/import/graphics/strips.rs index 84146c3383..9f0b39a24f 100644 --- a/crates/eps/src/import/graphics/strips.rs +++ b/crates/eps/src/import/graphics/strips.rs @@ -16,7 +16,7 @@ use super::{ Affine, Appearance, AppearanceItem, BezPath, Color, Drawn, FillLayer, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, HashSet, - MAX_STITCH_STOPS, Node, NodeId, NodeKind, Paint, PathData, Point, Vec2, + MAX_STITCH_STOPS, Node, NodeId, NodeKind, Paint, PathData, PathEl, Point, Vec2, }; /// Fewest strips (that paint something) a gradient is read from. @@ -116,6 +116,13 @@ impl Axes { self.o + self.w * s + self.v * t } + /// Does `p` lie within the run's length, and across it from `lo` to `hi`? + fn holds(&self, p: Point, lo: f64, hi: f64) -> bool { + let (ts, ss) = (EPS / self.v.hypot(), EPS / self.w.hypot()); + let l = self.inv * p; + l.x >= lo - ss && l.x <= hi + ss && l.y >= -ts && l.y <= 1.0 + ts + } + /// The strip's extent across the run (`s` from, to), when it spans the run's length exactly. fn band(&self, st: &Strip) -> Option<(f64, f64)> { let (ts, ss) = (EPS / self.v.hypot(), EPS / self.w.hypot()); @@ -131,12 +138,14 @@ impl Axes { } } -/// A run of strips found at one place in what was drawn: where it ends (exclusive) and the -/// object it becomes. +/// A run of strips found at one place in what was drawn: where it ends (exclusive), the object +/// it becomes, and whether that object covers the clip around it (so the clip path filled with +/// the gradient paints no more than the strips did). struct Run { start: usize, end: usize, node: Node, + covers_clip: bool, } /// Is `s` (from, to) too thin to paint anything? @@ -226,9 +235,19 @@ fn run_at(drawn: &Drawn, strips: &[Option<Strip>], i: usize) -> Option<Run> { bp.line_to(axes.at(lo, 1.0)); bp.close_path(); let item = AppearanceItem::Fill(FillLayer { overprint: a.overprint, ..FillLayer::new(Paint::Gradient(Box::new(g))) }); - let id = drawn.get(i).map(|(_, n)| n.id)?; - let node = Node::path(id, PathData::from_bezpath(&bp), Appearance { items: vec![item], ..Appearance::default() }); - Some(Run { start: i, end, node }) + let (clips, first_node) = drawn.get(i)?; + // Every point of a clip's path, control points included, inside the run: its curves are too + // (each lies in the hull of its points), and the run's area is convex. + let covers_clip = clips.last().and_then(|c| c.region.as_ref()).is_some_and(|(clip, _)| { + clip.elements().iter().all(|el| match *el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => axes.holds(p, lo, hi), + PathEl::QuadTo(a, p) => axes.holds(a, lo, hi) && axes.holds(p, lo, hi), + PathEl::CurveTo(a, b, p) => axes.holds(a, lo, hi) && axes.holds(b, lo, hi) && axes.holds(p, lo, hi), + PathEl::ClosePath => true, + }) + }); + let node = Node::path(first_node.id, PathData::from_bezpath(&bp), Appearance { items: vec![item], ..Appearance::default() }); + Some(Run { start: i, end, node, covers_clip }) } /// The stops `stops` (offset, strip) needs: those the gradient between the stops kept on either @@ -262,8 +281,9 @@ fn on_line(stops: &[(f32, &Strip)], a: usize, b: usize) -> bool { }) } -/// Runs of strips in `drawn` become one gradient-filled object each (marked in `shadings`, so a -/// clipping group of nothing else is its clip path filled with the gradient). +/// Runs of strips in `drawn` become one gradient-filled object each; one that covers its clip is +/// marked in `shadings`, so a clipping group of nothing else is its clip path filled with the +/// gradient (a clip larger than the strips keeps clipping the gradient to them). pub(crate) fn merge_strips(drawn: &mut Drawn, shadings: &mut HashSet<NodeId>) { let strips: Vec<Option<Strip>> = drawn.iter().map(|(_, n)| strip_of(n)).collect(); if strips.iter().filter(|s| s.is_some()).count() < MIN_STRIPS { @@ -292,7 +312,9 @@ pub(crate) fn merge_strips(drawn: &mut Drawn, shadings: &mut HashSet<NodeId>) { } if let Some(r) = runs.next_if(|r| r.start == k) { skip_to = r.end; - shadings.insert(r.node.id); + if r.covers_clip { + shadings.insert(r.node.id); + } drawn.push((item.0, r.node)); continue; } diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 03bce703db..4bc8a65be0 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -1486,6 +1486,23 @@ fn abutting_strips_of_a_gradient_become_one_gradient() { assert!((light.0 - 11.0).abs() < 0.01 && light.1.abs() < 1e-6, "{light:?}"); } +#[test] +fn strips_under_a_larger_clip_keep_the_clip() { + // The same ramp under a clip larger than the strips: the gradient fills only where the + // strips were, still clipped, not the whole clip. + let r = read( + "gsave 5 5 90 90 rectclip + 0 1 39 { /i exch def i 39 div setgray 88 i 2 mul sub 10 2 80 rectfill } for grestore", + ); + let o = objects(&r.document); + assert_eq!(o.len(), 1, "{o:#?}"); + let NodeKind::Group { children, clip: true } = &o[0].kind else { panic!("not a clipping group: {:#?}", o[0]) }; + let [_, fill] = children.as_slice() else { panic!("{children:#?}") }; + assert!(near(bounds(fill), Rect::new(10.0, 10.0, 90.0, 90.0)), "{:?}", bounds(fill)); + let (stops, _, _) = linear_stops(fill); + assert_eq!(stops.len(), 2, "{stops:?}"); +} + #[test] fn turned_strips_become_a_gradient_across_them() { // Strips drawn in a turned space: the gradient runs square to them.