mirror of
https://github.com/flectar/mail.git
synced 2026-11-12 02:05:59 +00:00
502 lines
17 KiB
Rust
502 lines
17 KiB
Rust
//! Reading colors are a reversible presentation layer over the sender's DOM.
|
|
//!
|
|
//! Resolve the sender's CSS (including prefers-color-scheme) first. Adapt solid
|
|
//! surfaces and gradient stops, then choose text against its actual ancestral
|
|
//! background. Raster images, layout, links and the stored/exported HTML are
|
|
//! untouched. UA !important declarations also cover legacy bgcolor attributes
|
|
//! and inline !important styles without rewriting the message.
|
|
use blitz_dom::{
|
|
QualName, ns,
|
|
util::{Color, ToColorColor},
|
|
};
|
|
use blitz_html::HtmlDocument;
|
|
use blitz_traits::shell::ColorScheme;
|
|
use std::fmt::Write;
|
|
use style::{
|
|
color::{AbsoluteColor, ColorSpace},
|
|
values::{
|
|
computed::Image,
|
|
generics::{
|
|
color::GenericColor,
|
|
image::{GenericGradient, GenericGradientItem, GenericImage},
|
|
},
|
|
},
|
|
};
|
|
use style_traits::ToCss;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub(crate) struct EmailAppearance {
|
|
pub original: bool,
|
|
pub dark: bool,
|
|
pub background: Color,
|
|
pub foreground: Color,
|
|
pub link: Color,
|
|
}
|
|
|
|
impl Default for EmailAppearance {
|
|
fn default() -> Self {
|
|
Self {
|
|
original: true,
|
|
dark: false,
|
|
background: Color::WHITE,
|
|
foreground: Color::BLACK,
|
|
link: Color::from_rgb8(9, 105, 218),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl EmailAppearance {
|
|
pub fn from_app(app: &crate::AppWindow) -> Self {
|
|
use slint::ComponentHandle;
|
|
let colors = app.global::<crate::MessageColors>();
|
|
let convert = |value: slint::Color| {
|
|
Color::from_rgba8(value.red(), value.green(), value.blue(), value.alpha())
|
|
};
|
|
Self {
|
|
original: colors.get_original(),
|
|
dark: colors.get_dark(),
|
|
background: convert(colors.get_background()),
|
|
foreground: convert(colors.get_foreground()),
|
|
link: convert(colors.get_link()),
|
|
}
|
|
}
|
|
|
|
pub fn scheme(self) -> ColorScheme {
|
|
if self.dark && !self.original {
|
|
ColorScheme::Dark
|
|
} else {
|
|
ColorScheme::Light
|
|
}
|
|
}
|
|
|
|
pub fn canvas(self) -> Color {
|
|
if self.original {
|
|
Color::WHITE
|
|
} else {
|
|
self.background.with_alpha(1.0)
|
|
}
|
|
}
|
|
|
|
fn background(self, color: Color) -> Color {
|
|
if color.components[3] == 0.0 {
|
|
return color;
|
|
}
|
|
let l = luminance(color);
|
|
let target = luminance(self.background);
|
|
if l > 0.98 {
|
|
self.background.with_alpha(color.components[3])
|
|
} else if self.dark && l > 0.18 {
|
|
// White is the theme's exact surface. Tinted and inset surfaces
|
|
// retain their hue and a little separation from that foundation.
|
|
let level = target + (1.0 - l) * 0.055;
|
|
blend_to_luminance(color, self.background, level)
|
|
} else if !self.dark && l < 0.18 && neutral(color) {
|
|
let level = (target - l * 0.45).max(0.6);
|
|
blend_to_luminance(color, self.background, level)
|
|
} else {
|
|
color
|
|
}
|
|
}
|
|
|
|
fn foreground(self, color: Color, background: Backdrop) -> Color {
|
|
if color.components[3] == 0.0 || background.image {
|
|
return color;
|
|
}
|
|
let mut candidate = color;
|
|
// Keep authored white labels on colored buttons and native dark text
|
|
// over images. Neutral prose on the reading surface uses the palette.
|
|
if neutral(color)
|
|
&& ((neutral(background.low) && neutral(background.high))
|
|
|| (same_color(background.low, self.canvas())
|
|
&& same_color(background.high, self.canvas())))
|
|
{
|
|
let l = luminance(color);
|
|
if (self.dark && l < 0.45) || (!self.dark && l < 0.18) {
|
|
candidate = self.foreground.with_alpha(color.components[3]);
|
|
if self.dark && l > 0.08 {
|
|
candidate = mix(candidate, background.low, 0.2);
|
|
}
|
|
}
|
|
} else if same_color(color, Color::from_rgb8(9, 105, 218)) {
|
|
candidate = self.link.with_alpha(color.components[3]);
|
|
}
|
|
// Leave a margin above WCAG AA's 4.5:1 threshold for CSS serialization
|
|
// and the renderer's conversion to eight-bit color channels.
|
|
ensure_contrast(candidate, background, 4.55)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct Backdrop {
|
|
low: Color,
|
|
high: Color,
|
|
image: bool,
|
|
}
|
|
|
|
impl Backdrop {
|
|
fn solid(color: Color) -> Self {
|
|
Self {
|
|
low: color,
|
|
high: color,
|
|
image: false,
|
|
}
|
|
}
|
|
|
|
fn over(self, color: Color) -> Self {
|
|
Self {
|
|
low: composite(color, self.low),
|
|
high: composite(color, self.high),
|
|
image: self.image && color.components[3] < 1.0,
|
|
}
|
|
}
|
|
|
|
fn contrast(self, color: Color) -> f32 {
|
|
let low = luminance(self.low);
|
|
let high = luminance(self.high);
|
|
let value = luminance(composite(color, self.low));
|
|
if value >= low.min(high) && value <= low.max(high) {
|
|
return 1.0;
|
|
}
|
|
contrast(composite(color, self.low), self.low)
|
|
.min(contrast(composite(color, self.high), self.high))
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub(crate) struct AppearanceDocument {
|
|
pub colors: EmailAppearance,
|
|
base: String,
|
|
overrides: String,
|
|
indexed: bool,
|
|
}
|
|
|
|
impl AppearanceDocument {
|
|
pub fn new(colors: EmailAppearance) -> Self {
|
|
Self {
|
|
colors,
|
|
..Self::default()
|
|
}
|
|
}
|
|
|
|
pub fn resolve(&mut self, document: &mut HtmlDocument) {
|
|
if !self.overrides.is_empty() {
|
|
document.remove_user_agent_stylesheet(&self.overrides);
|
|
self.overrides.clear();
|
|
}
|
|
let base = if self.colors.original {
|
|
String::new()
|
|
} else {
|
|
format!(
|
|
"html {{ background-color: {}; }}",
|
|
css_color(self.colors.canvas())
|
|
)
|
|
};
|
|
if base != self.base {
|
|
if !self.base.is_empty() {
|
|
document.remove_user_agent_stylesheet(&self.base);
|
|
}
|
|
if !base.is_empty() {
|
|
document.add_user_agent_stylesheet(&base);
|
|
}
|
|
self.base = base;
|
|
}
|
|
if !self.colors.original && !self.indexed {
|
|
let mut elements = Vec::new();
|
|
let mut stack = vec![document.root_node().id];
|
|
while let Some(id) = stack.pop() {
|
|
let Some(node) = document.get_node(id) else {
|
|
continue;
|
|
};
|
|
stack.extend(node.children.iter().copied());
|
|
if node.element_data().is_some() {
|
|
elements.push(id);
|
|
}
|
|
}
|
|
let mut mutation = document.mutate();
|
|
for id in elements {
|
|
mutation.set_attribute(
|
|
id,
|
|
QualName {
|
|
prefix: None,
|
|
ns: ns!(),
|
|
// Stylo buckets attribute selectors by name. A unique
|
|
// name keeps matching linear in the message size;
|
|
// one shared attribute with unique values is quadratic.
|
|
local: format!("data-flectar-appearance-{}", id.as_u64()).into(),
|
|
},
|
|
"",
|
|
);
|
|
}
|
|
self.indexed = true;
|
|
}
|
|
document.resolve(0.0);
|
|
if self.colors.original {
|
|
return;
|
|
}
|
|
self.overrides = self.stylesheet(document);
|
|
if !self.overrides.is_empty() {
|
|
document.add_user_agent_stylesheet(&self.overrides);
|
|
document.resolve(0.0);
|
|
}
|
|
}
|
|
|
|
fn stylesheet(&self, document: &HtmlDocument) -> String {
|
|
let mut css = String::new();
|
|
let canvas = Backdrop::solid(self.colors.canvas());
|
|
let mut stack = vec![(document.root_node().id, canvas, canvas)];
|
|
while let Some((id, inherited, authored_inherited)) = stack.pop() {
|
|
let Some(node) = document.get_node(id) else {
|
|
continue;
|
|
};
|
|
let mut backdrop = inherited;
|
|
let mut authored_backdrop = authored_inherited;
|
|
if let Some(style) = node.element_data().and_then(|_| node.primary_styles()) {
|
|
let foreground = style.clone_color();
|
|
let background = style.get_background();
|
|
let authored_bg = background
|
|
.background_color
|
|
.resolve_to_absolute(&foreground)
|
|
.as_color_color();
|
|
// A photograph's brightness cannot be inferred from CSS. Keep
|
|
// its surface and authored text together, including children.
|
|
let url_image = background.background_image.0.iter().any(has_image);
|
|
let mapped_bg = if url_image {
|
|
authored_bg
|
|
} else {
|
|
self.colors.background(authored_bg)
|
|
};
|
|
backdrop = inherited.over(mapped_bg);
|
|
authored_backdrop = authored_inherited.over(authored_bg);
|
|
let (images, stops) = if url_image {
|
|
(None, Vec::new())
|
|
} else {
|
|
self.images(&background.background_image.0, &foreground)
|
|
};
|
|
if url_image {
|
|
backdrop.image = true;
|
|
}
|
|
authored_backdrop.image |= url_image || !stops.is_empty();
|
|
if !stops.is_empty() && !url_image {
|
|
let mut composed: Vec<_> = stops
|
|
.into_iter()
|
|
.map(|c| composite(c, backdrop.low))
|
|
.collect();
|
|
composed.sort_by(|a, b| luminance(*a).total_cmp(&luminance(*b)));
|
|
backdrop.low = composed[0];
|
|
backdrop.high = *composed.last().unwrap();
|
|
}
|
|
let authored_fg = foreground.as_color_color();
|
|
let mapped_fg = if !authored_backdrop.image
|
|
&& same_color(authored_fg.with_alpha(1.0), authored_backdrop.low)
|
|
&& same_color(authored_fg.with_alpha(1.0), authored_backdrop.high)
|
|
{
|
|
// Some preheaders use identical foreground and background
|
|
// colors to conceal their preview padding.
|
|
backdrop.low.with_alpha(authored_fg.components[3])
|
|
} else {
|
|
self.colors.foreground(authored_fg, backdrop)
|
|
};
|
|
let _ = write!(
|
|
css,
|
|
"[data-flectar-appearance-{}] {{ color: {} !important;",
|
|
id.as_u64(),
|
|
css_color(mapped_fg)
|
|
);
|
|
if mapped_bg != authored_bg || !background.background_color.is_absolute() {
|
|
let _ = write!(
|
|
css,
|
|
"background-color: {} !important;",
|
|
css_color(mapped_bg)
|
|
);
|
|
}
|
|
if let Some(images) = images {
|
|
let _ = write!(css, "background-image: {images} !important;");
|
|
}
|
|
let border = style.get_border();
|
|
for (name, color, width) in [
|
|
(
|
|
"border-top-color",
|
|
&border.border_top_color,
|
|
&border.border_top_width,
|
|
),
|
|
(
|
|
"border-right-color",
|
|
&border.border_right_color,
|
|
&border.border_right_width,
|
|
),
|
|
(
|
|
"border-bottom-color",
|
|
&border.border_bottom_color,
|
|
&border.border_bottom_width,
|
|
),
|
|
(
|
|
"border-left-color",
|
|
&border.border_left_color,
|
|
&border.border_left_width,
|
|
),
|
|
] {
|
|
if width.0.to_f32_px() <= 0.0 {
|
|
continue;
|
|
}
|
|
let color = color.resolve_to_absolute(&foreground).as_color_color();
|
|
if neutral(color) && color.components[3] > 0.0 && !backdrop.image {
|
|
let mapped = mix(self.colors.foreground, backdrop.low, 0.75)
|
|
.with_alpha(color.components[3]);
|
|
let _ = write!(css, "{name}: {} !important;", css_color(mapped));
|
|
}
|
|
}
|
|
css.push_str("}\n");
|
|
}
|
|
stack.extend(
|
|
node.children
|
|
.iter()
|
|
.rev()
|
|
.map(|id| (*id, backdrop, authored_backdrop)),
|
|
);
|
|
}
|
|
css
|
|
}
|
|
|
|
fn images(&self, images: &[Image], current: &AbsoluteColor) -> (Option<String>, Vec<Color>) {
|
|
let mut mapped = images.to_vec();
|
|
let mut changed = false;
|
|
let mut stops = Vec::new();
|
|
for image in &mut mapped {
|
|
let GenericImage::Gradient(gradient) = image else {
|
|
continue;
|
|
};
|
|
match gradient.as_mut() {
|
|
GenericGradient::Linear { items, .. } | GenericGradient::Radial { items, .. } => {
|
|
for item in items.iter_mut() {
|
|
self.gradient_item(item, current, &mut changed, &mut stops);
|
|
}
|
|
}
|
|
GenericGradient::Conic { items, .. } => {
|
|
for item in items.iter_mut() {
|
|
self.gradient_item(item, current, &mut changed, &mut stops);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let css = changed.then(|| {
|
|
mapped
|
|
.iter()
|
|
.map(ToCss::to_css_string)
|
|
.collect::<Vec<_>>()
|
|
.join(",")
|
|
});
|
|
(css, stops)
|
|
}
|
|
|
|
fn gradient_item<T>(
|
|
&self,
|
|
item: &mut GenericGradientItem<GenericColor<style::values::computed::Percentage>, T>,
|
|
current: &AbsoluteColor,
|
|
changed: &mut bool,
|
|
stops: &mut Vec<Color>,
|
|
) {
|
|
let color = match item {
|
|
GenericGradientItem::SimpleColorStop(color)
|
|
| GenericGradientItem::ComplexColorStop { color, .. } => color,
|
|
GenericGradientItem::InterpolationHint(_) => return,
|
|
};
|
|
let original = color.resolve_to_absolute(current).as_color_color();
|
|
let mapped = self.colors.background(original);
|
|
stops.push(mapped);
|
|
if mapped != original || !color.is_absolute() {
|
|
let [r, g, b, a] = mapped.components;
|
|
*color = GenericColor::Absolute(AbsoluteColor::new(ColorSpace::Srgb, r, g, b, a));
|
|
*changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn has_image(image: &Image) -> bool {
|
|
!matches!(image, GenericImage::None | GenericImage::Gradient(_))
|
|
}
|
|
|
|
fn neutral(color: Color) -> bool {
|
|
let [r, g, b, _] = color.components;
|
|
r.max(g).max(b) - r.min(g).min(b) < 0.13
|
|
}
|
|
|
|
fn same_color(a: Color, b: Color) -> bool {
|
|
a.components
|
|
.into_iter()
|
|
.zip(b.components)
|
|
.all(|(a, b)| (a - b).abs() < 0.00001)
|
|
}
|
|
|
|
fn luminance(color: Color) -> f32 {
|
|
color.split().0.relative_luminance()
|
|
}
|
|
|
|
fn contrast(a: Color, b: Color) -> f32 {
|
|
let (a, b) = (luminance(a), luminance(b));
|
|
(a.max(b) + 0.05) / (a.min(b) + 0.05)
|
|
}
|
|
|
|
fn mix(a: Color, b: Color, amount: f32) -> Color {
|
|
a.lerp_rect(b.with_alpha(a.components[3]), amount)
|
|
}
|
|
|
|
fn composite(color: Color, background: Color) -> Color {
|
|
let alpha = color.components[3];
|
|
let mut result = mix(background, color, alpha);
|
|
result.components[3] = 1.0;
|
|
result
|
|
}
|
|
|
|
fn blend_to_luminance(color: Color, target: Color, level: f32) -> Color {
|
|
let decreasing = luminance(color) > luminance(target);
|
|
let (mut lo, mut hi) = (0.0, 1.0);
|
|
for _ in 0..16 {
|
|
let mid = (lo + hi) / 2.0;
|
|
if (luminance(mix(color, target, mid)) > level) == decreasing {
|
|
lo = mid;
|
|
} else {
|
|
hi = mid;
|
|
}
|
|
}
|
|
mix(color, target, hi)
|
|
}
|
|
|
|
fn ensure_contrast(color: Color, backdrop: Backdrop, minimum: f32) -> Color {
|
|
if backdrop.contrast(color) >= minimum {
|
|
return color;
|
|
}
|
|
let black = Color::BLACK.with_alpha(color.components[3]);
|
|
let white = Color::WHITE.with_alpha(color.components[3]);
|
|
let pole = if backdrop.contrast(black) > backdrop.contrast(white) {
|
|
black
|
|
} else {
|
|
white
|
|
};
|
|
if backdrop.contrast(pole) < minimum {
|
|
// Preserve authored transparency; it may be a hidden preheader. Never
|
|
// expose concealed content in an attempt to increase its contrast.
|
|
return pole;
|
|
}
|
|
let (mut lo, mut hi) = (0.0, 1.0);
|
|
for _ in 0..16 {
|
|
let mid = (lo + hi) / 2.0;
|
|
if backdrop.contrast(mix(color, pole, mid)) < minimum {
|
|
lo = mid;
|
|
} else {
|
|
hi = mid;
|
|
}
|
|
}
|
|
mix(color, pole, hi)
|
|
}
|
|
|
|
fn css_color(color: Color) -> String {
|
|
let [r, g, b, a] = color.components;
|
|
format!(
|
|
"rgba({:.5}%,{:.5}%,{:.5}%,{:.5})",
|
|
r * 100.0,
|
|
g * 100.0,
|
|
b * 100.0,
|
|
a
|
|
)
|
|
}
|