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.
This commit is contained in:
Dylan M. Taylor
2026-11-11 19:22:03 -05:00
committed by GitHub
parent e0a3b2b8c4
commit 8079878ae2
2 changed files with 185 additions and 1 deletions
+2 -1
View File
@@ -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;
+183
View File
@@ -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<ScriptFont>,
}
/// 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<Fill>,
pub lines: Vec<Line>,
pub effects: Vec<Effects>,
/// Background fills (index 1001–1003).
pub bg_fills: Vec<Fill>,
}
#[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<String>,
}
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::<Theme>(&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");
}
}