Skip to main content

resopt/
portable.rs

1//! In-memory operations shared by the native CLI and browser adapters.
2//! No filesystem, processes, sockets, or platform image framework is used here.
3use crate::{
4    image_backend::{Decoded, ImageInfo},
5    optimizer, png_pixels, quality,
6};
7use anyhow::{Context, Result, ensure};
8use serde::Serialize;
9
10pub const WEB_MAX_INPUT_BYTES: usize = 16 * 1024 * 1024;
11pub const WEB_MAX_PIXELS: usize = 2 * 1024 * 1024;
12
13#[derive(Debug, Serialize)]
14pub struct PngSummary {
15    pub width: usize,
16    pub height: usize,
17    pub original_bytes: usize,
18    pub optimized_bytes: usize,
19    pub saved_bytes: usize,
20    pub transparent_pixels: usize,
21    pub pixel_equivalent: bool,
22    pub ssimulacra2: f64,
23    pub reductions: bool,
24}
25
26pub struct OptimizedPng {
27    pub bytes: Vec<u8>,
28    pub summary: PngSummary,
29}
30
31/// Optimize a static PNG within the browser's conservative per-image budget.
32/// Returns the original bytes when no smaller encoding is found.
33pub fn optimize_png(bytes: &[u8], effort: u8, reductions: bool) -> Result<OptimizedPng> {
34    ensure!(
35        bytes.len() <= WEB_MAX_INPUT_BYTES,
36        "PNG exceeds the 16 MiB browser input limit"
37    );
38    ensure!(effort <= 4, "browser effort must be 0..=4");
39    let original = decode_png(bytes, WEB_MAX_PIXELS)?;
40    let candidate = optimizer::optimize(
41        bytes,
42        &optimizer::Policy {
43            png_level: effort,
44            reductions,
45            ..Default::default()
46        },
47    )?;
48    optimizer::verify(bytes, &candidate, reductions)?;
49    let chosen = if candidate.len() < bytes.len() {
50        candidate
51    } else {
52        bytes.to_vec()
53    };
54    let decoded = decode_png(&chosen, WEB_MAX_PIXELS)?;
55    let score = quality::ssimulacra2(&original, &decoded)?;
56    Ok(OptimizedPng {
57        summary: PngSummary {
58            width: original.info.width,
59            height: original.info.height,
60            original_bytes: bytes.len(),
61            optimized_bytes: chosen.len(),
62            saved_bytes: bytes.len().saturating_sub(chosen.len()),
63            transparent_pixels: original.info.transparent_pixels,
64            pixel_equivalent: true,
65            ssimulacra2: score,
66            reductions,
67        },
68        bytes: chosen,
69    })
70}
71
72pub(crate) fn decode_png(bytes: &[u8], max_pixels: usize) -> Result<Decoded> {
73    let limit = max_pixels.checked_mul(16).context("pixel limit overflow")?;
74    let mut reader = png_pixels::reader(bytes, limit)?;
75    let width = reader.info().width as usize;
76    let height = reader.info().height as usize;
77    let count = width
78        .checked_mul(height)
79        .context("PNG dimensions overflow")?;
80    ensure!(
81        count > 0 && count <= max_pixels,
82        "decoded_image_exceeds_max_pixels"
83    );
84    ensure!(
85        reader.info().animation_control.is_none(),
86        "animated PNG is not supported"
87    );
88    let mut pixels = Vec::with_capacity(count * 4);
89    let mut transparent_pixels = 0;
90    while let Some(row) = png_pixels::next_rgba_row(&mut reader)? {
91        for pixel in row.as_chunks::<4>().0 {
92            let alpha = f32::from(pixel[3]) / 65535.0;
93            transparent_pixels += usize::from(pixel[3] != u16::MAX);
94            pixels.extend([
95                f32::from(pixel[0]) / 65535.0 * alpha,
96                f32::from(pixel[1]) / 65535.0 * alpha,
97                f32::from(pixel[2]) / 65535.0 * alpha,
98                alpha,
99            ]);
100        }
101    }
102    ensure!(pixels.len() == count * 4, "incomplete PNG pixels");
103    Ok(Decoded {
104        info: ImageInfo {
105            decoder_type: "png".into(),
106            width,
107            height,
108            frames: 1,
109            bits_per_component: 16,
110            orientation: 1,
111            transparent_pixels,
112            has_transparent_pixels: transparent_pixels > 0,
113        },
114        pixels,
115    })
116}
117
118/// Score caller-supplied straight-alpha, 8-bit sRGB RGBA buffers (ImageData).
119/// Callers must normalize image color profiles to sRGB before using this API.
120pub fn score_srgb_rgba(
121    width: usize,
122    height: usize,
123    original: &[u8],
124    candidate: &[u8],
125) -> Result<f64> {
126    let count = width
127        .checked_mul(height)
128        .context("image dimensions overflow")?;
129    ensure!(
130        count > 0 && count <= WEB_MAX_PIXELS,
131        "comparison exceeds browser pixel limit"
132    );
133    ensure!(
134        original.len() == count * 4 && candidate.len() == count * 4,
135        "RGBA buffer length mismatch"
136    );
137    let decode = |bytes: &[u8]| {
138        let transparent_pixels = bytes
139            .as_chunks::<4>()
140            .0
141            .iter()
142            .filter(|p| p[3] != 255)
143            .count();
144        Decoded {
145            info: ImageInfo {
146                decoder_type: "sRGB ImageData".into(),
147                width,
148                height,
149                frames: 1,
150                bits_per_component: 8,
151                orientation: 1,
152                transparent_pixels,
153                has_transparent_pixels: transparent_pixels > 0,
154            },
155            pixels: bytes
156                .as_chunks::<4>()
157                .0
158                .iter()
159                .flat_map(|p| {
160                    let a = f32::from(p[3]) / 255.0;
161                    [
162                        f32::from(p[0]) / 255.0 * a,
163                        f32::from(p[1]) / 255.0 * a,
164                        f32::from(p[2]) / 255.0 * a,
165                        a,
166                    ]
167                })
168                .collect(),
169        }
170    };
171    quality::ssimulacra2(&decode(original), &decode(candidate))
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    fn png() -> Vec<u8> {
178        let mut bytes = Vec::new();
179        {
180            let mut encoder = png::Encoder::new(&mut bytes, 64, 64);
181            encoder.set_color(png::ColorType::Rgba);
182            encoder.set_compression(png::Compression::NoCompression);
183            encoder
184                .write_header()
185                .unwrap()
186                .write_image_data(&[40, 80, 120, 128].repeat(64 * 64))
187                .unwrap();
188        }
189        bytes
190    }
191    #[test]
192    fn portable_optimization_preserves_alpha_and_never_grows_output() {
193        let original = png();
194        let first = optimize_png(&original, 2, true).unwrap();
195        assert!(first.bytes.len() < original.len());
196        assert_eq!(first.summary.transparent_pixels, 64 * 64);
197        assert!((first.summary.ssimulacra2 - 100.0).abs() < 0.01);
198        let second = optimize_png(&first.bytes, 2, true).unwrap();
199        assert!(second.bytes.len() <= first.bytes.len());
200    }
201    #[test]
202    fn malformed_inputs_and_rgba_lengths_are_rejected() {
203        assert!(optimize_png(b"invalid", 2, true).is_err());
204        assert!(optimize_png(&png(), 5, true).is_err());
205        assert!(score_srgb_rgba(64, 64, &[0; 4], &[0; 4]).is_err());
206        assert!(score_srgb_rgba(WEB_MAX_PIXELS + 1, 1, &[], &[]).is_err());
207    }
208}