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