Skip to main content

oxideav_cli_convert/
raster_io.rs

1//! Raster output plumbing shared by every side-channel that produces
2//! pixel buffers (PDF page rasterisation, 3D scene rendering).
3//!
4//! Centralises:
5//!
6//! * The owned [`RgbaImage`] handoff buffer.
7//! * Raster output classification ([`OutputClass`] / [`RasterFormat`])
8//!   so a single match-arm covers `.png` / `.jpg` / `.bmp` / `.webp`.
9//! * The per-format encoders (`encode_png`, `encode_jpeg`,
10//!   `encode_bmp`, `encode_webp`) that wrap the workspace's
11//!   `oxideav-png` / `oxideav-mjpeg` / `oxideav-bmp` / `oxideav-webp`
12//!   public APIs.
13//! * The alpha-channel post-processors (`-alpha on/off/remove/set/
14//!   transparent`) so both the PDF runner and the 3D-render runner
15//!   honour `-alpha` identically.
16//!
17//! Keeping these one module up means the new 3D-render side-channel
18//! doesn't have to reach into [`crate::pdf_runner`] internals — and a
19//! future TIFF / GIF / PPM encoder lands in one place rather than two.
20
21use std::fs;
22use std::path::Path;
23
24use oxideav_core::{Error, PixelFormat, Result, VideoFrame, VideoPlane};
25use oxideav_pixfmt::{convert as pix_convert, ConvertOptions, FrameInfo};
26
27use crate::op::{AlphaOp, Op};
28
29/// What kind of output the convert verb is producing — drives the
30/// routing decision in the side-channel runners. Add new arms when new
31/// formats are wired (e.g. `.tiff` → Raster once oxideav-tiff has an
32/// encoder).
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum OutputClass {
35    /// Format consumes a whole [`oxideav_scene::Scene`] (`.pdf` today;
36    /// multi-page SVG would land here if we ever support it).
37    Scene,
38    /// Format takes a single [`oxideav_core::vector::VectorFrame`]
39    /// per file, no rasterisation (`.svg`).
40    Vector,
41    /// Format takes a rasterised RGBA / RGB buffer per file
42    /// (`.png` `.jpg` `.bmp` `.webp`, …).
43    Raster(RasterFormat),
44}
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum RasterFormat {
48    Png,
49    Jpeg,
50    Bmp,
51    Webp,
52}
53
54/// Classify an output path / printf template by extension.
55///
56/// Returns `Err(Error::Unsupported)` for extensions outside the
57/// currently-wired set. Used by every side-channel that decides
58/// "Scene-aware writer? Vector? Raster?" off the output filename.
59pub fn classify_output(path_or_template: &str) -> Result<OutputClass> {
60    let ext = ext_of(path_or_template)
61        .ok_or_else(|| {
62            Error::invalid(format!(
63                "convert: output '{path_or_template}' has no extension"
64            ))
65        })?
66        .to_ascii_lowercase();
67    match ext.as_str() {
68        "pdf" => Ok(OutputClass::Scene),
69        "svg" => Ok(OutputClass::Vector),
70        "png" | "apng" => Ok(OutputClass::Raster(RasterFormat::Png)),
71        "jpg" | "jpeg" => Ok(OutputClass::Raster(RasterFormat::Jpeg)),
72        "bmp" | "dib" => Ok(OutputClass::Raster(RasterFormat::Bmp)),
73        "webp" => Ok(OutputClass::Raster(RasterFormat::Webp)),
74        other => Err(Error::unsupported(format!(
75            "convert: output extension '.{other}' not yet supported (today png/jpg/bmp/webp/svg/pdf)"
76        ))),
77    }
78}
79
80fn ext_of(path: &str) -> Option<&str> {
81    let last = path.rsplit('/').next().unwrap_or(path);
82    let last = last.split('?').next().unwrap_or(last);
83    let dot = last.rfind('.')?;
84    Some(&last[dot + 1..])
85}
86
87/// Owned packed RGBA / RGB24 buffer with explicit dimensions. Lives
88/// only long enough to hand off to an encoder. The pixel-format side
89/// is encoded by `stride / width` (4 = Rgba, 3 = Rgb24).
90#[derive(Debug, Clone)]
91pub struct RgbaImage {
92    pub width: u32,
93    pub height: u32,
94    pub pixels: Vec<u8>,
95    pub stride: usize,
96}
97
98impl RgbaImage {
99    /// True when the buffer is packed Rgb24 (3 bytes per pixel) rather
100    /// than Rgba (4 bytes). Inferred off `stride` since the struct
101    /// doesn't carry an explicit pixel-format tag.
102    pub fn is_rgb(&self) -> bool {
103        self.stride == (self.width as usize) * 3
104    }
105}
106
107/// Encode a single rendered image to disk via the format-specific
108/// encoder. Format-side details (palette, YUV conversion, lossless
109/// vs lossy choice) live here so the routing in the side-channel
110/// runners stays simple.
111pub fn encode_raster_to_path(
112    img: &RgbaImage,
113    fmt: RasterFormat,
114    path: &str,
115    ops: &[Op],
116) -> Result<()> {
117    let bytes = match fmt {
118        RasterFormat::Png => encode_png(img)?,
119        RasterFormat::Bmp => encode_bmp(img)?,
120        RasterFormat::Webp => encode_webp(img)?,
121        RasterFormat::Jpeg => encode_jpeg(img, ops)?,
122    };
123    fs::write(Path::new(path), bytes)
124        .map_err(|e| Error::invalid(format!("convert: failed to write {path}: {e}")))?;
125    Ok(())
126}
127
128fn last_quality(ops: &[Op]) -> Option<u8> {
129    ops.iter().rev().find_map(|o| match o {
130        Op::Quality(q) => Some((*q).min(100) as u8),
131        _ => None,
132    })
133}
134
135/// Apply the `-alpha …` ops in source order to an in-place [`RgbaImage`].
136///
137/// `bg` is the active `-background` value (defaults to opaque white at
138/// the call site); only `AlphaOp::Remove` actually consults it.
139pub fn apply_alpha_ops(img: &mut RgbaImage, ops: &[Op], bg: [u8; 4]) {
140    for op in ops {
141        if let Op::Alpha(a) = op {
142            match a {
143                AlphaOp::On => {}
144                AlphaOp::Off => drop_alpha(img),
145                AlphaOp::Remove => flatten_alpha_over(img, bg),
146                AlphaOp::Set => set_alpha(img, 255),
147                AlphaOp::Transparent => set_alpha(img, 0),
148            }
149        }
150    }
151}
152
153fn flatten_alpha_over(img: &mut RgbaImage, bg: [u8; 4]) {
154    if img.is_rgb() {
155        return;
156    }
157    let bg_r = bg[0] as u32;
158    let bg_g = bg[1] as u32;
159    let bg_b = bg[2] as u32;
160    for px in img.pixels.chunks_exact_mut(4) {
161        let a = px[3] as u32;
162        if a == 255 {
163            continue;
164        }
165        let inv = 255 - a;
166        let r = (px[0] as u32 * a + bg_r * inv + 127) / 255;
167        let g = (px[1] as u32 * a + bg_g * inv + 127) / 255;
168        let b = (px[2] as u32 * a + bg_b * inv + 127) / 255;
169        px[0] = r as u8;
170        px[1] = g as u8;
171        px[2] = b as u8;
172        px[3] = 255;
173    }
174}
175
176fn drop_alpha(img: &mut RgbaImage) {
177    if img.is_rgb() {
178        return;
179    }
180    let w = img.width as usize;
181    let h = img.height as usize;
182    let mut out = Vec::with_capacity(w * h * 3);
183    for px in img.pixels.chunks_exact(4) {
184        out.extend_from_slice(&px[..3]);
185    }
186    img.pixels = out;
187    img.stride = w * 3;
188}
189
190fn set_alpha(img: &mut RgbaImage, value: u8) {
191    if img.is_rgb() {
192        return;
193    }
194    for px in img.pixels.chunks_exact_mut(4) {
195        px[3] = value;
196    }
197}
198
199fn encode_png(img: &RgbaImage) -> Result<Vec<u8>> {
200    use oxideav_png::{encode_png_image, PngImage, PngPixelFormat};
201    let pf = if img.is_rgb() {
202        PngPixelFormat::Rgb24
203    } else {
204        PngPixelFormat::Rgba
205    };
206    let png = PngImage {
207        width: img.width,
208        height: img.height,
209        pixel_format: pf,
210        stride: img.stride,
211        data: img.pixels.clone(),
212        palette: Vec::new(),
213    };
214    encode_png_image(&png).map_err(|e| Error::invalid(format!("convert: PNG encode failed: {e:?}")))
215}
216
217fn encode_bmp(img: &RgbaImage) -> Result<Vec<u8>> {
218    use oxideav_bmp::{encode_bmp as bmp_encode, BmpImage, BmpPixelFormat, BmpPlane};
219    let pf = if img.is_rgb() {
220        BmpPixelFormat::Rgb24
221    } else {
222        BmpPixelFormat::Rgba
223    };
224    let bmp = BmpImage {
225        width: img.width,
226        height: img.height,
227        pixel_format: pf,
228        planes: vec![BmpPlane {
229            stride: img.stride,
230            data: img.pixels.clone(),
231        }],
232        palette: None,
233        pts: None,
234    };
235    bmp_encode(&bmp)
236        .map(|(bytes, _format)| bytes)
237        .map_err(|e| Error::invalid(format!("convert: BMP encode failed: {e:?}")))
238}
239
240fn encode_webp(_img: &RgbaImage) -> Result<Vec<u8>> {
241    // VP8L encoder + RIFF assembly was removed by the 2026-05-20
242    // clean-room orphan rebuild of `oxideav-webp`. The crate now
243    // exposes only container walk + typed VP8X / ALPH / ANIM / ANMF
244    // headers (RFC 9649 §2.x) — no encoder, no RIFF builders. WebP
245    // encode is gated off until a clean-room VP8L encoder lands.
246    Err(Error::invalid(
247        "convert: WebP encode pending oxideav-webp clean-room VP8L encoder",
248    ))
249}
250
251fn encode_jpeg(img: &RgbaImage, ops: &[Op]) -> Result<Vec<u8>> {
252    use oxideav_mjpeg::encoder::encode_jpeg as jpeg_encode;
253    let quality = last_quality(ops).unwrap_or(85);
254
255    let src_format = if img.is_rgb() {
256        PixelFormat::Rgb24
257    } else {
258        PixelFormat::Rgba
259    };
260    let src_frame = VideoFrame {
261        pts: None,
262        planes: vec![VideoPlane {
263            stride: img.stride,
264            data: img.pixels.clone(),
265        }],
266    };
267    let info = FrameInfo::new(src_format, img.width, img.height);
268    let yuv = pix_convert(
269        &src_frame,
270        info,
271        PixelFormat::Yuv420P,
272        &ConvertOptions::default(),
273    )
274    .map_err(|e| Error::invalid(format!("convert: RGB→YUV420P conversion failed: {e:?}")))?;
275    jpeg_encode(&yuv, img.width, img.height, PixelFormat::Yuv420P, quality)
276        .map_err(|e| Error::invalid(format!("convert: JPEG encode failed: {e:?}")))
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn classify_output_covers_known_targets() {
285        assert!(matches!(classify_output("out.pdf"), Ok(OutputClass::Scene)));
286        assert!(matches!(
287            classify_output("out.svg"),
288            Ok(OutputClass::Vector)
289        ));
290        assert!(matches!(
291            classify_output("out.png"),
292            Ok(OutputClass::Raster(RasterFormat::Png))
293        ));
294        assert!(matches!(
295            classify_output("out.jpg"),
296            Ok(OutputClass::Raster(RasterFormat::Jpeg))
297        ));
298        assert!(matches!(
299            classify_output("out.jpeg"),
300            Ok(OutputClass::Raster(RasterFormat::Jpeg))
301        ));
302        assert!(matches!(
303            classify_output("out.bmp"),
304            Ok(OutputClass::Raster(RasterFormat::Bmp))
305        ));
306        assert!(matches!(
307            classify_output("out.webp"),
308            Ok(OutputClass::Raster(RasterFormat::Webp))
309        ));
310        assert!(classify_output("out.tiff").is_err());
311        assert!(classify_output("out.gif").is_err());
312        assert!(classify_output("noext").is_err());
313    }
314
315    #[test]
316    fn drop_alpha_shrinks_buffer_three_quarters() {
317        let mut img = RgbaImage {
318            width: 2,
319            height: 2,
320            pixels: vec![
321                10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160,
322            ],
323            stride: 8,
324        };
325        drop_alpha(&mut img);
326        assert_eq!(img.stride, 6);
327        assert_eq!(img.pixels.len(), 12);
328        assert_eq!(img.pixels[..3], [10, 20, 30]);
329        assert_eq!(img.pixels[3..6], [50, 60, 70]);
330    }
331
332    #[test]
333    fn flatten_alpha_over_white_preserves_opaque_pixels() {
334        let mut img = RgbaImage {
335            width: 2,
336            height: 1,
337            pixels: vec![255, 0, 0, 255, 0, 0, 0, 0],
338            stride: 8,
339        };
340        flatten_alpha_over(&mut img, [255, 255, 255, 255]);
341        assert_eq!(img.pixels[..4], [255, 0, 0, 255]);
342        assert_eq!(img.pixels[4..], [255, 255, 255, 255]);
343    }
344
345    #[test]
346    fn set_alpha_to_zero_leaves_colour_intact() {
347        let mut img = RgbaImage {
348            width: 1,
349            height: 1,
350            pixels: vec![10, 20, 30, 100],
351            stride: 4,
352        };
353        set_alpha(&mut img, 0);
354        assert_eq!(img.pixels, [10, 20, 30, 0]);
355    }
356}