Files
vectorcraft/crates/svg/tests/raster_paints.rs
T
Taha-Mahmoodi 2bad646821 Gradients: a Perceptual gradient mixes its colours in OKLab, and SVG and PDF write it as an image
Illustrator 30.0's gradient Method, from the public documentation
(https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html):
Classic or Perceptual; perceptual interpolation matches colour transitions to
how the eye perceives differences between colours, and perceptual gradients
are rasterised when saved as PDF or SVG.

- Gradient gains `method` (Classic | Perceptual; serde default Classic,
  written only when Perceptual, so older files open as Classic and Classic
  files are written as before). The colour crate's OKLab helpers open up for it.
- Perceptual mixes neighbouring stop colours in OKLab (sample, and
  sample_keep_model for the stroke meshes, Expand and blends); opacity stays
  linear. Renderers that mix linearly get stops on the perceptual curve,
  placed by halving each half segment where a straight line strays more than
  0.4 of a colour level (a nearly straight segment needs none). Classic
  gradients expand exactly as before.
- draws_as_pixels includes Perceptual gradients, so SVG and PDF write a
  Perceptual fill or stroke as an image at the document's raster effects
  resolution, clipped to what it paints. A gradient along or across a stroke
  stays slices (PDF no longer draws it as a within-stroke image).
- With Dither: a dithered Perceptual fill is the perceptual mix with dither's
  noise (the canvas's dithered grids are keyed by method too); SVG and PDF
  write it as an image with the noise, and a dithered Perceptual stroke as an
  image without it, as the canvas draws it (GradientPaint::pixels, SVG's
  raster_paint and PDF's area are told whether they paint a fill).
2026-10-23 01:53:29 +07:00

233 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Paints SVG can't express: freeform gradients fill and stroke objects as an `<image>` 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("<image ").count(), 1, "{svg}");
assert!(svg.contains("<clipPath") && svg.contains("<g clip-path=\"url(#clip-path-1)\">"), "{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("<image ").count(), 1, "{svg}");
assert!(svg.contains("opacity=\"0.8\"") && !svg.contains("linearGradient"), "{svg}");
let back = import(&svg).unwrap();
assert_similar(&render_artboard(&d), &render_artboard(&back), 12.0, 0.01);
}
#[test]
fn linked_images_write_the_gradient_next_to_the_svg_and_characters_stay_linear() {
let d = doc_with(Node::path(NodeId(0), shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 120.0)), Appearance::basic(freeform(), Paint::None, 0.0)));
let o = export_full(&d, &ExportOptions { images: ImageMode::Link, ..Default::default() }, None);
assert_eq!(o.linked.len(), 1);
assert!(o.linked[0].name.ends_with(".png") && o.svg.contains(&format!("xlink:href=\"{}\"", o.linked[0].name)), "{}", o.svg);
// Characters keep a (linear) gradient paint, with a warning.
let style = vectorcraft_doc::CharStyle { size: 30.0, fill: freeform(), ..Default::default() };
let t = vectorcraft_doc::TextObject::point(Point::new(20.0, 60.0), "Hi", style);
let o = export_full(&doc_with(Node::new(NodeId(0), NodeKind::Text(Box::new(t)))), &ExportOptions::default(), None);
assert!(o.svg.contains("linearGradient") && !o.svg.contains("<image"), "{}", o.svg);
assert!(o.warnings.iter().any(|w| w.contains("characters")), "{:?}", o.warnings);
}
/// A shallow grey ramp, dithered or not.
fn shallow(dither: bool) -> Paint {
let stops = vec![
vectorcraft_color::GradientStop::new(0.0, Color::rgb8(0x40, 0x40, 0x40)),
vectorcraft_color::GradientStop::new(1.0, Color::rgb8(0x4a, 0x4a, 0x4a)),
];
let mut g = Gradient::new(GradientKind::Linear, stops);
g.dither = dither;
Paint::Gradient(Box::new(GradientPaint::new(g)))
}
/// Illustrator's documentation describes dither as the gradient converted to pixels with subtle
/// noise, at the Document Raster Effects resolution
/// (https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-dither-in-gradients.html):
/// a dithered fill is written as an image of the gradient with its noise, clipped to the shape; a
/// stroke (dither applies to fills only) and an undithered fill stay vector gradients.
#[test]
fn a_dithered_fill_is_an_image_at_the_raster_effects_resolution() {
let shape = shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 140.0));
let mut d = doc_with(Node::path(NodeId(0), shape.clone(), Appearance::basic(shallow(true), shallow(true), 4.0)));
d.raster_effects_ppi = 144.0;
let o = export_full(&d, &ExportOptions::default(), None);
let svg = &o.svg;
assert_eq!(svg.matches("<image ").count(), 1, "the fill: {svg}");
assert_eq!(svg.matches("<linearGradient").count(), 1, "the stroke keeps its gradient: {svg}");
assert!(o.warnings.iter().any(|w| w.contains("drawn as pixels are written as images")), "{:?}", o.warnings);
// Two pixels a point at 144 ppi, the levels around the ramp's, with noise along each row.
let png = png_of(svg);
assert_eq!(png.dimensions(), (320, 240));
let row: Vec<u8> = (0..320).map(|x| png.get_pixel(x, 120).0[0]).collect();
let longest = row.chunk_by(|a, b| a == b).map(<[_]>::len).max().unwrap();
assert!(longest <= 12, "no bands: longest run {longest}");
assert!(row.iter().all(|v| (0x3f..=0x4b).contains(v)), "{row:?}");
// Undithered: a vector gradient, no image.
let plain = doc_with(Node::path(NodeId(0), shape, Appearance::basic(shallow(false), Paint::None, 0.0)));
let svg = export_full(&plain, &ExportOptions::default(), None).svg;
assert!(!svg.contains("<image ") && svg.contains("<linearGradient"), "{svg}");
}
/// Blue to yellow, left to right, mixed `method`'s way.
fn blue_yellow(method: vectorcraft_color::GradientMethod) -> Paint {
let stops = vec![
vectorcraft_color::GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)),
vectorcraft_color::GradientStop::new(1.0, Color::rgb(1.0, 1.0, 0.0)),
];
let mut g = Gradient::new(GradientKind::Linear, stops);
g.method = method;
Paint::Gradient(Box::new(GradientPaint::new(g)))
}
/// Illustrator's documentation: perceptual gradients aren't supported in SVG exports, and are
/// rasterised when saved that way
/// (https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html).
/// A Perceptual fill and stroke are written as images of the gradient (at the raster effects
/// resolution, clipped to the shape and the stroke's outline) mixed as the canvas mixes them; a
/// Classic gradient stays a vector gradient.
#[test]
fn perceptual_gradients_are_images_and_classic_ones_stay_vector() {
use vectorcraft_color::GradientMethod;
let shape = shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 140.0));
let p = blue_yellow(GradientMethod::Perceptual);
let mut d = doc_with(Node::path(NodeId(0), shape.clone(), Appearance::basic(p.clone(), p, 4.0)));
d.raster_effects_ppi = 144.0;
let o = export_full(&d, &ExportOptions::default(), None);
let svg = &o.svg;
assert_eq!(svg.matches("<image ").count(), 2, "the fill and the stroke: {svg}");
assert!(!svg.contains("<linearGradient"), "{svg}");
assert!(o.warnings.iter().any(|w| w.contains("drawn as pixels are written as images")), "{:?}", o.warnings);
// The fill's image: two pixels a point at 144 ppi, the middle a blue-grey (OKLab), not the
// mid grey a display RGB mix gives.
let png = png_of(svg);
assert_eq!(png.dimensions(), (320, 240));
let [r, g, b, a] = png.get_pixel(160, 120).0.map(i32::from);
assert_eq!(a, 255);
assert!(b > r + 20 && b > g + 10, "a blue-grey: {:?}", [r, g, b]);
let Paint::Gradient(want) = blue_yellow(GradientMethod::Perceptual) else { panic!("a gradient") };
let [wr, wg, wb] = want.gradient.sample(160.5 / 320.0).0.to_rgb().map(|v| (v * 255.0).round() as i32);
assert!((r - wr).abs() <= 1 && (g - wg).abs() <= 1 && (b - wb).abs() <= 1, "{:?} vs {:?}", [r, g, b], [wr, wg, wb]);
// Read back, it looks as the canvas paints it.
let back = import(svg).unwrap();
assert_similar(&render_artboard(&d), &render_artboard(&back), 12.0, 0.01);
// Classic: a vector gradient, no image.
let c = blue_yellow(GradientMethod::Classic);
let plain = doc_with(Node::path(NodeId(0), shape, Appearance::basic(c.clone(), c, 4.0)));
let svg = export_full(&plain, &ExportOptions::default(), None).svg;
assert!(!svg.contains("<image ") && svg.matches("<linearGradient").count() == 2, "{svg}");
}
/// A dithered Perceptual gradient: its fill is an image of the perceptual mix with dither's noise
/// (within a colour level or so of the mix, and not the undithered pixels), its stroke an image of
/// the perceptual mix without noise (dither applies to fills only).
#[test]
fn a_dithered_perceptual_fill_is_the_perceptual_mix_with_noise() {
use vectorcraft_color::GradientMethod;
let paint = |dither| {
let Paint::Gradient(mut g) = blue_yellow(GradientMethod::Perceptual) else { panic!("a gradient") };
g.gradient.dither = dither;
Paint::Gradient(g)
};
let png = |dither, stroke: bool| {
let shape = shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 140.0));
let (fill, stroke) = if stroke { (Paint::None, paint(dither)) } else { (paint(dither), Paint::None) };
let mut d = doc_with(Node::path(NodeId(0), shape, Appearance::basic(fill, stroke, 4.0)));
d.raster_effects_ppi = 144.0;
let svg = export_full(&d, &ExportOptions::default(), None).svg;
assert_eq!(svg.matches("<image ").count(), 1, "{svg}");
assert!(!svg.contains("<linearGradient"), "{svg}");
png_of(&svg)
};
let (dithered, plain) = (png(true, false), png(false, false));
assert_eq!(dithered.dimensions(), (320, 240));
let Paint::Gradient(want) = paint(false) else { panic!("a gradient") };
let mut noisy = 0;
for x in 0..320 {
let (p, q) = (dithered.get_pixel(x, 120).0, plain.get_pixel(x, 120).0);
let w = want.gradient.sample((x as f32 + 0.5) / 320.0).0.to_rgb();
for k in 0..3 {
let off = (f32::from(p[k]) - w[k].clamp(0.0, 1.0) * 255.0).abs();
assert!(off <= 2.0, "column {x}, channel {k}: {p:?} vs {w:?}");
}
noisy += usize::from(p != q);
}
assert!(noisy > 100, "dither's noise: {noisy} of 320 pixels differ");
assert_eq!(png(true, true), png(false, true), "a stroke has no dither");
}