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("