diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index bcf883b2f3..3eab236e36 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -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 { diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 65d638e641..06c5edcac5 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -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); diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 8d1570e996..77c8bf56a2 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -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] diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index da2049fe8d..f7e067aa30 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -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 = 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 diff --git a/crates/svg/tests/it/css_rules.rs b/crates/svg/tests/it/css_rules.rs index 8abd6eb48b..8bf50d4add 100644 --- a/crates/svg/tests/it/css_rules.rs +++ b/crates/svg/tests/it/css_rules.rs @@ -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(), diff --git a/docs/mcp.md b/docs/mcp.md index f9df09112d..a3910bec2f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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