mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 13:36:53 +00:00
Gradients: Expand reports a capped gradient as unavailable
- object.expand.info reports no gradient or fill to expand for a gradient that repeats in more runs across its box than are written, matching object.expand, which leaves it a gradient. For a path that a painting live effect draws, it measures each piece the effect returns. - CSS writes a repeating gradient's stop positions, and a repeating radial gradient's radius, with enough decimals that their rounding adds up to at most a hundredth of a period across the box (at least four for positions and three for the radius). - docs/mcp.md says that sharp steps and swatch-linked stops are the last to be left out rather than always kept, and that the CSS conic limit counts stops before midpoints and Perceptual curves add more.
This commit is contained in:
@@ -194,10 +194,12 @@ impl Expandable {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| r.style.has_stroke()),
|
||||
_ => shape && !c.appearance.stroke_paint().is_none(),
|
||||
};
|
||||
e.gradient |= match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
|
||||
_ => shape && c.appearance.items.iter().any(|i| gradient_fill(i).is_some()),
|
||||
};
|
||||
// Once one is found, the next path is not measured.
|
||||
e.gradient = e.gradient
|
||||
|| match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
|
||||
_ => shape && gradient_expands(d, c),
|
||||
};
|
||||
e.fill |= e.gradient;
|
||||
e.fill |= match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some())),
|
||||
@@ -222,6 +224,43 @@ fn gradient_fill(i: &AppearanceItem) -> Option<(&FillLayer, &GradientPaint)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether path `l` has a gradient fill that Fill expands ([`expand_fills`]): one that repeats or
|
||||
/// reflects stays a gradient when its box needs more runs than are written ([`padded_on`]). For a
|
||||
/// path that live-effect plug-ins paint, whether a piece of the art they paint has one
|
||||
/// ([`expand_pieces`]).
|
||||
fn gradient_expands(d: &Document, l: &Node) -> bool {
|
||||
if !l.appearance.items.iter().any(|i| gradient_fill(i).is_some()) {
|
||||
return false;
|
||||
}
|
||||
if !vectorcraft_render::effects::has_painted_effects(l) {
|
||||
return piece_gradient_expands(d, l);
|
||||
}
|
||||
let Some(art) = vectorcraft_render::effects::reshape(Some(d), l, None) else { return false };
|
||||
let mut any = false;
|
||||
art.walk(&mut |c| {
|
||||
any = any
|
||||
|| (matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
|
||||
&& !c.is_ruler_guide()
|
||||
&& c.appearance.items.iter().any(|i| gradient_fill(i).is_some())
|
||||
&& piece_gradient_expands(d, c));
|
||||
});
|
||||
any
|
||||
}
|
||||
|
||||
/// [`gradient_expands`] for a path that no live-effect plug-in paints ([`expand_piece_fills`]).
|
||||
fn piece_gradient_expands(d: &Document, l: &Node) -> bool {
|
||||
if !l.appearance.items.iter().filter_map(gradient_fill).any(|(_, g)| g.gradient.is_spread()) {
|
||||
return l.appearance.items.iter().any(|i| gradient_fill(i).is_some());
|
||||
}
|
||||
let reshaped = reshaped(d, l);
|
||||
let l = reshaped.as_ref().unwrap_or(l);
|
||||
let Some((path, _)) = node_path(l) else { return false };
|
||||
let Some(bounds) = path.bounds() else { return false };
|
||||
l.appearance.items.iter().filter(|i| gradient_fill(i).is_some()).any(|item| {
|
||||
fill_shape(d, l, item, &path, bounds).is_some_and(|(_, _, b, moved)| gradient_fill(&moved).is_some_and(|(_, g)| padded_on(g, b).is_some()))
|
||||
})
|
||||
}
|
||||
|
||||
/// The definition and placement of a visible pattern fill whose pattern `d` defines.
|
||||
fn pattern_fill<'a, 'd>(d: &'d Document, i: &'a AppearanceItem) -> Option<(&'a FillLayer, &'d PatternDef, Affine)> {
|
||||
match i {
|
||||
|
||||
@@ -510,14 +510,30 @@ fn a_spread_gradient_expands_into_whole_bands_for_each_run() {
|
||||
|
||||
/// A gradient that repeats across a box in more runs than are written (700 runs of 0.2 pt) is not
|
||||
/// expanded into its first runs and a flat last color: the fill stays a gradient, and the rest of
|
||||
/// the object (its stroke) still expands.
|
||||
/// the object (its stroke) still expands. `object.expand.info` reports no gradient or fill to
|
||||
/// expand, and Expand with only Fill on reports nothing to expand; the same gradient in 70 runs
|
||||
/// (10 repeats) reports both.
|
||||
#[test]
|
||||
fn a_spread_gradient_with_more_runs_than_written_stays_a_gradient() {
|
||||
let gradient = |repeats: u32| {
|
||||
json!({"kind": "linear", "spread": "repeat", "repeats": repeats, "start": [30, 100], "end": [50, 100],
|
||||
"stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#004080"}]})
|
||||
};
|
||||
for (repeats, expands) in [(10, true), (100, false)] {
|
||||
let mut s = session();
|
||||
gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gradient(repeats), true);
|
||||
let info = s.execute("object.expand.info", &json!({})).unwrap();
|
||||
assert_eq!(
|
||||
(info["gradient"].clone(), info["fill"].clone(), info["stroke"].clone()),
|
||||
(json!(expands), json!(expands), json!(true)),
|
||||
"{repeats}"
|
||||
);
|
||||
let only_fill = s.execute("object.expand", &json!({"object": false, "stroke": false}));
|
||||
assert_eq!(only_fill.is_ok(), expands, "{repeats}: {only_fill:?}");
|
||||
}
|
||||
for to in [json!({"steps": 240}), json!({"gradient": "mesh"})] {
|
||||
let mut s = session();
|
||||
let gr = json!({"kind": "linear", "spread": "repeat", "repeats": 100, "start": [30, 100], "end": [50, 100],
|
||||
"stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 1, "color": "#004080"}]});
|
||||
gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gr, true);
|
||||
gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gradient(100), true);
|
||||
let before = render(&s);
|
||||
run(&mut s, "object.expand", to.clone());
|
||||
let after = render(&s);
|
||||
|
||||
@@ -668,6 +668,27 @@ fn expanding_a_gradient_fill_keeps_each_piece_s_own_paint() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `object.expand.info` measures each piece a painted effect returns: a piece whose repeating
|
||||
/// gradient needs more runs across it than are written (1,000) reports no gradient to expand, as
|
||||
/// Expand leaves it a gradient, and the same gradient in 10 runs reports one.
|
||||
#[test]
|
||||
fn expand_info_measures_the_gradient_on_each_painted_piece() {
|
||||
for (end, expands) in [(120.0, true), (100.2, false)] {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 10.0, "#ff0000");
|
||||
let gradient = json!({"kind": "linear", "spread": "repeat", "repeats": 10, "start": [100, 50], "end": [end, 50]});
|
||||
run(&mut s, "paint.setFill", json!({"ids": [id.0], "gradient": gradient}));
|
||||
let out = json!({"objects": [{"path": square_at(100.0)}]}).to_string();
|
||||
install_wat(&mut s, &tpl::returning(&painting("test.engine.expandruns", true), &out));
|
||||
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.expandruns"}));
|
||||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||||
let info = s.execute("object.expand.info", &json!({})).unwrap();
|
||||
assert_eq!((info["gradient"].clone(), info["fill"].clone()), (json!(expands), json!(expands)), "{end}");
|
||||
let only_fill = s.execute("object.expand", &json!({"object": false, "fill": true, "stroke": false}));
|
||||
assert_eq!(only_fill.is_ok(), expands, "{end}: {only_fill:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Object › Expand of a pattern fill works piece by piece: a piece that keeps the pattern has its
|
||||
/// tiles expanded, and a piece with a paint of its own keeps that paint.
|
||||
#[test]
|
||||
|
||||
+53
-10
@@ -206,6 +206,14 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props {
|
||||
p
|
||||
}
|
||||
|
||||
/// The decimals, at least `least` and at most 12, for numbers that place a period of length
|
||||
/// `period` repeated out to `reach` (in the same units): each period then rounds by at most
|
||||
/// 10^-decimals, and over the `reach / period` periods that adds up to at most a hundredth of one.
|
||||
fn period_decimals(period: f64, reach: f64, least: u8) -> u8 {
|
||||
let d = (100.0 * reach / (period * period)).log10().ceil();
|
||||
if d.is_nan() || d == f64::INFINITY { 12 } else { d.clamp(f64::from(least), 12.0) as u8 }
|
||||
}
|
||||
|
||||
/// A colour at `opacity`: `#rrggbb` when opaque, else `rgba()`.
|
||||
fn color_css(c: &Color, opacity: f32) -> String {
|
||||
if opacity >= 1.0 {
|
||||
@@ -247,13 +255,22 @@ impl CssUnits {
|
||||
|
||||
/// `points` in these units (`0` without a unit).
|
||||
fn len(self, points: f64) -> String {
|
||||
let per_point = match self {
|
||||
self.len_to(points, 3)
|
||||
}
|
||||
|
||||
/// One point in these units.
|
||||
fn per_point(self) -> f64 {
|
||||
match self {
|
||||
CssUnits::Px | CssUnits::Pt => 1.0,
|
||||
CssUnits::Mm => 25.4 / 72.0,
|
||||
CssUnits::Cm => 2.54 / 72.0,
|
||||
CssUnits::In => 1.0 / 72.0,
|
||||
};
|
||||
match fmt_num(points * per_point, 3) {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::len`] with `decimals` decimals.
|
||||
fn len_to(self, points: f64, decimals: u8) -> String {
|
||||
match fmt_num(points * self.per_point(), decimals) {
|
||||
zero if zero == "0" => zero,
|
||||
v => format!("{v}{}", self.name()),
|
||||
}
|
||||
@@ -583,9 +600,18 @@ impl Rules<'_> {
|
||||
_ => (unrolled, geom, ""),
|
||||
};
|
||||
// A repeating gradient's period is the span from its first stop to its last, repeated
|
||||
// across the box: its positions get four decimals, so the period stays in place there.
|
||||
let decimals = if repeating.is_empty() { 2 } else { 4 };
|
||||
let stops = |pos: &dyn Fn(f32) -> f64| {
|
||||
// across the box (from 0% to `reach`%): its positions get at least four decimals, and more
|
||||
// when the box holds so many periods that their rounding would add up to more than a
|
||||
// hundredth of one ([`period_decimals`]).
|
||||
let span = gradient.expanded().next().zip(gradient.expanded().last()).map(|(a, z)| (a.0, z.0));
|
||||
let decimals = |pos: &dyn Fn(f32) -> f64, reach: f64| match span {
|
||||
Some((a, z)) if !repeating.is_empty() => {
|
||||
let (p0, p1) = (pos(a) * 100.0, pos(z) * 100.0);
|
||||
period_decimals((p1 - p0).abs(), p0.abs().max((reach - p0).abs()), 4)
|
||||
}
|
||||
_ => 2,
|
||||
};
|
||||
let stops = |pos: &dyn Fn(f32) -> f64, decimals: u8| {
|
||||
let stops: Vec<String> =
|
||||
gradient.expanded().map(|(t, c, o, _)| format!("{} {}%", color_css(&c, o * opacity), fmt_num(pos(t) * 100.0, decimals))).collect();
|
||||
stops.join(", ")
|
||||
@@ -604,7 +630,7 @@ impl Rules<'_> {
|
||||
let line = (b.width() * dir.x).abs() + (b.height() * dir.y).abs();
|
||||
let from = (geom.start - b.center()).dot(dir);
|
||||
let pos = |t: f32| if line > 1e-9 { (from + t as f64 * length) / line + 0.5 } else { t as f64 };
|
||||
Some(format!("{repeating}linear-gradient({}deg, {})", fmt_num(angle, 2), stops(&pos)))
|
||||
Some(format!("{repeating}linear-gradient({}deg, {})", fmt_num(angle, 2), stops(&pos, decimals(&pos, 100.0))))
|
||||
}
|
||||
// CSS has no diamond gradient: the ellipse through the square's corners stands in.
|
||||
GradientKind::Radial | GradientKind::Diamond => {
|
||||
@@ -614,13 +640,30 @@ impl Rules<'_> {
|
||||
} else if geom.focal.is_some() {
|
||||
self.warn("radial gradients start at their centre (an off-centre focal point is left out)");
|
||||
}
|
||||
// The positions are a share of the radius, and the box reaches out to its farthest
|
||||
// corner. A repeating gradient's period scales with its radius, so the radius gets
|
||||
// the decimals that keep the periods in place out to that corner too.
|
||||
let units = self.opts.units;
|
||||
let small = r * geom.aspect.abs().min(1.0);
|
||||
let far = [(b.x0, b.y0), (b.x1, b.y0), (b.x0, b.y1), (b.x1, b.y1)]
|
||||
.into_iter()
|
||||
.map(|(x, y)| (Point::new(x, y) - geom.start).hypot())
|
||||
.fold(0.0, f64::max);
|
||||
let reach = if small > 1e-12 { 100.0 * far / small } else { 0.0 };
|
||||
let pos = |t: f32| t as f64;
|
||||
let len = match span {
|
||||
Some((a, z)) if !repeating.is_empty() => {
|
||||
period_decimals(small * f64::from(z - a).abs() * units.per_point(), far * units.per_point(), 3)
|
||||
}
|
||||
_ => 3,
|
||||
};
|
||||
let shape = if (geom.aspect - 1.0).abs() < 1e-9 {
|
||||
format!("circle {}", self.len(r))
|
||||
format!("circle {}", units.len_to(r, len))
|
||||
} else {
|
||||
format!("ellipse {} {}", self.len(r), self.len(r * geom.aspect))
|
||||
format!("ellipse {} {}", units.len_to(r, len), units.len_to(r * geom.aspect, len))
|
||||
};
|
||||
let at = format!("at {} {}", self.len(geom.start.x - b.x0), self.len(geom.start.y - b.y0));
|
||||
Some(format!("{repeating}radial-gradient({shape} {at}, {})", stops(&|t| t as f64)))
|
||||
Some(format!("{repeating}radial-gradient({shape} {at}, {})", stops(&pos, decimals(&pos, reach))))
|
||||
}
|
||||
GradientKind::Conic => {
|
||||
// CSS sweeps clockwise from the `from` angle, 0deg straight up
|
||||
|
||||
@@ -366,6 +366,21 @@ fn spread_gradients_give_repeating_css_gradients() {
|
||||
css(GradientKind::Linear, GradientSpread::Repeat, short).as_deref(),
|
||||
Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 2.5863%)")
|
||||
);
|
||||
// About 1,000 periods across the box: six decimals, so their rounding adds up to at most a
|
||||
// hundredth of a period at the far side (with four, `0.1%` would be almost half a period out
|
||||
// there).
|
||||
let tiny = GradientGeom { end: Point::new(0.400196, 30.0), ..across };
|
||||
assert_eq!(
|
||||
css(GradientKind::Linear, GradientSpread::Repeat, tiny).as_deref(),
|
||||
Some("repeating-linear-gradient(90deg, #ffffff 0%, #000000 0.100049%)")
|
||||
);
|
||||
// A radial period of 0.03085 px out to a corner 67 px away: its radius keeps the decimals that
|
||||
// hold the periods in place there.
|
||||
let dot = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(40.1234, 30.0), aspect: 1.0, focal: None };
|
||||
assert_eq!(
|
||||
css(GradientKind::Radial, GradientSpread::Repeat, dot).as_deref(),
|
||||
Some("repeating-radial-gradient(circle 0.03085px at 40px 30px, #ffffff 0%, #000000 100%)")
|
||||
);
|
||||
let ring = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(80.0, 30.0), aspect: 1.0, focal: None };
|
||||
assert_eq!(
|
||||
css(GradientKind::Radial, GradientSpread::Reflect, ring).as_deref(),
|
||||
|
||||
+5
-2
@@ -1205,8 +1205,11 @@ legacy `.ai`), and for a radial or diamond gradient no farther from its center t
|
||||
times the smallest gap between its stops (measured in runs) when that is less. Past those it pads, with a warning.
|
||||
The `.ai` editing data has no spread either: it holds the stops repeated across the shape, at most 256 of them, with
|
||||
a warning. When the runs need more stops than these limits allow, every run is still written, without the stops that
|
||||
change its colors least (its ends, sharp steps and swatch-linked stops stay); the same applies to a gradient along
|
||||
or across a stroke, and to CSS's conic gradient, past 16,384 stops, and to Outline Stroke's mesh past 1,024. PDF
|
||||
change its colors least. Each run keeps its ends; sharp steps and swatch-linked stops are the last to be left out,
|
||||
but a run with more of them than its share of the limit loses some. The same applies to a gradient along or across a
|
||||
stroke, and to CSS's conic gradient, past 16,384 stops, and to Outline Stroke's mesh past 1,024. For CSS the 16,384
|
||||
counts the gradient's stops before they are written: CSS then adds a stop for each midpoint, and for a Perceptual
|
||||
gradient stops along each span's curve, so the written gradient can hold more. PDF
|
||||
import finds a sharp step between two colors in an axial or radial shading by halving the spans between its samples,
|
||||
so the step reads back where it is in the file (to 1/64 × 2^-14 of the axis) instead of blurred across the samples
|
||||
around it. A shading takes at most 16,384 samples, so the steps past the point where that budget runs out stay
|
||||
|
||||
Reference in New Issue
Block a user