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#[cfg(feature = "stylesheet-cache")]
49pub type StylesheetCache<S = DefaultHasher> = LruCache<String, String, S>;
50
51#[allow(clippy::struct_excessive_bools)]
53pub struct InlineOptions<'a> {
54 pub inline_style_tags: bool,
59 pub keep_style_tags: bool,
61 pub keep_link_tags: bool,
63 pub keep_at_rules: bool,
65 pub minify_css: bool,
67 pub base_url: Option<Url>,
69 pub load_remote_stylesheets: bool,
71 #[cfg(feature = "stylesheet-cache")]
73 pub cache: Option<std::sync::Mutex<StylesheetCache>>,
74 pub extra_css: Option<Cow<'a, str>>,
79 pub preallocate_node_capacity: usize,
82 pub resolver: Arc<dyn StylesheetResolver>,
84 pub remove_inlined_selectors: bool,
86}
87
88impl std::fmt::Debug for InlineOptions<'_> {
89 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
90 let mut debug = f.debug_struct("InlineOptions");
91 debug
92 .field("inline_style_tags", &self.inline_style_tags)
93 .field("keep_style_tags", &self.keep_style_tags)
94 .field("keep_link_tags", &self.keep_link_tags)
95 .field("base_url", &self.base_url)
96 .field("load_remote_stylesheets", &self.load_remote_stylesheets);
97 #[cfg(feature = "stylesheet-cache")]
98 {
99 debug.field("cache", &self.cache);
100 }
101 debug
102 .field("extra_css", &self.extra_css)
103 .field("preallocate_node_capacity", &self.preallocate_node_capacity)
104 .field("remove_inlined_selectors", &self.remove_inlined_selectors)
105 .finish_non_exhaustive()
106 }
107}
108
109#[derive(Debug)]
110struct CssChunk {
111 range: Range<usize>,
112 style_node: Option<NodeId>,
115}
116
117type SelectorList<'i> = SmallVec<[&'i str; 2]>;
118
119#[derive(Debug)]
120struct SelectorUsage<'i> {
121 selector: &'i str,
122 declarations: (usize, usize),
123 rule_id: usize,
124 chunk_index: usize,
125 matched: bool,
126}
127
128#[derive(Debug, Default)]
129struct RuleRemainder<'i> {
130 selectors: SelectorList<'i>,
131 declarations: (usize, usize),
132}
133
134#[derive(Debug, Default)]
135struct SelectorCleanupState<'i> {
136 chunks: Vec<CssChunk>,
137 usages: Vec<SelectorUsage<'i>>,
138}
139
140impl<'i> SelectorCleanupState<'i> {
141 fn record_usage(&mut self, usage: SelectorUsage<'i>) {
142 self.usages.push(usage);
143 }
144
145 fn has_unmatched(&self) -> bool {
146 self.usages.iter().any(|usage| !usage.matched)
147 }
148}
149
150fn find_chunk_index(chunks: &[CssChunk], offset: usize) -> Option<usize> {
152 chunks
153 .iter()
154 .position(|chunk| chunk.range.contains(&offset))
155}
156
157#[allow(clippy::arithmetic_side_effects)]
159fn compute_rule_chunk_indices(
160 rules: &[(&str, (usize, usize))],
161 source: &str,
162 chunks: &[CssChunk],
163) -> Vec<Option<usize>> {
164 let source_start = source.as_ptr() as usize;
165 let source_end = source_start.saturating_add(source.len());
166
167 rules
168 .iter()
169 .map(|(selectors, _)| {
170 let sel_start = selectors.as_ptr() as usize;
171 if sel_start >= source_start && sel_start < source_end {
173 let offset = sel_start.wrapping_sub(source_start);
174 find_chunk_index(chunks, offset)
175 } else {
176 None
177 }
178 })
179 .collect()
180}
181
182struct CssBuffer {
183 raw: String,
184 chunks: Option<Vec<CssChunk>>,
185}
186
187impl CssBuffer {
188 fn new(track_chunks: bool) -> Self {
189 CssBuffer {
190 raw: String::new(),
191 chunks: track_chunks.then(Vec::new),
192 }
193 }
194
195 fn push(&mut self, style_node: Option<NodeId>, content: &str, append_newline: bool) {
196 if content.is_empty() {
197 return;
198 }
199 let start = self.raw.len();
200 self.raw.push_str(content);
201 if append_newline {
202 self.raw.push('\n');
203 }
204 if let Some(chunks) = &mut self.chunks {
205 let end = self.raw.len();
206 chunks.push(CssChunk {
207 range: start..end,
208 style_node,
209 });
210 }
211 }
212
213 fn into_parts(self) -> (String, Option<Vec<CssChunk>>) {
214 (self.raw, self.chunks)
215 }
216}
217
218fn apply_selector_cleanup<'i>(
219 state: &SelectorCleanupState<'i>,
220 document: &mut Document,
221 requested_keep_style_tags: bool,
222 declarations: &[parser::Declaration<'i>],
223) {
224 if state.usages.is_empty() || state.chunks.is_empty() {
225 return;
226 }
227 rewrite_style_blocks(state, document, requested_keep_style_tags, declarations);
228}
229
230fn rewrite_style_blocks<'i>(
231 state: &SelectorCleanupState<'i>,
232 document: &mut Document,
233 requested_keep_style_tags: bool,
234 declarations: &[parser::Declaration<'i>],
235) {
236 let mut chunk_remainders: Vec<Vec<RuleRemainder<'i>>> =
237 (0..state.chunks.len()).map(|_| Vec::new()).collect();
238 let mut remainder_lookup: FxHashMap<(usize, usize), usize> = FxHashMap::default();
239
240 for usage in &state.usages {
241 if usage.matched {
242 continue;
243 }
244 let trimmed = usage.selector.trim();
245 if trimmed.is_empty() {
246 continue;
247 }
248 let key = (usage.chunk_index, usage.rule_id);
249 let entry_index = remainder_lookup.entry(key).or_insert_with(|| {
250 let idx = chunk_remainders[usage.chunk_index].len();
251 chunk_remainders[usage.chunk_index].push(RuleRemainder {
252 selectors: SelectorList::new(),
253 declarations: usage.declarations,
254 });
255 idx
256 });
257 chunk_remainders[usage.chunk_index][*entry_index]
258 .selectors
259 .push(trimmed);
260 }
261
262 for (idx, chunk) in state.chunks.iter().enumerate() {
263 let rules = &chunk_remainders[idx];
264 if rules.is_empty() {
265 handle_empty_remainder(document, chunk, requested_keep_style_tags);
266 continue;
267 }
268 let mut buffer = String::new();
269 for remainder in rules {
270 append_rule(&mut buffer, remainder, declarations);
271 }
272 if buffer.trim().is_empty() {
273 handle_empty_remainder(document, chunk, requested_keep_style_tags);
274 continue;
275 }
276 if let Some(node_id) = chunk.style_node {
277 overwrite_style_node(document, node_id, buffer.trim_end());
278 }
279 }
280}
281
282fn handle_empty_remainder(
283 document: &mut Document,
284 chunk: &CssChunk,
285 requested_keep_style_tags: bool,
286) {
287 if let Some(node_id) = chunk.style_node {
288 if requested_keep_style_tags {
289 overwrite_style_node(document, node_id, "");
290 } else {
291 document.detach_node(node_id);
292 }
293 }
294}
295
296fn append_rule<'i>(
297 buffer: &mut String,
298 remainder: &RuleRemainder<'i>,
299 declarations: &[parser::Declaration<'i>],
300) {
301 let (start, end) = remainder.declarations;
302 if start >= end || end > declarations.len() {
303 return;
304 }
305 let mut selectors_iter = remainder.selectors.iter().peekable();
306 while let Some(selector) = selectors_iter.next() {
307 buffer.push_str(selector);
308 if selectors_iter.peek().is_some() {
309 buffer.push_str(", ");
310 }
311 }
312 buffer.push_str(" {");
313 for (name, value) in &declarations[start..end] {
314 buffer.push(' ');
315 buffer.push_str(name);
316 buffer.push(':');
317 buffer.push(' ');
318 let value_trimmed = value.trim();
319 buffer.push_str(value_trimmed);
320 if !value_trimmed.ends_with(';') {
321 buffer.push(';');
322 }
323 }
324 buffer.push_str(" }\n");
325}
326
327fn overwrite_style_node(document: &mut Document, node_id: NodeId, new_css: &str) {
328 let new_css = new_css.trim();
329 if let Some(text_node_id) = document[node_id].first_child {
330 if let NodeData::Text { text } = &mut document[text_node_id].data {
331 text.clear();
332 text.push_slice(new_css);
333 }
334 }
335}
336
337impl<'a> InlineOptions<'a> {
338 #[must_use]
340 pub fn inline_style_tags(mut self, inline_style_tags: bool) -> Self {
341 self.inline_style_tags = inline_style_tags;
342 self
343 }
344
345 #[must_use]
347 pub fn keep_style_tags(mut self, keep_style_tags: bool) -> Self {
348 self.keep_style_tags = keep_style_tags;
349 self
350 }
351
352 #[must_use]
354 pub fn keep_link_tags(mut self, keep_link_tags: bool) -> Self {
355 self.keep_link_tags = keep_link_tags;
356 self
357 }
358
359 #[must_use]
361 pub fn keep_at_rules(mut self, keep_at_rules: bool) -> Self {
362 self.keep_at_rules = keep_at_rules;
363 self
364 }
365
366 #[must_use]
368 pub fn minify_css(mut self, minify_css: bool) -> Self {
369 self.minify_css = minify_css;
370 self
371 }
372
373 #[must_use]
375 pub fn base_url(mut self, base_url: Option<Url>) -> Self {
376 self.base_url = base_url;
377 self
378 }
379
380 #[must_use]
382 pub fn load_remote_stylesheets(mut self, load_remote_stylesheets: bool) -> Self {
383 self.load_remote_stylesheets = load_remote_stylesheets;
384 self
385 }
386
387 #[must_use]
389 #[cfg(feature = "stylesheet-cache")]
390 pub fn cache(mut self, cache: impl Into<Option<StylesheetCache>>) -> Self {
391 if let Some(cache) = cache.into() {
392 self.cache = Some(std::sync::Mutex::new(cache));
393 } else {
394 self.cache = None;
395 }
396 self
397 }
398
399 #[must_use]
401 pub fn extra_css(mut self, extra_css: Option<Cow<'a, str>>) -> Self {
402 self.extra_css = extra_css;
403 self
404 }
405
406 #[must_use]
408 pub fn preallocate_node_capacity(mut self, preallocate_node_capacity: usize) -> Self {
409 self.preallocate_node_capacity = preallocate_node_capacity;
410 self
411 }
412
413 #[must_use]
415 pub fn resolver(mut self, resolver: Arc<dyn StylesheetResolver>) -> Self {
416 self.resolver = resolver;
417 self
418 }
419
420 #[must_use]
422 pub fn remove_inlined_selectors(mut self, enabled: bool) -> Self {
423 self.remove_inlined_selectors = enabled;
424 self
425 }
426
427 #[must_use]
429 pub const fn build(self) -> CSSInliner<'a> {
430 CSSInliner::new(self)
431 }
432}
433
434impl Default for InlineOptions<'_> {
435 #[inline]
436 fn default() -> Self {
437 InlineOptions {
438 inline_style_tags: true,
439 keep_style_tags: false,
440 keep_link_tags: false,
441 keep_at_rules: false,
442 minify_css: false,
443 base_url: None,
444 load_remote_stylesheets: true,
445 #[cfg(feature = "stylesheet-cache")]
446 cache: None,
447 extra_css: None,
448 preallocate_node_capacity: 32,
449 resolver: Arc::new(DefaultStylesheetResolver),
450 remove_inlined_selectors: false,
451 }
452 }
453}
454
455pub type Result<T> = std::result::Result<T, InlineError>;
457
458#[derive(Debug)]
460pub struct CSSInliner<'a> {
461 options: InlineOptions<'a>,
462}
463
464const GROWTH_COEFFICIENT: f64 = 1.5;
465const DECLARATION_SIZE_COEFFICIENT: f64 = 30.0;
467
468fn allocate_output_buffer(html: &str) -> Vec<u8> {
469 #[allow(
471 clippy::cast_precision_loss,
472 clippy::cast_sign_loss,
473 clippy::cast_possible_truncation
474 )]
475 Vec::with_capacity(
476 (html.len() as f64 * GROWTH_COEFFICIENT)
477 .min(usize::MAX as f64)
478 .round() as usize,
479 )
480}
481
482impl<'a> CSSInliner<'a> {
483 #[must_use]
485 #[inline]
486 pub const fn new(options: InlineOptions<'a>) -> Self {
487 CSSInliner { options }
488 }
489
490 #[must_use]
509 #[inline]
510 pub fn options() -> InlineOptions<'a> {
511 InlineOptions::default()
512 }
513
514 #[inline]
530 pub fn inline(&self, html: &str) -> Result<String> {
531 let mut out = allocate_output_buffer(html);
532 self.inline_to(html, &mut out)?;
533 Ok(String::from_utf8_lossy(&out).to_string())
534 }
535
536 #[inline]
552 pub fn inline_to<W: Write>(&self, html: &str, target: &mut W) -> Result<()> {
553 self.inline_to_impl(html, None, target, InliningMode::Document)
554 }
555
556 pub fn inline_fragment(&self, html: &str, css: &str) -> Result<String> {
571 let mut out = allocate_output_buffer(html);
572 self.inline_fragment_to(html, css, &mut out)?;
573 Ok(String::from_utf8_lossy(&out).to_string())
574 }
575
576 pub fn inline_fragment_to<W: Write>(
591 &self,
592 html: &str,
593 css: &str,
594 target: &mut W,
595 ) -> Result<()> {
596 self.inline_to_impl(html, Some(css), target, InliningMode::Fragment)
597 }
598
599 #[allow(clippy::too_many_lines)]
600 fn inline_to_impl<W: Write>(
601 &self,
602 html: &str,
603 css: Option<&str>,
604 target: &mut W,
605 mode: InliningMode,
606 ) -> Result<()> {
607 let mut document = Document::parse_with_options(
608 html.as_bytes(),
609 self.options.preallocate_node_capacity,
610 mode,
611 );
612 let track_selector_cleanup = self.options.remove_inlined_selectors;
620 let mut size_estimate: usize = if self.options.inline_style_tags {
621 document
622 .styles()
623 .map(|(_, s)| {
624 s.len().saturating_add(1)
626 })
627 .sum()
628 } else {
629 0
630 };
631 if let Some(extra_css) = &self.options.extra_css {
632 size_estimate = size_estimate.saturating_add(extra_css.len());
633 }
634 if let Some(css) = css {
635 size_estimate = size_estimate.saturating_add(css.len());
636 }
637 let mut css_buffer = CssBuffer::new(track_selector_cleanup);
638 css_buffer.raw.reserve(size_estimate);
639 if self.options.inline_style_tags || self.options.keep_at_rules {
640 for (node_id, style) in document.styles() {
641 let style_node = track_selector_cleanup.then_some(node_id);
642 css_buffer.push(style_node, style, true);
643 }
644 }
645 if self.options.load_remote_stylesheets {
646 let mut links = document.stylesheets().collect::<Vec<&str>>();
647 links.sort_unstable();
648 links.dedup();
649 for href in &links {
650 let url = self.get_full_url(href);
651 #[cfg(feature = "stylesheet-cache")]
652 if let Some(lock) = self.options.cache.as_ref() {
653 let mut cache = lock.lock().expect("Cache lock is poisoned");
654 if let Some(cached) = cache.get(url.as_ref()) {
655 css_buffer.push(None, cached, true);
656 continue;
657 }
658 }
659
660 let css = self.options.resolver.retrieve(url.as_ref())?;
661 css_buffer.push(None, &css, true);
662
663 #[cfg(feature = "stylesheet-cache")]
664 if let Some(lock) = self.options.cache.as_ref() {
665 let mut cache = lock.lock().expect("Cache lock is poisoned");
666 cache.put(url.into_owned(), css);
667 }
668 }
669 }
670 if let Some(extra_css) = &self.options.extra_css {
671 css_buffer.push(None, extra_css, false);
672 }
673 if let Some(css) = css {
674 css_buffer.push(None, css, false);
675 }
676 let (raw_styles, css_chunks) = css_buffer.into_parts();
677 let mut selector_cleanup_state = if track_selector_cleanup {
678 Some(SelectorCleanupState::default())
679 } else {
680 None
681 };
682 if let (Some(state), Some(chunks)) = (&mut selector_cleanup_state, css_chunks) {
683 state.chunks = chunks;
684 }
685 let mut parse_input = cssparser::ParserInput::new(&raw_styles);
686 let mut parser = cssparser::Parser::new(&mut parse_input);
687 #[allow(
689 clippy::cast_precision_loss,
690 clippy::cast_sign_loss,
691 clippy::cast_possible_truncation
692 )]
693 let mut declarations = Vec::with_capacity(
694 ((raw_styles.len() as f64 / DECLARATION_SIZE_COEFFICIENT)
695 .min(usize::MAX as f64)
696 .round() as usize)
697 .max(16),
698 );
699 let mut rule_list = Vec::with_capacity(declarations.capacity() / 3);
700 let at_rules = if self.options.keep_at_rules {
701 let mut at_rules = String::new();
702 for rule in cssparser::StyleSheetParser::new(
703 &mut parser,
704 &mut parser::AtRuleFilteringParser::new(&mut declarations, &mut at_rules),
705 )
706 .flatten()
707 {
708 if self.options.inline_style_tags {
709 rule_list.push(rule);
710 }
711 }
712 Some(at_rules)
713 } else if !raw_styles.is_empty() {
714 for rule in cssparser::StyleSheetParser::new(
716 &mut parser,
717 &mut parser::CSSRuleListParser::new(&mut declarations),
718 )
719 .flatten()
720 {
721 rule_list.push(rule);
722 }
723 None
724 } else {
725 None
726 };
727 let rule_chunk_indices = selector_cleanup_state
729 .as_ref()
730 .map(|state| compute_rule_chunk_indices(&rule_list, &raw_styles, &state.chunks))
731 .unwrap_or_default();
732 let mut styles: Vec<Option<SmallVec<[_; 4]>>> = vec![None; document.nodes.len()];
734 let mut caches = SelectorCaches::default();
736 for (rule_id, (selectors, (start, end))) in rule_list.iter().enumerate() {
737 for selector in selectors.split(',') {
740 let mut matched_any = false;
741 if let Ok(matching_elements) = document.select(selector, &mut caches) {
742 let specificity = matching_elements.specificity();
743 for matching_element in matching_elements {
744 matched_any = true;
745 let element_styles = styles[matching_element.node_id.get()]
746 .get_or_insert_with(SmallVec::new);
747 for (name, value) in &declarations[*start..*end] {
750 let prop_name = name.as_ref();
751 if let Some(idx) =
753 element_styles.iter().position(|(n, _, _)| *n == prop_name)
754 {
755 let entry: &mut (&str, Specificity, &str) =
756 &mut element_styles[idx];
757 let new_important = value.trim_end().ends_with("!important");
758 let old_important = entry.2.trim_end().ends_with("!important");
759 match (new_important, old_important) {
760 (false, false) | (true, true) => {
762 if entry.1 <= specificity {
763 entry.1 = specificity;
764 entry.2 = *value;
765 }
766 }
767 (true, false) => {
769 entry.1 = specificity;
770 entry.2 = *value;
771 }
772 (false, true) => {}
775 }
776 } else {
777 element_styles.push((prop_name, specificity, *value));
778 }
779 }
780 }
781 }
782 if let Some(state) = selector_cleanup_state.as_mut() {
783 if let Some(chunk_index) = rule_chunk_indices.get(rule_id).copied().flatten() {
784 state.record_usage(SelectorUsage {
785 selector,
786 declarations: (*start, *end),
787 rule_id,
788 chunk_index,
789 matched: matched_any,
790 });
791 }
792 }
793 }
796 }
797 let cleanup_requires_css = selector_cleanup_state
798 .as_ref()
799 .is_some_and(SelectorCleanupState::has_unmatched);
800 let keep_style_tags = self.options.keep_style_tags || cleanup_requires_css;
801 if let Some(state) = selector_cleanup_state.as_ref() {
802 apply_selector_cleanup(
803 state,
804 &mut document,
805 self.options.keep_style_tags,
806 &declarations,
807 );
808 }
809 document.serialize(
810 target,
811 styles,
812 keep_style_tags,
813 self.options.keep_link_tags,
814 self.options.minify_css,
815 at_rules.as_ref(),
816 mode,
817 )?;
818 Ok(())
819 }
820
821 fn get_full_url<'u>(&self, href: &'u str) -> Cow<'u, str> {
822 if Url::parse(href).is_ok() {
824 return Cow::Borrowed(href);
825 }
826 if let Some(base_url) = &self.options.base_url {
827 if href.starts_with("//") {
829 return Cow::Owned(format!("{}:{}", base_url.scheme(), href));
830 }
831 if let Ok(new_url) = base_url.join(href) {
833 return Cow::Owned(new_url.into());
834 }
835 }
836 Cow::Borrowed(href)
838 }
839}
840
841impl Default for CSSInliner<'_> {
842 #[inline]
843 fn default() -> Self {
844 CSSInliner::new(InlineOptions::default())
845 }
846}
847
848#[inline]
863pub fn inline(html: &str) -> Result<String> {
864 CSSInliner::default().inline(html)
865}
866
867#[inline]
882pub fn inline_to<W: Write>(html: &str, target: &mut W) -> Result<()> {
883 CSSInliner::default().inline_to(html, target)
884}
885
886#[inline]
901pub fn inline_fragment(html: &str, css: &str) -> Result<String> {
902 CSSInliner::default().inline_fragment(html, css)
903}
904
905#[inline]
920pub fn inline_fragment_to<W: Write>(html: &str, css: &str, target: &mut W) -> Result<()> {
921 CSSInliner::default().inline_fragment_to(html, css, target)
922}
923
924#[cfg(test)]
925mod tests {
926 use crate::{CSSInliner, InlineOptions};
927
928 #[test]
929 fn test_inliner_sync_send() {
930 fn assert_send<T: Send + Sync>() {}
931 assert_send::<CSSInliner<'_>>();
932 assert_send::<InlineOptions<'_>>();
933 }
934}