diff --git a/crates/drawingml/src/color.rs b/crates/drawingml/src/color.rs index ce90db16..c9b3696f 100644 --- a/crates/drawingml/src/color.rs +++ b/crates/drawingml/src/color.rs @@ -1,6 +1,8 @@ //! Colour: sRGB colours, theme colour slots and DrawingML-style colour transforms //! (tint, shade, luminance modulation/offset, alpha…), so theme-relative colours resolve the way //! presentation files expect. +use std::collections::BTreeMap; + use serde::{Deserialize, Serialize}; /// An sRGB colour with straight alpha, components 0–255. @@ -137,7 +139,7 @@ impl Rgba { /// The twelve theme colour slots, plus the mapped aliases (`bg1`, `tx1`…) and `phClr` (the /// placeholder colour a style matrix entry is applied with). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum SchemeSlot { Dk1, @@ -236,15 +238,62 @@ impl SchemeSlot { } } -/// A colour scheme: the twelve theme colours. +/// Rows of tints and shades computed under a colour of the colour galleries. +pub const VARIANT_ROWS: usize = 5; +/// The most rows an explicit `tints` list may hold. +pub const MAX_TINT_ROWS: usize = 10; +/// The most extra named colours a scheme holds: what `design.colors` and the dialog accept, and +/// what a file carries in either direction. +pub const MAX_EXTRA_COLORS: usize = 200; + +/// A named colour beyond the twelve slots (a brand palette entry). It is shown in the colour +/// galleries after the theme colours and can be named wherever a scheme colour name is accepted; +/// applying it stores its RGB, since files have no slot for it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtraColor { + pub name: String, + pub color: Rgba, + /// Explicit gallery rows under the colour (0 to [`MAX_TINT_ROWS`]; exactly these are shown) + /// in place of the computed tints and shades. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tints: Option>, +} + +/// One cell under a gallery colour: the colour shown and how it is applied (`mods` on the slot +/// when computed, the colour itself when it comes from an explicit `tints` override). +#[derive(Clone, Debug, PartialEq)] +pub struct GalleryVariant { + pub color: Rgba, + pub label: String, + pub mods: Option>, +} + +/// A colour scheme: the twelve theme colours, plus any number of extra named colours and +/// optional explicit tint rows (both extensions: a file keeps them in an `a:extLst` of the colour +/// scheme that other applications ignore, see [`crate::theme::EXT_EXTRA_COLORS`]). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ColorScheme { pub name: String, pub colors: [Rgba; 12], + /// Named colours shown after the theme colours, in order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub extra: Vec, + /// Explicit gallery rows per theme slot (0 to [`MAX_TINT_ROWS`]; exactly these are shown), + /// replacing the computed tints and shades. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub tints: BTreeMap>, + /// Whether a colour without an explicit list gets the computed tint and shade rows (the + /// default); `false` shows no rows under it. + #[serde(default = "crate::yes", skip_serializing_if = "Clone::clone")] + pub computed_tints: bool, } impl ColorScheme { + pub fn new(name: impl Into, colors: [Rgba; 12]) -> Self { + ColorScheme { name: name.into(), colors, extra: Vec::new(), tints: BTreeMap::new(), computed_tints: true } + } pub fn get(&self, slot: SchemeSlot) -> Rgba { let i = match slot { SchemeSlot::Dk1 | SchemeSlot::Tx1 => 0, @@ -270,6 +319,36 @@ impl ColorScheme { *v = c; } } + /// The extra colour called `name` (case-insensitive, trimmed), if any. + pub fn extra_color(&self, name: &str) -> Option<&ExtraColor> { + let name = name.trim(); + if name.is_empty() { + return None; + } + self.extra.iter().find(|e| e.name.trim().eq_ignore_ascii_case(name)) + } + /// The gallery rows under theme colour `slot`: exactly its `tints` list where given, else the + /// computed tints and shades when `computed_tints`, else none. + pub fn slot_variants(&self, slot: SchemeSlot) -> Vec { + variants_of(self.get(slot), self.tints.get(&slot).map(Vec::as_slice), self.computed_tints) + } + /// The gallery rows under extra colour `i` (as for a slot). + pub fn extra_variants(&self, i: usize) -> Vec { + self.extra.get(i).map(|e| variants_of(e.color, e.tints.as_deref(), self.computed_tints)).unwrap_or_default() + } +} + +/// The cells under `base`: exactly the explicit `tints` when there is a list (possibly none), the +/// [`VARIANT_ROWS`] computed tints and shades when `computed`, nothing otherwise. +pub fn variants_of(base: Rgba, tints: Option<&[Rgba]>, computed: bool) -> Vec { + match tints { + Some(list) => list.iter().take(MAX_TINT_ROWS).map(|c| GalleryVariant { color: *c, label: format!("#{}", c.hex()), mods: None }).collect(), + None if computed => theme_variants(base) + .into_iter() + .map(|(mods, label)| GalleryVariant { color: apply(base, &mods), label: label.to_string(), mods: Some(mods) }) + .collect(), + None => Vec::new(), + } } /// One colour transform, in file units (percentages ×1000, angles ×60000), applied in order. @@ -972,7 +1051,7 @@ mod tests { #[test] fn scheme_slots() { - let mut s = ColorScheme { name: "t".into(), colors: [Rgba::BLACK; 12] }; + let mut s = ColorScheme::new("t", [Rgba::BLACK; 12]); s.set(SchemeSlot::Accent3, Rgba::WHITE); assert_eq!(s.get(SchemeSlot::Accent3), Rgba::WHITE); assert_eq!(s.get(SchemeSlot::Tx1), Rgba::BLACK); @@ -1003,4 +1082,70 @@ mod tests { assert_eq!(system("btnFace"), Some(Rgba::rgb(0xF0, 0xF0, 0xF0))); assert_eq!(system("scrollBar"), None); } + + #[test] + fn variants_are_the_list_given_else_computed_else_none() { + let base = Rgba::rgb(0x4D, 0xA0, 0x60); + let computed = variants_of(base, None, true); + assert_eq!(computed.len(), VARIANT_ROWS); + assert!(computed.iter().all(|v| v.mods.is_some())); + assert_eq!(computed[0].label, "Lighter 80%"); + assert!(variants_of(base, None, false).is_empty(), "no list and no computed rows: nothing"); + let two = [Rgba::rgb(0xC7, 0xEB, 0xBA), Rgba::rgb(0x0D, 0x4A, 0x21)]; + for computed in [true, false] { + let v = variants_of(base, Some(&two), computed); + assert_eq!(v.len(), 2, "exactly the list, no filling"); + assert_eq!(v[0], GalleryVariant { color: two[0], label: "#C7EBBA".into(), mods: None }); + assert_eq!(v[1].color, two[1]); + assert!(variants_of(base, Some(&[]), computed).is_empty(), "an empty list: no rows"); + } + assert_eq!(variants_of(base, Some(&[Rgba::WHITE; 20]), true).len(), MAX_TINT_ROWS, "a long list is cut"); + let mut s = ColorScheme::new("t", [base; 12]); + s.tints.insert(SchemeSlot::Accent5, two.to_vec()); + s.tints.insert(SchemeSlot::Accent6, vec![]); + assert_eq!(s.slot_variants(SchemeSlot::Accent5).len(), 2); + assert!(s.slot_variants(SchemeSlot::Accent6).is_empty()); + assert_eq!(s.slot_variants(SchemeSlot::Accent4), computed); + s.computed_tints = false; + assert_eq!(s.slot_variants(SchemeSlot::Accent5).len(), 2); + assert!(s.slot_variants(SchemeSlot::Accent4).is_empty()); + } + + #[test] + fn extra_colours_by_name() { + let mut s = ColorScheme::new("t", [Rgba::BLACK; 12]); + let sand = Rgba::rgb(0xEB, 0xE5, 0xE0); + s.extra.push(ExtraColor { name: "Sand".into(), color: sand, tints: Some(vec![Rgba::WHITE]) }); + assert_eq!(s.extra_color("sand").map(|e| e.color), Some(sand)); + assert_eq!(s.extra_color(" SAND ").map(|e| e.color), Some(sand)); + assert_eq!(s.extra_color("Navy"), None); + assert_eq!(s.extra_color(""), None); + assert_eq!(s.extra_variants(0), vec![GalleryVariant { color: Rgba::WHITE, label: "#FFFFFF".into(), mods: None }]); + assert!(s.extra_variants(7).is_empty()); + s.extra.push(ExtraColor { name: "Navy".into(), color: Rgba::rgb(0, 0, 0x61), tints: None }); + assert_eq!(s.extra_variants(1).len(), VARIANT_ROWS); + s.computed_tints = false; + assert!(s.extra_variants(1).is_empty()); + assert_eq!(s.extra_variants(0).len(), 1); + } + + #[test] + fn scheme_json_keeps_extras_and_reads_old_files() { + let mut s = ColorScheme::new("KTH", [Rgba::WHITE; 12]); + s.extra.push(ExtraColor { name: "Sand".into(), color: Rgba::rgb(0xEB, 0xE5, 0xE0), tints: None }); + s.tints.insert(SchemeSlot::Accent1, vec![Rgba::BLACK; VARIANT_ROWS]); + s.tints.insert(SchemeSlot::Accent2, vec![]); + s.computed_tints = false; + let json = serde_json::to_string(&s).unwrap(); + assert!(json.contains("\"accent1\"") && json.contains("\"computedTints\":false"), "{json}"); + assert_eq!(serde_json::from_str::(&json).unwrap(), s); + s.computed_tints = true; + assert!(!serde_json::to_string(&s).unwrap().contains("computedTints"), "the default is not written"); + // A file written before the extension has neither key. + let plain = ColorScheme::new("t", [Rgba::BLACK; 12]); + let json = serde_json::to_string(&plain).unwrap(); + assert!(!json.contains("extra") && !json.contains("tints"), "{json}"); + assert_eq!(serde_json::from_str::(&json).unwrap(), plain); + assert!(plain.computed_tints); + } } diff --git a/crates/drawingml/src/part.rs b/crates/drawingml/src/part.rs index 9797ac36..27215f85 100644 --- a/crates/drawingml/src/part.rs +++ b/crates/drawingml/src/part.rs @@ -192,4 +192,51 @@ mod tests { assert_eq!(back.format.fills, t.format.fills); assert_eq!(back.format.lines.len(), 3); } + + #[test] + fn extra_theme_colours_and_tint_rows_survive_writing_and_reading() { + use crate::color::{ExtraColor, MAX_EXTRA_COLORS, MAX_TINT_ROWS, Rgba, VARIANT_ROWS}; + let mut t = Theme::default(); + let c = &mut t.colors; + c.computed_tints = false; + c.tints.insert(SchemeSlot::Accent5, vec![Rgba::rgb(0xC7, 0xEB, 0xBA), Rgba::rgb(0x0D, 0x4A, 0x21)]); + c.tints.insert(SchemeSlot::Accent6, vec![]); + c.tints.insert(SchemeSlot::Dk2, vec![Rgba::WHITE; VARIANT_ROWS]); + c.extra.push(ExtraColor { name: "Sand".into(), color: Rgba::rgb(0xEB, 0xE5, 0xE0), tints: None }); + c.extra.push(ExtraColor { name: "Broken white".into(), color: Rgba::rgb(0xFC, 0xFC, 0xFC), tints: Some(vec![]) }); + c.extra.push(ExtraColor { + name: "Grey & \"tones\"".into(), + color: Rgba::rgb(0xA5, 0xA5, 0xA5), + tints: Some(vec![Rgba::rgb(0xE6, 0xE6, 0xE6), Rgba::rgb(0x32, 0x32, 0x32)]), + }); + let xml = String::from_utf8(crate::theme::theme_xml(&mut Part::new(), &t)).unwrap(); + let back = crate::theme::read_theme(&mut Part::new(), &parse(xml.as_bytes()).unwrap()); + assert_eq!(back.colors, t.colors, "lists of 0, 2 and 5 rows, no list, and computed_tints"); + let (cs_start, cs_end) = (xml.find("").unwrap()); + let ext = xml.find("").unwrap(); + assert!(cs_start < ext && ext < cs_end, "the extension belongs to the colour scheme"); + assert!(xml.contains("computed=\"0\""), "{xml}"); + assert!(xml.contains(""), "{xml}"); + assert!(xml.contains(""), "{xml}"); + assert!(xml.contains("Grey & "tones""), "{xml}"); + // A plain scheme writes no extension; `computed_tints = false` alone is kept. + let plain = String::from_utf8(crate::theme::theme_xml(&mut Part::new(), &Theme::default())).unwrap(); + assert!(!plain.contains("extraColors"), "{plain}"); + let mut t = Theme::default(); + t.colors.computed_tints = false; + let xml = crate::theme::theme_xml(&mut Part::new(), &t); + assert!(!crate::theme::read_theme(&mut Part::new(), &parse(&xml).unwrap()).colors.computed_tints); + // Lists and extras past the limits are cut the same way on writing and reading. + let mut t = Theme::default(); + let long: Vec = (0..MAX_TINT_ROWS + 3).map(|i| Rgba::rgb(i as u8, 0, 0)).collect(); + t.colors.tints.insert(SchemeSlot::Accent1, long.clone()); + for i in 0..MAX_EXTRA_COLORS + 7 { + t.colors.extra.push(ExtraColor { name: format!("Extra {i}"), color: Rgba::rgb((i % 256) as u8, 0, 0), tints: Some(long.clone()) }); + } + let xml = crate::theme::theme_xml(&mut Part::new(), &t); + let back = crate::theme::read_theme(&mut Part::new(), &parse(&xml).unwrap()).colors; + assert_eq!(back.tints.get(&SchemeSlot::Accent1).map(Vec::as_slice), Some(&long[..MAX_TINT_ROWS])); + assert_eq!(back.extra.len(), MAX_EXTRA_COLORS); + assert_eq!(back.extra[0].tints.as_deref(), Some(&long[..MAX_TINT_ROWS])); + } } diff --git a/crates/drawingml/src/theme.rs b/crates/drawingml/src/theme.rs index 79c99277..47994f83 100644 --- a/crates/drawingml/src/theme.rs +++ b/crates/drawingml/src/theme.rs @@ -73,7 +73,7 @@ impl Default for Theme { let colors = ["000000", "FFFFFF", "1E2B3C", "EDEBE7", "2E6FD8", "F26B1D", "2EA36F", "8A5CD8", "D83F6B", "E3B21C", "2E6FD8", "7A5BA6"]; Theme { name: "Theme".into(), - colors: ColorScheme { name: "Default".into(), colors: colors.map(hex) }, + colors: ColorScheme::new("Default", colors.map(hex)), fonts: FontScheme::default(), format: default_format(), raw_extra: None, @@ -182,6 +182,13 @@ mod tests { } } +/// The `a:ext` of a colour scheme that holds its extra named colours and explicit tint rows +/// ([`ColorScheme::extra`], [`ColorScheme::tints`], [`ColorScheme::computed_tints`]). The URI and +/// namespace are those of the files already written with it. +pub const EXT_EXTRA_COLORS: &str = "{D3C7C0DE-0C0A-4E57-9A1E-5C0E0C0DEC01}"; +/// The namespace of the elements inside [`EXT_EXTRA_COLORS`]. +pub const NS_EXTRA_COLORS: &str = "urn:deckcraft:theme:extra-colors:1"; + mod read; mod write; pub use read::read_theme; diff --git a/crates/drawingml/src/theme/read.rs b/crates/drawingml/src/theme/read.rs index 74e34f22..29e49dc9 100644 --- a/crates/drawingml/src/theme/read.rs +++ b/crates/drawingml/src/theme/read.rs @@ -28,7 +28,7 @@ pub fn read_theme(src: &mut dyn Source, root: &El) -> Theme { let mut t = Theme { name: root.attr_local("name").unwrap_or("Theme").to_string(), ..Default::default() }; let Some(te) = root.child(Local("themeElements")) else { return t }; if let Some(cs) = te.child(Local("clrScheme")) { - let mut scheme = ColorScheme { name: cs.attr_local("name").unwrap_or("").to_string(), colors: t.colors.colors }; + let mut scheme = ColorScheme::new(cs.attr_local("name").unwrap_or(""), t.colors.colors); for slot in SchemeSlot::THEME { if let Some(c) = cs.child(Local(slot.xml_name())).and_then(dml::color) { let rgb = match c.base { @@ -40,6 +40,7 @@ pub fn read_theme(src: &mut dyn Source, root: &El) -> Theme { scheme.set(slot, rgb); } } + extra_colors(cs, &mut scheme); t.colors = scheme; } if let Some(fs) = te.child(Local("fontScheme")) { @@ -99,6 +100,36 @@ pub fn read_theme(src: &mut dyn Source, root: &El) -> Theme { t } +/// The extra named colours and tint rows (see `write::extra_colors`); a scheme saved by another +/// application has none. +fn extra_colors(cs: &El, scheme: &mut ColorScheme) { + let Some(x) = cs + .child(Local("extLst")) + .and_then(|l| l.children(Local("ext")).find(|e| e.attr_local("uri") == Some(super::EXT_EXTRA_COLORS))) + .and_then(|e| e.child(Local("extraColors"))) + else { + return; + }; + let clrs = |e: &El| -> Vec { + e.children(Local("clr")).filter_map(|c| c.attr_local("val").and_then(Rgba::from_hex)).take(crate::color::MAX_TINT_ROWS).collect() + }; + scheme.computed_tints = x.attr_local("computed") != Some("0"); + for t in x.children(Local("tints")).take(SchemeSlot::THEME.len()) { + if let Some(slot) = t.attr_local("slot").and_then(SchemeSlot::from_xml) + && SchemeSlot::THEME.contains(&slot) + { + scheme.tints.insert(slot, clrs(t)); + } + } + for c in x.children(Local("color")).take(crate::color::MAX_EXTRA_COLORS) { + let (Some(name), Some(color)) = (c.attr_local("name"), c.attr_local("val").and_then(Rgba::from_hex)) else { continue }; + if name.trim().is_empty() { + continue; + } + scheme.extra.push(crate::color::ExtraColor { name: name.to_string(), color, tints: c.child(Local("tints")).map(clrs) }); + } +} + fn pad(v: &mut Vec, d: &[T]) { while v.len() < 3 { match d.get(v.len()) { diff --git a/crates/drawingml/src/theme/write.rs b/crates/drawingml/src/theme/write.rs index f6bb33b5..5e95e57e 100644 --- a/crates/drawingml/src/theme/write.rs +++ b/crates/drawingml/src/theme/write.rs @@ -1,6 +1,6 @@ //! Theme parts. -use crate::color::SchemeSlot; +use crate::color::{ColorScheme, Rgba, SchemeSlot}; use crate::style::{ColorRef, Effects, Fill}; use crate::theme::{FontSet, Theme, default_format}; @@ -28,6 +28,44 @@ fn style_fill(f: &Fill) -> Fill { } } +/// The scheme's extra named colours, explicit tint rows and `computed_tints`, as an `a:ext` +/// ([`super::EXT_EXTRA_COLORS`]) after the twelve slots: `` holding +/// `…` per slot with a list (an empty +/// list is an empty element) and `(… +/// when it has a list)` per extra colour. Nothing when there is nothing to keep. +fn extra_colors(w: &mut Writer, c: &ColorScheme) { + if c.extra.is_empty() && c.tints.is_empty() && c.computed_tints { + return; + } + w.open("a:extLst", &[]); + w.tag("a:ext").attr("uri", super::EXT_EXTRA_COLORS).open(); + w.tag("dc:extraColors").attr("xmlns:dc", super::NS_EXTRA_COLORS).attr_opt("computed", (!c.computed_tints).then_some("0")).open(); + for (slot, tints) in &c.tints { + w.tag("dc:tints").attr("slot", slot.xml_name()).open(); + rows(w, tints); + } + // What the reader takes back, no more. + for e in c.extra.iter().take(crate::color::MAX_EXTRA_COLORS) { + w.tag("dc:color").attr("name", &e.name).attr("val", e.color.hex()).open(); + if let Some(t) = &e.tints { + w.open("dc:tints", &[]); + rows(w, t); + } + w.close(); + } + w.close(); + w.close(); + w.close(); +} + +/// The rows of an open `dc:tints`, cut to `MAX_TINT_ROWS` as the reader takes them; closes it. +fn rows(w: &mut Writer, tints: &[Rgba]) { + for t in tints.iter().take(crate::color::MAX_TINT_ROWS) { + w.tag("dc:clr").attr("val", t.hex()).empty(); + } + w.close(); +} + pub fn theme_xml(out: &mut dyn Sink, t: &Theme) -> Vec { let mut w = Writer::document(); w.tag("a:theme").attr("xmlns:a", NS_A).attr("xmlns:r", NS_R).attr("name", if t.name.is_empty() { "Theme" } else { &t.name }).open(); @@ -39,6 +77,7 @@ pub fn theme_xml(out: &mut dyn Sink, t: &Theme) -> Vec { w.tag("a:srgbClr").attr("val", t.colors.get(slot).hex()).empty(); w.close(); } + extra_colors(&mut w, &t.colors); w.close(); w.tag("a:fontScheme").attr("name", if t.fonts.name.is_empty() { &t.name } else { &t.fonts.name }).open(); font_set(&mut w, "a:majorFont", &t.fonts.major);