1use std::collections::HashMap;
5use std::sync::Arc;
6
7use vello_cpu::peniko::{Blob, ImageAlphaType, ImageData, ImageFormat};
8
9use crate::Problem;
10use crate::parse::{self, Child, Element, Pos, TextPart, Value};
11
12#[derive(Debug, Clone, Copy, PartialEq)]
13pub struct Color {
14 pub r: u8,
15 pub g: u8,
16 pub b: u8,
17 pub a: u8,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq)]
21pub enum Size {
22 Px(f32),
23 Hug,
24 Fill,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub enum Layout {
29 None,
30 Row,
31 Column,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq)]
35pub enum Align {
36 Start,
37 Center,
38 End,
39 SpaceBetween,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub enum Kind {
44 Frame,
45 Text,
46 Rect,
47 Ellipse,
48 Vector,
49 Image,
50}
51
52#[derive(Debug, Clone, PartialEq)]
54pub enum Fill {
55 Solid(Color),
56 Linear {
59 angle: f32,
60 stops: Vec<(Color, f32)>,
61 },
62}
63
64#[derive(Debug, Clone, Copy, PartialEq)]
65pub enum TextAlign {
66 Left,
67 Center,
68 Right,
69}
70
71#[derive(Debug, Clone, PartialEq)]
73pub struct Vector {
74 pub d: String,
75 pub view_box: [f32; 4],
77}
78
79#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
83pub struct ImageId(pub [u8; 32]);
84
85impl ImageId {
86 pub fn of(bytes: &[u8]) -> ImageId {
87 use sha2::Digest;
88 ImageId(sha2::Sha256::digest(bytes).into())
89 }
90}
91
92impl std::fmt::Debug for ImageId {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 for b in &self.0[..6] {
95 write!(f, "{b:02x}")?;
96 }
97 Ok(())
98 }
99}
100
101pub const MAX_IMAGE_SIDE: u32 = 4096;
106
107#[derive(Debug)]
110pub struct ImageFile {
111 pub id: ImageId,
112 pub bytes: Vec<u8>,
113 pub width: u32,
115 pub height: u32,
116 pub pixels: Arc<Vec<u8>>,
118 pub pixels_width: u32,
119 pub pixels_height: u32,
120}
121
122impl ImageFile {
123 pub fn new(bytes: Vec<u8>) -> Result<ImageFile, String> {
125 ImageFile::with_id(ImageId::of(&bytes), bytes)
126 }
127
128 pub fn with_id(id: ImageId, bytes: Vec<u8>) -> Result<ImageFile, String> {
130 let decoded = decode(&bytes)?;
131 let (width, height) = (decoded.width, decoded.height);
132 let (pixels, pixels_width, pixels_height) =
133 fit(decoded.data.data(), width, height, MAX_IMAGE_SIDE);
134 Ok(ImageFile {
135 id,
136 bytes,
137 width,
138 height,
139 pixels: Arc::new(pixels),
140 pixels_width,
141 pixels_height,
142 })
143 }
144
145 pub fn image_data(&self) -> ImageData {
148 ImageData {
149 data: Blob::new(self.pixels.clone()),
150 format: ImageFormat::Rgba8,
151 alpha_type: ImageAlphaType::Alpha,
152 width: self.pixels_width,
153 height: self.pixels_height,
154 }
155 }
156}
157
158pub fn fit(rgba: &[u8], width: u32, height: u32, max: u32) -> (Vec<u8>, u32, u32) {
163 if width <= max && height <= max {
164 return (rgba.to_vec(), width, height);
165 }
166 let scale = max as f64 / width.max(height) as f64;
167 let w = ((width as f64 * scale).round() as u32).clamp(1, max);
168 let h = ((height as f64 * scale).round() as u32).clamp(1, max);
169 let weights = |from: u32, to: u32| -> Vec<Vec<(usize, f32)>> {
172 let ratio = from as f64 / to as f64;
173 (0..to)
174 .map(|o| {
175 let (a, b) = (o as f64 * ratio, (o + 1) as f64 * ratio);
176 (a.floor() as usize..(b.ceil() as usize).min(from as usize))
177 .map(|i| {
178 let cover = (b.min(i as f64 + 1.0) - a.max(i as f64)) / ratio;
179 (i, cover as f32)
180 })
181 .filter(|&(_, c)| c > 0.0)
182 .collect()
183 })
184 .collect()
185 };
186 let (across, down) = (weights(width, w), weights(height, h));
187 let mut rows = vec![0f32; 4 * w as usize * height as usize];
189 for y in 0..height as usize {
190 let line = &rgba[4 * y * width as usize..][..4 * width as usize];
191 for (x, ws) in across.iter().enumerate() {
192 let mut sum = [0f32; 4];
193 for &(i, c) in ws {
194 let p = &line[4 * i..][..4];
195 let a = p[3] as f32 * c;
196 sum[0] += p[0] as f32 * a;
197 sum[1] += p[1] as f32 * a;
198 sum[2] += p[2] as f32 * a;
199 sum[3] += a;
200 }
201 rows[4 * (y * w as usize + x)..][..4].copy_from_slice(&sum);
202 }
203 }
204 let mut out = vec![0u8; 4 * w as usize * h as usize];
205 for (y, ws) in down.iter().enumerate() {
206 for x in 0..w as usize {
207 let mut sum = [0f32; 4];
208 for &(i, c) in ws {
209 let p = &rows[4 * (i * w as usize + x)..][..4];
210 for k in 0..4 {
211 sum[k] += p[k] * c;
212 }
213 }
214 let px = &mut out[4 * (y * w as usize + x)..][..4];
215 if sum[3] > 0.0 {
216 for k in 0..3 {
217 px[k] = (sum[k] / sum[3]).round().clamp(0.0, 255.0) as u8;
218 }
219 px[3] = sum[3].round().clamp(0.0, 255.0) as u8;
220 }
221 }
222 }
223 (out, w, h)
224}
225
226#[derive(Debug, Clone)]
230pub struct Origin {
231 pub file: String,
232 pub pos: Pos,
233}
234
235impl Default for Origin {
236 fn default() -> Origin {
237 Origin {
238 file: String::new(),
239 pos: Pos { line: 1, col: 1 },
240 }
241 }
242}
243
244impl PartialEq for Origin {
245 fn eq(&self, _: &Origin) -> bool {
246 true
247 }
248}
249
250#[derive(Debug, Clone, PartialEq)]
253pub struct Image {
254 pub src: String,
255 pub id: ImageId,
256 pub at: Origin,
258}
259
260pub fn decode(data: &[u8]) -> Result<ImageData, String> {
262 let (pixels, width, height) = if data.starts_with(b"\x89PNG\r\n\x1a\n") {
263 let broken = |e: &dyn std::fmt::Display| format!("is a PNG that doesn't decode: {e}");
264 let mut decoder = png::Decoder::new(std::io::Cursor::new(data));
265 decoder.set_transformations(png::Transformations::normalize_to_color8());
266 let mut reader = decoder.read_info().map_err(|e| broken(&e))?;
267 let size = reader
268 .output_buffer_size()
269 .ok_or_else(|| broken(&"too big to hold"))?;
270 let mut buf = vec![0; size];
271 let info = reader.next_frame(&mut buf).map_err(|e| broken(&e))?;
272 buf.truncate(info.buffer_size());
273 let rgba = match info.color_type {
274 png::ColorType::Rgba => buf,
275 png::ColorType::Rgb => buf
276 .chunks(3)
277 .flat_map(|p| [p[0], p[1], p[2], 255])
278 .collect(),
279 png::ColorType::GrayscaleAlpha => buf
280 .chunks(2)
281 .flat_map(|p| [p[0], p[0], p[0], p[1]])
282 .collect(),
283 png::ColorType::Grayscale => buf.iter().flat_map(|&g| [g, g, g, 255]).collect(),
284 png::ColorType::Indexed => return Err(broken(&"an unexpanded palette")),
286 };
287 (rgba, info.width, info.height)
288 } else if data.starts_with(&[0xff, 0xd8, 0xff]) {
289 use zune_jpeg::JpegDecoder;
290 use zune_jpeg::zune_core::colorspace::ColorSpace;
291 use zune_jpeg::zune_core::options::DecoderOptions;
292 let broken = |e: &dyn std::fmt::Display| format!("is a JPEG that doesn't decode: {e}");
293 let options = DecoderOptions::default()
294 .set_strict_mode(true)
295 .jpeg_set_out_colorspace(ColorSpace::RGBA);
296 let mut decoder = JpegDecoder::new_with_options(std::io::Cursor::new(data), options);
297 let pixels = decoder.decode().map_err(|e| broken(&e))?;
298 let info = decoder.info().ok_or_else(|| broken(&"no header"))?;
299 (pixels, info.width as u32, info.height as u32)
300 } else {
301 return Err("is not a PNG or JPEG file".into());
302 };
303 if width == 0 || height == 0 || pixels.len() != width as usize * height as usize * 4 {
304 return Err(format!(
305 "is an image of {width}×{height} pixels that doesn't decode whole"
306 ));
307 }
308 Ok(ImageData {
309 data: Blob::new(Arc::new(pixels)),
310 format: ImageFormat::Rgba8,
311 alpha_type: ImageAlphaType::Alpha,
312 width,
313 height,
314 })
315}
316
317#[derive(Debug, Clone, PartialEq)]
318pub struct Shadow {
319 pub x: f32,
320 pub y: f32,
321 pub blur: f32,
322 pub spread: f32,
323 pub color: Color,
324 pub at: Origin,
326}
327
328#[derive(Debug, Clone, PartialEq)]
329pub struct TextStyle {
330 pub content: String,
331 pub size: f32,
332 pub weight: u16,
333 pub color: Color,
334 pub line_height: f32,
335 pub align: TextAlign,
336}
337
338#[derive(Debug, Clone, PartialEq)]
340pub struct Node {
341 pub kind: Kind,
342 pub name: String,
343 pub x: f32,
344 pub y: f32,
345 pub width: Size,
346 pub height: Size,
347 pub opacity: f32,
348 pub on_click: Option<String>,
349 pub fill: Option<Fill>,
350 pub stroke: Option<Color>,
351 pub stroke_width: f32,
352 pub radius: [f32; 4],
353 pub shadow: Option<Shadow>,
354 pub clip: bool,
355 pub layout: Layout,
356 pub gap: f32,
357 pub padding: [f32; 4],
358 pub align: Align,
359 pub justify: Align,
360 pub text: Option<TextStyle>,
361 pub vector: Option<Vector>,
362 pub image: Option<Image>,
363 pub children: Vec<Node>,
364}
365
366impl Node {
367 fn new(kind: Kind, name: &str) -> Node {
368 Node {
369 kind,
370 name: name.to_string(),
371 x: 0.0,
372 y: 0.0,
373 width: Size::Hug,
374 height: Size::Hug,
375 opacity: 1.0,
376 on_click: None,
377 fill: None,
378 stroke: None,
379 stroke_width: 1.0,
380 radius: [0.0; 4],
381 shadow: None,
382 clip: false,
383 layout: Layout::None,
384 gap: 0.0,
385 padding: [0.0; 4],
386 align: Align::Start,
387 justify: Align::Start,
388 text: None,
389 vector: None,
390 image: None,
391 children: Vec::new(),
392 }
393 }
394}
395
396pub trait Files {
398 fn bytes(&self, path: &str) -> Option<Vec<u8>>;
400 fn read(&self, path: &str) -> Option<String> {
402 String::from_utf8(self.bytes(path)?).ok()
403 }
404 fn image(&self, path: &str) -> Option<Result<Arc<ImageFile>, String>> {
408 self.bytes(path)
409 .map(|bytes| ImageFile::new(bytes).map(Arc::new))
410 }
411}
412
413impl Files for HashMap<String, String> {
414 fn bytes(&self, path: &str) -> Option<Vec<u8>> {
415 self.get(path).map(|s| s.clone().into_bytes())
416 }
417}
418
419#[derive(Debug, Clone, PartialEq)]
422pub struct Scene {
423 pub root: Node,
424 pub images: ImageFiles,
425 pub files: Vec<String>,
426}
427
428pub type ImageFiles = std::collections::BTreeMap<ImageId, Arc<ImageFile>>;
431
432impl PartialEq for ImageFile {
434 fn eq(&self, other: &ImageFile) -> bool {
435 self.id == other.id
436 }
437}
438
439pub fn resolve(files: &dyn Files, path: &str) -> Result<Scene, Vec<Problem>> {
441 let mut r = Resolver {
442 files,
443 problems: Vec::new(),
444 read: Vec::new(),
445 stack: Vec::new(),
446 missing: Vec::new(),
447 depth: 0,
448 images: ImageFiles::new(),
449 by_path: HashMap::new(),
450 };
451 let root = r.design(path, &[], None, "", None);
452 if r.problems.is_empty() {
453 Ok(Scene {
454 root: root.expect("no problems means a root"),
455 images: r.images,
456 files: r.read,
457 })
458 } else {
459 Err(r.problems)
460 }
461}
462
463struct Resolver<'a> {
464 files: &'a dyn Files,
465 problems: Vec<Problem>,
466 read: Vec<String>,
467 stack: Vec<String>,
468 missing: Vec<String>,
471 depth: usize,
473 images: ImageFiles,
475 by_path: HashMap<String, Result<ImageId, String>>,
477}
478
479const MAX_DEPTH: usize = parse::MAX_DEPTH;
482
483type Props = HashMap<String, Value>;
484
485impl Resolver<'_> {
486 fn problem(&mut self, file: &str, pos: Pos, message: String) {
487 self.problems.push(Problem {
488 file: file.to_string(),
489 pos,
490 message,
491 });
492 }
493
494 fn design(
496 &mut self,
497 path: &str,
498 args: &[parse::Attr],
499 from: Option<(&str, Pos)>,
500 used_as: &str,
501 parent: Option<Layout>,
502 ) -> Option<Node> {
503 if self.stack.iter().any(|p| p == path) {
504 let (file, pos) = from.unwrap_or((path, Pos { line: 1, col: 1 }));
505 self.problem(file, pos, format!("{path} uses itself"));
506 return None;
507 }
508 let Some(source) = self.files.read(path) else {
509 if from.is_some() && self.missing.iter().any(|p| p == path) {
510 return None;
511 }
512 let (file, pos) = from.unwrap_or((path, Pos { line: 1, col: 1 }));
513 self.problem(file, pos, format!("{path} — no such file"));
514 return None;
515 };
516 if !self.read.iter().any(|p| p == path) {
517 self.read.push(path.to_string());
518 }
519 let file = match parse::parse(&source) {
520 Ok(file) => file,
521 Err(mut problem) => {
522 problem.file = path.to_string();
523 self.problems.push(problem);
524 return None;
525 }
526 };
527 let mut imports = HashMap::new();
528 for import in &file.imports {
529 if ELEMENTS.contains(&import.name.as_str()) {
530 self.problem(
531 path,
532 import.pos,
533 format!("import {0} — {0} is a built-in element", import.name),
534 );
535 continue;
536 }
537 if imports.contains_key(&import.name) {
538 self.problem(
539 path,
540 import.pos,
541 format!("import {0} — {0} is already imported", import.name),
542 );
543 continue;
544 }
545 if !import.path.ends_with(".design") {
546 self.problem(
547 path,
548 import.pos,
549 format!("import {} — a design is a .design file", import.path),
550 );
551 continue;
552 }
553 match join(path, &import.path) {
554 Some(target) => {
555 if self.files.read(&target).is_none() {
557 self.problem(
558 path,
559 import.pos,
560 format!("import {} — no such file", import.path),
561 );
562 self.missing.push(target.clone());
563 }
564 imports.insert(import.name.clone(), target);
565 }
566 None => self.problem(
567 path,
568 import.pos,
569 format!("import {} — outside the served folder", import.path),
570 ),
571 }
572 }
573 if file.root.name != "Frame" {
574 self.problem(
575 path,
576 file.root.pos,
577 format!("the root element must be a Frame, not {}", file.root.name),
578 );
579 return None;
580 }
581 let mut props = Props::new();
583 if let Some(attr) = file.root.attrs.iter().find(|a| a.name == "props") {
584 match &attr.value {
585 Value::Object(fields) => {
586 for (k, v) in fields {
587 props.insert(k.clone(), v.clone());
588 }
589 }
590 _ => self.problem(
591 path,
592 attr.pos,
593 "props: expected an object of defaults".into(),
594 ),
595 }
596 }
597 let (caller, caller_pos) = from.unwrap_or((path, file.root.pos));
598 let caller = caller.to_string();
599 let mut overrides = Vec::new();
600 for arg in args {
601 if matches!(
602 arg.name.as_str(),
603 "x" | "y" | "width" | "height" | "name" | "opacity" | "onClick"
604 ) {
605 overrides.push(arg.clone());
606 } else if props.contains_key(&arg.name) {
607 props.insert(arg.name.clone(), arg.value.clone());
608 } else {
609 self.problem(
610 &caller,
611 arg.pos,
612 format!("{used_as} has no prop \"{}\"", arg.name),
613 );
614 }
615 }
616 let _ = caller_pos;
617 self.stack.push(path.to_string());
618 let mut root = self.element(path, &file.root, &props, &imports, true, parent);
619 self.stack.pop();
620 if let Some(root) = root.as_mut() {
621 for attr in &overrides {
622 self.common(&caller, root, attr, attr.value.clone());
624 self.fill_needs_layout(&caller, attr, &attr.value, parent);
625 self.place_needs_no_layout(&caller, attr, parent);
626 }
627 }
628 root
629 }
630
631 fn element(
632 &mut self,
633 path: &str,
634 el: &Element,
635 props: &Props,
636 imports: &HashMap<String, String>,
637 root: bool,
638 parent: Option<Layout>,
639 ) -> Option<Node> {
640 if self.depth >= MAX_DEPTH {
641 self.problem(path, el.pos, format!("nested more than {MAX_DEPTH} deep"));
642 return None;
643 }
644 self.depth += 1;
645 let node = self.element_inner(path, el, props, imports, root, parent);
646 self.depth -= 1;
647 node
648 }
649
650 fn element_inner(
651 &mut self,
652 path: &str,
653 el: &Element,
654 props: &Props,
655 imports: &HashMap<String, String>,
656 root: bool,
657 parent: Option<Layout>,
658 ) -> Option<Node> {
659 let mut seen: Vec<&str> = Vec::new();
662 let mut attrs = Vec::new();
663 for attr in &el.attrs {
664 if seen.contains(&attr.name.as_str()) {
665 self.problem(path, attr.pos, format!("{} is given twice", attr.name));
666 } else {
667 seen.push(&attr.name);
668 attrs.push(attr);
669 }
670 }
671 if let Some(target) = imports.get(&el.name) {
672 if !el.children.is_empty() {
673 self.problem(path, el.pos, format!("{} takes no children", el.name));
674 }
675 let args: Vec<parse::Attr> = attrs
676 .iter()
677 .map(|a| parse::Attr {
678 name: a.name.clone(),
679 value: substitute(&a.value, props),
680 pos: a.pos,
681 })
682 .collect();
683 let target = target.clone();
684 return self.design(&target, &args, Some((path, el.pos)), &el.name, parent);
685 }
686 let kind = match el.name.as_str() {
687 "Frame" => Kind::Frame,
689 "Text" => Kind::Text,
690 "Rect" => Kind::Rect,
691 "Ellipse" => Kind::Ellipse,
692 "Vector" => Kind::Vector,
693 "Image" => Kind::Image,
694 other => {
695 self.problem(path, el.pos, format!("unknown element {other}"));
696 return None;
697 }
698 };
699 let mut node = Node::new(kind, &el.name);
700 for attr in attrs {
701 if attr.name == "props" {
702 if !root {
703 self.problem(
704 path,
705 attr.pos,
706 "props: only a design's root declares props".into(),
707 );
708 }
709 continue;
710 }
711 if root && matches!(attr.name.as_str(), "x" | "y") {
714 self.problem(
715 path,
716 attr.pos,
717 format!(
718 "{}: a design's root has no place of its own; place an instance with x and y where it is used",
719 attr.name
720 ),
721 );
722 continue;
723 }
724 let value = match &attr.value {
725 Value::Prop(p) => match props.get(p) {
726 Some(v) => v.clone(),
727 None => {
728 self.problem(path, attr.pos, format!("{}: no prop \"{p}\"", attr.name));
729 continue;
730 }
731 },
732 v => v.clone(),
733 };
734 self.fill_needs_layout(path, attr, &value, parent);
735 self.place_needs_no_layout(path, attr, parent);
736 self.attribute(path, &mut node, attr, value);
737 }
738 match kind {
739 Kind::Vector if !el.attrs.iter().any(|a| a.name == "d") => self.problem(
740 path,
741 el.pos,
742 format!("{} needs a path, d=\"M…\"", node.name),
743 ),
744 Kind::Image if node.image.is_none() && !el.attrs.iter().any(|a| a.name == "src") => {
745 self.problem(
746 path,
747 el.pos,
748 format!("{} needs a file, src=\"…\"", node.name),
749 )
750 }
751 _ => {}
752 }
753 if kind == Kind::Text {
754 let mut content = String::new();
755 for child in &el.children {
756 match child {
757 Child::Text(parts, pos) => {
758 for part in parts {
759 match part {
760 TextPart::Literal(s) => content.push_str(s),
761 TextPart::Prop(p) => match props.get(p) {
762 Some(Value::Str(s)) => content.push_str(s),
763 Some(Value::Num(n)) => content.push_str(&n.to_string()),
764 _ => self.problem(path, *pos, format!("no text prop \"{p}\"")),
765 },
766 }
767 }
768 }
769 Child::Element(e) => self.problem(path, e.pos, "Text holds text only".into()),
770 }
771 }
772 node.text.get_or_insert_with(default_text).content = content;
773 } else {
774 for child in &el.children {
775 match child {
776 Child::Element(e) => {
777 if kind != Kind::Frame {
778 self.problem(path, e.pos, format!("{} takes no children", el.name));
779 } else if let Some(n) =
780 self.element(path, e, props, imports, false, Some(node.layout))
781 {
782 node.children.push(n);
783 }
784 }
785 Child::Text(_, pos) => self.problem(
786 path,
787 *pos,
788 format!("text belongs in a Text, not a {}", el.name),
789 ),
790 }
791 }
792 }
793 Some(node)
794 }
795
796 fn fill_needs_layout(
799 &mut self,
800 path: &str,
801 attr: &parse::Attr,
802 value: &Value,
803 parent: Option<Layout>,
804 ) {
805 if parent == Some(Layout::None)
806 && matches!(attr.name.as_str(), "width" | "height")
807 && matches!(value, Value::Str(s) if s == "fill")
808 {
809 self.problem(
810 path,
811 attr.pos,
812 format!(
813 "{}: \"fill\" needs a parent with layout=\"row\" or \"column\"",
814 attr.name
815 ),
816 );
817 }
818 }
819
820 fn place_needs_no_layout(&mut self, path: &str, attr: &parse::Attr, parent: Option<Layout>) {
823 if matches!(parent, Some(Layout::Row | Layout::Column))
824 && matches!(attr.name.as_str(), "x" | "y")
825 {
826 self.problem(
827 path,
828 attr.pos,
829 format!(
830 "{}: places a child only in a frame with layout=\"none\"",
831 attr.name
832 ),
833 );
834 }
835 }
836
837 #[allow(clippy::unit_arg)]
839 fn attribute(&mut self, path: &str, node: &mut Node, attr: &parse::Attr, value: Value) {
840 let kind = node.kind;
841 let ok = match (attr.name.as_str(), kind) {
842 ("x" | "y" | "width" | "height" | "name" | "opacity" | "onClick", _) => {
843 self.common(path, node, attr, value);
844 return;
845 }
846 ("fill", k) if k != Kind::Text => fill(&value).map(|f| node.fill = Some(f)),
847 ("stroke", k) if k != Kind::Text => color(&value).map(|c| node.stroke = Some(c)),
848 ("strokeWidth", k) if k != Kind::Text => px(&value).map(|n| node.stroke_width = n),
849 ("radius", k) if k != Kind::Text => match &value {
850 Value::Num(_) => px(&value).map(|n| node.radius = [n; 4]),
851 Value::Array(a) if a.len() == 4 => {
852 pxs(a).map(|v| node.radius = [v[0], v[1], v[2], v[3]])
853 }
854 _ => Err("expected pixels or an array of 4".into()),
855 },
856 ("shadow", k) if k != Kind::Text => shadow(&value).map(|mut s| {
857 s.at = Origin {
858 file: path.to_string(),
859 pos: attr.pos,
860 };
861 node.shadow = Some(s)
862 }),
863 ("clip", Kind::Frame) => match value {
865 Value::Bool(b) => Ok(node.clip = b),
866 _ => Err("expected true or false".into()),
867 },
868 ("layout", Kind::Frame) => match str_of(&value).as_deref() {
869 Some("row") => Ok(node.layout = Layout::Row),
870 Some("column") => Ok(node.layout = Layout::Column),
871 Some("none") => Ok(node.layout = Layout::None),
872 _ => Err("expected \"row\", \"column\" or \"none\"".into()),
873 },
874 ("gap", Kind::Frame) => px(&value).map(|n| node.gap = n),
875 ("padding", Kind::Frame) => match &value {
876 Value::Num(_) => px(&value).map(|n| node.padding = [n; 4]),
877 Value::Array(a) if a.len() == 2 => {
878 pxs(a).map(|v| node.padding = [v[0], v[1], v[0], v[1]])
879 }
880 Value::Array(a) if a.len() == 4 => {
881 pxs(a).map(|v| node.padding = [v[0], v[1], v[2], v[3]])
882 }
883 other => Err(format!(
884 "expected pixels or an array of 2 or 4, found {}",
885 show(other)
886 )),
887 },
888 ("align", Kind::Frame) => align(&value, false).map(|a| node.align = a),
889 ("justify", Kind::Frame) => align(&value, true).map(|a| node.justify = a),
890 ("size", Kind::Text) => {
891 px(&value).map(|n| node.text.get_or_insert_with(default_text).size = n)
892 }
893 ("weight", Kind::Text) => match num(&value) {
894 Ok(w) if [400.0, 500.0, 600.0, 700.0].contains(&w) => {
895 Ok(node.text.get_or_insert_with(default_text).weight = w as u16)
896 }
897 _ => Err("expected 400, 500, 600 or 700".into()),
898 },
899 ("color", Kind::Text) => {
900 color(&value).map(|c| node.text.get_or_insert_with(default_text).color = c)
901 }
902 ("lineHeight", Kind::Text) => {
903 px(&value).map(|n| node.text.get_or_insert_with(default_text).line_height = n)
904 }
905 ("textAlign", Kind::Text) => match str_of(&value).as_deref() {
906 Some("left") => {
907 Ok(node.text.get_or_insert_with(default_text).align = TextAlign::Left)
908 }
909 Some("center") => {
910 Ok(node.text.get_or_insert_with(default_text).align = TextAlign::Center)
911 }
912 Some("right") => {
913 Ok(node.text.get_or_insert_with(default_text).align = TextAlign::Right)
914 }
915 _ => Err("expected \"left\", \"center\" or \"right\"".into()),
916 },
917 ("d", Kind::Vector) => match str_of(&value) {
918 Some(d) => match path_numbers(&d).map_err(|e| e.to_string()).and_then(|()| {
919 vello_cpu::kurbo::BezPath::from_svg(&d)
920 .map_err(|e| format!("not SVG path data: {e}"))
921 }) {
922 Ok(_) => {
923 let view_box = node.vector.as_ref().map(|v| v.view_box);
924 let view_box = view_box.unwrap_or_else(|| bounds(&d));
925 Ok(node.vector = Some(Vector { d, view_box }))
926 }
927 Err(e) => Err(e),
928 },
929 None => Err("expected SVG path data".into()),
930 },
931 ("viewBox", Kind::Vector) => match str_of(&value).as_deref().map(view_box) {
932 Some(Ok(b)) => {
933 let v = node.vector.get_or_insert_with(|| Vector {
934 d: String::new(),
935 view_box: b,
936 });
937 Ok(v.view_box = b)
938 }
939 Some(Err(e)) => Err(e),
940 None => Err("expected \"minX minY width height\"".into()),
941 },
942 ("src", Kind::Image) => match str_of(&value) {
943 Some(src) => match join(path, &src) {
944 None => Err(format!("{src} — outside the served folder")),
945 Some(at) => match self.image_file(&at) {
946 None => Err(format!("{src} — no such file")),
947 Some(Err(e)) => Err(format!("{src} {e}")),
948 Some(Ok(id)) => {
949 node.image = Some(Image {
950 src: at,
951 id,
952 at: Origin {
953 file: path.to_string(),
954 pos: attr.pos,
955 },
956 });
957 Ok(())
958 }
959 },
960 },
961 None => Err("expected a path".into()),
962 },
963 (name, _) => {
964 self.problem(
965 path,
966 attr.pos,
967 format!("{} takes no attribute {name}", node.name),
968 );
969 return;
970 }
971 };
972 if let Err(e) = ok {
973 self.problem(path, attr.pos, format!("{}: {e}", attr.name));
974 }
975 }
976
977 fn image_file(&mut self, at: &str) -> Option<Result<ImageId, String>> {
981 if let Some(known) = self.by_path.get(at) {
982 return Some(known.clone());
983 }
984 let file = self.files.image(at)?;
985 if !self.read.iter().any(|p| p == at) {
986 self.read.push(at.to_string());
987 }
988 let id = file.map(|file| {
989 let id = file.id;
990 self.images.entry(id).or_insert(file);
991 id
992 });
993 self.by_path.insert(at.to_string(), id.clone());
994 Some(id)
995 }
996
997 fn interaction(&mut self, path: &str, action: &str) -> Result<String, String> {
1000 let Some((verb, target)) = action.split_once(' ') else {
1001 return Ok(action.to_string());
1002 };
1003 let target = target.trim();
1004 if !target.ends_with(".design") {
1005 return Err(format!("{action} — a design is a .design file"));
1006 }
1007 match join(path, target) {
1008 None => Err(format!("{action} — outside the served folder")),
1009 Some(at) if self.files.bytes(&at).is_none() => Err(format!("{action} — no such file")),
1010 Some(at) => Ok(format!("{verb} {at}")),
1011 }
1012 }
1013
1014 fn common(&mut self, path: &str, node: &mut Node, attr: &parse::Attr, value: Value) {
1015 let ok = match attr.name.as_str() {
1016 "x" => num(&value).map(|n| node.x = n),
1017 "y" => num(&value).map(|n| node.y = n),
1018 "width" => size(&value).map(|s| node.width = s),
1019 "height" => size(&value).map(|s| node.height = s),
1020 "name" => str_of(&value)
1021 .map(|s| node.name = s)
1022 .ok_or_else(|| "expected a string".into()),
1023 "opacity" => match value {
1024 Value::Num(n) if (0.0..=1.0).contains(&n) => {
1025 node.opacity = n as f32;
1026 Ok(())
1027 }
1028 other => Err(format!(
1029 "expected a number from 0 to 1, found {}",
1030 show(&other)
1031 )),
1032 },
1033 "onClick" => match str_of(&value).filter(|s| interaction_ok(s)) {
1034 Some(action) => self
1035 .interaction(path, &action)
1036 .map(|a| node.on_click = Some(a)),
1037 None => Err(
1038 "expected \"navigate <path>\", \"back\", \"overlay <path>\" or \"close\""
1039 .into(),
1040 ),
1041 },
1042 _ => unreachable!(),
1043 };
1044 if let Err(e) = ok {
1045 self.problem(path, attr.pos, format!("{}: {e}", attr.name));
1046 }
1047 }
1048}
1049
1050fn default_text() -> TextStyle {
1051 TextStyle {
1052 content: String::new(),
1053 size: 14.0,
1054 weight: 400,
1055 color: Color {
1056 r: 0x0f,
1057 g: 0x17,
1058 b: 0x2a,
1059 a: 255,
1060 },
1061 line_height: 1.4,
1062 align: TextAlign::Left,
1063 }
1064}
1065
1066fn view_box(s: &str) -> Result<[f32; 4], String> {
1067 let v: Vec<f32> = s
1068 .split([' ', ','])
1069 .filter(|p| !p.is_empty())
1070 .map(|p| parse::number(p).map(|n| n as f32))
1071 .collect::<Result<_, _>>()?;
1072 match v[..] {
1073 [x, y, w, h] if w > 0.0 && h > 0.0 => Ok([x, y, w, h]),
1074 _ => Err("expected \"minX minY width height\"".into()),
1075 }
1076}
1077
1078fn path_numbers(d: &str) -> Result<(), String> {
1082 let b = d.as_bytes();
1083 let mut i = 0;
1084 let mut command = b' ';
1085 let mut param = 0;
1087 while i < b.len() {
1088 let c = b[i];
1089 if c.is_ascii_alphabetic() && c != b'e' && c != b'E' {
1090 command = c;
1091 param = 0;
1092 i += 1;
1093 continue;
1094 }
1095 if c.is_ascii_whitespace() || c == b',' {
1096 i += 1;
1097 continue;
1098 }
1099 if matches!(command, b'A' | b'a') && matches!(param % 7, 3 | 4) && matches!(c, b'0' | b'1')
1100 {
1101 param += 1;
1102 i += 1;
1103 continue;
1104 }
1105 let start = i;
1106 if matches!(b[i], b'+' | b'-') {
1107 i += 1;
1108 }
1109 let mut dot = false;
1110 while i < b.len() && (b[i].is_ascii_digit() || (b[i] == b'.' && !dot)) {
1111 dot |= b[i] == b'.';
1112 i += 1;
1113 }
1114 if i < b.len() && matches!(b[i], b'e' | b'E') {
1115 let mut j = i + 1;
1116 if j < b.len() && matches!(b[j], b'+' | b'-') {
1117 j += 1;
1118 }
1119 if j < b.len() && b[j].is_ascii_digit() {
1120 i = j;
1121 while i < b.len() && b[i].is_ascii_digit() {
1122 i += 1;
1123 }
1124 }
1125 }
1126 if i == start {
1127 return Ok(());
1129 }
1130 let s = &d[start..i];
1131 if let Ok(n) = s.parse::<f64>() {
1132 parse::in_range(s, n)?;
1133 }
1134 param += 1;
1135 }
1136 Ok(())
1137}
1138
1139fn bounds(d: &str) -> [f32; 4] {
1141 use vello_cpu::kurbo::Shape;
1142 let b = vello_cpu::kurbo::BezPath::from_svg(d)
1143 .map(|p| p.bounding_box())
1144 .unwrap_or_default();
1145 [
1146 b.x0 as f32,
1147 b.y0 as f32,
1148 (b.width() as f32).max(1.0),
1149 (b.height() as f32).max(1.0),
1150 ]
1151}
1152
1153fn interaction_ok(s: &str) -> bool {
1154 s == "back"
1155 || s == "close"
1156 || s.strip_prefix("navigate ")
1157 .or(s.strip_prefix("overlay "))
1158 .is_some_and(|p| !p.trim().is_empty())
1159}
1160
1161pub fn join(from: &str, relative: &str) -> Option<String> {
1164 let mut parts: Vec<&str> = from.split('/').filter(|p| !p.is_empty()).collect();
1165 parts.pop();
1166 for part in relative.split('/') {
1167 match part {
1168 "." | "" => {}
1169 ".." => {
1170 parts.pop()?;
1171 }
1172 p => parts.push(p),
1173 }
1174 }
1175 Some(parts.join("/"))
1176}
1177
1178fn substitute(value: &Value, props: &Props) -> Value {
1179 match value {
1180 Value::Prop(p) => props.get(p).cloned().unwrap_or_else(|| value.clone()),
1181 v => v.clone(),
1182 }
1183}
1184
1185fn show(v: &Value) -> String {
1186 match v {
1187 Value::Str(s) => format!("\"{s}\""),
1188 Value::Num(n) => n.to_string(),
1189 Value::Bool(b) => b.to_string(),
1190 Value::Array(_) => "an array".into(),
1191 Value::Object(_) => "an object".into(),
1192 Value::Prop(p) => p.clone(),
1193 }
1194}
1195
1196fn str_of(v: &Value) -> Option<String> {
1197 match v {
1198 Value::Str(s) => Some(s.clone()),
1199 _ => None,
1200 }
1201}
1202
1203const ELEMENTS: [&str; 6] = ["Frame", "Text", "Rect", "Ellipse", "Vector", "Image"];
1205
1206fn num(v: &Value) -> Result<f32, String> {
1207 match v {
1208 Value::Num(n) => Ok(*n as f32),
1209 other => Err(format!("expected a number, found {}", show(other))),
1210 }
1211}
1212
1213fn px(v: &Value) -> Result<f32, String> {
1215 match v {
1216 Value::Num(n) if *n >= 0.0 => Ok(*n as f32),
1217 Value::Num(n) => Err(format!("expected a number of 0 or more, found {n}")),
1218 other => Err(format!("expected a number, found {}", show(other))),
1219 }
1220}
1221
1222fn pxs(a: &[Value]) -> Result<Vec<f32>, String> {
1223 a.iter().map(px).collect()
1224}
1225
1226fn size(v: &Value) -> Result<Size, String> {
1227 match v {
1228 Value::Num(n) if *n >= 0.0 => Ok(Size::Px(*n as f32)),
1229 Value::Num(n) => Err(format!("expected pixels of 0 or more, found {n}")),
1230 Value::Str(s) if s == "hug" => Ok(Size::Hug),
1231 Value::Str(s) if s == "fill" => Ok(Size::Fill),
1232 other => Err(format!(
1233 "expected pixels, \"hug\" or \"fill\", found {}",
1234 show(other)
1235 )),
1236 }
1237}
1238
1239fn align(v: &Value, justify: bool) -> Result<Align, String> {
1240 match str_of(v).as_deref() {
1241 Some("start") => Ok(Align::Start),
1242 Some("center") => Ok(Align::Center),
1243 Some("end") => Ok(Align::End),
1244 Some("space-between") if justify => Ok(Align::SpaceBetween),
1245 _ if justify => Err("expected \"start\", \"center\", \"end\" or \"space-between\"".into()),
1246 _ => Err("expected \"start\", \"center\" or \"end\"".into()),
1247 }
1248}
1249
1250fn color(v: &Value) -> Result<Color, String> {
1251 let Value::Str(s) = v else {
1252 return Err(format!("expected a color, found {}", show(v)));
1253 };
1254 let hex = s.strip_prefix('#').ok_or("expected a color like #rrggbb")?;
1255 if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
1256 return Err(format!("{s} is not a color"));
1257 }
1258 let d = |i: usize, n: usize| {
1259 u8::from_str_radix(&hex[i..i + n], 16).map_err(|_| format!("{s} is not a color"))
1260 };
1261 let dd = |i: usize| d(i, 1).map(|v| v * 17);
1262 match hex.len() {
1263 3 => Ok(Color {
1264 r: dd(0)?,
1265 g: dd(1)?,
1266 b: dd(2)?,
1267 a: 255,
1268 }),
1269 4 => Ok(Color {
1270 r: dd(0)?,
1271 g: dd(1)?,
1272 b: dd(2)?,
1273 a: dd(3)?,
1274 }),
1275 6 => Ok(Color {
1276 r: d(0, 2)?,
1277 g: d(2, 2)?,
1278 b: d(4, 2)?,
1279 a: 255,
1280 }),
1281 8 => Ok(Color {
1282 r: d(0, 2)?,
1283 g: d(2, 2)?,
1284 b: d(4, 2)?,
1285 a: d(6, 2)?,
1286 }),
1287 _ => Err(format!("{s} is not a color")),
1288 }
1289}
1290
1291fn fill(v: &Value) -> Result<Fill, String> {
1292 let Value::Str(s) = v else {
1293 return Err(format!("expected a color or a gradient, found {}", show(v)));
1294 };
1295 let Some(inner) = s
1296 .strip_prefix("linear-gradient(")
1297 .and_then(|r| r.strip_suffix(')'))
1298 else {
1299 return color(v).map(Fill::Solid);
1300 };
1301 let wrong =
1302 || format!("expected \"linear-gradient(<angle>deg, <color> <stop>%, …)\", found \"{s}\"");
1303 let mut parts = inner.split(',').map(str::trim);
1304 let angle = parts
1305 .next()
1306 .and_then(|a| a.strip_suffix("deg"))
1307 .ok_or_else(wrong)?;
1308 let angle = parse::number(angle.trim())? as f32;
1309 let mut stops = Vec::new();
1310 for part in parts {
1311 let (c, at) = part.split_once(' ').ok_or_else(wrong)?;
1312 let at = at.trim().strip_suffix('%').ok_or_else(wrong)?;
1313 let at = parse::number(at)? as f32;
1314 stops.push((color(&Value::Str(c.to_string()))?, at / 100.0));
1315 }
1316 if stops.len() < 2 {
1317 return Err(wrong());
1318 }
1319 Ok(Fill::Linear { angle, stops })
1320}
1321
1322fn shadow(v: &Value) -> Result<Shadow, String> {
1323 let Value::Object(fields) = v else {
1324 return Err("expected { x, y, blur, spread, color }".into());
1325 };
1326 let mut s = Shadow {
1327 x: 0.0,
1328 y: 0.0,
1329 blur: 0.0,
1330 spread: 0.0,
1331 color: Color {
1332 r: 0,
1333 g: 0,
1334 b: 0,
1335 a: 64,
1336 },
1337 at: Origin::default(),
1338 };
1339 for (k, v) in fields {
1340 match k.as_str() {
1341 "x" => s.x = num(v)?,
1342 "y" => s.y = num(v)?,
1343 "blur" => s.blur = num(v)?,
1344 "spread" => s.spread = num(v)?,
1345 "color" => s.color = color(v)?,
1346 other => return Err(format!("a shadow has no {other}")),
1347 }
1348 }
1349 Ok(s)
1350}
1351
1352#[cfg(test)]
1353mod tests {
1354 use super::*;
1355
1356 fn files(list: &[(&str, &str)]) -> HashMap<String, String> {
1357 list.iter()
1358 .map(|(p, s)| (p.to_string(), s.to_string()))
1359 .collect()
1360 }
1361
1362 #[test]
1363 fn an_instance_takes_its_props() {
1364 let fs = files(&[
1365 (
1366 "a/button.design",
1367 r##"<Frame props={{ label: "Button", fill: "#2563eb" }} fill={fill}><Text>{label}</Text></Frame>"##,
1368 ),
1369 (
1370 "a/screen.design",
1371 r#"import Button from "./button.design"
1372<Frame width={390}><Button label="Pay" x={10} opacity={0.5} /></Frame>"#,
1373 ),
1374 ]);
1375 let scene = resolve(&fs, "a/screen.design").unwrap();
1376 let button = &scene.root.children[0];
1377 assert_eq!(button.x, 10.0);
1378 assert_eq!(button.opacity, 0.5);
1379 assert_eq!(
1380 button.fill,
1381 Some(Fill::Solid(Color {
1382 r: 0x25,
1383 g: 0x63,
1384 b: 0xeb,
1385 a: 255
1386 }))
1387 );
1388 assert_eq!(button.children[0].text.as_ref().unwrap().content, "Pay");
1389 assert_eq!(scene.files, vec!["a/screen.design", "a/button.design"]);
1390 }
1391
1392 #[test]
1393 fn opacity_is_between_zero_and_one() {
1394 let fs = files(&[
1395 ("dot.design", "<Frame />"),
1396 (
1397 "screen.design",
1398 "import Dot from \"./dot.design\"\n<Frame opacity={1}>\n <Rect opacity={50} />\n <Dot opacity={-0.1} />\n</Frame>",
1399 ),
1400 ]);
1401 let problems: Vec<String> = resolve(&fs, "screen.design")
1402 .unwrap_err()
1403 .iter()
1404 .map(|p| p.to_string())
1405 .collect();
1406 assert_eq!(
1407 problems,
1408 vec![
1409 "screen.design:3:9: opacity: expected a number from 0 to 1, found 50",
1410 "screen.design:4:8: opacity: expected a number from 0 to 1, found -0.1",
1411 ]
1412 );
1413 }
1414
1415 fn problems_in(fs: &HashMap<String, String>, path: &str) -> Vec<String> {
1416 resolve(fs, path)
1417 .unwrap_err()
1418 .iter()
1419 .map(|p| p.to_string())
1420 .collect()
1421 }
1422
1423 #[test]
1424 fn a_color_with_any_character_but_hex_digits_is_a_problem() {
1425 for (value, col) in [
1427 ("#fff\u{200b}", 13),
1428 ("#ffé", 13),
1429 ("#ff€f00", 13),
1430 ("#ggg", 13),
1431 ] {
1432 let fs = files(&[("a.design", &format!(r#"<Frame fill="{value}" />"#))]);
1433 assert_eq!(
1434 problems_in(&fs, "a.design"),
1435 vec![format!("a.design:1:8: fill: {value} is not a color")],
1436 "{value:?} at {col}"
1437 );
1438 }
1439 }
1440
1441 #[test]
1442 fn props_belong_to_the_root_only() {
1443 let fs = files(&[(
1444 "a.design",
1445 r#"<Frame props={{ a: "x" }}>
1446 <Frame props={{ b: "y" }} />
1447</Frame>"#,
1448 )]);
1449 assert_eq!(
1450 problems_in(&fs, "a.design"),
1451 vec!["a.design:2:10: props: only a design's root declares props"]
1452 );
1453 }
1454
1455 #[test]
1456 fn a_root_has_no_place_of_its_own() {
1457 let fs = files(&[
1458 (
1459 "dot.design",
1460 r#"<Frame x={40} y={30} width={10} height={10} />"#,
1461 ),
1462 (
1463 "a.design",
1464 r#"import Dot from "./dot.design"
1465<Frame width={100} height={100}>
1466 <Dot x={5} y={6} />
1467</Frame>"#,
1468 ),
1469 ]);
1470 let expected = [
1471 "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",
1472 "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",
1473 ];
1474 assert_eq!(problems_in(&fs, "dot.design"), expected);
1475 assert_eq!(problems_in(&fs, "a.design"), expected);
1478 let fs = files(&[
1479 ("dot.design", r#"<Frame width={10} height={10} />"#),
1480 (
1481 "a.design",
1482 r#"import Dot from "./dot.design"
1483<Frame width={100} height={100}>
1484 <Dot x={5} y={6} />
1485</Frame>"#,
1486 ),
1487 ]);
1488 let dot = &resolve(&fs, "a.design").unwrap().root.children[0];
1489 assert_eq!((dot.x, dot.y), (5.0, 6.0));
1490 }
1491
1492 #[test]
1493 fn fill_needs_a_parent_with_auto_layout() {
1494 let fs = files(&[
1495 ("dot.design", r#"<Frame width="fill" />"#),
1496 (
1497 "a.design",
1498 r#"import Dot from "./dot.design"
1499<Frame width={100} height={100}>
1500 <Rect width="fill" height={10} />
1501 <Dot />
1502 <Dot height="fill" />
1503 <Frame layout="row"><Rect width="fill" /></Frame>
1504</Frame>"#,
1505 ),
1506 ]);
1507 assert_eq!(
1508 problems_in(&fs, "a.design"),
1509 vec![
1510 "a.design:3:9: width: \"fill\" needs a parent with layout=\"row\" or \"column\"",
1511 "dot.design:1:8: width: \"fill\" needs a parent with layout=\"row\" or \"column\"",
1512 "dot.design:1:8: width: \"fill\" needs a parent with layout=\"row\" or \"column\"",
1513 "a.design:5:8: height: \"fill\" needs a parent with layout=\"row\" or \"column\"",
1514 ]
1515 );
1516 }
1517
1518 #[test]
1519 fn a_tree_nests_at_most_so_deep_through_instances() {
1520 let mut list = Vec::new();
1522 let depth = 40;
1523 for i in 0..depth {
1524 let body = if i + 1 < depth {
1525 let inner = "<Frame>".repeat(10) + "<N />" + &"</Frame>".repeat(10);
1526 format!("import N from \"./{}.design\"\n{inner}", i + 1)
1527 } else {
1528 "<Frame />".to_string()
1529 };
1530 list.push((format!("{i}.design"), body));
1531 }
1532 let fs: HashMap<String, String> = list.into_iter().collect();
1533 let found = problems_in(&fs, "0.design");
1534 assert_eq!(found.len(), 1, "{found:?}");
1535 assert!(
1536 found[0].ends_with(&format!("nested more than {MAX_DEPTH} deep")),
1537 "{found:?}"
1538 );
1539 }
1540
1541 #[test]
1542 fn every_problem_is_reported() {
1543 let fs = files(&[
1544 (
1545 "button.design",
1546 r#"<Frame props={{ label: "B" }} padding="12px"><Text>{label}</Text></Frame>"#,
1547 ),
1548 (
1549 "screen.design",
1550 "import Button from \"./button.design\"\nimport Row from \"./row.design\"\n<Frame>\n <Button lable=\"x\" />\n <Row />\n <Blob />\n</Frame>",
1551 ),
1552 ]);
1553 let problems: Vec<String> = resolve(&fs, "screen.design")
1554 .unwrap_err()
1555 .iter()
1556 .map(|p| p.to_string())
1557 .collect();
1558 assert_eq!(
1559 problems,
1560 vec![
1561 "screen.design:2:1: import ./row.design — no such file",
1562 "screen.design:4:11: Button has no prop \"lable\"",
1563 "button.design:1:31: padding: expected pixels or an array of 2 or 4, found \"12px\"",
1564 "screen.design:6:3: unknown element Blob",
1565 ]
1566 );
1567 }
1568
1569 #[test]
1570 fn an_import_of_a_missing_file_is_a_problem_even_unused() {
1571 let fs = files(&[("cart.design", "import Row from \"./row.design\"\n<Frame />")]);
1572 let problems: Vec<String> = resolve(&fs, "cart.design")
1573 .unwrap_err()
1574 .iter()
1575 .map(|p| p.to_string())
1576 .collect();
1577 assert_eq!(
1578 problems,
1579 vec!["cart.design:1:1: import ./row.design — no such file"]
1580 );
1581 }
1582
1583 #[test]
1584 fn a_design_that_uses_itself() {
1585 let fs = files(&[(
1586 "a.design",
1587 "import A from \"./a.design\"\n<Frame><A /></Frame>",
1588 )]);
1589 let problems = resolve(&fs, "a.design").unwrap_err();
1590 assert_eq!(
1591 problems[0].to_string(),
1592 "a.design:2:8: a.design uses itself"
1593 );
1594 }
1595
1596 struct Mem(HashMap<String, Vec<u8>>);
1598
1599 impl Files for Mem {
1600 fn bytes(&self, path: &str) -> Option<Vec<u8>> {
1601 self.0.get(path).cloned()
1602 }
1603 }
1604
1605 fn png() -> Vec<u8> {
1607 let mut out = Vec::new();
1608 let mut e = png::Encoder::new(&mut out, 1, 1);
1609 e.set_color(png::ColorType::Rgba);
1610 let mut w = e.write_header().unwrap();
1611 w.write_image_data(&[0, 0, 0, 255]).unwrap();
1612 drop(w);
1613 out
1614 }
1615
1616 fn problems(source: &str) -> Vec<String> {
1617 let fs = Mem([
1618 ("d/a.design".to_string(), source.as_bytes().to_vec()),
1619 ("d/photo.png".to_string(), png()),
1620 ("d/notes.txt".to_string(), b"hi".to_vec()),
1621 (
1622 "d/b.design".to_string(),
1623 br#"<Frame props={{ label: "B" }}><Text>{label}</Text></Frame>"#.to_vec(),
1624 ),
1625 ]
1626 .into());
1627 match resolve(&fs, "d/a.design") {
1628 Ok(_) => Vec::new(),
1629 Err(p) => p.iter().map(|p| p.to_string()).collect(),
1630 }
1631 }
1632
1633 #[test]
1634 fn each_problem_names_its_place() {
1635 let cases = [
1636 (
1637 "<Rect />",
1638 "d/a.design:1:1: the root element must be a Frame, not Rect",
1639 ),
1640 (
1641 "<Frame props=\"x\" />",
1642 "d/a.design:1:8: props: expected an object of defaults",
1643 ),
1644 (
1645 "<Frame fill=\"red\" />",
1646 "d/a.design:1:8: fill: expected a color like #rrggbb",
1647 ),
1648 (
1649 "<Frame fill=\"#12\" />",
1650 "d/a.design:1:8: fill: #12 is not a color",
1651 ),
1652 (
1653 "<Frame fill=\"linear-gradient(90deg, #fff 0%)\" />",
1654 "d/a.design:1:8: fill: expected \"linear-gradient(<angle>deg, <color> <stop>%, …)\", found \"linear-gradient(90deg, #fff 0%)\"",
1655 ),
1656 (
1657 "<Frame width=\"wide\" />",
1658 "d/a.design:1:8: width: expected pixels, \"hug\" or \"fill\", found \"wide\"",
1659 ),
1660 (
1661 "<Frame gap=\"4\" />",
1662 "d/a.design:1:8: gap: expected a number, found \"4\"",
1663 ),
1664 (
1665 "<Frame radius={[1, 2]} />",
1666 "d/a.design:1:8: radius: expected pixels or an array of 4",
1667 ),
1668 (
1669 "<Frame layout=\"grid\" />",
1670 "d/a.design:1:8: layout: expected \"row\", \"column\" or \"none\"",
1671 ),
1672 (
1673 "<Frame align=\"space-between\" />",
1674 "d/a.design:1:8: align: expected \"start\", \"center\" or \"end\"",
1675 ),
1676 (
1677 "<Frame clip=\"yes\" />",
1678 "d/a.design:1:8: clip: expected true or false",
1679 ),
1680 (
1681 "<Frame shadow={{ blurr: 4 }} />",
1682 "d/a.design:1:8: shadow: a shadow has no blurr",
1683 ),
1684 (
1685 "<Frame onClick=\"go ./b.design\" />",
1686 "d/a.design:1:8: onClick: expected \"navigate <path>\", \"back\", \"overlay <path>\" or \"close\"",
1687 ),
1688 (
1689 "<Frame size={4} />",
1690 "d/a.design:1:8: Frame takes no attribute size",
1691 ),
1692 (
1693 "<Frame><Text fill=\"#fff\">a</Text></Frame>",
1694 "d/a.design:1:14: Text takes no attribute fill",
1695 ),
1696 (
1697 "<Frame><Text weight={450}>a</Text></Frame>",
1698 "d/a.design:1:14: weight: expected 400, 500, 600 or 700",
1699 ),
1700 (
1701 "<Frame><Text textAlign=\"justify\">a</Text></Frame>",
1702 "d/a.design:1:14: textAlign: expected \"left\", \"center\" or \"right\"",
1703 ),
1704 (
1705 "<Frame><Text><Rect /></Text></Frame>",
1706 "d/a.design:1:14: Text holds text only",
1707 ),
1708 (
1709 "<Frame>hello</Frame>",
1710 "d/a.design:1:8: text belongs in a Text, not a Frame",
1711 ),
1712 (
1713 "<Frame><Rect><Rect /></Rect></Frame>",
1714 "d/a.design:1:14: Rect takes no children",
1715 ),
1716 (
1717 "<Frame><Text>{nope}</Text></Frame>",
1718 "d/a.design:1:14: no text prop \"nope\"",
1719 ),
1720 (
1721 "<Frame gap={nope} />",
1722 "d/a.design:1:8: gap: no prop \"nope\"",
1723 ),
1724 (
1725 "<Frame><Vector viewBox=\"0 0 24 24\" /></Frame>",
1726 "d/a.design:1:8: Vector needs a path, d=\"M…\"",
1727 ),
1728 (
1729 "<Frame><Vector d=\"M0 0 Q\" /></Frame>",
1730 "d/a.design:1:16: d: not SVG path data: Unexpected EOF",
1731 ),
1732 (
1733 "<Frame><Vector d=\"M0 0L1 1\" viewBox=\"0 0 24\" /></Frame>",
1734 "d/a.design:1:29: viewBox: expected \"minX minY width height\"",
1735 ),
1736 (
1737 "<Frame><Image /></Frame>",
1738 "d/a.design:1:8: Image needs a file, src=\"…\"",
1739 ),
1740 (
1741 "<Frame><Image src=\"./gone.png\" /></Frame>",
1742 "d/a.design:1:15: src: ./gone.png — no such file",
1743 ),
1744 (
1745 "<Frame><Image src=\"../../x.png\" /></Frame>",
1746 "d/a.design:1:15: src: ../../x.png — outside the served folder",
1747 ),
1748 (
1749 "<Frame><Image src=\"./notes.txt\" /></Frame>",
1750 "d/a.design:1:15: src: ./notes.txt is not a PNG or JPEG file",
1751 ),
1752 (
1753 "<Frame><Rect clip /></Frame>",
1754 "d/a.design:1:14: Rect takes no attribute clip",
1755 ),
1756 (
1757 "<Frame fill=\"linear-gradient(2000000deg, #fff 0%, #000 100%)\" />",
1758 "d/a.design:1:8: fill: `2000000` is too large: numbers go up to 1000000",
1759 ),
1760 (
1761 "<Frame fill=\"linear-gradient(90deg, #fff -2000000%, #000 100%)\" />",
1762 "d/a.design:1:8: fill: `-2000000` is too small: numbers go down to -1000000",
1763 ),
1764 (
1765 "<Frame fill=\"linear-gradient(NaNdeg, #fff 0%, #000 100%)\" />",
1766 "d/a.design:1:8: fill: `NaN` is not a number",
1767 ),
1768 (
1769 "<Frame fill=\"linear-gradient(90deg, #fff 0%, #000 inf%)\" />",
1770 "d/a.design:1:8: fill: `inf` is not a number",
1771 ),
1772 (
1773 "<Frame><Vector d=\"M0 0L1 1\" viewBox=\"0 0 2000000 24\" /></Frame>",
1774 "d/a.design:1:29: viewBox: `2000000` is too large: numbers go up to 1000000",
1775 ),
1776 (
1777 "<Frame><Vector d=\"M0 0L1 1\" viewBox=\"0 0 inf 24\" /></Frame>",
1778 "d/a.design:1:29: viewBox: `inf` is not a number",
1779 ),
1780 (
1781 "<Frame><Vector d=\"M0 0L2000000 0\" /></Frame>",
1782 "d/a.design:1:16: d: `2000000` is too large: numbers go up to 1000000",
1783 ),
1784 (
1785 "<Frame><Vector d=\"M0 0L1e999 0\" /></Frame>",
1786 "d/a.design:1:16: d: `1e999` is too large: numbers go up to 1000000",
1787 ),
1788 (
1789 "<Frame><Vector d=\"M0 0L-3e6 0\" /></Frame>",
1790 "d/a.design:1:16: d: `-3e6` is too small: numbers go down to -1000000",
1791 ),
1792 (
1793 "import X from \"../../x.design\"\n<Frame />",
1794 "d/a.design:1:1: import ../../x.design — outside the served folder",
1795 ),
1796 (
1797 "import B from \"./b.design\"\n<Frame><B fill=\"#fff\" /></Frame>",
1798 "d/a.design:2:11: B has no prop \"fill\"",
1799 ),
1800 (
1801 "import B from \"./b.design\"\n<Frame><B>x</B></Frame>",
1802 "d/a.design:2:8: B takes no children",
1803 ),
1804 (
1805 "<Frame><Blob /></Frame>",
1806 "d/a.design:1:8: unknown element Blob",
1807 ),
1808 ];
1809 for (source, expected) in cases {
1810 assert_eq!(problems(source), vec![expected.to_string()], "{source}");
1811 }
1812 }
1813
1814 #[test]
1817 fn an_image_that_does_not_decode_is_a_problem() {
1818 let mut broken = b"\x89PNG\r\n\x1a\n".to_vec();
1819 broken.extend(b"not a PNG past its signature");
1820 let shirt = std::fs::read(concat!(
1821 env!("CARGO_MANIFEST_DIR"),
1822 "/../docs/designs/checkout/shirt.jpg"
1823 ))
1824 .unwrap();
1825 let truncated = shirt[..shirt.len() / 2].to_vec();
1826 for (name, data, expected) in [
1827 (
1828 "broken.png",
1829 broken,
1830 "d/a.design:2:8: src: ./broken.png is a PNG that doesn't decode: ",
1831 ),
1832 (
1833 "shirt.jpg",
1834 truncated,
1835 "d/a.design:2:8: src: ./shirt.jpg is a JPEG that doesn't decode: ",
1836 ),
1837 ] {
1838 let source =
1839 format!("<Frame>\n<Image src=\"./{name}\" width={{50}} height={{50}} />\n</Frame>");
1840 let fs = Mem([
1841 ("d/a.design".to_string(), source.into_bytes()),
1842 (format!("d/{name}"), data),
1843 ]
1844 .into());
1845 let found: Vec<String> = resolve(&fs, "d/a.design")
1846 .unwrap_err()
1847 .iter()
1848 .map(|p| p.to_string())
1849 .collect();
1850 assert_eq!(found.len(), 1, "{found:?}");
1851 assert!(found[0].starts_with(expected), "{found:?}");
1852 }
1853 let fs = Mem([
1855 (
1856 "a.design".to_string(),
1857 br#"<Frame><Image src="./s.jpg" /></Frame>"#.to_vec(),
1858 ),
1859 ("s.jpg".to_string(), shirt),
1860 ]
1861 .into());
1862 let scene = resolve(&fs, "a.design").unwrap();
1863 let image = scene.root.children[0].image.as_ref().unwrap();
1864 let file = &scene.images[&image.id];
1865 assert_eq!((file.width, file.height), (160, 160));
1866 assert_eq!((file.pixels_width, file.pixels_height), (160, 160));
1867 }
1868
1869 #[test]
1873 fn a_large_image_file_is_resampled_once_to_fit() {
1874 let mut data = Vec::new();
1875 let mut e = png::Encoder::new(&mut data, 9000, 10);
1876 e.set_color(png::ColorType::Rgba);
1877 let mut w = e.write_header().unwrap();
1878 let row: Vec<u8> = (0..9000)
1880 .flat_map(|x| {
1881 if x <= 4500 {
1882 [255, 0, 0, 255]
1883 } else {
1884 [0, 0, 255, 255]
1885 }
1886 })
1887 .collect();
1888 w.write_image_data(&row.repeat(10)).unwrap();
1889 drop(w);
1890 let file = ImageFile::new(data).unwrap();
1891 assert_eq!((file.width, file.height), (9000, 10));
1892 assert_eq!((file.pixels_width, file.pixels_height), (4096, 5));
1893 let px = |x: usize| &file.pixels[4 * x..][..4];
1894 assert_eq!(px(0), [255, 0, 0, 255]);
1895 assert_eq!(px(4095), [0, 0, 255, 255]);
1896 assert_eq!(px(2048), [116, 0, 139, 255]);
1898 let (same, w, h) = fit(&[1, 2, 3, 4], 1, 1, MAX_IMAGE_SIDE);
1900 assert_eq!((same, w, h), (vec![1, 2, 3, 4], 1, 1));
1901 let (mixed, _, _) = fit(&[255, 255, 255, 0, 0, 0, 255, 255], 2, 1, 1);
1903 assert_eq!(mixed, [0, 0, 255, 128]);
1904 }
1905
1906 #[test]
1907 fn an_interaction_is_relative_to_the_file_that_wrote_it() {
1908 let fs = files(&[
1909 (
1910 "app/a.design",
1911 "import B from \"../lib/b.design\"\n<Frame><B /><B onClick=\"overlay ./c.design\" /></Frame>",
1912 ),
1913 ("app/c.design", "<Frame />"),
1914 (
1915 "lib/b.design",
1916 "<Frame onClick=\"navigate ./next.design\" />",
1917 ),
1918 ("lib/next.design", "<Frame />"),
1919 ]);
1920 let root = resolve(&fs, "app/a.design").unwrap().root;
1921 assert_eq!(
1922 root.children[0].on_click.as_deref(),
1923 Some("navigate lib/next.design")
1924 );
1925 assert_eq!(
1926 root.children[1].on_click.as_deref(),
1927 Some("overlay app/c.design")
1928 );
1929 }
1930
1931 #[test]
1932 fn an_interaction_to_nowhere_is_a_problem() {
1933 for (click, expected) in [
1934 (
1935 "navigate ./nope.design",
1936 "d/a.design:1:8: onClick: navigate ./nope.design — no such file",
1937 ),
1938 (
1939 "overlay ../../x.design",
1940 "d/a.design:1:8: onClick: overlay ../../x.design — outside the served folder",
1941 ),
1942 ] {
1943 assert_eq!(
1944 problems(&format!("<Frame onClick=\"{click}\" />")),
1945 vec![expected.to_string()],
1946 "{click}"
1947 );
1948 }
1949 }
1950
1951 #[test]
1952 fn a_valid_design_has_no_problems() {
1953 assert_eq!(
1954 problems(
1955 r##"import B from "./b.design"
1956<Frame fill="linear-gradient(90deg, #ffffff 0%, #000000ff 100%)">
1957 <B label="Go" x={4} y={5} width={80} height="hug" name="Go" onClick="back" />
1958 <Vector d="M0 0L24 24" viewBox="0 0 24 24" stroke="#000" />
1959 <Frame radius={8} clip><Image src="./photo.png" radius={[1, 2, 3, 4]} /></Frame>
1960</Frame>"##
1961 ),
1962 Vec::<String>::new()
1963 );
1964 }
1965
1966 #[test]
1967 fn attributes_take_their_defaults() {
1968 let fs = files(&[("a.design", "<Frame><Text>a</Text><Rect /></Frame>")]);
1969 let root = resolve(&fs, "a.design").unwrap().root;
1970 assert_eq!((root.width, root.height), (Size::Hug, Size::Hug));
1971 assert_eq!(
1972 (root.layout, root.gap, root.padding),
1973 (Layout::None, 0.0, [0.0; 4])
1974 );
1975 assert_eq!((root.align, root.justify), (Align::Start, Align::Start));
1976 assert_eq!(
1977 (root.opacity, root.fill.clone(), root.clip),
1978 (1.0, None, false)
1979 );
1980 let text = root.children[0].text.clone().unwrap();
1981 assert_eq!(
1982 text,
1983 TextStyle {
1984 content: "a".into(),
1985 ..default_text()
1986 }
1987 );
1988 assert_eq!(
1989 (text.size, text.weight, text.line_height, text.align),
1990 (14.0, 400, 1.4, TextAlign::Left)
1991 );
1992 assert_eq!(root.children[1].stroke_width, 1.0);
1993 assert_eq!(root.children[1].radius, [0.0; 4]);
1994 }
1995
1996 #[test]
1997 fn values_parse_into_their_types() {
1998 let fs = Mem(
1999 [
2000 (
2001 "a.design".to_string(),
2002 br##"<Frame padding={[1, 2]} radius={[1, 2, 3, 4]} fill="linear-gradient(135deg, #f00 0%, #0000ff80 100%)">
2003 <Frame padding={[1, 2, 3, 4]} fill="#abcd" />
2004 <Vector d="M2 4L10 20" />
2005 <Image src="./p.png" />
2006</Frame>"##
2007 .to_vec(),
2008 ),
2009 ("p.png".to_string(), png()),
2010 ]
2011 .into(),
2012 );
2013 let scene = resolve(&fs, "a.design").unwrap();
2014 let root = &scene.root;
2015 assert_eq!(root.padding, [1.0, 2.0, 1.0, 2.0]);
2016 assert_eq!(root.radius, [1.0, 2.0, 3.0, 4.0]);
2017 let c = |r, g, b, a| Color { r, g, b, a };
2018 assert_eq!(
2019 root.fill,
2020 Some(Fill::Linear {
2021 angle: 135.0,
2022 stops: vec![(c(255, 0, 0, 255), 0.0), (c(0, 0, 255, 0x80), 1.0)]
2023 })
2024 );
2025 assert_eq!(root.children[0].padding, [1.0, 2.0, 3.0, 4.0]);
2026 assert_eq!(
2027 root.children[0].fill,
2028 Some(Fill::Solid(c(0xaa, 0xbb, 0xcc, 0xdd)))
2029 );
2030 assert_eq!(
2032 root.children[1].vector.as_ref().unwrap().view_box,
2033 [2.0, 4.0, 8.0, 16.0]
2034 );
2035 assert_eq!(root.children[2].image.as_ref().unwrap().src, "p.png");
2036 assert_eq!(scene.files, vec!["a.design", "p.png"]);
2037 }
2038
2039 #[test]
2040 fn paths_stay_in_the_served_folder() {
2041 assert_eq!(
2042 join("a/b.design", "./c.design").as_deref(),
2043 Some("a/c.design")
2044 );
2045 assert_eq!(
2046 join("a/b.design", "../c.design").as_deref(),
2047 Some("c.design")
2048 );
2049 assert_eq!(join("a/b.design", "../../c.design"), None);
2050 assert_eq!(
2051 join("/a/b.design", "x/../y.png").as_deref(),
2052 Some("a/y.png")
2053 );
2054 }
2055
2056 #[test]
2057 fn an_interaction_leads_only_to_a_design() {
2058 for (click, expected) in [
2059 (
2060 "navigate ./notes.txt",
2061 "d/a.design:1:8: onClick: navigate ./notes.txt — a design is a .design file",
2062 ),
2063 (
2064 "overlay ./photo.png",
2065 "d/a.design:1:8: onClick: overlay ./photo.png — a design is a .design file",
2066 ),
2067 ] {
2068 assert_eq!(
2069 problems(&format!("<Frame onClick=\"{click}\" />")),
2070 vec![expected.to_string()],
2071 "{click}"
2072 );
2073 }
2074 assert!(problems(r#"<Frame onClick="navigate ./b.design" />"#).is_empty());
2075 }
2076
2077 #[test]
2078 fn sizes_and_spacing_are_never_negative() {
2079 for (attr, element) in [
2080 ("gap={-4}", "Frame"),
2081 ("padding={-1}", "Frame"),
2082 ("padding={[4, -2]}", "Frame"),
2083 ("radius={-3}", "Rect"),
2084 ("radius={[1, 2, -3, 4]}", "Rect"),
2085 ("strokeWidth={-1}", "Rect"),
2086 ("size={-12}", "Text"),
2087 ("lineHeight={-1}", "Text"),
2088 ] {
2089 let found = problems(&format!("<Frame><{element} {attr} /></Frame>"));
2090 assert_eq!(found.len(), 1, "{attr}: {found:?}");
2091 assert!(found[0].contains("of 0 or more"), "{attr}: {found:?}");
2092 }
2093 }
2094
2095 #[test]
2096 fn an_import_name_is_unique_and_not_an_element() {
2097 let found = problems(
2098 r#"import B from "./b.design"
2099import B from "./b.design"
2100<Frame />"#,
2101 );
2102 assert_eq!(found, ["d/a.design:2:1: import B — B is already imported"]);
2103 let found = problems(
2104 r#"import Frame from "./b.design"
2105<Frame />"#,
2106 );
2107 assert_eq!(
2108 found,
2109 ["d/a.design:1:1: import Frame — Frame is a built-in element"]
2110 );
2111 }
2112
2113 #[test]
2114 fn justify_names_all_it_takes() {
2115 assert_eq!(
2116 problems(r#"<Frame justify="middle" />"#),
2117 [
2118 "d/a.design:1:8: justify: expected \"start\", \"center\", \"end\" or \"space-between\""
2119 ]
2120 );
2121 assert_eq!(
2122 problems(r#"<Frame align="middle" />"#),
2123 ["d/a.design:1:8: align: expected \"start\", \"center\" or \"end\""]
2124 );
2125 }
2126
2127 #[test]
2128 fn values_that_would_be_ignored_are_problems() {
2129 let found =
2131 problems(r#"<Frame layout="row"><Rect width={10} height={10} x={5} /></Frame>"#);
2132 assert!(
2133 found
2134 .iter()
2135 .any(|p| p.contains("x: places a child only in a frame with layout=\"none\"")),
2136 "{found:?}"
2137 );
2138 let found = problems(
2140 r#"import B from "./b.design"
2141<Frame layout="row"><B y={3} /></Frame>"#,
2142 );
2143 assert!(
2144 found.iter().any(|p| p.contains("y: places a child only")),
2145 "{found:?}"
2146 );
2147 let found = problems(r#"<Frame width={-1} height={-2} />"#);
2149 assert_eq!(found.len(), 2, "{found:?}");
2150 assert!(
2151 found[0].contains("width: expected pixels of 0 or more"),
2152 "{found:?}"
2153 );
2154 let found = problems(r##"<Frame fill="#ff0000" fill="#00ff00" />"##);
2156 assert!(
2157 found.iter().any(|p| p.contains("fill is given twice")),
2158 "{found:?}"
2159 );
2160 let found = problems(
2161 r#"import B from "./b.design"
2162<Frame><B label="a" label="b" /></Frame>"#,
2163 );
2164 assert!(
2165 found.iter().any(|p| p.contains("label is given twice")),
2166 "{found:?}"
2167 );
2168 let found = problems(
2170 r#"import P from "./photo.png"
2171<Frame />"#,
2172 );
2173 assert_eq!(
2174 found,
2175 ["d/a.design:1:1: import ./photo.png — a design is a .design file"]
2176 );
2177 assert!(problems(r#"<Frame><Rect width={1} height={1} x={5} y={5} /></Frame>"#).is_empty());
2179 }
2180}