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