Skip to main content

deckcraft_render/
lib.rs

1//! The DeckCraft renderer: draws slides, layouts and masters with `vello_cpu`.
2//!
3//! One code path serves the editor canvas, thumbnails, image/PDF-raster export and the slide show:
4//! [`render_slide`] draws the background, the master and layout graphics, then the slide's shapes
5//! (fills with theme shading, gradients, pictures, outlines with dashes and arrowheads, shadows,
6//! glow and soft edges, text, tables, charts, groups), optionally with per-shape animation state.
7#![deny(
8    clippy::unwrap_used,
9    clippy::expect_used,
10    clippy::panic,
11    clippy::unimplemented,
12    clippy::todo,
13    clippy::unreachable
14)]
15#![forbid(unsafe_code)]
16
17mod chart;
18mod colr;
19
20mod compound;
21mod extent;
22mod images;
23mod metafile;
24mod morph_path;
25mod morph_text;
26mod paint;
27mod pattern;
28
29mod pathgrad;
30mod placed;
31mod ramp;
32mod table;
33mod warp;
34
35use std::collections::HashMap;
36use std::sync::Arc;
37
38use deckcraft_color::Rgba;
39use deckcraft_fonts::FontDb;
40use deckcraft_geom::preset::{self, FillMode, Geometry, SubPath};
41use deckcraft_model::resolve::{self, Ctx};
42use deckcraft_model::style::{Effects, Fill, Line};
43use deckcraft_model::text::TextBody;
44use deckcraft_model::{Geom, PhType, Presentation, Shape, ShapeId, ShapeKind, Slide};
45use deckcraft_text::datetime::{self, DateTime};
46use deckcraft_text::{Fields, Opts};
47use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _, Vec2};
48use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
49use vello_cpu::{RenderContext, Resources, peniko};
50
51pub use extent::{Region, TILE, layout_extent, pasteboard_region, slide_extent};
52pub use images::{decode as decode_image, gif_frame};
53pub use morph_path::PathMorph;
54pub use morph_text::TextMorph;
55pub use placed::{Placed, PlacedLink, PlacedText, place_slide};
56pub use table::row_heights as table_row_heights;
57
58/// Largest pixmap side we render (vello_cpu uses u16 sizes).
59pub const MAX_SIDE: u32 = 16_000;
60
61/// A rendered image: premultiplied RGBA8.
62#[derive(Clone, Debug, Default)]
63pub struct Image {
64    pub width: u32,
65    pub height: u32,
66    pub pixels: Vec<u8>,
67}
68
69impl Image {
70    /// Straight (unpremultiplied) RGBA.
71    pub fn to_straight(&self) -> Vec<u8> {
72        let mut out = self.pixels.clone();
73        for px in out.as_chunks_mut::<4>().0 {
74            let a = px.get(3).copied().unwrap_or(255) as u32;
75            if a != 0 && a != 255 {
76                for c in px.iter_mut().take(3) {
77                    *c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8;
78                }
79            }
80        }
81        out
82    }
83    pub fn to_png(&self) -> Vec<u8> {
84        let mut px = self.to_straight();
85        px.resize(self.width as usize * self.height as usize * 4, 0);
86        let Some(img) = image::RgbaImage::from_raw(self.width, self.height, px) else {
87            return Vec::new();
88        };
89        let mut buf = Vec::new();
90        if let Err(e) = img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png) {
91            log::error!("PNG encode: {e}");
92            return Vec::new();
93        }
94        buf
95    }
96    pub fn to_jpeg(&self, quality: u8) -> Vec<u8> {
97        let straight = self.to_straight();
98        let rgb: Vec<u8> = straight
99            .as_chunks::<4>()
100            .0
101            .iter()
102            .flat_map(|p| [p[0], p[1], p[2]])
103            .collect();
104        let Some(img) = image::RgbImage::from_raw(self.width, self.height, rgb) else {
105            return Vec::new();
106        };
107        let mut buf = Vec::new();
108        let enc =
109            image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100));
110        if let Err(e) = img.write_with_encoder(enc) {
111            log::error!("JPEG encode: {e}");
112            return Vec::new();
113        }
114        buf
115    }
116    /// Pixel at (x, y) as straight RGBA.
117    pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {
118        let i = (y as usize * self.width as usize + x as usize) * 4;
119        let p = self.pixels.get(i..i + 4).unwrap_or(&[0, 0, 0, 0]);
120        let a = p.get(3).copied().unwrap_or(0) as u32;
121        let un = |c: u8| {
122            (c as u32 * 255 + a / 2)
123                .checked_div(a)
124                .map(|v| v.min(255) as u8)
125                .unwrap_or(0)
126        };
127        [un(p[0]), un(p[1]), un(p[2]), a as u8]
128    }
129}
130
131/// Animation state of one shape during a slide show or preview.
132#[derive(Clone, Debug)]
133pub struct ShapeState {
134    pub visible: bool,
135    pub opacity: f64,
136    /// Applied in slide space around the shape's centre: translation (pt), scale, rotation (deg).
137    pub offset: Vec2,
138    pub scale: (f64, f64),
139    pub rotate: f64,
140    /// Reveal clip in shape-local unit space (0..1): l, t, r, b.
141    pub clip: Option<[f64; 4]>,
142    /// Colour override for emphasis effects (fill).
143    pub tint: Option<Rgba>,
144    /// Per-paragraph states of by-paragraph text builds (paragraph index, state).
145    pub paras: Vec<(usize, ParaState)>,
146}
147
148impl Default for ShapeState {
149    fn default() -> Self {
150        ShapeState {
151            visible: true,
152            opacity: 1.0,
153            offset: Vec2::ZERO,
154            scale: (1.0, 1.0),
155            rotate: 0.0,
156            clip: None,
157            tint: None,
158            paras: vec![],
159        }
160    }
161}
162
163/// Animation state of one paragraph, relative to its shape.
164#[derive(Clone, Copy, Debug)]
165pub struct ParaState {
166    pub visible: bool,
167    pub opacity: f64,
168    /// Translation in shape-local points.
169    pub offset: Vec2,
170}
171
172impl Default for ParaState {
173    fn default() -> Self {
174        ParaState {
175            visible: true,
176            opacity: 1.0,
177            offset: Vec2::ZERO,
178        }
179    }
180}
181
182pub struct RenderOpts<'a> {
183    /// Pixels per point.
184    pub scale: f64,
185    /// Editor view: placeholder prompts and dashed placeholder outlines.
186    pub edit: bool,
187    /// Draw shapes marked hidden (Selection pane) — the editor hides them like the show does.
188    pub show_hidden: bool,
189    /// Per-shape animation state.
190    pub state: Option<&'a dyn Fn(ShapeId) -> ShapeState>,
191    /// Don't draw these shapes (being edited in place, drawn by the UI).
192    pub skip: &'a [ShapeId],
193    /// Hide the text of this shape (the UI draws it while editing).
194    pub skip_text: Option<ShapeId>,
195    /// Animated GIF pictures and how far into their play they are (seconds); others show frame 1.
196    pub gif_times: &'a [(ShapeId, f64)],
197    /// Worker threads (0 = this thread; required when effects use filters).
198    pub threads: u16,
199    /// Translate the slide within the output (pixels) and output size override.
200    pub offset: (f64, f64),
201    pub size: Option<(u32, u32)>,
202    /// Draw a background colour outside the slide (output larger than slide).
203    pub clear: Option<Rgba>,
204    /// Show this text in every date field instead of the date.
205    pub date_text: Option<String>,
206    /// The local date and time date fields show (`None`: the system clock).
207    pub now: Option<DateTime>,
208}
209
210impl Default for RenderOpts<'_> {
211    fn default() -> Self {
212        RenderOpts {
213            scale: 1.0,
214            edit: false,
215            show_hidden: false,
216            state: None,
217            skip: &[],
218            skip_text: None,
219            gif_times: &[],
220            threads: 0,
221            offset: (0.0, 0.0),
222            size: None,
223            clear: None,
224            date_text: None,
225            now: None,
226        }
227    }
228}
229
230struct SlideFields {
231    num: u32,
232    /// Text for every date field instead of the date ([`RenderOpts::date_text`]).
233    date: Option<String>,
234    footer: String,
235    /// The local date and time date fields show; `None`: they keep their saved text.
236    now: Option<DateTime>,
237}
238
239impl SlideFields {
240    fn new(num: u32, footer: String, opts: &RenderOpts) -> Self {
241        SlideFields {
242            num,
243            date: opts.date_text.clone(),
244            footer,
245            now: opts.now.or_else(DateTime::now_local),
246        }
247    }
248}
249
250impl Fields for SlideFields {
251    fn field(&self, kind: &str) -> Option<String> {
252        self.field_in(kind, None)
253    }
254
255    fn field_in(&self, kind: &str, lang: Option<&str>) -> Option<String> {
256        if kind == "slidenum" {
257            return Some(self.num.to_string());
258        }
259        if datetime::is_date_field(kind) {
260            // An automatic date: today's, in the format of its type. (A fixed date is plain text
261            // in the slide's placeholder, not a field.)
262            return self
263                .date
264                .clone()
265                .or_else(|| datetime::field_text(kind, lang, self.now?));
266        }
267        if kind == "footer" {
268            return Some(self.footer.clone());
269        }
270        None
271    }
272}
273
274pub struct Renderer {
275    resources: Resources,
276    /// Outlines morphing in the frame being drawn by `blend`.
277    paths: Vec<PathMorph>,
278    /// The background of the slide (layout, master) being drawn, its placeholder colour and the
279    /// slide's view transform: shapes filled with the background (`useBgFill`) show it through.
280    bg: Option<(Fill, Option<Rgba>, Affine)>,
281    /// What the frame being drawn has painted so far, in output pixels: the editor learns from it
282    /// how far a slide's drawing (text spilling out of its box too) reaches off the slide.
283    drawn: Option<Rect>,
284    /// The transform a top-level shape is drawn under (slide to pixels); a group's children draw
285    /// under it combined with the group's child-coordinate mapping, which must not scale lines.
286    view: Affine,
287}
288
289impl Default for Renderer {
290    fn default() -> Self {
291        Renderer {
292            resources: Resources::new(),
293            paths: Vec::new(),
294            bg: None,
295            drawn: None,
296            view: Affine::IDENTITY,
297        }
298    }
299}
300
301thread_local! {
302    static RENDERER: std::cell::RefCell<Renderer> = std::cell::RefCell::new(Renderer::default());
303}
304
305/// Does anything on this slide (or its layout/master graphics) use blur-based effects?
306pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
307    let Some(rctx) = Ctx::for_slide(pres, slide) else {
308        return false;
309    };
310    let mut any = false;
311    let mut check = |shapes: &[Shape]| {
312        deckcraft_model::walk(shapes, &mut |s, _| {
313            if any {
314                return;
315            }
316            let (e, _) = resolve::effects(&rctx, s);
317            if e.is_some_and(|e| !e.is_empty()) || text_has_effects(&rctx, s) {
318                any = true;
319            }
320        });
321    };
322    check(&slide.shapes);
323    if let Some(l) = rctx.layout {
324        check(&l.shapes);
325    }
326    check(&rctx.master.shapes);
327    any
328}
329
330/// Does the shape's text have a shadow or glow (on a run, in its list style, or in the master's
331/// text styles it inherits)? Such text is blurred, which needs filters.
332fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool {
333    use deckcraft_model::text::{ListStyle, RunProps};
334    let fx = |r: &RunProps| {
335        r.shadow.as_ref().is_some_and(|s| !s.inner)
336            || r.glow.as_ref().is_some_and(|g| g.radius > 0.0)
337    };
338    let styled = |l: &ListStyle| l.levels.iter().flatten().any(|lv| fx(&lv.run));
339    let Some(t) = &s.text else { return false };
340    t.paragraphs
341        .iter()
342        .any(|p| p.runs.iter().any(|r| fx(&r.props)) || fx(&p.end_props))
343        || styled(&t.list_style)
344        || styled(&rctx.master.title_style)
345        || styled(&rctx.master.body_style)
346        || styled(&rctx.master.other_style)
347}
348
349/// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a
350/// blank image (logged), never a crash.
351pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image {
352    render_slide_drawn(pres, index, opts).0
353}
354
355/// [`render_slide`], also returning how far the drawing reached, in slide space (at least the
356/// slide; off it, what the shapes, their effects and their text drew, the parts outside the output
357/// included). The editor grows its pasteboard to it ([`pasteboard_region`]).
358pub fn render_slide_drawn(pres: &Presentation, index: usize, opts: &RenderOpts) -> (Image, Rect) {
359    let page = Rect::new(0.0, 0.0, pres.slide_size.width, pres.slide_size.height);
360    let Some(slide) = pres.slides.get(index) else {
361        return (Image::default(), page);
362    };
363    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
364        RENDERER.with(|r| {
365            let mut r = r.borrow_mut();
366            r.drawn = None;
367            let img = r.slide(pres, slide, index, opts);
368            (img, r.drawn.take())
369        })
370    })) {
371        Ok((img, drawn)) => {
372            let k = opts.scale;
373            let to_slide = |d: Rect| {
374                Rect::new(
375                    (d.x0 - opts.offset.0) / k,
376                    (d.y0 - opts.offset.1) / k,
377                    (d.x1 - opts.offset.0) / k,
378                    (d.y1 - opts.offset.1) / k,
379                )
380            };
381            let extent = match drawn {
382                Some(d) if k.is_finite() && k > 0.0 && to_slide(d).is_finite() => {
383                    page.union(to_slide(d))
384                }
385                _ => page,
386            };
387            (img, extent)
388        }
389        Err(_) => {
390            log::error!("rendering slide {index} panicked; showing a blank slide");
391            RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
392            let (w, h) = output_size(pres, opts);
393            (
394                Image {
395                    width: w as u32,
396                    height: h as u32,
397                    pixels: vec![255; w as usize * h as usize * 4],
398                },
399                page,
400            )
401        }
402    }
403}
404
405/// Render a frame between slides `old` and `new` (indices into `pres.slides`), e.g. of Morph: the
406/// backdrop (background, master and layout graphics) of `old` cross-fading into that of `new` by
407/// `mix` (0..1), then `shapes` in order, each resolved against the slide it comes from (`true`:
408/// `old`). Pairs of shapes in `text` draw their boxes as usual but their texts morph by words or
409/// characters ([`TextMorph`]), on top of the later shape of the pair. Shapes listed in `paths` draw
410/// an outline morphing from another geometry into their own ([`PathMorph`]). `opts.state` applies
411/// to `shapes` only.
412pub fn render_blend(
413    pres: &Presentation,
414    old: usize,
415    new: usize,
416    mix: f64,
417    shapes: &[(Shape, bool)],
418    text: &[TextMorph],
419    paths: &[PathMorph],
420    opts: &RenderOpts,
421) -> Image {
422    let (Some(a), Some(b)) = (pres.slides.get(old), pres.slides.get(new)) else {
423        return Image::default();
424    };
425    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
426        RENDERER.with(|r| {
427            r.borrow_mut()
428                .blend(pres, (a, old), (b, new), mix, shapes, text, paths, opts)
429        })
430    })) {
431        Ok(img) => img,
432        Err(_) => {
433            log::error!(
434                "rendering a frame between slides {old} and {new} panicked; showing the new slide"
435            );
436            RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
437            render_slide(pres, new, opts)
438        }
439    }
440}
441
442/// Render a layout (Slide Master view thumbnail and canvas).
443pub fn render_layout(
444    pres: &Presentation,
445    master: usize,
446    layout: Option<usize>,
447    opts: &RenderOpts,
448) -> Image {
449    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
450        RENDERER.with(|r| r.borrow_mut().layout(pres, master, layout, opts))
451    })) {
452        Ok(img) => img,
453        Err(_) => {
454            log::error!("rendering a layout panicked; showing a blank image");
455            RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
456            Image::default()
457        }
458    }
459}
460
461fn output_size(pres: &Presentation, opts: &RenderOpts) -> (u16, u16) {
462    let (w, h) = opts.size.unwrap_or((
463        (pres.slide_size.width * opts.scale).round().max(1.0) as u32,
464        (pres.slide_size.height * opts.scale).round().max(1.0) as u32,
465    ));
466    (w.clamp(1, MAX_SIDE) as u16, h.clamp(1, MAX_SIDE) as u16)
467}
468
469pub(crate) fn color(c: Rgba, alpha: f64) -> peniko::Color {
470    let a = (c.a as f64 * alpha.clamp(0.0, 1.0))
471        .round()
472        .clamp(0.0, 255.0) as u8;
473    peniko::Color::from_rgba8(c.r, c.g, c.b, a)
474}
475
476fn shade(c: Rgba, m: FillMode) -> Rgba {
477    match m {
478        FillMode::Lighten => c.lerp(Rgba::WHITE.with_alpha(c.a), 0.4),
479        FillMode::LightenLess => c.lerp(Rgba::WHITE.with_alpha(c.a), 0.2),
480        FillMode::Darken => c.lerp(Rgba::BLACK.with_alpha(c.a), 0.4),
481        FillMode::DarkenLess => c.lerp(Rgba::BLACK.with_alpha(c.a), 0.2),
482        _ => c,
483    }
484}
485
486/// Geometry of a shape at its size.
487pub fn shape_geometry(shape: &Shape, w: f64, h: f64) -> Geometry {
488    match &shape.geom {
489        Geom::Preset { name, adj } => preset::build(name, w, h, adj)
490            .or_else(|| preset::build("rect", w, h, &[]))
491            .unwrap_or(Geometry {
492                paths: vec![],
493                text_rect: Rect::new(0.0, 0.0, w, h),
494                handles: vec![],
495                sites: vec![],
496            }),
497        Geom::Custom { paths } => {
498            let mut out = vec![];
499            for p in paths {
500                let (pw, ph) = (
501                    if p.w > 0.0 { p.w } else { w.max(1e-9) },
502                    if p.h > 0.0 { p.h } else { h.max(1e-9) },
503                );
504                let mut bp = parse_path(&p.d);
505                bp.apply_affine(Affine::scale_non_uniform(w / pw, h / ph));
506                out.push(SubPath {
507                    path: bp,
508                    fill: p.fill,
509                    stroke: p.stroke,
510                    even_odd: false,
511                });
512            }
513            Geometry {
514                paths: out,
515                text_rect: Rect::new(0.0, 0.0, w, h),
516                handles: vec![],
517                sites: vec![],
518            }
519        }
520    }
521}
522
523/// Parse our custom path syntax (`M x y L x y C … Q … Z`).
524pub fn parse_path(d: &str) -> BezPath {
525    let mut bp = BezPath::new();
526    let toks: Vec<&str> = d
527        .split(|c: char| c.is_whitespace() || c == ',')
528        .filter(|t| !t.is_empty())
529        .collect();
530    let mut i = 0;
531    let num = |i: usize| {
532        toks.get(i)
533            .and_then(|t| t.parse::<f64>().ok())
534            .filter(|v| v.is_finite())
535    };
536    let mut started = false;
537    while i < toks.len() {
538        let t = toks.get(i).copied().unwrap_or("");
539        match t {
540            "M" => {
541                if let (Some(x), Some(y)) = (num(i + 1), num(i + 2)) {
542                    bp.move_to((x, y));
543                    started = true;
544                }
545                i += 3;
546            }
547            "L" => {
548                if let (Some(x), Some(y)) = (num(i + 1), num(i + 2)) {
549                    if !started {
550                        bp.move_to((x, y));
551                        started = true;
552                    } else {
553                        bp.line_to((x, y));
554                    }
555                }
556                i += 3;
557            }
558            "C" => {
559                if let (Some(a), Some(b), Some(c), Some(d2), Some(e), Some(f)) = (
560                    num(i + 1),
561                    num(i + 2),
562                    num(i + 3),
563                    num(i + 4),
564                    num(i + 5),
565                    num(i + 6),
566                ) && started
567                {
568                    bp.curve_to((a, b), (c, d2), (e, f));
569                }
570                i += 7;
571            }
572            "Q" => {
573                if let (Some(a), Some(b), Some(c), Some(d2)) =
574                    (num(i + 1), num(i + 2), num(i + 3), num(i + 4))
575                    && started
576                {
577                    bp.quad_to((a, b), (c, d2));
578                }
579                i += 5;
580            }
581            "Z" | "z" => {
582                if started {
583                    bp.close_path();
584                }
585                i += 1;
586            }
587            _ => i += 1,
588        }
589    }
590    bp
591}
592
593/// The rectangle a picture's image occupies given its crop (fractions; negative = padding).
594pub fn crop_dest(bounds: Rect, crop: [f64; 4]) -> Rect {
595    let [l, t, r, b] = crop.map(|v| {
596        if v.is_finite() {
597            v.clamp(-10.0, 0.99)
598        } else {
599            0.0
600        }
601    });
602    let vis_w = (1.0 - l - r).max(1e-6);
603    let vis_h = (1.0 - t - b).max(1e-6);
604    let fw = bounds.width() / vis_w;
605    let fh = bounds.height() / vis_h;
606    Rect::new(
607        bounds.x0 - l * fw,
608        bounds.y0 - t * fh,
609        bounds.x0 - l * fw + fw,
610        bounds.y0 - t * fh + fh,
611    )
612}
613
614struct Frame<'a> {
615    pres: &'a Presentation,
616    opts: &'a RenderOpts<'a>,
617    fields: SlideFields,
618}
619
620impl Renderer {
621    fn canvas(
622        pres: &Presentation,
623        opts: &RenderOpts,
624        filters: bool,
625    ) -> (RenderContext, u16, u16, Affine) {
626        let (w, h) = output_size(pres, opts);
627        let mut ctx = RenderContext::new_with(
628            w,
629            h,
630            vello_cpu::RenderSettings {
631                num_threads: if cfg!(target_arch = "wasm32") || filters {
632                    0
633                } else {
634                    opts.threads
635                },
636                ..Default::default()
637            },
638        );
639        if let Some(c) = opts.clear {
640            ctx.set_paint(color(c, 1.0));
641            ctx.fill_rect(&Rect::new(0.0, 0.0, w as f64, h as f64));
642        }
643        let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale);
644        (ctx, w, h, view)
645    }
646
647    /// Note that `r` (local space, placed by `m`) was drawn.
648    fn track(&mut self, m: Affine, r: Rect) {
649        let b = m.transform_rect_bbox(r);
650        if b.is_finite() {
651            self.drawn = Some(self.drawn.map_or(b, |d| d.union(b)));
652        }
653    }
654
655    fn fields(pres: &Presentation, index: usize, opts: &RenderOpts) -> SlideFields {
656        SlideFields::new(
657            pres.slide_number(index),
658            pres.header_footer.footer_text.clone(),
659            opts,
660        )
661    }
662
663    /// Background plus the master and layout graphics the slide shows.
664    fn backdrop(
665        &mut self,
666        ctx: &mut RenderContext,
667        f: &Frame,
668        rctx: &Ctx,
669        slide: &Slide,
670        view: Affine,
671    ) {
672        self.background(ctx, f, rctx, Some(slide), view);
673        let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
674        if show_master {
675            let mctx = Ctx::for_master(f.pres, rctx.master);
676            for s in &rctx.master.shapes {
677                if s.ph.is_none() {
678                    self.shape(ctx, f, &mctx, s, view, 0);
679                }
680            }
681        }
682        if show_layout && let Some(l) = rctx.layout {
683            let lctx = Ctx::for_layout(f.pres, rctx.master, l);
684            for s in &l.shapes {
685                if s.ph.is_none() {
686                    self.shape(ctx, f, &lctx, s, view, 0);
687                }
688            }
689        }
690    }
691
692    fn slide(
693        &mut self,
694        pres: &Presentation,
695        slide: &Slide,
696        index: usize,
697        opts: &RenderOpts,
698    ) -> Image {
699        let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide));
700        let f = Frame {
701            pres,
702            opts,
703            fields: Self::fields(pres, index, opts),
704        };
705        if let Some(rctx) = Ctx::for_slide(pres, slide) {
706            self.backdrop(&mut ctx, &f, &rctx, slide, view);
707            for s in &slide.shapes {
708                self.shape(&mut ctx, &f, &rctx, s, view, 0);
709            }
710        }
711        self.finish(ctx, w, h)
712    }
713
714    #[allow(clippy::too_many_arguments)]
715    fn blend(
716        &mut self,
717        pres: &Presentation,
718        old: (&Slide, usize),
719        new: (&Slide, usize),
720        mix: f64,
721        shapes: &[(Shape, bool)],
722        text: &[TextMorph],
723        paths: &[PathMorph],
724        opts: &RenderOpts,
725    ) -> Image {
726        let mix = if mix.is_finite() {
727            mix.clamp(0.0, 1.0)
728        } else {
729            1.0
730        };
731        let (mut ctx, w, h, view) = Self::canvas(
732            pres,
733            opts,
734            needs_filters(pres, old.0) || needs_filters(pres, new.0),
735        );
736        let (Some(co), Some(cn)) = (Ctx::for_slide(pres, old.0), Ctx::for_slide(pres, new.0))
737        else {
738            return self.finish(ctx, w, h);
739        };
740        // Backdrop graphics never take shape states: their ids may clash with the frame's shapes.
741        let bopts = RenderOpts {
742            state: None,
743            date_text: opts.date_text.clone(),
744            ..*opts
745        };
746        if mix < 1.0 {
747            let f = Frame {
748                pres,
749                opts: &bopts,
750                fields: Self::fields(pres, old.1, opts),
751            };
752            self.backdrop(&mut ctx, &f, &co, old.0, view);
753        }
754        if mix > 0.0 {
755            let f = Frame {
756                pres,
757                opts: &bopts,
758                fields: Self::fields(pres, new.1, opts),
759            };
760            let layered = mix < 1.0;
761            if layered {
762                ctx.push_layer(None, None, Some(mix as f32), None, None);
763            }
764            self.backdrop(&mut ctx, &f, &cn, new.0, view);
765            if layered {
766                ctx.pop_layer();
767            }
768        }
769        let fo = Frame {
770            pres,
771            opts,
772            fields: Self::fields(pres, old.1, opts),
773        };
774        let fnew = Frame {
775            pres,
776            opts,
777            fields: Self::fields(pres, new.1, opts),
778        };
779        let mut drawn: Vec<ShapeId> = Vec::new();
780        // Set only now: the backdrop's ids may clash with the frame's.
781        self.paths = paths.to_vec();
782        for (s, from_old) in shapes {
783            let (f, c) = if *from_old { (&fo, &co) } else { (&fnew, &cn) };
784            let Some(tm) = text.iter().find(|m| m.old == s.id || m.new == s.id) else {
785                self.shape(&mut ctx, f, c, s, view, 0);
786                continue;
787            };
788            self.shape(
789                &mut ctx,
790                f,
791                c,
792                &Shape {
793                    text: None,
794                    ..s.clone()
795                },
796                view,
797                0,
798            );
799            drawn.push(s.id);
800            if !(drawn.contains(&tm.old) && drawn.contains(&tm.new)) {
801                continue;
802            }
803            let find = |id: ShapeId| shapes.iter().map(|(s, _)| s).find(|s| s.id == id);
804            if let (Some(a), Some(b)) = (find(tm.old), find(tm.new)) {
805                let now = b.xfrm.unwrap_or_else(|| resolve::xfrm(&cn, b));
806                morph_text::draw(
807                    &mut ctx,
808                    (&co, a, &fo.fields),
809                    (&cn, b, &fnew.fields),
810                    tm,
811                    now,
812                    view,
813                );
814            }
815        }
816        self.paths.clear();
817        self.finish(ctx, w, h)
818    }
819
820    fn layout(
821        &mut self,
822        pres: &Presentation,
823        mi: usize,
824        li: Option<usize>,
825        opts: &RenderOpts,
826    ) -> Image {
827        let (w, h) = output_size(pres, opts);
828        let mut ctx = RenderContext::new_with(
829            w,
830            h,
831            vello_cpu::RenderSettings {
832                num_threads: 0,
833                ..Default::default()
834            },
835        );
836        let view = Affine::translate((opts.offset.0, opts.offset.1)) * Affine::scale(opts.scale);
837        let f = Frame {
838            pres,
839            opts,
840            fields: SlideFields::new(1, String::new(), opts),
841        };
842        if let Some(m) = pres.masters.get(mi) {
843            match li.and_then(|i| m.layouts.get(i)) {
844                Some(l) => {
845                    let c = Ctx::for_layout(pres, m, l);
846                    self.background(&mut ctx, &f, &c, None, view);
847                    if l.show_master_shapes {
848                        let mc = Ctx::for_master(pres, m);
849                        for s in m.shapes.iter().filter(|s| s.ph.is_none()) {
850                            self.shape(&mut ctx, &f, &mc, s, view, 0);
851                        }
852                    }
853                    for s in &l.shapes {
854                        self.shape(&mut ctx, &f, &c, s, view, 0);
855                    }
856                }
857                None => {
858                    let c = Ctx::for_master(pres, m);
859                    self.background(&mut ctx, &f, &c, None, view);
860                    for s in &m.shapes {
861                        self.shape(&mut ctx, &f, &c, s, view, 0);
862                    }
863                }
864            }
865        }
866        self.finish(ctx, w, h)
867    }
868
869    fn finish(&mut self, mut ctx: RenderContext, w: u16, h: u16) -> Image {
870        ctx.flush();
871        let mut pixels = vec![0u8; w as usize * h as usize * 4];
872        if let Some(pm) = vello_cpu::PixmapMut::new(w, h, &mut pixels) {
873            ctx.render(pm, &mut self.resources);
874        }
875        Image {
876            width: w as u32,
877            height: h as u32,
878            pixels,
879        }
880    }
881
882    fn background(
883        &mut self,
884        ctx: &mut RenderContext,
885        f: &Frame,
886        rctx: &Ctx,
887        slide: Option<&Slide>,
888        view: Affine,
889    ) {
890        let (fill, ph) = resolve::background(rctx, slide);
891        let r = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
892        ctx.set_transform(view);
893        let path = r.to_path(0.1);
894        // PowerPoint lays every background over white: a picture stretched into part of the
895        // slide, or a see-through colour, leaves white around and under it.
896        ctx.set_paint(color(Rgba::WHITE, 1.0));
897        ctx.fill_path(&path);
898        paint::fill_path(
899            ctx,
900            rctx,
901            &fill,
902            ph,
903            &path,
904            r,
905            view,
906            FillMode::Norm,
907            1.0,
908            f.pres,
909        );
910        self.bg = Some((fill, ph, view));
911    }
912
913    fn shape(
914        &mut self,
915        ctx: &mut RenderContext,
916        f: &Frame,
917        rctx: &Ctx,
918        s: &Shape,
919        parent: Affine,
920        depth: usize,
921    ) {
922        if depth == 0 {
923            let outer = std::mem::replace(&mut self.view, parent);
924            self.shape_at(ctx, f, rctx, s, parent, depth);
925            self.view = outer;
926        } else {
927            self.shape_at(ctx, f, rctx, s, parent, depth);
928        }
929    }
930
931    /// How lines are drawn for a shape under `m` at `depth`: the transform to stroke under and the
932    /// one that takes the shape's outline into it. Inside a group they are the slide-level view and
933    /// the rest of the chain, so the group's child-coordinate scale moves and sizes the outline but
934    /// never the line width; outside a group, `m` itself and no change to the outline.
935    fn line_space(&self, m: Affine, depth: usize) -> (Affine, Affine) {
936        let view = self.view;
937        if depth > 0 && view.determinant().abs() > 1e-12 && view.is_finite() {
938            (view, view.inverse() * m)
939        } else {
940            (m, Affine::IDENTITY)
941        }
942    }
943
944    /// The size and transform a shape's text is set in: inside a group whose child coordinates
945    /// scale the shape, the shape's size in slide points and `m` with that scale taken out, so
946    /// insets and font sizes stay in points; otherwise `w`, `h` and `m` themselves.
947    fn text_space(&self, m: Affine, depth: usize, w: f64, h: f64) -> (f64, f64, Affine) {
948        let (_, rel) = self.line_space(m, depth);
949        let c = rel.as_coeffs();
950        let (sx, sy) = (c[0].hypot(c[1]), c[2].hypot(c[3]));
951        let ok = |k: f64| k.is_finite() && k > 1e-9;
952        if depth == 0 || !ok(sx) || !ok(sy) || ((sx - 1.0).abs() < 1e-9 && (sy - 1.0).abs() < 1e-9)
953        {
954            return (w, h, m);
955        }
956        let (tw, th) = (w * sx, h * sy);
957        if !tw.is_finite() || !th.is_finite() {
958            return (w, h, m);
959        }
960        (tw, th, m * Affine::scale_non_uniform(1.0 / sx, 1.0 / sy))
961    }
962
963    fn shape_at(
964        &mut self,
965        ctx: &mut RenderContext,
966        f: &Frame,
967        rctx: &Ctx,
968        s: &Shape,
969        parent: Affine,
970        depth: usize,
971    ) {
972        if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) {
973            return;
974        }
975        // Footer placeholders on layouts/masters show only on slides that include them.
976        let st = f.opts.state.map(|g| g(s.id)).unwrap_or_default();
977        if !st.visible || st.opacity <= 0.001 {
978            return;
979        }
980        let x = resolve::xfrm(rctx, s);
981        if !x.is_finite() {
982            return;
983        }
984        let mut m = parent * x.affine();
985        if st.offset != Vec2::ZERO || st.scale != (1.0, 1.0) || st.rotate != 0.0 {
986            let c = Point::new(x.w / 2.0, x.h / 2.0);
987            let around = Affine::translate(c.to_vec2())
988                * Affine::rotate(st.rotate.to_radians())
989                * Affine::scale_non_uniform(st.scale.0, st.scale.1)
990                * Affine::translate(-c.to_vec2());
991            m = parent * Affine::translate(st.offset) * x.affine() * around;
992        }
993        let layered = st.opacity < 0.999 || st.clip.is_some();
994        if layered {
995            ctx.set_transform(m);
996            let clip = st
997                .clip
998                .map(|[l, t, r, b]| Rect::new(l * x.w, t * x.h, r * x.w, b * x.h).to_path(0.1));
999            ctx.push_layer(
1000                clip.as_ref(),
1001                None,
1002                Some(st.opacity.clamp(0.0, 1.0) as f32),
1003                None,
1004                None,
1005            );
1006        }
1007        match &s.kind {
1008            ShapeKind::Group { children, child } => {
1009                let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
1010                let gm = parent * deckcraft_geom::group_child_affine(&x, ch);
1011                for c in children {
1012                    self.shape(ctx, f, rctx, c, gm, depth + 1);
1013                }
1014            }
1015            ShapeKind::Table(t) => {
1016                // Rows grow with their text: what the cells cover, borders included.
1017                let cells = table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields);
1018                self.track(
1019                    m,
1020                    cells
1021                        .unwrap_or(Rect::new(0.0, 0.0, x.w, x.h))
1022                        .inflate(2.0, 2.0),
1023                );
1024            }
1025            ShapeKind::Chart(c) => {
1026                self.track(m, Rect::new(0.0, 0.0, x.w, x.h));
1027                chart::draw(ctx, rctx, c, m, x.w, x.h)
1028            }
1029            ShapeKind::Ink { strokes } => {
1030                for sk in strokes {
1031                    let w = if sk.width.is_finite() {
1032                        sk.width.abs()
1033                    } else {
1034                        0.0
1035                    };
1036                    for (px, py, _) in &sk.points {
1037                        self.track(parent, Rect::new(*px, *py, *px, *py).inflate(w, w));
1038                    }
1039                    let mut bp = BezPath::new();
1040                    for (i, (px, py, _)) in sk.points.iter().enumerate() {
1041                        if i == 0 {
1042                            bp.move_to((*px, *py));
1043                        } else {
1044                            bp.line_to((*px, *py));
1045                        }
1046                    }
1047                    let (base, rel) = self.line_space(parent, depth);
1048                    bp.apply_affine(rel);
1049                    ctx.set_transform(base);
1050                    ctx.set_paint(color(sk.color, if sk.highlighter { 0.4 } else { 1.0 }));
1051                    ctx.set_stroke(
1052                        kurbo::Stroke::new(sk.width.max(0.1))
1053                            .with_caps(kurbo::Cap::Round)
1054                            .with_join(kurbo::Join::Round),
1055                    );
1056                    ctx.stroke_path(&bp);
1057                }
1058            }
1059            _ => self.geometry_shape(ctx, f, rctx, s, m, x.w, x.h, &st, depth),
1060        }
1061        if layered {
1062            ctx.pop_layer();
1063        }
1064    }
1065
1066    #[allow(clippy::too_many_arguments)]
1067    fn geometry_shape(
1068        &mut self,
1069        ctx: &mut RenderContext,
1070        f: &Frame,
1071        rctx: &Ctx,
1072        s: &Shape,
1073        m: Affine,
1074        w: f64,
1075        h: f64,
1076        st: &ShapeState,
1077        depth: usize,
1078    ) {
1079        let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) {
1080            Some(pm) => morph_path::geometry(pm, s, w, h),
1081            None => (shape_geometry(s, w, h), Vec::new()),
1082        };
1083        let fade = |i: usize| {
1084            fades
1085                .get(i)
1086                .copied()
1087                .unwrap_or(deckcraft_geom::morph::Fade {
1088                    fill: 1.0,
1089                    stroke: 1.0,
1090                })
1091        };
1092        let (fill, fill_ph) = resolve::fill(rctx, s);
1093        let (line, line_ph) = resolve::line(rctx, s);
1094        let (effects, fx_ph) = resolve::effects(rctx, s);
1095        let reach = extent::spill(Some(&line), effects.as_ref(), w, h);
1096        self.track(m, Rect::new(0.0, 0.0, w, h).inflate(reach, reach));
1097        let is_pic = matches!(s.kind, ShapeKind::Picture { .. });
1098        let outline = geo.outline();
1099        // Placeholders in the editor: dashed outline and prompt, nothing else when empty.
1100        let empty_ph = s.ph.is_some()
1101            && s.text.as_ref().is_none_or(TextBody::is_empty)
1102            && !is_pic
1103            && !matches!(s.kind, ShapeKind::Media(_));
1104        let fill = if let Some(t) = st.tint {
1105            Some(Fill::solid(deckcraft_model::ColorRef::rgb(t)))
1106        } else {
1107            fill
1108        };
1109        // Effects below the shape.
1110        if let Some(e) = &effects {
1111            // A picture (or picture fill) casts its shadow from its pixels, not its frame.
1112            let content = match (&s.kind, &fill) {
1113                (ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => {
1114                    Some(paint::resolved(rctx, pf, None).into_owned())
1115                }
1116                _ => None,
1117            };
1118            self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, content.as_ref(), m, w, h);
1119        }
1120        let soft = effects
1121            .as_ref()
1122            .and_then(|e| e.soft_edge)
1123            .filter(|r| *r > 0.0);
1124        if let Some(r) = soft {
1125            ctx.set_transform(Affine::IDENTITY);
1126            ctx.push_layer(None, None, None, None, None);
1127            let _ = r;
1128        }
1129        // Fill.
1130        match &s.kind {
1131            ShapeKind::Picture { fill: pf } => {
1132                ctx.set_transform(m);
1133                let bounds = Rect::new(0.0, 0.0, w, h);
1134                ctx.push_clip_layer(&outline);
1135                let t = f.opts.gif_times.iter().find(|g| g.0 == s.id).map(|g| g.1);
1136                paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t);
1137                ctx.pop_layer();
1138            }
1139            // An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its
1140            // preview picture, stretched over the frame, as PowerPoint draws it.
1141            ShapeKind::Opaque {
1142                preview: Some(media),
1143                ..
1144            } => {
1145                ctx.set_transform(m);
1146                let pf = deckcraft_model::style::PictureFill {
1147                    media: *media,
1148                    ..Default::default()
1149                };
1150                paint::picture(ctx, f.pres, &pf, Rect::new(0.0, 0.0, w, h), m, None);
1151            }
1152            ShapeKind::Media(mc) => {
1153                ctx.set_transform(m);
1154                let bounds = Rect::new(0.0, 0.0, w, h);
1155                if let Some(p) = mc.poster {
1156                    let pf = deckcraft_model::style::PictureFill {
1157                        media: p,
1158                        ..Default::default()
1159                    };
1160                    paint::picture(ctx, f.pres, &pf, bounds, m, None);
1161                } else if mc.video {
1162                    ctx.set_paint(peniko::Color::from_rgba8(20, 20, 24, 255));
1163                    ctx.fill_rect(&bounds);
1164                }
1165                if !mc.video || mc.poster.is_none() {
1166                    paint::media_glyph(ctx, mc.video, bounds);
1167                }
1168            }
1169            _ => {
1170                if let Some(fl) = &fill
1171                    && !(empty_ph && f.opts.edit && s.fill.is_none())
1172                {
1173                    for (i, sp) in geo.paths.iter().enumerate() {
1174                        if sp.fill == FillMode::None || fade(i).fill <= 0.0 {
1175                            continue;
1176                        }
1177                        ctx.set_transform(m);
1178                        ctx.set_fill_rule(if sp.even_odd {
1179                            peniko::Fill::EvenOdd
1180                        } else {
1181                            peniko::Fill::NonZero
1182                        });
1183                        match (fl, &self.bg) {
1184                            // `useBgFill`: the slide's background, laid out on the slide (not the
1185                            // shape), shows through the shape.
1186                            (Fill::Background, Some((bg, ph, view))) => {
1187                                let slide = Rect::new(
1188                                    0.0,
1189                                    0.0,
1190                                    f.pres.slide_size.width,
1191                                    f.pres.slide_size.height,
1192                                );
1193                                let path = (view.inverse() * m) * sp.path.clone();
1194                                paint::fill_path(
1195                                    ctx,
1196                                    rctx,
1197                                    bg,
1198                                    *ph,
1199                                    &path,
1200                                    slide,
1201                                    *view,
1202                                    sp.fill,
1203                                    fade(i).fill,
1204                                    f.pres,
1205                                );
1206                            }
1207                            _ => paint::fill_path(
1208                                ctx,
1209                                rctx,
1210                                fl,
1211                                fill_ph,
1212                                &sp.path,
1213                                Rect::new(0.0, 0.0, w, h),
1214                                m,
1215                                sp.fill,
1216                                fade(i).fill,
1217                                f.pres,
1218                            ),
1219                        }
1220                        ctx.set_fill_rule(peniko::Fill::NonZero);
1221                    }
1222                }
1223            }
1224        }
1225        // `a:fillOverlay`: a fill blended over the shape's fill (PowerPoint's "Flow" themes multiply
1226        // a gradient over a tiled picture), inside the filled outline, under the line.
1227        if let Some(ov) = effects.as_ref().and_then(|e| e.fill_overlay.as_ref())
1228            && fill.as_ref().is_some_and(|f| !f.is_none())
1229        {
1230            let mix = match ov.blend.as_str() {
1231                "mult" => peniko::Mix::Multiply,
1232                "screen" => peniko::Mix::Screen,
1233                "darken" => peniko::Mix::Darken,
1234                "lighten" => peniko::Mix::Lighten,
1235                _ => peniko::Mix::Normal,
1236            };
1237            for (i, sp) in geo
1238                .paths
1239                .iter()
1240                .enumerate()
1241                .filter(|(i, sp)| sp.fill != FillMode::None && fade(*i).fill > 0.0)
1242            {
1243                ctx.set_transform(m);
1244                ctx.push_layer(
1245                    Some(&sp.path),
1246                    Some(peniko::BlendMode::new(mix, peniko::Compose::SrcOver)),
1247                    None,
1248                    None,
1249                    None,
1250                );
1251                paint::fill_path(
1252                    ctx,
1253                    rctx,
1254                    &ov.fill,
1255                    fx_ph,
1256                    &sp.path,
1257                    Rect::new(0.0, 0.0, w, h),
1258                    m,
1259                    FillMode::Norm,
1260                    fade(i).fill,
1261                    f.pres,
1262                );
1263                ctx.pop_layer();
1264            }
1265        }
1266        // Outline.
1267        if line.fill.as_ref().is_some_and(|f| !f.is_none()) {
1268            let lw = line.width.unwrap_or(0.75).max(0.0);
1269            let (base, rel) = self.line_space(m, depth);
1270            for (i, sp) in geo
1271                .paths
1272                .iter()
1273                .enumerate()
1274                .filter(|(i, p)| p.stroke && fade(*i).stroke > 0.0)
1275            {
1276                let path = if rel == Affine::IDENTITY {
1277                    std::borrow::Cow::Borrowed(&sp.path)
1278                } else {
1279                    let mut moved = sp.path.clone();
1280                    moved.apply_affine(rel);
1281                    std::borrow::Cow::Owned(moved)
1282                };
1283                stroke(ctx, rctx, &line, line_ph, &path, base, lw, fade(i).stroke);
1284            }
1285            if s.is_line() || geo.is_open() {
1286                arrowheads(ctx, rctx, &line, line_ph, &geo, rel, base, lw);
1287            }
1288        }
1289        if let Some(r) = soft {
1290            // Feather: the shape (fill and line) fades to nothing at its outline. Measured on
1291            // PowerPoint's export: the mask is the outline shrunk by 0.85 r, blurred by a third of
1292            // r, so it's half opaque 0.85 r inside the edge and transparent at the edge itself.
1293            ctx.set_transform(m);
1294            ctx.push_layer(
1295                None,
1296                Some(peniko::BlendMode::new(
1297                    peniko::Mix::Normal,
1298                    peniko::Compose::DestIn,
1299                )),
1300                None,
1301                None,
1302                Some(blur(r / 3.0 * scale_of(m))),
1303            );
1304            ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255));
1305            ctx.fill_path(&outline);
1306            ctx.push_blend_layer(peniko::BlendMode::new(
1307                peniko::Mix::Normal,
1308                peniko::Compose::DestOut,
1309            ));
1310            ctx.set_stroke(kurbo::Stroke::new(r * 1.7).with_join(kurbo::Join::Round));
1311            ctx.stroke_path(&outline);
1312            ctx.pop_layer();
1313            ctx.pop_layer();
1314            ctx.pop_layer();
1315        }
1316        // Inner shadow.
1317        if let Some(e) = &effects
1318            && let Some(sh) = &e.inner_shadow
1319        {
1320            ctx.set_transform(m);
1321            ctx.push_clip_layer(&outline);
1322            let c = rctx.color(&sh.color, fx_ph);
1323            let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist;
1324            // As PowerPoint draws it (measured on its export): the shadow falls inside along the
1325            // edges it points to (45 degrees: the bottom and right), blurred by a third of the
1326            // blur radius.
1327            ctx.push_layer(
1328                None,
1329                None,
1330                None,
1331                None,
1332                Some(blur(sh.blur / 3.0 * scale_of(m))),
1333            );
1334            ctx.set_paint(color(c, 1.0));
1335            let mut ring = Rect::new(0.0, 0.0, w, h)
1336                .inflate(w.max(h), w.max(h))
1337                .to_path(0.1);
1338            let mut hole = outline.clone();
1339            hole.apply_affine(Affine::translate(-d));
1340            ring.extend(hole.iter());
1341            ctx.set_fill_rule(peniko::Fill::EvenOdd);
1342            ctx.fill_path(&ring);
1343            ctx.set_fill_rule(peniko::Fill::NonZero);
1344            ctx.pop_layer();
1345            ctx.pop_layer();
1346        }
1347        // Text (or the placeholder prompt in the editor).
1348        let skip_text = f.opts.skip_text == Some(s.id);
1349        if let Some(body) = &s.text
1350            && !skip_text
1351        {
1352            // Text in a group is set at the shape's size on the slide, in points: a group's
1353            // child-coordinate scale sizes and places the box, never the type (#233's twin).
1354            let (tw, th, m) = self.text_space(m, depth, w, h);
1355            let text_geo;
1356            let geo = if (tw, th) == (w, h) {
1357                &geo
1358            } else {
1359                text_geo = shape_geometry(s, tw, th);
1360                &text_geo
1361            };
1362            let tr = geo.text_rect;
1363            if !body.is_empty() {
1364                let l = deckcraft_text::layout(
1365                    rctx,
1366                    s,
1367                    body,
1368                    &Opts {
1369                        rect: tr,
1370                        fields: &f.fields,
1371                        prompt_color: None,
1372                        no_shrink: false,
1373                    },
1374                );
1375                let tm = text_transform(m, tr, l.rotation);
1376                for b in l.lines.iter().filter_map(line_bounds) {
1377                    // Glyphs reach a little past their carets and line box (italics, swashes).
1378                    let pad = b.height() * 0.3;
1379                    self.track(tm, b.inflate(pad, pad));
1380                }
1381                match text_warp(rctx, s, tr).filter(|_| l.rotation == 0.0) {
1382                    Some(w) => warp::draw(ctx, &l, tr, m, &st.paras, &w),
1383                    None => draw_layout_with(ctx, &l, tr, m, &st.paras),
1384                }
1385            } else if f.opts.edit && s.ph.is_some() {
1386                let kind = s.ph_type().unwrap_or(PhType::Body);
1387                let prompt_body = prompt_body(rctx, s, kind);
1388                if !prompt_body.is_empty() {
1389                    let grey = Rgba::rgb(0x59, 0x59, 0x59);
1390                    draw_text(ctx, rctx, s, &prompt_body, tr, m, &f.fields, Some(grey));
1391                }
1392            }
1393        }
1394        if empty_ph && f.opts.edit {
1395            // Dashed placeholder outline.
1396            ctx.set_transform(m);
1397            let px = 1.0 / scale_of(m).max(1e-6);
1398            ctx.set_paint(peniko::Color::from_rgba8(0xA6, 0xA6, 0xA6, 255));
1399            ctx.set_stroke(kurbo::Stroke::new(px).with_dashes(0.0, [3.0 * px, 2.0 * px]));
1400            ctx.stroke_path(&Rect::new(0.0, 0.0, w, h).to_path(0.1));
1401            if matches!(
1402                s.ph_type(),
1403                Some(
1404                    PhType::Picture
1405                        | PhType::Chart
1406                        | PhType::Table
1407                        | PhType::Media
1408                        | PhType::Diagram
1409                )
1410            ) {
1411                paint::placeholder_icon(
1412                    ctx,
1413                    s.ph_type().unwrap_or(PhType::Picture),
1414                    Rect::new(0.0, 0.0, w, h),
1415                );
1416            }
1417        }
1418    }
1419
1420    fn effects_below(
1421        &mut self,
1422        ctx: &mut RenderContext,
1423        rctx: &Ctx,
1424        e: &Effects,
1425        ph: Option<Rgba>,
1426        geo: &Geometry,
1427        fill: &Option<Fill>,
1428        content: Option<&deckcraft_model::style::PictureFill>,
1429        m: Affine,
1430        w: f64,
1431        h: f64,
1432    ) {
1433        let silhouette = {
1434            let mut p = BezPath::new();
1435            for sp in &geo.paths {
1436                if sp.fill != FillMode::None || fill.is_none() {
1437                    p.extend(sp.path.iter());
1438                }
1439            }
1440            if p.elements().is_empty() {
1441                geo.outline()
1442            } else {
1443                p
1444            }
1445        };
1446        let k = scale_of(m);
1447        if let Some(sh) = &e.outer_shadow {
1448            let c = rctx.color(&sh.color, ph);
1449            let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist;
1450            let c0 = Point::new(w / 2.0, h / 2.0);
1451            let local = Affine::translate(c0.to_vec2())
1452                * Affine::new([
1453                    sh.sx,
1454                    sh.ky.to_radians().tan(),
1455                    sh.kx.to_radians().tan(),
1456                    sh.sy,
1457                    0.0,
1458                    0.0,
1459                ])
1460                * Affine::translate(-c0.to_vec2());
1461            // Shadow offset is in slide space (doesn't rotate with the shape unless asked).
1462            let sm = if sh.rotate_with_shape {
1463                m * Affine::translate(d) * local
1464            } else {
1465                pre_translate(m, d * k) * local
1466            };
1467            ctx.set_transform(Affine::IDENTITY);
1468            // Blurred by a third of the blur radius, as PowerPoint's export shows.
1469            ctx.push_layer(
1470                None,
1471                None,
1472                Some((c.a as f32) / 255.0),
1473                None,
1474                Some(blur(sh.blur / 3.0 * k)),
1475            );
1476            ctx.set_transform(sm);
1477            match content {
1478                // As PowerPoint draws it: the shadow is the picture's own coverage (a logo on a
1479                // transparent background casts the logo's shadow): the picture, clipped to the
1480                // shape, recoloured to the shadow colour.
1481                Some(pf) => {
1482                    ctx.push_clip_layer(&silhouette);
1483                    paint::picture(ctx, rctx.pres, pf, Rect::new(0.0, 0.0, w, h), sm, None);
1484                    ctx.push_layer(
1485                        None,
1486                        Some(peniko::BlendMode::new(
1487                            peniko::Mix::Normal,
1488                            peniko::Compose::SrcIn,
1489                        )),
1490                        None,
1491                        None,
1492                        None,
1493                    );
1494                    ctx.set_paint(color(c.with_alpha(255), 1.0));
1495                    ctx.fill_rect(&Rect::new(0.0, 0.0, w, h).inflate(1.0, 1.0));
1496                    ctx.pop_layer();
1497                    ctx.pop_layer();
1498                }
1499                None => {
1500                    ctx.set_paint(color(c.with_alpha(255), 1.0));
1501                    ctx.fill_path(&silhouette);
1502                }
1503            }
1504            ctx.pop_layer();
1505        }
1506        if let Some(g) = &e.glow {
1507            let c = rctx.color(&g.color, ph);
1508            ctx.set_transform(Affine::IDENTITY);
1509            // Measured on PowerPoint's export: a glow of radius r is half strength about half of r
1510            // out from the outline and gone by r.
1511            ctx.push_layer(
1512                None,
1513                None,
1514                Some((c.a as f32) / 255.0),
1515                None,
1516                Some(blur(g.radius * 0.17 * k)),
1517            );
1518            ctx.set_transform(m);
1519            ctx.set_paint(color(c.with_alpha(255), 1.0));
1520            ctx.set_stroke(
1521                kurbo::Stroke::new(g.radius.max(0.0) * 0.95).with_join(kurbo::Join::Round),
1522            );
1523            ctx.stroke_path(&silhouette);
1524            ctx.fill_path(&silhouette);
1525            ctx.pop_layer();
1526        }
1527        if let Some(rf) = &e.reflection {
1528            // Mirror below the shape, fading out.
1529            let mm = m
1530                * Affine::translate((0.0, 2.0 * h + rf.dist))
1531                * Affine::scale_non_uniform(1.0, -1.0);
1532            ctx.set_transform(Affine::IDENTITY);
1533            ctx.push_layer(
1534                None,
1535                None,
1536                Some(rf.start_alpha.clamp(0.0, 1.0) as f32 * 0.6),
1537                None,
1538                Some(blur(rf.blur * 0.5 * k)),
1539            );
1540            ctx.set_transform(mm);
1541            if let Some(fl) = fill {
1542                paint::fill_path(
1543                    ctx,
1544                    rctx,
1545                    fl,
1546                    ph,
1547                    &silhouette,
1548                    Rect::new(0.0, 0.0, w, h),
1549                    mm,
1550                    FillMode::Norm,
1551                    1.0,
1552                    rctx.pres,
1553                );
1554            }
1555            // Fade mask.
1556            ctx.push_layer(
1557                None,
1558                Some(peniko::BlendMode::new(
1559                    peniko::Mix::Normal,
1560                    peniko::Compose::DestIn,
1561                )),
1562                None,
1563                None,
1564                None,
1565            );
1566            let grad = peniko::Gradient::new_linear(
1567                Point::new(0.0, h),
1568                Point::new(0.0, h * (1.0 - rf.end_pos.clamp(0.01, 1.0))),
1569            )
1570            .with_stops(
1571                [
1572                    (0.0, peniko::Color::from_rgba8(0, 0, 0, 255)),
1573                    (1.0, peniko::Color::from_rgba8(0, 0, 0, 0)),
1574                ]
1575                .as_slice(),
1576            );
1577            ctx.set_paint(grad);
1578            ctx.fill_rect(&Rect::new(0.0, 0.0, w, h).inflate(4.0, 4.0));
1579            ctx.pop_layer();
1580            ctx.pop_layer();
1581        }
1582    }
1583}
1584
1585fn pre_translate(m: Affine, d: Vec2) -> Affine {
1586    Affine::translate(d) * m
1587}
1588
1589pub(crate) fn scale_of(m: Affine) -> f64 {
1590    m.determinant().abs().sqrt()
1591}
1592
1593fn blur(sigma: f64) -> Filter {
1594    Filter::from_primitive(FilterPrimitive::GaussianBlur {
1595        std_deviation: sigma.clamp(0.0, 500.0) as f32,
1596        edge_mode: EdgeMode::None,
1597    })
1598}
1599
1600fn stroke(
1601    ctx: &mut RenderContext,
1602    rctx: &Ctx,
1603    line: &Line,
1604    ph: Option<Rgba>,
1605    path: &BezPath,
1606    m: Affine,
1607    lw: f64,
1608    alpha: f64,
1609) {
1610    use deckcraft_model::style::{LineCap, LineJoin};
1611    let cap = match line.cap.unwrap_or_default() {
1612        LineCap::Flat => kurbo::Cap::Butt,
1613        LineCap::Round => kurbo::Cap::Round,
1614        LineCap::Square => kurbo::Cap::Square,
1615    };
1616    let join = match line.join.unwrap_or_default() {
1617        LineJoin::Round => kurbo::Join::Round,
1618        LineJoin::Bevel => kurbo::Join::Bevel,
1619        LineJoin::Miter => kurbo::Join::Miter,
1620    };
1621    // Hairlines stay visible at any zoom.
1622    let min_w = 0.5 / scale_of(m).max(1e-6);
1623    let mut sk = kurbo::Stroke::new(lw.max(min_w))
1624        .with_caps(cap)
1625        .with_join(join);
1626    if let Some(d) = &line.dash {
1627        let pat: Vec<f64> = d.pattern().iter().map(|v| v * lw.max(0.75)).collect();
1628        if !pat.is_empty() && pat.iter().all(|v| *v > 0.0) {
1629            sk = sk.with_dashes(0.0, pat);
1630        }
1631    }
1632    ctx.set_transform(m);
1633    match &line.fill {
1634        Some(Fill::Solid { color: c }) => {
1635            ctx.set_paint(color(rctx.color(c, ph), alpha));
1636        }
1637        Some(Fill::Gradient(g)) => {
1638            let c = g
1639                .stops
1640                .first()
1641                .map(|s| rctx.color(&s.color, ph))
1642                .unwrap_or(Rgba::BLACK);
1643            ctx.set_paint(color(c, alpha));
1644        }
1645        _ => ctx.set_paint(color(Rgba::BLACK, alpha)),
1646    }
1647    // Compound lines: each band stroked along the path moved to its place across the width.
1648    let compound = line.compound.unwrap_or_default();
1649    if compound != deckcraft_model::style::Compound::Single && lw * scale_of(m) >= 1.5 {
1650        let tolerance = 0.1 / scale_of(m).max(1e-6);
1651        for &(a, b) in compound::bands(compound) {
1652            let d = (-0.5 + (a + b) / 2.0) * lw;
1653            let mut band = sk.clone();
1654            band.width = (b - a) * lw;
1655            ctx.set_stroke(band);
1656            ctx.stroke_path(&compound::offset(path, d, tolerance));
1657        }
1658        return;
1659    }
1660    ctx.set_stroke(sk);
1661    ctx.stroke_path(path);
1662}
1663
1664fn end_points(path: &BezPath) -> Option<((Point, Point), (Point, Point))> {
1665    let els: Vec<PathEl> = path.elements().to_vec();
1666    let mut pts: Vec<Point> = vec![];
1667    for e in &els {
1668        match e {
1669            PathEl::MoveTo(p) | PathEl::LineTo(p) => pts.push(*p),
1670            PathEl::QuadTo(a, b) => {
1671                pts.push(*a);
1672                pts.push(*b);
1673            }
1674            PathEl::CurveTo(a, b, c) => {
1675                pts.push(*a);
1676                pts.push(*b);
1677                pts.push(*c);
1678            }
1679            PathEl::ClosePath => {}
1680        }
1681    }
1682    if pts.len() < 2 {
1683        return None;
1684    }
1685    let first = *pts.first()?;
1686    let second = pts
1687        .iter()
1688        .skip(1)
1689        .find(|p| (**p - first).hypot() > 1e-6)
1690        .copied()
1691        .unwrap_or(*pts.get(1)?);
1692    let last = *pts.last()?;
1693    let prev = pts
1694        .iter()
1695        .rev()
1696        .skip(1)
1697        .find(|p| (**p - last).hypot() > 1e-6)
1698        .copied()
1699        .unwrap_or(*pts.get(pts.len() - 2)?);
1700    Some(((first, second), (last, prev)))
1701}
1702
1703#[allow(clippy::too_many_arguments)]
1704fn arrowheads(
1705    ctx: &mut RenderContext,
1706    rctx: &Ctx,
1707    line: &Line,
1708    ph: Option<Rgba>,
1709    geo: &Geometry,
1710    rel: Affine,
1711    m: Affine,
1712    lw: f64,
1713) {
1714    let Some(sp) = geo.paths.iter().find(|p| p.stroke) else {
1715        return;
1716    };
1717    let Some(((a, a2), (b, b2))) = end_points(&sp.path) else {
1718        return;
1719    };
1720    // The outline's ends in the space the arrowheads are sized and drawn in.
1721    let (a, a2, b, b2) = (rel * a, rel * a2, rel * b, rel * b2);
1722    let col = match &line.fill {
1723        Some(Fill::Solid { color: c }) => rctx.color(c, ph),
1724        _ => Rgba::BLACK,
1725    };
1726    let lw = lw.max(0.75);
1727    for (end, tip, from) in [(&line.head, a, a2), (&line.tail, b, b2)] {
1728        let Some(e) = end else { continue };
1729        if e.kind == "none" {
1730            continue;
1731        }
1732        let sz = |s: &str| match s {
1733            "sm" => 2.0,
1734            "lg" => 5.0,
1735            _ => 3.0,
1736        };
1737        let (wl, ll) = (sz(&e.w) * lw, sz(&e.len) * lw);
1738        let dir = (tip - from).normalize();
1739        if !dir.x.is_finite() {
1740            continue;
1741        }
1742        let n = Vec2::new(-dir.y, dir.x);
1743        let base = tip - dir * ll;
1744        let mut p = BezPath::new();
1745        match e.kind.as_str() {
1746            "oval" => {
1747                let c = kurbo::Ellipse::new(tip, (ll / 2.0, wl / 2.0), dir.y.atan2(dir.x));
1748                p = c.to_path(0.05);
1749            }
1750            "diamond" => {
1751                p.move_to(tip + dir * ll / 2.0);
1752                p.line_to(tip + n * wl / 2.0);
1753                p.line_to(tip - dir * ll / 2.0);
1754                p.line_to(tip - n * wl / 2.0);
1755                p.close_path();
1756            }
1757            "stealth" => {
1758                p.move_to(tip);
1759                p.line_to(base + n * wl / 2.0);
1760                p.line_to(tip - dir * ll * 0.6);
1761                p.line_to(base - n * wl / 2.0);
1762                p.close_path();
1763            }
1764            "arrow" => {
1765                let mut o = BezPath::new();
1766                o.move_to(base + n * wl / 2.0);
1767                o.line_to(tip);
1768                o.line_to(base - n * wl / 2.0);
1769                ctx.set_transform(m);
1770                ctx.set_paint(color(col, 1.0));
1771                ctx.set_stroke(kurbo::Stroke::new(lw).with_join(kurbo::Join::Miter));
1772                ctx.stroke_path(&o);
1773                continue;
1774            }
1775            _ => {
1776                p.move_to(tip);
1777                p.line_to(base + n * wl / 2.0);
1778                p.line_to(base - n * wl / 2.0);
1779                p.close_path();
1780            }
1781        }
1782        ctx.set_transform(m);
1783        ctx.set_paint(color(col, 1.0));
1784        ctx.fill_path(&p);
1785    }
1786}
1787
1788/// The prompt shown in an empty placeholder, formatted like its content would be.
1789fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType) -> TextBody {
1790    // Layout/master prompt text when the layout supplies a custom one.
1791    let (lp, _) = resolve::parents(rctx, s);
1792    let custom = lp
1793        .and_then(|l| {
1794            l.ph.as_ref()
1795                .filter(|p| p.has_custom_prompt)
1796                .and(l.text.as_ref())
1797        })
1798        .filter(|t| !t.is_empty());
1799    if let Some(t) = custom {
1800        return t.clone();
1801    }
1802    if matches!(rctx.owner, resolve::Owner::Layout | resolve::Owner::Master) {
1803        return TextBody::default();
1804    }
1805    let txt = kind.prompt();
1806    if txt.is_empty() || kind.is_footer_kind() {
1807        return TextBody::default();
1808    }
1809    let mut t = TextBody::from_text(txt);
1810    if matches!(
1811        kind,
1812        PhType::Picture
1813            | PhType::Chart
1814            | PhType::Table
1815            | PhType::Media
1816            | PhType::Diagram
1817            | PhType::ClipArt
1818    ) {
1819        t.body.anchor = Some(deckcraft_model::text::Anchor::Bottom);
1820        if let Some(p) = t.paragraphs.first_mut() {
1821            p.props.align = Some(deckcraft_model::text::Align::Center);
1822            p.props.bullet = Some(deckcraft_model::text::Bullet::None);
1823            p.props.margin_left = Some(0.0);
1824            p.props.indent = Some(0.0);
1825            for r in &mut p.runs {
1826                r.props.size = Some(14.0);
1827            }
1828        }
1829    }
1830    t
1831}
1832
1833thread_local! {
1834    static GLYPH_CACHE: std::cell::RefCell<HashMap<(u32, u32), Arc<BezPath>>> = std::cell::RefCell::new(HashMap::new());
1835}
1836
1837fn glyph(face: &deckcraft_fonts::FontFace, gid: u32) -> Arc<BezPath> {
1838    let key = (face.id(), gid);
1839    if let Some(p) = GLYPH_CACHE.with(|c| c.borrow().get(&key).cloned()) {
1840        return p;
1841    }
1842    let p = FontDb::global().outline(face, gid);
1843    GLYPH_CACHE.with(|c| {
1844        let mut c = c.borrow_mut();
1845        if c.len() > 200_000 {
1846            c.clear();
1847        }
1848        c.insert(key, p.clone());
1849    });
1850    p
1851}
1852
1853/// Draw a text body into shape space `m` within the text rect.
1854pub fn draw_text(
1855    ctx: &mut RenderContext,
1856    rctx: &Ctx,
1857    s: &Shape,
1858    body: &TextBody,
1859    tr: Rect,
1860    m: Affine,
1861    fields: &dyn Fields,
1862    prompt: Option<Rgba>,
1863) {
1864    let l = deckcraft_text::layout(
1865        rctx,
1866        s,
1867        body,
1868        &Opts {
1869            rect: tr,
1870            fields,
1871            prompt_color: prompt,
1872            no_shrink: false,
1873        },
1874    );
1875    draw_layout(ctx, &l, tr, m);
1876}
1877
1878/// Where text laid out in text rect `tr` is drawn, for a shape drawn with `m` (shape-local →
1879/// output): `m`, without mirroring, then the layout's `rotation` about the rect's centre.
1880///
1881/// Flips mirror a shape's geometry, never its text. When `m` reflects (flipH or flipV, the shape's
1882/// own or a group's) the text rect keeps its mirrored place and the text is mirrored back about
1883/// the rect's centre: a flipH shape's text stays readable and a flipV shape's text is turned 180°
1884/// (both flips: 180°), as in PowerPoint. Rendering, hit testing and the caret all use this.
1885pub fn text_transform(m: Affine, tr: Rect, rotation: f64) -> Affine {
1886    let c = tr.center().to_vec2();
1887    let mut t = m;
1888    if m.determinant() < 0.0 && c.is_finite() {
1889        t = t * Affine::translate(c) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate(-c);
1890    }
1891    if rotation != 0.0 && rotation.is_finite() && c.is_finite() {
1892        t = t
1893            * Affine::translate(c)
1894            * Affine::rotate(rotation.to_radians())
1895            * Affine::translate(-c);
1896    }
1897    t
1898}
1899
1900/// The WordArt warp of `s`'s text over its text rectangle `tr`, if it has one DeckCraft draws.
1901pub fn text_warp(rctx: &Ctx, s: &Shape, tr: Rect) -> Option<deckcraft_geom::warp::Warp> {
1902    let bp = resolve::body(rctx, s);
1903    let name = bp.warp.as_deref()?;
1904    deckcraft_geom::warp::warp(name, &bp.warp_adj, tr)
1905}
1906
1907/// Draw an already laid-out text block.
1908pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine) {
1909    draw_layout_with(ctx, l, tr, m, &[]);
1910}
1911
1912/// Draw a laid-out text block with per-paragraph animation states.
1913pub fn draw_layout_with(
1914    ctx: &mut RenderContext,
1915    l: &deckcraft_text::TextLayout,
1916    tr: Rect,
1917    m: Affine,
1918    paras: &[(usize, ParaState)],
1919) {
1920    let para = |i: usize| {
1921        paras
1922            .iter()
1923            .find(|(p, _)| *p == i)
1924            .map(|(_, s)| *s)
1925            .unwrap_or_default()
1926    };
1927    let m = text_transform(m, tr, l.rotation);
1928    let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| {
1929        let ps = para(d.para);
1930        if !ps.visible || ps.opacity <= 0.001 {
1931            return;
1932        }
1933        ctx.set_transform(m * Affine::translate(ps.offset));
1934        ctx.set_paint(color(d.color, ps.opacity));
1935        ctx.fill_rect(&d.rect);
1936    };
1937    for d in l.decos.iter().filter(|d| d.behind) {
1938        deco(ctx, d);
1939    }
1940    // Text shadows, then glows, under all the text.
1941    for pass in [TextEffect::Shadow, TextEffect::Glow] {
1942        for run in &l.runs {
1943            let ps = para(run.para);
1944            if ps.visible && ps.opacity > 0.001 {
1945                text_effect(ctx, run, pass, m * Affine::translate(ps.offset), ps.opacity);
1946            }
1947        }
1948    }
1949    for run in &l.runs {
1950        if (run.alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
1951            continue;
1952        }
1953        let ps = para(run.para);
1954        if !ps.visible || ps.opacity <= 0.001 {
1955            continue;
1956        }
1957        let m = m * Affine::translate(ps.offset);
1958        // A gradient fill starts over on every line and spans that line's text (from its top to
1959        // its bottom, from its first to its last character), as in PowerPoint.
1960        let mut grads: Vec<(usize, peniko::Gradient)> = Vec::new();
1961        for (gid, x, y) in &run.glyphs {
1962            let at = m * Affine::translate((*x, *y));
1963            let Some(g) = run.gradient.as_ref().filter(|g| !g.stops.is_empty()) else {
1964                draw_glyph(ctx, run, *gid, at, ps.opacity);
1965                continue;
1966            };
1967            let li = line_at(l, run.para, *y);
1968            if !grads.iter().any(|(i, _)| *i == li) {
1969                let scheme = deckcraft_color::ColorScheme {
1970                    name: String::new(),
1971                    colors: [Rgba::BLACK; 12],
1972                };
1973                let stops = g
1974                    .stops
1975                    .iter()
1976                    .map(|s| (s.pos, s.color.resolve(&scheme, None)))
1977                    .collect();
1978                let bounds = l.lines.get(li).and_then(line_bounds).unwrap_or(tr);
1979                grads.push((
1980                    li,
1981                    paint::gradient(&g.shape, stops, bounds, run.alpha * ps.opacity),
1982                ));
1983            }
1984            let fill = grads
1985                .iter()
1986                .find(|(i, _)| *i == li)
1987                .map(|(_, g)| (g, Affine::translate((-*x, -*y))));
1988            draw_glyph_with(ctx, run, *gid, at, ps.opacity, fill);
1989        }
1990    }
1991    for d in l.decos.iter().filter(|d| !d.behind) {
1992        deco(ctx, d);
1993    }
1994}
1995
1996/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
1997fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
1998    l.lines
1999        .iter()
2000        .enumerate()
2001        .filter(|(_, li)| li.para == para)
2002        .min_by(|a, b| {
2003            (a.1.baseline - y)
2004                .abs()
2005                .total_cmp(&(b.1.baseline - y).abs())
2006        })
2007        .map(|(i, _)| i)
2008        .unwrap_or(0)
2009}
2010
2011/// The box a line's text covers: its top and bottom, its first and last caret.
2012fn line_bounds(li: &deckcraft_text::LineInfo) -> Option<Rect> {
2013    let (x0, x1) = li
2014        .caret_x
2015        .iter()
2016        .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| {
2017            (a.min(*x), b.max(*x))
2018        });
2019    let r = Rect::new(x0, li.top, x1, li.bottom);
2020    (r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r)
2021}
2022
2023#[derive(Clone, Copy, PartialEq)]
2024enum TextEffect {
2025    Shadow,
2026    Glow,
2027}
2028
2029/// Draw `run`'s shadow or glow (if it has one) under its glyphs, placed by `m`.
2030fn text_effect(
2031    ctx: &mut RenderContext,
2032    run: &deckcraft_text::GlyphRun,
2033    which: TextEffect,
2034    m: Affine,
2035    opacity: f64,
2036) {
2037    let Some(fx) = run.effects else { return };
2038    let k = run.size / run.face.upem.max(1.0);
2039    let skew = if run.fake_italic {
2040        Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0])
2041    } else {
2042        Affine::IDENTITY
2043    };
2044    let px = scale_of(m);
2045    let (c, sigma, offset, grow) = match (which, fx.shadow, fx.glow) {
2046        (TextEffect::Shadow, Some(s), _) => {
2047            (s.color, s.blur * 0.5 * px, Vec2::new(s.dx, s.dy), 0.0)
2048        }
2049        // Measured on PowerPoint's export: a text glow of radius r is solid to about a third of r
2050        // past the glyph's edge and fades out by about two thirds of r.
2051        (TextEffect::Glow, _, Some((gc, r))) => (gc, r * 0.15 * px, Vec2::ZERO, r * 0.68),
2052        _ => return,
2053    };
2054    let alpha = (c.a as f64 / 255.0) * opacity;
2055    if alpha <= 0.0 {
2056        return;
2057    }
2058    ctx.set_transform(Affine::IDENTITY);
2059    ctx.push_layer(None, None, Some(alpha as f32), None, Some(blur(sigma)));
2060    ctx.set_paint(color(c.with_alpha(255), 1.0));
2061    for (gid, x, y) in &run.glyphs {
2062        let path = glyph(&run.face, *gid);
2063        if path.elements().is_empty() {
2064            continue;
2065        }
2066        // The offset is in slide space: a shadow falls the same way whatever the text's rotation.
2067        let at =
2068            pre_translate(m * Affine::translate((*x, *y)), offset * px) * skew * Affine::scale(k);
2069        ctx.set_transform(at);
2070        ctx.fill_path(&path);
2071        if grow > 0.0 {
2072            ctx.set_stroke(kurbo::Stroke::new(grow / k.max(1e-9)).with_join(kurbo::Join::Round));
2073            ctx.stroke_path(&path);
2074        }
2075    }
2076    ctx.pop_layer();
2077}
2078
2079/// Draw glyph `gid` of `run` with its origin (on the baseline) mapped by `at`, faded by `opacity`.
2080pub(crate) fn draw_glyph(
2081    ctx: &mut RenderContext,
2082    run: &deckcraft_text::GlyphRun,
2083    gid: u32,
2084    at: Affine,
2085    opacity: f64,
2086) {
2087    draw_glyph_with(ctx, run, gid, at, opacity, None);
2088}
2089
2090/// [`draw_glyph`], filled with `fill` (a gradient laid out in the text's space, and the map from
2091/// the glyph's origin to that space) instead of the run's colour.
2092fn draw_glyph_with(
2093    ctx: &mut RenderContext,
2094    run: &deckcraft_text::GlyphRun,
2095    gid: u32,
2096    at: Affine,
2097    opacity: f64,
2098    fill: Option<(&peniko::Gradient, Affine)>,
2099) {
2100    let alpha = run.alpha * opacity;
2101    if (alpha <= 0.0 || run.color.a == 0) && run.outline.is_none() {
2102        return;
2103    }
2104    let k = run.size / run.face.upem.max(1.0);
2105    // Colour glyphs (emoji) paint their own colours; an outline (text outline effect) still
2106    // strokes the plain glyph below.
2107    if alpha > 0.0
2108        && run.outline.is_none()
2109        && run.face.is_color()
2110        && colr::paint(ctx, &run.face, gid, at, k, run.color, alpha)
2111    {
2112        return;
2113    }
2114    let path = glyph(&run.face, gid);
2115    if path.elements().is_empty() {
2116        return;
2117    }
2118    let skew = if run.fake_italic {
2119        Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0])
2120    } else {
2121        Affine::IDENTITY
2122    };
2123    ctx.set_transform(at * skew * Affine::scale(k));
2124    if let Some((g, to_text)) = fill
2125        && alpha > 0.0
2126    {
2127        ctx.set_paint(g.clone());
2128        ctx.set_paint_transform((skew * Affine::scale(k)).inverse() * to_text);
2129        ctx.fill_path(&path);
2130        ctx.reset_paint_transform();
2131    } else if alpha > 0.0 && run.color.a > 0 {
2132        ctx.set_paint(color(run.color, alpha));
2133        ctx.fill_path(&path);
2134        if run.fake_bold {
2135            ctx.set_stroke(kurbo::Stroke::new(run.face.upem * 0.025).with_join(kurbo::Join::Round));
2136            ctx.stroke_path(&path);
2137        }
2138    }
2139    if let Some((oc, ow)) = run.outline {
2140        ctx.set_paint(color(oc, opacity));
2141        ctx.set_stroke(kurbo::Stroke::new(ow / k.max(1e-9)).with_join(kurbo::Join::Round));
2142        ctx.stroke_path(&path);
2143    }
2144}
2145
2146/// Render one shape alone onto a transparent canvas (drag previews, copy as picture).
2147pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) -> Option<Image> {
2148    let rctx = Ctx::for_slide(pres, slide)?;
2149    let s = slide.shape(id)?;
2150    let x = resolve::xfrm(&rctx, s);
2151    let b = x.bounds().inflate(8.0, 8.0);
2152    let w = (b.width() * scale).ceil().clamp(1.0, MAX_SIDE as f64) as u16;
2153    let h = (b.height() * scale).ceil().clamp(1.0, MAX_SIDE as f64) as u16;
2154    let mut ctx = RenderContext::new_with(
2155        w,
2156        h,
2157        vello_cpu::RenderSettings {
2158            num_threads: 0,
2159            ..Default::default()
2160        },
2161    );
2162    let opts = RenderOpts {
2163        scale,
2164        ..Default::default()
2165    };
2166    let f = Frame {
2167        pres,
2168        opts: &opts,
2169        fields: SlideFields::new(1, String::new(), &opts),
2170    };
2171    let view = Affine::scale(scale) * Affine::translate(-b.origin().to_vec2());
2172    RENDERER.with(|r| {
2173        let mut r = r.borrow_mut();
2174        r.shape(&mut ctx, &f, &rctx, s, view, 0);
2175        Some(r.finish(ctx, w, h))
2176    })
2177}
2178
2179#[cfg(test)]
2180mod tests;
2181
2182#[cfg(test)]
2183mod mt_tests {
2184    use super::*;
2185
2186    /// Regression: multithreaded rendering of a slide with shadows panicked inside vello_cpu.
2187    #[test]
2188    fn effects_render_with_threads_requested() {
2189        let mut s = deckcraft_model::Presentation::default();
2190        let sl = std::sync::Arc::make_mut(&mut s.slides[0]);
2191        let mut sh = deckcraft_model::Shape {
2192            id: ShapeId(900),
2193            xfrm: Some(deckcraft_geom::Xfrm::new(10.0, 10.0, 100.0, 100.0)),
2194            style: Some(deckcraft_model::ShapeStyle::accent(
2195                deckcraft_color::SchemeSlot::Accent1,
2196            )),
2197            ..Default::default()
2198        };
2199        sh.effects = Some(deckcraft_model::Effects {
2200            soft_edge: Some(5.0),
2201            ..Default::default()
2202        });
2203        sl.shapes.push(sh);
2204        assert!(needs_filters(&s, &s.slides[0]));
2205        let img = RENDERER.with(|r| {
2206            r.borrow_mut().slide(
2207                &s,
2208                &s.slides[0],
2209                0,
2210                &RenderOpts {
2211                    scale: 0.5,
2212                    threads: 4,
2213                    ..Default::default()
2214                },
2215            )
2216        });
2217        assert_eq!(img.width, 480);
2218        // The shape itself is drawn (not a blank fallback).
2219        assert_ne!(img.pixel(30, 30), [255, 255, 255, 255]);
2220    }
2221}