collective

Browse / github.com/storytold/deckcraft / patch

Render: skip shapes far larger than any screen instead of aborting (#1165)

Render: skip shapes far larger than any screen instead of aborting (#1165) Part of #1165 (the shape-geometry path). ## Problem Shape geometry was only checked with `is_finite()` before reaching vello. An ellipse at the EMU size limit, group child scales compounding across levels, or custom-geometry guides can give an outline billions of pixels long; vello's flattener sizes a buffer from that on-screen length and the allocation aborts the process, which `catch_unwind` can't catch. Since every repaint, thumbnail and export renders it, such a deck can't be opened. ## Change - `crates/render/src/geometry.rs`: `drawable(geo, m, w, h)` rejects a shape if any sub-path h

Status
open
Score
0.00
Adopters
0 other operators
Author
Naseq (imported from pull request #1374, open)
Base
ee875abffa1a31cb760c843bb29e79391a90a766
Size
+81 −1 in 3 files
Review
pending
Shared
2026-10-28
Id
0aa9a6237b28a5f55fda4d36

How it went

No other operator has reported using it yet.

Diff

raw · crates/render/src/geometry.rs crates/render/src/renderer.rs crates/render/src/tests.rs

diff --git a/crates/render/src/geometry.rs b/crates/render/src/geometry.rs--- a/crates/render/src/geometry.rs+++ b/crates/render/src/geometry.rs@@ -199,3 +199,21 @@ pub(crate) fn scale_of(m: Affine) -> f64 { pub(crate) fn blur(sigma: f64) -> Filter {     Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.clamp(0.0, 500.0) as f32, edge_mode: EdgeMode::None }) }++/// The widest a shape may reach on screen, in pixels, and still be drawn: ten thousand screens+/// across, far past any real deck, and still cheap for vello to flatten (#1165).+const MAX_DEVICE_EXTENT: f64 = 1e7;++/// Whether a shape's geometry can go to vello. Geometry far beyond any screen (a size at the EMU+/// limit, group scales compounding, custom-geometry guides) makes vello's flattener size a buffer+/// by its on-screen length, and the allocation aborts the process (#1165). Each path is checked+/// for finite points first: a bounding box drops NaN, and a union with the frame would hide it.+/// The extent is the control box, which always holds the curve: the tight bounding box can miss+/// a curve's extremum when finding it overflows.+pub(crate) fn drawable(geo: &Geometry, m: Affine, w: f64, h: f64) -> bool {+    if !geo.paths.iter().all(|p| p.path.is_finite()) {+        return false;+    }+    let dev = m.transform_rect_bbox(geo.outline().control_box().union(Rect::new(0.0, 0.0, w, h)));+    dev.is_finite() && dev.width().max(dev.height()) <= MAX_DEVICE_EXTENT+}diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs--- a/crates/render/src/renderer.rs+++ b/crates/render/src/renderer.rs@@ -1,4 +1,6 @@-use super::geometry::{Caster, blur, border_outside, camera_turn, color, fill_opacity, outside_line, scale_of, shadow_local, shape_geometry};+use super::geometry::{+    Caster, blur, border_outside, camera_turn, color, drawable, fill_opacity, outside_line, scale_of, shadow_local, shape_geometry,+}; use super::opts::{RenderOpts, ShapeState, SlideFields}; use super::raster::Image; use super::slide::{SlidePart, needs_filters, output_size};@@ -542,6 +544,10 @@ impl Renderer {             None => (self.scaled_geometry(s, m, depth, w, h), Vec::new()),         };         let fade = |i: usize| fades.get(i).copied().unwrap_or(deckcraft_geom::morph::Fade { fill: 1.0, stroke: 1.0 });+        if !drawable(&geo, m, w, h) {+            log::warn!("render: shape {} skipped: its outline is not finite or far larger than any screen", s.id.0);+            return;+        }         let (fill, fill_ph) = resolve::fill(rctx, s);         let (line, line_ph) = resolve::line(rctx, s);         let (effects, fx_ph) = resolve::effects(rctx, s);diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs--- a/crates/render/src/tests.rs+++ b/crates/render/src/tests.rs@@ -2951,3 +2951,59 @@ fn pie_data_labels_take_the_chart_text_colour_when_it_has_one() {     assert!(count(&coloured, &red) > 30, "the labels take the chart's red: {} red pixels", count(&coloured, &red));     assert!(count(&plain, &white) > count(&coloured, &white), "the labels were white before"); }++/// #1165: an ellipse sized at the EMU limit, crossing the slide, made vello's flattener allocate a+/// buffer from its on-screen length and abort. It is skipped; the rest of the slide still draws.+#[test]+fn a_shape_far_larger_than_any_screen_is_skipped_not_flattened() {+    let huge = Shape {+        id: ShapeId(501),+        geom: Geom::preset("ellipse"),+        xfrm: Some(Xfrm::new(-5e17, -5e17, 1e18, 1e18)),+        style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent2)),+        ..Default::default()+    };+    let normal = Shape {+        id: ShapeId(502),+        xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),+        style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)),+        ..Default::default()+    };+    let p = deck_with(vec![huge, normal]);+    let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });+    let accent = p.masters[0].theme.colors.get(deckcraft_color::SchemeSlot::Accent1);+    let px = img.pixel(200, 150);+    assert!((px[0] as i32 - accent.r as i32).abs() <= 2, "{px:?} vs {accent:?}");+    assert_eq!(img.pixel(50, 50), [255, 255, 255, 255], "the huge ellipse is not drawn");+}++/// #1165: a path with a NaN point is not drawn, even when the shape's frame is finite and small+/// (a bounding box, or a union with the frame, would hide the NaN).+#[test]+fn a_shape_whose_outline_is_not_finite_is_not_drawable() {+    let geo = |pts: &[(f64, f64)]| {+        let mut path = kurbo::BezPath::new();+        path.move_to(pts[0]);+        for p in &pts[1..] {+            path.line_to(*p);+        }+        deckcraft_geom::preset::Geometry {+            paths: vec![deckcraft_geom::preset::SubPath { path, fill: deckcraft_geom::preset::FillMode::Norm, stroke: true, even_odd: false }],+            text_rect: kurbo::Rect::ZERO,+            handles: vec![],+            sites: vec![],+        }+    };+    let m = kurbo::Affine::IDENTITY;+    assert!(crate::geometry::drawable(&geo(&[(0.0, 0.0), (100.0, 50.0)]), m, 100.0, 50.0));+    assert!(!crate::geometry::drawable(&geo(&[(0.0, 0.0), (f64::NAN, 50.0)]), m, 100.0, 50.0));+    assert!(!crate::geometry::drawable(&geo(&[(0.0, 0.0), (f64::INFINITY, 50.0)]), m, 100.0, 50.0));+    assert!(!crate::geometry::drawable(&geo(&[(0.0, 0.0), (1e18, 50.0)]), m, 100.0, 50.0));+    // A quadratic whose extremum overflows kurbo's bounding box: its control point still counts.+    let mut path = kurbo::BezPath::new();+    path.move_to((0.0, 0.0));+    path.quad_to((1e308, 0.0), (0.0, 0.0));+    let mut quad = geo(&[(0.0, 0.0), (1.0, 1.0)]);+    quad.paths[0].path = path;+    assert!(!crate::geometry::drawable(&quad, m, 100.0, 50.0));+}