828 lines
27 KiB
Rust
828 lines
27 KiB
Rust
use regex::Regex;
|
|
use rusqlite::types::{FromSql, FromSqlResult, ToSql, ToSqlOutput, ValueRef};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashSet;
|
|
use std::sync::LazyLock;
|
|
|
|
static HEX_COLOR_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r#"#[0-9a-fA-F]{3,8}\b"#).unwrap());
|
|
|
|
static RGB_COLOR_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r#"rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)"#).unwrap());
|
|
|
|
static RGBA_COLOR_RE: LazyLock<Regex> = LazyLock::new(|| {
|
|
Regex::new(r#"rgba\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([0-9]*\.?[0-9]+%?)\s*\)"#)
|
|
.unwrap()
|
|
});
|
|
|
|
static RGBA_MODERN_RE: LazyLock<Regex> = LazyLock::new(|| {
|
|
Regex::new(r#"rgb\s*\(\s*(\d+)\s+(\d+)\s+(\d+)\s*/\s*([0-9]*\.?[0-9]+%?)\s*\)"#).unwrap()
|
|
});
|
|
|
|
static STYLE_TAG_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r#"(?is)<style[^>]*>(.*?)</style>"#).unwrap());
|
|
|
|
static STYLE_ATTR_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r#"(?i)style\s*=\s*(?:"([^"]+)"|'([^']+)')"#).unwrap());
|
|
|
|
static BGCOLOR_ATTR_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r#"(?i)bgcolor\s*=\s*["']([^"']+)["']"#).unwrap());
|
|
|
|
static DARK_MEDIA_QUERY_RE: LazyLock<Regex> = LazyLock::new(|| {
|
|
Regex::new(r#"(?is)@media[^{]*prefers-color-scheme\s*:\s*dark[^{]*\{(.+?\})\s*\}"#).unwrap()
|
|
});
|
|
|
|
static BODY_CSS_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r#"(?i)body\s*\{([^}]+)\}"#).unwrap());
|
|
|
|
static CLASS_CSS_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r#"(?i)\.[a-zA-Z][a-zA-Z0-9_-]*\s*\{([^}]+)\}"#).unwrap());
|
|
|
|
static ELEM_CSS_RE: LazyLock<Regex> = LazyLock::new(|| {
|
|
Regex::new(r#"(?i)(body|table|td|div)\s*(?:,\s*#\w+)?\s*\{([^}]+)\}"#).unwrap()
|
|
});
|
|
|
|
static BG_PROP_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r#"(?i)background(?:-color)?\s*:\s*([^;}"']+)"#).unwrap());
|
|
|
|
static BODY_TAG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"<body\b[^>]*>"#).unwrap());
|
|
|
|
static TABLE_TAG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"<table\b[^>]*>"#).unwrap());
|
|
|
|
static TD_TAG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"<td\b[^>]*>"#).unwrap());
|
|
|
|
static DIV_TAG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"<div\b[^>]*>"#).unwrap());
|
|
|
|
static NAMED_COLOR_REGEXES: LazyLock<Vec<(&'static str, Regex)>> = LazyLock::new(|| {
|
|
NAMED_COLORS
|
|
.iter()
|
|
.map(|(name, _)| {
|
|
let pattern = format!(r#"(?i)\b{name}\b"#);
|
|
(*name, Regex::new(&pattern).unwrap())
|
|
})
|
|
.collect()
|
|
});
|
|
|
|
type NamedColorList = Vec<(&'static str, (u8, u8, u8))>;
|
|
static NAMED_COLORS: LazyLock<NamedColorList> = LazyLock::new(|| {
|
|
vec![
|
|
("black", (0, 0, 0)),
|
|
("white", (255, 255, 255)),
|
|
("red", (255, 0, 0)),
|
|
("green", (0, 128, 0)),
|
|
("blue", (0, 0, 255)),
|
|
("yellow", (255, 255, 0)),
|
|
("navy", (0, 0, 128)),
|
|
("gray", (128, 128, 128)),
|
|
("grey", (128, 128, 128)),
|
|
("silver", (192, 192, 192)),
|
|
("maroon", (128, 0, 0)),
|
|
("purple", (128, 0, 128)),
|
|
("teal", (0, 128, 128)),
|
|
("olive", (128, 128, 0)),
|
|
("aqua", (0, 255, 255)),
|
|
("fuchsia", (255, 0, 255)),
|
|
("lime", (0, 255, 0)),
|
|
("darkgray", (169, 169, 169)),
|
|
("darkgrey", (169, 169, 169)),
|
|
("lightgray", (211, 211, 211)),
|
|
("lightgrey", (211, 211, 211)),
|
|
("whitesmoke", (245, 245, 245)),
|
|
("ghostwhite", (248, 248, 255)),
|
|
("floralwhite", (255, 250, 240)),
|
|
("linen", (250, 240, 230)),
|
|
("cornsilk", (255, 248, 220)),
|
|
("gainsboro", (220, 220, 220)),
|
|
]
|
|
});
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum EmailTheme {
|
|
Light = 0,
|
|
Dark = 1,
|
|
Transparent = 2,
|
|
Adaptive = 3,
|
|
}
|
|
|
|
impl EmailTheme {
|
|
pub fn as_i32(self) -> i32 {
|
|
self as i32
|
|
}
|
|
}
|
|
|
|
impl FromSql for EmailTheme {
|
|
fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
|
|
match value.as_i64()? {
|
|
0 => Ok(EmailTheme::Light),
|
|
1 => Ok(EmailTheme::Dark),
|
|
2 => Ok(EmailTheme::Transparent),
|
|
3 => Ok(EmailTheme::Adaptive),
|
|
_ => Err(rusqlite::types::FromSqlError::InvalidType),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ToSql for EmailTheme {
|
|
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
|
|
Ok(ToSqlOutput::from(*self as i32))
|
|
}
|
|
}
|
|
|
|
pub fn detect_email_theme(html: &str) -> Option<EmailTheme> {
|
|
if html.trim().is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let decoded = decode_quoted_printable(html);
|
|
|
|
if has_dark_mode_media_query(&decoded) {
|
|
return Some(EmailTheme::Adaptive);
|
|
}
|
|
|
|
let backgrounds = extract_background_colors(&decoded);
|
|
|
|
if backgrounds.is_empty() {
|
|
return Some(EmailTheme::Transparent);
|
|
}
|
|
|
|
classify_by_luminance_groups(&backgrounds)
|
|
}
|
|
|
|
fn has_dark_mode_media_query(html: &str) -> bool {
|
|
let lower = html.to_lowercase();
|
|
for cap in DARK_MEDIA_QUERY_RE.captures_iter(&lower) {
|
|
let block = &cap[1];
|
|
if block.contains("background") {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
fn classify_by_luminance_groups(colors: &[BackgroundColor]) -> Option<EmailTheme> {
|
|
let mut light_weight: f32 = 0.0;
|
|
let mut dark_weight: f32 = 0.0;
|
|
let mut total_weight: f32 = 0.0;
|
|
let mut has_body_background = false;
|
|
|
|
for bg in colors {
|
|
let luminance = calculate_luminance(&bg.color);
|
|
total_weight += bg.element_weight;
|
|
if bg.element_weight >= 1.0 {
|
|
has_body_background = true;
|
|
}
|
|
if luminance < 0.5 {
|
|
dark_weight += bg.element_weight;
|
|
} else {
|
|
light_weight += bg.element_weight;
|
|
}
|
|
}
|
|
|
|
if total_weight == 0.0 {
|
|
return Some(EmailTheme::Transparent);
|
|
}
|
|
|
|
let light_coverage = light_weight / total_weight;
|
|
let dark_coverage = dark_weight / total_weight;
|
|
|
|
if light_coverage >= 0.5 {
|
|
if !has_body_background && is_only_white_wrapper(colors) {
|
|
return Some(EmailTheme::Transparent);
|
|
}
|
|
Some(EmailTheme::Light)
|
|
} else if dark_coverage >= 0.5 {
|
|
if total_weight <= 1.0 && colors.len() <= 2 && !has_body_background {
|
|
Some(EmailTheme::Transparent)
|
|
} else {
|
|
Some(EmailTheme::Dark)
|
|
}
|
|
} else {
|
|
Some(EmailTheme::Transparent)
|
|
}
|
|
}
|
|
|
|
fn is_only_white_wrapper(colors: &[BackgroundColor]) -> bool {
|
|
if colors.is_empty() {
|
|
return false;
|
|
}
|
|
for bg in colors {
|
|
if bg.element_weight >= 1.0 {
|
|
return false;
|
|
}
|
|
|
|
let c = bg.color.trim().to_lowercase();
|
|
if c != "#ffffff" && c != "white" {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
fn decode_quoted_printable(html: &str) -> String {
|
|
let mut result = html.to_string();
|
|
result = result.replace("=3D", "=");
|
|
result = result.replace("=3d", "=");
|
|
result = result.replace("=20", " ");
|
|
result = result.replace("=\r\n", "");
|
|
result = result.replace("=\n", "");
|
|
result
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct BackgroundColor {
|
|
color: String,
|
|
element_weight: f32,
|
|
}
|
|
|
|
fn extract_background_colors(html: &str) -> Vec<BackgroundColor> {
|
|
let mut colors = Vec::new();
|
|
|
|
extract_inline_style_colors(html, &mut colors);
|
|
extract_style_tag_colors(html, &mut colors);
|
|
extract_bgcolor_attrs(html, &mut colors);
|
|
|
|
deduplicate_colors(&mut colors);
|
|
|
|
colors
|
|
}
|
|
|
|
fn deduplicate_colors(colors: &mut Vec<BackgroundColor>) {
|
|
if colors.len() <= 1 {
|
|
return;
|
|
}
|
|
|
|
let mut seen: HashSet<String> = HashSet::new();
|
|
colors.retain(|bg| {
|
|
let key = format!("{}:{:.1}", bg.color, bg.element_weight);
|
|
seen.insert(key)
|
|
});
|
|
}
|
|
|
|
fn extract_inline_style_colors(html: &str, colors: &mut Vec<BackgroundColor>) {
|
|
let element_weights: &[(&LazyLock<Regex>, f32)] = &[
|
|
(&BODY_TAG_RE, 1.0),
|
|
(&TABLE_TAG_RE, 0.8),
|
|
(&TD_TAG_RE, 0.6),
|
|
(&DIV_TAG_RE, 0.4),
|
|
];
|
|
|
|
for (tag_re, weight) in element_weights {
|
|
for m in tag_re.find_iter(html) {
|
|
let tag_content = m.as_str();
|
|
let extracted = extract_colors_from_style(tag_content);
|
|
for ec in extracted {
|
|
if ec.alpha < 0.1 {
|
|
continue;
|
|
}
|
|
if !is_transparent(&ec.color) {
|
|
let normalized = normalize_color(&ec.color);
|
|
if !normalized.is_empty() {
|
|
colors.push(BackgroundColor {
|
|
color: normalized,
|
|
element_weight: *weight * ec.alpha,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn extract_style_tag_colors(html: &str, colors: &mut Vec<BackgroundColor>) {
|
|
for cap in STYLE_TAG_RE.captures_iter(html) {
|
|
let style_content = &cap[1];
|
|
|
|
let lower = style_content.to_lowercase();
|
|
if !lower.contains("background") {
|
|
continue;
|
|
}
|
|
|
|
if let Some(body_cap) = BODY_CSS_RE.captures(style_content) {
|
|
let body_styles = &body_cap[1];
|
|
if body_styles.to_lowercase().contains("background") {
|
|
extract_all_colors_from_css(body_styles, 1.0, colors);
|
|
}
|
|
}
|
|
|
|
for class_cap in CLASS_CSS_RE.captures_iter(style_content) {
|
|
let class_styles = &class_cap[1];
|
|
if class_styles.to_lowercase().contains("background") {
|
|
extract_all_colors_from_css(class_styles, 0.3, colors);
|
|
}
|
|
}
|
|
|
|
for elem_cap in ELEM_CSS_RE.captures_iter(style_content) {
|
|
let tag = elem_cap[1].to_lowercase();
|
|
let elem_styles = &elem_cap[2];
|
|
if elem_styles.to_lowercase().contains("background") {
|
|
let weight = match tag.as_str() {
|
|
"body" => 1.0,
|
|
"table" => 0.8,
|
|
"td" => 0.6,
|
|
"div" => 0.4,
|
|
_ => 0.3,
|
|
};
|
|
extract_all_colors_from_css(elem_styles, weight, colors);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn extract_all_colors_from_css(css: &str, weight: f32, colors: &mut Vec<BackgroundColor>) {
|
|
let lower = css.to_lowercase();
|
|
|
|
for bg_cap in BG_PROP_RE.captures_iter(&lower) {
|
|
let bg_value = bg_cap[1].trim();
|
|
|
|
for hex_cap in HEX_COLOR_RE.captures_iter(bg_value) {
|
|
let color = normalize_color(&hex_cap[0]);
|
|
if !is_transparent(&color) && !color.is_empty() {
|
|
colors.push(BackgroundColor {
|
|
color,
|
|
element_weight: weight,
|
|
});
|
|
}
|
|
}
|
|
|
|
for rgb_cap in RGB_COLOR_RE.captures_iter(bg_value) {
|
|
let color = format!("rgb({}, {}, {})", &rgb_cap[1], &rgb_cap[2], &rgb_cap[3]);
|
|
if !is_transparent(&color) {
|
|
colors.push(BackgroundColor {
|
|
color,
|
|
element_weight: weight,
|
|
});
|
|
}
|
|
}
|
|
|
|
for rgba_cap in RGBA_COLOR_RE.captures_iter(bg_value) {
|
|
let alpha = parse_alpha_str(&rgba_cap[4]);
|
|
if alpha < 0.1 {
|
|
continue;
|
|
}
|
|
let color = format!("rgb({}, {}, {})", &rgba_cap[1], &rgba_cap[2], &rgba_cap[3]);
|
|
if !is_transparent(&color) {
|
|
colors.push(BackgroundColor {
|
|
color,
|
|
element_weight: weight * alpha,
|
|
});
|
|
}
|
|
}
|
|
|
|
for rgba_cap in RGBA_MODERN_RE.captures_iter(bg_value) {
|
|
let alpha = parse_alpha_str(&rgba_cap[4]);
|
|
if alpha < 0.1 {
|
|
continue;
|
|
}
|
|
let color = format!("rgb({}, {}, {})", &rgba_cap[1], &rgba_cap[2], &rgba_cap[3]);
|
|
if !is_transparent(&color) {
|
|
colors.push(BackgroundColor {
|
|
color,
|
|
element_weight: weight * alpha,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (name, re) in NAMED_COLOR_REGEXES.iter() {
|
|
if re.is_match(bg_value) {
|
|
colors.push(BackgroundColor {
|
|
color: name.to_string(),
|
|
element_weight: weight,
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn extract_bgcolor_attrs(html: &str, colors: &mut Vec<BackgroundColor>) {
|
|
let element_weights: &[(&LazyLock<Regex>, f32)] =
|
|
&[(&BODY_TAG_RE, 1.0), (&TABLE_TAG_RE, 0.8), (&TD_TAG_RE, 0.6)];
|
|
|
|
for (tag_re, weight) in element_weights {
|
|
for m in tag_re.find_iter(html) {
|
|
let tag_content = m.as_str();
|
|
if let Some(cap) = BGCOLOR_ATTR_RE.captures(tag_content) {
|
|
let color = cap[1].to_string();
|
|
if !is_transparent(&color) {
|
|
let normalized = normalize_color(&color);
|
|
if !normalized.is_empty() {
|
|
colors.push(BackgroundColor {
|
|
color: normalized,
|
|
element_weight: *weight,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ExtractedColor {
|
|
color: String,
|
|
alpha: f32,
|
|
}
|
|
|
|
fn extract_colors_from_style(tag_content: &str) -> Vec<ExtractedColor> {
|
|
let mut colors = Vec::new();
|
|
|
|
if let Some(cap) = STYLE_ATTR_RE.captures(tag_content) {
|
|
let style_value = cap
|
|
.get(1)
|
|
.or_else(|| cap.get(2))
|
|
.map(|m| m.as_str().to_lowercase())
|
|
.unwrap_or_default();
|
|
|
|
for bg_cap in BG_PROP_RE.captures_iter(&style_value) {
|
|
let bg_value = bg_cap[1].trim();
|
|
|
|
for hex_cap in HEX_COLOR_RE.captures_iter(bg_value) {
|
|
let raw = hex_cap[0].to_string();
|
|
let alpha = parse_hex_alpha(&raw);
|
|
colors.push(ExtractedColor { color: raw, alpha });
|
|
}
|
|
|
|
for rgb_cap in RGB_COLOR_RE.captures_iter(bg_value) {
|
|
colors.push(ExtractedColor {
|
|
color: format!("rgb({}, {}, {})", &rgb_cap[1], &rgb_cap[2], &rgb_cap[3]),
|
|
alpha: 1.0,
|
|
});
|
|
}
|
|
|
|
for rgba_cap in RGBA_COLOR_RE.captures_iter(bg_value) {
|
|
let alpha = parse_alpha_str(&rgba_cap[4]);
|
|
colors.push(ExtractedColor {
|
|
color: format!("rgb({}, {}, {})", &rgba_cap[1], &rgba_cap[2], &rgba_cap[3]),
|
|
alpha,
|
|
});
|
|
}
|
|
|
|
for rgba_cap in RGBA_MODERN_RE.captures_iter(bg_value) {
|
|
let alpha = parse_alpha_str(&rgba_cap[4]);
|
|
colors.push(ExtractedColor {
|
|
color: format!("rgb({}, {}, {})", &rgba_cap[1], &rgba_cap[2], &rgba_cap[3]),
|
|
alpha,
|
|
});
|
|
}
|
|
|
|
for (name, re) in NAMED_COLOR_REGEXES.iter() {
|
|
if re.is_match(bg_value) {
|
|
colors.push(ExtractedColor {
|
|
color: name.to_string(),
|
|
alpha: 1.0,
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
colors
|
|
}
|
|
|
|
fn parse_hex_alpha(color: &str) -> f32 {
|
|
let c = color.trim().to_lowercase();
|
|
if let Some(hex) = c.strip_prefix('#') {
|
|
if hex.len() == 8 {
|
|
return u8::from_str_radix(&hex[6..8], 16).unwrap_or(255) as f32 / 255.0;
|
|
}
|
|
if hex.len() == 4 {
|
|
let digit = u8::from_str_radix(&hex[3..4], 16).unwrap_or(15);
|
|
return (digit * 17) as f32 / 255.0;
|
|
}
|
|
}
|
|
1.0
|
|
}
|
|
|
|
fn parse_alpha_str(s: &str) -> f32 {
|
|
if let Some(pct) = s.strip_suffix('%') {
|
|
pct.parse::<f32>().unwrap_or(100.0) / 100.0
|
|
} else {
|
|
s.parse::<f32>().unwrap_or(1.0)
|
|
}
|
|
}
|
|
|
|
fn is_transparent(color: &str) -> bool {
|
|
let c = color.to_lowercase();
|
|
c.contains("transparent")
|
|
|| c.contains("rgba(0, 0, 0, 0)")
|
|
|| c.contains("rgba(0,0,0,0)")
|
|
|| c == "initial"
|
|
|| c == "inherit"
|
|
|| c == "none"
|
|
}
|
|
|
|
fn normalize_color(color: &str) -> String {
|
|
let c = color.trim().to_lowercase();
|
|
if let Some(hex) = c.strip_prefix('#') {
|
|
match hex.len() {
|
|
3 => {
|
|
let mut chars = hex.chars();
|
|
let r = chars.next().unwrap_or('0');
|
|
let g = chars.next().unwrap_or('0');
|
|
let b = chars.next().unwrap_or('0');
|
|
format!("#{r}{r}{g}{g}{b}{b}")
|
|
}
|
|
6 => c,
|
|
8 => {
|
|
format!("#{}", &hex[0..6])
|
|
}
|
|
_ => c,
|
|
}
|
|
} else {
|
|
c
|
|
}
|
|
}
|
|
|
|
fn calculate_luminance(color: &str) -> f64 {
|
|
let (r, g, b) = parse_color(color);
|
|
(0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64) / 255.0
|
|
}
|
|
|
|
fn parse_color(color: &str) -> (u8, u8, u8) {
|
|
let c = color.trim().to_lowercase();
|
|
|
|
for (name, rgb) in NAMED_COLORS.iter() {
|
|
if c == *name {
|
|
return *rgb;
|
|
}
|
|
}
|
|
|
|
if let Some(hex) = c.strip_prefix('#') {
|
|
let hex = hex.trim();
|
|
if hex.len() >= 6 {
|
|
return (
|
|
u8::from_str_radix(&hex[0..2], 16).unwrap_or(255),
|
|
u8::from_str_radix(&hex[2..4], 16).unwrap_or(255),
|
|
u8::from_str_radix(&hex[4..6], 16).unwrap_or(255),
|
|
);
|
|
}
|
|
}
|
|
|
|
if let Some(cap) = RGB_COLOR_RE.captures(&c) {
|
|
return (
|
|
cap[1].parse().unwrap_or(255),
|
|
cap[2].parse().unwrap_or(255),
|
|
cap[3].parse().unwrap_or(255),
|
|
);
|
|
}
|
|
|
|
if let Some(cap) = RGBA_COLOR_RE.captures(&c) {
|
|
return (
|
|
cap[1].parse().unwrap_or(255),
|
|
cap[2].parse().unwrap_or(255),
|
|
cap[3].parse().unwrap_or(255),
|
|
);
|
|
}
|
|
|
|
(255, 255, 255)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_detect_dark_email_by_background() {
|
|
let html = r#"<html><body style="background-color: #101519">Dark content</body></html>"#;
|
|
assert_eq!(detect_email_theme(html), Some(EmailTheme::Dark));
|
|
}
|
|
|
|
#[test]
|
|
fn test_detect_light_email_by_background() {
|
|
let html = r#"<html><body style="background-color: #ffffff">Light content</body></html>"#;
|
|
assert_eq!(detect_email_theme(html), Some(EmailTheme::Light));
|
|
}
|
|
|
|
#[test]
|
|
fn test_detect_light_email_with_table() {
|
|
let html = r#"<html><body><table style="background-color: #f5f8fa"><tr><td>Light</td></tr></table></body></html>"#;
|
|
assert_eq!(detect_email_theme(html), Some(EmailTheme::Light));
|
|
}
|
|
|
|
#[test]
|
|
fn test_detect_transparent_email() {
|
|
let html = r#"<html><body>Just text, no background</body></html>"#;
|
|
assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent));
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_html_returns_none() {
|
|
assert_eq!(detect_email_theme(""), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_luminance_calculation() {
|
|
assert!(calculate_luminance("#000000") < 0.1);
|
|
assert!(calculate_luminance("#ffffff") > 0.9);
|
|
assert!(calculate_luminance("#101519") < 0.5);
|
|
}
|
|
|
|
#[test]
|
|
fn test_adaptive_media_query_detection() {
|
|
let html = r#"<html><head><style>
|
|
body { background: #F0EEE6; }
|
|
@media(prefers-color-scheme:dark) {
|
|
body { background: #1f1f1f !important; }
|
|
}
|
|
</style></head><body>Content</body></html>"#;
|
|
assert_eq!(detect_email_theme(html), Some(EmailTheme::Adaptive));
|
|
}
|
|
|
|
#[test]
|
|
fn test_luminance_grouping_mixed_light_colors() {
|
|
let html = r#"<html>
|
|
<body style="background-color: #ffffff">
|
|
<table style="background-color: #FAF9F5">
|
|
<tr><td style="background-color: #F0EEE6">Content</td></tr>
|
|
</table>
|
|
<div style="background-color: #E4E4E4">Footer</div>
|
|
</body>
|
|
</html>"#;
|
|
assert_eq!(detect_email_theme(html), Some(EmailTheme::Light));
|
|
}
|
|
|
|
#[test]
|
|
fn test_accent_td_not_treated_as_background() {
|
|
let html = r##"<html><body><table><tr><td bgcolor="#2672ec">Click here</td></tr></table></body></html>"##;
|
|
assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent));
|
|
}
|
|
|
|
#[test]
|
|
fn test_deduplicate_bgcolor_and_style() {
|
|
let html = r##"<html><body><table><tr><td bgcolor="#2672ec" style="background-color:#2672ec">Click</td></tr></table></body></html>"##;
|
|
assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent));
|
|
}
|
|
|
|
#[test]
|
|
fn test_lone_white_wrapper_table_is_transparent() {
|
|
let html = r#"<html><body><table style="background-color:white"><tr><td>Content</td></tr></table></body></html>"#;
|
|
assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent));
|
|
}
|
|
|
|
#[test]
|
|
fn test_named_color_white() {
|
|
let html = r#"<html><body style="background-color: white">Content</body></html>"#;
|
|
assert_eq!(detect_email_theme(html), Some(EmailTheme::Light));
|
|
}
|
|
|
|
#[test]
|
|
fn test_named_color_black() {
|
|
let html = r#"<html><body style="background-color: black">Content</body></html>"#;
|
|
assert_eq!(detect_email_theme(html), Some(EmailTheme::Dark));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rgb_color() {
|
|
let html = r#"<html><body><table style="background-color: rgb(214, 214, 214)"><tr><td>Content</td></tr></table></body></html>"#;
|
|
assert_eq!(detect_email_theme(html), Some(EmailTheme::Light));
|
|
}
|
|
|
|
#[test]
|
|
fn test_3_char_hex() {
|
|
let html = r#"<html><body style="background-color: #fff">Content</body></html>"#;
|
|
assert_eq!(detect_email_theme(html), Some(EmailTheme::Light));
|
|
}
|
|
|
|
#[test]
|
|
fn test_quoted_printable_decoding() {
|
|
let encoded = r#"<body style=3D"background-color: #101519">"#;
|
|
let decoded = decode_quoted_printable(encoded);
|
|
assert!(decoded.contains("style=\"background-color:"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_detect_dark_with_style_tag() {
|
|
let html = r#"<html><head><style>body { background-color: #101519; }</style></head><body>Dark</body></html>"#;
|
|
assert_eq!(detect_email_theme(html), Some(EmailTheme::Dark));
|
|
}
|
|
|
|
#[test]
|
|
fn test_white_does_not_match_whitesmoke() {
|
|
let html = r#"<html><body style="background-color: whitesmoke">Content</body></html>"#;
|
|
let colors = extract_background_colors(html);
|
|
assert_eq!(colors.len(), 1);
|
|
assert_eq!(colors[0].color, "whitesmoke");
|
|
}
|
|
|
|
#[test]
|
|
fn test_white_does_not_match_ghostwhite() {
|
|
let html = r#"<html><body style="background-color: ghostwhite">Content</body></html>"#;
|
|
let colors = extract_background_colors(html);
|
|
assert_eq!(colors.len(), 1);
|
|
assert_eq!(colors[0].color, "ghostwhite");
|
|
}
|
|
|
|
#[test]
|
|
fn test_white_does_not_match_floralwhite() {
|
|
let html = r#"<html><body style="background-color: floralwhite">Content</body></html>"#;
|
|
let colors = extract_background_colors(html);
|
|
assert_eq!(colors.len(), 1);
|
|
assert_eq!(colors[0].color, "floralwhite");
|
|
}
|
|
|
|
#[test]
|
|
fn test_gray_does_not_match_darkgray() {
|
|
let html = r#"<html><body style="background-color: darkgray">Content</body></html>"#;
|
|
let colors = extract_background_colors(html);
|
|
assert_eq!(colors.len(), 1);
|
|
assert_eq!(colors[0].color, "darkgray");
|
|
}
|
|
|
|
#[test]
|
|
fn test_gray_does_not_match_lightgray() {
|
|
let html = r#"<html><body style="background-color: lightgray">Content</body></html>"#;
|
|
let colors = extract_background_colors(html);
|
|
assert_eq!(colors.len(), 1);
|
|
assert_eq!(colors[0].color, "lightgray");
|
|
}
|
|
|
|
#[test]
|
|
fn test_exact_white_still_matches() {
|
|
let html = r#"<html><body style="background-color: white">Content</body></html>"#;
|
|
let colors = extract_background_colors(html);
|
|
assert_eq!(colors.len(), 1);
|
|
assert_eq!(colors[0].color, "white");
|
|
}
|
|
|
|
#[test]
|
|
fn test_named_color_in_style_tag_exact_match() {
|
|
let html = r#"<html><head><style>body { background-color: whitesmoke; }</style></head><body>Content</body></html>"#;
|
|
let colors = extract_background_colors(html);
|
|
assert!(
|
|
colors.iter().any(|c| c.color == "whitesmoke"),
|
|
"expected whitesmoke, got: {colors:?}"
|
|
);
|
|
assert!(
|
|
!colors.iter().any(|c| c.color == "white"),
|
|
"white should not match whitesmoke"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rgba_half_alpha_reduced_weight() {
|
|
let html_rgba =
|
|
r#"<html><body style="background-color: rgba(0,0,0,0.5)">Content</body></html>"#;
|
|
let html_rgb = r#"<html><body style="background-color: rgb(0,0,0)">Content</body></html>"#;
|
|
|
|
let colors_rgba = extract_background_colors(html_rgba);
|
|
let colors_rgb = extract_background_colors(html_rgb);
|
|
|
|
assert_eq!(colors_rgba.len(), 1);
|
|
assert_eq!(colors_rgb.len(), 1);
|
|
assert!(
|
|
(colors_rgba[0].element_weight - 0.5).abs() < 0.01,
|
|
"rgba(0,0,0,0.5) on body should have weight ~0.5, got {}",
|
|
colors_rgba[0].element_weight
|
|
);
|
|
assert!(
|
|
(colors_rgb[0].element_weight - 1.0).abs() < 0.01,
|
|
"rgb(0,0,0) on body should have weight 1.0, got {}",
|
|
colors_rgb[0].element_weight
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rgba_fully_transparent_ignored() {
|
|
let html = r#"<html><body style="background-color: rgba(0,0,0,0)">Content</body></html>"#;
|
|
let colors = extract_background_colors(html);
|
|
assert!(
|
|
colors.is_empty(),
|
|
"Fully transparent rgba should produce no colors, got {colors:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rgba_full_alpha_same_as_rgb() {
|
|
let html_rgba =
|
|
r#"<html><body style="background-color: rgba(255,255,255,1.0)">Content</body></html>"#;
|
|
let html_rgb =
|
|
r#"<html><body style="background-color: rgb(255,255,255)">Content</body></html>"#;
|
|
|
|
assert_eq!(detect_email_theme(html_rgba), Some(EmailTheme::Light));
|
|
assert_eq!(detect_email_theme(html_rgb), Some(EmailTheme::Light));
|
|
|
|
let colors_rgba = extract_background_colors(html_rgba);
|
|
let colors_rgb = extract_background_colors(html_rgb);
|
|
|
|
assert_eq!(colors_rgba.len(), 1);
|
|
assert_eq!(colors_rgb.len(), 1);
|
|
assert!(
|
|
(colors_rgba[0].element_weight - colors_rgb[0].element_weight).abs() < 0.01,
|
|
"rgba with alpha=1.0 should have same weight as rgb"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rgba_in_style_tag() {
|
|
let html = r#"<html><head><style>body { background-color: rgba(0,0,0,0.5); }</style></head><body>Content</body></html>"#;
|
|
let colors = extract_background_colors(html);
|
|
assert_eq!(colors.len(), 1);
|
|
assert!(
|
|
(colors[0].element_weight - 0.5).abs() < 0.01,
|
|
"rgba in style tag on body should have weight ~0.5, got {}",
|
|
colors[0].element_weight
|
|
);
|
|
}
|
|
}
|