use quick_xml::Reader;
use quick_xml::events::Event;
use crate::error::Result;
use crate::namespace::matches_local_name;
#[derive(Debug, Clone, Default)]
pub struct Theme {
pub colors: ThemeColors,
pub major_font: Option<String>,
pub minor_font: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ThemeColors {
pub dk1: Option<String>,
pub dk2: Option<String>,
pub lt1: Option<String>,
pub lt2: Option<String>,
pub accent1: Option<String>,
pub accent2: Option<String>,
pub accent3: Option<String>,
pub accent4: Option<String>,
pub accent5: Option<String>,
pub accent6: Option<String>,
pub hlink: Option<String>,
pub fol_hlink: Option<String>,
}
impl ThemeColors {
pub fn get(&self, name: &str) -> Option<&str> {
match name {
"dark1" | "dk1" => self.dk1.as_deref(),
"dark2" | "dk2" => self.dk2.as_deref(),
"light1" | "lt1" => self.lt1.as_deref(),
"light2" | "lt2" => self.lt2.as_deref(),
"accent1" => self.accent1.as_deref(),
"accent2" => self.accent2.as_deref(),
"accent3" => self.accent3.as_deref(),
"accent4" => self.accent4.as_deref(),
"accent5" => self.accent5.as_deref(),
"accent6" => self.accent6.as_deref(),
"hlink" | "hyperlink" => self.hlink.as_deref(),
"folHlink" | "followedHyperlink" => self.fol_hlink.as_deref(),
"text1" => self.dk1.as_deref(),
"text2" => self.dk2.as_deref(),
"background1" | "bg1" => self.lt1.as_deref(),
"background2" | "bg2" => self.lt2.as_deref(),
_ => None,
}
}
}
impl Theme {
pub fn from_xml(xml: &[u8]) -> Result<Self> {
let mut reader = Reader::from_reader(xml);
reader.config_mut().trim_text(true);
let mut theme = Theme::default();
let mut buf = Vec::new();
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) => {
let name = e.name();
if matches_local_name(name.as_ref(), b"clrScheme") {
parse_color_scheme(&mut reader, &mut theme.colors)?;
} else if matches_local_name(name.as_ref(), b"majorFont") {
theme.major_font = parse_font_scheme(&mut reader, b"majorFont")?;
} else if matches_local_name(name.as_ref(), b"minorFont") {
theme.minor_font = parse_font_scheme(&mut reader, b"minorFont")?;
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(e.into()),
_ => {}
}
buf.clear();
}
Ok(theme)
}
}
fn parse_color_scheme(reader: &mut Reader<&[u8]>, colors: &mut ThemeColors) -> Result<()> {
let mut buf = Vec::new();
let mut current_slot: Option<String> = None;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) => {
let name = e.name();
let local = local_name(name.as_ref());
match local {
b"dk1" | b"dk2" | b"lt1" | b"lt2" | b"accent1" | b"accent2" | b"accent3"
| b"accent4" | b"accent5" | b"accent6" | b"hlink" | b"folHlink" => {
current_slot = Some(std::str::from_utf8(local).unwrap_or("").to_string());
}
_ => {}
}
}
Ok(Event::Empty(ref e)) => {
let name = e.name();
let local = local_name(name.as_ref());
if let Some(ref slot) = current_slot {
let color = if matches_local_name(name.as_ref(), b"srgbClr") {
get_attr(e, b"val")
} else if matches_local_name(name.as_ref(), b"sysClr") {
get_attr(e, b"lastClr").or_else(|| get_attr(e, b"val"))
} else {
None
};
if let Some(hex) = color {
match slot.as_str() {
"dk1" => colors.dk1 = Some(hex),
"dk2" => colors.dk2 = Some(hex),
"lt1" => colors.lt1 = Some(hex),
"lt2" => colors.lt2 = Some(hex),
"accent1" => colors.accent1 = Some(hex),
"accent2" => colors.accent2 = Some(hex),
"accent3" => colors.accent3 = Some(hex),
"accent4" => colors.accent4 = Some(hex),
"accent5" => colors.accent5 = Some(hex),
"accent6" => colors.accent6 = Some(hex),
"hlink" => colors.hlink = Some(hex),
"folHlink" => colors.fol_hlink = Some(hex),
_ => {}
}
}
} else {
match local {
b"dk1" | b"dk2" | b"lt1" | b"lt2" | b"accent1" | b"accent2"
| b"accent3" | b"accent4" | b"accent5" | b"accent6" | b"hlink"
| b"folHlink" => {
}
_ => {}
}
}
}
Ok(Event::End(ref e)) => {
let name = e.name();
let local = local_name(name.as_ref());
match local {
b"dk1" | b"dk2" | b"lt1" | b"lt2" | b"accent1" | b"accent2" | b"accent3"
| b"accent4" | b"accent5" | b"accent6" | b"hlink" | b"folHlink" => {
current_slot = None;
}
b"clrScheme" => break,
_ => {}
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(e.into()),
_ => {}
}
buf.clear();
}
Ok(())
}
fn parse_font_scheme(reader: &mut Reader<&[u8]>, end_tag: &[u8]) -> Result<Option<String>> {
let mut buf = Vec::new();
let mut latin_font = None;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Empty(ref e)) => {
if matches_local_name(e.name().as_ref(), b"latin") {
latin_font = get_attr(e, b"typeface");
}
}
Ok(Event::End(ref e)) if matches_local_name(e.name().as_ref(), end_tag) => break,
Ok(Event::Eof) => break,
Err(e) => return Err(e.into()),
_ => {}
}
buf.clear();
}
Ok(latin_font)
}
fn local_name(qname: &[u8]) -> &[u8] {
match qname.iter().position(|&b| b == b':') {
Some(pos) => &qname[pos + 1..],
None => qname,
}
}
fn get_attr(e: &quick_xml::events::BytesStart, attr_name: &[u8]) -> Option<String> {
for attr in e.attributes().flatten() {
let key = attr.key.as_ref();
let local = local_name(key);
if local == attr_name {
return std::str::from_utf8(&attr.value).ok().map(|s| s.to_string());
}
}
None
}
pub fn apply_tint_shade(hex: &str, tint_val: Option<u8>, shade_val: Option<u8>) -> String {
let hex = hex.trim_start_matches('#');
if hex.len() < 6 {
return hex.to_string();
}
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0) as f64 / 255.0;
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0) as f64 / 255.0;
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0) as f64 / 255.0;
let (r, g, b) = if let Some(tint) = tint_val {
let t = tint as f64 / 255.0;
(r + (1.0 - r) * t, g + (1.0 - g) * t, b + (1.0 - b) * t)
} else if let Some(shade) = shade_val {
let s = shade as f64 / 255.0;
(r * s, g * s, b * s)
} else {
(r, g, b)
};
format!(
"{:02X}{:02X}{:02X}",
(r.clamp(0.0, 1.0) * 255.0) as u8,
(g.clamp(0.0, 1.0) * 255.0) as u8,
(b.clamp(0.0, 1.0) * 255.0) as u8,
)
}
impl From<&oxml_drawing::theme::CT_OfficeStyleSheet> for Theme {
fn from(theme: &oxml_drawing::theme::CT_OfficeStyleSheet) -> Self {
let colours = &theme.theme_elements.color_scheme;
let fonts = &theme.theme_elements.font_scheme;
Self {
colors: ThemeColors {
dk1: legacy_colour(colours.color(oxml_drawing::color::ThemeColorSlot::Dark1)),
dk2: legacy_colour(colours.color(oxml_drawing::color::ThemeColorSlot::Dark2)),
lt1: legacy_colour(colours.color(oxml_drawing::color::ThemeColorSlot::Light1)),
lt2: legacy_colour(colours.color(oxml_drawing::color::ThemeColorSlot::Light2)),
accent1: legacy_colour(colours.color(oxml_drawing::color::ThemeColorSlot::Accent1)),
accent2: legacy_colour(colours.color(oxml_drawing::color::ThemeColorSlot::Accent2)),
accent3: legacy_colour(colours.color(oxml_drawing::color::ThemeColorSlot::Accent3)),
accent4: legacy_colour(colours.color(oxml_drawing::color::ThemeColorSlot::Accent4)),
accent5: legacy_colour(colours.color(oxml_drawing::color::ThemeColorSlot::Accent5)),
accent6: legacy_colour(colours.color(oxml_drawing::color::ThemeColorSlot::Accent6)),
hlink: legacy_colour(colours.color(oxml_drawing::color::ThemeColorSlot::Hyperlink)),
fol_hlink: legacy_colour(
colours.color(oxml_drawing::color::ThemeColorSlot::FollowedHyperlink),
),
},
major_font: Some(fonts.major_font.latin.typeface.clone()),
minor_font: Some(fonts.minor_font.latin.typeface.clone()),
}
}
}
fn legacy_colour(colour: &oxml_drawing::color::ColorChoice) -> Option<String> {
match colour {
oxml_drawing::color::ColorChoice::Srgb { value, .. } => Some(value.to_string()),
oxml_drawing::color::ColorChoice::System {
value, last_color, ..
} => Some(
last_color
.map(|resolved| resolved.to_string())
.unwrap_or_else(|| value.clone()),
),
oxml_drawing::color::ColorChoice::Scheme { .. }
| oxml_drawing::color::ColorChoice::Preset { .. } => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shared_theme_adapter_matches_the_legacy_theme_projection() {
let shared = oxml_drawing::theme::CT_OfficeStyleSheet::from_xml(
oxml_drawing::theme::OFFICE_DEFAULT_XML.as_bytes(),
)
.unwrap();
let legacy = Theme::from_xml(oxml_drawing::theme::OFFICE_DEFAULT_XML.as_bytes()).unwrap();
let projected = Theme::from(&shared);
for slot in [
"dk1", "dk2", "lt1", "lt2", "accent1", "accent2", "accent3", "accent4", "accent5",
"accent6", "hlink", "folHlink",
] {
assert_eq!(projected.colors.get(slot), legacy.colors.get(slot));
}
assert_eq!(projected.major_font, legacy.major_font);
assert_eq!(projected.minor_font, legacy.minor_font);
}
#[test]
fn shared_theme_adapter_does_not_project_unresolved_colour_forms() {
let mut shared = oxml_drawing::theme::CT_OfficeStyleSheet::office_default();
shared.theme_elements.color_scheme.accent1 = oxml_drawing::color::ColorChoice::Scheme {
value: "accent2".to_owned(),
transforms: Vec::new(),
raw_children: oxml_drawing::order::OrderedRawChildren::default(),
};
shared.theme_elements.color_scheme.accent2 = oxml_drawing::color::ColorChoice::Preset {
value: "red".to_owned(),
transforms: Vec::new(),
raw_children: oxml_drawing::order::OrderedRawChildren::default(),
};
shared.theme_elements.color_scheme.accent3 = oxml_drawing::color::ColorChoice::System {
value: "windowText".to_owned(),
last_color: None,
transforms: Vec::new(),
raw_children: oxml_drawing::order::OrderedRawChildren::default(),
};
shared.theme_elements.color_scheme.accent4 = oxml_drawing::color::ColorChoice::System {
value: "window".to_owned(),
last_color: Some(oxml_drawing::color::RgbColor::new(0xab, 0xcd, 0xef)),
transforms: Vec::new(),
raw_children: oxml_drawing::order::OrderedRawChildren::default(),
};
let projected = Theme::from(&shared);
assert_eq!(projected.colors.accent1, None);
assert_eq!(projected.colors.accent2, None);
assert_eq!(projected.colors.accent3.as_deref(), Some("windowText"));
assert_eq!(projected.colors.accent4.as_deref(), Some("ABCDEF"));
}
#[test]
fn parse_office_theme() {
let xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Office Theme">
<a:themeElements>
<a:clrScheme name="Office">
<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>
<a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>
<a:dk2><a:srgbClr val="44546A"/></a:dk2>
<a:lt2><a:srgbClr val="E7E6E6"/></a:lt2>
<a:accent1><a:srgbClr val="4472C4"/></a:accent1>
<a:accent2><a:srgbClr val="ED7D31"/></a:accent2>
<a:accent3><a:srgbClr val="A5A5A5"/></a:accent3>
<a:accent4><a:srgbClr val="FFC000"/></a:accent4>
<a:accent5><a:srgbClr val="5B9BD5"/></a:accent5>
<a:accent6><a:srgbClr val="70AD47"/></a:accent6>
<a:hlink><a:srgbClr val="0563C1"/></a:hlink>
<a:folHlink><a:srgbClr val="954F72"/></a:folHlink>
</a:clrScheme>
<a:fontScheme name="Office">
<a:majorFont>
<a:latin typeface="Calibri Light"/>
<a:ea typeface=""/>
<a:cs typeface=""/>
</a:majorFont>
<a:minorFont>
<a:latin typeface="Calibri"/>
<a:ea typeface=""/>
<a:cs typeface=""/>
</a:minorFont>
</a:fontScheme>
</a:themeElements>
</a:theme>"#;
let theme = Theme::from_xml(xml).unwrap();
assert_eq!(theme.colors.dk1.as_deref(), Some("000000"));
assert_eq!(theme.colors.lt1.as_deref(), Some("FFFFFF"));
assert_eq!(theme.colors.dk2.as_deref(), Some("44546A"));
assert_eq!(theme.colors.lt2.as_deref(), Some("E7E6E6"));
assert_eq!(theme.colors.accent1.as_deref(), Some("4472C4"));
assert_eq!(theme.colors.accent2.as_deref(), Some("ED7D31"));
assert_eq!(theme.colors.hlink.as_deref(), Some("0563C1"));
assert_eq!(theme.colors.fol_hlink.as_deref(), Some("954F72"));
assert_eq!(theme.major_font.as_deref(), Some("Calibri Light"));
assert_eq!(theme.minor_font.as_deref(), Some("Calibri"));
}
#[test]
fn theme_color_lookup() {
let colors = ThemeColors {
dk1: Some("000000".to_string()),
lt1: Some("FFFFFF".to_string()),
accent1: Some("4472C4".to_string()),
..Default::default()
};
assert_eq!(colors.get("dark1"), Some("000000"));
assert_eq!(colors.get("text1"), Some("000000"));
assert_eq!(colors.get("light1"), Some("FFFFFF"));
assert_eq!(colors.get("background1"), Some("FFFFFF"));
assert_eq!(colors.get("accent1"), Some("4472C4"));
assert_eq!(colors.get("nonexistent"), None);
}
#[test]
fn tint_shade_modifiers() {
let result = apply_tint_shade("FF0000", Some(128), None);
assert_eq!(result, "FF8080");
let result = apply_tint_shade("FF0000", None, None);
assert_eq!(result, "FF0000");
}
}