use sup_xml_tree::dom::{Document, Node, NodeKind};
use crate::output::{XmlBuf, OutputCharset};
#[derive(Debug, Clone)]
pub struct SerializeOptions {
pub write_xml_decl: bool,
pub format: bool,
pub indent: String,
pub html_mode: bool,
pub xhtml: bool,
pub out_charset: OutputCharset,
}
impl Default for SerializeOptions {
fn default() -> Self {
Self {
write_xml_decl: true,
format: false,
indent: " ".to_string(),
html_mode: false,
xhtml: false,
out_charset: OutputCharset::Utf8,
}
}
}
fn is_html_void_element(name: &str) -> bool {
matches!(name,
"area" | "base" | "br" | "col" | "embed" | "hr" | "img" | "input" |
"link" | "meta" | "param" | "source" | "track" | "wbr" |
"keygen" | "menuitem"
)
}
fn is_html_raw_text_element(name: &str) -> bool {
matches!(name, "script" | "style")
}
fn is_html_boolean_attr(name: &str) -> bool {
const BOOLEAN_ATTRS: &[&str] = &[
"checked", "compact", "declare", "defer", "disabled", "ismap",
"multiple", "nohref", "noresize", "noshade", "nowrap", "readonly",
"selected",
];
BOOLEAN_ATTRS.iter().any(|b| name.eq_ignore_ascii_case(b))
}
pub fn serialize_to_string(doc: &Document) -> String {
serialize_with(doc, &SerializeOptions::default())
}
pub fn serialize_to_bytes(doc: &Document) -> Vec<u8> {
serialize_to_string(doc).into_bytes()
}
pub fn serialize_formatted(doc: &Document) -> String {
serialize_with(doc, &SerializeOptions {
write_xml_decl: true,
format: true,
indent: " ".to_string(),
html_mode: false,
xhtml: false,
out_charset: OutputCharset::Utf8,
})
}
pub fn serialize_with(doc: &Document, opts: &SerializeOptions) -> String {
let mut s = Serializer { buf: XmlBuf::with_charset(4096, opts.out_charset), opts };
s.write_document(doc);
s.buf.into_string()
}
pub fn serialize_node_to_string(node: &Node<'_>, opts: &SerializeOptions) -> String {
let mut s = Serializer { buf: XmlBuf::with_charset(256, opts.out_charset), opts };
s.write_node(node, 0);
s.buf.into_string()
}
pub fn serialize_html_to_string(doc: &Document) -> String {
serialize_with(doc, &SerializeOptions {
html_mode: true,
write_xml_decl: false,
..SerializeOptions::default()
})
}
struct Serializer<'o> {
buf: XmlBuf,
opts: &'o SerializeOptions,
}
impl Serializer<'_> {
fn write_document(&mut self, doc: &Document) {
if self.opts.html_mode {
if let Some(meta) = &doc.html_metadata {
if let Some(dt) = &meta.doctype {
self.buf.push_str("<!DOCTYPE ");
self.buf.push_str(&dt.name);
if !dt.public_id.is_empty() {
self.buf.push_str(" PUBLIC \"");
self.buf.push_str(&dt.public_id);
self.buf.push_byte(b'"');
if !dt.system_id.is_empty() {
self.buf.push_str(" \"");
self.buf.push_str(&dt.system_id);
self.buf.push_byte(b'"');
}
} else if !dt.system_id.is_empty() {
self.buf.push_str(" SYSTEM \"");
self.buf.push_str(&dt.system_id);
self.buf.push_byte(b'"');
}
self.buf.push_byte(b'>');
if self.opts.format {
self.buf.push_byte(b'\n');
}
}
}
} else if self.opts.write_xml_decl {
self.buf.push_str("<?xml version=\"");
self.buf.push_str(&doc.version);
self.buf.push_byte(b'"');
if !doc.encoding.is_empty() {
self.buf.push_str(" encoding=\"");
self.buf.push_str(&doc.encoding);
self.buf.push_byte(b'"');
}
if let Some(sa) = doc.standalone {
self.buf.push_str(if sa { " standalone=\"yes\"" } else { " standalone=\"no\"" });
}
self.buf.push_str("?>");
if self.opts.format {
self.buf.push_byte(b'\n');
}
}
self.write_node(doc.root(), 0);
if self.opts.format {
self.buf.push_byte(b'\n');
}
}
fn write_node(&mut self, node: &Node<'_>, depth: usize) {
match node.kind {
NodeKind::Element => self.write_element(node, depth),
NodeKind::Text => self.buf.push_escaped_text(node.content()),
NodeKind::Comment => {
self.buf.push_str("<!--");
self.buf.push_str(node.content());
self.buf.push_str("-->");
}
NodeKind::CData => {
self.buf.push_str("<![CDATA[");
let content = node.content();
if content.contains("]]>") {
self.buf.push_str(&content.replace("]]>", "]]]]><![CDATA[>"));
} else {
self.buf.push_str(content);
}
self.buf.push_str("]]>");
}
NodeKind::Pi => {
self.buf.push_str("<?");
self.buf.push_str(node.name());
if let Some(c) = node.content_opt() {
self.buf.push_byte(b' ');
self.buf.push_str(c);
}
self.buf.push_str("?>");
}
NodeKind::EntityRef => {
self.buf.push_str(node.content());
}
NodeKind::DtdDecl => {
self.buf.push_str(node.content());
}
NodeKind::Dtd => {}
NodeKind::Attribute => unreachable!("Attribute kind never appears on a Node"),
NodeKind::Document | NodeKind::DocumentFragment => {
for child in node.children() {
self.write_node(child, depth);
}
}
}
}
fn write_element(&mut self, el: &Node<'_>, depth: usize) {
let html_mode = self.opts.html_mode;
let name = el.name();
self.buf.push_byte(b'<');
#[cfg(feature = "c-abi")]
if let Some(ns) = el.namespace.get() {
if let Some(prefix) = ns.prefix() {
self.buf.push_str(prefix);
self.buf.push_byte(b':');
}
}
self.buf.push_str(name);
#[cfg(feature = "c-abi")]
if self.opts.xhtml
&& name == "html"
&& el.namespace.get().is_none()
&& el.ns_def.get().is_none()
{
self.buf.push_str(" xmlns=\"http://www.w3.org/1999/xhtml\"");
}
#[cfg(feature = "c-abi")]
{
let mut ns_cur = el.ns_def.get();
while let Some(ns) = ns_cur {
let skip = matches!(ns.prefix(), Some("xml"))
&& ns.href() == "http://www.w3.org/XML/1998/namespace";
if skip {
ns_cur = ns.next.get();
continue;
}
self.buf.push_byte(b' ');
match ns.prefix() {
None => self.buf.push_str("xmlns"),
Some(p) => {
self.buf.push_str("xmlns:");
self.buf.push_str(p);
}
}
self.buf.push_str("=\"");
self.buf.push_escaped_attr(ns.href());
self.buf.push_byte(b'"');
ns_cur = ns.next.get();
}
}
for attr in el.attributes() {
self.buf.push_byte(b' ');
#[cfg(feature = "c-abi")]
if let Some(ns) = attr.namespace.get() {
if let Some(prefix) = ns.prefix() {
self.buf.push_str(prefix);
self.buf.push_byte(b':');
}
}
self.buf.push_str(attr.name());
if html_mode && (is_html_boolean_attr(attr.name()) || attr.value().is_empty()) {
continue;
}
self.buf.push_str("=\"");
self.buf.push_escaped_attr(attr.value());
self.buf.push_byte(b'"');
}
let empty = el.first_child.get().is_none();
if html_mode {
if is_html_void_element(name) {
self.buf.push_byte(b'>');
return;
}
if empty {
self.buf.push_str("></");
self.write_element_qname(el);
self.buf.push_byte(b'>');
return;
}
} else if self.opts.xhtml && empty {
if is_html_void_element(name) {
self.buf.push_str(" />");
} else {
self.buf.push_str("></");
self.write_element_qname(el);
self.buf.push_byte(b'>');
}
return;
} else if empty {
self.buf.push_str("/>");
return;
}
self.buf.push_byte(b'>');
if html_mode && is_html_raw_text_element(name) {
for child in el.children() {
if child.kind == NodeKind::Text {
self.buf.push_str(child.content());
}
}
self.buf.push_str("</");
self.write_element_qname(el);
self.buf.push_byte(b'>');
return;
}
if self.opts.format {
if !self.opts.html_mode && contains_text_child(el) {
for child in el.children() {
self.write_node(child, depth + 1);
}
} else if is_inline(el) {
let text = el.children().find_map(|c| match c.kind {
NodeKind::Text => Some(c.content().trim()),
_ => None,
}).unwrap_or("");
self.buf.push_escaped_text(text);
} else if self.opts.html_mode && html_skip_indent(el) {
for child in el.children() {
self.write_node(child, depth + 1);
}
} else {
let indent = !self.opts.html_mode;
self.buf.push_byte(b'\n');
for child in el.children() {
if child.kind == NodeKind::Text && child.content().trim().is_empty() {
continue;
}
if indent {
self.write_indent(depth + 1);
}
self.write_node(child, depth + 1);
self.buf.push_byte(b'\n');
}
if indent {
self.write_indent(depth);
}
}
} else {
for child in el.children() {
self.write_node(child, depth + 1);
}
}
self.buf.push_str("</");
self.write_element_qname(el);
self.buf.push_byte(b'>');
}
#[inline]
fn write_element_qname(&mut self, el: &Node<'_>) {
#[cfg(feature = "c-abi")]
if let Some(ns) = el.namespace.get() {
if let Some(prefix) = ns.prefix() {
self.buf.push_str(prefix);
self.buf.push_byte(b':');
}
}
self.buf.push_str(el.name());
}
fn write_indent(&mut self, depth: usize) {
for _ in 0..depth {
self.buf.push_str(&self.opts.indent);
}
}
}
fn contains_text_child(el: &Node<'_>) -> bool {
el.children().any(|c| matches!(
c.kind,
NodeKind::Text | NodeKind::CData | NodeKind::EntityRef
))
}
fn html_skip_indent(el: &Node<'_>) -> bool {
let name = el.name();
if name.as_bytes().first().copied() == Some(b'p') {
return true;
}
let mut count = 0usize;
let mut first: Option<&Node> = None;
let mut last: Option<&Node> = None;
for child in el.children() {
if first.is_none() { first = Some(child); }
last = Some(child);
count += 1;
}
if count <= 1 { return true; }
let is_text_like = |n: &Node| matches!(n.kind, NodeKind::Text | NodeKind::CData);
if let Some(f) = first { if is_text_like(f) { return true; } }
if let Some(l) = last { if is_text_like(l) { return true; } }
false
}
fn is_inline(el: &Node<'_>) -> bool {
let mut count = 0;
let mut text_only = true;
for child in el.children() {
let is_ws_text = child.kind == NodeKind::Text && child.content().trim().is_empty();
if is_ws_text { continue; }
count += 1;
if child.kind != NodeKind::Text { text_only = false; }
if count > 1 { return false; }
}
count == 1 && text_only
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse_str;
use crate::options::ParseOptions;
fn parse(xml: &str) -> Document {
parse_str(xml, &ParseOptions::default()).expect("parse")
}
fn serialize_no_decl(doc: &Document) -> String {
serialize_with(doc, &SerializeOptions {
write_xml_decl: false,
format: false,
indent: " ".into(),
html_mode: false,
xhtml: false,
out_charset: OutputCharset::Utf8,
})
}
#[test]
fn empty_element_self_closing() {
let doc = parse("<r/>");
assert_eq!(serialize_no_decl(&doc), "<r/>");
}
#[test]
fn element_with_text() {
let doc = parse("<r>hello</r>");
assert_eq!(serialize_no_decl(&doc), "<r>hello</r>");
}
#[test]
fn attributes_preserved_in_source_order() {
let doc = parse(r#"<el id="1" class="x" data-y="42"/>"#);
let out = serialize_no_decl(&doc);
assert_eq!(out, r#"<el id="1" class="x" data-y="42"/>"#);
}
#[test]
fn nested_elements() {
let doc = parse("<a><b><c/></b></a>");
assert_eq!(serialize_no_decl(&doc), "<a><b><c/></b></a>");
}
#[test]
fn text_special_chars_escaped() {
let doc = parse("<r><hi&there></r>");
let out = serialize_no_decl(&doc);
assert!(out.contains("<hi&there>"), "got: {out}");
}
#[test]
fn attribute_quotes_escaped() {
let doc = parse(r#"<r a=""x""/>"#);
let out = serialize_no_decl(&doc);
assert!(out.contains(""x""), "got: {out}");
}
#[test]
fn cdata_preserved() {
let doc = parse("<r><![CDATA[<raw>]]></r>");
assert_eq!(serialize_no_decl(&doc), "<r><![CDATA[<raw>]]></r>");
}
#[test]
fn comments_preserved() {
let doc = parse("<r><!-- hi --></r>");
assert_eq!(serialize_no_decl(&doc), "<r><!-- hi --></r>");
}
#[test]
fn pi_preserved() {
let doc = parse(r#"<r><?xml-stylesheet href="s.xsl"?></r>"#);
assert_eq!(serialize_no_decl(&doc), r#"<r><?xml-stylesheet href="s.xsl"?></r>"#);
}
#[test]
fn xml_decl_emitted_by_default() {
let doc = parse("<r/>");
let out = serialize_to_string(&doc);
assert!(out.starts_with("<?xml version=\"1.0\"?>"), "got: {out}");
}
#[test]
fn round_trip_preserves_structure() {
let original = r#"<feed xmlns="http://www.w3.org/2005/Atom"><entry><title>X</title><id>1</id></entry></feed>"#;
let doc1 = parse(original);
let xml = serialize_no_decl(&doc1);
let doc2 = parse(&xml);
assert_eq!(doc1.root().name(), doc2.root().name());
let entry1 = doc1.root().first_child.get().unwrap();
let entry2 = doc2.root().first_child.get().unwrap();
assert_eq!(entry1.children().count(), entry2.children().count());
}
#[test]
fn pretty_print_block_layout() {
let doc = parse("<r><a/><b/></r>");
let out = serialize_formatted(&doc);
assert!(out.contains("<r>\n"));
assert!(out.contains(" <a/>"));
assert!(out.contains(" <b/>"));
assert!(out.contains("\n</r>"));
}
#[test]
fn pretty_print_inline_text() {
let doc = parse("<r><title>Hello</title></r>");
let out = serialize_formatted(&doc);
assert!(out.contains("<title>Hello</title>"), "got: {out}");
}
#[test]
fn serialize_to_bytes_matches_to_string() {
let doc = parse("<r/>");
let bytes = serialize_to_bytes(&doc);
let s = serialize_to_string(&doc);
assert_eq!(bytes, s.into_bytes());
}
#[test]
fn serialize_node_to_string_emits_just_the_subtree() {
let doc = parse("<r><a id='1'><b/></a></r>");
let a = doc.root().first_child.get().unwrap();
let out = serialize_node_to_string(a, &SerializeOptions {
write_xml_decl: false, format: false,
indent: " ".into(), html_mode: false, xhtml: false, out_charset: OutputCharset::Utf8,
});
assert_eq!(out, r#"<a id="1"><b/></a>"#);
}
#[test]
fn xml_decl_with_encoding_and_standalone() {
let doc = parse(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><r/>"#);
let out = serialize_to_string(&doc);
assert!(out.starts_with("<?xml version=\"1.0\""), "got: {out}");
assert!(out.contains("encoding=\"UTF-8\""), "got: {out}");
assert!(out.contains("standalone=\"yes\""), "got: {out}");
}
#[test]
fn xml_decl_standalone_no() {
let doc = parse(r#"<?xml version="1.0" standalone="no"?><r/>"#);
let out = serialize_to_string(&doc);
assert!(out.contains("standalone=\"no\""), "got: {out}");
}
#[cfg(feature = "html")]
fn parse_html(html: &str) -> Document {
crate::html::parse_html_str(html).expect("parse html")
}
#[cfg(feature = "html")]
#[test]
fn html_void_elements_emit_no_close_tag() {
let doc = parse_html("<html><body><br/><img src='x'/><hr/></body></html>");
let out = serialize_html_to_string(&doc);
assert!(out.contains("<br>"), "got: {out}");
assert!(out.contains("<img"), "got: {out}");
assert!(out.contains("<hr>"), "got: {out}");
assert!(!out.contains("</br>"), "got: {out}");
assert!(!out.contains("<br/>"), "got: {out}");
}
#[cfg(feature = "html")]
#[test]
fn html_empty_non_void_elements_emit_explicit_close() {
let doc = parse_html("<html><body><div></div></body></html>");
let out = serialize_html_to_string(&doc);
assert!(out.contains("<div></div>"), "got: {out}");
assert!(!out.contains("<div/>"), "got: {out}");
}
#[cfg(feature = "html")]
#[test]
fn html_boolean_attribute_shorthand() {
let doc = parse_html(r#"<html><body><input disabled=""></body></html>"#);
let out = serialize_html_to_string(&doc);
assert!(out.contains("<input disabled>") || out.contains("<input disabled "), "got: {out}");
assert!(!out.contains("disabled=\""), "got: {out}");
}
#[test]
fn html_boolean_attr_recognised_case_insensitive() {
assert!(is_html_boolean_attr("disabled"));
assert!(is_html_boolean_attr("DISABLED"));
assert!(is_html_boolean_attr("selected"));
assert!(is_html_boolean_attr("checked"));
assert!(!is_html_boolean_attr("href"));
assert!(!is_html_boolean_attr("class"));
}
#[cfg(feature = "html")]
#[test]
fn html_raw_text_elements_emit_verbatim_content() {
let doc = parse_html(
"<html><body><script>if (x < 3 && y > 1) {}</script></body></html>",
);
let out = serialize_html_to_string(&doc);
assert!(out.contains("if (x < 3 && y > 1) {}"), "got: {out}");
}
#[test]
fn html_void_element_predicate() {
assert!(is_html_void_element("br"));
assert!(is_html_void_element("img"));
assert!(is_html_void_element("input"));
assert!(is_html_void_element("meta"));
assert!(is_html_void_element("keygen"));
assert!(is_html_void_element("menuitem"));
assert!(!is_html_void_element("div"));
assert!(!is_html_void_element("span"));
}
#[test]
fn html_raw_text_predicate() {
assert!(is_html_raw_text_element("script"));
assert!(is_html_raw_text_element("style"));
assert!(!is_html_raw_text_element("div"));
}
}