Skip to main content

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    /// Apply `width` HTML attributes from CSS `width` properties on supported elements.
87    ///
88    /// This is useful for email compatibility with clients like Outlook that ignore CSS width.
89    /// Supported elements: `table`, `td`, `th`, `img`.
90    pub apply_width_attributes: bool,
91    /// Apply `height` HTML attributes from CSS `height` properties on supported elements.
92    ///
93    /// This is useful for email compatibility with clients like Outlook that ignore CSS height.
94    /// Supported elements: `table`, `td`, `th`, `img`.
95    pub apply_height_attributes: bool,
96}
97
98impl std::fmt::Debug for InlineOptions<'_> {
99    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
100        let mut debug = f.debug_struct("InlineOptions");
101        debug
102            .field("inline_style_tags", &self.inline_style_tags)
103            .field("keep_style_tags", &self.keep_style_tags)
104            .field("keep_link_tags", &self.keep_link_tags)
105            .field("base_url", &self.base_url)
106            .field("load_remote_stylesheets", &self.load_remote_stylesheets);
107        #[cfg(feature = "stylesheet-cache")]
108        {
109            debug.field("cache", &self.cache);
110        }
111        debug
112            .field("extra_css", &self.extra_css)
113            .field("preallocate_node_capacity", &self.preallocate_node_capacity)
114            .field("remove_inlined_selectors", &self.remove_inlined_selectors)
115            .field("apply_width_attributes", &self.apply_width_attributes)
116            .field("apply_height_attributes", &self.apply_height_attributes)
117            .finish_non_exhaustive()
118    }
119}
120
121#[derive(Debug)]
122struct CssChunk {
123    range: Range<usize>,
124    /// The style node this chunk came from, if any.
125    /// `None` for linked stylesheets, extra CSS, or fragment CSS.
126    style_node: Option<NodeId>,
127}
128
129type SelectorList<'i> = SmallVec<[&'i str; 2]>;
130
131#[derive(Debug)]
132struct SelectorUsage<'i> {
133    selector: &'i str,
134    declarations: (usize, usize),
135    rule_id: usize,
136    chunk_index: usize,
137    matched: bool,
138}
139
140#[derive(Debug, Default)]
141struct RuleRemainder<'i> {
142    selectors: SelectorList<'i>,
143    declarations: (usize, usize),
144    /// Offset of the first selector in the combined CSS.
145    offset: usize,
146}
147
148#[derive(Debug, Default)]
149struct SelectorCleanupState<'i> {
150    chunks: Vec<CssChunk>,
151    usages: Vec<SelectorUsage<'i>>,
152    /// `@`-rule ranges in the combined CSS, per chunk; empty for chunks without a style node.
153    chunk_at_rules: Vec<Vec<Range<usize>>>,
154}
155
156impl<'i> SelectorCleanupState<'i> {
157    fn record_usage(&mut self, usage: SelectorUsage<'i>) {
158        self.usages.push(usage);
159    }
160
161    /// Keep the `@`-rule starting at `rule` and ending at byte `end` of `source` in the `<style>`
162    /// block it came from.
163    fn record_at_rule(&mut self, source: &str, rule: &str, end: usize) {
164        if !rule.starts_with('@') {
165            return;
166        }
167        let start = (rule.as_ptr() as usize).wrapping_sub(source.as_ptr() as usize);
168        if let Some(idx) = find_chunk_index(&self.chunks, start) {
169            let chunk = &self.chunks[idx];
170            if chunk.style_node.is_some() {
171                // An unterminated `@`-rule runs into the following blocks; it ends with its own.
172                self.chunk_at_rules[idx].push(start..end.min(chunk.range.end));
173            }
174        }
175    }
176
177    /// Whether any rewritten `<style>` block keeps content.
178    fn needs_css(&self) -> bool {
179        self.usages.iter().any(|usage| !usage.matched)
180            || self.chunk_at_rules.iter().any(|ranges| !ranges.is_empty())
181    }
182}
183
184/// Find which chunk contains the given byte offset.
185fn find_chunk_index(chunks: &[CssChunk], offset: usize) -> Option<usize> {
186    chunks
187        .iter()
188        .position(|chunk| chunk.range.contains(&offset))
189}
190
191/// Compute chunk indices for all rules based on where their selectors point in the source.
192#[allow(clippy::arithmetic_side_effects)]
193fn compute_rule_chunk_indices(
194    rules: &[(&str, (usize, usize))],
195    source: &str,
196    chunks: &[CssChunk],
197) -> Vec<Option<usize>> {
198    let source_start = source.as_ptr() as usize;
199    let source_end = source_start.saturating_add(source.len());
200
201    rules
202        .iter()
203        .map(|(selectors, _)| {
204            let sel_start = selectors.as_ptr() as usize;
205            // Check if selectors slice is within source bounds
206            if sel_start >= source_start && sel_start < source_end {
207                let offset = sel_start.wrapping_sub(source_start);
208                find_chunk_index(chunks, offset)
209            } else {
210                None
211            }
212        })
213        .collect()
214}
215
216struct CssBuffer {
217    raw: String,
218    chunks: Option<Vec<CssChunk>>,
219}
220
221impl CssBuffer {
222    fn new(track_chunks: bool) -> Self {
223        CssBuffer {
224            raw: String::new(),
225            chunks: track_chunks.then(Vec::new),
226        }
227    }
228
229    fn push(&mut self, style_node: Option<NodeId>, content: &str, append_newline: bool) {
230        if content.is_empty() {
231            return;
232        }
233        let start = self.raw.len();
234        self.raw.push_str(content);
235        if append_newline {
236            self.raw.push('\n');
237        }
238        if let Some(chunks) = &mut self.chunks {
239            let end = self.raw.len();
240            chunks.push(CssChunk {
241                range: start..end,
242                style_node,
243            });
244        }
245    }
246
247    fn into_parts(self) -> (String, Option<Vec<CssChunk>>) {
248        (self.raw, self.chunks)
249    }
250}
251
252fn apply_selector_cleanup<'i>(
253    state: &SelectorCleanupState<'i>,
254    document: &mut Document,
255    requested_keep_style_tags: bool,
256    declarations: &[parser::Declaration<'i>],
257    source: &'i str,
258) {
259    if state.chunks.is_empty() {
260        return;
261    }
262    rewrite_style_blocks(
263        state,
264        document,
265        requested_keep_style_tags,
266        declarations,
267        source,
268    );
269}
270
271fn rewrite_style_blocks<'i>(
272    state: &SelectorCleanupState<'i>,
273    document: &mut Document,
274    requested_keep_style_tags: bool,
275    declarations: &[parser::Declaration<'i>],
276    source: &'i str,
277) {
278    let source_start = source.as_ptr() as usize;
279    let mut chunk_remainders: Vec<Vec<RuleRemainder<'i>>> =
280        (0..state.chunks.len()).map(|_| Vec::new()).collect();
281    let mut remainder_lookup: FxHashMap<(usize, usize), usize> = FxHashMap::default();
282
283    for usage in &state.usages {
284        if usage.matched {
285            continue;
286        }
287        let trimmed = usage.selector.trim();
288        if trimmed.is_empty() {
289            continue;
290        }
291        let key = (usage.chunk_index, usage.rule_id);
292        let entry_index = remainder_lookup.entry(key).or_insert_with(|| {
293            let idx = chunk_remainders[usage.chunk_index].len();
294            chunk_remainders[usage.chunk_index].push(RuleRemainder {
295                selectors: SelectorList::new(),
296                declarations: usage.declarations,
297                offset: (trimmed.as_ptr() as usize).wrapping_sub(source_start),
298            });
299            idx
300        });
301        chunk_remainders[usage.chunk_index][*entry_index]
302            .selectors
303            .push(trimmed);
304    }
305
306    for (idx, chunk) in state.chunks.iter().enumerate() {
307        // Remainders and `@`-rules, keyed by their offset so the block keeps source order.
308        let mut items: Vec<(usize, String)> = Vec::new();
309        for remainder in &chunk_remainders[idx] {
310            let mut text = String::new();
311            append_rule(&mut text, remainder, declarations);
312            items.push((remainder.offset, text.trim().to_string()));
313        }
314        for range in &state.chunk_at_rules[idx] {
315            items.push((range.start, source[range.clone()].trim().to_string()));
316        }
317        items.retain(|(_, text)| !text.is_empty());
318        if items.is_empty() {
319            handle_empty_remainder(document, chunk, requested_keep_style_tags);
320            continue;
321        }
322        items.sort_by_key(|(offset, _)| *offset);
323        let buffer = items
324            .into_iter()
325            .map(|(_, text)| text)
326            .collect::<Vec<_>>()
327            .join("\n");
328        if let Some(node_id) = chunk.style_node {
329            overwrite_style_node(document, node_id, &buffer);
330        }
331    }
332}
333
334fn handle_empty_remainder(
335    document: &mut Document,
336    chunk: &CssChunk,
337    requested_keep_style_tags: bool,
338) {
339    if let Some(node_id) = chunk.style_node {
340        if requested_keep_style_tags {
341            overwrite_style_node(document, node_id, "");
342        } else {
343            document.detach_node(node_id);
344        }
345    }
346}
347
348fn append_rule<'i>(
349    buffer: &mut String,
350    remainder: &RuleRemainder<'i>,
351    declarations: &[parser::Declaration<'i>],
352) {
353    let (start, end) = remainder.declarations;
354    if start >= end || end > declarations.len() {
355        return;
356    }
357    let mut selectors_iter = remainder.selectors.iter().peekable();
358    while let Some(selector) = selectors_iter.next() {
359        buffer.push_str(selector);
360        if selectors_iter.peek().is_some() {
361            buffer.push_str(", ");
362        }
363    }
364    buffer.push_str(" {");
365    for (name, value) in &declarations[start..end] {
366        buffer.push(' ');
367        buffer.push_str(name);
368        buffer.push(':');
369        buffer.push(' ');
370        let value_trimmed = value.trim();
371        buffer.push_str(value_trimmed);
372        if !value_trimmed.ends_with(';') {
373            buffer.push(';');
374        }
375    }
376    buffer.push_str(" }\n");
377}
378
379fn overwrite_style_node(document: &mut Document, node_id: NodeId, new_css: &str) {
380    let new_css = new_css.trim();
381    if let Some(text_node_id) = document[node_id].first_child {
382        if let NodeData::Text { text } = &mut document[text_node_id].data {
383            text.clear();
384            text.push_slice(new_css);
385        }
386    }
387}
388
389impl<'a> InlineOptions<'a> {
390    /// Override whether "style" tags should be inlined.
391    #[must_use]
392    pub fn inline_style_tags(mut self, inline_style_tags: bool) -> Self {
393        self.inline_style_tags = inline_style_tags;
394        self
395    }
396
397    /// Override whether "style" tags should be kept after processing.
398    #[must_use]
399    pub fn keep_style_tags(mut self, keep_style_tags: bool) -> Self {
400        self.keep_style_tags = keep_style_tags;
401        self
402    }
403
404    /// Override whether "link" tags should be kept after processing.
405    #[must_use]
406    pub fn keep_link_tags(mut self, keep_link_tags: bool) -> Self {
407        self.keep_link_tags = keep_link_tags;
408        self
409    }
410
411    /// Override whether "at-rules" should be kept after processing.
412    #[must_use]
413    pub fn keep_at_rules(mut self, keep_at_rules: bool) -> Self {
414        self.keep_at_rules = keep_at_rules;
415        self
416    }
417
418    /// Override whether trailing semicolons and spaces between properties and values should be removed.
419    #[must_use]
420    pub fn minify_css(mut self, minify_css: bool) -> Self {
421        self.minify_css = minify_css;
422        self
423    }
424
425    /// Set base URL that will be used for loading external stylesheets via relative URLs.
426    #[must_use]
427    pub fn base_url(mut self, base_url: Option<Url>) -> Self {
428        self.base_url = base_url;
429        self
430    }
431
432    /// Override whether remote stylesheets should be loaded.
433    #[must_use]
434    pub fn load_remote_stylesheets(mut self, load_remote_stylesheets: bool) -> Self {
435        self.load_remote_stylesheets = load_remote_stylesheets;
436        self
437    }
438
439    /// Set external stylesheet cache.
440    #[must_use]
441    #[cfg(feature = "stylesheet-cache")]
442    pub fn cache(mut self, cache: impl Into<Option<StylesheetCache>>) -> Self {
443        if let Some(cache) = cache.into() {
444            self.cache = Some(std::sync::Mutex::new(cache));
445        } else {
446            self.cache = None;
447        }
448        self
449    }
450
451    /// Set additional CSS to inline.
452    #[must_use]
453    pub fn extra_css(mut self, extra_css: Option<Cow<'a, str>>) -> Self {
454        self.extra_css = extra_css;
455        self
456    }
457
458    /// Set the initial node capacity for HTML tree.
459    #[must_use]
460    pub fn preallocate_node_capacity(mut self, preallocate_node_capacity: usize) -> Self {
461        self.preallocate_node_capacity = preallocate_node_capacity;
462        self
463    }
464
465    /// Set the way to resolve stylesheets from various sources.
466    #[must_use]
467    pub fn resolver(mut self, resolver: Arc<dyn StylesheetResolver>) -> Self {
468        self.resolver = resolver;
469        self
470    }
471
472    /// Remove selectors that were successfully inlined from inline `<style>` blocks.
473    #[must_use]
474    pub fn remove_inlined_selectors(mut self, enabled: bool) -> Self {
475        self.remove_inlined_selectors = enabled;
476        self
477    }
478
479    /// Apply `width` HTML attributes from CSS `width` properties on supported elements.
480    ///
481    /// This is useful for email compatibility with clients like Outlook that ignore CSS width.
482    /// Supported elements: `table`, `td`, `th`, `img`.
483    #[must_use]
484    pub fn apply_width_attributes(mut self, apply: bool) -> Self {
485        self.apply_width_attributes = apply;
486        self
487    }
488
489    /// Apply `height` HTML attributes from CSS `height` properties on supported elements.
490    ///
491    /// This is useful for email compatibility with clients like Outlook that ignore CSS height.
492    /// Supported elements: `table`, `td`, `th`, `img`.
493    #[must_use]
494    pub fn apply_height_attributes(mut self, apply: bool) -> Self {
495        self.apply_height_attributes = apply;
496        self
497    }
498
499    /// Create a new `CSSInliner` instance from this options.
500    #[must_use]
501    pub const fn build(self) -> CSSInliner<'a> {
502        CSSInliner::new(self)
503    }
504}
505
506impl Default for InlineOptions<'_> {
507    #[inline]
508    fn default() -> Self {
509        InlineOptions {
510            inline_style_tags: true,
511            keep_style_tags: false,
512            keep_link_tags: false,
513            keep_at_rules: false,
514            minify_css: false,
515            base_url: None,
516            load_remote_stylesheets: true,
517            #[cfg(feature = "stylesheet-cache")]
518            cache: None,
519            extra_css: None,
520            preallocate_node_capacity: 32,
521            resolver: Arc::new(DefaultStylesheetResolver),
522            remove_inlined_selectors: false,
523            apply_width_attributes: false,
524            apply_height_attributes: false,
525        }
526    }
527}
528
529/// A specialized `Result` type for CSS inlining operations.
530pub type Result<T> = std::result::Result<T, InlineError>;
531
532/// Customizable CSS inliner.
533#[derive(Debug)]
534pub struct CSSInliner<'a> {
535    options: InlineOptions<'a>,
536}
537
538const GROWTH_COEFFICIENT: f64 = 1.5;
539// A rough coefficient to calculate the number of individual declarations based on the total CSS size.
540const DECLARATION_SIZE_COEFFICIENT: f64 = 30.0;
541
542fn allocate_output_buffer(html: &str) -> Vec<u8> {
543    // Allocating more memory than the input HTML, as the inlined version is usually bigger
544    #[allow(
545        clippy::cast_precision_loss,
546        clippy::cast_sign_loss,
547        clippy::cast_possible_truncation
548    )]
549    Vec::with_capacity(
550        (html.len() as f64 * GROWTH_COEFFICIENT)
551            .min(usize::MAX as f64)
552            .round() as usize,
553    )
554}
555
556impl<'a> CSSInliner<'a> {
557    /// Create a new `CSSInliner` instance with given options.
558    #[must_use]
559    #[inline]
560    pub const fn new(options: InlineOptions<'a>) -> Self {
561        CSSInliner { options }
562    }
563
564    /// Return a default `InlineOptions` that can fully configure the CSS inliner.
565    ///
566    /// # Examples
567    ///
568    /// Get default `InlineOptions`, then change base url
569    ///
570    /// ```rust
571    /// use css_inline::{CSSInliner, Url};
572    /// # use url::ParseError;
573    /// # fn run() -> Result<(), ParseError> {
574    /// let url = Url::parse("https://api.example.com")?;
575    /// let inliner = CSSInliner::options()
576    ///     .base_url(Some(url))
577    ///     .build();
578    /// # Ok(())
579    /// # }
580    /// # run().unwrap();
581    /// ```
582    #[must_use]
583    #[inline]
584    pub fn options() -> InlineOptions<'a> {
585        InlineOptions::default()
586    }
587
588    /// Inline CSS styles from <style> tags to matching elements in the HTML tree and return a
589    /// string.
590    ///
591    /// # Errors
592    ///
593    /// Inlining might fail for the following reasons:
594    ///   - Missing stylesheet file;
595    ///   - Remote stylesheet is not available;
596    ///   - IO errors;
597    ///   - Internal CSS selector parsing error;
598    ///
599    /// # Panics
600    ///
601    /// This function may panic if external stylesheet cache lock is poisoned, i.e. another thread
602    /// using the same inliner panicked while resolving external stylesheets.
603    #[inline]
604    pub fn inline(&self, html: &str) -> Result<String> {
605        let mut out = allocate_output_buffer(html);
606        self.inline_to(html, &mut out)?;
607        Ok(String::from_utf8_lossy(&out).to_string())
608    }
609
610    /// Inline CSS & write the result to a generic writer. Use it if you want to write
611    /// the inlined document to a file.
612    ///
613    /// # Errors
614    ///
615    /// Inlining might fail for the following reasons:
616    ///   - Missing stylesheet file;
617    ///   - Remote stylesheet is not available;
618    ///   - IO errors;
619    ///   - Internal CSS selector parsing error;
620    ///
621    /// # Panics
622    ///
623    /// This function may panic if external stylesheet cache lock is poisoned, i.e. another thread
624    /// using the same inliner panicked while resolving external stylesheets.
625    #[inline]
626    pub fn inline_to<W: Write>(&self, html: &str, target: &mut W) -> Result<()> {
627        self.inline_to_impl(html, None, target, InliningMode::Document)
628    }
629
630    /// Inline CSS into an HTML fragment.
631    ///
632    /// Unlike [`inline`](CSSInliner::inline), this method does not wrap the output in `<html>` and
633    /// `<body>` tags. Structural tags (`<html>`, `<head>`, `<body>`) are stripped from the output;
634    /// only their contents are preserved. CSS from `<style>` tags within the input is also
635    /// processed and inlined.
636    ///
637    /// If you need to preserve the full HTML document structure, use [`inline`](CSSInliner::inline)
638    /// instead.
639    ///
640    /// # Errors
641    ///
642    /// Inlining might fail for the following reasons:
643    ///   - Missing stylesheet file;
644    ///   - Remote stylesheet is not available;
645    ///   - IO errors;
646    ///   - Internal CSS selector parsing error;
647    ///
648    /// # Panics
649    ///
650    /// This function may panic if external stylesheet cache lock is poisoned, i.e. another thread
651    /// using the same inliner panicked while resolving external stylesheets.
652    pub fn inline_fragment(&self, html: &str, css: &str) -> Result<String> {
653        let mut out = allocate_output_buffer(html);
654        self.inline_fragment_to(html, css, &mut out)?;
655        Ok(String::from_utf8_lossy(&out).to_string())
656    }
657
658    /// Inline CSS into an HTML fragment and write the result to a generic writer.
659    ///
660    /// See [`inline_fragment`](CSSInliner::inline_fragment) for details on fragment handling.
661    ///
662    /// # Errors
663    ///
664    /// Inlining might fail for the following reasons:
665    ///   - Missing stylesheet file;
666    ///   - Remote stylesheet is not available;
667    ///   - IO errors;
668    ///   - Internal CSS selector parsing error;
669    ///
670    /// # Panics
671    ///
672    /// This function may panic if external stylesheet cache lock is poisoned, i.e. another thread
673    /// using the same inliner panicked while resolving external stylesheets.
674    pub fn inline_fragment_to<W: Write>(
675        &self,
676        html: &str,
677        css: &str,
678        target: &mut W,
679    ) -> Result<()> {
680        self.inline_to_impl(html, Some(css), target, InliningMode::Fragment)
681    }
682
683    #[allow(clippy::too_many_lines)]
684    fn inline_to_impl<W: Write>(
685        &self,
686        html: &str,
687        css: Option<&str>,
688        target: &mut W,
689        mode: InliningMode,
690    ) -> Result<()> {
691        let mut document = Document::parse_with_options(
692            html.as_bytes(),
693            self.options.preallocate_node_capacity,
694            mode,
695        );
696        // CSS rules may overlap, and the final set of rules applied to an element depend on
697        // selectors' specificity - selectors with higher specificity have more priority.
698        // Inlining happens in two major steps:
699        //   1. All available styles are mapped to respective elements together with their
700        //      selector's specificity. When two rules overlap on the same declaration, then
701        //      the one with higher specificity replaces another.
702        //   2. Resulting styles are merged into existing "style" tags.
703        // Without `inline_style_tags` no `<style>` rule is inlined, so none is removed.
704        let track_selector_cleanup =
705            self.options.remove_inlined_selectors && self.options.inline_style_tags;
706        let mut size_estimate: usize = if self.options.inline_style_tags {
707            document
708                .styles()
709                .map(|(_, s)| {
710                    // Add 1 to account for the extra `\n` char we add between styles
711                    s.len().saturating_add(1)
712                })
713                .sum()
714        } else {
715            0
716        };
717        if let Some(extra_css) = &self.options.extra_css {
718            size_estimate = size_estimate.saturating_add(extra_css.len());
719        }
720        if let Some(css) = css {
721            size_estimate = size_estimate.saturating_add(css.len());
722        }
723        let mut css_buffer = CssBuffer::new(track_selector_cleanup);
724        css_buffer.raw.reserve(size_estimate);
725        if self.options.inline_style_tags || self.options.keep_at_rules {
726            for (node_id, style) in document.styles() {
727                let style_node = track_selector_cleanup.then_some(node_id);
728                css_buffer.push(style_node, style, true);
729            }
730        }
731        if self.options.load_remote_stylesheets {
732            let mut links = document.stylesheets().collect::<Vec<&str>>();
733            links.sort_unstable();
734            links.dedup();
735            for href in &links {
736                let url = self.get_full_url(href);
737                #[cfg(feature = "stylesheet-cache")]
738                if let Some(lock) = self.options.cache.as_ref() {
739                    let mut cache = lock.lock().expect("Cache lock is poisoned");
740                    if let Some(cached) = cache.get(url.as_ref()) {
741                        css_buffer.push(None, cached, true);
742                        continue;
743                    }
744                }
745
746                let css = self.options.resolver.retrieve(url.as_ref())?;
747                css_buffer.push(None, &css, true);
748
749                #[cfg(feature = "stylesheet-cache")]
750                if let Some(lock) = self.options.cache.as_ref() {
751                    let mut cache = lock.lock().expect("Cache lock is poisoned");
752                    cache.put(url.into_owned(), css);
753                }
754            }
755        }
756        if let Some(extra_css) = &self.options.extra_css {
757            css_buffer.push(None, extra_css, false);
758        }
759        if let Some(css) = css {
760            css_buffer.push(None, css, false);
761        }
762        let (raw_styles, css_chunks) = css_buffer.into_parts();
763        let mut selector_cleanup_state = if track_selector_cleanup {
764            Some(SelectorCleanupState::default())
765        } else {
766            None
767        };
768        if let (Some(state), Some(chunks)) = (&mut selector_cleanup_state, css_chunks) {
769            state.chunk_at_rules = vec![Vec::new(); chunks.len()];
770            state.chunks = chunks;
771        }
772        let mut parser = cssparser::Parser::new(&raw_styles);
773        // Allocating some memory for all the parsed declarations
774        #[allow(
775            clippy::cast_precision_loss,
776            clippy::cast_sign_loss,
777            clippy::cast_possible_truncation
778        )]
779        let mut declarations = Vec::with_capacity(
780            ((raw_styles.len() as f64 / DECLARATION_SIZE_COEFFICIENT)
781                .min(usize::MAX as f64)
782                .round() as usize)
783                .max(16),
784        );
785        let mut rule_list = Vec::with_capacity(declarations.capacity() / 3);
786        let at_rules = if self.options.keep_at_rules {
787            let mut at_rules = String::new();
788            for rule in cssparser::StyleSheetParser::new(
789                &mut parser,
790                &mut parser::AtRuleFilteringParser::new(&mut declarations, &mut at_rules),
791            )
792            .flatten()
793            {
794                if self.options.inline_style_tags {
795                    rule_list.push(rule);
796                }
797            }
798            Some(at_rules)
799        } else if !raw_styles.is_empty() {
800            // At this point, we collected some styles from at least one source, hence we need to process it.
801            // `CSSRuleListParser` rejects `@`-rules. Selector cleanup keeps them in the rewritten
802            // `<style>` blocks, so it needs each rejected rule's span.
803            let mut rule_list_parser = parser::CSSRuleListParser::new(&mut declarations);
804            let mut items = cssparser::StyleSheetParser::new(&mut parser, &mut rule_list_parser);
805            // A rejected rule's block stays pending until the parser moves on; skipping
806            // whitespace consumes it, so the rule's span ends there.
807            // The error carries the rule's start, past any whitespace and comments.
808            let mut rejected = None;
809            loop {
810                if let Some(rule) = rejected.take() {
811                    items.input.skip_whitespace();
812                    let end = items.input.position().byte_index();
813                    if let Some(state) = selector_cleanup_state.as_mut() {
814                        state.record_at_rule(&raw_styles, rule, end);
815                    }
816                }
817                match items.next() {
818                    None => break,
819                    Some(Ok(rule)) => rule_list.push(rule),
820                    Some(Err((_, rule, _))) if selector_cleanup_state.is_some() => {
821                        rejected = Some(rule);
822                    }
823                    Some(Err(_)) => {}
824                }
825            }
826            None
827        } else {
828            None
829        };
830        // Compute chunk indices for all rules once, before processing
831        let rule_chunk_indices = selector_cleanup_state
832            .as_ref()
833            .map(|state| compute_rule_chunk_indices(&rule_list, &raw_styles, &state.chunks))
834            .unwrap_or_default();
835        // Vec indexed by NodeId for O(1) access instead of hash lookups
836        let mut styles: Vec<Option<SmallVec<[_; 4]>>> = vec![None; document.nodes.len()];
837        // This cache is unused but required in the `selectors` API
838        let mut caches = SelectorCaches::default();
839        for (rule_id, (selectors, (start, end))) in rule_list.iter().enumerate() {
840            // Only CSS Syntax Level 3 is supported, therefore it is OK to split by `,`
841            // With `is` or `where` selectors (Level 4) this split should be done on the parser level
842            for selector in selectors.split(',') {
843                let mut matched_any = false;
844                // Quick check: skip selectors whose anchor doesn't exist in the document
845                // This avoids parsing selectors that can't possibly match anything
846                if !document.anchor_exists(selector) {
847                    if let Some(state) = selector_cleanup_state.as_mut() {
848                        if let Some(chunk_index) =
849                            rule_chunk_indices.get(rule_id).copied().flatten()
850                        {
851                            state.record_usage(SelectorUsage {
852                                selector,
853                                declarations: (*start, *end),
854                                rule_id,
855                                chunk_index,
856                                matched: false,
857                            });
858                        }
859                    }
860                    continue;
861                }
862                if let Ok(matching_elements) = document.select(selector, &mut caches) {
863                    let specificity = matching_elements.specificity();
864                    for matching_element in matching_elements {
865                        matched_any = true;
866                        let element_styles = styles[matching_element.node_id.get()]
867                            .get_or_insert_with(SmallVec::new);
868                        // Iterate over pairs of property name & value
869                        // Example: `padding`, `0`
870                        for (name, value) in &declarations[*start..*end] {
871                            let prop_name = name.as_ref();
872                            // Linear search for existing property
873                            if let Some(idx) =
874                                element_styles.iter().position(|(n, _, _)| *n == prop_name)
875                            {
876                                let entry: &mut (&str, Specificity, &str) =
877                                    &mut element_styles[idx];
878                                let new_important = value.trim_end().ends_with("!important");
879                                let old_important = entry.2.trim_end().ends_with("!important");
880                                match (new_important, old_important) {
881                                    // Equal importance; the higher specificity wins.
882                                    (false, false) | (true, true) => {
883                                        if entry.1 <= specificity {
884                                            entry.1 = specificity;
885                                            entry.2 = *value;
886                                        }
887                                    }
888                                    // Only the new value is important; it wins.
889                                    (true, false) => {
890                                        entry.1 = specificity;
891                                        entry.2 = *value;
892                                    }
893                                    // The old value is important and the new one is not; keep
894                                    // the old value.
895                                    (false, true) => {}
896                                }
897                            } else {
898                                element_styles.push((prop_name, specificity, *value));
899                            }
900                        }
901                    }
902                }
903                if let Some(state) = selector_cleanup_state.as_mut() {
904                    if let Some(chunk_index) = rule_chunk_indices.get(rule_id).copied().flatten() {
905                        state.record_usage(SelectorUsage {
906                            selector,
907                            declarations: (*start, *end),
908                            rule_id,
909                            chunk_index,
910                            matched: matched_any,
911                        });
912                    }
913                }
914                // Ignore not parsable selectors. E.g. there is no parser for @media queries
915                // Which means that they will fall into this category and will be ignored
916            }
917        }
918        let cleanup_requires_css = selector_cleanup_state
919            .as_ref()
920            .is_some_and(SelectorCleanupState::needs_css);
921        let keep_style_tags = self.options.keep_style_tags || cleanup_requires_css;
922        if let Some(state) = selector_cleanup_state.as_ref() {
923            apply_selector_cleanup(
924                state,
925                &mut document,
926                self.options.keep_style_tags,
927                &declarations,
928                &raw_styles,
929            );
930        }
931        document.serialize(
932            target,
933            styles,
934            keep_style_tags,
935            self.options.keep_link_tags,
936            self.options.minify_css,
937            at_rules.as_ref(),
938            mode,
939            self.options.apply_width_attributes,
940            self.options.apply_height_attributes,
941        )?;
942        Ok(())
943    }
944
945    fn get_full_url<'u>(&self, href: &'u str) -> Cow<'u, str> {
946        // Valid absolute URL
947        if Url::parse(href).is_ok() {
948            return Cow::Borrowed(href);
949        }
950        if let Some(base_url) = &self.options.base_url {
951            // Use the same scheme as the base URL
952            if href.starts_with("//") {
953                return Cow::Owned(format!("{}:{}", base_url.scheme(), href));
954            }
955            // Not a URL, then it is a relative URL
956            if let Ok(new_url) = base_url.join(href) {
957                return Cow::Owned(new_url.into());
958            }
959        }
960        // If it is not a valid URL and there is no base URL specified, we assume a local path
961        Cow::Borrowed(href)
962    }
963}
964
965impl Default for CSSInliner<'_> {
966    #[inline]
967    fn default() -> Self {
968        CSSInliner::new(InlineOptions::default())
969    }
970}
971
972/// Shortcut for inlining CSS with default parameters.
973///
974/// # Errors
975///
976/// Inlining might fail for the following reasons:
977///   - Missing stylesheet file;
978///   - Remote stylesheet is not available;
979///   - IO errors;
980///   - Internal CSS selector parsing error;
981///
982/// # Panics
983///
984/// This function may panic if external stylesheet cache lock is poisoned, i.e. another thread
985/// using the same inliner panicked while resolving external stylesheets.
986#[inline]
987pub fn inline(html: &str) -> Result<String> {
988    CSSInliner::default().inline(html)
989}
990
991/// Shortcut for inlining CSS with default parameters and writing the output to a generic writer.
992///
993/// # Errors
994///
995/// Inlining might fail for the following reasons:
996///   - Missing stylesheet file;
997///   - Remote stylesheet is not available;
998///   - IO errors;
999///   - Internal CSS selector parsing error;
1000///
1001/// # Panics
1002///
1003/// This function may panic if external stylesheet cache lock is poisoned, i.e. another thread
1004/// using the same inliner panicked while resolving external stylesheets.
1005#[inline]
1006pub fn inline_to<W: Write>(html: &str, target: &mut W) -> Result<()> {
1007    CSSInliner::default().inline_to(html, target)
1008}
1009
1010/// Shortcut for inlining CSS into an HTML fragment with default parameters.
1011///
1012/// See [`CSSInliner::inline_fragment`] for details on fragment handling.
1013///
1014/// # Errors
1015///
1016/// Inlining might fail for the following reasons:
1017///   - Missing stylesheet file;
1018///   - Remote stylesheet is not available;
1019///   - IO errors;
1020///   - Internal CSS selector parsing error;
1021///
1022/// # Panics
1023///
1024/// This function may panic if external stylesheet cache lock is poisoned, i.e. another thread
1025/// using the same inliner panicked while resolving external stylesheets.
1026#[inline]
1027pub fn inline_fragment(html: &str, css: &str) -> Result<String> {
1028    CSSInliner::default().inline_fragment(html, css)
1029}
1030
1031/// Shortcut for inlining CSS into an HTML fragment with default parameters and writing the output to a generic writer.
1032///
1033/// # Errors
1034///
1035/// Inlining might fail for the following reasons:
1036///   - Missing stylesheet file;
1037///   - Remote stylesheet is not available;
1038///   - IO errors;
1039///   - Internal CSS selector parsing error;
1040///
1041/// # Panics
1042///
1043/// This function may panic if external stylesheet cache lock is poisoned, i.e. another thread
1044/// using the same inliner panicked while resolving external stylesheets.
1045#[inline]
1046pub fn inline_fragment_to<W: Write>(html: &str, css: &str, target: &mut W) -> Result<()> {
1047    CSSInliner::default().inline_fragment_to(html, css, target)
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052    use crate::{CSSInliner, InlineOptions};
1053
1054    #[test]
1055    fn test_inliner_sync_send() {
1056        fn assert_send<T: Send + Sync>() {}
1057        assert_send::<CSSInliner<'_>>();
1058        assert_send::<InlineOptions<'_>>();
1059    }
1060}