Gradients: a linear or radial gradient can be drawn as pixels, and SVG and PDF write such a gradient as an image

Shared groundwork for gradient options SVG and PDF can't express (Illustrator
30's dither and perceptual gradients): GradientPaint::pixels samples a placed
linear or radial gradient on a grid (Ramp: its stops, midpoints as stops, mixed
as the renderer mixes them), and draws_as_pixels says which gradients are drawn
that way (freeform ones, so far). The SVG and PDF writers' freeform-gradient
image path takes any such gradient, a linear or radial one at the document's
raster effects resolution. No change in output yet.
This commit is contained in:
Taha-Mahmoodi
2026-10-22 22:38:41 +07:00
committed by bflatastic
parent 4bb6f32911
commit a1d8952bc8
3 changed files with 135 additions and 27 deletions
+80
View File
@@ -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<Item = [u8; 4]> + 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]);
}
}
+20 -10
View File
@@ -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<u8> = field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect();
let rgba: Vec<u8> = 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),
+35 -17
View File
@@ -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 `<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.
/// A gradient `g` painted over `bounds` (the space [`Self::xf`] maps into user space) that is
/// drawn as pixels ([`GradientPaint::draws_as_pixels`]): an `<image>` 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<u8> = 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<u8>, 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,
}
}