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