Skip to main content

oxideav_scribe/
color_glyph.rs

1//! Color-glyph rasterizer — bridges `oxideav_ttf::ColorBitmap` (raw
2//! CBDT PNG bytes + per-glyph metrics) to a straight-alpha RGBA8
3//! buffer.
4//!
5//! Round-5 scope: CBDT/CBLC color bitmap glyphs (Google's embedded-PNG
6//! emoji format used by Noto Color Emoji and most Android emoji
7//! fonts). The two other color-glyph table families — Microsoft's
8//! COLR (layered vectors) + Apple's sbix (PNG/JPEG strikes) — are
9//! deferred to future rounds.
10//!
11//! ## Pipeline
12//!
13//! 1. The shaper / face-chain produces a `PositionedGlyph`.
14//! 2. The composer asks the face for the colour bitmap at the requested
15//!    `size_px` via [`Face::raster_color_glyph`]. That entry point
16//!    walks CBLC → CBDT and hands the raw PNG byte stream to
17//!    `oxideav_png::decode_png_to_frame`.
18//! 3. The decoded `VideoFrame` is unwrapped to an [`RgbaBitmap`]
19//!    (always Rgba8 because Noto Color Emoji + every other CBDT font
20//!    we've seen ships colour type 6 / 8-bit RGBA PNGs). Other PNG
21//!    pixel formats are converted on the fly: Rgb24 → opaque RGBA,
22//!    Ya8 → grayscale-as-RGB-with-alpha, Pal8 + tRNS → RGBA via the
23//!    palette + per-entry alpha. (Decode-time PNG colour conversion
24//!    is the consumer crate's responsibility per the upstream
25//!    `oxideav-png` API contract.)
26//!
27//! ## Scaling
28//!
29//! CBDT entries carry a per-strike `ppem` (typically 109 or 136 for
30//! Noto Color Emoji). The face picks the closest strike via
31//! [`oxideav_ttf::Font::glyph_color_bitmap`]; if `size_px` doesn't
32//! match the strike exactly, [`Face::raster_color_glyph_at`] runs a
33//! bilinear resample to the requested size before handing the bitmap
34//! back. Once the bitmap reaches `Face::glyph_node` it gets wrapped in
35//! an `oxideav_core::ImageRef` carrying a `VideoFrame` so downstream
36//! `oxideav-raster` blits it through the same image-rendering path it
37//! uses for any other embedded raster.
38//!
39//! No third-party PNG / image crate is used per workspace policy;
40//! `oxideav-png` (a sibling) is the sole PNG dependency.
41
42use crate::face::Face;
43use crate::Error;
44
45use oxideav_core::VideoFrame;
46
47/// A grayscale-irrelevant straight-alpha RGBA8 bitmap. Stride is
48/// `width * 4`. Used internally to carry the decoded colour-glyph
49/// pixels through resampling before they're wrapped in a
50/// `VideoFrame` for the outer `Node::Image`.
51#[derive(Debug, Clone, Default)]
52pub struct RgbaBitmap {
53    /// Bitmap width in pixels.
54    pub width: u32,
55    /// Bitmap height in pixels.
56    pub height: u32,
57    /// Row-major straight-alpha RGBA8 bytes (`width * height * 4`).
58    pub data: Vec<u8>,
59}
60
61impl RgbaBitmap {
62    /// Allocate a fully-transparent (alpha = 0) bitmap.
63    pub fn new(width: u32, height: u32) -> Self {
64        Self {
65            width,
66            height,
67            data: vec![0; (width as usize) * (height as usize) * 4],
68        }
69    }
70
71    /// True if the bitmap holds zero pixels.
72    pub fn is_empty(&self) -> bool {
73        self.width == 0 || self.height == 0
74    }
75
76    /// Read RGBA at `(x, y)`. Out-of-range reads return `[0; 4]`.
77    pub fn get(&self, x: u32, y: u32) -> [u8; 4] {
78        if x >= self.width || y >= self.height {
79            return [0; 4];
80        }
81        let off = ((y as usize) * (self.width as usize) + (x as usize)) * 4;
82        [
83            self.data[off],
84            self.data[off + 1],
85            self.data[off + 2],
86            self.data[off + 3],
87        ]
88    }
89
90    /// Number of pixels with non-zero alpha.
91    pub fn nonzero_alpha_count(&self) -> usize {
92        self.data.chunks_exact(4).filter(|p| p[3] != 0).count()
93    }
94
95    /// Bilinearly resample this bitmap to `(dst_width, dst_height)`.
96    ///
97    /// Used by the colour-bitmap pipeline to scale a CBDT strike
98    /// (typically 109 px or 136 px ppem for Noto Color Emoji) down to
99    /// the requested raster size. Edge sampling clamps at the source
100    /// borders so we never read outside the bitmap. Interpolation is
101    /// performed in **straight-alpha space** independently per channel
102    /// — the simpler model that matches what FreeType's bitmap-strike
103    /// scaling does. Premultiplied interpolation produces sharper
104    /// alpha-edge silhouettes but requires un-premultiplying afterwards
105    /// to keep downstream consumers happy; for emoji glyphs at typical
106    /// body-text sizes the visual difference is imperceptible.
107    ///
108    /// Returns the same bitmap unchanged when `dst_width == self.width`
109    /// and `dst_height == self.height` (cheap pass-through). Returns an
110    /// empty bitmap when either source or destination has a zero
111    /// dimension.
112    pub fn resample_bilinear(&self, dst_width: u32, dst_height: u32) -> RgbaBitmap {
113        if self.is_empty() || dst_width == 0 || dst_height == 0 {
114            return RgbaBitmap::default();
115        }
116        if dst_width == self.width && dst_height == self.height {
117            return self.clone();
118        }
119        let src_w = self.width as usize;
120        let src_h = self.height as usize;
121        let dw = dst_width as usize;
122        let dh = dst_height as usize;
123        let mut out = RgbaBitmap::new(dst_width, dst_height);
124        // Map each destination pixel centre to a source coordinate via
125        // half-pixel offsets so the corner samples land on the source
126        // corner pixel centres (the standard "centre-sample" mapping).
127        let sx = self.width as f32 / dst_width as f32;
128        let sy = self.height as f32 / dst_height as f32;
129        for dy in 0..dh {
130            // Source Y at the destination pixel centre.
131            let src_y = (dy as f32 + 0.5) * sy - 0.5;
132            let y0_f = src_y.floor();
133            let fy = src_y - y0_f;
134            let y0 = (y0_f as i32).clamp(0, src_h as i32 - 1) as usize;
135            let y1 = (y0_f as i32 + 1).clamp(0, src_h as i32 - 1) as usize;
136            for dx in 0..dw {
137                let src_x = (dx as f32 + 0.5) * sx - 0.5;
138                let x0_f = src_x.floor();
139                let fx = src_x - x0_f;
140                let x0 = (x0_f as i32).clamp(0, src_w as i32 - 1) as usize;
141                let x1 = (x0_f as i32 + 1).clamp(0, src_w as i32 - 1) as usize;
142                let off00 = (y0 * src_w + x0) * 4;
143                let off10 = (y0 * src_w + x1) * 4;
144                let off01 = (y1 * src_w + x0) * 4;
145                let off11 = (y1 * src_w + x1) * 4;
146                let dst_off = (dy * dw + dx) * 4;
147                let w00 = (1.0 - fx) * (1.0 - fy);
148                let w10 = fx * (1.0 - fy);
149                let w01 = (1.0 - fx) * fy;
150                let w11 = fx * fy;
151                for c in 0..4 {
152                    let s00 = self.data[off00 + c] as f32;
153                    let s10 = self.data[off10 + c] as f32;
154                    let s01 = self.data[off01 + c] as f32;
155                    let s11 = self.data[off11 + c] as f32;
156                    let mixed = s00 * w00 + s10 * w10 + s01 * w01 + s11 * w11;
157                    out.data[dst_off + c] = mixed.round().clamp(0.0, 255.0) as u8;
158                }
159            }
160        }
161        out
162    }
163}
164
165/// Result of decoding one CBDT entry — the rasterised RGBA bitmap plus
166/// the per-glyph metrics needed for placement.
167#[derive(Debug, Clone)]
168pub struct ColorGlyphBitmap {
169    /// Decoded RGBA8 bitmap (straight alpha; same convention as the
170    /// rest of the scribe pipeline).
171    pub bitmap: RgbaBitmap,
172    /// Distance in pixels from the horizontal pen origin to the LEFT
173    /// edge of the bitmap (positive = bitmap starts to the right of
174    /// the pen).
175    pub bearing_x: i32,
176    /// Distance in pixels from the horizontal pen origin to the TOP
177    /// edge of the bitmap (positive = bitmap top is above the pen).
178    /// In raster-Y-down coordinates the placement Y is `pen_y -
179    /// bearing_y`.
180    pub bearing_y: i32,
181    /// Horizontal advance the strike author chose for this glyph,
182    /// in pixels at the strike's native ppem.
183    pub advance: u32,
184    /// The CBDT strike's native pixels-per-em. Callers comparing to
185    /// their requested `size_px` can compute a scale factor as
186    /// `size_px / ppem as f32`.
187    pub ppem: u8,
188}
189
190impl Face {
191    /// `true` when this face ships CBDT/CBLC tables. Wraps
192    /// [`oxideav_ttf::Font::has_color_bitmaps`].
193    pub fn has_color_bitmaps(&self) -> bool {
194        // OTF (CFF) faces don't ship CBDT in any font we've seen;
195        // short-circuit to avoid the re-parse.
196        match self.kind() {
197            crate::FaceKind::Otf => false,
198            crate::FaceKind::Ttf => self.with_font(|f| f.has_color_bitmaps()).unwrap_or(false),
199        }
200    }
201
202    /// All `(ppem_x, ppem_y)` strikes the face's CBDT/CBLC tables ship.
203    /// Empty when the face has no colour bitmaps.
204    pub fn color_strike_sizes(&self) -> Vec<(u8, u8)> {
205        match self.kind() {
206            crate::FaceKind::Otf => Vec::new(),
207            crate::FaceKind::Ttf => self
208                .with_font(|f| f.color_strike_sizes())
209                .unwrap_or_default(),
210        }
211    }
212
213    /// Rasterise the colour bitmap for `glyph_id` at the strike whose
214    /// `ppem_y` is closest to `size_px.round()`, **at the strike's
215    /// native pixel dimensions**.
216    ///
217    /// The returned bitmap is the un-scaled CBDT strike — typically
218    /// 109 px or 136 px on a side for Noto Color Emoji even when the
219    /// caller asked for `size_px = 32`. Use
220    /// [`Face::raster_color_glyph_at`] when you want the bitmap
221    /// pre-resampled to `size_px`.
222    ///
223    /// Returns `Ok(None)` if the face has no CBDT/CBLC, or no strike
224    /// covers the glyph, or the per-glyph CBDT entry is in a format we
225    /// don't decode (anything other than 17/18/19 — the three
226    /// PNG-payload formats).
227    ///
228    /// Returns `Err(Error::InvalidSize)` if `size_px` is non-positive
229    /// or NaN, mirroring the rest of the scribe entry points.
230    pub fn raster_color_glyph(
231        &self,
232        glyph_id: u16,
233        size_px: f32,
234    ) -> Result<Option<ColorGlyphBitmap>, Error> {
235        if size_px <= 0.0 || !size_px.is_finite() {
236            return Err(Error::InvalidSize);
237        }
238        if self.kind() != crate::FaceKind::Ttf {
239            return Ok(None);
240        }
241        let target_ppem = size_px.round().clamp(1.0, 255.0) as u8;
242        let bitmap_descriptor = self.with_font(|f| {
243            f.glyph_color_bitmap(glyph_id, target_ppem).map(|cb| {
244                (
245                    cb.png_bytes.to_vec(),
246                    cb.width,
247                    cb.height,
248                    cb.bearing_x,
249                    cb.bearing_y,
250                    cb.advance,
251                    cb.ppem,
252                )
253            })
254        })?;
255        let (png_bytes, _meta_w, _meta_h, bx, by, adv, ppem) = match bitmap_descriptor {
256            Some(t) => t,
257            None => return Ok(None),
258        };
259        let frame = match oxideav_png::decode_png_to_frame(&png_bytes, None) {
260            Ok(f) => f,
261            Err(_) => return Ok(None),
262        };
263        // Recover the true PNG image dimensions from the IHDR chunk
264        // (frame metadata doesn't carry width/height — see VideoFrame
265        // doc-comment in oxideav-core). CBDT metrics CAN round-down
266        // vs the PNG's true pixel grid (legal per spec — metrics are
267        // the layout box, the PNG can be larger and is drawn into
268        // that box).
269        let (png_w, png_h) = match read_png_dimensions(&png_bytes) {
270            Some(d) => d,
271            None => return Ok(None),
272        };
273        // Noto Color Emoji ships PLTE-encoded (colour type 3) PNGs —
274        // smaller payload than direct RGBA. `oxideav_png::decode_png_to_frame`
275        // hands those back as a 1-byte-per-pixel `Pal8` plane, with the
276        // PLTE/tRNS chunks NOT exposed through the `VideoFrame`. We
277        // re-parse those two tiny chunks here at the boundary (same
278        // pattern as `read_png_dimensions` already does for IHDR) and
279        // splice them into the conversion. Direct RGBA strikes (Apple
280        // Color Emoji, EmojiOne) skip the palette path entirely —
281        // `read_png_palette` returns `None` and we fall through to the
282        // existing colour-type sniff in `videoframe_to_rgba`.
283        let palette = read_png_palette(&png_bytes);
284        let bitmap = videoframe_to_rgba(&frame, png_w, png_h, palette.as_ref());
285        Ok(Some(ColorGlyphBitmap {
286            bitmap,
287            bearing_x: bx as i32,
288            bearing_y: by as i32,
289            advance: adv as u32,
290            ppem,
291        }))
292    }
293
294    /// Rasterise the colour bitmap for `glyph_id`, **bilinearly
295    /// resampled** to the requested `size_px`.
296    ///
297    /// Walks CBLC → CBDT to find the closest strike, decodes the PNG to
298    /// straight-alpha RGBA8 (via [`Face::raster_color_glyph`]), then
299    /// runs [`RgbaBitmap::resample_bilinear`] to scale the bitmap to
300    /// the dimensions matching `size_px` at the strike's aspect ratio.
301    /// The returned [`ColorGlyphBitmap::bearing_x`] / `bearing_y` /
302    /// `advance` are also pre-scaled by `size_px / ppem` (rounded),
303    /// and `ppem` reports the requested raster size (not the strike's
304    /// native ppem) so the caller can use the metrics directly without
305    /// a second-stage scale.
306    ///
307    /// Returns the same `Ok(None)` / `Err(Error::InvalidSize)` cases as
308    /// [`Face::raster_color_glyph`].
309    pub fn raster_color_glyph_at(
310        &self,
311        glyph_id: u16,
312        size_px: f32,
313    ) -> Result<Option<ColorGlyphBitmap>, Error> {
314        let native = match self.raster_color_glyph(glyph_id, size_px)? {
315            Some(c) => c,
316            None => return Ok(None),
317        };
318        if native.bitmap.is_empty() || native.ppem == 0 {
319            return Ok(Some(native));
320        }
321        let strike_scale = size_px / native.ppem as f32;
322        let new_w = (native.bitmap.width as f32 * strike_scale).round().max(1.0) as u32;
323        let new_h = (native.bitmap.height as f32 * strike_scale)
324            .round()
325            .max(1.0) as u32;
326        let resampled = native.bitmap.resample_bilinear(new_w, new_h);
327        let new_bx = (native.bearing_x as f32 * strike_scale).round() as i32;
328        let new_by = (native.bearing_y as f32 * strike_scale).round() as i32;
329        let new_adv = (native.advance as f32 * strike_scale).round().max(0.0) as u32;
330        // Clamp the reported "ppem" to a u8 (CBDT spec range) — at
331        // size_px > 255 we cap rather than wrap. Callers wanting the
332        // exact requested raster size should use `size_px` directly.
333        let reported_ppem = size_px.round().clamp(1.0, 255.0) as u8;
334        Ok(Some(ColorGlyphBitmap {
335            bitmap: resampled,
336            bearing_x: new_bx,
337            bearing_y: new_by,
338            advance: new_adv,
339            ppem: reported_ppem,
340        }))
341    }
342}
343
344/// Convert a VideoFrame from `oxideav-png` into a straight-alpha RGBA8
345/// [`RgbaBitmap`] given the PNG's true pixel dimensions (recovered
346/// from the IHDR chunk by [`read_png_dimensions`]) and an optional
347/// palette (recovered from PLTE + tRNS chunks by
348/// [`read_png_palette`]).
349///
350/// Handles the four PNG output flavours we'll ever see from a CBDT
351/// entry — derived from `plane.stride / width`:
352///
353/// - 4 B/px → `Rgba` (common case for Apple Color Emoji, EmojiOne):
354///   copy through unchanged.
355/// - 3 B/px → `Rgb24` (some glyphs ship without alpha): pad to opaque.
356/// - 2 B/px → `Ya8` (grayscale + alpha): splat luma into RGB.
357/// - 1 B/px → `Gray8` (no `palette`) OR `Pal8` (`palette` is `Some`):
358///   - When `palette` is supplied, do a per-pixel palette lookup (Noto
359///     Color Emoji ships colour type 3 PNGs — palette + per-entry
360///     alpha via tRNS — for compactness).
361///   - Otherwise splat the byte as grayscale + opaque alpha (grayscale
362///     CBDT PNGs are rare but spec-legal).
363/// - any other ratio → empty bitmap (the composer skips empty glyphs).
364fn videoframe_to_rgba(
365    frame: &VideoFrame,
366    width: u32,
367    height: u32,
368    palette: Option<&PngPalette>,
369) -> RgbaBitmap {
370    if frame.planes.is_empty() || width == 0 || height == 0 {
371        return RgbaBitmap::default();
372    }
373    let plane = &frame.planes[0];
374    if plane.stride == 0 || plane.data.is_empty() {
375        return RgbaBitmap::default();
376    }
377    let w = width as usize;
378    let h = height as usize;
379    // bytes-per-pixel inferred from `stride / width` (oxideav-png
380    // packs rows tightly with no padding).
381    if plane.stride % w != 0 {
382        return RgbaBitmap::default();
383    }
384    let bpp = plane.stride / w;
385    if !(1..=4).contains(&bpp) {
386        return RgbaBitmap::default();
387    }
388    if plane.data.len() < plane.stride * h {
389        return RgbaBitmap::default();
390    }
391    let mut out = RgbaBitmap::new(width, height);
392    for row in 0..h {
393        for col in 0..w {
394            let src_off = row * plane.stride + col * bpp;
395            let dst_off = (row * w + col) * 4;
396            match bpp {
397                4 => {
398                    out.data[dst_off] = plane.data[src_off];
399                    out.data[dst_off + 1] = plane.data[src_off + 1];
400                    out.data[dst_off + 2] = plane.data[src_off + 2];
401                    out.data[dst_off + 3] = plane.data[src_off + 3];
402                }
403                3 => {
404                    out.data[dst_off] = plane.data[src_off];
405                    out.data[dst_off + 1] = plane.data[src_off + 1];
406                    out.data[dst_off + 2] = plane.data[src_off + 2];
407                    out.data[dst_off + 3] = 255;
408                }
409                2 => {
410                    let y = plane.data[src_off];
411                    let a = plane.data[src_off + 1];
412                    out.data[dst_off] = y;
413                    out.data[dst_off + 1] = y;
414                    out.data[dst_off + 2] = y;
415                    out.data[dst_off + 3] = a;
416                }
417                1 => {
418                    let idx = plane.data[src_off];
419                    if let Some(p) = palette {
420                        let rgba = p.lookup(idx);
421                        out.data[dst_off] = rgba[0];
422                        out.data[dst_off + 1] = rgba[1];
423                        out.data[dst_off + 2] = rgba[2];
424                        out.data[dst_off + 3] = rgba[3];
425                    } else {
426                        out.data[dst_off] = idx;
427                        out.data[dst_off + 1] = idx;
428                        out.data[dst_off + 2] = idx;
429                        out.data[dst_off + 3] = 255;
430                    }
431                }
432                _ => unreachable!(),
433            }
434        }
435    }
436    out
437}
438
439/// PNG palette parsed from the PLTE + tRNS chunks. PLTE always carries
440/// 1..=256 RGB triplets; tRNS (when present, ≤ palette length) carries
441/// per-entry alpha bytes for the leading entries (entries past tRNS's
442/// length are opaque).
443#[derive(Debug, Clone)]
444struct PngPalette {
445    /// Per-index RGBA. `entries.len()` matches PLTE's entry count
446    /// (1..=256).
447    entries: Vec<[u8; 4]>,
448}
449
450impl PngPalette {
451    /// Look up an index. Out-of-range indices return transparent black
452    /// (matching what FreeType + libpng do for malformed palettes).
453    fn lookup(&self, idx: u8) -> [u8; 4] {
454        self.entries
455            .get(idx as usize)
456            .copied()
457            .unwrap_or([0, 0, 0, 0])
458    }
459}
460
461/// Walk PNG chunks looking for `PLTE` + `tRNS`. Returns `Some(palette)`
462/// when both PLTE is present (palette PNGs only) — the tRNS chunk is
463/// optional (without it every entry is opaque). For non-palette PNGs
464/// (no PLTE chunk in the stream) returns `None` so the caller falls
465/// back to the existing colour-type heuristic in
466/// [`videoframe_to_rgba`].
467///
468/// Chunk format per PNG §5.3: `length (u32 BE) + type (4 ASCII) +
469/// data + CRC (u32 BE)`. The 8-byte signature precedes the first
470/// chunk. Walking is bounds-checked against the slice length on every
471/// step.
472fn read_png_palette(bytes: &[u8]) -> Option<PngPalette> {
473    if bytes.len() < 8 || &bytes[0..8] != b"\x89PNG\r\n\x1a\n" {
474        return None;
475    }
476    let mut off = 8usize;
477    let mut plte: Option<&[u8]> = None;
478    let mut trns: Option<&[u8]> = None;
479    while off + 12 <= bytes.len() {
480        let len = u32::from_be_bytes([bytes[off], bytes[off + 1], bytes[off + 2], bytes[off + 3]])
481            as usize;
482        let chunk_end = off.checked_add(8).and_then(|x| x.checked_add(len))?;
483        // CRC is 4 bytes after data; total chunk footprint = 12 + len.
484        let total_end = chunk_end.checked_add(4)?;
485        if total_end > bytes.len() {
486            break;
487        }
488        let chunk_type = &bytes[off + 4..off + 8];
489        let chunk_data = &bytes[off + 8..chunk_end];
490        match chunk_type {
491            b"PLTE" => plte = Some(chunk_data),
492            b"tRNS" => trns = Some(chunk_data),
493            b"IDAT" => {
494                // IDAT comes after PLTE/tRNS per PNG ordering; once we
495                // hit it we know we won't find any more colour-type-3
496                // metadata. Bail early to keep the walk bounded.
497                break;
498            }
499            b"IEND" => break,
500            _ => {}
501        }
502        off = total_end;
503    }
504    let plte = plte?;
505    if plte.is_empty() || plte.len() % 3 != 0 || plte.len() > 256 * 3 {
506        return None;
507    }
508    let n = plte.len() / 3;
509    let mut entries: Vec<[u8; 4]> = Vec::with_capacity(n);
510    for i in 0..n {
511        entries.push([plte[i * 3], plte[i * 3 + 1], plte[i * 3 + 2], 255]);
512    }
513    if let Some(t) = trns {
514        // Per spec the tRNS chunk for colour type 3 carries up to N
515        // alpha bytes (one per palette entry); entries past tRNS's
516        // length stay opaque.
517        let m = t.len().min(n);
518        for (i, &alpha) in t.iter().take(m).enumerate() {
519            entries[i][3] = alpha;
520        }
521    }
522    Some(PngPalette { entries })
523}
524
525/// Read `(width, height)` from the IHDR chunk of a PNG byte stream.
526/// Returns `None` for streams that don't start with the standard 8-byte
527/// PNG signature followed by a 13-byte IHDR chunk in the canonical
528/// position (every spec-conformant PNG does — IHDR is required to be
529/// the first chunk).
530///
531/// We avoid decoding the full IHDR via `oxideav_png::Ihdr::parse`
532/// because that's an internal API and pulling more of `oxideav-png` in
533/// here would tighten the coupling unnecessarily; the four bytes at
534/// known offsets are enough.
535fn read_png_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
536    // PNG signature (8) + chunk length (4) + chunk type (4) = 16.
537    // IHDR data starts at offset 16 with width:u32 + height:u32.
538    if bytes.len() < 24 {
539        return None;
540    }
541    if &bytes[0..8] != b"\x89PNG\r\n\x1a\n" {
542        return None;
543    }
544    if &bytes[12..16] != b"IHDR" {
545        return None;
546    }
547    let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]);
548    let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
549    if w == 0 || h == 0 {
550        return None;
551    }
552    Some((w, h))
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use oxideav_core::{VideoFrame, VideoPlane};
559
560    /// 2×2 RGBA frame round-trip — the rgba8 happy path.
561    #[test]
562    fn videoframe_rgba8_to_bitmap() {
563        let frame = VideoFrame {
564            pts: None,
565            planes: vec![VideoPlane {
566                stride: 8, // 2 px * 4 B
567                data: vec![
568                    255, 0, 0, 255, // (0,0) red
569                    0, 255, 0, 128, // (1,0) green half-alpha
570                    0, 0, 255, 64, // (0,1) blue quarter-alpha
571                    255, 255, 0, 255, // (1,1) yellow opaque
572                ],
573            }],
574        };
575        let bm = videoframe_to_rgba(&frame, 2, 2, None);
576        assert_eq!(bm.width, 2);
577        assert_eq!(bm.height, 2);
578        assert_eq!(bm.get(0, 0), [255, 0, 0, 255]);
579        assert_eq!(bm.get(1, 0), [0, 255, 0, 128]);
580        assert_eq!(bm.get(0, 1), [0, 0, 255, 64]);
581        assert_eq!(bm.get(1, 1), [255, 255, 0, 255]);
582    }
583
584    #[test]
585    fn videoframe_rgb24_to_bitmap_fills_opaque_alpha() {
586        let frame = VideoFrame {
587            pts: None,
588            planes: vec![VideoPlane {
589                stride: 6, // 2 px * 3 B
590                data: vec![
591                    255, 0, 0, // red
592                    0, 255, 0, // green
593                ],
594            }],
595        };
596        let bm = videoframe_to_rgba(&frame, 2, 1, None);
597        assert_eq!(bm.width, 2);
598        assert_eq!(bm.height, 1);
599        assert_eq!(bm.get(0, 0), [255, 0, 0, 255]);
600        assert_eq!(bm.get(1, 0), [0, 255, 0, 255]);
601    }
602
603    #[test]
604    fn empty_videoframe_returns_empty_bitmap() {
605        let frame = VideoFrame {
606            pts: None,
607            planes: vec![],
608        };
609        let bm = videoframe_to_rgba(&frame, 0, 0, None);
610        assert!(bm.is_empty());
611    }
612
613    /// Real PNG signature + IHDR — verify the IHDR width/height
614    /// extraction without pulling oxideav-png into the test.
615    #[test]
616    fn read_png_dimensions_extracts_ihdr() {
617        let mut buf: Vec<u8> = Vec::new();
618        // Signature.
619        buf.extend_from_slice(b"\x89PNG\r\n\x1a\n");
620        // IHDR length (13).
621        buf.extend_from_slice(&13u32.to_be_bytes());
622        // IHDR type.
623        buf.extend_from_slice(b"IHDR");
624        // 96 wide x 109 tall, 8-bit, ct=6, etc.
625        buf.extend_from_slice(&96u32.to_be_bytes());
626        buf.extend_from_slice(&109u32.to_be_bytes());
627        buf.extend_from_slice(&[8, 6, 0, 0, 0]);
628        // CRC stub (4 B); ignored.
629        buf.extend_from_slice(&[0; 4]);
630        let dim = read_png_dimensions(&buf).expect("ihdr");
631        assert_eq!(dim, (96, 109));
632
633        // Wrong signature → None.
634        let mut bad = buf.clone();
635        bad[0] = 0;
636        assert!(read_png_dimensions(&bad).is_none());
637
638        // Truncated → None.
639        assert!(read_png_dimensions(&buf[..16]).is_none());
640    }
641
642    /// Build a synthetic PNG with PLTE + tRNS, walk it via
643    /// `read_png_palette`. Verifies (a) the chunk walker traverses past
644    /// IHDR + PLTE + tRNS to the entries, (b) the palette/alpha pairing
645    /// is correct, (c) the bail at IDAT works, (d) palette-less PNGs
646    /// return `None`.
647    #[test]
648    fn read_png_palette_extracts_plte_and_trns() {
649        // Construct: signature + IHDR + PLTE + tRNS + IDAT + IEND.
650        let mut buf: Vec<u8> = Vec::new();
651        buf.extend_from_slice(b"\x89PNG\r\n\x1a\n");
652        // IHDR (length 13)
653        buf.extend_from_slice(&13u32.to_be_bytes());
654        buf.extend_from_slice(b"IHDR");
655        buf.extend_from_slice(&8u32.to_be_bytes()); // width
656        buf.extend_from_slice(&8u32.to_be_bytes()); // height
657        buf.extend_from_slice(&[8, 3, 0, 0, 0]); // depth, ct=3 (palette)
658        buf.extend_from_slice(&[0u8; 4]); // CRC stub
659                                          // PLTE: 3 entries (red, green, blue)
660        buf.extend_from_slice(&9u32.to_be_bytes()); // length 9
661        buf.extend_from_slice(b"PLTE");
662        buf.extend_from_slice(&[
663            255, 0, 0, // red
664            0, 255, 0, // green
665            0, 0, 255, // blue
666        ]);
667        buf.extend_from_slice(&[0u8; 4]); // CRC stub
668                                          // tRNS: 2 entries (red opaque, green half-alpha) — the third
669                                          // palette entry stays opaque.
670        buf.extend_from_slice(&2u32.to_be_bytes()); // length 2
671        buf.extend_from_slice(b"tRNS");
672        buf.extend_from_slice(&[255, 128]);
673        buf.extend_from_slice(&[0u8; 4]); // CRC stub
674                                          // IDAT placeholder so the walker bails before IEND.
675        buf.extend_from_slice(&0u32.to_be_bytes());
676        buf.extend_from_slice(b"IDAT");
677        buf.extend_from_slice(&[0u8; 4]); // CRC stub
678
679        let pal = read_png_palette(&buf).expect("palette");
680        assert_eq!(pal.entries.len(), 3);
681        assert_eq!(pal.lookup(0), [255, 0, 0, 255]);
682        assert_eq!(pal.lookup(1), [0, 255, 0, 128]);
683        assert_eq!(pal.lookup(2), [0, 0, 255, 255]);
684        // Out-of-range index → transparent black.
685        assert_eq!(pal.lookup(3), [0, 0, 0, 0]);
686
687        // Drop PLTE → None.
688        let mut nopal = Vec::new();
689        nopal.extend_from_slice(b"\x89PNG\r\n\x1a\n");
690        nopal.extend_from_slice(&13u32.to_be_bytes());
691        nopal.extend_from_slice(b"IHDR");
692        nopal.extend_from_slice(&[0u8; 13]);
693        nopal.extend_from_slice(&[0u8; 4]);
694        assert!(
695            read_png_palette(&nopal).is_none(),
696            "palette-less PNG must return None"
697        );
698
699        // Wrong signature → None.
700        let mut bad = buf.clone();
701        bad[0] = 0;
702        assert!(read_png_palette(&bad).is_none());
703    }
704
705    /// `videoframe_to_rgba` with a palette translates the 1-byte plane
706    /// indices into the right RGBA values.
707    #[test]
708    fn videoframe_pal8_to_bitmap_via_palette() {
709        let frame = VideoFrame {
710            pts: None,
711            planes: vec![VideoPlane {
712                stride: 2, // 2 px * 1 B
713                data: vec![
714                    0, 1, // (0,0) idx 0, (1,0) idx 1
715                    2, 1, // (0,1) idx 2, (1,1) idx 1
716                ],
717            }],
718        };
719        let pal = PngPalette {
720            entries: vec![
721                [255, 0, 0, 255], // 0 → red opaque
722                [0, 255, 0, 128], // 1 → green half-alpha
723                [0, 0, 255, 255], // 2 → blue opaque
724            ],
725        };
726        let bm = videoframe_to_rgba(&frame, 2, 2, Some(&pal));
727        assert_eq!(bm.get(0, 0), [255, 0, 0, 255]);
728        assert_eq!(bm.get(1, 0), [0, 255, 0, 128]);
729        assert_eq!(bm.get(0, 1), [0, 0, 255, 255]);
730        assert_eq!(bm.get(1, 1), [0, 255, 0, 128]);
731    }
732
733    /// `videoframe_to_rgba` with no palette continues to splat the
734    /// 1-byte plane as grayscale (Gray8 path) — back-compat for
735    /// non-palette grayscale PNGs.
736    #[test]
737    fn videoframe_gray8_to_bitmap_without_palette() {
738        let frame = VideoFrame {
739            pts: None,
740            planes: vec![VideoPlane {
741                stride: 2,
742                data: vec![100, 200, 50, 25],
743            }],
744        };
745        let bm = videoframe_to_rgba(&frame, 2, 2, None);
746        assert_eq!(bm.get(0, 0), [100, 100, 100, 255]);
747        assert_eq!(bm.get(1, 0), [200, 200, 200, 255]);
748        assert_eq!(bm.get(0, 1), [50, 50, 50, 255]);
749        assert_eq!(bm.get(1, 1), [25, 25, 25, 255]);
750    }
751}