//! XML serializer. //! //! Serializes a `Document` tree into a well-formed XML string. use crate::parser_xmloxide::tree::{Attribute, Document, NodeId, NodeKind}; use alloc::string::{String, ToString}; /// Options controlling XML serialization output. /// /// # Examples /// /// ```text,xml-sec-example /// use xmloxide::Document; /// use xmloxide::serial::{serialize_with_options, SerializeOptions}; /// /// let doc = Document::parse_str("Hello").unwrap(); /// let xml = serialize_with_options(&doc, &SerializeOptions::default().indent(true)); /// assert!(xml.contains(" ")); /// ``` #[derive(Debug, Clone)] pub struct SerializeOptions { /// Whether to produce indented (pretty-printed) output. /// Defaults to `false`. pub indent: bool, /// The indentation string used for each level when `indent` is `true`. /// Defaults to two spaces. pub indent_str: String, } impl Default for SerializeOptions { fn default() -> Self { Self { indent: false, indent_str: " ".to_string(), } } } impl SerializeOptions { /// Enables or disables indented (pretty-printed) output. /// /// When enabled, child elements are placed on their own lines with /// indentation (two spaces per level by default). Mixed-content elements /// (those containing both text and element children) are not indented. /// Use [`indent_str`](Self::indent_str) to customize the indentation /// string. Disabled by default. #[must_use] pub fn indent(mut self, indent: bool) -> Self { self.indent = indent; self } /// Sets the indentation string used for each nesting level. /// /// The default is two spaces (`" "`). Common alternatives include a tab /// (`"\t"`) or four spaces (`" "`). This only takes effect when /// [`indent`](Self::indent) is enabled. #[must_use] pub fn indent_str(mut self, s: &str) -> Self { self.indent_str = s.to_string(); self } } /// Serializes a document to an XML string. /// /// # Examples /// /// ```text,xml-sec-example /// use xmloxide::Document; /// use xmloxide::serial::serialize; /// /// let doc = Document::parse_str("Hello").unwrap(); /// let xml = serialize(&doc); /// assert!(xml.contains("")); /// ``` #[must_use] pub fn serialize(doc: &Document) -> String { serialize_with_options(doc, &SerializeOptions::default()) } /// Serializes a document to an XML string with the given options. /// /// When `options.indent` is `true`, produces pretty-printed output with /// newlines and indentation between elements. /// /// # Examples /// /// ```text,xml-sec-example /// use xmloxide::Document; /// use xmloxide::serial::{serialize_with_options, SerializeOptions}; /// /// let doc = Document::parse_str("Hello").unwrap(); /// let xml = serialize_with_options(&doc, &SerializeOptions::default().indent(true)); /// assert!(xml.contains(" ")); /// ``` #[must_use] pub fn serialize_with_options(doc: &Document, options: &SerializeOptions) -> String { let mut output = String::new(); // XML declaration — always emit, defaulting to version 1.0 (matches libxml2) let version = doc.version.as_deref().unwrap_or("1.0"); output.push_str("\n"); // When no encoding is declared, non-ASCII chars in attributes are // re-encoded as hex character references (matches libxml2 behavior). let reencode_non_ascii = doc.encoding.is_none(); // Serialize children of the document root for child in doc.children(doc.root()) { serialize_node( doc, child, &mut output, reencode_non_ascii, options, 0, false, ); } // Trailing newline (matches libxml2 output convention) output.push('\n'); output } /// Returns `true` if the element contains only other elements (and optional /// whitespace text), meaning it's safe to add indentation. fn is_element_only(doc: &Document, id: NodeId) -> bool { let mut has_element_child = false; for child in doc.children(id) { match &doc.node(child).kind { NodeKind::Element { .. } => has_element_child = true, NodeKind::Text { content } if !content.trim().is_empty() => { return false; } NodeKind::CData { .. } | NodeKind::EntityRef { .. } => return false, _ => {} } } has_element_child } fn serialize_node( doc: &Document, id: NodeId, out: &mut String, reencode_non_ascii: bool, options: &SerializeOptions, depth: usize, parent_is_element_only: bool, ) { let indent = options.indent; match &doc.node(id).kind { NodeKind::Element { .. } => serialize_element_node( doc, id, out, reencode_non_ascii, options, depth, parent_is_element_only, ), NodeKind::Text { content } => { write_escaped_text(out, content, reencode_non_ascii); } NodeKind::CData { content } => { // XML 1.0 section 2.7: content cannot hold `]]>`, so each one is // split across adjacent sections, as libxml2 writes it. out.push_str("", "]]]]>")); out.push_str("]]>"); } NodeKind::Comment { content } => { if indent && parent_is_element_only { for _ in 0..depth { out.push_str(&options.indent_str); } } out.push_str(""); if indent && parent_is_element_only { out.push('\n'); } } NodeKind::ProcessingInstruction { target, data } => { if indent && parent_is_element_only { for _ in 0..depth { out.push_str(&options.indent_str); } } out.push_str(""); if indent && parent_is_element_only { out.push('\n'); } } NodeKind::EntityRef { name, .. } => { out.push('&'); out.push_str(name); out.push(';'); } NodeKind::DocumentType { name, system_id, public_id, internal_subset, } => { out.push_str(" { out.push_str(" PUBLIC \""); out.push_str(pub_id); out.push_str("\" \""); out.push_str(sys_id); out.push('"'); } (None, Some(sys_id)) => { out.push_str(" SYSTEM \""); out.push_str(sys_id); out.push('"'); } _ => {} } if let Some(subset) = internal_subset { out.push_str(" ["); out.push_str(subset); out.push_str("]>"); } else { out.push('>'); } } NodeKind::Document => { // Should not appear as a child node } } } fn serialize_element_node( doc: &Document, id: NodeId, out: &mut String, reencode_non_ascii: bool, options: &SerializeOptions, depth: usize, parent_is_element_only: bool, ) { let NodeKind::Element { name, prefix, attributes, .. } = &doc.node(id).kind else { unreachable!("element serialization only receives an element"); }; let indent = options.indent; if indent && parent_is_element_only { for _ in 0..depth { out.push_str(&options.indent_str); } } out.push('<'); if let Some(pfx) = prefix { out.push_str(pfx); out.push(':'); } out.push_str(name); let is_ns_decl = |attr: &&Attribute| attr.name == "xmlns" || attr.prefix.as_deref() == Some("xmlns"); // libxml2 writes an element's namespace declarations before its // attributes, each group in its own order. let declarations = attributes.iter().filter(is_ns_decl); let others = attributes.iter().filter(|attr| !is_ns_decl(attr)); for attr in declarations.chain(others) { out.push(' '); if let Some(pfx) = &attr.prefix { out.push_str(pfx); out.push(':'); } out.push_str(&attr.name); out.push_str("=\""); let is_ns_decl = is_ns_decl(&attr); if let Some(raw) = attr.raw_value.as_ref().filter(|_| !is_ns_decl) { write_escaped_attr_preserve_refs(out, raw, reencode_non_ascii); } else { write_escaped_attr(out, &attr.value, reencode_non_ascii); } out.push('"'); } if doc.first_child(id).is_none() { out.push_str("/>"); if indent && parent_is_element_only { out.push('\n'); } } else { out.push('>'); let element_only = indent && is_element_only(doc, id); if element_only { out.push('\n'); } for child in doc.children(id) { if element_only && let NodeKind::Text { content } = &doc.node(child).kind && content.trim().is_empty() { continue; } serialize_node( doc, child, out, reencode_non_ascii, options, depth + 1, element_only, ); } if element_only { for _ in 0..depth { out.push_str(&options.indent_str); } } out.push_str("'); if indent && parent_is_element_only { out.push('\n'); } } } /// Writes a hexadecimal character reference (`&#xHH;`) for a Unicode code point. fn write_hex_char_ref(out: &mut String, ch: char) { use core::fmt::Write; let _ = write!(out, "&#x{:X};", ch as u32); } /// Escapes text content for XML output. /// /// Follows current libxml2's text escaping: /// - `<`, `>`, `&` are escaped with named entity references /// - `\r` is encoded as ` `, as `xmlDocDumpMemory` writes it /// - `\t` and `\n` are passed through /// - Control characters below 0x20 (other than `\t`, `\n`, `\r`) are hex-encoded /// - Non-ASCII characters are passed through as raw UTF-8 fn write_escaped_text(out: &mut String, text: &str, reencode_non_ascii: bool) { for ch in text.chars() { match ch { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '\r' => out.push_str(" "), '\t' | '\n' => out.push(ch), c if (c as u32) < 0x20 => write_hex_char_ref(out, c), c if reencode_non_ascii && (c as u32) >= 0x80 => write_hex_char_ref(out, c), _ => out.push(ch), } } } /// Escapes an attribute value that contains preserved entity references. /// /// Custom entity references (`&name;` where name is not a builtin) are /// preserved as-is. Character references (`&#...;`) and builtin entity refs /// (`&`, `<`, `>`, `'`, `"`) are decoded to their /// actual characters and then re-escaped normally through the attribute /// escaping logic. This matches libxml2's serialization behavior. fn write_escaped_attr_preserve_refs(out: &mut String, text: &str, reencode_non_ascii: bool) { let bytes = text.as_bytes(); let len = bytes.len(); let mut i = 0; while i < len { let b = bytes[i]; if b == b'&' { // Check if this starts a valid reference if let Some(ref_end) = find_attr_reference_end(bytes, i) { let ref_str = &text[i..=ref_end]; if ref_str.starts_with("&#") { // Character reference — decode to char, then escape normally if let Some(ch) = decode_char_ref(ref_str) { write_escaped_attr_char(out, ch, reencode_non_ascii); } else { out.push_str(ref_str); } } else if let Some(ch) = decode_builtin_entity_ref(ref_str) { // Builtin entity ref — decode and re-escape normally write_escaped_attr_char(out, ch, reencode_non_ascii); } else { // Custom entity reference — preserve as-is out.push_str(ref_str); } i = ref_end + 1; continue; } out.push_str("&"); i += 1; } else if b == b'<' { out.push_str("<"); i += 1; } else if b == b'>' { out.push_str(">"); i += 1; } else if b == b'"' { out.push_str("""); i += 1; } else if b == b'\t' { out.push_str(" "); i += 1; } else if b == b'\n' { out.push_str(" "); i += 1; } else if b == b'\r' { out.push_str(" "); i += 1; } else { let ch = &text[i..]; if let Some(c) = ch.chars().next() { if (c as u32) < 0x20 || (reencode_non_ascii && (c as u32) >= 0x80) { write_hex_char_ref(out, c); } else { out.push(c); } i += c.len_utf8(); } else { i += 1; } } } } /// Decodes a character reference (`&#NNN;` or `&#xHHH;`) to a `char`. fn decode_char_ref(s: &str) -> Option { let inner = s.strip_prefix("&#")?.strip_suffix(';')?; let code_point = if let Some(hex) = inner.strip_prefix('x') { u32::from_str_radix(hex, 16).ok()? } else { inner.parse::().ok()? }; char::from_u32(code_point) } /// Decodes a builtin entity reference to a `char`, if it is one. /// Returns `None` for custom (non-builtin) entity references. fn decode_builtin_entity_ref(s: &str) -> Option { match s { "&" => Some('&'), "<" => Some('<'), ">" => Some('>'), "'" => Some('\''), """ => Some('"'), _ => None, } } /// Writes a single character with attribute escaping rules. fn write_escaped_attr_char(out: &mut String, ch: char, reencode_non_ascii: bool) { match ch { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\t' => out.push_str(" "), '\n' => out.push_str(" "), '\r' => out.push_str(" "), c if (c as u32) < 0x20 => write_hex_char_ref(out, c), c if reencode_non_ascii && (c as u32) >= 0x80 => write_hex_char_ref(out, c), _ => out.push(ch), } } /// Finds the end of an entity/character reference in attribute raw value. fn find_attr_reference_end(bytes: &[u8], start: usize) -> Option { if start >= bytes.len() || bytes[start] != b'&' { return None; } let mut i = start + 1; if i >= bytes.len() { return None; } if bytes[i] == b'#' { i += 1; if i >= bytes.len() { return None; } if bytes[i] == b'x' { i += 1; let d = i; while i < bytes.len() && bytes[i].is_ascii_hexdigit() { i += 1; } if i == d || i >= bytes.len() || bytes[i] != b';' { return None; } } else { let d = i; while i < bytes.len() && bytes[i].is_ascii_digit() { i += 1; } if i == d || i >= bytes.len() || bytes[i] != b';' { return None; } } Some(i) } else { if !bytes[i].is_ascii_alphabetic() && bytes[i] != b'_' && bytes[i] != b':' { return None; } i += 1; while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_' || bytes[i] == b':' || bytes[i] == b'-' || bytes[i] == b'.') { i += 1; } if i >= bytes.len() || bytes[i] != b';' { return None; } Some(i) } } /// Escapes attribute values for XML output. /// /// Follows libxml2's `xmlAttrSerializeTxtContent` behavior: /// - `<`, `>`, `&`, `"` are escaped with named entity references /// - `\t` → ` `, `\n` → ` `, `\r` → ` ` /// - When `reencode_non_ascii` is true (doc has no declared encoding), non-ASCII /// characters (>= U+0080) are encoded as hex character references fn write_escaped_attr(out: &mut String, text: &str, reencode_non_ascii: bool) { for ch in text.chars() { match ch { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\t' => out.push_str(" "), '\n' => out.push_str(" "), '\r' => out.push_str(" "), c if (c as u32) < 0x20 => write_hex_char_ref(out, c), c if reencode_non_ascii && (c as u32) >= 0x80 => write_hex_char_ref(out, c), _ => out.push(ch), } } } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; use crate::parser_xmloxide::tree::Attribute; #[test] fn element_dispatch_preserves_empty_mixed_and_indented_bytes() { // The extracted element branch must keep the same child/indentation // decisions, including the caller's depth and mixed-content boundary. for (source, compact, indented) in [ ("", "", " \n"), ( "", "", " \n \n \n", ), ( "leftright", "leftright", " leftright\n", ), ] { let doc = Document::parse_str(source).unwrap(); let id = doc.root_element().unwrap(); for indent in [false, true] { let options = SerializeOptions::default().indent(indent); let mut direct = String::new(); serialize_element_node(&doc, id, &mut direct, false, &options, 2, true); let mut dispatched = String::new(); serialize_node(&doc, id, &mut dispatched, false, &options, 2, true); assert_eq!(direct, if indent { indented } else { compact }); assert_eq!(dispatched, direct); } } } #[test] fn test_serialize_empty_element() { let mut doc = Document::new(); let root = doc.root(); let elem = doc.create_node(NodeKind::Element { name: "br".to_string(), prefix: None, namespace: None, attributes: vec![], }); doc.append_child(root, elem); assert_eq!(serialize(&doc), "\n
\n"); } #[test] fn test_serialize_element_with_text() { let mut doc = Document::new(); let root = doc.root(); let elem = doc.create_node(NodeKind::Element { name: "p".to_string(), prefix: None, namespace: None, attributes: vec![], }); let text = doc.create_node(NodeKind::Text { content: "Hello".to_string(), }); doc.append_child(root, elem); doc.append_child(elem, text); assert_eq!(serialize(&doc), "\n

Hello

\n"); } #[test] fn test_serialize_element_with_attributes() { let mut doc = Document::new(); let root = doc.root(); let elem = doc.create_node(NodeKind::Element { name: "div".to_string(), prefix: None, namespace: None, attributes: vec![ Attribute { name: "id".to_string(), value: "main".to_string(), prefix: None, namespace: None, raw_value: None, }, Attribute { name: "class".to_string(), value: "big".to_string(), prefix: None, namespace: None, raw_value: None, }, ], }); doc.append_child(root, elem); assert_eq!( serialize(&doc), "\n
\n" ); } #[test] fn test_serialize_escaping() { let mut doc = Document::new(); let root = doc.root(); let elem = doc.create_node(NodeKind::Element { name: "p".to_string(), prefix: None, namespace: None, attributes: vec![], }); let text = doc.create_node(NodeKind::Text { content: "a < b & c > d".to_string(), }); doc.append_child(root, elem); doc.append_child(elem, text); assert_eq!( serialize(&doc), "\n

a < b & c > d

\n" ); } #[test] fn test_serialize_comment() { let mut doc = Document::new(); let root = doc.root(); let comment = doc.create_node(NodeKind::Comment { content: " a comment ".to_string(), }); doc.append_child(root, comment); assert_eq!( serialize(&doc), "\n\n" ); } #[test] fn test_serialize_cdata() { let mut doc = Document::new(); let root = doc.root(); let elem = doc.create_node(NodeKind::Element { name: "script".to_string(), prefix: None, namespace: None, attributes: vec![], }); let cdata = doc.create_node(NodeKind::CData { content: "x < 1 && y > 2".to_string(), }); doc.append_child(root, elem); doc.append_child(elem, cdata); assert_eq!( serialize(&doc), "\n\n" ); } #[test] fn test_serialize_processing_instruction() { let mut doc = Document::new(); let root = doc.root(); let pi = doc.create_node(NodeKind::ProcessingInstruction { target: "xml-stylesheet".to_string(), data: Some("type=\"text/css\" href=\"style.css\"".to_string()), }); doc.append_child(root, pi); assert_eq!( serialize(&doc), "\n\n" ); } #[test] fn test_serialize_xml_declaration() { let mut doc = Document::new(); doc.version = Some("1.0".to_string()); doc.encoding = Some("UTF-8".to_string()); let root = doc.root(); let elem = doc.create_node(NodeKind::Element { name: "root".to_string(), prefix: None, namespace: None, attributes: vec![], }); doc.append_child(root, elem); assert_eq!( serialize(&doc), "\n\n" ); } #[test] fn test_serialize_attr_escaping() { let mut doc = Document::new(); let root = doc.root(); let elem = doc.create_node(NodeKind::Element { name: "a".to_string(), prefix: None, namespace: None, attributes: vec![Attribute { name: "title".to_string(), value: "He said \"hello\" & ".to_string(), prefix: None, namespace: None, raw_value: None, }], }); doc.append_child(root, elem); assert_eq!( serialize(&doc), "\n\n" ); } #[test] fn test_serialize_pretty_print() { let doc = Document::parse_str("text").unwrap(); let opts = SerializeOptions::default().indent(true); let xml = serialize_with_options(&doc, &opts); assert_eq!( xml, "\n\n \n text\n \n\n" ); } #[test] fn test_serialize_pretty_print_mixed_content() { // Mixed content (element + non-whitespace text) should not be indented let doc = Document::parse_str("

Hello world

").unwrap(); let opts = SerializeOptions::default().indent(true); let xml = serialize_with_options(&doc, &opts); // Mixed content children are not indented assert!(xml.contains("

Hello world

")); } #[test] fn test_serialize_pretty_print_custom_indent() { let doc = Document::parse_str("").unwrap(); let opts = SerializeOptions::default().indent(true).indent_str("\t"); let xml = serialize_with_options(&doc, &opts); assert!(xml.contains("\t")); } #[test] fn test_serialize_no_indent_unchanged() { // Default (no indent) should produce same output as serialize() let doc = Document::parse_str("Hello").unwrap(); let xml1 = serialize(&doc); let xml2 = serialize_with_options(&doc, &SerializeOptions::default()); assert_eq!(xml1, xml2); } }