Render: clamp the blur size before the filter planner squares it (#2640)

* Render: a blur size is clamped to 1,000,000 pt before the filter planner squares it

* address review: bound effect extents and sigma before spread, padding and reach arithmetic
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-12 00:33:50 +00:00
1 parent 4bdc036769
commit 27d8eabb10
1 file changed
+50 -17
+50 -17
View File
@@ -29,6 +29,7 @@ use designcraft_compose::ShownOn;
/// Shadow offset (spread units) for an InDesign angle (degrees, light direction) and distance.
pub(crate) fn offset(angle: f64, distance: f64) -> Vec2 {
let a = angle.to_radians();
let distance = bound(distance);
Vec2::new(-a.cos() * distance, a.sin() * distance)
}
@@ -37,33 +38,43 @@ pub(crate) fn outset(it: &Item) -> f64 {
let e = &it.effects;
let mut o: f64 = 0.0;
if e.drop_shadow.on {
o = o.max(e.drop_shadow.distance.abs() + e.drop_shadow.size.abs() * 1.6 + 1.0);
o = o.max(bound(e.drop_shadow.distance).abs() + bound(e.drop_shadow.size).abs() * 1.6 + 1.0);
}
if e.outer_glow.on {
o = o.max(e.outer_glow.size.abs() * 1.6 + 1.0);
o = o.max(bound(e.outer_glow.size).abs() * 1.6 + 1.0);
}
o
}
/// (blur σ, growth) in points for an effect `size` with `pct` percent hardened.
fn split(size: f64, pct: f64) -> (f64, f64) {
let size = size.max(0.0);
let size = bound(size).max(0.0);
let p = (pct / 100.0).clamp(0.0, 1.0);
(size * (1.0 - p) / 2.0, size * p)
}
/// Largest blur standard deviation handed to the filter planner, in points: far wider than any
/// page, so a larger one looks the same. Its planner squares the value in f32, so an unbounded
/// finite size would overflow to infinity there and never finish.
const MAX_SIGMA: f64 = 1.0e6;
/// Largest effect extent (size, distance, feather width) used for geometry, in points: twice the
/// largest blur, so a size of any finite value, or infinity, cannot overflow later arithmetic
/// (`sigma * 3.0`, padding, reach). NaN is 0.
fn bound(v: f64) -> f64 {
if v.is_nan() { 0.0 } else { v.clamp(-2.0 * MAX_SIGMA, 2.0 * MAX_SIGMA) }
}
fn std_dev(sigma: f64) -> f32 {
if sigma.is_nan() { 0.0 } else { sigma.clamp(0.0, MAX_SIGMA) as f32 }
}
fn blur(sigma: f64) -> Filter {
Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.max(0.0) as f32, edge_mode: EdgeMode::None })
Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: std_dev(sigma), edge_mode: EdgeMode::None })
}
fn shadow(sigma: f64, color: peniko::Color) -> Filter {
Filter::from_primitive(FilterPrimitive::DropShadowOnly {
dx: 0.0,
dy: 0.0,
std_deviation: sigma.max(0.0) as f32,
color,
edge_mode: EdgeMode::None,
})
Filter::from_primitive(FilterPrimitive::DropShadowOnly { dx: 0.0, dy: 0.0, std_deviation: std_dev(sigma), color, edge_mode: EdgeMode::None })
}
/// Keep what was drawn in the current layer as much as `it`'s gradient feather says. Cover the
@@ -150,7 +161,7 @@ impl Renderer {
// fade); Sharp and Rounded corners follow the inset shape more tightly than Diffused.
if e.feather > 0.0 && it.path.is_closed() {
let fo = &e.feather_options;
let w = e.feather * (1.0 - fo.choke.clamp(0.0, 100.0) / 100.0).max(0.01);
let w = bound(e.feather) * (1.0 - fo.choke.clamp(0.0, 100.0) / 100.0).max(0.01);
let (sigma, join) = match fo.corners {
designcraft_doc::FeatherCorners::Diffused => (w / 4.0, kurbo::Join::Round),
designcraft_doc::FeatherCorners::Rounded => (w / 3.0, kurbo::Join::Round),
@@ -266,7 +277,7 @@ impl Renderer {
c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestOut)), None, None, None);
c.set_transform(fr.view * xf);
c.set_paint(peniko::Color::BLACK);
c.set_stroke(kurbo::Stroke::new((g.size - choke).max(0.5) * 2.0).with_join(kurbo::Join::Round));
c.set_stroke(kurbo::Stroke::new((bound(g.size) - choke).max(0.5) * 2.0).with_join(kurbo::Join::Round));
c.stroke_path(bp);
c.pop_layer();
c.pop_layer();
@@ -281,7 +292,7 @@ impl Renderer {
{
let st = &e.satin;
let off = offset(st.angle, st.distance / 2.0);
let sigma = st.size.max(0.0) / 2.0;
let sigma = bound(st.size).max(0.0) / 2.0;
let paint = color_of(&c, st.opacity.clamp(0.0, 1.0));
let invert = st.invert;
self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, (bp, xf), |_, c, fr| {
@@ -309,8 +320,8 @@ impl Renderer {
// Bevel and Emboss (inner bevel): highlight from the lit side, shadow from the other.
if e.bevel.on {
let b = &e.bevel;
let off = offset(doc.light_angle(b.angle, b.global_light), b.size * (b.depth / 100.0).clamp(0.01, 10.0) * 0.5);
let sigma = b.size.max(0.0) / 2.0;
let off = offset(doc.light_angle(b.angle, b.global_light), bound(b.size) * (b.depth / 100.0).clamp(0.01, 10.0) * 0.5);
let sigma = bound(b.size).max(0.0) / 2.0;
if let Some(c) = doc.resolve_color(&b.highlight, 1.0) {
// The inverse shape moved away from the light lights the edges facing it.
self.inner_edge(ctx, f, reach, bp, xf, off, sigma, 0.0, color_of(&c, b.highlight_opacity.clamp(0.0, 1.0)), Mix::Screen, painted);
@@ -340,6 +351,8 @@ impl Renderer {
mix: Mix,
painted: Option<(&Item, ShownOn)>,
) {
let sigma = bound(sigma).max(0.0);
let choke = bound(choke);
if let Some((it, page)) = painted {
self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |me, c, fr| {
let pad = sigma * 4.0 + choke + off.hypot() + 4.0 * fr.px;
@@ -425,8 +438,9 @@ impl Renderer {
clip: Option<(&BezPath, Affine)>,
draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame),
) {
let sigma = bound(sigma).max(0.0);
// 3σ of the Gaussian in pixels, plus a pixel of antialiasing.
let spread = sigma.max(0.0) * 3.0 / f.px + 2.0;
let spread = sigma * 3.0 / f.px + 2.0;
if !f.mt && spread <= MAX_BLUR_SPREAD {
push_clip(ctx, f, clip);
draw(self, ctx, f);
@@ -498,6 +512,25 @@ fn offscreen(reach: Rect, spread: f64, width: u16, height: u16) -> Option<Offscr
#[cfg(test)]
mod tests {
#[test]
fn a_huge_blur_size_reaches_the_planner_finite_and_squarable() {
use super::{MAX_SIGMA, bound, std_dev};
for v in [f64::MAX, f64::INFINITY, f64::NEG_INFINITY, f64::NAN] {
let b = bound(v);
assert!(b.is_finite() && (b * 3.0 + 4.0 * b).is_finite(), "{v}");
}
assert_eq!(bound(7.5), 7.5);
assert_eq!(super::split(f64::MAX, 50.0), (MAX_SIGMA / 2.0 * 2.0 * 0.5, 2.0 * MAX_SIGMA * 0.5));
for s in [1.0e300, f64::MAX, f64::INFINITY] {
let d = std_dev(s);
assert_eq!(f64::from(d), MAX_SIGMA);
assert!((d * d).is_finite());
}
assert_eq!(std_dev(-3.0), 0.0);
assert_eq!(std_dev(f64::NAN), 0.0);
assert_eq!(std_dev(12.5), 12.5);
}
use super::*;
#[test]