Skip to main content

deckcraft_render/
extent.rs

1//! The pasteboard: what the editor draws around the slide.
2//!
3//! PowerPoint's Normal view draws shapes, pictures and text that sit partly or wholly off the slide
4//! on the area around it, at full strength and with their effects (master and layout graphics
5//! too), so they can be seen and grabbed. Slide shows, exports and thumbnails clip to the slide.
6//!
7//! The editor renders only what it needs: the slide, grown to take in the drawing that spills off
8//! it and is in view ([`pasteboard_region`]). A slide with nothing off it costs what it always did.
9
10use deckcraft_geom::Xfrm;
11use deckcraft_model::resolve::{self, Ctx};
12use deckcraft_model::style::{Effects, Line};
13use deckcraft_model::{Presentation, Shape, ShapeKind};
14use kurbo::Rect;
15
16use crate::MAX_SIDE;
17
18/// Largest distance (points) a shape's outline or effects are taken to reach beyond its box:
19/// hostile files can't blow the pasteboard up.
20const MAX_SPILL: f64 = 2000.0;
21
22/// The visible area is rounded out to tiles of this many pixels, so scrolling over a pasteboard
23/// that is wider than the window re-renders only when a new tile comes into view.
24pub const TILE: i64 = 256;
25
26/// How far (points) a shape's outline and effects reach beyond its box: half the outline and its
27/// arrowheads, the outer shadow (offset, blur and scale), the glow and the reflection.
28pub(crate) fn spill(line: Option<&Line>, effects: Option<&Effects>, w: f64, h: f64) -> f64 {
29    let fin = |v: f64| if v.is_finite() { v.abs() } else { 0.0 };
30    let mut s: f64 = 0.0;
31    if let Some(l) = line
32        && l.fill.as_ref().is_some_and(|f| !f.is_none())
33    {
34        // Arrowheads reach up to a few line widths past the end point.
35        s = s.max(fin(l.width.unwrap_or(0.75)) * 3.0 + 1.0);
36    }
37    if let Some(e) = effects {
38        let big = fin(w).max(fin(h));
39        if let Some(sh) = &e.outer_shadow {
40            let grow = (fin(sh.sx).max(fin(sh.sy)) - 1.0).max(0.0) * big;
41            s = s.max(fin(sh.dist) + fin(sh.blur) + grow);
42        }
43        if let Some(g) = &e.glow {
44            s = s.max(fin(g.radius));
45        }
46        if let Some(r) = &e.reflection {
47            s = s.max(fin(h) + fin(r.dist));
48        }
49    }
50    s.min(MAX_SPILL)
51}
52
53/// Slide-space box a top-level shape draws into, its spill included.
54fn shape_box(rctx: &Ctx, s: &Shape) -> Option<Rect> {
55    if s.hidden {
56        return None;
57    }
58    let x: Xfrm = resolve::xfrm(rctx, s);
59    if !x.is_finite() {
60        return None;
61    }
62    let b = match &s.kind {
63        ShapeKind::Ink { strokes } => {
64            let mut pts = strokes
65                .iter()
66                .flat_map(|k| k.points.iter())
67                .filter(|(px, py, _)| px.is_finite() && py.is_finite());
68            let (px, py, _) = pts.next()?;
69            let r = pts.fold(Rect::new(*px, *py, *px, *py), |r, (px, py, _)| {
70                r.union_pt(kurbo::Point::new(*px, *py))
71            });
72            let w = strokes
73                .iter()
74                .map(|k| k.width)
75                .filter(|w| w.is_finite())
76                .fold(0.0_f64, f64::max)
77                .min(MAX_SPILL);
78            return Some(r.inflate(w, w));
79        }
80        _ => x.bounds(),
81    };
82    let (line, _) = resolve::line(rctx, s);
83    let (fx, _) = resolve::effects(rctx, s);
84    let d = spill(Some(&line), fx.as_ref(), x.w, x.h);
85    Some(b.inflate(d, d))
86}
87
88fn union_boxes<'a>(rctx: &Ctx, shapes: impl IntoIterator<Item = &'a Shape>, into: Rect) -> Rect {
89    shapes
90        .into_iter()
91        .filter_map(|s| shape_box(rctx, s))
92        .filter(|r| r.is_finite())
93        .fold(into, |a, r| a.union(r))
94}
95
96fn slide_rect(pres: &Presentation) -> Rect {
97    Rect::new(0.0, 0.0, pres.slide_size.width, pres.slide_size.height)
98}
99
100/// What slide `index` draws, in slide space: at least the slide, grown by every shape (and master
101/// and layout graphic) that reaches off it. Estimated from the shapes' boxes, outlines and effects;
102/// text spilling out of its box is found while rendering ([`crate::render_slide_drawn`]).
103pub fn slide_extent(pres: &Presentation, index: usize) -> Rect {
104    let page = slide_rect(pres);
105    let Some(slide) = pres.slides.get(index) else {
106        return page;
107    };
108    let Some(rctx) = Ctx::for_slide(pres, slide) else {
109        return page;
110    };
111    let mut r = union_boxes(&rctx, &slide.shapes, page);
112    let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
113    if show_master {
114        let mctx = Ctx::for_master(pres, rctx.master);
115        r = union_boxes(
116            &mctx,
117            rctx.master.shapes.iter().filter(|s| s.ph.is_none()),
118            r,
119        );
120    }
121    if show_layout && let Some(l) = rctx.layout {
122        let lctx = Ctx::for_layout(pres, rctx.master, l);
123        r = union_boxes(&lctx, l.shapes.iter().filter(|s| s.ph.is_none()), r);
124    }
125    r
126}
127
128/// What a layout (or the master, `layout: None`) draws in Slide Master view, in slide space.
129pub fn layout_extent(pres: &Presentation, master: usize, layout: Option<usize>) -> Rect {
130    let page = slide_rect(pres);
131    let Some(m) = pres.masters.get(master) else {
132        return page;
133    };
134    match layout.and_then(|i| m.layouts.get(i)) {
135        Some(l) => {
136            let mut r = union_boxes(&Ctx::for_layout(pres, m, l), &l.shapes, page);
137            if l.show_master_shapes {
138                r = union_boxes(
139                    &Ctx::for_master(pres, m),
140                    m.shapes.iter().filter(|s| s.ph.is_none()),
141                    r,
142                );
143            }
144            r
145        }
146        None => union_boxes(&Ctx::for_master(pres, m), &m.shapes, page),
147    }
148}
149
150/// A rectangle of pixels on the editor canvas, relative to the slide's top-left corner.
151#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
152pub struct Region {
153    pub x: i32,
154    pub y: i32,
155    pub w: u32,
156    pub h: u32,
157}
158
159impl Region {
160    /// The slide itself, `w` × `h` pixels.
161    pub fn slide(w: u32, h: u32) -> Self {
162        Region {
163            x: 0,
164            y: 0,
165            w: w.max(1),
166            h: h.max(1),
167        }
168    }
169
170    fn edges(&self) -> (i64, i64, i64, i64) {
171        let (x, y) = (self.x as i64, self.y as i64);
172        (x, y, x + self.w as i64, y + self.h as i64)
173    }
174
175    fn from_edges(x0: i64, y0: i64, x1: i64, y1: i64) -> Self {
176        let clamp = |v: i64| v.clamp(i32::MIN as i64, i32::MAX as i64) as i32;
177        let side = |a: i64, b: i64| (b - a).clamp(1, MAX_SIDE as i64) as u32;
178        Region {
179            x: clamp(x0),
180            y: clamp(y0),
181            w: side(x0, x1),
182            h: side(y0, y1),
183        }
184    }
185
186    /// Does this region cover all of `other`?
187    pub fn contains(&self, other: &Region) -> bool {
188        let (a, b) = (self.edges(), other.edges());
189        a.0 <= b.0 && a.1 <= b.1 && a.2 >= b.2 && a.3 >= b.3
190    }
191
192    /// Is the region more than the slide (some of the pasteboard is drawn)?
193    pub fn beyond(&self, slide: (u32, u32)) -> bool {
194        *self != Region::slide(slide.0, slide.1)
195    }
196
197    /// Render options' `offset` that puts the slide's top-left corner where it falls in the region.
198    pub fn offset(&self) -> (f64, f64) {
199        (-(self.x as f64), -(self.y as f64))
200    }
201
202    /// The region's size, for render options' `size`.
203    pub fn size(&self) -> (u32, u32) {
204        (self.w, self.h)
205    }
206}
207
208/// `r` (slide space) in pixels at `scale` pixels per point, rounded outwards; `None` when not finite.
209fn to_px(r: Rect, scale: f64) -> Option<(i64, i64, i64, i64)> {
210    let lim = i32::MAX as f64 / 2.0;
211    let px = |v: f64| {
212        if v.is_finite() {
213            Some(v.clamp(-lim, lim))
214        } else {
215            None
216        }
217    };
218    Some((
219        px((r.x0 * scale).floor())? as i64,
220        px((r.y0 * scale).floor())? as i64,
221        px((r.x1 * scale).ceil())? as i64,
222        px((r.y1 * scale).ceil())? as i64,
223    ))
224}
225
226/// The pixels the editor renders: the slide (`slide_px`, its size in pixels at `scale` pixels per
227/// point) plus the part of `extent` (slide space, from [`slide_extent`] or what a render drew) that
228/// falls within `visible`, the canvas's area relative to the slide's top-left corner. The visible
229/// area is rounded out to whole [`TILE`]s.
230pub fn pasteboard_region(
231    slide_px: (u32, u32),
232    scale: f64,
233    extent: Rect,
234    visible: Region,
235) -> Region {
236    let page = Region::slide(slide_px.0, slide_px.1);
237    if !(scale.is_finite() && scale > 0.0) {
238        return page;
239    }
240    let Some((ex0, ey0, ex1, ey1)) = to_px(extent, scale) else {
241        return page;
242    };
243    let (vx0, vy0, vx1, vy1) = visible.edges();
244    let (vx0, vy0) = (vx0.div_euclid(TILE) * TILE, vy0.div_euclid(TILE) * TILE);
245    let (vx1, vy1) = (
246        (vx1 + TILE - 1).div_euclid(TILE) * TILE,
247        (vy1 + TILE - 1).div_euclid(TILE) * TILE,
248    );
249    let (cx0, cy0, cx1, cy1) = (ex0.max(vx0), ey0.max(vy0), ex1.min(vx1), ey1.min(vy1));
250    if cx0 >= cx1 || cy0 >= cy1 {
251        return page;
252    }
253    let (px0, py0, px1, py1) = page.edges();
254    Region::from_edges(cx0.min(px0), cy0.min(py0), cx1.max(px1), cy1.max(py1))
255}
256
257#[cfg(test)]
258mod tests {
259    #![allow(clippy::unwrap_used, clippy::expect_used)]
260
261    use super::*;
262    use deckcraft_model::Shape;
263
264    fn black() -> deckcraft_model::ColorRef {
265        deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::BLACK)
266    }
267
268    fn deck_with(x: f64, y: f64, w: f64, h: f64) -> Presentation {
269        let mut p = Presentation::default();
270        let sl = std::sync::Arc::make_mut(&mut p.slides[0]);
271        sl.shapes.push(Shape {
272            id: deckcraft_model::ShapeId(900),
273            xfrm: Some(Xfrm::new(x, y, w, h)),
274            ..Default::default()
275        });
276        p
277    }
278
279    #[test]
280    fn a_slide_with_nothing_off_it_extends_no_further_than_the_slide() {
281        let p = deck_with(100.0, 100.0, 50.0, 50.0);
282        assert_eq!(
283            slide_extent(&p, 0),
284            Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height)
285        );
286    }
287
288    #[test]
289    fn shapes_off_the_slide_grow_the_extent() {
290        let p = deck_with(-200.0, 50.0, 100.0, 80.0);
291        let e = slide_extent(&p, 0);
292        assert!(e.x0 <= -200.0 && e.x0 > -220.0, "{e:?}");
293        assert_eq!(e.y0, 0.0);
294        assert_eq!(e.x1, p.slide_size.width);
295        // Missing slides are just the page.
296        assert_eq!(
297            slide_extent(&p, 7),
298            Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height)
299        );
300    }
301
302    #[test]
303    fn hidden_shapes_and_hostile_boxes_do_not_grow_it() {
304        let mut p = deck_with(-5000.0, 0.0, 10.0, 10.0);
305        std::sync::Arc::make_mut(&mut p.slides[0])
306            .shapes
307            .last_mut()
308            .unwrap()
309            .hidden = true;
310        let page = Rect::new(0.0, 0.0, p.slide_size.width, p.slide_size.height);
311        assert_eq!(slide_extent(&p, 0), page);
312        let p = deck_with(f64::NAN, 0.0, 10.0, 10.0);
313        assert_eq!(slide_extent(&p, 0), page);
314        let p = deck_with(f64::INFINITY, 0.0, 10.0, 10.0);
315        assert_eq!(slide_extent(&p, 0), page);
316    }
317
318    #[test]
319    fn effects_count_towards_the_extent() {
320        let mut p = deck_with(10.0, -100.0, 100.0, 50.0);
321        let sh = std::sync::Arc::make_mut(&mut p.slides[0])
322            .shapes
323            .last_mut()
324            .unwrap();
325        sh.effects = Some(Effects {
326            outer_shadow: Some(deckcraft_model::style::Shadow {
327                color: black(),
328                blur: 10.0,
329                dist: 20.0,
330                dir: 270.0,
331                inner: false,
332                sx: 1.0,
333                sy: 1.0,
334                kx: 0.0,
335                ky: 0.0,
336                align: String::new(),
337                rotate_with_shape: false,
338            }),
339            ..Default::default()
340        });
341        assert!(slide_extent(&p, 0).y0 <= -130.0);
342        assert_eq!(
343            spill(
344                None,
345                Some(&Effects {
346                    glow: Some(deckcraft_model::style::Glow {
347                        color: black(),
348                        radius: f64::MAX
349                    }),
350                    ..Default::default()
351                }),
352                1.0,
353                1.0
354            ),
355            MAX_SPILL
356        );
357    }
358
359    #[test]
360    fn master_graphics_off_the_slide_count_but_not_master_placeholders() {
361        let mut p = Presentation::default();
362        let m = std::sync::Arc::make_mut(&mut p.masters[0]);
363        m.shapes.push(Shape {
364            id: deckcraft_model::ShapeId(901),
365            xfrm: Some(Xfrm::new(-150.0, 10.0, 100.0, 50.0)),
366            ..Default::default()
367        });
368        assert!(slide_extent(&p, 0).x0 <= -150.0);
369        assert!(layout_extent(&p, 0, None).x0 <= -150.0);
370        assert!(
371            layout_extent(&p, 0, Some(0)).x0 <= -150.0
372                || !p.masters[0].layouts[0].show_master_shapes
373        );
374    }
375
376    #[test]
377    fn the_region_is_the_slide_unless_something_off_it_is_in_view() {
378        let slide = (960, 540);
379        let page = Region::slide(960, 540);
380        let view = Region {
381            x: -100,
382            y: -50,
383            w: 1160,
384            h: 640,
385        };
386        let whole = Rect::new(0.0, 0.0, 960.0, 540.0);
387        assert_eq!(pasteboard_region(slide, 1.0, whole, view), page);
388        assert!(!pasteboard_region(slide, 1.0, whole, view).beyond(slide));
389        // Off to the left, in view: the region reaches it.
390        let r = pasteboard_region(slide, 1.0, Rect::new(-80.0, 0.0, 960.0, 540.0), view);
391        assert_eq!((r.x, r.y, r.w, r.h), (-80, 0, 1040, 540));
392        assert!(r.beyond(slide) && r.contains(&page));
393        assert_eq!(r.offset(), (80.0, 0.0));
394        // Far off and out of view (the view rounds out to tiles): only what's visible.
395        let r = pasteboard_region(slide, 1.0, Rect::new(-5000.0, 0.0, 960.0, 540.0), view);
396        assert_eq!(r.x, -256);
397        // Wholly out of view: the slide.
398        assert_eq!(
399            pasteboard_region(
400                slide,
401                1.0,
402                Rect::new(-5000.0, 0.0, -4000.0, 540.0).union(whole),
403                Region {
404                    x: 0,
405                    y: 0,
406                    w: 960,
407                    h: 540
408                }
409            ),
410            page
411        );
412        // Scale applies; nonsense falls back to the slide.
413        let r = pasteboard_region((480, 270), 0.5, Rect::new(-80.0, 0.0, 960.0, 540.0), view);
414        assert_eq!(r.x, -40);
415        assert_eq!(pasteboard_region(slide, f64::NAN, whole, view), page);
416        assert_eq!(
417            pasteboard_region(slide, 1.0, Rect::new(f64::NAN, 0.0, 1.0, 1.0), view),
418            page
419        );
420        // Huge extents are capped.
421        let r = pasteboard_region(
422            slide,
423            1.0,
424            Rect::new(-1e12, -1e12, 1e12, 1e12),
425            Region {
426                x: -1_000_000,
427                y: -1_000_000,
428                w: 2_000_000,
429                h: 2_000_000,
430            },
431        );
432        assert!(r.w <= MAX_SIDE && r.h <= MAX_SIDE);
433    }
434
435    #[test]
436    fn scrolling_within_a_tile_keeps_the_region() {
437        let slide = (2000, 1125);
438        let e = Rect::new(-500.0, 0.0, 960.0, 540.0);
439        let a = pasteboard_region(
440            slide,
441            2.0,
442            e,
443            Region {
444                x: -300,
445                y: 0,
446                w: 1000,
447                h: 700,
448            },
449        );
450        let b = pasteboard_region(
451            slide,
452            2.0,
453            e,
454            Region {
455                x: -290,
456                y: 10,
457                w: 1000,
458                h: 700,
459            },
460        );
461        assert_eq!(a, b);
462    }
463}