css_inline/
lib.rs

1#![doc = include_str!("../README.md")]
2#![warn(
3    clippy::pedantic,
4    clippy::doc_markdown,
5    clippy::redundant_closure,
6    clippy::explicit_iter_loop,
7    clippy::match_same_arms,
8    clippy::needless_borrow,
9    clippy::print_stdout,
10    clippy::arithmetic_side_effects,
11    clippy::cast_possible_truncation,
12    clippy::unwrap_used,
13    clippy::map_unwrap_or,
14    clippy::trivially_copy_pass_by_ref,
15    clippy::needless_pass_by_value,
16    missing_docs,
17    missing_debug_implementations,
18    trivial_casts,
19    trivial_numeric_casts,
20    unreachable_pub,
21    unused_extern_crates,
22    unused_import_braces,
23    unused_qualifications,
24    variant_size_differences,
25    rust_2018_idioms,
26    rust_2018_compatibility,
27    rust_2021_compatibility
28)]
29#![allow(clippy::module_name_repetitions)]
30pub mod error;
31mod html;
32mod parser;
33mod resolver;
34
35pub use error::InlineError;
36#[cfg(feature = "stylesheet-cache")]
37use lru::{DefaultHasher, LruCache};
38use selectors::context::SelectorCaches;
39use smallvec::SmallVec;
40use std::{borrow::Cow, fmt::Formatter, io::Write, ops::Range, sync::Arc};
41
42use html::{Document, InliningMode, NodeData, NodeId, Specificity};
43pub use resolver::{DefaultStylesheetResolver, StylesheetResolver};
44use rustc_hash::FxHashMap;
45pub use url::{ParseError, Url};
46
47/// An LRU Cache for external stylesheets.
48#[cfg(feature = "stylesheet-cache")]
49pub type StylesheetCache<S = DefaultHasher> = LruCache<String, String, S>;
50
51/// Configuration options for CSS inlining process.
52#[allow(clippy::struct_excessive_bools)]
53pub struct InlineOptions<'a> {
54    /// Whether to inline CSS from "style" tags.
55    ///
56    /// Sometimes HTML may include a lot of boilerplate styles, that are not applicable in every
57    /// scenario and it is useful to ignore them and use `extra_css` instead.
58    pub inline_style_tags: bool,
59    /// Keep "style" tags after inlining.
60    pub keep_style_tags: bool,
61    /// Keep "link" tags after inlining.
62    pub keep_link_tags: bool,
63    /// Keep "at-rules" after inlining.
64    pub keep_at_rules: bool,
65    /// Remove trailing semicolons and spaces between properties and values.
66    pub minify_css: bool,
67    /// Used for loading external stylesheets via relative URLs.
68    pub base_url: Option<Url>,
69    /// Whether remote stylesheets should be loaded or not.
70    pub load_remote_stylesheets: bool,
71    /// External stylesheet cache.
72    #[cfg(feature = "stylesheet-cache")]
73    pub cache: Option<std::sync::Mutex<StylesheetCache>>,
74    // The point of using `Cow` here is Python bindings, where it is problematic to pass a reference
75    // without dealing with memory leaks & unsafe. With `Cow` we can use moved values as `String` in
76    // Python wrapper for `CSSInliner` and `&str` in Rust & simple functions on the Python side
77    /// Additional CSS to inline.
78    pub extra_css: Option<Cow<'a, str>>,
79    /// Pre-allocate capacity for HTML nodes during parsing.
80    /// It can improve performance when you have an estimate of the number of nodes in your HTML document.
81    pub preallocate_node_capacity: usize,
82    /// A way to resolve stylesheets from various sources.
83    pub resolver: Arc<dyn StylesheetResolver>,
84    /// Remove selectors that were successfully inlined from inline `<style>` blocks.
85    pub remove_inlined_selectors: bool,
86}
87
88impl std::fmt::Debug for InlineOptions<'_> {
89    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
90        let mut debug = f.debug_struct("InlineOptions");
91        debug
92            .field("inline_style_tags", &self.inline_style_tags)
93            .field("keep_style_tags", &self.keep_style_tags)
94            .field("keep_link_tags", &self.keep_link_tags)
95            .field("base_url", &self.base_url)
96            .field("load_remote_stylesheets", &self.load_remote_stylesheets);
97        #[cfg(feature = "stylesheet-cache")]
98        {
99            debug.field("cache", &self.cache);
100        }
101        debug
102            .field("extra_css", &self.extra_css)
103            .field("preallocate_node_capacity", &self.preallocate_node_capacity)
104            .field("remove_inlined_selectors", &self.remove_inlined_selectors)
105            .finish_non_exhaustive()
106    }
107}
108
109#[derive(Debug)]
110struct CssChunk {
111    range: Range<usize>,
112    /// The style node this chunk came from, if any.
113    /// `None` for linked stylesheets, extra CSS, or fragment CSS.
114    style_node: Option<NodeId>,
115}
116
117type SelectorList<'i> = SmallVec<[&'i str; 2]>;
118
119#[derive(Debug)]
120struct SelectorUsage<'i> {
121    selector: &'i str,
122    declarations: (usize, usize),
123    rule_id: usize,
124    chunk_index: usize,
125    matched: bool,
126}
127
128#[derive(Debug, Default)]
129struct RuleRemainder<'i> {
130    selectors: SelectorList<'i>,
131    declarations: (usize, usize),
132}
133
134#[derive(Debug, Default)]
135struct SelectorCleanupState<'i> {
136    chunks: Vec<CssChunk>,
137    usages: Vec<SelectorUsage<'i>>,
138}
139
140impl<'i> SelectorCleanupState<'i> {
141    fn record_usage(&mut self, usage: SelectorUsage<'i>) {
142        self.usages.push(usage);
143    }
144
145    fn has_unmatched(&self) -> bool {
146        self.usages.iter().any(|usage| !usage.matched)
147    }
148}
149
150/// Find which chunk contains the given byte offset.
151fn find_chunk_index(chunks: &[CssChunk], offset: usize) -> Option<usize> {
152    chunks
153        .iter()
154        .position(|chunk| chunk.range.contains(&offset))
155}
156
157/// Compute chunk indices for all rules based on where their selectors point in the source.
158#[allow(clippy::arithmetic_side_effects)]
159fn compute_rule_chunk_indices(
160    rules: &[(&str, (usize, usize))],
161    source: &str,
162    chunks: &[CssChunk],
163) -> Vec<Option<usize>> {
164    let source_start = source.as_ptr() as usize;
165    let source_end = source_start.saturating_add(source.len());
166
167    rules
168        .iter()
169        .map(|(selectors, _)| {
170            let sel_start = selectors.as_ptr() as usize;
171            // Check if selectors slice is within source bounds
172            if sel_start >= source_start && sel_start < source_end {
173                let offset = sel_start.wrapping_sub(source_start);
174                find_chunk_index(chunks, offset)
175            } else {
176                None
177            }
178        })
179        .collect()
180}
181
182struct CssBuffer {
183    raw: String,
184    chunks: Option<Vec<CssChunk>>,
185}
186
187impl CssBuffer {
188    fn new(track_chunks: bool) -> Self {
189        CssBuffer {
190            raw: String::new(),
191            chunks: track_chunks.then(Vec::new),
192        }
193    }
194
195    fn push(&mut self, style_node: Option<NodeId>, content: &str, append_newline: bool) {
196        if content.is_empty() {
197            return;
198        }
199        let start = self.raw.len();
200        self.raw.push_str(content);
201        if append_newline {
202            self.raw.push('\n');
203        }
204        if let Some(chunks) = &mut self.chunks {
205            let end = self.raw.len();
206            chunks.push(CssChunk {
207                range: start..end,
208                style_node,
209            });
210        }
211    }
212
213    fn into_parts(self) -> (String, Option<Vec<CssChunk>>) {
214        (self.raw, self.chunks)
215    }
216}
217
218fn apply_selector_cleanup<'i>(
219    state: &SelectorCleanupState<'i>,
220    document: &mut Document,
221    requested_keep_style_tags: bool,
222    declarations: &[parser::Declaration<'i>],
223) {
224    if state.usages.is_empty() || state.chunks.is_empty() {
225        return;
226    }
227    rewrite_style_blocks(state, document, requested_keep_style_tags, declarations);
228}
229
230fn rewrite_style_blocks<'i>(
231    state: &SelectorCleanupState<'i>,
232    document: &mut Document,
233    requested_keep_style_tags: bool,
234    declarations: &[parser::Declaration<'i>],
235) {
236    let mut chunk_remainders: Vec<Vec<RuleRemainder<'i>>> =
237        (0..state.chunks.len()).map(|_| Vec::new()).collect();
238    let mut remainder_lookup: FxHashMap<(usize, usize), usize> = FxHashMap::default();
239
240    for usage in &state.usages {
241        if usage.matched {
242            continue;
243        }
244        let trimmed = usage.selector.trim();
245        if trimmed.is_empty() {
246            continue;
247        }
248        let key = (usage.chunk_index, usage.rule_id);
249        let entry_index = remainder_lookup.entry(key).or_insert_with(|| {
250            let idx = chunk_remainders[usage.chunk_index].len();
251            chunk_remainders[usage.chunk_index].push(RuleRemainder {
252                selectors: SelectorList::new(),
253                declarations: usage.declarations,
254            });
255            idx
256        });
257        chunk_remainders[usage.chunk_index][*entry_index]
258            .selectors
259            .push(trimmed);
260    }
261
262    for (idx, chunk) in state.chunks.iter().enumerate() {
263        let rules = &chunk_remainders[idx];
264        if rules.is_empty() {
265            handle_empty_remainder(document, chunk, requested_keep_style_tags);
266            continue;
267        }
268        let mut buffer = String::new();
269        for remainder in rules {
270            append_rule(&mut buffer, remainder, declarations);
271        }
272        if buffer.trim().is_empty() {
273            handle_empty_remainder(document, chunk, requested_keep_style_tags);
274            continue;
275        }
276        if let Some(node_id) = chunk.style_node {
277            overwrite_style_node(document, node_id, buffer.trim_end());
278        }
279    }
280}
281
282fn handle_empty_remainder(
283    document: &mut Document,
284    chunk: &CssChunk,
285    requested_keep_style_tags: bool,
286) {
287    if let Some(node_id) = chunk.style_node {
288        if requested_keep_style_tags {
289            overwrite_style_node(document, node_id, "");
290        } else {
291            document.detach_node(node_id);
292        }
293    }
294}
295
296fn append_rule<'i>(
297    buffer: &mut String,
298    remainder: &RuleRemainder<'i>,
299    declarations: &[parser::Declaration<'i>],
300) {
301    let (start, end) = remainder.declarations;
302    if start >= end || end > declarations.len() {
303        return;
304    }
305    let mut selectors_iter = remainder.selectors.iter().peekable();
306    while let Some(selector) = selectors_iter.next() {
307        buffer.push_str(selector);
308        if selectors_iter.peek().is_some() {
309            buffer.push_str(", ");
310        }
311    }
312    buffer.push_str(" {");
313    for (name, value) in &declarations[start..end] {
314        buffer.push(' ');
315        buffer.push_str(name);
316        buffer.push(':');
317        buffer.push(' ');
318        let value_trimmed = value.trim();
319        buffer.push_str(value_trimmed);
320        if !value_trimmed.ends_with(';') {
321            buffer.push(';');
322        }
323    }
324    buffer.push_str(" }\n");
325}
326
327fn overwrite_style_node(document: &mut Document, node_id: NodeId, new_css: &str) {
328    let new_css = new_css.trim();
329    if let Some(text_node_id) = document[node_id].first_child {
330        if let NodeData::Text { text } = &mut document[text_node_id].data {
331            text.clear();
332            text.push_slice(new_css);
333        }
334    }
335}
336
337impl<'a> InlineOptions<'a> {
338    /// Override whether "style" tags should be inlined.
339    #[must_use]
340    pub fn inline_style_tags(mut self, inline_style_tags: bool) -> Self {
341        self.inline_style_tags = inline_style_tags;
342        self
343    }
344
345    /// Override whether "style" tags should be kept after processing.
346    #[must_use]
347    pub fn keep_style_tags(mut self, keep_style_tags: bool) -> Self {
348        self.keep_style_tags = keep_style_tags;
349        self
350    }
351
352    /// Override whether "link" tags should be kept after processing.
353    #[must_use]
354    pub fn keep_link_tags(mut self, keep_link_tags: bool) -> Self {
355        self.keep_link_tags = keep_link_tags;
356        self
357    }
358
359    /// Override whether "at-rules" should be kept after processing.
360    #[must_use]
361    pub fn keep_at_rules(mut self, keep_at_rules: bool) -> Self {
362        self.keep_at_rules = keep_at_rules;
363        self
364    }
365
366    /// Override whether trailing semicolons and spaces between properties and values should be removed.
367    #[must_use]
368    pub fn minify_css(mut self, minify_css: bool) -> Self {
369        self.minify_css = minify_css;
370        self
371    }
372
373    /// Set base URL that will be used for loading external stylesheets via relative URLs.
374    #[must_use]
375    pub fn base_url(mut self, base_url: Option<Url>) -> Self {
376        self.base_url = base_url;
377        self
378    }
379
380    /// Override whether remote stylesheets should be loaded.
381    #[must_use]
382    pub fn load_remote_stylesheets(mut self, load_remote_stylesheets: bool) -> Self {
383        self.load_remote_stylesheets = load_remote_stylesheets;
384        self
385    }
386
387    /// Set external stylesheet cache.
388    #[must_use]
389    #[cfg(feature = "stylesheet-cache")]
390    pub fn cache(mut self, cache: impl Into<Option<StylesheetCache>>) -> Self {
391        if let Some(cache) = cache.into() {
392            self.cache = Some(std::sync::Mutex::new(cache));
393        } else {
394            self.cache = None;
395        }
396        self
397    }
398
399    /// Set additional CSS to inline.
400    #[must_use]
401    pub fn extra_css(mut self, extra_css: Option<Cow<'a, str>>) -> Self {
402        self.extra_css = extra_css;
403        self
404    }
405
406    /// Set the initial node capacity for HTML tree.
407    #[must_use]
408    pub fn preallocate_node_capacity(mut self, preallocate_node_capacity: usize) -> Self {
409        self.preallocate_node_capacity = preallocate_node_capacity;
410        self
411    }
412
413    /// Set the way to resolve stylesheets from various sources.
414    #[must_use]
415    pub fn resolver(mut self, resolver: Arc<dyn StylesheetResolver>) -> Self {
416        self.resolver = resolver;
417        self
418    }
419
420    /// Remove selectors that were successfully inlined from inline `<style>` blocks.
421    #[must_use]
422    pub fn remove_inlined_selectors(mut self, enabled: bool) -> Self {
423        self.remove_inlined_selectors = enabled;
424        self
425    }
426
427    /// Create a new `CSSInliner` instance from this options.
428    #[must_use]
429    pub const fn build(self) -> CSSInliner<'a> {
430        CSSInliner::new(self)
431    }
432}
433
434impl Default for InlineOptions<'_> {
435    #[inline]
436    fn default() -> Self {
437        InlineOptions {
438            inline_style_tags: true,
439            keep_style_tags: false,
440            keep_link_tags: false,
441            keep_at_rules: false,
442            minify_css: false,
443            base_url: None,
444            load_remote_stylesheets: true,
445            #[cfg(feature = "stylesheet-cache")]
446            cache: None,
447            extra_css: None,
448            preallocate_node_capacity: 32,
449            resolver: Arc::new(DefaultStylesheetResolver),
450            remove_inlined_selectors: false,
451        }
452    }
453}
454
455/// A specialized `Result` type for CSS inlining operations.
456pub type Result<T> = std::result::Result<T, InlineError>;
457
458/// Customizable CSS inliner.
459#[derive(Debug)]
460pub struct CSSInliner<'a> {
461    options: InlineOptions<'a>,
462}
463
464const GROWTH_COEFFICIENT: f64 = 1.5;
465// A rough coefficient to calculate the number of individual declarations based on the total CSS size.
466const DECLARATION_SIZE_COEFFICIENT: f64 = 30.0;
467
468fn allocate_output_buffer(html: &str) -> Vec<u8> {
469    // Allocating more memory than the input HTML, as the inlined version is usually bigger
470    #[allow(
471        clippy::cast_precision_loss,
472        clippy::cast_sign_loss,
473        clippy::cast_possible_truncation
474    )]
475    Vec::with_capacity(
476        (html.len() as f64 * GROWTH_COEFFICIENT)
477            .min(usize::MAX as f64)
478            .round() as usize,
479    )
480}
481
482impl<'a> CSSInliner<'a> {
483    /// Create a new `CSSInliner` instance with given options.
484    #[must_use]
485    #[inline]
486    pub const fn new(options: InlineOptions<'a>) -> Self {
487        CSSInliner { options }
488    }
489
490    /// Return a default `InlineOptions` that can fully configure the CSS inliner.
491    ///
492    /// # Examples
493    ///
494    /// Get default `InlineOptions`, then change base url
495    ///
496    /// ```rust
497    /// use css_inline::{CSSInliner, Url};
498    /// # use url::ParseError;
499    /// # fn run() -> Result<(), ParseError> {
500    /// let url = Url::parse("https://api.example.com")?;
501    /// let inliner = CSSInliner::options()
502    ///     .base_url(Some(url))
503    ///     .build();
504    /// # Ok(())
505    /// # }
506    /// # run().unwrap();
507    /// ```
508    #[must_use]
509    #[inline]
510    pub fn options() -> InlineOptions<'a> {
511        InlineOptions::default()
512    }
513
514    /// Inline CSS styles from <style> tags to matching elements in the HTML tree and return a
515    /// string.
516    ///
517    /// # Errors
518    ///
519    /// Inlining might fail for the following reasons:
520    ///   - Missing stylesheet file;
521    ///   - Remote stylesheet is not available;
522    ///   - IO errors;
523    ///   - Internal CSS selector parsing error;
524    ///
525    /// # Panics
526    ///
527    /// This function may panic if external stylesheet cache lock is poisoned, i.e. another thread
528    /// using the same inliner panicked while resolving external stylesheets.
529    #[inline]
530    pub fn inline(&self, html: &str) -> Result<String> {
531        let mut out = allocate_output_buffer(html);
532        self.inline_to(html, &mut out)?;
533        Ok(String::from_utf8_lossy(&out).to_string())
534    }
535
536    /// Inline CSS & write the result to a generic writer. Use it if you want to write
537    /// the inlined document to a file.
538    ///
539    /// # Errors
540    ///
541    /// Inlining might fail for the following reasons:
542    ///   - Missing stylesheet file;
543    ///   - Remote stylesheet is not available;
544    ///   - IO errors;
545    ///   - Internal CSS selector parsing error;
546    ///
547    /// # Panics
548    ///
549    /// This function may panic if external stylesheet cache lock is poisoned, i.e. another thread
550    /// using the same inliner panicked while resolving external stylesheets.
551    #[inline]
552    pub fn inline_to<W: Write>(&self, html: &str, target: &mut W) -> Result<()> {
553        self.inline_to_impl(html, None, target, InliningMode::Document)
554    }
555
556    /// Inline CSS into an HTML fragment.
557    ///
558    /// # Errors
559    ///
560    /// Inlining might fail for the following reasons:
561    ///   - Missing stylesheet file;
562    ///   - Remote stylesheet is not available;
563    ///   - IO errors;
564    ///   - Internal CSS selector parsing error;
565    ///
566    /// # Panics
567    ///
568    /// This function may panic if external stylesheet cache lock is poisoned, i.e. another thread
569    /// using the same inliner panicked while resolving external stylesheets.
570    pub fn inline_fragment(&self, html: &str, css: &str) -> Result<String> {
571        let mut out = allocate_output_buffer(html);
572        self.inline_fragment_to(html, css, &mut out)?;
573        Ok(String::from_utf8_lossy(&out).to_string())
574    }
575
576    /// Inline CSS into an HTML fragment and write the result to a generic writer.
577    ///
578    /// # Errors
579    ///
580    /// Inlining might fail for the following reasons:
581    ///   - Missing stylesheet file;
582    ///   - Remote stylesheet is not available;
583    ///   - IO errors;
584    ///   - Internal CSS selector parsing error;
585    ///
586    /// # Panics
587    ///
588    /// This function may panic if external stylesheet cache lock is poisoned, i.e. another thread
589    /// using the same inliner panicked while resolving external stylesheets.
590    pub fn inline_fragment_to<W: Write>(
591        &self,
592        html: &str,
593        css: &str,
594        target: &mut W,
595    ) -> Result<()> {
596        self.inline_to_impl(html, Some(css), target, InliningMode::Fragment)
597    }
598
599    #[allow(clippy::too_many_lines)]
600    fn inline_to_impl<W: Write>(
601        &self,
602        html: &str,
603        css: Option<&str>,
604        target: &mut W,
605        mode: InliningMode,
606    ) -> Result<()> {
607        let mut document = Document::parse_with_options(
608            html.as_bytes(),
609            self.options.preallocate_node_capacity,
610            mode,
611        );
612        // CSS rules may overlap, and the final set of rules applied to an element depend on
613        // selectors' specificity - selectors with higher specificity have more priority.
614        // Inlining happens in two major steps:
615        //   1. All available styles are mapped to respective elements together with their
616        //      selector's specificity. When two rules overlap on the same declaration, then
617        //      the one with higher specificity replaces another.
618        //   2. Resulting styles are merged into existing "style" tags.
619        let track_selector_cleanup = self.options.remove_inlined_selectors;
620        let mut size_estimate: usize = if self.options.inline_style_tags {
621            document
622                .styles()
623                .map(|(_, s)| {
624                    // Add 1 to account for the extra `\n` char we add between styles
625                    s.len().saturating_add(1)
626                })
627                .sum()
628        } else {
629            0
630        };
631        if let Some(extra_css) = &self.options.extra_css {
632            size_estimate = size_estimate.saturating_add(extra_css.len());
633        }
634        if let Some(css) = css {
635            size_estimate = size_estimate.saturating_add(css.len());
636        }
637        let mut css_buffer = CssBuffer::new(track_selector_cleanup);
638        css_buffer.raw.reserve(size_estimate);
639        if self.options.inline_style_tags || self.options.keep_at_rules {
640            for (node_id, style) in document.styles() {
641                let style_node = track_selector_cleanup.then_some(node_id);
642                css_buffer.push(style_node, style, true);
643            }
644        }
645        if self.options.load_remote_stylesheets {
646            let mut links = document.stylesheets().collect::<Vec<&str>>();
647            links.sort_unstable();
648            links.dedup();
649            for href in &links {
650                let url = self.get_full_url(href);
651                #[cfg(feature = "stylesheet-cache")]
652                if let Some(lock) = self.options.cache.as_ref() {
653                    let mut cache = lock.lock().expect("Cache lock is poisoned");
654                    if let Some(cached) = cache.get(url.as_ref()) {
655                        css_buffer.push(None, cached, true);
656                        continue;
657                    }
658                }
659
660                let css = self.options.resolver.retrieve(url.as_ref())?;
661                css_buffer.push(None, &css, true);
662
663                #[cfg(feature = "stylesheet-cache")]
664                if let Some(lock) = self.options.cache.as_ref() {
665                    let mut cache = lock.lock().expect("Cache lock is poisoned");
666                    cache.put(url.into_owned(), css);
667                }
668            }
669        }
670        if let Some(extra_css) = &self.options.extra_css {
671            css_buffer.push(None, extra_css, false);
672        }
673        if let Some(css) = css {
674            css_buffer.push(None, css, false);
675        }
676        let (raw_styles, css_chunks) = css_buffer.into_parts();
677        let mut selector_cleanup_state = if track_selector_cleanup {
678            Some(SelectorCleanupState::default())
679        } else {
680            None
681        };
682        if let (Some(state), Some(chunks)) = (&mut selector_cleanup_state, css_chunks) {
683            state.chunks = chunks;
684        }
685        let mut parse_input = cssparser::ParserInput::new(&raw_styles);
686        let mut parser = cssparser::Parser::new(&mut parse_input);
687        // Allocating some memory for all the parsed declarations
688        #[allow(
689            clippy::cast_precision_loss,
690            clippy::cast_sign_loss,
691            clippy::cast_possible_truncation
692        )]
693        let mut declarations = Vec::with_capacity(
694            ((raw_styles.len() as f64 / DECLARATION_SIZE_COEFFICIENT)
695                .min(usize::MAX as f64)
696                .round() as usize)
697                .max(16),
698        );
699        let mut rule_list = Vec::with_capacity(declarations.capacity() / 3);
700        let at_rules = if self.options.keep_at_rules {
701            let mut at_rules = String::new();
702            for rule in cssparser::StyleSheetParser::new(
703                &mut parser,
704                &mut parser::AtRuleFilteringParser::new(&mut declarations, &mut at_rules),
705            )
706            .flatten()
707            {
708                if self.options.inline_style_tags {
709                    rule_list.push(rule);
710                }
711            }
712            Some(at_rules)
713        } else if !raw_styles.is_empty() {
714            // At this point, we collected some styles from at least one source, hence we need to process it.
715            for rule in cssparser::StyleSheetParser::new(
716                &mut parser,
717                &mut parser::CSSRuleListParser::new(&mut declarations),
718            )
719            .flatten()
720            {
721                rule_list.push(rule);
722            }
723            None
724        } else {
725            None
726        };
727        // Compute chunk indices for all rules once, before processing
728        let rule_chunk_indices = selector_cleanup_state
729            .as_ref()
730            .map(|state| compute_rule_chunk_indices(&rule_list, &raw_styles, &state.chunks))
731            .unwrap_or_default();
732        // Vec indexed by NodeId for O(1) access instead of hash lookups
733        let mut styles: Vec<Option<SmallVec<[_; 4]>>> = vec![None; document.nodes.len()];
734        // This cache is unused but required in the `selectors` API
735        let mut caches = SelectorCaches::default();
736        for (rule_id, (selectors, (start, end))) in rule_list.iter().enumerate() {
737            // Only CSS Syntax Level 3 is supported, therefore it is OK to split by `,`
738            // With `is` or `where` selectors (Level 4) this split should be done on the parser level
739            for selector in selectors.split(',') {
740                let mut matched_any = false;
741                if let Ok(matching_elements) = document.select(selector, &mut caches) {
742                    let specificity = matching_elements.specificity();
743                    for matching_element in matching_elements {
744                        matched_any = true;
745                        let element_styles = styles[matching_element.node_id.get()]
746                            .get_or_insert_with(SmallVec::new);
747                        // Iterate over pairs of property name & value
748                        // Example: `padding`, `0`
749                        for (name, value) in &declarations[*start..*end] {
750                            let prop_name = name.as_ref();
751                            // Linear search for existing property
752                            if let Some(idx) =
753                                element_styles.iter().position(|(n, _, _)| *n == prop_name)
754                            {
755                                let entry: &mut (&str, Specificity, &str) =
756                                    &mut element_styles[idx];
757                                let new_important = value.trim_end().ends_with("!important");
758                                let old_important = entry.2.trim_end().ends_with("!important");
759                                match (new_important, old_important) {
760                                    // Equal importance; the higher specificity wins.
761                                    (false, false) | (true, true) => {
762                                        if entry.1 <= specificity {
763                                            entry.1 = specificity;
764                                            entry.2 = *value;
765                                        }
766                                    }
767                                    // Only the new value is important; it wins.
768                                    (true, false) => {
769                                        entry.1 = specificity;
770                                        entry.2 = *value;
771                                    }
772                                    // The old value is important and the new one is not; keep
773                                    // the old value.
774                                    (false, true) => {}
775                                }
776                            } else {
777                                element_styles.push((prop_name, specificity, *value));
778                            }
779                        }
780                    }
781                }
782                if let Some(state) = selector_cleanup_state.as_mut() {
783                    if let Some(chunk_index) = rule_chunk_indices.get(rule_id).copied().flatten() {
784                        state.record_usage(SelectorUsage {
785                            selector,
786                            declarations: (*start, *end),
787                            rule_id,
788                            chunk_index,
789                            matched: matched_any,
790                        });
791                    }
792                }
793                // Ignore not parsable selectors. E.g. there is no parser for @media queries
794                // Which means that they will fall into this category and will be ignored
795            }
796        }
797        let cleanup_requires_css = selector_cleanup_state
798            .as_ref()
799            .is_some_and(SelectorCleanupState::has_unmatched);
800        let keep_style_tags = self.options.keep_style_tags || cleanup_requires_css;
801        if let Some(state) = selector_cleanup_state.as_ref() {
802            apply_selector_cleanup(
803                state,
804                &mut document,
805                self.options.keep_style_tags,
806                &declarations,
807            );
808        }
809        document.serialize(
810            target,
811            styles,
812            keep_style_tags,
813            self.options.keep_link_tags,
814            self.options.minify_css,
815            at_rules.as_ref(),
816            mode,
817        )?;
818        Ok(())
819    }
820
821    fn get_full_url<'u>(&self, href: &'u str) -> Cow<'u, str> {
822        // Valid absolute URL
823        if Url::parse(href).is_ok() {
824            return Cow::Borrowed(href);
825        }
826        if let Some(base_url) = &self.options.base_url {
827            // Use the same scheme as the base URL
828            if href.starts_with("//") {
829                return Cow::Owned(format!("{}:{}", base_url.scheme(), href));
830            }
831            // Not a URL, then it is a relative URL
832            if let Ok(new_url) = base_url.join(href) {
833                return Cow::Owned(new_url.into());
834            }
835        }
836        // If it is not a valid URL and there is no base URL specified, we assume a local path
837        Cow::Borrowed(href)
838    }
839}
840
841impl Default for CSSInliner<'_> {
842    #[inline]
843    fn default() -> Self {
844        CSSInliner::new(InlineOptions::default())
845    }
846}
847
848/// Shortcut for inlining CSS with default parameters.
849///
850/// # Errors
851///
852/// Inlining might fail for the following reasons:
853///   - Missing stylesheet file;
854///   - Remote stylesheet is not available;
855///   - IO errors;
856///   - Internal CSS selector parsing error;
857///
858/// # Panics
859///
860/// This function may panic if external stylesheet cache lock is poisoned, i.e. another thread
861/// using the same inliner panicked while resolving external stylesheets.
862#[inline]
863pub fn inline(html: &str) -> Result<String> {
864    CSSInliner::default().inline(html)
865}
866
867/// Shortcut for inlining CSS with default parameters and writing the output to a generic writer.
868///
869/// # Errors
870///
871/// Inlining might fail for the following reasons:
872///   - Missing stylesheet file;
873///   - Remote stylesheet is not available;
874///   - IO errors;
875///   - Internal CSS selector parsing error;
876///
877/// # Panics
878///
879/// This function may panic if external stylesheet cache lock is poisoned, i.e. another thread
880/// using the same inliner panicked while resolving external stylesheets.
881#[inline]
882pub fn inline_to<W: Write>(html: &str, target: &mut W) -> Result<()> {
883    CSSInliner::default().inline_to(html, target)
884}
885
886/// Shortcut for inlining CSS into an HTML fragment with default parameters.
887///
888/// # Errors
889///
890/// Inlining might fail for the following reasons:
891///   - Missing stylesheet file;
892///   - Remote stylesheet is not available;
893///   - IO errors;
894///   - Internal CSS selector parsing error;
895///
896/// # Panics
897///
898/// This function may panic if external stylesheet cache lock is poisoned, i.e. another thread
899/// using the same inliner panicked while resolving external stylesheets.
900#[inline]
901pub fn inline_fragment(html: &str, css: &str) -> Result<String> {
902    CSSInliner::default().inline_fragment(html, css)
903}
904
905/// Shortcut for inlining CSS into an HTML fragment with default parameters and writing the output to a generic writer.
906///
907/// # Errors
908///
909/// Inlining might fail for the following reasons:
910///   - Missing stylesheet file;
911///   - Remote stylesheet is not available;
912///   - IO errors;
913///   - Internal CSS selector parsing error;
914///
915/// # Panics
916///
917/// This function may panic if external stylesheet cache lock is poisoned, i.e. another thread
918/// using the same inliner panicked while resolving external stylesheets.
919#[inline]
920pub fn inline_fragment_to<W: Write>(html: &str, css: &str, target: &mut W) -> Result<()> {
921    CSSInliner::default().inline_fragment_to(html, css, target)
922}
923
924#[cfg(test)]
925mod tests {
926    use crate::{CSSInliner, InlineOptions};
927
928    #[test]
929    fn test_inliner_sync_send() {
930        fn assert_send<T: Send + Sync>() {}
931        assert_send::<CSSInliner<'_>>();
932        assert_send::<InlineOptions<'_>>();
933    }
934}