Skip to main content

devpage_design/
resolve.rs

1//! Syntax tree → scene: imports and props substituted, every attribute
2//! checked, typed and defaulted.
3
4use 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/// How a box is filled: one color, or a CSS-style linear gradient.
53#[derive(Debug, Clone, PartialEq)]
54pub enum Fill {
55    Solid(Color),
56    /// `angle` in degrees, CSS's: 0 points up, 90 right, 180 down. Each stop
57    /// is a color at an offset from 0 to 1 along the gradient line.
58    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/// A `Vector`'s path and the box of path coordinates it is drawn from.
72#[derive(Debug, Clone, PartialEq)]
73pub struct Vector {
74    pub d: String,
75    /// `[minX, minY, width, height]`.
76    pub view_box: [f32; 4],
77}
78
79/// An image file's identity: the SHA-256 of its bytes. Two `Image`s showing
80/// the same bytes — the same file used twice, or two copies of it — share one
81/// [`ImageFile`], decoded once and sent once.
82#[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
101/// The most pixels a side an image is drawn from. The GPU keeps every image a
102/// drawing paints in one atlas of [`crate::atlas::SIDE`] pixels a side, so a
103/// larger file is resampled to fit this, keeping its aspect, when it is
104/// decoded — and both backends draw those same pixels.
105pub const MAX_IMAGE_SIDE: u32 = 4096;
106
107/// A decoded image file: its bytes, its own size, and the pixels it is drawn
108/// from — the file's own, or resampled to at most [`MAX_IMAGE_SIDE`] a side.
109#[derive(Debug)]
110pub struct ImageFile {
111    pub id: ImageId,
112    pub bytes: Vec<u8>,
113    /// The file's size in pixels: what an `Image` hugs by.
114    pub width: u32,
115    pub height: u32,
116    /// Straight-alpha RGBA, `pixels_width` × `pixels_height`.
117    pub pixels: Arc<Vec<u8>>,
118    pub pixels_width: u32,
119    pub pixels_height: u32,
120}
121
122impl ImageFile {
123    /// Decodes `bytes`, a PNG or JPEG file, or says why it can't.
124    pub fn new(bytes: Vec<u8>) -> Result<ImageFile, String> {
125        ImageFile::with_id(ImageId::of(&bytes), bytes)
126    }
127
128    /// Decodes `bytes`, already known to be the file `id`.
129    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    /// The pixels as an image for one drawing to paint, under a blob of its
146    /// own that shares the pixels: see `render::Renderer::drawing`.
147    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
158/// `rgba`, `width` × `height`, resampled to at most `max` pixels a side with
159/// its aspect kept, or as it is when it already fits. Each new pixel is the
160/// area-weighted average of the pixels under it, in premultiplied alpha so a
161/// transparent pixel lends no color.
162pub 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    // For each output index along one axis, the input indices it covers and
170    // how much of each.
171    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    // Across each row first, premultiplied, then down each column.
188    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/// Where an attribute was written, for a problem found only once the design
227/// is laid out and painted. It is never part of what a node draws: two nodes
228/// that differ only in it are equal, and it doesn't travel on the wire.
229#[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/// An `Image`'s file: its address in the served folder and which
251/// [`ImageFile`] in the scene it shows.
252#[derive(Debug, Clone, PartialEq)]
253pub struct Image {
254    pub src: String,
255    pub id: ImageId,
256    /// Where `src` was written.
257    pub at: Origin,
258}
259
260/// A PNG or JPEG file's pixels, as straight-alpha RGBA, or why it has none.
261pub 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            // Expanded to RGB by `normalize_to_color8`.
285            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    /// Where `shadow` was written.
325    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/// One node of a scene.
339#[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
396/// Reads a design's files by their path from the top of the served folder.
397pub trait Files {
398    /// A file's bytes: a design's text, or an `Image`'s file.
399    fn bytes(&self, path: &str) -> Option<Vec<u8>>;
400    /// A design file's text.
401    fn read(&self, path: &str) -> Option<String> {
402        String::from_utf8(self.bytes(path)?).ok()
403    }
404    /// The image file at `path`, decoded, or why it doesn't decode; `None`
405    /// when there is no such file. A store that keeps files between resolves
406    /// keeps their decoded images too, so a save doesn't decode them again.
407    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/// A resolved design: its tree, each distinct image file its `Image`s show,
420/// and the files it was made from.
421#[derive(Debug, Clone, PartialEq)]
422pub struct Scene {
423    pub root: Node,
424    pub images: ImageFiles,
425    pub files: Vec<String>,
426}
427
428/// A scene's image files by id, each decoded once however many `Image`s
429/// show it.
430pub type ImageFiles = std::collections::BTreeMap<ImageId, Arc<ImageFile>>;
431
432/// Image files compare by their identity, which is their bytes.
433impl PartialEq for ImageFile {
434    fn eq(&self, other: &ImageFile) -> bool {
435        self.id == other.id
436    }
437}
438
439/// Resolves the design at `path`, reporting every problem found.
440pub 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    /// Imports already reported as `no such file`, so a use of one isn't
469    /// reported a second time.
470    missing: Vec<String>,
471    /// How many elements enclose the current one, across instances.
472    depth: usize,
473    /// Every image file the design shows, by id.
474    images: ImageFiles,
475    /// Each image file read so far, by its path.
476    by_path: HashMap<String, Result<ImageId, String>>,
477}
478
479/// How deep a resolved design's tree may nest, counting through instances:
480/// the parser's own limit for one file, and so far beyond any real design.
481const 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    /// The root node of the design at `path`, with `args` given to its props.
495    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                    // Every import must exist, used or not.
556                    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        // Props: the root's declared defaults, overridden by the instance's.
582        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                // The caller's own props are already substituted into these.
623                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        // An attribute given twice is a problem, on an element or an instance
660        // alike; the first one counts.
661        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            // Keep in step with ELEMENTS.
688            "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            // Shown on its own a design has no parent to place it in, and used
712            // as an instance it goes where the instance's own x and y say.
713            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    /// `"fill"` takes the free space of a parent's auto layout, so a child of a
797    /// frame with no layout can't have it.
798    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    /// `x` and `y` place a child inside a frame with no layout; auto layout
821    /// places its children itself, so there they would mean nothing.
822    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    // Each arm sets a field and says whether the value fit: `Ok(field = v)`.
838    #[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            // Only a frame has children to clip.
864            ("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    /// The image file at `at`, by its content — read and decoded once per
978    /// resolve however many `Image`s show it — or why it doesn't decode;
979    /// `None` when there is no such file.
980    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    /// An interaction with its path, if it has one, made relative to the top of
998    /// the served folder instead of to `path`, the file it is written in.
999    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
1078/// Every number in SVG path data `d` held to the same limits as a design's
1079/// other numbers. A number here is SVG's — it may have an exponent — and an
1080/// arc's two flags are single digits that may be written run together.
1081fn path_numbers(d: &str) -> Result<(), String> {
1082    let b = d.as_bytes();
1083    let mut i = 0;
1084    let mut command = b' ';
1085    // Which parameter of the current command comes next.
1086    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            // Not a number: the path parser says what is wrong.
1128            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
1139/// A path's own bounds, the view box of a `Vector` that names none.
1140fn 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
1161/// A path relative to a design's file, from the top of the served folder, or
1162/// `None` when it climbs out of the served folder.
1163pub 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
1203/// The built-in elements; an import can't take one of their names.
1204const 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
1213/// A length or a multiple that can't be negative: pixels, a size, a line height.
1214fn 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        // A pasted zero-width space, other non-ASCII, and plain non-hex.
1426        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        // Used as an instance, the root's own x and y are still a problem; the
1476        // instance's x and y are where it goes.
1477        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        // Each file nests a little; through instances the tree goes too deep.
1521        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    /// Files as bytes, so a test can hold an image.
1597    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    /// A 1×1 PNG that decodes.
1606    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    /// An image file is decoded whole while resolving, so a broken one is a
1815    /// problem at its `src` — not a blank box — wherever the design is shown.
1816    #[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        // The whole file decodes: its pixels go with the scene.
1854        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    /// A file larger than [`MAX_IMAGE_SIDE`] is drawn from its pixels
1870    /// resampled to fit it, keeping its aspect; an `Image` still hugs the
1871    /// file's own size.
1872    #[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        // Red to x = 4500, blue past it.
1879        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        // Column 2048 covers x = 4500 to 4502.2: one part red, 1.2 blue.
1897        assert_eq!(px(2048), [116, 0, 139, 255]);
1898        // A fitting file is drawn from its own pixels.
1899        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        // A transparent pixel lends no color to its neighbour.
1902        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        // A vector with no view box is drawn from its path's own bounds.
2031        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        // x and y place a child only in a frame with no layout.
2130        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        // The same on an instance placed in a row.
2139        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        // Negative sizes.
2148        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        // An attribute given twice.
2155        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        // An import of something that isn't a design says so.
2169        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        // x and y in a frame with no layout are fine.
2178        assert!(problems(r#"<Frame><Rect width={1} height={1} x={5} y={5} /></Frame>"#).is_empty());
2179    }
2180}