Merge pull request #5991 from mockydevs/fix/eps01-editing-overlay

EPS import: gradients drawn as abutting strips come in as one gradient, without seams
This commit is contained in:
BFlat
2026-11-09 07:57:02 +08:00
committed by GitHub
6 changed files with 419 additions and 4 deletions
+2 -1
View File
@@ -308,7 +308,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);
+2
View File
@@ -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;
+323
View File
@@ -0,0 +1,323 @@
//! 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, PathEl, 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<Strip> {
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<Point> = 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<Self> {
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
}
/// 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());
let local: Vec<Point> = 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), 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?
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<Strip>], i: usize) -> Option<Run> {
let chain = |k: usize| drawn.get(k).map(|(c, _)| c.iter().map(|c| c.id).collect::<Vec<_>>());
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<GradientStop> = 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 (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
/// 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; 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 {
return;
}
let mut runs: Vec<Run> = 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;
if r.covers_clip {
shadings.insert(r.node.id);
}
drawn.push((item.0, r.node));
continue;
}
drawn.push(item);
}
}
+86
View File
@@ -1462,3 +1462,89 @@ 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 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.
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);
}
+3 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (EPS import: composite fonts draw their Type 42 glyphs; 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: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; 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
@@ -274,7 +274,7 @@ An object in a pattern's art or in a brush's art takes the file's art styles in
| 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); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and set other descendants' characters as type at the composite font's size (other mappings and CID-keyed fonts are still set as type in the font of their name); 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); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and set other descendants' characters as type at the composite font's size (other mappings and CID-keyed fonts are still set as type in the font of their name); 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 `<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 |
@@ -320,6 +320,7 @@ An object in a pattern's art or in a brush's art takes the file's art styles in
| 2026-11-06 | minor | EPS and `.ai` editing data: the art styles are found before the art is read, so objects in symbols defined ahead of `%AI9_BeginArtStyles` (as Illustrator 24 writes it) come in as themselves with their style instead of their drawn look. Checked on two users' EPS files (used locally, not committed): one's Symbols panel gains live Feather and Round Corners, its drawn-look count drops from 5 to 2, and both render unchanged; own-fixture test in `import::ai::tests`; scores and hours unchanged |
| 2026-11-06 | minor | Legacy `.ai` (version 7) export: the clipping path around a flattened transparent image followed 2 pt cells, a staircase that was visibly jagged at the image's resolution. It now follows the image's own pixels (in blocks only past 4 million pixels), grown by one pixel so the whole anti-aliased edge is inside, and runs through the middle of each pixel edge with Douglas–Peucker simplification, so slopes and curves are sloped lines instead of steps (#5264). Version 7 images have no alpha channel (§8.1 of the published format specification), so the image itself is still opaque. Test `legacy_ai::a_flattened_images_clip_follows_its_edge_smoothly` (crate `vectorcraft-engine`); scores unchanged |
| 2026-11-06 | minor | Paste: cells copied from Excel, Google Sheets, LibreOffice Calc or Numbers (tab-separated text, quoted cells too) paste as a group of point type, one object per cell in columns and rows, over 0.5 pt gridlines; other text pastes as before; scores and estimates unchanged (#5296) |
| 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 |
+3 -1
View File
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; 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; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; 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
@@ -38,6 +38,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-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. |
| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates 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. |
@@ -187,6 +188,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 (the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box, and the fit under Gravity does not reproduce the file's layout); 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.