Skip to main content

apace_dom/node/
serialize.rs

1//! Serialization of nodes to HTML, and of inline `<svg>` subtrees to SVG source for usvg.
2
3use html_escape::{encode_quoted_attribute_to_string, encode_text_to_string};
4use markup5ever::{QualName, ns};
5use style_traits::values::ToCss;
6
7use super::{Node, NodeData};
8
9#[derive(Clone, Copy)]
10enum OutputStyle {
11    Normal,
12    Pretty,
13}
14
15#[derive(Clone, Copy)]
16struct SerializeOpts {
17    style: OutputStyle,
18    /// Replace `currentColor` in attribute values with the element's computed `color`
19    resolve_current_color: bool,
20    /// Declare the `xlink` prefix on the root element so that the output is well-formed XML
21    declare_xlink_ns: bool,
22}
23
24impl SerializeOpts {
25    const fn html(style: OutputStyle) -> Self {
26        Self {
27            style,
28            resolve_current_color: false,
29            declare_xlink_ns: false,
30        }
31    }
32}
33
34const XLINK_NS: &str = "http://www.w3.org/1999/xlink";
35
36/// Writes an attribute's serialized name, per the HTML fragment serialization algorithm
37fn write_attr_name(name: &QualName, writer: &mut String) {
38    match name.ns {
39        ns!() | ns!(html) => {}
40        ns!(xml) => writer.push_str("xml:"),
41        ns!(xmlns) if name.local.as_str() == "xmlns" => {}
42        ns!(xmlns) => writer.push_str("xmlns:"),
43        ns!(xlink) => writer.push_str("xlink:"),
44        _ => {
45            if let Some(prefix) = &name.prefix {
46                writer.push_str(prefix);
47                writer.push(':');
48            }
49        }
50    }
51    writer.push_str(&name.local);
52}
53
54fn is_xlink_ns_decl(name: &QualName) -> bool {
55    (name.ns == ns!(xmlns) && name.local.as_str() == "xlink")
56        || name.local.as_str() == "xmlns:xlink"
57}
58
59impl Node {
60    /// Renders the HTML of this node and all its children as a `String` without extra whitespace.
61    ///
62    /// Example output:
63    ///
64    /// ```text
65    /// <html><head /><body><main id="main"><div class="arbitrary-class" /></main></body></html>
66    /// ```
67    pub fn outer_html(&self) -> String {
68        let mut output = String::new();
69        self.write_outer_html(&mut output);
70        output
71    }
72
73    /// Renders the HTML of this node and all its children as a `String` with whitespace for human
74    /// readability.
75    ///
76    /// Example output:
77    ///
78    /// ```text
79    /// <html>
80    ///   <head />
81    ///   <body>
82    ///     <main id="main">
83    ///       <div class="arbitrary-class" />
84    ///     </main>
85    ///   </body>
86    /// </html>
87    /// ```
88    pub fn outer_html_pretty(&self) -> String {
89        let mut output = String::new();
90        self.write_outer_html_pretty(&mut output);
91        output
92    }
93
94    pub fn write_outer_html(&self, writer: &mut String) {
95        self.write_outer_html_in_style(writer, SerializeOpts::html(OutputStyle::Normal), 0);
96    }
97
98    pub fn write_outer_html_pretty(&self, writer: &mut String) {
99        self.write_outer_html_in_style(writer, SerializeOpts::html(OutputStyle::Pretty), 0);
100    }
101
102    /// Serializes this `<svg>` subtree as a standalone SVG document for usvg, with
103    /// `currentColor` resolved against each element's computed `color`.
104    #[cfg(feature = "svg")]
105    pub(crate) fn svg_source(&self) -> String {
106        let mut output = String::new();
107        let opts = SerializeOpts {
108            style: OutputStyle::Normal,
109            resolve_current_color: true,
110            declare_xlink_ns: true,
111        };
112        self.write_outer_html_in_style(&mut output, opts, 0);
113        output
114    }
115
116    fn write_outer_html_in_style(&self, writer: &mut String, opts: SerializeOpts, nesting: usize) {
117        const INDENT: &str = "  ";
118        let style = opts.style;
119        let has_children = !self.children.is_empty();
120        let current_color = opts
121            .resolve_current_color
122            .then(|| self.primary_styles())
123            .flatten()
124            .map(|style| style.clone_color())
125            .map(|color| color.to_css_string());
126
127        match &self.data {
128            NodeData::Document(_) => {}
129            NodeData::Comment { .. } => {}
130            NodeData::AnonymousBlock(_) => {}
131            // NodeData::Doctype { name, .. } => write!(s, "DOCTYPE {name}"),
132            NodeData::Text(data) => {
133                if matches!(style, OutputStyle::Pretty) {
134                    for _ in 0..nesting {
135                        writer.push_str(INDENT);
136                    }
137                }
138                let in_raw_text_element = self
139                    .parent
140                    .and_then(|id| self.tree()[id].data.downcast_element())
141                    .is_some_and(|el| {
142                        // Documents are parsed with scripting disabled, so `<noscript>` is not raw text
143                        el.name.ns == ns!(html)
144                            && matches!(
145                                el.name.local.as_ref(),
146                                "style"
147                                    | "script"
148                                    | "xmp"
149                                    | "iframe"
150                                    | "noembed"
151                                    | "noframes"
152                                    | "plaintext"
153                            )
154                    });
155                if in_raw_text_element {
156                    writer.push_str(data.content.as_str());
157                } else {
158                    encode_text_to_string(data.content.as_str(), writer);
159                }
160                if matches!(style, OutputStyle::Pretty) {
161                    writer.push('\n');
162                }
163            }
164            NodeData::Element(data) => {
165                if matches!(style, OutputStyle::Pretty) {
166                    for _ in 0..nesting {
167                        writer.push_str(INDENT);
168                    }
169                }
170                writer.push('<');
171                writer.push_str(&data.name.local);
172
173                if opts.declare_xlink_ns
174                    && nesting == 0
175                    && !data.attrs().iter().any(|attr| is_xlink_ns_decl(&attr.name))
176                {
177                    writer.push_str(" xmlns:xlink=\"");
178                    writer.push_str(XLINK_NS);
179                    writer.push('"');
180                }
181
182                for attr in data.attrs() {
183                    writer.push(' ');
184                    write_attr_name(&attr.name, writer);
185                    writer.push_str("=\"");
186                    #[allow(clippy::unnecessary_unwrap)] // Convert to if-let chain once stabilised
187                    if current_color.is_some() && attr.value.contains("currentColor") {
188                        let value = attr
189                            .value
190                            .replace("currentColor", current_color.as_ref().unwrap());
191                        encode_quoted_attribute_to_string(&value, writer);
192                    } else {
193                        encode_quoted_attribute_to_string(&attr.value, writer);
194                    }
195                    writer.push('"');
196                }
197                if !has_children {
198                    writer.push_str(" /");
199                }
200                writer.push('>');
201                if matches!(style, OutputStyle::Pretty) {
202                    writer.push('\n');
203                }
204
205                if has_children {
206                    for &child_id in &self.children {
207                        self.tree()[child_id].write_outer_html_in_style(writer, opts, nesting + 1);
208                    }
209
210                    if matches!(style, OutputStyle::Pretty) {
211                        for _ in 0..nesting {
212                            writer.push_str(INDENT);
213                        }
214                    }
215                    writer.push_str("</");
216                    writer.push_str(&data.name.local);
217                    writer.push('>');
218                    if matches!(style, OutputStyle::Pretty) {
219                        writer.push('\n');
220                    }
221                }
222            }
223        }
224    }
225}