//! `theme1.xml`: colour scheme and font scheme. //! //! In the file the colour scheme is ordered dk1, lt1, dk2, lt2, accent1–6, hlink, folHlink, //! while cell colour `theme` indices (and the model's `Theme.colors`) use lt1, dk1, lt2, dk2, …. use std::fmt::Write as _; use gridcraft_model::Theme; use crate::xml::{El, esc_attr}; const MAX_SCRIPT_FONTS: usize = 64; const MAX_SCRIPT_FONT_CHARS: usize = 256; const SLOTS: [(&str, usize); 12] = [ ("dk1", 1), ("lt1", 0), ("dk2", 3), ("lt2", 2), ("accent1", 4), ("accent2", 5), ("accent3", 6), ("accent4", 7), ("accent5", 8), ("accent6", 9), ("hlink", 10), ("folHlink", 11), ]; fn scheme_rgb(e: &El) -> Option { if let Some(c) = e.child("srgbClr").and_then(|c| c.attr("val")) { return u32::from_str_radix(c.trim(), 16).ok().map(|v| v & 0xFFFFFF); } if let Some(c) = e.child("sysClr") { if let Some(v) = c.attr("lastClr").and_then(|v| u32::from_str_radix(v.trim(), 16).ok()) { return Some(v & 0xFFFFFF); } return match c.attr("val") { Some("windowText") => Some(0x000000), Some("window") => Some(0xFFFFFF), _ => None, }; } None } /// The typeface of `//` (`latin`, `ea` or `cs`), `None` when absent or /// empty (an empty typeface means "not set" and must not overwrite the default). fn typeface<'a>(fs: &'a El, font: &str, kind: &str) -> Option<&'a str> { fs.path(&[font, kind]).and_then(|e| e.attr("typeface")).filter(|f| !f.is_empty()) } pub fn read_theme(root: &El) -> Theme { let mut t = Theme::default(); if let Some(n) = root.attr("name") { t.name = n.to_string(); } let Some(el) = root.child("themeElements") else { return t }; if let Some(cs) = el.child("clrScheme") { for (name, idx) in SLOTS { if let Some(v) = cs.child(name).and_then(scheme_rgb) && let Some(slot) = t.colors.get_mut(idx) { *slot = v; } } } if let Some(fs) = el.child("fontScheme") { if let Some(f) = typeface(fs, "majorFont", "latin") { t.major_font = f.to_string(); } if let Some(f) = typeface(fs, "minorFont", "latin") { t.minor_font = f.to_string(); } if let Some(f) = typeface(fs, "majorFont", "ea") { t.major_font_ea = f.to_string(); } if let Some(f) = typeface(fs, "minorFont", "ea") { t.minor_font_ea = f.to_string(); } if let Some(f) = typeface(fs, "majorFont", "cs") { t.major_font_cs = f.to_string(); } if let Some(f) = typeface(fs, "minorFont", "cs") { t.minor_font_cs = f.to_string(); } t.major_font_scripts = fs.child("majorFont").map(script_fonts).unwrap_or_default(); t.minor_font_scripts = fs.child("minorFont").map(script_fonts).unwrap_or_default(); } t } /// The `` entries of a font collection, in file order. A hostile file is /// held to [`MAX_SCRIPT_FONTS`] entries of at most [`MAX_SCRIPT_FONT_CHARS`] characters each. fn script_fonts(collection: &El) -> Vec<(String, String)> { let capped = |v: Option<&str>| v.unwrap_or_default().chars().take(MAX_SCRIPT_FONT_CHARS).collect::(); collection.kids("font").take(MAX_SCRIPT_FONTS).map(|e| (capped(e.attr("script")), capped(e.attr("typeface")))).collect() } fn script_fonts_xml(fonts: &[(String, String)]) -> String { fonts.iter().map(|(script, face)| format!("", esc_attr(script), esc_attr(face))).collect() } /// A minimal, schema-complete theme part for `t`. pub fn write_theme(t: &Theme) -> String { let mut s = String::from("\n"); let name = if t.name.is_empty() { "Craft" } else { &t.name }; let _ = write!(s, "", esc_attr(name)); let _ = write!(s, "", esc_attr(name)); for (slot, idx) in SLOTS { let rgb = t.colors.get(idx).copied().unwrap_or(0) & 0xFFFFFF; let _ = write!(s, ""); } s.push_str(""); let _ = write!( s, "{}{}", esc_attr(name), esc_attr(&t.major_font), esc_attr(&t.major_font_ea), esc_attr(&t.major_font_cs), script_fonts_xml(&t.major_font_scripts), esc_attr(&t.minor_font), esc_attr(&t.minor_font_ea), esc_attr(&t.minor_font_cs), script_fonts_xml(&t.minor_font_scripts) ); // Format scheme: three entries per list, as the schema requires. s.push_str(""); for _ in 0..3 { s.push_str(""); } s.push_str(""); for w in [6350, 12700, 19050] { let _ = write!( s, "" ); } s.push_str(""); for _ in 0..3 { s.push_str(""); } s.push_str(""); for _ in 0..3 { s.push_str(""); } s.push_str(""); s } #[cfg(test)] mod tests { use super::*; /// A Japanese Office theme: the heading font sets latin, ea and cs; the body font only ea. const EA_THEME: &str = r#""#; #[test] fn roundtrip() { let mut t = Theme::default(); t.colors[0] = 0xFEFEFE; t.colors[1] = 0x101010; t.colors[9] = 0x123456; t.major_font = "Cambria".into(); t.name = "Mine".into(); let x = write_theme(&t); let back = read_theme(&crate::xml::parse(x.as_bytes()).unwrap()); assert_eq!(back, t); } #[test] fn ea_and_cs_typefaces_roundtrip() { let t = read_theme(&crate::xml::parse(EA_THEME.as_bytes()).unwrap()); assert_eq!(t.major_font, "Calibri"); assert_eq!(t.major_font_ea, "游ゴシック"); assert_eq!(t.major_font_cs, "Arial"); // No `` under the minor font: the default stays. assert_eq!(t.minor_font, Theme::default().minor_font); assert_eq!(t.minor_font_ea, "MS Pゴシック"); assert!(t.minor_font_cs.is_empty()); let x = write_theme(&t); assert!( x.contains(r#""#), "{x}" ); assert!( x.contains(r#""#), "{x}" ); assert_eq!(read_theme(&crate::xml::parse(x.as_bytes()).unwrap()), t); } #[test] fn theme_without_ea_or_cs_writes_empty_attributes() { let t = Theme::default(); let x = write_theme(&t); assert!(x.contains(r#""#), "{x}"); assert!(x.contains(r#""#), "{x}"); } #[test] fn explicit_empty_ea_and_cs_read_as_unset() { let xml = EA_THEME .replace(r#""#, r#""#) .replace(r#""#, r#""#); let t = read_theme(&crate::xml::parse(xml.as_bytes()).unwrap()); assert!(t.major_font_ea.is_empty() && t.minor_font_ea.is_empty()); assert_eq!(t.major_font_cs, "Arial"); let x = write_theme(&t); assert!(x.contains(r#""#), "{x}"); assert_eq!(read_theme(&crate::xml::parse(x.as_bytes()).unwrap()), t); } #[test] fn ea_and_cs_typefaces_are_escaped() { let t = Theme { major_font_ea: "游&ゴシック".into(), minor_font_cs: "A\"C\"".into(), ..Theme::default() }; let x = write_theme(&t); assert!(x.contains(r#""#), "{x}"); assert!(x.contains(r#""#), "{x}"); assert_eq!(read_theme(&crate::xml::parse(x.as_bytes()).unwrap()), t); } /// An Arabic Office theme: Arabic, Hebrew and Japanese faces in the per-script lists. const SCRIPT_THEME: &str = r#""#; #[test] fn per_script_typefaces_roundtrip_in_file_order() { let t = read_theme(&crate::xml::parse(SCRIPT_THEME.as_bytes()).unwrap()); let pairs = |v: &[(String, String)]| v.iter().map(|(s, f)| (s.clone(), f.clone())).collect::>(); let expect = |a: &str, j: &str| vec![("Arab".to_string(), a.to_string()), ("Hebr".to_string(), a.to_string()), ("Jpan".to_string(), j.to_string())]; assert_eq!(pairs(&t.major_font_scripts), expect("Times New Roman", "游ゴシック Light")); assert_eq!(pairs(&t.minor_font_scripts), expect("Arial", "游ゴシック")); let x = write_theme(&t); assert!( x.contains(r#""#), "{x}" ); assert_eq!(read_theme(&crate::xml::parse(x.as_bytes()).unwrap()), t); } #[test] fn hostile_per_script_lists_are_capped() { let entries: String = (0..100).map(|i| format!(r#""#, "x".repeat(300))).collect(); let xml = format!( r#"{entries}"# ); let t = read_theme(&crate::xml::parse(xml.as_bytes()).unwrap()); assert_eq!(t.major_font_scripts.len(), 64); assert!(t.major_font_scripts.iter().all(|(script, face)| script.chars().count() <= 256 && face.chars().count() <= 256)); } }