1use std::collections::{BTreeMap, HashMap};
5use std::rc::Rc;
6use std::sync::Arc;
7
8use vello_cpu::peniko::{Blob, ImageAlphaType, ImageData, ImageFormat};
9
10use crate::Problem;
11use crate::parse::{self, Child, Element, Pos, TextPart, Value};
12
13#[derive(Debug, Clone, Copy, PartialEq)]
15pub struct Rgba {
16 pub r: u8,
17 pub g: u8,
18 pub b: u8,
19 pub a: u8,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum Theme {
25 #[default]
26 Light,
27 Dark,
28}
29
30impl Theme {
31 pub fn named(name: &str) -> Option<Theme> {
33 match name {
34 "light" => Some(Theme::Light),
35 "dark" => Some(Theme::Dark),
36 _ => None,
37 }
38 }
39}
40
41#[derive(Debug, Clone, Copy, PartialEq)]
44pub struct Color {
45 pub light: Rgba,
46 pub dark: Rgba,
47}
48
49impl Color {
50 pub const fn fixed(c: Rgba) -> Color {
52 Color { light: c, dark: c }
53 }
54
55 pub fn get(self, theme: Theme) -> Rgba {
57 match theme {
58 Theme::Light => self.light,
59 Theme::Dark => self.dark,
60 }
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq)]
65pub enum Size {
66 Px(f32),
67 Hug,
68 Fill,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq)]
72pub enum Layout {
73 None,
74 Row,
75 Column,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq)]
79pub enum Align {
80 Start,
81 Center,
82 End,
83 SpaceBetween,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq)]
87pub enum Kind {
88 Frame,
89 Text,
90 Rect,
91 Ellipse,
92 Vector,
93 Image,
94}
95
96#[derive(Debug, Clone, PartialEq)]
98pub enum Fill {
99 Solid(Color),
100 Linear {
103 angle: f32,
104 stops: Vec<(Color, f32)>,
105 },
106}
107
108#[derive(Debug, Clone, Copy, PartialEq)]
109pub enum TextAlign {
110 Left,
111 Center,
112 Right,
113}
114
115#[derive(Debug, Clone, PartialEq)]
117pub struct Vector {
118 pub d: String,
119 pub view_box: [f32; 4],
121}
122
123#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
127pub struct ImageId(pub [u8; 32]);
128
129impl ImageId {
130 pub fn of(bytes: &[u8]) -> ImageId {
131 use sha2::Digest;
132 ImageId(sha2::Sha256::digest(bytes).into())
133 }
134}
135
136impl std::fmt::Debug for ImageId {
137 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138 for b in &self.0[..6] {
139 write!(f, "{b:02x}")?;
140 }
141 Ok(())
142 }
143}
144
145pub const MAX_IMAGE_SIDE: u32 = 4096;
150
151#[derive(Debug)]
154pub struct ImageFile {
155 pub id: ImageId,
156 pub bytes: Vec<u8>,
157 pub width: u32,
159 pub height: u32,
160 pub pixels: Arc<Vec<u8>>,
162 pub pixels_width: u32,
163 pub pixels_height: u32,
164}
165
166impl ImageFile {
167 pub fn new(bytes: Vec<u8>) -> Result<ImageFile, String> {
169 ImageFile::with_id(ImageId::of(&bytes), bytes)
170 }
171
172 pub fn with_id(id: ImageId, bytes: Vec<u8>) -> Result<ImageFile, String> {
174 let decoded = decode(&bytes)?;
175 let (width, height) = (decoded.width, decoded.height);
176 let (pixels, pixels_width, pixels_height) =
177 fit(decoded.data.data(), width, height, MAX_IMAGE_SIDE);
178 Ok(ImageFile {
179 id,
180 bytes,
181 width,
182 height,
183 pixels: Arc::new(pixels),
184 pixels_width,
185 pixels_height,
186 })
187 }
188
189 pub fn image_data(&self) -> ImageData {
192 ImageData {
193 data: Blob::new(self.pixels.clone()),
194 format: ImageFormat::Rgba8,
195 alpha_type: ImageAlphaType::Alpha,
196 width: self.pixels_width,
197 height: self.pixels_height,
198 }
199 }
200}
201
202pub fn fit(rgba: &[u8], width: u32, height: u32, max: u32) -> (Vec<u8>, u32, u32) {
207 if width <= max && height <= max {
208 return (rgba.to_vec(), width, height);
209 }
210 let scale = max as f64 / width.max(height) as f64;
211 let w = ((width as f64 * scale).round() as u32).clamp(1, max);
212 let h = ((height as f64 * scale).round() as u32).clamp(1, max);
213 let weights = |from: u32, to: u32| -> Vec<Vec<(usize, f32)>> {
216 let ratio = from as f64 / to as f64;
217 (0..to)
218 .map(|o| {
219 let (a, b) = (o as f64 * ratio, (o + 1) as f64 * ratio);
220 (a.floor() as usize..(b.ceil() as usize).min(from as usize))
221 .map(|i| {
222 let cover = (b.min(i as f64 + 1.0) - a.max(i as f64)) / ratio;
223 (i, cover as f32)
224 })
225 .filter(|&(_, c)| c > 0.0)
226 .collect()
227 })
228 .collect()
229 };
230 let (across, down) = (weights(width, w), weights(height, h));
231 let mut rows = vec![0f32; 4 * w as usize * height as usize];
233 for y in 0..height as usize {
234 let line = &rgba[4 * y * width as usize..][..4 * width as usize];
235 for (x, ws) in across.iter().enumerate() {
236 let mut sum = [0f32; 4];
237 for &(i, c) in ws {
238 let p = &line[4 * i..][..4];
239 let a = p[3] as f32 * c;
240 sum[0] += p[0] as f32 * a;
241 sum[1] += p[1] as f32 * a;
242 sum[2] += p[2] as f32 * a;
243 sum[3] += a;
244 }
245 rows[4 * (y * w as usize + x)..][..4].copy_from_slice(&sum);
246 }
247 }
248 let mut out = vec![0u8; 4 * w as usize * h as usize];
249 for (y, ws) in down.iter().enumerate() {
250 for x in 0..w as usize {
251 let mut sum = [0f32; 4];
252 for &(i, c) in ws {
253 let p = &rows[4 * (i * w as usize + x)..][..4];
254 for k in 0..4 {
255 sum[k] += p[k] * c;
256 }
257 }
258 let px = &mut out[4 * (y * w as usize + x)..][..4];
259 if sum[3] > 0.0 {
260 for k in 0..3 {
261 px[k] = (sum[k] / sum[3]).round().clamp(0.0, 255.0) as u8;
262 }
263 px[3] = sum[3].round().clamp(0.0, 255.0) as u8;
264 }
265 }
266 }
267 (out, w, h)
268}
269
270#[derive(Debug, Clone)]
274pub struct Origin {
275 pub file: String,
276 pub pos: Pos,
277}
278
279impl Default for Origin {
280 fn default() -> Origin {
281 Origin {
282 file: String::new(),
283 pos: Pos { line: 1, col: 1 },
284 }
285 }
286}
287
288impl PartialEq for Origin {
289 fn eq(&self, _: &Origin) -> bool {
290 true
291 }
292}
293
294#[derive(Debug, Clone, PartialEq)]
297pub struct Image {
298 pub src: String,
299 pub id: ImageId,
300 pub at: Origin,
302}
303
304pub fn decode(data: &[u8]) -> Result<ImageData, String> {
306 let (pixels, width, height) = if data.starts_with(b"\x89PNG\r\n\x1a\n") {
307 let broken = |e: &dyn std::fmt::Display| format!("is a PNG that doesn't decode: {e}");
308 let mut decoder = png::Decoder::new(std::io::Cursor::new(data));
309 decoder.set_transformations(png::Transformations::normalize_to_color8());
310 let mut reader = decoder.read_info().map_err(|e| broken(&e))?;
311 let size = reader
312 .output_buffer_size()
313 .ok_or_else(|| broken(&"too big to hold"))?;
314 let mut buf = vec![0; size];
315 let info = reader.next_frame(&mut buf).map_err(|e| broken(&e))?;
316 buf.truncate(info.buffer_size());
317 let rgba = match info.color_type {
318 png::ColorType::Rgba => buf,
319 png::ColorType::Rgb => buf
320 .chunks(3)
321 .flat_map(|p| [p[0], p[1], p[2], 255])
322 .collect(),
323 png::ColorType::GrayscaleAlpha => buf
324 .chunks(2)
325 .flat_map(|p| [p[0], p[0], p[0], p[1]])
326 .collect(),
327 png::ColorType::Grayscale => buf.iter().flat_map(|&g| [g, g, g, 255]).collect(),
328 png::ColorType::Indexed => return Err(broken(&"an unexpanded palette")),
330 };
331 (rgba, info.width, info.height)
332 } else if data.starts_with(&[0xff, 0xd8, 0xff]) {
333 use zune_jpeg::JpegDecoder;
334 use zune_jpeg::zune_core::colorspace::ColorSpace;
335 use zune_jpeg::zune_core::options::DecoderOptions;
336 let broken = |e: &dyn std::fmt::Display| format!("is a JPEG that doesn't decode: {e}");
337 let options = DecoderOptions::default()
338 .set_strict_mode(true)
339 .jpeg_set_out_colorspace(ColorSpace::RGBA);
340 let mut decoder = JpegDecoder::new_with_options(std::io::Cursor::new(data), options);
341 let pixels = decoder.decode().map_err(|e| broken(&e))?;
342 let info = decoder.info().ok_or_else(|| broken(&"no header"))?;
343 (pixels, info.width as u32, info.height as u32)
344 } else {
345 return Err("is not a PNG or JPEG file".into());
346 };
347 if width == 0 || height == 0 || pixels.len() != width as usize * height as usize * 4 {
348 return Err(format!(
349 "is an image of {width}×{height} pixels that doesn't decode whole"
350 ));
351 }
352 Ok(ImageData {
353 data: Blob::new(Arc::new(pixels)),
354 format: ImageFormat::Rgba8,
355 alpha_type: ImageAlphaType::Alpha,
356 width,
357 height,
358 })
359}
360
361#[derive(Debug, Clone, PartialEq)]
362pub struct Shadow {
363 pub x: f32,
364 pub y: f32,
365 pub blur: f32,
366 pub spread: f32,
367 pub color: Color,
368 pub at: Origin,
370}
371
372#[derive(Debug, Clone, PartialEq)]
373pub struct TextStyle {
374 pub content: String,
375 pub size: f32,
376 pub weight: u16,
377 pub color: Color,
378 pub line_height: f32,
379 pub align: TextAlign,
380}
381
382#[derive(Debug, Clone, PartialEq)]
384pub struct Node {
385 pub kind: Kind,
386 pub name: String,
387 pub x: f32,
388 pub y: f32,
389 pub width: Size,
390 pub height: Size,
391 pub opacity: f32,
392 pub on_click: Option<String>,
393 pub fill: Option<Fill>,
394 pub stroke: Option<Color>,
395 pub stroke_width: f32,
396 pub radius: [f32; 4],
397 pub shadow: Option<Shadow>,
398 pub clip: bool,
399 pub layout: Layout,
400 pub gap: f32,
401 pub padding: [f32; 4],
402 pub align: Align,
403 pub justify: Align,
404 pub text: Option<TextStyle>,
405 pub vector: Option<Vector>,
406 pub image: Option<Image>,
407 pub children: Vec<Node>,
408}
409
410impl Node {
411 fn new(kind: Kind, name: &str) -> Node {
412 Node {
413 kind,
414 name: name.to_string(),
415 x: 0.0,
416 y: 0.0,
417 width: Size::Hug,
418 height: Size::Hug,
419 opacity: 1.0,
420 on_click: None,
421 fill: None,
422 stroke: None,
423 stroke_width: 1.0,
424 radius: [0.0; 4],
425 shadow: None,
426 clip: false,
427 layout: Layout::None,
428 gap: 0.0,
429 padding: [0.0; 4],
430 align: Align::Start,
431 justify: Align::Start,
432 text: None,
433 vector: None,
434 image: None,
435 children: Vec::new(),
436 }
437 }
438}
439
440pub trait Files {
442 fn bytes(&self, path: &str) -> Option<Vec<u8>>;
444 fn read(&self, path: &str) -> Option<String> {
446 String::from_utf8(self.bytes(path)?).ok()
447 }
448 fn image(&self, path: &str) -> Option<Result<Arc<ImageFile>, String>> {
452 self.bytes(path)
453 .map(|bytes| ImageFile::new(bytes).map(Arc::new))
454 }
455}
456
457impl Files for HashMap<String, String> {
458 fn bytes(&self, path: &str) -> Option<Vec<u8>> {
459 self.get(path).map(|s| s.clone().into_bytes())
460 }
461}
462
463#[derive(Debug, Clone, PartialEq)]
467pub struct Scene {
468 pub root: Node,
469 pub images: ImageFiles,
470 pub files: Vec<String>,
471 pub themed: bool,
472}
473
474pub type ImageFiles = std::collections::BTreeMap<ImageId, Arc<ImageFile>>;
477
478impl PartialEq for ImageFile {
480 fn eq(&self, other: &ImageFile) -> bool {
481 self.id == other.id
482 }
483}
484
485pub fn resolve(files: &dyn Files, path: &str) -> Result<Scene, Vec<Problem>> {
487 let mut r = Resolver {
488 files,
489 problems: Vec::new(),
490 read: Vec::new(),
491 stack: Vec::new(),
492 missing: Vec::new(),
493 depth: 0,
494 images: ImageFiles::new(),
495 by_path: HashMap::new(),
496 themes: HashMap::new(),
497 themed: false,
498 };
499 let root = r.design(path, &[], None, "", None);
500 if r.problems.is_empty() {
501 Ok(Scene {
502 root: root.expect("no problems means a root"),
503 images: r.images,
504 files: r.read,
505 themed: r.themed,
506 })
507 } else {
508 Err(r.problems)
509 }
510}
511
512struct Resolver<'a> {
513 files: &'a dyn Files,
514 problems: Vec<Problem>,
515 read: Vec<String>,
516 stack: Vec<String>,
517 missing: Vec<String>,
520 depth: usize,
522 images: ImageFiles,
524 by_path: HashMap<String, Result<ImageId, String>>,
526 themes: HashMap<String, Rc<Themes>>,
529 themed: bool,
531}
532
533#[derive(Debug, Clone, Default)]
537struct Themes {
538 found: bool,
540 light: BTreeMap<String, Rgba>,
541 dark: BTreeMap<String, Rgba>,
542}
543
544struct ThemesFile {
546 light: BTreeMap<String, Rgba>,
547 dark: BTreeMap<String, Rgba>,
548}
549
550pub const THEMES_FILE: &str = "themes.yml";
552
553const MAX_DEPTH: usize = parse::MAX_DEPTH;
556
557type Props = HashMap<String, Value>;
558
559impl Resolver<'_> {
560 fn problem(&mut self, file: &str, pos: Pos, message: String) {
561 self.problems.push(Problem {
562 file: file.to_string(),
563 pos,
564 message,
565 });
566 }
567
568 fn bind_element(&mut self, path: &str, el: &mut Element) {
571 for attr in &mut el.attrs {
572 self.bind(path, &mut attr.value);
573 }
574 for child in &mut el.children {
575 if let Child::Element(e) = child {
576 self.bind_element(path, e);
577 }
578 }
579 }
580
581 fn bind(&mut self, path: &str, value: &mut Value) {
585 match value {
586 Value::Theme(name, pos) => {
587 let (light, dark) = self
590 .theme_color(path, name, *pos)
591 .map_or(("#0000".into(), "#0000".into()), |(l, d)| (hex(l), hex(d)));
592 *value = Value::Themed {
593 light,
594 dark,
595 written: format!("theme.{name}"),
596 };
597 }
598 Value::Str(s, pos) if s.starts_with("linear-gradient(") && s.contains("theme.") => {
599 let (mut light, mut dark) = (String::new(), String::new());
600 let mut rest = s.as_str();
601 let mut col = pos.col + 1;
602 while let Some(at) = rest.find("theme.") {
603 let before = &rest[..at];
604 light.push_str(before);
605 dark.push_str(before);
606 col += before.chars().count() as u32;
607 let after = &rest[at + "theme.".len()..];
608 let len = after
609 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '-'))
610 .unwrap_or(after.len());
611 let name = &after[..len];
612 let at_ref = Pos {
613 line: pos.line,
614 col,
615 };
616 match self.theme_color(path, name, at_ref) {
617 Some((l, d)) => {
618 light.push_str(&hex(l));
619 dark.push_str(&hex(d));
620 }
621 None => {
623 light.push_str("#0000");
624 dark.push_str("#0000");
625 }
626 }
627 col += ("theme.".len() + name.chars().count()) as u32;
628 rest = &after[len..];
629 }
630 light.push_str(rest);
631 dark.push_str(rest);
632 *value = Value::Themed {
633 light,
634 dark,
635 written: format!("\"{s}\""),
636 };
637 }
638 Value::Array(items) => {
639 for item in items {
640 self.bind(path, item);
641 }
642 }
643 Value::Object(fields) => {
644 for (_, v) in fields {
645 self.bind(path, v);
646 }
647 }
648 _ => {}
649 }
650 }
651
652 fn theme_color(&mut self, path: &str, name: &str, pos: Pos) -> Option<(Rgba, Rgba)> {
655 let folder = path.rsplit_once('/').map_or("", |(folder, _)| folder);
656 let themes = self.themes(folder);
657 let problem = if !themes.found {
658 format!(
659 "theme.{name}: no {THEMES_FILE} with kind: themes in this design's folder or above it"
660 )
661 } else {
662 match (themes.light.get(name), themes.dark.get(name)) {
663 (Some(l), Some(d)) => return Some((*l, *d)),
664 (None, None) => {
665 let mut names: Vec<&String> =
666 themes.light.keys().chain(themes.dark.keys()).collect();
667 names.sort();
668 names.dedup();
669 let names: Vec<&str> = names.iter().map(|n| n.as_str()).collect();
670 if names.is_empty() {
671 format!("theme.{name}: the themes have no colors")
672 } else {
673 format!(
674 "theme.{name}: the themes have no {name}; they have {}",
675 names.join(", ")
676 )
677 }
678 }
679 (Some(_), None) => format!(
680 "theme.{name}: only the light theme has {name}; a name needs a color in both"
681 ),
682 (None, Some(_)) => format!(
683 "theme.{name}: only the dark theme has {name}; a name needs a color in both"
684 ),
685 }
686 };
687 self.problem(path, pos, problem);
688 None
689 }
690
691 fn themes(&mut self, folder: &str) -> Rc<Themes> {
695 if let Some(themes) = self.themes.get(folder) {
696 return themes.clone();
697 }
698 let above = match folder.rsplit_once('/') {
699 Some((parent, _)) => self.themes(parent),
700 None if folder.is_empty() => Rc::new(Themes::default()),
701 None => self.themes(""),
702 };
703 let file = if folder.is_empty() {
704 THEMES_FILE.to_string()
705 } else {
706 format!("{folder}/{THEMES_FILE}")
707 };
708 let themes = match self.themes_file(&file) {
709 None => above,
710 Some(own) => {
711 let mut merged = (*above).clone();
712 merged.found = true;
713 merged.light.extend(own.light);
714 merged.dark.extend(own.dark);
715 Rc::new(merged)
716 }
717 };
718 self.themes.insert(folder.to_string(), themes.clone());
719 themes
720 }
721
722 fn themes_file(&mut self, path: &str) -> Option<ThemesFile> {
726 let text = self.files.read(path)?;
727 if !self.read.iter().any(|p| p == path) {
728 self.read.push(path.to_string());
729 }
730 let mut file = ThemesFile {
731 light: BTreeMap::new(),
732 dark: BTreeMap::new(),
733 };
734 let yaml: serde_yaml::Value = match serde_yaml::from_str(&text) {
735 Ok(yaml) => yaml,
736 Err(e) => {
737 if !text.lines().any(|l| l.trim() == "kind: themes") {
739 return None;
740 }
741 let pos = e.location().map_or(Pos { line: 1, col: 1 }, |l| Pos {
742 line: l.line() as u32,
743 col: l.column() as u32,
744 });
745 self.problem(path, pos, format!("this themes file isn't valid YAML: {e}"));
746 return Some(file);
747 }
748 };
749 let serde_yaml::Value::Mapping(top) = yaml else {
750 return None;
751 };
752 if top.get("kind").and_then(|k| k.as_str()) != Some("themes") {
753 return None;
754 }
755 let start = Pos { line: 1, col: 1 };
756 for (key, value) in &top {
757 let key = key.as_str().unwrap_or_default();
758 let theme = match key {
759 "kind" => continue,
760 "light" => &mut file.light,
761 "dark" => &mut file.dark,
762 other => {
763 let pos = key_pos(&text, None, other).unwrap_or(start);
764 self.problem(
765 path,
766 pos,
767 format!("{other}: a themes file has kind, light and dark only"),
768 );
769 continue;
770 }
771 };
772 let at = key_pos(&text, None, key).unwrap_or(start);
773 let serde_yaml::Value::Mapping(colors) = value else {
774 self.problem(
775 path,
776 at,
777 format!("{key}: expected a map of names to colors, like surface: \"#ffffff\""),
778 );
779 continue;
780 };
781 for (name, color) in colors {
782 let Some(name) = name.as_str() else {
783 self.problem(path, at, format!("{key}: a color's name is text"));
784 continue;
785 };
786 let pos = key_pos(&text, Some(key), name).unwrap_or(at);
787 if name.is_empty()
788 || name.starts_with(|c: char| c.is_ascii_digit() || c == '-')
789 || !name
790 .chars()
791 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
792 {
793 self.problem(
794 path,
795 pos,
796 format!(
797 "{key}.{name}: a name holds letters, digits, _ and -, and starts with a letter"
798 ),
799 );
800 continue;
801 }
802 let value = match color {
803 serde_yaml::Value::String(s) => rgba(s),
804 serde_yaml::Value::Null => Err(
805 "no color: write it in quotes, \"#rrggbb\", as YAML reads # as a comment"
806 .to_string(),
807 ),
808 _ => Err("expected a color like \"#rrggbb\"".to_string()),
809 };
810 match value {
811 Ok(c) => {
812 theme.insert(name.to_string(), c);
813 }
814 Err(e) => self.problem(path, pos, format!("{key}.{name}: {e}")),
815 }
816 }
817 }
818 for needed in ["light", "dark"] {
819 if !top.contains_key(needed) {
820 self.problem(
821 path,
822 start,
823 format!("a themes file has a light and a dark theme; this one has no {needed}"),
824 );
825 }
826 }
827 Some(file)
828 }
829
830 fn design(
832 &mut self,
833 path: &str,
834 args: &[parse::Attr],
835 from: Option<(&str, Pos)>,
836 used_as: &str,
837 parent: Option<Layout>,
838 ) -> Option<Node> {
839 if self.stack.iter().any(|p| p == path) {
840 let (file, pos) = from.unwrap_or((path, Pos { line: 1, col: 1 }));
841 self.problem(file, pos, format!("{path} uses itself"));
842 return None;
843 }
844 let Some(source) = self.files.read(path) else {
845 if from.is_some() && self.missing.iter().any(|p| p == path) {
846 return None;
847 }
848 let (file, pos) = from.unwrap_or((path, Pos { line: 1, col: 1 }));
849 self.problem(file, pos, format!("{path} — no such file"));
850 return None;
851 };
852 if !self.read.iter().any(|p| p == path) {
853 self.read.push(path.to_string());
854 }
855 let mut file = match parse::parse(&source) {
856 Ok(file) => file,
857 Err(mut problem) => {
858 problem.file = path.to_string();
859 self.problems.push(problem);
860 return None;
861 }
862 };
863 self.bind_element(path, &mut file.root);
866 let mut imports = HashMap::new();
867 for import in &file.imports {
868 if import.name == parse::THEME {
869 self.problem(
870 path,
871 import.pos,
872 format!(
873 "import {0} — {0} is the name of the design's themes",
874 import.name
875 ),
876 );
877 continue;
878 }
879 if ELEMENTS.contains(&import.name.as_str()) {
880 self.problem(
881 path,
882 import.pos,
883 format!("import {0} — {0} is a built-in element", import.name),
884 );
885 continue;
886 }
887 if imports.contains_key(&import.name) {
888 self.problem(
889 path,
890 import.pos,
891 format!("import {0} — {0} is already imported", import.name),
892 );
893 continue;
894 }
895 if !import.path.ends_with(".design") {
896 self.problem(
897 path,
898 import.pos,
899 format!("import {} — a design is a .design file", import.path),
900 );
901 continue;
902 }
903 match join(path, &import.path) {
904 Some(target) => {
905 if self.files.read(&target).is_none() {
907 self.problem(
908 path,
909 import.pos,
910 format!("import {} — no such file", import.path),
911 );
912 self.missing.push(target.clone());
913 }
914 imports.insert(import.name.clone(), target);
915 }
916 None => self.problem(
917 path,
918 import.pos,
919 format!("import {} — outside the served folder", import.path),
920 ),
921 }
922 }
923 if file.root.name != "Frame" {
924 self.problem(
925 path,
926 file.root.pos,
927 format!("the root element must be a Frame, not {}", file.root.name),
928 );
929 return None;
930 }
931 let mut props = Props::new();
933 if let Some(attr) = file.root.attrs.iter().find(|a| a.name == "props") {
934 match &attr.value {
935 Value::Object(fields) => {
936 for (k, v) in fields {
937 if k == parse::THEME {
938 self.problem(
939 path,
940 attr.pos,
941 format!(
942 "props: {k} is the name of the design's themes, not a prop"
943 ),
944 );
945 continue;
946 }
947 props.insert(k.clone(), v.clone());
948 }
949 }
950 _ => self.problem(
951 path,
952 attr.pos,
953 "props: expected an object of defaults".into(),
954 ),
955 }
956 }
957 let (caller, caller_pos) = from.unwrap_or((path, file.root.pos));
958 let caller = caller.to_string();
959 let mut overrides = Vec::new();
960 for arg in args {
961 if matches!(
962 arg.name.as_str(),
963 "x" | "y" | "width" | "height" | "name" | "opacity" | "onClick"
964 ) {
965 overrides.push(arg.clone());
966 } else if props.contains_key(&arg.name) {
967 props.insert(arg.name.clone(), arg.value.clone());
968 } else {
969 self.problem(
970 &caller,
971 arg.pos,
972 format!("{used_as} has no prop \"{}\"", arg.name),
973 );
974 }
975 }
976 let _ = caller_pos;
977 self.stack.push(path.to_string());
978 let mut root = self.element(path, &file.root, &props, &imports, true, parent);
979 self.stack.pop();
980 if let Some(root) = root.as_mut() {
981 for attr in &overrides {
982 self.common(&caller, root, attr, attr.value.clone());
984 self.fill_needs_layout(&caller, attr, &attr.value, parent);
985 self.place_needs_no_layout(&caller, attr, parent);
986 }
987 }
988 root
989 }
990
991 fn element(
992 &mut self,
993 path: &str,
994 el: &Element,
995 props: &Props,
996 imports: &HashMap<String, String>,
997 root: bool,
998 parent: Option<Layout>,
999 ) -> Option<Node> {
1000 if self.depth >= MAX_DEPTH {
1001 self.problem(path, el.pos, format!("nested more than {MAX_DEPTH} deep"));
1002 return None;
1003 }
1004 self.depth += 1;
1005 let node = self.element_inner(path, el, props, imports, root, parent);
1006 self.depth -= 1;
1007 node
1008 }
1009
1010 fn element_inner(
1011 &mut self,
1012 path: &str,
1013 el: &Element,
1014 props: &Props,
1015 imports: &HashMap<String, String>,
1016 root: bool,
1017 parent: Option<Layout>,
1018 ) -> Option<Node> {
1019 let mut seen: Vec<&str> = Vec::new();
1022 let mut attrs = Vec::new();
1023 for attr in &el.attrs {
1024 if seen.contains(&attr.name.as_str()) {
1025 self.problem(path, attr.pos, format!("{} is given twice", attr.name));
1026 } else {
1027 seen.push(&attr.name);
1028 attrs.push(attr);
1029 }
1030 }
1031 if let Some(target) = imports.get(&el.name) {
1032 if !el.children.is_empty() {
1033 self.problem(path, el.pos, format!("{} takes no children", el.name));
1034 }
1035 let args: Vec<parse::Attr> = attrs
1036 .iter()
1037 .map(|a| parse::Attr {
1038 name: a.name.clone(),
1039 value: substitute(&a.value, props),
1040 pos: a.pos,
1041 })
1042 .collect();
1043 let target = target.clone();
1044 return self.design(&target, &args, Some((path, el.pos)), &el.name, parent);
1045 }
1046 let kind = match el.name.as_str() {
1047 "Frame" => Kind::Frame,
1049 "Text" => Kind::Text,
1050 "Rect" => Kind::Rect,
1051 "Ellipse" => Kind::Ellipse,
1052 "Vector" => Kind::Vector,
1053 "Image" => Kind::Image,
1054 other => {
1055 self.problem(path, el.pos, format!("unknown element {other}"));
1056 return None;
1057 }
1058 };
1059 let mut node = Node::new(kind, &el.name);
1060 for attr in attrs {
1061 if attr.name == "props" {
1062 if !root {
1063 self.problem(
1064 path,
1065 attr.pos,
1066 "props: only a design's root declares props".into(),
1067 );
1068 }
1069 continue;
1070 }
1071 if root && matches!(attr.name.as_str(), "x" | "y") {
1074 self.problem(
1075 path,
1076 attr.pos,
1077 format!(
1078 "{}: a design's root has no place of its own; place an instance with x and y where it is used",
1079 attr.name
1080 ),
1081 );
1082 continue;
1083 }
1084 let value = match &attr.value {
1085 Value::Prop(p) => match props.get(p) {
1086 Some(v) => v.clone(),
1087 None => {
1088 self.problem(path, attr.pos, format!("{}: no prop \"{p}\"", attr.name));
1089 continue;
1090 }
1091 },
1092 v => v.clone(),
1093 };
1094 self.themed |= has_theme(&value);
1095 self.fill_needs_layout(path, attr, &value, parent);
1096 self.place_needs_no_layout(path, attr, parent);
1097 self.attribute(path, &mut node, attr, value);
1098 }
1099 match kind {
1100 Kind::Vector if !el.attrs.iter().any(|a| a.name == "d") => self.problem(
1101 path,
1102 el.pos,
1103 format!("{} needs a path, d=\"M…\"", node.name),
1104 ),
1105 Kind::Image if node.image.is_none() && !el.attrs.iter().any(|a| a.name == "src") => {
1106 self.problem(
1107 path,
1108 el.pos,
1109 format!("{} needs a file, src=\"…\"", node.name),
1110 )
1111 }
1112 _ => {}
1113 }
1114 if kind == Kind::Text {
1115 let mut content = String::new();
1116 for child in &el.children {
1117 match child {
1118 Child::Text(parts, pos) => {
1119 for part in parts {
1120 match part {
1121 TextPart::Literal(s) => content.push_str(s),
1122 TextPart::Prop(p) => match props.get(p) {
1123 Some(Value::Str(s, _)) => content.push_str(s),
1124 Some(Value::Num(n)) => content.push_str(&n.to_string()),
1125 _ => self.problem(path, *pos, format!("no text prop \"{p}\"")),
1126 },
1127 TextPart::Theme(name, at) => self.problem(
1128 path,
1129 *at,
1130 format!(
1131 "theme.{name} is a color, not text: give it to color={{theme.{name}}}"
1132 ),
1133 ),
1134 }
1135 }
1136 }
1137 Child::Element(e) => self.problem(path, e.pos, "Text holds text only".into()),
1138 }
1139 }
1140 node.text.get_or_insert_with(default_text).content = content;
1141 } else {
1142 for child in &el.children {
1143 match child {
1144 Child::Element(e) => {
1145 if kind != Kind::Frame {
1146 self.problem(path, e.pos, format!("{} takes no children", el.name));
1147 } else if let Some(n) =
1148 self.element(path, e, props, imports, false, Some(node.layout))
1149 {
1150 node.children.push(n);
1151 }
1152 }
1153 Child::Text(_, pos) => self.problem(
1154 path,
1155 *pos,
1156 format!("text belongs in a Text, not a {}", el.name),
1157 ),
1158 }
1159 }
1160 }
1161 Some(node)
1162 }
1163
1164 fn fill_needs_layout(
1167 &mut self,
1168 path: &str,
1169 attr: &parse::Attr,
1170 value: &Value,
1171 parent: Option<Layout>,
1172 ) {
1173 if parent == Some(Layout::None)
1174 && matches!(attr.name.as_str(), "width" | "height")
1175 && matches!(value, Value::Str(s, _) if s == "fill")
1176 {
1177 self.problem(
1178 path,
1179 attr.pos,
1180 format!(
1181 "{}: \"fill\" needs a parent with layout=\"row\" or \"column\"",
1182 attr.name
1183 ),
1184 );
1185 }
1186 }
1187
1188 fn place_needs_no_layout(&mut self, path: &str, attr: &parse::Attr, parent: Option<Layout>) {
1191 if matches!(parent, Some(Layout::Row | Layout::Column))
1192 && matches!(attr.name.as_str(), "x" | "y")
1193 {
1194 self.problem(
1195 path,
1196 attr.pos,
1197 format!(
1198 "{}: places a child only in a frame with layout=\"none\"",
1199 attr.name
1200 ),
1201 );
1202 }
1203 }
1204
1205 #[allow(clippy::unit_arg)]
1207 fn attribute(&mut self, path: &str, node: &mut Node, attr: &parse::Attr, value: Value) {
1208 let kind = node.kind;
1209 let ok = match (attr.name.as_str(), kind) {
1210 ("x" | "y" | "width" | "height" | "name" | "opacity" | "onClick", _) => {
1211 self.common(path, node, attr, value);
1212 return;
1213 }
1214 ("fill", k) if k != Kind::Text => fill(&value).map(|f| node.fill = Some(f)),
1215 ("stroke", k) if k != Kind::Text => color(&value).map(|c| node.stroke = Some(c)),
1216 ("strokeWidth", k) if k != Kind::Text => px(&value).map(|n| node.stroke_width = n),
1217 ("radius", k) if k != Kind::Text => match &value {
1218 Value::Num(_) => px(&value).map(|n| node.radius = [n; 4]),
1219 Value::Array(a) if a.len() == 4 => {
1220 pxs(a).map(|v| node.radius = [v[0], v[1], v[2], v[3]])
1221 }
1222 _ => Err("expected pixels or an array of 4".into()),
1223 },
1224 ("shadow", k) if k != Kind::Text => shadow(&value).map(|mut s| {
1225 s.at = Origin {
1226 file: path.to_string(),
1227 pos: attr.pos,
1228 };
1229 node.shadow = Some(s)
1230 }),
1231 ("clip", Kind::Frame) => match value {
1233 Value::Bool(b) => Ok(node.clip = b),
1234 _ => Err("expected true or false".into()),
1235 },
1236 ("layout", Kind::Frame) => match str_of(&value).as_deref() {
1237 Some("row") => Ok(node.layout = Layout::Row),
1238 Some("column") => Ok(node.layout = Layout::Column),
1239 Some("none") => Ok(node.layout = Layout::None),
1240 _ => Err("expected \"row\", \"column\" or \"none\"".into()),
1241 },
1242 ("gap", Kind::Frame) => px(&value).map(|n| node.gap = n),
1243 ("padding", Kind::Frame) => match &value {
1244 Value::Num(_) => px(&value).map(|n| node.padding = [n; 4]),
1245 Value::Array(a) if a.len() == 2 => {
1246 pxs(a).map(|v| node.padding = [v[0], v[1], v[0], v[1]])
1247 }
1248 Value::Array(a) if a.len() == 4 => {
1249 pxs(a).map(|v| node.padding = [v[0], v[1], v[2], v[3]])
1250 }
1251 other => Err(format!(
1252 "expected pixels or an array of 2 or 4, found {}",
1253 show(other)
1254 )),
1255 },
1256 ("align", Kind::Frame) => align(&value, false).map(|a| node.align = a),
1257 ("justify", Kind::Frame) => align(&value, true).map(|a| node.justify = a),
1258 ("size", Kind::Text) => {
1259 px(&value).map(|n| node.text.get_or_insert_with(default_text).size = n)
1260 }
1261 ("weight", Kind::Text) => match num(&value) {
1262 Ok(w) if [400.0, 500.0, 600.0, 700.0].contains(&w) => {
1263 Ok(node.text.get_or_insert_with(default_text).weight = w as u16)
1264 }
1265 _ => Err("expected 400, 500, 600 or 700".into()),
1266 },
1267 ("color", Kind::Text) => {
1268 color(&value).map(|c| node.text.get_or_insert_with(default_text).color = c)
1269 }
1270 ("lineHeight", Kind::Text) => {
1271 px(&value).map(|n| node.text.get_or_insert_with(default_text).line_height = n)
1272 }
1273 ("textAlign", Kind::Text) => match str_of(&value).as_deref() {
1274 Some("left") => {
1275 Ok(node.text.get_or_insert_with(default_text).align = TextAlign::Left)
1276 }
1277 Some("center") => {
1278 Ok(node.text.get_or_insert_with(default_text).align = TextAlign::Center)
1279 }
1280 Some("right") => {
1281 Ok(node.text.get_or_insert_with(default_text).align = TextAlign::Right)
1282 }
1283 _ => Err("expected \"left\", \"center\" or \"right\"".into()),
1284 },
1285 ("d", Kind::Vector) => match str_of(&value) {
1286 Some(d) => match path_numbers(&d).map_err(|e| e.to_string()).and_then(|()| {
1287 vello_cpu::kurbo::BezPath::from_svg(&d)
1288 .map_err(|e| format!("not SVG path data: {e}"))
1289 }) {
1290 Ok(_) => {
1291 let view_box = node.vector.as_ref().map(|v| v.view_box);
1292 let view_box = view_box.unwrap_or_else(|| bounds(&d));
1293 Ok(node.vector = Some(Vector { d, view_box }))
1294 }
1295 Err(e) => Err(e),
1296 },
1297 None => Err("expected SVG path data".into()),
1298 },
1299 ("viewBox", Kind::Vector) => match str_of(&value).as_deref().map(view_box) {
1300 Some(Ok(b)) => {
1301 let v = node.vector.get_or_insert_with(|| Vector {
1302 d: String::new(),
1303 view_box: b,
1304 });
1305 Ok(v.view_box = b)
1306 }
1307 Some(Err(e)) => Err(e),
1308 None => Err("expected \"minX minY width height\"".into()),
1309 },
1310 ("src", Kind::Image) => match str_of(&value) {
1311 Some(src) => match join(path, &src) {
1312 None => Err(format!("{src} — outside the served folder")),
1313 Some(at) => match self.image_file(&at) {
1314 None => Err(format!("{src} — no such file")),
1315 Some(Err(e)) => Err(format!("{src} {e}")),
1316 Some(Ok(id)) => {
1317 node.image = Some(Image {
1318 src: at,
1319 id,
1320 at: Origin {
1321 file: path.to_string(),
1322 pos: attr.pos,
1323 },
1324 });
1325 Ok(())
1326 }
1327 },
1328 },
1329 None => Err("expected a path".into()),
1330 },
1331 (name, _) => {
1332 self.problem(
1333 path,
1334 attr.pos,
1335 format!("{} takes no attribute {name}", node.name),
1336 );
1337 return;
1338 }
1339 };
1340 if let Err(e) = ok {
1341 self.problem(path, attr.pos, format!("{}: {e}", attr.name));
1342 }
1343 }
1344
1345 fn image_file(&mut self, at: &str) -> Option<Result<ImageId, String>> {
1349 if let Some(known) = self.by_path.get(at) {
1350 return Some(known.clone());
1351 }
1352 let file = self.files.image(at)?;
1353 if !self.read.iter().any(|p| p == at) {
1354 self.read.push(at.to_string());
1355 }
1356 let id = file.map(|file| {
1357 let id = file.id;
1358 self.images.entry(id).or_insert(file);
1359 id
1360 });
1361 self.by_path.insert(at.to_string(), id.clone());
1362 Some(id)
1363 }
1364
1365 fn interaction(&mut self, path: &str, action: &str) -> Result<String, String> {
1368 let Some((verb, target)) = action.split_once(' ') else {
1369 return Ok(action.to_string());
1370 };
1371 let target = target.trim();
1372 if !target.ends_with(".design") {
1373 return Err(format!("{action} — a design is a .design file"));
1374 }
1375 match join(path, target) {
1376 None => Err(format!("{action} — outside the served folder")),
1377 Some(at) if self.files.bytes(&at).is_none() => Err(format!("{action} — no such file")),
1378 Some(at) => Ok(format!("{verb} {at}")),
1379 }
1380 }
1381
1382 fn common(&mut self, path: &str, node: &mut Node, attr: &parse::Attr, value: Value) {
1383 let ok = match attr.name.as_str() {
1384 "x" => num(&value).map(|n| node.x = n),
1385 "y" => num(&value).map(|n| node.y = n),
1386 "width" => size(&value).map(|s| node.width = s),
1387 "height" => size(&value).map(|s| node.height = s),
1388 "name" => str_of(&value)
1389 .map(|s| node.name = s)
1390 .ok_or_else(|| "expected a string".into()),
1391 "opacity" => match value {
1392 Value::Num(n) if (0.0..=1.0).contains(&n) => {
1393 node.opacity = n as f32;
1394 Ok(())
1395 }
1396 other => Err(format!(
1397 "expected a number from 0 to 1, found {}",
1398 show(&other)
1399 )),
1400 },
1401 "onClick" => match str_of(&value).filter(|s| interaction_ok(s)) {
1402 Some(action) => self
1403 .interaction(path, &action)
1404 .map(|a| node.on_click = Some(a)),
1405 None => Err(
1406 "expected \"navigate <path>\", \"back\", \"overlay <path>\" or \"close\""
1407 .into(),
1408 ),
1409 },
1410 _ => unreachable!(),
1411 };
1412 if let Err(e) = ok {
1413 self.problem(path, attr.pos, format!("{}: {e}", attr.name));
1414 }
1415 }
1416}
1417
1418fn default_text() -> TextStyle {
1419 TextStyle {
1420 content: String::new(),
1421 size: 14.0,
1422 weight: 400,
1423 color: Color::fixed(Rgba {
1424 r: 0x0f,
1425 g: 0x17,
1426 b: 0x2a,
1427 a: 255,
1428 }),
1429 line_height: 1.4,
1430 align: TextAlign::Left,
1431 }
1432}
1433
1434fn view_box(s: &str) -> Result<[f32; 4], String> {
1435 let v: Vec<f32> = s
1436 .split([' ', ','])
1437 .filter(|p| !p.is_empty())
1438 .map(|p| parse::number(p).map(|n| n as f32))
1439 .collect::<Result<_, _>>()?;
1440 match v[..] {
1441 [x, y, w, h] if w > 0.0 && h > 0.0 => Ok([x, y, w, h]),
1442 _ => Err("expected \"minX minY width height\"".into()),
1443 }
1444}
1445
1446fn path_numbers(d: &str) -> Result<(), String> {
1450 let b = d.as_bytes();
1451 let mut i = 0;
1452 let mut command = b' ';
1453 let mut param = 0;
1455 while i < b.len() {
1456 let c = b[i];
1457 if c.is_ascii_alphabetic() && c != b'e' && c != b'E' {
1458 command = c;
1459 param = 0;
1460 i += 1;
1461 continue;
1462 }
1463 if c.is_ascii_whitespace() || c == b',' {
1464 i += 1;
1465 continue;
1466 }
1467 if matches!(command, b'A' | b'a') && matches!(param % 7, 3 | 4) && matches!(c, b'0' | b'1')
1468 {
1469 param += 1;
1470 i += 1;
1471 continue;
1472 }
1473 let start = i;
1474 if matches!(b[i], b'+' | b'-') {
1475 i += 1;
1476 }
1477 let mut dot = false;
1478 while i < b.len() && (b[i].is_ascii_digit() || (b[i] == b'.' && !dot)) {
1479 dot |= b[i] == b'.';
1480 i += 1;
1481 }
1482 if i < b.len() && matches!(b[i], b'e' | b'E') {
1483 let mut j = i + 1;
1484 if j < b.len() && matches!(b[j], b'+' | b'-') {
1485 j += 1;
1486 }
1487 if j < b.len() && b[j].is_ascii_digit() {
1488 i = j;
1489 while i < b.len() && b[i].is_ascii_digit() {
1490 i += 1;
1491 }
1492 }
1493 }
1494 if i == start {
1495 return Ok(());
1497 }
1498 let s = &d[start..i];
1499 if let Ok(n) = s.parse::<f64>() {
1500 parse::in_range(s, n)?;
1501 }
1502 param += 1;
1503 }
1504 Ok(())
1505}
1506
1507fn bounds(d: &str) -> [f32; 4] {
1509 use vello_cpu::kurbo::Shape;
1510 let b = vello_cpu::kurbo::BezPath::from_svg(d)
1511 .map(|p| p.bounding_box())
1512 .unwrap_or_default();
1513 [
1514 b.x0 as f32,
1515 b.y0 as f32,
1516 (b.width() as f32).max(1.0),
1517 (b.height() as f32).max(1.0),
1518 ]
1519}
1520
1521fn interaction_ok(s: &str) -> bool {
1522 s == "back"
1523 || s == "close"
1524 || s.strip_prefix("navigate ")
1525 .or(s.strip_prefix("overlay "))
1526 .is_some_and(|p| !p.trim().is_empty())
1527}
1528
1529pub fn join(from: &str, relative: &str) -> Option<String> {
1532 let mut parts: Vec<&str> = from.split('/').filter(|p| !p.is_empty()).collect();
1533 parts.pop();
1534 for part in relative.split('/') {
1535 match part {
1536 "." | "" => {}
1537 ".." => {
1538 parts.pop()?;
1539 }
1540 p => parts.push(p),
1541 }
1542 }
1543 Some(parts.join("/"))
1544}
1545
1546fn substitute(value: &Value, props: &Props) -> Value {
1547 match value {
1548 Value::Prop(p) => props.get(p).cloned().unwrap_or_else(|| value.clone()),
1549 v => v.clone(),
1550 }
1551}
1552
1553fn show(v: &Value) -> String {
1554 match v {
1555 Value::Str(s, _) => format!("\"{s}\""),
1556 Value::Num(n) => n.to_string(),
1557 Value::Bool(b) => b.to_string(),
1558 Value::Array(_) => "an array".into(),
1559 Value::Object(_) => "an object".into(),
1560 Value::Prop(p) => p.clone(),
1561 Value::Theme(name, _) => format!("theme.{name}"),
1562 Value::Themed { written, .. } => written.clone(),
1563 }
1564}
1565
1566fn str_of(v: &Value) -> Option<String> {
1567 match v {
1568 Value::Str(s, _) => Some(s.clone()),
1569 _ => None,
1570 }
1571}
1572
1573fn key_pos(text: &str, under: Option<&str>, key: &str) -> Option<Pos> {
1578 let names = |line: &str, key: &str| {
1579 let line = line.trim_start();
1580 [
1581 format!("{key}:"),
1582 format!("\"{key}\":"),
1583 format!("'{key}':"),
1584 ]
1585 .iter()
1586 .any(|k| line.starts_with(k.as_str()))
1587 };
1588 let top = |line: &str| !line.starts_with([' ', '\t']) && !line.trim().is_empty();
1589 let mut inside = under.is_none();
1590 for (n, line) in text.lines().enumerate() {
1591 if line.trim_start().starts_with('#') {
1592 continue;
1593 }
1594 let pos = Pos {
1595 line: n as u32 + 1,
1596 col: (line.len() - line.trim_start().len()) as u32 + 1,
1597 };
1598 match under {
1599 None if top(line) && names(line, key) => return Some(pos),
1600 None => {}
1601 Some(block) if top(line) => inside = names(line, block),
1602 Some(_) if inside && names(line, key) => return Some(pos),
1603 Some(_) => {}
1604 }
1605 }
1606 None
1607}
1608
1609const ELEMENTS: [&str; 6] = ["Frame", "Text", "Rect", "Ellipse", "Vector", "Image"];
1611
1612fn num(v: &Value) -> Result<f32, String> {
1613 match v {
1614 Value::Num(n) => Ok(*n as f32),
1615 other => Err(format!("expected a number, found {}", show(other))),
1616 }
1617}
1618
1619fn px(v: &Value) -> Result<f32, String> {
1621 match v {
1622 Value::Num(n) if *n >= 0.0 => Ok(*n as f32),
1623 Value::Num(n) => Err(format!("expected a number of 0 or more, found {n}")),
1624 other => Err(format!("expected a number, found {}", show(other))),
1625 }
1626}
1627
1628fn pxs(a: &[Value]) -> Result<Vec<f32>, String> {
1629 a.iter().map(px).collect()
1630}
1631
1632fn size(v: &Value) -> Result<Size, String> {
1633 match v {
1634 Value::Num(n) if *n >= 0.0 => Ok(Size::Px(*n as f32)),
1635 Value::Num(n) => Err(format!("expected pixels of 0 or more, found {n}")),
1636 Value::Str(s, _) if s == "hug" => Ok(Size::Hug),
1637 Value::Str(s, _) if s == "fill" => Ok(Size::Fill),
1638 other => Err(format!(
1639 "expected pixels, \"hug\" or \"fill\", found {}",
1640 show(other)
1641 )),
1642 }
1643}
1644
1645fn align(v: &Value, justify: bool) -> Result<Align, String> {
1646 match str_of(v).as_deref() {
1647 Some("start") => Ok(Align::Start),
1648 Some("center") => Ok(Align::Center),
1649 Some("end") => Ok(Align::End),
1650 Some("space-between") if justify => Ok(Align::SpaceBetween),
1651 _ if justify => Err("expected \"start\", \"center\", \"end\" or \"space-between\"".into()),
1652 _ => Err("expected \"start\", \"center\" or \"end\"".into()),
1653 }
1654}
1655
1656fn color(v: &Value) -> Result<Color, String> {
1659 match v {
1660 Value::Str(s, _) => rgba(s).map(Color::fixed),
1661 Value::Themed { light, dark, .. } => Ok(Color {
1662 light: rgba(light)?,
1663 dark: rgba(dark)?,
1664 }),
1665 _ => Err(format!("expected a color, found {}", show(v))),
1666 }
1667}
1668
1669pub fn rgba(s: &str) -> Result<Rgba, String> {
1671 if s.starts_with("theme.") {
1672 return Err(format!(
1673 "{s} is a theme color: write it in braces, {{{s}}}, not in quotes"
1674 ));
1675 }
1676 let hex = s.strip_prefix('#').ok_or("expected a color like #rrggbb")?;
1677 if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
1678 return Err(format!("{s} is not a color"));
1679 }
1680 let d = |i: usize, n: usize| {
1681 u8::from_str_radix(&hex[i..i + n], 16).map_err(|_| format!("{s} is not a color"))
1682 };
1683 let dd = |i: usize| d(i, 1).map(|v| v * 17);
1684 match hex.len() {
1685 3 => Ok(Rgba {
1686 r: dd(0)?,
1687 g: dd(1)?,
1688 b: dd(2)?,
1689 a: 255,
1690 }),
1691 4 => Ok(Rgba {
1692 r: dd(0)?,
1693 g: dd(1)?,
1694 b: dd(2)?,
1695 a: dd(3)?,
1696 }),
1697 6 => Ok(Rgba {
1698 r: d(0, 2)?,
1699 g: d(2, 2)?,
1700 b: d(4, 2)?,
1701 a: 255,
1702 }),
1703 8 => Ok(Rgba {
1704 r: d(0, 2)?,
1705 g: d(2, 2)?,
1706 b: d(4, 2)?,
1707 a: d(6, 2)?,
1708 }),
1709 _ => Err(format!("{s} is not a color")),
1710 }
1711}
1712
1713fn hex(c: Rgba) -> String {
1715 format!("#{:02x}{:02x}{:02x}{:02x}", c.r, c.g, c.b, c.a)
1716}
1717
1718fn fill(v: &Value) -> Result<Fill, String> {
1719 match v {
1720 Value::Str(s, _) => fill_of(s),
1721 Value::Themed { light, dark, .. } => match (fill_of(light)?, fill_of(dark)?) {
1724 (Fill::Solid(l), Fill::Solid(d)) => Ok(Fill::Solid(Color {
1725 light: l.light,
1726 dark: d.light,
1727 })),
1728 (
1729 Fill::Linear { angle, stops: l },
1730 Fill::Linear {
1731 stops: d,
1732 angle: dark_angle,
1733 },
1734 ) if angle == dark_angle && l.len() == d.len() => Ok(Fill::Linear {
1735 angle,
1736 stops: l
1737 .iter()
1738 .zip(&d)
1739 .map(|((l, at), (d, _))| {
1740 (
1741 Color {
1742 light: l.light,
1743 dark: d.light,
1744 },
1745 *at,
1746 )
1747 })
1748 .collect(),
1749 }),
1750 _ => Err(format!("{} is not the same fill in both themes", show(v))),
1751 },
1752 _ => Err(format!("expected a color or a gradient, found {}", show(v))),
1753 }
1754}
1755
1756fn fill_of(s: &str) -> Result<Fill, String> {
1758 let Some(inner) = s
1759 .strip_prefix("linear-gradient(")
1760 .and_then(|r| r.strip_suffix(')'))
1761 else {
1762 return rgba(s).map(|c| Fill::Solid(Color::fixed(c)));
1763 };
1764 let wrong =
1765 || format!("expected \"linear-gradient(<angle>deg, <color> <stop>%, …)\", found \"{s}\"");
1766 let mut parts = inner.split(',').map(str::trim);
1767 let angle = parts
1768 .next()
1769 .and_then(|a| a.strip_suffix("deg"))
1770 .ok_or_else(wrong)?;
1771 let angle = parse::number(angle.trim())? as f32;
1772 let mut stops = Vec::new();
1773 for part in parts {
1774 let (c, at) = part.split_once(' ').ok_or_else(wrong)?;
1775 let at = at.trim().strip_suffix('%').ok_or_else(wrong)?;
1776 let at = parse::number(at)? as f32;
1777 stops.push((Color::fixed(rgba(c)?), at / 100.0));
1778 }
1779 if stops.len() < 2 {
1780 return Err(wrong());
1781 }
1782 Ok(Fill::Linear { angle, stops })
1783}
1784
1785fn has_theme(v: &Value) -> bool {
1787 match v {
1788 Value::Themed { .. } | Value::Theme(..) => true,
1789 Value::Array(items) => items.iter().any(has_theme),
1790 Value::Object(fields) => fields.iter().any(|(_, v)| has_theme(v)),
1791 _ => false,
1792 }
1793}
1794
1795fn shadow(v: &Value) -> Result<Shadow, String> {
1796 let Value::Object(fields) = v else {
1797 return Err("expected { x, y, blur, spread, color }".into());
1798 };
1799 let mut s = Shadow {
1800 x: 0.0,
1801 y: 0.0,
1802 blur: 0.0,
1803 spread: 0.0,
1804 color: Color::fixed(Rgba {
1805 r: 0,
1806 g: 0,
1807 b: 0,
1808 a: 64,
1809 }),
1810 at: Origin::default(),
1811 };
1812 for (k, v) in fields {
1813 match k.as_str() {
1814 "x" => s.x = num(v)?,
1815 "y" => s.y = num(v)?,
1816 "blur" => s.blur = num(v)?,
1817 "spread" => s.spread = num(v)?,
1818 "color" => s.color = color(v)?,
1819 other => return Err(format!("a shadow has no {other}")),
1820 }
1821 }
1822 Ok(s)
1823}
1824
1825#[cfg(test)]
1826mod tests {
1827 use super::*;
1828
1829 fn files(list: &[(&str, &str)]) -> HashMap<String, String> {
1830 list.iter()
1831 .map(|(p, s)| (p.to_string(), s.to_string()))
1832 .collect()
1833 }
1834
1835 #[test]
1836 fn an_instance_takes_its_props() {
1837 let fs = files(&[
1838 (
1839 "a/button.design",
1840 r##"<Frame props={{ label: "Button", fill: "#2563eb" }} fill={fill}><Text>{label}</Text></Frame>"##,
1841 ),
1842 (
1843 "a/screen.design",
1844 r#"import Button from "./button.design"
1845<Frame width={390}><Button label="Pay" x={10} opacity={0.5} /></Frame>"#,
1846 ),
1847 ]);
1848 let scene = resolve(&fs, "a/screen.design").unwrap();
1849 let button = &scene.root.children[0];
1850 assert_eq!(button.x, 10.0);
1851 assert_eq!(button.opacity, 0.5);
1852 assert_eq!(
1853 button.fill,
1854 Some(Fill::Solid(Color::fixed(Rgba {
1855 r: 0x25,
1856 g: 0x63,
1857 b: 0xeb,
1858 a: 255
1859 })))
1860 );
1861 assert_eq!(button.children[0].text.as_ref().unwrap().content, "Pay");
1862 assert_eq!(scene.files, vec!["a/screen.design", "a/button.design"]);
1863 }
1864
1865 #[test]
1866 fn opacity_is_between_zero_and_one() {
1867 let fs = files(&[
1868 ("dot.design", "<Frame />"),
1869 (
1870 "screen.design",
1871 "import Dot from \"./dot.design\"\n<Frame opacity={1}>\n <Rect opacity={50} />\n <Dot opacity={-0.1} />\n</Frame>",
1872 ),
1873 ]);
1874 let problems: Vec<String> = resolve(&fs, "screen.design")
1875 .unwrap_err()
1876 .iter()
1877 .map(|p| p.to_string())
1878 .collect();
1879 assert_eq!(
1880 problems,
1881 vec![
1882 "screen.design:3:9: opacity: expected a number from 0 to 1, found 50",
1883 "screen.design:4:8: opacity: expected a number from 0 to 1, found -0.1",
1884 ]
1885 );
1886 }
1887
1888 fn problems_in(fs: &HashMap<String, String>, path: &str) -> Vec<String> {
1889 resolve(fs, path)
1890 .unwrap_err()
1891 .iter()
1892 .map(|p| p.to_string())
1893 .collect()
1894 }
1895
1896 #[test]
1897 fn a_color_with_any_character_but_hex_digits_is_a_problem() {
1898 for (value, col) in [
1900 ("#fff\u{200b}", 13),
1901 ("#ffé", 13),
1902 ("#ff€f00", 13),
1903 ("#ggg", 13),
1904 ] {
1905 let fs = files(&[("a.design", &format!(r#"<Frame fill="{value}" />"#))]);
1906 assert_eq!(
1907 problems_in(&fs, "a.design"),
1908 vec![format!("a.design:1:8: fill: {value} is not a color")],
1909 "{value:?} at {col}"
1910 );
1911 }
1912 }
1913
1914 #[test]
1915 fn props_belong_to_the_root_only() {
1916 let fs = files(&[(
1917 "a.design",
1918 r#"<Frame props={{ a: "x" }}>
1919 <Frame props={{ b: "y" }} />
1920</Frame>"#,
1921 )]);
1922 assert_eq!(
1923 problems_in(&fs, "a.design"),
1924 vec!["a.design:2:10: props: only a design's root declares props"]
1925 );
1926 }
1927
1928 #[test]
1929 fn a_root_has_no_place_of_its_own() {
1930 let fs = files(&[
1931 (
1932 "dot.design",
1933 r#"<Frame x={40} y={30} width={10} height={10} />"#,
1934 ),
1935 (
1936 "a.design",
1937 r#"import Dot from "./dot.design"
1938<Frame width={100} height={100}>
1939 <Dot x={5} y={6} />
1940</Frame>"#,
1941 ),
1942 ]);
1943 let expected = [
1944 "dot.design:1:8: x: a design's root has no place of its own; place an instance with x and y where it is used",
1945 "dot.design:1:15: y: a design's root has no place of its own; place an instance with x and y where it is used",
1946 ];
1947 assert_eq!(problems_in(&fs, "dot.design"), expected);
1948 assert_eq!(problems_in(&fs, "a.design"), expected);
1951 let fs = files(&[
1952 ("dot.design", r#"<Frame width={10} height={10} />"#),
1953 (
1954 "a.design",
1955 r#"import Dot from "./dot.design"
1956<Frame width={100} height={100}>
1957 <Dot x={5} y={6} />
1958</Frame>"#,
1959 ),
1960 ]);
1961 let dot = &resolve(&fs, "a.design").unwrap().root.children[0];
1962 assert_eq!((dot.x, dot.y), (5.0, 6.0));
1963 }
1964
1965 #[test]
1966 fn fill_needs_a_parent_with_auto_layout() {
1967 let fs = files(&[
1968 ("dot.design", r#"<Frame width="fill" />"#),
1969 (
1970 "a.design",
1971 r#"import Dot from "./dot.design"
1972<Frame width={100} height={100}>
1973 <Rect width="fill" height={10} />
1974 <Dot />
1975 <Dot height="fill" />
1976 <Frame layout="row"><Rect width="fill" /></Frame>
1977</Frame>"#,
1978 ),
1979 ]);
1980 assert_eq!(
1981 problems_in(&fs, "a.design"),
1982 vec![
1983 "a.design:3:9: width: \"fill\" needs a parent with layout=\"row\" or \"column\"",
1984 "dot.design:1:8: width: \"fill\" needs a parent with layout=\"row\" or \"column\"",
1985 "dot.design:1:8: width: \"fill\" needs a parent with layout=\"row\" or \"column\"",
1986 "a.design:5:8: height: \"fill\" needs a parent with layout=\"row\" or \"column\"",
1987 ]
1988 );
1989 }
1990
1991 #[test]
1992 fn a_tree_nests_at_most_so_deep_through_instances() {
1993 let mut list = Vec::new();
1995 let depth = 40;
1996 for i in 0..depth {
1997 let body = if i + 1 < depth {
1998 let inner = "<Frame>".repeat(10) + "<N />" + &"</Frame>".repeat(10);
1999 format!("import N from \"./{}.design\"\n{inner}", i + 1)
2000 } else {
2001 "<Frame />".to_string()
2002 };
2003 list.push((format!("{i}.design"), body));
2004 }
2005 let fs: HashMap<String, String> = list.into_iter().collect();
2006 let found = problems_in(&fs, "0.design");
2007 assert_eq!(found.len(), 1, "{found:?}");
2008 assert!(
2009 found[0].ends_with(&format!("nested more than {MAX_DEPTH} deep")),
2010 "{found:?}"
2011 );
2012 }
2013
2014 #[test]
2015 fn every_problem_is_reported() {
2016 let fs = files(&[
2017 (
2018 "button.design",
2019 r#"<Frame props={{ label: "B" }} padding="12px"><Text>{label}</Text></Frame>"#,
2020 ),
2021 (
2022 "screen.design",
2023 "import Button from \"./button.design\"\nimport Row from \"./row.design\"\n<Frame>\n <Button lable=\"x\" />\n <Row />\n <Blob />\n</Frame>",
2024 ),
2025 ]);
2026 let problems: Vec<String> = resolve(&fs, "screen.design")
2027 .unwrap_err()
2028 .iter()
2029 .map(|p| p.to_string())
2030 .collect();
2031 assert_eq!(
2032 problems,
2033 vec![
2034 "screen.design:2:1: import ./row.design — no such file",
2035 "screen.design:4:11: Button has no prop \"lable\"",
2036 "button.design:1:31: padding: expected pixels or an array of 2 or 4, found \"12px\"",
2037 "screen.design:6:3: unknown element Blob",
2038 ]
2039 );
2040 }
2041
2042 #[test]
2043 fn an_import_of_a_missing_file_is_a_problem_even_unused() {
2044 let fs = files(&[("cart.design", "import Row from \"./row.design\"\n<Frame />")]);
2045 let problems: Vec<String> = resolve(&fs, "cart.design")
2046 .unwrap_err()
2047 .iter()
2048 .map(|p| p.to_string())
2049 .collect();
2050 assert_eq!(
2051 problems,
2052 vec!["cart.design:1:1: import ./row.design — no such file"]
2053 );
2054 }
2055
2056 #[test]
2057 fn a_design_that_uses_itself() {
2058 let fs = files(&[(
2059 "a.design",
2060 "import A from \"./a.design\"\n<Frame><A /></Frame>",
2061 )]);
2062 let problems = resolve(&fs, "a.design").unwrap_err();
2063 assert_eq!(
2064 problems[0].to_string(),
2065 "a.design:2:8: a.design uses itself"
2066 );
2067 }
2068
2069 struct Mem(HashMap<String, Vec<u8>>);
2071
2072 impl Files for Mem {
2073 fn bytes(&self, path: &str) -> Option<Vec<u8>> {
2074 self.0.get(path).cloned()
2075 }
2076 }
2077
2078 fn png() -> Vec<u8> {
2080 let mut out = Vec::new();
2081 let mut e = png::Encoder::new(&mut out, 1, 1);
2082 e.set_color(png::ColorType::Rgba);
2083 let mut w = e.write_header().unwrap();
2084 w.write_image_data(&[0, 0, 0, 255]).unwrap();
2085 drop(w);
2086 out
2087 }
2088
2089 fn problems(source: &str) -> Vec<String> {
2090 let fs = Mem([
2091 ("d/a.design".to_string(), source.as_bytes().to_vec()),
2092 ("d/photo.png".to_string(), png()),
2093 ("d/notes.txt".to_string(), b"hi".to_vec()),
2094 (
2095 "d/b.design".to_string(),
2096 br#"<Frame props={{ label: "B" }}><Text>{label}</Text></Frame>"#.to_vec(),
2097 ),
2098 ]
2099 .into());
2100 match resolve(&fs, "d/a.design") {
2101 Ok(_) => Vec::new(),
2102 Err(p) => p.iter().map(|p| p.to_string()).collect(),
2103 }
2104 }
2105
2106 #[test]
2107 fn each_problem_names_its_place() {
2108 let cases = [
2109 (
2110 "<Rect />",
2111 "d/a.design:1:1: the root element must be a Frame, not Rect",
2112 ),
2113 (
2114 "<Frame props=\"x\" />",
2115 "d/a.design:1:8: props: expected an object of defaults",
2116 ),
2117 (
2118 "<Frame fill=\"red\" />",
2119 "d/a.design:1:8: fill: expected a color like #rrggbb",
2120 ),
2121 (
2122 "<Frame fill=\"#12\" />",
2123 "d/a.design:1:8: fill: #12 is not a color",
2124 ),
2125 (
2126 "<Frame fill=\"linear-gradient(90deg, #fff 0%)\" />",
2127 "d/a.design:1:8: fill: expected \"linear-gradient(<angle>deg, <color> <stop>%, …)\", found \"linear-gradient(90deg, #fff 0%)\"",
2128 ),
2129 (
2130 "<Frame width=\"wide\" />",
2131 "d/a.design:1:8: width: expected pixels, \"hug\" or \"fill\", found \"wide\"",
2132 ),
2133 (
2134 "<Frame gap=\"4\" />",
2135 "d/a.design:1:8: gap: expected a number, found \"4\"",
2136 ),
2137 (
2138 "<Frame radius={[1, 2]} />",
2139 "d/a.design:1:8: radius: expected pixels or an array of 4",
2140 ),
2141 (
2142 "<Frame layout=\"grid\" />",
2143 "d/a.design:1:8: layout: expected \"row\", \"column\" or \"none\"",
2144 ),
2145 (
2146 "<Frame align=\"space-between\" />",
2147 "d/a.design:1:8: align: expected \"start\", \"center\" or \"end\"",
2148 ),
2149 (
2150 "<Frame clip=\"yes\" />",
2151 "d/a.design:1:8: clip: expected true or false",
2152 ),
2153 (
2154 "<Frame shadow={{ blurr: 4 }} />",
2155 "d/a.design:1:8: shadow: a shadow has no blurr",
2156 ),
2157 (
2158 "<Frame onClick=\"go ./b.design\" />",
2159 "d/a.design:1:8: onClick: expected \"navigate <path>\", \"back\", \"overlay <path>\" or \"close\"",
2160 ),
2161 (
2162 "<Frame size={4} />",
2163 "d/a.design:1:8: Frame takes no attribute size",
2164 ),
2165 (
2166 "<Frame><Text fill=\"#fff\">a</Text></Frame>",
2167 "d/a.design:1:14: Text takes no attribute fill",
2168 ),
2169 (
2170 "<Frame><Text weight={450}>a</Text></Frame>",
2171 "d/a.design:1:14: weight: expected 400, 500, 600 or 700",
2172 ),
2173 (
2174 "<Frame><Text textAlign=\"justify\">a</Text></Frame>",
2175 "d/a.design:1:14: textAlign: expected \"left\", \"center\" or \"right\"",
2176 ),
2177 (
2178 "<Frame><Text><Rect /></Text></Frame>",
2179 "d/a.design:1:14: Text holds text only",
2180 ),
2181 (
2182 "<Frame>hello</Frame>",
2183 "d/a.design:1:8: text belongs in a Text, not a Frame",
2184 ),
2185 (
2186 "<Frame><Rect><Rect /></Rect></Frame>",
2187 "d/a.design:1:14: Rect takes no children",
2188 ),
2189 (
2190 "<Frame><Text>{nope}</Text></Frame>",
2191 "d/a.design:1:14: no text prop \"nope\"",
2192 ),
2193 (
2194 "<Frame gap={nope} />",
2195 "d/a.design:1:8: gap: no prop \"nope\"",
2196 ),
2197 (
2198 "<Frame><Vector viewBox=\"0 0 24 24\" /></Frame>",
2199 "d/a.design:1:8: Vector needs a path, d=\"M…\"",
2200 ),
2201 (
2202 "<Frame><Vector d=\"M0 0 Q\" /></Frame>",
2203 "d/a.design:1:16: d: not SVG path data: Unexpected EOF",
2204 ),
2205 (
2206 "<Frame><Vector d=\"M0 0L1 1\" viewBox=\"0 0 24\" /></Frame>",
2207 "d/a.design:1:29: viewBox: expected \"minX minY width height\"",
2208 ),
2209 (
2210 "<Frame><Image /></Frame>",
2211 "d/a.design:1:8: Image needs a file, src=\"…\"",
2212 ),
2213 (
2214 "<Frame><Image src=\"./gone.png\" /></Frame>",
2215 "d/a.design:1:15: src: ./gone.png — no such file",
2216 ),
2217 (
2218 "<Frame><Image src=\"../../x.png\" /></Frame>",
2219 "d/a.design:1:15: src: ../../x.png — outside the served folder",
2220 ),
2221 (
2222 "<Frame><Image src=\"./notes.txt\" /></Frame>",
2223 "d/a.design:1:15: src: ./notes.txt is not a PNG or JPEG file",
2224 ),
2225 (
2226 "<Frame><Rect clip /></Frame>",
2227 "d/a.design:1:14: Rect takes no attribute clip",
2228 ),
2229 (
2230 "<Frame fill=\"linear-gradient(2000000deg, #fff 0%, #000 100%)\" />",
2231 "d/a.design:1:8: fill: `2000000` is too large: numbers go up to 1000000",
2232 ),
2233 (
2234 "<Frame fill=\"linear-gradient(90deg, #fff -2000000%, #000 100%)\" />",
2235 "d/a.design:1:8: fill: `-2000000` is too small: numbers go down to -1000000",
2236 ),
2237 (
2238 "<Frame fill=\"linear-gradient(NaNdeg, #fff 0%, #000 100%)\" />",
2239 "d/a.design:1:8: fill: `NaN` is not a number",
2240 ),
2241 (
2242 "<Frame fill=\"linear-gradient(90deg, #fff 0%, #000 inf%)\" />",
2243 "d/a.design:1:8: fill: `inf` is not a number",
2244 ),
2245 (
2246 "<Frame><Vector d=\"M0 0L1 1\" viewBox=\"0 0 2000000 24\" /></Frame>",
2247 "d/a.design:1:29: viewBox: `2000000` is too large: numbers go up to 1000000",
2248 ),
2249 (
2250 "<Frame><Vector d=\"M0 0L1 1\" viewBox=\"0 0 inf 24\" /></Frame>",
2251 "d/a.design:1:29: viewBox: `inf` is not a number",
2252 ),
2253 (
2254 "<Frame><Vector d=\"M0 0L2000000 0\" /></Frame>",
2255 "d/a.design:1:16: d: `2000000` is too large: numbers go up to 1000000",
2256 ),
2257 (
2258 "<Frame><Vector d=\"M0 0L1e999 0\" /></Frame>",
2259 "d/a.design:1:16: d: `1e999` is too large: numbers go up to 1000000",
2260 ),
2261 (
2262 "<Frame><Vector d=\"M0 0L-3e6 0\" /></Frame>",
2263 "d/a.design:1:16: d: `-3e6` is too small: numbers go down to -1000000",
2264 ),
2265 (
2266 "import X from \"../../x.design\"\n<Frame />",
2267 "d/a.design:1:1: import ../../x.design — outside the served folder",
2268 ),
2269 (
2270 "import B from \"./b.design\"\n<Frame><B fill=\"#fff\" /></Frame>",
2271 "d/a.design:2:11: B has no prop \"fill\"",
2272 ),
2273 (
2274 "import B from \"./b.design\"\n<Frame><B>x</B></Frame>",
2275 "d/a.design:2:8: B takes no children",
2276 ),
2277 (
2278 "<Frame><Blob /></Frame>",
2279 "d/a.design:1:8: unknown element Blob",
2280 ),
2281 ];
2282 for (source, expected) in cases {
2283 assert_eq!(problems(source), vec![expected.to_string()], "{source}");
2284 }
2285 }
2286
2287 #[test]
2290 fn an_image_that_does_not_decode_is_a_problem() {
2291 let mut broken = b"\x89PNG\r\n\x1a\n".to_vec();
2292 broken.extend(b"not a PNG past its signature");
2293 let shirt = std::fs::read(concat!(
2294 env!("CARGO_MANIFEST_DIR"),
2295 "/../docs/designs/checkout/shirt.jpg"
2296 ))
2297 .unwrap();
2298 let truncated = shirt[..shirt.len() / 2].to_vec();
2299 for (name, data, expected) in [
2300 (
2301 "broken.png",
2302 broken,
2303 "d/a.design:2:8: src: ./broken.png is a PNG that doesn't decode: ",
2304 ),
2305 (
2306 "shirt.jpg",
2307 truncated,
2308 "d/a.design:2:8: src: ./shirt.jpg is a JPEG that doesn't decode: ",
2309 ),
2310 ] {
2311 let source =
2312 format!("<Frame>\n<Image src=\"./{name}\" width={{50}} height={{50}} />\n</Frame>");
2313 let fs = Mem([
2314 ("d/a.design".to_string(), source.into_bytes()),
2315 (format!("d/{name}"), data),
2316 ]
2317 .into());
2318 let found: Vec<String> = resolve(&fs, "d/a.design")
2319 .unwrap_err()
2320 .iter()
2321 .map(|p| p.to_string())
2322 .collect();
2323 assert_eq!(found.len(), 1, "{found:?}");
2324 assert!(found[0].starts_with(expected), "{found:?}");
2325 }
2326 let fs = Mem([
2328 (
2329 "a.design".to_string(),
2330 br#"<Frame><Image src="./s.jpg" /></Frame>"#.to_vec(),
2331 ),
2332 ("s.jpg".to_string(), shirt),
2333 ]
2334 .into());
2335 let scene = resolve(&fs, "a.design").unwrap();
2336 let image = scene.root.children[0].image.as_ref().unwrap();
2337 let file = &scene.images[&image.id];
2338 assert_eq!((file.width, file.height), (160, 160));
2339 assert_eq!((file.pixels_width, file.pixels_height), (160, 160));
2340 }
2341
2342 #[test]
2346 fn a_large_image_file_is_resampled_once_to_fit() {
2347 let mut data = Vec::new();
2348 let mut e = png::Encoder::new(&mut data, 9000, 10);
2349 e.set_color(png::ColorType::Rgba);
2350 let mut w = e.write_header().unwrap();
2351 let row: Vec<u8> = (0..9000)
2353 .flat_map(|x| {
2354 if x <= 4500 {
2355 [255, 0, 0, 255]
2356 } else {
2357 [0, 0, 255, 255]
2358 }
2359 })
2360 .collect();
2361 w.write_image_data(&row.repeat(10)).unwrap();
2362 drop(w);
2363 let file = ImageFile::new(data).unwrap();
2364 assert_eq!((file.width, file.height), (9000, 10));
2365 assert_eq!((file.pixels_width, file.pixels_height), (4096, 5));
2366 let px = |x: usize| &file.pixels[4 * x..][..4];
2367 assert_eq!(px(0), [255, 0, 0, 255]);
2368 assert_eq!(px(4095), [0, 0, 255, 255]);
2369 assert_eq!(px(2048), [116, 0, 139, 255]);
2371 let (same, w, h) = fit(&[1, 2, 3, 4], 1, 1, MAX_IMAGE_SIDE);
2373 assert_eq!((same, w, h), (vec![1, 2, 3, 4], 1, 1));
2374 let (mixed, _, _) = fit(&[255, 255, 255, 0, 0, 0, 255, 255], 2, 1, 1);
2376 assert_eq!(mixed, [0, 0, 255, 128]);
2377 }
2378
2379 #[test]
2380 fn an_interaction_is_relative_to_the_file_that_wrote_it() {
2381 let fs = files(&[
2382 (
2383 "app/a.design",
2384 "import B from \"../lib/b.design\"\n<Frame><B /><B onClick=\"overlay ./c.design\" /></Frame>",
2385 ),
2386 ("app/c.design", "<Frame />"),
2387 (
2388 "lib/b.design",
2389 "<Frame onClick=\"navigate ./next.design\" />",
2390 ),
2391 ("lib/next.design", "<Frame />"),
2392 ]);
2393 let root = resolve(&fs, "app/a.design").unwrap().root;
2394 assert_eq!(
2395 root.children[0].on_click.as_deref(),
2396 Some("navigate lib/next.design")
2397 );
2398 assert_eq!(
2399 root.children[1].on_click.as_deref(),
2400 Some("overlay app/c.design")
2401 );
2402 }
2403
2404 #[test]
2405 fn an_interaction_to_nowhere_is_a_problem() {
2406 for (click, expected) in [
2407 (
2408 "navigate ./nope.design",
2409 "d/a.design:1:8: onClick: navigate ./nope.design — no such file",
2410 ),
2411 (
2412 "overlay ../../x.design",
2413 "d/a.design:1:8: onClick: overlay ../../x.design — outside the served folder",
2414 ),
2415 ] {
2416 assert_eq!(
2417 problems(&format!("<Frame onClick=\"{click}\" />")),
2418 vec![expected.to_string()],
2419 "{click}"
2420 );
2421 }
2422 }
2423
2424 #[test]
2425 fn a_valid_design_has_no_problems() {
2426 assert_eq!(
2427 problems(
2428 r##"import B from "./b.design"
2429<Frame fill="linear-gradient(90deg, #ffffff 0%, #000000ff 100%)">
2430 <B label="Go" x={4} y={5} width={80} height="hug" name="Go" onClick="back" />
2431 <Vector d="M0 0L24 24" viewBox="0 0 24 24" stroke="#000" />
2432 <Frame radius={8} clip><Image src="./photo.png" radius={[1, 2, 3, 4]} /></Frame>
2433</Frame>"##
2434 ),
2435 Vec::<String>::new()
2436 );
2437 }
2438
2439 #[test]
2440 fn attributes_take_their_defaults() {
2441 let fs = files(&[("a.design", "<Frame><Text>a</Text><Rect /></Frame>")]);
2442 let root = resolve(&fs, "a.design").unwrap().root;
2443 assert_eq!((root.width, root.height), (Size::Hug, Size::Hug));
2444 assert_eq!(
2445 (root.layout, root.gap, root.padding),
2446 (Layout::None, 0.0, [0.0; 4])
2447 );
2448 assert_eq!((root.align, root.justify), (Align::Start, Align::Start));
2449 assert_eq!(
2450 (root.opacity, root.fill.clone(), root.clip),
2451 (1.0, None, false)
2452 );
2453 let text = root.children[0].text.clone().unwrap();
2454 assert_eq!(
2455 text,
2456 TextStyle {
2457 content: "a".into(),
2458 ..default_text()
2459 }
2460 );
2461 assert_eq!(
2462 (text.size, text.weight, text.line_height, text.align),
2463 (14.0, 400, 1.4, TextAlign::Left)
2464 );
2465 assert_eq!(root.children[1].stroke_width, 1.0);
2466 assert_eq!(root.children[1].radius, [0.0; 4]);
2467 }
2468
2469 #[test]
2470 fn values_parse_into_their_types() {
2471 let fs = Mem(
2472 [
2473 (
2474 "a.design".to_string(),
2475 br##"<Frame padding={[1, 2]} radius={[1, 2, 3, 4]} fill="linear-gradient(135deg, #f00 0%, #0000ff80 100%)">
2476 <Frame padding={[1, 2, 3, 4]} fill="#abcd" />
2477 <Vector d="M2 4L10 20" />
2478 <Image src="./p.png" />
2479</Frame>"##
2480 .to_vec(),
2481 ),
2482 ("p.png".to_string(), png()),
2483 ]
2484 .into(),
2485 );
2486 let scene = resolve(&fs, "a.design").unwrap();
2487 let root = &scene.root;
2488 assert_eq!(root.padding, [1.0, 2.0, 1.0, 2.0]);
2489 assert_eq!(root.radius, [1.0, 2.0, 3.0, 4.0]);
2490 let c = |r, g, b, a| Color::fixed(Rgba { r, g, b, a });
2491 assert_eq!(
2492 root.fill,
2493 Some(Fill::Linear {
2494 angle: 135.0,
2495 stops: vec![(c(255, 0, 0, 255), 0.0), (c(0, 0, 255, 0x80), 1.0)]
2496 })
2497 );
2498 assert_eq!(root.children[0].padding, [1.0, 2.0, 3.0, 4.0]);
2499 assert_eq!(
2500 root.children[0].fill,
2501 Some(Fill::Solid(c(0xaa, 0xbb, 0xcc, 0xdd)))
2502 );
2503 assert_eq!(
2505 root.children[1].vector.as_ref().unwrap().view_box,
2506 [2.0, 4.0, 8.0, 16.0]
2507 );
2508 assert_eq!(root.children[2].image.as_ref().unwrap().src, "p.png");
2509 assert_eq!(scene.files, vec!["a.design", "p.png"]);
2510 }
2511
2512 #[test]
2513 fn paths_stay_in_the_served_folder() {
2514 assert_eq!(
2515 join("a/b.design", "./c.design").as_deref(),
2516 Some("a/c.design")
2517 );
2518 assert_eq!(
2519 join("a/b.design", "../c.design").as_deref(),
2520 Some("c.design")
2521 );
2522 assert_eq!(join("a/b.design", "../../c.design"), None);
2523 assert_eq!(
2524 join("/a/b.design", "x/../y.png").as_deref(),
2525 Some("a/y.png")
2526 );
2527 }
2528
2529 #[test]
2530 fn an_interaction_leads_only_to_a_design() {
2531 for (click, expected) in [
2532 (
2533 "navigate ./notes.txt",
2534 "d/a.design:1:8: onClick: navigate ./notes.txt — a design is a .design file",
2535 ),
2536 (
2537 "overlay ./photo.png",
2538 "d/a.design:1:8: onClick: overlay ./photo.png — a design is a .design file",
2539 ),
2540 ] {
2541 assert_eq!(
2542 problems(&format!("<Frame onClick=\"{click}\" />")),
2543 vec![expected.to_string()],
2544 "{click}"
2545 );
2546 }
2547 assert!(problems(r#"<Frame onClick="navigate ./b.design" />"#).is_empty());
2548 }
2549
2550 #[test]
2551 fn sizes_and_spacing_are_never_negative() {
2552 for (attr, element) in [
2553 ("gap={-4}", "Frame"),
2554 ("padding={-1}", "Frame"),
2555 ("padding={[4, -2]}", "Frame"),
2556 ("radius={-3}", "Rect"),
2557 ("radius={[1, 2, -3, 4]}", "Rect"),
2558 ("strokeWidth={-1}", "Rect"),
2559 ("size={-12}", "Text"),
2560 ("lineHeight={-1}", "Text"),
2561 ] {
2562 let found = problems(&format!("<Frame><{element} {attr} /></Frame>"));
2563 assert_eq!(found.len(), 1, "{attr}: {found:?}");
2564 assert!(found[0].contains("of 0 or more"), "{attr}: {found:?}");
2565 }
2566 }
2567
2568 #[test]
2569 fn an_import_name_is_unique_and_not_an_element() {
2570 let found = problems(
2571 r#"import B from "./b.design"
2572import B from "./b.design"
2573<Frame />"#,
2574 );
2575 assert_eq!(found, ["d/a.design:2:1: import B — B is already imported"]);
2576 let found = problems(
2577 r#"import Frame from "./b.design"
2578<Frame />"#,
2579 );
2580 assert_eq!(
2581 found,
2582 ["d/a.design:1:1: import Frame — Frame is a built-in element"]
2583 );
2584 }
2585
2586 #[test]
2587 fn justify_names_all_it_takes() {
2588 assert_eq!(
2589 problems(r#"<Frame justify="middle" />"#),
2590 [
2591 "d/a.design:1:8: justify: expected \"start\", \"center\", \"end\" or \"space-between\""
2592 ]
2593 );
2594 assert_eq!(
2595 problems(r#"<Frame align="middle" />"#),
2596 ["d/a.design:1:8: align: expected \"start\", \"center\" or \"end\""]
2597 );
2598 }
2599
2600 #[test]
2601 fn values_that_would_be_ignored_are_problems() {
2602 let found =
2604 problems(r#"<Frame layout="row"><Rect width={10} height={10} x={5} /></Frame>"#);
2605 assert!(
2606 found
2607 .iter()
2608 .any(|p| p.contains("x: places a child only in a frame with layout=\"none\"")),
2609 "{found:?}"
2610 );
2611 let found = problems(
2613 r#"import B from "./b.design"
2614<Frame layout="row"><B y={3} /></Frame>"#,
2615 );
2616 assert!(
2617 found.iter().any(|p| p.contains("y: places a child only")),
2618 "{found:?}"
2619 );
2620 let found = problems(r#"<Frame width={-1} height={-2} />"#);
2622 assert_eq!(found.len(), 2, "{found:?}");
2623 assert!(
2624 found[0].contains("width: expected pixels of 0 or more"),
2625 "{found:?}"
2626 );
2627 let found = problems(r##"<Frame fill="#ff0000" fill="#00ff00" />"##);
2629 assert!(
2630 found.iter().any(|p| p.contains("fill is given twice")),
2631 "{found:?}"
2632 );
2633 let found = problems(
2634 r#"import B from "./b.design"
2635<Frame><B label="a" label="b" /></Frame>"#,
2636 );
2637 assert!(
2638 found.iter().any(|p| p.contains("label is given twice")),
2639 "{found:?}"
2640 );
2641 let found = problems(
2643 r#"import P from "./photo.png"
2644<Frame />"#,
2645 );
2646 assert_eq!(
2647 found,
2648 ["d/a.design:1:1: import ./photo.png — a design is a .design file"]
2649 );
2650 assert!(problems(r#"<Frame><Rect width={1} height={1} x={5} y={5} /></Frame>"#).is_empty());
2652 }
2653
2654 const THEMES: &str = r##"kind: themes
2655light:
2656 surface: "#ffffff"
2657 text: "#000"
2658dark:
2659 surface: "#0f172a"
2660 text: "#f1f5f9"
2661"##;
2662
2663 fn rgb(r: u8, g: u8, b: u8) -> Rgba {
2664 Rgba { r, g, b, a: 255 }
2665 }
2666
2667 #[test]
2668 fn a_theme_color_has_a_value_in_each_theme() {
2669 let fs = files(&[
2670 ("themes.yml", THEMES),
2671 (
2672 "a.design",
2673 r##"<Frame fill={theme.surface} stroke="#ff0000"
2674 shadow={{ y: 2, color: theme.text }}>
2675 <Text color={theme.text}>Hi</Text>
2676 <Rect width={1} height={1} fill="linear-gradient(90deg, theme.surface 0%, #ff0000 100%)" />
2677</Frame>"##,
2678 ),
2679 ]);
2680 let scene = resolve(&fs, "a.design").unwrap();
2681 assert!(scene.themed);
2682 let surface = Color {
2683 light: rgb(255, 255, 255),
2684 dark: rgb(0x0f, 0x17, 0x2a),
2685 };
2686 let text = Color {
2687 light: rgb(0, 0, 0),
2688 dark: rgb(0xf1, 0xf5, 0xf9),
2689 };
2690 let root = &scene.root;
2691 assert_eq!(root.fill, Some(Fill::Solid(surface)));
2692 assert_eq!(root.stroke, Some(Color::fixed(rgb(255, 0, 0))));
2694 assert_eq!(root.shadow.as_ref().unwrap().color, text);
2695 assert_eq!(root.children[0].text.as_ref().unwrap().color, text);
2696 assert_eq!(
2697 root.children[1].fill,
2698 Some(Fill::Linear {
2699 angle: 90.0,
2700 stops: vec![(surface, 0.0), (Color::fixed(rgb(255, 0, 0)), 1.0)]
2701 })
2702 );
2703 assert_eq!(scene.files, vec!["a.design", "themes.yml"]);
2704 let plain = resolve(
2706 &files(&[("themes.yml", THEMES), ("b.design", "<Frame />")]),
2707 "b.design",
2708 )
2709 .unwrap();
2710 assert!(!plain.themed);
2711 assert_eq!(plain.files, vec!["b.design"]);
2712 }
2713
2714 struct Asked(HashMap<String, String>, std::cell::RefCell<Vec<String>>);
2716
2717 impl Files for Asked {
2718 fn bytes(&self, path: &str) -> Option<Vec<u8>> {
2719 self.1.borrow_mut().push(path.to_string());
2720 self.0.bytes(path)
2721 }
2722 }
2723
2724 #[test]
2725 fn the_nearest_themes_override_those_above_name_by_name() {
2726 let fs = Asked(
2727 files(&[
2728 ("themes.yml", THEMES),
2729 (
2730 "app/themes.yml",
2731 "kind: themes\nlight:\n text: \"#111111\"\ndark: {}\n",
2732 ),
2733 ("app/screens/themes.yml", "kind: tokens\nlight: 1\n"),
2735 (
2736 "app/screens/a.design",
2737 "<Frame fill={theme.surface}><Text color={theme.text}>Hi</Text></Frame>",
2738 ),
2739 ]),
2740 Default::default(),
2741 );
2742 let scene = resolve(&fs, "app/screens/a.design").unwrap();
2743 assert_eq!(
2744 scene.root.fill,
2745 Some(Fill::Solid(Color {
2746 light: rgb(255, 255, 255),
2747 dark: rgb(0x0f, 0x17, 0x2a)
2748 }))
2749 );
2750 assert_eq!(
2752 scene.root.children[0].text.as_ref().unwrap().color,
2753 Color {
2754 light: rgb(0x11, 0x11, 0x11),
2755 dark: rgb(0xf1, 0xf5, 0xf9)
2756 }
2757 );
2758 let mut asked = fs.1.borrow().clone();
2761 asked.retain(|p| p.ends_with("themes.yml"));
2762 assert_eq!(
2763 asked,
2764 vec!["app/screens/themes.yml", "app/themes.yml", "themes.yml"]
2765 .into_iter()
2766 .rev()
2767 .collect::<Vec<_>>()
2768 );
2769 }
2770
2771 #[test]
2772 fn a_theme_color_belongs_to_the_file_that_wrote_it() {
2773 let dark_red =
2774 "kind: themes\nlight:\n accent: \"#ff0000\"\ndark:\n accent: \"#880000\"\n";
2775 let blue = "kind: themes\nlight:\n accent: \"#0000ff\"\ndark:\n accent: \"#000088\"\n";
2776 let fs = files(&[
2777 ("kit/themes.yml", dark_red),
2778 (
2779 "kit/button.design",
2780 "<Frame props={{ fill: theme.accent }} fill={fill} />",
2781 ),
2782 ("app/themes.yml", blue),
2783 (
2784 "app/screen.design",
2785 "import Button from \"../kit/button.design\"\n<Frame layout=\"row\">\n <Button />\n <Button fill={theme.accent} />\n</Frame>",
2786 ),
2787 ]);
2788 let scene = resolve(&fs, "app/screen.design").unwrap();
2789 let fill = |n: usize| scene.root.children[n].fill.clone();
2790 assert_eq!(
2792 fill(0),
2793 Some(Fill::Solid(Color {
2794 light: rgb(255, 0, 0),
2795 dark: rgb(0x88, 0, 0)
2796 }))
2797 );
2798 assert_eq!(
2799 fill(1),
2800 Some(Fill::Solid(Color {
2801 light: rgb(0, 0, 255),
2802 dark: rgb(0, 0, 0x88)
2803 }))
2804 );
2805 assert!(scene.themed);
2806 }
2807
2808 #[test]
2809 fn a_theme_color_that_isnt_there_is_a_problem_at_the_reference() {
2810 let one_sided = "kind: themes\nlight:\n surface: \"#fff\"\n edge: \"#000\"\ndark:\n surface: \"#000\"\n";
2811 let design = r##"<Frame fill={theme.srface}>
2812 <Rect width={1} height={1} fill={theme.edge} />
2813 <Rect width={1} height={1} fill="linear-gradient(90deg, #000 0%, theme.nope 100%)" />
2814 <Text>{theme.surface}</Text>
2815</Frame>"##;
2816 let fs = files(&[("themes.yml", one_sided), ("a.design", design)]);
2817 assert_eq!(
2818 problems_in(&fs, "a.design"),
2819 vec![
2820 "a.design:1:14: theme.srface: the themes have no srface; they have edge, surface",
2821 "a.design:2:36: theme.edge: only the light theme has edge; a name needs a color in both",
2822 "a.design:3:68: theme.nope: the themes have no nope; they have edge, surface",
2823 "a.design:4:10: theme.surface is a color, not text: give it to color={theme.surface}",
2824 ]
2825 );
2826 let fs = files(&[("a/a.design", "<Frame fill={theme.surface} />")]);
2828 assert_eq!(
2829 problems_in(&fs, "a/a.design"),
2830 vec![
2831 "a/a.design:1:14: theme.surface: no themes.yml with kind: themes in this design's folder or above it"
2832 ]
2833 );
2834 }
2835
2836 #[test]
2837 fn a_malformed_themes_file_is_a_problem_at_its_line() {
2838 let broken = r##"kind: themes
2839# the light one
2840light:
2841 surface: "#ffff"
2842 text: "#gg0000"
2843 edge: #000
2844 9lives: "#000"
2845dark: "#000"
2846colour: red
2847"##;
2848 let fs = files(&[
2849 ("themes.yml", broken),
2850 ("a.design", "<Frame fill={theme.surface} />"),
2851 ]);
2852 assert_eq!(
2853 problems_in(&fs, "a.design"),
2854 vec![
2855 "themes.yml:5:3: light.text: #gg0000 is not a color",
2856 "themes.yml:6:3: light.edge: no color: write it in quotes, \"#rrggbb\", as YAML reads # as a comment",
2857 "themes.yml:7:3: light.9lives: a name holds letters, digits, _ and -, and starts with a letter",
2858 "themes.yml:8:1: dark: expected a map of names to colors, like surface: \"#ffffff\"",
2859 "themes.yml:9:1: colour: a themes file has kind, light and dark only",
2860 "a.design:1:14: theme.surface: only the light theme has surface; a name needs a color in both",
2861 ]
2862 );
2863 let fs = files(&[
2864 ("themes.yml", "kind: themes\nlight: {}\n"),
2865 ("a.design", "<Frame fill={theme.surface} />"),
2866 ]);
2867 assert_eq!(
2868 problems_in(&fs, "a.design")[0],
2869 "themes.yml:1:1: a themes file has a light and a dark theme; this one has no dark"
2870 );
2871 let fs = files(&[
2872 ("themes.yml", "kind: themes\nlight: [\n"),
2873 ("a.design", "<Frame fill={theme.surface} />"),
2874 ]);
2875 assert!(
2876 problems_in(&fs, "a.design")[0]
2877 .starts_with("themes.yml:3:1: this themes file isn't valid YAML: "),
2878 "{:?}",
2879 problems_in(&fs, "a.design")
2880 );
2881 let fs = files(&[("themes.yml", "light: [\n"), ("a.design", "<Frame />")]);
2883 assert!(resolve(&fs, "a.design").is_ok());
2884 }
2885
2886 #[test]
2887 fn theme_is_no_props_or_imports_name() {
2888 let fs = files(&[
2889 ("themes.yml", THEMES),
2890 ("b.design", "<Frame />"),
2891 (
2892 "a.design",
2893 "import theme from \"./b.design\"\n<Frame props={{ theme: \"#fff\" }} fill={theme} />",
2894 ),
2895 ]);
2896 assert_eq!(
2897 problems_in(&fs, "a.design"),
2898 vec![
2899 "a.design:1:1: import theme — theme is the name of the design's themes",
2900 "a.design:2:8: props: theme is the name of the design's themes, not a prop",
2901 "a.design:2:34: fill: no prop \"theme\"",
2902 ]
2903 );
2904 let fs = files(&[
2906 ("themes.yml", THEMES),
2907 ("a.design", "<Frame fill=\"theme.surface\" />"),
2908 ]);
2909 assert_eq!(
2910 problems_in(&fs, "a.design"),
2911 vec![
2912 "a.design:1:8: fill: theme.surface is a theme color: write it in braces, {theme.surface}, not in quotes"
2913 ]
2914 );
2915 }
2916}