#![doc = include_str!("../README.md")]
#![warn(
clippy::doc_markdown,
clippy::redundant_closure,
clippy::explicit_iter_loop,
clippy::match_same_arms,
clippy::needless_borrow,
clippy::print_stdout,
clippy::integer_arithmetic,
clippy::cast_possible_truncation,
clippy::unwrap_used,
clippy::map_unwrap_or,
clippy::trivially_copy_pass_by_ref,
clippy::needless_pass_by_value,
missing_docs,
missing_debug_implementations,
trivial_casts,
trivial_numeric_casts,
unreachable_pub,
unused_extern_crates,
unused_import_braces,
unused_qualifications,
variant_size_differences,
rust_2018_idioms,
rust_2018_compatibility
)]
use kuchiki::{
parse_html, traits::TendrilSink, ElementData, Node, NodeDataRef, NodeRef, Specificity,
};
pub mod error;
mod parser;
pub use error::InlineError;
use indexmap::IndexMap;
use smallvec::{smallvec, SmallVec};
use std::{
borrow::Cow,
io::{ErrorKind, Write},
};
pub use url::{ParseError, Url};
macro_rules! replace_double_quotes {
($target:expr, $name:expr, $value:expr) => {
if $name.starts_with("font-family") && memchr::memchr(b'"', $value.as_bytes()).is_some() {
$target.push_str(&$value.replace('"', "\'"))
} else {
$target.push_str($value)
};
};
}
#[derive(Debug)]
pub struct InlineOptions<'a> {
pub inline_style_tags: bool,
pub remove_style_tags: bool,
pub base_url: Option<Url>,
pub load_remote_stylesheets: bool,
pub extra_css: Option<Cow<'a, str>>,
}
impl<'a> InlineOptions<'a> {
#[must_use]
#[inline]
pub const fn compact() -> Self {
InlineOptions {
inline_style_tags: true,
remove_style_tags: true,
base_url: None,
load_remote_stylesheets: true,
extra_css: None,
}
}
#[must_use]
pub fn inline_style_tags(mut self, inline_style_tags: bool) -> Self {
self.inline_style_tags = inline_style_tags;
self
}
#[must_use]
pub fn remove_style_tags(mut self, remove_style_tags: bool) -> Self {
self.remove_style_tags = remove_style_tags;
self
}
#[must_use]
pub fn base_url(mut self, base_url: Option<Url>) -> Self {
self.base_url = base_url;
self
}
#[must_use]
pub fn load_remote_stylesheets(mut self, load_remote_stylesheets: bool) -> Self {
self.load_remote_stylesheets = load_remote_stylesheets;
self
}
#[must_use]
pub fn extra_css(mut self, extra_css: Option<Cow<'a, str>>) -> Self {
self.extra_css = extra_css;
self
}
#[must_use]
pub const fn build(self) -> CSSInliner<'a> {
CSSInliner::new(self)
}
}
impl Default for InlineOptions<'_> {
#[inline]
fn default() -> Self {
InlineOptions {
inline_style_tags: true,
remove_style_tags: false,
base_url: None,
load_remote_stylesheets: true,
extra_css: None,
}
}
}
type Result<T> = std::result::Result<T, InlineError>;
const CSS_INLINE_ATTRIBUTE: &str = "data-css-inline";
#[derive(Debug)]
pub struct CSSInliner<'a> {
options: InlineOptions<'a>,
}
impl<'a> CSSInliner<'a> {
#[must_use]
#[inline]
pub const fn new(options: InlineOptions<'a>) -> Self {
CSSInliner { options }
}
#[must_use]
#[inline]
pub fn options() -> InlineOptions<'a> {
InlineOptions::default()
}
#[must_use]
#[inline]
pub const fn compact() -> Self {
CSSInliner {
options: InlineOptions::compact(),
}
}
#[inline]
pub fn inline(&self, html: &str) -> Result<String> {
let mut out = Vec::with_capacity(html.len().saturating_mul(2));
self.inline_to(html, &mut out)?;
Ok(String::from_utf8_lossy(&out).to_string())
}
#[inline]
pub fn inline_to<W: Write>(&self, html: &str, target: &mut W) -> Result<()> {
let document = parse_html().one(html);
#[allow(clippy::mutable_key_type)]
let mut styles = IndexMap::with_capacity(128);
let mut style_tags: SmallVec<[NodeDataRef<ElementData>; 4]> = smallvec![];
if self.options.inline_style_tags {
for style_tag in document
.select("style")
.map_err(|_| InlineError::ParseError(Cow::from("Unknown error")))?
{
if style_tag.attributes.borrow().get(CSS_INLINE_ATTRIBUTE) == Some("ignore") {
continue;
}
if let Some(first_child) = style_tag.as_node().first_child() {
if let Some(css_cell) = first_child.as_text() {
process_css(&document, css_cell.borrow().as_str(), &mut styles);
}
}
if self.options.remove_style_tags {
style_tags.push(style_tag);
}
}
}
if self.options.remove_style_tags {
if !self.options.inline_style_tags {
style_tags.extend(
document
.select("style")
.map_err(|_| error::InlineError::ParseError(Cow::from("Unknown error")))?,
);
}
for style_tag in &style_tags {
style_tag.as_node().detach();
}
}
if self.options.load_remote_stylesheets {
let mut links = document
.select("link[rel~=stylesheet]")
.map_err(|_| error::InlineError::ParseError(Cow::from("Unknown error")))?
.filter_map(|link_tag| {
if link_tag.attributes.borrow().get(CSS_INLINE_ATTRIBUTE) == Some("ignore") {
None
} else {
link_tag.attributes.borrow().get("href").map(str::to_string)
}
})
.filter(|link| !link.is_empty())
.collect::<Vec<String>>();
links.sort_unstable();
links.dedup();
for href in &links {
let url = self.get_full_url(href);
let css = load_external(url.as_ref())?;
process_css(&document, css.as_str(), &mut styles);
}
}
if let Some(extra_css) = &self.options.extra_css {
process_css(&document, extra_css, &mut styles);
}
for (node_id, styles) in styles {
let node = unsafe { &*node_id };
if let Ok(mut attributes) = node
.as_element()
.expect("Element is expected")
.attributes
.try_borrow_mut()
{
if attributes.get(CSS_INLINE_ATTRIBUTE) == Some("ignore") {
continue;
}
if let Some(existing_style) = attributes.get_mut("style") {
*existing_style = merge_styles(existing_style, &styles)?;
} else {
let mut final_styles = String::with_capacity(128);
let mut styles = styles.iter().collect::<Vec<_>>();
styles.sort_unstable_by(|(_, (a, _)), (_, (b, _))| a.cmp(b));
for (name, (_, value)) in styles {
final_styles.push_str(name.as_str());
final_styles.push(':');
replace_double_quotes!(final_styles, name, value);
final_styles.push(';');
}
attributes.insert("style", final_styles);
};
}
}
document.serialize(target)?;
Ok(())
}
fn get_full_url<'u>(&self, href: &'u str) -> Cow<'u, str> {
if Url::parse(href).is_ok() {
return Cow::Borrowed(href);
};
if let Some(base_url) = &self.options.base_url {
if href.starts_with("//") {
return Cow::Owned(format!("{}:{}", base_url.scheme(), href));
}
if let Ok(new_url) = base_url.join(href) {
return Cow::Owned(new_url.into());
}
};
Cow::Borrowed(href)
}
}
fn load_external(mut location: &str) -> Result<String> {
if location.starts_with("https") | location.starts_with("http") {
#[cfg(feature = "http")]
{
let request = attohttpc::RequestBuilder::try_new(attohttpc::Method::GET, location)?;
let response = request.send()?;
Ok(response.text()?)
}
#[cfg(not(feature = "http"))]
{
Err(InlineError::IO(std::io::Error::new(
ErrorKind::Unsupported,
"Loading external URLs requires the `http` feature",
)))
}
} else {
#[cfg(feature = "file")]
{
location = location.trim_start_matches("file://");
std::fs::read_to_string(location).map_err(|error| match error.kind() {
ErrorKind::NotFound => InlineError::MissingStyleSheet {
path: location.to_string(),
},
_ => InlineError::IO(error),
})
}
#[cfg(not(feature = "file"))]
{
Err(InlineError::IO(std::io::Error::new(
ErrorKind::Unsupported,
"Loading local files requires the `file` feature",
)))
}
}
}
type NodeId = *const Node;
#[allow(clippy::mutable_key_type)]
fn process_css(
document: &NodeRef,
css: &str,
styles: &mut IndexMap<NodeId, IndexMap<String, (Specificity, String)>>,
) {
let mut parse_input = cssparser::ParserInput::new(css);
let mut parser = cssparser::Parser::new(&mut parse_input);
let rule_list =
cssparser::RuleListParser::new_for_stylesheet(&mut parser, parser::CSSRuleListParser);
for (selectors, declarations) in rule_list.flatten() {
for selector in selectors.split(',') {
if let Ok(matching_elements) = document.select(selector) {
let specificity = matching_elements.selectors.0[0].specificity();
for matching_element in matching_elements {
let element_styles = styles
.entry(&**matching_element.as_node())
.or_insert_with(|| IndexMap::with_capacity(8));
for (name, value) in &declarations {
match element_styles.entry(name.to_string()) {
indexmap::map::Entry::Occupied(mut entry) => {
if entry.get().0 <= specificity {
entry.insert((specificity, (*value).to_string()));
}
}
indexmap::map::Entry::Vacant(entry) => {
entry.insert((specificity, (*value).to_string()));
}
}
}
}
}
}
}
}
impl Default for CSSInliner<'_> {
#[inline]
fn default() -> Self {
CSSInliner::new(InlineOptions::default())
}
}
#[inline]
pub fn inline(html: &str) -> Result<String> {
CSSInliner::default().inline(html)
}
#[inline]
pub fn inline_to<W: Write>(html: &str, target: &mut W) -> Result<()> {
CSSInliner::default().inline_to(html, target)
}
fn merge_styles(
existing_style: &str,
new_styles: &IndexMap<String, (Specificity, String)>,
) -> Result<String> {
let mut input = cssparser::ParserInput::new(existing_style);
let mut parser = cssparser::Parser::new(&mut input);
let declarations =
cssparser::DeclarationListParser::new(&mut parser, parser::CSSDeclarationListParser);
let mut buffer: SmallVec<[String; 8]> = smallvec![];
let mut final_styles: Vec<String> = Vec::new();
for declaration in declarations {
let (name, value) = declaration?;
let mut style = String::with_capacity(256);
style.push_str(&name);
style.push_str(": ");
replace_double_quotes!(style, name, value.trim());
final_styles.push(style);
buffer.push(name.to_string());
}
let mut new_styles = new_styles.iter().collect::<Vec<_>>();
new_styles.sort_unstable_by(|(_, (a, _)), (_, (b, _))| a.cmp(b));
for (property, (_, value)) in new_styles {
match (
value.strip_suffix("!important"),
buffer.iter().position(|r| r == property),
) {
#[allow(clippy::integer_arithmetic)]
(Some(value), Some(index)) => {
let target = &mut final_styles[index];
target.truncate(property.len() + 2);
target.push_str(value.trim());
}
(Some(value), None) => final_styles.push(format!("{}: {}", property, value.trim())),
(None, None) => final_styles.push(format!("{}: {}", property, value.trim())),
(None, Some(_)) => {}
}
}
Ok(final_styles.join(";"))
}