Skip to main content

baken_export/pdb/
string.rs

1//! DeviceSQL string encoding as rekordbox 7 writes it.
2//!
3//! Measured on a real export: every ASCII string is the short form (flag byte
4//! `((len + 1) << 1) | 1`, no terminator), every non-ASCII string is the
5//! UTF-16LE form (`0x90`, u16 total length, pad byte, data). The `0x40` long
6//! ASCII form never appeared and is not produced.
7
8/// Encode one string into its DeviceSQL bytes.
9pub fn encode(s: &str) -> Vec<u8> {
10    if s.is_ascii() && s.len() < 0x7f {
11        let mut out = Vec::with_capacity(s.len() + 1);
12        out.push((((s.len() + 1) as u8) << 1) | 1);
13        out.extend_from_slice(s.as_bytes());
14        out
15    } else {
16        let data: Vec<u8> = s.encode_utf16().flat_map(u16::to_le_bytes).collect();
17        let total = (data.len() + 4) as u16;
18        let mut out = Vec::with_capacity(data.len() + 4);
19        out.push(0x90);
20        out.extend_from_slice(&total.to_le_bytes());
21        out.push(0);
22        out.extend_from_slice(&data);
23        out
24    }
25}
26
27/// Decode a DeviceSQL string at `off`, returning it with the number of bytes consumed.
28pub fn decode(buf: &[u8], off: usize) -> Option<(String, usize)> {
29    let flag = *buf.get(off)?;
30    if flag & 1 == 1 {
31        let n = (flag >> 1) as usize;
32        let body = buf.get(off + 1..off + n)?;
33        return Some((String::from_utf8_lossy(body).into_owned(), n));
34    }
35    let n = u16::from_le_bytes([*buf.get(off + 1)?, *buf.get(off + 2)?]) as usize;
36    let body = buf.get(off + 4..off + n)?;
37    let s = match flag {
38        0x40 => String::from_utf8_lossy(body).into_owned(),
39        0x90 => {
40            let units: Vec<u16> = body
41                .chunks_exact(2)
42                .map(|c| u16::from_le_bytes([c[0], c[1]]))
43                .collect();
44            String::from_utf16_lossy(&units)
45        }
46        _ => return None,
47    };
48    Some((s, n))
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54
55    #[test]
56    fn short_ascii_matches_fixture_bytes() {
57        // keys row "1A" from the reference export: 07 31 41
58        assert_eq!(encode("1A"), vec![0x07, b'1', b'A']);
59        // empty string is a lone 0x03
60        assert_eq!(encode(""), vec![0x03]);
61        assert_eq!(encode("Pink"), vec![0x0b, b'P', b'i', b'n', b'k']);
62    }
63
64    #[test]
65    fn utf16_matches_columns_row() {
66        // columns row 1: 90 12 00 00 fa ff 47 00 45 00 4e 00 52 00 45 00 fb ff
67        let s = "\u{fffa}GENRE\u{fffb}";
68        let enc = encode(s);
69        assert_eq!(&enc[..4], &[0x90, 0x12, 0x00, 0x00]);
70        assert_eq!(enc.len(), 0x12);
71        assert_eq!(decode(&enc, 0).unwrap(), (s.to_string(), 0x12));
72    }
73
74    #[test]
75    fn round_trip() {
76        for s in [
77            "",
78            "a",
79            "Techno",
80            "22. 盾",
81            "Jesse Pinkman's Hydrofluoric Acid / JPHFA",
82        ] {
83            let enc = encode(s);
84            assert_eq!(decode(&enc, 0).unwrap(), (s.to_string(), enc.len()));
85        }
86    }
87}