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::fonts::{self, Family, FontFile, FontFiles, FontId};
12use crate::parse::{self, Child, Element, Pos, TextPart, Value};
13
14#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct Rgba {
17 pub r: u8,
18 pub g: u8,
19 pub b: u8,
20 pub a: u8,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum Theme {
26 #[default]
27 Light,
28 Dark,
29}
30
31impl Theme {
32 pub fn named(name: &str) -> Option<Theme> {
34 match name {
35 "light" => Some(Theme::Light),
36 "dark" => Some(Theme::Dark),
37 _ => None,
38 }
39 }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct Color {
46 pub light: Rgba,
47 pub dark: Rgba,
48}
49
50impl Color {
51 pub const fn fixed(c: Rgba) -> Color {
53 Color { light: c, dark: c }
54 }
55
56 pub fn get(self, theme: Theme) -> Rgba {
58 match theme {
59 Theme::Light => self.light,
60 Theme::Dark => self.dark,
61 }
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq)]
66pub enum Size {
67 Px(f32),
68 Hug,
69 Fill,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq)]
73pub enum Layout {
74 None,
75 Row,
76 Column,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq)]
80pub enum Align {
81 Start,
82 Center,
83 End,
84 SpaceBetween,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq)]
88pub enum Kind {
89 Frame,
90 Text,
91 Rect,
92 Ellipse,
93 Vector,
94 Image,
95}
96
97#[derive(Debug, Clone, PartialEq)]
99pub enum Fill {
100 Solid(Color),
101 Linear {
104 angle: f32,
105 stops: Vec<(Color, f32)>,
106 },
107}
108
109#[derive(Debug, Clone, Copy, PartialEq)]
110pub enum TextAlign {
111 Left,
112 Center,
113 Right,
114}
115
116#[derive(Debug, Clone, PartialEq)]
118pub struct Vector {
119 pub d: String,
120 pub view_box: [f32; 4],
122}
123
124#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
128pub struct ImageId(pub [u8; 32]);
129
130impl ImageId {
131 pub fn of(bytes: &[u8]) -> ImageId {
132 use sha2::Digest;
133 ImageId(sha2::Sha256::digest(bytes).into())
134 }
135}
136
137impl std::fmt::Debug for ImageId {
138 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139 for b in &self.0[..6] {
140 write!(f, "{b:02x}")?;
141 }
142 Ok(())
143 }
144}
145
146pub const MAX_IMAGE_SIDE: u32 = 4096;
151
152#[derive(Debug)]
155pub struct ImageFile {
156 pub id: ImageId,
157 pub bytes: Vec<u8>,
158 pub width: u32,
160 pub height: u32,
161 pub pixels: Arc<Vec<u8>>,
163 pub pixels_width: u32,
164 pub pixels_height: u32,
165}
166
167impl ImageFile {
168 pub fn new(bytes: Vec<u8>) -> Result<ImageFile, String> {
170 ImageFile::with_id(ImageId::of(&bytes), bytes)
171 }
172
173 pub fn with_id(id: ImageId, bytes: Vec<u8>) -> Result<ImageFile, String> {
175 let decoded = decode(&bytes)?;
176 let (width, height) = (decoded.width, decoded.height);
177 let (pixels, pixels_width, pixels_height) =
178 fit(decoded.data.data(), width, height, MAX_IMAGE_SIDE);
179 Ok(ImageFile {
180 id,
181 bytes,
182 width,
183 height,
184 pixels: Arc::new(pixels),
185 pixels_width,
186 pixels_height,
187 })
188 }
189
190 pub fn image_data(&self) -> ImageData {
193 ImageData {
194 data: Blob::new(self.pixels.clone()),
195 format: ImageFormat::Rgba8,
196 alpha_type: ImageAlphaType::Alpha,
197 width: self.pixels_width,
198 height: self.pixels_height,
199 }
200 }
201}
202
203pub fn fit(rgba: &[u8], width: u32, height: u32, max: u32) -> (Vec<u8>, u32, u32) {
208 if width <= max && height <= max {
209 return (rgba.to_vec(), width, height);
210 }
211 let scale = max as f64 / width.max(height) as f64;
212 let w = ((width as f64 * scale).round() as u32).clamp(1, max);
213 let h = ((height as f64 * scale).round() as u32).clamp(1, max);
214 let weights = |from: u32, to: u32| -> Vec<Vec<(usize, f32)>> {
217 let ratio = from as f64 / to as f64;
218 (0..to)
219 .map(|o| {
220 let (a, b) = (o as f64 * ratio, (o + 1) as f64 * ratio);
221 (a.floor() as usize..(b.ceil() as usize).min(from as usize))
222 .map(|i| {
223 let cover = (b.min(i as f64 + 1.0) - a.max(i as f64)) / ratio;
224 (i, cover as f32)
225 })
226 .filter(|&(_, c)| c > 0.0)
227 .collect()
228 })
229 .collect()
230 };
231 let (across, down) = (weights(width, w), weights(height, h));
232 let mut rows = vec![0f32; 4 * w as usize * height as usize];
234 for y in 0..height as usize {
235 let line = &rgba[4 * y * width as usize..][..4 * width as usize];
236 for (x, ws) in across.iter().enumerate() {
237 let mut sum = [0f32; 4];
238 for &(i, c) in ws {
239 let p = &line[4 * i..][..4];
240 let a = p[3] as f32 * c;
241 sum[0] += p[0] as f32 * a;
242 sum[1] += p[1] as f32 * a;
243 sum[2] += p[2] as f32 * a;
244 sum[3] += a;
245 }
246 rows[4 * (y * w as usize + x)..][..4].copy_from_slice(&sum);
247 }
248 }
249 let mut out = vec![0u8; 4 * w as usize * h as usize];
250 for (y, ws) in down.iter().enumerate() {
251 for x in 0..w as usize {
252 let mut sum = [0f32; 4];
253 for &(i, c) in ws {
254 let p = &rows[4 * (i * w as usize + x)..][..4];
255 for k in 0..4 {
256 sum[k] += p[k] * c;
257 }
258 }
259 let px = &mut out[4 * (y * w as usize + x)..][..4];
260 if sum[3] > 0.0 {
261 for k in 0..3 {
262 px[k] = (sum[k] / sum[3]).round().clamp(0.0, 255.0) as u8;
263 }
264 px[3] = sum[3].round().clamp(0.0, 255.0) as u8;
265 }
266 }
267 }
268 (out, w, h)
269}
270
271#[derive(Debug, Clone)]
275pub struct Origin {
276 pub file: String,
277 pub pos: Pos,
278}
279
280impl Default for Origin {
281 fn default() -> Origin {
282 Origin {
283 file: String::new(),
284 pos: Pos { line: 1, col: 1 },
285 }
286 }
287}
288
289impl PartialEq for Origin {
290 fn eq(&self, _: &Origin) -> bool {
291 true
292 }
293}
294
295#[derive(Debug, Clone, PartialEq)]
298pub struct Image {
299 pub src: String,
300 pub id: ImageId,
301 pub at: Origin,
303}
304
305pub fn decode(data: &[u8]) -> Result<ImageData, String> {
307 let (pixels, width, height) = if data.starts_with(b"\x89PNG\r\n\x1a\n") {
308 let broken = |e: &dyn std::fmt::Display| format!("is a PNG that doesn't decode: {e}");
309 let mut decoder = png::Decoder::new(std::io::Cursor::new(data));
310 decoder.set_transformations(png::Transformations::normalize_to_color8());
311 let mut reader = decoder.read_info().map_err(|e| broken(&e))?;
312 let size = reader
313 .output_buffer_size()
314 .ok_or_else(|| broken(&"too big to hold"))?;
315 let mut buf = vec![0; size];
316 let info = reader.next_frame(&mut buf).map_err(|e| broken(&e))?;
317 buf.truncate(info.buffer_size());
318 let rgba = match info.color_type {
319 png::ColorType::Rgba => buf,
320 png::ColorType::Rgb => buf
321 .chunks(3)
322 .flat_map(|p| [p[0], p[1], p[2], 255])
323 .collect(),
324 png::ColorType::GrayscaleAlpha => buf
325 .chunks(2)
326 .flat_map(|p| [p[0], p[0], p[0], p[1]])
327 .collect(),
328 png::ColorType::Grayscale => buf.iter().flat_map(|&g| [g, g, g, 255]).collect(),
329 png::ColorType::Indexed => return Err(broken(&"an unexpanded palette")),
331 };
332 (rgba, info.width, info.height)
333 } else if data.starts_with(&[0xff, 0xd8, 0xff]) {
334 use zune_jpeg::JpegDecoder;
335 use zune_jpeg::zune_core::colorspace::ColorSpace;
336 use zune_jpeg::zune_core::options::DecoderOptions;
337 let broken = |e: &dyn std::fmt::Display| format!("is a JPEG that doesn't decode: {e}");
338 let options = DecoderOptions::default()
339 .set_strict_mode(true)
340 .jpeg_set_out_colorspace(ColorSpace::RGBA);
341 let mut decoder = JpegDecoder::new_with_options(std::io::Cursor::new(data), options);
342 let pixels = decoder.decode().map_err(|e| broken(&e))?;
343 let info = decoder.info().ok_or_else(|| broken(&"no header"))?;
344 (pixels, info.width as u32, info.height as u32)
345 } else {
346 return Err("is not a PNG or JPEG file".into());
347 };
348 if width == 0 || height == 0 || pixels.len() != width as usize * height as usize * 4 {
349 return Err(format!(
350 "is an image of {width}×{height} pixels that doesn't decode whole"
351 ));
352 }
353 Ok(ImageData {
354 data: Blob::new(Arc::new(pixels)),
355 format: ImageFormat::Rgba8,
356 alpha_type: ImageAlphaType::Alpha,
357 width,
358 height,
359 })
360}
361
362#[derive(Debug, Clone, PartialEq)]
363pub struct Shadow {
364 pub x: f32,
365 pub y: f32,
366 pub blur: f32,
367 pub spread: f32,
368 pub color: Color,
369 pub at: Origin,
371}
372
373#[derive(Debug, Clone, PartialEq)]
374pub struct TextStyle {
375 pub font: Option<FontId>,
378 pub content: String,
379 pub size: f32,
380 pub weight: u16,
381 pub color: Color,
382 pub line_height: f32,
383 pub align: TextAlign,
384}
385
386#[derive(Debug, Clone, PartialEq)]
388pub struct Node {
389 pub kind: Kind,
390 pub name: String,
391 pub x: f32,
392 pub y: f32,
393 pub width: Size,
394 pub height: Size,
395 pub opacity: f32,
396 pub on_click: Option<String>,
397 pub fill: Option<Fill>,
398 pub stroke: Option<Color>,
399 pub stroke_width: f32,
400 pub radius: [f32; 4],
401 pub shadow: Option<Shadow>,
402 pub clip: bool,
403 pub layout: Layout,
404 pub gap: f32,
405 pub padding: [f32; 4],
406 pub align: Align,
407 pub justify: Align,
408 pub text: Option<TextStyle>,
409 pub vector: Option<Vector>,
410 pub image: Option<Image>,
411 pub children: Vec<Node>,
412}
413
414impl Node {
415 fn new(kind: Kind, name: &str) -> Node {
416 Node {
417 kind,
418 name: name.to_string(),
419 x: 0.0,
420 y: 0.0,
421 width: Size::Hug,
422 height: Size::Hug,
423 opacity: 1.0,
424 on_click: None,
425 fill: None,
426 stroke: None,
427 stroke_width: 1.0,
428 radius: [0.0; 4],
429 shadow: None,
430 clip: false,
431 layout: Layout::None,
432 gap: 0.0,
433 padding: [0.0; 4],
434 align: Align::Start,
435 justify: Align::Start,
436 text: None,
437 vector: None,
438 image: None,
439 children: Vec::new(),
440 }
441 }
442}
443
444pub trait Files {
446 fn bytes(&self, path: &str) -> Option<Vec<u8>>;
448 fn list(&self, path: &str) -> Option<Vec<String>>;
451 fn read(&self, path: &str) -> Option<String> {
453 String::from_utf8(self.bytes(path)?).ok()
454 }
455 fn image(&self, path: &str) -> Option<Result<Arc<ImageFile>, String>> {
459 self.bytes(path)
460 .map(|bytes| ImageFile::new(bytes).map(Arc::new))
461 }
462 fn font(&self, path: &str) -> Option<Result<Arc<FontFile>, String>> {
466 self.bytes(path)
467 .map(|bytes| FontFile::new(bytes).map(Arc::new))
468 }
469}
470
471pub fn listed<'a>(paths: impl Iterator<Item = &'a String>, folder: &str) -> Option<Vec<String>> {
474 let prefix = format!("{folder}/");
475 let mut names: Vec<String> = paths
476 .filter_map(|p| p.strip_prefix(&prefix))
477 .filter(|rest| !rest.contains('/'))
478 .map(str::to_string)
479 .collect();
480 names.sort();
481 (!names.is_empty()).then_some(names)
482}
483
484impl Files for HashMap<String, String> {
485 fn bytes(&self, path: &str) -> Option<Vec<u8>> {
486 self.get(path).map(|s| s.clone().into_bytes())
487 }
488 fn list(&self, path: &str) -> Option<Vec<String>> {
489 listed(self.keys(), path)
490 }
491}
492
493#[derive(Debug, Clone, PartialEq)]
498pub struct Scene {
499 pub root: Node,
500 pub images: ImageFiles,
501 pub fonts: FontFiles,
502 pub files: Vec<String>,
503 pub themed: bool,
504}
505
506pub type ImageFiles = std::collections::BTreeMap<ImageId, Arc<ImageFile>>;
509
510impl PartialEq for ImageFile {
512 fn eq(&self, other: &ImageFile) -> bool {
513 self.id == other.id
514 }
515}
516
517pub fn resolve(files: &dyn Files, path: &str) -> Result<Scene, Vec<Problem>> {
519 let mut r = Resolver {
520 files,
521 problems: Vec::new(),
522 read: Vec::new(),
523 stack: Vec::new(),
524 missing: Vec::new(),
525 depth: 0,
526 images: ImageFiles::new(),
527 by_path: HashMap::new(),
528 fonts: FontFiles::new(),
529 families: HashMap::new(),
530 themes: HashMap::new(),
531 themed: false,
532 };
533 let root = r.design(path, &[], None, "", None);
534 if r.problems.is_empty() {
535 Ok(Scene {
536 root: root.expect("no problems means a root"),
537 images: r.images,
538 fonts: r.fonts,
539 files: r.read,
540 themed: r.themed,
541 })
542 } else {
543 Err(r.problems)
544 }
545}
546
547struct Resolver<'a> {
548 files: &'a dyn Files,
549 problems: Vec<Problem>,
550 read: Vec<String>,
551 stack: Vec<String>,
552 missing: Vec<String>,
555 depth: usize,
557 images: ImageFiles,
559 by_path: HashMap<String, Result<ImageId, String>>,
561 fonts: FontFiles,
563 families: HashMap<String, (Found, bool)>,
567 themes: HashMap<String, Rc<Themes>>,
570 themed: bool,
572}
573
574#[derive(Debug, Clone, Default)]
578struct Themes {
579 found: bool,
581 light: BTreeMap<String, Rgba>,
582 dark: BTreeMap<String, Rgba>,
583 fonts: BTreeMap<String, ThemesFont>,
585}
586
587#[derive(Debug, Clone)]
590struct ThemesFont {
591 written: String,
592 at: Option<String>,
593 origin: Origin,
594}
595
596struct ThemesFile {
599 light: BTreeMap<String, Rgba>,
600 dark: BTreeMap<String, Rgba>,
601 fonts: BTreeMap<String, ThemesFont>,
602}
603
604pub const THEMES_FILE: &str = "themes.yml";
606
607const MAX_DEPTH: usize = parse::MAX_DEPTH;
610
611#[derive(Debug, Clone)]
614struct Given {
615 value: Value,
616 by: String,
617}
618
619type Props = HashMap<String, Given>;
620
621type Found = Option<Result<Rc<Family>, String>>;
624
625impl Resolver<'_> {
626 fn problem(&mut self, file: &str, pos: Pos, message: String) {
627 self.problems.push(Problem {
628 file: file.to_string(),
629 pos,
630 message,
631 });
632 }
633
634 fn bind_element(&mut self, path: &str, el: &mut Element) {
637 for attr in &mut el.attrs {
638 self.bind(path, &mut attr.value);
639 }
640 for child in &mut el.children {
641 if let Child::Element(e) = child {
642 self.bind_element(path, e);
643 }
644 }
645 }
646
647 fn bind(&mut self, path: &str, value: &mut Value) {
651 match value {
652 Value::Theme(name, pos) => {
653 let (light, dark) = self
656 .theme_color(path, name, *pos)
657 .map_or(("#0000".into(), "#0000".into()), |(l, d)| (hex(l), hex(d)));
658 *value = Value::Themed {
659 light,
660 dark,
661 written: format!("theme.{name}"),
662 };
663 }
664 Value::Str(s, pos) if s.starts_with("linear-gradient(") && s.contains("theme.") => {
665 let (mut light, mut dark) = (String::new(), String::new());
666 let mut rest = s.as_str();
667 let mut col = pos.col + 1;
668 while let Some(at) = rest.find("theme.") {
669 let before = &rest[..at];
670 light.push_str(before);
671 dark.push_str(before);
672 col += before.chars().count() as u32;
673 let after = &rest[at + "theme.".len()..];
674 let len = after
675 .find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '-'))
676 .unwrap_or(after.len());
677 let name = &after[..len];
678 let at_ref = Pos {
679 line: pos.line,
680 col,
681 };
682 match self.theme_color(path, name, at_ref) {
683 Some((l, d)) => {
684 light.push_str(&hex(l));
685 dark.push_str(&hex(d));
686 }
687 None => {
689 light.push_str("#0000");
690 dark.push_str("#0000");
691 }
692 }
693 col += ("theme.".len() + name.chars().count()) as u32;
694 rest = &after[len..];
695 }
696 light.push_str(rest);
697 dark.push_str(rest);
698 *value = Value::Themed {
699 light,
700 dark,
701 written: format!("\"{s}\""),
702 };
703 }
704 Value::Array(items) => {
705 for item in items {
706 self.bind(path, item);
707 }
708 }
709 Value::Object(fields) => {
710 for (_, v) in fields {
711 self.bind(path, v);
712 }
713 }
714 _ => {}
715 }
716 }
717
718 fn theme_color(&mut self, path: &str, name: &str, pos: Pos) -> Option<(Rgba, Rgba)> {
721 let folder = path.rsplit_once('/').map_or("", |(folder, _)| folder);
722 let themes = self.themes(folder);
723 let problem = if !themes.found {
724 format!(
725 "theme.{name}: no {THEMES_FILE} with kind: themes in this design's folder or above it"
726 )
727 } else {
728 match (themes.light.get(name), themes.dark.get(name)) {
729 (Some(l), Some(d)) => return Some((*l, *d)),
730 (None, None) => {
731 let mut names: Vec<&String> =
732 themes.light.keys().chain(themes.dark.keys()).collect();
733 names.sort();
734 names.dedup();
735 let names: Vec<&str> = names.iter().map(|n| n.as_str()).collect();
736 if names.is_empty() {
737 format!("theme.{name}: the themes have no colors")
738 } else {
739 format!(
740 "theme.{name}: the themes have no {name}; they have {}",
741 names.join(", ")
742 )
743 }
744 }
745 (Some(_), None) => format!(
746 "theme.{name}: only the light theme has {name}; a name needs a color in both"
747 ),
748 (None, Some(_)) => format!(
749 "theme.{name}: only the dark theme has {name}; a name needs a color in both"
750 ),
751 }
752 };
753 self.problem(path, pos, problem);
754 None
755 }
756
757 fn themes(&mut self, folder: &str) -> Rc<Themes> {
761 if let Some(themes) = self.themes.get(folder) {
762 return themes.clone();
763 }
764 let above = match folder.rsplit_once('/') {
765 Some((parent, _)) => self.themes(parent),
766 None if folder.is_empty() => Rc::new(Themes::default()),
767 None => self.themes(""),
768 };
769 let file = if folder.is_empty() {
770 THEMES_FILE.to_string()
771 } else {
772 format!("{folder}/{THEMES_FILE}")
773 };
774 let themes = match self.themes_file(&file) {
775 None => above,
776 Some(own) => {
777 let mut merged = (*above).clone();
778 merged.found = true;
779 merged.light.extend(own.light);
780 merged.dark.extend(own.dark);
781 merged.fonts.extend(own.fonts);
782 Rc::new(merged)
783 }
784 };
785 self.themes.insert(folder.to_string(), themes.clone());
786 themes
787 }
788
789 fn themes_file(&mut self, path: &str) -> Option<ThemesFile> {
793 let text = self.files.read(path)?;
794 if !self.read.iter().any(|p| p == path) {
795 self.read.push(path.to_string());
796 }
797 let mut file = ThemesFile {
798 light: BTreeMap::new(),
799 dark: BTreeMap::new(),
800 fonts: BTreeMap::new(),
801 };
802 let yaml: serde_yaml::Value = match serde_yaml::from_str(&text) {
803 Ok(yaml) => yaml,
804 Err(e) => {
805 if !text.lines().any(|l| l.trim() == "kind: themes") {
807 return None;
808 }
809 let pos = e.location().map_or(Pos { line: 1, col: 1 }, |l| Pos {
810 line: l.line() as u32,
811 col: l.column() as u32,
812 });
813 self.problem(path, pos, format!("this themes file isn't valid YAML: {e}"));
814 return Some(file);
815 }
816 };
817 let serde_yaml::Value::Mapping(top) = yaml else {
818 return None;
819 };
820 if top.get("kind").and_then(|k| k.as_str()) != Some("themes") {
821 return None;
822 }
823 let start = Pos { line: 1, col: 1 };
824 for (key, value) in &top {
825 let key = key.as_str().unwrap_or_default();
826 let theme = match key {
827 "kind" => continue,
828 "light" => &mut file.light,
829 "dark" => &mut file.dark,
830 "fonts" => {
831 self.themes_fonts(path, &text, value, &mut file.fonts);
832 continue;
833 }
834 other => {
835 let pos = key_pos(&text, None, other).unwrap_or(start);
836 self.problem(
837 path,
838 pos,
839 format!("{other}: a themes file has kind, fonts, light and dark only"),
840 );
841 continue;
842 }
843 };
844 let at = key_pos(&text, None, key).unwrap_or(start);
845 let serde_yaml::Value::Mapping(colors) = value else {
846 self.problem(
847 path,
848 at,
849 format!("{key}: expected a map of names to colors, like surface: \"#ffffff\""),
850 );
851 continue;
852 };
853 for (name, color) in colors {
854 let Some(name) = name.as_str() else {
855 self.problem(path, at, format!("{key}: a color's name is text"));
856 continue;
857 };
858 let pos = key_pos(&text, Some(key), name).unwrap_or(at);
859 if name.is_empty()
860 || name.starts_with(|c: char| c.is_ascii_digit() || c == '-')
861 || !name
862 .chars()
863 .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
864 {
865 self.problem(
866 path,
867 pos,
868 format!(
869 "{key}.{name}: a name holds letters, digits, _ and -, and starts with a letter"
870 ),
871 );
872 continue;
873 }
874 let value = match color {
875 serde_yaml::Value::String(s) => rgba(s),
876 serde_yaml::Value::Null => Err(
877 "no color: write it in quotes, \"#rrggbb\", as YAML reads # as a comment"
878 .to_string(),
879 ),
880 _ => Err("expected a color like \"#rrggbb\"".to_string()),
881 };
882 match value {
883 Ok(c) => {
884 theme.insert(name.to_string(), c);
885 }
886 Err(e) => self.problem(path, pos, format!("{key}.{name}: {e}")),
887 }
888 }
889 }
890 for needed in ["light", "dark"] {
891 if !top.contains_key(needed) {
892 self.problem(
893 path,
894 start,
895 format!("a themes file has a light and a dark theme; this one has no {needed}"),
896 );
897 }
898 }
899 Some(file)
900 }
901
902 fn themes_fonts(
906 &mut self,
907 path: &str,
908 text: &str,
909 value: &serde_yaml::Value,
910 into: &mut BTreeMap<String, ThemesFont>,
911 ) {
912 let start = Pos { line: 1, col: 1 };
913 let at = key_pos(text, None, "fonts").unwrap_or(start);
914 let serde_yaml::Value::Mapping(entries) = value else {
915 self.problem(
916 path,
917 at,
918 "fonts: expected a map of names to font files or folders, like figtree: ./fonts/Figtree".into(),
919 );
920 return;
921 };
922 for (name, written) in entries {
923 let Some(name) = name.as_str() else {
924 self.problem(path, at, "fonts: a font's name is text".into());
925 continue;
926 };
927 let pos = key_pos(text, Some("fonts"), name).unwrap_or(at);
928 if name.is_empty()
929 || !name
930 .chars()
931 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
932 {
933 self.problem(
934 path,
935 pos,
936 format!(
937 "fonts.{name}: a font's name holds lowercase letters, digits and - only"
938 ),
939 );
940 continue;
941 }
942 let Some(written) = written.as_str() else {
943 self.problem(
944 path,
945 pos,
946 format!("fonts.{name}: expected a path to a font file or a folder of one family's, like ./fonts/Figtree"),
947 );
948 continue;
949 };
950 into.insert(
951 name.to_string(),
952 ThemesFont {
953 written: written.to_string(),
954 at: join(path, written),
955 origin: Origin {
956 file: path.to_string(),
957 pos,
958 },
959 },
960 );
961 }
962 }
963
964 fn design(
966 &mut self,
967 path: &str,
968 args: &[(parse::Attr, String)],
969 from: Option<(&str, Pos)>,
970 used_as: &str,
971 parent: Option<Layout>,
972 ) -> Option<Node> {
973 if self.stack.iter().any(|p| p == path) {
974 let (file, pos) = from.unwrap_or((path, Pos { line: 1, col: 1 }));
975 self.problem(file, pos, format!("{path} uses itself"));
976 return None;
977 }
978 let Some(source) = self.files.read(path) else {
979 if from.is_some() && self.missing.iter().any(|p| p == path) {
980 return None;
981 }
982 let (file, pos) = from.unwrap_or((path, Pos { line: 1, col: 1 }));
983 self.problem(file, pos, format!("{path} — no such file"));
984 return None;
985 };
986 if !self.read.iter().any(|p| p == path) {
987 self.read.push(path.to_string());
988 }
989 let mut file = match parse::parse(&source) {
990 Ok(file) => file,
991 Err(mut problem) => {
992 problem.file = path.to_string();
993 self.problems.push(problem);
994 return None;
995 }
996 };
997 self.bind_element(path, &mut file.root);
1000 let mut imports = HashMap::new();
1001 for import in &file.imports {
1002 if import.name == parse::THEME {
1003 self.problem(
1004 path,
1005 import.pos,
1006 format!(
1007 "import {0} — {0} is the name of the design's themes",
1008 import.name
1009 ),
1010 );
1011 continue;
1012 }
1013 if ELEMENTS.contains(&import.name.as_str()) {
1014 self.problem(
1015 path,
1016 import.pos,
1017 format!("import {0} — {0} is a built-in element", import.name),
1018 );
1019 continue;
1020 }
1021 if imports.contains_key(&import.name) {
1022 self.problem(
1023 path,
1024 import.pos,
1025 format!("import {0} — {0} is already imported", import.name),
1026 );
1027 continue;
1028 }
1029 if !import.path.ends_with(".design") {
1030 self.problem(
1031 path,
1032 import.pos,
1033 format!("import {} — a design is a .design file", import.path),
1034 );
1035 continue;
1036 }
1037 match join(path, &import.path) {
1038 Some(target) => {
1039 if self.files.read(&target).is_none() {
1041 self.problem(
1042 path,
1043 import.pos,
1044 format!("import {} — no such file", import.path),
1045 );
1046 self.missing.push(target.clone());
1047 }
1048 imports.insert(import.name.clone(), target);
1049 }
1050 None => self.problem(
1051 path,
1052 import.pos,
1053 format!("import {} — outside the served folder", import.path),
1054 ),
1055 }
1056 }
1057 if file.root.name != "Frame" {
1058 self.problem(
1059 path,
1060 file.root.pos,
1061 format!("the root element must be a Frame, not {}", file.root.name),
1062 );
1063 return None;
1064 }
1065 let mut props = Props::new();
1067 if let Some(attr) = file.root.attrs.iter().find(|a| a.name == "props") {
1068 match &attr.value {
1069 Value::Object(fields) => {
1070 for (k, v) in fields {
1071 if k == parse::THEME {
1072 self.problem(
1073 path,
1074 attr.pos,
1075 format!(
1076 "props: {k} is the name of the design's themes, not a prop"
1077 ),
1078 );
1079 continue;
1080 }
1081 props.insert(
1082 k.clone(),
1083 Given {
1084 value: v.clone(),
1085 by: path.to_string(),
1086 },
1087 );
1088 }
1089 }
1090 _ => self.problem(
1091 path,
1092 attr.pos,
1093 "props: expected an object of defaults".into(),
1094 ),
1095 }
1096 }
1097 let (caller, caller_pos) = from.unwrap_or((path, file.root.pos));
1098 let caller = caller.to_string();
1099 let mut overrides = Vec::new();
1100 for (arg, by) in args {
1101 if matches!(
1102 arg.name.as_str(),
1103 "x" | "y" | "width" | "height" | "name" | "opacity" | "onClick"
1104 ) {
1105 overrides.push((arg.clone(), by.clone()));
1106 } else if props.contains_key(&arg.name) {
1107 props.insert(
1108 arg.name.clone(),
1109 Given {
1110 value: arg.value.clone(),
1111 by: by.clone(),
1112 },
1113 );
1114 } else {
1115 self.problem(
1116 &caller,
1117 arg.pos,
1118 format!("{used_as} has no prop \"{}\"", arg.name),
1119 );
1120 }
1121 }
1122 let _ = caller_pos;
1123 self.stack.push(path.to_string());
1124 let mut root = self.element(path, &file.root, &props, &imports, true, parent);
1125 self.stack.pop();
1126 if let Some(root) = root.as_mut() {
1127 for (attr, by) in &overrides {
1128 self.common(&caller, by, root, attr, attr.value.clone());
1130 self.fill_needs_layout(&caller, attr, &attr.value, parent);
1131 self.place_needs_no_layout(&caller, attr, parent);
1132 }
1133 }
1134 root
1135 }
1136
1137 fn element(
1138 &mut self,
1139 path: &str,
1140 el: &Element,
1141 props: &Props,
1142 imports: &HashMap<String, String>,
1143 root: bool,
1144 parent: Option<Layout>,
1145 ) -> Option<Node> {
1146 if self.depth >= MAX_DEPTH {
1147 self.problem(path, el.pos, format!("nested more than {MAX_DEPTH} deep"));
1148 return None;
1149 }
1150 self.depth += 1;
1151 let node = self.element_inner(path, el, props, imports, root, parent);
1152 self.depth -= 1;
1153 node
1154 }
1155
1156 fn element_inner(
1157 &mut self,
1158 path: &str,
1159 el: &Element,
1160 props: &Props,
1161 imports: &HashMap<String, String>,
1162 root: bool,
1163 parent: Option<Layout>,
1164 ) -> Option<Node> {
1165 let mut seen: Vec<&str> = Vec::new();
1168 let mut attrs = Vec::new();
1169 for attr in &el.attrs {
1170 if seen.contains(&attr.name.as_str()) {
1171 self.problem(path, attr.pos, format!("{} is given twice", attr.name));
1172 } else {
1173 seen.push(&attr.name);
1174 attrs.push(attr);
1175 }
1176 }
1177 if let Some(target) = imports.get(&el.name) {
1178 if !el.children.is_empty() {
1179 self.problem(path, el.pos, format!("{} takes no children", el.name));
1180 }
1181 let args: Vec<(parse::Attr, String)> = attrs
1182 .iter()
1183 .map(|a| {
1184 let (value, by) = substitute(&a.value, props, path);
1185 let arg = parse::Attr {
1186 name: a.name.clone(),
1187 value,
1188 pos: a.pos,
1189 };
1190 (arg, by)
1191 })
1192 .collect();
1193 let target = target.clone();
1194 return self.design(&target, &args, Some((path, el.pos)), &el.name, parent);
1195 }
1196 let kind = match el.name.as_str() {
1197 "Frame" => Kind::Frame,
1199 "Text" => Kind::Text,
1200 "Rect" => Kind::Rect,
1201 "Ellipse" => Kind::Ellipse,
1202 "Vector" => Kind::Vector,
1203 "Image" => Kind::Image,
1204 other => {
1205 self.problem(path, el.pos, format!("unknown element {other}"));
1206 return None;
1207 }
1208 };
1209 let mut node = Node::new(kind, &el.name);
1210 let mut font = None;
1213 for &attr in &attrs {
1214 if attr.name == "props" {
1215 if !root {
1216 self.problem(
1217 path,
1218 attr.pos,
1219 "props: only a design's root declares props".into(),
1220 );
1221 }
1222 continue;
1223 }
1224 if root && matches!(attr.name.as_str(), "x" | "y") {
1227 self.problem(
1228 path,
1229 attr.pos,
1230 format!(
1231 "{}: a design's root has no place of its own; place an instance with x and y where it is used",
1232 attr.name
1233 ),
1234 );
1235 continue;
1236 }
1237 let (value, by) = match &attr.value {
1238 Value::Prop(p) => match props.get(p) {
1239 Some(given) => (given.value.clone(), given.by.clone()),
1240 None => {
1241 self.problem(path, attr.pos, format!("{}: no prop \"{p}\"", attr.name));
1242 continue;
1243 }
1244 },
1245 v => (v.clone(), path.to_string()),
1246 };
1247 self.themed |= has_theme(&value);
1248 self.fill_needs_layout(path, attr, &value, parent);
1249 self.place_needs_no_layout(path, attr, parent);
1250 if kind == Kind::Text && attr.name == "font" {
1251 font = Some((attr, value, by));
1252 continue;
1253 }
1254 self.attribute(path, &by, &mut node, attr, value);
1255 }
1256 match kind {
1257 Kind::Vector if !el.attrs.iter().any(|a| a.name == "d") => self.problem(
1258 path,
1259 el.pos,
1260 format!("{} needs a path, d=\"M…\"", node.name),
1261 ),
1262 Kind::Image if node.image.is_none() && !el.attrs.iter().any(|a| a.name == "src") => {
1263 self.problem(
1264 path,
1265 el.pos,
1266 format!("{} needs a file, src=\"…\"", node.name),
1267 )
1268 }
1269 _ => {}
1270 }
1271 if kind == Kind::Text {
1272 let mut content = String::new();
1273 for child in &el.children {
1274 match child {
1275 Child::Text(parts, pos) => {
1276 for part in parts {
1277 match part {
1278 TextPart::Literal(s) => content.push_str(s),
1279 TextPart::Prop(p) => match props.get(p).map(|g| &g.value) {
1280 Some(Value::Str(s, _)) => content.push_str(s),
1281 Some(Value::Num(n)) => content.push_str(&n.to_string()),
1282 _ => self.problem(path, *pos, format!("no text prop \"{p}\"")),
1283 },
1284 TextPart::Theme(name, at) => self.problem(
1285 path,
1286 *at,
1287 format!(
1288 "theme.{name} is a color, not text: give it to color={{theme.{name}}}"
1289 ),
1290 ),
1291 }
1292 }
1293 }
1294 Child::Element(e) => self.problem(path, e.pos, "Text holds text only".into()),
1295 }
1296 }
1297 let text = node.text.get_or_insert_with(default_text);
1298 text.content = content;
1299 let weight = text.weight;
1300 let weight_at = attrs.iter().find(|a| a.name == "weight").map(|a| a.pos);
1301 text.font = self.text_font(path, el.pos, font, weight, weight_at);
1302 } else {
1303 for child in &el.children {
1304 match child {
1305 Child::Element(e) => {
1306 if kind != Kind::Frame {
1307 self.problem(path, e.pos, format!("{} takes no children", el.name));
1308 } else if let Some(n) =
1309 self.element(path, e, props, imports, false, Some(node.layout))
1310 {
1311 node.children.push(n);
1312 }
1313 }
1314 Child::Text(_, pos) => self.problem(
1315 path,
1316 *pos,
1317 format!("text belongs in a Text, not a {}", el.name),
1318 ),
1319 }
1320 }
1321 }
1322 Some(node)
1323 }
1324
1325 fn fill_needs_layout(
1328 &mut self,
1329 path: &str,
1330 attr: &parse::Attr,
1331 value: &Value,
1332 parent: Option<Layout>,
1333 ) {
1334 if parent == Some(Layout::None)
1335 && matches!(attr.name.as_str(), "width" | "height")
1336 && matches!(value, Value::Str(s, _) if s == "fill")
1337 {
1338 self.problem(
1339 path,
1340 attr.pos,
1341 format!(
1342 "{}: \"fill\" needs a parent with layout=\"row\" or \"column\"",
1343 attr.name
1344 ),
1345 );
1346 }
1347 }
1348
1349 fn place_needs_no_layout(&mut self, path: &str, attr: &parse::Attr, parent: Option<Layout>) {
1352 if matches!(parent, Some(Layout::Row | Layout::Column))
1353 && matches!(attr.name.as_str(), "x" | "y")
1354 {
1355 self.problem(
1356 path,
1357 attr.pos,
1358 format!(
1359 "{}: places a child only in a frame with layout=\"none\"",
1360 attr.name
1361 ),
1362 );
1363 }
1364 }
1365
1366 #[allow(clippy::unit_arg)]
1368 fn attribute(
1369 &mut self,
1370 path: &str,
1371 by: &str,
1372 node: &mut Node,
1373 attr: &parse::Attr,
1374 value: Value,
1375 ) {
1376 let kind = node.kind;
1377 let (at_file, at_pos) = written_at(path, by, attr, &value);
1380 let ok = match (attr.name.as_str(), kind) {
1381 ("x" | "y" | "width" | "height" | "name" | "opacity" | "onClick", _) => {
1382 self.common(path, by, node, attr, value);
1383 return;
1384 }
1385 ("fill", k) if k != Kind::Text => fill(&value).map(|f| node.fill = Some(f)),
1386 ("stroke", k) if k != Kind::Text => color(&value).map(|c| node.stroke = Some(c)),
1387 ("strokeWidth", k) if k != Kind::Text => px(&value).map(|n| node.stroke_width = n),
1388 ("radius", k) if k != Kind::Text => match &value {
1389 Value::Num(_) => px(&value).map(|n| node.radius = [n; 4]),
1390 Value::Array(a) if a.len() == 4 => {
1391 pxs(a).map(|v| node.radius = [v[0], v[1], v[2], v[3]])
1392 }
1393 _ => Err("expected pixels or an array of 4".into()),
1394 },
1395 ("shadow", k) if k != Kind::Text => shadow(&value).map(|mut s| {
1396 s.at = Origin {
1397 file: path.to_string(),
1398 pos: attr.pos,
1399 };
1400 node.shadow = Some(s)
1401 }),
1402 ("clip", Kind::Frame) => match value {
1404 Value::Bool(b) => Ok(node.clip = b),
1405 _ => Err("expected true or false".into()),
1406 },
1407 ("layout", Kind::Frame) => match str_of(&value).as_deref() {
1408 Some("row") => Ok(node.layout = Layout::Row),
1409 Some("column") => Ok(node.layout = Layout::Column),
1410 Some("none") => Ok(node.layout = Layout::None),
1411 _ => Err("expected \"row\", \"column\" or \"none\"".into()),
1412 },
1413 ("gap", Kind::Frame) => px(&value).map(|n| node.gap = n),
1414 ("padding", Kind::Frame) => match &value {
1415 Value::Num(_) => px(&value).map(|n| node.padding = [n; 4]),
1416 Value::Array(a) if a.len() == 2 => {
1417 pxs(a).map(|v| node.padding = [v[0], v[1], v[0], v[1]])
1418 }
1419 Value::Array(a) if a.len() == 4 => {
1420 pxs(a).map(|v| node.padding = [v[0], v[1], v[2], v[3]])
1421 }
1422 other => Err(format!(
1423 "expected pixels or an array of 2 or 4, found {}",
1424 show(other)
1425 )),
1426 },
1427 ("align", Kind::Frame) => align(&value, false).map(|a| node.align = a),
1428 ("justify", Kind::Frame) => align(&value, true).map(|a| node.justify = a),
1429 ("size", Kind::Text) => {
1430 px(&value).map(|n| node.text.get_or_insert_with(default_text).size = n)
1431 }
1432 ("weight", Kind::Text) => match num(&value) {
1433 Ok(w) if fonts::WEIGHTS.iter().any(|&k| k as f32 == w) => {
1434 Ok(node.text.get_or_insert_with(default_text).weight = w as u16)
1435 }
1436 _ => Err("expected 100 to 900 in steps of 100".into()),
1437 },
1438 ("color", Kind::Text) => {
1439 color(&value).map(|c| node.text.get_or_insert_with(default_text).color = c)
1440 }
1441 ("lineHeight", Kind::Text) => {
1442 px(&value).map(|n| node.text.get_or_insert_with(default_text).line_height = n)
1443 }
1444 ("textAlign", Kind::Text) => match str_of(&value).as_deref() {
1445 Some("left") => {
1446 Ok(node.text.get_or_insert_with(default_text).align = TextAlign::Left)
1447 }
1448 Some("center") => {
1449 Ok(node.text.get_or_insert_with(default_text).align = TextAlign::Center)
1450 }
1451 Some("right") => {
1452 Ok(node.text.get_or_insert_with(default_text).align = TextAlign::Right)
1453 }
1454 _ => Err("expected \"left\", \"center\" or \"right\"".into()),
1455 },
1456 ("d", Kind::Vector) => match str_of(&value) {
1457 Some(d) => match path_numbers(&d).map_err(|e| e.to_string()).and_then(|()| {
1458 vello_cpu::kurbo::BezPath::from_svg(&d)
1459 .map_err(|e| format!("not SVG path data: {e}"))
1460 }) {
1461 Ok(_) => {
1462 let view_box = node.vector.as_ref().map(|v| v.view_box);
1463 let view_box = view_box.unwrap_or_else(|| bounds(&d));
1464 Ok(node.vector = Some(Vector { d, view_box }))
1465 }
1466 Err(e) => Err(e),
1467 },
1468 None => Err("expected SVG path data".into()),
1469 },
1470 ("viewBox", Kind::Vector) => match str_of(&value).as_deref().map(view_box) {
1471 Some(Ok(b)) => {
1472 let v = node.vector.get_or_insert_with(|| Vector {
1473 d: String::new(),
1474 view_box: b,
1475 });
1476 Ok(v.view_box = b)
1477 }
1478 Some(Err(e)) => Err(e),
1479 None => Err("expected \"minX minY width height\"".into()),
1480 },
1481 ("src", Kind::Image) => match str_of(&value) {
1482 Some(src) => match join(by, &src) {
1483 None => {
1484 let e = format!("src: {src} — outside the served folder");
1485 self.problem(&at_file, at_pos, e);
1486 return;
1487 }
1488 Some(at) => match self.image_file(&at) {
1489 None => {
1490 self.problem(&at_file, at_pos, format!("src: {src} — no such file"));
1491 return;
1492 }
1493 Some(Err(e)) => {
1494 self.problem(&at_file, at_pos, format!("src: {src} {e}"));
1495 return;
1496 }
1497 Some(Ok(id)) => {
1498 node.image = Some(Image {
1499 src: at,
1500 id,
1501 at: Origin {
1502 file: path.to_string(),
1503 pos: attr.pos,
1504 },
1505 });
1506 Ok(())
1507 }
1508 },
1509 },
1510 None => Err("expected a path".into()),
1511 },
1512 (name, _) => {
1513 self.problem(
1514 path,
1515 attr.pos,
1516 format!("{} takes no attribute {name}", node.name),
1517 );
1518 return;
1519 }
1520 };
1521 if let Err(e) = ok {
1522 self.problem(path, attr.pos, format!("{}: {e}", attr.name));
1523 }
1524 }
1525
1526 fn image_file(&mut self, at: &str) -> Option<Result<ImageId, String>> {
1530 if let Some(known) = self.by_path.get(at) {
1531 return Some(known.clone());
1532 }
1533 let file = self.files.image(at)?;
1534 if !self.read.iter().any(|p| p == at) {
1535 self.read.push(at.to_string());
1536 }
1537 let id = file.map(|file| {
1538 let id = file.id;
1539 self.images.entry(id).or_insert(file);
1540 id
1541 });
1542 self.by_path.insert(at.to_string(), id.clone());
1543 Some(id)
1544 }
1545
1546 fn text_font(
1553 &mut self,
1554 path: &str,
1555 pos: Pos,
1556 font: Option<(&parse::Attr, Value, String)>,
1557 weight: u16,
1558 weight_at: Option<Pos>,
1559 ) -> Option<FontId> {
1560 let Some((attr, value, by)) = font else {
1561 if !fonts::INTER_WEIGHTS.contains(&weight) {
1562 self.no_weight(
1563 path,
1564 pos,
1565 weight_at,
1566 weight,
1567 fonts::INTER,
1568 &fonts::INTER_WEIGHTS,
1569 );
1570 }
1571 return None;
1572 };
1573 let (file, at_pos) = written_at(path, &by, attr, &value);
1574 let Some(name) = str_of(&value) else {
1575 self.problem(
1576 &file,
1577 at_pos,
1578 "font: expected the name of a font the themes name, like \"figtree\"".into(),
1579 );
1580 return None;
1581 };
1582 let folder = by.rsplit_once('/').map_or("", |(folder, _)| folder);
1583 let named = self.themes(folder).fonts.clone();
1584 let Some(entry) = named.get(&name) else {
1585 let problem = if named.is_empty() {
1586 format!(
1587 "font: no font named {name}; no {THEMES_FILE} in this design's folder or above it names fonts"
1588 )
1589 } else {
1590 let names: Vec<&str> = named.keys().map(String::as_str).collect();
1591 format!(
1592 "font: no font named {name}; the themes name {}",
1593 names.join(", ")
1594 )
1595 };
1596 self.problem(&file, at_pos, problem);
1597 return None;
1598 };
1599 let origin = entry.origin.clone();
1600 let Some(at) = entry.at.clone() else {
1601 let key = format!("{}\0{}", origin.file, name);
1602 if let std::collections::hash_map::Entry::Vacant(first) = self.families.entry(key) {
1603 first.insert((None, true));
1604 self.problem(
1605 &origin.file,
1606 origin.pos,
1607 format!(
1608 "fonts.{name}: {} — outside the served folder",
1609 entry.written
1610 ),
1611 );
1612 }
1613 return None;
1614 };
1615 let (family, reported) = self.family(&at);
1616 let family = match family {
1617 Some(Ok(family)) => family,
1618 missing_or_bad => {
1619 if !reported {
1620 let problem = match missing_or_bad {
1621 Some(Err(e)) => format!("fonts.{name}: {} {e}", entry.written),
1622 _ => format!("fonts.{name}: {} — no such file or folder", entry.written),
1623 };
1624 self.problem(&origin.file, origin.pos, problem);
1625 if let Some(known) = self.families.get_mut(&at) {
1626 known.1 = true;
1627 }
1628 }
1629 return None;
1630 }
1631 };
1632 match family.face(weight) {
1633 Some(face) => {
1634 self.fonts.entry(face.id).or_insert_with(|| face.clone());
1635 Some(face.id)
1636 }
1637 None => {
1638 let has = family.weights();
1639 self.no_weight(path, pos, weight_at, weight, &family.name, &has);
1640 None
1641 }
1642 }
1643 }
1644
1645 fn no_weight(
1648 &mut self,
1649 path: &str,
1650 pos: Pos,
1651 weight_at: Option<Pos>,
1652 weight: u16,
1653 family: &str,
1654 has: &[u16],
1655 ) {
1656 let message = if has.is_empty() {
1657 format!(
1658 "weight: {family} has no {weight}; it has no weight from 100 to 900 in steps of 100"
1659 )
1660 } else {
1661 format!(
1662 "weight: {family} has no {weight}; it has {}",
1663 fonts::list(has)
1664 )
1665 };
1666 self.problem(path, weight_at.unwrap_or(pos), message);
1667 }
1668
1669 fn family(&mut self, at: &str) -> (Found, bool) {
1673 if let Some(known) = self.families.get(at) {
1674 return known.clone();
1675 }
1676 let family = match self.font_file(at) {
1677 Some(Ok(file)) => Some(Ok(Rc::new(Family {
1678 name: file.family.clone(),
1679 faces: vec![file],
1680 }))),
1681 Some(Err(e)) => Some(Err(e)),
1682 None => self.files.list(at).map(|names| {
1683 let mut faces = Vec::new();
1684 for name in names.into_iter().filter(|n| fonts::is_font_name(n)) {
1685 match self.font_file(&format!("{at}/{name}")) {
1686 Some(Ok(file)) => faces.push((name, file)),
1687 Some(Err(e)) => return Err(format!("holds {name}, which {e}")),
1688 None => {}
1690 }
1691 }
1692 Family::of_folder(faces).map(Rc::new)
1693 }),
1694 };
1695 self.families
1696 .insert(at.to_string(), (family.clone(), false));
1697 (family, false)
1698 }
1699
1700 fn font_file(&mut self, at: &str) -> Option<Result<Arc<FontFile>, String>> {
1703 let file = self.files.font(at)?;
1704 if !self.read.iter().any(|p| p == at) {
1705 self.read.push(at.to_string());
1706 }
1707 Some(file)
1708 }
1709
1710 fn interaction(&mut self, path: &str, action: &str) -> Result<String, String> {
1713 let Some((verb, target)) = action.split_once(' ') else {
1714 return Ok(action.to_string());
1715 };
1716 let target = target.trim();
1717 if !target.ends_with(".design") {
1718 return Err(format!("{action} — a design is a .design file"));
1719 }
1720 match join(path, target) {
1721 None => Err(format!("{action} — outside the served folder")),
1722 Some(at) if self.files.bytes(&at).is_none() => Err(format!("{action} — no such file")),
1723 Some(at) => Ok(format!("{verb} {at}")),
1724 }
1725 }
1726
1727 fn common(&mut self, path: &str, by: &str, node: &mut Node, attr: &parse::Attr, value: Value) {
1728 let (at_file, at_pos) = written_at(path, by, attr, &value);
1729 let ok = match attr.name.as_str() {
1730 "x" => num(&value).map(|n| node.x = n),
1731 "y" => num(&value).map(|n| node.y = n),
1732 "width" => size(&value).map(|s| node.width = s),
1733 "height" => size(&value).map(|s| node.height = s),
1734 "name" => str_of(&value)
1735 .map(|s| node.name = s)
1736 .ok_or_else(|| "expected a string".into()),
1737 "opacity" => match value {
1738 Value::Num(n) if (0.0..=1.0).contains(&n) => {
1739 node.opacity = n as f32;
1740 Ok(())
1741 }
1742 other => Err(format!(
1743 "expected a number from 0 to 1, found {}",
1744 show(&other)
1745 )),
1746 },
1747 "onClick" => match str_of(&value).filter(|s| interaction_ok(s)) {
1748 Some(action) => match self.interaction(by, &action) {
1749 Ok(a) => {
1750 node.on_click = Some(a);
1751 Ok(())
1752 }
1753 Err(e) => {
1754 self.problem(&at_file, at_pos, format!("onClick: {e}"));
1755 return;
1756 }
1757 },
1758 None => Err(
1759 "expected \"navigate <path>\", \"back\", \"overlay <path>\" or \"close\""
1760 .into(),
1761 ),
1762 },
1763 _ => unreachable!(),
1764 };
1765 if let Err(e) = ok {
1766 self.problem(path, attr.pos, format!("{}: {e}", attr.name));
1767 }
1768 }
1769}
1770
1771fn default_text() -> TextStyle {
1772 TextStyle {
1773 font: None,
1774 content: String::new(),
1775 size: 14.0,
1776 weight: 400,
1777 color: Color::fixed(Rgba {
1778 r: 0x0f,
1779 g: 0x17,
1780 b: 0x2a,
1781 a: 255,
1782 }),
1783 line_height: 1.4,
1784 align: TextAlign::Left,
1785 }
1786}
1787
1788fn view_box(s: &str) -> Result<[f32; 4], String> {
1789 let v: Vec<f32> = s
1790 .split([' ', ','])
1791 .filter(|p| !p.is_empty())
1792 .map(|p| parse::number(p).map(|n| n as f32))
1793 .collect::<Result<_, _>>()?;
1794 match v[..] {
1795 [x, y, w, h] if w > 0.0 && h > 0.0 => Ok([x, y, w, h]),
1796 _ => Err("expected \"minX minY width height\"".into()),
1797 }
1798}
1799
1800fn path_numbers(d: &str) -> Result<(), String> {
1804 let b = d.as_bytes();
1805 let mut i = 0;
1806 let mut command = b' ';
1807 let mut param = 0;
1809 while i < b.len() {
1810 let c = b[i];
1811 if c.is_ascii_alphabetic() && c != b'e' && c != b'E' {
1812 command = c;
1813 param = 0;
1814 i += 1;
1815 continue;
1816 }
1817 if c.is_ascii_whitespace() || c == b',' {
1818 i += 1;
1819 continue;
1820 }
1821 if matches!(command, b'A' | b'a') && matches!(param % 7, 3 | 4) && matches!(c, b'0' | b'1')
1822 {
1823 param += 1;
1824 i += 1;
1825 continue;
1826 }
1827 let start = i;
1828 if matches!(b[i], b'+' | b'-') {
1829 i += 1;
1830 }
1831 let mut dot = false;
1832 while i < b.len() && (b[i].is_ascii_digit() || (b[i] == b'.' && !dot)) {
1833 dot |= b[i] == b'.';
1834 i += 1;
1835 }
1836 if i < b.len() && matches!(b[i], b'e' | b'E') {
1837 let mut j = i + 1;
1838 if j < b.len() && matches!(b[j], b'+' | b'-') {
1839 j += 1;
1840 }
1841 if j < b.len() && b[j].is_ascii_digit() {
1842 i = j;
1843 while i < b.len() && b[i].is_ascii_digit() {
1844 i += 1;
1845 }
1846 }
1847 }
1848 if i == start {
1849 return Ok(());
1851 }
1852 let s = &d[start..i];
1853 if let Ok(n) = s.parse::<f64>() {
1854 parse::in_range(s, n)?;
1855 }
1856 param += 1;
1857 }
1858 Ok(())
1859}
1860
1861fn bounds(d: &str) -> [f32; 4] {
1863 use vello_cpu::kurbo::Shape;
1864 let b = vello_cpu::kurbo::BezPath::from_svg(d)
1865 .map(|p| p.bounding_box())
1866 .unwrap_or_default();
1867 [
1868 b.x0 as f32,
1869 b.y0 as f32,
1870 (b.width() as f32).max(1.0),
1871 (b.height() as f32).max(1.0),
1872 ]
1873}
1874
1875fn interaction_ok(s: &str) -> bool {
1876 s == "back"
1877 || s == "close"
1878 || s.strip_prefix("navigate ")
1879 .or(s.strip_prefix("overlay "))
1880 .is_some_and(|p| !p.trim().is_empty())
1881}
1882
1883pub fn join(from: &str, relative: &str) -> Option<String> {
1886 let mut parts: Vec<&str> = from.split('/').filter(|p| !p.is_empty()).collect();
1887 parts.pop();
1888 for part in relative.split('/') {
1889 match part {
1890 "." | "" => {}
1891 ".." => {
1892 parts.pop()?;
1893 }
1894 p => parts.push(p),
1895 }
1896 }
1897 Some(parts.join("/"))
1898}
1899
1900fn substitute(value: &Value, props: &Props, path: &str) -> (Value, String) {
1903 match value {
1904 Value::Prop(p) => match props.get(p) {
1905 Some(given) => (given.value.clone(), given.by.clone()),
1906 None => (value.clone(), path.to_string()),
1907 },
1908 v => (v.clone(), path.to_string()),
1909 }
1910}
1911
1912fn written_at(path: &str, by: &str, attr: &parse::Attr, value: &Value) -> (String, Pos) {
1916 match value {
1917 Value::Str(_, pos) if by != path => (by.to_string(), *pos),
1918 _ => (path.to_string(), attr.pos),
1919 }
1920}
1921
1922fn show(v: &Value) -> String {
1923 match v {
1924 Value::Str(s, _) => format!("\"{s}\""),
1925 Value::Num(n) => n.to_string(),
1926 Value::Bool(b) => b.to_string(),
1927 Value::Array(_) => "an array".into(),
1928 Value::Object(_) => "an object".into(),
1929 Value::Prop(p) => p.clone(),
1930 Value::Theme(name, _) => format!("theme.{name}"),
1931 Value::Themed { written, .. } => written.clone(),
1932 }
1933}
1934
1935fn str_of(v: &Value) -> Option<String> {
1936 match v {
1937 Value::Str(s, _) => Some(s.clone()),
1938 _ => None,
1939 }
1940}
1941
1942fn key_pos(text: &str, under: Option<&str>, key: &str) -> Option<Pos> {
1947 let names = |line: &str, key: &str| {
1948 let line = line.trim_start();
1949 [
1950 format!("{key}:"),
1951 format!("\"{key}\":"),
1952 format!("'{key}':"),
1953 ]
1954 .iter()
1955 .any(|k| line.starts_with(k.as_str()))
1956 };
1957 let top = |line: &str| !line.starts_with([' ', '\t']) && !line.trim().is_empty();
1958 let mut inside = under.is_none();
1959 for (n, line) in text.lines().enumerate() {
1960 if line.trim_start().starts_with('#') {
1961 continue;
1962 }
1963 let pos = Pos {
1964 line: n as u32 + 1,
1965 col: (line.len() - line.trim_start().len()) as u32 + 1,
1966 };
1967 match under {
1968 None if top(line) && names(line, key) => return Some(pos),
1969 None => {}
1970 Some(block) if top(line) => inside = names(line, block),
1971 Some(_) if inside && names(line, key) => return Some(pos),
1972 Some(_) => {}
1973 }
1974 }
1975 None
1976}
1977
1978const ELEMENTS: [&str; 6] = ["Frame", "Text", "Rect", "Ellipse", "Vector", "Image"];
1980
1981fn num(v: &Value) -> Result<f32, String> {
1982 match v {
1983 Value::Num(n) => Ok(*n as f32),
1984 other => Err(format!("expected a number, found {}", show(other))),
1985 }
1986}
1987
1988fn px(v: &Value) -> Result<f32, String> {
1990 match v {
1991 Value::Num(n) if *n >= 0.0 => Ok(*n as f32),
1992 Value::Num(n) => Err(format!("expected a number of 0 or more, found {n}")),
1993 other => Err(format!("expected a number, found {}", show(other))),
1994 }
1995}
1996
1997fn pxs(a: &[Value]) -> Result<Vec<f32>, String> {
1998 a.iter().map(px).collect()
1999}
2000
2001fn size(v: &Value) -> Result<Size, String> {
2002 match v {
2003 Value::Num(n) if *n >= 0.0 => Ok(Size::Px(*n as f32)),
2004 Value::Num(n) => Err(format!("expected pixels of 0 or more, found {n}")),
2005 Value::Str(s, _) if s == "hug" => Ok(Size::Hug),
2006 Value::Str(s, _) if s == "fill" => Ok(Size::Fill),
2007 other => Err(format!(
2008 "expected pixels, \"hug\" or \"fill\", found {}",
2009 show(other)
2010 )),
2011 }
2012}
2013
2014fn align(v: &Value, justify: bool) -> Result<Align, String> {
2015 match str_of(v).as_deref() {
2016 Some("start") => Ok(Align::Start),
2017 Some("center") => Ok(Align::Center),
2018 Some("end") => Ok(Align::End),
2019 Some("space-between") if justify => Ok(Align::SpaceBetween),
2020 _ if justify => Err("expected \"start\", \"center\", \"end\" or \"space-between\"".into()),
2021 _ => Err("expected \"start\", \"center\" or \"end\"".into()),
2022 }
2023}
2024
2025fn color(v: &Value) -> Result<Color, String> {
2028 match v {
2029 Value::Str(s, _) => rgba(s).map(Color::fixed),
2030 Value::Themed { light, dark, .. } => Ok(Color {
2031 light: rgba(light)?,
2032 dark: rgba(dark)?,
2033 }),
2034 _ => Err(format!("expected a color, found {}", show(v))),
2035 }
2036}
2037
2038pub fn rgba(s: &str) -> Result<Rgba, String> {
2040 if s.starts_with("theme.") {
2041 return Err(format!(
2042 "{s} is a theme color: write it in braces, {{{s}}}, not in quotes"
2043 ));
2044 }
2045 let hex = s.strip_prefix('#').ok_or("expected a color like #rrggbb")?;
2046 if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
2047 return Err(format!("{s} is not a color"));
2048 }
2049 let d = |i: usize, n: usize| {
2050 u8::from_str_radix(&hex[i..i + n], 16).map_err(|_| format!("{s} is not a color"))
2051 };
2052 let dd = |i: usize| d(i, 1).map(|v| v * 17);
2053 match hex.len() {
2054 3 => Ok(Rgba {
2055 r: dd(0)?,
2056 g: dd(1)?,
2057 b: dd(2)?,
2058 a: 255,
2059 }),
2060 4 => Ok(Rgba {
2061 r: dd(0)?,
2062 g: dd(1)?,
2063 b: dd(2)?,
2064 a: dd(3)?,
2065 }),
2066 6 => Ok(Rgba {
2067 r: d(0, 2)?,
2068 g: d(2, 2)?,
2069 b: d(4, 2)?,
2070 a: 255,
2071 }),
2072 8 => Ok(Rgba {
2073 r: d(0, 2)?,
2074 g: d(2, 2)?,
2075 b: d(4, 2)?,
2076 a: d(6, 2)?,
2077 }),
2078 _ => Err(format!("{s} is not a color")),
2079 }
2080}
2081
2082fn hex(c: Rgba) -> String {
2084 format!("#{:02x}{:02x}{:02x}{:02x}", c.r, c.g, c.b, c.a)
2085}
2086
2087fn fill(v: &Value) -> Result<Fill, String> {
2088 match v {
2089 Value::Str(s, _) => fill_of(s),
2090 Value::Themed { light, dark, .. } => match (fill_of(light)?, fill_of(dark)?) {
2093 (Fill::Solid(l), Fill::Solid(d)) => Ok(Fill::Solid(Color {
2094 light: l.light,
2095 dark: d.light,
2096 })),
2097 (
2098 Fill::Linear { angle, stops: l },
2099 Fill::Linear {
2100 stops: d,
2101 angle: dark_angle,
2102 },
2103 ) if angle == dark_angle && l.len() == d.len() => Ok(Fill::Linear {
2104 angle,
2105 stops: l
2106 .iter()
2107 .zip(&d)
2108 .map(|((l, at), (d, _))| {
2109 (
2110 Color {
2111 light: l.light,
2112 dark: d.light,
2113 },
2114 *at,
2115 )
2116 })
2117 .collect(),
2118 }),
2119 _ => Err(format!("{} is not the same fill in both themes", show(v))),
2120 },
2121 _ => Err(format!("expected a color or a gradient, found {}", show(v))),
2122 }
2123}
2124
2125fn fill_of(s: &str) -> Result<Fill, String> {
2127 let Some(inner) = s
2128 .strip_prefix("linear-gradient(")
2129 .and_then(|r| r.strip_suffix(')'))
2130 else {
2131 return rgba(s).map(|c| Fill::Solid(Color::fixed(c)));
2132 };
2133 let wrong =
2134 || format!("expected \"linear-gradient(<angle>deg, <color> <stop>%, …)\", found \"{s}\"");
2135 let mut parts = inner.split(',').map(str::trim);
2136 let angle = parts
2137 .next()
2138 .and_then(|a| a.strip_suffix("deg"))
2139 .ok_or_else(wrong)?;
2140 let angle = parse::number(angle.trim())? as f32;
2141 let mut stops = Vec::new();
2142 for part in parts {
2143 let (c, at) = part.split_once(' ').ok_or_else(wrong)?;
2144 let at = at.trim().strip_suffix('%').ok_or_else(wrong)?;
2145 let at = parse::number(at)? as f32;
2146 stops.push((Color::fixed(rgba(c)?), at / 100.0));
2147 }
2148 if stops.len() < 2 {
2149 return Err(wrong());
2150 }
2151 Ok(Fill::Linear { angle, stops })
2152}
2153
2154fn has_theme(v: &Value) -> bool {
2156 match v {
2157 Value::Themed { .. } | Value::Theme(..) => true,
2158 Value::Array(items) => items.iter().any(has_theme),
2159 Value::Object(fields) => fields.iter().any(|(_, v)| has_theme(v)),
2160 _ => false,
2161 }
2162}
2163
2164fn shadow(v: &Value) -> Result<Shadow, String> {
2165 let Value::Object(fields) = v else {
2166 return Err("expected { x, y, blur, spread, color }".into());
2167 };
2168 let mut s = Shadow {
2169 x: 0.0,
2170 y: 0.0,
2171 blur: 0.0,
2172 spread: 0.0,
2173 color: Color::fixed(Rgba {
2174 r: 0,
2175 g: 0,
2176 b: 0,
2177 a: 64,
2178 }),
2179 at: Origin::default(),
2180 };
2181 for (k, v) in fields {
2182 match k.as_str() {
2183 "x" => s.x = num(v)?,
2184 "y" => s.y = num(v)?,
2185 "blur" => s.blur = num(v)?,
2186 "spread" => s.spread = num(v)?,
2187 "color" => s.color = color(v)?,
2188 other => return Err(format!("a shadow has no {other}")),
2189 }
2190 }
2191 Ok(s)
2192}
2193
2194#[cfg(test)]
2195mod tests {
2196 use super::*;
2197
2198 fn files(list: &[(&str, &str)]) -> HashMap<String, String> {
2199 list.iter()
2200 .map(|(p, s)| (p.to_string(), s.to_string()))
2201 .collect()
2202 }
2203
2204 #[test]
2205 fn an_instance_takes_its_props() {
2206 let fs = files(&[
2207 (
2208 "a/button.design",
2209 r##"<Frame props={{ label: "Button", fill: "#2563eb" }} fill={fill}><Text>{label}</Text></Frame>"##,
2210 ),
2211 (
2212 "a/screen.design",
2213 r#"import Button from "./button.design"
2214<Frame width={390}><Button label="Pay" x={10} opacity={0.5} /></Frame>"#,
2215 ),
2216 ]);
2217 let scene = resolve(&fs, "a/screen.design").unwrap();
2218 let button = &scene.root.children[0];
2219 assert_eq!(button.x, 10.0);
2220 assert_eq!(button.opacity, 0.5);
2221 assert_eq!(
2222 button.fill,
2223 Some(Fill::Solid(Color::fixed(Rgba {
2224 r: 0x25,
2225 g: 0x63,
2226 b: 0xeb,
2227 a: 255
2228 })))
2229 );
2230 assert_eq!(button.children[0].text.as_ref().unwrap().content, "Pay");
2231 assert_eq!(scene.files, vec!["a/screen.design", "a/button.design"]);
2232 }
2233
2234 #[test]
2235 fn opacity_is_between_zero_and_one() {
2236 let fs = files(&[
2237 ("dot.design", "<Frame />"),
2238 (
2239 "screen.design",
2240 "import Dot from \"./dot.design\"\n<Frame opacity={1}>\n <Rect opacity={50} />\n <Dot opacity={-0.1} />\n</Frame>",
2241 ),
2242 ]);
2243 let problems: Vec<String> = resolve(&fs, "screen.design")
2244 .unwrap_err()
2245 .iter()
2246 .map(|p| p.to_string())
2247 .collect();
2248 assert_eq!(
2249 problems,
2250 vec![
2251 "screen.design:3:9: opacity: expected a number from 0 to 1, found 50",
2252 "screen.design:4:8: opacity: expected a number from 0 to 1, found -0.1",
2253 ]
2254 );
2255 }
2256
2257 fn problems_in(fs: &HashMap<String, String>, path: &str) -> Vec<String> {
2258 resolve(fs, path)
2259 .unwrap_err()
2260 .iter()
2261 .map(|p| p.to_string())
2262 .collect()
2263 }
2264
2265 #[test]
2266 fn a_color_with_any_character_but_hex_digits_is_a_problem() {
2267 for (value, col) in [
2269 ("#fff\u{200b}", 13),
2270 ("#ffé", 13),
2271 ("#ff€f00", 13),
2272 ("#ggg", 13),
2273 ] {
2274 let fs = files(&[("a.design", &format!(r#"<Frame fill="{value}" />"#))]);
2275 assert_eq!(
2276 problems_in(&fs, "a.design"),
2277 vec![format!("a.design:1:8: fill: {value} is not a color")],
2278 "{value:?} at {col}"
2279 );
2280 }
2281 }
2282
2283 #[test]
2284 fn props_belong_to_the_root_only() {
2285 let fs = files(&[(
2286 "a.design",
2287 r#"<Frame props={{ a: "x" }}>
2288 <Frame props={{ b: "y" }} />
2289</Frame>"#,
2290 )]);
2291 assert_eq!(
2292 problems_in(&fs, "a.design"),
2293 vec!["a.design:2:10: props: only a design's root declares props"]
2294 );
2295 }
2296
2297 #[test]
2298 fn a_root_has_no_place_of_its_own() {
2299 let fs = files(&[
2300 (
2301 "dot.design",
2302 r#"<Frame x={40} y={30} width={10} height={10} />"#,
2303 ),
2304 (
2305 "a.design",
2306 r#"import Dot from "./dot.design"
2307<Frame width={100} height={100}>
2308 <Dot x={5} y={6} />
2309</Frame>"#,
2310 ),
2311 ]);
2312 let expected = [
2313 "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",
2314 "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",
2315 ];
2316 assert_eq!(problems_in(&fs, "dot.design"), expected);
2317 assert_eq!(problems_in(&fs, "a.design"), expected);
2320 let fs = files(&[
2321 ("dot.design", r#"<Frame width={10} height={10} />"#),
2322 (
2323 "a.design",
2324 r#"import Dot from "./dot.design"
2325<Frame width={100} height={100}>
2326 <Dot x={5} y={6} />
2327</Frame>"#,
2328 ),
2329 ]);
2330 let dot = &resolve(&fs, "a.design").unwrap().root.children[0];
2331 assert_eq!((dot.x, dot.y), (5.0, 6.0));
2332 }
2333
2334 #[test]
2335 fn fill_needs_a_parent_with_auto_layout() {
2336 let fs = files(&[
2337 ("dot.design", r#"<Frame width="fill" />"#),
2338 (
2339 "a.design",
2340 r#"import Dot from "./dot.design"
2341<Frame width={100} height={100}>
2342 <Rect width="fill" height={10} />
2343 <Dot />
2344 <Dot height="fill" />
2345 <Frame layout="row"><Rect width="fill" /></Frame>
2346</Frame>"#,
2347 ),
2348 ]);
2349 assert_eq!(
2350 problems_in(&fs, "a.design"),
2351 vec![
2352 "a.design:3:9: width: \"fill\" needs a parent with layout=\"row\" or \"column\"",
2353 "dot.design:1:8: width: \"fill\" needs a parent with layout=\"row\" or \"column\"",
2354 "dot.design:1:8: width: \"fill\" needs a parent with layout=\"row\" or \"column\"",
2355 "a.design:5:8: height: \"fill\" needs a parent with layout=\"row\" or \"column\"",
2356 ]
2357 );
2358 }
2359
2360 #[test]
2361 fn a_tree_nests_at_most_so_deep_through_instances() {
2362 let mut list = Vec::new();
2364 let depth = 40;
2365 for i in 0..depth {
2366 let body = if i + 1 < depth {
2367 let inner = "<Frame>".repeat(10) + "<N />" + &"</Frame>".repeat(10);
2368 format!("import N from \"./{}.design\"\n{inner}", i + 1)
2369 } else {
2370 "<Frame />".to_string()
2371 };
2372 list.push((format!("{i}.design"), body));
2373 }
2374 let fs: HashMap<String, String> = list.into_iter().collect();
2375 let found = problems_in(&fs, "0.design");
2376 assert_eq!(found.len(), 1, "{found:?}");
2377 assert!(
2378 found[0].ends_with(&format!("nested more than {MAX_DEPTH} deep")),
2379 "{found:?}"
2380 );
2381 }
2382
2383 #[test]
2384 fn every_problem_is_reported() {
2385 let fs = files(&[
2386 (
2387 "button.design",
2388 r#"<Frame props={{ label: "B" }} padding="12px"><Text>{label}</Text></Frame>"#,
2389 ),
2390 (
2391 "screen.design",
2392 "import Button from \"./button.design\"\nimport Row from \"./row.design\"\n<Frame>\n <Button lable=\"x\" />\n <Row />\n <Blob />\n</Frame>",
2393 ),
2394 ]);
2395 let problems: Vec<String> = resolve(&fs, "screen.design")
2396 .unwrap_err()
2397 .iter()
2398 .map(|p| p.to_string())
2399 .collect();
2400 assert_eq!(
2401 problems,
2402 vec![
2403 "screen.design:2:1: import ./row.design — no such file",
2404 "screen.design:4:11: Button has no prop \"lable\"",
2405 "button.design:1:31: padding: expected pixels or an array of 2 or 4, found \"12px\"",
2406 "screen.design:6:3: unknown element Blob",
2407 ]
2408 );
2409 }
2410
2411 #[test]
2412 fn an_import_of_a_missing_file_is_a_problem_even_unused() {
2413 let fs = files(&[("cart.design", "import Row from \"./row.design\"\n<Frame />")]);
2414 let problems: Vec<String> = resolve(&fs, "cart.design")
2415 .unwrap_err()
2416 .iter()
2417 .map(|p| p.to_string())
2418 .collect();
2419 assert_eq!(
2420 problems,
2421 vec!["cart.design:1:1: import ./row.design — no such file"]
2422 );
2423 }
2424
2425 #[test]
2426 fn a_design_that_uses_itself() {
2427 let fs = files(&[(
2428 "a.design",
2429 "import A from \"./a.design\"\n<Frame><A /></Frame>",
2430 )]);
2431 let problems = resolve(&fs, "a.design").unwrap_err();
2432 assert_eq!(
2433 problems[0].to_string(),
2434 "a.design:2:8: a.design uses itself"
2435 );
2436 }
2437
2438 struct Mem(HashMap<String, Vec<u8>>);
2440
2441 impl Files for Mem {
2442 fn bytes(&self, path: &str) -> Option<Vec<u8>> {
2443 self.0.get(path).cloned()
2444 }
2445 fn list(&self, path: &str) -> Option<Vec<String>> {
2446 listed(self.0.keys(), path)
2447 }
2448 }
2449
2450 fn png() -> Vec<u8> {
2452 let mut out = Vec::new();
2453 let mut e = png::Encoder::new(&mut out, 1, 1);
2454 e.set_color(png::ColorType::Rgba);
2455 let mut w = e.write_header().unwrap();
2456 w.write_image_data(&[0, 0, 0, 255]).unwrap();
2457 drop(w);
2458 out
2459 }
2460
2461 fn problems(source: &str) -> Vec<String> {
2462 let fs = Mem([
2463 ("d/a.design".to_string(), source.as_bytes().to_vec()),
2464 ("d/photo.png".to_string(), png()),
2465 ("d/notes.txt".to_string(), b"hi".to_vec()),
2466 (
2467 "d/b.design".to_string(),
2468 br#"<Frame props={{ label: "B" }}><Text>{label}</Text></Frame>"#.to_vec(),
2469 ),
2470 ]
2471 .into());
2472 match resolve(&fs, "d/a.design") {
2473 Ok(_) => Vec::new(),
2474 Err(p) => p.iter().map(|p| p.to_string()).collect(),
2475 }
2476 }
2477
2478 #[test]
2479 fn each_problem_names_its_place() {
2480 let cases = [
2481 (
2482 "<Rect />",
2483 "d/a.design:1:1: the root element must be a Frame, not Rect",
2484 ),
2485 (
2486 "<Frame props=\"x\" />",
2487 "d/a.design:1:8: props: expected an object of defaults",
2488 ),
2489 (
2490 "<Frame fill=\"red\" />",
2491 "d/a.design:1:8: fill: expected a color like #rrggbb",
2492 ),
2493 (
2494 "<Frame fill=\"#12\" />",
2495 "d/a.design:1:8: fill: #12 is not a color",
2496 ),
2497 (
2498 "<Frame fill=\"linear-gradient(90deg, #fff 0%)\" />",
2499 "d/a.design:1:8: fill: expected \"linear-gradient(<angle>deg, <color> <stop>%, …)\", found \"linear-gradient(90deg, #fff 0%)\"",
2500 ),
2501 (
2502 "<Frame width=\"wide\" />",
2503 "d/a.design:1:8: width: expected pixels, \"hug\" or \"fill\", found \"wide\"",
2504 ),
2505 (
2506 "<Frame gap=\"4\" />",
2507 "d/a.design:1:8: gap: expected a number, found \"4\"",
2508 ),
2509 (
2510 "<Frame radius={[1, 2]} />",
2511 "d/a.design:1:8: radius: expected pixels or an array of 4",
2512 ),
2513 (
2514 "<Frame layout=\"grid\" />",
2515 "d/a.design:1:8: layout: expected \"row\", \"column\" or \"none\"",
2516 ),
2517 (
2518 "<Frame align=\"space-between\" />",
2519 "d/a.design:1:8: align: expected \"start\", \"center\" or \"end\"",
2520 ),
2521 (
2522 "<Frame clip=\"yes\" />",
2523 "d/a.design:1:8: clip: expected true or false",
2524 ),
2525 (
2526 "<Frame shadow={{ blurr: 4 }} />",
2527 "d/a.design:1:8: shadow: a shadow has no blurr",
2528 ),
2529 (
2530 "<Frame onClick=\"go ./b.design\" />",
2531 "d/a.design:1:8: onClick: expected \"navigate <path>\", \"back\", \"overlay <path>\" or \"close\"",
2532 ),
2533 (
2534 "<Frame size={4} />",
2535 "d/a.design:1:8: Frame takes no attribute size",
2536 ),
2537 (
2538 "<Frame><Text fill=\"#fff\">a</Text></Frame>",
2539 "d/a.design:1:14: Text takes no attribute fill",
2540 ),
2541 (
2542 "<Frame><Text weight={450}>a</Text></Frame>",
2543 "d/a.design:1:14: weight: expected 100 to 900 in steps of 100",
2544 ),
2545 (
2546 "<Frame><Text textAlign=\"justify\">a</Text></Frame>",
2547 "d/a.design:1:14: textAlign: expected \"left\", \"center\" or \"right\"",
2548 ),
2549 (
2550 "<Frame><Text><Rect /></Text></Frame>",
2551 "d/a.design:1:14: Text holds text only",
2552 ),
2553 (
2554 "<Frame>hello</Frame>",
2555 "d/a.design:1:8: text belongs in a Text, not a Frame",
2556 ),
2557 (
2558 "<Frame><Rect><Rect /></Rect></Frame>",
2559 "d/a.design:1:14: Rect takes no children",
2560 ),
2561 (
2562 "<Frame><Text>{nope}</Text></Frame>",
2563 "d/a.design:1:14: no text prop \"nope\"",
2564 ),
2565 (
2566 "<Frame gap={nope} />",
2567 "d/a.design:1:8: gap: no prop \"nope\"",
2568 ),
2569 (
2570 "<Frame><Vector viewBox=\"0 0 24 24\" /></Frame>",
2571 "d/a.design:1:8: Vector needs a path, d=\"M…\"",
2572 ),
2573 (
2574 "<Frame><Vector d=\"M0 0 Q\" /></Frame>",
2575 "d/a.design:1:16: d: not SVG path data: Unexpected EOF",
2576 ),
2577 (
2578 "<Frame><Vector d=\"M0 0L1 1\" viewBox=\"0 0 24\" /></Frame>",
2579 "d/a.design:1:29: viewBox: expected \"minX minY width height\"",
2580 ),
2581 (
2582 "<Frame><Image /></Frame>",
2583 "d/a.design:1:8: Image needs a file, src=\"…\"",
2584 ),
2585 (
2586 "<Frame><Image src=\"./gone.png\" /></Frame>",
2587 "d/a.design:1:15: src: ./gone.png — no such file",
2588 ),
2589 (
2590 "<Frame><Image src=\"../../x.png\" /></Frame>",
2591 "d/a.design:1:15: src: ../../x.png — outside the served folder",
2592 ),
2593 (
2594 "<Frame><Image src=\"./notes.txt\" /></Frame>",
2595 "d/a.design:1:15: src: ./notes.txt is not a PNG or JPEG file",
2596 ),
2597 (
2598 "<Frame><Rect clip /></Frame>",
2599 "d/a.design:1:14: Rect takes no attribute clip",
2600 ),
2601 (
2602 "<Frame fill=\"linear-gradient(2000000deg, #fff 0%, #000 100%)\" />",
2603 "d/a.design:1:8: fill: `2000000` is too large: numbers go up to 1000000",
2604 ),
2605 (
2606 "<Frame fill=\"linear-gradient(90deg, #fff -2000000%, #000 100%)\" />",
2607 "d/a.design:1:8: fill: `-2000000` is too small: numbers go down to -1000000",
2608 ),
2609 (
2610 "<Frame fill=\"linear-gradient(NaNdeg, #fff 0%, #000 100%)\" />",
2611 "d/a.design:1:8: fill: `NaN` is not a number",
2612 ),
2613 (
2614 "<Frame fill=\"linear-gradient(90deg, #fff 0%, #000 inf%)\" />",
2615 "d/a.design:1:8: fill: `inf` is not a number",
2616 ),
2617 (
2618 "<Frame><Vector d=\"M0 0L1 1\" viewBox=\"0 0 2000000 24\" /></Frame>",
2619 "d/a.design:1:29: viewBox: `2000000` is too large: numbers go up to 1000000",
2620 ),
2621 (
2622 "<Frame><Vector d=\"M0 0L1 1\" viewBox=\"0 0 inf 24\" /></Frame>",
2623 "d/a.design:1:29: viewBox: `inf` is not a number",
2624 ),
2625 (
2626 "<Frame><Vector d=\"M0 0L2000000 0\" /></Frame>",
2627 "d/a.design:1:16: d: `2000000` is too large: numbers go up to 1000000",
2628 ),
2629 (
2630 "<Frame><Vector d=\"M0 0L1e999 0\" /></Frame>",
2631 "d/a.design:1:16: d: `1e999` is too large: numbers go up to 1000000",
2632 ),
2633 (
2634 "<Frame><Vector d=\"M0 0L-3e6 0\" /></Frame>",
2635 "d/a.design:1:16: d: `-3e6` is too small: numbers go down to -1000000",
2636 ),
2637 (
2638 "import X from \"../../x.design\"\n<Frame />",
2639 "d/a.design:1:1: import ../../x.design — outside the served folder",
2640 ),
2641 (
2642 "import B from \"./b.design\"\n<Frame><B fill=\"#fff\" /></Frame>",
2643 "d/a.design:2:11: B has no prop \"fill\"",
2644 ),
2645 (
2646 "import B from \"./b.design\"\n<Frame><B>x</B></Frame>",
2647 "d/a.design:2:8: B takes no children",
2648 ),
2649 (
2650 "<Frame><Blob /></Frame>",
2651 "d/a.design:1:8: unknown element Blob",
2652 ),
2653 ];
2654 for (source, expected) in cases {
2655 assert_eq!(problems(source), vec![expected.to_string()], "{source}");
2656 }
2657 }
2658
2659 #[test]
2662 fn an_image_that_does_not_decode_is_a_problem() {
2663 let mut broken = b"\x89PNG\r\n\x1a\n".to_vec();
2664 broken.extend(b"not a PNG past its signature");
2665 let shirt = std::fs::read(concat!(
2666 env!("CARGO_MANIFEST_DIR"),
2667 "/../docs/designs/checkout/shirt.jpg"
2668 ))
2669 .unwrap();
2670 let truncated = shirt[..shirt.len() / 2].to_vec();
2671 for (name, data, expected) in [
2672 (
2673 "broken.png",
2674 broken,
2675 "d/a.design:2:8: src: ./broken.png is a PNG that doesn't decode: ",
2676 ),
2677 (
2678 "shirt.jpg",
2679 truncated,
2680 "d/a.design:2:8: src: ./shirt.jpg is a JPEG that doesn't decode: ",
2681 ),
2682 ] {
2683 let source =
2684 format!("<Frame>\n<Image src=\"./{name}\" width={{50}} height={{50}} />\n</Frame>");
2685 let fs = Mem([
2686 ("d/a.design".to_string(), source.into_bytes()),
2687 (format!("d/{name}"), data),
2688 ]
2689 .into());
2690 let found: Vec<String> = resolve(&fs, "d/a.design")
2691 .unwrap_err()
2692 .iter()
2693 .map(|p| p.to_string())
2694 .collect();
2695 assert_eq!(found.len(), 1, "{found:?}");
2696 assert!(found[0].starts_with(expected), "{found:?}");
2697 }
2698 let fs = Mem([
2700 (
2701 "a.design".to_string(),
2702 br#"<Frame><Image src="./s.jpg" /></Frame>"#.to_vec(),
2703 ),
2704 ("s.jpg".to_string(), shirt),
2705 ]
2706 .into());
2707 let scene = resolve(&fs, "a.design").unwrap();
2708 let image = scene.root.children[0].image.as_ref().unwrap();
2709 let file = &scene.images[&image.id];
2710 assert_eq!((file.width, file.height), (160, 160));
2711 assert_eq!((file.pixels_width, file.pixels_height), (160, 160));
2712 }
2713
2714 #[test]
2718 fn a_large_image_file_is_resampled_once_to_fit() {
2719 let mut data = Vec::new();
2720 let mut e = png::Encoder::new(&mut data, 9000, 10);
2721 e.set_color(png::ColorType::Rgba);
2722 let mut w = e.write_header().unwrap();
2723 let row: Vec<u8> = (0..9000)
2725 .flat_map(|x| {
2726 if x <= 4500 {
2727 [255, 0, 0, 255]
2728 } else {
2729 [0, 0, 255, 255]
2730 }
2731 })
2732 .collect();
2733 w.write_image_data(&row.repeat(10)).unwrap();
2734 drop(w);
2735 let file = ImageFile::new(data).unwrap();
2736 assert_eq!((file.width, file.height), (9000, 10));
2737 assert_eq!((file.pixels_width, file.pixels_height), (4096, 5));
2738 let px = |x: usize| &file.pixels[4 * x..][..4];
2739 assert_eq!(px(0), [255, 0, 0, 255]);
2740 assert_eq!(px(4095), [0, 0, 255, 255]);
2741 assert_eq!(px(2048), [116, 0, 139, 255]);
2743 let (same, w, h) = fit(&[1, 2, 3, 4], 1, 1, MAX_IMAGE_SIDE);
2745 assert_eq!((same, w, h), (vec![1, 2, 3, 4], 1, 1));
2746 let (mixed, _, _) = fit(&[255, 255, 255, 0, 0, 0, 255, 255], 2, 1, 1);
2748 assert_eq!(mixed, [0, 0, 255, 128]);
2749 }
2750
2751 #[test]
2752 fn an_interaction_is_relative_to_the_file_that_wrote_it() {
2753 let fs = files(&[
2754 (
2755 "app/a.design",
2756 "import B from \"../lib/b.design\"\n<Frame><B /><B onClick=\"overlay ./c.design\" /></Frame>",
2757 ),
2758 ("app/c.design", "<Frame />"),
2759 (
2760 "lib/b.design",
2761 "<Frame onClick=\"navigate ./next.design\" />",
2762 ),
2763 ("lib/next.design", "<Frame />"),
2764 ]);
2765 let root = resolve(&fs, "app/a.design").unwrap().root;
2766 assert_eq!(
2767 root.children[0].on_click.as_deref(),
2768 Some("navigate lib/next.design")
2769 );
2770 assert_eq!(
2771 root.children[1].on_click.as_deref(),
2772 Some("overlay app/c.design")
2773 );
2774 }
2775
2776 #[test]
2777 fn an_interaction_to_nowhere_is_a_problem() {
2778 for (click, expected) in [
2779 (
2780 "navigate ./nope.design",
2781 "d/a.design:1:8: onClick: navigate ./nope.design — no such file",
2782 ),
2783 (
2784 "overlay ../../x.design",
2785 "d/a.design:1:8: onClick: overlay ../../x.design — outside the served folder",
2786 ),
2787 ] {
2788 assert_eq!(
2789 problems(&format!("<Frame onClick=\"{click}\" />")),
2790 vec![expected.to_string()],
2791 "{click}"
2792 );
2793 }
2794 }
2795
2796 #[test]
2797 fn a_valid_design_has_no_problems() {
2798 assert_eq!(
2799 problems(
2800 r##"import B from "./b.design"
2801<Frame fill="linear-gradient(90deg, #ffffff 0%, #000000ff 100%)">
2802 <B label="Go" x={4} y={5} width={80} height="hug" name="Go" onClick="back" />
2803 <Vector d="M0 0L24 24" viewBox="0 0 24 24" stroke="#000" />
2804 <Frame radius={8} clip><Image src="./photo.png" radius={[1, 2, 3, 4]} /></Frame>
2805</Frame>"##
2806 ),
2807 Vec::<String>::new()
2808 );
2809 }
2810
2811 #[test]
2812 fn attributes_take_their_defaults() {
2813 let fs = files(&[("a.design", "<Frame><Text>a</Text><Rect /></Frame>")]);
2814 let root = resolve(&fs, "a.design").unwrap().root;
2815 assert_eq!((root.width, root.height), (Size::Hug, Size::Hug));
2816 assert_eq!(
2817 (root.layout, root.gap, root.padding),
2818 (Layout::None, 0.0, [0.0; 4])
2819 );
2820 assert_eq!((root.align, root.justify), (Align::Start, Align::Start));
2821 assert_eq!(
2822 (root.opacity, root.fill.clone(), root.clip),
2823 (1.0, None, false)
2824 );
2825 let text = root.children[0].text.clone().unwrap();
2826 assert_eq!(
2827 text,
2828 TextStyle {
2829 content: "a".into(),
2830 ..default_text()
2831 }
2832 );
2833 assert_eq!(
2834 (text.size, text.weight, text.line_height, text.align),
2835 (14.0, 400, 1.4, TextAlign::Left)
2836 );
2837 assert_eq!(root.children[1].stroke_width, 1.0);
2838 assert_eq!(root.children[1].radius, [0.0; 4]);
2839 }
2840
2841 #[test]
2842 fn values_parse_into_their_types() {
2843 let fs = Mem(
2844 [
2845 (
2846 "a.design".to_string(),
2847 br##"<Frame padding={[1, 2]} radius={[1, 2, 3, 4]} fill="linear-gradient(135deg, #f00 0%, #0000ff80 100%)">
2848 <Frame padding={[1, 2, 3, 4]} fill="#abcd" />
2849 <Vector d="M2 4L10 20" />
2850 <Image src="./p.png" />
2851</Frame>"##
2852 .to_vec(),
2853 ),
2854 ("p.png".to_string(), png()),
2855 ]
2856 .into(),
2857 );
2858 let scene = resolve(&fs, "a.design").unwrap();
2859 let root = &scene.root;
2860 assert_eq!(root.padding, [1.0, 2.0, 1.0, 2.0]);
2861 assert_eq!(root.radius, [1.0, 2.0, 3.0, 4.0]);
2862 let c = |r, g, b, a| Color::fixed(Rgba { r, g, b, a });
2863 assert_eq!(
2864 root.fill,
2865 Some(Fill::Linear {
2866 angle: 135.0,
2867 stops: vec![(c(255, 0, 0, 255), 0.0), (c(0, 0, 255, 0x80), 1.0)]
2868 })
2869 );
2870 assert_eq!(root.children[0].padding, [1.0, 2.0, 3.0, 4.0]);
2871 assert_eq!(
2872 root.children[0].fill,
2873 Some(Fill::Solid(c(0xaa, 0xbb, 0xcc, 0xdd)))
2874 );
2875 assert_eq!(
2877 root.children[1].vector.as_ref().unwrap().view_box,
2878 [2.0, 4.0, 8.0, 16.0]
2879 );
2880 assert_eq!(root.children[2].image.as_ref().unwrap().src, "p.png");
2881 assert_eq!(scene.files, vec!["a.design", "p.png"]);
2882 }
2883
2884 #[test]
2885 fn paths_stay_in_the_served_folder() {
2886 assert_eq!(
2887 join("a/b.design", "./c.design").as_deref(),
2888 Some("a/c.design")
2889 );
2890 assert_eq!(
2891 join("a/b.design", "../c.design").as_deref(),
2892 Some("c.design")
2893 );
2894 assert_eq!(join("a/b.design", "../../c.design"), None);
2895 assert_eq!(
2896 join("/a/b.design", "x/../y.png").as_deref(),
2897 Some("a/y.png")
2898 );
2899 }
2900
2901 #[test]
2902 fn an_interaction_leads_only_to_a_design() {
2903 for (click, expected) in [
2904 (
2905 "navigate ./notes.txt",
2906 "d/a.design:1:8: onClick: navigate ./notes.txt — a design is a .design file",
2907 ),
2908 (
2909 "overlay ./photo.png",
2910 "d/a.design:1:8: onClick: overlay ./photo.png — a design is a .design file",
2911 ),
2912 ] {
2913 assert_eq!(
2914 problems(&format!("<Frame onClick=\"{click}\" />")),
2915 vec![expected.to_string()],
2916 "{click}"
2917 );
2918 }
2919 assert!(problems(r#"<Frame onClick="navigate ./b.design" />"#).is_empty());
2920 }
2921
2922 #[test]
2923 fn sizes_and_spacing_are_never_negative() {
2924 for (attr, element) in [
2925 ("gap={-4}", "Frame"),
2926 ("padding={-1}", "Frame"),
2927 ("padding={[4, -2]}", "Frame"),
2928 ("radius={-3}", "Rect"),
2929 ("radius={[1, 2, -3, 4]}", "Rect"),
2930 ("strokeWidth={-1}", "Rect"),
2931 ("size={-12}", "Text"),
2932 ("lineHeight={-1}", "Text"),
2933 ] {
2934 let found = problems(&format!("<Frame><{element} {attr} /></Frame>"));
2935 assert_eq!(found.len(), 1, "{attr}: {found:?}");
2936 assert!(found[0].contains("of 0 or more"), "{attr}: {found:?}");
2937 }
2938 }
2939
2940 #[test]
2941 fn an_import_name_is_unique_and_not_an_element() {
2942 let found = problems(
2943 r#"import B from "./b.design"
2944import B from "./b.design"
2945<Frame />"#,
2946 );
2947 assert_eq!(found, ["d/a.design:2:1: import B — B is already imported"]);
2948 let found = problems(
2949 r#"import Frame from "./b.design"
2950<Frame />"#,
2951 );
2952 assert_eq!(
2953 found,
2954 ["d/a.design:1:1: import Frame — Frame is a built-in element"]
2955 );
2956 }
2957
2958 #[test]
2959 fn justify_names_all_it_takes() {
2960 assert_eq!(
2961 problems(r#"<Frame justify="middle" />"#),
2962 [
2963 "d/a.design:1:8: justify: expected \"start\", \"center\", \"end\" or \"space-between\""
2964 ]
2965 );
2966 assert_eq!(
2967 problems(r#"<Frame align="middle" />"#),
2968 ["d/a.design:1:8: align: expected \"start\", \"center\" or \"end\""]
2969 );
2970 }
2971
2972 #[test]
2973 fn values_that_would_be_ignored_are_problems() {
2974 let found =
2976 problems(r#"<Frame layout="row"><Rect width={10} height={10} x={5} /></Frame>"#);
2977 assert!(
2978 found
2979 .iter()
2980 .any(|p| p.contains("x: places a child only in a frame with layout=\"none\"")),
2981 "{found:?}"
2982 );
2983 let found = problems(
2985 r#"import B from "./b.design"
2986<Frame layout="row"><B y={3} /></Frame>"#,
2987 );
2988 assert!(
2989 found.iter().any(|p| p.contains("y: places a child only")),
2990 "{found:?}"
2991 );
2992 let found = problems(r#"<Frame width={-1} height={-2} />"#);
2994 assert_eq!(found.len(), 2, "{found:?}");
2995 assert!(
2996 found[0].contains("width: expected pixels of 0 or more"),
2997 "{found:?}"
2998 );
2999 let found = problems(r##"<Frame fill="#ff0000" fill="#00ff00" />"##);
3001 assert!(
3002 found.iter().any(|p| p.contains("fill is given twice")),
3003 "{found:?}"
3004 );
3005 let found = problems(
3006 r#"import B from "./b.design"
3007<Frame><B label="a" label="b" /></Frame>"#,
3008 );
3009 assert!(
3010 found.iter().any(|p| p.contains("label is given twice")),
3011 "{found:?}"
3012 );
3013 let found = problems(
3015 r#"import P from "./photo.png"
3016<Frame />"#,
3017 );
3018 assert_eq!(
3019 found,
3020 ["d/a.design:1:1: import ./photo.png — a design is a .design file"]
3021 );
3022 assert!(problems(r#"<Frame><Rect width={1} height={1} x={5} y={5} /></Frame>"#).is_empty());
3024 }
3025
3026 const THEMES: &str = r##"kind: themes
3027light:
3028 surface: "#ffffff"
3029 text: "#000"
3030dark:
3031 surface: "#0f172a"
3032 text: "#f1f5f9"
3033"##;
3034
3035 fn rgb(r: u8, g: u8, b: u8) -> Rgba {
3036 Rgba { r, g, b, a: 255 }
3037 }
3038
3039 #[test]
3040 fn a_theme_color_has_a_value_in_each_theme() {
3041 let fs = files(&[
3042 ("themes.yml", THEMES),
3043 (
3044 "a.design",
3045 r##"<Frame fill={theme.surface} stroke="#ff0000"
3046 shadow={{ y: 2, color: theme.text }}>
3047 <Text color={theme.text}>Hi</Text>
3048 <Rect width={1} height={1} fill="linear-gradient(90deg, theme.surface 0%, #ff0000 100%)" />
3049</Frame>"##,
3050 ),
3051 ]);
3052 let scene = resolve(&fs, "a.design").unwrap();
3053 assert!(scene.themed);
3054 let surface = Color {
3055 light: rgb(255, 255, 255),
3056 dark: rgb(0x0f, 0x17, 0x2a),
3057 };
3058 let text = Color {
3059 light: rgb(0, 0, 0),
3060 dark: rgb(0xf1, 0xf5, 0xf9),
3061 };
3062 let root = &scene.root;
3063 assert_eq!(root.fill, Some(Fill::Solid(surface)));
3064 assert_eq!(root.stroke, Some(Color::fixed(rgb(255, 0, 0))));
3066 assert_eq!(root.shadow.as_ref().unwrap().color, text);
3067 assert_eq!(root.children[0].text.as_ref().unwrap().color, text);
3068 assert_eq!(
3069 root.children[1].fill,
3070 Some(Fill::Linear {
3071 angle: 90.0,
3072 stops: vec![(surface, 0.0), (Color::fixed(rgb(255, 0, 0)), 1.0)]
3073 })
3074 );
3075 assert_eq!(scene.files, vec!["a.design", "themes.yml"]);
3076 let plain = resolve(
3078 &files(&[("themes.yml", THEMES), ("b.design", "<Frame />")]),
3079 "b.design",
3080 )
3081 .unwrap();
3082 assert!(!plain.themed);
3083 assert_eq!(plain.files, vec!["b.design"]);
3084 }
3085
3086 struct Asked(HashMap<String, String>, std::cell::RefCell<Vec<String>>);
3088
3089 impl Files for Asked {
3090 fn bytes(&self, path: &str) -> Option<Vec<u8>> {
3091 self.1.borrow_mut().push(path.to_string());
3092 self.0.bytes(path)
3093 }
3094 fn list(&self, path: &str) -> Option<Vec<String>> {
3095 self.0.list(path)
3096 }
3097 }
3098
3099 #[test]
3100 fn the_nearest_themes_override_those_above_name_by_name() {
3101 let fs = Asked(
3102 files(&[
3103 ("themes.yml", THEMES),
3104 (
3105 "app/themes.yml",
3106 "kind: themes\nlight:\n text: \"#111111\"\ndark: {}\n",
3107 ),
3108 ("app/screens/themes.yml", "kind: tokens\nlight: 1\n"),
3110 (
3111 "app/screens/a.design",
3112 "<Frame fill={theme.surface}><Text color={theme.text}>Hi</Text></Frame>",
3113 ),
3114 ]),
3115 Default::default(),
3116 );
3117 let scene = resolve(&fs, "app/screens/a.design").unwrap();
3118 assert_eq!(
3119 scene.root.fill,
3120 Some(Fill::Solid(Color {
3121 light: rgb(255, 255, 255),
3122 dark: rgb(0x0f, 0x17, 0x2a)
3123 }))
3124 );
3125 assert_eq!(
3127 scene.root.children[0].text.as_ref().unwrap().color,
3128 Color {
3129 light: rgb(0x11, 0x11, 0x11),
3130 dark: rgb(0xf1, 0xf5, 0xf9)
3131 }
3132 );
3133 let mut asked = fs.1.borrow().clone();
3136 asked.retain(|p| p.ends_with("themes.yml"));
3137 assert_eq!(
3138 asked,
3139 vec!["app/screens/themes.yml", "app/themes.yml", "themes.yml"]
3140 .into_iter()
3141 .rev()
3142 .collect::<Vec<_>>()
3143 );
3144 }
3145
3146 #[test]
3147 fn a_theme_color_belongs_to_the_file_that_wrote_it() {
3148 let dark_red =
3149 "kind: themes\nlight:\n accent: \"#ff0000\"\ndark:\n accent: \"#880000\"\n";
3150 let blue = "kind: themes\nlight:\n accent: \"#0000ff\"\ndark:\n accent: \"#000088\"\n";
3151 let fs = files(&[
3152 ("kit/themes.yml", dark_red),
3153 (
3154 "kit/button.design",
3155 "<Frame props={{ fill: theme.accent }} fill={fill} />",
3156 ),
3157 ("app/themes.yml", blue),
3158 (
3159 "app/screen.design",
3160 "import Button from \"../kit/button.design\"\n<Frame layout=\"row\">\n <Button />\n <Button fill={theme.accent} />\n</Frame>",
3161 ),
3162 ]);
3163 let scene = resolve(&fs, "app/screen.design").unwrap();
3164 let fill = |n: usize| scene.root.children[n].fill.clone();
3165 assert_eq!(
3167 fill(0),
3168 Some(Fill::Solid(Color {
3169 light: rgb(255, 0, 0),
3170 dark: rgb(0x88, 0, 0)
3171 }))
3172 );
3173 assert_eq!(
3174 fill(1),
3175 Some(Fill::Solid(Color {
3176 light: rgb(0, 0, 255),
3177 dark: rgb(0, 0, 0x88)
3178 }))
3179 );
3180 assert!(scene.themed);
3181 }
3182
3183 #[test]
3184 fn a_theme_color_that_isnt_there_is_a_problem_at_the_reference() {
3185 let one_sided = "kind: themes\nlight:\n surface: \"#fff\"\n edge: \"#000\"\ndark:\n surface: \"#000\"\n";
3186 let design = r##"<Frame fill={theme.srface}>
3187 <Rect width={1} height={1} fill={theme.edge} />
3188 <Rect width={1} height={1} fill="linear-gradient(90deg, #000 0%, theme.nope 100%)" />
3189 <Text>{theme.surface}</Text>
3190</Frame>"##;
3191 let fs = files(&[("themes.yml", one_sided), ("a.design", design)]);
3192 assert_eq!(
3193 problems_in(&fs, "a.design"),
3194 vec![
3195 "a.design:1:14: theme.srface: the themes have no srface; they have edge, surface",
3196 "a.design:2:36: theme.edge: only the light theme has edge; a name needs a color in both",
3197 "a.design:3:68: theme.nope: the themes have no nope; they have edge, surface",
3198 "a.design:4:10: theme.surface is a color, not text: give it to color={theme.surface}",
3199 ]
3200 );
3201 let fs = files(&[("a/a.design", "<Frame fill={theme.surface} />")]);
3203 assert_eq!(
3204 problems_in(&fs, "a/a.design"),
3205 vec![
3206 "a/a.design:1:14: theme.surface: no themes.yml with kind: themes in this design's folder or above it"
3207 ]
3208 );
3209 }
3210
3211 #[test]
3212 fn a_malformed_themes_file_is_a_problem_at_its_line() {
3213 let broken = r##"kind: themes
3214# the light one
3215light:
3216 surface: "#ffff"
3217 text: "#gg0000"
3218 edge: #000
3219 9lives: "#000"
3220dark: "#000"
3221colour: red
3222"##;
3223 let fs = files(&[
3224 ("themes.yml", broken),
3225 ("a.design", "<Frame fill={theme.surface} />"),
3226 ]);
3227 assert_eq!(
3228 problems_in(&fs, "a.design"),
3229 vec![
3230 "themes.yml:5:3: light.text: #gg0000 is not a color",
3231 "themes.yml:6:3: light.edge: no color: write it in quotes, \"#rrggbb\", as YAML reads # as a comment",
3232 "themes.yml:7:3: light.9lives: a name holds letters, digits, _ and -, and starts with a letter",
3233 "themes.yml:8:1: dark: expected a map of names to colors, like surface: \"#ffffff\"",
3234 "themes.yml:9:1: colour: a themes file has kind, fonts, light and dark only",
3235 "a.design:1:14: theme.surface: only the light theme has surface; a name needs a color in both",
3236 ]
3237 );
3238 let fs = files(&[
3239 ("themes.yml", "kind: themes\nlight: {}\n"),
3240 ("a.design", "<Frame fill={theme.surface} />"),
3241 ]);
3242 assert_eq!(
3243 problems_in(&fs, "a.design")[0],
3244 "themes.yml:1:1: a themes file has a light and a dark theme; this one has no dark"
3245 );
3246 let fs = files(&[
3247 ("themes.yml", "kind: themes\nlight: [\n"),
3248 ("a.design", "<Frame fill={theme.surface} />"),
3249 ]);
3250 assert!(
3251 problems_in(&fs, "a.design")[0]
3252 .starts_with("themes.yml:3:1: this themes file isn't valid YAML: "),
3253 "{:?}",
3254 problems_in(&fs, "a.design")
3255 );
3256 let fs = files(&[("themes.yml", "light: [\n"), ("a.design", "<Frame />")]);
3258 assert!(resolve(&fs, "a.design").is_ok());
3259 }
3260
3261 #[test]
3262 fn theme_is_no_props_or_imports_name() {
3263 let fs = files(&[
3264 ("themes.yml", THEMES),
3265 ("b.design", "<Frame />"),
3266 (
3267 "a.design",
3268 "import theme from \"./b.design\"\n<Frame props={{ theme: \"#fff\" }} fill={theme} />",
3269 ),
3270 ]);
3271 assert_eq!(
3272 problems_in(&fs, "a.design"),
3273 vec![
3274 "a.design:1:1: import theme — theme is the name of the design's themes",
3275 "a.design:2:8: props: theme is the name of the design's themes, not a prop",
3276 "a.design:2:34: fill: no prop \"theme\"",
3277 ]
3278 );
3279 let fs = files(&[
3281 ("themes.yml", THEMES),
3282 ("a.design", "<Frame fill=\"theme.surface\" />"),
3283 ]);
3284 assert_eq!(
3285 problems_in(&fs, "a.design"),
3286 vec![
3287 "a.design:1:8: fill: theme.surface is a theme color: write it in braces, {theme.surface}, not in quotes"
3288 ]
3289 );
3290 }
3291
3292 fn figtree() -> Vec<u8> {
3293 std::fs::read(concat!(
3294 env!("CARGO_MANIFEST_DIR"),
3295 "/../docs/designs/checkout/fonts/Figtree/Figtree-Variable.ttf"
3296 ))
3297 .unwrap()
3298 }
3299
3300 fn inter(face: &str) -> Vec<u8> {
3301 std::fs::read(format!(
3302 "{}/fonts/Inter-{face}.ttf",
3303 env!("CARGO_MANIFEST_DIR")
3304 ))
3305 .unwrap()
3306 }
3307
3308 fn mem(list: &[(&str, Vec<u8>)]) -> Mem {
3309 Mem(list
3310 .iter()
3311 .map(|(p, b)| (p.to_string(), b.clone()))
3312 .collect())
3313 }
3314
3315 fn fonts_of(node: &Node, out: &mut Vec<Option<FontId>>) {
3316 if let Some(t) = &node.text {
3317 out.push(t.font);
3318 }
3319 for c in &node.children {
3320 fonts_of(c, out);
3321 }
3322 }
3323
3324 fn named(fonts: &str) -> Vec<u8> {
3326 format!("kind: themes\nfonts:\n{fonts}light: {{}}\ndark: {{}}\n").into_bytes()
3327 }
3328
3329 #[test]
3335 fn a_text_is_set_in_the_font_it_names_or_inter() {
3336 let fig = FontId::of(&figtree());
3337 let bold = FontId::of(&inter("Bold"));
3338 let fs = mem(&[
3339 (
3340 "themes.yml",
3341 named(" figtree: ./fonts/Figtree\n heading: ./fonts/Figtree\n"),
3342 ),
3343 ("fonts/Figtree/Figtree-Variable.ttf", figtree()),
3344 ("fonts/Figtree/OFL.txt", b"licence".to_vec()),
3345 ("fonts/Inter/Inter-Bold.ttf", inter("Bold")),
3346 ("fonts/Inter/Inter-Regular.ttf", inter("Regular")),
3347 ("app/themes.yml", named(" heading: ../fonts/Inter\n")),
3348 (
3349 "app/a.design",
3350 br#"<Frame><Text font="figtree">a</Text><Text font="heading" weight={700}>b</Text><Text>c</Text></Frame>"#
3351 .to_vec(),
3352 ),
3353 ]);
3354 let scene = resolve(&fs, "app/a.design").unwrap();
3355 let mut found = Vec::new();
3356 fonts_of(&scene.root, &mut found);
3357 assert_eq!(found, vec![Some(fig), Some(bold), None]);
3358 assert_eq!(scene.fonts.len(), 2);
3359 assert!(!scene.themed, "a font is no theme color");
3360 let plain = files(&[("a.design", "<Frame><Text>a</Text></Frame>")]);
3361 assert!(resolve(&plain, "a.design").unwrap().fonts.is_empty());
3362 }
3363
3364 #[test]
3368 fn weight_picks_the_face_and_a_scene_carries_only_those_used() {
3369 let fs = mem(&[
3370 ("themes.yml", named(" inter: ./f\n fig: ./v.ttf\n")),
3371 ("f/Inter-Regular.ttf", inter("Regular")),
3372 ("f/Inter-Medium.ttf", inter("Medium")),
3373 ("f/Inter-SemiBold.ttf", inter("SemiBold")),
3374 ("f/Inter-Bold.ttf", inter("Bold")),
3375 ("v.ttf", figtree()),
3376 (
3377 "a.design",
3378 br#"<Frame><Text font="inter" weight={600}>a</Text><Text font="inter" weight={600}>b</Text><Text font="inter">c</Text><Text font="fig" weight={800}>d</Text><Text font="fig" weight={300}>e</Text></Frame>"#
3379 .to_vec(),
3380 ),
3381 ]);
3382 let scene = resolve(&fs, "a.design").unwrap();
3383 let mut found = Vec::new();
3384 fonts_of(&scene.root, &mut found);
3385 let (semi, regular, fig) = (
3386 FontId::of(&inter("SemiBold")),
3387 FontId::of(&inter("Regular")),
3388 FontId::of(&figtree()),
3389 );
3390 assert_eq!(
3391 found,
3392 vec![Some(semi), Some(semi), Some(regular), Some(fig), Some(fig)]
3393 );
3394 assert_eq!(scene.fonts.len(), 3, "medium and bold aren't used");
3395 }
3396
3397 fn font_problems(list: &[(&str, Vec<u8>)], path: &str) -> Vec<String> {
3398 match resolve(&mem(list), path) {
3399 Ok(_) => Vec::new(),
3400 Err(p) => p.iter().map(|p| p.to_string()).collect(),
3401 }
3402 }
3403
3404 #[test]
3407 fn a_weight_the_font_lacks_is_a_problem_listing_those_it_has() {
3408 let design = |text: &str| {
3409 vec![
3410 ("themes.yml", named(" fig: ./v.ttf\n bold: ./b\n")),
3411 ("v.ttf", figtree()),
3412 ("b/Inter-Bold.ttf", inter("Bold")),
3413 ("a.design", format!("<Frame>{text}</Frame>").into_bytes()),
3414 ]
3415 };
3416 for (text, expected) in [
3417 (
3418 r#"<Text weight={300}>a</Text>"#,
3419 "a.design:1:14: weight: Inter has no 300; it has 400, 500, 600 and 700",
3420 ),
3421 (
3422 r#"<Text font="fig" weight={200}>a</Text>"#,
3423 "a.design:1:25: weight: Figtree has no 200; it has 300, 400, 500, 600, 700, 800 and 900",
3424 ),
3425 (
3426 r#"<Text font="bold">a</Text>"#,
3427 "a.design:1:8: weight: Inter has no 400; it has 700",
3428 ),
3429 ] {
3430 assert_eq!(
3431 font_problems(&design(text), "a.design"),
3432 vec![expected],
3433 "{text}"
3434 );
3435 }
3436 }
3437
3438 #[test]
3442 fn a_font_that_cant_be_used_is_a_problem_at_its_entry() {
3443 assert_eq!(
3444 font_problems(
3445 &[
3446 ("themes.yml", named(" fig: ./v.ttf\n body: ./v.ttf\n")),
3447 ("v.ttf", figtree()),
3448 (
3449 "a.design",
3450 br#"<Frame><Text font="figg">a</Text></Frame>"#.to_vec()
3451 ),
3452 ],
3453 "a.design"
3454 ),
3455 vec!["a.design:1:14: font: no font named figg; the themes name body, fig"]
3456 );
3457 assert_eq!(
3458 font_problems(
3459 &[(
3460 "a.design",
3461 br#"<Frame><Text font="fig">a</Text></Frame>"#.to_vec()
3462 )],
3463 "a.design"
3464 ),
3465 vec![
3466 "a.design:1:14: font: no font named fig; no themes.yml in this design's folder or above it names fonts"
3467 ]
3468 );
3469 assert_eq!(
3470 font_problems(
3471 &[(
3472 "a.design",
3473 br#"<Frame><Text font={3}>a</Text></Frame>"#.to_vec()
3474 )],
3475 "a.design"
3476 ),
3477 vec![
3478 "a.design:1:14: font: expected the name of a font the themes name, like \"figtree\""
3479 ]
3480 );
3481 let mut big = vec![0, 1, 0, 0];
3482 big.resize(fonts::MAX_FONT_BYTES + 1, 0);
3483 let mut copy = inter("Regular");
3484 copy.push(0);
3485 let list = vec![
3486 ("d/v.ttf", figtree()),
3487 ("d/notes.txt", b"hi".to_vec()),
3488 ("d/web.woff2", b"wOF2rest".to_vec()),
3489 ("d/web.woff", b"wOFFrest".to_vec()),
3490 ("d/all.ttc", b"ttcfrest".to_vec()),
3491 ("d/big.ttf", big),
3492 ("d/broken.ttf", b"\0\x01\0\0broken".to_vec()),
3493 ("d/empty/OFL.txt", b"licence".to_vec()),
3494 ("d/mixed/Figtree.ttf", figtree()),
3495 ("d/mixed/Inter.ttf", inter("Regular")),
3496 ("d/twice/a.ttf", inter("Regular")),
3497 ("d/twice/b.ttf", copy),
3498 ("d/bad/a.ttf", b"nope".to_vec()),
3499 ];
3500 for (path, expected) in [
3501 ("./nope", "— no such file or folder"),
3502 ("../../up", "— outside the served folder"),
3503 (
3504 "./notes.txt",
3505 "is not a font: devpage reads .ttf and .otf files",
3506 ),
3507 (
3508 "./web.woff2",
3509 "is a WOFF2 file, which devpage doesn't read — decompress it to a .ttf or .otf",
3510 ),
3511 (
3512 "./web.woff",
3513 "is a WOFF file, which devpage doesn't read — decompress it to a .ttf or .otf",
3514 ),
3515 (
3516 "./all.ttc",
3517 "is a collection of fonts; name one font's own .ttf or .otf file",
3518 ),
3519 (
3520 "./big.ttf",
3521 "is 4.0 MB; a font file is at most 4 MB — subset it to the characters the design shows",
3522 ),
3523 (
3524 "./broken.ttf",
3525 "is a .ttf or .otf file that doesn't read as a font",
3526 ),
3527 ("./empty", "holds no .ttf or .otf file"),
3528 (
3529 "./mixed",
3530 "holds more than one family: Figtree (Figtree.ttf), Inter (Inter.ttf)",
3531 ),
3532 ("./twice", "holds two files of weight 400: a.ttf and b.ttf"),
3533 (
3534 "./bad",
3535 "holds a.ttf, which is not a font: devpage reads .ttf and .otf files",
3536 ),
3537 ] {
3538 let mut files = list.clone();
3539 files.push(("d/themes.yml", named(&format!(" x: {path}\n"))));
3540 files.push((
3541 "d/a.design",
3542 br#"<Frame><Text font="x">a</Text><Text font="x">b</Text></Frame>"#.to_vec(),
3543 ));
3544 assert_eq!(
3545 font_problems(&files, "d/a.design"),
3546 vec![format!("d/themes.yml:3:3: fonts.x: {path} {expected}")]
3547 );
3548 }
3549 }
3550
3551 #[test]
3555 fn a_themes_files_fonts_are_names_to_paths() {
3556 let problems = font_problems(
3557 &[
3558 (
3559 "themes.yml",
3560 b"kind: themes\nfonts:\n Figtree: ./v.ttf\n my_font: ./v.ttf\n ok: [1]\nfont: ./v.ttf\nlight: {}\ndark: {}\n"
3561 .to_vec(),
3562 ),
3563 ("a.design", b"<Frame><Text>a</Text></Frame>".to_vec()),
3564 ],
3565 "a.design",
3566 );
3567 assert_eq!(
3568 problems,
3569 Vec::<String>::new(),
3570 "read only for a text that names a font"
3571 );
3572 let problems = font_problems(
3573 &[
3574 (
3575 "themes.yml",
3576 b"kind: themes\nfonts:\n Figtree: ./v.ttf\n my_font: ./v.ttf\n ok: [1]\nfont: ./v.ttf\nlight: {}\ndark: {}\n"
3577 .to_vec(),
3578 ),
3579 ("a.design", br#"<Frame><Text font="ok">a</Text></Frame>"#.to_vec()),
3580 ],
3581 "a.design",
3582 );
3583 assert_eq!(
3584 problems,
3585 vec![
3586 "themes.yml:3:3: fonts.Figtree: a font's name holds lowercase letters, digits and - only",
3587 "themes.yml:4:3: fonts.my_font: a font's name holds lowercase letters, digits and - only",
3588 "themes.yml:5:3: fonts.ok: expected a path to a font file or a folder of one family's, like ./fonts/Figtree",
3589 "themes.yml:6:1: font: a themes file has kind, fonts, light and dark only",
3590 "a.design:1:14: font: no font named ok; no themes.yml in this design's folder or above it names fonts",
3591 ]
3592 );
3593 }
3594
3595 #[test]
3599 fn a_value_given_as_a_prop_is_read_where_it_was_written() {
3600 let list = |caller: &str| {
3601 vec![
3602 (
3603 "app/a.design",
3604 format!("import Card from \"../lib/card.design\"\n<Frame>{caller}</Frame>")
3605 .into_bytes(),
3606 ),
3607 ("app/themes.yml", named(" mine: ./v.ttf\n")),
3608 ("app/me.png", png()),
3609 ("app/v.ttf", figtree()),
3610 ("app/next.design", b"<Frame />".to_vec()),
3611 ("lib/themes.yml", named(" own: ./own.ttf\n")),
3612 (
3613 "lib/card.design",
3614 br#"<Frame props={{ src: "./own.png", font: "own", go: "back" }} onClick={go}><Image src={src} /><Text font={font}>a</Text></Frame>"#
3615 .to_vec(),
3616 ),
3617 ("lib/own.png", png()),
3618 ("lib/own.ttf", inter("Regular")),
3619 ]
3620 };
3621 let scene = resolve(
3622 &mem(&list(
3623 r#"<Card src="./me.png" font="mine" go="navigate ./next.design" />"#,
3624 )),
3625 "app/a.design",
3626 )
3627 .unwrap();
3628 let card = &scene.root.children[0];
3629 assert_eq!(card.children[0].image.as_ref().unwrap().src, "app/me.png");
3630 assert_eq!(
3631 card.children[1].text.as_ref().unwrap().font,
3632 Some(FontId::of(&figtree()))
3633 );
3634 assert_eq!(card.on_click.as_deref(), Some("navigate app/next.design"));
3635 let scene = resolve(&mem(&list("<Card />")), "app/a.design").unwrap();
3637 let card = &scene.root.children[0];
3638 assert_eq!(card.children[0].image.as_ref().unwrap().src, "lib/own.png");
3639 assert_eq!(
3640 card.children[1].text.as_ref().unwrap().font,
3641 Some(FontId::of(&inter("Regular")))
3642 );
3643 assert_eq!(
3645 font_problems(
3646 &list(r#"<Card src="./no.png" font="own" go="overlay ./no.design" />"#),
3647 "app/a.design"
3648 ),
3649 vec![
3650 "app/a.design:2:43: onClick: overlay ./no.design — no such file",
3651 "app/a.design:2:18: src: ./no.png — no such file",
3652 "app/a.design:2:34: font: no font named own; the themes name mine",
3653 ]
3654 );
3655 }
3656}