Skip to content

Commit 8717672

Browse files
committed
perf: Rewrite serializer
1 parent 1a99435 commit 8717672

File tree

7 files changed

+227
-33
lines changed

7 files changed

+227
-33
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased]
44

5+
### Performance
6+
7+
- Optimized HTML serialization for a performance boost of up to 25%.
8+
59
## [0.10.2] - 2023-06-25
610

711
### Changed

bindings/python/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased]
44

5+
### Performance
6+
7+
- Optimized HTML serialization for a performance boost of up to 25%.
8+
59
## [0.10.2] - 2023-06-25
610

711
### Changed

bindings/python/README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ into:
3636
```
3737

3838
- Uses reliable components from Mozilla's Servo project
39-
- 10-300x faster than alternatives
39+
- 10-400x faster than alternatives
4040
- Inlines CSS from `style` and `link` tags
4141
- Removes `style` and `link` tags
4242
- Resolves external stylesheets (including local files)
@@ -169,11 +169,11 @@ It achieves over a **10x** speed advantage compared to the next fastest alternat
169169

170170
Here is the performance comparison:
171171

172-
| | `css_inline 0.10.2` | `premailer 3.10.0` | `toronado 0.1.0` | `inlinestyler 0.2.5` | `pynliner 0.8.0` |
172+
| | `css_inline 0.10.3` | `premailer 3.10.0` | `toronado 0.1.0` | `inlinestyler 0.2.5` | `pynliner 0.8.0` |
173173
|-------------|---------------------|-------------------------|--------------------------|-------------------------|-------------------------|
174-
| Basic | 7.95 µs | 197.08 µs (**24.77x**) | 960.04 µs (**120.64x**) | 1.52 ms (**192.13x**) | 1.79 ms (**225.64x**) |
175-
| Realistic-1 | 216.00 µs | 2.09 ms (**9.72x**) | 25.15 ms (**116.47x**) | 42.75 ms (**197.96x**) | 71.83 ms (**332.59x**) |
176-
| Realistic-2 | 137.20 µs | 3.98 ms (**29.03x**) | ERROR | 26.63 ms (**194.15x**) | ERROR |
174+
| Basic | 7.58 µs | 192.50 µs (**25.39x**) | 951.66 µs (**125.50x**) | 1.52 ms (**201.12x**) | 1.78 ms (**235.59x**) |
175+
| Realistic-1 | 172.58 µs | 2.08 ms (**12.09x**) | 25.01 ms (**144.92x**) | 42.50 ms (**246.31x**) | 71.75 ms (**415.76x**) |
176+
| Realistic-2 | 119.16 µs | 3.93 ms (**33.00x**) | ERROR | 26.49 ms (**222.31x**) | ERROR |
177177

178178
The above data was obtained from benchmarking the inlining of CSS in HTML, as described in the Usage section.
179179
Note that the `toronado` and `pynliner` libraries both encountered errors when used to inline CSS in the last scenario.

bindings/ruby/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased]
44

5+
### Performance
6+
7+
- Optimized HTML serialization for a performance boost of up to 25%.
8+
59
## [0.10.2] - 2023-06-25
610

711
### Changed

bindings/ruby/README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -132,15 +132,15 @@ inliner.inline("...")
132132
## Performance
133133

134134
Leveraging efficient tools from Mozilla's Servo project, this library delivers superior performance.
135-
It consistently outperforms `premailer`, offering speed increases often exceeding **30 times**.
135+
It consistently outperforms `premailer`, offering speed increases often exceeding **40 times**.
136136

137137
The table below provides a detailed comparison between `css_inline` and `premailer` when inlining CSS into an HTML document (like in the Usage section above):
138138

139-
| | `css_inline 0.10.0` | `premailer 1.21.0 with Nokogiri 1.15.2` | Difference |
139+
| | `css_inline 0.10.3` | `premailer 1.21.0 with Nokogiri 1.15.2` | Difference |
140140
|-------------------|---------------------|------------------------------------------------|------------|
141-
| Basic usage | 9.77 µs | 414.42 µs | **42.42x** |
142-
| Realistic email 1 | 249.62 µs | 10.75 ms | **42.97x** |
143-
| Realistic email 2 | 162.33 µs | Error: Cannot parse 0 calc((100% - 500px) / 2) | - |
141+
| Basic usage | 9.16 µs | 406.53 µs | **44.32x** |
142+
| Realistic email 1 | 190.90 µs | 10.10 ms | **52.72x** |
143+
| Realistic email 2 | 135.86 µs | Error: Cannot parse 0 calc((100% - 500px) / 2) | - |
144144

145145
Please refer to the `test/bench.rb` file to review the benchmark code.
146146
The results displayed above were measured using stable `rustc 1.70` on Ruby `3.2.2`.

bindings/wasm/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased]
44

5+
### Performance
6+
7+
- Optimized HTML serialization for a performance boost of up to 25%.
8+
59
## [0.10.2] - 2023-06-25
610

711
### Changed

css-inline/src/html/serializer.rs

Lines changed: 201 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
use super::{
2+
attributes::Attributes,
23
document::Document,
34
node::{ElementData, NodeData, NodeId},
45
};
5-
use html5ever::{
6-
local_name,
7-
serialize::{serialize, Serialize, SerializeOpts, Serializer, TraversalScope},
8-
};
6+
use html5ever::{local_name, namespace_url, ns, LocalName, QualName};
97
use std::{io, io::Write};
108

119
pub(crate) fn serialize_to<W: Write>(
@@ -20,7 +18,8 @@ pub(crate) fn serialize_to<W: Write>(
2018
keep_style_tags,
2119
keep_link_tags,
2220
);
23-
serialize(writer, &sink, SerializeOpts::default())
21+
let mut ser = HtmlSerializer::new(writer);
22+
sink.serialize(&mut ser)
2423
}
2524

2625
/// Intermediary structure for serializing an HTML document.
@@ -70,37 +69,25 @@ impl<'a> Sink<'a> {
7069
false
7170
}
7271
}
73-
fn serialize_children<S: Serializer>(&self, serializer: &mut S) -> io::Result<()> {
72+
73+
fn serialize_children<W: Write>(&self, serializer: &mut HtmlSerializer<W>) -> io::Result<()> {
7474
for child in self.document.children(self.node) {
75-
self.for_node(child)
76-
.serialize(serializer, TraversalScope::IncludeNode)?;
75+
self.for_node(child).serialize(serializer)?;
7776
}
7877
Ok(())
7978
}
80-
}
8179

82-
impl<'a> Serialize for Sink<'a> {
83-
fn serialize<S>(&self, serializer: &mut S, _: TraversalScope) -> io::Result<()>
84-
where
85-
S: Serializer,
86-
{
80+
fn serialize<W: Write>(&self, serializer: &mut HtmlSerializer<W>) -> io::Result<()> {
8781
match self.data() {
8882
NodeData::Element { element, .. } => {
8983
if self.should_skip_element(element) {
9084
return Ok(());
9185
}
92-
serializer.start_elem(
93-
element.name.clone(),
94-
element
95-
.attributes
96-
.map
97-
.iter()
98-
.map(|(key, value)| (key, &**value)),
99-
)?;
86+
serializer.start_elem(&element.name, &element.attributes)?;
10087

10188
self.serialize_children(serializer)?;
10289

103-
serializer.end_elem(element.name.clone())?;
90+
serializer.end_elem(&element.name)?;
10491
Ok(())
10592
}
10693
NodeData::Document => self.serialize_children(serializer),
@@ -114,6 +101,197 @@ impl<'a> Serialize for Sink<'a> {
114101
}
115102
}
116103

104+
#[derive(Default)]
105+
struct ElemInfo {
106+
html_name: Option<LocalName>,
107+
ignore_children: bool,
108+
}
109+
110+
/// Inspired by HTML serializer from `html5ever`
111+
/// Source: <https://github.com/servo/html5ever/blob/98d3c0cd01471af997cd60849a38da45a9414dfd/html5ever/src/serialize/mod.rs#L77>
112+
struct HtmlSerializer<Wr: Write> {
113+
writer: Wr,
114+
stack: Vec<ElemInfo>,
115+
}
116+
117+
impl<W: Write> HtmlSerializer<W> {
118+
fn new(writer: W) -> Self {
119+
HtmlSerializer {
120+
writer,
121+
stack: vec![ElemInfo {
122+
html_name: None,
123+
ignore_children: false,
124+
}],
125+
}
126+
}
127+
128+
fn parent(&mut self) -> &mut ElemInfo {
129+
self.stack.last_mut().expect("no parent ElemInfo")
130+
}
131+
132+
fn write_escaped(&mut self, text: &str) -> io::Result<()> {
133+
// UTF-8 characters are maximum 4 bytes wide.
134+
let mut buffer = [0u8; 4];
135+
for c in text.chars() {
136+
match c {
137+
'&' => self.writer.write_all(b"&amp;"),
138+
'\u{00A0}' => self.writer.write_all(b"&nbsp;"),
139+
'<' => self.writer.write_all(b"&lt;"),
140+
'>' => self.writer.write_all(b"&gt;"),
141+
c => {
142+
let slice = c.encode_utf8(&mut buffer);
143+
self.writer.write_all(slice.as_bytes())
144+
}
145+
}?;
146+
}
147+
Ok(())
148+
}
149+
150+
fn write_attributes(&mut self, text: &str) -> io::Result<()> {
151+
// UTF-8 characters are maximum 4 bytes wide.
152+
let mut buffer = [0u8; 4];
153+
for c in text.chars() {
154+
match c {
155+
'&' => self.writer.write_all(b"&amp;"),
156+
'\u{00A0}' => self.writer.write_all(b"&nbsp;"),
157+
'"' => self.writer.write_all(b"&quot;"),
158+
c => {
159+
let slice = c.encode_utf8(&mut buffer);
160+
self.writer.write_all(slice.as_bytes())
161+
}
162+
}?;
163+
}
164+
Ok(())
165+
}
166+
167+
fn start_elem(&mut self, name: &QualName, attrs: &Attributes) -> io::Result<()> {
168+
let html_name = match name.ns {
169+
ns!(html) => Some(name.local.clone()),
170+
_ => None,
171+
};
172+
173+
if self.parent().ignore_children {
174+
self.stack.push(ElemInfo {
175+
html_name,
176+
ignore_children: true,
177+
});
178+
return Ok(());
179+
}
180+
181+
self.writer.write_all(b"<")?;
182+
self.writer.write_all(name.local.as_bytes())?;
183+
for (name, value) in &attrs.map {
184+
self.writer.write_all(b" ")?;
185+
186+
match name.ns {
187+
ns!() => (),
188+
ns!(xml) => self.writer.write_all(b"xml:")?,
189+
ns!(xmlns) => {
190+
if name.local != local_name!("xmlns") {
191+
self.writer.write_all(b"xmlns:")?;
192+
}
193+
}
194+
ns!(xlink) => self.writer.write_all(b"xlink:")?,
195+
_ => {
196+
self.writer.write_all(b"unknown_namespace:")?;
197+
}
198+
}
199+
200+
self.writer.write_all(name.local.as_bytes())?;
201+
self.writer.write_all(b"=\"")?;
202+
self.write_attributes(value)?;
203+
self.writer.write_all(b"\"")?;
204+
}
205+
self.writer.write_all(b">")?;
206+
207+
let ignore_children = name.ns == ns!(html)
208+
&& matches!(
209+
name.local,
210+
local_name!("area")
211+
| local_name!("base")
212+
| local_name!("basefont")
213+
| local_name!("bgsound")
214+
| local_name!("br")
215+
| local_name!("col")
216+
| local_name!("embed")
217+
| local_name!("frame")
218+
| local_name!("hr")
219+
| local_name!("img")
220+
| local_name!("input")
221+
| local_name!("keygen")
222+
| local_name!("link")
223+
| local_name!("meta")
224+
| local_name!("param")
225+
| local_name!("source")
226+
| local_name!("track")
227+
| local_name!("wbr")
228+
);
229+
230+
self.stack.push(ElemInfo {
231+
html_name,
232+
ignore_children,
233+
});
234+
235+
Ok(())
236+
}
237+
238+
fn end_elem(&mut self, name: &QualName) -> io::Result<()> {
239+
let info = match self.stack.pop() {
240+
Some(info) => info,
241+
_ => panic!("no ElemInfo"),
242+
};
243+
if info.ignore_children {
244+
return Ok(());
245+
}
246+
247+
self.writer.write_all(b"</")?;
248+
self.writer.write_all(name.local.as_bytes())?;
249+
self.writer.write_all(b">")
250+
}
251+
252+
fn write_text(&mut self, text: &str) -> io::Result<()> {
253+
let escape = !matches!(
254+
self.parent().html_name,
255+
Some(
256+
local_name!("style")
257+
| local_name!("script")
258+
| local_name!("xmp")
259+
| local_name!("iframe")
260+
| local_name!("noembed")
261+
| local_name!("noframes")
262+
| local_name!("plaintext")
263+
| local_name!("noscript")
264+
),
265+
);
266+
267+
if escape {
268+
self.write_escaped(text)
269+
} else {
270+
self.writer.write_all(text.as_bytes())
271+
}
272+
}
273+
274+
fn write_comment(&mut self, text: &str) -> io::Result<()> {
275+
self.writer.write_all(b"<!--")?;
276+
self.writer.write_all(text.as_bytes())?;
277+
self.writer.write_all(b"-->")
278+
}
279+
280+
fn write_doctype(&mut self, name: &str) -> io::Result<()> {
281+
self.writer.write_all(b"<!DOCTYPE ")?;
282+
self.writer.write_all(name.as_bytes())?;
283+
self.writer.write_all(b">")
284+
}
285+
286+
fn write_processing_instruction(&mut self, target: &str, data: &str) -> io::Result<()> {
287+
self.writer.write_all(b"<?")?;
288+
self.writer.write_all(target.as_bytes())?;
289+
self.writer.write_all(b" ")?;
290+
self.writer.write_all(data.as_bytes())?;
291+
self.writer.write_all(b">")
292+
}
293+
}
294+
117295
#[cfg(test)]
118296
mod tests {
119297
use super::Document;

0 commit comments

Comments
 (0)