1use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16
17use crate::{ErrorCode, FileMakerError, Result, Unit};
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
21#[serde(tag = "space", rename_all = "snake_case")]
22pub enum Color {
23 Rgb {
25 r: u8,
27 g: u8,
29 b: u8,
31 },
32 Rgba {
34 r: u8,
36 g: u8,
38 b: u8,
40 a: u8,
42 },
43 Gray {
45 value: u8,
47 },
48 Cmyk {
50 c: u32,
52 m: u32,
54 y: u32,
56 k: u32,
58 },
59}
60
61impl Color {
62 pub fn parse(source: &str) -> Result<Self> {
65 match source {
66 "black" => return Ok(Self::Rgb { r: 0, g: 0, b: 0 }),
67 "white" => {
68 return Ok(Self::Rgb {
69 r: 255,
70 g: 255,
71 b: 255,
72 })
73 }
74 "red" => return Ok(Self::Rgb { r: 255, g: 0, b: 0 }),
75 "green" => return Ok(Self::Rgb { r: 0, g: 128, b: 0 }),
76 "blue" => return Ok(Self::Rgb { r: 0, g: 0, b: 255 }),
77 "transparent" => {
78 return Ok(Self::Rgba {
79 r: 0,
80 g: 0,
81 b: 0,
82 a: 0,
83 })
84 }
85 _ => {
86 if source.ends_with(')') {
87 return parse_function_color(source);
88 }
89 }
90 }
91 let hex = source
92 .strip_prefix('#')
93 .ok_or_else(|| style_error("invalid color syntax"))?;
94 match hex.len() {
95 3 => Ok(Self::Rgb {
96 r: duplicate_nibble(hex, 0)?,
97 g: duplicate_nibble(hex, 1)?,
98 b: duplicate_nibble(hex, 2)?,
99 }),
100 6 => Ok(Self::Rgb {
101 r: hex_byte(hex, 0)?,
102 g: hex_byte(hex, 2)?,
103 b: hex_byte(hex, 4)?,
104 }),
105 8 => Ok(Self::Rgba {
106 r: hex_byte(hex, 0)?,
107 g: hex_byte(hex, 2)?,
108 b: hex_byte(hex, 4)?,
109 a: hex_byte(hex, 6)?,
110 }),
111 _ => Err(style_error("hex color requires 3, 6, or 8 digits")),
112 }
113 }
114
115 pub fn validate(self) -> Result<()> {
117 if let Self::Cmyk { c, m, y, k } = self {
118 if [c, m, y, k].iter().any(|channel| *channel > 1_000_000) {
119 return Err(style_error("CMYK channels must be at most 1000000"));
120 }
121 }
122 Ok(())
123 }
124
125 #[must_use]
127 pub fn to_rgba(self) -> [u8; 4] {
128 match self {
129 Self::Rgb { r, g, b } => [r, g, b, 255],
130 Self::Rgba { r, g, b, a } => [r, g, b, a],
131 Self::Gray { value } => [value, value, value, 255],
132 Self::Cmyk { c, m, y, k } => {
133 let convert = |channel: u32| {
134 let combined = channel.saturating_add(k).min(1_000_000);
135 ((1_000_000 - combined) * 255 / 1_000_000) as u8
136 };
137 [convert(c), convert(m), convert(y), 255]
138 }
139 }
140 }
141}
142
143#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
145pub struct Style {
146 pub fill: Option<Color>,
148 pub stroke: Option<Color>,
150 pub stroke_width: Option<Unit>,
152 pub opacity: Option<u32>,
154 pub font: Option<String>,
156 pub font_size: Option<Unit>,
158 pub color: Option<Color>,
160}
161
162#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
164pub struct ElementStyleRule {
165 pub when: String,
167 pub style: Style,
169}
170
171impl Style {
172 pub fn validate(&self) -> Result<()> {
174 for color in [self.fill, self.stroke, self.color].into_iter().flatten() {
175 color.validate()?;
176 }
177 if self.opacity.is_some_and(|value| value > 1_000_000)
178 || self.stroke_width.is_some_and(|value| value < Unit::ZERO)
179 || self.font_size.is_some_and(|value| value <= Unit::ZERO)
180 {
181 return Err(style_error("style range is invalid"));
182 }
183 Ok(())
184 }
185
186 pub(crate) fn overlay(&mut self, next: &Self) {
187 if next.fill.is_some() {
188 self.fill = next.fill;
189 }
190 if next.stroke.is_some() {
191 self.stroke = next.stroke;
192 }
193 if next.stroke_width.is_some() {
194 self.stroke_width = next.stroke_width;
195 }
196 if next.opacity.is_some() {
197 self.opacity = next.opacity;
198 }
199 if next.font.is_some() {
200 self.font.clone_from(&next.font);
201 }
202 if next.font_size.is_some() {
203 self.font_size = next.font_size;
204 }
205 if next.color.is_some() {
206 self.color = next.color;
207 }
208 }
209}
210
211fn parse_function_color(source: &str) -> Result<Color> {
212 let (name, values) = source
213 .split_once('(')
214 .ok_or_else(|| style_error("invalid functional color syntax"))?;
215 let values = values
216 .strip_suffix(')')
217 .ok_or_else(|| style_error("invalid functional color syntax"))?
218 .split(',')
219 .map(|value| {
220 value
221 .trim()
222 .parse::<u32>()
223 .map_err(|_| style_error("functional color channels must be unsigned integers"))
224 })
225 .collect::<Result<Vec<_>>>()?;
226 match (name, values.as_slice()) {
227 ("rgb", [r, g, b]) => Ok(Color::Rgb {
228 r: channel_u8(*r)?,
229 g: channel_u8(*g)?,
230 b: channel_u8(*b)?,
231 }),
232 ("rgba", [r, g, b, a]) => Ok(Color::Rgba {
233 r: channel_u8(*r)?,
234 g: channel_u8(*g)?,
235 b: channel_u8(*b)?,
236 a: channel_u8(*a)?,
237 }),
238 ("gray", [value]) => Ok(Color::Gray {
239 value: channel_u8(*value)?,
240 }),
241 ("cmyk", [c, m, y, k]) if [c, m, y, k].iter().all(|value| **value <= 1_000_000) => {
242 Ok(Color::Cmyk {
243 c: *c,
244 m: *m,
245 y: *y,
246 k: *k,
247 })
248 }
249 ("cmyk", _) => Err(style_error("CMYK requires four channels up to 1000000")),
250 _ => Err(style_error("unsupported functional color syntax")),
251 }
252}
253
254fn channel_u8(value: u32) -> Result<u8> {
255 u8::try_from(value).map_err(|_| style_error("RGB/Gray channels must be at most 255"))
256}
257
258#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
260pub struct ComputedStyle {
261 pub fill: Option<Color>,
263 pub stroke: Option<Color>,
265 pub stroke_width: Unit,
267 pub opacity: u32,
269 pub font: Option<String>,
271 pub font_size: Unit,
273 pub color: Color,
275}
276
277impl ComputedStyle {
278 pub fn validate(&self) -> Result<()> {
280 for color in [self.fill, self.stroke, Some(self.color)]
281 .into_iter()
282 .flatten()
283 {
284 color.validate()?;
285 }
286 if self.opacity > 1_000_000
287 || self.stroke_width < Unit::ZERO
288 || self.font_size <= Unit::ZERO
289 || self
290 .font
291 .as_ref()
292 .is_some_and(|font| font.is_empty() || font.len() > 128)
293 {
294 return Err(style_error("computed style range is invalid"));
295 }
296 Ok(())
297 }
298}
299
300#[derive(Clone, Debug, Default)]
302pub struct StyleCascade {
303 pub defaults: Style,
305 pub theme: Style,
307 pub template: Style,
309 pub component: Style,
311 pub data_rule: Style,
313 pub runtime: Style,
315 pub export: Style,
317}
318
319impl StyleCascade {
320 pub fn compute(&self) -> Result<ComputedStyle> {
322 let mut merged = Style::default();
323 for layer in [
324 &self.defaults,
325 &self.theme,
326 &self.template,
327 &self.component,
328 &self.data_rule,
329 &self.runtime,
330 &self.export,
331 ] {
332 layer.validate()?;
333 merged.overlay(layer);
334 }
335 Ok(ComputedStyle {
336 fill: merged.fill,
337 stroke: merged.stroke,
338 stroke_width: merged.stroke_width.unwrap_or(Unit::ZERO),
339 opacity: merged.opacity.unwrap_or(1_000_000),
340 font: merged.font,
341 font_size: merged.font_size.unwrap_or(Unit::points(12)?),
342 color: merged.color.unwrap_or(Color::Rgb { r: 0, g: 0, b: 0 }),
343 })
344 }
345}
346
347pub fn resolve_token(
349 token: &str,
350 themes: &BTreeMap<String, BTreeMap<String, String>>,
351 active_theme: &str,
352 max_depth: usize,
353) -> Result<String> {
354 let key = token
355 .strip_prefix('$')
356 .ok_or_else(|| style_error("token must begin with `$`"))?;
357 let mut current = active_theme;
358 for _ in 0..max_depth {
359 let theme = themes
360 .get(current)
361 .ok_or_else(|| style_error("theme was not found"))?;
362 if let Some(value) = theme.get(key) {
363 return Ok(value.clone());
364 }
365 current = theme.get("$extends").map_or("", String::as_str);
366 if current.is_empty() {
367 break;
368 }
369 }
370 Err(style_error(format!("token `{token}` was not resolved")))
371}
372
373fn duplicate_nibble(hex: &str, offset: usize) -> Result<u8> {
374 let value = u8::from_str_radix(&hex[offset..=offset], 16)
375 .map_err(|_| style_error("invalid hex color digit"))?;
376 Ok(value * 17)
377}
378
379fn hex_byte(hex: &str, offset: usize) -> Result<u8> {
380 u8::from_str_radix(&hex[offset..offset + 2], 16)
381 .map_err(|_| style_error("invalid hex color digit"))
382}
383
384fn style_error(message: impl Into<String>) -> FileMakerError {
385 FileMakerError::new(ErrorCode::SchemaField, message)
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[test]
393 fn runtime_layer_overrides_template_layer() {
394 let cascade = StyleCascade {
395 template: Style {
396 fill: Some(Color::parse("#ff0000").unwrap()),
397 ..Style::default()
398 },
399 runtime: Style {
400 fill: Some(Color::parse("blue").unwrap()),
401 ..Style::default()
402 },
403 ..StyleCascade::default()
404 };
405 assert_eq!(
406 cascade.compute().unwrap().fill,
407 Some(Color::Rgb { r: 0, g: 0, b: 255 })
408 );
409 }
410}