mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-29 13:06:56 -06:00
230 lines
10 KiB
Rust
230 lines
10 KiB
Rust
use super::opts::{RenderOpts, SlideFields};
|
|
use super::raster::{Image, MAX_SIDE};
|
|
use super::renderer::{Frame, RENDERER, Renderer};
|
|
use super::{Affine, Ctx, PathMorph, Presentation, Rect, RenderContext, Shape, ShapeId, Slide, TextMorph, resolve};
|
|
|
|
static PANICS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
|
|
fn note_panic() {
|
|
PANICS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
}
|
|
|
|
/// Render panics caught (and drawn as blank) since start; soak tests require it to stay 0.
|
|
pub fn panics_caught() -> u64 {
|
|
PANICS.load(std::sync::atomic::Ordering::Relaxed)
|
|
}
|
|
|
|
/// Does anything on this slide (or its layout/master graphics) use blur-based effects?
|
|
pub fn needs_filters(pres: &Presentation, slide: &Slide) -> bool {
|
|
let Some(rctx) = Ctx::for_slide(pres, slide) else { return false };
|
|
let mut any = false;
|
|
let mut check = |shapes: &[Shape]| {
|
|
deckcraft_model::walk(shapes, &mut |s, _| {
|
|
if any {
|
|
return;
|
|
}
|
|
let (e, _) = resolve::effects(&rctx, s);
|
|
if e.is_some_and(|e| !e.is_empty()) || text_has_effects(&rctx, s) {
|
|
any = true;
|
|
}
|
|
});
|
|
};
|
|
check(&slide.shapes);
|
|
if let Some(l) = rctx.layout {
|
|
check(&l.shapes);
|
|
}
|
|
check(&rctx.master.shapes);
|
|
any
|
|
}
|
|
|
|
/// Does the shape's text have a shadow or glow (on a run, in its list style, or in the master's
|
|
/// text styles it inherits)? Such text is blurred, which needs filters.
|
|
fn text_has_effects(rctx: &Ctx, s: &Shape) -> bool {
|
|
use deckcraft_model::text::{ListStyle, RunProps};
|
|
let fx = |r: &RunProps| r.shadow.as_ref().is_some_and(|s| !s.inner) || r.glow.as_ref().is_some_and(|g| g.radius > 0.0);
|
|
let styled = |l: &ListStyle| l.levels.iter().flatten().any(|lv| fx(&lv.run));
|
|
let Some(t) = &s.text else { return false };
|
|
t.paragraphs.iter().any(|p| p.runs.iter().any(|r| fx(&r.props)) || fx(&p.end_props))
|
|
|| styled(&t.list_style)
|
|
|| styled(&rctx.master.title_style)
|
|
|| styled(&rctx.master.body_style)
|
|
|| styled(&rctx.master.other_style)
|
|
}
|
|
|
|
/// Render slide `index` at `opts.scale` pixels per point. A panic inside the renderer becomes a
|
|
/// blank image (logged), never a crash.
|
|
pub fn render_slide(pres: &Presentation, index: usize, opts: &RenderOpts) -> Image {
|
|
render_slide_drawn(pres, index, opts).0
|
|
}
|
|
|
|
/// [`render_slide`], also returning how far the drawing reached, in slide space (at least the
|
|
/// slide; off it, what the shapes, their effects and their text drew, the parts outside the output
|
|
/// included). The editor grows its pasteboard to it ([`pasteboard_region`]).
|
|
pub fn render_slide_drawn(pres: &Presentation, index: usize, opts: &RenderOpts) -> (Image, Rect) {
|
|
let page = Rect::new(0.0, 0.0, pres.slide_size.width, pres.slide_size.height);
|
|
let Some(slide) = pres.slides.get(index) else { return (Image::default(), page) };
|
|
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
RENDERER.with(|r| {
|
|
let mut r = r.borrow_mut();
|
|
r.drawn = None;
|
|
let img = r.slide(pres, slide, index, opts);
|
|
(img, r.drawn.take())
|
|
})
|
|
})) {
|
|
Ok((img, drawn)) => {
|
|
let k = opts.scale;
|
|
let to_slide =
|
|
|d: Rect| Rect::new((d.x0 - opts.offset.0) / k, (d.y0 - opts.offset.1) / k, (d.x1 - opts.offset.0) / k, (d.y1 - opts.offset.1) / k);
|
|
let extent = match drawn {
|
|
Some(d) if k.is_finite() && k > 0.0 && to_slide(d).is_finite() => page.union(to_slide(d)),
|
|
_ => page,
|
|
};
|
|
(img, extent)
|
|
}
|
|
Err(_) => {
|
|
log::error!("rendering slide {index} panicked; showing a blank slide");
|
|
note_panic();
|
|
RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
|
|
let (w, h) = output_size(pres, opts);
|
|
(Image { width: w as u32, height: h as u32, pixels: vec![255; w as usize * h as usize * 4] }, page)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Which part of a slide [`render_slide_part`] draws.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum SlidePart {
|
|
/// The background fill with the master and layout graphics.
|
|
Backdrop,
|
|
/// The slide's own shapes over transparency.
|
|
Content,
|
|
}
|
|
|
|
/// Render one part of slide `index` (transitions that move the contents over the backgrounds).
|
|
/// A panic inside the renderer becomes a transparent image (logged), never a crash.
|
|
pub fn render_slide_part(pres: &Presentation, index: usize, part: SlidePart, opts: &RenderOpts) -> Image {
|
|
let Some(slide) = pres.slides.get(index) else { return Image::default() };
|
|
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().slide_part(pres, slide, index, part, opts)))) {
|
|
Ok(img) => img,
|
|
Err(_) => {
|
|
log::error!("rendering part of slide {index} panicked; showing nothing");
|
|
note_panic();
|
|
RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
|
|
let (w, h) = output_size(pres, opts);
|
|
Image { width: w as u32, height: h as u32, pixels: vec![0; w as usize * h as usize * 4] }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Render a frame between slides `old` and `new` (indices into `pres.slides`), e.g. of Morph: the
|
|
/// backdrop (background, master and layout graphics) of `old` cross-fading into that of `new` by
|
|
/// `mix` (0..1), then `shapes` in order, each resolved against the slide it comes from (`true`:
|
|
/// `old`). Pairs of shapes in `text` draw their boxes as usual but their texts morph by words or
|
|
/// characters ([`TextMorph`]), on top of the later shape of the pair. Shapes listed in `paths` draw
|
|
/// an outline morphing from another geometry into their own ([`PathMorph`]). `opts.state` applies
|
|
/// to `shapes` only.
|
|
pub fn render_blend(
|
|
pres: &Presentation,
|
|
(old, new): (usize, usize),
|
|
mix: f64,
|
|
shapes: &[(Shape, bool)],
|
|
text: &[TextMorph],
|
|
paths: &[PathMorph],
|
|
opts: &RenderOpts,
|
|
) -> Image {
|
|
let (Some(a), Some(b)) = (pres.slides.get(old), pres.slides.get(new)) else { return Image::default() };
|
|
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
RENDERER.with(|r| r.borrow_mut().blend(pres, (a, old), (b, new), mix, shapes, text, paths, opts))
|
|
})) {
|
|
Ok(img) => img,
|
|
Err(_) => {
|
|
log::error!("rendering a frame between slides {old} and {new} panicked; showing the new slide");
|
|
note_panic();
|
|
RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
|
|
render_slide(pres, new, opts)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Render a layout (Slide Master view thumbnail and canvas).
|
|
pub fn render_layout(pres: &Presentation, master: usize, layout: Option<usize>, opts: &RenderOpts) -> Image {
|
|
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| RENDERER.with(|r| r.borrow_mut().layout(pres, master, layout, opts)))) {
|
|
Ok(img) => img,
|
|
Err(_) => {
|
|
log::error!("rendering a layout panicked; showing a blank image");
|
|
note_panic();
|
|
RENDERER.with(|r| *r.borrow_mut() = Renderer::default());
|
|
Image::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn output_size(pres: &Presentation, opts: &RenderOpts) -> (u16, u16) {
|
|
let (w, h) = opts
|
|
.size
|
|
.unwrap_or(((pres.slide_size.width * opts.scale).round().max(1.0) as u32, (pres.slide_size.height * opts.scale).round().max(1.0) as u32));
|
|
(w.clamp(1, MAX_SIDE) as u16, h.clamp(1, MAX_SIDE) as u16)
|
|
}
|
|
|
|
/// Render `ids` together onto a transparent canvas, in their slide positions (copy as a picture).
|
|
pub fn render_shapes(pres: &Presentation, slide: &Slide, ids: &[ShapeId], scale: f64) -> Option<Image> {
|
|
if ids.is_empty() || !scale.is_finite() || scale <= 0.0 {
|
|
return None;
|
|
}
|
|
let rctx = Ctx::for_slide(pres, slide)?;
|
|
let mut items = Vec::new();
|
|
let mut bounds: Option<Rect> = None;
|
|
for id in ids.iter().take(256) {
|
|
let Some(s) = slide.shape(*id) else { continue };
|
|
let b = resolve::xfrm(&rctx, s).bounds();
|
|
if !b.width().is_finite() || !b.height().is_finite() || b.width() < 0.0 || b.height() < 0.0 {
|
|
continue;
|
|
}
|
|
bounds = Some(match bounds {
|
|
Some(u) => u.union(b),
|
|
None => b,
|
|
});
|
|
items.push(s);
|
|
}
|
|
let b = bounds?.inflate(8.0, 8.0);
|
|
if !(b.width() > 0.0 && b.height() > 0.0 && b.width().is_finite() && b.height().is_finite()) {
|
|
return None;
|
|
}
|
|
let fit = (2048.0 / b.width().max(b.height())).min(scale);
|
|
if !(fit.is_finite() && fit > 0.0) {
|
|
return None;
|
|
}
|
|
let w = (b.width() * fit).ceil().clamp(1.0, 2048.0) as u16;
|
|
let h = (b.height() * fit).ceil().clamp(1.0, 2048.0) as u16;
|
|
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
|
let opts = RenderOpts { scale: fit, ..Default::default() };
|
|
let f = Frame { pres, opts: &opts, fields: SlideFields::new(1, String::new(), &opts) };
|
|
let view = Affine::scale(fit) * Affine::translate(-b.origin().to_vec2());
|
|
RENDERER.with(|r| {
|
|
let mut r = r.borrow_mut();
|
|
for s in items {
|
|
r.shape(&mut ctx, &f, &rctx, s, view, 0);
|
|
}
|
|
Some(r.finish(ctx, w, h))
|
|
})
|
|
}
|
|
|
|
/// Render one shape alone onto a transparent canvas (drag previews, copy as picture).
|
|
pub fn render_shape(pres: &Presentation, slide: &Slide, id: ShapeId, scale: f64) -> Option<Image> {
|
|
let rctx = Ctx::for_slide(pres, slide)?;
|
|
let s = slide.shape(id)?;
|
|
let x = resolve::xfrm(&rctx, s);
|
|
let b = x.bounds().inflate(8.0, 8.0);
|
|
let w = (b.width() * scale).ceil().clamp(1.0, MAX_SIDE as f64) as u16;
|
|
let h = (b.height() * scale).ceil().clamp(1.0, MAX_SIDE as f64) as u16;
|
|
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
|
let opts = RenderOpts { scale, ..Default::default() };
|
|
let f = Frame { pres, opts: &opts, fields: SlideFields::new(1, String::new(), &opts) };
|
|
let view = Affine::scale(scale) * Affine::translate(-b.origin().to_vec2());
|
|
RENDERER.with(|r| {
|
|
let mut r = r.borrow_mut();
|
|
r.shape(&mut ctx, &f, &rctx, s, view, 0);
|
|
Some(r.finish(ctx, w, h))
|
|
})
|
|
}
|