diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index c0b80ecef..fe130b186 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -538,6 +538,64 @@ impl GradientPaint { f.transform(a); } } + /// Is this gradient drawn as pixels rather than as a vector gradient (SVG and PDF draw such + /// gradients as images): a freeform gradient. + pub fn draws_as_pixels(&self) -> bool { + self.gradient.kind == GradientKind::Freeform + } + /// A linear or radial gradient placed on box `bounds` as a `cols` × `rows` grid of + /// unpremultiplied RGBA8 pixels over `area`, row by row, each sampled at its centre (colours + /// as `rgb` gives them, mixed as the renderer mixes them: [`Ramp`]). + pub fn pixels(&self, bounds: Rect, area: Rect, cols: u16, rows: u16, rgb: &dyn Fn(&Color) -> [f32; 3]) -> impl Iterator + use<> { + let (kind, geom) = (self.gradient.kind, self.resolve(bounds)); + let ramp = Ramp::new(&self.gradient, rgb); + let (cw, ch) = (area.width() / f64::from(cols.max(1)), area.height() / f64::from(rows.max(1))); + let (x0, y0) = (area.x0, area.y0); + (0..u32::from(rows)).flat_map(move |j| (0..u32::from(cols)).map(move |i| (i, j))).map(move |(i, j)| { + let p = Point::new(x0 + (f64::from(i) + 0.5) * cw, y0 + (f64::from(j) + 0.5) * ch); + let c = ramp.at(geom.param_at(kind, p) as f32); + let level = |k: usize| (c[k].clamp(0.0, 1.0) * 255.0).round() as u8; + [level(0), level(1), level(2), level(3)] + }) + } +} + +/// A gradient's colours ready to be sampled pixel by pixel, in a colour space of the caller's +/// (display RGB, or an ink's coverage): its stops, midpoints as stops (as +/// [`Gradient::expanded_stops`]), each turned into that space once, with colour and opacity +/// mixed linearly between them, each on its own (as the renderer's vector gradients mix them). +pub struct Ramp { + stops: Vec<(f32, [f32; 4])>, +} + +impl Ramp { + pub fn new(g: &Gradient, rgb: &dyn Fn(&Color) -> [f32; 3]) -> Self { + let stops = g + .expanded() + .map(|(t, c, o, _)| { + let [r, gr, b] = rgb(&c); + (t, [r, gr, b, o]) + }) + .collect(); + Self { stops } + } + /// Unpremultiplied colour and opacity at `t`, padded past the ends (transparent black + /// without stops). + pub fn at(&self, t: f32) -> [f32; 4] { + let Some(&(t0, first)) = self.stops.first() else { return [0.0; 4] }; + // Not after the first stop (or not a number): its colour. + if t.partial_cmp(&t0) != Some(std::cmp::Ordering::Greater) { + return first; + } + for w in self.stops.windows(2) { + let ((a, ca), (b, cb)) = (w[0], w[1]); + if t <= b { + let u = if b - a > 1e-9 { (t - a) / (b - a) } else { 1.0 }; + return std::array::from_fn(|k| ca[k] + (cb[k] - ca[k]) * u); + } + } + self.stops.last().map_or([0.0; 4], |s| s.1) + } } #[cfg(test)] @@ -828,4 +886,26 @@ mod tests { h.keep_focal_from(&before); assert!(close(h.focal.unwrap(), Point::new(0.0, -20.0)), "{h:?}"); } + + #[test] + fn pixels_sample_the_ramp_at_each_cell_centre() { + // White → black left to right over a 10 × 2 box, a midpoint at 30% and a half-opaque end. + let mut g = GradientPaint::new(Gradient::default()); + g.gradient.stops[0].midpoint = 0.3; + g.gradient.stops[1].opacity = 0.5; + let b = Rect::new(0.0, 0.0, 10.0, 2.0); + let px: Vec<[u8; 4]> = g.pixels(b, b, 10, 2, &Color::to_rgb).collect(); + assert_eq!(px.len(), 20); + let ramp = Ramp::new(&g.gradient, &Color::to_rgb); + for (i, p) in px.iter().take(10).enumerate() { + let c = ramp.at((i as f32 + 0.5) / 10.0).map(|v| (v * 255.0).round() as u8); + assert_eq!(*p, c, "pixel {i}"); + } + assert_eq!(px[3], px[13], "rows alike"); + // The midpoint stop: grey at 30%; padded past the ends. + assert!((ramp.at(0.3)[0] - 0.5).abs() < 0.02, "{:?}", ramp.at(0.3)); + assert_eq!(ramp.at(-1.0), [1.0, 1.0, 1.0, 1.0]); + assert_eq!(ramp.at(2.0), [0.0, 0.0, 0.0, 0.5]); + assert_eq!(ramp.at(f32::NAN), [1.0, 1.0, 1.0, 1.0]); + } } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 3997d5ec2..ee2500c56 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -675,6 +675,8 @@ struct GlyphLine { /// Most pixels along a side of a freeform gradient's image (its colour field is smooth). const MAX_FIELD_PX: f64 = 512.0; +/// Most pixels along a side of a linear or radial gradient's image. +const MAX_GRADIENT_PX: f64 = 4096.0; /// The largest magnitude a coordinate, length or transform entry may have in the content stream it /// is drawn into (for glyph spacing, in thousandths of an em). Art that would go beyond it is left @@ -1276,19 +1278,19 @@ impl Exporter<'_> { } /// Is `p` a paint PDF has no paint for, which [`Self::area`] draws as art: a pattern swatch or - /// a freeform gradient? + /// a gradient drawn as pixels ([`vectorcraft_color::GradientPaint::draws_as_pixels`])? fn area_paint(&self, p: &Paint) -> bool { match p { Paint::Pattern { pattern, .. } => self.doc.pattern(pattern).is_some(), - Paint::Gradient(g) => g.gradient.kind == GradientKind::Freeform, + Paint::Gradient(g) => g.draws_as_pixels(), _ => false, } } /// Paint the area `clip` (filled by `rule`; bounds `area`) with `p` at `transparency` (opacity, /// blend mode) when it is an [area paint](Self::area_paint): a pattern's tile instances - /// covering it, or an image of a freeform gradient placed on `bounds` (the painted object's - /// box, as on the canvas), clipped to it. False (nothing drawn) for other paints. + /// covering it, or an image of a gradient drawn as pixels placed on `bounds` (the painted + /// object's box, as on the canvas), clipped to it. False (nothing drawn) for other paints. #[allow(clippy::too_many_arguments)] fn area( &mut self, @@ -1331,8 +1333,9 @@ impl Exporter<'_> { true } - /// Draw freeform gradient `g`, placed on `bounds`, as an image of its colour field over `area`, - /// sampled at the document's raster effects resolution (at most [`MAX_FIELD_PX`] a side). + /// Draw gradient `g`, placed on `bounds`, as an image over `area`, sampled at the document's + /// raster effects resolution: a freeform gradient's colour field (at most [`MAX_FIELD_PX`] a + /// side), or a linear or radial gradient (at most [`MAX_GRADIENT_PX`] a side). fn field_image(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect) { use vectorcraft_color::freeform::{painted_box, spread_scale}; let Some(b) = painted_box(bounds) else { return }; @@ -1341,11 +1344,18 @@ impl Exporter<'_> { if !(long > 1e-9 && long.is_finite()) { return; } - let k = (self.doc.raster_effects_ppi / 72.0).min(MAX_FIELD_PX / long); - let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_FIELD_PX) as u16; + let freeform = g.gradient.kind == GradientKind::Freeform; + let max = if freeform { MAX_FIELD_PX } else { MAX_GRADIENT_PX }; + let k = (self.doc.raster_effects_ppi / 72.0).min(max / long); + let along = |len: f64| (len * k).ceil().clamp(1.0, max) as u16; let (cols, rows) = (along(area.width()), along(area.height())); - let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb()); - let rgba: Vec = field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect(); + let rgba: Vec = if freeform { + let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb()); + field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect() + } else { + self.warn("linear and radial gradients drawn as pixels are exported as images"); + g.pixels(b, area, cols, rows, &|c| c.to_rgb()).flatten().collect() + }; let pixels = if self.convert { self.out.pixels() } else { None }; let (Some(img), Some(size)) = ( crate::images::from_rgba_in(rgba, cols.into(), rows.into(), self.interpolate, pixels), diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 08f0794af..65db09c8e 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -4,7 +4,7 @@ use std::collections::{BTreeSet, HashMap, HashSet}; use std::fmt::Write as _; use std::sync::Arc; -use vectorcraft_color::{BlendMode, GradientKind, GradientPaint, Paint}; +use vectorcraft_color::{BlendMode, Color, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{ AppearanceItem, Document, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, SVG_EVENTS, StrokeAlign, StrokeLayer, TextKind, TextObject, }; @@ -1047,7 +1047,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()) + && !items.iter().any(|i| pixel_gradient(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(); @@ -1081,7 +1081,7 @@ impl Writer<'_> { match (it, plan) { (AppearanceItem::Fill(f), _) => { 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) { + if let (Some(g), Some(b)) = (pixel_gradient(&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, ""); @@ -1113,7 +1113,7 @@ impl Writer<'_> { 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())); 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))) { + match (&w.shape, pixel_gradient(&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)); @@ -1534,24 +1534,38 @@ impl Writer<'_> { 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. + /// A gradient `g` painted over `bounds` (the space [`Self::xf`] maps into user space) that is + /// drawn as pixels ([`GradientPaint::draws_as_pixels`]): an `` over its painted box, + /// clipped to `clip` (outlines in user space filled by `rule`), in a group with `props` and + /// the `side` attribute. A freeform gradient's colour field, which SVG can't express, is + /// sampled as the canvas samples it (at twice the box's size in points); a linear or radial + /// gradient at the document's raster effects resolution (at most [`MAX_GRADIENT_PX`] a + /// side). 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 freeform = g.gradient.kind == GradientKind::Freeform; + let (rgba, cols, rows): (Vec, u16, u16) = if freeform { + 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; + (field.grid(b, cols, rows).flat_map(|([r, g, bl], a)| [q(r), q(g), q(bl), q(a)]).collect(), cols, rows) + } else { + let k = self.doc.raster_effects_ppi / 72.0; + let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_GRADIENT_PX) as u16; + let (cols, rows) = (along(b.width()), along(b.height())); + (g.pixels(b, b, cols, rows, &Color::to_rgb).flatten().collect(), cols, rows) + }; 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"); + self.warn(if freeform { + "freeform gradients are written as images clipped to their shapes" + } else { + "linear and radial gradients drawn as pixels 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 { "" }; @@ -2252,10 +2266,14 @@ fn doc_path(paths: &[&PathData]) -> BezPath { /// 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> { +/// Most pixels along a side of a linear or radial gradient's image. +const MAX_GRADIENT_PX: f64 = 4096.0; + +/// The gradient a paint is, if it is drawn as pixels ([`GradientPaint::draws_as_pixels`]; SVG +/// has no freeform gradients, for one). +fn pixel_gradient(p: &Paint) -> Option<&GradientPaint> { match p { - Paint::Gradient(g) if g.gradient.kind == GradientKind::Freeform => Some(g), + Paint::Gradient(g) if g.draws_as_pixels() => Some(g), _ => None, } }