diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index c28ac48f..3f910872 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -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)> + '_ { + 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(); diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index edc962ce..27d181d1 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -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, /// `` def ids by pattern name + placement. patterns: HashMap, + /// Gradient def ids by content (everything but the id). + gradients: HashMap, 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) -> String { match p { Paint::None => "none".into(), @@ -451,63 +464,57 @@ impl Writer<'_> { Some(id) } + /// The id of a ``/`` 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!( - "", - 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!( - "", - 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!( - "", - 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!("", fmt_num(off as f64, 4), c.to_hex())); + let stops: Vec = 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!("", 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!("")); id @@ -1123,7 +1130,10 @@ impl Writer<'_> { } } - fn char_props(&mut self, st: &CharStyle) -> Props { + /// The ``/`` 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(""); } - /// 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!("")); 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(""); } @@ -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 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!(""); 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(""); @@ -1286,10 +1300,10 @@ impl Writer<'_> { } /// One line of laid-out text starting at `start`. Default: a positioned `` per segment - /// (only the first of an `anchored` line). Fewer tspans: one positioned `` per line with + /// (only the first of an anchored line). Fewer tspans: one positioned `` 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!("", 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!("")); - let base = self.char_props(&t.first_style()); + // The 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!(""); 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!("{}", 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 `` share. +struct TextBase { + /// The `` element's character properties, which each `` 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 ``. struct Segment { /// Index into `TextObject::runs`. diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index f913d7da..f022eec8 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -33,7 +33,7 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec), 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, + /// Midpoint stops by gradient id (see [`midpoint_stops`]). + midpoints: HashMap>>, +} + +/// 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>> { + xml.descendants() + .filter(|n| matches!(n.tag_name().name(), "linearGradient" | "radialGradient")) + .filter_map(|g| { + let id = g.attribute("id")?; + let marks: Vec> = 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]>) -> Vec { + let marks = marks.filter(|m| m.len() == g.stops().len()); + let mut out: Vec = 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 `` elements, by mask id: @@ -285,6 +320,11 @@ impl Importer { } } + /// A gradient's stops, midpoints restored. + fn stops(&self, g: &usvg::BaseGradient) -> Vec { + 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::>() - }; 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), } diff --git a/crates/svg/tests/gradients.rs b/crates/svg/tests/gradients.rs new file mode 100644 index 00000000..aba55acc --- /dev/null +++ b/crates/svg/tests/gradients.rs @@ -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) -> 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 element's user space: the gradient runs across the glyphs. + assert!((attr(&svg, "