mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
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:
committed by
bflatastic
parent
4bb6f32911
commit
a1d8952bc8
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user