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}