M4.98: freeform gradients export to SVG as images clipped to their shapes

SVG has no freeform shading: an object's freeform fill or stroke is now
written as an <image> of the colour field over its painted box, sampled
on the grid the canvas uses (twice the box's size in points), clipped to
the shape or to the stroke's outline, with the fill or stroke opacity and
blend mode on the group. Linked image mode writes the PNG next to the
SVG. Character paints keep the linear approximation (with a warning);
gradients along or across strokes stay vector slices. The grid sizing
moved to the colour crate so the canvas and the export share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 13:17:56 +07:00
parent fa3dcab411
commit 33023c5a2b
5 changed files with 255 additions and 45 deletions

View File

@ -96,6 +96,22 @@ pub fn painted_box(bounds: Rect) -> Option<Rect> {
(side > 1e-9 && side.is_finite()).then(|| Rect::from_center_size(b.center(), (b.width().max(side * 1e-3), b.height().max(side * 1e-3))))
}
/// Device pixels per cell of the grid a freeform gradient is sampled on.
const CELL_PX: f64 = 4.0;
/// Fewest and most cells along the painted box's longer side.
const MIN_CELLS: f64 = 8.0;
const MAX_CELLS: f64 = 256.0;
/// The grid (columns, rows) a freeform gradient on box `b` (see [`painted_box`]) is sampled on
/// when the box's longer side spans `device` pixels: a cell per few pixels, in power-of-two steps
/// (so zooming reuses grids), 8 to 256 cells along the longer side.
pub fn grid_size(b: Rect, device: f64) -> (u16, u16) {
let side = b.width().max(b.height());
let cells = 2f64.powf((device / CELL_PX).max(1.0).log2().ceil()).clamp(MIN_CELLS, MAX_CELLS);
let along = |len: f64| (cells * len / side).ceil().clamp(2.0, MAX_CELLS) as u16;
(along(b.width()), along(b.height()))
}
/// Where a point lies nearest on a line (see [`Freeform::nearest_on_lines`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LineHit {

View File

@ -11,7 +11,7 @@ use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use vectorcraft_color::freeform::{painted_box, spread_scale};
use vectorcraft_color::freeform::{grid_size, painted_box, spread_scale};
use vectorcraft_color::{Color, Freeform, GradientPaint};
use vectorcraft_geom::{Affine, Rect};
use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler};
@ -19,11 +19,6 @@ use vello_cpu::{Pixmap, RenderContext};
use crate::ink::Ink;
/// Device pixels per grid cell.
const CELL_PX: f64 = 4.0;
/// Fewest and most cells along the box's longer side.
const MIN_CELLS: f64 = 8.0;
const MAX_CELLS: f64 = 256.0;
/// Grids kept before the cache starts over.
const MAX_GRIDS: usize = 128;
@ -69,19 +64,12 @@ pub(crate) fn rasterize(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -
Pixmap::from_parts(data, cols, rows)
}
/// Cells along a side `len` (document units) long whose box's longer side `side` gets `cells`.
fn cells_along(len: f64, side: f64, cells: f64) -> u16 {
(cells * len / side).ceil().clamp(2.0, MAX_CELLS) as u16
}
/// Set freeform gradient `g` on box `bounds` (in the paint's space, which the context's current
/// transform maps to pixels) as the context paint. Returns false for an empty box.
pub(crate) fn set_freeform_paint(ctx: &mut RenderContext, g: &GradientPaint, bounds: Rect, ink: Ink) -> bool {
let Some(b) = painted_box(bounds) else { return false };
let side = b.width().max(b.height());
let device = side * ctx.transform().determinant().abs().sqrt();
let cells = 2f64.powf((device / CELL_PX).max(1.0).log2().ceil()).clamp(MIN_CELLS, MAX_CELLS);
let (cols, rows) = (cells_along(b.width(), side, cells), cells_along(b.height(), side, cells));
let device = b.width().max(b.height()) * ctx.transform().determinant().abs().sqrt();
let (cols, rows) = grid_size(b, device);
let f = g.freeform_on(b);
let k = key(&f, b, cols, rows, ink);
let pm = GRIDS.with(|c| c.borrow().get(&k).cloned()).unwrap_or_else(|| {

View File

@ -8,7 +8,7 @@ use vectorcraft_color::{BlendMode, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{
AppearanceItem, Document, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextKind, TextObject,
};
use vectorcraft_doc::{CharStyle, Justify};
use vectorcraft_doc::{CharStyle, ImageBlob, Justify};
use vectorcraft_effects::stroke::{self, Written, WrittenShape};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
@ -712,6 +712,10 @@ impl Writer<'_> {
/// 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 {
if g.gradient.kind == GradientKind::Freeform {
// Object fills and strokes are images (see `raster_paint`); characters' paints aren't.
self.warn("freeform gradients on characters are written as linear gradients");
}
let mut geom = g.resolve(bounds);
geom.transform(self.xf, g.gradient.kind);
let (s, e) = (geom.start, geom.end);
@ -813,13 +817,7 @@ impl Writer<'_> {
if stroke::is_plain(st) {
return StrokePlan::Written(stroke::for_writer(&BezPath::new(), st));
}
let bp = bp.get_or_insert_with(|| {
let mut bp = BezPath::new();
for p in paths {
bp.extend(p.to_bezpath());
}
bp
});
let bp = bp.get_or_insert_with(|| doc_path(paths));
let doc = self.doc;
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
match st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) {
@ -894,6 +892,7 @@ impl Writer<'_> {
&& strokes <= 1
&& !items.iter().any(|i| has_raster(i.effects()))
&& items.iter().all(|i| i.blend() == BlendMode::Normal)
&& !items.iter().any(|i| freeform(i.paint()).is_some())
&& plans.iter().flatten().all(|p| matches!(p, StrokePlan::Written(Written { shape: WrittenShape::Stroke { .. }, side: None })));
if simple {
let mut p = Props::new();
@ -926,12 +925,17 @@ impl Writer<'_> {
let mut p = Props::new();
match (it, plan) {
(AppearanceItem::Fill(f), _) => {
self.fill_props(f, rule, bounds, &mut p);
if f.blend != BlendMode::Normal {
p.push(("mix-blend-mode", blend_css(f.blend).into()));
let blend = (f.blend != BlendMode::Normal).then(|| ("mix-blend-mode", blend_css(f.blend).to_string()));
if let (Some(g), Some(b)) = (freeform(&f.paint), bounds) {
p.extend((f.opacity < 1.0).then(|| ("opacity", fmt_num(f.opacity as f64, 3))));
p.extend(blend);
self.raster_paint(g, b, &[d.to_string()], rule, &p, "");
} else {
self.fill_props(f, rule, bounds, &mut p);
p.extend(blend);
let a = self.attrs(&p);
self.line(&format!("<path d=\"{d}\"{a}/>"));
}
let a = self.attrs(&p);
self.line(&format!("<path d=\"{d}\"{a}/>"));
}
(AppearanceItem::Stroke(s), Some(StrokePlan::Brush(art))) => {
// The brush art takes the stroke's opacity and blend mode as a group.
@ -953,23 +957,39 @@ impl Writer<'_> {
(AppearanceItem::Stroke(s), Some(StrokePlan::Written(w))) => {
let side = self.side_attr(w.side, d, rule, w.reach(s, bounds.unwrap_or_default()));
let blend = (s.blend != BlendMode::Normal).then(|| ("mix-blend-mode", blend_css(s.blend).to_string()));
match &w.shape {
WrittenShape::Stroke { width } => {
let opacity = (s.opacity < 1.0).then(|| ("opacity", fmt_num(s.opacity as f64, 3)));
match (&w.shape, freeform(&s.paint).zip(paint_bounds(s))) {
(WrittenShape::Stroke { width }, Some((g, b))) => {
// The stroke's outline clips the gradient's image.
let bp = bp.get_or_insert_with(|| doc_path(paths));
let outline = stroke::line_outline(bp, s, *width, OUTLINE_TOLERANCE);
let clip = self.path_d(&PathData::from_bezpath(&outline), self.xf);
p.extend(opacity);
p.extend(blend);
self.raster_paint(g, b, &[clip], FillRule::NonZero, &p, &side);
}
(WrittenShape::Fill(outlines), Some((g, b))) if s.path_gradient().is_none() => {
let clip: Vec<String> = outlines.iter().map(|o| self.path_d(&PathData::from_bezpath(o), self.xf)).collect();
p.extend(opacity);
p.extend(blend);
self.raster_paint(g, b, &clip, FillRule::NonZero, &p, &side);
}
(WrittenShape::Stroke { width }, _) => {
p.push(("fill", "none".into()));
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}/>"));
}
WrittenShape::Fill(outlines) if s.path_gradient().is_some() => {
(WrittenShape::Fill(outlines), _) if s.path_gradient().is_some() => {
// A gradient along or across the stroke: slices clipped to its outlines.
if let Some(ws) = bp.as_ref().and_then(|bp| stroke::written_slices(bp, rule, s, outlines)) {
p.extend((s.opacity < 1.0).then(|| ("opacity", fmt_num(s.opacity as f64, 3))));
p.extend(opacity);
p.extend(blend);
self.sliced(&ws, &p, &side);
}
}
WrittenShape::Fill(outlines) => {
(WrittenShape::Fill(outlines), _) => {
let paint = self.paint(&s.paint, paint_bounds(s));
let ds: Vec<String> = outlines.iter().map(|o| self.path_d(&PathData::from_bezpath(o), self.xf)).collect();
let opacity = (s.opacity < 1.0).then(|| fmt_num(s.opacity as f64, 3));
@ -1265,18 +1285,10 @@ impl Writer<'_> {
}
NodeKind::Text(t) => self.text_node(n, t),
NodeKind::Image(im) => {
let link = self.opts.images == ImageMode::Link;
let href = match (im.link.as_ref().filter(|_| link), self.doc.images.get(&im.key)) {
let (doc, link) = (self.doc, self.opts.images == ImageMode::Link);
let href = match (im.link.as_ref().filter(|_| link), doc.images.get(&im.key)) {
(Some(l), _) => l.path.clone(),
(None, Some(b)) if !b.bytes.is_empty() && link => {
// Named after the bytes: image keys ("raster-1"…) repeat across documents.
let name = format!("{}.{}", b.content_key(), image_ext(&b.mime));
if !self.linked.iter().any(|l| l.name == name) {
self.linked.push(LinkedImage { name: name.clone(), bytes: b.bytes.clone() });
}
name
}
(None, Some(b)) if !b.bytes.is_empty() => format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes)),
(None, Some(b)) if !b.bytes.is_empty() => self.blob_href(b),
_ => match &im.link {
Some(l) => l.path.clone(),
None => return,
@ -1336,6 +1348,70 @@ impl Writer<'_> {
}
}
/// The `href` of image bytes `b`: a `data:` URI, or with [`ImageMode::Link`] a file written
/// next to the SVG, named after the bytes (image keys such as "raster-1" repeat across
/// documents).
fn blob_href(&mut self, b: &ImageBlob) -> String {
if self.opts.images != ImageMode::Link {
return format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes));
}
let name = format!("{}.{}", b.content_key(), image_ext(&b.mime));
if !self.linked.iter().any(|l| l.name == name) {
self.linked.push(LinkedImage { name: name.clone(), bytes: b.bytes.clone() });
}
name
}
/// A freeform gradient `g` painted over `bounds` (the space [`Self::xf`] maps into user
/// space), which SVG can't express: an `<image>` of the colour field over its painted box,
/// sampled as the canvas samples it (at twice the box's size in points) and clipped to
/// `clip` (outlines in user space filled by `rule`), in a group with `props` and the `side`
/// attribute. Nothing is written for an empty box.
fn raster_paint(&mut self, g: &GradientPaint, bounds: Rect, clip: &[String], rule: FillRule, props: &Props, side: &str) {
use vectorcraft_color::freeform::{grid_size, painted_box, spread_scale};
let Some(b) = painted_box(bounds) else { return };
let (cols, rows) = grid_size(b, 2.0 * b.width().max(b.height()));
let field = g.freeform_on(b).field(spread_scale(b));
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
let rgba: Vec<u8> = field.grid(b, cols, rows).flat_map(|([r, g, bl], a)| [q(r), q(g), q(bl), q(a)]).collect();
let mut png = Vec::new();
let Some(img) = image::RgbaImage::from_raw(u32::from(cols), u32::from(rows), rgba) else { return };
if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() {
return;
}
self.warn("freeform gradients are written as images clipped to their shapes");
let href = self.blob_href(&ImageBlob::new("image/png", png));
let cid = self.fresh_id("clip-path");
let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" };
self.def(1, &format!("<clipPath id=\"{cid}\">"));
for d in clip {
self.def(2, &format!("<path d=\"{d}\"{r}/>"));
}
self.def(1, "</clipPath>");
let a = self.attrs(props);
let nested = !side.is_empty();
if nested {
self.line(&format!("<g{a}{side}>"));
self.depth += 1;
self.line(&format!("<g clip-path=\"url(#{cid})\">"));
} else {
self.line(&format!("<g clip-path=\"url(#{cid})\"{a}>"));
}
self.depth += 1;
let (x, y, w, h) = (self.num(b.x0), self.num(b.y0), self.num(b.width()), self.num(b.height()));
let tr = if self.xf == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(self.xf)) };
self.line(&format!(
"<image x=\"{x}\" y=\"{y}\" width=\"{w}\" height=\"{h}\"{tr} preserveAspectRatio=\"none\" xlink:href=\"{}\"/>",
xml_escape(&href)
));
self.depth -= 1;
self.line("</g>");
if nested {
self.depth -= 1;
self.line("</g>");
}
}
/// The id of the `<symbol>` def instance `inst` (transform `xf`) can `<use>`, written on first
/// use. `None` when the instance needs its own copy of the art: stained by its fill, or moved
/// by a transform under which the def would not look as the canvas paints it ([`Reuse`]).
@ -1819,6 +1895,26 @@ fn initial_value(k: &str) -> Option<&'static str> {
})
}
/// A shape's paths as one path (document space).
fn doc_path(paths: &[&PathData]) -> BezPath {
let mut bp = BezPath::new();
for p in paths {
bp.extend(p.to_bezpath());
}
bp
}
/// How closely stroke outlines that clip an image follow the stroke (points).
const OUTLINE_TOLERANCE: f64 = 0.01;
/// The freeform gradient a paint is, if it is one (SVG has no freeform gradients).
fn freeform(p: &Paint) -> Option<&GradientPaint> {
match p {
Paint::Gradient(g) if g.gradient.kind == GradientKind::Freeform => Some(g),
_ => None,
}
}
/// Any visible raster effect (shadow, glow, blur, feather) in `effects`?
fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool {
effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id))

View File

@ -24,7 +24,11 @@
//! * **Multiple fills/strokes** (or per-fill/stroke blend modes) are written as a `<g>` holding one
//! `<path>` per appearance item, in paint order.
//! * Live geometry effects are written as their result and raster effects (shadows, glows, blurs)
//! as SVG filters. Freeform gradients export as linear gradients.
//! as SVG filters.
//! * **Freeform gradients**, which SVG can't express, fill or stroke objects as an `<image>` of
//! their colour field (sampled as the canvas samples it) clipped to the shape or the stroke's
//! outline; on characters they are written as linear gradients. Gradients along or across
//! strokes are written as slices of linear gradients clipped to the stroke.
//! * **Symbol instances** the shared `<symbol>` would paint differently from the canvas (stained,
//! scaled with strokes, turned or scaled with effects, brushes or live objects, or holding
//! pattern paints or unlinked masks, which stay on the page) are written as their own art.

View File

@ -0,0 +1,106 @@
//! Paints SVG can't express: freeform gradients fill and stroke objects as an `<image>` of their
//! colour field clipped to the shape (or the stroke's outline), and the SVG reads back looking as
//! the canvas paints it.
// 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::freeform::spread_scale;
use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{Point, Rect, shapes};
use vectorcraft_svg::{ExportOptions, ImageMode, export_full, import};
use vectorcraft_testkit::format::base64_decode;
use vectorcraft_testkit::raster::{assert_similar, render_artboard};
/// Red at the top left, blue at the bottom right, a half-transparent green at the top right.
fn freeform() -> Paint {
let p = |x, y, c, opacity| FreeformPoint { opacity, spread: 0.1, ..FreeformPoint::new(Point::new(x, y), c) };
let f = Freeform {
points: vec![
p(30.0, 30.0, Color::rgb(1.0, 0.0, 0.0), 1.0),
p(170.0, 130.0, Color::rgb(0.0, 0.0, 1.0), 1.0),
p(170.0, 30.0, Color::rgb(0.0, 1.0, 0.0), 0.5),
],
..Default::default()
};
let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() });
g.freeform = Some(f);
Paint::Gradient(Box::new(g))
}
/// A 200 × 160 document holding `n`.
fn doc_with(mut n: Node) -> Document {
let mut d = Document::new(200.0, 160.0);
n.id = d.alloc_id();
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
/// The data of the one embedded PNG.
fn png_of(svg: &str) -> image::RgbaImage {
let data = svg.split("data:image/png;base64,").nth(1).expect("an embedded PNG").split('"').next().unwrap();
image::load_from_memory(&base64_decode(data).unwrap()).unwrap().to_rgba8()
}
#[test]
fn a_freeform_fill_is_an_image_clipped_to_the_shape() {
let shape = shapes::ellipse(Rect::new(20.0, 20.0, 180.0, 140.0));
let d = doc_with(Node::path(NodeId(0), shape, Appearance::basic(freeform(), Paint::None, 0.0)));
let o = export_full(&d, &ExportOptions::default(), None);
let svg = &o.svg;
assert!(!svg.contains("linearGradient"), "{svg}");
assert_eq!(svg.matches("<image ").count(), 1, "{svg}");
assert!(svg.contains("<clipPath") && svg.contains("<g clip-path=\"url(#clip-path-1)\">"), "{svg}");
assert!(o.warnings.iter().any(|w| w.contains("freeform")), "{:?}", o.warnings);
// The image is the colour field over the shape's box, straight alpha: the green point is
// half transparent.
let png = png_of(svg);
let (w, h) = png.dimensions();
assert!((8..=256).contains(&w) && (8..=256).contains(&h), "{w}×{h}");
let b = Rect::new(20.0, 20.0, 180.0, 140.0);
let Paint::Gradient(g) = freeform() else { panic!("a gradient") };
let field = g.freeform.unwrap().field(spread_scale(b));
for (x, y) in [(2, 2), (w / 2, h / 2), (w - 3, 2), (w - 3, h - 3)] {
let at = Point::new(b.x0 + (x as f64 + 0.5) * b.width() / w as f64, b.y0 + (y as f64 + 0.5) * b.height() / h as f64);
let ([r, g, bl], a) = field.sample(at);
let want = [r, g, bl, a].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as i32);
let got = png.get_pixel(x, y).0.map(i32::from);
assert!(want.iter().zip(got).all(|(w, g)| (w - g).abs() <= 1), "({x}, {y}): {got:?} vs {want:?}");
}
// Read back, it looks as the canvas paints the gradient.
let back = import(svg).unwrap();
let mut images = 0;
back.walk(|n| images += usize::from(matches!(n.kind, NodeKind::Image(_))));
assert_eq!(images, 1, "the image comes back (in its clip group)");
assert_similar(&render_artboard(&d), &render_artboard(&back), 12.0, 0.01);
}
#[test]
fn a_freeform_stroke_is_an_image_clipped_to_the_stroke_outline() {
let shape = shapes::rectangle(Rect::new(40.0, 40.0, 160.0, 120.0));
let mut ap = Appearance::basic(Paint::solid(Color::WHITE), freeform(), 16.0);
if let Some(AppearanceItem::Stroke(s)) = ap.items.get_mut(1) {
s.opacity = 0.8;
}
let d = doc_with(Node::path(NodeId(0), shape, ap));
let svg = export_full(&d, &ExportOptions::default(), None).svg;
assert_eq!(svg.matches("<image ").count(), 1, "{svg}");
assert!(svg.contains("opacity=\"0.8\"") && !svg.contains("linearGradient"), "{svg}");
let back = import(&svg).unwrap();
assert_similar(&render_artboard(&d), &render_artboard(&back), 12.0, 0.01);
}
#[test]
fn linked_images_write_the_gradient_next_to_the_svg_and_characters_stay_linear() {
let d = doc_with(Node::path(NodeId(0), shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 120.0)), Appearance::basic(freeform(), Paint::None, 0.0)));
let o = export_full(&d, &ExportOptions { images: ImageMode::Link, ..Default::default() }, None);
assert_eq!(o.linked.len(), 1);
assert!(o.linked[0].name.ends_with(".png") && o.svg.contains(&format!("xlink:href=\"{}\"", o.linked[0].name)), "{}", o.svg);
// Characters keep a (linear) gradient paint, with a warning.
let style = vectorcraft_doc::CharStyle { size: 30.0, fill: freeform(), ..Default::default() };
let t = vectorcraft_doc::TextObject::point(Point::new(20.0, 60.0), "Hi", style);
let o = export_full(&doc_with(Node::new(NodeId(0), NodeKind::Text(Box::new(t)))), &ExportOptions::default(), None);
assert!(o.svg.contains("linearGradient") && !o.svg.contains("<image"), "{}", o.svg);
assert!(o.warnings.iter().any(|w| w.contains("characters")), "{:?}", o.warnings);
}