1use std::fmt::Write;
20
21use crate::ast::{OutputSpec, QName, Standalone};
22use crate::error::XsltError;
23use crate::result_tree::{ResultNode, ResultTree};
24
25fn effective_method(tree: &ResultTree) -> &str {
31 if let Some(m) = tree.output.method.as_deref() { return m; }
32 if let Some(ResultNode::Element { name, .. }) = tree.children.iter()
33 .find(|n| matches!(n, ResultNode::Element { .. }))
34 {
35 if name.local.eq_ignore_ascii_case("html") && name.uri.is_empty() {
36 return "html";
37 }
38 }
39 "xml"
40}
41
42impl ResultTree {
43 pub fn to_string(&self) -> Result<String, XsltError> {
52 let method = effective_method(self);
53 let html_family = matches!(method, "html" | "xhtml");
54 let indent = self.output.indent.unwrap_or(html_family);
55 let escape_uri = html_family
56 && self.output.escape_uri_attributes.unwrap_or(true);
57 let content_type = html_family
58 && self.output.include_content_type.unwrap_or(true);
59
60 let owned;
64 let children: &[ResultNode] = if content_type {
65 owned = with_content_type_meta(&self.children, &self.output, method == "xhtml");
66 &owned
67 } else {
68 &self.children
69 };
70
71 let mut out = match method {
72 "html" => serialize_html(children, &self.output, indent, escape_uri),
73 "text" => serialize_text(children),
74 _ => serialize_xml(children, &self.output, &self.character_map,
77 indent, escape_uri),
78 };
79 if self.output.byte_order_mark == Some(true) {
82 out.insert(0, '\u{feff}');
83 }
84 Ok(out)
85 }
86
87 pub fn write_to(&self, w: &mut dyn std::io::Write) -> Result<(), XsltError> {
89 let s = self.to_string()?;
90 w.write_all(s.as_bytes())
91 .map_err(|e| XsltError::InvalidStylesheet(format!("write failed: {e}")))
92 }
93}
94
95pub fn serialize_xml(
98 children: &[ResultNode],
99 output: &OutputSpec,
100 cmap: &[(char, String)],
101 indent: bool,
102 escape_uri: bool,
103) -> String {
104 let mut out = String::new();
105 if should_emit_xml_decl(output) {
106 let _ = write!(out, r#"<?xml version="{}" encoding="{}""#,
107 output.version.as_deref().unwrap_or("1.0"),
108 output.encoding.as_deref().unwrap_or("UTF-8"),
109 );
110 match output.standalone {
113 Some(Standalone::Yes) => { let _ = write!(out, r#" standalone="yes""#); }
114 Some(Standalone::No) => { let _ = write!(out, r#" standalone="no""#); }
115 Some(Standalone::Omit) | None => {}
116 }
117 out.push_str("?>\n");
118 }
119 if let Some(dt_sys) = output.doctype_system.as_deref() {
120 if let Some(root) = first_element_name(children) {
121 if let Some(pubid) = output.doctype_public.as_deref() {
122 let _ = writeln!(out, r#"<!DOCTYPE {root} PUBLIC "{pubid}" "{dt_sys}">"#);
123 } else {
124 let _ = writeln!(out, r#"<!DOCTYPE {root} SYSTEM "{dt_sys}">"#);
125 }
126 }
127 }
128 for child in children {
133 serialize_xml_node(child, &mut out, output, "", cmap, indent, escape_uri, 0);
134 }
135 if indent && !out.ends_with('\n') {
136 out.push('\n');
137 }
138 out
139}
140
141fn push_indent(out: &mut String, level: usize) {
143 for _ in 0..level {
144 out.push_str(" ");
145 }
146}
147
148fn should_emit_xml_decl(output: &OutputSpec) -> bool {
149 !output.omit_xml_declaration.unwrap_or(false)
151}
152
153fn first_element_name(nodes: &[ResultNode]) -> Option<String> {
154 nodes.iter().find_map(|n| match n {
155 ResultNode::Element { name, .. } => Some(name.to_qname_string()),
156 _ => None,
157 })
158}
159
160#[allow(clippy::too_many_arguments)]
167fn serialize_xml_node(
168 node: &ResultNode,
169 out: &mut String,
170 opts: &OutputSpec,
171 parent_default_ns: &str,
172 cmap: &[(char, String)],
173 format: bool,
174 escape_uri: bool,
175 level: usize,
176) {
177 let xml_11 = opts.version.as_deref() == Some("1.1");
178 let enc_cap = encoding_capability(opts.encoding.as_deref());
179 match node {
180 ResultNode::Element { name, namespaces, attributes, children, .. } => {
181 let q = name.to_qname_string();
182 out.push('<');
183 out.push_str(&q);
184 let mut child_default_ns: &str = parent_default_ns;
190 for (prefix, uri) in namespaces {
191 match prefix {
192 Some(p) if p == "xml"
200 && uri == "http://www.w3.org/XML/1998/namespace" => {}
201 Some(p) => { let _ = write!(out, r#" xmlns:{p}="{}""#, escape_attr(uri, xml_11, enc_cap)); }
202 None => {
203 if uri != parent_default_ns {
209 let _ = write!(out, r#" xmlns="{}""#, escape_attr(uri, xml_11, enc_cap));
210 }
211 child_default_ns = uri.as_str();
212 }
213 }
214 }
215 for (aname, value) in attributes {
216 let _ = write!(out, r#" {}="{}""#,
217 aname.to_qname_string(),
218 render_attr_value(name, aname, value, escape_uri, xml_11, enc_cap, cmap));
219 }
220 if children.is_empty() {
221 out.push_str("/>");
222 return;
223 }
224 out.push('>');
225 let is_cdata = opts.cdata_section_elements.iter()
228 .any(|q| q.uri == name.uri && q.local == name.local);
229 let child_format = format
233 && !children.iter().any(|c| matches!(c, ResultNode::Text { .. }));
234 for c in children {
235 if child_format {
236 out.push('\n');
237 push_indent(out, level + 1);
238 }
239 if is_cdata {
240 if let ResultNode::Text { content, .. } = c {
241 out.push_str("<![CDATA[");
242 out.push_str(&content.replace("]]>", "]]]]><![CDATA[>"));
243 out.push_str("]]>");
244 continue;
245 }
246 }
247 serialize_xml_node(c, out, opts, child_default_ns, cmap, child_format, escape_uri, level + 1);
248 }
249 if child_format {
250 out.push('\n');
251 push_indent(out, level);
252 }
253 out.push_str("</");
254 out.push_str(&q);
255 out.push('>');
256 }
257 ResultNode::Text { content, dose } => {
258 if *dose {
259 out.push_str(content);
260 } else {
261 out.push_str(&escape_text_with_map(content, xml_11, enc_cap, cmap));
262 }
263 }
264 ResultNode::Comment(s) => {
265 let _ = write!(out, "<!--{}-->", s);
266 }
267 ResultNode::ProcessingInstruction { target, data } => {
268 if data.is_empty() {
269 let _ = write!(out, "<?{target}?>");
270 } else {
271 let _ = write!(out, "<?{target} {data}?>");
272 }
273 }
274 ResultNode::Attribute { .. } => {}
278 }
279}
280
281#[inline]
288fn xml_11_must_escape(c: char) -> bool {
289 matches!(c as u32,
290 0x01..=0x08 | 0x0B..=0x0C | 0x0E..=0x1F |
291 0x7F..=0x84 | 0x85 | 0x86..=0x9F |
292 0x2028
293 )
294}
295
296fn encoding_capability(enc: Option<&str>) -> Option<u32> {
303 let name = enc.unwrap_or("UTF-8").to_ascii_lowercase();
304 let norm: String = name.chars().filter(|c| !matches!(c, '-' | '_' | ' ')).collect();
305 match norm.as_str() {
306 "ascii" | "usascii" | "iso646us" => Some(0x7F),
308 "iso88591" | "latin1" | "l1" | "cp1252" | "windows1252" => Some(0xFF),
312 _ => None,
316 }
317}
318
319#[inline]
320fn must_ncr_escape(c: char, enc_cap: Option<u32>) -> bool {
321 matches!(enc_cap, Some(max) if c as u32 > max)
322}
323
324fn escape_text(s: &str, xml_11: bool, enc_cap: Option<u32>) -> String {
325 escape_text_with_map(s, xml_11, enc_cap, &[])
326}
327
328fn escape_attr(s: &str, xml_11: bool, enc_cap: Option<u32>) -> String {
329 escape_attr_with_map(s, xml_11, enc_cap, &[])
330}
331
332#[inline]
337fn cmap_lookup<'a>(c: char, cmap: &'a [(char, String)]) -> Option<&'a str> {
338 for (k, v) in cmap {
339 if *k == c { return Some(v.as_str()); }
340 }
341 None
342}
343
344fn escape_text_with_map(
345 s: &str,
346 xml_11: bool,
347 enc_cap: Option<u32>,
348 cmap: &[(char, String)],
349) -> String {
350 let mut out = String::with_capacity(s.len());
351 for c in s.chars() {
352 if let Some(replacement) = cmap_lookup(c, cmap) {
353 out.push_str(replacement);
354 continue;
355 }
356 match c {
357 '<' => out.push_str("<"),
358 '>' => out.push_str(">"),
359 '&' => out.push_str("&"),
360 '\r' => out.push_str("
"),
364 c if xml_11 && xml_11_must_escape(c) => {
365 let _ = write!(out, "&#{};", c as u32);
366 }
367 c if must_ncr_escape(c, enc_cap) => {
368 let _ = write!(out, "&#{};", c as u32);
369 }
370 _ => out.push(c),
371 }
372 }
373 out
374}
375
376fn escape_attr_with_map(
377 s: &str,
378 xml_11: bool,
379 enc_cap: Option<u32>,
380 cmap: &[(char, String)],
381) -> String {
382 let mut out = String::with_capacity(s.len());
383 for c in s.chars() {
384 if let Some(replacement) = cmap_lookup(c, cmap) {
385 out.push_str(replacement);
386 continue;
387 }
388 match c {
389 '<' => out.push_str("<"),
390 '>' => out.push_str(">"),
391 '&' => out.push_str("&"),
392 '"' => out.push_str("""),
393 '\n' => out.push_str(" "),
394 '\r' => out.push_str(" "),
395 '\t' => out.push_str("	"),
396 c if xml_11 && xml_11_must_escape(c) => {
397 let _ = write!(out, "&#{};", c as u32);
398 }
399 c if must_ncr_escape(c, enc_cap) => {
400 let _ = write!(out, "&#{};", c as u32);
401 }
402 _ => out.push(c),
403 }
404 }
405 out
406}
407
408#[allow(clippy::too_many_arguments)]
416fn render_attr_value(
417 element: &QName,
418 attr: &QName,
419 value: &str,
420 escape_uri: bool,
421 xml_11: bool,
422 enc_cap: Option<u32>,
423 cmap: &[(char, String)],
424) -> String {
425 if escape_uri
426 && attr.uri.is_empty()
427 && is_uri_attribute(&element.local.to_ascii_lowercase(),
428 &attr.local.to_ascii_lowercase())
429 {
430 escape_attr_with_map(&escape_html_uri(value), xml_11, enc_cap, cmap)
431 } else {
432 escape_attr_with_map(value, xml_11, enc_cap, cmap)
433 }
434}
435
436fn escape_html_uri(s: &str) -> String {
442 let mut out = String::with_capacity(s.len());
443 let mut buf = [0u8; 4];
444 for c in s.chars() {
445 if matches!(c as u32, 0x20..=0x7E) {
446 out.push(c);
447 } else {
448 for b in c.encode_utf8(&mut buf).bytes() {
449 let _ = write!(out, "%{b:02X}");
450 }
451 }
452 }
453 out
454}
455
456fn is_uri_attribute(element: &str, attr: &str) -> bool {
461 match attr {
462 "action" => element == "form",
463 "archive" => element == "object",
464 "background" => element == "body",
465 "cite" => matches!(element, "blockquote" | "del" | "ins" | "q"),
466 "classid" => element == "object",
467 "codebase" => matches!(element, "applet" | "object"),
468 "data" => element == "object",
469 "datasrc" => matches!(element,
470 "button" | "div" | "input" | "object" | "select" | "span" | "table" | "textarea"),
471 "for" => element == "script",
472 "formaction" => matches!(element, "button" | "input"),
473 "href" => matches!(element, "a" | "area" | "base" | "link"),
474 "icon" => element == "command",
475 "longdesc" => matches!(element, "frame" | "iframe" | "img"),
476 "manifest" => element == "html",
477 "name" => element == "a",
478 "poster" => element == "video",
479 "profile" => element == "head",
480 "src" => matches!(element,
481 "audio" | "embed" | "frame" | "iframe" | "img" | "input" | "script"
482 | "source" | "track" | "video"),
483 "usemap" => matches!(element, "img" | "input" | "object"),
484 "value" => element == "input",
485 _ => false,
486 }
487}
488
489fn with_content_type_meta(children: &[ResultNode], output: &OutputSpec, xhtml: bool) -> Vec<ResultNode> {
498 let charset = output.encoding.as_deref().unwrap_or("UTF-8");
499 let media = output.media_type.as_deref().unwrap_or("text/html");
500 let content = format!("{media}; charset={charset}");
501 let ns = if xhtml { "http://www.w3.org/1999/xhtml" } else { "" };
504 let mut result = children.to_vec();
505 inject_content_type(&mut result, &content, ns);
506 result
507}
508
509fn inject_content_type(nodes: &mut [ResultNode], content: &str, ns: &str) -> bool {
513 for node in nodes.iter_mut() {
514 if let ResultNode::Element { name, children, .. } = node {
515 if name.local.eq_ignore_ascii_case("head") && name.uri == ns {
516 children.retain(|c| !is_content_type_meta(c));
517 children.insert(0, content_type_meta(content, ns));
518 return true;
519 }
520 if inject_content_type(children, content, ns) {
521 return true;
522 }
523 }
524 }
525 false
526}
527
528fn is_content_type_meta(node: &ResultNode) -> bool {
532 matches!(node, ResultNode::Element { name, attributes, .. }
533 if name.local.eq_ignore_ascii_case("meta")
534 && attributes.iter().any(|(a, v)|
535 a.local.eq_ignore_ascii_case("http-equiv")
536 && v.eq_ignore_ascii_case("content-type")))
537}
538
539fn content_type_meta(content: &str, ns: &str) -> ResultNode {
540 let attr = |local: &str| QName { prefix: None, local: local.into(), uri: String::new() };
541 ResultNode::Element {
542 name: QName { prefix: None, local: "meta".into(), uri: ns.into() },
543 namespaces: Vec::new(),
544 attributes: vec![
545 (attr("http-equiv"), "Content-Type".into()),
546 (attr("content"), content.into()),
547 ],
548 children: Vec::new(),
549 schema_type: None,
550 attr_types: Vec::new(),
551 }
552}
553
554const VOID_ELEMENTS: &[&str] = &[
560 "area", "base", "br", "col", "embed", "hr", "img", "input",
561 "link", "meta", "param", "source", "track", "wbr",
562];
563
564const RAW_TEXT_ELEMENTS: &[&str] = &["script", "style"];
567
568pub fn serialize_html(
569 children: &[ResultNode],
570 output: &OutputSpec,
571 indent: bool,
572 escape_uri: bool,
573) -> String {
574 let mut out = String::new();
575 if let Some(dt_sys) = output.doctype_system.as_deref() {
576 if let Some(root) = first_element_name(children) {
577 if let Some(pubid) = output.doctype_public.as_deref() {
578 let _ = writeln!(out, r#"<!DOCTYPE {root} PUBLIC "{pubid}" "{dt_sys}">"#);
579 } else {
580 let _ = writeln!(out, r#"<!DOCTYPE {root} SYSTEM "{dt_sys}">"#);
581 }
582 }
583 } else if let Some(pubid) = output.doctype_public.as_deref() {
584 if let Some(root) = first_element_name(children) {
585 let _ = writeln!(out, r#"<!DOCTYPE {root} PUBLIC "{pubid}">"#);
586 }
587 }
588 for c in children { serialize_html_node(c, &mut out, indent, escape_uri, 0); }
589 if indent && !out.ends_with('\n') {
590 out.push('\n');
591 }
592 out
593}
594
595fn serialize_html_node(node: &ResultNode, out: &mut String, format: bool, escape_uri: bool, level: usize) {
596 match node {
597 ResultNode::Element { name, namespaces, attributes, children, .. } => {
598 let local_lc = name.local.to_lowercase();
599 let q = name.to_qname_string();
600 out.push('<');
601 out.push_str(&q);
602 for (prefix, uri) in namespaces {
603 match prefix {
604 Some(p) => { let _ = write!(out, r#" xmlns:{p}="{}""#, escape_attr(uri, false, None)); }
605 None => { let _ = write!(out, r#" xmlns="{}""#, escape_attr(uri, false, None)); }
606 }
607 }
608 for (aname, value) in attributes {
609 let _ = write!(out, r#" {}="{}""#,
610 aname.to_qname_string(),
611 render_attr_value(name, aname, value, escape_uri, false, None, &[]));
612 }
613 if name.uri.is_empty() && VOID_ELEMENTS.iter().any(|v| *v == local_lc) {
615 out.push('>');
616 return;
617 }
618 out.push('>');
619 let raw_text = name.uri.is_empty()
620 && RAW_TEXT_ELEMENTS.iter().any(|v| *v == local_lc);
621 let child_format = format
625 && !children.iter().any(|c| matches!(c, ResultNode::Text { .. }));
626 for c in children {
627 if child_format {
628 out.push('\n');
629 push_indent(out, level + 1);
630 }
631 if raw_text {
632 if let ResultNode::Text { content, .. } = c {
633 out.push_str(content);
634 continue;
635 }
636 }
637 serialize_html_node(c, out, child_format, escape_uri, level + 1);
638 }
639 if child_format {
640 out.push('\n');
641 push_indent(out, level);
642 }
643 out.push_str("</");
644 out.push_str(&q);
645 out.push('>');
646 }
647 ResultNode::Text { content, dose } => {
648 if *dose { out.push_str(content); }
649 else { out.push_str(&escape_text(content, false, None)); }
650 }
651 ResultNode::Comment(s) => {
652 let _ = write!(out, "<!--{s}-->");
653 }
654 ResultNode::ProcessingInstruction { target, data } => {
655 if data.is_empty() {
658 let _ = write!(out, "<?{target}>");
659 } else {
660 let _ = write!(out, "<?{target} {data}>");
661 }
662 }
663 ResultNode::Attribute { .. } => {}
665 }
666}
667
668pub fn serialize_text(children: &[ResultNode]) -> String {
671 let mut out = String::new();
672 for c in children { append_text(c, &mut out); }
673 out
674}
675
676fn append_text(node: &ResultNode, out: &mut String) {
677 match node {
678 ResultNode::Text { content, .. } => out.push_str(content),
679 ResultNode::Element { children, .. } => {
680 for c in children { append_text(c, out); }
681 }
682 _ => {}
684 }
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690 use crate::ast::QName;
691
692 fn elt(name: &str, children: Vec<ResultNode>) -> ResultNode {
693 ResultNode::Element {
694 name: QName { prefix: None, local: name.into(), uri: String::new() },
695 namespaces: Vec::new(),
696 attributes: Vec::new(),
697 children,
698 schema_type: None,
699 attr_types: Vec::new(),
700 }
701 }
702
703 fn text(s: &str) -> ResultNode {
704 ResultNode::Text { content: s.into(), dose: false }
705 }
706
707 fn tree_of(nodes: Vec<ResultNode>, method: Option<&str>) -> ResultTree {
708 let mut spec = OutputSpec::default();
709 spec.method = method.map(str::to_string);
710 spec.omit_xml_declaration = Some(true); ResultTree { children: nodes, output: spec, character_map: Vec::new(), secondary: Vec::new() }
712 }
713
714 #[test]
717 fn xml_empty_element_self_closes() {
718 let t = tree_of(vec![elt("br", vec![])], None);
719 assert_eq!(t.to_string().unwrap(), "<br/>");
720 }
721
722 #[test]
723 fn xml_escapes_text_specials() {
724 let t = tree_of(
725 vec![elt("p", vec![text("a < b && c > d")])],
726 None,
727 );
728 assert_eq!(t.to_string().unwrap(), "<p>a < b && c > d</p>");
729 }
730
731 #[test]
732 fn xml_escapes_attr_quote_and_specials() {
733 let t = tree_of(vec![ResultNode::Element {
734 name: QName { prefix: None, local: "a".into(), uri: String::new() },
735 namespaces: Vec::new(),
736 attributes: vec![(
737 QName { prefix: None, local: "href".into(), uri: String::new() },
738 r#"x"&y<z"#.to_string(),
739 )],
740 children: Vec::new(),
741 schema_type: None,
742 attr_types: Vec::new(),
743 }],None);
744 assert_eq!(t.to_string().unwrap(), r#"<a href="x"&y<z"/>"#);
745 }
746
747 #[test]
748 fn xml_text_dose_skips_escape() {
749 let t = tree_of(
750 vec![elt("p", vec![ResultNode::Text { content: "<raw/>".into(), dose: true }])],
751 None,
752 );
753 assert_eq!(t.to_string().unwrap(), "<p><raw/></p>");
754 }
755
756 fn tree_indented(nodes: Vec<ResultNode>) -> ResultTree {
757 let mut spec = OutputSpec::default();
758 spec.omit_xml_declaration = Some(true);
759 spec.indent = Some(true);
760 ResultTree { children: nodes, output: spec, character_map: Vec::new(), secondary: Vec::new() }
761 }
762
763 #[test]
764 fn xml_indent_yes_pretty_prints_element_only_content() {
765 let t = tree_indented(vec![elt("root", vec![
766 elt("a", vec![elt("b", vec![])]),
767 elt("c", vec![]),
768 ])]);
769 assert_eq!(
770 t.to_string().unwrap(),
771 "<root>\n <a>\n <b/>\n </a>\n <c/>\n</root>\n",
772 );
773 }
774
775 #[test]
776 fn xml_indent_yes_preserves_mixed_content() {
777 let t = tree_indented(vec![elt("root", vec![
780 elt("p", vec![text("hello "), elt("b", vec![text("world")])]),
781 ])]);
782 assert_eq!(
783 t.to_string().unwrap(),
784 "<root>\n <p>hello <b>world</b></p>\n</root>\n",
785 );
786 }
787
788 #[test]
789 fn xml_indent_off_by_default() {
790 let t = tree_of(vec![elt("root", vec![elt("a", vec![])])], None);
791 assert_eq!(t.to_string().unwrap(), "<root><a/></root>");
792 }
793
794 #[test]
797 fn html_void_elements_get_no_close_no_slash() {
798 let t = tree_of(vec![
799 elt("html", vec![
800 elt("head", vec![ elt("meta", vec![]) ]),
801 elt("body", vec![ elt("br", vec![]), elt("img", vec![]) ]),
802 ]),
803 ], Some("html"));
804 let s = t.to_string().unwrap();
805 assert!(s.contains("<meta>"), "got: {s}");
806 assert!(s.contains("<br>"), "got: {s}");
807 assert!(s.contains("<img>"), "got: {s}");
808 assert!(!s.contains("<br/>"), "got: {s}");
809 assert!(!s.contains("<meta/>"), "got: {s}");
810 }
811
812 #[test]
813 fn html_script_content_not_escaped() {
814 let t = tree_of(vec![ elt("script", vec![ text("if (a < b) alert('x');") ]) ],
815 Some("html"));
816 let s = t.to_string().unwrap();
817 assert!(s.contains("if (a < b)"), "script body should be raw: {s}");
818 }
819
820 #[test]
821 fn html_default_detected_by_root_html_element() {
822 let t = tree_of(vec![ elt("html", vec![ elt("br", vec![]) ]) ], None);
824 let s = t.to_string().unwrap();
825 assert!(s.contains("<br>"), "got: {s}");
827 assert!(!s.contains("<br/>"));
828 }
829
830 fn tree_indented_method(nodes: Vec<ResultNode>, method: &str) -> ResultTree {
831 let mut spec = OutputSpec::default();
832 spec.method = Some(method.into());
833 spec.indent = Some(true);
834 spec.include_content_type = Some(false);
837 ResultTree { children: nodes, output: spec, character_map: Vec::new(), secondary: Vec::new() }
838 }
839
840 #[test]
841 fn html_indent_yes_pretty_prints_element_only_content() {
842 let t = tree_indented_method(vec![
843 elt("html", vec![
844 elt("head", vec![elt("meta", vec![])]),
845 elt("body", vec![elt("p", vec![text("hi")])]),
846 ]),
847 ], "html");
848 assert_eq!(
849 t.to_string().unwrap(),
850 "<html>\n <head>\n <meta>\n </head>\n <body>\n <p>hi</p>\n </body>\n</html>\n",
851 );
852 }
853
854 #[test]
855 fn html_indent_yes_preserves_mixed_content_and_raw_text() {
856 let t = tree_indented_method(vec![
857 elt("body", vec![
858 elt("p", vec![text("a "), elt("b", vec![text("c")]), text(" d")]),
859 elt("script", vec![text("if (a < b) x();")]),
860 ]),
861 ], "html");
862 assert_eq!(
863 t.to_string().unwrap(),
864 "<body>\n <p>a <b>c</b> d</p>\n <script>if (a < b) x();</script>\n</body>\n",
865 );
866 }
867
868 #[test]
871 fn text_strips_markup() {
872 let t = tree_of(vec![ elt("p", vec![
873 text("Hello, "),
874 elt("b", vec![text("world")]),
875 text("!"),
876 ]) ], Some("text"));
877 assert_eq!(t.to_string().unwrap(), "Hello, world!");
878 }
879
880 #[test]
881 fn text_strips_comments_and_pis() {
882 let t = tree_of(vec![
883 ResultNode::Comment("ignored".into()),
884 elt("p", vec![text("kept")]),
885 ResultNode::ProcessingInstruction { target: "pi".into(), data: "ignored".into() },
886 ], Some("text"));
887 assert_eq!(t.to_string().unwrap(), "kept");
888 }
889
890 #[test]
893 fn write_to_io_writer() {
894 let t = tree_of(vec![elt("r", vec![text("hi")])], None);
895 let mut buf = Vec::<u8>::new();
896 t.write_to(&mut buf).unwrap();
897 assert_eq!(buf, b"<r>hi</r>");
898 }
899
900 #[test]
903 fn xml_decl_emitted_when_not_omitted() {
904 let mut spec = OutputSpec::default();
905 spec.omit_xml_declaration = Some(false);
906 spec.version = Some("1.0".into());
907 spec.encoding = Some("UTF-8".into());
908 let t = ResultTree {
909 children: vec![elt("r", vec![])],
910 output: spec,
911 character_map: Vec::new(),
912 secondary: Vec::new(),
913 };
914 let s = t.to_string().unwrap();
915 assert!(s.starts_with(r#"<?xml version="1.0" encoding="UTF-8"?>"#), "got: {s}");
916 }
917
918 #[test]
919 fn xml_decl_emits_standalone_yes() {
920 let mut spec = OutputSpec::default();
921 spec.omit_xml_declaration = Some(false);
922 spec.standalone = Some(Standalone::Yes);
923 let t = ResultTree {
924 children: vec![elt("r", vec![])],
925 output: spec,
926 character_map: Vec::new(),
927 secondary: Vec::new(),
928 };
929 let s = t.to_string().unwrap();
930 assert!(s.contains(r#"standalone="yes""#), "got: {s}");
931 }
932
933 #[test]
934 fn xml_decl_emits_standalone_no() {
935 let mut spec = OutputSpec::default();
936 spec.omit_xml_declaration = Some(false);
937 spec.standalone = Some(Standalone::No);
938 let t = ResultTree {
939 children: vec![elt("r", vec![])],
940 output: spec,
941 character_map: Vec::new(),
942 secondary: Vec::new(),
943 };
944 let s = t.to_string().unwrap();
945 assert!(s.contains(r#"standalone="no""#), "got: {s}");
946 }
947
948 #[test]
951 fn xml_doctype_system() {
952 let mut spec = OutputSpec::default();
953 spec.omit_xml_declaration = Some(true);
954 spec.doctype_system = Some("foo.dtd".into());
955 let t = ResultTree {
956 children: vec![elt("r", vec![])],
957 output: spec,
958 character_map: Vec::new(),
959 secondary: Vec::new(),
960 };
961 let s = t.to_string().unwrap();
962 assert!(s.contains(r#"<!DOCTYPE r SYSTEM "foo.dtd">"#), "got: {s}");
963 }
964
965 #[test]
966 fn xml_doctype_public() {
967 let mut spec = OutputSpec::default();
968 spec.omit_xml_declaration = Some(true);
969 spec.doctype_system = Some("foo.dtd".into());
970 spec.doctype_public = Some("-//ID//PUB".into());
971 let t = ResultTree {
972 children: vec![elt("r", vec![])],
973 output: spec,
974 character_map: Vec::new(),
975 secondary: Vec::new(),
976 };
977 let s = t.to_string().unwrap();
978 assert!(s.contains(r#"<!DOCTYPE r PUBLIC "-//ID//PUB" "foo.dtd">"#), "got: {s}");
979 }
980
981 #[test]
984 fn xml_emits_namespace_declarations() {
985 let t = tree_of(vec![ResultNode::Element {
986 name: QName { prefix: Some("xs".into()), local: "schema".into(), uri: "http://www.w3.org/2001/XMLSchema".into() },
987 namespaces: vec![
988 (Some("xs".into()), "http://www.w3.org/2001/XMLSchema".into()),
989 (None, "http://example.com/default".into()),
990 ],
991 attributes: Vec::new(),
992 children: Vec::new(),
993 schema_type: None,
994 attr_types: Vec::new(),
995 }],None);
996 let s = t.to_string().unwrap();
997 assert!(s.contains(r#"xmlns:xs="http://www.w3.org/2001/XMLSchema""#), "got: {s}");
998 assert!(s.contains(r#"xmlns="http://example.com/default""#), "got: {s}");
999 }
1000
1001 #[test]
1004 fn xml_serializes_comment() {
1005 let t = tree_of(vec![ResultNode::Comment(" hello ".into())], None);
1006 assert_eq!(t.to_string().unwrap(), "<!-- hello -->");
1007 }
1008
1009 #[test]
1010 fn xml_serializes_pi_no_data() {
1011 let t = tree_of(vec![
1012 ResultNode::ProcessingInstruction { target: "pi".into(), data: String::new() },
1013 ], None);
1014 assert_eq!(t.to_string().unwrap(), "<?pi?>");
1015 }
1016
1017 #[test]
1018 fn xml_serializes_pi_with_data() {
1019 let t = tree_of(vec![
1020 ResultNode::ProcessingInstruction {
1021 target: "xml-stylesheet".into(),
1022 data: r#"href="s.xsl""#.into(),
1023 },
1024 ], None);
1025 assert_eq!(t.to_string().unwrap(),
1026 r#"<?xml-stylesheet href="s.xsl"?>"#);
1027 }
1028
1029 #[test]
1032 fn xml_cdata_section_elements_wrap_text_children() {
1033 let mut spec = OutputSpec::default();
1034 spec.omit_xml_declaration = Some(true);
1035 spec.cdata_section_elements = vec![
1036 QName { prefix: None, local: "raw".into(), uri: String::new() },
1037 ];
1038 let t = ResultTree {
1039 children: vec![elt("raw", vec![text("a < b & c")])],
1040 output: spec,
1041 character_map: Vec::new(),
1042 secondary: Vec::new(),
1043 };
1044 let s = t.to_string().unwrap();
1045 assert!(s.contains("<![CDATA[a < b & c]]>"), "got: {s}");
1046 }
1047
1048 #[test]
1049 fn xml_cdata_section_splits_embedded_close_seq() {
1050 let mut spec = OutputSpec::default();
1052 spec.omit_xml_declaration = Some(true);
1053 spec.cdata_section_elements = vec![
1054 QName { prefix: None, local: "raw".into(), uri: String::new() },
1055 ];
1056 let t = ResultTree {
1057 children: vec![elt("raw", vec![text("end ]]> here")])],
1058 output: spec,
1059 character_map: Vec::new(),
1060 secondary: Vec::new(),
1061 };
1062 let s = t.to_string().unwrap();
1063 assert!(s.contains("]]]]><![CDATA[>"), "got: {s}");
1065 }
1066
1067 #[test]
1070 fn xml_attr_escapes_newline_tab_cr() {
1071 let t = tree_of(vec![ResultNode::Element {
1072 name: QName { prefix: None, local: "a".into(), uri: String::new() },
1073 namespaces: Vec::new(),
1074 attributes: vec![(
1075 QName { prefix: None, local: "v".into(), uri: String::new() },
1076 "x\ny\tz\rw".to_string(),
1077 )],
1078 children: Vec::new(),
1079 schema_type: None,
1080 attr_types: Vec::new(),
1081 }],None);
1082 let s = t.to_string().unwrap();
1083 assert!(s.contains(" "), "got: {s}");
1084 assert!(s.contains("	"), "got: {s}");
1085 assert!(s.contains(" "), "got: {s}");
1086 }
1087
1088 #[test]
1091 fn html_doctype_system_only() {
1092 let mut spec = OutputSpec::default();
1093 spec.method = Some("html".into());
1094 spec.doctype_system = Some("about:legacy-compat".into());
1095 let t = ResultTree {
1096 children: vec![elt("html", vec![])],
1097 output: spec,
1098 character_map: Vec::new(),
1099 secondary: Vec::new(),
1100 };
1101 let s = t.to_string().unwrap();
1102 assert!(s.contains(r#"<!DOCTYPE html SYSTEM "about:legacy-compat">"#), "got: {s}");
1103 }
1104
1105 #[test]
1106 fn html_doctype_public_only() {
1107 let mut spec = OutputSpec::default();
1109 spec.method = Some("html".into());
1110 spec.doctype_public = Some("-//W3C//DTD HTML 4.01//EN".into());
1111 let t = ResultTree {
1112 children: vec![elt("html", vec![])],
1113 output: spec,
1114 character_map: Vec::new(),
1115 secondary: Vec::new(),
1116 };
1117 let s = t.to_string().unwrap();
1118 assert!(s.contains(r#"<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN">"#),
1119 "got: {s}");
1120 }
1121
1122 #[test]
1123 fn html_doctype_public_and_system() {
1124 let mut spec = OutputSpec::default();
1125 spec.method = Some("html".into());
1126 spec.doctype_public = Some("-//W3C//DTD HTML 4.01//EN".into());
1127 spec.doctype_system = Some("http://www.w3.org/TR/html4/strict.dtd".into());
1128 let t = ResultTree {
1129 children: vec![elt("html", vec![])],
1130 output: spec,
1131 character_map: Vec::new(),
1132 secondary: Vec::new(),
1133 };
1134 let s = t.to_string().unwrap();
1135 assert!(s.contains(r#"<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">"#),
1136 "got: {s}");
1137 }
1138
1139 #[test]
1142 fn html_emits_namespace_declarations() {
1143 let t = tree_of(vec![ResultNode::Element {
1144 name: QName { prefix: None, local: "html".into(), uri: String::new() },
1145 namespaces: vec![
1146 (Some("svg".into()), "http://www.w3.org/2000/svg".into()),
1147 (None, "http://www.w3.org/1999/xhtml".into()),
1148 ],
1149 attributes: Vec::new(),
1150 children: Vec::new(),
1151 schema_type: None,
1152 attr_types: Vec::new(),
1153 }],Some("html"));
1154 let s = t.to_string().unwrap();
1155 assert!(s.contains(r#"xmlns:svg="http://www.w3.org/2000/svg""#), "got: {s}");
1156 assert!(s.contains(r#"xmlns="http://www.w3.org/1999/xhtml""#), "got: {s}");
1157 }
1158
1159 #[test]
1160 fn html_serializes_comment() {
1161 let t = tree_of(vec![
1162 elt("html", vec![ResultNode::Comment(" hi ".into())]),
1163 ], Some("html"));
1164 let s = t.to_string().unwrap();
1165 assert!(s.contains("<!-- hi -->"), "got: {s}");
1166 }
1167
1168 #[test]
1169 fn html_serializes_pi_with_and_without_data() {
1170 let t = tree_of(vec![
1171 elt("html", vec![
1172 ResultNode::ProcessingInstruction { target: "a".into(), data: String::new() },
1173 ResultNode::ProcessingInstruction { target: "b".into(), data: "x".into() },
1174 ]),
1175 ], Some("html"));
1176 let s = t.to_string().unwrap();
1177 assert!(s.contains("<?a>"), "got: {s}");
1179 assert!(s.contains("<?b x>"), "got: {s}");
1180 }
1181
1182 #[test]
1183 fn html_text_with_dose_skips_escape() {
1184 let t = tree_of(vec![
1185 elt("html", vec![
1186 ResultNode::Text { content: "<raw>".into(), dose: true },
1187 ]),
1188 ], Some("html"));
1189 let s = t.to_string().unwrap();
1190 assert!(s.contains("<raw>"), "got: {s}");
1191 }
1192
1193 #[test]
1194 fn html_default_method_when_no_root_html() {
1195 let t = tree_of(vec![elt("r", vec![])], None);
1197 let s = t.to_string().unwrap();
1198 assert_eq!(s, "<r/>");
1200 }
1201
1202 #[test]
1205 fn text_method_concatenates_all_text() {
1206 let t = tree_of(vec![
1207 elt("a", vec![
1208 text("one"),
1209 elt("b", vec![text("two")]),
1210 ResultNode::Text { content: "three".into(), dose: true }, ]),
1212 ], Some("text"));
1213 assert_eq!(t.to_string().unwrap(), "onetwothree");
1214 }
1215
1216 fn out_tree(nodes: Vec<ResultNode>, spec: OutputSpec) -> ResultTree {
1223 ResultTree { children: nodes, output: spec, character_map: Vec::new(), secondary: Vec::new() }
1224 }
1225
1226 fn elt_attrs(name: &str, attrs: &[(&str, &str)], children: Vec<ResultNode>) -> ResultNode {
1227 ResultNode::Element {
1228 name: QName { prefix: None, local: name.into(), uri: String::new() },
1229 namespaces: Vec::new(),
1230 attributes: attrs.iter().map(|(k, v)| (
1231 QName { prefix: None, local: (*k).into(), uri: String::new() },
1232 (*v).to_string(),
1233 )).collect(),
1234 children,
1235 schema_type: None,
1236 attr_types: Vec::new(),
1237 }
1238 }
1239
1240 #[test]
1243 fn standalone_omit_suppresses_pseudo_attribute() {
1244 let spec = OutputSpec { standalone: Some(Standalone::Omit), ..Default::default() };
1245 let s = out_tree(vec![elt("r", vec![])], spec).to_string().unwrap();
1246 assert!(s.starts_with(r#"<?xml version="1.0" encoding="UTF-8"?>"#), "got: {s}");
1247 assert!(!s.contains("standalone"), "omit must not emit a standalone pseudo-attr: {s}");
1248 }
1249
1250 #[test]
1251 fn standalone_absent_suppresses_pseudo_attribute() {
1252 let s = out_tree(vec![elt("r", vec![])], OutputSpec::default()).to_string().unwrap();
1253 assert!(!s.contains("standalone"), "got: {s}");
1254 }
1255
1256 #[test]
1259 fn indent_defaults_to_no_for_xml_method() {
1260 let spec = OutputSpec {
1261 method: Some("xml".into()),
1262 omit_xml_declaration: Some(true),
1263 ..Default::default()
1264 };
1265 let s = out_tree(vec![elt("root", vec![elt("a", vec![])])], spec).to_string().unwrap();
1266 assert_eq!(s, "<root><a/></root>");
1267 }
1268
1269 #[test]
1270 fn indent_defaults_to_yes_for_html_method() {
1271 let spec = OutputSpec {
1272 method: Some("html".into()),
1273 include_content_type: Some(false), ..Default::default()
1275 };
1276 let s = out_tree(vec![elt("html", vec![
1277 elt("body", vec![elt("p", vec![])]),
1278 ])], spec).to_string().unwrap();
1279 assert!(s.contains("<html>\n <body>\n <p>"), "html should indent by default: {s}");
1280 }
1281
1282 #[test]
1283 fn indent_defaults_to_yes_for_xhtml_method() {
1284 let spec = OutputSpec {
1285 method: Some("xhtml".into()),
1286 omit_xml_declaration: Some(true),
1287 include_content_type: Some(false),
1288 ..Default::default()
1289 };
1290 let s = out_tree(vec![elt("root", vec![elt("a", vec![])])], spec).to_string().unwrap();
1291 assert_eq!(s, "<root>\n <a/>\n</root>\n");
1292 }
1293
1294 #[test]
1295 fn indent_defaults_to_yes_when_html_method_auto_detected() {
1296 let spec = OutputSpec { include_content_type: Some(false), ..Default::default() };
1299 let s = out_tree(vec![elt("html", vec![elt("body", vec![elt("p", vec![])])])], spec)
1300 .to_string().unwrap();
1301 assert!(s.contains("<html>\n <body>"), "got: {s}");
1302 }
1303
1304 #[test]
1305 fn indent_no_overrides_html_default() {
1306 let spec = OutputSpec {
1307 method: Some("html".into()),
1308 indent: Some(false),
1309 include_content_type: Some(false),
1310 ..Default::default()
1311 };
1312 let s = out_tree(vec![elt("html", vec![elt("body", vec![])])], spec).to_string().unwrap();
1313 assert_eq!(s, "<html><body></body></html>");
1314 }
1315
1316 #[test]
1319 fn escape_uri_attributes_default_escapes_non_ascii_in_href() {
1320 let spec = OutputSpec {
1321 method: Some("html".into()),
1322 include_content_type: Some(false),
1323 ..Default::default()
1324 };
1325 let node = elt_attrs("a", &[("href", "/caf\u{e9}?x=1 2")], vec![]);
1328 let s = out_tree(vec![node], spec).to_string().unwrap();
1329 assert!(s.contains(r#"href="/caf%C3%A9?x=1 2""#), "got: {s}");
1330 }
1331
1332 #[test]
1333 fn escape_uri_attributes_no_disables_escaping() {
1334 let spec = OutputSpec {
1335 method: Some("html".into()),
1336 escape_uri_attributes: Some(false),
1337 include_content_type: Some(false),
1338 ..Default::default()
1339 };
1340 let node = elt_attrs("a", &[("href", "/caf\u{e9}")], vec![]);
1341 let s = out_tree(vec![node], spec).to_string().unwrap();
1342 assert!(s.contains("href=\"/caf\u{e9}\""), "got: {s}");
1343 }
1344
1345 #[test]
1346 fn escape_uri_attributes_only_applies_to_uri_valued_attributes() {
1347 let spec = OutputSpec {
1349 method: Some("html".into()),
1350 include_content_type: Some(false),
1351 ..Default::default()
1352 };
1353 let node = elt_attrs("a", &[("title", "caf\u{e9}")], vec![]);
1354 let s = out_tree(vec![node], spec).to_string().unwrap();
1355 assert!(s.contains("title=\"caf\u{e9}\""), "got: {s}");
1356 }
1357
1358 #[test]
1359 fn escape_uri_attributes_is_element_specific() {
1360 let spec = OutputSpec {
1363 method: Some("html".into()),
1364 include_content_type: Some(false),
1365 ..Default::default()
1366 };
1367 let s = out_tree(vec![elt("body", vec![
1368 elt_attrs("a", &[("href", "/\u{e9}")], vec![]),
1369 elt_attrs("span", &[("href", "/\u{e9}")], vec![]),
1370 ])], spec).to_string().unwrap();
1371 assert!(s.contains("<a href=\"/%C3%A9\">"), "a/href escaped: {s}");
1372 assert!(s.contains("<span href=\"/\u{e9}\">"), "span/href untouched: {s}");
1373 }
1374
1375 #[test]
1376 fn escape_uri_attributes_not_applied_for_xml_method() {
1377 let spec = OutputSpec {
1378 method: Some("xml".into()),
1379 omit_xml_declaration: Some(true),
1380 ..Default::default()
1381 };
1382 let node = elt_attrs("a", &[("href", "/caf\u{e9}")], vec![]);
1383 let s = out_tree(vec![node], spec).to_string().unwrap();
1384 assert!(s.contains("href=\"/caf\u{e9}\""), "got: {s}");
1385 }
1386
1387 #[test]
1390 fn include_content_type_default_inserts_meta_into_head() {
1391 let spec = OutputSpec { method: Some("html".into()), ..Default::default() };
1392 let s = out_tree(vec![elt("html", vec![
1393 elt("head", vec![elt("title", vec![text("T")])]),
1394 elt("body", vec![]),
1395 ])], spec).to_string().unwrap();
1396 assert!(
1397 s.contains(r#"<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">"#),
1398 "got: {s}");
1399 assert!(s.find("http-equiv").unwrap() < s.find("<title>").unwrap(),
1401 "meta must precede title: {s}");
1402 }
1403
1404 #[test]
1405 fn include_content_type_uses_media_type_and_encoding() {
1406 let spec = OutputSpec {
1407 method: Some("html".into()),
1408 media_type: Some("application/xhtml+xml".into()),
1409 encoding: Some("ISO-8859-1".into()),
1410 ..Default::default()
1411 };
1412 let s = out_tree(vec![elt("html", vec![elt("head", vec![])])], spec).to_string().unwrap();
1413 assert!(s.contains(r#"content="application/xhtml+xml; charset=ISO-8859-1""#), "got: {s}");
1414 }
1415
1416 #[test]
1417 fn include_content_type_replaces_existing_content_type_meta() {
1418 let spec = OutputSpec { method: Some("html".into()), ..Default::default() };
1419 let existing = elt_attrs("meta",
1420 &[("http-equiv", "Content-Type"), ("content", "text/html; charset=stale")], vec![]);
1421 let s = out_tree(vec![elt("html", vec![elt("head", vec![existing])])], spec)
1422 .to_string().unwrap();
1423 assert!(!s.contains("charset=stale"), "stale meta must be removed: {s}");
1424 assert_eq!(s.matches("http-equiv").count(), 1, "exactly one content-type meta: {s}");
1425 }
1426
1427 #[test]
1428 fn include_content_type_no_disables_meta() {
1429 let spec = OutputSpec {
1430 method: Some("html".into()),
1431 include_content_type: Some(false),
1432 ..Default::default()
1433 };
1434 let s = out_tree(vec![elt("html", vec![elt("head", vec![])])], spec).to_string().unwrap();
1435 assert!(!s.contains("http-equiv"), "got: {s}");
1436 }
1437
1438 #[test]
1439 fn include_content_type_noop_without_head() {
1440 let spec = OutputSpec { method: Some("html".into()), ..Default::default() };
1441 let s = out_tree(vec![elt("html", vec![elt("body", vec![])])], spec).to_string().unwrap();
1442 assert!(!s.contains("http-equiv"), "no head → no meta: {s}");
1443 }
1444
1445 #[test]
1446 fn include_content_type_not_applied_for_xml_method() {
1447 let spec = OutputSpec {
1448 method: Some("xml".into()),
1449 omit_xml_declaration: Some(true),
1450 ..Default::default()
1451 };
1452 let s = out_tree(vec![elt("html", vec![elt("head", vec![])])], spec).to_string().unwrap();
1453 assert!(!s.contains("http-equiv"), "xml method must not inject meta: {s}");
1454 }
1455}