Skip to main content

edgeparse_core/pdf/
pdf_string.rs

1//! Decode PDF string objects (ISO 32000-1 §7.9.2).
2//!
3//! Handles:
4//! - UTF-16BE with BOM (`FE FF`) — common for `/ActualText` / `/Alt`
5//! - UTF-8 when valid
6//! - PDFDocEncoding fallback for Latin text
7
8/// Decode a PDF literal/hex string into Unicode.
9pub fn decode_pdf_string(bytes: &[u8]) -> String {
10    if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
11        return decode_utf16be(&bytes[2..]);
12    }
13    if bytes.len() >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF {
14        return String::from_utf8_lossy(&bytes[3..]).into_owned();
15    }
16    if let Ok(s) = std::str::from_utf8(bytes) {
17        // Prefer UTF-8 when the bytes are valid and don't look like UTF-16BE
18        // without BOM (rare for ActualText).
19        if !s.chars().any(|c| c == '\u{0}') {
20            return s.to_string();
21        }
22    }
23    decode_pdfdoc_encoding(bytes)
24}
25
26fn decode_utf16be(bytes: &[u8]) -> String {
27    let mut out = String::with_capacity(bytes.len() / 2);
28    let mut i = 0;
29    while i + 1 < bytes.len() {
30        let code = u16::from_be_bytes([bytes[i], bytes[i + 1]]);
31        i += 2;
32        if let Some(c) = char::from_u32(u32::from(code)) {
33            // Skip lone surrogates; handle pairs
34            if (0xD800..=0xDBFF).contains(&code) {
35                if i + 1 < bytes.len() {
36                    let low = u16::from_be_bytes([bytes[i], bytes[i + 1]]);
37                    i += 2;
38                    if (0xDC00..=0xDFFF).contains(&low) {
39                        let cp = 0x10000
40                            + (((code as u32) - 0xD800) << 10)
41                            + ((low as u32) - 0xDC00);
42                        if let Some(ch) = char::from_u32(cp) {
43                            out.push(ch);
44                        }
45                    }
46                }
47            } else if !(0xDC00..=0xDFFF).contains(&code) {
48                out.push(c);
49            }
50        }
51    }
52    out
53}
54
55/// Minimal PDFDocEncoding (ISO 32000-1 Annex D) — identity for 0x20–0x7E,
56/// plus common Latin-1 / Windows extensions used in ActualText.
57fn decode_pdfdoc_encoding(bytes: &[u8]) -> String {
58    let mut out = String::with_capacity(bytes.len());
59    for &b in bytes {
60        let c = match b {
61            0x20..=0x7E => b as char,
62            // Common PDFDocEncoding extras (subset)
63            0x80 => '\u{2022}', // bullet
64            0x8A => '\u{0160}',
65            0x8C => '\u{0152}',
66            0x8E => '\u{017D}',
67            0x91 => '\u{2018}',
68            0x92 => '\u{2019}',
69            0x93 => '\u{201C}',
70            0x94 => '\u{201D}',
71            0x95 => '\u{2013}', // en-dash (also bullet in WinAnsi — ActualText usually ASCII)
72            0x96 => '\u{2014}',
73            0x97 => '\u{2022}',
74            0xA0 => '\u{00A0}',
75            0xA1..=0xFF => char::from_u32(u32::from(b)).unwrap_or('\u{FFFD}'),
76            _ => '\u{FFFD}',
77        };
78        out.push(c);
79    }
80    out
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn utf16be_with_bom() {
89        // BOM + "2"
90        let bytes = [0xFE, 0xFF, 0x00, b'2'];
91        assert_eq!(decode_pdf_string(&bytes), "2");
92    }
93
94    #[test]
95    fn ascii_literal() {
96        assert_eq!(decode_pdf_string(b"Hello"), "Hello");
97    }
98
99    #[test]
100    fn utf16be_word() {
101        let mut bytes = vec![0xFE, 0xFF];
102        for c in "Hi".encode_utf16() {
103            bytes.extend_from_slice(&c.to_be_bytes());
104        }
105        assert_eq!(decode_pdf_string(&bytes), "Hi");
106    }
107}