Files
vectorcraft/crates/svg/tests/it/spread.rs
T
dotCooCoo 91d705e155 Gradients: repeat or reflect a gradient's stops a set number of times
A linear, radial, conic or diamond gradient has a spread: Pad (the
default, as before), Repeat or Reflect, with a count of 1 to 100
(VectorCraft's own limit). Repeat and Reflect run the stops that many
times along the vector (around the turn for a conic) and keep going past
both ends, Reflect running every other run backwards, as SVG's
spreadMethod does.

The Gradient panel has a Spread menu and a Repeats field, and the
Gradient tool marks where the runs meet. paint.setFill and
paint.editGradient take `spread` and `repeats`; bad values are errors.
Presets, swatches, blends, strokes along or across a path, Outline
Stroke and Object › Expand follow the spread; Expand gives each run the
same whole number of objects, and Outline Stroke's mesh keeps the color
on each side of a sharp step between two stops at one offset.

SVG writes and reads spreadMethod: SVG import now reads a repeating or
reflecting gradient as one that repeats or reflects once, instead of
expanding its stops over the shape. CSS writes repeating gradients.
PDF writes a conic's sweep shading over one run. PDF, EPS, legacy .ai
and the .ai editing data write the other kinds with their stops
repeated across the shape: at most 256 runs and 16,384 stops (256 stops
in .ai files), and for a radial or diamond no farther than 65,536 runs
from its center, with a warning past those. PDF import now finds sharp
steps in any axial or radial shading instead of blurring them.

Docs: docs/mcp.md (Spread), docs/target-app-parity.md, docs/gaps.md.
2026-11-12 06:52:38 -08:00

133 lines
7.3 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.
//! Gradients that repeat or reflect: SVG export writes `spreadMethod` over one run of the stops
//! (a conic's wedges and a diamond's quarters repeat with them), and an independent renderer
//! draws the file as the canvas draws the gradient; SVG import reads `spreadMethod` as the
//! gradient's spread; CSS writes repeating gradients.
use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientSpread, GradientStop, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeKind};
use vectorcraft_geom::{Point, Rect, Vec2, shapes};
use vectorcraft_svg::{ExportOptions, export_full, import};
use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard};
/// The SVG rendered by resvg on white at 1 px/pt.
fn resvg_render(svg: &str, w: u32, h: u32) -> Image {
let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("parse");
let mut pm = resvg::tiny_skia::Pixmap::new(w, h).unwrap();
pm.fill(resvg::tiny_skia::Color::WHITE);
resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm.as_mut());
Image { width: w, height: h, rgba: pm.data().to_vec() }
}
/// A red-to-blue gradient of `kind` that spreads by `spread` 3 times, placed at `geom`.
fn paint(kind: GradientKind, spread: GradientSpread, geom: GradientGeom) -> Paint {
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
let mut g = GradientPaint::new(Gradient { spread, repeats: 3, ..Gradient::new(kind, stops) });
g.geom = Some(geom);
Paint::Gradient(Box::new(g))
}
/// A 200 × 160 document holding a rectangle painted with `fill` and `stroke`.
fn doc(fill: Paint, stroke: Paint, width: f64) -> Document {
let mut d = Document::new(200.0, 160.0);
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 140.0)), Appearance::basic(fill, stroke, width));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
fn cases() -> Vec<(&'static str, GradientKind, GradientGeom)> {
let c = Point::new(100.0, 80.0);
let geom = GradientGeom { start: c, end: c + Vec2::new(50.0, -20.0), aspect: 0.7, focal: None };
vec![
("linear", GradientKind::Linear, GradientGeom { start: Point::new(60.0, 60.0), end: Point::new(150.0, 90.0), aspect: 1.0, focal: None }),
("radial", GradientKind::Radial, geom),
("off-center radial", GradientKind::Radial, GradientGeom { focal: Some(c + Vec2::new(-12.0, 10.0)), ..geom }),
("conic", GradientKind::Conic, geom),
("diamond", GradientKind::Diamond, geom),
]
}
/// Each kind's fill and stroke, repeating and reflecting, exported and drawn by resvg, looks as the
/// canvas paints it, inside the vector and past its ends.
#[test]
fn an_independent_renderer_draws_the_export_as_the_canvas_does() {
for (what, kind, geom) in cases() {
for spread in [GradientSpread::Repeat, GradientSpread::Reflect] {
for stroke in [false, true] {
let d = if stroke { doc(Paint::None, paint(kind, spread, geom), 14.0) } else { doc(paint(kind, spread, geom), Paint::None, 0.0) };
let o = export_full(&d, &ExportOptions::default(), None);
if matches!(kind, GradientKind::Linear | GradientKind::Radial | GradientKind::Diamond) {
let attr = if spread == GradientSpread::Repeat { "spreadMethod=\"repeat\"" } else { "spreadMethod=\"reflect\"" };
assert!(o.svg.contains(attr), "{what} {spread:?}: {}", o.svg);
}
// Hard edges (where Repeat starts the stops again, and between a conic's wedges)
// differ by a pixel's antialiasing at most.
assert_similar(&render_artboard(&d), &resvg_render(&o.svg, 200, 160), 10.0, 0.03);
}
}
}
}
/// One run lies along the vector: a linear gradient's `x2` is a third of the way along it.
#[test]
fn the_written_vector_holds_one_run() {
let geom = GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(120.0, 50.0), aspect: 1.0, focal: None };
let o = export_full(&doc(paint(GradientKind::Linear, GradientSpread::Repeat, geom), Paint::None, 0.0), &ExportOptions::default(), None);
assert!(o.svg.contains("x1=\"30\" y1=\"50\" x2=\"60\" y2=\"50\" spreadMethod=\"repeat\""), "{}", o.svg);
}
/// On type characters, where SVG writes a conic as a linear gradient along its vector, the runs
/// lie along that vector: a third of it each.
#[test]
fn a_conic_on_characters_repeats_along_its_vector() {
use vectorcraft_doc::{CharStyle, TextObject};
let geom = GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(120.0, 50.0), aspect: 1.0, focal: None };
let Paint::Gradient(g) = paint(GradientKind::Conic, GradientSpread::Repeat, geom) else { panic!("a gradient") };
let st = CharStyle { fill: Paint::Gradient(g), size: 48.0, ..CharStyle::default() };
let mut d = Document::new(200.0, 160.0);
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 80.0), "Fan", st))));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let o = export_full(&d, &ExportOptions::default(), None);
assert!(o.svg.contains("x1=\"30\" y1=\"50\" x2=\"60\" y2=\"50\" spreadMethod=\"repeat\""), "{}", o.svg);
}
/// `spreadMethod` reads as the gradient's spread, with its stops and vector as written.
#[test]
fn import_reads_spread_method_as_the_spread() {
for (attr, want) in [("repeat", GradientSpread::Repeat), ("reflect", GradientSpread::Reflect), ("pad", GradientSpread::Pad)] {
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="120"><defs><linearGradient id="g" x1="40" y1="0" x2="70" y2="0" gradientUnits="userSpaceOnUse" spreadMethod="{attr}"><stop offset="0.2" stop-color="#f00"/><stop offset="1" stop-color="#00f"/></linearGradient></defs><rect x="10" y="10" width="180" height="100" fill="url(#g)"/></svg>"##
);
let d = import(&svg).unwrap();
let mut found = None;
d.walk(|n| {
if let (NodeKind::Path { .. }, Paint::Gradient(g)) = (&n.kind, n.appearance.fill_paint()) {
found = Some(g.clone());
}
});
let g = found.unwrap_or_else(|| panic!("{attr}: no gradient"));
assert_eq!((g.gradient.spread, g.gradient.repeat_count(), g.gradient.stops.len()), (want, 1, 2), "{attr}");
let geom = g.geom.unwrap();
assert_eq!((geom.start, geom.end), (Point::new(40.0, 0.0), Point::new(70.0, 0.0)), "{attr}");
}
}
/// Exported and read back, a repeating gradient keeps its spread; its count becomes part of the
/// vector, so it looks the same.
#[test]
fn a_spread_survives_export_and_import() {
let geom = GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(150.0, 90.0), aspect: 1.0, focal: None };
let d = doc(paint(GradientKind::Linear, GradientSpread::Reflect, geom), Paint::None, 0.0);
let o = export_full(&d, &ExportOptions::default(), None);
let back = import(&o.svg).unwrap();
let mut spreads = vec![];
back.walk(|n| {
if let Paint::Gradient(g) = n.appearance.fill_paint() {
spreads.push(g.gradient.spread);
}
});
assert_eq!(spreads, [GradientSpread::Reflect]);
assert_similar(&render_artboard(&d), &render_artboard(&back), 4.0, 0.01);
}