From 33023c5a2b31c2544e7b4b5bf809aef09eecd5c1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 13:17:56 +0700 Subject: [PATCH] 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 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 --- crates/color/src/freeform.rs | 16 ++++ crates/render/src/freeform.rs | 18 +--- crates/svg/src/export.rs | 154 ++++++++++++++++++++++++------ crates/svg/src/lib.rs | 6 +- crates/svg/tests/raster_paints.rs | 106 ++++++++++++++++++++ 5 files changed, 255 insertions(+), 45 deletions(-) create mode 100644 crates/svg/tests/raster_paints.rs diff --git a/crates/color/src/freeform.rs b/crates/color/src/freeform.rs index 9dc8f063..3ce5f8ab 100644 --- a/crates/color/src/freeform.rs +++ b/crates/color/src/freeform.rs @@ -96,6 +96,22 @@ pub fn painted_box(bounds: Rect) -> Option { (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 { diff --git a/crates/render/src/freeform.rs b/crates/render/src/freeform.rs index f6153038..f583af20 100644 --- a/crates/render/src/freeform.rs +++ b/crates/render/src/freeform.rs @@ -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(|| { diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 35585dd3..24ff2203 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -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!("")); } - let a = self.attrs(&p); - self.line(&format!("")); } (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 = 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!("")); } - 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 = 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 `` 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 = 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!("")); + for d in clip { + self.def(2, &format!("")); + } + self.def(1, ""); + let a = self.attrs(props); + let nested = !side.is_empty(); + if nested { + self.line(&format!("")); + self.depth += 1; + self.line(&format!("")); + } else { + self.line(&format!("")); + } + 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!( + "", + xml_escape(&href) + )); + self.depth -= 1; + self.line(""); + if nested { + self.depth -= 1; + self.line(""); + } + } + /// The id of the `` def instance `inst` (transform `xf`) can ``, 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)) diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 74f9c14f..a3d9bc76 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -24,7 +24,11 @@ //! * **Multiple fills/strokes** (or per-fill/stroke blend modes) are written as a `` holding one //! `` 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 `` 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 `` 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. diff --git a/crates/svg/tests/raster_paints.rs b/crates/svg/tests/raster_paints.rs new file mode 100644 index 00000000..31eb41a2 --- /dev/null +++ b/crates/svg/tests/raster_paints.rs @@ -0,0 +1,106 @@ +//! Paints SVG can't express: freeform gradients fill and stroke objects as an `` 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(""), "{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("