Skip to main content

sup_xml_core/
output.rs

1#![forbid(unsafe_code)]  // see CONTRIBUTING.md § "Unsafe policy"
2
3/// The charset the serialized bytes will ultimately be encoded into.
4/// Characters the charset cannot represent are emitted as numeric
5/// character references (`&#N;`) in text / attribute content, matching
6/// libxml2's serializer: a non-UTF-8 output encoding escapes anything
7/// outside its repertoire rather than dropping or mis-encoding it.
8#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
9pub enum OutputCharset {
10    /// Every Unicode scalar value is representable (UTF-8/16/32 output).
11    #[default]
12    Utf8,
13    /// ISO-8859-1: code points `<= 0xFF` pass through; the rest escape.
14    Latin1,
15    /// US-ASCII: code points `<= 0x7F` pass through; the rest escape.
16    /// Also the effective charset when no output encoding is requested
17    /// (libxml2's default serialization escapes non-ASCII).
18    Ascii,
19}
20
21impl OutputCharset {
22    #[inline]
23    fn represents(self, c: char) -> bool {
24        match self {
25            OutputCharset::Utf8   => true,
26            OutputCharset::Latin1 => (c as u32) <= 0xFF,
27            OutputCharset::Ascii  => (c as u32) <= 0x7F,
28        }
29    }
30}
31
32/// 64-bit clean output buffer. Replaces the libxml2 `xmlBuf` / `xmlBuffer`
33/// duality — only one type here, always size_t-indexed.
34pub struct XmlBuf {
35    inner: Vec<u8>,
36    charset: OutputCharset,
37}
38
39impl XmlBuf {
40    pub fn new() -> Self {
41        Self { inner: Vec::new(), charset: OutputCharset::Utf8 }
42    }
43
44    pub fn with_capacity(n: usize) -> Self {
45        Self { inner: Vec::with_capacity(n), charset: OutputCharset::Utf8 }
46    }
47
48    /// Buffer that escapes characters outside `charset` as numeric
49    /// character references in text / attribute content.
50    pub fn with_charset(n: usize, charset: OutputCharset) -> Self {
51        Self { inner: Vec::with_capacity(n), charset }
52    }
53
54    /// Emit `c` verbatim (UTF-8) when the target charset can represent
55    /// it, otherwise as a numeric character reference.
56    #[inline]
57    fn push_char_for_charset(&mut self, c: char) {
58        if self.charset.represents(c) {
59            let mut b = [0u8; 4];
60            self.inner.extend_from_slice(c.encode_utf8(&mut b).as_bytes());
61        } else {
62            // Decimal numeric character reference, e.g. `&#248;`.
63            self.push_str("&#");
64            let mut digits = [0u8; 10];
65            let mut i = digits.len();
66            let mut n = c as u32;
67            loop {
68                i -= 1;
69                digits[i] = b'0' + (n % 10) as u8;
70                n /= 10;
71                if n == 0 { break; }
72            }
73            self.inner.extend_from_slice(&digits[i..]);
74            self.push_byte(b';');
75        }
76    }
77
78    pub fn push_str(&mut self, s: &str) {
79        self.inner.extend_from_slice(s.as_bytes());
80    }
81
82    pub fn push_byte(&mut self, b: u8) {
83        self.inner.push(b);
84    }
85
86    pub fn len(&self) -> usize {
87        self.inner.len()
88    }
89
90    pub fn is_empty(&self) -> bool {
91        self.inner.is_empty()
92    }
93
94    pub fn as_bytes(&self) -> &[u8] {
95        &self.inner
96    }
97
98    pub fn into_bytes(self) -> Vec<u8> {
99        self.inner
100    }
101
102    /// Infallible: the buffer only ever receives valid UTF-8.
103    pub fn into_string(self) -> String {
104        String::from_utf8(self.inner).expect("XmlBuf is always valid UTF-8")
105    }
106
107    /// Write `s` with XML text content escaping (`&`, `<`, `>`).
108    ///
109    /// Also escapes `\r` (U+000D) as `&#xD;` so that text containing a
110    /// literal carriage return survives a parse → serialize → parse
111    /// round-trip — without the char-ref, the second parse's §2.11
112    /// end-of-line normalization would rewrite the `\r` to `\n`.  The
113    /// same reasoning applies to NEL (U+0085) and LS (U+2028) under
114    /// XML 1.1, but those are rare enough in text content that we let
115    /// them through verbatim; documents written by sup-xml are
116    /// version="1.0" by default and so won't trigger NEL/LS
117    /// normalization on the receiving end.
118    pub fn push_escaped_text(&mut self, s: &str) {
119        for c in s.chars() {
120            match c {
121                '&'      => self.push_str("&amp;"),
122                '<'      => self.push_str("&lt;"),
123                '>'      => self.push_str("&gt;"),
124                '\u{D}'  => self.push_str("&#xD;"),
125                c        => self.push_char_for_charset(c),
126            }
127        }
128    }
129
130    /// Write `s` with XML attribute value escaping (`&`, `<`, `"`).
131    ///
132    /// Also escapes `\t` / `\n` / `\r` as `&#x9;` / `&#xA;` / `&#xD;`
133    /// so the value survives a parse → serialize → parse round-trip
134    /// — XML §3.3.3 attribute-value normalization rewrites literal
135    /// tab / LF / CR to a single space on the receiving end, so
136    /// preserving them through the wire requires char-ref escaping.
137    pub fn push_escaped_attr(&mut self, s: &str) {
138        for c in s.chars() {
139            match c {
140                '&'      => self.push_str("&amp;"),
141                '<'      => self.push_str("&lt;"),
142                '"'      => self.push_str("&quot;"),
143                '\u{9}'  => self.push_str("&#x9;"),
144                '\u{A}'  => self.push_str("&#xA;"),
145                '\u{D}'  => self.push_str("&#xD;"),
146                c        => self.push_char_for_charset(c),
147            }
148        }
149    }
150}
151
152impl Default for XmlBuf {
153    fn default() -> Self {
154        Self::new()
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn escape_text() {
164        let mut b = XmlBuf::new();
165        b.push_escaped_text("a & b < c > d");
166        assert_eq!(b.into_string(), "a &amp; b &lt; c &gt; d");
167    }
168
169    #[test]
170    fn escape_attr() {
171        let mut b = XmlBuf::new();
172        b.push_escaped_attr(r#"say "hello" & <bye>"#);
173        // '>' does not require escaping in attribute values (XML spec § 2.4)
174        assert_eq!(b.into_string(), r#"say &quot;hello&quot; &amp; &lt;bye>"#);
175    }
176
177    #[test]
178    fn unicode_passthrough() {
179        let mut b = XmlBuf::new();
180        b.push_escaped_text("日本語 🦀");
181        assert_eq!(b.into_string(), "日本語 🦀");
182    }
183
184    #[test]
185    fn new_is_empty() {
186        let b = XmlBuf::new();
187        assert!(b.is_empty());
188        assert_eq!(b.len(), 0);
189        assert_eq!(b.as_bytes(), &[] as &[u8]);
190    }
191
192    #[test]
193    fn default_matches_new() {
194        let b = XmlBuf::default();
195        assert!(b.is_empty());
196    }
197
198    #[test]
199    fn with_capacity_reserves_but_is_empty() {
200        let b = XmlBuf::with_capacity(64);
201        assert!(b.is_empty());
202        assert_eq!(b.len(), 0);
203    }
204
205    #[test]
206    fn push_str_and_len() {
207        let mut b = XmlBuf::new();
208        b.push_str("hi");
209        assert_eq!(b.len(), 2);
210        assert!(!b.is_empty());
211        assert_eq!(b.as_bytes(), b"hi");
212    }
213
214    #[test]
215    fn push_byte_appends_one_byte() {
216        let mut b = XmlBuf::new();
217        b.push_byte(b'x');
218        b.push_byte(b'y');
219        assert_eq!(b.as_bytes(), b"xy");
220    }
221
222    #[test]
223    fn into_bytes_returns_inner() {
224        let mut b = XmlBuf::new();
225        b.push_str("abc");
226        assert_eq!(b.into_bytes(), b"abc".to_vec());
227    }
228}