⚠️ VeridianOS Kernel Documentation - This is low-level kernel code. All functions are unsafe unless explicitly marked otherwise. no_std

veridian_kernel/desktop/
image_viewer.rs

1//! Image Viewer
2//!
3//! Displays PPM (P3/P6) and BMP images with scaling support.
4
5#![allow(dead_code)]
6
7use alloc::{string::String, vec, vec::Vec};
8
9use super::renderer::draw_string_into_buffer;
10
11// ---------------------------------------------------------------------------
12// Image types
13// ---------------------------------------------------------------------------
14
15/// Supported image formats.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ImageFormat {
18    Ppm,
19    Bmp,
20    Tga,
21    Qoi,
22    Unknown,
23}
24
25/// Decoded image stored as BGRA `u32` pixels.
26#[derive(Debug, Clone)]
27pub struct Image {
28    pub width: usize,
29    pub height: usize,
30    /// Row-major BGRA pixels: `pixels[y * width + x]`.
31    pub pixels: Vec<u32>,
32    pub format: ImageFormat,
33}
34
35/// State of the viewer.
36#[derive(Debug, Clone)]
37pub enum ImageViewerState {
38    Empty,
39    Loading,
40    Loaded,
41    Error(String),
42}
43
44// ---------------------------------------------------------------------------
45// Actions returned by input handlers
46// ---------------------------------------------------------------------------
47
48/// Action produced by image viewer interaction.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum ImageViewerAction {
51    None,
52    Close,
53    ZoomIn,
54    ZoomOut,
55    ZoomFit,
56}
57
58// ---------------------------------------------------------------------------
59// Main application struct
60// ---------------------------------------------------------------------------
61
62/// Image viewer application state.
63pub struct ImageViewer {
64    pub state: ImageViewerState,
65    pub image: Option<Image>,
66    pub filename: String,
67
68    /// Zoom level as a percentage (100 = 1:1). Range 25..=400.
69    pub zoom_level: usize,
70
71    /// Pan offset (pixels in image-space).
72    pub offset_x: isize,
73    pub offset_y: isize,
74
75    /// Compositor surface ID (set when wired to the desktop).
76    pub surface_id: Option<u32>,
77
78    /// Window dimensions.
79    pub width: usize,
80    pub height: usize,
81
82    /// Height of the toolbar in pixels.
83    pub toolbar_height: usize,
84}
85
86const ZOOM_MIN: usize = 25;
87const ZOOM_MAX: usize = 400;
88const ZOOM_STEP: usize = 25;
89const PAN_STEP: isize = 16;
90
91impl ImageViewer {
92    /// Create a new, empty image viewer.
93    pub fn new() -> Self {
94        Self {
95            state: ImageViewerState::Empty,
96            image: None,
97            filename: String::new(),
98            zoom_level: 100,
99            offset_x: 0,
100            offset_y: 0,
101            surface_id: None,
102            width: 640,
103            height: 480,
104            toolbar_height: 32,
105        }
106    }
107
108    // -----------------------------------------------------------------------
109    // Loaders
110    // -----------------------------------------------------------------------
111
112    /// Load a PPM image (P3 ASCII or P6 binary).
113    pub fn load_ppm(data: &[u8]) -> Result<Image, &'static str> {
114        if data.len() < 3 {
115            return Err("ppm: data too short");
116        }
117
118        let is_p6 = data.starts_with(b"P6");
119        let is_p3 = data.starts_with(b"P3");
120        if !is_p3 && !is_p6 {
121            return Err("ppm: unsupported magic (expected P3 or P6)");
122        }
123
124        // Find the header portion (width, height, maxval).
125        // Skip comments (lines starting with '#').
126        let mut pos: usize = 2; // skip "Px"
127        let mut tokens: Vec<usize> = Vec::new();
128
129        while tokens.len() < 3 && pos < data.len() {
130            // Skip whitespace
131            while pos < data.len()
132                && (data[pos] == b' '
133                    || data[pos] == b'\n'
134                    || data[pos] == b'\r'
135                    || data[pos] == b'\t')
136            {
137                pos += 1;
138            }
139            // Skip comment
140            if pos < data.len() && data[pos] == b'#' {
141                while pos < data.len() && data[pos] != b'\n' {
142                    pos += 1;
143                }
144                continue;
145            }
146            // Read number
147            let start = pos;
148            while pos < data.len() && data[pos] >= b'0' && data[pos] <= b'9' {
149                pos += 1;
150            }
151            if pos > start {
152                let num = parse_ascii_usize(&data[start..pos])?;
153                tokens.push(num);
154            }
155        }
156
157        if tokens.len() < 3 {
158            return Err("ppm: incomplete header");
159        }
160
161        let width = tokens[0];
162        let height = tokens[1];
163        let max_val = tokens[2];
164        if width == 0 || height == 0 || max_val == 0 {
165            return Err("ppm: zero dimension or maxval");
166        }
167
168        let pixel_count = width * height;
169        let mut pixels = Vec::with_capacity(pixel_count);
170
171        if is_p6 {
172            // Binary -- skip exactly one whitespace byte after maxval
173            if pos < data.len() {
174                pos += 1;
175            }
176            let bpp = if max_val > 255 { 6 } else { 3 };
177            let needed = pixel_count * bpp;
178            if pos + needed > data.len() {
179                return Err("ppm: P6 data truncated");
180            }
181            if bpp == 3 {
182                for i in 0..pixel_count {
183                    let base = pos + i * 3;
184                    let r = data[base] as u32;
185                    let g = data[base + 1] as u32;
186                    let b = data[base + 2] as u32;
187                    pixels.push(0xFF00_0000 | (r << 16) | (g << 8) | b);
188                }
189            } else {
190                // 16-bit channels -- take high byte
191                for i in 0..pixel_count {
192                    let base = pos + i * 6;
193                    let r = data[base] as u32;
194                    let g = data[base + 2] as u32;
195                    let b = data[base + 4] as u32;
196                    pixels.push(0xFF00_0000 | (r << 16) | (g << 8) | b);
197                }
198            }
199        } else {
200            // ASCII (P3) -- read 3 * pixel_count integers
201            let mut rgb_vals: Vec<u32> = Vec::new();
202            while rgb_vals.len() < pixel_count * 3 && pos < data.len() {
203                // Skip whitespace / comments
204                while pos < data.len()
205                    && (data[pos] == b' '
206                        || data[pos] == b'\n'
207                        || data[pos] == b'\r'
208                        || data[pos] == b'\t')
209                {
210                    pos += 1;
211                }
212                if pos < data.len() && data[pos] == b'#' {
213                    while pos < data.len() && data[pos] != b'\n' {
214                        pos += 1;
215                    }
216                    continue;
217                }
218                let start = pos;
219                while pos < data.len() && data[pos] >= b'0' && data[pos] <= b'9' {
220                    pos += 1;
221                }
222                if pos > start {
223                    let v = parse_ascii_usize(&data[start..pos]).unwrap_or(0) as u32;
224                    // Normalise to 0..255 if max_val != 255
225                    let normalised = if max_val != 255 {
226                        v * 255 / (max_val as u32)
227                    } else {
228                        v
229                    };
230                    rgb_vals.push(normalised);
231                }
232            }
233
234            if rgb_vals.len() < pixel_count * 3 {
235                return Err("ppm: P3 data truncated");
236            }
237
238            for i in 0..pixel_count {
239                let r = rgb_vals[i * 3];
240                let g = rgb_vals[i * 3 + 1];
241                let b = rgb_vals[i * 3 + 2];
242                pixels.push(0xFF00_0000 | (r << 16) | (g << 8) | b);
243            }
244        }
245
246        Ok(Image {
247            width,
248            height,
249            pixels,
250            format: ImageFormat::Ppm,
251        })
252    }
253
254    /// Load a BMP image (24-bit or 32-bit uncompressed).
255    pub fn load_bmp(data: &[u8]) -> Result<Image, &'static str> {
256        if data.len() < 54 {
257            return Err("bmp: data too short for header");
258        }
259        if data[0] != 0x42 || data[1] != 0x4D {
260            return Err("bmp: invalid magic");
261        }
262
263        // BMP header fields (little-endian)
264        let data_offset = read_le_u32(data, 10) as usize;
265        let dib_size = read_le_u32(data, 14);
266        if dib_size < 40 {
267            return Err("bmp: unsupported DIB header size");
268        }
269
270        let width = read_le_i32(data, 18);
271        let height_raw = read_le_i32(data, 22);
272        let bpp = read_le_u16(data, 28) as usize;
273        let compression = read_le_u32(data, 30);
274
275        if compression != 0 && compression != 3 {
276            return Err("bmp: compressed BMPs not supported");
277        }
278        if bpp != 24 && bpp != 32 {
279            return Err("bmp: only 24-bit and 32-bit supported");
280        }
281        if width <= 0 {
282            return Err("bmp: invalid width");
283        }
284
285        let w = width as usize;
286        // Negative height means top-down storage.
287        let (h, bottom_up) = if height_raw < 0 {
288            ((-height_raw) as usize, false)
289        } else {
290            (height_raw as usize, true)
291        };
292
293        if w == 0 || h == 0 {
294            return Err("bmp: zero dimension");
295        }
296
297        let bytes_per_pixel = bpp / 8;
298        let row_size_raw = w * bytes_per_pixel;
299        // BMP rows are padded to 4-byte boundaries.
300        let row_stride = (row_size_raw + 3) & !3;
301
302        let needed = data_offset + row_stride * h;
303        if data.len() < needed {
304            return Err("bmp: pixel data truncated");
305        }
306
307        let pixel_count = w * h;
308        let mut pixels = vec![0u32; pixel_count];
309
310        for row in 0..h {
311            let src_row = if bottom_up { h - 1 - row } else { row };
312            let src_off = data_offset + src_row * row_stride;
313            let dst_off = row * w;
314
315            for col in 0..w {
316                let px_off = src_off + col * bytes_per_pixel;
317                let b = data[px_off] as u32;
318                let g = data[px_off + 1] as u32;
319                let r = data[px_off + 2] as u32;
320                let a = if bpp == 32 {
321                    data[px_off + 3] as u32
322                } else {
323                    0xFF
324                };
325                pixels[dst_off + col] = (a << 24) | (r << 16) | (g << 8) | b;
326            }
327        }
328
329        Ok(Image {
330            width: w,
331            height: h,
332            pixels,
333            format: ImageFormat::Bmp,
334        })
335    }
336
337    /// Load a TGA image via the video decoder, converting to BGRA u32 pixels.
338    pub fn load_tga(data: &[u8]) -> Result<Image, &'static str> {
339        let frame = crate::video::decode::decode_tga(data).map_err(|_| "tga: decode failed")?;
340        Ok(video_frame_to_image(&frame, ImageFormat::Tga))
341    }
342
343    /// Load a QOI image via the video decoder, converting to BGRA u32 pixels.
344    pub fn load_qoi(data: &[u8]) -> Result<Image, &'static str> {
345        let frame = crate::video::decode::decode_qoi(data).map_err(|_| "qoi: decode failed")?;
346        Ok(video_frame_to_image(&frame, ImageFormat::Qoi))
347    }
348
349    /// Load an image file from raw byte data, auto-detecting the format.
350    pub fn load_file(&mut self, filename: &str, data: &[u8]) {
351        self.filename = String::from(filename);
352        self.state = ImageViewerState::Loading;
353
354        let fmt = detect_image_format(data);
355        let result = match fmt {
356            ImageFormat::Ppm => Self::load_ppm(data),
357            ImageFormat::Bmp => Self::load_bmp(data),
358            ImageFormat::Tga => Self::load_tga(data),
359            ImageFormat::Qoi => Self::load_qoi(data),
360            ImageFormat::Unknown => {
361                // Try extension-based detection
362                if filename.ends_with(".ppm") || filename.ends_with(".pnm") {
363                    Self::load_ppm(data)
364                } else if filename.ends_with(".bmp") {
365                    Self::load_bmp(data)
366                } else if filename.ends_with(".tga") {
367                    Self::load_tga(data)
368                } else if filename.ends_with(".qoi") {
369                    Self::load_qoi(data)
370                } else {
371                    Err("unknown image format")
372                }
373            }
374        };
375
376        match result {
377            Ok(img) => {
378                self.image = Some(img);
379                self.state = ImageViewerState::Loaded;
380                self.zoom_level = 100;
381                self.offset_x = 0;
382                self.offset_y = 0;
383            }
384            Err(e) => {
385                self.image = None;
386                self.state = ImageViewerState::Error(String::from(e));
387            }
388        }
389    }
390
391    // -----------------------------------------------------------------------
392    // Zoom & pan
393    // -----------------------------------------------------------------------
394
395    /// Increase zoom by one step.
396    pub fn zoom_in(&mut self) {
397        if self.zoom_level < ZOOM_MAX {
398            self.zoom_level += ZOOM_STEP;
399        }
400    }
401
402    /// Decrease zoom by one step.
403    pub fn zoom_out(&mut self) {
404        if self.zoom_level > ZOOM_MIN {
405            self.zoom_level -= ZOOM_STEP;
406        }
407    }
408
409    /// Fit image to viewer area.
410    pub fn zoom_fit(&mut self) {
411        if let Some(ref img) = self.image {
412            if img.width == 0 || img.height == 0 {
413                return;
414            }
415            let view_w = self.width;
416            let view_h = self.height.saturating_sub(self.toolbar_height);
417            // zoom = min(view_w * 100 / img_w, view_h * 100 / img_h), clamped
418            let zx = view_w * 100 / img.width;
419            let zy = view_h * 100 / img.height;
420            let z = zx.min(zy).clamp(ZOOM_MIN, ZOOM_MAX);
421            self.zoom_level = z;
422            self.offset_x = 0;
423            self.offset_y = 0;
424        }
425    }
426
427    // -----------------------------------------------------------------------
428    // Input
429    // -----------------------------------------------------------------------
430
431    /// Handle a keyboard event and return the resulting action.
432    pub fn handle_key(&mut self, key: u8) -> ImageViewerAction {
433        match key {
434            b'+' | b'=' => {
435                self.zoom_in();
436                ImageViewerAction::ZoomIn
437            }
438            b'-' => {
439                self.zoom_out();
440                ImageViewerAction::ZoomOut
441            }
442            b'0' => {
443                self.zoom_fit();
444                ImageViewerAction::ZoomFit
445            }
446            // Arrow-key navigation (vi-style j/k/h/l)
447            b'h' | b'H' => {
448                self.offset_x -= PAN_STEP;
449                ImageViewerAction::None
450            }
451            b'l' | b'L' => {
452                self.offset_x += PAN_STEP;
453                ImageViewerAction::None
454            }
455            b'k' | b'K' => {
456                self.offset_y -= PAN_STEP;
457                ImageViewerAction::None
458            }
459            b'j' | b'J' => {
460                self.offset_y += PAN_STEP;
461                ImageViewerAction::None
462            }
463            0x1B => ImageViewerAction::Close,
464            _ => ImageViewerAction::None,
465        }
466    }
467
468    // -----------------------------------------------------------------------
469    // Rendering
470    // -----------------------------------------------------------------------
471
472    /// Render the image viewer into a `u32` BGRA pixel buffer.
473    ///
474    /// `buffer` must be at least `buf_width * buf_height` elements.
475    pub fn render_to_buffer(&self, buffer: &mut [u32], buf_width: usize, buf_height: usize) {
476        let byte_len = buf_width * buf_height * 4;
477        let mut byte_buf = vec![0u8; byte_len];
478
479        // -- checkerboard background below toolbar --
480        let tb_h = self.toolbar_height;
481        for y in tb_h..buf_height {
482            for x in 0..buf_width {
483                let off = (y * buf_width + x) * 4;
484                if off + 3 >= byte_buf.len() {
485                    break;
486                }
487                // 16x16 checkerboard
488                let gray: u8 = if ((x >> 4) ^ ((y - tb_h) >> 4)) & 1 == 0 {
489                    0x3C
490                } else {
491                    0x48
492                };
493                byte_buf[off] = gray;
494                byte_buf[off + 1] = gray;
495                byte_buf[off + 2] = gray;
496                byte_buf[off + 3] = 0xFF;
497            }
498        }
499
500        // -- toolbar background (dark) --
501        for y in 0..tb_h.min(buf_height) {
502            for x in 0..buf_width {
503                let off = (y * buf_width + x) * 4;
504                if off + 3 < byte_buf.len() {
505                    byte_buf[off] = 0x2A;
506                    byte_buf[off + 1] = 0x2A;
507                    byte_buf[off + 2] = 0x2A;
508                    byte_buf[off + 3] = 0xFF;
509                }
510            }
511        }
512
513        // -- toolbar text --
514        {
515            // Filename
516            if !self.filename.is_empty() {
517                draw_string_into_buffer(
518                    &mut byte_buf,
519                    buf_width,
520                    self.filename.as_bytes(),
521                    8,
522                    8,
523                    0xDDDDDD,
524                );
525            } else {
526                draw_string_into_buffer(&mut byte_buf, buf_width, b"(no image)", 8, 8, 0x888888);
527            }
528
529            // Zoom indicator on right side
530            let zoom_str = format_zoom(self.zoom_level);
531            let zoom_x = buf_width.saturating_sub(zoom_str.len() * 8 + 8);
532            draw_string_into_buffer(&mut byte_buf, buf_width, &zoom_str, zoom_x, 8, 0xAAFFAA);
533
534            // Zoom buttons: [-] [+]  (visual hint; not clickable yet)
535            let btn_x = zoom_x.saturating_sub(56);
536            draw_string_into_buffer(&mut byte_buf, buf_width, b"[-] [+]", btn_x, 8, 0x888888);
537        }
538
539        // -- separator line under toolbar --
540        {
541            let y = tb_h.saturating_sub(1);
542            for x in 0..buf_width {
543                let off = (y * buf_width + x) * 4;
544                if off + 3 < byte_buf.len() {
545                    byte_buf[off] = 0x55;
546                    byte_buf[off + 1] = 0x55;
547                    byte_buf[off + 2] = 0x55;
548                    byte_buf[off + 3] = 0xFF;
549                }
550            }
551        }
552
553        // -- draw image --
554        if let Some(ref img) = self.image {
555            let view_w = buf_width;
556            let view_h = buf_height.saturating_sub(tb_h);
557
558            // Scaled image dimensions (integer math)
559            let scaled_w = img.width * self.zoom_level / 100;
560            let scaled_h = img.height * self.zoom_level / 100;
561
562            // Center if smaller than viewport, otherwise use offset
563            let base_x: isize = if scaled_w < view_w {
564                ((view_w - scaled_w) / 2) as isize
565            } else {
566                -self.offset_x
567            };
568            let base_y: isize = if scaled_h < view_h {
569                ((view_h - scaled_h) / 2) as isize
570            } else {
571                -self.offset_y
572            };
573
574            // Blit with nearest-neighbor scaling
575            for dy in 0..view_h {
576                let dst_y = tb_h + dy;
577                if dst_y >= buf_height {
578                    break;
579                }
580
581                let img_rel_y = dy as isize - base_y;
582                if img_rel_y < 0 || img_rel_y >= scaled_h as isize {
583                    continue;
584                }
585                // Map back to source pixel: src_y = rel_y * 100 / zoom
586                let src_y = (img_rel_y as usize) * 100 / self.zoom_level;
587                if src_y >= img.height {
588                    continue;
589                }
590
591                for dx in 0..view_w {
592                    if dx >= buf_width {
593                        break;
594                    }
595
596                    let img_rel_x = dx as isize - base_x;
597                    if img_rel_x < 0 || img_rel_x >= scaled_w as isize {
598                        continue;
599                    }
600                    let src_x = (img_rel_x as usize) * 100 / self.zoom_level;
601                    if src_x >= img.width {
602                        continue;
603                    }
604
605                    let src_px = img.pixels[src_y * img.width + src_x];
606                    let a = ((src_px >> 24) & 0xFF) as u8;
607                    let r = ((src_px >> 16) & 0xFF) as u8;
608                    let g = ((src_px >> 8) & 0xFF) as u8;
609                    let b = (src_px & 0xFF) as u8;
610
611                    let off = (dst_y * buf_width + dx) * 4;
612                    if off + 3 < byte_buf.len() {
613                        if a == 0xFF {
614                            byte_buf[off] = b;
615                            byte_buf[off + 1] = g;
616                            byte_buf[off + 2] = r;
617                            byte_buf[off + 3] = 0xFF;
618                        } else if a > 0 {
619                            // Alpha blend with background (integer math)
620                            let inv = 255 - a as u16;
621                            let a16 = a as u16;
622                            byte_buf[off] =
623                                ((b as u16 * a16 + byte_buf[off] as u16 * inv) / 255) as u8;
624                            byte_buf[off + 1] =
625                                ((g as u16 * a16 + byte_buf[off + 1] as u16 * inv) / 255) as u8;
626                            byte_buf[off + 2] =
627                                ((r as u16 * a16 + byte_buf[off + 2] as u16 * inv) / 255) as u8;
628                            byte_buf[off + 3] = 0xFF;
629                        }
630                        // a == 0: fully transparent, keep background
631                    }
632                }
633            }
634        } else {
635            // No image -- show status message
636            let msg: &[u8] = match &self.state {
637                ImageViewerState::Empty => b"No image loaded. Open PPM, BMP, TGA, or QOI.",
638                ImageViewerState::Loading => b"Loading...",
639                ImageViewerState::Error(_) => b"Error loading image.",
640                ImageViewerState::Loaded => b"(empty)",
641            };
642            let msg_x = buf_width / 2 - (msg.len() * 8) / 2;
643            let msg_y = tb_h + (buf_height.saturating_sub(tb_h)) / 2;
644            draw_string_into_buffer(&mut byte_buf, buf_width, msg, msg_x, msg_y, 0x888888);
645        }
646
647        // Convert byte buffer (BGRA u8) into u32 buffer
648        for (i, chunk) in byte_buf.chunks_exact(4).enumerate() {
649            if i < buffer.len() {
650                buffer[i] = (chunk[3] as u32) << 24
651                    | (chunk[2] as u32) << 16
652                    | (chunk[1] as u32) << 8
653                    | (chunk[0] as u32);
654            }
655        }
656    }
657}
658
659// ---------------------------------------------------------------------------
660// Standalone helpers
661// ---------------------------------------------------------------------------
662
663/// Detect image format from the first bytes of the data.
664pub fn detect_image_format(data: &[u8]) -> ImageFormat {
665    if data.len() >= 4 {
666        // QOI: magic "qoif"
667        if data[0] == b'q' && data[1] == b'o' && data[2] == b'i' && data[3] == b'f' {
668            return ImageFormat::Qoi;
669        }
670    }
671    if data.len() >= 2 {
672        // PPM magic: P3 or P6
673        if data[0] == b'P' && (data[1] == b'3' || data[1] == b'6') {
674            return ImageFormat::Ppm;
675        }
676        // BMP magic: 0x42 0x4D ("BM")
677        if data[0] == 0x42 && data[1] == 0x4D {
678            return ImageFormat::Bmp;
679        }
680    }
681    // TGA heuristic (no reliable magic): check header fields
682    if data.len() >= 18 {
683        let color_map_type = data[1];
684        let image_type = data[2];
685        let pixel_depth = data[16];
686        let valid_cmt = color_map_type <= 1;
687        let valid_type = matches!(image_type, 1 | 2 | 3 | 9 | 10 | 11);
688        let valid_depth = matches!(pixel_depth, 8 | 15 | 16 | 24 | 32);
689        if valid_cmt && valid_type && valid_depth {
690            return ImageFormat::Tga;
691        }
692    }
693    ImageFormat::Unknown
694}
695
696/// Convert a `VideoFrame` from the video subsystem into the image viewer's
697/// `Image` format (BGRA u32 pixels).
698fn video_frame_to_image(frame: &crate::video::VideoFrame, fmt: ImageFormat) -> Image {
699    let w = frame.width as usize;
700    let h = frame.height as usize;
701    let pixel_count = w * h;
702    let mut pixels = Vec::with_capacity(pixel_count);
703
704    for y in 0..frame.height {
705        for x in 0..frame.width {
706            let (r, g, b, a) = frame.get_pixel(x, y);
707            // Image stores BGRA as u32: A(31:24) R(23:16) G(15:8) B(7:0)
708            let px = ((a as u32) << 24) | ((r as u32) << 16) | ((g as u32) << 8) | (b as u32);
709            pixels.push(px);
710        }
711    }
712
713    Image {
714        width: w,
715        height: h,
716        pixels,
717        format: fmt,
718    }
719}
720
721/// Scale an image to `dst_width x dst_height` using nearest-neighbor sampling.
722///
723/// Returns a new pixel buffer.
724pub fn nearest_neighbor_scale(src: &Image, dst_width: usize, dst_height: usize) -> Vec<u32> {
725    if dst_width == 0 || dst_height == 0 || src.width == 0 || src.height == 0 {
726        return Vec::new();
727    }
728
729    let mut out = vec![0u32; dst_width * dst_height];
730    for dy in 0..dst_height {
731        let sy = dy * src.height / dst_height;
732        let sy = sy.min(src.height - 1);
733        for dx in 0..dst_width {
734            let sx = dx * src.width / dst_width;
735            let sx = sx.min(src.width - 1);
736            out[dy * dst_width + dx] = src.pixels[sy * src.width + sx];
737        }
738    }
739    out
740}
741
742// ---------------------------------------------------------------------------
743// Integer parsing / formatting helpers (no_std friendly)
744// ---------------------------------------------------------------------------
745
746/// Parse an ASCII decimal number from a byte slice.
747fn parse_ascii_usize(bytes: &[u8]) -> Result<usize, &'static str> {
748    let mut val: usize = 0;
749    for &b in bytes {
750        if !b.is_ascii_digit() {
751            return Err("non-digit in number");
752        }
753        val = val
754            .checked_mul(10)
755            .and_then(|v| v.checked_add((b - b'0') as usize))
756            .ok_or("number overflow")?;
757    }
758    Ok(val)
759}
760
761/// Read a little-endian u32 from a byte slice at the given offset.
762fn read_le_u32(data: &[u8], off: usize) -> u32 {
763    (data[off] as u32)
764        | ((data[off + 1] as u32) << 8)
765        | ((data[off + 2] as u32) << 16)
766        | ((data[off + 3] as u32) << 24)
767}
768
769/// Read a little-endian i32 from a byte slice at the given offset.
770fn read_le_i32(data: &[u8], off: usize) -> i32 {
771    read_le_u32(data, off) as i32
772}
773
774/// Read a little-endian u16 from a byte slice at the given offset.
775fn read_le_u16(data: &[u8], off: usize) -> u16 {
776    (data[off] as u16) | ((data[off + 1] as u16) << 8)
777}
778
779impl ImageViewer {
780    /// Render the image viewer into a `u8` BGRA pixel buffer.
781    ///
782    /// Delegates to `render_to_buffer` (u32), then converts to u8 bytes.
783    pub fn render_to_u8_buffer(&self, buf: &mut [u8], buf_width: usize, buf_height: usize) {
784        let pixel_count = buf_width * buf_height;
785        let mut u32_buf = vec![0u32; pixel_count];
786        self.render_to_buffer(&mut u32_buf, buf_width, buf_height);
787        // Convert u32 BGRA pixels to u8 BGRA bytes
788        for (i, &px) in u32_buf.iter().enumerate() {
789            let off = i * 4;
790            if off + 3 < buf.len() {
791                buf[off] = (px & 0xFF) as u8; // B
792                buf[off + 1] = ((px >> 8) & 0xFF) as u8; // G
793                buf[off + 2] = ((px >> 16) & 0xFF) as u8; // R
794                buf[off + 3] = ((px >> 24) & 0xFF) as u8; // A
795            }
796        }
797    }
798}
799
800impl Default for ImageViewer {
801    fn default() -> Self {
802        Self::new()
803    }
804}
805
806/// Format a zoom percentage as a stack-allocated ASCII string.
807///
808/// Returns a `Vec<u8>` like `b"100%"`.
809fn format_zoom(pct: usize) -> Vec<u8> {
810    use alloc::format;
811    let s = format!("{}%", pct);
812    s.into_bytes()
813}