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:
parent
1982ecf98d
commit
32252c68ea
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue