1use crate::error::{PanimgError, Result};
2use crate::format::ImageFormat;
3use image::DynamicImage;
4use std::path::Path;
5
6#[derive(Debug, Clone)]
8pub struct DecodeOptions {
9 pub dpi: f32,
11}
12
13impl Default for DecodeOptions {
14 fn default() -> Self {
15 Self { dpi: 150.0 }
16 }
17}
18
19impl DecodeOptions {
20 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#[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
48pub struct CodecRegistry;
50
51impl CodecRegistry {
52 pub fn decode(path: &Path) -> Result<DynamicImage> {
54 Self::decode_with_options(path, &DecodeOptions::default())
55 }
56
57 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 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 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 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 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 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 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 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 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}