M4.28: SVG gradient export fidelity

Character gradients on live type export as url(#…) spanning the
laid-out text (point, area, on-path and outlined type, as on the
canvas); character stroke gradients span the text grown by half the
weight. Identical gradients share one def, and a swatch gradient takes
the swatch's name as its id. Midpoints are written as extra stops marked
data-vc-midpoint, which import turns back into midpoints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 18:52:22 +07:00
parent 1982ecf98d
commit 32252c68ea
4 changed files with 267 additions and 101 deletions

View File

@ -117,18 +117,19 @@ impl Gradient {
}
/// Stops expanded so that midpoints are represented as explicit stops (for renderers without midpoints).
pub fn expanded_stops(&self) -> Vec<(f32, Color, f32)> {
let mut out = Vec::new();
for (i, s) in self.stops.iter().enumerate() {
out.push((s.offset, s.color, s.opacity));
if let Some(n) = self.stops.get(i + 1)
&& (s.midpoint - 0.5).abs() > 1e-3
{
self.expanded().map(|(t, c, o, _)| (t, c, o)).collect()
}
/// [`Self::expanded_stops`], each stop that stands for a midpoint with that midpoint (`None`
/// on the gradient's own stops).
pub fn expanded(&self) -> impl Iterator<Item = (f32, Color, f32, Option<f32>)> + '_ {
self.stops.iter().enumerate().flat_map(move |(i, s)| {
let mid = self.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| {
let t = s.offset + (n.offset - s.offset) * s.midpoint;
let (c, o) = self.sample(t);
out.push((t, c, o));
}
}
out
(t, c, o, Some(s.midpoint))
});
std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid)
})
}
pub fn reverse(&mut self) {
self.stops.reverse();

View File

@ -47,6 +47,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
names: HashMap::new(),
depth: 1,
patterns: HashMap::new(),
gradients: HashMap::new(),
pattern_nest: 0,
brushes: None,
knockout: doc.page_knockout,
@ -155,6 +156,8 @@ struct Writer<'a> {
depth: usize,
/// `<pattern>` def ids by pattern name + placement.
patterns: HashMap<String, String>,
/// Gradient def ids by content (everything but the id).
gradients: HashMap<String, String>,
pattern_nest: u32,
/// Whether the group being written is a knockout group (what its neutral children inherit).
knockout: bool,
@ -251,6 +254,19 @@ impl Writer<'_> {
i += 1;
}
}
/// `base` (with the unique prefix) if free, else `base-2`, `base-3`…
fn unique_id(&mut self, base: &str) -> String {
let base = format!("{}{base}", self.id_prefix);
let mut cand = base.clone();
for i in 2.. {
if self.used_ids.insert(cand.clone()) {
break;
}
cand = format!("{base}-{i}");
}
cand
}
/// Reserve ids for every named object up front so generated def ids never collide with them.
fn assign_name_ids(&mut self) {
if self.opts.object_ids == ObjectIds::Minimal {
@ -259,16 +275,11 @@ impl Writer<'_> {
let mut named: Vec<(NodeId, String)> = Vec::new();
self.doc.walk(|n| {
if let Some(name) = &n.name {
named.push((n.id, format!("{}{}", self.id_prefix, sanitize_id(name))));
named.push((n.id, sanitize_id(name)));
}
});
for (id, base) in named {
let mut cand = base.clone();
let mut i = 2;
while !self.used_ids.insert(cand.clone()) {
cand = format!("{base}-{i}");
i += 1;
}
let cand = self.unique_id(&base);
self.names.insert(id, cand);
}
}
@ -398,6 +409,8 @@ impl Writer<'_> {
s
}
/// A fill or stroke value. Gradients resolve unset geometry against `bounds` (the space
/// [`Self::xf`] maps into user space, as the renderer does).
fn paint(&mut self, p: &Paint, bounds: Option<Rect>) -> String {
match p {
Paint::None => "none".into(),
@ -451,63 +464,57 @@ impl Writer<'_> {
Some(id)
}
/// The id of a `<linearGradient>`/`<radialGradient>` def for `g` resolved against `bounds`.
/// Identical gradients share one def; a gradient from a swatch takes the swatch's name as its
/// id. A midpoint other than halfway is written as an extra stop marked `data-vc-midpoint`
/// (what import turns back into a midpoint).
fn gradient_def(&mut self, g: &GradientPaint, bounds: Rect) -> String {
let mut geom = g.resolve(bounds);
geom.transform(self.xf, g.gradient.kind);
let (s, e) = (geom.start, geom.end);
let head = match g.gradient.kind {
// An off-centre focal point: `fx`/`fy`, in the gradient's own (unsquashed) space.
let focal = |w: &Self, f: Point| format!(" fx=\"{}\" fy=\"{}\"", w.num(f.x), w.num(f.y));
let (tag, prefix, attrs) = match g.gradient.kind {
GradientKind::Radial => {
let id = self.fresh_id("radial-gradient");
let r = (e - s).hypot();
// An off-centre focal point: `fx`/`fy`, in the gradient's own (unsquashed) space.
let focal = |f: Point| format!(" fx=\"{}\" fy=\"{}\"", self.num(f.x), self.num(f.y));
if (geom.aspect - 1.0).abs() < 1e-9 {
(
id.clone(),
format!(
"<radialGradient id=\"{id}\" cx=\"{}\" cy=\"{}\" r=\"{}\"{} gradientUnits=\"userSpaceOnUse\">",
self.num(s.x),
self.num(s.y),
self.num(r),
geom.focal.map(focal).unwrap_or_default()
),
"radialGradient",
)
let r = self.num((e - s).hypot());
let attrs = if (geom.aspect - 1.0).abs() < 1e-9 {
let f = geom.focal.map(|f| focal(self, f)).unwrap_or_default();
format!(" cx=\"{}\" cy=\"{}\" r=\"{r}\"{f}", self.num(s.x), self.num(s.y))
} else {
let v = e - s;
let m = Affine::translate(s.to_vec2()) * Affine::rotate(v.y.atan2(v.x)) * Affine::scale_non_uniform(1.0, geom.aspect);
(
id.clone(),
format!(
"<radialGradient id=\"{id}\" cx=\"0\" cy=\"0\" r=\"{}\"{} gradientTransform=\"{}\" gradientUnits=\"userSpaceOnUse\">",
self.num(r),
geom.focal.map(|f| focal(m.inverse() * f)).unwrap_or_default(),
self.matrix(m)
),
"radialGradient",
)
}
}
GradientKind::Linear | GradientKind::Freeform => {
let id = self.fresh_id("linear-gradient");
(
id.clone(),
format!(
"<linearGradient id=\"{id}\" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" gradientUnits=\"userSpaceOnUse\">",
self.num(s.x),
self.num(s.y),
self.num(e.x),
self.num(e.y)
),
"linearGradient",
)
let f = geom.focal.map(|f| focal(self, m.inverse() * f)).unwrap_or_default();
format!(" cx=\"0\" cy=\"0\" r=\"{r}\"{f} gradientTransform=\"{}\"", self.matrix(m))
};
("radialGradient", "radial-gradient", attrs)
}
GradientKind::Linear | GradientKind::Freeform => (
"linearGradient",
"linear-gradient",
format!(" x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\"", self.num(s.x), self.num(s.y), self.num(e.x), self.num(e.y)),
),
};
let (id, open, tag) = head;
self.def(1, &open);
for (off, c, o) in g.gradient.expanded_stops() {
let op = if o < 1.0 { format!(" stop-opacity=\"{}\"", fmt_num(o as f64, 3)) } else { String::new() };
self.def(2, &format!("<stop offset=\"{}\" stop-color=\"{}\"{op}/>", fmt_num(off as f64, 4), c.to_hex()));
let stops: Vec<String> = g
.gradient
.expanded()
.map(|(offset, c, o, mid)| {
let op = if o < 1.0 { format!(" stop-opacity=\"{}\"", fmt_num(o as f64, 3)) } else { String::new() };
let mid = mid.map(|m| format!(" data-vc-midpoint=\"{}\"", fmt_num(m as f64, 4))).unwrap_or_default();
format!("<stop offset=\"{}\" stop-color=\"{}\"{op}{mid}/>", fmt_num(offset as f64, 4), c.to_hex())
})
.collect();
let key = format!("{tag}{attrs}{}", stops.concat());
if let Some(id) = self.gradients.get(&key) {
return id.clone();
}
let id = match &g.swatch {
Some(name) => self.unique_id(&sanitize_id(name)),
None => self.fresh_id(prefix),
};
self.gradients.insert(key, id.clone());
self.def(1, &format!("<{tag} id=\"{id}\"{attrs} gradientUnits=\"userSpaceOnUse\">"));
for stop in &stops {
self.def(2, stop);
}
self.def(1, &format!("</{tag}>"));
id
@ -1123,7 +1130,10 @@ impl Writer<'_> {
}
}
fn char_props(&mut self, st: &CharStyle) -> Props {
/// The `<text>`/`<tspan>` properties of a character style. `space`: the text's layout bounds
/// and the map from text space to user space, which character gradients resolve against
/// (as on the canvas).
fn char_props(&mut self, st: &CharStyle, space: (Rect, Affine)) -> Props {
let mut p = Props::new();
let fam =
if st.font_family.contains(|c: char| c.is_whitespace() || c == ',') { format!("'{}'", st.font_family) } else { st.font_family.clone() };
@ -1137,20 +1147,18 @@ impl Writer<'_> {
if fs.contains("italic") || fs.contains("oblique") {
p.push(("font-style", "italic".into()));
}
let fill = match &st.fill {
Paint::Gradient(g) => g.gradient.stops.first().map(|s| s.color.to_hex()).unwrap_or_else(|| "none".into()),
p => self.paint(p, None),
};
// Character paints resolve in the text's space (`to_user` maps it into user space).
let (bounds, to_user) = space;
let saved = std::mem::replace(&mut self.xf, to_user);
let fill = self.paint(&st.fill, Some(bounds));
p.push(("fill", fill));
if st.has_stroke() {
// Weight, cap, join, miter limit and dashes as for object strokes (a gradient: its
// first colour, as for the fill).
let mut layer = st.stroke_layer();
if let Paint::Gradient(g) = &layer.paint {
layer.paint = g.gradient.stops.first().map_or(Paint::None, |s| Paint::solid(s.color));
}
self.stroke_props(&layer, layer.width, None, &mut p);
// Weight, cap, join, miter limit and dashes as for object strokes; a gradient spans
// the text grown by half the weight, as on the canvas.
let layer = st.stroke_layer();
self.stroke_props(&layer, layer.width, Some(layer.paint_bounds(bounds)), &mut p);
}
self.xf = saved;
// Tracking and manual kerning both add space after every character.
let spacing = st.tracking + st.kerning.unwrap_or(0.0);
if spacing != 0.0 {
@ -1214,7 +1222,8 @@ impl Writer<'_> {
self.line("</g>");
}
/// Text as glyph outlines: one compound path per run, painted like the run.
/// Text as glyph outlines: one compound path per run, painted like the run (gradients span
/// the whole text, as on the canvas).
fn text_outlines(&mut self, n: &Node, t: &TextObject) {
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let mut runs: Vec<(usize, kurbo::BezPath)> = vec![];
@ -1228,15 +1237,18 @@ impl Writer<'_> {
let a = self.attrs(&Self::node_props(n));
self.line(&format!("<g{id}{a}>"));
self.depth += 1;
for (r, mut bp) in runs {
// The glyphs stay in text space; the text's transform joins the document's meanwhile.
let saved = self.xf;
self.xf = saved * t.xf;
for (r, bp) in runs {
let Some(run) = t.runs.get(r) else { continue };
bp.apply_affine(t.xf);
let pd = PathData::from_bezpath(&bp);
// 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, pd.bounds());
self.shape(&glyphs, &d, &[&pd], FillRule::NonZero, Some(lay.bounds));
}
self.xf = saved;
self.depth -= 1;
self.line("</g>");
}
@ -1258,8 +1270,10 @@ impl Writer<'_> {
_ => None,
}
.filter(|_| !lines.iter().flatten().any(|s| s.brk));
let base = self.char_props(&t.first_style());
let mut props = base.clone();
// The <text> element's user space is text space.
let space = (lay.bounds, Affine::IDENTITY);
let base = TextBase { props: self.char_props(&t.first_style(), space), space, anchored: anchor.is_some() };
let mut props = base.props.clone();
if let Some((a, _)) = anchor {
props.push(("text-anchor", a.into()));
}
@ -1278,7 +1292,7 @@ impl Writer<'_> {
let mut s = format!("<text{id}{tr}{at} xml:space=\"preserve\"{a}>");
for (line, start) in lines.iter().zip(starts) {
if let Some(start) = start {
self.text_line(&mut s, t, line, start, anchor.is_some(), &base);
self.text_line(&mut s, t, line, start, &base);
}
}
s.push_str("</text>");
@ -1286,10 +1300,10 @@ impl Writer<'_> {
}
/// One line of laid-out text starting at `start`. Default: a positioned `<tspan>` per segment
/// (only the first of an `anchored` line). Fewer tspans: one positioned `<tspan>` per line with
/// (only the first of an anchored line). Fewer tspans: one positioned `<tspan>` per line with
/// the style changes nested in it, positioned again only after tabs and justified word spaces.
/// Baseline shifts are relative (`dy`), undone by the next segment that isn't shifted.
fn text_line(&mut self, s: &mut String, t: &TextObject, line: &[Segment], start: (f64, f64), anchored: bool, base: &Props) {
fn text_line(&mut self, s: &mut String, t: &TextObject, line: &[Segment], start: (f64, f64), base: &TextBase) {
let fewer = self.opts.fewer_tspans;
if fewer {
s.push_str(&format!("<tspan x=\"{}\" y=\"{}\">", self.num(start.0), self.num(start.1)));
@ -1312,14 +1326,14 @@ impl Writer<'_> {
shift = st.baseline_shift;
}
// After a tab or a justified word space the next segment is placed.
place = seg.brk || (!fewer && !anchored);
place = seg.brk || (!fewer && !base.anchored);
if (st.h_scale - st.v_scale).abs() > 1e-9 {
attrs.push_str(&format!(" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"", self.num(seg.advance)));
}
if st.rotation != 0.0 {
attrs.push_str(&format!(" rotate=\"{}\"", self.num(-st.rotation)));
}
let diff = run_diff(base, self.char_props(st));
let diff = run_diff(&base.props, self.char_props(st, base.space));
attrs.push_str(&self.attrs(&diff));
let text = xml_escape(&seg.text);
if attrs.is_empty() && fewer {
@ -1339,7 +1353,10 @@ impl Writer<'_> {
let pid = self.fresh_id("text-path");
let d = self.path_d(path, self.xf * t.xf);
self.def(1, &format!("<path id=\"{pid}\" d=\"{d}\"/>"));
let base = self.char_props(&t.first_style());
// The <text> element's user space is the document's; gradients span the laid-out text.
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let space = (lay.bounds, self.xf * t.xf);
let base = self.char_props(&t.first_style(), space);
let mut props = base.clone();
match t.para.justify {
Justify::Center | Justify::JustifyCenter => props.push(("text-anchor", "middle".into())),
@ -1351,7 +1368,7 @@ impl Writer<'_> {
let offset = fmt_num(start * 100.0, 3);
let mut s = format!("<text{id} xml:space=\"preserve\"{a}><textPath xlink:href=\"#{pid}\" startOffset=\"{offset}%\">");
for run in &t.runs {
let diff = run_diff(&base, self.char_props(&run.style));
let diff = run_diff(&base, self.char_props(&run.style, space));
let a = self.attrs(&diff);
s.push_str(&format!("<tspan{a}>{}</tspan>", xml_escape(&run.text.replace('\n', " "))));
}
@ -1389,6 +1406,16 @@ fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool {
effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id))
}
/// What the lines of one `<text>` share.
struct TextBase {
/// The `<text>` element's character properties, which each `<tspan>` differs from.
props: Props,
/// The text's layout bounds and the map from text space to user space (character paints).
space: (Rect, Affine),
/// Lines anchored at their centre or right end (`text-anchor`), positioned once each.
anchored: bool,
}
/// Characters of one style on one line, written as one `<tspan>`.
struct Segment {
/// Index into `TextObject::runs`.

View File

@ -33,7 +33,7 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec<String>), SvgError> {
let (kx, ky) = units.k;
let mut doc = Document::new(size.width() as f64 * kx, size.height() as f64 * ky);
doc.units = units.unit;
let mut im = Importer { doc, warnings, mask_flags: mask_flags(&xml), links, slots, patterns: HashMap::new() };
let mut im = Importer { doc, warnings, mask_flags: mask_flags(&xml), links, slots, patterns: HashMap::new(), midpoints: midpoint_stops(&xml) };
// usvg wraps everything in an id-less group carrying the viewBox transform when needed.
let mut top = tree.root();
@ -140,6 +140,41 @@ struct Importer {
slots: TextSlots,
/// Pattern swatch made for each usvg pattern.
patterns: HashMap<usize, String>,
/// Midpoint stops by gradient id (see [`midpoint_stops`]).
midpoints: HashMap<String, Vec<Option<f32>>>,
}
/// The stops our export added for midpoints (`data-vc-midpoint`), by gradient id: for each stop,
/// the midpoint it stands for.
fn midpoint_stops(xml: &roxmltree::Document) -> HashMap<String, Vec<Option<f32>>> {
xml.descendants()
.filter(|n| matches!(n.tag_name().name(), "linearGradient" | "radialGradient"))
.filter_map(|g| {
let id = g.attribute("id")?;
let marks: Vec<Option<f32>> = g
.children()
.filter(|c| c.tag_name().name() == "stop")
.map(|c| c.attribute("data-vc-midpoint").and_then(|v| v.parse().ok()))
.collect();
marks.iter().any(Option::is_some).then(|| (id.to_string(), marks))
})
.collect()
}
/// A gradient's stops, with the midpoint stops `marks` names folded back into the midpoints of
/// the stops before them.
fn gradient_stops(g: &usvg::BaseGradient, marks: Option<&[Option<f32>]>) -> Vec<GradientStop> {
let marks = marks.filter(|m| m.len() == g.stops().len());
let mut out: Vec<GradientStop> = Vec::with_capacity(g.stops().len());
for (i, s) in g.stops().iter().enumerate() {
if let (Some(mid), Some(prev)) = (marks.and_then(|m| m.get(i).copied().flatten()), out.last_mut()) {
prev.midpoint = mid.clamp(0.0, 1.0);
continue;
}
let c = s.color();
out.push(GradientStop { opacity: s.opacity().get(), ..GradientStop::new(s.offset().get(), Color::rgb8(c.red, c.green, c.blue)) });
}
out
}
/// The options [`crate::export::MASK_FLAGS`] records on exported `<mask>` elements, by mask id:
@ -285,6 +320,11 @@ impl Importer {
}
}
/// A gradient's stops, midpoints restored.
fn stops(&self, g: &usvg::BaseGradient) -> Vec<GradientStop> {
gradient_stops(g, self.midpoints.get(g.id()).map(Vec::as_slice))
}
fn named(&mut self, id: &str, kind: NodeKind) -> Node {
let nid = self.doc.alloc_id();
let mut n = Node::new(nid, kind);
@ -473,15 +513,6 @@ impl Importer {
}
fn paint(&mut self, p: &usvg::Paint, m: Affine) -> Paint {
let stops = |g: &usvg::BaseGradient| {
g.stops()
.iter()
.map(|s| {
let c = s.color();
GradientStop { opacity: s.opacity().get(), ..GradientStop::new(s.offset().get(), Color::rgb8(c.red, c.green, c.blue)) }
})
.collect::<Vec<_>>()
};
let gp = |kind, stops, geom: GradientGeom| {
Paint::Gradient(Box::new(GradientPaint {
gradient: Gradient { kind, stops },
@ -504,7 +535,7 @@ impl Importer {
focal: None,
};
geom.transform(m * aff(lg.transform()), GradientKind::Linear);
gp(GradientKind::Linear, stops(lg), geom)
gp(GradientKind::Linear, self.stops(lg), geom)
}
usvg::Paint::RadialGradient(rg) => {
if rg.spread_method() != usvg::SpreadMethod::Pad {
@ -518,7 +549,7 @@ impl Importer {
// A focal point outside the circle is pulled inside it.
geom.set_focal(Some(Point::new(rg.fx() as f64, rg.fy() as f64)));
geom.transform(m * aff(rg.transform()), GradientKind::Radial);
gp(GradientKind::Radial, stops(rg), geom)
gp(GradientKind::Radial, self.stops(rg), geom)
}
usvg::Paint::Pattern(pt) => self.pattern(pt, m),
}

View File

@ -0,0 +1,107 @@
//! Gradient defs: text gradients span the laid-out text, stroke gradients the stroked area,
//! identical gradients share a def named after their swatch, and midpoints survive re-import.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeKind, TextObject};
use vectorcraft_geom::{Point, Rect, shapes};
use vectorcraft_svg::{ExportOptions, export, import};
fn black_to_white() -> GradientPaint {
GradientPaint::new(Gradient {
kind: GradientKind::Linear,
stops: vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::WHITE)],
})
}
fn doc_with(nodes: impl FnOnce(&mut Document) -> Vec<Node>) -> Document {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
for n in nodes(&mut d) {
d.insert(Some(l), usize::MAX, n).unwrap();
}
d
}
/// The value of `name` on the first element whose tag starts with `tag`.
fn attr(svg: &str, tag: &str, name: &str) -> f64 {
let el = &svg[svg.find(tag).unwrap_or_else(|| panic!("no {tag}: {svg}"))..];
let el = &el[..el.find('>').unwrap()];
let k = format!(" {name}=\"");
let i = el.find(&k).unwrap_or_else(|| panic!("no {name} in {el}")) + k.len();
el[i..i + el[i..].find('"').unwrap()].parse().unwrap()
}
#[test]
fn gradient_text_spans_its_layout() {
let st = CharStyle { size: 24.0, fill: Paint::Gradient(Box::new(black_to_white())), ..CharStyle::default() };
let t = TextObject::point(Point::new(30.0, 100.0), "Gradient", st);
let bounds = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds;
let d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)))]);
let svg = export(&d, &ExportOptions::default());
assert!(svg.contains("fill=\"url(#linear-gradient-1)\""), "{svg}");
// Text space is the <text> element's user space: the gradient runs across the glyphs.
assert!((attr(&svg, "<linearGradient", "x1") - bounds.x0).abs() < 0.01, "{svg}");
assert!((attr(&svg, "<linearGradient", "x2") - bounds.x1).abs() < 0.01, "{svg}");
// Outlined, the same gradient sits on the outlines.
let outlined = export(&d, &ExportOptions { outline_text: true, ..Default::default() });
assert!((attr(&outlined, "<linearGradient", "x1") - (30.0 + bounds.x0)).abs() < 0.01, "{outlined}");
}
#[test]
fn stroke_gradients_span_the_stroke() {
let d = doc_with(|d| {
vec![Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)),
Appearance::basic(Paint::None, Paint::Gradient(Box::new(black_to_white())), 10.0),
)]
});
let svg = export(&d, &ExportOptions::default());
assert!(svg.contains("stroke=\"url(#"), "{svg}");
assert_eq!(attr(&svg, "<linearGradient", "x1"), 5.0, "bounds less half the width");
assert_eq!(attr(&svg, "<linearGradient", "x2"), 115.0);
}
#[test]
fn identical_gradients_share_a_def_named_after_the_swatch() {
let mut g = black_to_white();
g.swatch = Some("Night Fade".into());
let fill = Appearance::basic(Paint::Gradient(Box::new(g.clone())), Paint::None, 0.0);
let d = doc_with(|d| {
let mut nodes = vec![];
for _ in 0..3 {
nodes.push(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 60.0)), fill.clone()));
}
// Elsewhere, the gradient fits other bounds: a def of its own.
nodes.push(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 10.0, 200.0, 60.0)), fill.clone()));
nodes
});
let svg = export(&d, &ExportOptions::default());
assert_eq!(svg.matches("<linearGradient").count(), 2, "{svg}");
assert_eq!(svg.matches("url(#Night_Fade)").count(), 3, "{svg}");
assert!(svg.contains("id=\"Night_Fade-2\""), "{svg}");
}
#[test]
fn midpoints_come_back() {
let mut g = black_to_white();
g.gradient.stops[0].midpoint = 0.25;
let d = doc_with(|d| {
vec![Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)),
Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0),
)]
});
let svg = export(&d, &ExportOptions::default());
assert_eq!(svg.matches("<stop ").count(), 3, "the midpoint as a stop for other viewers: {svg}");
assert!(svg.contains("offset=\"0.25\"") && svg.contains("data-vc-midpoint=\"0.25\""), "{svg}");
let back = import(&svg).unwrap();
let n = &back.layers[0].children().unwrap()[0];
let Paint::Gradient(g) = n.appearance.fill_paint() else { panic!("{svg}") };
assert_eq!(g.gradient.stops.len(), 2);
assert!((g.gradient.stops[0].midpoint - 0.25).abs() < 1e-6);
assert!((g.gradient.stops[1].midpoint - 0.5).abs() < 1e-6);
}