sup_xml_core/serializer.rs
1//! Serialize a [`sup_xml_tree::dom::Document`] back to XML text.
2//!
3//! Mirrors the existing [`crate::serializer`] module — same option type
4//! ([`crate::SerializeOptions`]), same escaping rules, same compact and
5//! pretty-print layouts — but walks the arena tree (`Node::children()` /
6//! `Node::attributes()` iterators) rather than the legacy `Vec<Node>` /
7//! `Vec<Attribute>` fields.
8//!
9//! # Example
10//!
11//! ```
12//! use sup_xml_core::{parse_str, serialize_to_string, ParseOptions};
13//! let doc = parse_str("<r><a id='1'/></r>", &ParseOptions::default()).unwrap();
14//! let xml = serialize_to_string(&doc);
15//! assert!(xml.contains(r#"<a id="1"/>"#));
16//! ```
17
18use sup_xml_tree::dom::{Document, Node, NodeKind};
19
20use crate::output::{XmlBuf, OutputCharset};
21
22// ── options ──────────────────────────────────────────────────────────────────
23
24/// Options that control how a [`Document`] is serialized back to XML text.
25///
26/// # Example
27/// ```
28/// use sup_xml_core::{parse_str, serialize_with, SerializeOptions, ParseOptions};
29///
30/// let doc = parse_str("<root><child/></root>", &ParseOptions::default()).unwrap();
31/// let opts = SerializeOptions { format: true, ..SerializeOptions::default() };
32/// println!("{}", serialize_with(&doc, &opts));
33/// ```
34#[derive(Debug, Clone)]
35pub struct SerializeOptions {
36 /// Emit `<?xml version="…" encoding="…"?>` as the first line.
37 /// Default: `true`. Has no effect when [`html_mode`](Self::html_mode)
38 /// is on (HTML5 has no XML declaration).
39 pub write_xml_decl: bool,
40 /// Pretty-print with newlines and indentation. When `false` (the
41 /// default), whitespace text nodes are preserved exactly as parsed.
42 /// When `true`, whitespace-only text nodes between elements are dropped
43 /// and fresh indentation is added. Default: `false`.
44 pub format: bool,
45 /// The string used for one indentation level when `format` is `true`.
46 /// Default: two spaces (`" "`).
47 pub indent: String,
48 /// Emit HTML5-style serialization rather than XML. Differences:
49 ///
50 /// - Void elements (`<br>`, `<img>`, `<input>`, …) emit *with no
51 /// end tag and no self-closing slash* — `<br>` not `<br/>`.
52 /// - Raw-text elements (`<script>`, `<style>`) emit their text
53 /// content verbatim — no entity escaping. This is required
54 /// because the HTML5 tokenizer treats those tags' contents as
55 /// literal text up to the close tag.
56 /// - Empty non-void elements emit as `<div></div>`, not `<div/>`.
57 /// HTML5 doesn't support self-closing on non-void elements;
58 /// browsers parse `<div/>` as `<div>` with no close.
59 /// - Boolean attributes use shorthand: `<input disabled>` instead
60 /// of `<input disabled="">` or `<input disabled="disabled">`.
61 /// Detected when the attribute value is empty or matches the
62 /// attribute name (case-insensitive) — these are
63 /// spec-equivalent forms in HTML5.
64 /// - The XML declaration is suppressed regardless of
65 /// [`write_xml_decl`](Self::write_xml_decl).
66 /// - If the document carries `html_metadata` with a captured
67 /// DOCTYPE, that DOCTYPE is emitted as the first line.
68 /// For documents without a stored DOCTYPE we don't fabricate
69 /// one — caller's responsibility to insert `<!DOCTYPE html>`
70 /// if they want it.
71 ///
72 /// Default: `false`. Turn on when emitting output meant to be
73 /// consumed by browsers / HTML parsers rather than XML parsers.
74 pub html_mode: bool,
75 /// Serialize per libxml2's `xhtmlNodeDumpOutput` (XML syntax with
76 /// XHTML accommodations), used when the document's DOCTYPE identifies
77 /// it as XHTML. Distinct from [`html_mode`](Self::html_mode): output
78 /// is still well-formed XML, but a root `<html>` carrying no namespace
79 /// gains `xmlns="http://www.w3.org/1999/xhtml"`, an empty non-void
80 /// element is written `<tag></tag>` (browsers mis-parse `<tag/>`), and
81 /// an empty void element (`br`, `img`, …) is written `<tag />`.
82 ///
83 /// Default: `false`.
84 pub xhtml: bool,
85 /// The charset the output bytes will ultimately be encoded into.
86 /// Characters it cannot represent are written as numeric character
87 /// references in text / attribute content — matching libxml2, where
88 /// a non-UTF-8 output encoding (or no encoding at all, which defaults
89 /// to ASCII) escapes anything outside its repertoire. Default:
90 /// [`OutputCharset::Utf8`] (no extra escaping).
91 pub out_charset: OutputCharset,
92}
93
94impl Default for SerializeOptions {
95 fn default() -> Self {
96 Self {
97 write_xml_decl: true,
98 format: false,
99 indent: " ".to_string(),
100 html_mode: false,
101 xhtml: false,
102 out_charset: OutputCharset::Utf8,
103 }
104 }
105}
106
107// HTML5 helpers — duplicated from `crate::serializer` rather than re-exported
108// to keep the two modules independent. Tiny; if they ever drift apart that's
109// a bug we'd catch in the round-trip tests below.
110fn is_html_void_element(name: &str) -> bool {
111 matches!(name,
112 "area" | "base" | "br" | "col" | "embed" | "hr" | "img" | "input" |
113 "link" | "meta" | "param" | "source" | "track" | "wbr" |
114 "keygen" | "menuitem"
115 )
116}
117fn is_html_raw_text_element(name: &str) -> bool {
118 matches!(name, "script" | "style")
119}
120/// libxml2's set of HTML boolean attributes (`htmlBooleanAttrs`). When
121/// an attribute's name is one of these, the HTML serializer emits just
122/// the name and drops the value entirely — `selected="bar"` becomes
123/// `selected` — regardless of what the value was.
124fn is_html_boolean_attr(name: &str) -> bool {
125 const BOOLEAN_ATTRS: &[&str] = &[
126 "checked", "compact", "declare", "defer", "disabled", "ismap",
127 "multiple", "nohref", "noresize", "noshade", "nowrap", "readonly",
128 "selected",
129 ];
130 BOOLEAN_ATTRS.iter().any(|b| name.eq_ignore_ascii_case(b))
131}
132
133// ── public entry points ─────────────────────────────────────────────────────
134
135/// Serialize an arena [`Document`] to a compact XML string with default options.
136pub fn serialize_to_string(doc: &Document) -> String {
137 serialize_with(doc, &SerializeOptions::default())
138}
139
140/// Serialize an arena [`Document`] to a compact UTF-8 byte vector.
141pub fn serialize_to_bytes(doc: &Document) -> Vec<u8> {
142 serialize_to_string(doc).into_bytes()
143}
144
145/// Serialize an arena [`Document`] with pretty-printing (newlines + indentation).
146pub fn serialize_formatted(doc: &Document) -> String {
147 serialize_with(doc, &SerializeOptions {
148 write_xml_decl: true,
149 format: true,
150 indent: " ".to_string(),
151 html_mode: false,
152 xhtml: false,
153 out_charset: OutputCharset::Utf8,
154 })
155}
156
157/// Serialize an arena [`Document`] with full control via [`SerializeOptions`].
158pub fn serialize_with(doc: &Document, opts: &SerializeOptions) -> String {
159 let mut s = Serializer { buf: XmlBuf::with_charset(4096, opts.out_charset), opts };
160 s.write_document(doc);
161 s.buf.into_string()
162}
163
164/// Serialize a single [`Node`] (and its descendants) without the
165/// surrounding document machinery (no XML declaration, no DOCTYPE).
166/// Used by the libxml2 ABI shim's `xmlNodeDump` and by callers that
167/// want a subtree string.
168pub fn serialize_node_to_string(node: &Node<'_>, opts: &SerializeOptions) -> String {
169 let mut s = Serializer { buf: XmlBuf::with_charset(256, opts.out_charset), opts };
170 s.write_node(node, 0);
171 s.buf.into_string()
172}
173
174/// Serialize an arena [`Document`] as HTML5. Emits the DOCTYPE if the
175/// document carries one, never emits an XML declaration, uses HTML5 void-
176/// element / raw-text / boolean-attribute conventions. Same shape as
177/// [`crate::serialize_html_to_string`] but accepts the arena tree.
178pub fn serialize_html_to_string(doc: &Document) -> String {
179 serialize_with(doc, &SerializeOptions {
180 html_mode: true,
181 write_xml_decl: false,
182 ..SerializeOptions::default()
183 })
184}
185
186// ── serializer ──────────────────────────────────────────────────────────────
187
188struct Serializer<'o> {
189 buf: XmlBuf,
190 opts: &'o SerializeOptions,
191}
192
193impl Serializer<'_> {
194 fn write_document(&mut self, doc: &Document) {
195 if self.opts.html_mode {
196 // HTML5: emit DOCTYPE if the document carries one, never an
197 // XML declaration.
198 if let Some(meta) = &doc.html_metadata {
199 if let Some(dt) = &meta.doctype {
200 self.buf.push_str("<!DOCTYPE ");
201 self.buf.push_str(&dt.name);
202 if !dt.public_id.is_empty() {
203 self.buf.push_str(" PUBLIC \"");
204 self.buf.push_str(&dt.public_id);
205 self.buf.push_byte(b'"');
206 if !dt.system_id.is_empty() {
207 self.buf.push_str(" \"");
208 self.buf.push_str(&dt.system_id);
209 self.buf.push_byte(b'"');
210 }
211 } else if !dt.system_id.is_empty() {
212 self.buf.push_str(" SYSTEM \"");
213 self.buf.push_str(&dt.system_id);
214 self.buf.push_byte(b'"');
215 }
216 self.buf.push_byte(b'>');
217 if self.opts.format {
218 self.buf.push_byte(b'\n');
219 }
220 }
221 }
222 } else if self.opts.write_xml_decl {
223 self.buf.push_str("<?xml version=\"");
224 self.buf.push_str(&doc.version);
225 self.buf.push_byte(b'"');
226 // Only emit `encoding=` when the doc carries one.
227 // libxml2's serializer behaves the same — when
228 // doc->encoding is NULL (no `<?xml encoding="…"?>`
229 // declaration in source), the attribute is omitted.
230 if !doc.encoding.is_empty() {
231 self.buf.push_str(" encoding=\"");
232 self.buf.push_str(&doc.encoding);
233 self.buf.push_byte(b'"');
234 }
235 if let Some(sa) = doc.standalone {
236 self.buf.push_str(if sa { " standalone=\"yes\"" } else { " standalone=\"no\"" });
237 }
238 self.buf.push_str("?>");
239 if self.opts.format {
240 self.buf.push_byte(b'\n');
241 }
242 }
243 self.write_node(doc.root(), 0);
244 if self.opts.format {
245 self.buf.push_byte(b'\n');
246 }
247 }
248
249 fn write_node(&mut self, node: &Node<'_>, depth: usize) {
250 match node.kind {
251 NodeKind::Element => self.write_element(node, depth),
252 NodeKind::Text => self.buf.push_escaped_text(node.content()),
253 NodeKind::Comment => {
254 self.buf.push_str("<!--");
255 self.buf.push_str(node.content());
256 self.buf.push_str("-->");
257 }
258 NodeKind::CData => {
259 self.buf.push_str("<![CDATA[");
260 // A literal `]]>` in the content would close the section
261 // early; libxml2 splits it so the `]]` ends one CDATA and
262 // the `>` opens the next (`]]>` → `]]]]><![CDATA[>`).
263 let content = node.content();
264 if content.contains("]]>") {
265 self.buf.push_str(&content.replace("]]>", "]]]]><![CDATA[>"));
266 } else {
267 self.buf.push_str(content);
268 }
269 self.buf.push_str("]]>");
270 }
271 NodeKind::Pi => {
272 self.buf.push_str("<?");
273 self.buf.push_str(node.name());
274 // libxml2 emits the separating space whenever `content`
275 // is non-NULL — even for an empty data section — and omits
276 // it for a no-data PI (NULL content).
277 if let Some(c) = node.content_opt() {
278 self.buf.push_byte(b' ');
279 self.buf.push_str(c);
280 }
281 self.buf.push_str("?>");
282 }
283 NodeKind::EntityRef => {
284 // `content` was populated with the literal `&name;`
285 // source form by the parser when emitting the ref —
286 // write it verbatim to round-trip without expansion.
287 self.buf.push_str(node.content());
288 }
289 NodeKind::DtdDecl => {
290 // Raw internal-subset markup declarations — emit
291 // verbatim (already newline-terminated per declaration).
292 self.buf.push_str(node.content());
293 }
294 // The internal-subset node itself carries no markup of its
295 // own (the `<!DOCTYPE …>` header is emitted by the compat
296 // serializer / lxml via `doc->intSubset`); skip it when it
297 // appears as a document-level sibling.
298 NodeKind::Dtd => {}
299 // c-abi-only discriminants. `Attribute` only sits on the
300 // `xmlAttr` struct (which we never reach through a `Node`
301 // pointer). `Document` shows up when a C-ABI consumer
302 // hands us the document itself cast to a `Node*` — e.g.
303 // libxml2's `xmlNodeDumpOutput(buf, doc, doc_as_node, …)`.
304 // In that case "serialize the node" means "serialize each
305 // child", since the document itself emits no markup.
306 NodeKind::Attribute => unreachable!("Attribute kind never appears on a Node"),
307 NodeKind::Document | NodeKind::DocumentFragment => {
308 // Both are transparent containers — serialize children
309 // without emitting any markup of their own. Fragment
310 // is produced by `xmlNewDocFragment` in the compat
311 // shim; Document is reached when a C consumer casts
312 // the doc itself to xmlNode* and asks to dump it.
313 for child in node.children() {
314 self.write_node(child, depth);
315 }
316 }
317 }
318 }
319
320 fn write_element(&mut self, el: &Node<'_>, depth: usize) {
321 let html_mode = self.opts.html_mode;
322 let name = el.name();
323
324 self.buf.push_byte(b'<');
325 // In c-abi mode `Node::name` is the local part only (libxml2
326 // convention); the prefix lives on the namespace. Re-prepend
327 // `prefix:` here so serialisation reconstructs the QName that
328 // the parser ingested. Non-c-abi keeps the QName in `name`
329 // directly — no prefix prepend needed.
330 #[cfg(feature = "c-abi")]
331 if let Some(ns) = el.namespace.get() {
332 if let Some(prefix) = ns.prefix() {
333 self.buf.push_str(prefix);
334 self.buf.push_byte(b':');
335 }
336 }
337 self.buf.push_str(name);
338 // XHTML serialization (libxml2 `xhtmlNodeDumpOutput`): a root
339 // `<html>` that carries no namespace is given the XHTML namespace
340 // declaration, matching the spec's strictly-conforming form.
341 #[cfg(feature = "c-abi")]
342 if self.opts.xhtml
343 && name == "html"
344 && el.namespace.get().is_none()
345 && el.ns_def.get().is_none()
346 {
347 self.buf.push_str(" xmlns=\"http://www.w3.org/1999/xhtml\"");
348 }
349 // In the c-abi build, namespace declarations live on the
350 // separate `ns_def` chain (libxml2 convention) rather than
351 // in the attribute list. Emit them first so the resulting
352 // serialization carries the same `xmlns[:p]="..."` syntax
353 // regardless of which build the consumer parsed through.
354 #[cfg(feature = "c-abi")]
355 {
356 let mut ns_cur = el.ns_def.get();
357 while let Some(ns) = ns_cur {
358 // The `xml` prefix is predefined by XML 1.0 §3.7 and
359 // must never be re-declared in serialization — emitting
360 // `xmlns:xml="…"` is non-conforming output. Skip it
361 // silently if some consumer pushed one onto ns_def.
362 let skip = matches!(ns.prefix(), Some("xml"))
363 && ns.href() == "http://www.w3.org/XML/1998/namespace";
364 if skip {
365 ns_cur = ns.next.get();
366 continue;
367 }
368 self.buf.push_byte(b' ');
369 match ns.prefix() {
370 None => self.buf.push_str("xmlns"),
371 Some(p) => {
372 self.buf.push_str("xmlns:");
373 self.buf.push_str(p);
374 }
375 }
376 self.buf.push_str("=\"");
377 self.buf.push_escaped_attr(ns.href());
378 self.buf.push_byte(b'"');
379 ns_cur = ns.next.get();
380 }
381 }
382 for attr in el.attributes() {
383 self.buf.push_byte(b' ');
384 // Same prefix reconstruction as element names (c-abi
385 // only): libxml2 stores the attribute's local name in
386 // `name` and the prefix on `ns`. Without prepending
387 // we'd emit `a="…"` instead of `ns0:a="…"`.
388 #[cfg(feature = "c-abi")]
389 if let Some(ns) = attr.namespace.get() {
390 if let Some(prefix) = ns.prefix() {
391 self.buf.push_str(prefix);
392 self.buf.push_byte(b':');
393 }
394 }
395 self.buf.push_str(attr.name());
396 // HTML minimizes an attribute to its name alone when the name
397 // is a known boolean attribute (libxml2's `htmlIsBooleanAttr`,
398 // value dropped regardless) or when it carries no value at all
399 // (`<tag attribute>`, `el.set(name, None)`).
400 if html_mode && (is_html_boolean_attr(attr.name()) || attr.value().is_empty()) {
401 continue;
402 }
403 self.buf.push_str("=\"");
404 self.buf.push_escaped_attr(attr.value());
405 self.buf.push_byte(b'"');
406 }
407
408 let empty = el.first_child.get().is_none();
409
410 if html_mode {
411 // Void elements: no end tag, no self-closing slash.
412 if is_html_void_element(name) {
413 self.buf.push_byte(b'>');
414 return;
415 }
416 // Empty non-void element: explicit `<tag></tag>` (HTML5 forbids
417 // self-closing on non-void elements).
418 if empty {
419 self.buf.push_str("></");
420 self.write_element_qname(el);
421 self.buf.push_byte(b'>');
422 return;
423 }
424 } else if self.opts.xhtml && empty {
425 // XHTML (libxml2 `xhtmlNodeDumpOutput`): a void element is
426 // `<br />`; any other empty element is `<tag></tag>`, since
427 // browsers parse the XML self-closing form `<tag/>` of a
428 // non-void HTML element as an unterminated open tag.
429 if is_html_void_element(name) {
430 self.buf.push_str(" />");
431 } else {
432 self.buf.push_str("></");
433 self.write_element_qname(el);
434 self.buf.push_byte(b'>');
435 }
436 return;
437 } else if empty {
438 // XML: empty element gets `/>` shorthand.
439 self.buf.push_str("/>");
440 return;
441 }
442
443 self.buf.push_byte(b'>');
444
445 // HTML5 raw-text elements: script and style content is verbatim.
446 if html_mode && is_html_raw_text_element(name) {
447 for child in el.children() {
448 if child.kind == NodeKind::Text {
449 self.buf.push_str(child.content());
450 }
451 }
452 self.buf.push_str("</");
453 self.write_element_qname(el);
454 self.buf.push_byte(b'>');
455 return;
456 }
457
458 if self.opts.format {
459 if !self.opts.html_mode && contains_text_child(el) {
460 // libxml2's XML pretty-print rule: when an element
461 // has *any* text/cdata/entity-ref child, formatting
462 // is disabled inside it — every child is emitted
463 // verbatim (no indent added, no whitespace trimmed,
464 // no whitespace-only nodes dropped). Matches
465 // libxml2 2.15.3 xmlsave.c lines 1050-1062
466 // (sets `ctxt->format = 0` and remembers the
467 // `unformattedNode`).
468 //
469 // HTML mode has its own gating via
470 // `html_skip_indent` below — keep them separate
471 // because the rules differ in the edge cases.
472 for child in el.children() {
473 self.write_node(child, depth + 1);
474 }
475 } else if is_inline(el) {
476 // Single non-empty text child rendered inline.
477 let text = el.children().find_map(|c| match c.kind {
478 NodeKind::Text => Some(c.content().trim()),
479 _ => None,
480 }).unwrap_or("");
481 self.buf.push_escaped_text(text);
482 } else if self.opts.html_mode && html_skip_indent(el) {
483 // libxml2's HTMLtree.c rule: skip the newline/indent
484 // dance for the opening *and* closing tag when the
485 // element has a single child, when the first/last
486 // child is text/entity-ref, or when the element is a
487 // formatting-sensitive container ("p", "pre", "param").
488 // Matches the htmlNodeDumpInternal logic
489 // (libxml2 2.15.3 HTMLtree.c lines 969-977 and 1085-1091).
490 for child in el.children() {
491 self.write_node(child, depth + 1);
492 }
493 } else {
494 // libxml2's HTML serializer inserts newlines between
495 // block children but never indentation — leading
496 // whitespace can change how inline content renders, so
497 // HTMLtree.c emits a bare '\n' where xmlsave.c would
498 // also write `level` indent strings.
499 let indent = !self.opts.html_mode;
500 self.buf.push_byte(b'\n');
501 for child in el.children() {
502 // Skip whitespace-only text nodes when pretty-printing.
503 if child.kind == NodeKind::Text && child.content().trim().is_empty() {
504 continue;
505 }
506 if indent {
507 self.write_indent(depth + 1);
508 }
509 self.write_node(child, depth + 1);
510 self.buf.push_byte(b'\n');
511 }
512 if indent {
513 self.write_indent(depth);
514 }
515 }
516 } else {
517 for child in el.children() {
518 self.write_node(child, depth + 1);
519 }
520 }
521
522 self.buf.push_str("</");
523 self.write_element_qname(el);
524 self.buf.push_byte(b'>');
525 }
526
527 /// Emit `prefix:local` for an element, or just `local` when no
528 /// namespace prefix is set. In non-c-abi builds where the
529 /// element name still carries the QName, this is equivalent to
530 /// `push_str(el.name())`.
531 #[inline]
532 fn write_element_qname(&mut self, el: &Node<'_>) {
533 #[cfg(feature = "c-abi")]
534 if let Some(ns) = el.namespace.get() {
535 if let Some(prefix) = ns.prefix() {
536 self.buf.push_str(prefix);
537 self.buf.push_byte(b':');
538 }
539 }
540 self.buf.push_str(el.name());
541 }
542
543 fn write_indent(&mut self, depth: usize) {
544 for _ in 0..depth {
545 self.buf.push_str(&self.opts.indent);
546 }
547 }
548}
549
550/// Returns true if `el` has any direct text-like child (text /
551/// CDATA / entity-ref). Used to mirror libxml2's XML pretty-print
552/// behaviour: as soon as one text child is present in an element,
553/// formatting is disabled for that element's content so existing
554/// whitespace round-trips unchanged.
555fn contains_text_child(el: &Node<'_>) -> bool {
556 el.children().any(|c| matches!(
557 c.kind,
558 NodeKind::Text | NodeKind::CData | NodeKind::EntityRef
559 ))
560}
561
562/// libxml2's HTML serializer skips the newline-and-indent around an
563/// element's content when:
564///
565/// * the element has only one child (so `cur->children == cur->last`), OR
566/// * the first/last child is a text node or entity-ref, OR
567/// * the element is in the "p, pre, param" formatting-sensitive family.
568///
569/// Returns true for any of those, signaling that the caller should
570/// emit `<tag>...children...</tag>` without inserting whitespace.
571/// Matches libxml2 2.15.3 `HTMLtree.c::htmlNodeDumpInternal`
572/// (around lines 969-977 for the opening newline and 1085-1091 for
573/// the closing one — both gates are functionally the same).
574fn html_skip_indent(el: &Node<'_>) -> bool {
575 // p, pre, param — libxml2's `cur->name[0] != 'p'` check covers
576 // every element whose name starts with 'p' (a slight over-match,
577 // but that's what the reference code does).
578 let name = el.name();
579 if name.as_bytes().first().copied() == Some(b'p') {
580 return true;
581 }
582 // Count children and remember first / last.
583 let mut count = 0usize;
584 let mut first: Option<&Node> = None;
585 let mut last: Option<&Node> = None;
586 for child in el.children() {
587 if first.is_none() { first = Some(child); }
588 last = Some(child);
589 count += 1;
590 }
591 if count <= 1 { return true; }
592 // Multiple children: skip only when first or last is text /
593 // entity-ref (matches libxml2's `children->type != HTML_TEXT_NODE`
594 // and `last->type != HTML_TEXT_NODE` gates).
595 let is_text_like = |n: &Node| matches!(n.kind, NodeKind::Text | NodeKind::CData);
596 if let Some(f) = first { if is_text_like(f) { return true; } }
597 if let Some(l) = last { if is_text_like(l) { return true; } }
598 false
599}
600
601/// Render inline when the element has exactly one significant child and that
602/// child is text. Matches `crate::serializer::is_inline` semantics.
603fn is_inline(el: &Node<'_>) -> bool {
604 let mut count = 0;
605 let mut text_only = true;
606 for child in el.children() {
607 let is_ws_text = child.kind == NodeKind::Text && child.content().trim().is_empty();
608 if is_ws_text { continue; }
609 count += 1;
610 if child.kind != NodeKind::Text { text_only = false; }
611 if count > 1 { return false; }
612 }
613 count == 1 && text_only
614}
615
616// ── tests ───────────────────────────────────────────────────────────────────
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621 use crate::parser::parse_str;
622 use crate::options::ParseOptions;
623
624 fn parse(xml: &str) -> Document {
625 parse_str(xml, &ParseOptions::default()).expect("parse")
626 }
627
628 fn serialize_no_decl(doc: &Document) -> String {
629 serialize_with(doc, &SerializeOptions {
630 write_xml_decl: false,
631 format: false,
632 indent: " ".into(),
633 html_mode: false,
634 xhtml: false,
635 out_charset: OutputCharset::Utf8,
636 })
637 }
638
639 #[test]
640 fn empty_element_self_closing() {
641 let doc = parse("<r/>");
642 assert_eq!(serialize_no_decl(&doc), "<r/>");
643 }
644
645 #[test]
646 fn element_with_text() {
647 let doc = parse("<r>hello</r>");
648 assert_eq!(serialize_no_decl(&doc), "<r>hello</r>");
649 }
650
651 #[test]
652 fn attributes_preserved_in_source_order() {
653 let doc = parse(r#"<el id="1" class="x" data-y="42"/>"#);
654 let out = serialize_no_decl(&doc);
655 assert_eq!(out, r#"<el id="1" class="x" data-y="42"/>"#);
656 }
657
658 #[test]
659 fn nested_elements() {
660 let doc = parse("<a><b><c/></b></a>");
661 assert_eq!(serialize_no_decl(&doc), "<a><b><c/></b></a>");
662 }
663
664 #[test]
665 fn text_special_chars_escaped() {
666 let doc = parse("<r><hi&there></r>");
667 // After parse: text content is "<hi&there>". Re-serialized with escapes.
668 let out = serialize_no_decl(&doc);
669 assert!(out.contains("<hi&there>"), "got: {out}");
670 }
671
672 #[test]
673 fn attribute_quotes_escaped() {
674 let doc = parse(r#"<r a=""x""/>"#);
675 let out = serialize_no_decl(&doc);
676 assert!(out.contains(""x""), "got: {out}");
677 }
678
679 #[test]
680 fn cdata_preserved() {
681 let doc = parse("<r><![CDATA[<raw>]]></r>");
682 assert_eq!(serialize_no_decl(&doc), "<r><![CDATA[<raw>]]></r>");
683 }
684
685 #[test]
686 fn comments_preserved() {
687 let doc = parse("<r><!-- hi --></r>");
688 assert_eq!(serialize_no_decl(&doc), "<r><!-- hi --></r>");
689 }
690
691 #[test]
692 fn pi_preserved() {
693 let doc = parse(r#"<r><?xml-stylesheet href="s.xsl"?></r>"#);
694 assert_eq!(serialize_no_decl(&doc), r#"<r><?xml-stylesheet href="s.xsl"?></r>"#);
695 }
696
697 #[test]
698 fn xml_decl_emitted_by_default() {
699 let doc = parse("<r/>");
700 let out = serialize_to_string(&doc);
701 // No `<?xml encoding=...?>` declaration in source → emit
702 // only `version` (matches libxml2's behaviour when
703 // doc->encoding is NULL).
704 assert!(out.starts_with("<?xml version=\"1.0\"?>"), "got: {out}");
705 }
706
707 #[test]
708 fn round_trip_preserves_structure() {
709 // Parse → serialize → parse again → compare structure.
710 let original = r#"<feed xmlns="http://www.w3.org/2005/Atom"><entry><title>X</title><id>1</id></entry></feed>"#;
711 let doc1 = parse(original);
712 let xml = serialize_no_decl(&doc1);
713 let doc2 = parse(&xml);
714 // Element names match
715 assert_eq!(doc1.root().name(), doc2.root().name());
716 // Same number of children at each level
717 let entry1 = doc1.root().first_child.get().unwrap();
718 let entry2 = doc2.root().first_child.get().unwrap();
719 assert_eq!(entry1.children().count(), entry2.children().count());
720 }
721
722 #[test]
723 fn pretty_print_block_layout() {
724 let doc = parse("<r><a/><b/></r>");
725 let out = serialize_formatted(&doc);
726 assert!(out.contains("<r>\n"));
727 assert!(out.contains(" <a/>"));
728 assert!(out.contains(" <b/>"));
729 assert!(out.contains("\n</r>"));
730 }
731
732 #[test]
733 fn pretty_print_inline_text() {
734 let doc = parse("<r><title>Hello</title></r>");
735 let out = serialize_formatted(&doc);
736 // Inline text element stays on one line
737 assert!(out.contains("<title>Hello</title>"), "got: {out}");
738 }
739
740 // ── public entry points ─────────────────────────────────────────────
741
742 #[test]
743 fn serialize_to_bytes_matches_to_string() {
744 let doc = parse("<r/>");
745 let bytes = serialize_to_bytes(&doc);
746 let s = serialize_to_string(&doc);
747 assert_eq!(bytes, s.into_bytes());
748 }
749
750 #[test]
751 fn serialize_node_to_string_emits_just_the_subtree() {
752 // No XML decl, no document wrapping — just the node and its
753 // descendants.
754 let doc = parse("<r><a id='1'><b/></a></r>");
755 let a = doc.root().first_child.get().unwrap();
756 let out = serialize_node_to_string(a, &SerializeOptions {
757 write_xml_decl: false, format: false,
758 indent: " ".into(), html_mode: false, xhtml: false, out_charset: OutputCharset::Utf8,
759 });
760 assert_eq!(out, r#"<a id="1"><b/></a>"#);
761 }
762
763 // ── XML declaration variants ────────────────────────────────────────
764
765 #[test]
766 fn xml_decl_with_encoding_and_standalone() {
767 let doc = parse(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><r/>"#);
768 let out = serialize_to_string(&doc);
769 assert!(out.starts_with("<?xml version=\"1.0\""), "got: {out}");
770 assert!(out.contains("encoding=\"UTF-8\""), "got: {out}");
771 assert!(out.contains("standalone=\"yes\""), "got: {out}");
772 }
773
774 #[test]
775 fn xml_decl_standalone_no() {
776 let doc = parse(r#"<?xml version="1.0" standalone="no"?><r/>"#);
777 let out = serialize_to_string(&doc);
778 assert!(out.contains("standalone=\"no\""), "got: {out}");
779 }
780
781 // ── HTML mode ───────────────────────────────────────────────────────
782
783 #[cfg(feature = "html")]
784 fn parse_html(html: &str) -> Document {
785 crate::html::parse_html_str(html).expect("parse html")
786 }
787
788 #[cfg(feature = "html")]
789 #[test]
790 fn html_void_elements_emit_no_close_tag() {
791 let doc = parse_html("<html><body><br/><img src='x'/><hr/></body></html>");
792 let out = serialize_html_to_string(&doc);
793 // <br>, <img>, <hr> should NOT self-close and should NOT have a close tag.
794 assert!(out.contains("<br>"), "got: {out}");
795 assert!(out.contains("<img"), "got: {out}");
796 assert!(out.contains("<hr>"), "got: {out}");
797 assert!(!out.contains("</br>"), "got: {out}");
798 assert!(!out.contains("<br/>"), "got: {out}");
799 }
800
801 #[cfg(feature = "html")]
802 #[test]
803 fn html_empty_non_void_elements_emit_explicit_close() {
804 let doc = parse_html("<html><body><div></div></body></html>");
805 let out = serialize_html_to_string(&doc);
806 // <div></div> not <div/>.
807 assert!(out.contains("<div></div>"), "got: {out}");
808 assert!(!out.contains("<div/>"), "got: {out}");
809 }
810
811 #[cfg(feature = "html")]
812 #[test]
813 fn html_boolean_attribute_shorthand() {
814 let doc = parse_html(r#"<html><body><input disabled=""></body></html>"#);
815 let out = serialize_html_to_string(&doc);
816 // Empty-value attr → shorthand `<input disabled>` (no =).
817 assert!(out.contains("<input disabled>") || out.contains("<input disabled "), "got: {out}");
818 assert!(!out.contains("disabled=\""), "got: {out}");
819 }
820
821 #[test]
822 fn html_boolean_attr_recognised_case_insensitive() {
823 // Known boolean attributes minimize regardless of value;
824 // recognition is by name, case-insensitively.
825 assert!(is_html_boolean_attr("disabled"));
826 assert!(is_html_boolean_attr("DISABLED"));
827 assert!(is_html_boolean_attr("selected"));
828 assert!(is_html_boolean_attr("checked"));
829 assert!(!is_html_boolean_attr("href"));
830 assert!(!is_html_boolean_attr("class"));
831 }
832
833 #[cfg(feature = "html")]
834 #[test]
835 fn html_raw_text_elements_emit_verbatim_content() {
836 // <script> and <style> bodies must NOT be entity-escaped.
837 let doc = parse_html(
838 "<html><body><script>if (x < 3 && y > 1) {}</script></body></html>",
839 );
840 let out = serialize_html_to_string(&doc);
841 // Raw content should NOT have entity escapes inside <script>.
842 assert!(out.contains("if (x < 3 && y > 1) {}"), "got: {out}");
843 }
844
845 #[test]
846 fn html_void_element_predicate() {
847 assert!(is_html_void_element("br"));
848 assert!(is_html_void_element("img"));
849 assert!(is_html_void_element("input"));
850 assert!(is_html_void_element("meta"));
851 assert!(is_html_void_element("keygen"));
852 assert!(is_html_void_element("menuitem"));
853 assert!(!is_html_void_element("div"));
854 assert!(!is_html_void_element("span"));
855 }
856
857 #[test]
858 fn html_raw_text_predicate() {
859 assert!(is_html_raw_text_element("script"));
860 assert!(is_html_raw_text_element("style"));
861 assert!(!is_html_raw_text_element("div"));
862 }
863}