Skip to main content

mos_eval/
image.rs

1//! Raster image resolution and decode for `#image(...)` directives.
2//!
3//! The lowerer hands us a relative path (typed as `"path.png"` in the
4//! source) plus the source file's location. We resolve it to an
5//! absolute path, read the bytes, and decode them through the `image`
6//! crate's PNG / JPEG decoders. The decoded pixels are flattened to
7//! `RGB8` (alpha channels are composited onto white) so the PDF
8//! backend can emit them as a `/DeviceRGB` Image `XObject` without
9//! threading a soft-mask through the emit path.
10//!
11//! Diagnostics:
12//!
13//! - `MOS0037`: `#image(...)` called without a path string.
14//! - `MOS0012`: cannot read the file on disk.
15//! - `MOS0029`: cannot decode the bytes as PNG/JPEG.
16
17use std::path::{Path, PathBuf};
18
19use mos_core::{Diagnostic, DiagnosticAnnotation, SourceSpan, codes};
20
21/// One decoded raster image, ready to be lowered onto a
22/// [`mos_core::NodeKind::Image`] node.
23#[derive(Debug, Clone)]
24pub(crate) struct DecodedImage {
25    /// Decoded width in pixels.
26    pub width: u32,
27    /// Decoded height in pixels.
28    pub height: u32,
29    /// Flat RGB8 byte buffer (`3 * width * height` bytes). Alpha
30    /// channels are composited onto an opaque white background during
31    /// decode so the PDF emit path can ship `/DeviceRGB` directly.
32    pub rgb8: Vec<u8>,
33}
34
35/// Resolve `src_path` (as written in the source) relative to `source_file`
36/// (the `.mos` file currently being lowered), then read + decode it.
37///
38/// Returns `Err(Diagnostic)` on I/O or decode failure; the resolver
39/// surfaces these to the user without aborting the rest of the
40/// document so a broken `#image(...)` still produces a partial PDF.
41pub(crate) fn load(
42    src_path: &str,
43    source_file: &Path,
44    call_span: &SourceSpan,
45) -> Result<(PathBuf, DecodedImage), Box<Diagnostic>> {
46    let resolved = mos_core::resolve_source_path(src_path, source_file).map_err(|err| {
47        Box::new(
48            Diagnostic::simple(
49                &codes::MOS0049,
50                None,
51                format!("cannot use image path `{src_path}`: {err}"),
52            )
53            .with_span(call_span.clone()),
54        )
55    })?;
56    let bytes = std::fs::read(&resolved).map_err(|err| {
57        Box::new(
58            Diagnostic::simple(
59                &codes::MOS0012,
60                None,
61                format!(
62                    "cannot read image `{}`: {err}",
63                    mos_core::display_path(&resolved)
64                ),
65            )
66            .with_span(call_span.clone()),
67        )
68    })?;
69    let decoded = decode(&bytes).map_err(|err| {
70        Box::new(
71            Diagnostic::simple(
72                &codes::MOS0029,
73                None,
74                format!(
75                    "cannot decode `{}`: {err}",
76                    mos_core::display_path(&resolved)
77                ),
78            )
79            .with_span(call_span.clone())
80            .with_annotation(DiagnosticAnnotation::Note(
81                "supported formats are PNG and JPEG".to_owned(),
82            )),
83        )
84    })?;
85    Ok((resolved, decoded))
86}
87
88#[allow(
89    clippy::many_single_char_names,
90    reason = "r/g/b/a are conventional pixel channel names"
91)]
92fn decode(bytes: &[u8]) -> Result<DecodedImage, String> {
93    // `image::load_from_memory` picks the format from the magic bytes;
94    // we leave format detection to it so PNG-with-.jpg-extension still
95    // works. The `default-features = false` build only links the PNG
96    // and JPEG decoders, so an unsupported format errors cleanly here.
97    let img = image::load_from_memory(bytes).map_err(|err| err.to_string())?;
98    let rgba = img.to_rgba8();
99    let (w, h) = rgba.dimensions();
100    let pixels = rgba.into_raw();
101    // Composite alpha onto white: `out = src*alpha + white*(1-alpha)`.
102    // The PDF emit path ships /DeviceRGB without a soft-mask in this
103    // slice, so any partial alpha would otherwise punch through to the
104    // page background. Fully transparent pixels render as pure white,
105    // which is the conventional default for figure backgrounds.
106    let pixel_count = pixels.len() / 4;
107    let mut rgb8 = Vec::with_capacity(pixel_count * 3);
108    for chunk in pixels.chunks_exact(4) {
109        let [r, g, b, a] = [chunk[0], chunk[1], chunk[2], chunk[3]];
110        if a == 255 {
111            rgb8.push(r);
112            rgb8.push(g);
113            rgb8.push(b);
114        } else if a == 0 {
115            rgb8.extend_from_slice(&[255, 255, 255]);
116        } else {
117            rgb8.push(composite(r, a));
118            rgb8.push(composite(g, a));
119            rgb8.push(composite(b, a));
120        }
121    }
122    Ok(DecodedImage {
123        width: w,
124        height: h,
125        rgb8,
126    })
127}
128
129/// Composite a single 8-bit colour channel onto opaque white using its
130/// 8-bit alpha. `((c * a) + 255 * (255 - a)) / 255`, rounded to nearest.
131fn composite(channel: u8, alpha: u8) -> u8 {
132    let c = u32::from(channel);
133    let a = u32::from(alpha);
134    // `(c*a + 255*(255-a) + 127) / 255` rounds half away from zero;
135    // operates entirely in u32 so no intermediate overflow on i32.
136    let v = (c * a + 255 * (255 - a) + 127) / 255;
137    u8::try_from(v.min(255)).unwrap_or(255)
138}
139
140#[cfg(test)]
141mod tests {
142    #![allow(clippy::unwrap_used, clippy::expect_used)]
143
144    use super::*;
145
146    /// A 2×1 fully opaque PNG: red pixel + blue pixel. Hand-crafted so
147    /// tests don't depend on filesystem access.
148    fn red_blue_png() -> Vec<u8> {
149        // Use `image` crate to produce the bytes; this is the same
150        // round-trip we exercise in production.
151        let mut buf = image::RgbaImage::new(2, 1);
152        buf.put_pixel(0, 0, image::Rgba([255, 0, 0, 255]));
153        buf.put_pixel(1, 0, image::Rgba([0, 0, 255, 255]));
154        let mut out = std::io::Cursor::new(Vec::new());
155        image::DynamicImage::ImageRgba8(buf)
156            .write_to(&mut out, image::ImageFormat::Png)
157            .unwrap();
158        out.into_inner()
159    }
160
161    #[test]
162    fn decode_opaque_png_round_trips_dimensions_and_colors() {
163        let png = red_blue_png();
164        let img = decode(&png).unwrap();
165        assert_eq!((img.width, img.height), (2, 1));
166        assert_eq!(img.rgb8, vec![255, 0, 0, 0, 0, 255]);
167    }
168
169    #[test]
170    fn decode_transparent_pixel_composites_to_white() {
171        let mut buf = image::RgbaImage::new(1, 1);
172        buf.put_pixel(0, 0, image::Rgba([0, 128, 255, 0]));
173        let mut out = std::io::Cursor::new(Vec::new());
174        image::DynamicImage::ImageRgba8(buf)
175            .write_to(&mut out, image::ImageFormat::Png)
176            .unwrap();
177        let img = decode(&out.into_inner()).unwrap();
178        assert_eq!(img.rgb8, vec![255, 255, 255]);
179    }
180
181    #[test]
182    fn decode_partial_alpha_composites_against_white() {
183        // 50% alpha red on white → roughly (255, 127, 127).
184        let mut buf = image::RgbaImage::new(1, 1);
185        buf.put_pixel(0, 0, image::Rgba([255, 0, 0, 128]));
186        let mut out = std::io::Cursor::new(Vec::new());
187        image::DynamicImage::ImageRgba8(buf)
188            .write_to(&mut out, image::ImageFormat::Png)
189            .unwrap();
190        let img = decode(&out.into_inner()).unwrap();
191        // ((255 * 128) + (255 * 127) + 127) / 255 = 255: red channel
192        // stays at 255 (white is also 255 red). Green/blue go halfway
193        // between 0 and 255.
194        assert_eq!(img.rgb8[0], 255);
195        assert!((i32::from(img.rgb8[1]) - 127).abs() <= 1);
196        assert!((i32::from(img.rgb8[2]) - 127).abs() <= 1);
197    }
198
199    #[test]
200    fn decode_bogus_bytes_returns_error() {
201        let r = decode(b"not an image at all");
202        assert!(r.is_err());
203    }
204
205    #[test]
206    fn resolve_relative_path_uses_source_parent() {
207        let src = Path::new("/tmp/proj/main.mos");
208        let resolved = mos_core::resolve_source_path("img/scan.png", src);
209        assert_eq!(resolved, Ok(PathBuf::from("/tmp/proj/img/scan.png")));
210    }
211
212    #[test]
213    fn resolve_absolute_path_passes_through() {
214        let src = Path::new("/tmp/proj/main.mos");
215        let resolved = mos_core::resolve_source_path("/abs/path.png", src);
216        assert_eq!(resolved, Ok(PathBuf::from("/abs/path.png")));
217    }
218}