oxidize_pdf/graphics/
image.rs

1//! Image support for PDF generation
2//!
3//! Currently supports:
4//! - JPEG images
5
6use crate::objects::{Dictionary, Object};
7use crate::{PdfError, Result};
8use std::fs::File;
9use std::io::Read;
10use std::path::Path;
11
12/// Represents an image that can be embedded in a PDF
13#[derive(Debug, Clone)]
14pub struct Image {
15    /// Image data
16    data: Vec<u8>,
17    /// Image format
18    format: ImageFormat,
19    /// Width in pixels
20    width: u32,
21    /// Height in pixels
22    height: u32,
23    /// Color space
24    color_space: ColorSpace,
25    /// Bits per component
26    bits_per_component: u8,
27}
28
29/// Supported image formats
30#[derive(Debug, Clone, Copy, PartialEq)]
31pub enum ImageFormat {
32    /// JPEG format
33    Jpeg,
34    // Future: PNG, TIFF, etc.
35}
36
37/// Color spaces for images
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub enum ColorSpace {
40    /// Grayscale
41    DeviceGray,
42    /// RGB color
43    DeviceRGB,
44    /// CMYK color
45    DeviceCMYK,
46}
47
48impl Image {
49    /// Load a JPEG image from a file
50    pub fn from_jpeg_file<P: AsRef<Path>>(path: P) -> Result<Self> {
51        let mut file = File::open(path)?;
52        let mut data = Vec::new();
53        file.read_to_end(&mut data)?;
54        Self::from_jpeg_data(data)
55    }
56
57    /// Create an image from JPEG data
58    pub fn from_jpeg_data(data: Vec<u8>) -> Result<Self> {
59        // Parse JPEG header to get dimensions and color info
60        let (width, height, color_space, bits_per_component) = parse_jpeg_header(&data)?;
61
62        Ok(Image {
63            data,
64            format: ImageFormat::Jpeg,
65            width,
66            height,
67            color_space,
68            bits_per_component,
69        })
70    }
71
72    /// Get image width in pixels
73    pub fn width(&self) -> u32 {
74        self.width
75    }
76
77    /// Get image height in pixels
78    pub fn height(&self) -> u32 {
79        self.height
80    }
81
82    /// Get image data
83    pub fn data(&self) -> &[u8] {
84        &self.data
85    }
86
87    /// Convert to PDF XObject
88    pub fn to_pdf_object(&self) -> Object {
89        let mut dict = Dictionary::new();
90
91        // Required entries for image XObject
92        dict.set("Type", Object::Name("XObject".to_string()));
93        dict.set("Subtype", Object::Name("Image".to_string()));
94        dict.set("Width", Object::Integer(self.width as i64));
95        dict.set("Height", Object::Integer(self.height as i64));
96
97        // Color space
98        let color_space_name = match self.color_space {
99            ColorSpace::DeviceGray => "DeviceGray",
100            ColorSpace::DeviceRGB => "DeviceRGB",
101            ColorSpace::DeviceCMYK => "DeviceCMYK",
102        };
103        dict.set("ColorSpace", Object::Name(color_space_name.to_string()));
104
105        // Bits per component
106        dict.set(
107            "BitsPerComponent",
108            Object::Integer(self.bits_per_component as i64),
109        );
110
111        // Filter for JPEG
112        match self.format {
113            ImageFormat::Jpeg => {
114                dict.set("Filter", Object::Name("DCTDecode".to_string()));
115            }
116        }
117
118        // Create stream with image data
119        Object::Stream(dict, self.data.clone())
120    }
121}
122
123/// Parse JPEG header to extract image information
124fn parse_jpeg_header(data: &[u8]) -> Result<(u32, u32, ColorSpace, u8)> {
125    if data.len() < 2 || data[0] != 0xFF || data[1] != 0xD8 {
126        return Err(PdfError::InvalidImage("Not a valid JPEG file".to_string()));
127    }
128
129    let mut pos = 2;
130    let mut width = 0;
131    let mut height = 0;
132    let mut components = 0;
133
134    while pos < data.len() - 1 {
135        if data[pos] != 0xFF {
136            return Err(PdfError::InvalidImage("Invalid JPEG marker".to_string()));
137        }
138
139        let marker = data[pos + 1];
140        pos += 2;
141
142        // Skip padding bytes
143        if marker == 0xFF {
144            continue;
145        }
146
147        // Check for SOF markers (Start of Frame)
148        if (0xC0..=0xCF).contains(&marker) && marker != 0xC4 && marker != 0xC8 && marker != 0xCC {
149            // This is a SOF marker
150            if pos + 7 >= data.len() {
151                return Err(PdfError::InvalidImage("Truncated JPEG file".to_string()));
152            }
153
154            // Skip length
155            pos += 2;
156
157            // Skip precision
158            pos += 1;
159
160            // Read height and width
161            height = ((data[pos] as u32) << 8) | (data[pos + 1] as u32);
162            pos += 2;
163            width = ((data[pos] as u32) << 8) | (data[pos + 1] as u32);
164            pos += 2;
165
166            // Read number of components
167            components = data[pos];
168            break;
169        } else if marker == 0xD9 {
170            // End of image
171            break;
172        } else if marker == 0xD8 || (0xD0..=0xD7).contains(&marker) {
173            // No length field for these markers
174            continue;
175        } else {
176            // Read length and skip segment
177            if pos + 1 >= data.len() {
178                return Err(PdfError::InvalidImage("Truncated JPEG file".to_string()));
179            }
180            let length = ((data[pos] as usize) << 8) | (data[pos + 1] as usize);
181            pos += length;
182        }
183    }
184
185    if width == 0 || height == 0 {
186        return Err(PdfError::InvalidImage(
187            "Could not find image dimensions".to_string(),
188        ));
189    }
190
191    let color_space = match components {
192        1 => ColorSpace::DeviceGray,
193        3 => ColorSpace::DeviceRGB,
194        4 => ColorSpace::DeviceCMYK,
195        _ => {
196            return Err(PdfError::InvalidImage(format!(
197                "Unsupported number of components: {components}"
198            )))
199        }
200    };
201
202    Ok((width, height, color_space, 8)) // JPEG typically uses 8 bits per component
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn test_parse_jpeg_header() {
211        // Minimal JPEG header for testing
212        let jpeg_data = vec![
213            0xFF, 0xD8, // SOI marker
214            0xFF, 0xC0, // SOF0 marker
215            0x00, 0x11, // Length (17 bytes)
216            0x08, // Precision (8 bits)
217            0x00, 0x64, // Height (100)
218            0x00, 0xC8, // Width (200)
219            0x03, // Components (3 = RGB)
220                  // ... rest of data
221        ];
222
223        let result = parse_jpeg_header(&jpeg_data);
224        assert!(result.is_ok());
225        let (width, height, color_space, bits) = result.unwrap();
226        assert_eq!(width, 200);
227        assert_eq!(height, 100);
228        assert_eq!(color_space, ColorSpace::DeviceRGB);
229        assert_eq!(bits, 8);
230    }
231
232    #[test]
233    fn test_invalid_jpeg() {
234        let invalid_data = vec![0x00, 0x00];
235        let result = parse_jpeg_header(&invalid_data);
236        assert!(result.is_err());
237    }
238}