Files
RustBox/src/email_theme.rs
T
2026-10-31 14:36:07 +01:00

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
);
}
}