jpegli/encode/
exif.rs

1//! Minimal EXIF builder for common metadata fields.
2//!
3//! Provides a type-safe API for embedding EXIF metadata without requiring
4//! users to construct raw TIFF/EXIF bytes.
5//!
6//! # Usage
7//!
8//! ```ignore
9//! use jpegli::encoder::{EncoderConfig, ChromaSubsampling, Exif, Orientation};
10//!
11//! // Build from fields (compile-time safe - can't mix with raw)
12//! let config = EncoderConfig::ycbcr(85.0, ChromaSubsampling::Quarter)
13//!     .exif(Exif::build()
14//!         .orientation(Orientation::Rotate90)
15//!         .copyright("© 2024 Example Corp"));
16//!
17//! // Or use raw EXIF bytes
18//! let config = EncoderConfig::ycbcr(85.0, ChromaSubsampling::Quarter)
19//!     .exif(Exif::raw(my_exif_bytes));
20//! ```
21
22/// EXIF orientation values (rotation/flip).
23///
24/// These correspond to the EXIF Orientation tag (0x0112) values 1-8.
25/// Most image viewers and browsers respect this tag for display.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27#[repr(u16)]
28pub enum Orientation {
29    /// Normal (no transformation needed)
30    #[default]
31    Normal = 1,
32    /// Flip horizontally
33    FlipHorizontal = 2,
34    /// Rotate 180°
35    Rotate180 = 3,
36    /// Flip vertically
37    FlipVertical = 4,
38    /// Transpose (flip + rotate 90° CW)
39    Transpose = 5,
40    /// Rotate 90° clockwise
41    Rotate90 = 6,
42    /// Transverse (flip + rotate 90° CCW)
43    Transverse = 7,
44    /// Rotate 90° counter-clockwise (270° CW)
45    Rotate270 = 8,
46}
47
48/// EXIF metadata - either raw bytes or built from common fields.
49///
50/// Use [`Exif::raw`] for user-provided EXIF TIFF bytes, or [`Exif::build`]
51/// to construct EXIF from common fields like orientation and copyright.
52///
53/// The two modes are mutually exclusive at compile time - you cannot
54/// accidentally mix raw bytes with field-based building.
55#[derive(Debug, Clone)]
56pub enum Exif {
57    /// Raw EXIF TIFF bytes (without the `Exif\0\0` APP1 prefix).
58    Raw(Vec<u8>),
59    /// Built from common fields.
60    Fields(ExifFields),
61}
62
63impl Exif {
64    /// Create EXIF from raw TIFF bytes.
65    ///
66    /// The bytes should be raw TIFF data without the `Exif\0\0` APP1 prefix
67    /// (the encoder adds that automatically).
68    #[must_use]
69    pub fn raw(bytes: impl Into<Vec<u8>>) -> Self {
70        Exif::Raw(bytes.into())
71    }
72
73    /// Start building EXIF from common fields.
74    ///
75    /// Returns an [`ExifFields`] builder that can be chained with
76    /// `.orientation()` and `.copyright()` methods.
77    #[must_use]
78    pub fn build() -> ExifFields {
79        ExifFields::default()
80    }
81
82    /// Convert to raw TIFF bytes for embedding.
83    ///
84    /// Returns `None` if no fields are set (for the `Fields` variant).
85    #[must_use]
86    pub fn to_bytes(&self) -> Option<Vec<u8>> {
87        match self {
88            Exif::Raw(bytes) => Some(bytes.clone()),
89            Exif::Fields(fields) => fields.to_bytes(),
90        }
91    }
92}
93
94impl From<ExifFields> for Exif {
95    fn from(fields: ExifFields) -> Self {
96        Exif::Fields(fields)
97    }
98}
99
100/// Common EXIF fields for building metadata.
101///
102/// Created via [`Exif::build()`], this struct provides a type-safe builder
103/// for common EXIF tags. Chain methods to set fields, then pass to
104/// [`encoder::EncoderConfig::exif`][crate::encoder::EncoderConfig::exif].
105#[derive(Debug, Clone, Default)]
106pub struct ExifFields {
107    orientation: Option<Orientation>,
108    copyright: Option<String>,
109}
110
111impl ExifFields {
112    /// Set the EXIF orientation tag.
113    ///
114    /// This controls how image viewers should rotate/flip the image for display.
115    #[must_use]
116    pub fn orientation(mut self, orientation: Orientation) -> Self {
117        self.orientation = Some(orientation);
118        self
119    }
120
121    /// Set the EXIF copyright tag.
122    ///
123    /// Standard format is "Copyright, Owner Name, Year" but any string works.
124    #[must_use]
125    pub fn copyright(mut self, copyright: impl Into<String>) -> Self {
126        self.copyright = Some(copyright.into());
127        self
128    }
129
130    /// Convert to raw TIFF bytes.
131    ///
132    /// Returns `None` if no fields are set.
133    #[must_use]
134    pub fn to_bytes(&self) -> Option<Vec<u8>> {
135        if self.orientation.is_none() && self.copyright.is_none() {
136            return None;
137        }
138        Some(build_exif_tiff(self.orientation, self.copyright.as_deref()))
139    }
140}
141
142/// Build minimal EXIF TIFF data with orientation and optional copyright.
143///
144/// Returns raw TIFF data (without the `Exif\0\0` APP1 prefix - that's added
145/// by the encoder automatically).
146fn build_exif_tiff(orientation: Option<Orientation>, copyright: Option<&str>) -> Vec<u8> {
147    // Count how many IFD entries we need
148    let mut entry_count: u16 = 0;
149    if orientation.is_some() {
150        entry_count += 1;
151    }
152    if copyright.is_some() {
153        entry_count += 1;
154    }
155
156    if entry_count == 0 {
157        return Vec::new();
158    }
159
160    // Calculate sizes
161    // TIFF header: 8 bytes
162    // IFD: 2 (count) + 12*entries + 4 (next IFD offset)
163    let ifd_size = 2 + 12 * entry_count as usize + 4;
164    let header_and_ifd = 8 + ifd_size;
165
166    // Copyright string goes after IFD if it doesn't fit inline (>4 bytes)
167    let copyright_bytes = copyright.map(|s| {
168        let mut bytes = s.as_bytes().to_vec();
169        bytes.push(0); // Null terminator
170        bytes
171    });
172    let copyright_len = copyright_bytes.as_ref().map(|b| b.len()).unwrap_or(0);
173    let copyright_inline = copyright_len <= 4;
174
175    let total_size = if copyright_inline {
176        header_and_ifd
177    } else {
178        header_and_ifd + copyright_len
179    };
180
181    let mut exif = Vec::with_capacity(total_size);
182
183    // === TIFF Header (8 bytes) ===
184    // Byte order: little-endian (Intel)
185    exif.extend_from_slice(b"II");
186    // TIFF magic number (42)
187    exif.extend_from_slice(&42u16.to_le_bytes());
188    // Offset to first IFD (immediately after header)
189    exif.extend_from_slice(&8u32.to_le_bytes());
190
191    // === IFD0 ===
192    // Number of entries
193    exif.extend_from_slice(&entry_count.to_le_bytes());
194
195    // Track offset for non-inline values (after IFD)
196    let value_offset = header_and_ifd as u32;
197
198    // Entry 1: Orientation (tag 0x0112)
199    if let Some(orient) = orientation {
200        write_ifd_entry(
201            &mut exif,
202            0x0112,        // Tag: Orientation
203            3,             // Type: SHORT
204            1,             // Count: 1
205            orient as u32, // Value (inline for SHORT)
206        );
207    }
208
209    // Entry 2: Copyright (tag 0x8298)
210    if let Some(ref bytes) = copyright_bytes {
211        let count = bytes.len() as u32;
212        let value_or_offset = if copyright_inline {
213            // Inline: pad to 4 bytes
214            let mut val = [0u8; 4];
215            val[..bytes.len()].copy_from_slice(bytes);
216            u32::from_le_bytes(val)
217        } else {
218            // Offset to value
219            value_offset
220        };
221
222        write_ifd_entry(
223            &mut exif,
224            0x8298, // Tag: Copyright
225            2,      // Type: ASCII
226            count,
227            value_or_offset,
228        );
229    }
230
231    // Next IFD offset (0 = no more IFDs)
232    exif.extend_from_slice(&0u32.to_le_bytes());
233
234    // === Values that didn't fit inline ===
235    if !copyright_inline {
236        if let Some(bytes) = copyright_bytes {
237            exif.extend_from_slice(&bytes);
238        }
239    }
240
241    exif
242}
243
244/// Write a single IFD entry (12 bytes).
245fn write_ifd_entry(buf: &mut Vec<u8>, tag: u16, type_: u16, count: u32, value: u32) {
246    buf.extend_from_slice(&tag.to_le_bytes());
247    buf.extend_from_slice(&type_.to_le_bytes());
248    buf.extend_from_slice(&count.to_le_bytes());
249    buf.extend_from_slice(&value.to_le_bytes());
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_orientation_only() {
258        let exif = Exif::build().orientation(Orientation::Rotate90);
259        let bytes = exif.to_bytes().expect("should produce bytes");
260
261        // Should have TIFF header + 1 IFD entry
262        assert!(bytes.len() >= 8 + 2 + 12 + 4); // header + count + entry + next
263
264        // Check TIFF header
265        assert_eq!(&bytes[0..2], b"II"); // Little-endian
266        assert_eq!(&bytes[2..4], &42u16.to_le_bytes()); // Magic
267
268        // Check entry count
269        assert_eq!(&bytes[8..10], &1u16.to_le_bytes());
270
271        // Check orientation tag
272        assert_eq!(&bytes[10..12], &0x0112u16.to_le_bytes()); // Tag
273        assert_eq!(&bytes[12..14], &3u16.to_le_bytes()); // Type: SHORT
274        assert_eq!(&bytes[14..18], &1u32.to_le_bytes()); // Count: 1
275        assert_eq!(&bytes[18..20], &6u16.to_le_bytes()); // Value: Rotate90 = 6
276    }
277
278    #[test]
279    fn test_copyright_short() {
280        let exif = Exif::build().copyright("AB");
281        let bytes = exif.to_bytes().expect("should produce bytes");
282
283        // Short copyright fits inline
284        assert_eq!(bytes.len(), 8 + 2 + 12 + 4); // No extra data
285
286        // Check copyright tag
287        assert_eq!(&bytes[10..12], &0x8298u16.to_le_bytes()); // Tag
288        assert_eq!(&bytes[12..14], &2u16.to_le_bytes()); // Type: ASCII
289        assert_eq!(&bytes[14..18], &3u32.to_le_bytes()); // Count: 3 (AB + null)
290    }
291
292    #[test]
293    fn test_copyright_long() {
294        let long_copyright = "Copyright 2024 Example Corp";
295        let exif = Exif::build().copyright(long_copyright);
296        let bytes = exif.to_bytes().expect("should produce bytes");
297
298        // Long copyright stored after IFD
299        let expected_len = 8 + 2 + 12 + 4 + long_copyright.len() + 1;
300        assert_eq!(bytes.len(), expected_len);
301
302        // Copyright string should be at the end
303        let string_start = 8 + 2 + 12 + 4;
304        assert_eq!(
305            &bytes[string_start..string_start + long_copyright.len()],
306            long_copyright.as_bytes()
307        );
308    }
309
310    #[test]
311    fn test_both_fields() {
312        let exif = Exif::build()
313            .orientation(Orientation::Rotate180)
314            .copyright("Test");
315        let bytes = exif.to_bytes().expect("should produce bytes");
316
317        // 2 entries
318        assert_eq!(&bytes[8..10], &2u16.to_le_bytes());
319
320        // Both tags should be present (in order by tag number)
321        // Orientation: 0x0112, Copyright: 0x8298
322        assert_eq!(&bytes[10..12], &0x0112u16.to_le_bytes());
323        assert_eq!(&bytes[22..24], &0x8298u16.to_le_bytes());
324    }
325
326    #[test]
327    fn test_empty_fields() {
328        let exif = Exif::build();
329        assert!(exif.to_bytes().is_none(), "empty fields should return None");
330    }
331
332    #[test]
333    fn test_raw_bytes() {
334        let raw = vec![1u8, 2, 3, 4, 5];
335        let exif = Exif::raw(raw.clone());
336        let bytes = exif.to_bytes().expect("should produce bytes");
337        assert_eq!(bytes, raw);
338    }
339
340    #[test]
341    fn test_chaining_preserves_both() {
342        // This is the key test - verify chaining works correctly
343        let exif = Exif::build()
344            .orientation(Orientation::Rotate90)
345            .copyright("Test");
346
347        let bytes = exif.to_bytes().expect("should produce bytes");
348
349        // Should have 2 entries
350        assert_eq!(&bytes[8..10], &2u16.to_le_bytes());
351    }
352}