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}