1use crate::geometry::Rect;
7
8#[derive(Debug, Clone)]
12pub struct ImageContent {
13 pub bbox: Rect,
15 pub format: ImageFormat,
17 pub data: Vec<u8>,
21 pub width: u32,
23 pub height: u32,
25 pub bits_per_component: u8,
27 pub color_space: ColorSpace,
29 pub reading_order: Option<usize>,
31 pub alt_text: Option<String>,
33
34 pub horizontal_dpi: Option<f32>,
36 pub vertical_dpi: Option<f32>,
38
39 pub soft_mask: Option<Vec<u8>>,
45 pub matrix: Option<[f32; 6]>,
51 pub is_artifact: bool,
55}
56
57impl ImageContent {
58 pub fn new(bbox: Rect, format: ImageFormat, data: Vec<u8>, width: u32, height: u32) -> Self {
60 let mut image = Self {
61 bbox,
62 format,
63 data,
64 width,
65 height,
66 bits_per_component: 8,
67 color_space: ColorSpace::RGB,
68 reading_order: None,
69 alt_text: None,
70 horizontal_dpi: None,
71 vertical_dpi: None,
72 soft_mask: None,
73 matrix: None,
74 is_artifact: false,
75 };
76 image.calculate_dpi();
77 image
78 }
79
80 pub fn as_artifact(mut self) -> Self {
84 self.is_artifact = true;
85 self.alt_text = None;
86 self
87 }
88
89 pub fn with_soft_mask(mut self, mask: Vec<u8>) -> Self {
93 self.soft_mask = Some(mask);
94 self
95 }
96
97 pub fn with_reading_order(mut self, order: usize) -> Self {
99 self.reading_order = Some(order);
100 self
101 }
102
103 pub fn with_alt_text(mut self, text: impl Into<String>) -> Self {
105 self.alt_text = Some(text.into());
106 self
107 }
108
109 pub fn aspect_ratio(&self) -> f32 {
111 if self.height == 0 {
112 1.0
113 } else {
114 self.width as f32 / self.height as f32
115 }
116 }
117
118 pub fn is_grayscale(&self) -> bool {
120 matches!(self.color_space, ColorSpace::Gray)
121 }
122
123 pub fn calculate_dpi(&mut self) {
127 let width_inches = self.bbox.width / 72.0;
129 let height_inches = self.bbox.height / 72.0;
130
131 if width_inches > 0.0 && self.width > 0 {
132 self.horizontal_dpi = Some(self.width as f32 / width_inches);
133 }
134
135 if height_inches > 0.0 && self.height > 0 {
136 self.vertical_dpi = Some(self.height as f32 / height_inches);
137 }
138 }
139
140 pub fn resolution(&self) -> Option<(f32, f32)> {
144 match (self.horizontal_dpi, self.vertical_dpi) {
145 (Some(h), Some(v)) => Some((h, v)),
146 _ => None,
147 }
148 }
149
150 pub fn get_horizontal_dpi(&self) -> Option<f32> {
152 self.horizontal_dpi
153 }
154
155 pub fn get_vertical_dpi(&self) -> Option<f32> {
157 self.vertical_dpi
158 }
159
160 pub fn is_high_resolution(&self) -> bool {
164 match self.resolution() {
165 Some((h, v)) => h >= 300.0 && v >= 300.0,
166 None => false,
167 }
168 }
169
170 pub fn is_low_resolution(&self) -> bool {
174 match self.resolution() {
175 Some((h, v)) => h < 150.0 || v < 150.0,
176 None => false,
177 }
178 }
179
180 pub fn is_medium_resolution(&self) -> bool {
182 match self.resolution() {
183 Some((h, v)) => {
184 let min_dpi = h.min(v);
185 (150.0..300.0).contains(&min_dpi)
186 }
187 None => false,
188 }
189 }
190
191 pub fn from_bytes(bbox: Rect, data: Vec<u8>) -> Result<Self, crate::error::Error> {
199 let parsed = parse_image_header(&data)?;
200 let mut image = Self {
201 bbox,
202 format: parsed.format,
203 data,
204 width: parsed.width,
205 height: parsed.height,
206 bits_per_component: parsed.bits_per_component,
207 color_space: parsed.color_space,
208 reading_order: None,
209 alt_text: None,
210 horizontal_dpi: None,
211 vertical_dpi: None,
212 soft_mask: parsed.soft_mask,
213 matrix: None,
214 is_artifact: false,
215 };
216 image.calculate_dpi();
217 Ok(image)
218 }
219}
220
221impl Default for ImageContent {
222 fn default() -> Self {
223 Self {
224 bbox: Rect::new(0.0, 0.0, 0.0, 0.0),
225 format: ImageFormat::Unknown,
226 data: Vec::new(),
227 width: 0,
228 height: 0,
229 bits_per_component: 8,
230 color_space: ColorSpace::RGB,
231 reading_order: None,
232 alt_text: None,
233 horizontal_dpi: None,
234 vertical_dpi: None,
235 soft_mask: None,
236 matrix: None,
237 is_artifact: false,
238 }
239 }
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
244pub enum ImageFormat {
245 Jpeg,
247 Png,
249 Jpeg2000,
251 Jbig2,
253 Raw,
255 Unknown,
257}
258
259impl ImageFormat {
260 pub fn mime_type(&self) -> &'static str {
262 match self {
263 ImageFormat::Jpeg => "image/jpeg",
264 ImageFormat::Png => "image/png",
265 ImageFormat::Jpeg2000 => "image/jp2",
266 ImageFormat::Jbig2 => "image/jbig2",
267 ImageFormat::Raw => "application/octet-stream",
268 ImageFormat::Unknown => "application/octet-stream",
269 }
270 }
271
272 pub fn extension(&self) -> &'static str {
274 match self {
275 ImageFormat::Jpeg => "jpg",
276 ImageFormat::Png => "png",
277 ImageFormat::Jpeg2000 => "jp2",
278 ImageFormat::Jbig2 => "jbig2",
279 ImageFormat::Raw => "raw",
280 ImageFormat::Unknown => "bin",
281 }
282 }
283}
284
285#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
287#[allow(clippy::upper_case_acronyms)]
288pub enum ColorSpace {
289 Gray,
291 #[default]
293 RGB,
294 CMYK,
296 Indexed,
298 Lab,
300}
301
302impl ColorSpace {
303 pub fn components(&self) -> u8 {
305 match self {
306 ColorSpace::Gray => 1,
307 ColorSpace::RGB => 3,
308 ColorSpace::CMYK => 4,
309 ColorSpace::Indexed => 1,
310 ColorSpace::Lab => 3,
311 }
312 }
313}
314
315struct ParsedImageHeader {
317 width: u32,
318 height: u32,
319 bits_per_component: u8,
320 color_space: ColorSpace,
321 format: ImageFormat,
322 soft_mask: Option<Vec<u8>>,
323}
324
325fn parse_image_header(data: &[u8]) -> Result<ParsedImageHeader, crate::error::Error> {
334 if data.len() >= 2 && data[0] == 0xFF && data[1] == 0xD8 {
335 let (width, height, color_space) = parse_jpeg_dimensions(data).map_err(crate::error::Error::Image)?;
336 return Ok(ParsedImageHeader {
337 width,
338 height,
339 bits_per_component: 8,
340 color_space,
341 format: ImageFormat::Jpeg,
342 soft_mask: None,
343 });
344 }
345
346 if data.len() >= 8 && &data[0..8] == b"\x89PNG\r\n\x1a\n" {
347 return parse_png_header(data);
348 }
349
350 Err(crate::error::Error::Image("Unsupported image format".to_string()))
351}
352
353fn parse_jpeg_dimensions(data: &[u8]) -> Result<(u32, u32, ColorSpace), String> {
356 if data.len() < 2 || data[0] != 0xFF || data[1] != 0xD8 {
357 return Err("Not a valid JPEG".to_string());
358 }
359
360 let mut pos = 2;
361 while pos < data.len() - 1 {
362 if data[pos] != 0xFF {
363 pos += 1;
364 continue;
365 }
366
367 let marker = data[pos + 1];
368 pos += 2;
369
370 if marker == 0xFF || marker == 0x00 {
371 continue;
372 }
373
374 if matches!(
376 marker,
377 0xC0 | 0xC1 | 0xC2 | 0xC3 | 0xC5 | 0xC6 | 0xC7 | 0xC9 | 0xCA | 0xCB | 0xCD | 0xCE | 0xCF
378 ) {
379 if pos + 7 > data.len() {
380 return Err("Truncated JPEG header".to_string());
381 }
382
383 let _precision = data[pos + 2];
384 let height = u16::from_be_bytes([data[pos + 3], data[pos + 4]]) as u32;
385 let width = u16::from_be_bytes([data[pos + 5], data[pos + 6]]) as u32;
386 let components = data[pos + 7];
387
388 let color_space = match components {
389 1 => ColorSpace::Gray,
390 3 => ColorSpace::RGB,
391 4 => ColorSpace::CMYK,
392 _ => ColorSpace::RGB,
393 };
394
395 return Ok((width, height, color_space));
396 }
397
398 if pos + 2 > data.len() {
399 break;
400 }
401 let length = u16::from_be_bytes([data[pos], data[pos + 1]]) as usize;
402 pos += length;
403 }
404
405 Err("Could not find JPEG dimensions".to_string())
406}
407
408fn parse_png_header(data: &[u8]) -> Result<ParsedImageHeader, crate::error::Error> {
415 use image::GenericImageView;
416
417 let img = image::load_from_memory_with_format(data, image::ImageFormat::Png)
418 .map_err(|e| crate::error::Error::Image(e.to_string()))?;
419 let (width, height) = img.dimensions();
420
421 let (color_space, alpha): (ColorSpace, Option<Vec<u8>>) = match img.color() {
422 image::ColorType::L8 | image::ColorType::L16 => (ColorSpace::Gray, None),
423 image::ColorType::La8 | image::ColorType::La16 => {
424 let luma_alpha = img.to_luma_alpha8();
425 let alpha_channel = luma_alpha.pixels().map(|pixel| pixel.0[1]).collect();
426 (ColorSpace::Gray, Some(alpha_channel))
427 }
428 image::ColorType::Rgb8 | image::ColorType::Rgb16 => (ColorSpace::RGB, None),
429 image::ColorType::Rgba8 | image::ColorType::Rgba16 => {
430 let rgba = img.to_rgba8();
431 let alpha_channel = rgba.pixels().map(|pixel| pixel.0[3]).collect();
432 (ColorSpace::RGB, Some(alpha_channel))
433 }
434 _ => (ColorSpace::RGB, None),
435 };
436
437 let soft_mask = alpha.map(|a| compress_soft_mask(&a, width)).transpose()?;
439
440 Ok(ParsedImageHeader {
441 width,
442 height,
443 bits_per_component: 8,
444 color_space,
445 format: ImageFormat::Png,
446 soft_mask,
447 })
448}
449
450fn compress_soft_mask(pixels: &[u8], width: u32) -> Result<Vec<u8>, crate::error::Error> {
460 use std::io::Write;
461
462 use flate2::Compression;
463 use flate2::write::ZlibEncoder;
464
465 let row_bytes = width as usize;
466
467 if row_bytes == 0 || pixels.is_empty() {
468 let encoder = ZlibEncoder::new(Vec::new(), Compression::default());
469 return encoder.finish().map_err(|e| crate::error::Error::Image(e.to_string()));
470 }
471
472 let num_rows = pixels.len().div_ceil(row_bytes);
474 let mut filtered = Vec::with_capacity(pixels.len() + num_rows);
475 for row in pixels.chunks(row_bytes) {
476 filtered.push(0u8);
477 filtered.extend_from_slice(row);
478 }
479
480 let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
481 encoder
482 .write_all(&filtered)
483 .map_err(|e| crate::error::Error::Image(e.to_string()))?;
484 encoder.finish().map_err(|e| crate::error::Error::Image(e.to_string()))
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490
491 #[test]
492 fn test_image_content_creation() {
493 let image = ImageContent::new(
494 Rect::new(0.0, 0.0, 100.0, 100.0),
495 ImageFormat::Jpeg,
496 vec![0u8; 1000],
497 800,
498 600,
499 );
500
501 assert_eq!(image.width, 800);
502 assert_eq!(image.height, 600);
503 assert_eq!(image.format, ImageFormat::Jpeg);
504 }
505
506 #[test]
507 fn test_aspect_ratio() {
508 let image = ImageContent::new(Rect::new(0.0, 0.0, 100.0, 100.0), ImageFormat::Png, vec![], 1920, 1080);
509
510 let ratio = image.aspect_ratio();
511 assert!((ratio - (1920.0 / 1080.0)).abs() < 0.001);
512 }
513
514 #[test]
515 fn test_color_space_components() {
516 assert_eq!(ColorSpace::Gray.components(), 1);
517 assert_eq!(ColorSpace::RGB.components(), 3);
518 assert_eq!(ColorSpace::CMYK.components(), 4);
519 }
520
521 #[test]
522 fn test_image_format_extension() {
523 assert_eq!(ImageFormat::Jpeg.extension(), "jpg");
524 assert_eq!(ImageFormat::Png.extension(), "png");
525 assert_eq!(ImageFormat::Jpeg2000.extension(), "jp2");
526 }
527
528 #[test]
529 fn test_dpi_calculation_high_res() {
530 let image = ImageContent::new(Rect::new(0.0, 0.0, 144.0, 144.0), ImageFormat::Jpeg, vec![], 600, 600);
532
533 let (h, v) = image.resolution().unwrap();
534 assert!((h - 300.0).abs() < 1.0);
535 assert!((v - 300.0).abs() < 1.0);
536 assert!(image.is_high_resolution());
537 assert!(!image.is_low_resolution());
538 }
539
540 #[test]
541 fn test_dpi_calculation_low_res() {
542 let image = ImageContent::new(Rect::new(0.0, 0.0, 72.0, 72.0), ImageFormat::Png, vec![], 100, 100);
544
545 let (h, v) = image.resolution().unwrap();
546 assert!((h - 100.0).abs() < 1.0);
547 assert!((v - 100.0).abs() < 1.0);
548 assert!(!image.is_high_resolution());
549 assert!(image.is_low_resolution());
550 }
551
552 #[test]
553 fn test_dpi_calculation_medium_res() {
554 let image = ImageContent::new(Rect::new(0.0, 0.0, 72.0, 72.0), ImageFormat::Png, vec![], 200, 200);
556
557 let (h, v) = image.resolution().unwrap();
558 assert!((h - 200.0).abs() < 1.0);
559 assert!((v - 200.0).abs() < 1.0);
560 assert!(image.is_medium_resolution());
561 }
562
563 #[test]
564 fn test_dpi_asymmetric() {
565 let image = ImageContent::new(Rect::new(0.0, 0.0, 72.0, 72.0), ImageFormat::Png, vec![], 300, 100);
569
570 let (h, v) = image.resolution().unwrap();
571 assert!((h - 300.0).abs() < 1.0);
572 assert!((v - 100.0).abs() < 1.0);
573
574 assert!(image.is_low_resolution());
576 assert!(!image.is_high_resolution());
578 }
579
580 #[test]
581 fn test_dpi_zero_dimensions() {
582 let image = ImageContent::default();
584 assert!(image.resolution().is_none());
585 }
586
587 #[test]
588 fn test_dpi_getters() {
589 let image = ImageContent::new(Rect::new(0.0, 0.0, 72.0, 72.0), ImageFormat::Png, vec![], 300, 300);
590
591 assert!(image.get_horizontal_dpi().is_some());
592 assert!(image.get_vertical_dpi().is_some());
593 assert!((image.get_horizontal_dpi().unwrap() - 300.0).abs() < 1.0);
594 }
595
596 fn encode_png(image: image::DynamicImage) -> Vec<u8> {
597 let mut bytes = Vec::new();
598 image
599 .write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png)
600 .expect("encode test PNG");
601 bytes
602 }
603
604 fn encode_jpeg(image: image::DynamicImage) -> Vec<u8> {
605 let mut bytes = Vec::new();
606 image
607 .write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Jpeg)
608 .expect("encode test JPEG");
609 bytes
610 }
611
612 #[test]
613 fn from_bytes_should_parse_rgb_png_header_without_soft_mask() {
614 let rgb = image::RgbImage::from_pixel(4, 3, image::Rgb([10, 20, 30]));
615 let png_bytes = encode_png(image::DynamicImage::ImageRgb8(rgb));
616
617 let image = ImageContent::from_bytes(Rect::new(0.0, 0.0, 72.0, 72.0), png_bytes.clone())
618 .expect("from_bytes should parse a valid PNG header");
619
620 assert_eq!(image.width, 4);
621 assert_eq!(image.height, 3);
622 assert_eq!(image.format, ImageFormat::Png);
623 assert_eq!(image.color_space, ColorSpace::RGB);
624 assert_eq!(image.bits_per_component, 8);
625 assert!(image.soft_mask.is_none());
626 assert_eq!(image.data, png_bytes);
628 }
629
630 #[test]
631 fn from_bytes_should_extract_soft_mask_from_rgba_png() {
632 let rgba = image::RgbaImage::from_pixel(2, 2, image::Rgba([1, 2, 3, 128]));
633 let png_bytes = encode_png(image::DynamicImage::ImageRgba8(rgba));
634
635 let image = ImageContent::from_bytes(Rect::new(0.0, 0.0, 72.0, 72.0), png_bytes)
636 .expect("from_bytes should parse an RGBA PNG header");
637
638 assert_eq!(image.width, 2);
639 assert_eq!(image.height, 2);
640 assert_eq!(image.color_space, ColorSpace::RGB);
641 assert!(image.soft_mask.is_some(), "RGBA PNG must produce a soft mask");
642 }
643
644 #[test]
645 fn from_bytes_should_parse_grayscale_png_header() {
646 let gray = image::GrayImage::from_pixel(5, 6, image::Luma([42]));
647 let png_bytes = encode_png(image::DynamicImage::ImageLuma8(gray));
648
649 let image = ImageContent::from_bytes(Rect::new(0.0, 0.0, 72.0, 72.0), png_bytes)
650 .expect("from_bytes should parse a grayscale PNG header");
651
652 assert_eq!(image.width, 5);
653 assert_eq!(image.height, 6);
654 assert_eq!(image.color_space, ColorSpace::Gray);
655 assert!(image.soft_mask.is_none());
656 }
657
658 #[test]
659 fn from_bytes_should_parse_jpeg_header_via_sof_marker() {
660 let rgb = image::RgbImage::from_pixel(16, 8, image::Rgb([200, 100, 50]));
661 let jpeg_bytes = encode_jpeg(image::DynamicImage::ImageRgb8(rgb));
662
663 let image = ImageContent::from_bytes(Rect::new(0.0, 0.0, 72.0, 72.0), jpeg_bytes.clone())
664 .expect("from_bytes should parse a valid JPEG SOF marker");
665
666 assert_eq!(image.width, 16);
667 assert_eq!(image.height, 8);
668 assert_eq!(image.format, ImageFormat::Jpeg);
669 assert_eq!(image.color_space, ColorSpace::RGB);
670 assert_eq!(image.bits_per_component, 8);
671 assert!(image.soft_mask.is_none());
672 assert_eq!(image.data, jpeg_bytes);
673 }
674
675 #[test]
676 fn from_bytes_should_reject_unrecognised_magic_number() {
677 let result = ImageContent::from_bytes(Rect::new(0.0, 0.0, 72.0, 72.0), vec![0u8; 16]);
678 assert!(result.is_err(), "non-image bytes must not parse as an image header");
679 }
680}