Skip to main content

panimg_core/codec/
mod.rs

1use crate::error::{PanimgError, Result};
2use crate::format::ImageFormat;
3use image::DynamicImage;
4use std::path::Path;
5
6/// Options for decoding an image.
7#[derive(Debug, Clone)]
8pub struct DecodeOptions {
9    /// DPI for rasterizing vector/document formats (PDF). Default: 150.
10    pub dpi: f32,
11}
12
13impl Default for DecodeOptions {
14    fn default() -> Self {
15        Self { dpi: 150.0 }
16    }
17}
18
19impl DecodeOptions {
20    /// Create DecodeOptions with an optional DPI override.
21    /// Falls back to the default (150) when `None` is given.
22    pub fn with_dpi(dpi: Option<f32>) -> Self {
23        match dpi {
24            Some(d) => Self { dpi: d },
25            None => Self::default(),
26        }
27    }
28}
29
30/// Options for encoding an image.
31#[derive(Debug, Clone)]
32pub struct EncodeOptions {
33    pub format: ImageFormat,
34    pub quality: Option<u8>,
35    pub strip_metadata: bool,
36}
37
38impl Default for EncodeOptions {
39    fn default() -> Self {
40        Self {
41            format: ImageFormat::Png,
42            quality: None,
43            strip_metadata: false,
44        }
45    }
46}
47
48/// Registry that handles decoding and encoding images.
49pub struct CodecRegistry;
50
51impl CodecRegistry {
52    /// Decode an image from a file path with default options.
53    pub fn decode(path: &Path) -> Result<DynamicImage> {
54        Self::decode_with_options(path, &DecodeOptions::default())
55    }
56
57    /// Decode an image from a file path with custom options.
58    pub fn decode_with_options(path: &Path, options: &DecodeOptions) -> Result<DynamicImage> {
59        if !path.exists() {
60            return Err(PanimgError::FileNotFound {
61                path: path.to_path_buf(),
62                suggestion: "check that the file path is correct".into(),
63            });
64        }
65
66        let data = std::fs::read(path).map_err(|e| PanimgError::IoError {
67            message: e.to_string(),
68            path: Some(path.to_path_buf()),
69            suggestion: "check file permissions".into(),
70        })?;
71
72        let format = ImageFormat::from_bytes(&data)
73            .or_else(|| ImageFormat::from_path_extension(path))
74            .ok_or_else(|| PanimgError::UnknownFormat {
75                path: path.to_path_buf(),
76                suggestion: "specify the format explicitly or use a recognized extension".into(),
77            })?;
78
79        Self::decode_bytes(&data, format, Some(path), options)
80    }
81
82    /// Decode an image from bytes with a known format.
83    fn decode_bytes(
84        data: &[u8],
85        format: ImageFormat,
86        path: Option<&Path>,
87        _options: &DecodeOptions,
88    ) -> Result<DynamicImage> {
89        match format {
90            ImageFormat::Jpeg
91            | ImageFormat::Png
92            | ImageFormat::WebP
93            | ImageFormat::Gif
94            | ImageFormat::Bmp
95            | ImageFormat::Tiff
96            | ImageFormat::Qoi
97            | ImageFormat::Avif => {
98                let img_fmt = format.to_image_format().unwrap();
99                image::load_from_memory_with_format(data, img_fmt).map_err(|e| {
100                    PanimgError::DecodeError {
101                        message: e.to_string(),
102                        path: path.map(|p| p.to_path_buf()),
103                        suggestion: "the file may be corrupted".into(),
104                    }
105                })
106            }
107            #[cfg(feature = "svg")]
108            ImageFormat::Svg => decode_svg(data, path),
109            #[cfg(feature = "jxl")]
110            ImageFormat::Jxl => decode_jxl(data, path),
111            #[cfg(feature = "pdf")]
112            ImageFormat::Pdf => decode_pdf(data, path, _options),
113            #[cfg(all(feature = "heic", target_vendor = "apple"))]
114            ImageFormat::Heic => decode_heic(data, path),
115            #[allow(unreachable_patterns)]
116            _ => Err(PanimgError::UnsupportedFormat {
117                format: format.to_string(),
118                suggestion: format!(
119                    "enable the '{}' feature to support this format",
120                    format.extension()
121                ),
122            }),
123        }
124    }
125
126    /// Encode an image and write to a file path.
127    pub fn encode(img: &DynamicImage, path: &Path, options: &EncodeOptions) -> Result<()> {
128        if !options.format.can_encode() {
129            return Err(PanimgError::UnsupportedFormat {
130                format: options.format.to_string(),
131                suggestion: "this format is not supported for encoding".into(),
132            });
133        }
134
135        let img_fmt =
136            options
137                .format
138                .to_image_format()
139                .ok_or_else(|| PanimgError::UnsupportedFormat {
140                    format: options.format.to_string(),
141                    suggestion: "this format is not supported for encoding".into(),
142                })?;
143
144        // For JPEG, set quality
145        if options.format == ImageFormat::Jpeg {
146            let quality = options.quality.unwrap_or(85);
147            let file = std::fs::File::create(path).map_err(|e| PanimgError::IoError {
148                message: e.to_string(),
149                path: Some(path.to_path_buf()),
150                suggestion: "check output directory exists and permissions".into(),
151            })?;
152            let mut writer = std::io::BufWriter::new(file);
153            let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut writer, quality);
154            img.write_with_encoder(encoder)
155                .map_err(|e| PanimgError::EncodeError {
156                    message: e.to_string(),
157                    path: Some(path.to_path_buf()),
158                    suggestion: "check that the image data is valid".into(),
159                })?;
160            return Ok(());
161        }
162
163        // Default: use image crate's save method
164        img.save_with_format(path, img_fmt)
165            .map_err(|e| PanimgError::EncodeError {
166                message: e.to_string(),
167                path: Some(path.to_path_buf()),
168                suggestion: "check output directory exists and permissions".into(),
169            })
170    }
171}
172
173#[cfg(feature = "svg")]
174fn decode_svg(data: &[u8], path: Option<&Path>) -> Result<DynamicImage> {
175    let tree =
176        resvg::usvg::Tree::from_data(data, &resvg::usvg::Options::default()).map_err(|e| {
177            PanimgError::DecodeError {
178                message: e.to_string(),
179                path: path.map(|p| p.to_path_buf()),
180                suggestion: "check that the SVG is well-formed".into(),
181            }
182        })?;
183    let size = tree.size();
184    let width = size.width() as u32;
185    let height = size.height() as u32;
186    let mut pixmap =
187        resvg::tiny_skia::Pixmap::new(width, height).ok_or_else(|| PanimgError::DecodeError {
188            message: "failed to create pixmap".into(),
189            path: path.map(|p| p.to_path_buf()),
190            suggestion: "SVG dimensions may be invalid".into(),
191        })?;
192    resvg::render(
193        &tree,
194        resvg::usvg::Transform::default(),
195        &mut pixmap.as_mut(),
196    );
197    let rgba_data = pixmap.data().to_vec();
198    image::RgbaImage::from_raw(width, height, rgba_data)
199        .map(DynamicImage::ImageRgba8)
200        .ok_or_else(|| PanimgError::DecodeError {
201            message: "failed to create image from SVG render".into(),
202            path: path.map(|p| p.to_path_buf()),
203            suggestion: "SVG dimensions may be invalid".into(),
204        })
205}
206
207#[cfg(all(feature = "heic", target_vendor = "apple"))]
208fn decode_heic(data: &[u8], path: Option<&Path>) -> Result<DynamicImage> {
209    use libheif_rs::{ColorSpace, HeifContext, LibHeif, RgbChroma};
210
211    let ctx = HeifContext::read_from_bytes(data).map_err(|e| PanimgError::DecodeError {
212        message: e.to_string(),
213        path: path.map(|p| p.to_path_buf()),
214        suggestion: "check that the HEIC/HEIF file is valid".into(),
215    })?;
216
217    let handle = ctx
218        .primary_image_handle()
219        .map_err(|e| PanimgError::DecodeError {
220            message: e.to_string(),
221            path: path.map(|p| p.to_path_buf()),
222            suggestion: "failed to get primary image from HEIC container".into(),
223        })?;
224
225    let has_alpha = handle.has_alpha_channel();
226    let color_space = if has_alpha {
227        ColorSpace::Rgb(RgbChroma::Rgba)
228    } else {
229        ColorSpace::Rgb(RgbChroma::Rgb)
230    };
231
232    let lib_heif = LibHeif::new();
233    let decoded =
234        lib_heif
235            .decode(&handle, color_space, None)
236            .map_err(|e| PanimgError::DecodeError {
237                message: e.to_string(),
238                path: path.map(|p| p.to_path_buf()),
239                suggestion: "failed to decode HEIC image data".into(),
240            })?;
241
242    let width = decoded.width();
243    let height = decoded.height();
244    let planes = decoded.planes();
245    let interleaved = planes.interleaved.ok_or_else(|| PanimgError::DecodeError {
246        message: "no interleaved plane data in decoded HEIC image".into(),
247        path: path.map(|p| p.to_path_buf()),
248        suggestion: "the HEIC file may use an unsupported pixel format".into(),
249    })?;
250
251    let stride = interleaved.stride;
252    let src_data = interleaved.data;
253    let channels: usize = if has_alpha { 4 } else { 3 };
254    let row_bytes = (width as usize) * channels;
255
256    // Validate that the source buffer is large enough
257    let required_len = (height as usize).saturating_sub(1) * stride + row_bytes;
258    if src_data.len() < required_len {
259        return Err(PanimgError::DecodeError {
260            message: format!(
261                "HEIC plane data too short: need {} bytes but got {}",
262                required_len,
263                src_data.len()
264            ),
265            path: path.map(|p| p.to_path_buf()),
266            suggestion: "the HEIC file may be truncated or corrupted".into(),
267        });
268    }
269
270    // Copy pixel data, handling stride != row_bytes
271    let buf = if stride == row_bytes {
272        src_data[..row_bytes * (height as usize)].to_vec()
273    } else {
274        let mut buf = Vec::with_capacity((width as usize) * (height as usize) * channels);
275        for row in 0..height as usize {
276            let start = row * stride;
277            buf.extend_from_slice(&src_data[start..start + row_bytes]);
278        }
279        buf
280    };
281
282    if has_alpha {
283        image::RgbaImage::from_raw(width, height, buf)
284            .map(DynamicImage::ImageRgba8)
285            .ok_or_else(|| PanimgError::DecodeError {
286                message: "failed to create image from HEIC data".into(),
287                path: path.map(|p| p.to_path_buf()),
288                suggestion: "HEIC data may be invalid".into(),
289            })
290    } else {
291        image::RgbImage::from_raw(width, height, buf)
292            .map(DynamicImage::ImageRgb8)
293            .ok_or_else(|| PanimgError::DecodeError {
294                message: "failed to create image from HEIC data".into(),
295                path: path.map(|p| p.to_path_buf()),
296                suggestion: "HEIC data may be invalid".into(),
297            })
298    }
299}
300
301#[cfg(feature = "jxl")]
302fn decode_jxl(data: &[u8], path: Option<&Path>) -> Result<DynamicImage> {
303    use jxl_oxide::JxlImage;
304    let image = JxlImage::builder()
305        .read(std::io::Cursor::new(data))
306        .map_err(|e| PanimgError::DecodeError {
307            message: e.to_string(),
308            path: path.map(|p| p.to_path_buf()),
309            suggestion: "check that the JPEG XL file is valid".into(),
310        })?;
311    let render = image
312        .render_frame(0)
313        .map_err(|e| PanimgError::DecodeError {
314            message: e.to_string(),
315            path: path.map(|p| p.to_path_buf()),
316            suggestion: "failed to render JPEG XL frame".into(),
317        })?;
318    let fb = render.image_all_channels();
319    let width = fb.width() as u32;
320    let height = fb.height() as u32;
321    let buf: Vec<u8> = fb
322        .buf()
323        .iter()
324        .map(|&f| (f.clamp(0.0, 1.0) * 255.0) as u8)
325        .collect();
326    let channels = fb.channels();
327    match channels {
328        3 => image::RgbImage::from_raw(width, height, buf)
329            .map(DynamicImage::ImageRgb8)
330            .ok_or_else(|| PanimgError::DecodeError {
331                message: "failed to create image from JXL data".into(),
332                path: path.map(|p| p.to_path_buf()),
333                suggestion: "JXL data may be invalid".into(),
334            }),
335        4 => image::RgbaImage::from_raw(width, height, buf)
336            .map(DynamicImage::ImageRgba8)
337            .ok_or_else(|| PanimgError::DecodeError {
338                message: "failed to create image from JXL data".into(),
339                path: path.map(|p| p.to_path_buf()),
340                suggestion: "JXL data may be invalid".into(),
341            }),
342        _ => Err(PanimgError::DecodeError {
343            message: format!("unsupported channel count: {channels}"),
344            path: path.map(|p| p.to_path_buf()),
345            suggestion: "only RGB and RGBA JPEG XL images are supported".into(),
346        }),
347    }
348}
349
350#[cfg(feature = "pdf")]
351fn decode_pdf(data: &[u8], path: Option<&Path>, options: &DecodeOptions) -> Result<DynamicImage> {
352    use hayro::hayro_interpret::InterpreterSettings;
353    use hayro::hayro_syntax::Pdf;
354    use hayro::RenderSettings;
355
356    let pdf_data: std::sync::Arc<dyn AsRef<[u8]> + Send + Sync> =
357        std::sync::Arc::new(data.to_vec());
358    let pdf = Pdf::new(pdf_data).map_err(|e| PanimgError::DecodeError {
359        // LoadPdfError does not implement Display, use Debug
360        message: format!("{e:?}"),
361        path: path.map(|p| p.to_path_buf()),
362        suggestion: "check that the PDF file is valid and not encrypted".into(),
363    })?;
364
365    let pages = pdf.pages();
366    if pages.is_empty() {
367        return Err(PanimgError::DecodeError {
368            message: "PDF has no pages".into(),
369            path: path.map(|p| p.to_path_buf()),
370            suggestion: "the PDF file appears to be empty".into(),
371        });
372    }
373
374    // Render the first page only. PDF default is 72 DPI, so scale = dpi / 72.
375    let scale = options.dpi / 72.0;
376    let interpreter_settings = InterpreterSettings::default();
377    let render_settings = RenderSettings {
378        x_scale: scale,
379        y_scale: scale,
380        bg_color: hayro::vello_cpu::color::palette::css::WHITE,
381        ..Default::default()
382    };
383
384    let pixmap = hayro::render(&pages[0], &interpreter_settings, &render_settings);
385    let width = pixmap.width() as u32;
386    let height = pixmap.height() as u32;
387    let unpremultiplied = pixmap.take_unpremultiplied();
388    let rgba_data: Vec<u8> = unpremultiplied
389        .into_iter()
390        .flat_map(|p| [p.r, p.g, p.b, p.a])
391        .collect();
392    image::RgbaImage::from_raw(width, height, rgba_data)
393        .map(DynamicImage::ImageRgba8)
394        .ok_or_else(|| PanimgError::DecodeError {
395            message: "failed to create image from PDF render".into(),
396            path: path.map(|p| p.to_path_buf()),
397            suggestion: "PDF page dimensions may be invalid".into(),
398        })
399}