use super::util::SvgTheme;
use crate::chart_palette::{XyChartPaletteConfig, resolve_xychart_plot_palette};
use serde_json::Value;
#[derive(Debug, Clone)]
pub(super) struct CommonCssTheme {
pub(super) theme_name: String,
pub(super) look: String,
pub(super) font_family_css: String,
pub(super) font_size_px: f64,
pub(super) text_color: String,
pub(super) line_color: String,
pub(super) error_bkg: String,
pub(super) error_text: String,
}
impl CommonCssTheme {
pub(super) fn is_dark_theme(&self) -> bool {
self.theme_name.contains("dark")
}
pub(super) fn is_neo(&self) -> bool {
self.look == "neo"
}
}
#[derive(Debug, Clone)]
pub(super) struct NodeDiagramTheme {
pub(super) common: CommonCssTheme,
pub(super) node_text_color: String,
pub(super) title_color: String,
pub(super) main_bkg: String,
pub(super) node_border: String,
pub(super) arrowhead_color: String,
pub(super) stroke_width: String,
pub(super) edge_label_background: String,
pub(super) tertiary: String,
pub(super) cluster_bkg: String,
pub(super) cluster_border: String,
}
#[derive(Debug, Clone)]
pub(super) struct ClassDiagramTheme {
pub(super) common: CommonCssTheme,
pub(super) class_text: String,
pub(super) note_text: String,
pub(super) class_group_text: String,
pub(super) title_color: String,
pub(super) text_color: String,
pub(super) main_bkg: String,
pub(super) node_border: String,
pub(super) cluster_bkg: String,
pub(super) cluster_border: String,
pub(super) stroke_width: String,
}
#[derive(Debug, Clone)]
pub(super) struct SequenceDiagramTheme {
pub(super) common: CommonCssTheme,
pub(super) actor_border: String,
pub(super) actor_fill: String,
pub(super) stroke_width: String,
pub(super) drop_shadow: String,
pub(super) note_border: String,
pub(super) note_fill: String,
pub(super) actor_text: String,
pub(super) actor_line: String,
pub(super) signal_color: String,
pub(super) sequence_number: String,
pub(super) signal_text: String,
pub(super) label_box_border: String,
pub(super) label_box_fill: String,
pub(super) label_text: String,
pub(super) loop_text: String,
pub(super) note_text: String,
pub(super) activation_fill: String,
pub(super) activation_border: String,
pub(super) node_border: String,
pub(super) note_font_weight: String,
pub(super) label_box_filter: String,
}
#[derive(Debug, Clone)]
pub(super) struct StateDiagramTheme {
pub(super) common: CommonCssTheme,
pub(super) transition_color: String,
pub(super) node_border: String,
pub(super) background: String,
pub(super) main_bkg: String,
pub(super) alt_background: String,
pub(super) stroke_width: String,
pub(super) stroke_width_px: String,
pub(super) rough_stroke_width_value: f64,
pub(super) note_border: String,
pub(super) note_bkg: String,
pub(super) note_text: String,
pub(super) label_background: String,
pub(super) edge_label_background: String,
pub(super) transition_label_color: String,
pub(super) special_state_color: String,
pub(super) inner_end_background: String,
pub(super) end_outer_fill: String,
pub(super) end_outer_stroke: String,
pub(super) end_inner_stroke: String,
pub(super) composite_background: String,
pub(super) state_bkg: String,
pub(super) state_border: String,
pub(super) composite_title_background: String,
pub(super) state_label_color: String,
pub(super) drop_shadow: String,
}
#[derive(Debug, Clone)]
pub(crate) struct XyChartTheme {
pub(crate) background_color: String,
pub(crate) title_color: String,
pub(crate) x_axis_title_color: String,
pub(crate) x_axis_label_color: String,
pub(crate) x_axis_tick_color: String,
pub(crate) x_axis_line_color: String,
pub(crate) y_axis_title_color: String,
pub(crate) y_axis_label_color: String,
pub(crate) y_axis_tick_color: String,
pub(crate) y_axis_line_color: String,
pub(crate) plot_color_palette: Vec<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct QuadrantChartTheme {
pub(crate) quadrant1_fill: String,
pub(crate) quadrant2_fill: String,
pub(crate) quadrant3_fill: String,
pub(crate) quadrant4_fill: String,
pub(crate) quadrant1_text_fill: String,
pub(crate) quadrant2_text_fill: String,
pub(crate) quadrant3_text_fill: String,
pub(crate) quadrant4_text_fill: String,
pub(crate) quadrant_point_fill: String,
pub(crate) quadrant_point_text_fill: String,
pub(crate) quadrant_x_axis_text_fill: String,
pub(crate) quadrant_y_axis_text_fill: String,
pub(crate) quadrant_title_fill: String,
pub(crate) quadrant_internal_border_stroke_fill: String,
pub(crate) quadrant_external_border_stroke_fill: String,
}
#[derive(Debug, Clone)]
pub(crate) struct TreeViewTheme {
pub(crate) label_font_size: f64,
pub(crate) label_font_size_css: String,
pub(crate) label_color: String,
pub(crate) line_color: String,
}
#[derive(Debug, Clone)]
pub(crate) struct EventModelingTheme {
pub(crate) font_family_css: String,
pub(crate) text_color: String,
pub(crate) ui_fill: String,
pub(crate) ui_stroke: String,
pub(crate) processor_fill: String,
pub(crate) processor_stroke: String,
pub(crate) read_model_fill: String,
pub(crate) read_model_stroke: String,
pub(crate) command_fill: String,
pub(crate) command_stroke: String,
pub(crate) event_fill: String,
pub(crate) event_stroke: String,
pub(crate) swimlane_background_fill: String,
pub(crate) swimlane_background_stroke: String,
pub(crate) relation_stroke: String,
pub(crate) arrowhead_fill: String,
}
pub(crate) struct PresentationTheme<'a> {
raw: SvgTheme<'a>,
common: CommonCssTheme,
}
impl<'a> PresentationTheme<'a> {
pub(crate) fn new(effective_config: &'a Value) -> Self {
let raw = SvgTheme::new(effective_config);
let common = CommonCssTheme {
theme_name: raw.theme_name(),
look: raw.look(),
font_family_css: raw.font_family_css(),
font_size_px: raw.font_size_px(),
text_color: raw.color("textColor", "#333"),
line_color: raw.color("lineColor", "#333333"),
error_bkg: raw.color("errorBkgColor", "#552222"),
error_text: raw.color("errorTextColor", "#552222"),
};
Self { raw, common }
}
pub(crate) fn xychart(&self) -> XyChartTheme {
let background = self
.raw
.optional_nested_color("xyChart", "backgroundColor")
.or_else(|| self.raw.optional_color("background"))
.unwrap_or_else(|| "white".to_string());
let primary_color = self
.raw
.optional_color("primaryColor")
.unwrap_or_else(|| "#ECECFF".to_string());
let primary_text = self
.raw
.optional_color("primaryTextColor")
.or_else(|| invert_hex_color(&primary_color))
.unwrap_or_else(|| "#333".to_string());
XyChartTheme {
background_color: background.clone(),
title_color: self
.raw
.optional_nested_color("xyChart", "titleColor")
.unwrap_or_else(|| primary_text.clone()),
x_axis_title_color: self
.raw
.optional_nested_color("xyChart", "xAxisTitleColor")
.unwrap_or_else(|| primary_text.clone()),
x_axis_label_color: self
.raw
.optional_nested_color("xyChart", "xAxisLabelColor")
.unwrap_or_else(|| primary_text.clone()),
x_axis_tick_color: self
.raw
.optional_nested_color("xyChart", "xAxisTickColor")
.unwrap_or_else(|| primary_text.clone()),
x_axis_line_color: self
.raw
.optional_nested_color("xyChart", "xAxisLineColor")
.unwrap_or_else(|| primary_text.clone()),
y_axis_title_color: self
.raw
.optional_nested_color("xyChart", "yAxisTitleColor")
.unwrap_or_else(|| primary_text.clone()),
y_axis_label_color: self
.raw
.optional_nested_color("xyChart", "yAxisLabelColor")
.unwrap_or_else(|| primary_text.clone()),
y_axis_tick_color: self
.raw
.optional_nested_color("xyChart", "yAxisTickColor")
.unwrap_or_else(|| primary_text.clone()),
y_axis_line_color: self
.raw
.optional_nested_color("xyChart", "yAxisLineColor")
.unwrap_or_else(|| primary_text.clone()),
plot_color_palette: resolve_xychart_plot_palette(XyChartPaletteConfig {
theme_name: self.common.theme_name.clone(),
plot_color_palette: self
.raw
.optional_nested_color("xyChart", "plotColorPalette"),
accent_color: self
.raw
.optional_nested_color("xyChart", "accentColor")
.or_else(|| self.raw.optional_color("primaryColor")),
background_color: Some(background),
}),
}
}
pub(crate) fn quadrantchart(&self) -> QuadrantChartTheme {
let quadrant1_fill = self
.raw
.optional_color("primaryColor")
.unwrap_or_else(|| "#ECECFF".to_string());
let primary_text = self
.raw
.optional_color("primaryTextColor")
.or_else(|| invert_hex_color(&quadrant1_fill))
.unwrap_or_else(|| "#131300".to_string());
let border_stroke = self
.raw
.optional_color("primaryBorderColor")
.and_then(|v| css_color_to_rgb_string(&v))
.unwrap_or_else(|| "rgb(199, 199, 241)".to_string());
let quadrant_point_fill = derive_quadrant_point_fill(&quadrant1_fill, &border_stroke);
let mut theme = QuadrantChartTheme {
quadrant1_fill: quadrant1_fill.clone(),
quadrant2_fill: adjust_hex_rgb(&quadrant1_fill, 5)
.unwrap_or_else(|| "#f1f1ff".to_string()),
quadrant3_fill: adjust_hex_rgb(&quadrant1_fill, 10)
.unwrap_or_else(|| "#f6f6ff".to_string()),
quadrant4_fill: adjust_hex_rgb(&quadrant1_fill, 15)
.unwrap_or_else(|| "#fbfbff".to_string()),
quadrant1_text_fill: primary_text.clone(),
quadrant2_text_fill: adjust_hex_rgb(&primary_text, -5)
.unwrap_or_else(|| "#0e0e00".to_string()),
quadrant3_text_fill: adjust_hex_rgb(&primary_text, -10)
.unwrap_or_else(|| "#090900".to_string()),
quadrant4_text_fill: adjust_hex_rgb(&primary_text, -15)
.unwrap_or_else(|| "#040400".to_string()),
quadrant_point_fill,
quadrant_point_text_fill: primary_text.clone(),
quadrant_x_axis_text_fill: primary_text.clone(),
quadrant_y_axis_text_fill: primary_text.clone(),
quadrant_title_fill: primary_text,
quadrant_internal_border_stroke_fill: border_stroke.clone(),
quadrant_external_border_stroke_fill: border_stroke,
};
let set = |field: &mut String, key: &str| {
if let Some(v) = self.raw.optional_color(key) {
*field = v;
}
};
set(&mut theme.quadrant1_fill, "quadrant1Fill");
set(&mut theme.quadrant2_fill, "quadrant2Fill");
set(&mut theme.quadrant3_fill, "quadrant3Fill");
set(&mut theme.quadrant4_fill, "quadrant4Fill");
set(&mut theme.quadrant1_text_fill, "quadrant1TextFill");
set(&mut theme.quadrant2_text_fill, "quadrant2TextFill");
set(&mut theme.quadrant3_text_fill, "quadrant3TextFill");
set(&mut theme.quadrant4_text_fill, "quadrant4TextFill");
if let Some(v) = self.raw.optional_color("quadrantPointFill") {
if !is_invalid_css_token(&v) {
theme.quadrant_point_fill = v;
}
}
set(&mut theme.quadrant_point_text_fill, "quadrantPointTextFill");
set(
&mut theme.quadrant_x_axis_text_fill,
"quadrantXAxisTextFill",
);
set(
&mut theme.quadrant_y_axis_text_fill,
"quadrantYAxisTextFill",
);
set(&mut theme.quadrant_title_fill, "quadrantTitleFill");
set(
&mut theme.quadrant_internal_border_stroke_fill,
"quadrantInternalBorderStrokeFill",
);
set(
&mut theme.quadrant_external_border_stroke_fill,
"quadrantExternalBorderStrokeFill",
);
theme
}
pub(crate) fn tree_view(&self) -> TreeViewTheme {
TreeViewTheme {
label_font_size: self
.raw
.optional_nested_css_px("treeView", "labelFontSize")
.unwrap_or(16.0)
.max(1.0),
label_font_size_css: self
.raw
.optional_nested_css_value("treeView", "labelFontSize")
.unwrap_or_else(|| "16px".to_string()),
label_color: self
.raw
.optional_nested_color("treeView", "labelColor")
.unwrap_or_else(|| "black".to_string()),
line_color: self
.raw
.optional_nested_color("treeView", "lineColor")
.unwrap_or_else(|| "black".to_string()),
}
}
pub(crate) fn eventmodeling(&self) -> EventModelingTheme {
EventModelingTheme {
font_family_css: self.raw.font_family_css_root_first(),
text_color: self.raw.color("textColor", "#333"),
ui_fill: self.raw.color("emUiFill", "white"),
ui_stroke: self.raw.color("emUiStroke", "#dbdada"),
processor_fill: self.raw.color("emProcessorFill", "#edb3f6"),
processor_stroke: self.raw.color("emProcessorStroke", "#b88cbf"),
read_model_fill: self.raw.color("emReadModelFill", "#d3f1a2"),
read_model_stroke: self.raw.color("emReadModelStroke", "#a3b732"),
command_fill: self.raw.color("emCommandFill", "#bcd6fe"),
command_stroke: self.raw.color("emCommandStroke", "#679ac3"),
event_fill: self.raw.color("emEventFill", "#ffb778"),
event_stroke: self.raw.color("emEventStroke", "#c19a0f"),
swimlane_background_fill: self
.raw
.optional_color("emSwimlaneBackgroundOdd")
.or_else(|| self.raw.optional_color("emSwimlaneBackground"))
.unwrap_or_else(|| "rgb(250,250,250)".to_string()),
swimlane_background_stroke: self
.raw
.optional_color("emSwimlaneBackgroundStroke")
.or_else(|| self.raw.optional_color("emSwimlaneBorder"))
.unwrap_or_else(|| "rgb(240,240,240)".to_string()),
relation_stroke: self.raw.color("emRelationStroke", "#000"),
arrowhead_fill: self.raw.color("emArrowhead", "#000000"),
}
}
pub(super) fn common(&self) -> &CommonCssTheme {
&self.common
}
pub(super) fn node_diagram(&self) -> NodeDiagramTheme {
let node_border = self.raw.color("nodeBorder", "#9370DB");
let main_bkg = self.raw.color("mainBkg", "#ECECFF");
NodeDiagramTheme {
common: self.common.clone(),
node_text_color: self
.raw
.color("nodeTextColor", self.common.text_color.as_str()),
title_color: self
.raw
.color("titleColor", self.common.text_color.as_str()),
main_bkg,
node_border,
arrowhead_color: self
.raw
.color("arrowheadColor", self.common.line_color.as_str()),
stroke_width: self.raw.css_value("strokeWidth", "1"),
edge_label_background: self
.raw
.color("edgeLabelBackground", "rgba(232,232,232, 0.8)"),
tertiary: self
.raw
.color("tertiaryColor", "hsl(80, 100%, 96.2745098039%)"),
cluster_bkg: self.raw.color("clusterBkg", "#ffffde"),
cluster_border: self.raw.color("clusterBorder", "#aaaa33"),
}
}
pub(super) fn class_diagram(&self) -> ClassDiagramTheme {
let class_text = self.raw.color(
"classText",
&self
.raw
.color("primaryTextColor", self.common.text_color.as_str()),
);
ClassDiagramTheme {
common: self.common.clone(),
class_text: class_text.clone(),
note_text: self.raw.color("noteTextColor", "#333"),
class_group_text: self
.raw
.optional_color("nodeBorder")
.unwrap_or_else(|| class_text.clone()),
title_color: self.raw.color("titleColor", "#333"),
text_color: self.raw.color("textColor", class_text.as_str()),
main_bkg: self.raw.color("mainBkg", "#ECECFF"),
node_border: self.raw.color("nodeBorder", "#9370DB"),
cluster_bkg: self.raw.color("clusterBkg", "#ffffde"),
cluster_border: self.raw.color("clusterBorder", "#aaaa33"),
stroke_width: self.raw.css_value("strokeWidth", "1"),
}
}
pub(super) fn sequence_diagram(&self) -> SequenceDiagramTheme {
let actor_border = self.raw.color("actorBorder", "#9370DB");
let actor_fill = self.raw.color("actorBkg", "#ECECFF");
let actor_text = self.raw.color("actorTextColor", "black");
SequenceDiagramTheme {
common: self.common.clone(),
actor_border: actor_border.clone(),
actor_fill: actor_fill.clone(),
stroke_width: self.raw.css_value("strokeWidth", "1"),
drop_shadow: self.raw.css_value("dropShadow", "none"),
note_border: self.raw.color("noteBorderColor", "#aaaa33"),
note_fill: self.raw.color("noteBkgColor", "#fff5ad"),
actor_text: actor_text.clone(),
actor_line: self.raw.color("actorLineColor", actor_border.as_str()),
signal_color: self.raw.color("signalColor", "#333"),
sequence_number: self.raw.color("sequenceNumberColor", "white"),
signal_text: self.raw.color("signalTextColor", "#333"),
label_box_border: self.raw.color("labelBoxBorderColor", actor_border.as_str()),
label_box_fill: self.raw.color("labelBoxBkgColor", actor_fill.as_str()),
label_text: self.raw.color("labelTextColor", actor_text.as_str()),
loop_text: self.raw.color("loopTextColor", actor_text.as_str()),
note_text: self.raw.color("noteTextColor", "black"),
activation_fill: self.raw.color("activationBkgColor", "#f4f4f4"),
activation_border: self.raw.color("activationBorderColor", "#666"),
node_border: self.raw.color("nodeBorder", actor_border.as_str()),
note_font_weight: self
.raw
.optional_value("noteFontWeight")
.map(|font_weight| format!("font-weight:{};", font_weight))
.unwrap_or_default(),
label_box_filter: if self.common.is_neo() {
self.raw.css_value("dropShadow", "none")
} else {
"none".to_string()
},
}
}
pub(super) fn state_diagram(&self) -> StateDiagramTheme {
let node_border = self.raw.color("nodeBorder", "#9370DB");
let main_bkg = self.raw.color("mainBkg", "#ECECFF");
let background = self.raw.color("background", "white");
let stroke_width = self.raw.css_value("strokeWidth", "1");
let stroke_width_px = if stroke_width.trim_end().ends_with("px") {
stroke_width.clone()
} else {
format!("{stroke_width}px")
};
let stroke_width_value = stroke_width
.trim()
.trim_end_matches("px")
.trim()
.parse::<f64>()
.unwrap_or(1.0)
.max(0.0);
let rough_stroke_width_value = if (stroke_width_value - 1.0).abs() <= 1e-9 {
1.3
} else {
stroke_width_value
};
let transition_color = self
.raw
.color("transitionColor", self.common.line_color.as_str());
let special_state_color = self
.raw
.color("specialStateColor", self.common.line_color.as_str());
let inner_end_background = self.raw.color("innerEndBackground", node_border.as_str());
let end_outer_fill = if special_state_color.eq_ignore_ascii_case("#333333") {
"#ECECFF".to_string()
} else {
special_state_color.clone()
};
let end_outer_stroke = special_state_color.clone();
let end_inner_stroke = if background.eq_ignore_ascii_case("white") {
inner_end_background.clone()
} else {
background.clone()
};
StateDiagramTheme {
common: self.common.clone(),
transition_color,
node_border: node_border.clone(),
background: background.clone(),
main_bkg: main_bkg.clone(),
alt_background: self.raw.color("altBackground", "#efefef"),
stroke_width,
stroke_width_px,
rough_stroke_width_value,
note_border: self.raw.color("noteBorderColor", "#aaaa33"),
note_bkg: self.raw.color("noteBkgColor", "#fff5ad"),
note_text: self.raw.color("noteTextColor", "black"),
label_background: self.raw.color("labelBackgroundColor", main_bkg.as_str()),
edge_label_background: self
.raw
.color("edgeLabelBackground", "rgba(232,232,232, 0.8)"),
transition_label_color: self
.raw
.optional_color("transitionLabelColor")
.or_else(|| self.raw.optional_color("tertiaryTextColor"))
.unwrap_or_else(|| self.common.text_color.clone()),
special_state_color,
inner_end_background,
end_outer_fill,
end_outer_stroke,
end_inner_stroke,
composite_background: self
.raw
.optional_color("compositeBackground")
.unwrap_or_else(|| background.to_string()),
state_bkg: self
.raw
.optional_color("stateBkg")
.unwrap_or_else(|| main_bkg.clone()),
state_border: self
.raw
.optional_color("stateBorder")
.unwrap_or_else(|| node_border.clone()),
composite_title_background: self
.raw
.color("compositeTitleBackground", main_bkg.as_str()),
state_label_color: self.raw.color("stateLabelColor", "#131300"),
drop_shadow: self
.raw
.optional_value("dropShadow")
.unwrap_or_else(|| "none".to_string()),
}
}
}
fn invert_hex_color(s: &str) -> Option<String> {
let s = s.trim();
let hex = s.strip_prefix('#')?;
if hex.len() != 6 {
return None;
}
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(format!("#{:02x}{:02x}{:02x}", 255 - r, 255 - g, 255 - b))
}
fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
let t = s.trim().strip_prefix('#').unwrap_or(s.trim());
if t.len() != 6 || !t.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let r = u8::from_str_radix(&t[0..2], 16).ok()?;
let g = u8::from_str_radix(&t[2..4], 16).ok()?;
let b = u8::from_str_radix(&t[4..6], 16).ok()?;
Some((r, g, b))
}
fn adjust_hex_rgb(hex: &str, delta: i16) -> Option<String> {
let (r, g, b) = parse_hex_rgb(hex)?;
let adj = |c: u8| -> u8 {
let v = c as i16 + delta;
v.clamp(0, 255) as u8
};
Some(format!("#{:02x}{:02x}{:02x}", adj(r), adj(g), adj(b)))
}
fn fmt_rgb(r: u8, g: u8, b: u8) -> String {
format!("rgb({r}, {g}, {b})")
}
fn parse_rgb_css(s: &str) -> Option<(u8, u8, u8)> {
let inner = s.trim().strip_prefix("rgb(")?.strip_suffix(')')?;
let mut parts = inner.split(',').map(|p| p.trim());
let parse_channel = |part: &str| -> Option<u8> {
let value = part.parse::<f64>().ok()?;
if !value.is_finite() {
return None;
}
Some(value.round().clamp(0.0, 255.0) as u8)
};
let r = parse_channel(parts.next()?)?;
let g = parse_channel(parts.next()?)?;
let b = parse_channel(parts.next()?)?;
Some((r, g, b))
}
fn parse_hsl_css(s: &str) -> Option<(f64, f64, f64)> {
let inner = s.trim().strip_prefix("hsl(")?.strip_suffix(')')?;
let mut parts = inner.split(',').map(|p| p.trim());
let h = parts.next()?.parse::<f64>().ok()?;
let s = parts
.next()?
.strip_suffix('%')
.unwrap_or_default()
.parse::<f64>()
.ok()?;
let l = parts
.next()?
.strip_suffix('%')
.unwrap_or_default()
.parse::<f64>()
.ok()?;
Some((h, s, l))
}
fn hsl_to_rgb_u8(h_deg: f64, s_pct: f64, l_pct: f64) -> Option<(u8, u8, u8)> {
if !(h_deg.is_finite() && s_pct.is_finite() && l_pct.is_finite()) {
return None;
}
let h = (h_deg / 360.0).rem_euclid(1.0);
let s = (s_pct / 100.0).clamp(0.0, 1.0);
let l = (l_pct / 100.0).clamp(0.0, 1.0);
if s == 0.0 {
let v = (l * 255.0).round().clamp(0.0, 255.0) as u8;
return Some((v, v, v));
}
let q = if l < 0.5 {
l * (1.0 + s)
} else {
l + s - l * s
};
let p = 2.0 * l - q;
fn hue_to_rgb(p: f64, q: f64, mut t: f64) -> f64 {
if t < 0.0 {
t += 1.0;
}
if t > 1.0 {
t -= 1.0;
}
if t < 1.0 / 6.0 {
return p + (q - p) * 6.0 * t;
}
if t < 1.0 / 2.0 {
return q;
}
if t < 2.0 / 3.0 {
return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
}
p
}
let r = hue_to_rgb(p, q, h + 1.0 / 3.0);
let g = hue_to_rgb(p, q, h);
let b = hue_to_rgb(p, q, h - 1.0 / 3.0);
let to_u8 = |v: f64| (v * 255.0).round().clamp(0.0, 255.0) as u8;
Some((to_u8(r), to_u8(g), to_u8(b)))
}
fn css_color_to_rgb(s: &str) -> Option<(u8, u8, u8)> {
let t = s.trim();
if let Some(rgb) = parse_rgb_css(t) {
return Some(rgb);
}
if let Some(rgb) = parse_hex_rgb(t) {
return Some(rgb);
}
if let Some((h, s, l)) = parse_hsl_css(t) {
return hsl_to_rgb_u8(h, s, l);
}
None
}
fn css_color_to_rgb_string(s: &str) -> Option<String> {
let (r, g, b) = css_color_to_rgb(s)?;
Some(fmt_rgb(r, g, b))
}
fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
fn to_linear(channel: u8) -> f64 {
let v = channel as f64 / 255.0;
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
}
0.2126 * to_linear(r) + 0.7152 * to_linear(g) + 0.0722 * to_linear(b)
}
fn rgb_to_hsl_pct(r: u8, g: u8, b: u8) -> (f64, f64, f64) {
let r = r as f64 / 255.0;
let g = g as f64 / 255.0;
let b = b as f64 / 255.0;
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) / 2.0;
if (max - min).abs() < f64::EPSILON {
return (0.0, 0.0, l * 100.0);
}
let d = max - min;
let s = if l > 0.5 {
d / (2.0 - max - min)
} else {
d / (max + min)
};
let h = if (max - r).abs() < f64::EPSILON {
(g - b) / d + if g < b { 6.0 } else { 0.0 }
} else if (max - g).abs() < f64::EPSILON {
(b - r) / d + 2.0
} else {
(r - g) / d + 4.0
} / 6.0;
(h * 360.0, s * 100.0, l * 100.0)
}
fn derive_quadrant_point_fill(quadrant1_fill: &str, fallback: &str) -> String {
let Some((r, g, b)) = css_color_to_rgb(quadrant1_fill) else {
return fallback.to_string();
};
let (h, s, l) = rgb_to_hsl_pct(r, g, b);
let delta = if relative_luminance(r, g, b) < 0.5 {
10.0
} else {
-10.0
};
let adjusted_l = (l + delta).clamp(0.0, 100.0);
let Some((r, g, b)) = hsl_to_rgb_u8(h, s, adjusted_l) else {
return fallback.to_string();
};
fmt_rgb(r, g, b)
}
fn is_invalid_css_token(value: &str) -> bool {
let lower = value.trim().to_ascii_lowercase();
lower.is_empty()
|| lower.contains("nan")
|| lower.contains("undefined")
|| lower.contains("infinity")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn presentation_theme_node_diagram_uses_shared_fallbacks() {
let cfg = json!({});
let theme = PresentationTheme::new(&cfg);
let node = theme.node_diagram();
assert_eq!(node.common.text_color, "#333");
assert_eq!(node.common.line_color, "#333333");
assert_eq!(node.node_text_color, "#333");
assert_eq!(node.title_color, "#333");
assert_eq!(node.main_bkg, "#ECECFF");
assert_eq!(node.node_border, "#9370DB");
assert_eq!(node.arrowhead_color, "#333333");
assert_eq!(node.stroke_width, "1");
}
#[test]
fn presentation_theme_sequence_neo_uses_drop_shadow_for_label_box_filter() {
let cfg = json!({
"look": "neo",
"themeVariables": {
"dropShadow": "drop-shadow(1px 2px 3px rgba(0,0,0,.4))"
}
});
let theme = PresentationTheme::new(&cfg);
let sequence = theme.sequence_diagram();
assert_eq!(
sequence.label_box_filter,
"drop-shadow(1px 2px 3px rgba(0,0,0,.4))"
);
}
#[test]
fn presentation_theme_xychart_resolves_chart_roles() {
let cfg = json!({
"theme": "neo",
"themeVariables": {
"primaryColor": "#123456",
"primaryTextColor": "#f8fafc",
"background": "#010203",
"xyChart": {
"backgroundColor": "#0f172a",
"titleColor": "#f43f5e",
"xAxisLabelColor": "#22c55e",
"plotColorPalette": "#001122, #334455"
}
}
});
let xychart = PresentationTheme::new(&cfg).xychart();
assert_eq!(xychart.background_color, "#0f172a");
assert_eq!(xychart.title_color, "#f43f5e");
assert_eq!(xychart.x_axis_title_color, "#f8fafc");
assert_eq!(xychart.x_axis_label_color, "#22c55e");
assert_eq!(xychart.y_axis_line_color, "#f8fafc");
assert_eq!(
xychart.plot_color_palette,
vec!["#001122".to_string(), "#334455".to_string()]
);
}
#[test]
fn presentation_theme_quadrantchart_resolves_chart_roles() {
let cfg = json!({
"theme": "redux-dark",
"themeVariables": {
"primaryColor": "#123456",
"primaryTextColor": "#f8fafc",
"primaryBorderColor": "#445566",
"quadrant1Fill": "#010203",
"quadrant2Fill": "#020304",
"quadrant3Fill": "#030405",
"quadrant4Fill": "#040506",
"quadrantPointFill": "#facc15",
"quadrantPointTextFill": "#111827",
"quadrantXAxisTextFill": "#22c55e",
"quadrantYAxisTextFill": "#38bdf8",
"quadrantTitleFill": "#f43f5e",
"quadrantInternalBorderStrokeFill": "#aabbcc",
"quadrantExternalBorderStrokeFill": "#ddeeff"
}
});
let quadrant = PresentationTheme::new(&cfg).quadrantchart();
assert_eq!(quadrant.quadrant1_fill, "#010203");
assert_eq!(quadrant.quadrant2_fill, "#020304");
assert_eq!(quadrant.quadrant1_text_fill, "#f8fafc");
assert_eq!(quadrant.quadrant_point_fill, "#facc15");
assert_eq!(quadrant.quadrant_point_text_fill, "#111827");
assert_eq!(quadrant.quadrant_x_axis_text_fill, "#22c55e");
assert_eq!(quadrant.quadrant_y_axis_text_fill, "#38bdf8");
assert_eq!(quadrant.quadrant_title_fill, "#f43f5e");
assert_eq!(quadrant.quadrant_internal_border_stroke_fill, "#aabbcc");
assert_eq!(quadrant.quadrant_external_border_stroke_fill, "#ddeeff");
}
#[test]
fn presentation_theme_tree_view_resolves_tree_view_roles() {
let cfg = json!({
"themeVariables": {
"treeView": {
"labelFontSize": "20px",
"labelColor": "#FF0000",
"lineColor": "#00FF00"
}
}
});
let tree_view = PresentationTheme::new(&cfg).tree_view();
assert_eq!(tree_view.label_font_size, 20.0);
assert_eq!(tree_view.label_font_size_css, "20px");
assert_eq!(tree_view.label_color, "#FF0000");
assert_eq!(tree_view.line_color, "#00FF00");
}
#[test]
fn presentation_theme_tree_view_uses_default_tree_view_roles() {
let cfg = json!({});
let tree_view = PresentationTheme::new(&cfg).tree_view();
assert_eq!(tree_view.label_font_size, 16.0);
assert_eq!(tree_view.label_font_size_css, "16px");
assert_eq!(tree_view.label_color, "black");
assert_eq!(tree_view.line_color, "black");
}
#[test]
fn presentation_theme_eventmodeling_resolves_eventmodeling_roles() {
let cfg = json!({
"fontFamily": "Inter, sans-serif",
"themeVariables": {
"textColor": "#111111",
"emUiFill": "#fefefe",
"emUiStroke": "#222222",
"emCommandFill": "#DDEEFF",
"emCommandStroke": "#336699",
"emSwimlaneBackgroundOdd": "#fafafa",
"emSwimlaneBackgroundStroke": "#efefef",
"emRelationStroke": "#135790",
"emArrowhead": "#02468a"
}
});
let eventmodeling = PresentationTheme::new(&cfg).eventmodeling();
assert_eq!(eventmodeling.font_family_css, "Inter,sans-serif");
assert_eq!(eventmodeling.text_color, "#111111");
assert_eq!(eventmodeling.ui_fill, "#fefefe");
assert_eq!(eventmodeling.ui_stroke, "#222222");
assert_eq!(eventmodeling.command_fill, "#DDEEFF");
assert_eq!(eventmodeling.command_stroke, "#336699");
assert_eq!(eventmodeling.swimlane_background_fill, "#fafafa");
assert_eq!(eventmodeling.swimlane_background_stroke, "#efefef");
assert_eq!(eventmodeling.relation_stroke, "#135790");
assert_eq!(eventmodeling.arrowhead_fill, "#02468a");
}
#[test]
fn presentation_theme_eventmodeling_uses_default_eventmodeling_roles() {
let cfg = json!({});
let eventmodeling = PresentationTheme::new(&cfg).eventmodeling();
assert_eq!(
eventmodeling.font_family_css,
"\"trebuchet ms\",verdana,arial,sans-serif"
);
assert_eq!(eventmodeling.text_color, "#333");
assert_eq!(eventmodeling.ui_fill, "white");
assert_eq!(eventmodeling.ui_stroke, "#dbdada");
assert_eq!(eventmodeling.swimlane_background_fill, "rgb(250,250,250)");
assert_eq!(eventmodeling.swimlane_background_stroke, "rgb(240,240,240)");
assert_eq!(eventmodeling.relation_stroke, "#000");
assert_eq!(eventmodeling.arrowhead_fill, "#000000");
}
}