mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 13:17:19 -06:00
SVG export: rectangles, circles, ellipses, lines and straight paths as SVG shape elements (#4652)
This commit is contained in:
@@ -68,7 +68,7 @@ fn solid_effects_apply_save_edit_export_expand_and_undo() {
|
||||
assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"bevel": "nope"}, "ids": [id.0], "item": null})).is_err());
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
|
||||
let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string());
|
||||
assert!(svg.matches("<path").count() >= 2, "{effect}: exported as vector faces");
|
||||
assert!(vectorcraft_testkit::svg_shapes(&svg) >= 2, "{effect}: exported as vector faces");
|
||||
s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap();
|
||||
assert!(matches!(node(&s, id).kind, NodeKind::Group { .. }), "{effect}");
|
||||
assert!(!effects::has_solid(&node(&s, id)));
|
||||
|
||||
@@ -101,7 +101,7 @@ fn selected_only_exports_the_selection_in_its_layers() {
|
||||
// Other formats take it too.
|
||||
s.execute("select.set", &json!({"ids": [line]})).unwrap();
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg", "selectedOnly": true})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert_eq!((svg.matches("<path").count(), svg.matches("<rect").count()), (1, 0), "{svg}");
|
||||
assert_eq!((vectorcraft_testkit::svg_shapes(&svg), svg.matches("<line ").count()), (1, 1), "{svg}");
|
||||
assert!(svg.contains("id=\"Top\"") && !svg.contains("Layer"), "{svg}");
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ fn every_option_changes_the_output() {
|
||||
changed(&mut s, json!({"svg": {"styling": "css"}}), &|o| o.contains("<style>") && o.contains("class=\"cls-1\""));
|
||||
changed(&mut s, json!({"objectIds": "minimal"}), &|o| !o.contains(" id=\""));
|
||||
changed(&mut s, json!({"objectIds": "unique"}), &|o| o.contains(" id=\"u") && o.contains("-Layer_1\""));
|
||||
changed(&mut s, json!({"decimals": 1}), &|o| o.contains("M10.1 ") && !o.contains("10.123"));
|
||||
changed(&mut s, json!({"decimals": 1}), &|o| o.contains("x=\"10.1\"") && !o.contains("10.123"));
|
||||
changed(&mut s, json!({"decimals": 6}), &|o| o.contains("10.123456"));
|
||||
changed(&mut s, json!({"minify": true}), &|o| !o.trim_end().contains('\n') && !o.contains("<?xml"));
|
||||
changed(&mut s, json!({"responsive": true}), &|o| !o.contains(" width=\"200\"") && o.contains("viewBox=\"0 0 200 100\""));
|
||||
@@ -218,7 +218,7 @@ fn save_writes_svg_and_remembers_its_options() {
|
||||
s.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 30, "height": 30})).unwrap();
|
||||
s.execute("document.save", &json!({})).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.contains("<style>") && text.matches("<path").count() == 2, "Save reuses the options: {text}");
|
||||
assert!(text.contains("<style>") && vectorcraft_testkit::svg_shapes(&text) == 2, "Save reuses the options: {text}");
|
||||
assert!(s.execute("document.save", &json!({"path": dir.join("x.png").to_string_lossy()})).is_err(), "PNG is an export");
|
||||
// A native save switches back.
|
||||
let native = dir.join("doc.vectorcraft").to_string_lossy().to_string();
|
||||
|
||||
@@ -61,7 +61,7 @@ fn a_chosen_artboard_s_svg_holds_only_its_own_art() {
|
||||
s.execute("shape.rectangle", &json!({"x": first + 10.0, "y": 10, "width": 30, "height": 30})).unwrap();
|
||||
s.execute("shape.ellipse", &json!({"x": third + 10.0, "y": 10, "width": 30, "height": 30})).unwrap();
|
||||
// A rectangle has straight sides only; the ellipse is curves.
|
||||
let shapes = |svg: &str| (svg.matches("<path").count() + svg.matches("<rect").count(), svg.contains(" C") || svg.contains("<ellipse"));
|
||||
let shapes = |svg: &str| (vectorcraft_testkit::svg_shapes(svg), svg.contains(" C") || svg.contains("<ellipse") || svg.contains("<circle"));
|
||||
let text = |v: &serde_json::Value| v["text"].as_str().unwrap().to_string();
|
||||
let third_only = text(&s.execute("document.serialize", &json!({"format": "svg", "useArtboards": true, "range": "3"})).unwrap());
|
||||
assert_eq!(shapes(&third_only), (1, true), "{third_only}");
|
||||
@@ -88,8 +88,8 @@ fn a_chosen_artboard_s_svg_leaves_out_its_neighbours_art_in_a_shared_group() {
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
let text = |v: &serde_json::Value| v["text"].as_str().unwrap().to_string();
|
||||
let third_only = text(&s.execute("document.serialize", &json!({"format": "svg", "useArtboards": true, "range": "3"})).unwrap());
|
||||
assert_eq!(third_only.matches("<path").count() + third_only.matches("<rect").count(), 1, "{third_only}");
|
||||
assert!(third_only.contains(" C") || third_only.contains("<ellipse"), "{third_only}");
|
||||
assert_eq!(vectorcraft_testkit::svg_shapes(&third_only), 1, "{third_only}");
|
||||
assert!(third_only.contains(" C") || third_only.contains("<ellipse") || third_only.contains("<circle"), "{third_only}");
|
||||
}
|
||||
|
||||
/// An object off a chosen artboard whose drop shadow reaches onto it is in that artboard's SVG.
|
||||
@@ -102,7 +102,7 @@ fn a_chosen_artboard_s_svg_keeps_an_object_whose_shadow_reaches_it() {
|
||||
let paths = |s: &mut Session| {
|
||||
let svg = s.execute("document.serialize", &json!({"format": "svg", "artboard": 0})).unwrap();
|
||||
let text = svg["text"].as_str().unwrap().to_string();
|
||||
text.matches("<path").count() + text.matches("<rect").count()
|
||||
vectorcraft_testkit::svg_shapes(&text)
|
||||
};
|
||||
assert_eq!(paths(&mut s), 0, "the plain rectangle is off the artboard");
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id], "params": {"x": 20, "y": 0, "blur": 0}})).unwrap();
|
||||
|
||||
@@ -69,7 +69,7 @@ fn a_clipping_paths_fill_and_stroke_show_in_render_svg_and_pdf() {
|
||||
assert_eq!(rgb(&doc, 120), [255, 255, 255]);
|
||||
assert_eq!(rgb(&doc, 45), [255, 255, 255]);
|
||||
let svg = vectorcraft_svg::export(&doc, &Default::default());
|
||||
assert_eq!(svg.matches("<path").count(), 2, "the clip region and the red art: {svg}");
|
||||
assert_eq!(vectorcraft_testkit::svg_shapes(&svg), 2, "the clip region and the red art: {svg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -336,7 +336,7 @@ fn type_in_a_group_with_a_geometry_effect_exports_and_expands_with_its_look() {
|
||||
run(&mut s, "select.set", json!({"ids": [t.0]}));
|
||||
run(&mut s, "object.hide", json!({}));
|
||||
let hidden = svg(&s);
|
||||
assert_eq!(hidden.matches("<path").count() + 1, shown.matches("<path").count(), "{hidden}");
|
||||
assert_eq!(vectorcraft_testkit::svg_shapes(&hidden) + 1, vectorcraft_testkit::svg_shapes(&shown), "{hidden}");
|
||||
// Expand Appearance keeps the type's opacity and visibility on its outlines.
|
||||
run(&mut s, "select.set", json!({"ids": [g.0]}));
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
@@ -355,5 +355,5 @@ fn hidden_type_with_a_geometry_effect_is_left_out_of_svg_export() {
|
||||
run(&mut s, "select.set", json!({"ids": [t.0]}));
|
||||
run(&mut s, "object.hide", json!({}));
|
||||
let hidden = svg(&s);
|
||||
assert_eq!((shown.matches("<path").count(), hidden.matches("<path").count()), (1, 0), "{hidden}");
|
||||
assert_eq!((vectorcraft_testkit::svg_shapes(&shown), vectorcraft_testkit::svg_shapes(&hidden)), (1, 0), "{hidden}");
|
||||
}
|
||||
|
||||
@@ -409,7 +409,7 @@ fn export_selection_crops_to_the_selection() {
|
||||
assert_eq!(u32::from_be_bytes([png[16], png[17], png[18], png[19]]), (w * 2.0).round() as u32);
|
||||
let svg = s.execute("document.exportSelection", &json!({"format": "svg"})).unwrap();
|
||||
let text = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
|
||||
assert_eq!(text.matches("<path").count(), 1, "{text}");
|
||||
assert_eq!(vectorcraft_testkit::svg_shapes(&text), 1, "{text}");
|
||||
}
|
||||
|
||||
/// The crop of a selection export takes in the object's effects: a drop shadow, an outer glow and
|
||||
|
||||
@@ -1094,7 +1094,7 @@ fn svg_export_expands_live_objects() {
|
||||
let s = live_doc();
|
||||
let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
|
||||
// 4 blend objects + envelope content + 4 patches × 64 mesh pieces.
|
||||
let paths = svg.matches("<path").count();
|
||||
let paths = vectorcraft_testkit::svg_shapes(&svg);
|
||||
assert!(paths >= 4 + 1 + 256, "{paths}");
|
||||
assert!(vectorcraft_svg::import(&svg).is_ok());
|
||||
}
|
||||
|
||||
@@ -495,7 +495,7 @@ fn svg_export_writes_pattern_def() {
|
||||
// The tile art is inside the pattern.
|
||||
let start = svg.find("<pattern").unwrap();
|
||||
let end = svg.find("</pattern>").unwrap();
|
||||
assert!(svg[start..end].contains("<path"));
|
||||
assert!(vectorcraft_testkit::svg_shapes(&svg[start..end]) > 0);
|
||||
}
|
||||
|
||||
/// A pattern fill is written as a PDF tiling pattern, which PDF import reads back as a pattern
|
||||
|
||||
@@ -157,9 +157,9 @@ fn crop_marks_effect_follows_the_object() {
|
||||
run(&mut s, "object.move", json!({"dx": 10, "dy": 0}));
|
||||
assert_eq!(mark(&s, 48)[0], 255, "nothing left behind");
|
||||
assert!(mark(&s, 88)[0] < 200);
|
||||
// SVG export and Expand Appearance draw the marks as paths.
|
||||
// SVG export and Expand Appearance draw the marks as shapes.
|
||||
let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
|
||||
assert_eq!(svg.matches("<path").count(), 9, "{svg}");
|
||||
assert_eq!(vectorcraft_testkit::svg_shapes(&svg), 9, "{svg}");
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
let n = node(&s, a);
|
||||
assert!(n.appearance.effects.is_empty() && n.children().unwrap().len() == 2, "a group of the object and its marks");
|
||||
|
||||
@@ -48,7 +48,7 @@ fn all_four_light_colors_save_reopen_undo_and_expand_without_editing_source() {
|
||||
assert_eq!(doc(&s), &red);
|
||||
let svg = s.execute("document.serialize", &json!({"format":"svg"})).unwrap();
|
||||
let text = svg["text"].as_str().unwrap();
|
||||
assert!(text.contains("<path") && !text.contains("<image"), "{effect}");
|
||||
assert!(vectorcraft_testkit::svg_shapes(text) > 0 && !text.contains("<image"), "{effect}");
|
||||
if effect == "threeD.rotate" {
|
||||
assert!(text.contains("#ff4040"), "front plane is white under red light and neutral 25% ambient: {text}");
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ use vectorcraft_doc::{
|
||||
};
|
||||
use vectorcraft_doc::{CharStyle, ImageBlob, Justify};
|
||||
use vectorcraft_effects::stroke::{self, Written, WrittenShape};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
|
||||
use vectorcraft_geom::shapes::KAPPA;
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, SubPath};
|
||||
|
||||
use vectorcraft_text::{FontDb, FontFace};
|
||||
|
||||
|
||||
@@ -131,8 +131,10 @@ pub(crate) const MASK_FLAGS: &str = "data-vectorcraft-mask";
|
||||
|
||||
impl Writer<'_> {
|
||||
/// Paint a shape (`d`) with a node's appearance stack. `paths` is the same shape in document
|
||||
/// space, for strokes that aren't plain (it may be empty when all are).
|
||||
pub(super) fn shape(&mut self, n: &Node, d: &str, paths: &[&PathData], rule: FillRule, bounds: Option<Rect>) {
|
||||
/// space, for strokes that aren't plain (it may be empty when all are). `basic` is the shape
|
||||
/// as an SVG basic shape element ([`Writer::basic_shape`]) to paint instead of `<path>`.
|
||||
pub(super) fn shape(&mut self, n: &Node, d: &str, basic: Option<(&str, String)>, paths: &[&PathData], rule: FillRule, bounds: Option<Rect>) {
|
||||
let (tag, geom) = basic.unwrap_or_else(|| ("path", format!(" d=\"{d}\"")));
|
||||
let id = self.id_attr(n);
|
||||
let items: Vec<&AppearanceItem> = n
|
||||
.appearance
|
||||
@@ -180,7 +182,7 @@ impl Writer<'_> {
|
||||
}
|
||||
p.extend(css::transparency(n));
|
||||
let a = self.attrs(&p);
|
||||
self.line(&format!("<path{id} d=\"{d}\"{a}/>"));
|
||||
self.line(&format!("<{tag}{id}{geom}{a}/>"));
|
||||
return;
|
||||
}
|
||||
let a = self.attrs(&css::transparency(n));
|
||||
@@ -201,7 +203,7 @@ impl Writer<'_> {
|
||||
self.fill_props(f, rule, bounds, &mut p);
|
||||
p.extend(blend);
|
||||
let a = self.attrs(&p);
|
||||
self.line(&format!("<path d=\"{d}\"{a}/>"));
|
||||
self.line(&format!("<{tag}{geom}{a}/>"));
|
||||
}
|
||||
}
|
||||
(AppearanceItem::Stroke(s), Some(StrokePlan::Brush(art))) => {
|
||||
@@ -246,7 +248,7 @@ impl Writer<'_> {
|
||||
self.stroke_props(s, *width, paint_bounds(s), &mut p);
|
||||
p.extend(blend);
|
||||
let a = self.attrs(&p);
|
||||
self.line(&format!("<path d=\"{d}\"{a}{side}/>"));
|
||||
self.line(&format!("<{tag}{geom}{a}{side}/>"));
|
||||
}
|
||||
(WrittenShape::Fill(outlines), _) if s.path_gradient().is_some() => {
|
||||
// A gradient along or across the stroke: slices clipped to its outlines.
|
||||
@@ -548,7 +550,10 @@ impl Writer<'_> {
|
||||
return;
|
||||
}
|
||||
let d = self.path_d(path, self.xf);
|
||||
self.shape(n, &d, &[path], *rule, n.geometric_bounds());
|
||||
let dashed =
|
||||
n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.dash.as_ref().is_some_and(|d| d.is_dashed())));
|
||||
let basic = self.basic_shape(path, self.xf, dashed);
|
||||
self.shape(n, &d, basic, &[path], *rule, n.geometric_bounds());
|
||||
}
|
||||
NodeKind::Compound { children, rule } => {
|
||||
let paths: Vec<&PathData> = children.iter().filter(|c| c.visible).filter_map(|c| c.path_data()).filter(|p| !p.is_empty()).collect();
|
||||
@@ -556,7 +561,7 @@ impl Writer<'_> {
|
||||
return;
|
||||
}
|
||||
let d: Vec<String> = paths.iter().map(|p| self.path_d(p, self.xf)).collect();
|
||||
self.shape(n, &d.join(" "), &paths, *rule, n.geometric_bounds());
|
||||
self.shape(n, &d.join(" "), None, &paths, *rule, n.geometric_bounds());
|
||||
}
|
||||
NodeKind::Text(t) => self.text_node(n, t),
|
||||
NodeKind::Image(im) => {
|
||||
|
||||
@@ -418,9 +418,102 @@ impl Writer<'_> {
|
||||
s
|
||||
}
|
||||
|
||||
/// `p` written with `m` as the SVG basic shape it exactly is, as the reference app's SVG
|
||||
/// export writes simple shapes (#4652): an axis-aligned `<rect>`, `<circle>` or `<ellipse>`,
|
||||
/// a `<line>`, or a `<polygon>`/`<polyline>` of straight segments. The element name and its
|
||||
/// geometry attributes, at the written precision; `None` keeps `<path>`. `<rect>` and
|
||||
/// `<ellipse>` start their outline elsewhere than the path, so with a `dashed` stroke a
|
||||
/// rectangle is a `<polygon>` and an ellipse stays `<path>`.
|
||||
/// Sources: an Illustrator ellipse is an `ellipse` tag unless an anchor is added
|
||||
/// (<https://community.adobe.com/t5/illustrator-discussions/trouble-exporting-shape-with-points-to-svg/td-p/10720325>);
|
||||
/// rectangles export as `<rect>` and `<polygon>`
|
||||
/// (<https://community.adobe.com/t5/illustrator-discussions/force-svg-export-using-all-lt-rect-gt/td-p/11282378>);
|
||||
/// a triangle without curves exports as a polygon (<https://hackaday.io/project/170755/log/175565>).
|
||||
pub(super) fn basic_shape(&self, p: &PathData, m: Affine, dashed: bool) -> Option<(&'static str, String)> {
|
||||
let [sp] = p.subpaths.as_slice() else { return None };
|
||||
if !(0..sp.segment_count()).all(|i| sp.segment_is_line(i)) {
|
||||
return self.ellipse_shape(sp, m).filter(|_| !dashed);
|
||||
}
|
||||
let pts: Vec<Point> = sp.anchors.iter().map(|a| m * a.p).collect();
|
||||
if sp.closed && !dashed && pts.len() == 4 {
|
||||
// The corners as written, so the rectangle's numbers are the path's own.
|
||||
let snap = |v: f64| self.num(v).parse::<f64>().unwrap_or(v);
|
||||
let corners: Vec<Point> = pts.iter().map(|q| Point::new(snap(q.x), snap(q.y))).collect();
|
||||
if let Some(r) = axis_rect(&corners) {
|
||||
let (x, y, w, h) = (self.num(r.x0), self.num(r.y0), self.num(r.width()), self.num(r.height()));
|
||||
return Some(("rect", format!(" x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\"")));
|
||||
}
|
||||
}
|
||||
let points = |tag: &'static str| {
|
||||
let mut s = String::from(" points=\"");
|
||||
for (i, q) in pts.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push(' ');
|
||||
}
|
||||
crate::push_num(&mut s, q.x, self.opts.decimals);
|
||||
s.push(',');
|
||||
crate::push_num(&mut s, q.y, self.opts.decimals);
|
||||
}
|
||||
s.push('"');
|
||||
Some((tag, s))
|
||||
};
|
||||
match (sp.closed, pts.as_slice()) {
|
||||
(false, [a, b]) => {
|
||||
let (x1, y1, x2, y2) = (self.num(a.x), self.num(a.y), self.num(b.x), self.num(b.y));
|
||||
// A line of zero length as written keeps `<path>`: renderers disagree on its caps.
|
||||
((&x1, &y1) != (&x2, &y2)).then(|| ("line", format!(" x1=\"{x1}\" y1=\"{y1}\" x2=\"{x2}\" y2=\"{y2}\"")))
|
||||
}
|
||||
(false, [_, _, _, ..]) => points("polyline"),
|
||||
(true, [_, _, _, ..]) => points("polygon"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed 4-anchor subpath that is an axis-aligned ellipse drawn the standard way (each
|
||||
/// quarter a cubic with handles [`KAPPA`] of the radii long), within the written precision:
|
||||
/// `<circle>` when its radii are written the same, else `<ellipse>`.
|
||||
fn ellipse_shape(&self, sp: &SubPath, m: Affine) -> Option<(&'static str, String)> {
|
||||
let tol = 10f64.powi(-i32::from(self.opts.decimals));
|
||||
let (anchors, closed) = (sp.anchors.as_slice(), sp.closed);
|
||||
let [a0, a1, a2, a3] = anchors else { return None };
|
||||
let [p0, p1, p2, p3] = [a0.p, a1.p, a2.p, a3.p].map(|p| m * p);
|
||||
let c = p0.midpoint(p2);
|
||||
let near = |a: f64, b: f64| (a - b).abs() <= tol;
|
||||
// Opposite anchors share the centre: one pair on the horizontal axis, the other on the vertical.
|
||||
let (rx, ry) = if near(p0.y, c.y) && near(p1.x, c.x) {
|
||||
((p0.x - c.x).abs(), (p1.y - c.y).abs())
|
||||
} else if near(p0.x, c.x) && near(p1.y, c.y) {
|
||||
((p1.x - c.x).abs(), (p0.y - c.y).abs())
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let quarters = anchors.iter().zip(anchors.iter().cycle().skip(1)).all(|(a, b)| {
|
||||
let (pa, pb) = (m * a.p, m * b.p);
|
||||
(m * a.h_out).distance(pa + (pb - c) * KAPPA) <= tol && (m * b.h_in).distance(pb + (pa - c) * KAPPA) <= tol
|
||||
});
|
||||
if !closed || c.distance(p1.midpoint(p3)) > tol || rx <= tol || ry <= tol || !quarters {
|
||||
return None;
|
||||
}
|
||||
let (cx, cy, rx, ry) = (self.num(c.x), self.num(c.y), self.num(rx), self.num(ry));
|
||||
Some(if rx == ry {
|
||||
("circle", format!(" cx=\"{cx}\" cy=\"{cy}\" r=\"{rx}\""))
|
||||
} else {
|
||||
("ellipse", format!(" cx=\"{cx}\" cy=\"{cy}\" rx=\"{rx}\" ry=\"{ry}\""))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn warn(&mut self, w: &str) {
|
||||
if !self.warnings.iter().any(|x| x == w) {
|
||||
self.warnings.push(w.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The rectangle whose corners `q` are, in order either way round, with its edges alternately
|
||||
/// horizontal and vertical and none of zero length (`<rect>` doesn't draw an empty one).
|
||||
fn axis_rect(q: &[Point]) -> Option<Rect> {
|
||||
let [a, b, c, d] = *q else { return None };
|
||||
let h = |p: Point, q: Point| p.y == q.y && p.x != q.x;
|
||||
let v = |p: Point, q: Point| p.x == q.x && p.y != q.y;
|
||||
((h(a, b) && v(b, c) && h(c, d) && v(d, a)) || (v(a, b) && h(b, c) && v(c, d) && h(d, a))).then(|| Rect::from_points(a, c))
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ impl Writer<'_> {
|
||||
let glyphs = |w: &mut Self, items: &[AppearanceItem]| {
|
||||
if !items.is_empty() {
|
||||
let ap = vectorcraft_doc::Appearance { items: items.to_vec(), ..Default::default() };
|
||||
w.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, &[&pd], FillRule::NonZero, tb);
|
||||
w.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, None, &[&pd], FillRule::NonZero, tb);
|
||||
}
|
||||
};
|
||||
glyphs(self, below);
|
||||
@@ -192,7 +192,7 @@ impl Writer<'_> {
|
||||
// An unnamed id: the pieces carry no id attribute (the group has it).
|
||||
let glyphs = Node::path(NodeId(u64::MAX), pd.clone(), run.style.appearance());
|
||||
let d = self.path_d(&pd, self.xf);
|
||||
self.shape(&glyphs, &d, &[&pd], FillRule::NonZero, Some(lay.bounds));
|
||||
self.shape(&glyphs, &d, None, &[&pd], FillRule::NonZero, Some(lay.bounds));
|
||||
}
|
||||
self.xf = saved;
|
||||
self.depth -= 1;
|
||||
@@ -335,7 +335,7 @@ impl Writer<'_> {
|
||||
let pd = PathData::from_bezpath(&bp);
|
||||
let ap = vectorcraft_doc::Appearance { items: vec![AppearanceItem::Stroke(run.style.stroke_layer())], ..Default::default() };
|
||||
let d = self.path_d(&pd, self.xf);
|
||||
self.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, &[&pd], FillRule::NonZero, Some(lay.bounds));
|
||||
self.shape(&Node::path(NodeId(u64::MAX), pd.clone(), ap), &d, None, &[&pd], FillRule::NonZero, Some(lay.bounds));
|
||||
}
|
||||
self.xf = saved;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Simple shapes export as SVG basic shapes (#4652): axis-aligned rectangles, circles and
|
||||
//! ellipses, straight lines, and polygons/polylines of straight segments. Everything else, and
|
||||
//! rectangles and ellipses whose dashes would start elsewhere, stays `<path>`; the picture and the
|
||||
//! reimported geometry don't change.
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, FillLayer, Node, StrokeLayer};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, shapes};
|
||||
use vectorcraft_svg::{ExportOptions, export, import};
|
||||
use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard};
|
||||
|
||||
fn painted(dash: bool) -> Appearance {
|
||||
let mut st = StrokeLayer::new(Paint::solid(Color::rgb(0.1, 0.2, 0.6)), 3.0);
|
||||
if dash {
|
||||
st.dash = Some(Dash { pattern: vec![6.0, 4.0], offset: 0.0, align_corners: false });
|
||||
}
|
||||
Appearance {
|
||||
items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.9, 0.5, 0.1)))), AppearanceItem::Stroke(st)],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn doc(paths: Vec<(PathData, bool)>) -> Document {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
for (p, dash) in paths {
|
||||
let n = Node::path(d.alloc_id(), p, painted(dash));
|
||||
d.insert(Some(l), usize::MAX, n).unwrap();
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn svg_of(p: PathData, dash: bool) -> String {
|
||||
export(&doc(vec![(p, dash)]), &ExportOptions::default())
|
||||
}
|
||||
|
||||
fn poly(pts: &[(f64, f64)], closed: bool) -> PathData {
|
||||
let pts: Vec<Point> = pts.iter().map(|&(x, y)| Point::new(x, y)).collect();
|
||||
PathData::single(SubPath::polyline(&pts, closed))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_simple_shape_writes_its_element() {
|
||||
let cases = [
|
||||
(shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), "<rect x=\"10\" y=\"20\" width=\"100\" height=\"50\""),
|
||||
(shapes::ellipse(Rect::new(50.0, 50.0, 90.0, 90.0)), "<circle cx=\"70\" cy=\"70\" r=\"20\""),
|
||||
(shapes::ellipse(Rect::new(10.0, 10.0, 70.0, 40.0)), "<ellipse cx=\"40\" cy=\"25\" rx=\"30\" ry=\"15\""),
|
||||
(shapes::line(Point::new(10.0, 10.0), Point::new(90.5, 40.0)), "<line x1=\"10\" y1=\"10\" x2=\"90.5\" y2=\"40\""),
|
||||
(poly(&[(60.0, 185.0), (100.0, 185.0), (80.0, 135.0)], true), "<polygon points=\"60,185 100,185 80,135\""),
|
||||
(poly(&[(10.0, 10.0), (50.0, 30.0), (90.0, 10.0)], false), "<polyline points=\"10,10 50,30 90,10\""),
|
||||
];
|
||||
for (p, want) in cases {
|
||||
let svg = svg_of(p, false);
|
||||
assert!(svg.contains(want), "{want}:\n{svg}");
|
||||
assert!(!svg.contains("<path"), "{want}:\n{svg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_are_the_written_precision() {
|
||||
let opts = ExportOptions { decimals: 2, ..Default::default() };
|
||||
let svg = export(&doc(vec![(shapes::rectangle(Rect::new(10.123, 20.0, 30.456, 40.0)), false)]), &opts);
|
||||
assert!(svg.contains("<rect x=\"10.12\" y=\"20\" width=\"20.34\" height=\"20\""), "{svg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turned_curved_and_dashed_shapes_keep_their_exact_form() {
|
||||
let turned = |p: PathData| p.transformed(Affine::rotate_about(0.5, Point::new(60.0, 60.0)));
|
||||
// A turned rectangle is still straight segments: a polygon, as the reference app writes one.
|
||||
let svg = svg_of(turned(shapes::rectangle(Rect::new(20.0, 40.0, 100.0, 80.0))), false);
|
||||
assert!(svg.contains("<polygon") && !svg.contains("<rect"), "{svg}");
|
||||
// Polygons start their dashes where the path does; rectangles and ellipses don't.
|
||||
let svg = svg_of(shapes::rectangle(Rect::new(20.0, 40.0, 100.0, 80.0)), true);
|
||||
assert!(svg.contains("<polygon points=\"20,40 100,40 100,80 20,80\""), "{svg}");
|
||||
let ellipse = shapes::ellipse(Rect::new(20.0, 40.0, 100.0, 80.0));
|
||||
for (p, dash) in [(turned(ellipse.clone()), false), (ellipse, true)] {
|
||||
let svg = svg_of(p, dash);
|
||||
assert!(svg.contains("<path") && !svg.contains("<ellipse"), "{svg}");
|
||||
}
|
||||
// A curve and a zero-length line stay paths.
|
||||
let mut curve = shapes::ellipse(Rect::new(20.0, 40.0, 100.0, 80.0));
|
||||
curve.subpaths[0].anchors[0].h_out.y += 5.0;
|
||||
for p in [curve, poly(&[(5.0, 5.0), (5.0, 5.0)], false)] {
|
||||
assert!(svg_of(p, false).contains("<path"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The exported SVG rendered by resvg on white at 1 px/pt.
|
||||
fn resvg_render(svg: &str) -> Image {
|
||||
let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("parse");
|
||||
let mut pm = resvg::tiny_skia::Pixmap::new(200, 200).unwrap();
|
||||
pm.fill(resvg::tiny_skia::Color::WHITE);
|
||||
resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm.as_mut());
|
||||
Image { width: 200, height: 200, rgba: pm.data().to_vec() }
|
||||
}
|
||||
|
||||
fn shapes_doc() -> Document {
|
||||
doc(vec![
|
||||
(shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), false),
|
||||
(shapes::rectangle(Rect::new(120.0, 20.0, 190.0, 70.0)), true),
|
||||
(shapes::ellipse(Rect::new(10.0, 80.0, 50.0, 120.0)), false),
|
||||
(shapes::ellipse(Rect::new(60.0, 80.0, 140.0, 120.0)), false),
|
||||
(shapes::line(Point::new(150.0, 80.0), Point::new(190.0, 120.0)), true),
|
||||
(poly(&[(20.0, 185.0), (60.0, 185.0), (40.0, 135.0)], true), false),
|
||||
(poly(&[(80.0, 140.0), (120.0, 180.0), (160.0, 140.0)], false), true),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_elements_render_like_the_canvas() {
|
||||
let d = shapes_doc();
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert!(!svg.contains("<path"), "{svg}");
|
||||
assert_similar(&render_artboard(&d), &resvg_render(&svg), 24.0, 0.002);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_elements_reimport_with_the_same_geometry() {
|
||||
let d = shapes_doc();
|
||||
let back = import(&export(&d, &ExportOptions::default())).unwrap();
|
||||
let bounds = |d: &Document| {
|
||||
let mut v = Vec::new();
|
||||
d.walk(|n| {
|
||||
if !n.is_layer() {
|
||||
v.push(n.geometric_bounds().unwrap());
|
||||
}
|
||||
});
|
||||
v
|
||||
};
|
||||
let (a, b) = (bounds(&d), bounds(&back));
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (a, b) in a.iter().zip(&b) {
|
||||
let far = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().any(|v| v.abs() > 1e-3);
|
||||
assert!(!far, "{a:?} came back as {b:?}");
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,11 @@ fn revolve_caps_export_as_additional_vector_faces_and_leave_source_editable() {
|
||||
d.node_mut(id).unwrap().appearance.effects[0].params["cap"] = json!(true);
|
||||
let before = d.clone();
|
||||
let capped = export(&d, &ExportOptions::default());
|
||||
assert_eq!(capped.matches("<path").count(), open.matches("<path").count() + 2, "two continuous caps, each with a fill and rim stroke");
|
||||
assert_eq!(
|
||||
vectorcraft_testkit::svg_shapes(&capped),
|
||||
vectorcraft_testkit::svg_shapes(&open) + 2,
|
||||
"two continuous caps, each with a fill and rim stroke"
|
||||
);
|
||||
assert!(!capped.contains("<image"));
|
||||
assert_eq!(d, before);
|
||||
}
|
||||
@@ -47,7 +51,7 @@ fn revolve_exports_shaded_vector_faces_and_keeps_the_source_live() {
|
||||
let d = doc_with(n);
|
||||
let before = d.clone();
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert!(svg.matches("<path").count() > 50, "Revolve should export vector surfaces");
|
||||
assert!(vectorcraft_testkit::svg_shapes(&svg) > 50, "Revolve should export vector surfaces");
|
||||
assert!(!svg.contains("<image"), "no raster fallback");
|
||||
assert_eq!(d, before, "export leaves source editable");
|
||||
}
|
||||
@@ -66,7 +70,7 @@ fn smooth_revolve_exports_vector_gradients_without_rasterizing_or_editing_the_so
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert!(svg.matches("<linearGradient").count() > 50);
|
||||
assert!(svg.contains("gradientUnits=\"userSpaceOnUse\""));
|
||||
assert!(svg.matches("<path").count() > 100);
|
||||
assert!(vectorcraft_testkit::svg_shapes(&svg) > 100);
|
||||
assert!(svg.matches("stroke-linejoin=\"round\"").count() > 100, "seam strokes keep bounded joins in SVG");
|
||||
assert!(!svg.contains("stroke-miterlimit"));
|
||||
assert!(!svg.contains("<image") && !svg.contains("<filter"));
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
mod basic_shapes;
|
||||
mod blend_export;
|
||||
mod colour_glyphs;
|
||||
mod container_export;
|
||||
|
||||
@@ -43,7 +43,7 @@ fn many_css_styles_preserve_first_use_numbers() {
|
||||
|
||||
for (styling, definition_marker) in [(Styling::InternalCss, ".cls-"), (Styling::StyleEntities, "<!ENTITY st")] {
|
||||
let svg = export(&d, &ExportOptions { styling, ..Default::default() });
|
||||
let paths: Vec<_> = svg.lines().filter(|line| line.contains("<path ")).collect();
|
||||
let paths: Vec<_> = svg.lines().filter(|line| line.contains("<rect ")).collect();
|
||||
assert_eq!(paths.len(), 259, "{styling:?}");
|
||||
for (i, path) in paths.iter().enumerate() {
|
||||
let number = if i < 256 { i + 1 } else { [1, 129, 256][i - 256] };
|
||||
|
||||
@@ -67,7 +67,7 @@ fn curves_roundtrip() {
|
||||
let n = Node::path(id, shapes::ellipse(Rect::new(20.0, 30.0, 120.0, 90.0)), Appearance::default_art());
|
||||
let d = doc_with(vec![n]);
|
||||
let s = export(&d, &ExportOptions::default());
|
||||
assert!(s.contains(" C"), "{s}");
|
||||
assert!(s.contains("<ellipse cx=\"70\" cy=\"60\" rx=\"50\" ry=\"30\""), "{s}");
|
||||
let r = roundtrip(&d);
|
||||
let a = art(&r);
|
||||
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(20.0, 30.0, 120.0, 90.0), 0.01));
|
||||
@@ -295,7 +295,7 @@ fn multiple_fills_stack_in_paint_order() {
|
||||
let green = s.find("#00ff00").unwrap();
|
||||
let blue = s.find("#0000ff").unwrap();
|
||||
assert!(red < green && green < blue, "{s}");
|
||||
assert_eq!(s.matches("<path").count(), 3);
|
||||
assert_eq!(s.matches("<rect").count(), 3);
|
||||
let r = import(&s).unwrap();
|
||||
let a = art(&r);
|
||||
assert!(matches!(a[0].kind, NodeKind::Group { .. }));
|
||||
@@ -324,7 +324,7 @@ fn hidden_and_guides_omitted() {
|
||||
a.visible = false;
|
||||
let b = rect_node(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), Appearance::default_art());
|
||||
let s = export(&doc_with(vec![a, b]), &ExportOptions::default());
|
||||
assert_eq!(s.matches("<path").count(), 1);
|
||||
assert_eq!(s.matches("<rect").count(), 1);
|
||||
let r = import(&s).unwrap();
|
||||
assert_eq!(art(&r).len(), 1);
|
||||
}
|
||||
@@ -537,9 +537,9 @@ fn options_decimals_minify_responsive_artboard() {
|
||||
assert!(!s.contains("<?xml"));
|
||||
assert!(!s.contains(" width=\"200\""));
|
||||
assert!(s.contains("viewBox=\"0 0 200 100\""), "{s}");
|
||||
assert!(s.contains("M10.12 20"), "{s}");
|
||||
assert!(s.contains("<rect x=\"10.12\" y=\"20\""), "{s}");
|
||||
let s = export(&d, &ExportOptions::default());
|
||||
assert!(s.contains("width=\"200\" height=\"100\"") && s.contains("M10.123 20"), "{s}");
|
||||
assert!(s.contains("width=\"200\" height=\"100\"") && s.contains("<rect x=\"10.123\" y=\"20\""), "{s}");
|
||||
// All art bounds: the rectangle and half its 1 pt stroke (right-angle miters stay inside).
|
||||
let s = export(&d, &ExportOptions { artboard: None, ..Default::default() });
|
||||
assert!(s.contains("viewBox=\"0 0 40.877 31\""), "{s}");
|
||||
|
||||
@@ -50,7 +50,7 @@ fn three_instances_give_one_symbol_and_three_uses() {
|
||||
assert_eq!(svg.matches("<use ").count(), 3, "{svg}");
|
||||
assert!(svg.contains("<symbol id=\"My_Mark\"") && svg.contains("xlink:href=\"#My_Mark\""), "{svg}");
|
||||
// The art is written once.
|
||||
assert_eq!(svg.matches("<path").count(), 1, "{svg}");
|
||||
assert_eq!(svg.matches("<rect").count(), 1, "{svg}");
|
||||
assert_similar(&render_artboard(&d), &resvg_render(&svg, 200, 120), 24.0, 0.002);
|
||||
// Read back (since SVG import keeps symbols: as the symbol and three instances): the three
|
||||
// squares are where the instances are.
|
||||
@@ -185,14 +185,14 @@ fn layered() -> Document {
|
||||
fn hidden_layers_are_kept_hidden_when_asked() {
|
||||
let d = layered();
|
||||
let plain = export(&d, &ExportOptions::default());
|
||||
assert!(!plain.contains("Notes") && plain.matches("<path").count() == 1, "{plain}");
|
||||
assert!(!plain.contains("Notes") && plain.matches("<rect").count() == 1, "{plain}");
|
||||
// Page isolation wraps the layers in one more group.
|
||||
let mut isolated = d.clone();
|
||||
isolated.page_isolate = true;
|
||||
let styles = [vectorcraft_svg::Styling::PresentationAttributes, vectorcraft_svg::Styling::InlineStyle, vectorcraft_svg::Styling::InternalCss];
|
||||
for (d, styling) in styles.iter().map(|s| (&d, *s)).chain([(&isolated, vectorcraft_svg::Styling::InternalCss)]) {
|
||||
let kept = export(d, &ExportOptions { hidden_layers: true, styling, ..Default::default() });
|
||||
assert!(kept.contains("id=\"Notes\"") && kept.contains("display") && kept.matches("<path").count() == 2, "{kept}");
|
||||
assert!(kept.contains("id=\"Notes\"") && kept.contains("display") && kept.matches("<rect").count() == 2, "{kept}");
|
||||
// Viewers don't show it.
|
||||
assert_eq!(resvg_render(&kept, 100, 100).pixel(70, 70), [255, 255, 255, 255], "{kept}");
|
||||
// It reopens as a hidden layer with its art.
|
||||
|
||||
@@ -64,6 +64,12 @@ pub fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
dir
|
||||
}
|
||||
|
||||
/// How many shapes an SVG draws: its `<path>` elements and the basic shapes SVG export writes
|
||||
/// simple shapes as (`<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polyline>`, `<polygon>`).
|
||||
pub fn svg_shapes(svg: &str) -> usize {
|
||||
["<path", "<rect", "<circle", "<ellipse", "<line ", "<polyline", "<polygon"].iter().map(|t| svg.matches(t).count()).sum()
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static QUIET: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
@@ -407,7 +407,7 @@ mod tests {
|
||||
app.run("file.save", Value::Null).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(written.borrow()[1].0, "/tmp/doc.svg");
|
||||
assert!(text(&written, 1).contains("style=\"fill:") && text(&written, 1).matches("<path").count() == 2);
|
||||
assert!(text(&written, 1).contains("style=\"fill:") && vectorcraft_testkit::svg_shapes(&text(&written, 1)) == 2);
|
||||
// Save a Copy (options given) leaves the document's path alone.
|
||||
app.run("file.saveCopy", json!({"path": "/tmp/copy.svg", "svg": {"minify": true}})).unwrap();
|
||||
assert!(!text(&written, 2).trim_end().contains('\n') && !text(&written, 2).contains("<?xml"), "minified");
|
||||
|
||||
@@ -250,7 +250,7 @@ fn pdf_artboards_continue_in_the_save_pdf_dialog() {
|
||||
/// format, each file holding that artboard's art (the ellipse is on artboard 1).
|
||||
#[test]
|
||||
fn export_as_writes_only_the_chosen_artboard_in_every_format() {
|
||||
for (format, ellipse) in [("svg", "<path"), ("dxf", "HATCH"), ("pdf", ""), ("png", "")] {
|
||||
for (format, ellipse) in [("svg", "<ellipse"), ("dxf", "HATCH"), ("pdf", ""), ("png", "")] {
|
||||
for (range, on) in [("1", true), ("3", false)] {
|
||||
let (mut app, written) = app(3);
|
||||
app.run("file.exportAs", json!({"format": format})).unwrap();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.ai` import: placed files on `%_` lines no longer send the file to its PDF part) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (SVG export: simple shapes as SVG basic shapes) · **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
|
||||
@@ -97,7 +97,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
|
||||
| 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+) | 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); 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) |
|
||||
| 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`) |
|
||||
| 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). CNC preset and VCarve QA remain open |
|
||||
| DWG | open, export | no | no | no open spec; DXF instead |
|
||||
| EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) |
|
||||
@@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | SVG export: axis-aligned rectangles are written as `<rect>`, circles and ellipses drawn with four standard arcs as `<circle>`/`<ellipse>`, straight lines as `<line>` and other straight-segment paths as `<polygon>`/`<polyline>`, at the written precision; a rectangle or ellipse with a dashed stroke, turned ellipses, curves and compound paths stay `<path>` (#4652); scores unchanged |
|
||||
| 2026-10-30 | minor | `.ai` import: a placed (linked) file written as an object's copy on `%_` lines is read: its `%AI5_BeginPlace` and `%AI5_EndPlace` markers there were dropped, so the `~` that ends it was an unknown operator and the whole file came in as its PDF part; its preview's header, on a `%_` line too, is read. Own-fixture test in `import::ai::tests`; scores unchanged |
|
||||
| 2026-10-30 | minor | PDF export: an opaque gradient mesh is written as one PDF Type 6 (Coons patch) shading (ISO 32000-1, 8.7.4.5.7) filling its outline, so viewers blend its colours smoothly instead of showing the bands and seams of the 8 × 8 flat quads a patch it was cut into; a file of 65,558 small meshes exports in 14 s instead of 56 s, at half the size. A mesh with see-through points is still written as quads. Own-fixture tests in `tests::mesh_export`; scores unchanged |
|
||||
| 2026-10-30 | minor | `.ai` import: a file whose PDF part is an empty page opens from its editing data where that reads, instead of opening blank; scores unchanged |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (UI chrome: with Real-time Drawing and Editing off, a ruler guide shows while it is dragged (#4646); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (File formats: SVG export writes simple shapes as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` (#4652); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -998,6 +998,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
A gap between glyphs of two sizes is measured against the smaller one, so a word set beside a much larger one with no space glyph between them (a browser's margin between two sizes, as in `≤ 4` and a small `hours`) keeps its word space (#3623).
|
||||
SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `<style>` rules for its id, its classes and its element name, and the rules whose selector ends in none of these (`*`, `[x]`).
|
||||
A `.svgz` file copied in a file manager pastes as its art, as a copied `.svg` file does.
|
||||
SVG export (Save As, Export As, Export for Screens, Asset Export and the clipboard share the writer) writes simple shapes as SVG basic shapes, as the reference app does (an ellipse is an `ellipse` tag until an anchor is added, [forum](https://community.adobe.com/t5/illustrator-discussions/trouble-exporting-shape-with-points-to-svg/td-p/10720325); rectangles come out as `<rect>` and `<polygon>`, [forum](https://community.adobe.com/t5/illustrator-discussions/force-svg-export-using-all-lt-rect-gt/td-p/11282378)): an axis-aligned rectangle as `<rect>`, a circle or ellipse drawn with four standard arcs as `<circle>`/`<ellipse>`, a straight line as `<line>`, and other straight-segment paths as `<polygon>`/`<polyline>`, at the written precision (#4652). A rectangle or ellipse with a dashed stroke, whose dashes would start elsewhere, a turned ellipse, curves and compound paths stay `<path>`. `crates/svg/tests/it/basic_shapes.rs` checks each element, that the picture renders as the canvas does and that the geometry reimports.
|
||||
SVG import keeps attributes in other applications' XML namespaces (`plotter:pen="2"`) on the objects and layers they belong to, as it keeps their `data-*` attributes: copies, duplicates and `.vectorcraft` files keep them, and SVG export writes them back with each namespace declared once on the root (the rest of #522; `crates/svg/tests/it/foreign_attributes.rs`, `tests/attributes.rs`).
|
||||
In a `.ai` or EPS opened from its editing data, type has its text object's opacity and blend mode, whether it is made from the file's text document or taken from the page. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, that type is left out only when, for each piece of it, one of those text objects has the piece's Latin letters and digits in its text, in capitals or not; other such type is kept as the page's other type of no text object is.
|
||||
In SVG export without Fewer `<tspan>` Elements, each character of live type that justification or Mojikumi moves (the letter gaps of a Justify All line without word spaces, the gaps between the characters of a justified Japanese line, closed-up punctuation, the space between Japanese and Latin letters) starts its own positioned `<tspan>`, and type scaled horizontally on a justified line is stretched to its own width (`crates/svg/tests/it/text_layout.rs`).
|
||||
@@ -1208,6 +1209,7 @@ applies to fills). Tests: `color::gradient::tests` (perceptual midpoint, expansi
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | File formats: SVG export writes axis-aligned rectangles as `<rect>`, circles and ellipses as `<circle>`/`<ellipse>`, straight lines as `<line>` and other straight-segment paths as `<polygon>`/`<polyline>`, keeping `<path>` where a shape element would draw differently (#4652); evidence added; scores and hours unchanged (estimated). |
|
||||
| 2026-10-30 | minor | UI chrome: with Preferences › Performance › Real-time Drawing and Editing off, a ruler guide dragged out of a ruler or moved shows while it is dragged (#4646); evidence added; score and hours unchanged (estimated). |
|
||||
| 2026-10-30 | minor | Direct Selection: a marquee over part of a path segment, with neither of its anchors inside, selects the segment (#4654); scores unchanged |
|
||||
| 2026-10-30 | minor | Tools: Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J) moves two open path ends to their midpoint and joins them there, the joined anchor a corner or a smooth point as its dialog's Points choice asks, in one undo step; evidence added; scores and hours unchanged (estimated). |
|
||||
|
||||
Reference in New Issue
Block a user