Files
deckcraft/crates/model/src/theme.rs
4b09f35c1d feat(text, fonts): UAX #9 BiDi and Arabic complex script typography engine (#4)
* Sprint 1: bidi and Arabic typography engine

fonts: ISO 15924 script and a:latin/a:ea/a:cs slot classification;
shape_range shapes an item with the whole paragraph as HarfRust
pre/post context, explicit script/language/direction, glyphs kept in
visual order; per-grapheme-cluster fallback (slot face, theme script
font, OS cascade families, bundled craft-fonts face); GDEF ligature
carets.

text: UAX #9 levels for the whole paragraph (a:pPr/@rtl), itemization
by level, script, face and shaping style (paint-only changes no longer
split words), logical line breaking at cluster boundaries, per-line
L1/L2 reordering (L4 mirroring by the shaper); RTL start edge and
bullets, justLow kashida; caret Affinity, logical/visual movement,
bidi hit testing and selection as the union of visual boxes.

model/pptx: theme a:font script fonts and algn="justLow" round-trip.
engine: format.rtl (Right-to-Left Text Direction), justifyLow align.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fonts: the bundled script face comes before installed fonts; no Microsoft family names

Arabic, Hebrew, Thai and Devanagari clusters try the craft-fonts face first, so every machine
draws the same, then openly licensed or OS-native installed families, then any installed face.
Installed Microsoft faces are still used through that scan, but not named (AGENTS.md §1.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Tayeb <tayeb@192.168.11.179>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 10:48:26 -04:00

270 lines
9.5 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Themes: colour scheme, font scheme and format scheme (the fill/line/effect "style matrix").
use deckcraft_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 {
fn default() -> Self {
builtin_themes().into_iter().next().unwrap_or_else(|| Theme {
name: "DeckCraft".into(),
colors: ColorScheme { name: "DeckCraft".into(), colors: [Rgba::BLACK; 12] },
fonts: FontScheme::default(),
format: FormatScheme::default(),
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 hex(s: &str) -> Rgba {
Rgba::from_hex(s).unwrap_or(Rgba::BLACK)
}
fn scheme(name: &str, c: [&str; 12]) -> ColorScheme {
ColorScheme { name: name.into(), colors: c.map(hex) }
}
fn ph() -> ColorRef {
ColorRef::scheme(SchemeSlot::PhClr)
}
/// Our default format scheme: 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 {
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,
})
};
FormatScheme {
name: "DeckCraft".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),
],
}
}
/// Our original built-in themes (shown in Design ▸ Themes). Colours and names are our own.
pub fn builtin_themes() -> Vec<Theme> {
let t = |name: &str, colors: ColorScheme, major: &str, minor: &str| Theme {
name: name.into(),
colors,
fonts: FontScheme {
name: name.into(),
major: FontSet { latin: major.into(), ..Default::default() },
minor: FontSet { latin: minor.into(), ..Default::default() },
},
format: default_format(),
raw_extra: None,
};
vec![
t(
"DeckCraft",
scheme(
"DeckCraft",
["000000", "FFFFFF", "1E2B3C", "EDEBE7", "2E6FD8", "F26B1D", "2EA36F", "8A5CD8", "D83F6B", "E3B21C", "2E6FD8", "7A5BA6"],
),
"Inter",
"Inter",
),
t(
"Harbor",
scheme(
"Harbor",
["0B1F2A", "FFFFFF", "12384D", "E4EEF2", "1B7FA6", "36B3A8", "F2C14E", "F78154", "5D576B", "8AA29E", "1B7FA6", "5D576B"],
),
"Merriweather",
"Open Sans",
),
t(
"Ember",
scheme("Ember", ["1A1A1A", "FFFCF7", "3D2B1F", "F6EDE3", "D9480F", "F59F00", "A61E4D", "5C940D", "1971C2", "862E9C", "D9480F", "862E9C"]),
"Montserrat",
"Lato",
),
t(
"Meadow",
scheme(
"Meadow",
["14281D", "FFFFFF", "2D4A37", "EEF5EE", "4C9A2A", "A4C639", "2F6F4E", "E9B44C", "9B2915", "50A2A7", "2F6F4E", "9B2915"],
),
"Merriweather",
"Open Sans",
),
t(
"Nocturne",
scheme(
"Nocturne",
["FFFFFF", "121826", "F2F4F8", "1E2638", "7C5CFF", "00C2A8", "FF6B9A", "FFC53D", "4DA3FF", "B2F35F", "7C5CFF", "FF6B9A"],
),
"Inter",
"Inter",
),
t(
"Paper",
scheme("Paper", ["262626", "FFFFFF", "404040", "F2F2F2", "595959", "8C8C8C", "C0504D", "4F81BD", "9BBB59", "F79646", "4F81BD", "8064A2"]),
"Playfair Display",
"Lato",
),
t(
"Coral Reef",
scheme(
"Coral Reef",
["0F2A33", "FFFFFF", "0E4D64", "FDF1EC", "FF6F59", "254441", "43AA8B", "B2B09B", "EF3054", "3F88C5", "3F88C5", "EF3054"],
),
"Poppins",
"Nunito Sans",
),
t(
"Slate",
scheme("Slate", ["111827", "FFFFFF", "374151", "F3F4F6", "6366F1", "0EA5E9", "10B981", "F59E0B", "EF4444", "8B5CF6", "6366F1", "8B5CF6"]),
"Roboto",
"Roboto",
),
]
}
/// Named colour schemes for Design ▸ Variants ▸ Colors.
pub fn builtin_color_schemes() -> Vec<ColorScheme> {
let mut v: Vec<ColorScheme> = builtin_themes().into_iter().map(|t| t.colors).collect();
v.push(scheme(
"Grayscale",
["000000", "FFFFFF", "000000", "F8F8F8", "DDDDDD", "B2B2B2", "969696", "808080", "5F5F5F", "4D4D4D", "5F5F5F", "919191"],
));
v.push(scheme("Ocean", ["000000", "FFFFFF", "13293D", "E8F1F2", "006494", "247BA0", "1B98E0", "6CC1E8", "13293D", "0B3954", "1B98E0", "6CC1E8"]));
v.push(scheme(
"Sunset",
["2B2118", "FFFFFF", "6F1A07", "FFF3E6", "FF9F1C", "FFBF69", "CB997E", "E76F51", "F4A261", "2A9D8F", "E76F51", "2A9D8F"],
));
v.push(scheme(
"Forest",
["1B1B1B", "FFFFFF", "283618", "FEFAE0", "606C38", "DDA15E", "BC6C25", "283618", "8A9A5B", "4F772D", "606C38", "BC6C25"],
));
v.push(scheme("Berry", ["1F0A1D", "FFFFFF", "4A1942", "F7E8F3", "893168", "C6426E", "EB6A8F", "4A1942", "7D7ABC", "56A3A6", "893168", "56A3A6"]));
v
}
/// Named font pairs for Design ▸ Variants ▸ Fonts.
pub fn builtin_font_schemes() -> Vec<FontScheme> {
let f = |name: &str, major: &str, minor: &str| FontScheme {
name: name.into(),
major: FontSet { latin: major.into(), ..Default::default() },
minor: FontSet { latin: minor.into(), ..Default::default() },
};
vec![
f("Inter", "Inter", "Inter"),
f("Classic", "Merriweather", "Open Sans"),
f("Geometric", "Montserrat", "Lato"),
f("Editorial", "Playfair Display", "Lato"),
f("Humanist", "Merriweather", "Open Sans"),
f("Friendly", "Poppins", "Nunito Sans"),
f("Neutral", "Roboto", "Roboto"),
f("Office Compatible", "Carlito", "Carlito"),
f("Times / Arial", "Liberation Serif", "Liberation Sans"),
]
}