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