Skip to main content

zenith_render/
render.rs

1//! Public entry points: rasterize a scene to pixels or PNG bytes.
2
3use zenith_core::{AssetProvider, FontProvider};
4use zenith_scene::Scene;
5
6use crate::backend::{RasterBackend, RasterImage};
7use crate::error::RenderError;
8use crate::tiny_skia::TinySkiaBackend;
9
10/// Rasterize `scene` and encode the result as PNG bytes.
11///
12/// Uses the [`TinySkiaBackend`] internally.  The output is deterministic:
13/// the same scene always produces identical bytes.
14///
15/// The `fonts` parameter is used to resolve font bytes for any
16/// [`zenith_scene::SceneCommand::DrawGlyphRun`] commands in the scene; the
17/// `assets` parameter resolves raster image bytes for any
18/// [`zenith_scene::SceneCommand::DrawImage`] commands. Runs/images whose id
19/// cannot be resolved are silently skipped.
20///
21/// # Errors
22///
23/// Returns [`RenderError`] when the scene dimensions are invalid or PNG
24/// encoding fails.
25pub fn render_png(
26    scene: &Scene,
27    fonts: &dyn FontProvider,
28    assets: &dyn AssetProvider,
29) -> Result<Vec<u8>, RenderError> {
30    let backend = TinySkiaBackend;
31    let image = backend.rasterize(scene, fonts, assets)?;
32    backend.encode_png(&image)
33}
34
35/// Rasterize two scenes (`left`, `right`), composite them SIDE BY SIDE via
36/// [`composite_spread`], and encode the result as deterministic PNG bytes.
37///
38/// `left` is blitted at `x = 0` and `right` at `x = left.width + gutter_px`.
39/// `gutter_px` transparent columns are inserted between the two pages. When
40/// `gutter_px = 0` the output is byte-identical to the pre-gutter behavior.
41/// The shared `fonts`/`assets` providers resolve glyph runs and images for
42/// both scenes.
43///
44/// # Errors
45///
46/// Returns [`RenderError`] when either scene's dimensions are invalid, the
47/// combined width overflows, or PNG encoding fails.
48pub fn render_spread_png(
49    left: &Scene,
50    right: &Scene,
51    gutter_px: u32,
52    fonts: &dyn FontProvider,
53    assets: &dyn AssetProvider,
54) -> Result<Vec<u8>, RenderError> {
55    let backend = TinySkiaBackend;
56    let left_img = backend.rasterize(left, fonts, assets)?;
57    let right_img = backend.rasterize(right, fonts, assets)?;
58    let spread = composite_spread(&left_img, &right_img, gutter_px)?;
59    backend.encode_png(&spread)
60}
61
62/// Composite two rasterized pages SIDE BY SIDE into one image with an optional
63/// transparent gutter between them.
64///
65/// `left` is blitted at `x = 0`, `right` at `x = left.width + gutter_px`,
66/// both at `y = 0`. The `gutter_px` columns between the two pages remain fully
67/// transparent (the canvas is initialised to straight-alpha `0,0,0,0`; no
68/// special fill is required). When `gutter_px = 0` the output is byte-identical
69/// to the pre-gutter behavior.
70///
71/// The output canvas is `width = left.width + gutter_px + right.width` and
72/// `height = max(left.height, right.height)`. Pixels are copied verbatim
73/// (straight-alpha RGBA8) — there is no blending, so the result is
74/// deterministic.
75///
76/// # Errors
77///
78/// Returns [`RenderError`] if the combined width overflows `u32`.
79pub fn composite_spread(
80    left: &RasterImage,
81    right: &RasterImage,
82    gutter_px: u32,
83) -> Result<RasterImage, RenderError> {
84    let width = left
85        .width
86        .checked_add(gutter_px)
87        .and_then(|w| w.checked_add(right.width))
88        .ok_or_else(|| {
89            RenderError::new(format!(
90                "spread width overflow: {} + {} + {} exceeds u32",
91                left.width, gutter_px, right.width
92            ))
93        })?;
94    let height = left.height.max(right.height);
95
96    let stride = (width as usize)
97        .checked_mul(4)
98        .ok_or_else(|| RenderError::new(format!("spread row stride overflow for width {width}")))?;
99    let total = stride.checked_mul(height as usize).ok_or_else(|| {
100        RenderError::new(format!("spread buffer size overflow ({width}×{height})"))
101    })?;
102
103    // Fully transparent canvas (straight-alpha 0,0,0,0). The gutter columns
104    // are never written, so they remain transparent automatically.
105    let mut rgba = vec![0u8; total];
106
107    blit(&mut rgba, stride, left, 0);
108    blit(&mut rgba, stride, right, (left.width + gutter_px) as usize);
109
110    Ok(RasterImage {
111        width,
112        height,
113        rgba,
114    })
115}
116
117/// Copy every row of `src` into `dst` (row stride `dst_stride` bytes) starting
118/// at pixel column `x_offset`, with `y = 0`. Pixels are copied straight (no
119/// blending). `dst` is assumed large enough (the caller sized it from
120/// `composite_spread`).
121fn blit(dst: &mut [u8], dst_stride: usize, src: &RasterImage, x_offset: usize) {
122    let src_stride = src.width as usize * 4;
123    let byte_offset = x_offset * 4;
124    for row in 0..src.height as usize {
125        let src_start = row * src_stride;
126        let src_row = match src.rgba.get(src_start..src_start + src_stride) {
127            Some(r) => r,
128            None => break, // src buffer shorter than declared height — stop safely
129        };
130        let dst_start = row * dst_stride + byte_offset;
131        if let Some(dst_row) = dst.get_mut(dst_start..dst_start + src_stride) {
132            dst_row.copy_from_slice(src_row);
133        }
134    }
135}
136
137/// Rasterize `scene` to a [`RasterImage`] (straight-alpha RGBA8 pixels).
138///
139/// Useful for pixel-level assertions in tests without decoding a PNG.
140///
141/// The `fonts` parameter is used to resolve font bytes for any
142/// [`zenith_scene::SceneCommand::DrawGlyphRun`] commands in the scene; the
143/// `assets` parameter resolves raster image bytes for any
144/// [`zenith_scene::SceneCommand::DrawImage`] commands. Runs/images whose id
145/// cannot be resolved are silently skipped.
146///
147/// # Errors
148///
149/// Returns [`RenderError`] when the scene dimensions are invalid.
150pub fn render_image(
151    scene: &Scene,
152    fonts: &dyn FontProvider,
153    assets: &dyn AssetProvider,
154) -> Result<RasterImage, RenderError> {
155    let backend = TinySkiaBackend;
156    backend.rasterize(scene, fonts, assets)
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    /// Build a solid-color RasterImage of `w`×`h` filled with `rgba`.
164    fn solid(w: u32, h: u32, rgba: [u8; 4]) -> RasterImage {
165        let mut buf = Vec::with_capacity((w * h * 4) as usize);
166        for _ in 0..(w * h) {
167            buf.extend_from_slice(&rgba);
168        }
169        RasterImage {
170            width: w,
171            height: h,
172            rgba: buf,
173        }
174    }
175
176    #[test]
177    fn composite_spread_width_is_sum() {
178        let left = solid(30, 20, [255, 0, 0, 255]);
179        let right = solid(40, 20, [0, 0, 255, 255]);
180        let out = composite_spread(&left, &right, 0).expect("composite");
181        assert_eq!(out.width, 70, "spread width must be wA + wB");
182        assert_eq!(out.height, 20, "spread height must be max(hA, hB)");
183        assert_eq!(out.rgba.len(), (70 * 20 * 4) as usize);
184    }
185
186    #[test]
187    fn composite_spread_height_is_max() {
188        let left = solid(10, 50, [1, 2, 3, 255]);
189        let right = solid(10, 30, [4, 5, 6, 255]);
190        let out = composite_spread(&left, &right, 0).expect("composite");
191        assert_eq!(out.height, 50, "height must be the taller of the two");
192    }
193
194    #[test]
195    fn composite_spread_places_pages_side_by_side() {
196        let left = solid(2, 1, [10, 20, 30, 255]);
197        let right = solid(3, 1, [40, 50, 60, 255]);
198        let out = composite_spread(&left, &right, 0).expect("composite");
199        // Row 0: two left pixels, then three right pixels.
200        assert_eq!(&out.rgba[0..4], &[10, 20, 30, 255], "x=0 is left page");
201        assert_eq!(&out.rgba[4..8], &[10, 20, 30, 255], "x=1 is left page");
202        assert_eq!(&out.rgba[8..12], &[40, 50, 60, 255], "x=2 is right page");
203        assert_eq!(&out.rgba[12..16], &[40, 50, 60, 255], "x=3 is right page");
204        assert_eq!(&out.rgba[16..20], &[40, 50, 60, 255], "x=4 is right page");
205    }
206
207    #[test]
208    fn composite_spread_short_page_leaves_transparent_gap() {
209        // Left is taller; the right page's bottom rows stay transparent.
210        let left = solid(1, 2, [9, 9, 9, 255]);
211        let right = solid(1, 1, [8, 8, 8, 255]);
212        let out = composite_spread(&left, &right, 0).expect("composite");
213        assert_eq!(out.width, 2);
214        assert_eq!(out.height, 2);
215        // Row 1 (second row), right column (x=1) was never written → transparent.
216        let stride = 2 * 4;
217        let row1_right = &out.rgba[stride + 4..stride + 8];
218        assert_eq!(
219            row1_right,
220            &[0, 0, 0, 0],
221            "gap below short page is transparent"
222        );
223    }
224
225    /// gutter=0 must produce output byte-identical to the pre-gutter behavior:
226    /// width = left.width + right.width, right page starts at x = left.width.
227    #[test]
228    fn composite_spread_gutter_zero_is_byte_identical_to_no_gutter() {
229        let left = solid(5, 3, [1, 2, 3, 255]);
230        let right = solid(7, 3, [4, 5, 6, 255]);
231        // Build what the "no gutter" path would produce manually.
232        let width = (left.width + right.width) as usize;
233        let height = left.height.max(right.height) as usize;
234        let mut expected = vec![0u8; width * height * 4];
235        let stride = width * 4;
236        for row in 0..left.height as usize {
237            for col in 0..left.width as usize {
238                let dst = row * stride + col * 4;
239                expected[dst..dst + 4].copy_from_slice(&[1, 2, 3, 255]);
240            }
241        }
242        for row in 0..right.height as usize {
243            for col in 0..right.width as usize {
244                let dst = row * stride + (left.width as usize + col) * 4;
245                expected[dst..dst + 4].copy_from_slice(&[4, 5, 6, 255]);
246            }
247        }
248        let out = composite_spread(&left, &right, 0).expect("composite gutter=0");
249        assert_eq!(out.width as usize, width, "width with gutter=0");
250        assert_eq!(
251            out.rgba, expected,
252            "gutter=0 output must be byte-identical to no-gutter"
253        );
254    }
255
256    /// gutter=40 must produce width = left+40+right, with the right page shifted
257    /// by 40 columns and the 40 gutter columns staying fully transparent.
258    #[test]
259    fn composite_spread_gutter_shifts_right_page_and_leaves_transparent_columns() {
260        let left = solid(10, 1, [11, 22, 33, 255]);
261        let right = solid(20, 1, [44, 55, 66, 255]);
262        let gutter: u32 = 40;
263        let out = composite_spread(&left, &right, gutter).expect("composite gutter=40");
264        assert_eq!(out.width, 10 + 40 + 20, "width = left + gutter + right");
265        // The last pixel of the left page (x=9) is left-colored.
266        assert_eq!(
267            &out.rgba[9 * 4..10 * 4],
268            &[11, 22, 33, 255],
269            "x=9 is left page"
270        );
271        // x=10..x=49 are the gutter — must be fully transparent.
272        for x in 10usize..50 {
273            let pixel = &out.rgba[x * 4..(x + 1) * 4];
274            assert_eq!(
275                pixel,
276                &[0, 0, 0, 0],
277                "gutter column {x} must be transparent"
278            );
279        }
280        // The first pixel of the right page (x=50) is right-colored.
281        assert_eq!(
282            &out.rgba[50 * 4..51 * 4],
283            &[44, 55, 66, 255],
284            "x=50 is right page"
285        );
286    }
287}