use std::collections::HashMap;
use std::sync::Arc;
use vello_cpu::peniko::{Blob, ImageAlphaType, ImageData, ImageFormat};
use crate::Problem;
use crate::parse::{self, Child, Element, Pos, TextPart, Value};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Size {
Px(f32),
Hug,
Fill,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Layout {
None,
Row,
Column,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Align {
Start,
Center,
End,
SpaceBetween,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Kind {
Frame,
Text,
Rect,
Ellipse,
Vector,
Image,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Fill {
Solid(Color),
Linear {
angle: f32,
stops: Vec<(Color, f32)>,
},
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TextAlign {
Left,
Center,
Right,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Vector {
pub d: String,
pub view_box: [f32; 4],
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ImageId(pub [u8; 32]);
impl ImageId {
pub fn of(bytes: &[u8]) -> ImageId {
use sha2::Digest;
ImageId(sha2::Sha256::digest(bytes).into())
}
}
impl std::fmt::Debug for ImageId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for b in &self.0[..6] {
write!(f, "{b:02x}")?;
}
Ok(())
}
}
pub const MAX_IMAGE_SIDE: u32 = 4096;
#[derive(Debug)]
pub struct ImageFile {
pub id: ImageId,
pub bytes: Vec<u8>,
pub width: u32,
pub height: u32,
pub pixels: Arc<Vec<u8>>,
pub pixels_width: u32,
pub pixels_height: u32,
}
impl ImageFile {
pub fn new(bytes: Vec<u8>) -> Result<ImageFile, String> {
ImageFile::with_id(ImageId::of(&bytes), bytes)
}
pub fn with_id(id: ImageId, bytes: Vec<u8>) -> Result<ImageFile, String> {
let decoded = decode(&bytes)?;
let (width, height) = (decoded.width, decoded.height);
let (pixels, pixels_width, pixels_height) =
fit(decoded.data.data(), width, height, MAX_IMAGE_SIDE);
Ok(ImageFile {
id,
bytes,
width,
height,
pixels: Arc::new(pixels),
pixels_width,
pixels_height,
})
}
pub fn image_data(&self) -> ImageData {
ImageData {
data: Blob::new(self.pixels.clone()),
format: ImageFormat::Rgba8,
alpha_type: ImageAlphaType::Alpha,
width: self.pixels_width,
height: self.pixels_height,
}
}
}
pub fn fit(rgba: &[u8], width: u32, height: u32, max: u32) -> (Vec<u8>, u32, u32) {
if width <= max && height <= max {
return (rgba.to_vec(), width, height);
}
let scale = max as f64 / width.max(height) as f64;
let w = ((width as f64 * scale).round() as u32).clamp(1, max);
let h = ((height as f64 * scale).round() as u32).clamp(1, max);
let weights = |from: u32, to: u32| -> Vec<Vec<(usize, f32)>> {
let ratio = from as f64 / to as f64;
(0..to)
.map(|o| {
let (a, b) = (o as f64 * ratio, (o + 1) as f64 * ratio);
(a.floor() as usize..(b.ceil() as usize).min(from as usize))
.map(|i| {
let cover = (b.min(i as f64 + 1.0) - a.max(i as f64)) / ratio;
(i, cover as f32)
})
.filter(|&(_, c)| c > 0.0)
.collect()
})
.collect()
};
let (across, down) = (weights(width, w), weights(height, h));
let mut rows = vec![0f32; 4 * w as usize * height as usize];
for y in 0..height as usize {
let line = &rgba[4 * y * width as usize..][..4 * width as usize];
for (x, ws) in across.iter().enumerate() {
let mut sum = [0f32; 4];
for &(i, c) in ws {
let p = &line[4 * i..][..4];
let a = p[3] as f32 * c;
sum[0] += p[0] as f32 * a;
sum[1] += p[1] as f32 * a;
sum[2] += p[2] as f32 * a;
sum[3] += a;
}
rows[4 * (y * w as usize + x)..][..4].copy_from_slice(&sum);
}
}
let mut out = vec![0u8; 4 * w as usize * h as usize];
for (y, ws) in down.iter().enumerate() {
for x in 0..w as usize {
let mut sum = [0f32; 4];
for &(i, c) in ws {
let p = &rows[4 * (i * w as usize + x)..][..4];
for k in 0..4 {
sum[k] += p[k] * c;
}
}
let px = &mut out[4 * (y * w as usize + x)..][..4];
if sum[3] > 0.0 {
for k in 0..3 {
px[k] = (sum[k] / sum[3]).round().clamp(0.0, 255.0) as u8;
}
px[3] = sum[3].round().clamp(0.0, 255.0) as u8;
}
}
}
(out, w, h)
}
#[derive(Debug, Clone)]
pub struct Origin {
pub file: String,
pub pos: Pos,
}
impl Default for Origin {
fn default() -> Origin {
Origin {
file: String::new(),
pos: Pos { line: 1, col: 1 },
}
}
}
impl PartialEq for Origin {
fn eq(&self, _: &Origin) -> bool {
true
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Image {
pub src: String,
pub id: ImageId,
pub at: Origin,
}
pub fn decode(data: &[u8]) -> Result<ImageData, String> {
let (pixels, width, height) = if data.starts_with(b"\x89PNG\r\n\x1a\n") {
let broken = |e: &dyn std::fmt::Display| format!("is a PNG that doesn't decode: {e}");
let mut decoder = png::Decoder::new(std::io::Cursor::new(data));
decoder.set_transformations(png::Transformations::normalize_to_color8());
let mut reader = decoder.read_info().map_err(|e| broken(&e))?;
let size = reader
.output_buffer_size()
.ok_or_else(|| broken(&"too big to hold"))?;
let mut buf = vec![0; size];
let info = reader.next_frame(&mut buf).map_err(|e| broken(&e))?;
buf.truncate(info.buffer_size());
let rgba = match info.color_type {
png::ColorType::Rgba => buf,
png::ColorType::Rgb => buf
.chunks(3)
.flat_map(|p| [p[0], p[1], p[2], 255])
.collect(),
png::ColorType::GrayscaleAlpha => buf
.chunks(2)
.flat_map(|p| [p[0], p[0], p[0], p[1]])
.collect(),
png::ColorType::Grayscale => buf.iter().flat_map(|&g| [g, g, g, 255]).collect(),
png::ColorType::Indexed => return Err(broken(&"an unexpanded palette")),
};
(rgba, info.width, info.height)
} else if data.starts_with(&[0xff, 0xd8, 0xff]) {
use zune_jpeg::JpegDecoder;
use zune_jpeg::zune_core::colorspace::ColorSpace;
use zune_jpeg::zune_core::options::DecoderOptions;
let broken = |e: &dyn std::fmt::Display| format!("is a JPEG that doesn't decode: {e}");
let options = DecoderOptions::default()
.set_strict_mode(true)
.jpeg_set_out_colorspace(ColorSpace::RGBA);
let mut decoder = JpegDecoder::new_with_options(std::io::Cursor::new(data), options);
let pixels = decoder.decode().map_err(|e| broken(&e))?;
let info = decoder.info().ok_or_else(|| broken(&"no header"))?;
(pixels, info.width as u32, info.height as u32)
} else {
return Err("is not a PNG or JPEG file".into());
};
if width == 0 || height == 0 || pixels.len() != width as usize * height as usize * 4 {
return Err(format!(
"is an image of {width}×{height} pixels that doesn't decode whole"
));
}
Ok(ImageData {
data: Blob::new(Arc::new(pixels)),
format: ImageFormat::Rgba8,
alpha_type: ImageAlphaType::Alpha,
width,
height,
})
}
#[derive(Debug, Clone, PartialEq)]
pub struct Shadow {
pub x: f32,
pub y: f32,
pub blur: f32,
pub spread: f32,
pub color: Color,
pub at: Origin,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TextStyle {
pub content: String,
pub size: f32,
pub weight: u16,
pub color: Color,
pub line_height: f32,
pub align: TextAlign,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Node {
pub kind: Kind,
pub name: String,
pub x: f32,
pub y: f32,
pub width: Size,
pub height: Size,
pub opacity: f32,
pub on_click: Option<String>,
pub fill: Option<Fill>,
pub stroke: Option<Color>,
pub stroke_width: f32,
pub radius: [f32; 4],
pub shadow: Option<Shadow>,
pub clip: bool,
pub layout: Layout,
pub gap: f32,
pub padding: [f32; 4],
pub align: Align,
pub justify: Align,
pub text: Option<TextStyle>,
pub vector: Option<Vector>,
pub image: Option<Image>,
pub children: Vec<Node>,
}
impl Node {
fn new(kind: Kind, name: &str) -> Node {
Node {
kind,
name: name.to_string(),
x: 0.0,
y: 0.0,
width: Size::Hug,
height: Size::Hug,
opacity: 1.0,
on_click: None,
fill: None,
stroke: None,
stroke_width: 1.0,
radius: [0.0; 4],
shadow: None,
clip: false,
layout: Layout::None,
gap: 0.0,
padding: [0.0; 4],
align: Align::Start,
justify: Align::Start,
text: None,
vector: None,
image: None,
children: Vec::new(),
}
}
}
pub trait Files {
fn bytes(&self, path: &str) -> Option<Vec<u8>>;
fn read(&self, path: &str) -> Option<String> {
String::from_utf8(self.bytes(path)?).ok()
}
fn image(&self, path: &str) -> Option<Result<Arc<ImageFile>, String>> {
self.bytes(path)
.map(|bytes| ImageFile::new(bytes).map(Arc::new))
}
}
impl Files for HashMap<String, String> {
fn bytes(&self, path: &str) -> Option<Vec<u8>> {
self.get(path).map(|s| s.clone().into_bytes())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Scene {
pub root: Node,
pub images: ImageFiles,
pub files: Vec<String>,
}
pub type ImageFiles = std::collections::BTreeMap<ImageId, Arc<ImageFile>>;
impl PartialEq for ImageFile {
fn eq(&self, other: &ImageFile) -> bool {
self.id == other.id
}
}
pub fn resolve(files: &dyn Files, path: &str) -> Result<Scene, Vec<Problem>> {
let mut r = Resolver {
files,
problems: Vec::new(),
read: Vec::new(),
stack: Vec::new(),
missing: Vec::new(),
depth: 0,
images: ImageFiles::new(),
by_path: HashMap::new(),
};
let root = r.design(path, &[], None, "", None);
if r.problems.is_empty() {
Ok(Scene {
root: root.expect("no problems means a root"),
images: r.images,
files: r.read,
})
} else {
Err(r.problems)
}
}
struct Resolver<'a> {
files: &'a dyn Files,
problems: Vec<Problem>,
read: Vec<String>,
stack: Vec<String>,
missing: Vec<String>,
depth: usize,
images: ImageFiles,
by_path: HashMap<String, Result<ImageId, String>>,
}
const MAX_DEPTH: usize = parse::MAX_DEPTH;
type Props = HashMap<String, Value>;
impl Resolver<'_> {
fn problem(&mut self, file: &str, pos: Pos, message: String) {
self.problems.push(Problem {
file: file.to_string(),
pos,
message,
});
}
fn design(
&mut self,
path: &str,
args: &[parse::Attr],
from: Option<(&str, Pos)>,
used_as: &str,
parent: Option<Layout>,
) -> Option<Node> {
if self.stack.iter().any(|p| p == path) {
let (file, pos) = from.unwrap_or((path, Pos { line: 1, col: 1 }));
self.problem(file, pos, format!("{path} uses itself"));
return None;
}
let Some(source) = self.files.read(path) else {
if from.is_some() && self.missing.iter().any(|p| p == path) {
return None;
}
let (file, pos) = from.unwrap_or((path, Pos { line: 1, col: 1 }));
self.problem(file, pos, format!("{path} — no such file"));
return None;
};
if !self.read.iter().any(|p| p == path) {
self.read.push(path.to_string());
}
let file = match parse::parse(&source) {
Ok(file) => file,
Err(mut problem) => {
problem.file = path.to_string();
self.problems.push(problem);
return None;
}
};
let mut imports = HashMap::new();
for import in &file.imports {
if ELEMENTS.contains(&import.name.as_str()) {
self.problem(
path,
import.pos,
format!("import {0} — {0} is a built-in element", import.name),
);
continue;
}
if imports.contains_key(&import.name) {
self.problem(
path,
import.pos,
format!("import {0} — {0} is already imported", import.name),
);
continue;
}
if !import.path.ends_with(".design") {
self.problem(
path,
import.pos,
format!("import {} — a design is a .design file", import.path),
);
continue;
}
match join(path, &import.path) {
Some(target) => {
if self.files.read(&target).is_none() {
self.problem(
path,
import.pos,
format!("import {} — no such file", import.path),
);
self.missing.push(target.clone());
}
imports.insert(import.name.clone(), target);
}
None => self.problem(
path,
import.pos,
format!("import {} — outside the served folder", import.path),
),
}
}
if file.root.name != "Frame" {
self.problem(
path,
file.root.pos,
format!("the root element must be a Frame, not {}", file.root.name),
);
return None;
}
let mut props = Props::new();
if let Some(attr) = file.root.attrs.iter().find(|a| a.name == "props") {
match &attr.value {
Value::Object(fields) => {
for (k, v) in fields {
props.insert(k.clone(), v.clone());
}
}
_ => self.problem(
path,
attr.pos,
"props: expected an object of defaults".into(),
),
}
}
let (caller, caller_pos) = from.unwrap_or((path, file.root.pos));
let caller = caller.to_string();
let mut overrides = Vec::new();
for arg in args {
if matches!(
arg.name.as_str(),
"x" | "y" | "width" | "height" | "name" | "opacity" | "onClick"
) {
overrides.push(arg.clone());
} else if props.contains_key(&arg.name) {
props.insert(arg.name.clone(), arg.value.clone());
} else {
self.problem(
&caller,
arg.pos,
format!("{used_as} has no prop \"{}\"", arg.name),
);
}
}
let _ = caller_pos;
self.stack.push(path.to_string());
let mut root = self.element(path, &file.root, &props, &imports, true, parent);
self.stack.pop();
if let Some(root) = root.as_mut() {
for attr in &overrides {
self.common(&caller, root, attr, attr.value.clone());
self.fill_needs_layout(&caller, attr, &attr.value, parent);
self.place_needs_no_layout(&caller, attr, parent);
}
}
root
}
fn element(
&mut self,
path: &str,
el: &Element,
props: &Props,
imports: &HashMap<String, String>,
root: bool,
parent: Option<Layout>,
) -> Option<Node> {
if self.depth >= MAX_DEPTH {
self.problem(path, el.pos, format!("nested more than {MAX_DEPTH} deep"));
return None;
}
self.depth += 1;
let node = self.element_inner(path, el, props, imports, root, parent);
self.depth -= 1;
node
}
fn element_inner(
&mut self,
path: &str,
el: &Element,
props: &Props,
imports: &HashMap<String, String>,
root: bool,
parent: Option<Layout>,
) -> Option<Node> {
let mut seen: Vec<&str> = Vec::new();
let mut attrs = Vec::new();
for attr in &el.attrs {
if seen.contains(&attr.name.as_str()) {
self.problem(path, attr.pos, format!("{} is given twice", attr.name));
} else {
seen.push(&attr.name);
attrs.push(attr);
}
}
if let Some(target) = imports.get(&el.name) {
if !el.children.is_empty() {
self.problem(path, el.pos, format!("{} takes no children", el.name));
}
let args: Vec<parse::Attr> = attrs
.iter()
.map(|a| parse::Attr {
name: a.name.clone(),
value: substitute(&a.value, props),
pos: a.pos,
})
.collect();
let target = target.clone();
return self.design(&target, &args, Some((path, el.pos)), &el.name, parent);
}
let kind = match el.name.as_str() {
"Frame" => Kind::Frame,
"Text" => Kind::Text,
"Rect" => Kind::Rect,
"Ellipse" => Kind::Ellipse,
"Vector" => Kind::Vector,
"Image" => Kind::Image,
other => {
self.problem(path, el.pos, format!("unknown element {other}"));
return None;
}
};
let mut node = Node::new(kind, &el.name);
for attr in attrs {
if attr.name == "props" {
if !root {
self.problem(
path,
attr.pos,
"props: only a design's root declares props".into(),
);
}
continue;
}
if root && matches!(attr.name.as_str(), "x" | "y") {
self.problem(
path,
attr.pos,
format!(
"{}: a design's root has no place of its own; place an instance with x and y where it is used",
attr.name
),
);
continue;
}
let value = match &attr.value {
Value::Prop(p) => match props.get(p) {
Some(v) => v.clone(),
None => {
self.problem(path, attr.pos, format!("{}: no prop \"{p}\"", attr.name));
continue;
}
},
v => v.clone(),
};
self.fill_needs_layout(path, attr, &value, parent);
self.place_needs_no_layout(path, attr, parent);
self.attribute(path, &mut node, attr, value);
}
match kind {
Kind::Vector if !el.attrs.iter().any(|a| a.name == "d") => self.problem(
path,
el.pos,
format!("{} needs a path, d=\"M…\"", node.name),
),
Kind::Image if node.image.is_none() && !el.attrs.iter().any(|a| a.name == "src") => {
self.problem(
path,
el.pos,
format!("{} needs a file, src=\"…\"", node.name),
)
}
_ => {}
}
if kind == Kind::Text {
let mut content = String::new();
for child in &el.children {
match child {
Child::Text(parts, pos) => {
for part in parts {
match part {
TextPart::Literal(s) => content.push_str(s),
TextPart::Prop(p) => match props.get(p) {
Some(Value::Str(s)) => content.push_str(s),
Some(Value::Num(n)) => content.push_str(&n.to_string()),
_ => self.problem(path, *pos, format!("no text prop \"{p}\"")),
},
}
}
}
Child::Element(e) => self.problem(path, e.pos, "Text holds text only".into()),
}
}
node.text.get_or_insert_with(default_text).content = content;
} else {
for child in &el.children {
match child {
Child::Element(e) => {
if kind != Kind::Frame {
self.problem(path, e.pos, format!("{} takes no children", el.name));
} else if let Some(n) =
self.element(path, e, props, imports, false, Some(node.layout))
{
node.children.push(n);
}
}
Child::Text(_, pos) => self.problem(
path,
*pos,
format!("text belongs in a Text, not a {}", el.name),
),
}
}
}
Some(node)
}
fn fill_needs_layout(
&mut self,
path: &str,
attr: &parse::Attr,
value: &Value,
parent: Option<Layout>,
) {
if parent == Some(Layout::None)
&& matches!(attr.name.as_str(), "width" | "height")
&& matches!(value, Value::Str(s) if s == "fill")
{
self.problem(
path,
attr.pos,
format!(
"{}: \"fill\" needs a parent with layout=\"row\" or \"column\"",
attr.name
),
);
}
}
fn place_needs_no_layout(&mut self, path: &str, attr: &parse::Attr, parent: Option<Layout>) {
if matches!(parent, Some(Layout::Row | Layout::Column))
&& matches!(attr.name.as_str(), "x" | "y")
{
self.problem(
path,
attr.pos,
format!(
"{}: places a child only in a frame with layout=\"none\"",
attr.name
),
);
}
}
#[allow(clippy::unit_arg)]
fn attribute(&mut self, path: &str, node: &mut Node, attr: &parse::Attr, value: Value) {
let kind = node.kind;
let ok = match (attr.name.as_str(), kind) {
("x" | "y" | "width" | "height" | "name" | "opacity" | "onClick", _) => {
self.common(path, node, attr, value);
return;
}
("fill", k) if k != Kind::Text => fill(&value).map(|f| node.fill = Some(f)),
("stroke", k) if k != Kind::Text => color(&value).map(|c| node.stroke = Some(c)),
("strokeWidth", k) if k != Kind::Text => px(&value).map(|n| node.stroke_width = n),
("radius", k) if k != Kind::Text => match &value {
Value::Num(_) => px(&value).map(|n| node.radius = [n; 4]),
Value::Array(a) if a.len() == 4 => {
pxs(a).map(|v| node.radius = [v[0], v[1], v[2], v[3]])
}
_ => Err("expected pixels or an array of 4".into()),
},
("shadow", k) if k != Kind::Text => shadow(&value).map(|mut s| {
s.at = Origin {
file: path.to_string(),
pos: attr.pos,
};
node.shadow = Some(s)
}),
("clip", Kind::Frame) => match value {
Value::Bool(b) => Ok(node.clip = b),
_ => Err("expected true or false".into()),
},
("layout", Kind::Frame) => match str_of(&value).as_deref() {
Some("row") => Ok(node.layout = Layout::Row),
Some("column") => Ok(node.layout = Layout::Column),
Some("none") => Ok(node.layout = Layout::None),
_ => Err("expected \"row\", \"column\" or \"none\"".into()),
},
("gap", Kind::Frame) => px(&value).map(|n| node.gap = n),
("padding", Kind::Frame) => match &value {
Value::Num(_) => px(&value).map(|n| node.padding = [n; 4]),
Value::Array(a) if a.len() == 2 => {
pxs(a).map(|v| node.padding = [v[0], v[1], v[0], v[1]])
}
Value::Array(a) if a.len() == 4 => {
pxs(a).map(|v| node.padding = [v[0], v[1], v[2], v[3]])
}
other => Err(format!(
"expected pixels or an array of 2 or 4, found {}",
show(other)
)),
},
("align", Kind::Frame) => align(&value, false).map(|a| node.align = a),
("justify", Kind::Frame) => align(&value, true).map(|a| node.justify = a),
("size", Kind::Text) => {
px(&value).map(|n| node.text.get_or_insert_with(default_text).size = n)
}
("weight", Kind::Text) => match num(&value) {
Ok(w) if [400.0, 500.0, 600.0, 700.0].contains(&w) => {
Ok(node.text.get_or_insert_with(default_text).weight = w as u16)
}
_ => Err("expected 400, 500, 600 or 700".into()),
},
("color", Kind::Text) => {
color(&value).map(|c| node.text.get_or_insert_with(default_text).color = c)
}
("lineHeight", Kind::Text) => {
px(&value).map(|n| node.text.get_or_insert_with(default_text).line_height = n)
}
("textAlign", Kind::Text) => match str_of(&value).as_deref() {
Some("left") => {
Ok(node.text.get_or_insert_with(default_text).align = TextAlign::Left)
}
Some("center") => {
Ok(node.text.get_or_insert_with(default_text).align = TextAlign::Center)
}
Some("right") => {
Ok(node.text.get_or_insert_with(default_text).align = TextAlign::Right)
}
_ => Err("expected \"left\", \"center\" or \"right\"".into()),
},
("d", Kind::Vector) => match str_of(&value) {
Some(d) => match path_numbers(&d).map_err(|e| e.to_string()).and_then(|()| {
vello_cpu::kurbo::BezPath::from_svg(&d)
.map_err(|e| format!("not SVG path data: {e}"))
}) {
Ok(_) => {
let view_box = node.vector.as_ref().map(|v| v.view_box);
let view_box = view_box.unwrap_or_else(|| bounds(&d));
Ok(node.vector = Some(Vector { d, view_box }))
}
Err(e) => Err(e),
},
None => Err("expected SVG path data".into()),
},
("viewBox", Kind::Vector) => match str_of(&value).as_deref().map(view_box) {
Some(Ok(b)) => {
let v = node.vector.get_or_insert_with(|| Vector {
d: String::new(),
view_box: b,
});
Ok(v.view_box = b)
}
Some(Err(e)) => Err(e),
None => Err("expected \"minX minY width height\"".into()),
},
("src", Kind::Image) => match str_of(&value) {
Some(src) => match join(path, &src) {
None => Err(format!("{src} — outside the served folder")),
Some(at) => match self.image_file(&at) {
None => Err(format!("{src} — no such file")),
Some(Err(e)) => Err(format!("{src} {e}")),
Some(Ok(id)) => {
node.image = Some(Image {
src: at,
id,
at: Origin {
file: path.to_string(),
pos: attr.pos,
},
});
Ok(())
}
},
},
None => Err("expected a path".into()),
},
(name, _) => {
self.problem(
path,
attr.pos,
format!("{} takes no attribute {name}", node.name),
);
return;
}
};
if let Err(e) = ok {
self.problem(path, attr.pos, format!("{}: {e}", attr.name));
}
}
fn image_file(&mut self, at: &str) -> Option<Result<ImageId, String>> {
if let Some(known) = self.by_path.get(at) {
return Some(known.clone());
}
let file = self.files.image(at)?;
if !self.read.iter().any(|p| p == at) {
self.read.push(at.to_string());
}
let id = file.map(|file| {
let id = file.id;
self.images.entry(id).or_insert(file);
id
});
self.by_path.insert(at.to_string(), id.clone());
Some(id)
}
fn interaction(&mut self, path: &str, action: &str) -> Result<String, String> {
let Some((verb, target)) = action.split_once(' ') else {
return Ok(action.to_string());
};
let target = target.trim();
if !target.ends_with(".design") {
return Err(format!("{action} — a design is a .design file"));
}
match join(path, target) {
None => Err(format!("{action} — outside the served folder")),
Some(at) if self.files.bytes(&at).is_none() => Err(format!("{action} — no such file")),
Some(at) => Ok(format!("{verb} {at}")),
}
}
fn common(&mut self, path: &str, node: &mut Node, attr: &parse::Attr, value: Value) {
let ok = match attr.name.as_str() {
"x" => num(&value).map(|n| node.x = n),
"y" => num(&value).map(|n| node.y = n),
"width" => size(&value).map(|s| node.width = s),
"height" => size(&value).map(|s| node.height = s),
"name" => str_of(&value)
.map(|s| node.name = s)
.ok_or_else(|| "expected a string".into()),
"opacity" => match value {
Value::Num(n) if (0.0..=1.0).contains(&n) => {
node.opacity = n as f32;
Ok(())
}
other => Err(format!(
"expected a number from 0 to 1, found {}",
show(&other)
)),
},
"onClick" => match str_of(&value).filter(|s| interaction_ok(s)) {
Some(action) => self
.interaction(path, &action)
.map(|a| node.on_click = Some(a)),
None => Err(
"expected \"navigate <path>\", \"back\", \"overlay <path>\" or \"close\""
.into(),
),
},
_ => unreachable!(),
};
if let Err(e) = ok {
self.problem(path, attr.pos, format!("{}: {e}", attr.name));
}
}
}
fn default_text() -> TextStyle {
TextStyle {
content: String::new(),
size: 14.0,
weight: 400,
color: Color {
r: 0x0f,
g: 0x17,
b: 0x2a,
a: 255,
},
line_height: 1.4,
align: TextAlign::Left,
}
}
fn view_box(s: &str) -> Result<[f32; 4], String> {
let v: Vec<f32> = s
.split([' ', ','])
.filter(|p| !p.is_empty())
.map(|p| parse::number(p).map(|n| n as f32))
.collect::<Result<_, _>>()?;
match v[..] {
[x, y, w, h] if w > 0.0 && h > 0.0 => Ok([x, y, w, h]),
_ => Err("expected \"minX minY width height\"".into()),
}
}
fn path_numbers(d: &str) -> Result<(), String> {
let b = d.as_bytes();
let mut i = 0;
let mut command = b' ';
let mut param = 0;
while i < b.len() {
let c = b[i];
if c.is_ascii_alphabetic() && c != b'e' && c != b'E' {
command = c;
param = 0;
i += 1;
continue;
}
if c.is_ascii_whitespace() || c == b',' {
i += 1;
continue;
}
if matches!(command, b'A' | b'a') && matches!(param % 7, 3 | 4) && matches!(c, b'0' | b'1')
{
param += 1;
i += 1;
continue;
}
let start = i;
if matches!(b[i], b'+' | b'-') {
i += 1;
}
let mut dot = false;
while i < b.len() && (b[i].is_ascii_digit() || (b[i] == b'.' && !dot)) {
dot |= b[i] == b'.';
i += 1;
}
if i < b.len() && matches!(b[i], b'e' | b'E') {
let mut j = i + 1;
if j < b.len() && matches!(b[j], b'+' | b'-') {
j += 1;
}
if j < b.len() && b[j].is_ascii_digit() {
i = j;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
}
}
if i == start {
return Ok(());
}
let s = &d[start..i];
if let Ok(n) = s.parse::<f64>() {
parse::in_range(s, n)?;
}
param += 1;
}
Ok(())
}
fn bounds(d: &str) -> [f32; 4] {
use vello_cpu::kurbo::Shape;
let b = vello_cpu::kurbo::BezPath::from_svg(d)
.map(|p| p.bounding_box())
.unwrap_or_default();
[
b.x0 as f32,
b.y0 as f32,
(b.width() as f32).max(1.0),
(b.height() as f32).max(1.0),
]
}
fn interaction_ok(s: &str) -> bool {
s == "back"
|| s == "close"
|| s.strip_prefix("navigate ")
.or(s.strip_prefix("overlay "))
.is_some_and(|p| !p.trim().is_empty())
}
pub fn join(from: &str, relative: &str) -> Option<String> {
let mut parts: Vec<&str> = from.split('/').filter(|p| !p.is_empty()).collect();
parts.pop();
for part in relative.split('/') {
match part {
"." | "" => {}
".." => {
parts.pop()?;
}
p => parts.push(p),
}
}
Some(parts.join("/"))
}
fn substitute(value: &Value, props: &Props) -> Value {
match value {
Value::Prop(p) => props.get(p).cloned().unwrap_or_else(|| value.clone()),
v => v.clone(),
}
}
fn show(v: &Value) -> String {
match v {
Value::Str(s) => format!("\"{s}\""),
Value::Num(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Array(_) => "an array".into(),
Value::Object(_) => "an object".into(),
Value::Prop(p) => p.clone(),
}
}
fn str_of(v: &Value) -> Option<String> {
match v {
Value::Str(s) => Some(s.clone()),
_ => None,
}
}
const ELEMENTS: [&str; 6] = ["Frame", "Text", "Rect", "Ellipse", "Vector", "Image"];
fn num(v: &Value) -> Result<f32, String> {
match v {
Value::Num(n) => Ok(*n as f32),
other => Err(format!("expected a number, found {}", show(other))),
}
}
fn px(v: &Value) -> Result<f32, String> {
match v {
Value::Num(n) if *n >= 0.0 => Ok(*n as f32),
Value::Num(n) => Err(format!("expected a number of 0 or more, found {n}")),
other => Err(format!("expected a number, found {}", show(other))),
}
}
fn pxs(a: &[Value]) -> Result<Vec<f32>, String> {
a.iter().map(px).collect()
}
fn size(v: &Value) -> Result<Size, String> {
match v {
Value::Num(n) if *n >= 0.0 => Ok(Size::Px(*n as f32)),
Value::Num(n) => Err(format!("expected pixels of 0 or more, found {n}")),
Value::Str(s) if s == "hug" => Ok(Size::Hug),
Value::Str(s) if s == "fill" => Ok(Size::Fill),
other => Err(format!(
"expected pixels, \"hug\" or \"fill\", found {}",
show(other)
)),
}
}
fn align(v: &Value, justify: bool) -> Result<Align, String> {
match str_of(v).as_deref() {
Some("start") => Ok(Align::Start),
Some("center") => Ok(Align::Center),
Some("end") => Ok(Align::End),
Some("space-between") if justify => Ok(Align::SpaceBetween),
_ if justify => Err("expected \"start\", \"center\", \"end\" or \"space-between\"".into()),
_ => Err("expected \"start\", \"center\" or \"end\"".into()),
}
}
fn color(v: &Value) -> Result<Color, String> {
let Value::Str(s) = v else {
return Err(format!("expected a color, found {}", show(v)));
};
let hex = s.strip_prefix('#').ok_or("expected a color like #rrggbb")?;
if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(format!("{s} is not a color"));
}
let d = |i: usize, n: usize| {
u8::from_str_radix(&hex[i..i + n], 16).map_err(|_| format!("{s} is not a color"))
};
let dd = |i: usize| d(i, 1).map(|v| v * 17);
match hex.len() {
3 => Ok(Color {
r: dd(0)?,
g: dd(1)?,
b: dd(2)?,
a: 255,
}),
4 => Ok(Color {
r: dd(0)?,
g: dd(1)?,
b: dd(2)?,
a: dd(3)?,
}),
6 => Ok(Color {
r: d(0, 2)?,
g: d(2, 2)?,
b: d(4, 2)?,
a: 255,
}),
8 => Ok(Color {
r: d(0, 2)?,
g: d(2, 2)?,
b: d(4, 2)?,
a: d(6, 2)?,
}),
_ => Err(format!("{s} is not a color")),
}
}
fn fill(v: &Value) -> Result<Fill, String> {
let Value::Str(s) = v else {
return Err(format!("expected a color or a gradient, found {}", show(v)));
};
let Some(inner) = s
.strip_prefix("linear-gradient(")
.and_then(|r| r.strip_suffix(')'))
else {
return color(v).map(Fill::Solid);
};
let wrong =
|| format!("expected \"linear-gradient(<angle>deg, <color> <stop>%, …)\", found \"{s}\"");
let mut parts = inner.split(',').map(str::trim);
let angle = parts
.next()
.and_then(|a| a.strip_suffix("deg"))
.ok_or_else(wrong)?;
let angle = parse::number(angle.trim())? as f32;
let mut stops = Vec::new();
for part in parts {
let (c, at) = part.split_once(' ').ok_or_else(wrong)?;
let at = at.trim().strip_suffix('%').ok_or_else(wrong)?;
let at = parse::number(at)? as f32;
stops.push((color(&Value::Str(c.to_string()))?, at / 100.0));
}
if stops.len() < 2 {
return Err(wrong());
}
Ok(Fill::Linear { angle, stops })
}
fn shadow(v: &Value) -> Result<Shadow, String> {
let Value::Object(fields) = v else {
return Err("expected { x, y, blur, spread, color }".into());
};
let mut s = Shadow {
x: 0.0,
y: 0.0,
blur: 0.0,
spread: 0.0,
color: Color {
r: 0,
g: 0,
b: 0,
a: 64,
},
at: Origin::default(),
};
for (k, v) in fields {
match k.as_str() {
"x" => s.x = num(v)?,
"y" => s.y = num(v)?,
"blur" => s.blur = num(v)?,
"spread" => s.spread = num(v)?,
"color" => s.color = color(v)?,
other => return Err(format!("a shadow has no {other}")),
}
}
Ok(s)
}
#[cfg(test)]
mod tests {
use super::*;
fn files(list: &[(&str, &str)]) -> HashMap<String, String> {
list.iter()
.map(|(p, s)| (p.to_string(), s.to_string()))
.collect()
}
#[test]
fn an_instance_takes_its_props() {
let fs = files(&[
(
"a/button.design",
r##"<Frame props={{ label: "Button", fill: "#2563eb" }} fill={fill}><Text>{label}</Text></Frame>"##,
),
(
"a/screen.design",
r#"import Button from "./button.design"
<Frame width={390}><Button label="Pay" x={10} opacity={0.5} /></Frame>"#,
),
]);
let scene = resolve(&fs, "a/screen.design").unwrap();
let button = &scene.root.children[0];
assert_eq!(button.x, 10.0);
assert_eq!(button.opacity, 0.5);
assert_eq!(
button.fill,
Some(Fill::Solid(Color {
r: 0x25,
g: 0x63,
b: 0xeb,
a: 255
}))
);
assert_eq!(button.children[0].text.as_ref().unwrap().content, "Pay");
assert_eq!(scene.files, vec!["a/screen.design", "a/button.design"]);
}
#[test]
fn opacity_is_between_zero_and_one() {
let fs = files(&[
("dot.design", "<Frame />"),
(
"screen.design",
"import Dot from \"./dot.design\"\n<Frame opacity={1}>\n <Rect opacity={50} />\n <Dot opacity={-0.1} />\n</Frame>",
),
]);
let problems: Vec<String> = resolve(&fs, "screen.design")
.unwrap_err()
.iter()
.map(|p| p.to_string())
.collect();
assert_eq!(
problems,
vec![
"screen.design:3:9: opacity: expected a number from 0 to 1, found 50",
"screen.design:4:8: opacity: expected a number from 0 to 1, found -0.1",
]
);
}
fn problems_in(fs: &HashMap<String, String>, path: &str) -> Vec<String> {
resolve(fs, path)
.unwrap_err()
.iter()
.map(|p| p.to_string())
.collect()
}
#[test]
fn a_color_with_any_character_but_hex_digits_is_a_problem() {
for (value, col) in [
("#fff\u{200b}", 13),
("#ffé", 13),
("#ff€f00", 13),
("#ggg", 13),
] {
let fs = files(&[("a.design", &format!(r#"<Frame fill="{value}" />"#))]);
assert_eq!(
problems_in(&fs, "a.design"),
vec![format!("a.design:1:8: fill: {value} is not a color")],
"{value:?} at {col}"
);
}
}
#[test]
fn props_belong_to_the_root_only() {
let fs = files(&[(
"a.design",
r#"<Frame props={{ a: "x" }}>
<Frame props={{ b: "y" }} />
</Frame>"#,
)]);
assert_eq!(
problems_in(&fs, "a.design"),
vec!["a.design:2:10: props: only a design's root declares props"]
);
}
#[test]
fn a_root_has_no_place_of_its_own() {
let fs = files(&[
(
"dot.design",
r#"<Frame x={40} y={30} width={10} height={10} />"#,
),
(
"a.design",
r#"import Dot from "./dot.design"
<Frame width={100} height={100}>
<Dot x={5} y={6} />
</Frame>"#,
),
]);
let expected = [
"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",
"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",
];
assert_eq!(problems_in(&fs, "dot.design"), expected);
assert_eq!(problems_in(&fs, "a.design"), expected);
let fs = files(&[
("dot.design", r#"<Frame width={10} height={10} />"#),
(
"a.design",
r#"import Dot from "./dot.design"
<Frame width={100} height={100}>
<Dot x={5} y={6} />
</Frame>"#,
),
]);
let dot = &resolve(&fs, "a.design").unwrap().root.children[0];
assert_eq!((dot.x, dot.y), (5.0, 6.0));
}
#[test]
fn fill_needs_a_parent_with_auto_layout() {
let fs = files(&[
("dot.design", r#"<Frame width="fill" />"#),
(
"a.design",
r#"import Dot from "./dot.design"
<Frame width={100} height={100}>
<Rect width="fill" height={10} />
<Dot />
<Dot height="fill" />
<Frame layout="row"><Rect width="fill" /></Frame>
</Frame>"#,
),
]);
assert_eq!(
problems_in(&fs, "a.design"),
vec![
"a.design:3:9: width: \"fill\" needs a parent with layout=\"row\" or \"column\"",
"dot.design:1:8: width: \"fill\" needs a parent with layout=\"row\" or \"column\"",
"dot.design:1:8: width: \"fill\" needs a parent with layout=\"row\" or \"column\"",
"a.design:5:8: height: \"fill\" needs a parent with layout=\"row\" or \"column\"",
]
);
}
#[test]
fn a_tree_nests_at_most_so_deep_through_instances() {
let mut list = Vec::new();
let depth = 40;
for i in 0..depth {
let body = if i + 1 < depth {
let inner = "<Frame>".repeat(10) + "<N />" + &"</Frame>".repeat(10);
format!("import N from \"./{}.design\"\n{inner}", i + 1)
} else {
"<Frame />".to_string()
};
list.push((format!("{i}.design"), body));
}
let fs: HashMap<String, String> = list.into_iter().collect();
let found = problems_in(&fs, "0.design");
assert_eq!(found.len(), 1, "{found:?}");
assert!(
found[0].ends_with(&format!("nested more than {MAX_DEPTH} deep")),
"{found:?}"
);
}
#[test]
fn every_problem_is_reported() {
let fs = files(&[
(
"button.design",
r#"<Frame props={{ label: "B" }} padding="12px"><Text>{label}</Text></Frame>"#,
),
(
"screen.design",
"import Button from \"./button.design\"\nimport Row from \"./row.design\"\n<Frame>\n <Button lable=\"x\" />\n <Row />\n <Blob />\n</Frame>",
),
]);
let problems: Vec<String> = resolve(&fs, "screen.design")
.unwrap_err()
.iter()
.map(|p| p.to_string())
.collect();
assert_eq!(
problems,
vec![
"screen.design:2:1: import ./row.design — no such file",
"screen.design:4:11: Button has no prop \"lable\"",
"button.design:1:31: padding: expected pixels or an array of 2 or 4, found \"12px\"",
"screen.design:6:3: unknown element Blob",
]
);
}
#[test]
fn an_import_of_a_missing_file_is_a_problem_even_unused() {
let fs = files(&[("cart.design", "import Row from \"./row.design\"\n<Frame />")]);
let problems: Vec<String> = resolve(&fs, "cart.design")
.unwrap_err()
.iter()
.map(|p| p.to_string())
.collect();
assert_eq!(
problems,
vec!["cart.design:1:1: import ./row.design — no such file"]
);
}
#[test]
fn a_design_that_uses_itself() {
let fs = files(&[(
"a.design",
"import A from \"./a.design\"\n<Frame><A /></Frame>",
)]);
let problems = resolve(&fs, "a.design").unwrap_err();
assert_eq!(
problems[0].to_string(),
"a.design:2:8: a.design uses itself"
);
}
struct Mem(HashMap<String, Vec<u8>>);
impl Files for Mem {
fn bytes(&self, path: &str) -> Option<Vec<u8>> {
self.0.get(path).cloned()
}
}
fn png() -> Vec<u8> {
let mut out = Vec::new();
let mut e = png::Encoder::new(&mut out, 1, 1);
e.set_color(png::ColorType::Rgba);
let mut w = e.write_header().unwrap();
w.write_image_data(&[0, 0, 0, 255]).unwrap();
drop(w);
out
}
fn problems(source: &str) -> Vec<String> {
let fs = Mem([
("d/a.design".to_string(), source.as_bytes().to_vec()),
("d/photo.png".to_string(), png()),
("d/notes.txt".to_string(), b"hi".to_vec()),
(
"d/b.design".to_string(),
br#"<Frame props={{ label: "B" }}><Text>{label}</Text></Frame>"#.to_vec(),
),
]
.into());
match resolve(&fs, "d/a.design") {
Ok(_) => Vec::new(),
Err(p) => p.iter().map(|p| p.to_string()).collect(),
}
}
#[test]
fn each_problem_names_its_place() {
let cases = [
(
"<Rect />",
"d/a.design:1:1: the root element must be a Frame, not Rect",
),
(
"<Frame props=\"x\" />",
"d/a.design:1:8: props: expected an object of defaults",
),
(
"<Frame fill=\"red\" />",
"d/a.design:1:8: fill: expected a color like #rrggbb",
),
(
"<Frame fill=\"#12\" />",
"d/a.design:1:8: fill: #12 is not a color",
),
(
"<Frame fill=\"linear-gradient(90deg, #fff 0%)\" />",
"d/a.design:1:8: fill: expected \"linear-gradient(<angle>deg, <color> <stop>%, …)\", found \"linear-gradient(90deg, #fff 0%)\"",
),
(
"<Frame width=\"wide\" />",
"d/a.design:1:8: width: expected pixels, \"hug\" or \"fill\", found \"wide\"",
),
(
"<Frame gap=\"4\" />",
"d/a.design:1:8: gap: expected a number, found \"4\"",
),
(
"<Frame radius={[1, 2]} />",
"d/a.design:1:8: radius: expected pixels or an array of 4",
),
(
"<Frame layout=\"grid\" />",
"d/a.design:1:8: layout: expected \"row\", \"column\" or \"none\"",
),
(
"<Frame align=\"space-between\" />",
"d/a.design:1:8: align: expected \"start\", \"center\" or \"end\"",
),
(
"<Frame clip=\"yes\" />",
"d/a.design:1:8: clip: expected true or false",
),
(
"<Frame shadow={{ blurr: 4 }} />",
"d/a.design:1:8: shadow: a shadow has no blurr",
),
(
"<Frame onClick=\"go ./b.design\" />",
"d/a.design:1:8: onClick: expected \"navigate <path>\", \"back\", \"overlay <path>\" or \"close\"",
),
(
"<Frame size={4} />",
"d/a.design:1:8: Frame takes no attribute size",
),
(
"<Frame><Text fill=\"#fff\">a</Text></Frame>",
"d/a.design:1:14: Text takes no attribute fill",
),
(
"<Frame><Text weight={450}>a</Text></Frame>",
"d/a.design:1:14: weight: expected 400, 500, 600 or 700",
),
(
"<Frame><Text textAlign=\"justify\">a</Text></Frame>",
"d/a.design:1:14: textAlign: expected \"left\", \"center\" or \"right\"",
),
(
"<Frame><Text><Rect /></Text></Frame>",
"d/a.design:1:14: Text holds text only",
),
(
"<Frame>hello</Frame>",
"d/a.design:1:8: text belongs in a Text, not a Frame",
),
(
"<Frame><Rect><Rect /></Rect></Frame>",
"d/a.design:1:14: Rect takes no children",
),
(
"<Frame><Text>{nope}</Text></Frame>",
"d/a.design:1:14: no text prop \"nope\"",
),
(
"<Frame gap={nope} />",
"d/a.design:1:8: gap: no prop \"nope\"",
),
(
"<Frame><Vector viewBox=\"0 0 24 24\" /></Frame>",
"d/a.design:1:8: Vector needs a path, d=\"M…\"",
),
(
"<Frame><Vector d=\"M0 0 Q\" /></Frame>",
"d/a.design:1:16: d: not SVG path data: Unexpected EOF",
),
(
"<Frame><Vector d=\"M0 0L1 1\" viewBox=\"0 0 24\" /></Frame>",
"d/a.design:1:29: viewBox: expected \"minX minY width height\"",
),
(
"<Frame><Image /></Frame>",
"d/a.design:1:8: Image needs a file, src=\"…\"",
),
(
"<Frame><Image src=\"./gone.png\" /></Frame>",
"d/a.design:1:15: src: ./gone.png — no such file",
),
(
"<Frame><Image src=\"../../x.png\" /></Frame>",
"d/a.design:1:15: src: ../../x.png — outside the served folder",
),
(
"<Frame><Image src=\"./notes.txt\" /></Frame>",
"d/a.design:1:15: src: ./notes.txt is not a PNG or JPEG file",
),
(
"<Frame><Rect clip /></Frame>",
"d/a.design:1:14: Rect takes no attribute clip",
),
(
"<Frame fill=\"linear-gradient(2000000deg, #fff 0%, #000 100%)\" />",
"d/a.design:1:8: fill: `2000000` is too large: numbers go up to 1000000",
),
(
"<Frame fill=\"linear-gradient(90deg, #fff -2000000%, #000 100%)\" />",
"d/a.design:1:8: fill: `-2000000` is too small: numbers go down to -1000000",
),
(
"<Frame fill=\"linear-gradient(NaNdeg, #fff 0%, #000 100%)\" />",
"d/a.design:1:8: fill: `NaN` is not a number",
),
(
"<Frame fill=\"linear-gradient(90deg, #fff 0%, #000 inf%)\" />",
"d/a.design:1:8: fill: `inf` is not a number",
),
(
"<Frame><Vector d=\"M0 0L1 1\" viewBox=\"0 0 2000000 24\" /></Frame>",
"d/a.design:1:29: viewBox: `2000000` is too large: numbers go up to 1000000",
),
(
"<Frame><Vector d=\"M0 0L1 1\" viewBox=\"0 0 inf 24\" /></Frame>",
"d/a.design:1:29: viewBox: `inf` is not a number",
),
(
"<Frame><Vector d=\"M0 0L2000000 0\" /></Frame>",
"d/a.design:1:16: d: `2000000` is too large: numbers go up to 1000000",
),
(
"<Frame><Vector d=\"M0 0L1e999 0\" /></Frame>",
"d/a.design:1:16: d: `1e999` is too large: numbers go up to 1000000",
),
(
"<Frame><Vector d=\"M0 0L-3e6 0\" /></Frame>",
"d/a.design:1:16: d: `-3e6` is too small: numbers go down to -1000000",
),
(
"import X from \"../../x.design\"\n<Frame />",
"d/a.design:1:1: import ../../x.design — outside the served folder",
),
(
"import B from \"./b.design\"\n<Frame><B fill=\"#fff\" /></Frame>",
"d/a.design:2:11: B has no prop \"fill\"",
),
(
"import B from \"./b.design\"\n<Frame><B>x</B></Frame>",
"d/a.design:2:8: B takes no children",
),
(
"<Frame><Blob /></Frame>",
"d/a.design:1:8: unknown element Blob",
),
];
for (source, expected) in cases {
assert_eq!(problems(source), vec![expected.to_string()], "{source}");
}
}
#[test]
fn an_image_that_does_not_decode_is_a_problem() {
let mut broken = b"\x89PNG\r\n\x1a\n".to_vec();
broken.extend(b"not a PNG past its signature");
let shirt = std::fs::read(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../docs/designs/checkout/shirt.jpg"
))
.unwrap();
let truncated = shirt[..shirt.len() / 2].to_vec();
for (name, data, expected) in [
(
"broken.png",
broken,
"d/a.design:2:8: src: ./broken.png is a PNG that doesn't decode: ",
),
(
"shirt.jpg",
truncated,
"d/a.design:2:8: src: ./shirt.jpg is a JPEG that doesn't decode: ",
),
] {
let source =
format!("<Frame>\n<Image src=\"./{name}\" width={{50}} height={{50}} />\n</Frame>");
let fs = Mem([
("d/a.design".to_string(), source.into_bytes()),
(format!("d/{name}"), data),
]
.into());
let found: Vec<String> = resolve(&fs, "d/a.design")
.unwrap_err()
.iter()
.map(|p| p.to_string())
.collect();
assert_eq!(found.len(), 1, "{found:?}");
assert!(found[0].starts_with(expected), "{found:?}");
}
let fs = Mem([
(
"a.design".to_string(),
br#"<Frame><Image src="./s.jpg" /></Frame>"#.to_vec(),
),
("s.jpg".to_string(), shirt),
]
.into());
let scene = resolve(&fs, "a.design").unwrap();
let image = scene.root.children[0].image.as_ref().unwrap();
let file = &scene.images[&image.id];
assert_eq!((file.width, file.height), (160, 160));
assert_eq!((file.pixels_width, file.pixels_height), (160, 160));
}
#[test]
fn a_large_image_file_is_resampled_once_to_fit() {
let mut data = Vec::new();
let mut e = png::Encoder::new(&mut data, 9000, 10);
e.set_color(png::ColorType::Rgba);
let mut w = e.write_header().unwrap();
let row: Vec<u8> = (0..9000)
.flat_map(|x| {
if x <= 4500 {
[255, 0, 0, 255]
} else {
[0, 0, 255, 255]
}
})
.collect();
w.write_image_data(&row.repeat(10)).unwrap();
drop(w);
let file = ImageFile::new(data).unwrap();
assert_eq!((file.width, file.height), (9000, 10));
assert_eq!((file.pixels_width, file.pixels_height), (4096, 5));
let px = |x: usize| &file.pixels[4 * x..][..4];
assert_eq!(px(0), [255, 0, 0, 255]);
assert_eq!(px(4095), [0, 0, 255, 255]);
assert_eq!(px(2048), [116, 0, 139, 255]);
let (same, w, h) = fit(&[1, 2, 3, 4], 1, 1, MAX_IMAGE_SIDE);
assert_eq!((same, w, h), (vec![1, 2, 3, 4], 1, 1));
let (mixed, _, _) = fit(&[255, 255, 255, 0, 0, 0, 255, 255], 2, 1, 1);
assert_eq!(mixed, [0, 0, 255, 128]);
}
#[test]
fn an_interaction_is_relative_to_the_file_that_wrote_it() {
let fs = files(&[
(
"app/a.design",
"import B from \"../lib/b.design\"\n<Frame><B /><B onClick=\"overlay ./c.design\" /></Frame>",
),
("app/c.design", "<Frame />"),
(
"lib/b.design",
"<Frame onClick=\"navigate ./next.design\" />",
),
("lib/next.design", "<Frame />"),
]);
let root = resolve(&fs, "app/a.design").unwrap().root;
assert_eq!(
root.children[0].on_click.as_deref(),
Some("navigate lib/next.design")
);
assert_eq!(
root.children[1].on_click.as_deref(),
Some("overlay app/c.design")
);
}
#[test]
fn an_interaction_to_nowhere_is_a_problem() {
for (click, expected) in [
(
"navigate ./nope.design",
"d/a.design:1:8: onClick: navigate ./nope.design — no such file",
),
(
"overlay ../../x.design",
"d/a.design:1:8: onClick: overlay ../../x.design — outside the served folder",
),
] {
assert_eq!(
problems(&format!("<Frame onClick=\"{click}\" />")),
vec![expected.to_string()],
"{click}"
);
}
}
#[test]
fn a_valid_design_has_no_problems() {
assert_eq!(
problems(
r##"import B from "./b.design"
<Frame fill="linear-gradient(90deg, #ffffff 0%, #000000ff 100%)">
<B label="Go" x={4} y={5} width={80} height="hug" name="Go" onClick="back" />
<Vector d="M0 0L24 24" viewBox="0 0 24 24" stroke="#000" />
<Frame radius={8} clip><Image src="./photo.png" radius={[1, 2, 3, 4]} /></Frame>
</Frame>"##
),
Vec::<String>::new()
);
}
#[test]
fn attributes_take_their_defaults() {
let fs = files(&[("a.design", "<Frame><Text>a</Text><Rect /></Frame>")]);
let root = resolve(&fs, "a.design").unwrap().root;
assert_eq!((root.width, root.height), (Size::Hug, Size::Hug));
assert_eq!(
(root.layout, root.gap, root.padding),
(Layout::None, 0.0, [0.0; 4])
);
assert_eq!((root.align, root.justify), (Align::Start, Align::Start));
assert_eq!(
(root.opacity, root.fill.clone(), root.clip),
(1.0, None, false)
);
let text = root.children[0].text.clone().unwrap();
assert_eq!(
text,
TextStyle {
content: "a".into(),
..default_text()
}
);
assert_eq!(
(text.size, text.weight, text.line_height, text.align),
(14.0, 400, 1.4, TextAlign::Left)
);
assert_eq!(root.children[1].stroke_width, 1.0);
assert_eq!(root.children[1].radius, [0.0; 4]);
}
#[test]
fn values_parse_into_their_types() {
let fs = Mem(
[
(
"a.design".to_string(),
br##"<Frame padding={[1, 2]} radius={[1, 2, 3, 4]} fill="linear-gradient(135deg, #f00 0%, #0000ff80 100%)">
<Frame padding={[1, 2, 3, 4]} fill="#abcd" />
<Vector d="M2 4L10 20" />
<Image src="./p.png" />
</Frame>"##
.to_vec(),
),
("p.png".to_string(), png()),
]
.into(),
);
let scene = resolve(&fs, "a.design").unwrap();
let root = &scene.root;
assert_eq!(root.padding, [1.0, 2.0, 1.0, 2.0]);
assert_eq!(root.radius, [1.0, 2.0, 3.0, 4.0]);
let c = |r, g, b, a| Color { r, g, b, a };
assert_eq!(
root.fill,
Some(Fill::Linear {
angle: 135.0,
stops: vec![(c(255, 0, 0, 255), 0.0), (c(0, 0, 255, 0x80), 1.0)]
})
);
assert_eq!(root.children[0].padding, [1.0, 2.0, 3.0, 4.0]);
assert_eq!(
root.children[0].fill,
Some(Fill::Solid(c(0xaa, 0xbb, 0xcc, 0xdd)))
);
assert_eq!(
root.children[1].vector.as_ref().unwrap().view_box,
[2.0, 4.0, 8.0, 16.0]
);
assert_eq!(root.children[2].image.as_ref().unwrap().src, "p.png");
assert_eq!(scene.files, vec!["a.design", "p.png"]);
}
#[test]
fn paths_stay_in_the_served_folder() {
assert_eq!(
join("a/b.design", "./c.design").as_deref(),
Some("a/c.design")
);
assert_eq!(
join("a/b.design", "../c.design").as_deref(),
Some("c.design")
);
assert_eq!(join("a/b.design", "../../c.design"), None);
assert_eq!(
join("/a/b.design", "x/../y.png").as_deref(),
Some("a/y.png")
);
}
#[test]
fn an_interaction_leads_only_to_a_design() {
for (click, expected) in [
(
"navigate ./notes.txt",
"d/a.design:1:8: onClick: navigate ./notes.txt — a design is a .design file",
),
(
"overlay ./photo.png",
"d/a.design:1:8: onClick: overlay ./photo.png — a design is a .design file",
),
] {
assert_eq!(
problems(&format!("<Frame onClick=\"{click}\" />")),
vec![expected.to_string()],
"{click}"
);
}
assert!(problems(r#"<Frame onClick="navigate ./b.design" />"#).is_empty());
}
#[test]
fn sizes_and_spacing_are_never_negative() {
for (attr, element) in [
("gap={-4}", "Frame"),
("padding={-1}", "Frame"),
("padding={[4, -2]}", "Frame"),
("radius={-3}", "Rect"),
("radius={[1, 2, -3, 4]}", "Rect"),
("strokeWidth={-1}", "Rect"),
("size={-12}", "Text"),
("lineHeight={-1}", "Text"),
] {
let found = problems(&format!("<Frame><{element} {attr} /></Frame>"));
assert_eq!(found.len(), 1, "{attr}: {found:?}");
assert!(found[0].contains("of 0 or more"), "{attr}: {found:?}");
}
}
#[test]
fn an_import_name_is_unique_and_not_an_element() {
let found = problems(
r#"import B from "./b.design"
import B from "./b.design"
<Frame />"#,
);
assert_eq!(found, ["d/a.design:2:1: import B — B is already imported"]);
let found = problems(
r#"import Frame from "./b.design"
<Frame />"#,
);
assert_eq!(
found,
["d/a.design:1:1: import Frame — Frame is a built-in element"]
);
}
#[test]
fn justify_names_all_it_takes() {
assert_eq!(
problems(r#"<Frame justify="middle" />"#),
[
"d/a.design:1:8: justify: expected \"start\", \"center\", \"end\" or \"space-between\""
]
);
assert_eq!(
problems(r#"<Frame align="middle" />"#),
["d/a.design:1:8: align: expected \"start\", \"center\" or \"end\""]
);
}
#[test]
fn values_that_would_be_ignored_are_problems() {
let found =
problems(r#"<Frame layout="row"><Rect width={10} height={10} x={5} /></Frame>"#);
assert!(
found
.iter()
.any(|p| p.contains("x: places a child only in a frame with layout=\"none\"")),
"{found:?}"
);
let found = problems(
r#"import B from "./b.design"
<Frame layout="row"><B y={3} /></Frame>"#,
);
assert!(
found.iter().any(|p| p.contains("y: places a child only")),
"{found:?}"
);
let found = problems(r#"<Frame width={-1} height={-2} />"#);
assert_eq!(found.len(), 2, "{found:?}");
assert!(
found[0].contains("width: expected pixels of 0 or more"),
"{found:?}"
);
let found = problems(r##"<Frame fill="#ff0000" fill="#00ff00" />"##);
assert!(
found.iter().any(|p| p.contains("fill is given twice")),
"{found:?}"
);
let found = problems(
r#"import B from "./b.design"
<Frame><B label="a" label="b" /></Frame>"#,
);
assert!(
found.iter().any(|p| p.contains("label is given twice")),
"{found:?}"
);
let found = problems(
r#"import P from "./photo.png"
<Frame />"#,
);
assert_eq!(
found,
["d/a.design:1:1: import ./photo.png — a design is a .design file"]
);
assert!(problems(r#"<Frame><Rect width={1} height={1} x={5} y={5} /></Frame>"#).is_empty());
}
}