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}