Skip to content

Commit 5609e08

Browse files
committed
fix: Ignored selectors' priorities
Ref: #108
1 parent 46366ce commit 5609e08

File tree

7 files changed

+129
-40
lines changed

7 files changed

+129
-40
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+
### Fixed
6+
7+
- Ignored selectors specificity. [#108](https://github.com/Stranger6667/css-inline/issues/108)
8+
59
## [0.6.1] - 2020-12-07
610

711
### Fixed

bindings/python/CHANGELOG.md

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

33
## [Unreleased]
44

5+
### Fixed
6+
7+
- Ignored selectors specificity. [#108](https://github.com/Stranger6667/css-inline/issues/108)
8+
9+
### Changed
10+
511
- Upgrade `Pyo3` to `0.13`.
612

713
## [0.6.2] - 2021-01-28

bindings/python/README.rst

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,17 +67,17 @@ library has good performance characteristics. In comparison with other Python pr
6767

6868
For inlining CSS in the html document from the ``Usage`` section above we have the following breakdown in our benchmarks:
6969

70-
- ``css_inline 0.6.0`` - 23.01 us
71-
- ``premailer 3.7.0`` - 340.89 us (**x14.81**)
72-
- ``inlinestyler 0.2.4`` - 2.44 ms (**x106.35**)
73-
- ``pynliner 0.8.0`` - 2.78 ms (**x121.04**)
70+
- ``css_inline 0.7.0`` - 25.21 us
71+
- ``premailer 3.7.0`` - 340.89 us (**x13.52**)
72+
- ``inlinestyler 0.2.4`` - 2.44 ms (**x96.78**)
73+
- ``pynliner 0.8.0`` - 2.78 ms (**x110.27**)
7474

7575
And for a more realistic email:
7676

77-
- ``css_inline 0.6.0`` - 483.3 us
78-
- ``premailer 3.7.0`` - 3.38 ms (**x7**)
79-
- ``inlinestyler 0.2.4`` - 64.41 ms (**x133.28**)
80-
- ``pynliner 0.8.0`` - 93.11 ms (**x192.65**)
77+
- ``css_inline 0.6.0`` - 529.1 us
78+
- ``premailer 3.7.0`` - 3.38 ms (**x6.38**)
79+
- ``inlinestyler 0.2.4`` - 64.41 ms (**x121.73**)
80+
- ``pynliner 0.8.0`` - 93.11 ms (**x175.97**)
8181

8282
You can take a look at the benchmarks' code at ``benches/bench.py`` file.
8383
The results above were measured with stable ``rustc 1.47.0``, ``Python 3.8.6`` on i8700K, and 32GB RAM.

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+
### Fixed
6+
7+
- Ignored selectors specificity. [#108](https://github.com/Stranger6667/css-inline/issues/108)
8+
59
## [0.6.1] - 2020-12-07
610

711
### Fixed

css-inline/src/lib.rs

Lines changed: 86 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -104,18 +104,19 @@
104104
rust_2018_idioms,
105105
rust_2018_compatibility
106106
)]
107-
use kuchiki::{parse_html, traits::TendrilSink, NodeRef};
107+
use kuchiki::{parse_html, traits::TendrilSink, Node, NodeRef, Specificity};
108108

109109
pub mod error;
110110
mod parser;
111111

112-
use cssparser::CowRcStr;
113112
pub use error::InlineError;
114113
use smallvec::{smallvec, SmallVec};
115114
use std::{
116115
borrow::Cow,
116+
collections::{hash_map::Entry, HashMap},
117117
fs::File,
118118
io::{Read, Write},
119+
ops::Deref,
119120
};
120121
pub use url::{ParseError, Url};
121122

@@ -264,14 +265,27 @@ impl<'a> CSSInliner<'a> {
264265
#[inline]
265266
pub fn inline_to<W: Write>(&self, html: &str, target: &mut W) -> Result<()> {
266267
let document = parse_html().one(html);
268+
// CSS rules may overlap, and the final set of rules applied to an element depend on
269+
// selectors' specificity - selectors with higher specificity have more priority.
270+
// Inlining happens in two major steps:
271+
// 1. All available styles are mapped to respective elements together with their
272+
// selector's specificity. When two rules overlap on the same declaration, then
273+
// the one with higher specificity replaces another.
274+
// 2. Resulting styles are merged into existing "style" tags.
275+
#[allow(clippy::mutable_key_type)]
276+
// Each matched element is identified by their raw pointers - they are evaluated once
277+
// and then reused, which allows O(1) access to find them.
278+
// Internally, their raw pointers are used to implement `Eq`, which seems like the only
279+
// reasonable approach to compare them (performance-wise).
280+
let mut styles = HashMap::with_capacity(128);
267281
if self.options.inline_style_tags {
268282
for style_tag in document
269283
.select("style")
270284
.map_err(|_| error::InlineError::ParseError(Cow::from("Unknown error")))?
271285
{
272286
if let Some(first_child) = style_tag.as_node().first_child() {
273287
if let Some(css_cell) = first_child.as_text() {
274-
process_css(&document, css_cell.borrow().as_str())?;
288+
process_css(&document, css_cell.borrow().as_str(), &mut styles)?;
275289
}
276290
}
277291
if self.options.remove_style_tags {
@@ -298,12 +312,39 @@ impl<'a> CSSInliner<'a> {
298312
if !href.is_empty() {
299313
let url = self.get_full_url(href);
300314
let css = load_external(url.as_ref())?;
301-
process_css(&document, css.as_str())?;
315+
process_css(&document, css.as_str(), &mut styles)?;
302316
}
303317
}
304318
}
305319
if let Some(extra_css) = &self.options.extra_css {
306-
process_css(&document, extra_css)?;
320+
process_css(&document, extra_css, &mut styles)?;
321+
}
322+
for (node_id, styles) in styles {
323+
// SAFETY: All nodes are alive as long as `document` is in scope.
324+
// Therefore, any `document` children should be alive and it is safe to dereference
325+
// pointers to them
326+
let node = unsafe { &*node_id };
327+
// It can be borrowed if the current selector matches <link> tag, that is
328+
// already borrowed in `inline_to`. We can ignore such matches
329+
if let Ok(mut attributes) = node
330+
.as_element()
331+
.expect("Element is expected")
332+
.attributes
333+
.try_borrow_mut()
334+
{
335+
if let Some(existing_style) = attributes.get_mut("style") {
336+
*existing_style = merge_styles(existing_style, &styles)?
337+
} else {
338+
let mut final_styles = String::with_capacity(128);
339+
for (name, (_, value)) in styles {
340+
final_styles.push_str(name.as_str());
341+
final_styles.push(':');
342+
final_styles.push_str(value.as_str());
343+
final_styles.push(';');
344+
}
345+
attributes.insert("style", final_styles);
346+
};
347+
}
307348
}
308349
document.serialize(target)?;
309350
Ok(())
@@ -342,35 +383,47 @@ fn load_external(url: &str) -> Result<String> {
342383
}
343384
}
344385

345-
fn process_css(document: &NodeRef, css: &str) -> Result<()> {
386+
type NodeId = *const Node;
387+
388+
#[allow(clippy::mutable_key_type)]
389+
fn process_css(
390+
document: &NodeRef,
391+
css: &str,
392+
styles: &mut HashMap<NodeId, HashMap<String, (Specificity, String)>>,
393+
) -> Result<()> {
346394
let mut parse_input = cssparser::ParserInput::new(css);
347395
let mut parser = cssparser::Parser::new(&mut parse_input);
348396
let rule_list =
349397
cssparser::RuleListParser::new_for_stylesheet(&mut parser, parser::CSSRuleListParser);
350-
for (selector, declarations) in rule_list.flatten() {
351-
if let Ok(matching_elements) = document.select(selector) {
352-
for matching_element in matching_elements {
353-
// It can be borrowed if the current selector matches <link> tag, that is
354-
// already borrowed in `inline_to`. We can ignore such matches
355-
if let Ok(mut attributes) = matching_element.attributes.try_borrow_mut() {
356-
if let Some(existing_style) = attributes.get_mut("style") {
357-
*existing_style = merge_styles(existing_style, &declarations)?
358-
} else {
359-
let mut final_styles = String::with_capacity(64);
360-
for (name, value) in &declarations {
361-
final_styles.push_str(name);
362-
final_styles.push(':');
363-
final_styles.push_str(value);
364-
final_styles.push(';');
398+
for (selectors, declarations) in rule_list.flatten() {
399+
// Only CSS Syntax Level 3 is supported, therefore it is OK to split by `,`
400+
// With `is` or `where` selectors (Level 4) this split should be done on the parser level
401+
for selector in selectors.split(',') {
402+
if let Ok(matching_elements) = document.select(selector) {
403+
// There is always only one selector applied
404+
let specificity = matching_elements.selectors.0[0].specificity();
405+
for matching_element in matching_elements {
406+
let element_styles = styles
407+
.entry(matching_element.as_node().deref())
408+
.or_insert_with(|| HashMap::with_capacity(16));
409+
for (name, value) in &declarations {
410+
match element_styles.entry(name.to_string()) {
411+
Entry::Occupied(mut entry) => {
412+
if entry.get().0 <= specificity {
413+
entry.insert((specificity, value.to_string()));
414+
}
415+
}
416+
Entry::Vacant(entry) => {
417+
entry.insert((specificity, value.to_string()));
418+
}
365419
}
366-
attributes.insert("style", final_styles);
367-
};
420+
}
368421
}
369422
}
423+
// Skip selectors that can't be parsed
424+
// Ignore not parsable entries. E.g. there is no parser for @media queries
425+
// Which means that they will fall into this category and will be ignored
370426
}
371-
// Skip selectors that can't be parsed
372-
// Ignore not parsable entries. E.g. there is no parser for @media queries
373-
// Which means that they will fall into this category and will be ignored
374427
}
375428
Ok(())
376429
}
@@ -394,16 +447,19 @@ pub fn inline_to<W: Write>(html: &str, target: &mut W) -> Result<()> {
394447
CSSInliner::default().inline_to(html, target)
395448
}
396449

397-
fn merge_styles(existing_style: &str, new_styles: &[parser::Declaration<'_>]) -> Result<String> {
450+
fn merge_styles(
451+
existing_style: &str,
452+
new_styles: &HashMap<String, (Specificity, String)>,
453+
) -> Result<String> {
398454
// Parse existing declarations in "style" attribute
399455
let mut input = cssparser::ParserInput::new(existing_style);
400456
let mut parser = cssparser::Parser::new(&mut input);
401457
let declarations =
402458
cssparser::DeclarationListParser::new(&mut parser, parser::CSSDeclarationListParser);
403459
// New rules override old ones and we store selectors inline to check the old rules later
404-
let mut buffer: SmallVec<[&CowRcStr<'_>; 8]> = smallvec![];
460+
let mut buffer: SmallVec<[&str; 8]> = smallvec![];
405461
let mut final_styles = String::with_capacity(256);
406-
for (property, value) in new_styles {
462+
for (property, (_, value)) in new_styles {
407463
final_styles.push_str(property);
408464
final_styles.push(':');
409465
final_styles.push_str(value);
@@ -414,7 +470,7 @@ fn merge_styles(existing_style: &str, new_styles: &[parser::Declaration<'_>]) ->
414470
for declaration in declarations {
415471
let (name, value) = declaration?;
416472
// Usually this buffer is small and it is faster than checking a {Hash,BTree}Map
417-
if !buffer.contains(&&name) {
473+
if !buffer.contains(&name.as_ref()) {
418474
final_styles.push_str(&name);
419475
final_styles.push(':');
420476
final_styles.push_str(value);

css-inline/src/parser.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
pub(crate) struct CSSRuleListParser;
22
pub(crate) struct CSSDeclarationListParser;
33

4-
pub(crate) type Declaration<'i> = (cssparser::CowRcStr<'i>, &'i str);
4+
pub(crate) type Name<'i> = cssparser::CowRcStr<'i>;
5+
pub(crate) type Declaration<'i> = (Name<'i>, &'i str);
56
pub(crate) type QualifiedRule<'i> = (&'i str, Vec<Declaration<'i>>);
67

78
fn exhaust<'i>(input: &mut cssparser::Parser<'i, '_>) -> &'i str {
@@ -46,7 +47,7 @@ impl<'i> cssparser::DeclarationParser<'i> for CSSDeclarationListParser {
4647

4748
fn parse_value<'t>(
4849
&mut self,
49-
name: cssparser::CowRcStr<'i>,
50+
name: Name<'i>,
5051
input: &mut cssparser::Parser<'i, 't>,
5152
) -> Result<Self::Declaration, cssparser::ParseError<'i, Self::Error>> {
5253
Ok((name, exhaust(input)))

css-inline/tests/test_inlining.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,24 @@ p.footer { font-size: 1px}"#,
3535
)
3636
}
3737

38+
#[test]
39+
fn overlap_styles() {
40+
// When two selectors match the same element
41+
assert_inlined!(
42+
style = r#"
43+
.test-class {
44+
color: #ffffff;
45+
}
46+
a {
47+
color: #17bebb;
48+
}"#,
49+
body = r#"<a class="test-class" href="https://example.com">Test</a>"#,
50+
// Then the final style should come from the more specific selector
51+
expected =
52+
r#"<a class="test-class" href="https://example.com" style="color: #ffffff;">Test</a>"#
53+
)
54+
}
55+
3856
#[test]
3957
fn simple_merge() {
4058
// When "style" attributes exist and collides with values defined in "style" tag

0 commit comments

Comments
 (0)