From 8079878ae2b166c1ea8229e40eff7e08c916e532 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 19:22:03 -0500 Subject: [PATCH] craft-drawingml: the theme model (#940) * Add craft-drawingml: units, colours, preset shapes, custom geometry and text warps The parts of DrawingML that need no document model, moved from DeckCraft (60f91f78): crates/color, crates/geom (preset, warp, Xfrm and the EMU conversions) and crates/pptx/src/custgeom.rs with CustomPath from crates/model. The code is unchanged except that custgeom reads craft-xml trees and writes to a craft-xml Writer instead of DeckCraft's own XML helper, and matches elements by local name. The 59 tests of the moved code come with it; six more cover the custom geometry limits. * drawingml: evaluate a custom geometry's guide lists once per coordinate space read_cust_geom rebuilt Guides and evaluated avLst and gdLst again for every path: up to 1,000 paths times up to 10,000 guides, each name copied and hashed. Guides are now cached per path coordinate space (w, h). At most 16 spaces and 4 MiB of guide names are kept per geometry; paths in further spaces are left out. The first space is always kept, and a geometry without guides is not capped. Ported from storytold/deckcraft#1789. A list counts as having guides only when it has gd children, as that PR's review asked. * drawingml: the model types for fills, lines, text, tables, charts and diagrams Moved from DeckCraft (60f91f78) crates/model/src: style.rs, text.rs, table.rs, chart.rs, numfmt.rs and trendline.rs, plus Diagram, DiagramNode and the MediaId and SlideId newtypes from lib.rs. The files are unchanged except that deckcraft_color:: is crate::color:: and Fill and TableBorder allow clippy::large_enum_variant. Their 60 tests come with them. Readers and writers follow. * drawingml: the theme model (colour, font and format schemes) Moved from DeckCraft crates/model/src/theme.rs at 60f91f78: FontSet, ScriptFont, FontScheme, FormatScheme, Theme and default_format. The app's theme gallery, its variants and its own default theme stay in the app. Theme::default() is a plain theme built in place, so nothing here depends on a gallery. --- crates/drawingml/src/lib.rs | 3 +- crates/drawingml/src/theme.rs | 183 ++++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 crates/drawingml/src/theme.rs diff --git a/crates/drawingml/src/lib.rs b/crates/drawingml/src/lib.rs index 47d6ee10..1e47301d 100644 --- a/crates/drawingml/src/lib.rs +++ b/crates/drawingml/src/lib.rs @@ -11,7 +11,7 @@ //! - [`warp`]: preset text warps (`a:prstTxWarp`). //! - The model, without readers and writers yet: [`style`] (colour references, fills, lines, //! effects), [`text`] (text bodies, paragraphs, runs), [`table`], [`chart`] with [`numfmt`] -//! (number format codes) and [`diagram`] (SmartArt outlines). Media are named by [`MediaId`]; +//! (number format codes), [`theme`] (colour, font and format schemes) and [`diagram`] (SmartArt outlines). Media are named by [`MediaId`]; //! the bytes stay with the caller. //! //! Nothing here opens a package: readers take a [`craft_xml::El`] and writers a @@ -27,6 +27,7 @@ pub mod preset; pub mod style; pub mod table; pub mod text; +pub mod theme; mod trendline; pub mod warp; diff --git a/crates/drawingml/src/theme.rs b/crates/drawingml/src/theme.rs new file mode 100644 index 00000000..d4416e2f --- /dev/null +++ b/crates/drawingml/src/theme.rs @@ -0,0 +1,183 @@ +//! Themes: colour scheme, font scheme and format scheme (the fill/line/effect "style matrix"). + +use crate::color::{ColorScheme, ColorTransform, Rgba, SchemeSlot}; +use serde::{Deserialize, Serialize}; + +use crate::style::{ColorRef, Effects, Fill, Gradient, GradientShape, GradientStop, Line, Shadow}; + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct FontSet { + pub latin: String, + pub ea: String, + pub cs: String, + /// Per-script typefaces (`a:font script="Arab" typeface="…"`), in document order. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub scripts: Vec, +} + +/// A theme font for one script (ISO 15924 tag, e.g. `Arab`). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ScriptFont { + pub script: String, + pub typeface: String, +} + +impl FontSet { + /// The typeface the theme gives `script` (`"Arab"`), if any. + pub fn script(&self, script: &str) -> Option<&str> { + self.scripts.iter().find(|f| f.script.eq_ignore_ascii_case(script) && !f.typeface.is_empty()).map(|f| f.typeface.as_str()) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct FontScheme { + pub name: String, + /// Headings. + pub major: FontSet, + /// Body. + pub minor: FontSet, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct FormatScheme { + pub name: String, + /// Subtle, moderate, intense (style index 1–3). + pub fills: Vec, + pub lines: Vec, + pub effects: Vec, + /// Background fills (index 1001–1003). + pub bg_fills: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Theme { + pub name: String, + pub colors: ColorScheme, + pub fonts: FontScheme, + pub format: FormatScheme, + /// Raw theme XML extras kept for round-trip (object defaults, extra colour lists). + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_extra: Option, +} + +impl Default for Theme { + /// A plain theme: black and white, a slate and a warm grey, six accents, no font names (the + /// writer puts `Arial` where a Latin face is required) and [`default_format`]. + fn default() -> Self { + let hex = |s: &str| Rgba::from_hex(s).unwrap_or(Rgba::BLACK); + 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) }, + fonts: FontScheme::default(), + format: default_format(), + raw_extra: None, + } + } +} + +impl Theme { + /// Resolve a theme font reference (`+mj-lt`, `+mn-ea`…) to a family name. + pub fn font(&self, name: &str) -> String { + match name { + "+mj-lt" => self.fonts.major.latin.clone(), + "+mn-lt" => self.fonts.minor.latin.clone(), + "+mj-ea" => self.fonts.major.ea.clone(), + "+mn-ea" => self.fonts.minor.ea.clone(), + "+mj-cs" => self.fonts.major.cs.clone(), + "+mn-cs" => self.fonts.minor.cs.clone(), + other => other.to_string(), + } + } +} + +fn ph() -> ColorRef { + ColorRef::scheme(SchemeSlot::PhClr) +} + +/// The format scheme used where a theme has none, and to fill a short style list: flat solid fills, a soft gradient, and a gentle shadow for "intense". +pub fn default_format() -> FormatScheme { + let grad = |a: i32, b: i32| { + Fill::Gradient(Gradient { + tile: [0.0; 4], + stops: vec![ + GradientStop { pos: 0.0, color: ph().with(ColorTransform::LumMod(a)).with(ColorTransform::SatMod(103000)) }, + GradientStop { pos: 1.0, color: ph().with(ColorTransform::LumMod(b)) }, + ], + shape: GradientShape::Linear { angle: 90.0, scaled: false }, + rotate_with_shape: true, + flip: None, + }) + }; + FormatScheme { + name: "Default".into(), + fills: vec![Fill::solid(ph()), grad(110000, 92000), grad(105000, 80000)], + lines: vec![Line::solid(ph(), 0.75), Line::solid(ph(), 1.0), Line::solid(ph(), 1.5)], + effects: vec![ + Effects::default(), + Effects::default(), + Effects { + outer_shadow: Some(Shadow { + color: ColorRef::rgb(Rgba::BLACK).with(ColorTransform::Alpha(40000)), + blur: 6.0, + dist: 3.0, + dir: 90.0, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: "t".into(), + rotate_with_shape: false, + }), + ..Default::default() + }, + ], + bg_fills: vec![ + Fill::solid(ph()), + Fill::solid(ph().with(ColorTransform::Tint(95000)).with(ColorTransform::SatMod(170000))), + grad(102000, 85000), + ], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_default_theme_has_a_full_style_matrix_and_no_font_names() { + let t = Theme::default(); + assert_eq!((t.format.fills.len(), t.format.lines.len(), t.format.effects.len(), t.format.bg_fills.len()), (3, 3, 3, 3)); + assert_eq!(t.colors.get(SchemeSlot::Lt1), Rgba::WHITE); + assert_eq!(t.fonts, FontScheme::default()); + } + + #[test] + fn theme_font_references_resolve_and_other_names_pass_through() { + let mut t = Theme::default(); + t.fonts.major.latin = "Head".into(); + t.fonts.minor.cs = "Body CS".into(); + t.fonts.minor.scripts.push(ScriptFont { script: "Arab".into(), typeface: "Naskh".into() }); + assert_eq!(t.font("+mj-lt"), "Head"); + assert_eq!(t.font("+mn-cs"), "Body CS"); + assert_eq!(t.font("Georgia"), "Georgia"); + assert_eq!(t.fonts.minor.script("arab"), Some("Naskh")); + assert_eq!(t.fonts.minor.script("Hebr"), None); + } + + #[test] + fn a_theme_survives_json_and_missing_fields_take_the_default() { + let t = Theme::default(); + let json = serde_json::to_string(&t).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), t); + let partial: Theme = serde_json::from_str(r#"{"name":"X"}"#).unwrap(); + assert_eq!(partial.colors, t.colors); + assert_eq!(partial.name, "X"); + } +}