Skip to main content

zenith_render/tiny_skia/
backend.rs

1//! The `tiny-skia` raster backend: the [`RasterBackend`] implementation and the
2//! command-dispatch render loop.
3//!
4//! The loop owns the clip / transform stacks, the effect-capture stack, and the
5//! compositing-layer stack. Structural and capture commands (clip, transform,
6//! layer, and the blur/shadow/filter/mask brackets) are handled inline here —
7//! they mutate those stacks and `continue`. Drawing commands resolve the active
8//! target pixmap and delegate to [`draw_command`](super::commands::draw_command),
9//! which routes each to a handler in the [`draw`](super::draw) submodules.
10
11use tiny_skia::{FilterQuality, Pixmap, PixmapPaint, Transform};
12use zenith_core::{AssetProvider, FontProvider};
13use zenith_scene::{
14    BlendMode as IrBlendMode, FilterSpec, MaskSpec, Scene, SceneCommand, ShadowSpec,
15};
16
17use super::commands::{DrawCtx, draw_command};
18use super::filter::apply_filters;
19use super::mask::attenuate_by_mask;
20use super::paths::intersect_rects;
21use super::pixels::{f64_to_px, premultiplied_to_straight};
22use super::shadow::{composite_shadows, gaussian_blur_premul};
23use crate::backend::{RasterBackend, RasterImage};
24use crate::error::RenderError;
25
26// ── TinySkiaBackend ───────────────────────────────────────────────────────────
27
28/// CPU rasterization backend backed by the `tiny-skia` library.
29///
30/// Determinism guarantees:
31/// - Anti-aliasing is disabled for rect fills → integer-aligned rects produce
32///   exact, reproducible pixels with no sub-pixel variance.
33/// - Anti-aliasing is enabled for glyph fills — glyph edges are curved and
34///   require AA for legible output. tiny-skia AA is pure-software and
35///   deterministic on the same machine (no GPU, no random numbers).
36/// - No `HashMap`, no random numbers, no timestamps.
37/// - PNG encoding via `tiny_skia::Pixmap::encode_png` writes no timestamps.
38pub struct TinySkiaBackend;
39
40/// Map a scene-IR [`IrBlendMode`] to the `tiny_skia::BlendMode` used when a
41/// compositing layer is painted back onto its parent.
42///
43/// `None` and `Some(Normal)` both yield `SourceOver` — plain compositing — so a
44/// layer with no blend (or an explicit `normal`) composites byte-identically to
45/// having no layer at all. Every other variant maps to the tiny-skia operator of
46/// the same name. Exhaustive over `IrBlendMode`.
47fn map_blend_mode(b: Option<IrBlendMode>) -> tiny_skia::BlendMode {
48    use tiny_skia::BlendMode as Tk;
49    match b {
50        None | Some(IrBlendMode::Normal) => Tk::SourceOver,
51        Some(IrBlendMode::Multiply) => Tk::Multiply,
52        Some(IrBlendMode::Screen) => Tk::Screen,
53        Some(IrBlendMode::Overlay) => Tk::Overlay,
54        Some(IrBlendMode::Darken) => Tk::Darken,
55        Some(IrBlendMode::Lighten) => Tk::Lighten,
56        Some(IrBlendMode::ColorDodge) => Tk::ColorDodge,
57        Some(IrBlendMode::ColorBurn) => Tk::ColorBurn,
58        Some(IrBlendMode::HardLight) => Tk::HardLight,
59        Some(IrBlendMode::SoftLight) => Tk::SoftLight,
60        Some(IrBlendMode::Difference) => Tk::Difference,
61        Some(IrBlendMode::Exclusion) => Tk::Exclusion,
62    }
63}
64
65// The effect type associated with an active offscreen capture. Either a
66// shadow (blurred shadow layers composited behind the crisp ink) or a
67// Gaussian blur (the ink itself blurred in place) or a color filter.
68enum CaptureEffect {
69    Shadow(Vec<ShadowSpec>),
70    Blur(f64),
71    Filter(Vec<FilterSpec>),
72    Mask(MaskSpec),
73}
74
75// One entry of the effect-capture stack. Effect captures (blur/shadow/
76// filter) nest: each Begin* pushes a layer, each End* pops it and
77// composites the captured ink onto the target below.
78struct CaptureLayer {
79    /// The offscreen ink buffer. `None` when allocation failed — draws
80    /// fall through to the target below and the matching End* skips
81    /// compositing (keeps Begin*/End* balanced so nesting stays correct).
82    pm: Option<Pixmap>,
83    effect: CaptureEffect,
84}
85
86// Resolve the current draw / composite target, innermost-first: the
87// topmost effect-capture entry that holds a buffer, else the top blend
88// layer, else the base canvas. With an empty `capture_stack` this is
89// exactly the old `layer_stack.last() else base` target.
90fn current_target<'a>(
91    capture_stack: &'a mut [CaptureLayer],
92    layer_stack: &'a mut [(Pixmap, f32, tiny_skia::BlendMode)],
93    base: &'a mut Pixmap,
94) -> &'a mut Pixmap {
95    if let Some(layer) = capture_stack.iter_mut().rev().find(|l| l.pm.is_some()) {
96        // safe: just checked is_some
97        if let Some(pm) = layer.pm.as_mut() {
98            return pm;
99        }
100    }
101    if let Some((pm, _, _)) = layer_stack.last_mut() {
102        return pm;
103    }
104    base
105}
106
107impl RasterBackend for TinySkiaBackend {
108    fn rasterize(
109        &self,
110        scene: &Scene,
111        fonts: &dyn FontProvider,
112        assets: &dyn AssetProvider,
113    ) -> Result<RasterImage, RenderError> {
114        let width = f64_to_px(scene.width, "width")?;
115        let height = f64_to_px(scene.height, "height")?;
116
117        let mut pixmap = Pixmap::new(width, height).ok_or_else(|| {
118            RenderError::new(format!("failed to allocate pixmap ({width}×{height})"))
119        })?;
120        // Background starts fully transparent (0,0,0,0) — the deterministic default.
121
122        // Clip stack: each entry is (x, y, x2, y2) in scene coordinates.
123        // The outermost clip is the page rectangle.
124        let page_clip = (0.0_f64, 0.0_f64, scene.width, scene.height);
125        let mut clip_stack: Vec<(f64, f64, f64, f64)> = vec![page_clip];
126
127        // Transform stack: the top entry is the current affine transform applied
128        // to every draw. The base entry is identity, so unrotated scenes pass
129        // `Transform::identity()` to every draw call (byte-identical to before).
130        let mut transform_stack: Vec<Transform> = vec![Transform::identity()];
131
132        // Lazily-built fontdb for SVG text→path conversion. Initialised at most
133        // once per render, only when an SVG asset is actually drawn. Never loads
134        // system fonts — only the registered faces from `fonts`.
135        let mut svg_fontdb: Option<resvg::usvg::fontdb::Database> = None;
136
137        // The effect-capture stack. The innermost active capture (topmost entry
138        // with `Some(pm)`) is the current draw target; an empty stack means
139        // draws target the top blend layer or the real canvas — byte-identical
140        // to before this stack existed.
141        let mut capture_stack: Vec<CaptureLayer> = Vec::new();
142
143        // Active compositing layers. Each entry is a full-page offscreen pixmap
144        // that buffers the ink of a blend-mode node (or its children), plus the
145        // opacity and tiny-skia blend operator used to composite it back onto
146        // its parent at the matching PopLayer. Empty in the common case — with
147        // no layers active the draw target resolution is byte-identical to
148        // before (the layer check below short-circuits on an empty Vec).
149        let mut layer_stack: Vec<(Pixmap, f32, tiny_skia::BlendMode)> = Vec::new();
150
151        for cmd in &scene.commands {
152            // Hoist once per iteration. Push/pop arms mutate the stack and
153            // never consume current_ts; draw arms read it and never mutate the
154            // stack — so hoisting is behavior-identical to reading in each arm.
155            let current_ts = *transform_stack.last().unwrap_or(&Transform::identity());
156
157            // ── Structural / capture commands first ───────────────────────────
158            // These never draw into a target pixmap; they mutate the clip /
159            // transform stacks or open/close the shadow capture, then `continue`
160            // so the drawing dispatch below is reached only by drawing commands.
161            match cmd {
162                SceneCommand::PushClip { x, y, w, h } => {
163                    let new_rect = (*x, *y, x + w, y + h);
164                    let current = *clip_stack.last().unwrap_or(&page_clip);
165                    // Push the intersection so the stack always represents the
166                    // effective clip at the current nesting depth.
167                    let intersected =
168                        intersect_rects(current, new_rect).unwrap_or((0.0, 0.0, 0.0, 0.0)); // empty → degenerate
169                    clip_stack.push(intersected);
170                    continue;
171                }
172
173                // Never pop below the page clip (index 0).
174                SceneCommand::PopClip => {
175                    if clip_stack.len() > 1 {
176                        clip_stack.pop();
177                    }
178                    continue;
179                }
180
181                SceneCommand::PushTransform { angle_deg, cx, cy } => {
182                    let rot = Transform::from_rotate_at(*angle_deg as f32, *cx as f32, *cy as f32);
183                    transform_stack.push(current_ts.pre_concat(rot));
184                    continue;
185                }
186
187                SceneCommand::PopTransform => {
188                    if transform_stack.len() > 1 {
189                        transform_stack.pop();
190                    }
191                    continue;
192                }
193
194                // Open an offscreen capture for shadowed ink. Always pushes a
195                // capture layer so Begin/End stay balanced and captures nest.
196                // On allocation failure `pm` is `None` — pushed anyway so the
197                // ink draws crisp (no shadow) and the matching End* is balanced.
198                SceneCommand::BeginShadow { shadows } => {
199                    let pm = Pixmap::new(width, height);
200                    capture_stack.push(CaptureLayer {
201                        pm,
202                        effect: CaptureEffect::Shadow(shadows.clone()),
203                    });
204                    continue;
205                }
206
207                // Close the active shadow capture: paint the blurred shadow
208                // layers onto the target below this capture, then composite the
209                // crisp ink. After the pop, `current_target` sees the stack
210                // without this layer — the next capture, blend layer, or base.
211                SceneCommand::EndShadow => {
212                    if let Some(layer) = capture_stack.pop()
213                        && let (Some(ink), CaptureEffect::Shadow(shadows)) =
214                            (layer.pm, layer.effect)
215                    {
216                        let shadow_target =
217                            current_target(&mut capture_stack, &mut layer_stack, &mut pixmap);
218                        composite_shadows(shadow_target, &ink, &shadows, width, height);
219                    }
220                    continue;
221                }
222
223                // Open an offscreen capture for a Gaussian-blurred element.
224                // Always pushes (nesting); `None` buffer on alloc failure draws
225                // crisp and keeps Begin/End balanced.
226                SceneCommand::BeginBlur { radius } => {
227                    let pm = Pixmap::new(width, height);
228                    capture_stack.push(CaptureLayer {
229                        pm,
230                        effect: CaptureEffect::Blur(*radius),
231                    });
232                    continue;
233                }
234
235                // Close the active blur capture: blur the ink in place, then
236                // composite it onto the target below this capture.
237                SceneCommand::EndBlur => {
238                    if let Some(layer) = capture_stack.pop()
239                        && let (Some(mut ink), CaptureEffect::Blur(sigma)) =
240                            (layer.pm, layer.effect)
241                    {
242                        gaussian_blur_premul(&mut ink, sigma);
243                        let blur_target =
244                            current_target(&mut capture_stack, &mut layer_stack, &mut pixmap);
245                        blur_target.draw_pixmap(
246                            0,
247                            0,
248                            ink.as_ref(),
249                            &PixmapPaint::default(),
250                            Transform::identity(),
251                            None,
252                        );
253                    }
254                    continue;
255                }
256
257                // Open an offscreen capture for a color-filtered element. Always
258                // pushes (nesting). An empty filter list — or allocation failure
259                // — yields a `None` buffer: draws fall through (crisp, no
260                // filter) and the matching EndFilter skips compositing, exactly
261                // as the old empty-list/alloc-failure no-op did.
262                SceneCommand::BeginFilter { filters } => {
263                    let pm = if filters.is_empty() {
264                        None
265                    } else {
266                        Pixmap::new(width, height)
267                    };
268                    capture_stack.push(CaptureLayer {
269                        pm,
270                        effect: CaptureEffect::Filter(filters.clone()),
271                    });
272                    continue;
273                }
274
275                // Close the active filter capture: transform the captured ink
276                // in place, then composite it onto the target below this capture.
277                SceneCommand::EndFilter => {
278                    if let Some(layer) = capture_stack.pop()
279                        && let (Some(mut ink), CaptureEffect::Filter(filters)) =
280                            (layer.pm, layer.effect)
281                    {
282                        apply_filters(&mut ink, &filters);
283                        let filter_target =
284                            current_target(&mut capture_stack, &mut layer_stack, &mut pixmap);
285                        filter_target.draw_pixmap(
286                            0,
287                            0,
288                            ink.as_ref(),
289                            &PixmapPaint::default(),
290                            Transform::identity(),
291                            None,
292                        );
293                    }
294                    continue;
295                }
296
297                // Open an offscreen capture for a masked element. Always pushes
298                // (nesting). On allocation failure `pm` is `None` — draws fall
299                // through (unmasked) and the matching EndMask skips compositing,
300                // keeping Begin/End balanced.
301                SceneCommand::BeginMask { mask } => {
302                    let pm = Pixmap::new(width, height);
303                    capture_stack.push(CaptureLayer {
304                        pm,
305                        effect: CaptureEffect::Mask(*mask),
306                    });
307                    continue;
308                }
309
310                // Close the active mask capture: attenuate the captured ink by
311                // the coverage field, then composite it onto the target below.
312                SceneCommand::EndMask => {
313                    if let Some(layer) = capture_stack.pop()
314                        && let (Some(mut ink), CaptureEffect::Mask(spec)) = (layer.pm, layer.effect)
315                    {
316                        attenuate_by_mask(&mut ink, &spec);
317                        let target =
318                            current_target(&mut capture_stack, &mut layer_stack, &mut pixmap);
319                        target.draw_pixmap(
320                            0,
321                            0,
322                            ink.as_ref(),
323                            &PixmapPaint::default(),
324                            Transform::identity(),
325                            None,
326                        );
327                    }
328                    continue;
329                }
330
331                // Open a compositing layer: allocate a full-page offscreen pixmap
332                // that the following draws (and any nested layers/shadows) paint
333                // into, to be composited back at PopLayer. On allocation failure
334                // we skip pushing — draws then fall through to the previous
335                // target and paint source-over (degraded, never a crash).
336                SceneCommand::PushLayer {
337                    opacity,
338                    blend_mode,
339                } => {
340                    if let Some(pm) = Pixmap::new(width, height) {
341                        layer_stack.push((pm, *opacity as f32, map_blend_mode(*blend_mode)));
342                    }
343                    continue;
344                }
345
346                // Close the most-recent layer: composite its buffered ink onto
347                // the NEW current target — the next layer down if one remains,
348                // else the active shadow capture, else the canvas — using the
349                // layer's opacity and blend operator.
350                SceneCommand::PopLayer => {
351                    if let Some((layer_pm, op, bm)) = layer_stack.pop() {
352                        // After popping this blend layer, composite onto the new
353                        // current target: the next blend layer down if any, else
354                        // the innermost active capture, else the base canvas.
355                        // `current_target` resolves capture-first, so when a
356                        // capture is open and no blend layer remains it returns
357                        // the capture pixmap — byte-identical to the old order.
358                        let target_after_pop =
359                            current_target(&mut capture_stack, &mut layer_stack, &mut pixmap);
360                        target_after_pop.draw_pixmap(
361                            0,
362                            0,
363                            layer_pm.as_ref(),
364                            &PixmapPaint {
365                                opacity: op.clamp(0.0, 1.0),
366                                blend_mode: bm,
367                                quality: FilterQuality::Nearest,
368                            },
369                            Transform::identity(),
370                            None,
371                        );
372                    }
373                    continue;
374                }
375
376                // Drawing commands: fall through to the dispatch below (no
377                // `continue`). Listed explicitly so this structural match stays
378                // exhaustive over `SceneCommand` — no wildcard arm.
379                SceneCommand::FillRect { .. }
380                | SceneCommand::StrokeRect { .. }
381                | SceneCommand::FillRoundedRect { .. }
382                | SceneCommand::StrokeRoundedRect { .. }
383                | SceneCommand::FillEllipse { .. }
384                | SceneCommand::StrokeEllipse { .. }
385                | SceneCommand::StrokeLine { .. }
386                | SceneCommand::FillPolygon { .. }
387                | SceneCommand::StrokePolyline { .. }
388                | SceneCommand::DrawImage { .. }
389                | SceneCommand::DrawSvgAsset { .. }
390                | SceneCommand::DrawGlyphRun { .. } => {}
391            }
392
393            // The active drawing target, innermost-first: the topmost effect
394            // capture holding a buffer (capture ink is always the innermost draw
395            // target), else the top compositing layer if any, else the real
396            // canvas. Computed once per drawing command, after the structural
397            // match above has run (so no borrow overlaps). With no capture and no
398            // layer active this resolves to `&mut pixmap` exactly as before —
399            // the no-layer path is byte-identical.
400            let target: &mut Pixmap =
401                current_target(&mut capture_stack, &mut layer_stack, &mut pixmap);
402
403            let ctx = DrawCtx {
404                current_ts,
405                effective_clip: *clip_stack.last().unwrap_or(&page_clip),
406                width,
407                height,
408            };
409            draw_command(target, ctx, cmd, fonts, assets, &mut svg_fontdb);
410        }
411
412        // Convert tiny-skia's premultiplied RGBA8 to straight-alpha RGBA8.
413        let raw = pixmap.data(); // &[u8], len = width*height*4, premul RGBA
414        let mut rgba = Vec::with_capacity(raw.len());
415        for chunk in raw.chunks_exact(4) {
416            let (sr, sg, sb, sa) =
417                premultiplied_to_straight(chunk[0], chunk[1], chunk[2], chunk[3]);
418            rgba.push(sr);
419            rgba.push(sg);
420            rgba.push(sb);
421            rgba.push(sa);
422        }
423
424        Ok(RasterImage {
425            width,
426            height,
427            rgba,
428        })
429    }
430
431    fn encode_png(&self, image: &RasterImage) -> Result<Vec<u8>, RenderError> {
432        // Re-premultiply straight-alpha back to premultiplied for tiny-skia.
433        let mut premul = Vec::with_capacity(image.rgba.len());
434        for chunk in image.rgba.chunks_exact(4) {
435            let (r, g, b, a) = (chunk[0], chunk[1], chunk[2], chunk[3]);
436            if a == 0 {
437                premul.extend_from_slice(&[0, 0, 0, 0]);
438            } else {
439                let a_u16 = u16::from(a);
440                let mul = |v: u8| -> u8 {
441                    let result = (u16::from(v) * a_u16 + 127) / 255;
442                    result.min(255) as u8
443                };
444                premul.push(mul(r));
445                premul.push(mul(g));
446                premul.push(mul(b));
447                premul.push(a);
448            }
449        }
450
451        let mut pixmap = Pixmap::new(image.width, image.height).ok_or_else(|| {
452            RenderError::new(format!(
453                "failed to allocate pixmap for encoding ({}×{})",
454                image.width, image.height
455            ))
456        })?;
457
458        let dst = pixmap.data_mut();
459        if dst.len() != premul.len() {
460            return Err(RenderError::new(
461                "pixel buffer length mismatch during PNG encoding",
462            ));
463        }
464        dst.copy_from_slice(&premul);
465
466        pixmap
467            .encode_png()
468            .map_err(|e| RenderError::new(format!("PNG encoding failed: {e}")))
469    }
470}