1pub const MAGIC: [u8; 4] = *b"C2TG";
14
15fn ascii_to_tag(b: u8) -> Option<char> {
17 (b <= 0x7F).then(|| char::from_u32(0xE0000 + b as u32).expect("tag code points are valid"))
18}
19
20pub fn tag_to_ascii(c: char) -> Option<u8> {
22 match c as u32 {
23 cp @ 0xE0000..=0xE007F => Some((cp - 0xE0000) as u8),
24 _ => None,
25 }
26}
27
28pub fn is_tag(c: char) -> bool {
30 tag_to_ascii(c).is_some()
31}
32
33pub fn encode(payload: &[u8]) -> String {
36 let mut bytes = MAGIC.to_vec();
37 bytes.extend_from_slice(payload);
38 hex::encode(bytes)
39 .bytes()
40 .filter_map(ascii_to_tag)
41 .collect()
42}
43
44pub fn embed(text: &str, payload: &[u8]) -> String {
46 let mut s = String::with_capacity(text.len() + text.len());
47 s.push_str(text);
48 s.push_str(&encode(payload));
49 s
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum Decoded {
55 None,
56 Corrupt,
57 Payload(Vec<u8>),
58}
59
60pub fn extract(text: &str) -> Decoded {
62 let ascii: Vec<u8> = text.chars().filter_map(tag_to_ascii).collect();
63 if ascii.is_empty() {
64 return Decoded::None;
65 }
66 match hex::decode(&ascii) {
67 Ok(bytes) if bytes.len() >= MAGIC.len() && bytes[..MAGIC.len()] == MAGIC => {
68 Decoded::Payload(bytes[MAGIC.len()..].to_vec())
69 }
70 _ => Decoded::Corrupt,
71 }
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77
78 #[test]
79 fn round_trip_recovers_payload() {
80 let payload = b"https://fabrikam.com/m/a1b2c3.c2pa";
81 let text = embed("A short document.", payload);
82 assert_eq!(extract(&text), Decoded::Payload(payload.to_vec()));
83 }
84
85 #[test]
86 fn two_tag_code_points_per_byte() {
87 assert_eq!(encode(b"abc").chars().count(), (MAGIC.len() + 3) * 2);
89 }
90
91 #[test]
92 fn stripping_tags_yields_none() {
93 let text = embed("hi", b"x");
94 let stripped: String = text.chars().filter(|c| !is_tag(*c)).collect();
95 assert_eq!(extract(&stripped), Decoded::None);
96 }
97
98 #[test]
99 fn odd_length_run_fails_safe() {
100 let mut chars: Vec<char> = embed("hi", b"payload").chars().collect();
101 chars.pop(); let mangled: String = chars.into_iter().collect();
103 assert_eq!(extract(&mangled), Decoded::Corrupt);
104 }
105}