Skip to main content

exiftool_rs/
encoding.rs

1//! Text encoding utilities for metadata decoding.
2//!
3//! Many file formats store text metadata in Latin-1 (ISO 8859-1) or other
4//! non-UTF-8 encodings. These helpers provide correct decoding instead of
5//! the lossy `String::from_utf8_lossy()` which silently replaces bytes
6//! >= 0x80 with U+FFFD.
7
8/// Decode bytes as Latin-1 (ISO 8859-1) to String.
9///
10/// Each byte maps directly to its Unicode code point (U+0000–U+00FF),
11/// which is the correct mapping for ISO 8859-1.
12pub fn decode_latin1(bytes: &[u8]) -> String {
13    bytes.iter().map(|&b| b as char).collect()
14}
15
16/// Try decoding as UTF-8 first; fall back to Latin-1 if invalid.
17///
18/// This matches Perl ExifTool's behavior for fields that are historically
19/// Latin-1 but may contain valid UTF-8 in modern files.
20pub fn decode_utf8_or_latin1(bytes: &[u8]) -> String {
21    match std::str::from_utf8(bytes) {
22        Ok(s) => s.to_string(),
23        Err(_) => decode_latin1(bytes),
24    }
25}
26
27/// Encode a UTF-8 string as Latin-1 (ISO 8859-1) bytes — the inverse of
28/// [`decode_latin1`].
29///
30/// Each code point U+0000–U+00FF maps to a single byte; anything above is
31/// not representable in Latin-1 and is substituted with `?`, matching Perl
32/// ExifTool's default behaviour when writing a character the IPTC internal
33/// charset can't hold (the alternative is declaring `CodedCharacterSet` =
34/// UTF8). Without this, a UTF-8 `&str` written straight to an IPTC-IIM
35/// dataset double-encodes: `í` (U+00ED → UTF-8 `C3 AD`) reads back as `í`
36/// under the default Latin-1 IPTC charset.
37pub fn encode_latin1(s: &str) -> Vec<u8> {
38    s.chars()
39        .map(|c| {
40            let cp = c as u32;
41            if cp <= 0xFF {
42                cp as u8
43            } else {
44                b'?'
45            }
46        })
47        .collect()
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    #[test]
55    fn test_encode_latin1_roundtrip() {
56        // Inverse of decode_latin1 across the representable range.
57        assert_eq!(encode_latin1("hello"), b"hello");
58        assert_eq!(encode_latin1("éüñ"), vec![0xE9, 0xFC, 0xF1]);
59        assert_eq!(encode_latin1("©®ö"), vec![0xA9, 0xAE, 0xF6]);
60        // The reported case: "Martín" → 'í' is 0xED, one byte.
61        assert_eq!(
62            encode_latin1("Martín"),
63            vec![b'M', b'a', b'r', b't', 0xED, b'n']
64        );
65    }
66
67    #[test]
68    fn test_encode_latin1_substitutes_unrepresentable() {
69        // Beyond Latin-1 (e.g. Cyrillic, emoji) → '?'.
70        assert_eq!(encode_latin1("Пример"), b"??????");
71        assert_eq!(encode_latin1("a😀b"), b"a?b");
72    }
73
74    #[test]
75    fn test_encode_decode_latin1_inverse() {
76        let s = "Àéîõü©";
77        assert_eq!(decode_latin1(&encode_latin1(&s)), s);
78    }
79
80    #[test]
81    fn test_decode_latin1_ascii() {
82        assert_eq!(decode_latin1(b"hello"), "hello");
83    }
84
85    #[test]
86    fn test_decode_latin1_high_bytes() {
87        // 0xE9 = é, 0xFC = ü, 0xF1 = ñ
88        assert_eq!(decode_latin1(&[0xE9, 0xFC, 0xF1]), "éüñ");
89    }
90
91    #[test]
92    fn test_decode_latin1_full_range() {
93        // 0xA9 = ©, 0xAE = ®, 0xF6 = ö
94        assert_eq!(decode_latin1(&[0xA9, 0xAE, 0xF6]), "©®ö");
95    }
96
97    #[test]
98    fn test_decode_utf8_or_latin1_valid_utf8() {
99        assert_eq!(decode_utf8_or_latin1("café".as_bytes()), "café");
100    }
101
102    #[test]
103    fn test_decode_utf8_or_latin1_latin1_fallback() {
104        // 0xE9 alone is invalid UTF-8 but valid Latin-1 for 'é'
105        assert_eq!(decode_utf8_or_latin1(&[0x63, 0x61, 0x66, 0xE9]), "café");
106    }
107
108    #[test]
109    fn test_decode_utf8_or_latin1_pure_ascii() {
110        assert_eq!(decode_utf8_or_latin1(b"hello"), "hello");
111    }
112
113    #[test]
114    fn test_decode_utf8_or_latin1_empty() {
115        assert_eq!(decode_utf8_or_latin1(b""), "");
116        assert_eq!(decode_latin1(b""), "");
117    }
118}